import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";

import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import * as log from "../../core/log.ts";
import { readConfigFile } from "../../core/conf.ts";

/**
 * The on-disk folders (`f/`, `u/`) that the absolute workspace import paths
 * `/f/...` and `/u/...` map to. tsconfig `paths` and Deno import maps remap the
 * `/f/`,`/u/` prefixes to these local folders, so the same workspace import
 * resolves both on the Windmill worker and in a local editor (tsc/Bun/Deno all
 * honor the `/`-prefixed key).
 */
const WORKSPACE_IMPORT_DIRS = ["f", "u"];

// wmill-managed files holding the recommended config. They are always
// (re)written so we can ship updated recommendations over time; users keep
// their own overrides in tsconfig.json / deno.json, which reference these
// managed files and are never overwritten. This mirrors how AGENTS.wmill.md
// (managed) and AGENTS.md (user-owned) work for AI prompts.
const MANAGED_TSCONFIG = "tsconfig.wmill.json";
const MANAGED_IMPORT_MAP = "import_map.wmill.json";

const MANAGED_NOTICE =
  "// Managed by wmill — regenerated by `wmill init` / `wmill refresh tsconfig`.\n" +
  "// Do not edit; put your overrides in tsconfig.json (which extends this file).\n";

// Embedded in tsconfig.wmill.json so any command can detect a stale managed file
// (the recommended config changed) and nudge the user to `wmill refresh tsconfig`
// — mirroring the prompts freshness marker in AGENTS.wmill.md.
const TSCONFIG_HASH_PREFIX = "// wmill-tsconfig-hash: ";
const TSCONFIG_HASH_REGEX = /^\/\/ wmill-tsconfig-hash: ([0-9a-f]{12})/m;

/**
 * The recommended managed tsconfig, minus environment-dependent bits (`types`
 * depends on whether bun-types is installed locally). This is both the source
 * of the written file and the input to the freshness hash, so the hash only
 * changes when wmill's *recommended* config changes — not when bun-types
 * appears/disappears on a given machine.
 */
function buildManagedTsconfig(): {
  compilerOptions: Record<string, unknown>;
  include: string[];
} {
  // Map "/f/*" -> ["./f/*"], "/u/*" -> ["./u/*"] so the editor resolves
  // workspace imports against the local script folders.
  //
  // Known limitation: this resolves imports written with a plain `.ts` extension
  // (the canonical form). Scripts stored with a flavor-specific extension —
  // `.bun.ts`/`.deno.ts`/`.fetch.ts` for languages other than the project default
  // (see filePathExtensionFromContentType) — won't resolve via these `paths` in a
  // local editor. The worker (extension-agnostic API) and the in-app editor (ATA
  // normalizes to `.ts`) handle those fine; only local tsc / VS Code is affected.
  const paths: Record<string, string[]> = {};
  for (const dir of WORKSPACE_IMPORT_DIRS) {
    paths[`/${dir}/*`] = [`./${dir}/*`];
  }
  return {
    compilerOptions: {
      target: "ESNext",
      module: "ESNext",
      moduleResolution: "bundler",
      // Workspace imports carry an explicit `.ts` extension (e.g. "/f/foo/bar.ts");
      // allow it so the editor doesn't flag every cross-script import.
      allowImportingTsExtensions: true,
      noEmit: true,
      strict: false,
      // No `baseUrl`: with moduleResolution "bundler" the `paths` patterns resolve
      // relative to this file, and `baseUrl` is deprecated in TypeScript 7+.
      paths,
    },
    include: ["**/*.ts", "rt.d.ts"],
  };
}

function currentTsconfigHash(): string {
  return createHash("sha256")
    .update(JSON.stringify(buildManagedTsconfig()))
    .digest("hex")
    .slice(0, 12);
}

// Exact `tsconfig.json` shapes the *previous* CLI generated (single-file, before
// the managed/user split — see the now-deleted resource-type/tsconfig.ts). When
// an existing tsconfig.json matches one of these verbatim, we know it's ours (not
// a user customization), so we can safely replace it wholesale with the thin stub
// that extends tsconfig.wmill.json. Anything else is treated as user-authored.
const LEGACY_GENERATED_TSCONFIGS: Record<string, unknown>[] = [
  {
    compilerOptions: {
      target: "ESNext",
      module: "ESNext",
      moduleResolution: "bundler",
      noEmit: true,
      strict: false,
    },
    include: ["**/*.ts", "rt.d.ts"],
  },
  {
    compilerOptions: {
      target: "ESNext",
      module: "ESNext",
      moduleResolution: "bundler",
      noEmit: true,
      strict: false,
      types: ["bun-types"],
    },
    include: ["**/*.ts", "rt.d.ts"],
  },
];

// Order-sensitive deep equality (arrays compared positionally, objects by key
// set). Used only on small parsed JSON config objects.
function deepEqual(a: unknown, b: unknown): boolean {
  if (a === b) return true;
  if (Array.isArray(a) || Array.isArray(b)) {
    if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
      return false;
    }
    return a.every((x, i) => deepEqual(x, b[i]));
  }
  if (a && b && typeof a === "object" && typeof b === "object") {
    const ka = Object.keys(a as object);
    const kb = Object.keys(b as object);
    if (ka.length !== kb.length) return false;
    return ka.every((k) =>
      deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
    );
  }
  return false;
}

// How to handle an existing, user-authored config that doesn't yet reference the
// managed file. `assumeYes` (from `--yes` / `wmill init --default`) wires it
// without asking; otherwise we only wire it after an interactive confirmation —
// a non-interactive run with neither leaves the file untouched.
type WireMode = { interactive: boolean; assumeYes: boolean };

/**
 * (Re)generate the wmill-managed TypeScript/Deno IDE config so the editor
 * resolves `/f/`/`/u/` workspace imports against the local script folders.
 *
 * Split into a managed base file (always refreshed) and a user-owned file that
 * references it. We only ever touch files that are ours: the managed file is
 * regenerated, a tsconfig.json still in the previously-generated shape is
 * migrated to the new split, and a genuinely custom config is wired only with
 * the user's consent (interactive prompt, or `--yes`).
 *
 * Programmatic entry point reused by `wmill init`; also exposed as
 * `wmill refresh tsconfig`.
 */
export async function refreshTsconfig(opts?: { yes?: boolean }): Promise<void> {
  let defaultTs: "bun" | "deno" = "bun";
  try {
    const conf = await readConfigFile({ warnIfMissing: false });
    if (conf?.defaultTs === "deno") {
      defaultTs = "deno";
    }
  } catch {
    // fall back to bun if wmill.yaml is missing or unreadable
  }

  const assumeYes = opts?.yes === true;
  const mode: WireMode = {
    assumeYes,
    interactive: !!process.stdin.isTTY && !assumeYes,
  };

  // tsconfig.json is useful for Bun and general TS tooling regardless of the
  // default; the Deno import map is only relevant for Deno-default projects
  // (the Deno LSP ignores tsconfig.json).
  await refreshManagedTsconfig(defaultTs, mode);
  if (defaultTs === "deno") {
    await refreshManagedDenoImportMap(mode);
  }
}

async function refreshManagedTsconfig(defaultTs: "bun" | "deno", mode: WireMode) {
  const managed = buildManagedTsconfig();

  // Only reference bun-types if it's actually available; otherwise the IDE
  // would flag the missing type definitions. (Excluded from the freshness hash
  // since it's environment-, not recommendation-, dependent.)
  const bunTypesAvailable =
    defaultTs === "bun" ? ensureBunTypesAvailable() : false;
  if (bunTypesAvailable) {
    managed.compilerOptions.types = ["bun-types"];
  }

  const header = MANAGED_NOTICE + TSCONFIG_HASH_PREFIX + currentTsconfigHash() + "\n";
  writeFileSync(
    path.join(process.cwd(), MANAGED_TSCONFIG),
    header + JSON.stringify(managed, null, 2) + "\n"
  );
  log.info(colors.green(`Refreshed ${MANAGED_TSCONFIG}`));

  await ensureUserReferencesManaged({
    file: "tsconfig.json",
    create: { extends: `./${MANAGED_TSCONFIG}` },
    legacyFormats: LEGACY_GENERATED_TSCONFIGS,
    mode,
    wire: (parsed) => {
      // TypeScript does NOT merge `compilerOptions.paths` across `extends` — the
      // nearest config that defines `paths` wins wholesale. So a config with its
      // own `paths` would shadow the managed `/f/`/`/u/` mappings and they would
      // silently fail to resolve. We don't touch the user's paths — warn
      // and leave it for them to wire manually.
      const co = parsed.compilerOptions;
      const paths =
        co && typeof co === "object" && !Array.isArray(co)
          ? (co as Record<string, unknown>).paths
          : undefined;
      if (
        paths &&
        typeof paths === "object" &&
        !Array.isArray(paths) &&
        Object.keys(paths).length > 0
      ) {
        return (
          "defines its own `compilerOptions.paths` (TS won't merge ours in via " +
          '`extends`); add "/f/*": ["./f/*"] and "/u/*": ["./u/*"] to it'
        );
      }
      const ext = parsed.extends;
      const managed = `./${MANAGED_TSCONFIG}`;
      // Insert the managed config FIRST in `extends` so the user's own base config
      // keeps precedence on overlapping compilerOptions (strict/target/module)
      // rather than being overridden by our defaults.
      if (ext === undefined) {
        parsed.extends = managed;
      } else if (typeof ext === "string") {
        parsed.extends = [managed, ext];
      } else if (Array.isArray(ext)) {
        ext.unshift(managed);
      } else {
        return "unexpected `extends` value";
      }
      return true;
    },
    token: MANAGED_TSCONFIG,
    hint: `"extends": "./${MANAGED_TSCONFIG}"`,
  });
}

async function refreshManagedDenoImportMap(mode: WireMode) {
  // Import-map prefix keys must end with "/": "/f/" -> "./f/", "/u/" -> "./u/".
  const imports: Record<string, string> = {};
  for (const dir of WORKSPACE_IMPORT_DIRS) {
    imports[`/${dir}/`] = `./${dir}/`;
  }

  // Deno import maps only allow `imports`/`scopes`, so no comment header here.
  writeFileSync(
    path.join(process.cwd(), MANAGED_IMPORT_MAP),
    JSON.stringify({ imports }, null, 2) + "\n"
  );
  log.info(colors.green(`Refreshed ${MANAGED_IMPORT_MAP}`));

  await ensureUserReferencesManaged({
    file: "deno.json",
    // Don't write deno.json if the project already uses deno.jsonc — a new
    // deno.json would take precedence and shadow the existing config.
    altFiles: ["deno.jsonc"],
    create: { importMap: `./${MANAGED_IMPORT_MAP}` },
    mode,
    wire: (parsed) => {
      // Deno rejects `imports` + `importMap` together, so we can't auto-wire a
      // deno.json that already defines its own imports.
      if (parsed.imports !== undefined) {
        return "deno.json already defines `imports` (can't also use importMap)";
      }
      if (
        parsed.importMap !== undefined &&
        parsed.importMap !== `./${MANAGED_IMPORT_MAP}`
      ) {
        return "deno.json already sets a different `importMap`";
      }
      parsed.importMap = `./${MANAGED_IMPORT_MAP}`;
      return true;
    },
    token: MANAGED_IMPORT_MAP,
    hint: `"importMap": "./${MANAGED_IMPORT_MAP}"`,
  });
}

/**
 * Ensure a user-owned config file references the wmill-managed file. Mirrors how
 * `wmill refresh prompts` wires `@AGENTS.wmill.md` into AGENTS.md:
 *   - missing → create the minimal file (already linked);
 *   - exists & linked → leave it alone;
 *   - exists & unlinked → auto-wire it (parse JSON, apply `wire`, write back).
 * Falls back to a one-line warning when the file can't be auto-edited safely
 * (JSONC comments fail JSON.parse, or the structure already conflicts) — we
 * never corrupt a file we can't round-trip.
 */
async function ensureUserReferencesManaged(opts: {
  file: string;
  // Sibling configs that, if already present, must not be shadowed by writing
  // `opts.file` next to them (e.g. an existing deno.jsonc vs a new deno.json).
  altFiles?: string[];
  create: Record<string, unknown>;
  // Mutate the parsed user config to reference the managed file. Returns true
  // when wired, or a short reason string when it can't be wired cleanly (→ warn).
  wire: (parsed: Record<string, unknown>) => true | string;
  // Verbatim shapes a previous CLI generated for this file. A match means the
  // file is ours, so it's replaced wholesale (no prompt); anything else is
  // treated as user-authored and only wired with consent.
  legacyFormats?: Record<string, unknown>[];
  mode: WireMode;
  token: string;
  hint: string;
}) {
  // Prefer any existing config (including alternates) over creating a fresh one,
  // so we never shadow a config the user already has.
  const existing = [opts.file, ...(opts.altFiles ?? [])]
    .map((f) => path.join(process.cwd(), f))
    .find((p) => existsSync(p));

  if (!existing) {
    const userPath = path.join(process.cwd(), opts.file);
    writeFileSync(userPath, JSON.stringify(opts.create, null, 2) + "\n");
    log.info(colors.green(`Created ${opts.file} (references ${opts.token})`));
    return;
  }

  const existingName = path.basename(existing);
  let text = "";
  try {
    text = readFileSync(existing, "utf-8");
  } catch {
    return;
  }
  if (text.includes(opts.token)) {
    log.info(
      colors.gray(`${existingName} already references ${opts.token}, leaving it untouched`)
    );
    return;
  }

  // We only ever rewrite a config we can round-trip as JSON. Files with comments
  // (JSONC) fail JSON.parse, so we warn instead of corrupting them.
  let parsed: Record<string, unknown>;
  try {
    parsed = JSON.parse(text);
  } catch {
    log.warn(
      `${existingName} couldn't be auto-edited (it may contain comments). Add ${opts.hint} ` +
        `to pick up wmill's recommended settings (incl. workspace /f/, /u/ import resolution).`
    );
    return;
  }

  // The file is still exactly what a previous CLI generated → it's ours, so
  // migrate it to the new split (replace with the thin stub that extends the
  // managed file). No prompt: we're not touching user-authored content.
  if (opts.legacyFormats?.some((fmt) => deepEqual(parsed, fmt))) {
    writeFileSync(existing, JSON.stringify(opts.create, null, 2) + "\n");
    log.info(
      colors.green(
        `Migrated previously-generated ${existingName} to reference ${opts.token}`
      )
    );
    return;
  }

  // Custom config. Try wiring a clone so we can report un-wireable cases without
  // mutating, then only persist with the user's consent.
  const next = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
  const wired = opts.wire(next);
  if (wired !== true) {
    log.warn(
      `${existingName}: ${wired}. Add ${opts.hint} manually to pick up wmill's ` +
        `recommended settings (incl. workspace /f/, /u/ import resolution).`
    );
    return;
  }

  const consent = opts.mode.assumeYes
    ? true
    : opts.mode.interactive
    ? await Confirm.prompt({
        message:
          `${existingName} isn't linked to wmill's ${opts.token}. Add ${opts.hint}? ` +
          `Your settings are preserved (it's inserted first, so your config wins).`,
        default: true,
      })
    : false;

  if (!consent) {
    log.info(
      colors.gray(
        `Left ${existingName} unchanged — add ${opts.hint} when ready to enable ` +
          `workspace /f/, /u/ import resolution (or re-run \`wmill refresh tsconfig\`).`
      )
    );
    return;
  }

  writeFileSync(existing, JSON.stringify(next, null, 2) + "\n");
  log.info(colors.green(`Linked ${existingName} → ${opts.token}`));
}

function ensureBunTypesAvailable(): boolean {
  const cwd = process.cwd();
  if (existsSync(path.join(cwd, "node_modules", "bun-types"))) {
    return true;
  }

  try {
    execSync("bun --version", { stdio: "ignore" });
  } catch {
    log.info(
      "Install bun (https://bun.sh), run 'bun add -d bun-types', then re-run 'wmill refresh tsconfig' for Bun API autocompletion."
    );
    return false;
  }

  try {
    log.info(
      colors.yellow("Installing bun-types with 'bun add -d bun-types'...")
    );
    execSync("bun add -d bun-types", { stdio: "inherit" });
    log.info(colors.green("Installed bun-types."));
    return true;
  } catch (e) {
    log.warn(
      `Failed to install bun-types automatically: ${
        e instanceof Error ? e.message : e
      }`
    );
    log.info(
      "Run 'bun add -d bun-types' manually, then 'wmill refresh tsconfig', for Bun API autocompletion."
    );
    return false;
  }
}

/**
 * One-line, non-blocking warning (to stderr) when the managed config is out of
 * date. Mirrors the prompts freshness check (`warnIfPromptsStale`) exactly:
 * gated on the *managed* file existing (= the project opted in by running
 * init/refresh), and only ever warns that it's **stale** — never about a
 * missing or unlinked user tsconfig.json. So a deliberately-unlinked / custom
 * setup is never nagged, and a not-yet-initialized project stays silent.
 * Gated identically in main.ts so it never fires during `wmill init`/`refresh`.
 */
export async function warnIfTsconfigStale(opts?: { cwd?: string }): Promise<void> {
  const cwd = opts?.cwd ?? process.cwd();
  const managedPath = path.join(cwd, MANAGED_TSCONFIG);
  if (!existsSync(managedPath)) return;

  let managedText: string;
  try {
    managedText = readFileSync(managedPath, "utf-8");
  } catch {
    return;
  }
  const match = managedText.match(TSCONFIG_HASH_REGEX);
  if (!match) {
    emitTsconfigWarning(
      `${MANAGED_TSCONFIG} predates versioning. Run \`wmill refresh tsconfig\` to refresh it.`
    );
    return;
  }
  if (match[1] !== currentTsconfigHash()) {
    emitTsconfigWarning(
      `${MANAGED_TSCONFIG} is out of date. Run \`wmill refresh tsconfig\` to refresh.`
    );
  }
}

// Send to stderr (not log.warn → stdout) so it never contaminates a piped
// command's output, matching the prompts freshness warning.
function emitTsconfigWarning(message: string): void {
  process.stderr.write(`${colors.yellow(message)}\n`);
}

const command = new Command()
  .description(
    "Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects)"
  )
  .option(
    "--yes",
    "Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically)."
  )
  .action((async (opts: { yes?: boolean }) => {
    await refreshTsconfig({ yes: opts.yes === true });
  }) as any);

export default command;
