/* @internal */
namespace ts.JsTyping {

    export interface TypingResolutionHost {
        directoryExists(path: string): boolean;
        fileExists(fileName: string): boolean;
        readFile(path: string, encoding?: string): string | undefined;
        readDirectory(rootDir: string, extensions: readonly string[], excludes: readonly string[] | undefined, includes: readonly string[] | undefined, depth?: number): string[];
    }

    interface PackageJson {
        dependencies?: MapLike<string>;
        devDependencies?: MapLike<string>;
        name?: string;
        optionalDependencies?: MapLike<string>;
        peerDependencies?: MapLike<string>;
        types?: string;
        typings?: string;
    }

    export interface CachedTyping {
        typingLocation: string;
        version: Version;
    }

    export function isTypingUpToDate(cachedTyping: CachedTyping, availableTypingVersions: MapLike<string>) {
        const availableVersion = new Version(getProperty(availableTypingVersions, `ts${versionMajorMinor}`) || getProperty(availableTypingVersions, "latest")!);
        return availableVersion.compareTo(cachedTyping.version) <= 0;
    }

    const unprefixedNodeCoreModuleList = [
        "assert",
        "assert/strict",
        "async_hooks",
        "buffer",
        "child_process",
        "cluster",
        "console",
        "constants",
        "crypto",
        "dgram",
        "diagnostics_channel",
        "dns",
        "dns/promises",
        "domain",
        "events",
        "fs",
        "fs/promises",
        "http",
        "https",
        "http2",
        "inspector",
        "module",
        "net",
        "os",
        "path",
        "perf_hooks",
        "process",
        "punycode",
        "querystring",
        "readline",
        "repl",
        "stream",
        "stream/promises",
        "string_decoder",
        "timers",
        "timers/promises",
        "tls",
        "trace_events",
        "tty",
        "url",
        "util",
        "util/types",
        "v8",
        "vm",
        "wasi",
        "worker_threads",
        "zlib"
    ];

    export const prefixedNodeCoreModuleList = unprefixedNodeCoreModuleList.map(name => `node:${name}`);

    export const nodeCoreModuleList: readonly string[] = [...unprefixedNodeCoreModuleList, ...prefixedNodeCoreModuleList];

    export const nodeCoreModules = new Set(nodeCoreModuleList);

    export function nonRelativeModuleNameForTypingCache(moduleName: string) {
        return nodeCoreModules.has(moduleName) ? "node" : moduleName;
    }

    /**
     * A map of loose file names to library names that we are confident require typings
     */
    export type SafeList = ReadonlyESMap<string, string>;

    export function loadSafeList(host: TypingResolutionHost, safeListPath: Path): SafeList {
        const result = readConfigFile(safeListPath, path => host.readFile(path));
        return new Map(getEntries<string>(result.config));
    }

    export function loadTypesMap(host: TypingResolutionHost, typesMapPath: Path): SafeList | undefined {
        const result = readConfigFile(typesMapPath, path => host.readFile(path));
        if (result.config) {
            return new Map(getEntries<string>(result.config.simpleMap));
        }
        return undefined;
    }

    /**
     * @param host is the object providing I/O related operations.
     * @param fileNames are the file names that belong to the same project
     * @param projectRootPath is the path to the project root directory
     * @param safeListPath is the path used to retrieve the safe list
     * @param packageNameToTypingLocation is the map of package names to their cached typing locations and installed versions
     * @param typeAcquisition is used to customize the typing acquisition process
     * @param compilerOptions are used as a source for typing inference
     */
    export function discoverTypings(
        host: TypingResolutionHost,
        log: ((message: string) => void) | undefined,
        fileNames: string[],
        projectRootPath: Path,
        safeList: SafeList,
        packageNameToTypingLocation: ReadonlyESMap<string, CachedTyping>,
        typeAcquisition: TypeAcquisition,
        unresolvedImports: readonly string[],
        typesRegistry: ReadonlyESMap<string, MapLike<string>>,
        compilerOptions: CompilerOptions):
        { cachedTypingPaths: string[], newTypingNames: string[], filesToWatch: string[] } {

        if (!typeAcquisition || !typeAcquisition.enable) {
            return { cachedTypingPaths: [], newTypingNames: [], filesToWatch: [] };
        }

        // A typing name to typing file path mapping
        const inferredTypings = new Map<string, string>();

        // Only infer typings for .js and .jsx files
        fileNames = mapDefined(fileNames, fileName => {
            const path = normalizePath(fileName);
            if (hasJSFileExtension(path)) {
                return path;
            }
        });

        const filesToWatch: string[] = [];

        if (typeAcquisition.include) addInferredTypings(typeAcquisition.include, "Explicitly included types");
        const exclude = typeAcquisition.exclude || [];

        // Directories to search for package.json, bower.json and other typing information
        if (!compilerOptions.types) {
            const possibleSearchDirs = new Set(fileNames.map(getDirectoryPath));
            possibleSearchDirs.add(projectRootPath);
            possibleSearchDirs.forEach((searchDir) => {
                getTypingNames(searchDir, "bower.json", "bower_components", filesToWatch);
                getTypingNames(searchDir, "package.json", "node_modules", filesToWatch);
                getTypingNames(searchDir, "oh-package.json5", "oh_modules", filesToWatch);
            });
        }

        if(!typeAcquisition.disableFilenameBasedTypeAcquisition) {
            getTypingNamesFromSourceFileNames(fileNames);
        }
        // add typings for unresolved imports
        if (unresolvedImports) {
            const module = deduplicate<string>(
                unresolvedImports.map(nonRelativeModuleNameForTypingCache),
                equateStringsCaseSensitive,
                compareStringsCaseSensitive);
            addInferredTypings(module, "Inferred typings from unresolved imports");
        }
        // Add the cached typing locations for inferred typings that are already installed
        packageNameToTypingLocation.forEach((typing, name) => {
            const registryEntry = typesRegistry.get(name);
            if (inferredTypings.has(name) && inferredTypings.get(name) === undefined && registryEntry !== undefined && isTypingUpToDate(typing, registryEntry)) {
                inferredTypings.set(name, typing.typingLocation);
            }
        });

        // Remove typings that the user has added to the exclude list
        for (const excludeTypingName of exclude) {
            const didDelete = inferredTypings.delete(excludeTypingName);
            if (didDelete && log) log(`Typing for ${excludeTypingName} is in exclude list, will be ignored.`);
        }

        const newTypingNames: string[] = [];
        const cachedTypingPaths: string[] = [];
        inferredTypings.forEach((inferred, typing) => {
            if (inferred !== undefined) {
                cachedTypingPaths.push(inferred);
            }
            else {
                newTypingNames.push(typing);
            }
        });
        const result = { cachedTypingPaths, newTypingNames, filesToWatch };
        if (log) log(`Result: ${JSON.stringify(result)}`);
        return result;

        function addInferredTyping(typingName: string) {
            if (!inferredTypings.has(typingName)) {
                inferredTypings.set(typingName, undefined!); // TODO: GH#18217
            }
        }
        function addInferredTypings(typingNames: readonly string[], message: string) {
            if (log) log(`${message}: ${JSON.stringify(typingNames)}`);
            forEach(typingNames, addInferredTyping);
        }

        /**
         * Adds inferred typings from manifest/module pairs (think package.json + node_modules)
         *
         * @param projectRootPath is the path to the directory where to look for package.json, bower.json and other typing information
         * @param manifestName is the name of the manifest (package.json or bower.json)
         * @param modulesDirName is the directory name for modules (node_modules or bower_components). Should be lowercase!
         * @param filesToWatch are the files to watch for changes. We will push things into this array.
         */
        function getTypingNames(projectRootPath: string, manifestName: string, modulesDirName: string, filesToWatch: string[]): void {
            // First, we check the manifests themselves. They're not
            // _required_, but they allow us to do some filtering when dealing
            // with big flat dep directories.
            const manifestPath = combinePaths(projectRootPath, manifestName);
            let manifest;
            let manifestTypingNames;
            if (host.fileExists(manifestPath)) {
                filesToWatch.push(manifestPath);
                manifest = readConfigFile(manifestPath, path => host.readFile(path)).config;
                manifestTypingNames = flatMap([manifest.dependencies, manifest.devDependencies, manifest.optionalDependencies, manifest.peerDependencies], getOwnKeys);
                addInferredTypings(manifestTypingNames, `Typing names in '${manifestPath}' dependencies`);
            }

            // Now we scan the directories for typing information in
            // already-installed dependencies (if present). Note that this
            // step happens regardless of whether a manifest was present,
            // which is certainly a valid configuration, if an unusual one.
            const packagesFolderPath = combinePaths(projectRootPath, modulesDirName);
            filesToWatch.push(packagesFolderPath);
            if (!host.directoryExists(packagesFolderPath)) {
                return;
            }

            // There's two cases we have to take into account here:
            // 1. If manifest is undefined, then we're not using a manifest.
            //    That means that we should scan _all_ dependencies at the top
            //    level of the modulesDir.
            // 2. If manifest is defined, then we can do some special
            //    filtering to reduce the amount of scanning we need to do.
            //
            // Previous versions of this algorithm checked for a `_requiredBy`
            // field in the package.json, but that field is only present in
            // `npm@>=3 <7`.

            // Package names that do **not** provide their own typings, so
            // we'll look them up.
            const packageNames: string[] = [];

            const dependencyManifestNames = manifestTypingNames
                // This is #1 described above.
                ? manifestTypingNames.map(typingName => combinePaths(packagesFolderPath, typingName, manifestName))
                // And #2. Depth = 3 because scoped packages look like `node_modules/@foo/bar/package.json`
                : host.readDirectory(packagesFolderPath, [Extension.Json], /*excludes*/ undefined, /*includes*/ undefined, /*depth*/ 3)
                    .filter(manifestPath => {
                        if (getBaseFileName(manifestPath) !== manifestName) {
                            return false;
                        }
                        // It's ok to treat
                        // `node_modules/@foo/bar/package.json` as a manifest,
                        // but not `node_modules/jquery/nested/package.json`.
                        // We only assume depth 3 is ok for formally scoped
                        // packages. So that needs this dance here.
                        const pathComponents = getPathComponents(normalizePath(manifestPath));
                        const isScoped = pathComponents[pathComponents.length - 3][0] === "@";
                        return isScoped && pathComponents[pathComponents.length - 4].toLowerCase() === modulesDirName || // `node_modules/@foo/bar`
                            !isScoped && pathComponents[pathComponents.length - 3].toLowerCase() === modulesDirName; // `node_modules/foo`
                    });

            if (log) log(`Searching for typing names in ${packagesFolderPath}; all files: ${JSON.stringify(dependencyManifestNames)}`);

            // Once we have the names of things to look up, we iterate over
            // and either collect their included typings, or add them to the
            // list of typings we need to look up separately.
            for (const manifestPath of dependencyManifestNames) {
                const normalizedFileName = normalizePath(manifestPath);
                const result = readConfigFile(normalizedFileName, (path: string) => host.readFile(path));
                const manifest: PackageJson = result.config;

                // If the package has its own d.ts typings, those will take precedence. Otherwise the package name will be used
                // to download d.ts files from DefinitelyTyped
                if (!manifest.name) {
                    continue;
                }
                const ownTypes = manifest.types || manifest.typings;
                if (ownTypes) {
                    const absolutePath = getNormalizedAbsolutePath(ownTypes, getDirectoryPath(normalizedFileName));
                    if (host.fileExists(absolutePath)) {
                        if (log) log(`    Package '${manifest.name}' provides its own types.`);
                        inferredTypings.set(manifest.name, absolutePath);
                    }
                    else {
                        if (log) log(`    Package '${manifest.name}' provides its own types but they are missing.`);
                    }
                }
                else {
                    packageNames.push(manifest.name);
                }
            }

            addInferredTypings(packageNames, "    Found package names");
        }

        /**
         * Infer typing names from given file names. For example, the file name "jquery-min.2.3.4.js"
         * should be inferred to the 'jquery' typing name; and "angular-route.1.2.3.js" should be inferred
         * to the 'angular-route' typing name.
         * @param fileNames are the names for source files in the project
         */
        function getTypingNamesFromSourceFileNames(fileNames: string[]) {
            const fromFileNames = mapDefined(fileNames, j => {
                if (!hasJSFileExtension(j)) return undefined;

                const inferredTypingName = removeFileExtension(getBaseFileName(j.toLowerCase()));
                const cleanedTypingName = removeMinAndVersionNumbers(inferredTypingName);
                return safeList.get(cleanedTypingName);
            });
            if (fromFileNames.length) {
                addInferredTypings(fromFileNames, "Inferred typings from file names");
            }

            const hasJsxFile = some(fileNames, f => fileExtensionIs(f, Extension.Jsx));
            if (hasJsxFile) {
                if (log) log(`Inferred 'react' typings due to presence of '.jsx' extension`);
                addInferredTyping("react");
            }
        }
    }

    export const enum NameValidationResult {
        Ok,
        EmptyName,
        NameTooLong,
        NameStartsWithDot,
        NameStartsWithUnderscore,
        NameContainsNonURISafeCharacters
    }

    const maxPackageNameLength = 214;

    export interface ScopedPackageNameValidationResult {
        name: string;
        isScopeName: boolean;
        result: NameValidationResult;
    }
    export type PackageNameValidationResult = NameValidationResult | ScopedPackageNameValidationResult;

    /**
     * Validates package name using rules defined at https://docs.npmjs.com/files/package.json
     */
    export function validatePackageName(packageName: string): PackageNameValidationResult {
        return validatePackageNameWorker(packageName, /*supportScopedPackage*/ true);
    }

    function validatePackageNameWorker(packageName: string, supportScopedPackage: false): NameValidationResult;
    function validatePackageNameWorker(packageName: string, supportScopedPackage: true): PackageNameValidationResult;
    function validatePackageNameWorker(packageName: string, supportScopedPackage: boolean): PackageNameValidationResult {
        if (!packageName) {
            return NameValidationResult.EmptyName;
        }
        if (packageName.length > maxPackageNameLength) {
            return NameValidationResult.NameTooLong;
        }
        if (packageName.charCodeAt(0) === CharacterCodes.dot) {
            return NameValidationResult.NameStartsWithDot;
        }
        if (packageName.charCodeAt(0) === CharacterCodes._) {
            return NameValidationResult.NameStartsWithUnderscore;
        }
        // check if name is scope package like: starts with @ and has one '/' in the middle
        // scoped packages are not currently supported
        if (supportScopedPackage) {
            const matches = /^@([^/]+)\/([^/]+)$/.exec(packageName);
            if (matches) {
                const scopeResult = validatePackageNameWorker(matches[1], /*supportScopedPackage*/ false);
                if (scopeResult !== NameValidationResult.Ok) {
                    return { name: matches[1], isScopeName: true, result: scopeResult };
                }
                const packageResult = validatePackageNameWorker(matches[2], /*supportScopedPackage*/ false);
                if (packageResult !== NameValidationResult.Ok) {
                    return { name: matches[2], isScopeName: false, result: packageResult };
                }
                return NameValidationResult.Ok;
            }
        }
        if (encodeURIComponent(packageName) !== packageName) {
            return NameValidationResult.NameContainsNonURISafeCharacters;
        }
        return NameValidationResult.Ok;
    }

    export function renderPackageNameValidationFailure(result: PackageNameValidationResult, typing: string): string {
        return typeof result === "object" ?
            renderPackageNameValidationFailureWorker(typing, result.result, result.name, result.isScopeName) :
            renderPackageNameValidationFailureWorker(typing, result, typing, /*isScopeName*/ false);
    }

    function renderPackageNameValidationFailureWorker(typing: string, result: NameValidationResult, name: string, isScopeName: boolean): string {
        const kind = isScopeName ? "Scope" : "Package";
        switch (result) {
            case NameValidationResult.EmptyName:
                return `'${typing}':: ${kind} name '${name}' cannot be empty`;
            case NameValidationResult.NameTooLong:
                return `'${typing}':: ${kind} name '${name}' should be less than ${maxPackageNameLength} characters`;
            case NameValidationResult.NameStartsWithDot:
                return `'${typing}':: ${kind} name '${name}' cannot start with '.'`;
            case NameValidationResult.NameStartsWithUnderscore:
                return `'${typing}':: ${kind} name '${name}' cannot start with '_'`;
            case NameValidationResult.NameContainsNonURISafeCharacters:
                return `'${typing}':: ${kind} name '${name}' contains non URI safe characters`;
            case NameValidationResult.Ok:
                return Debug.fail(); // Shouldn't have called this.
            default:
                throw Debug.assertNever(result);
        }
    }
}