import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, writeFile, readFile, access, rm, chmod, readdir, symlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir, platform } from "node:os";
import {
  archiveToolOutputs,
  removeManagedFilesForPaths,
  getManagedFilesForTool,
  pruneArchives,
} from "../../archive/index.js";
import { createManifest } from "../../manifest/hatchJson.js";
import { ARCHIVE_DIR, MANAGED_BLOCK_START, MANAGED_BLOCK_END, type HatchManifest } from "../../types.js";

function wrapManaged(content: string): string {
  return `${MANAGED_BLOCK_START}\n${content}\n${MANAGED_BLOCK_END}`;
}

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

  beforeEach(async () => {
    tempDir = await mkdtemp(join(tmpdir(), "hatch3r-archive-"));
  });

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

  function makeManifest(tools: string[]): HatchManifest {
    return createManifest({
      tools: tools as HatchManifest["tools"],
      mcpServers: [],
      features: { mcp: false },
    });
  }

  describe("archiveToolOutputs", () => {
    it("archives generated files to .hatch3r-archive/<tool>/<timestamp>/", async () => {
      await mkdir(join(tempDir, ".cursor", "rules"), { recursive: true });
      await writeFile(
        join(tempDir, ".cursor", "rules", "hatch3r-test.mdc"),
        wrapManaged("managed content"),
      );

      const result = await archiveToolOutputs(tempDir, "cursor");

      expect(result.archivedFiles.length).toBeGreaterThan(0);
      expect(result.archivedFiles).toContain(".cursor/rules/hatch3r-test.mdc");
      await expect(
        access(join(tempDir, ".cursor", "rules", "hatch3r-test.mdc")),
      ).rejects.toThrow();

      const archiveDir = join(tempDir, ".hatch3r-archive", "cursor");
      await expect(access(archiveDir)).resolves.toBeUndefined();
    });

    it("detects custom content outside managed blocks and migrates to .hatch3r/", async () => {
      await mkdir(join(tempDir, ".cursor", "rules"), { recursive: true });

      const fileContent = `---
description: My rule
alwaysApply: true
---

${MANAGED_BLOCK_START}
Managed content here
${MANAGED_BLOCK_END}

My custom additions that should be preserved`;

      await writeFile(
        join(tempDir, ".cursor", "rules", "hatch3r-my-rule.mdc"),
        fileContent,
      );

      const result = await archiveToolOutputs(tempDir, "cursor");

      expect(result.migrations.length).toBe(1);
      expect(result.migrations[0].type).toBe("rules");
      expect(result.migrations[0].id).toBe("my-rule");

      const customizePath = join(tempDir, ".hatch3r", "rules", "my-rule.customize.md");
      const customizeContent = await readFile(customizePath, "utf-8");
      expect(customizeContent).toContain("My custom additions");
      expect(customizeContent).not.toContain("alwaysApply");
    });

    it("does not overwrite existing .hatch3r/ customization files", async () => {
      await mkdir(join(tempDir, ".cursor", "rules"), { recursive: true });
      await mkdir(join(tempDir, ".hatch3r", "rules"), { recursive: true });

      await writeFile(
        join(tempDir, ".hatch3r", "rules", "my-rule.customize.md"),
        "Existing customization\n",
      );

      const fileContent = `${MANAGED_BLOCK_START}\nManaged\n${MANAGED_BLOCK_END}\n\nNew custom content`;
      await writeFile(
        join(tempDir, ".cursor", "rules", "hatch3r-my-rule.mdc"),
        fileContent,
      );

      const result = await archiveToolOutputs(tempDir, "cursor");

      expect(result.migrations.length).toBe(0);

      const existing = await readFile(
        join(tempDir, ".hatch3r", "rules", "my-rule.customize.md"),
        "utf-8",
      );
      expect(existing).toBe("Existing customization\n");
    });

    it("archives files without managed blocks without attempting migration", async () => {
      await mkdir(join(tempDir, ".cursor"), { recursive: true });

      await writeFile(
        join(tempDir, ".cursor", "mcp.json"),
        JSON.stringify({ mcpServers: {} }),
      );

      const result = await archiveToolOutputs(tempDir, "cursor");

      expect(result.migrations.length).toBe(0);
      expect(result.archivedFiles).toContain(".cursor/mcp.json");
    });

    it("returns empty result for tool with no existing output files", async () => {
      const result = await archiveToolOutputs(tempDir, "claude");

      expect(result.archivedFiles).toHaveLength(0);
      expect(result.migrations).toHaveLength(0);
    });

    it("archives claude output including root-level files", async () => {
      await mkdir(join(tempDir, ".claude", "rules"), { recursive: true });

      await writeFile(join(tempDir, "CLAUDE.md"), wrapManaged("claude instructions"));
      await writeFile(join(tempDir, ".mcp.json"), JSON.stringify({ mcpServers: {} }));
      await writeFile(join(tempDir, ".claude", "rules", "hatch3r-test.md"), wrapManaged("rule content"));

      const result = await archiveToolOutputs(tempDir, "claude");

      expect(result.archivedFiles).toContain("CLAUDE.md");
      expect(result.archivedFiles).toContain(".mcp.json");
      expect(result.archivedFiles).toContain(".claude/rules/hatch3r-test.md");
    });

    it("migrates agent customizations from file path", async () => {
      await mkdir(join(tempDir, ".cursor", "agents"), { recursive: true });

      const fileContent = `---
name: hatch3r-reviewer
description: Code reviewer
---

${MANAGED_BLOCK_START}
Review code
${MANAGED_BLOCK_END}

Always check for SQL injection`;

      await writeFile(
        join(tempDir, ".cursor", "agents", "hatch3r-reviewer.md"),
        fileContent,
      );

      const result = await archiveToolOutputs(tempDir, "cursor");

      expect(result.migrations.length).toBe(1);
      expect(result.migrations[0].type).toBe("agents");
      expect(result.migrations[0].id).toBe("reviewer");
    });
  });

  describe("removeManagedFilesForPaths", () => {
    it("removes specified paths from managedFiles", () => {
      const manifest = makeManifest(["cursor"]);
      manifest.managedFiles = [
        ".cursor/rules/a.mdc",
        ".cursor/rules/b.mdc",
        "AGENTS.md",
      ];

      removeManagedFilesForPaths(manifest, [".cursor/rules/a.mdc", ".cursor/rules/b.mdc"]);

      expect(manifest.managedFiles).toEqual(["AGENTS.md"]);
    });

    it("handles empty paths array", () => {
      const manifest = makeManifest(["cursor"]);
      manifest.managedFiles = ["file1.md", "file2.md"];

      removeManagedFilesForPaths(manifest, []);

      expect(manifest.managedFiles).toEqual(["file1.md", "file2.md"]);
    });

    it("handles paths not in managedFiles", () => {
      const manifest = makeManifest(["cursor"]);
      manifest.managedFiles = ["file1.md"];

      removeManagedFilesForPaths(manifest, ["nonexistent.md"]);

      expect(manifest.managedFiles).toEqual(["file1.md"]);
    });
  });

  describe("getManagedFilesForTool", () => {
    it("returns cursor managed files", () => {
      const manifest = makeManifest(["cursor", "claude"]);
      manifest.managedFiles = [
        ".cursor/rules/test.mdc",
        ".cursor/mcp.json",
        "CLAUDE.md",
        ".claude/settings.json",
        "AGENTS.md",
      ];

      const result = getManagedFilesForTool(manifest, "cursor");

      expect(result).toEqual([".cursor/rules/test.mdc", ".cursor/mcp.json"]);
    });

    it("returns claude managed files including root-level files", () => {
      const manifest = makeManifest(["cursor", "claude"]);
      manifest.managedFiles = [
        ".cursor/rules/test.mdc",
        "CLAUDE.md",
        ".mcp.json",
        ".claude/settings.json",
        "AGENTS.md",
      ];

      const result = getManagedFilesForTool(manifest, "claude");

      expect(result).toEqual(["CLAUDE.md", ".mcp.json", ".claude/settings.json"]);
    });

    it("returns empty for tool with no managed files", () => {
      const manifest = makeManifest(["cursor"]);
      manifest.managedFiles = ["AGENTS.md"];

      const result = getManagedFilesForTool(manifest, "copilot");

      expect(result).toEqual([]);
    });
  });

  // C7.5-W2B2-H10 (D3.4-F1): Coverage for `pruneArchives` -- the 24-line
  // rollback-on-corruption path in src/archive/index.ts:252-284. Archive is the
  // safety net for destructive sync operations; the prune function constrains
  // retention so a bug here could silently delete user snapshots. Previously
  // uncovered per coverage run (lines 252-284).
  describe("pruneArchives", () => {
    it("returns empty array when archive root does not exist (ENOENT path)", async () => {
      // No .hatch3r-archive directory -- readdir throws ENOENT which must be
      // swallowed and return []. Covers lines 256-261 ENOENT branch.
      const pruned = await pruneArchives(tempDir);
      expect(pruned).toEqual([]);
    });

    it("rethrows non-ENOENT errors from archive root readdir", async () => {
      // Replace `.hatch3r-archive` with a regular file so readdir fails with
      // ENOTDIR (or on some systems a different non-ENOENT code). The function
      // must rethrow -- covers the `throw err` branch at line 260.
      const archiveRootAsFile = join(tempDir, ARCHIVE_DIR);
      await writeFile(archiveRootAsFile, "not a directory");

      await expect(pruneArchives(tempDir)).rejects.toMatchObject({
        code: expect.stringMatching(/ENOTDIR|EACCES|EPERM/),
      });
    });

    it("returns empty when archive root exists but contains no tool directories", async () => {
      // Archive root exists but is empty -- the for-loop never executes and
      // pruned stays []. Confirms the happy short-circuit path.
      await mkdir(join(tempDir, ARCHIVE_DIR), { recursive: true });

      const pruned = await pruneArchives(tempDir);
      expect(pruned).toEqual([]);
    });

    it("skips stray files under archive root (non-directory entries)", async () => {
      // The orchestrator should only prune directories. A stray file at
      // .hatch3r-archive/<name> must be left alone -- covers the
      // `if (!s.isDirectory()) continue;` branch at line 268.
      const archiveRoot = join(tempDir, ARCHIVE_DIR);
      await mkdir(archiveRoot, { recursive: true });
      const strayFile = join(archiveRoot, "README.md");
      await writeFile(strayFile, "accidental file in archive root");

      const pruned = await pruneArchives(tempDir);

      expect(pruned).toEqual([]);
      // Stray file still present
      await expect(access(strayFile)).resolves.toBeUndefined();
    });

    it.skipIf(platform() === "win32")(
      "continues past tool directories whose entries cannot be read",
      async () => {
        // Create a dangling symlink as a tool-dir entry. readdir of the archive
        // root lists the symlink name; stat follows the link and fails with
        // ENOENT -- caught and the loop continues. Another valid tool dir
        // alongside must still be pruned. Covers the `catch { continue; }`
        // branch at lines 270-272. POSIX-only because Windows symlink
        // semantics require elevated privileges.
        const archiveRoot = join(tempDir, ARCHIVE_DIR);
        await mkdir(archiveRoot, { recursive: true });
        // Broken symlink pointing at a path that does not exist.
        await symlink(
          join(tempDir, "does-not-exist"),
          join(archiveRoot, "cursor"),
        );
        // A real tool dir with 6 timestamped entries -> 1 must be pruned.
        const copilotPath = join(archiveRoot, "copilot");
        await mkdir(copilotPath, { recursive: true });
        for (let i = 0; i < 6; i++) {
          await mkdir(
            join(copilotPath, `2026-04-${String(10 + i).padStart(2, "0")}T00-00-00-000Z`),
            { recursive: true },
          );
        }

        const pruned = await pruneArchives(tempDir);

        // cursor skipped silently (stat threw). copilot pruned its oldest.
        expect(pruned).toHaveLength(1);
        expect(pruned[0]).toBe("copilot/2026-04-10T00-00-00-000Z");
      },
    );

    it("keeps all entries when count equals the retention limit", async () => {
      // Boundary test: exactly 5 entries -> nothing pruned. Guards against an
      // off-by-one at `entries.slice(MAX_ARCHIVE_ENTRIES)`.
      const toolPath = join(tempDir, ARCHIVE_DIR, "cursor");
      await mkdir(toolPath, { recursive: true });
      for (let i = 0; i < 5; i++) {
        await mkdir(join(toolPath, `2026-04-${String(10 + i).padStart(2, "0")}T00-00-00-000Z`), {
          recursive: true,
        });
      }

      const pruned = await pruneArchives(tempDir);

      expect(pruned).toEqual([]);
      const remaining = await readdir(toolPath);
      expect(remaining).toHaveLength(5);
    });

    it("keeps all entries when count is below the retention limit", async () => {
      const toolPath = join(tempDir, ARCHIVE_DIR, "cursor");
      await mkdir(toolPath, { recursive: true });
      for (let i = 0; i < 3; i++) {
        await mkdir(join(toolPath, `2026-04-${String(10 + i).padStart(2, "0")}T00-00-00-000Z`), {
          recursive: true,
        });
      }

      const pruned = await pruneArchives(tempDir);

      expect(pruned).toEqual([]);
    });

    it("prunes oldest entries beyond the retention limit, keeping newest 5", async () => {
      // 7 ISO-timestamped entries -> 2 oldest (by lexical string compare since
      // timestamps are ISO) are pruned. Covers lines 274-281 (sort + slice loop).
      const toolPath = join(tempDir, ARCHIVE_DIR, "cursor");
      await mkdir(toolPath, { recursive: true });
      const timestamps = [
        "2026-04-10T00-00-00-000Z",
        "2026-04-11T00-00-00-000Z",
        "2026-04-12T00-00-00-000Z",
        "2026-04-13T00-00-00-000Z",
        "2026-04-14T00-00-00-000Z",
        "2026-04-15T00-00-00-000Z",
        "2026-04-16T00-00-00-000Z",
      ];
      for (const ts of timestamps) {
        const entryDir = join(toolPath, ts);
        await mkdir(entryDir, { recursive: true });
        // Put a file inside so rm recursive:true is actually exercised.
        await writeFile(join(entryDir, "snapshot.txt"), `archived at ${ts}`);
      }

      const pruned = await pruneArchives(tempDir);

      // 2 oldest pruned.
      expect(pruned).toHaveLength(2);
      expect(pruned).toContain("cursor/2026-04-10T00-00-00-000Z");
      expect(pruned).toContain("cursor/2026-04-11T00-00-00-000Z");

      // 5 newest remain, verified by directory listing.
      const remaining = await readdir(toolPath);
      expect(remaining.sort()).toEqual([
        "2026-04-12T00-00-00-000Z",
        "2026-04-13T00-00-00-000Z",
        "2026-04-14T00-00-00-000Z",
        "2026-04-15T00-00-00-000Z",
        "2026-04-16T00-00-00-000Z",
      ]);
    });

    it("prunes independently per tool directory", async () => {
      // Two tools with different entry counts in the same archive root.
      // Confirms the outer for-loop iterates over every tool directory.
      const cursorPath = join(tempDir, ARCHIVE_DIR, "cursor");
      const copilotPath = join(tempDir, ARCHIVE_DIR, "copilot");
      await mkdir(cursorPath, { recursive: true });
      await mkdir(copilotPath, { recursive: true });

      // cursor: 6 entries -> 1 pruned
      for (let i = 0; i < 6; i++) {
        await mkdir(join(cursorPath, `2026-04-${String(10 + i).padStart(2, "0")}T00-00-00-000Z`), {
          recursive: true,
        });
      }
      // copilot: 4 entries -> 0 pruned
      for (let i = 0; i < 4; i++) {
        await mkdir(join(copilotPath, `2026-04-${String(10 + i).padStart(2, "0")}T00-00-00-000Z`), {
          recursive: true,
        });
      }

      const pruned = await pruneArchives(tempDir);

      expect(pruned).toHaveLength(1);
      expect(pruned[0]).toBe("cursor/2026-04-10T00-00-00-000Z");

      const cursorRemaining = await readdir(cursorPath);
      expect(cursorRemaining).toHaveLength(5);
      const copilotRemaining = await readdir(copilotPath);
      expect(copilotRemaining).toHaveLength(4);
    });

    it("prunes directories with nested content (exercises rm recursive:true)", async () => {
      // Pruned entries often contain a deep directory tree mirroring the
      // original layout. Confirms rm(..., { recursive: true, force: true })
      // at line 279 removes nested files without throwing.
      const toolPath = join(tempDir, ARCHIVE_DIR, "claude");
      await mkdir(toolPath, { recursive: true });
      for (let i = 0; i < 7; i++) {
        const ts = `2026-04-${String(10 + i).padStart(2, "0")}T00-00-00-000Z`;
        const nested = join(toolPath, ts, ".claude", "rules");
        await mkdir(nested, { recursive: true });
        await writeFile(join(nested, "hatch3r-rule.md"), "rule body");
        await writeFile(join(toolPath, ts, "CLAUDE.md"), "instructions");
      }

      const pruned = await pruneArchives(tempDir);

      expect(pruned).toHaveLength(2);
      const remaining = await readdir(toolPath);
      expect(remaining).toHaveLength(5);
      // Confirm pruned directories are actually gone (no partial removal).
      for (const p of pruned) {
        const relPath = p.replace("claude/", "");
        await expect(access(join(toolPath, relPath))).rejects.toThrow();
      }
    });
  });

  // Supplementary coverage for two small uncovered branches identified in the
  // same coverage report that drove C7.5-W2B2-H10.
  describe("archiveToolOutputs -- edge branches", () => {
    it("archives a file whose path does not match any customizable pattern", async () => {
      // File lives inside a tool prefix but its path does not match any of
      // PATH_PATTERNS (rules/agents/skills/commands). The file has content
      // outside the managed block so `customContent.length > 0` is true,
      // forcing parseOutputPath to run; since no pattern matches, it returns
      // null, no migration is emitted, and the file is still archived.
      // Covers the `return null` branch at line 67 of parseOutputPath.
      await mkdir(join(tempDir, ".cursor"), { recursive: true });
      const unusualFile = join(tempDir, ".cursor", "custom-config.yml");
      const fileContent = `${MANAGED_BLOCK_START}\ncursor config\n${MANAGED_BLOCK_END}\n\nuser-authored tail`;
      await writeFile(unusualFile, fileContent);

      const result = await archiveToolOutputs(tempDir, "cursor");

      expect(result.migrations).toHaveLength(0);
      expect(result.archivedFiles).toContain(".cursor/custom-config.yml");
    });

    it.skipIf(platform() === "win32")(
      "continues past files that become unreadable during iteration",
      async () => {
        // Force readFile to fail for one file via chmod 0000. The archive
        // function must swallow the error and keep processing -- covers the
        // `try/catch { continue; }` at lines 146-151. POSIX-only because
        // Windows chmod semantics differ.
        if (process.getuid && process.getuid() === 0) {
          // Running as root bypasses permission checks; skip under sudo.
          return;
        }
        await mkdir(join(tempDir, ".cursor", "rules"), { recursive: true });
        const unreadable = join(tempDir, ".cursor", "rules", "hatch3r-locked.mdc");
        const readable = join(tempDir, ".cursor", "rules", "hatch3r-ok.mdc");
        await writeFile(unreadable, wrapManaged("locked"));
        await writeFile(readable, wrapManaged("readable"));
        await chmod(unreadable, 0o000);

        try {
          const result = await archiveToolOutputs(tempDir, "cursor");
          // The readable file still gets archived; the locked file is skipped
          // silently without throwing.
          expect(result.archivedFiles).toContain(".cursor/rules/hatch3r-ok.mdc");
        } finally {
          // Restore permissions so afterEach rm cleanup succeeds.
          await chmod(unreadable, 0o644);
        }
      },
    );
  });
});
