import { execa } from "execa";
import ChangelogFinder from "get-changelog-lib";
import console from "node:console";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { diff, satisfies } from "semver";

import { findWorkspaceRootSync } from "../../findWorkspaceRoot.js";

import type { PnpmWorkspaceUpdateOpts } from "./program.js";

const workspaceRoot = findWorkspaceRootSync();

async function pnpmWorkspaceUpdateExecute({ catalog, nodeVersion }: PnpmWorkspaceUpdateOpts) {
    const pnpmWorkspaceYmlPath = path.join(workspaceRoot, "pnpm-workspace.yaml");
    let pnpmWorkspaceYmlContent = await readFile(pnpmWorkspaceYmlPath, { encoding: "utf-8" });

    /**
     * A list of `@types/*` packages that are defined in the `pnpm-workspace.yaml` file.
     */
    let definitelyTypedDefinedDependencies: string[] = [];

    const outdatedResult = await getPnpmOutdatedResult(catalog);
    let definitelyTypedOutdatedResult: typeof outdatedResult = {};
    const packageNames = Object.keys(outdatedResult);
    if (packageNames.length === 0) {
        console.log("No packages to update!");
        return;
    }

    // Include DefinitelyTyped packages in the outdated result if the catalog is not "types"
    if (catalog !== "types") {
        const definitelyTypedDependencies = packageNames.map((name) => `@types/${name}`);
        definitelyTypedOutdatedResult = await getPnpmOutdatedResult(definitelyTypedDependencies);

        definitelyTypedDefinedDependencies = definitelyTypedDependencies.filter((name) =>
            new RegExp(`^(\\s*)(")?${name}(")?:\\s(.*)$`, "gm").test(pnpmWorkspaceYmlContent),
        );
    }

    // Sort so that @types/* always appears immediately after its original (non-@types) package
    const sortedOutdatedResult: typeof outdatedResult = {};
    for (const packageName of packageNames) {
        sortedOutdatedResult[packageName] = outdatedResult[packageName];
        const typesPackageName = `@types/${packageName}`;
        if (definitelyTypedOutdatedResult[typesPackageName]) {
            sortedOutdatedResult[typesPackageName] = definitelyTypedOutdatedResult[typesPackageName];
        }
    }

    await checkIsMajor(sortedOutdatedResult);
    await checkNodeEngineCompatibility(sortedOutdatedResult, nodeVersion);
    await checkIsEsmOnly(sortedOutdatedResult);

    let updated = 0;

    for (const [packageName, item] of Object.entries(sortedOutdatedResult)) {
        let outputItems = 0;
        const { current, latest, isEsmOnly, isDeprecated, isNodeEngineCompatible } = item;

        const prefix = `${packageName}@${current} -> ${latest}`;

        if (isEsmOnly === true) {
            console.error(`\x1b[31m%s\x1b[0m`, `${prefix} is ESM only.`);
            outputItems++;
        } else if (isEsmOnly === undefined) {
            console.warn(`\x1b[33m%s\x1b[0m`, `${prefix} could not be resolved to check for ESM only.`);
            outputItems++;
        }

        if (isDeprecated) {
            console.warn(`\x1b[33m%s\x1b[0m`, `${prefix} is deprecated.`);
            outputItems++;
        }

        if (isNodeEngineCompatible === false) {
            console.error(
                `\x1b[33m%s\x1b[0m`,
                `${prefix} is not compatible with node version ${nodeVersion}. Requested: ${item.latestManifest.engines.node}`,
            );
            outputItems++;
        } else if (!isNodeEngineCompatible) {
            console.warn(`\x1b[33m%s\x1b[0m`, `${prefix} does not have a node engine version specified.`);
            outputItems++;
        }

        if (item.isMajor) {
            console.warn(`\x1b[33m%s\x1b[0m`, `${prefix} is a major update.`);
            outputItems++;
        }

        const typedPackageName = `@types/${packageName}`;
        if (!packageName.startsWith("@types/") && catalog !== "types") {
            const hasTypePackageUpdate = !!sortedOutdatedResult[typedPackageName];
            if (hasTypePackageUpdate) {
                console.warn(
                    `\x1b[33m%s\x1b[0m`,
                    `${prefix} also has a DefinitelyTyped version update. After this update you get asked to update the DefinitelyTyped version as well.`,
                );
                outputItems++;
            } else if (definitelyTypedDefinedDependencies.includes(typedPackageName)) {
                console.warn(
                    `\x1b[33m%s\x1b[0m`,
                    `${prefix} also has a @types/${packageName} installed, without update. You should check if both the type and the original package are compatible.`,
                );
                outputItems++;
            }
        }

        if (diff(latest, current) !== null) {
            // Interactively ask if the user wants to update the package
            const changelog = await getChangelog(packageName);
            if (changelog) {
                console.log(`Changelog: ${changelog}`);
            }
            console.log(`NPM: https://www.npmjs.com/package/${packageName}`);
            const answer = await askYesNo(
                `Do you want to update ${prefix} (${outputItems} warnings and errors)?`,
                outputItems > 0 ? "n" : "y",
            );

            if (answer) {
                pnpmWorkspaceYmlContent = replaceVersionInPnpmWorkspaceYml(
                    pnpmWorkspaceYmlContent,
                    packageName,
                    latest,
                );
                updated++;
            }
        }
        console.log("");
    }

    if (updated > 0) {
        console.log(`\x1b[32m%s\x1b[0m`, `Updating ${updated} packages...`);
        await writeFile(pnpmWorkspaceYmlPath, pnpmWorkspaceYmlContent, { encoding: "utf-8" });
        await execa({ cwd: workspaceRoot, stdio: "inherit" })`pnpm install`;
    }

    // get changelog via get-changelog-lib
    // ast grep the import of the package
    // allow to configure packages which are allowed to be ESM only (e.g. binaries lint-staged)

    // An interactive prompt makes the CLI "hang" forever; we need to explicitly exit the process after the prompt
    process.exit(0);
}

async function getChangelog(packageName: string) {
    try {
        const changelogFinder = new ChangelogFinder();
        const changelog = await changelogFinder.getChangelog(packageName);
        return changelog;
    } catch (e) {
        return undefined;
    }
}

async function getPnpmOutdatedResult(catalogOrDependencies: string | string[]) {
    const useDependencies = Array.isArray(catalogOrDependencies)
        ? catalogOrDependencies
        : `$(yq -r '.catalogs.${catalogOrDependencies} | keys | .[]' ${workspaceRoot}/pnpm-workspace.yaml)`;

    return JSON.parse(
        (
            await execa({
                shell: true,
            })`pnpm outdated -r --json --long ${useDependencies} | jq 'map_values(.latestManifest = (.latestManifest | {name, version, engines}))'`
        ).stdout,
    ) as PnpmOutdatedResult;
}

async function checkNodeEngineCompatibility(items: PnpmOutdatedResult, currentNodeVersion: string): Promise<void> {
    for (const [packageName, item] of Object.entries(items)) {
        const requiredNodeVersion = item.latestManifest.engines?.node;
        if (requiredNodeVersion) {
            items[packageName].isNodeEngineCompatible = satisfies(currentNodeVersion, requiredNodeVersion);
        }
    }
}

async function checkIsMajor(items: PnpmOutdatedResult) {
    for (const [packageName, item] of Object.entries(items)) {
        const { latest, current } = item;
        const diffResult = diff(latest, current);
        if (diffResult !== null) {
            if (diffResult === "major") {
                items[packageName].isMajor = true;
            } else if (diffResult === "minor" && current.startsWith("0")) {
                // Consider minor updates of zero-versions as major updates
                items[packageName].isMajor = true;
            }
        }
    }
}

/**
 * @see https://github.com/rollup/plugins/blob/c8e78c8584007999050f7d9878d87e15046bbf09/packages/node-resolve/src/index.js#L258
 */
async function checkIsEsmOnly(items: PnpmOutdatedResult) {
    // First, create a temporary directory with a package.json file that contains the packages and their dependencies
    const tempDir = await mkdtemp(path.join(tmpdir(), "pnpm-esm-only-"));
    try {
        const dependencies = Object.keys(items);
        const packageJson = {
            name: "test",
            version: "1.0.0",
            dependencies: Object.fromEntries(Object.entries(items).map(([name, item]) => [name, item.latest])),
        };
        await writeFile(path.join(tempDir, "package.json"), JSON.stringify(packageJson, null, 2), {
            encoding: "utf-8",
        });
        await execa({ cwd: tempDir, stdio: "inherit" })`pnpm install`;

        const nodeCommand = `console.log(JSON.stringify(${JSON.stringify(dependencies)}.map(name => {try {return require.resolve(name)} catch (e) {return null}})))`;
        const resolvedPaths = JSON.parse((await execa({ cwd: tempDir })`node -e ${nodeCommand}`).stdout) as string[];

        const readFileContent = await Promise.all(
            resolvedPaths.map((path) => (path ? readFile(path, { encoding: "utf-8" }) : null)),
        );

        for (let i = 0; i < resolvedPaths.length; i++) {
            const dependencyName = dependencies[i];
            const content = readFileContent[i];

            items[dependencyName].isEsmOnly = content ? isModule(content) : undefined;
        }
    } catch (e) {
        console.log(e);
        throw e;
    } finally {
        await rm(tempDir, { recursive: true });
    }
}

function askYesNo(question: string, defaultValue: "y" | "n" = "n") {
    return new Promise<boolean>((resolve) => {
        process.stdin.setEncoding("utf8");
        process.stdout.write(`${question} (y/n; default: ${defaultValue}): `);

        function onData(data: string) {
            const input = data.trim().toLowerCase() || defaultValue;
            if (input === "y") {
                process.stdin.removeListener("data", onData);
                resolve(true);
            } else if (input === "n") {
                process.stdin.removeListener("data", onData);
                resolve(false);
            } else {
                process.stdout.write("Please enter y (yes) or n (no): ");
            }
        }

        process.stdin.on("data", onData);
    });
}

/**
 * @see https://github.com/component/is-module/blob/master/index.js
 */
function isModule(code: string) {
    const ES6ImportExportRegExp =
        // eslint-disable-next-line no-useless-escape
        /(?:^\s*|[}{\(\);,\n]\s*)(import[\s+|{]['"]|(import|module)[\s+|*|{][^"'\(\)\n;]+[\s|}]+from\s*['"]|export[\s+|{](\*|\{|default|function|var|const|let|[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*))/;
    // eslint-disable-next-line no-useless-escape
    const ES6AliasRegExp = /(?:^\s*|[}{\(\);,\n]\s*)(export\s*\*\s*from\s*(?:'([^']+)'|"([^"]+)"))/;
    return ES6ImportExportRegExp.test(code) || ES6AliasRegExp.test(code);
}

function replaceVersionInPnpmWorkspaceYml(pnpmWorkspaceYmlContent: string, packageName: string, version: string) {
    return pnpmWorkspaceYmlContent.replace(
        new RegExp(`^(\\s*)(")?${packageName}(")?:\\s(\\d+.*)$`, "gm"),
        `$1$2${packageName}$3: ${version}`,
    );
}

type PnpmOutdatedResult = {
    [packageName: string]: {
        current: string;
        latest: string;
        wanted: string;
        isDeprecated: boolean;
        isNodeEngineCompatible?: boolean;
        isEsmOnly?: boolean;
        isMajor?: boolean;
        dependencyType: string;
        dependentPackages: Array<{
            name: string;
            location: string;
        }>;
        latestManifest: {
            name: string;
            version: string;
            engines: {
                npm: string;
                node: string;
            };
        };
    };
};

export { pnpmWorkspaceUpdateExecute };
