import consola from 'consola';
import { glob } from 'tinyglobby';
import { dirname } from 'node:path';
import pMap from 'p-map';
import * as semver from 'semver';
import { styleText } from 'node:util';

const HELP_MESSAGE = `
Upgrades dependencies throughout WXT using custom rules.

Usage:
  bun run scripts/upgrade-deps.ts [options]

Options:
  --write, -w       Write changes to package.json files
  --major, -m       Allow major version upgrades
  --dev-only, -d    Only check/upgrade dev dependencies
  --prod-only, -p   Only check/upgrade production dependencies
  --help, -h        Display this help message
`.slice(1, -1);

const IGNORED_PACKAGES = [
  // Very touchy, don't change:
  'typedoc',
  'typedoc-plugin-markdown',
  'typedoc-vitepress-theme',
  // Manually manage version so a single version is used:
  'esbuild',
  // Maintained manually to match min-node version
  '@types/node',
  // License changed in newer versions
  'ua-parser-js',
];

await main();

async function main(): Promise<never> {
  const args = process.argv.slice(2);
  const isHelp = ['-h', '--help'].some((arg) => args.includes(arg));
  const isWrite = ['-w', '--write'].some((arg) => args.includes(arg));
  const isDevOnly = ['-d', '--dev-only'].some((arg) => args.includes(arg));
  const isProdOnly = ['-p', '--prod-only'].some((arg) => args.includes(arg));

  if (isHelp) {
    console.log(HELP_MESSAGE);
    process.exit(0);
  }

  const { packageJsonFiles, dependencyVersionsMap } =
    await getPackageJsonDependencies(isDevOnly, isProdOnly);
  const dependencyVersionMap = validateNoMultipleVersions(
    dependencyVersionsMap,
  );

  consola.start(
    `Fetching ${Object.keys(dependencyVersionsMap).length} dependencies...`,
  );
  const dependencies = await fetchAllPackageInfos(dependencyVersionMap);
  consola.success('Done!');

  const isMajor = ['-m', '--major'].some((arg) => args.includes(arg));
  const upgrades = await detectUpgrades(dependencies, isMajor);

  if (!upgrades.length) {
    consola.info("\nNo upgrades found, you're up to date!\n");
    process.exit(0);
  }

  printUpgrades(upgrades);

  if (!isWrite) {
    consola.info('Run with `-w` to write changes to package.json files\n');
    process.exit(0);
  }

  consola.start('Writing new versions to package.json files...');
  await writeUpgrades(packageJsonFiles, upgrades);
  consola.success('Done!');
  consola.info('\nRun `bun install` to install new dependencies\n');
  process.exit(0);
}

type DependencyVersionsMap = Record<string, Set<string>>;
type PackageJsonData = {
  content: any;
  path: string;
  folder: string;
};

async function getPackageJsonDependencies(
  isDevOnly: boolean,
  isProdOnly: boolean,
): Promise<{
  packageJsonFiles: string[];
  dependencyVersionsMap: DependencyVersionsMap;
}> {
  const packageJsonFiles = await glob(
    ['package.json', '*/*/package.json', '!**/node_modules'],
    { onlyFiles: true, expandDirectories: false },
  );
  const packageJsons: PackageJsonData[] = await Promise.all(
    packageJsonFiles.map(async (path) => ({
      content: await Bun.file(path).json(),
      path,
      folder: dirname(path),
    })),
  );

  const dependencyVersionsMap = packageJsons.reduce<DependencyVersionsMap>(
    (map, { content }) => {
      const addToMap = ([name, version]: [string, any]) => {
        if (
          name === 'wxt' ||
          version.startsWith('workspace:') ||
          IGNORED_PACKAGES.includes(name)
        )
          return;

        map[name] ||= new Set();
        map[name].add(version as string);
      };
      if (!isDevOnly)
        Object.entries(content.dependencies || {}).forEach(addToMap);
      if (!isProdOnly)
        Object.entries(content.devDependencies || {}).forEach(addToMap);
      return map;
    },
    {},
  );

  return {
    packageJsonFiles,
    dependencyVersionsMap,
  };
}

type DependencyVersionMap = Record<string, string>;

function validateNoMultipleVersions(
  dependencyVersionsMap: DependencyVersionsMap,
): DependencyVersionMap {
  const depsWithMultipleVersions = Object.entries(dependencyVersionsMap).filter(
    ([_, versions]) => versions.size > 1,
  );
  if (depsWithMultipleVersions.length === 0) {
    return Object.fromEntries(
      Object.entries(dependencyVersionsMap).map(([name, versions]) => [
        name,
        [...versions][0],
      ]),
    );
  }

  const maxWidth = Math.max(
    ...depsWithMultipleVersions.map(([name]) => name.length),
  );

  consola.info('Found multiple versions of:');
  for (const [name, versions] of depsWithMultipleVersions) {
    console.log(
      `    ${styleText('magenta', name.padEnd(maxWidth))}  ${Array.from(
        versions,
      )
        .map((text) => styleText('cyan', text))
        .join('\t')}`,
    );
  }
  consola.error(`${depsWithMultipleVersions.length} problem(s) found`);
  process.exit(1);
}

async function spawnJson(cmd: string[]): Promise<any> {
  const child = Bun.spawn({ cmd });
  const exitCode = await child.exited;
  if (exitCode !== 0)
    throw new Error(`Command failed with exit code ${exitCode}`);

  return JSON.parse(
    // @ts-ignore: text() is an untyped util Bun provides for ReadableStreams
    await child.stdout.text(),
  );
}

async function fetchPackageInfo(name: string): Promise<PackageInfo> {
  // Use Bun instead of API in case dependencies don't come from NPM.
  const partial = await spawnJson(['bun', 'info', name, '--json']);

  // Bun doesn't return the dist-tags and time fields by default, have to fetch
  // them separately.
  const distTags = await spawnJson([
    'bun',
    'info',
    name,
    'dist-tags',
    '--json',
  ]);
  const time = await spawnJson(['bun', 'info', name, 'time', '--json']);

  return {
    ...partial,
    'dist-tags': distTags,
    time,
  };
}

type PackageInfo = {
  name: string;
  'dist-tags': {
    latest: string;
    [tag: string]: string;
  };
  versions: string[];
  time: {
    created: string;
    modified: string;
    [version: string]: string;
  };
};

type DependencyInfo = {
  name: string;
  currentVersionRange: string;
  info: PackageInfo;
};

async function fetchAllPackageInfos(
  deps: DependencyVersionMap,
): Promise<DependencyInfo[]> {
  const infos: DependencyInfo[] = await pMap(
    Object.entries(deps),
    async ([name, currentVersionRange]) => {
      const info = await fetchPackageInfo(name);
      return { name, currentVersionRange, info };
    },
    { concurrency: 20 },
  );
  return infos.toSorted((a, b) => a.name.localeCompare(b.name));
}

type UpgradeDetails = {
  name: string;
  rangePrefix: string;
  currentVersion: string;
  currentVersionReleasedAt: Date;
  currentRange: string;
  upgradeToVersion: string;
  upgradeToVersionReleasedAt: Date;
  upgradeToRange: string;
  diff: semver.ReleaseType | null;
  latestVersion: string;
  latestVersionReleasedAt: Date;
};

async function detectUpgrades(
  deps: DependencyInfo[],
  isMajor: boolean,
): Promise<UpgradeDetails[]> {
  const results: UpgradeDetails[] = [];

  for (const dep of deps) {
    const currentRange = dep.currentVersionRange;
    if (currentRange === '*') continue;

    const parts = currentRange.split(' || ').map((part) => part.trim());
    const lastRange = parts[parts.length - 1];
    const isUnion = parts.length > 1;

    const rangePrefix = lastRange.match(/^(.?)(\d+\.\d+\.\d+)/)?.[1] ?? '';

    const currentVersion = semver.minVersion(lastRange)?.version;
    if (currentVersion == null)
      throw Error(`Invalid version specifier: ${dep.name}@${currentRange}`);

    const currentVersionReleasedAt = new Date(dep.info.time[currentVersion]);
    const isPre1 = currentVersion.startsWith('0.');

    const latestVersion = dep.info['dist-tags'].latest;
    const latestVersionReleasedAt = new Date(dep.info.time[latestVersion]);

    const upgradeToVersion = isMajor
      ? // Always use the latest version for major upgrades
        latestVersion
      : // Otherwise use the last stable version greater than the current version that is not a major release
        (dep.info.versions.findLast(
          (v) =>
            semver.gt(v, currentVersion) &&
            (isPre1 ? ['patch'] : ['patch', 'minor']).includes(
              semver.diff(v, currentVersion)!,
            ) &&
            semver.prerelease(v) == null,
        ) ?? currentVersion);
    const upgradeToVersionReleasedAt = new Date(
      dep.info.time[upgradeToVersion],
    );

    let upgradeToRange = `${rangePrefix}${upgradeToVersion}`;
    upgradeToRange = isUnion
      ? semver.satisfies(upgradeToVersion, currentRange)
        ? currentRange
        : `${currentRange} || ${upgradeToRange}`
      : upgradeToRange;

    let diff = semver.diff(currentVersion, upgradeToVersion);
    if (isPre1) {
      if (diff === 'minor') diff = 'major';
      if (diff === 'patch') diff = 'minor';
    }

    if (upgradeToRange === currentRange) continue;

    results.push({
      name: dep.name,
      rangePrefix,
      diff,
      currentVersion,
      currentVersionReleasedAt,
      currentRange,
      upgradeToVersion,
      upgradeToVersionReleasedAt,
      upgradeToRange,
      latestVersion,
      latestVersionReleasedAt,
    });
  }

  return results;
}

function printUpgrades(upgrades: UpgradeDetails[]): void {
  const namePadding = Math.max(...upgrades.map((u) => u.name.length));
  const currentVersionPadding = Math.max(
    ...upgrades.map((u) => u.currentRange.length),
  );
  const upgradeToVersionPadding = Math.max(
    ...upgrades.map((u) => u.upgradeToRange.length),
  );
  const numberPadding = String(upgrades.length + 1).length + 1;

  consola.info(`Found ${upgrades.length} upgrades:\n`);

  for (let i = 0; i < upgrades.length; i++) {
    const upgrade = upgrades[i];
    const num = styleText(
      'bold',
      (i + 1).toString().padStart(numberPadding) + '.',
    );
    const name = styleText('magenta', upgrade.name.padEnd(namePadding));
    const color =
      upgrade.diff == null
        ? 'bold'
        : upgrade.diff === 'patch'
          ? 'green'
          : upgrade.diff === 'minor'
            ? 'yellow'
            : 'red';
    const currentVersion = styleText(
      'bold',
      upgrade.currentRange.padEnd(currentVersionPadding),
    );
    const upgradeToVersion = styleText(
      color,
      upgrade.upgradeToRange.padEnd(upgradeToVersionPadding),
    );
    const latest =
      upgrade.latestVersion !== upgrade.upgradeToVersion
        ? ' ' +
          styleText(['bold', 'red'], `(${upgrade.latestVersion} available)`)
        : '';
    console.log(
      `  ${num} ${name}  ${currentVersion}  ${styleText('dim', '→')}  ${upgradeToVersion}${latest}`,
    );
  }
  console.log();
}

async function writeUpgrades(
  packageJsonFiles: string[],
  upgrades: UpgradeDetails[],
) {
  for (const packageJsonFile of packageJsonFiles) {
    const file = Bun.file(packageJsonFile);
    const oldText = await file.text();
    let newText = oldText;
    for (const upgrade of upgrades) {
      const search = `"${upgrade.name}": "${upgrade.currentRange}"`;
      const replace = `"${upgrade.name}": "${upgrade.upgradeToRange}"`;
      newText = newText.replaceAll(search, replace);
    }
    if (newText !== oldText) {
      await file.write(newText);
    }
  }
}
