import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { HatchError, HATCH3R_DIR } from "../../types.js";

// Wave 7 (1.9.0) rewrite contract:
//   - Manifest lives at `.hatch3r/hatch.json` (Wave 6 relocation).
//   - Adapters source canonical content from the bundled package
//     (`resolveBundledContentRoot`) — no `.agents/` materialisation.
//   - Root `AGENTS.md` is no longer emitted (Wave 3 decision #3).
//   - Integrity-manifest subsystem deleted in Wave 7 — no preflight,
//     no `.integrity.json`, no `expectedAdapters`/`successfulAdapters`.
//   - Canonical orphan-file scan retired in user repos (no `.agents/`
//     means no canonical subdirs to scan).
//
// The remaining sync behaviour exercised here:
//   - missing-manifest error path
//   - adapter outputs land in `.cursor/`/`.claude/`
//   - skipped + force re-sync semantics
//   - `--minimal` flag passthrough
//   - MCP env-var warnings
//   - adapter failure surfacing
//   - orphan adapter-output unlink history (`managedFilesByAdapter`)
//
// Removed tests (Wave 7 cleanup):
//   - "should create or update AGENTS.md" (no AGENTS.md emission)
//   - "should skip AGENTS.md when it has no managed block markers" (ditto)
//   - integrity preflight describe block (subsystem deleted)
//   - integrity manifest adapter metadata describe block (subsystem deleted)
//   - C9-M26 canonical orphan-file scan describe (scan retired in user repos)

function createTestManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    version: "3.0.0",
    hatch3rVersion: "1.9.0",
    owner: "test-org",
    repo: "test-repo",
    namespace: "test-org",
    project: "test-repo",
    tools: ["cursor"],
    features: {
      agents: true,
      skills: true,
      rules: true,
      prompts: true,
      commands: true,
      mcp: true,
      githubAgents: true,
      hooks: true,
      handoffs: true,
    },
    mcp: { servers: [] },
    managedFiles: [],
    ...overrides,
  };
}

async function createTestProject(root: string, overrides: Record<string, unknown> = {}): Promise<void> {
  const hatch3rDir = join(root, HATCH3R_DIR);
  await mkdir(hatch3rDir, { recursive: true });

  const manifest = createTestManifest(overrides);
  await writeFile(join(hatch3rDir, "hatch.json"), JSON.stringify(manifest, null, 2));
}

describe("sync command", () => {
  let tempDir: string;
  let cwdSpy: MockInstance;
  let exitSpy: MockInstance;
  let consoleSpy: MockInstance;
  let consoleErrorSpy: MockInstance;

  beforeEach(async () => {
    tempDir = await mkdtemp(join(tmpdir(), "hatch3r-sync-"));
    cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tempDir);
    exitSpy = vi
      .spyOn(process, "exit")
      .mockImplementation((() => {
        throw new Error("process.exit called");
      }) as never);
    consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
    consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
  });

  afterEach(async () => {
    cwdSpy.mockRestore();
    exitSpy.mockRestore();
    consoleSpy.mockRestore();
    consoleErrorSpy.mockRestore();
    await rm(tempDir, { recursive: true, force: true });
  });

  it("should exit with error when no manifest exists", async () => {
    const { syncCommand } = await import("../../cli/commands/sync.js");

    await expect(syncCommand()).rejects.toThrow(HatchError);
    try { await syncCommand(); } catch (e) { expect((e as HatchError).exitCode).toBe(1); }

    // Wave 6: error message references the new manifest location.
    const allOutput = [
      ...consoleSpy.mock.calls.map((c) => String(c[0])),
      ...consoleErrorSpy.mock.calls.map((c) => String(c[0])),
    ].join(" ");
    expect(allOutput).toContain(".hatch3r/hatch.json");
  });

  it("should sync and create adapter output files", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    // Wave 4: cursor adapter writes its precedence-prefixed rules into
    // `.cursor/rules/`. The fixture rule has no `precedence:` frontmatter
    // so it lands in the default `normal` bucket (rank 500 -> `50-`
    // prefix). Look for any emitted .mdc rather than a specific filename,
    // since which canonical rules ship is content-driven.
    const cursorRulesDir = join(tempDir, ".cursor", "rules");
    const entries = await import("node:fs/promises").then((m) => m.readdir(cursorRulesDir));
    const mdc = entries.find((f) => f.endsWith(".mdc"));
    expect(mdc).toBeDefined();
  });

  it("does NOT emit root AGENTS.md (Wave 3 removal)", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf-8").catch(() => null);
    expect(agentsMd).toBeNull();
  });

  it("does NOT materialise canonical content under .agents/ (Wave 3 removal)", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    const stat = await import("node:fs/promises").then((m) => m.stat);
    let dotAgentsExists = false;
    try {
      await stat(join(tempDir, ".agents"));
      dotAgentsExists = true;
    } catch (err) {
      void err;
    }
    expect(dotAgentsExists).toBe(false);
  });

  it("should report sync summary", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
    expect(output).toContain("Sync complete");
  });

  it("should sync multiple tools when configured", async () => {
    await createTestProject(tempDir, { tools: ["cursor", "claude"] });

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    const cursorRulesExists = await readFile(
      join(tempDir, ".cursor", "rules", "hatch3r-bridge.mdc"),
      "utf-8",
    ).catch(() => null);
    expect(cursorRulesExists).not.toBeNull();

    const claudeMdExists = await readFile(
      join(tempDir, "CLAUDE.md"),
      "utf-8",
    ).catch(() => null);
    expect(claudeMdExists).not.toBeNull();
  });

  it("should warn about new MCP env vars when servers require them", async () => {
    await createTestProject(tempDir, {
      mcp: { servers: ["github"] },
    });

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    // D12-M1: warn() routes to console.error (stderr) per POSIX convention.
    const output = [
      ...consoleSpy.mock.calls.map((c) => String(c[0])),
      ...consoleErrorSpy.mock.calls.map((c) => String(c[0])),
    ].join("\n");
    expect(output).toContain("New secrets needed in .env.mcp");
    expect(output).toContain("GITHUB_PAT");
  });

  it("should report 'skipped' for unchanged non-managed files on re-sync", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    consoleSpy.mockClear();
    await syncCommand();

    const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
    expect(output).toContain("skipped");
  });

  it("should report 'skipped' when a non-managed file has changed on disk", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    const envJsonPath = join(tempDir, ".cursor", "environment.json");
    await writeFile(envJsonPath, '{"changed": true}');

    consoleSpy.mockClear();
    await syncCommand();

    const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
    expect(output).toContain("skipped");
  });

  it("should exit with error when adapter generation fails (invalid tool)", async () => {
    // Use an invalid tool name which now fails manifest validation (#108)
    await createTestProject(tempDir, { tools: ["nonexistent-tool"] });

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await expect(syncCommand()).rejects.toThrow(/Invalid manifest|required fields/);
  });

  it("should log minimal mode info when --minimal flag is passed", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand({ minimal: true });

    const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
    expect(output).toContain("Minimal generation mode");
  });

  it("should complete sync and report results with --minimal flag", async () => {
    await createTestProject(tempDir);

    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand({ minimal: true });

    const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
    expect(output).toContain("Sync complete");
  });

  // Wave 4 (1.9.0): the previous "context budget pre-write gate" tests
  // (C7.5-W2B2-H22 / D6-SA6.1-2) seeded an oversized rule into a user-repo
  // `.agents/rules/` tree. That tree no longer exists — canonical content
  // is sourced from the bundled package — so we can no longer construct
  // the over-budget vs. under-budget contrast deterministically from this
  // test surface. The budget-gate code path is exercised by the dedicated
  // pipeline unit tests under `src/__tests__/pipeline/` (which inject
  // synthetic content into the budget checker without going through sync).

  // C8-D8-M1 (D8): aggregated recovery guidance on thrown HatchError
  describe("aggregated recovery guidance", () => {
    it("HatchError thrown on all-adapter failure carries a recovery hint", async () => {
      await createTestProject(tempDir, { tools: ["cursor"] });

      // Force every adapter invocation to return completed:false so the
      // adapter loop's catch block populates adapterFailures and the terminal
      // "All adapters failed" branch fires with our new aggregated guidance.
      const adapterTimeoutMod = await import("../../pipeline/adapterTimeout.js");
      const spy = vi.spyOn(adapterTimeoutMod, "generateWithTimeout").mockResolvedValue({
        tool: "cursor",
        completed: false,
        elapsedMs: 10,
        error: "invalid config: missing required field",
        warnings: [],
      });

      const { syncCommand } = await import("../../cli/commands/sync.js");
      try {
        await syncCommand();
        expect.fail("expected syncCommand to throw HatchError");
      } catch (e) {
        const err = e as HatchError;
        expect(err).toBeInstanceOf(HatchError);
        expect(err.message).toMatch(/All adapters failed/);
        expect(err.message).toMatch(/substantive|transient|Retry|Inspect|resolve/i);
      } finally {
        spy.mockRestore();
      }
    });
  });

  // Task #11: orphan adapter-output cleanup on sync. Verifies that files
  // previously written by hatch3r but no longer emitted by the current
  // adapter set are unlinked, and that the manifest's
  // `managedFilesByAdapter` history is maintained across runs.
  describe("orphan adapter-output cleanup", () => {
    it("unlinks a pre-B3 hatch3r-*.mdc after sync emits NN-hatch3r-*.mdc", async () => {
      await createTestProject(tempDir);

      // Seed the manifest with a prior path the current sync will NOT
      // re-emit (the pre-B3 filename shape). Also physically place the
      // orphan file so the cleanup has something to unlink.
      const manifestPath = join(tempDir, HATCH3R_DIR, "hatch.json");
      const manifestRaw = await readFile(manifestPath, "utf-8");
      const manifest = JSON.parse(manifestRaw) as Record<string, unknown>;
      (manifest.managedFilesByAdapter as Record<string, string[]> | undefined) = {
        cursor: [".cursor/rules/hatch3r-test.mdc"],
      };
      await writeFile(manifestPath, JSON.stringify(manifest, null, 2));

      const cursorRulesDir = join(tempDir, ".cursor", "rules");
      await mkdir(cursorRulesDir, { recursive: true });
      const orphanPath = join(cursorRulesDir, "hatch3r-test.mdc");
      await writeFile(orphanPath, "# pre-B3 stray file\n");

      const { syncCommand } = await import("../../cli/commands/sync.js");
      await syncCommand();

      // Old file should be gone.
      const stillThere = await readFile(orphanPath, "utf-8").catch(() => null);
      expect(stillThere).toBeNull();

      // Manifest should record new paths under managedFilesByAdapter.cursor
      // (the canonical B3 paths carry a `NN-` precedence prefix).
      const updatedManifest = JSON.parse(await readFile(manifestPath, "utf-8")) as {
        managedFilesByAdapter?: Record<string, string[]>;
      };
      expect(updatedManifest.managedFilesByAdapter?.cursor).toBeDefined();
      expect(updatedManifest.managedFilesByAdapter!.cursor.length).toBeGreaterThan(0);
      expect(updatedManifest.managedFilesByAdapter!.cursor).not.toContain(
        ".cursor/rules/hatch3r-test.mdc",
      );
    });

    it("no-op when the manifest has no managedFilesByAdapter history (first run)", async () => {
      await createTestProject(tempDir);
      // Seed a file that would be an orphan IF there were any history.
      // Without history, the cleanup should not touch it.
      const cursorRulesDir = join(tempDir, ".cursor", "rules");
      await mkdir(cursorRulesDir, { recursive: true });
      const foreignPath = join(cursorRulesDir, "hatch3r-untracked.mdc");
      await writeFile(foreignPath, "# not in manifest history\n");

      const { syncCommand } = await import("../../cli/commands/sync.js");
      await syncCommand();

      // Untracked file remains — no history means no inferred orphans.
      const stillThere = await readFile(foreignPath, "utf-8").catch(() => null);
      expect(stillThere).not.toBeNull();
    });

    it("does NOT unlink a file the user has wrapped with custom content outside the managed block", async () => {
      await createTestProject(tempDir);

      const manifestPath = join(tempDir, HATCH3R_DIR, "hatch.json");
      const manifestRaw = await readFile(manifestPath, "utf-8");
      const manifest = JSON.parse(manifestRaw) as Record<string, unknown>;
      (manifest.managedFilesByAdapter as Record<string, string[]> | undefined) = {
        cursor: [".cursor/rules/hatch3r-edited.mdc"],
      };
      await writeFile(manifestPath, JSON.stringify(manifest, null, 2));

      const cursorRulesDir = join(tempDir, ".cursor", "rules");
      await mkdir(cursorRulesDir, { recursive: true });
      const editedPath = join(cursorRulesDir, "hatch3r-edited.mdc");
      // User has customised the file — surrounding text outside the managed block.
      const userContent =
        "# My team preamble (keep this)\n\n" +
        "<!-- HATCH3R:BEGIN -->\n# managed rule body\n<!-- HATCH3R:END -->\n\n" +
        "# My team footer (keep this)\n";
      await writeFile(editedPath, userContent);

      const { syncCommand } = await import("../../cli/commands/sync.js");
      await syncCommand();

      // File remains untouched because it contains user content.
      const preserved = await readFile(editedPath, "utf-8");
      expect(preserved).toBe(userContent);
    });

    it("refuses to unlink a manifest-claimed path outside any known adapter output root", async () => {
      await createTestProject(tempDir);

      const manifestPath = join(tempDir, HATCH3R_DIR, "hatch.json");
      const manifestRaw = await readFile(manifestPath, "utf-8");
      const manifest = JSON.parse(manifestRaw) as Record<string, unknown>;
      // Tampered manifest: claims ownership of a path under src/.
      (manifest.managedFilesByAdapter as Record<string, string[]> | undefined) = {
        cursor: ["src/hatch3r-evil.md"],
      };
      await writeFile(manifestPath, JSON.stringify(manifest, null, 2));

      // Plant the file so if the defense is broken we see the loss.
      await mkdir(join(tempDir, "src"), { recursive: true });
      const foreignPath = join(tempDir, "src", "hatch3r-evil.md");
      await writeFile(foreignPath, "# not an adapter output\n");

      const { syncCommand } = await import("../../cli/commands/sync.js");
      await syncCommand();

      // File remains — root-containment filter rejected the unlink.
      const stillThere = await readFile(foreignPath, "utf-8").catch(() => null);
      expect(stillThere).not.toBeNull();
    });

    it("preserves managedFilesByAdapter entries for adapters not part of this sync", async () => {
      await createTestProject(tempDir, { tools: ["cursor"] });

      const manifestPath = join(tempDir, HATCH3R_DIR, "hatch.json");
      const manifestRaw = await readFile(manifestPath, "utf-8");
      const manifest = JSON.parse(manifestRaw) as Record<string, unknown>;
      // Wave 1 deleted 12 adapters including claude-output-only emitters. Use
      // a removed-adapter id (claude is still supported, but a manifest from
      // a pre-1.9 install may carry a historic claude entry — preserve it
      // because the current sync did not run that adapter set differently).
      (manifest.managedFilesByAdapter as Record<string, string[]> | undefined) = {
        claude: ["CLAUDE.md"],
      };
      await writeFile(manifestPath, JSON.stringify(manifest, null, 2));

      const { syncCommand } = await import("../../cli/commands/sync.js");
      await syncCommand();

      const updatedManifest = JSON.parse(await readFile(manifestPath, "utf-8")) as {
        managedFilesByAdapter?: Record<string, string[]>;
      };
      // claude entry preserved — we did not run claude this sync so we
      // cannot claim its history is stale.
      expect(updatedManifest.managedFilesByAdapter?.claude).toEqual([
        "CLAUDE.md",
      ]);
      // cursor entry populated from this run.
      expect(updatedManifest.managedFilesByAdapter?.cursor).toBeDefined();
      expect(updatedManifest.managedFilesByAdapter!.cursor.length).toBeGreaterThan(0);
    });
  });
});
