import { readdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

async function packageDirectories(nodeModules) {
  const directories = [];
  for (const entry of await readdir(nodeModules, { withFileTypes: true }).catch(() => [])) {
    if (!entry.isDirectory() || entry.name === ".bin") continue;
    if (entry.name.startsWith("@")) {
      for (const child of await readdir(join(nodeModules, entry.name), { withFileTypes: true }).catch(() => [])) {
        if (child.isDirectory()) directories.push(join(nodeModules, entry.name, child.name));
      }
    } else directories.push(join(nodeModules, entry.name));
  }
  return directories;
}

async function packageAt(directory) {
  let manifest;
  try { manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); } catch { return null; }
  return { directory, manifest };
}

export async function createThirdPartyNotices({ modules, manifestPath = null }) {
  const modulesRoot = resolve(modules);
  const scopedManifestPath = manifestPath ? resolve(manifestPath) : null;
  async function resolvePackage(name, fromDirectory) {
    let current = resolve(fromDirectory);
    const insideModules = (directory) => {
      const candidate = relative(modulesRoot, directory);
      const parentPrefix = `..${process.platform === "win32" ? "\\" : "/"}`;
      return candidate === "" || (!isAbsolute(candidate) && candidate !== ".." && !candidate.startsWith(parentPrefix));
    };
    while (insideModules(current)) {
      const nested = await packageAt(join(current, "node_modules", ...name.split("/")));
      if (nested) return nested;
      if (current === modulesRoot) break;
      current = dirname(current);
    }
    return packageAt(join(modulesRoot, ...name.split("/")));
  }

  const packages = new Map();
  const scopedManifest = scopedManifestPath
    ? JSON.parse(await readFile(scopedManifestPath, "utf8"))
    : null;
  const pending = scopedManifest
    ? Object.keys({
        ...scopedManifest.dependencies,
        ...scopedManifest.optionalDependencies,
      }).map((name) => ({ name, fromDirectory: dirname(scopedManifestPath) }))
    : (await packageDirectories(modulesRoot)).map((directory) => ({ directory }));
  while (pending.length > 0) {
    const next = pending.shift();
    const found = next.directory ? await packageAt(next.directory) : await resolvePackage(next.name, next.fromDirectory);
    if (!found) continue;
    const { directory, manifest } = found;
    const key = `${manifest.name ?? directory}@${manifest.version ?? ""}`;
    if (packages.has(key)) continue;
    packages.set(key, { directory, manifest });
    if (scopedManifest) {
      pending.push(...Object.keys({
        ...manifest.dependencies,
        ...manifest.optionalDependencies,
      }).map((name) => ({ name, fromDirectory: directory })));
    } else {
      pending.push(...(await packageDirectories(join(directory, "node_modules"))).map((nested) => ({ directory: nested })));
    }
  }

  const sections = [];
  for (const [key, { directory, manifest }] of [...packages].sort(([left], [right]) => left.localeCompare(right))) {
    const names = await readdir(directory).catch(() => []);
    const licenseName = names.find((name) => /^(license|licence|copying)(\..*)?$/i.test(name));
    let licenseText = "License text not included by package; see package metadata.";
    if (licenseName) licenseText = await readFile(join(directory, licenseName), "utf8").catch(() => licenseText);
    sections.push(`${key.replace(/@(?=[^@]*$)/, " ")}\nLicense: ${manifest.license ?? "See text"}\n\n${licenseText.trim()}\n`);
  }
  return `THIRD-PARTY NPM NOTICES\n\n${sections.join(`\n${"-".repeat(72)}\n\n`)}`;
}

async function main() {
  const modules = process.argv[2];
  const output = process.argv[3];
  const manifestFlag = process.argv.indexOf("--manifest");
  const manifestPath = manifestFlag >= 0 ? process.argv[manifestFlag + 1] : null;
  if (!modules || !output || (manifestFlag >= 0 && !manifestPath)) {
    throw new Error("Use: node generate-third-party-notices.mjs <node_modules> <output> [--manifest <package.json>]");
  }
  await writeFile(resolve(output), await createThirdPartyNotices({ modules, manifestPath }));
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  main().catch((error) => {
    console.error(error.message);
    process.exitCode = 1;
  });
}
