/**
 * Build every @wrnexus/* package (JS + .d.ts via tsup) and STAGE a publishable
 * copy under .publish/<name>/ with a rewritten manifest:
 *   - main/module/types/exports/bin point at ./dist/*
 *   - workspace:* deps → ^<version>
 *   - `private` removed, publishConfig.registry set
 *   - runtime assets (ui components/, ui.css) copied alongside dist
 *
 * The dev package.json (pointing at src/) is never modified, so in-repo dev and
 * tests keep working.
 *
 *   bun run scripts/publish-packages.ts            # build + stage all
 *   bun run scripts/publish-packages.ts core db    # build + stage only these
 *
 * Then publish from the staging dirs (see printed order), or dry-run with npm pack.
 */
import { build } from "tsup";
import {
  cpSync,
  existsSync,
  mkdirSync,
  readdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import process from "node:process";
import { validateAndHashStage, type StagedPackageIntegrity } from "./lib/package-integrity.ts";

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const packagesDir = join(repoRoot, "packages");
const stageRoot = join(repoRoot, ".publish");
const REGISTRY = "https://registry.npmjs.org/";
// First release is published PRIVATE ("restricted") so we can test before the
// world sees it; flip to public with `npm access public @wrnexus/<name>` (or set
// WRNEXUS_NPM_ACCESS=public to stage public manifests). Requires the `wrnexus`
// npm org to exist and be on a paid plan (private scoped packages need Teams).
const ACCESS = process.env.WRNEXUS_NPM_ACCESS === "public" ? "public" : "restricted";
const LICENSE = "MIT";
const REPOSITORY = "https://git.workroot.in/WorkRoot/WRNexusJS.git";
const HOMEPAGE = "https://wrnexusjs.dev";

/** Extra (non-code) files each package must ship, relative to the package root. */
const ASSETS: Record<string, string[]> = {
  "@wrnexus/ui": [
    "components",
    "ui.css",
    "component-catalog.json",
    "component-migrations.json",
    "component-reference.json",
    "COMPONENTS.md",
  ],
};

function packageAssets(m: Record<string, any>): string[] {
  const declared = Array.isArray(m.files)
    ? m.files.filter(
        (entry: unknown): entry is string =>
          typeof entry === "string" &&
          entry !== "src" &&
          !entry.startsWith("src/") &&
          entry !== "dist" &&
          entry !== "README.md",
      )
    : [];
  return [...new Set([...(ASSETS[m.name] ?? []), ...declared])];
}

interface Pkg {
  dir: string;
  name: string;
  manifest: Record<string, any>;
}

function readPackages(filter: string[]): Pkg[] {
  const out: Pkg[] = [];
  for (const entry of readdirSync(packagesDir)) {
    const dir = join(packagesDir, entry);
    const pj = join(dir, "package.json");
    if (!existsSync(pj)) continue;
    const manifest = JSON.parse(readFileSync(pj, "utf8"));
    if (!manifest.name?.startsWith("@wrnexus/")) continue;
    if (filter.length && !filter.includes(entry) && !filter.includes(manifest.name)) continue;
    out.push({ dir, name: manifest.name, manifest });
  }
  return out;
}

/** Topologically sort by @wrnexus deps so dependencies publish first. */
function topoSort(pkgs: Pkg[]): Pkg[] {
  const byName = new Map(pkgs.map((p) => [p.name, p]));
  const sorted: Pkg[] = [];
  const seen = new Set<string>();
  const visit = (p: Pkg) => {
    if (seen.has(p.name)) return;
    seen.add(p.name);
    for (const dep of Object.keys(p.manifest.dependencies ?? {})) {
      const d = byName.get(dep);
      if (d) visit(d);
    }
    sorted.push(p);
  };
  pkgs.forEach(visit);
  return sorted;
}

/**
 * Build a tsup entry map { outName: absSourcePath } from every `./x.ts` target
 * referenced by main/bin/exports. Object entries (unlike arrays) are NOT glob-
 * expanded by tsup, so absolute Windows paths work. `outName` is the source path
 * relative to `src/` without extension, matching the js()/dts() manifest mapping.
 */
function entriesOf(dir: string, m: Record<string, any>): Record<string, string> {
  const map: Record<string, string> = {};
  const add = (v: unknown) => {
    if (typeof v !== "string" || !v.endsWith(".ts")) return;
    const rel = v.replace(/^\.?\/?src\//, "").replace(/\.ts$/, "");
    map[rel] = join(dir, v.replace(/^\.\//, "")).replace(/\\/g, "/");
  };
  add(m.main);
  if (m.bin) Object.values(m.bin).forEach(add);
  if (m.exports) for (const v of Object.values(m.exports)) add(v);
  if (m.wrnexus?.plugin) {
    const routesDir = join(dir, "src", "routes");
    if (existsSync(routesDir)) {
      for (const file of readdirSync(routesDir, { recursive: true }) as string[]) {
        if (typeof file !== "string" || !file.endsWith(".ts")) continue;
        const source = join(routesDir, file);
        const outName = join("routes", file).replace(/\\/g, "/").replace(/\.ts$/, "");
        map[outName] = source.replace(/\\/g, "/");
      }
    }
  }
  return map;
}

function removeDirectoryWithRetry(path: string): void {
  try {
    rmSync(path, {
      recursive: true,
      force: true,
      maxRetries: 10,
      retryDelay: 300,
    });
  } catch (error) {
    const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";

    if (code === "EBUSY" || code === "EPERM") {
      throw new Error(
        `Unable to clean ${path}. Close any Explorer window, editor, terminal, antivirus scan, or process using this directory, then retry.`,
        { cause: error },
      );
    }

    throw error;
  }
}

/** Rewrite the manifest for publishing. */
function publishManifest(
  m: Record<string, any>,
  version: string,
  workspaceVersions: Map<string, string>,
): Record<string, any> {
  const js = (ts: string) => "./dist/" + ts.replace(/^\.?\/?src\//, "").replace(/\.ts$/, ".js");
  const dts = (ts: string) => "./dist/" + ts.replace(/^\.?\/?src\//, "").replace(/\.ts$/, ".d.ts");

  const out: Record<string, any> = {
    name: m.name,
    version,
    type: "module",
    description: m.description ?? `${m.name} — part of the WrNexus framework.`,
    license: LICENSE,
    repository: {
      type: "git",
      url: REPOSITORY,
      directory: `packages/${String(m.name).replace("@wrnexus/", "")}`,
    },
    homepage: `${HOMEPAGE}/packages/${String(m.name).replace("@wrnexus/", "")}`,
    bugs: { url: `${REPOSITORY.replace(/\.git$/, "")}/issues` },
    keywords: ["wrnexus", "bun", "typescript", String(m.name).replace("@wrnexus/", "")],
    sideEffects: m.sideEffects ?? false,
    main: m.main ? js(m.main) : "./dist/index.js",
    module: m.main ? js(m.main) : "./dist/index.js",
    types: m.main ? dts(m.main) : "./dist/index.d.ts",
    engines: { bun: ">=1.3.0" },
    publishConfig: { registry: REGISTRY, access: ACCESS },
  };

  // exports: rewrite .ts targets to { types, import }; copy non-.ts (assets) as-is.
  if (m.exports) {
    const ex: Record<string, any> = {};
    for (const [key, val] of Object.entries(m.exports)) {
      if (typeof val === "string" && val.endsWith(".ts")) {
        ex[key] = { types: dts(val), import: js(val) };
      } else {
        ex[key] = val; // e.g. "./ui.css"
      }
    }
    out.exports = ex;
  } else {
    out.exports = { ".": { types: out.types, import: out.main } };
  }

  if (m.bin) {
    out.bin = {};
    for (const [k, v] of Object.entries(m.bin)) out.bin[k] = js(v as string);
  }

  if (m.dependencies) {
    out.dependencies = {};
    for (const [dep, range] of Object.entries(m.dependencies)) {
      out.dependencies[dep] = String(range).startsWith("workspace:")
        ? `^${workspaceVersions.get(dep) ?? version}`
        : range;
    }
  }

  if (m.wrnexus) {
    out.wrnexus = structuredClone(m.wrnexus);
    const plugin = out.wrnexus?.plugin?.plugin;
    if (typeof plugin === "string" && plugin.endsWith(".ts")) {
      out.wrnexus.plugin.plugin = js(plugin);
    }
  }

  const files = new Set(["dist", "README.md"]);
  for (const a of packageAssets(m)) files.add(a);
  out.files = [...files];

  return out;
}

async function main() {
  const filter = process.argv.slice(2);
  const allPackages = readPackages([]);
  const workspaceVersions = new Map(
    allPackages.map((pkg) => [pkg.name, String(pkg.manifest.version)]),
  );
  const selected = filter.length
    ? allPackages.filter(
        (pkg) => filter.includes(pkg.dir.split(/[\\/]/).pop()!) || filter.includes(pkg.name),
      )
    : allPackages;
  const pkgs = topoSort(selected);
  if (!pkgs.length) {
    console.error("No @wrnexus packages matched.");
    process.exit(1);
  }
  removeDirectoryWithRetry(stageRoot);
  mkdirSync(stageRoot, { recursive: true });

  const order: string[] = [];
  const packageIntegrity: StagedPackageIntegrity[] = [];
  for (const p of pkgs) {
    const entries = entriesOf(p.dir, p.manifest);
    process.stdout.write(`▸ ${p.name} … `);

    await build({
      entry: entries,
      outDir: join(p.dir, "dist"),
      format: ["esm"],
      dts: true,
      external: [/^@wrnexus\//, /^bun:/, /^node:/],
      tsconfig: join(repoRoot, "tsconfig.json"),
      clean: true,
      splitting: false,
      sourcemap: false,
      shims: false,
      silent: true,
      target: "esnext",
      platform: "node",
    });

    // tsup's dts pass can occasionally leak .d.ts files next to source (the
    // jsxImportSource files). Source is all .ts, so any .d.ts under src/ is a
    // stray artifact — remove it so it never pollutes the tree.
    for (const f of readdirSync(join(p.dir, "src"), { recursive: true }) as string[]) {
      if (typeof f === "string" && f.endsWith(".d.ts")) {
        rmSync(join(p.dir, "src", f), { force: true });
      }
    }

    // Stage: dist + assets + manifest + README.
    const stage = join(stageRoot, p.name.replace("@wrnexus/", ""));
    mkdirSync(stage, { recursive: true });
    cpSync(join(p.dir, "dist"), join(stage, "dist"), { recursive: true });
    for (const a of packageAssets(p.manifest)) {
      if (existsSync(join(p.dir, a))) cpSync(join(p.dir, a), join(stage, a), { recursive: true });
    }
    const manifest = publishManifest(p.manifest, p.manifest.version, workspaceVersions);
    writeFileSync(join(stage, "package.json"), JSON.stringify(manifest, null, 2) + "\n");
    if (p.manifest.wrnexus && !manifest.wrnexus?.plugin?.plugin) {
      throw new Error(`${p.name} lost its wrnexus.plugin metadata while staging.`);
    }
    for (const asset of packageAssets(p.manifest)) {
      if (!existsSync(join(stage, asset))) {
        throw new Error(`${p.name} did not stage declared runtime asset ${asset}.`);
      }
    }
    // Ship the package's own README.md if it has one; else a minimal placeholder.
    const srcReadme = join(p.dir, "README.md");
    if (existsSync(srcReadme)) {
      cpSync(srcReadme, join(stage, "README.md"));
    } else {
      writeFileSync(
        join(stage, "README.md"),
        `# ${p.name}\n\n${manifest.description}\n\nPart of the WrNexus framework.\n`,
      );
    }

    packageIntegrity.push(validateAndHashStage(stage, manifest));
    order.push(p.name);
    console.log("staged + verified");
  }

  const integrityDocument = {
    schemaVersion: 1,
    frameworkVersion: String(
      JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version,
    ),
    access: ACCESS,
    packages: packageIntegrity,
  };
  writeFileSync(
    join(stageRoot, "PACKAGE-INTEGRITY.json"),
    `${JSON.stringify(integrityDocument, null, 2)}\n`,
  );

  console.log(
    `\n✓ Staged and verified ${order.length} package(s) under .publish/  (access: ${ACCESS})`,
  );
  console.log("Publish order (deps first):");
  for (const n of order) {
    console.log(`  npm publish .publish/${n.replace("@wrnexus/", "")} --access ${ACCESS}`);
  }
  if (ACCESS === "restricted") {
    console.log("\nAfter testing, flip every package to public with:");
    for (const n of order) console.log(`  npm access public ${n}`);
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
