import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { mkdtemp, mkdir, writeFile, readFile, rm, access } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
  createManifest,
  addManagedFile,
  removeManagedFile,
  migrateManifest,
  readManifest,
  writeManifest,
  extractPreservedManifestFields,
  applyPreservedManifestFields,
} from "../../manifest/hatchJson.js";
import { HatchError, type BoardConfig, type HatchManifest } from "../../types.js";
import { HATCH3R_VERSION } from "../../version.js";

describe("hatchJson", () => {
  describe("createManifest", () => {
    it("creates manifest with defaults", () => {
      const manifest = createManifest({
        tools: ["cursor"],
      });
      // Wave 6 (1.9.0 / schemaVersion 3): manifest path and shape bumped.
      expect(manifest.version).toBe("3.0.0");
      expect(manifest.hatch3rVersion).toBe(HATCH3R_VERSION);
      expect(manifest.platform).toBe("github");
      expect(manifest.tools).toEqual(["cursor"]);
      expect(manifest.features.agents).toBe(true);
      expect(manifest.features.skills).toBe(true);
      expect(manifest.features.rules).toBe(true);
      expect(manifest.features.prompts).toBe(true);
      expect(manifest.features.commands).toBe(true);
      expect(manifest.features.mcp).toBe(true);
      expect(manifest.features.githubAgents).toBe(true);
      expect(manifest.managedFiles).toEqual([]);
    });

    it("accepts custom features as partial overrides", () => {
      const manifest = createManifest({
        tools: ["cursor"],
        features: { agents: false },
      });
      expect(manifest.features.agents).toBe(false);
      expect(manifest.features.rules).toBe(true);
      expect(manifest.features.skills).toBe(true);
    });

    it("accepts multiple disabled features", () => {
      const manifest = createManifest({
        tools: ["cursor"],
        features: { agents: false, skills: false, mcp: false },
      });
      expect(manifest.features.agents).toBe(false);
      expect(manifest.features.skills).toBe(false);
      expect(manifest.features.mcp).toBe(false);
      expect(manifest.features.rules).toBe(true);
    });

    it("accepts MCP servers", () => {
      const manifest = createManifest({
        tools: ["cursor"],
        mcpServers: ["github", "context7"],
      });
      expect(manifest.mcp.servers).toEqual(["github", "context7"]);
    });

    it("defaults MCP servers to empty array", () => {
      const manifest = createManifest({
        tools: ["cursor"],
      });
      expect(manifest.mcp.servers).toEqual([]);
    });

    it("accepts multiple tools", () => {
      const manifest = createManifest({
        tools: ["cursor", "copilot", "claude"],
      });
      expect(manifest.tools).toEqual(["cursor", "copilot", "claude"]);
    });

    it("sets platform to github by default", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      expect(manifest.platform).toBe("github");
    });

    it("accepts azure-devops platform", () => {
      const manifest = createManifest({
        platform: "azure-devops",
        owner: "my-org",
        repo: "my-repo",
        namespace: "my-org",
        project: "my-project",
        tools: ["cursor"],
      });
      expect(manifest.platform).toBe("azure-devops");
      expect(manifest.namespace).toBe("my-org");
      expect(manifest.project).toBe("my-project");
    });

    it("accepts gitlab platform", () => {
      const manifest = createManifest({
        platform: "gitlab",
        owner: "my-group",
        repo: "my-project",
        tools: ["cursor"],
      });
      expect(manifest.platform).toBe("gitlab");
      expect(manifest.namespace).toBe("my-group");
      expect(manifest.project).toBe("my-project");
    });

    it("derives namespace from owner and project from repo when not specified", () => {
      const manifest = createManifest({
        owner: "acme",
        repo: "webapp",
        tools: ["cursor"],
      });
      expect(manifest.namespace).toBe("acme");
      expect(manifest.project).toBe("webapp");
    });
  });

  describe("migrateManifest", () => {
    it("leaves platform undefined when missing so migration checkpoint can prompt", () => {
      const result = migrateManifest({
        version: "1.0.0",
        hatch3rVersion: "0.9.0",
        owner: "acme",
        repo: "app",
        tools: ["cursor"],
        features: {},
        mcp: { servers: [] },
        managedFiles: [],
      });
      expect(result.platform).toBeUndefined();
    });

    it("derives namespace from owner when missing", () => {
      const result = migrateManifest({
        version: "1.0.0",
        owner: "acme",
        repo: "app",
      });
      expect(result.namespace).toBe("acme");
    });

    it("derives project from repo when missing", () => {
      const result = migrateManifest({
        version: "1.0.0",
        owner: "acme",
        repo: "app",
      });
      expect(result.project).toBe("app");
    });

    it("bumps version from 1.0.0 to 3.0.0 (chained 1->2->3 migration)", () => {
      const result = migrateManifest({
        version: "1.0.0",
        owner: "acme",
        repo: "app",
      });
      // Wave 6 (1.9.0 / schemaVersion 3): migration chain runs
      // 1.0.0 -> 2.0.0 -> 3.0.0 in a single pass.
      expect(result.version).toBe("3.0.0");
    });

    it("does not overwrite existing platform", () => {
      const result = migrateManifest({
        version: "2.0.0",
        platform: "gitlab",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
      });
      expect(result.platform).toBe("gitlab");
    });

    it("does not overwrite existing namespace", () => {
      const result = migrateManifest({
        version: "1.0.0",
        owner: "acme",
        repo: "app",
        namespace: "custom-ns",
      });
      expect(result.namespace).toBe("custom-ns");
    });

    it("does not overwrite existing project", () => {
      const result = migrateManifest({
        version: "1.0.0",
        owner: "acme",
        repo: "app",
        project: "custom-proj",
      });
      expect(result.project).toBe("custom-proj");
    });

    it("bumps version from 2.0.0 to 3.0.0", () => {
      const result = migrateManifest({
        version: "2.0.0",
        platform: "github",
        owner: "acme",
        repo: "app",
      });
      // Wave 6 (1.9.0 / schemaVersion 3): 2.0.0 -> 3.0.0 on read.
      expect(result.version).toBe("3.0.0");
    });

    it("handles empty manifest gracefully", () => {
      const result = migrateManifest({});
      expect(result.platform).toBeUndefined();
      expect(result.namespace).toBe("");
      expect(result.project).toBe("");
    });
  });

  describe("addManagedFile", () => {
    it("adds file to managed list", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      addManagedFile(manifest, ".cursor/rules/test.mdc");
      expect(manifest.managedFiles).toContain(".cursor/rules/test.mdc");
    });

    it("does not duplicate entries", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      addManagedFile(manifest, "test.md");
      addManagedFile(manifest, "test.md");
      expect(manifest.managedFiles.filter((f) => f === "test.md")).toHaveLength(1);
    });

    it("can add multiple different files", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      addManagedFile(manifest, "file1.md");
      addManagedFile(manifest, "file2.md");
      addManagedFile(manifest, "file3.md");
      expect(manifest.managedFiles).toHaveLength(3);
      expect(manifest.managedFiles).toEqual(["file1.md", "file2.md", "file3.md"]);
    });

    it("mutates manifest in place", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      const ref = manifest.managedFiles;
      addManagedFile(manifest, "new-file.md");
      expect(ref).toContain("new-file.md");
    });
  });

  describe("removeManagedFile", () => {
    it("removes a file from managed list", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      addManagedFile(manifest, "file1.md");
      addManagedFile(manifest, "file2.md");
      removeManagedFile(manifest, "file1.md");
      expect(manifest.managedFiles).toEqual(["file2.md"]);
    });

    it("is a no-op when file is not in list", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      addManagedFile(manifest, "file1.md");
      removeManagedFile(manifest, "nonexistent.md");
      expect(manifest.managedFiles).toEqual(["file1.md"]);
    });

    it("handles empty managed files list", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      removeManagedFile(manifest, "file.md");
      expect(manifest.managedFiles).toEqual([]);
    });
  });

  describe("readManifest", () => {
    let tempDir: string;

    afterEach(async () => {
      if (tempDir) {
        await rm(tempDir, { recursive: true, force: true });
      }
    });

    async function setup(): Promise<string> {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-manifest-"));
      // Wave 6: manifest lives at `.hatch3r/hatch.json` now.
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });
      return tempDir;
    }

    async function writeManifestJson(rootDir: string, data: unknown): Promise<void> {
      await writeFile(
        join(rootDir, ".hatch3r", "hatch.json"),
        JSON.stringify(data, null, 2),
        "utf-8",
      );
    }

    it("returns null when manifest file does not exist", async () => {
      const rootDir = await setup();
      const result = await readManifest(rootDir);
      expect(result).toBeNull();
    });

    it("throws on malformed JSON", async () => {
      const rootDir = await setup();
      await writeFile(
        join(rootDir, ".hatch3r", "hatch.json"),
        "{ not valid json }}}",
        "utf-8",
      );
      await expect(readManifest(rootDir)).rejects.toThrow("Malformed JSON");
    });

    // C7-H14: readManifest throws HatchError (not plain Error) so the CLI can
    // distinguish CONFIG_ERROR (malformed JSON / invalid manifest) from other
    // failure modes and surface a structured exitCode.
    it("throws HatchError with CONFIG_ERROR code on malformed JSON", async () => {
      const rootDir = await setup();
      await writeFile(
        join(rootDir, ".hatch3r", "hatch.json"),
        "{ not valid json }}}",
        "utf-8",
      );
      try {
        await readManifest(rootDir);
        throw new Error("expected throw did not occur");
      } catch (e) {
        expect(e).toBeInstanceOf(HatchError);
        expect((e as HatchError).errorCode).toBe("CONFIG_ERROR");
        expect((e as HatchError).exitCode).toBe(1);
      }
    });

    it("throws with descriptive message when required field 'tools' is missing", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        // tools: missing
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Invalid manifest.*required fields missing or malformed/,
      );
    });

    it("throws with descriptive message when required field 'version' is missing", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        // version: missing
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Invalid manifest.*required fields missing or malformed/,
      );
    });

    it("throws when tools is a string instead of an array", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: "cursor",
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Invalid manifest.*required fields missing or malformed/,
      );
    });

    it("throws when managedFiles is missing", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        // managedFiles: missing
      });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Invalid manifest.*required fields missing or malformed/,
      );
    });

    it("throws when features is null instead of an object", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: null,
        mcp: { servers: [] },
        managedFiles: [],
      });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Invalid manifest.*required fields missing or malformed/,
      );
    });

    it("preserves extra unexpected fields in the manifest", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
        customField: "should be preserved",
        extraNumber: 42,
      });
      const result = await readManifest(rootDir);
      expect(result).not.toBeNull();
      // Extra fields should pass through validation and be preserved on the returned object
      const raw = result as unknown as Record<string, unknown>;
      expect(raw.customField).toBe("should be preserved");
      expect(raw.extraNumber).toBe(42);
    });

    it("reads a valid manifest successfully", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      const result = await readManifest(rootDir);
      expect(result).not.toBeNull();
      // Wave 6: 2.0.0 manifests are migrated to 3.0.0 on read.
      expect(result!.version).toBe("3.0.0");
      expect(result!.tools).toEqual(["cursor"]);
    });

    it("migrates v1.0.0 manifest and validates successfully", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "1.0.0",
        hatch3rVersion: "0.9.0",
        owner: "acme",
        repo: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      const result = await readManifest(rootDir);
      expect(result).not.toBeNull();
      // Wave 6: chained migration runs 1.0.0 -> 2.0.0 -> 3.0.0.
      expect(result!.version).toBe("3.0.0");
      expect(result!.namespace).toBe("acme");
      expect(result!.project).toBe("app");
    });

    it("throws descriptive error suggesting hatch3r init to regenerate", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, { invalid: true });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Run hatch3r init to regenerate/,
      );
    });

    it("validates content sub-schema: rejects non-string preset", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
        content: {
          preset: 123,
          projectType: "brownfield",
          teamSize: "solo",
          items: { agents: [], skills: [], rules: [], commands: [], prompts: [], hooks: [], githubAgents: [] },
        },
      });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Invalid manifest.*required fields missing or malformed/,
      );
    });

    it("validates content sub-schema: rejects missing items", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
        content: {
          preset: "standard",
          projectType: "brownfield",
          teamSize: "solo",
          // items is missing
        },
      });
      await expect(readManifest(rootDir)).rejects.toThrow(
        /Invalid manifest.*required fields missing or malformed/,
      );
    });

    it("accepts valid content sub-schema", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
        content: {
          preset: "standard",
          projectType: "brownfield",
          teamSize: "solo",
          items: { agents: ["hatch3r-researcher"], skills: [], rules: [], commands: [], prompts: [], hooks: [], githubAgents: [] },
        },
      });
      const result = await readManifest(rootDir);
      expect(result).not.toBeNull();
      expect(result!.content?.preset).toBe("standard");
      expect(result!.content?.items.agents).toContain("hatch3r-researcher");
    });
  });

  // Wave 6 (1.9.0): one-shot relocation of `.agents/hatch.json` ->
  // `.hatch3r/hatch.json` triggered the first time `readManifest` runs
  // against a pre-1.9 install. The shim must be idempotent and emit a
  // single console warning so operators see why the directory changed.
  describe("readManifest migration shim (.agents/ -> .hatch3r/)", () => {
    let tempDir: string;
    let warnSpy: ReturnType<typeof vi.spyOn>;

    beforeEach(async () => {
      warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
    });

    afterEach(async () => {
      warnSpy.mockRestore();
      if (tempDir) await rm(tempDir, { recursive: true, force: true });
    });

    it("relocates a pre-1.9 .agents/hatch.json to .hatch3r/hatch.json on read", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-shim-"));
      // Seed a pre-1.9 install with the legacy `.agents/` location.
      // Inline literal — this is one of the only legitimate uses of `.agents`
      // in the rewritten test surface (migration-shim test setup).
      const legacyDir = join(tempDir, ".agents");
      await mkdir(legacyDir, { recursive: true });
      const legacyManifest = {
        version: "2.0.0",
        hatch3rVersion: "1.8.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true, handoffs: true },
        mcp: { servers: [] },
        managedFiles: [],
      };
      await writeFile(join(legacyDir, "hatch.json"), JSON.stringify(legacyManifest, null, 2), "utf-8");

      const result = await readManifest(tempDir);
      expect(result).not.toBeNull();
      expect(result!.tools).toEqual(["cursor"]);

      // Manifest now lives under .hatch3r/.
      await expect(access(join(tempDir, ".hatch3r", "hatch.json"))).resolves.toBeUndefined();
      // Legacy path is gone.
      await expect(access(join(tempDir, ".agents", "hatch.json"))).rejects.toThrow();

      // A single one-shot warning was emitted.
      const warnCalls = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
      const matchingWarn = warnCalls.find((m: string) => /Migrated manifest from .agents/.test(m));
      expect(matchingWarn).toBeDefined();
    });

    it("is a no-op when .hatch3r/hatch.json already exists (legacy ignored)", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-shim-"));
      // Pre-existing new layout
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });
      const newManifest = {
        version: "3.0.0",
        hatch3rVersion: "1.9.0",
        owner: "current",
        repo: "current",
        namespace: "current",
        project: "current",
        tools: ["claude"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true, handoffs: true },
        mcp: { servers: [] },
        managedFiles: [],
      };
      await writeFile(join(tempDir, ".hatch3r", "hatch.json"), JSON.stringify(newManifest, null, 2), "utf-8");

      // Also seed a stale legacy manifest — shim must NOT touch it.
      await mkdir(join(tempDir, ".agents"), { recursive: true });
      await writeFile(
        join(tempDir, ".agents", "hatch.json"),
        JSON.stringify({ ...newManifest, owner: "stale" }, null, 2),
        "utf-8",
      );

      const result = await readManifest(tempDir);
      expect(result).not.toBeNull();
      // We read from the new path, not the legacy stale one.
      expect(result!.owner).toBe("current");

      // No migration warning was emitted because no migration happened.
      const warnCalls = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
      const matchingWarn = warnCalls.find((m: string) => /Migrated manifest from .agents/.test(m));
      expect(matchingWarn).toBeUndefined();

      // Legacy file untouched.
      await expect(access(join(tempDir, ".agents", "hatch.json"))).resolves.toBeUndefined();
    });
  });

  describe("writeManifest", () => {
    let tempDir: string;

    afterEach(async () => {
      if (tempDir) {
        await rm(tempDir, { recursive: true, force: true });
      }
    });

    it("writes a manifest that can be read back", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const manifest = createManifest({ tools: ["cursor"], mcpServers: ["github"] });
      await writeManifest(tempDir, manifest);

      const result = await readManifest(tempDir);
      expect(result).not.toBeNull();
      expect(result!.tools).toEqual(["cursor"]);
      expect(result!.mcp.servers).toEqual(["github"]);
    });

    it("writes valid JSON with trailing newline", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const manifest = createManifest({ tools: ["cursor"] });
      await writeManifest(tempDir, manifest);

      const raw = await readFile(join(tempDir, ".hatch3r", "hatch.json"), "utf-8");
      expect(raw.endsWith("\n")).toBe(true);
      expect(() => JSON.parse(raw)).not.toThrow();
    });

    it("overwrites existing manifest", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const original = createManifest({ tools: ["cursor"] });
      await writeManifest(tempDir, original);

      const updated = createManifest({ tools: ["claude", "cursor"] });
      await writeManifest(tempDir, updated);

      const result = await readManifest(tempDir);
      expect(result!.tools).toEqual(["claude", "cursor"]);
    });

    // C8-D1-M2: writeManifest must validate the manifest schema before
    // persisting to disk to prevent corrupt state from being serialized.
    it("throws HatchError(CONFIG_ERROR) when managedFiles is not an array", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const manifest = createManifest({ tools: ["cursor"] });
      // Force an invalid shape past TS via cast.
      (manifest as unknown as Record<string, unknown>).managedFiles = "not-an-array";

      try {
        await writeManifest(tempDir, manifest);
        throw new Error("expected throw did not occur");
      } catch (e) {
        expect(e).toBeInstanceOf(HatchError);
        expect((e as HatchError).errorCode).toBe("CONFIG_ERROR");
        expect((e as HatchError).exitCode).toBe(1);
      }
    });

    it("throws when tools contains invalid tool name outside VALID_TOOLS", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const manifest = createManifest({ tools: ["cursor"] });
      (manifest as unknown as Record<string, unknown>).tools = ["not-a-real-tool"];

      await expect(writeManifest(tempDir, manifest)).rejects.toThrow(
        /Invalid manifest schema/,
      );
    });

    it("throws when required field (mcp) is missing", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const manifest = createManifest({ tools: ["cursor"] });
      // Remove mcp entirely.
      delete (manifest as unknown as Record<string, unknown>).mcp;

      await expect(writeManifest(tempDir, manifest)).rejects.toThrow(HatchError);
    });

    it("does NOT write file when validation fails", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const manifest = createManifest({ tools: ["cursor"] });
      (manifest as unknown as Record<string, unknown>).managedFiles = null;

      await expect(writeManifest(tempDir, manifest)).rejects.toThrow(HatchError);

      // Confirm no file was written — access must reject with ENOENT.
      const manifestPath = join(tempDir, ".hatch3r", "hatch.json");
      try {
        await access(manifestPath);
        throw new Error("manifest file should not exist after failed validation");
      } catch (e) {
        expect((e as NodeJS.ErrnoException).code).toBe("ENOENT");
      }
    });

    it("round-trip: createManifest then write then read succeeds", async () => {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-write-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });

      const manifest = createManifest({
        tools: ["cursor", "claude"],
        mcpServers: ["github"],
        owner: "acme",
        repo: "app",
      });
      await writeManifest(tempDir, manifest);

      const result = await readManifest(tempDir);
      expect(result).not.toBeNull();
      expect(result!.tools).toEqual(["cursor", "claude"]);
      expect(result!.mcp.servers).toEqual(["github"]);
      expect(result!.owner).toBe("acme");
      expect(result!.repo).toBe("app");
    });
  });

  describe("manifest validation (#108)", () => {
    let tempDir: string;

    afterEach(async () => {
      if (tempDir) {
        await rm(tempDir, { recursive: true, force: true });
      }
    });

    async function setup(): Promise<string> {
      tempDir = await mkdtemp(join(tmpdir(), "hatch3r-validate-"));
      await mkdir(join(tempDir, ".hatch3r"), { recursive: true });
      return tempDir;
    }

    async function writeManifestJson(rootDir: string, data: unknown): Promise<void> {
      await writeFile(
        join(rootDir, ".hatch3r", "hatch.json"),
        JSON.stringify(data, null, 2),
        "utf-8",
      );
    }

    it("rejects invalid tool names in tools array", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["invalid-tool"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      await expect(readManifest(rootDir)).rejects.toThrow(/Invalid manifest/);
    });

    it("rejects non-string tool entries", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: [123, "cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      await expect(readManifest(rootDir)).rejects.toThrow(/Invalid manifest/);
    });

    it("accepts all known tool names", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor", "claude", "copilot"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
      });
      const result = await readManifest(rootDir);
      expect(result).not.toBeNull();
      expect(result!.tools).toEqual(["cursor", "claude", "copilot"]);
    });

    it("validates board sub-schema", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
        board: { owner: 123 },
      });
      await expect(readManifest(rootDir)).rejects.toThrow(/Invalid manifest/);
    });

    it("validates worktree.extraPatterns must be string array", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
        worktree: { enabled: true, extraPatterns: [123] },
      });
      await expect(readManifest(rootDir)).rejects.toThrow(/Invalid manifest/);
    });

    it("accepts valid worktree.extraPatterns", async () => {
      const rootDir = await setup();
      await writeManifestJson(rootDir, {
        version: "2.0.0",
        hatch3rVersion: "1.5.0",
        owner: "acme",
        repo: "app",
        namespace: "acme",
        project: "app",
        tools: ["cursor"],
        features: { agents: true, skills: true, rules: true, prompts: true, commands: true, mcp: true, githubAgents: true, hooks: true },
        mcp: { servers: [] },
        managedFiles: [],
        worktree: { enabled: true, extraPatterns: [".custom-dir/"] },
      });
      const result = await readManifest(rootDir);
      expect(result).not.toBeNull();
      expect(result!.worktree?.extraPatterns).toEqual([".custom-dir/"]);
    });
  });

  // 1.7.1: Bug fix for clean+reinit losing GitHub Projects v2 IDs and other
  // user/platform-specific manifest fields. Helpers below are the centralized
  // extract/apply pair called from src/cli/commands/clean.ts (capture path)
  // and src/cli/commands/init.ts (apply path, with existingManifest fallback).
  describe("extractPreservedManifestFields", () => {
    function makeBoard(overrides: Partial<BoardConfig> = {}): BoardConfig {
      return {
        owner: "acme",
        repo: "app",
        defaultBranch: "main",
        projectNumber: 42,
        statusFieldId: 99,
        statusOptions: {
          backlog: "PVTSSF_backlog",
          ready: "PVTSSF_ready",
          inProgress: "PVTSSF_in_progress",
          inReview: "PVTSSF_in_review",
          done: "PVTSSF_done",
        },
        labels: {
          types: ["type:bug"],
          executors: ["executor:agent"],
          statuses: ["status:triage"],
          meta: ["meta:board-overview"],
        },
        branchConvention: "{type}/{short-description}",
        areas: ["api", "ui"],
        ...overrides,
      };
    }

    function makeManifest(overrides: Partial<HatchManifest> = {}): HatchManifest {
      const base = createManifest({ tools: ["cursor"], owner: "acme", repo: "app" });
      return { ...base, ...overrides };
    }

    it("returns empty object for a minimal manifest without any preservable fields", () => {
      const manifest = makeManifest();
      expect(extractPreservedManifestFields(manifest)).toEqual({});
    });

    it("extracts board with full GitHub Projects v2 state", () => {
      const board = makeBoard();
      const manifest = makeManifest({ board });
      const result = extractPreservedManifestFields(manifest);
      expect(result.board).toEqual(board);
      expect(result.board?.projectNumber).toBe(42);
      expect(result.board?.statusFieldId).toBe(99);
      expect(result.board?.statusOptions.backlog).toBe("PVTSSF_backlog");
      expect(result.board?.areas).toEqual(["api", "ui"]);
    });

    it("extracts costTracking budgets", () => {
      const manifest = makeManifest({
        costTracking: { sessionBudget: 5, issueBudget: 25, currency: "USD", hardStop: true },
      });
      const result = extractPreservedManifestFields(manifest);
      expect(result.costTracking).toEqual({
        sessionBudget: 5,
        issueBudget: 25,
        currency: "USD",
        hardStop: true,
      });
    });

    it("extracts specs paths and lastGenerated", () => {
      const manifest = makeManifest({
        specs: { paths: ["docs/api.md", "docs/architecture.md"], lastGenerated: "2026-05-01" },
      });
      const result = extractPreservedManifestFields(manifest);
      expect(result.specs?.paths).toEqual(["docs/api.md", "docs/architecture.md"]);
      expect(result.specs?.lastGenerated).toBe("2026-05-01");
    });

    it("extracts userContent counters", () => {
      const manifest = makeManifest({
        userContent: { count: 3, lastModified: "2026-05-12T10:00:00Z", types: { agent: 1, skill: 2 } },
      });
      const result = extractPreservedManifestFields(manifest);
      expect(result.userContent?.count).toBe(3);
      expect(result.userContent?.types).toEqual({ agent: 1, skill: 2 });
    });

    it("extracts hooks, models, and claude extension config", () => {
      const manifest = makeManifest({
        hooks: { enabled: true },
        models: { default: "sonnet", agents: { researcher: "opus" } },
        claude: { permissions: { allow: ["Bash"] }, teammateMode: "auto" },
      });
      const result = extractPreservedManifestFields(manifest);
      expect(result.hooks).toEqual({ enabled: true });
      expect(result.models?.default).toBe("sonnet");
      expect(result.claude?.permissions?.allow).toEqual(["Bash"]);
    });

    it("extracts repos and packages workspace state", () => {
      const manifest = makeManifest({
        repos: [{ owner: "acme", repo: "lib", name: "shared-lib" }],
        packages: [{ name: "core", path: "packages/core" }],
      });
      const result = extractPreservedManifestFields(manifest);
      expect(result.repos).toHaveLength(1);
      expect(result.repos?.[0]?.repo).toBe("lib");
      expect(result.packages?.[0]?.name).toBe("core");
    });

    it("extracts workspace metadata", () => {
      const manifest = makeManifest({
        workspace: {
          rootPath: "..",
          lastSync: "2026-05-12T10:00:00Z",
          syncVersion: "1.7.0",
          workspaceChecksum: "sha256:abc",
          excludedContent: ["hatch3r-foo"],
        },
      });
      const result = extractPreservedManifestFields(manifest);
      expect(result.workspace?.rootPath).toBe("..");
      expect(result.workspace?.excludedContent).toEqual(["hatch3r-foo"]);
    });

    it("extracts worktree extras (extraPatterns + nodeModules) into a dedicated shape", () => {
      const manifest = makeManifest({
        worktree: { enabled: true, extraPatterns: [".custom/"], nodeModules: "skip" },
      });
      const result = extractPreservedManifestFields(manifest);
      expect(result.worktreeExtras).toEqual({
        extraPatterns: [".custom/"],
        nodeModules: "skip",
      });
    });

    it("omits worktreeExtras when worktree has only `enabled` set", () => {
      const manifest = makeManifest({ worktree: { enabled: true } });
      const result = extractPreservedManifestFields(manifest);
      expect(result.worktreeExtras).toBeUndefined();
    });
  });

  describe("applyPreservedManifestFields", () => {
    function makeBoard(overrides: Partial<BoardConfig> = {}): BoardConfig {
      return {
        owner: "acme",
        repo: "app",
        defaultBranch: "main",
        projectNumber: 42,
        statusFieldId: 99,
        statusOptions: {
          backlog: "PVTSSF_backlog",
          ready: null,
          inProgress: null,
          inReview: null,
          done: null,
        },
        labels: { types: [], executors: [], statuses: [], meta: [] },
        branchConvention: "{type}/{short-description}",
        areas: [],
        ...overrides,
      };
    }

    it("is a no-op when preserved is empty", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      const before = JSON.stringify(manifest);
      applyPreservedManifestFields(manifest, {});
      expect(JSON.stringify(manifest)).toBe(before);
    });

    it("preserves board.projectNumber, statusFieldId, statusOptions when no board exists yet", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      const preserved = makeBoard();
      applyPreservedManifestFields(manifest, { board: preserved });
      expect(manifest.board?.projectNumber).toBe(42);
      expect(manifest.board?.statusFieldId).toBe(99);
      expect(manifest.board?.statusOptions.backlog).toBe("PVTSSF_backlog");
    });

    it("lets init-supplied board.owner/repo/defaultBranch win over preserved values", () => {
      const manifest = createManifest({ tools: ["cursor"], owner: "new-owner", repo: "new-repo", defaultBranch: "develop" });
      const preserved = makeBoard({ owner: "old-owner", repo: "old-repo", defaultBranch: "main", projectNumber: 42 });
      applyPreservedManifestFields(manifest, { board: preserved });
      expect(manifest.board?.owner).toBe("new-owner");
      expect(manifest.board?.repo).toBe("new-repo");
      expect(manifest.board?.defaultBranch).toBe("develop");
      // But projectNumber, statusOptions etc. are preserved
      expect(manifest.board?.projectNumber).toBe(42);
      expect(manifest.board?.statusOptions.backlog).toBe("PVTSSF_backlog");
    });

    it("preserves board.areas and branchConvention", () => {
      const manifest = createManifest({ tools: ["cursor"], owner: "acme", repo: "app", defaultBranch: "main" });
      const preserved = makeBoard({ areas: ["api", "ui"], branchConvention: "{type}/{name}" });
      applyPreservedManifestFields(manifest, { board: preserved });
      expect(manifest.board?.areas).toEqual(["api", "ui"]);
      expect(manifest.board?.branchConvention).toBe("{type}/{name}");
    });

    it("preserves costTracking budgets", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      applyPreservedManifestFields(manifest, {
        costTracking: { sessionBudget: 10, currency: "EUR" },
      });
      expect(manifest.costTracking?.sessionBudget).toBe(10);
      expect(manifest.costTracking?.currency).toBe("EUR");
    });

    it("preserves specs, userContent, hooks, models, claude", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      applyPreservedManifestFields(manifest, {
        specs: { paths: ["docs/api.md"] },
        userContent: { count: 2, lastModified: "2026-05-12", types: { agent: 2 } },
        hooks: { enabled: true },
        models: { default: "opus" },
        claude: { teammateMode: "tmux" },
      });
      expect(manifest.specs?.paths).toEqual(["docs/api.md"]);
      expect(manifest.userContent?.count).toBe(2);
      expect(manifest.hooks?.enabled).toBe(true);
      expect(manifest.models?.default).toBe("opus");
      expect(manifest.claude?.teammateMode).toBe("tmux");
    });

    it("preserves repos, packages, workspace metadata", () => {
      const manifest = createManifest({ tools: ["cursor"] });
      applyPreservedManifestFields(manifest, {
        repos: [{ owner: "acme", repo: "lib" }],
        packages: [{ name: "core", path: "packages/core" }],
        workspace: {
          rootPath: "..",
          lastSync: "2026-05-12T10:00:00Z",
          syncVersion: "1.7.0",
          workspaceChecksum: "sha256:abc",
        },
      });
      expect(manifest.repos).toHaveLength(1);
      expect(manifest.packages?.[0]?.name).toBe("core");
      expect(manifest.workspace?.rootPath).toBe("..");
    });

    it("applies worktree extras only when manifest.worktree.enabled is true", () => {
      // Worktree enabled: extras get applied.
      const enabled = createManifest({ tools: ["claude"], worktreeEnabled: true });
      applyPreservedManifestFields(enabled, {
        worktreeExtras: { extraPatterns: [".custom/"], nodeModules: "skip" },
      });
      expect(enabled.worktree?.extraPatterns).toEqual([".custom/"]);
      expect(enabled.worktree?.nodeModules).toBe("skip");

      // Worktree disabled (or absent): extras dropped, worktree config not materialized.
      const disabled = createManifest({ tools: ["cursor"], worktreeEnabled: false });
      applyPreservedManifestFields(disabled, {
        worktreeExtras: { extraPatterns: [".custom/"], nodeModules: "skip" },
      });
      expect(disabled.worktree).toBeUndefined();
    });

    it("round-trip: extract then apply reproduces the preserved subset", () => {
      const source: HatchManifest = {
        ...createManifest({ tools: ["claude"], owner: "acme", repo: "app", defaultBranch: "main", worktreeEnabled: true }),
        board: makeBoard({ projectNumber: 7, areas: ["a"] }),
        costTracking: { sessionBudget: 3 },
        specs: { paths: ["a.md"] },
      };
      const preserved = extractPreservedManifestFields(source);
      const target = createManifest({ tools: ["claude"], owner: "acme", repo: "app", defaultBranch: "main", worktreeEnabled: true });
      applyPreservedManifestFields(target, preserved);
      expect(target.board?.projectNumber).toBe(7);
      expect(target.board?.areas).toEqual(["a"]);
      expect(target.costTracking?.sessionBudget).toBe(3);
      expect(target.specs?.paths).toEqual(["a.md"]);
    });
  });
});
