import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";

const repoRoot = process.cwd();
const tempWorkspaces = new Set<string>();

afterEach(async () => {
  await Promise.all([...tempWorkspaces].map((workspace) => rm(workspace, { recursive: true, force: true })));
  tempWorkspaces.clear();
});

describe("development scripts", () => {
  it.each([
    { message: "feat: add side chats", expectedStatus: 0 },
    { message: "Merge pull request #123 from owner/feature", expectedStatus: 0 },
    { message: 'Revert "feat: add side chats"', expectedStatus: 1 },
    { message: "v5.1.0", expectedStatus: 1 },
    { message: "fixup! feat: add side chats", expectedStatus: 1 },
    { message: "squash! feat: add side chats", expectedStatus: 1 },
  ])("enforces the commit message policy for $message", ({ message, expectedStatus }) => {
    expect(runCommitlint(message)).toBe(expectedStatus);
  });

  it("fails style builds when CSS files are missing from the style order file", async () => {
    const cwd = await styleOrderFixture();

    const result = runNodeScript("scripts/build-styles.mjs", [], cwd);

    expect(result.status).toBe(1);
    expect(result.stderr).toContain("CSS files missing from src/styles/order.json: 10-unlisted.css");
  });

  it("passes the expected codex generate-ts arguments", async () => {
    const cwd = await tempWorkspace();
    const calls: { command: string; args: string[]; cwd: string }[] = [];
    const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href);

    await writeAppServerCompatibility(cwd, "0.144.4", ["app-server", "generate-ts", "--experimental", "--test-setting"]);

    await generateAppServerTypes({
      cwd,
      readCodexVersion: () => "0.144.4",
      async runCommand(command: string, args: string[], options: { cwd: string }) {
        calls.push({ command, args, cwd: options.cwd });
        const outIndex = args.indexOf("--out");
        const outputDir = args[outIndex + 1];
        if (!outputDir) throw new Error("Missing generated output directory");
        await mkdir(path.join(options.cwd, outputDir, "v2"), { recursive: true });
        await writeFile(
          path.join(options.cwd, outputDir, "v2", "Example.ts"),
          "// GENERATED CODE! DO NOT MODIFY BY HAND!\nexport type Example = string | null | null;\n",
        );
      },
    });

    expect(calls).toHaveLength(1);
    expect(calls[0]).toMatchObject({ command: "codex", cwd });
    expect(calls[0]?.args.slice(0, 5)).toEqual(["app-server", "generate-ts", "--experimental", "--test-setting", "--out"]);
    expect(calls[0]?.args[5]?.replaceAll("\\", "/")).toMatch(/^src\/generated\/\.app-server-/);
    await expect(readFile(path.join(cwd, "src", "generated", "app-server", "v2", "Example.ts"), "utf8")).resolves.toContain(
      "export type Example = string | null;",
    );
  });

  it("preserves generated bindings when generation fails", async () => {
    const cwd = await tempWorkspace();
    const generatedDir = path.join(cwd, "src", "generated", "app-server");
    await mkdir(generatedDir, { recursive: true });
    await writeFile(path.join(generatedDir, "Existing.ts"), "export type Existing = true;\n");
    await writeAppServerCompatibility(cwd, "0.144.4");
    const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href);

    await expect(
      generateAppServerTypes({
        cwd,
        readCodexVersion: () => "0.144.4",
        runCommand: async () => {
          throw new Error("generation failed");
        },
      }),
    ).rejects.toThrow("generation failed");

    await expect(readFile(path.join(generatedDir, "Existing.ts"), "utf8")).resolves.toBe("export type Existing = true;\n");
    await expect(readdir(path.join(cwd, "src", "generated"))).resolves.toEqual(["app-server"]);
  });

  it("checks normalized generated bindings without replacing the tracked tree", async () => {
    const cwd = await tempWorkspace();
    const generatedDir = path.join(cwd, "src", "generated", "app-server");
    await mkdir(path.join(generatedDir, "v2"), { recursive: true });
    await writeFile(
      path.join(generatedDir, "v2", "Example.ts"),
      "// GENERATED CODE! DO NOT MODIFY BY HAND!\n// This file was mechanically normalized after generation by scripts/generate-app-server-types.mjs.\nexport type Example = string | null;\n",
    );
    await writeAppServerCompatibility(cwd, "0.144.4");
    const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href);

    const generate = async (_command: string, args: string[], options: { cwd: string }) => {
      const outputDir = args[args.indexOf("--out") + 1];
      if (!outputDir) throw new Error("Missing generated output directory");
      await mkdir(path.join(options.cwd, outputDir, "v2"), { recursive: true });
      await writeFile(
        path.join(options.cwd, outputDir, "v2", "Example.ts"),
        "// GENERATED CODE! DO NOT MODIFY BY HAND!\nexport type Example = string | null | null;\n",
      );
    };

    await expect(
      generateAppServerTypes({ cwd, check: true, readCodexVersion: () => "0.144.4", runCommand: generate }),
    ).resolves.toBeUndefined();

    const trackedSource = await readFile(path.join(generatedDir, "v2", "Example.ts"), "utf8");
    await expect(
      generateAppServerTypes({
        cwd,
        check: true,
        readCodexVersion: () => "0.144.4",
        async runCommand(command: string, args: string[], options: { cwd: string }) {
          await generate(command, args, options);
          const outputDir = args[args.indexOf("--out") + 1];
          if (!outputDir) throw new Error("Missing generated output directory");
          await writeFile(path.join(options.cwd, outputDir, "v2", "Extra.ts"), "export type Extra = true;\n");
        },
      }),
    ).rejects.toThrow("generated app-server bindings are out of date:\n  added: v2/Extra.ts");
    await expect(readFile(path.join(generatedDir, "v2", "Example.ts"), "utf8")).resolves.toBe(trackedSource);
    await expect(readdir(path.join(cwd, "src", "generated"))).resolves.toEqual(["app-server"]);
  });

  it("refuses to generate bindings with a different Codex CLI patch", async () => {
    const cwd = await tempWorkspace();
    await writeAppServerCompatibility(cwd, "0.144.4");
    const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href);

    await expect(generateAppServerTypes({ cwd, readCodexVersion: () => "0.144.5", runCommand: async () => undefined })).rejects.toThrow(
      "Codex CLI 0.144.4 is required to generate app-server bindings; found 0.144.5.",
    );
  });

  it("reads app-server compatibility policy from the declared baseline in API baseline checks", async () => {
    const cwd = await tempWorkspace();
    await mkdir(path.join(cwd, "src", "app-server"), { recursive: true });
    const { createApiBaselineReport } = await import(pathToFileURL(path.join(repoRoot, "scripts", "api-baseline.mjs")).href);

    await writeJson(path.join(cwd, "package.json"), {
      version: "1.0.0",
      devDependencies: {
        obsidian: "~1.12.3",
      },
    });
    await writeJson(path.join(cwd, "package-lock.json"), {
      packages: {
        "node_modules/obsidian": {
          version: "1.12.3",
        },
      },
    });
    await writeJson(path.join(cwd, "manifest.json"), {
      minAppVersion: "1.12.0",
    });
    await writeJson(path.join(cwd, "versions.json"), {
      "1.0.0": "1.12.0",
    });
    await writeFile(
      path.join(cwd, "README.md"),
      [
        "## Compatibility",
        "",
        "| Key | Version | Notes |",
        "| --- | --- | --- |",
        "| `codex.testedCliVersion` | `0.139.0` | Tested CLI. |",
        "| `manifest.minAppVersion` | `1.12.0` | Minimum app version. |",
        "| `obsidian` API types | `1.12.3` | Compile-time API package. |",
      ].join("\n"),
    );
    await mkdir(path.join(cwd, "src", "app-server", "connection"), { recursive: true });
    await writeJson(path.join(cwd, "src", "app-server", "connection", "compatibility.json"), {
      codexAppServer: {
        testedCliVersion: "0.139.0",
        typeGeneration: {
          arguments: ["app-server", "generate-ts", "--experimental"],
        },
        initialize: {
          capabilities: {
            experimentalApi: true,
            requestAttestation: false,
          },
        },
      },
    });

    const report = await createApiBaselineReport({
      cwd,
      readCodexVersion: () => "0.139.0",
    });

    expect(report.codex.recordedTestedCliVersion).toBe("0.139.0");
    expect(report.codex.generationArguments).toEqual(["app-server", "generate-ts", "--experimental"]);
    expect(report.codex.readmeMatchesRecordedVersion).toBe(true);
    expect(report.codex.localCliMatchesRecordedVersion).toBe(true);
    expect(report.codex.initializeExperimentalApi).toBe(true);
    expect(report.codex.initializeRequestAttestationDisabled).toBe(true);
    expect(report.failures).toEqual([]);

    const missingLocalCliReport = await createApiBaselineReport({
      cwd,
      readCodexVersion: () => null,
    });
    expect(missingLocalCliReport.failures).toContain("local codex --version could not be read.");
  });

  it("rejects the obsolete recorded-only API baseline mode", () => {
    const result = runNodeScript("scripts/api-baseline.mjs", ["--recorded-only"], repoRoot);

    expect(result.status).toBe(1);
    expect(result.stderr).toContain("Usage: node scripts/api-baseline.mjs [--json]");
  });

  it("reports app-server provenance drift between the recorded, README, and local CLI versions", async () => {
    const cwd = await apiBaselineFixture({ recordedCodexVersion: "0.144.4", readmeCodexVersion: "0.144.0" });
    const { createApiBaselineReport } = await import(pathToFileURL(path.join(repoRoot, "scripts", "api-baseline.mjs")).href);

    const report = await createApiBaselineReport({ cwd, readCodexVersion: () => "0.144.5" });

    expect(report.failures).toContain("README Codex CLI 0.144.0 does not match recorded tested CLI 0.144.4.");
    expect(report.failures).toContain("local Codex CLI 0.144.5 does not match recorded tested CLI 0.144.4.");
  });

  it("checks generated app-server bindings in CI and release paths", async () => {
    const [checkWorkflow, releaseWorkflow, releasePreflight] = await Promise.all([
      readFile(path.join(repoRoot, ".github", "workflows", "check.yml"), "utf8"),
      readFile(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8"),
      readFile(path.join(repoRoot, "scripts", "release", "preflight.mjs"), "utf8"),
    ]);

    for (const workflow of [checkWorkflow, releaseWorkflow]) {
      expect(workflow).toContain("npm install --global");
      expect(workflow).toContain("node scripts/app-server-compatibility.mjs --tested-cli-version");
      expect(workflow).toContain("npm run generate:app-server-types:check");
    }
    expect(releasePreflight).toContain('run("npm", ["run", "generate:app-server-types:check"]);');
  });

  it("reports representative CSS usage policy failures", async () => {
    const cwd = await cssUsageFixture({
      "src/styles/10-component.css": [
        ".codex-panel__used { display: block; }",
        ".codex-panel__test-only { display: block; }",
        ".codex-panel__unused { display: block; }",
      ].join("\n"),
      "src/component.ts": [
        'export const className = "codex-panel__used";',
        "export const dynamicClassName = `codex-panel__task-step--$" + "{status}`;",
      ].join("\n"),
      "tests/component.test.ts": 'expect("codex-panel__test-only").toBeTruthy();\n',
    });

    const result = runNodeScript("scripts/check-css-usage.mjs", [], cwd);

    expect(result.status).toBe(1);
    expect(result.stdout).toBe("");
    expect(result.stderr).toContain("CSS usage check failed.");
    expect(result.stderr).toContain("Dynamic CSS class prefixes are not allowed:");
    expect(result.stderr).toContain("codex-panel__task-step--");
    expect(result.stderr).toContain("codex-panel__test-only");
    expect(result.stderr).toContain("codex-panel__unused");
  });

  it("reports unused panel custom properties", async () => {
    const cwd = await cssUsageFixture({
      "src/styles/10-component.css": [
        ".codex-panel__used {",
        "  --codex-panel-used-size: 1px;",
        "  --codex-panel-unused-size: 2px;",
        "  width: var(--codex-panel-used-size);",
        "  /*",
        "  --codex-panel-commented-size: 3px;",
        "  width: var(--codex-panel-unused-size);",
        "  */",
        "}",
      ].join("\n"),
      "src/component.ts": 'export const className = "codex-panel__used";\n',
    });

    const result = runNodeScript("scripts/check-css-usage.mjs", [], cwd);

    expect(result.status).toBe(1);
    expect(result.stderr).toContain("Unused CSS custom property candidates:");
    expect(result.stderr).toContain("--codex-panel-unused-size");
    expect(result.stderr).not.toContain("--codex-panel-used-size\n");
    expect(result.stderr).not.toContain("--codex-panel-commented-size");
  });

  it("does not count a longer source class name as usage", async () => {
    const cwd = await cssUsageFixture({
      "src/styles/10-component.css": [".codex-panel__item { display: block; }", ".codex-panel__item-detail { display: block; }"].join("\n"),
      "src/component.ts": 'export const className = "codex-panel__item-detail";\n',
    });

    const result = runNodeScript("scripts/check-css-usage.mjs", [], cwd);

    expect(result.status).toBe(1);
    expect(result.stderr).toContain("Unused CSS class candidates:");
    expect(result.stderr).toContain("  codex-panel__item\n");
  });

  it("prepares release notes from conventional commits since the previous tag", async () => {
    const cwd = await tempWorkspace();
    await writeJson(path.join(cwd, "package.json"), { version: "2.3.2" });
    await writeJson(path.join(cwd, "package-lock.json"), {
      version: "2.3.2",
      packages: { "": { version: "2.3.2" } },
    });
    await writeJson(path.join(cwd, "manifest.json"), { version: "2.3.2", minAppVersion: "1.12.0" });
    await writeJson(path.join(cwd, "versions.json"), { "2.3.2": "1.12.0" });
    runGit(["init"], cwd);
    runGit(["config", "user.name", "Codex Panel Tests"], cwd);
    runGit(["config", "user.email", "tests@example.com"], cwd);
    runGit(["add", "."], cwd);
    runGit(["commit", "-m", "chore: establish release baseline"], cwd);
    runGit(["tag", "2.3.2"], cwd);
    await writeFile(path.join(cwd, "change.txt"), "side chats\n");
    runGit(["add", "change.txt"], cwd);
    runGit(["commit", "-m", "feat(chat): add side chat support"], cwd);

    const result = runNodeScript("scripts/release/prepare.mjs", ["2.4.0"], cwd);

    expect(result.status).toBe(0);
    await expect(readFile(path.join(cwd, ".github", "release-notes", "2.4.0.md"), "utf8")).resolves.toBe(
      "## Changes\n\n- Add side chat support.\n",
    );
    await expect(readJson(path.join(cwd, "package.json"))).resolves.toMatchObject({ version: "2.4.0" });
  });

  it("fails release prepare before changing version files when release notes already exist", async () => {
    const cwd = await tempWorkspace();
    await mkdir(path.join(cwd, ".github", "release-notes"), { recursive: true });

    const packageJson = { version: "2.3.2" };
    const packageLockJson = { version: "2.3.2", packages: { "": { version: "2.3.2" } } };
    const manifestJson = { version: "2.3.2", minAppVersion: "1.12.0" };
    const versionsJson = { "2.3.2": "1.12.0" };

    await writeJson(path.join(cwd, "package.json"), packageJson);
    await writeJson(path.join(cwd, "package-lock.json"), packageLockJson);
    await writeJson(path.join(cwd, "manifest.json"), manifestJson);
    await writeJson(path.join(cwd, "versions.json"), versionsJson);
    await writeFile(path.join(cwd, ".github", "release-notes", "2.3.3.md"), "## Changes\n\n- Existing\n");

    const result = runNodeScript("scripts/release/prepare.mjs", ["2.3.3"], cwd);

    expect(result.status).toBe(1);
    expect(result.stderr).toContain(".github/release-notes/2.3.3.md already exists");
    await expect(readJson(path.join(cwd, "package.json"))).resolves.toEqual(packageJson);
    await expect(readJson(path.join(cwd, "package-lock.json"))).resolves.toEqual(packageLockJson);
    await expect(readJson(path.join(cwd, "manifest.json"))).resolves.toEqual(manifestJson);
    await expect(readJson(path.join(cwd, "versions.json"))).resolves.toEqual(versionsJson);
  });
});

async function tempWorkspace(): Promise<string> {
  const workspace = await mkdtemp(path.join(tmpdir(), "codex-panel-scripts-"));
  tempWorkspaces.add(workspace);
  return workspace;
}

async function styleOrderFixture(): Promise<string> {
  const cwd = await tempWorkspace();
  await mkdir(path.join(cwd, "src", "styles"), { recursive: true });
  await writeJson(path.join(cwd, "src", "styles", "order.json"), ["00-tokens.css"]);
  await writeFile(path.join(cwd, "src", "styles", "00-tokens.css"), ".codex-panel { color: var(--text-normal); }\n");
  await writeFile(path.join(cwd, "src", "styles", "10-unlisted.css"), ".codex-panel__extra { display: block; }\n");
  return cwd;
}

async function apiBaselineFixture(options: { recordedCodexVersion: string; readmeCodexVersion: string }): Promise<string> {
  const cwd = await tempWorkspace();
  await writeJson(path.join(cwd, "package.json"), { version: "1.0.0", devDependencies: { obsidian: "~1.12.3" } });
  await writeJson(path.join(cwd, "package-lock.json"), {
    packages: { "node_modules/obsidian": { version: "1.12.3" } },
  });
  await writeJson(path.join(cwd, "manifest.json"), { minAppVersion: "1.12.0" });
  await writeJson(path.join(cwd, "versions.json"), { "1.0.0": "1.12.0" });
  await writeFile(
    path.join(cwd, "README.md"),
    [
      "## Compatibility",
      "",
      "| Key | Version | Notes |",
      "| --- | --- | --- |",
      `| \`codex.testedCliVersion\` | \`${options.readmeCodexVersion}\` | Tested CLI. |`,
      "| `manifest.minAppVersion` | `1.12.0` | Minimum app version. |",
      "| `obsidian` API types | `1.12.3` | Compile-time API package. |",
    ].join("\n"),
  );
  await writeAppServerCompatibility(cwd, options.recordedCodexVersion);
  return cwd;
}

async function writeAppServerCompatibility(
  cwd: string,
  testedCliVersion: string,
  generationArguments = ["app-server", "generate-ts", "--experimental"],
): Promise<void> {
  await mkdir(path.join(cwd, "src", "app-server", "connection"), { recursive: true });
  await writeJson(path.join(cwd, "src", "app-server", "connection", "compatibility.json"), {
    codexAppServer: {
      testedCliVersion,
      typeGeneration: {
        arguments: generationArguments,
      },
      initialize: {
        capabilities: {
          experimentalApi: true,
          requestAttestation: false,
        },
      },
    },
  });
}

async function cssUsageFixture(files: Record<string, string>): Promise<string> {
  const cwd = await tempWorkspace();
  await mkdir(path.join(cwd, "src", "styles"), { recursive: true });
  await mkdir(path.join(cwd, "tests"), { recursive: true });
  await writeJson(path.join(cwd, "src", "styles", "order.json"), ["10-component.css"]);

  for (const [file, source] of Object.entries(files)) {
    await mkdir(path.dirname(path.join(cwd, file)), { recursive: true });
    await writeFile(path.join(cwd, file), source);
  }

  return cwd;
}

function runNodeScript(script: string, args: string[] = [], cwd = repoRoot, env: NodeJS.ProcessEnv = {}) {
  return spawnSync(process.execPath, [path.join(repoRoot, script), ...args], {
    cwd,
    encoding: "utf8",
    env: { ...process.env, ...env },
    shell: false,
  });
}

function runCommitlint(message: string): number | null {
  return spawnSync(process.execPath, [path.join(repoRoot, "node_modules", "@commitlint", "cli", "cli.js")], {
    cwd: repoRoot,
    encoding: "utf8",
    input: `${message}\n`,
    shell: false,
  }).status;
}

function runGit(args: string[], cwd: string): void {
  const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false });
  if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
}

async function writeJson(file: string, value: unknown): Promise<void> {
  await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
}

async function readJson(file: string): Promise<unknown> {
  return JSON.parse(await readFile(file, "utf8"));
}
