import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
import { mkdtemp, mkdir, writeFile, readFile, rm, access, readdir } from "node:fs/promises";
import { join, relative } from "node:path";
import { tmpdir } from "node:os";
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { HatchError } from "../../types.js";
import { HATCH3R_VERSION } from "../../version.js";

/**
 * G6 (v1.7.1): recursively SHA-256 every regular file under `root` and
 * return a `relPath -> hash` map. Used by the sync-idempotency test to
 * compare project state between successive syncs and catch regressions
 * where any adapter output drifts by even one byte. Skips `.git/` and
 * `node_modules/` (irrelevant + slow), and orphan `.tmp.<hex>` files
 * (atomic-write tmpfiles that may exist transiently).
 */
async function snapshotProject(root: string): Promise<Map<string, string>> {
  const snapshot = new Map<string, string>();
  async function walk(dir: string): Promise<void> {
    const entries = await readdir(dir, { withFileTypes: true });
    for (const entry of entries) {
      const full = join(dir, entry.name);
      if (entry.isDirectory()) {
        if (entry.name === ".git" || entry.name === "node_modules") continue;
        await walk(full);
        continue;
      }
      if (!entry.isFile()) continue;
      if (/\.tmp\.[0-9a-f]{8}$/.test(entry.name)) continue;
      const buf = await readFile(full);
      const hash = createHash("sha256").update(buf).digest("hex");
      snapshot.set(relative(root, full), hash);
    }
  }
  await walk(root);
  return snapshot;
}

// Mock child_process to prevent actual git/npx calls from update command
vi.mock("node:child_process", async (importOriginal) => {
  const actual = await importOriginal<typeof import("node:child_process")>();
  return { ...actual, execFileSync: vi.fn() };
});

// Wave 6: manifest moved from .agents/hatch.json to .hatch3r/hatch.json.
// Wave 3: .agents/ no longer materialized in user repos.
// Wave 5: user-authored overrides live under .hatch3r/overrides/{type}/.
const HATCH3R_DIR = ".hatch3r";

// Heavy filesystem I/O per test (mkdtemp + init creates 131 files + per-adapter
// generation + integrity hashing + rm -rf teardown). On Windows Node 22 CI
// runners this regularly exceeds the 30s default (vitest#7302, nodejs/node#60397).
describe("init -> sync -> update lifecycle", { timeout: 60_000 }, () => {
  let tempDir: string;
  let originalCwd: string;
  let exitSpy: MockInstance;
  let consoleSpy: MockInstance;
  let consoleErrorSpy: MockInstance;

  beforeEach(async () => {
    tempDir = await mkdtemp(join(tmpdir(), "hatch3r-lifecycle-"));
    originalCwd = process.cwd();
    process.chdir(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(() => {});
    vi.mocked(execFileSync).mockReturnValue(Buffer.from(""));
  });

  afterEach(async () => {
    process.chdir(originalCwd);
    exitSpy.mockRestore();
    consoleSpy.mockRestore();
    consoleErrorSpy.mockRestore();
    await rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
  });

  it("full lifecycle: init creates project, sync generates output, update refreshes", async () => {
    // ── Phase 1: Init ──────────────────────────────────────────
    const { initCommand } = await import("../../cli/commands/init.js");
    await initCommand({ yes: true, tools: "cursor" });

    // Verify init created the .hatch3r/ footprint (Wave 6) — no .agents/ tree (Wave 3).
    await expect(access(join(tempDir, HATCH3R_DIR))).resolves.toBeUndefined();
    await expect(access(join(tempDir, HATCH3R_DIR, "hatch.json"))).resolves.toBeUndefined();
    await expect(access(join(tempDir, ".agents"))).rejects.toThrow();

    const manifestRaw = await readFile(join(tempDir, HATCH3R_DIR, "hatch.json"), "utf-8");
    const manifest = JSON.parse(manifestRaw);
    expect(manifest.tools).toContain("cursor");
    expect(manifest.features.rules).toBe(true);

    // ── Phase 2: Sync ──────────────────────────────────────────
    consoleSpy.mockClear();
    const { syncCommand } = await import("../../cli/commands/sync.js");
    await syncCommand();

    // Verify sync generated adapter output
    const cursorRulesDir = join(tempDir, ".cursor", "rules");
    const bridgePath = join(cursorRulesDir, "hatch3r-bridge.mdc");
    const bridgeContent = await readFile(bridgePath, "utf-8").catch(() => null);
    expect(bridgeContent).not.toBeNull();
    expect(bridgeContent).toContain("Hatch3r Bridge");

    // Wave 3: root AGENTS.md is no longer emitted; each adapter writes only its native surface.
    await expect(access(join(tempDir, "AGENTS.md"))).rejects.toThrow();

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

    // ── Phase 3: Update ────────────────────────────────────────
    consoleSpy.mockClear();
    const { updateCommand } = await import("../../cli/commands/update.js");
    await updateCommand({ backup: false });

    // Verify update refreshed the manifest version
    const updatedManifestRaw = await readFile(join(tempDir, HATCH3R_DIR, "hatch.json"), "utf-8");
    const updatedManifest = JSON.parse(updatedManifestRaw);
    expect(updatedManifest.hatch3rVersion).toBe(HATCH3R_VERSION);

    // Verify adapter output was regenerated
    const updatedBridge = await readFile(bridgePath, "utf-8").catch(() => null);
    expect(updatedBridge).not.toBeNull();

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

  it("sync is idempotent: re-sync after init produces byte-identical files", async () => {
    const { initCommand } = await import("../../cli/commands/init.js");
    await initCommand({ yes: true, tools: "cursor" });

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

    // First sync
    await syncCommand();
    const snapshot1 = await snapshotProject(tempDir);

    // Second sync (must be byte-identical — every adapter output, every
    // managed file, every wrap/insert round-trip. The previous version of
    // this test only compared AGENTS.md, which missed the v1.7.0 worktree-
    // setup symptom where adapter outputs drifted by trailing-newline bytes
    // between syncs.)
    consoleSpy.mockClear();
    await syncCommand();
    const snapshot2 = await snapshotProject(tempDir);

    expect(snapshot2).toEqual(snapshot1);

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

  it("update preserves user override files under .hatch3r/overrides/", async () => {
    const { initCommand } = await import("../../cli/commands/init.js");
    await initCommand({ yes: true, tools: "cursor" });

    // Wave 5: user-authored content lives at .hatch3r/overrides/{type}/.
    const overridesRulesDir = join(tempDir, HATCH3R_DIR, "overrides", "rules");
    await mkdir(overridesRulesDir, { recursive: true });
    const customRulePath = join(overridesRulesDir, "my-team-rule.md");
    await writeFile(
      customRulePath,
      "---\nid: my-team-rule\ntype: rule\ndescription: Team rule\nscope: always\n---\n# My Team Rule\n\nCustom team content.\n",
    );

    const { updateCommand } = await import("../../cli/commands/update.js");
    await updateCommand({ backup: false });

    // Custom file should be preserved under .hatch3r/overrides/.
    const customContent = await readFile(customRulePath, "utf-8");
    expect(customContent).toContain("My Team Rule");
    expect(customContent).toContain("Custom team content");
  });

  it("update after sync refreshes hatch3r-prefixed adapter outputs", async () => {
    const { initCommand } = await import("../../cli/commands/init.js");
    await initCommand({ yes: true, tools: "cursor" });

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

    // Wave 3: canonical files are no longer materialized in the user repo.
    // The cursor adapter writes hatch3r-prefixed rules to .cursor/rules/ instead.
    const rulesDir = join(tempDir, ".cursor", "rules");
    const ruleFiles = await readdir(rulesDir);
    const hatch3rRules = ruleFiles.filter((f) => f.startsWith("hatch3r-"));
    expect(hatch3rRules.length).toBeGreaterThan(0);

    // Run update
    const { updateCommand } = await import("../../cli/commands/update.js");
    await updateCommand({ backup: false });

    // hatch3r-prefixed files should still exist after update.
    const updatedRuleFiles = await readdir(rulesDir);
    const updatedHatch3rRules = updatedRuleFiles.filter((f) => f.startsWith("hatch3r-"));
    expect(updatedHatch3rRules.length).toBeGreaterThan(0);
  });

  it("multi-tool lifecycle: init with multiple tools, sync generates all outputs", async () => {
    const { initCommand } = await import("../../cli/commands/init.js");
    await initCommand({ yes: true, tools: "cursor,claude" });

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

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

    // Verify claude output exists
    const claudeMd = await readFile(
      join(tempDir, "CLAUDE.md"),
      "utf-8",
    ).catch(() => null);
    expect(claudeMd).not.toBeNull();

    // Run update to verify both tools are refreshed
    consoleSpy.mockClear();
    const { updateCommand } = await import("../../cli/commands/update.js");
    await updateCommand({ backup: false });

    const updateOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
    expect(updateOutput).toContain("2 tool(s) re-synced");
  });
});
