import { parseArgs } from "node:util";

const excludedPrefixes = [
  ".agents/",
  ".claude/skills/",
  "node_modules/",
  "repos/",
  "dist/",
  "build/",
  "apps/web/build/",
  "apps/web/.react-router/",
  "apps/native/.expo/",
  "apps/native/dist/",
  "apps/native/web-build/",
  "packages/backend/convex/_generated/",
];
const maxPromptCharacters = 900_000;

const diffExclusions = excludedPrefixes.map(
  (prefix) => `:(exclude,glob)${prefix}**`
);

const documentationNames = new Set([
  "AGENTS.md",
  "README.md",
  "README.mdx",
  "LLMS.md",
]);

const supportedAgents = ["codex", "opencode", "claude"] as const;
type AgentName = (typeof supportedAgents)[number];

interface CommandResult {
  readonly exitCode: number;
  readonly stdout: string;
  readonly stderr: string;
}

const run = async (
  command: readonly string[],
  options: { readonly stdin?: string; readonly inherit?: boolean } = {}
): Promise<CommandResult> => {
  const process = Bun.spawn([...command], {
    cwd: processCwd,
    stdin: options.stdin === undefined ? "ignore" : new Blob([options.stdin]),
    stdout: options.inherit ? "inherit" : "pipe",
    stderr: options.inherit ? "inherit" : "pipe",
  });
  const [exitCode, stdout, stderr] = await Promise.all([
    process.exited,
    options.inherit ? Promise.resolve("") : new Response(process.stdout).text(),
    options.inherit ? Promise.resolve("") : new Response(process.stderr).text(),
  ]);
  return { exitCode, stdout, stderr };
};

const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
const formatCommand = (command: readonly string[]) =>
  command.map(shellQuote).join(" ");

const isExcluded = (path: string) =>
  excludedPrefixes.some((prefix) => {
    const directory = prefix.slice(0, -1);
    return (
      path === directory ||
      path.startsWith(prefix) ||
      path.includes(`/${directory}/`) ||
      path.endsWith(`/${directory}`)
    );
  });

const isDocumentation = (path: string) => {
  const name = path.split("/").at(-1) ?? path;
  return (
    documentationNames.has(name) ||
    name.endsWith(".md") ||
    name.endsWith(".mdx")
  );
};

const resolveRange = async (revision: string | undefined) => {
  const requested = revision ?? "HEAD";
  if (requested.includes("..")) {
    const result = await run(["git", "rev-list", "--max-count=1", requested]);
    if (result.exitCode !== 0 || result.stdout.trim() === "") {
      throw new Error(
        `Invalid revision range ${shellQuote(requested)}: ${result.stderr.trim()}`
      );
    }
    return requested;
  }
  const verify = await run([
    "git",
    "rev-parse",
    "--verify",
    `${requested}^{commit}`,
  ]);
  if (verify.exitCode !== 0) {
    throw new Error(
      `Invalid commit ${shellQuote(requested)}: ${verify.stderr.trim()}`
    );
  }
  return `${requested}^!`;
};

const listChangedFiles = async (range: string) => {
  const result = await run([
    "git",
    "diff",
    "--name-only",
    "--diff-filter=ACDMRTUXB",
    range,
    "--",
  ]);
  if (result.exitCode !== 0) {
    throw new Error(
      `Unable to inspect ${shellQuote(range)}: ${result.stderr.trim()}`
    );
  }
  return result.stdout
    .split("\n")
    .map((path) => path.trim())
    .filter((path) => path !== "" && !isExcluded(path));
};

const listDocumentationFiles = async () => {
  const result = await run(["git", "ls-files", "*.md", "*.mdx"]);
  if (result.exitCode !== 0) {
    throw new Error(
      `Unable to list documentation files: ${result.stderr.trim()}`
    );
  }
  return result.stdout
    .split("\n")
    .map((path) => path.trim())
    .filter(
      (path) => path !== "" && !isExcluded(path) && isDocumentation(path)
    );
};

const resolveAgent = (requested: string | undefined): AgentName => {
  const configured = requested ?? process.env.DOCS_AGENT;
  if (configured !== undefined) {
    if (!supportedAgents.includes(configured as AgentName)) {
      throw new Error(
        `Unsupported agent ${shellQuote(configured)}. Use codex, opencode, or claude.`
      );
    }
    if (Bun.which(configured) === null) {
      throw new Error(
        `Agent executable ${shellQuote(configured)} was not found on PATH.`
      );
    }
    return configured as AgentName;
  }
  const detected = supportedAgents.find((agent) => Bun.which(agent) !== null);
  if (detected === undefined) {
    throw new Error(
      "No supported coding agent found. Install codex, opencode, or claude, or set DOCS_AGENT."
    );
  }
  return detected;
};

const agentCommand = (agent: AgentName) => {
  switch (agent) {
    case "codex":
      return [
        "codex",
        "exec",
        "-",
        "--sandbox",
        "workspace-write",
        "--cd",
        processCwd,
        "--ephemeral",
      ];
    case "opencode":
      return ["opencode", "run", "--auto"];
    case "claude":
      return ["claude", "--print", "--permission-mode", "acceptEdits"];
  }
};

const buildPrompt = (input: {
  readonly range: string;
  readonly changedFiles: readonly string[];
  readonly documentationFiles: readonly string[];
  readonly diff: string;
}) => `Update repository documentation for revision ${input.range} in the current checkout.

Requirements:
- Read and follow the root AGENTS.md and any more specific AGENTS.md governing files you edit.
- Inspect the supplied diff and the current code before editing; do not document behavior that is not implemented.
- Update affected README.md, AGENTS.md, LLMS.md, or other first-party Markdown/MDX files when the revision made them stale.
- Keep documentation concise and practical. Preserve generated markers and package-specific instructions.
- Never edit repos/**, .agents/**, .claude/skills/**, generated files, or dependency/build output.
- Do not modify source code, run broad formatting, or commit changes.
- If documentation is already accurate, make no edits and say so.

Changed first-party files:
${input.changedFiles.map((path) => `- ${path}`).join("\n") || "- None"}

First-party documentation candidates:
${input.documentationFiles.map((path) => `- ${path}`).join("\n") || "- None"}

Revision diff:
\`\`\`diff
${input.diff}
\`\`\`
`;

const processCwd = process.cwd();
const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  options: {
    agent: { type: "string" },
    "dry-run": { type: "boolean", default: false },
    help: { type: "boolean", short: "h", default: false },
  },
  allowPositionals: true,
  strict: true,
});

if (values.help || positionals.length > 1) {
  console.log(`Usage: bun run docs:update [<commit-or-range>] [--agent codex|opencode|claude] [--dry-run]

A single commit defaults to HEAD and is converted to <commit>^!. Revision ranges such as main..HEAD and main...HEAD are accepted directly. Set DOCS_AGENT to choose the default coding-agent CLI.`);
  process.exit(positionals.length > 1 ? 1 : 0);
}

try {
  const range = await resolveRange(positionals[0]);
  const [changedFiles, documentationFiles, diffResult] = await Promise.all([
    listChangedFiles(range),
    listDocumentationFiles(),
    run(["git", "diff", "--find-renames", range, "--", ".", ...diffExclusions]),
  ]);
  if (diffResult.exitCode !== 0) {
    throw new Error(
      `Unable to read revision diff: ${diffResult.stderr.trim()}`
    );
  }
  const agent = resolveAgent(values.agent);
  const command = agentCommand(agent);
  const prompt = buildPrompt({
    range,
    changedFiles,
    documentationFiles,
    diff: diffResult.stdout,
  });
  if (prompt.length > maxPromptCharacters) {
    throw new Error(
      `Generated documentation prompt is ${prompt.length} characters, exceeding the ${maxPromptCharacters} character safety limit. Use a smaller revision range.`
    );
  }

  console.log(`Revision: ${range}`);
  console.log(`Agent: ${agent}`);
  console.log(`Changed first-party files: ${changedFiles.length}`);
  console.log(`Prompt characters: ${prompt.length}`);
  console.log(`Command: ${formatCommand(command)} < generated-prompt`);

  if (values["dry-run"]) {
    console.log("\n--- Generated prompt ---\n");
    console.log(prompt);
    process.exit(0);
  }

  const result = await run(command, { stdin: prompt, inherit: true });
  if (result.exitCode !== 0) {
    process.exit(result.exitCode);
  }
  const status = await run(["git", "status", "--short", "--", "*.md", "*.mdx"]);
  if (status.stdout.trim() !== "") {
    console.log("\nDocumentation changes:");
    console.log(status.stdout.trimEnd());
  } else {
    console.log("\nNo documentation files changed.");
  }
} catch (error) {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
}
