/**
 * @file Tests for server/lib/worktree.js against a REAL git repository created
 * in a temp directory. Every behaviour worth testing here is git's own — branch
 * collisions, what `clean -fd` spares, what `worktree list` reports — so mocking
 * git would only test our idea of git.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");

const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-wt-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes");

const wt = require("../lib/worktree");

const SRC = path.join(ROOT, "src-repo");
const g = (cwd, ...args) => {
  // Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
  // so the fixture builder doesn't inherit them from the test harness.
  const env = { ...process.env };
  delete env.GIT_DIR;
  delete env.GIT_WORK_TREE;
  delete env.GIT_INDEX_FILE;
  delete env.GIT_COMMON_DIR;
  delete env.GIT_OBJECT_DIRECTORY;
  delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
  delete env.GIT_PREFIX;
  delete env.GIT_NAMESPACE;
  delete env.GIT_CONFIG_PARAMETERS;
  env.GIT_TERMINAL_PROMPT = "0";
  return execFileSync("git", args, { cwd, encoding: "utf8", env });
};

before(() => {
  fs.mkdirSync(SRC, { recursive: true });
  g(SRC, "init", "-b", "main");
  g(SRC, "config", "user.email", "t@example.com");
  g(SRC, "config", "user.name", "Test");
  fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
  fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n");
  g(SRC, "add", "-A");
  g(SRC, "commit", "-m", "init");
});

after(() => fs.rmSync(ROOT, { recursive: true, force: true }));

function laneFor(dir, branch, over = {}) {
  return {
    id: 1,
    kind: "managed",
    cwd: dir,
    branch,
    source_repo: SRC,
    base_branch: "main",
    ...over,
  };
}

describe("worktree", () => {
  it("slugifies a title into a safe single segment", () => {
    assert.equal(wt.slugify("Rename Metric → Rule!"), "rename-metric-rule");
    assert.equal(wt.slugify("  a//b  "), "a-b");
    assert.ok(wt.slugify("x".repeat(80)).length <= 40);
  });

  it("resolves the base branch, falling back when origin has none", async () => {
    assert.equal(await wt.resolveBase(SRC, "main"), "main");
    assert.equal(await wt.resolveBase(SRC, "does-not-exist"), "main");
  });

  it("creates a worktree on a new branch and lists it", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    const r = await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/alpha", base: "main" });
    assert.equal(r.created, true);
    assert.ok(fs.existsSync(path.join(dir, "README.md")));
    const list = await wt.listWorktrees(SRC);
    assert.ok(list.some((w) => w.path === dir && w.branch === "feat/alpha"));
  });

  it("refuses a branch already checked out in another worktree", async () => {
    const dir2 = path.join(process.env.LANES_ROOT, "src-repo__alpha2");
    await assert.rejects(
      () => wt.addWorktree({ sourceRepo: SRC, dir: dir2, branch: "feat/alpha", base: "main" }),
      (e) => e.code === "EBRANCHBUSY" && typeof e.checkedOutAt === "string"
    );
  });

  it("counts dirty, untracked and unpushed work", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    fs.appendFileSync(path.join(dir, "README.md"), "edit\n");
    fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n");
    fs.mkdirSync(path.join(dir, "node_modules"), { recursive: true });
    fs.writeFileSync(path.join(dir, "node_modules", "dep.js"), "x\n");
    const s = await wt.statusCounts(dir);
    assert.equal(s.dirty, 1);
    assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
    assert.match(s.head, /^[0-9a-f]{7,40}$/);
    // The fixture has no remotes. Given the lane's base branch, unpushedCount
    // measures base..HEAD — this worktree has committed nothing of its own, so 0.
    assert.equal(await wt.unpushedCount(dir, "main"), 0);
    // With no base to measure against (an adopted lane has none), it falls back
    // to the total commit count, since every commit is then at risk.
    assert.equal(await wt.unpushedCount(dir), 1);
  });

  it("reset restores base, drops untracked files, and spares gitignored ones", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    await wt.resetWorktree(laneFor(dir, "feat/alpha"));
    assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hello\n");
    assert.equal(fs.existsSync(path.join(dir, "scratch.txt")), false);
    assert.equal(fs.existsSync(path.join(dir, "node_modules", "dep.js")), true);
    const s = await wt.statusCounts(dir);
    assert.equal(s.dirty, 0);
    // Verify we're on the feature branch, not left on base
    const branch = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
    assert.equal(branch, "feat/alpha");
  });

  it("reset against a bogus base_branch throws ENOBASE and leaves worktree untouched", async () => {
    // Create a new worktree with a bogus base that will exist as a branch
    // but we'll change it to non-existent in the lane
    const dir = path.join(process.env.LANES_ROOT, "src-repo__bogus-test");
    await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/bogus-test", base: "main" });

    // Make a modification to detect if the worktree is mutated
    fs.writeFileSync(path.join(dir, "test-file.txt"), "test\n");

    // Get the current state before the failed reset
    const branchBefore = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
    const statusBefore = wt.statusCounts(dir);

    // Try to reset against a bogus base_branch
    await assert.rejects(
      () =>
        wt.resetWorktree(laneFor(dir, "feat/bogus-test", { base_branch: "non-existent-branch" })),
      (e) => e.code === "ENOBASE" && e.message.includes("non-existent-branch")
    );

    // Verify the worktree was not mutated: still on the same branch
    const branchAfter = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
    assert.equal(branchAfter, branchBefore);

    // Verify the file still exists (no mutations happened)
    assert.ok(fs.existsSync(path.join(dir, "test-file.txt")));

    // Clean up
    await wt.removeWorktree(laneFor(dir, "feat/bogus-test"));
  });

  it("refuses to destroy an adopted lane, a path outside LANES_ROOT, or a non-worktree", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    await assert.rejects(
      () => wt.assertDestroyable(laneFor(dir, "feat/alpha", { kind: "adopted" })),
      (e) => e.code === "ENOTMANAGED"
    );
    await assert.rejects(
      () => wt.assertDestroyable(laneFor("/tmp", "feat/alpha")),
      (e) => e.code === "EOUTSIDEROOT"
    );
    const ghost = path.join(process.env.LANES_ROOT, "src-repo__ghost");
    fs.mkdirSync(ghost, { recursive: true });
    await assert.rejects(
      () => wt.assertDestroyable(laneFor(ghost, "feat/ghost")),
      (e) => e.code === "ENOTWORKTREE"
    );
  });

  // These call removeWorktree DIRECTLY. The route short-circuits adopted lanes
  // before reaching it, so route-level tests can never pin its guard: deleting
  // `await assertDestroyable(lane)` from removeWorktree left all 844 server tests
  // green. Each case asserts the error code AND that nothing was destroyed.
  describe("removeWorktree refuses what it must not destroy", () => {
    it("refuses an adopted lane and leaves its directory and files alone", async () => {
      const dir = path.join(ROOT, "adopted-project");
      fs.mkdirSync(dir, { recursive: true });
      const file = path.join(dir, "real-work.txt");
      fs.writeFileSync(file, "the user's own project\n");
      await assert.rejects(
        () => wt.removeWorktree(laneFor(dir, "feat/whatever", { kind: "adopted" })),
        (e) => e.code === "ENOTMANAGED"
      );
      assert.equal(fs.existsSync(dir), true);
      assert.equal(fs.readFileSync(file, "utf8"), "the user's own project\n");
    });

    it("refuses a cwd outside LANES_ROOT and leaves that directory alone", async () => {
      const dir = path.join(ROOT, "outside-root");
      fs.mkdirSync(dir, { recursive: true });
      const file = path.join(dir, "keep.txt");
      fs.writeFileSync(file, "outside the sandbox\n");
      await assert.rejects(
        () => wt.removeWorktree(laneFor(dir, "feat/outside")),
        (e) => e.code === "EOUTSIDEROOT"
      );
      assert.equal(fs.existsSync(dir), true);
      assert.equal(fs.readFileSync(file, "utf8"), "outside the sandbox\n");
    });

    it("refuses a real directory inside LANES_ROOT that git does not list as a worktree", async () => {
      const dir = path.join(process.env.LANES_ROOT, "src-repo__not-a-worktree");
      fs.mkdirSync(dir, { recursive: true });
      const file = path.join(dir, "keep.txt");
      fs.writeFileSync(file, "never registered with git\n");
      await assert.rejects(
        () => wt.removeWorktree(laneFor(dir, "feat/not-a-worktree")),
        (e) => e.code === "ENOTWORKTREE"
      );
      assert.equal(fs.existsSync(dir), true);
      assert.equal(fs.readFileSync(file, "utf8"), "never registered with git\n");
    });
  });

  it("removes a lane whose worktree was deleted by hand, pruning git's stale record", async () => {
    // The design promises "the lane reports `missing` and only `remove` is
    // offered, taking the prune path". Before this, check 2 mapped the vanished
    // path to EOUTSIDEROOT and the lane could never be removed at all.
    const dir = path.join(process.env.LANES_ROOT, "src-repo__hand-deleted");
    await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/hand-deleted", base: "main" });
    fs.rmSync(dir, { recursive: true, force: true });
    assert.ok(
      (await wt.listWorktrees(SRC)).some((w) => w.path === dir),
      "git should still list the hand-deleted worktree"
    );

    await wt.removeWorktree(laneFor(dir, "feat/hand-deleted"));

    assert.equal(
      (await wt.listWorktrees(SRC)).some((w) => w.path === dir),
      false
    );
    assert.equal(g(SRC, "branch", "--list", "feat/hand-deleted").trim(), "");
  });

  it("removes a lane git never knew about, when its directory is already gone", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__never-existed");
    assert.equal(fs.existsSync(dir), false);
    // No throw: there is nothing on disk and nothing in git to clean up.
    await wt.removeWorktree(laneFor(dir, "feat/never-existed"));
  });

  it("still refuses the prune path for a missing cwd outside LANES_ROOT", async () => {
    // The directory is gone, so check 2 cannot realpath it — but it must still
    // hold, or a lane pointing anywhere could prune a source repo's worktrees.
    await assert.rejects(
      () => wt.removeWorktree(laneFor(path.join(ROOT, "gone-and-outside"), "feat/gone")),
      (e) => e.code === "EOUTSIDEROOT"
    );
  });

  it("one prunable sibling worktree does not break reset or remove for other lanes", async () => {
    // git keeps listing a hand-deleted worktree as `prunable`. realpathSync on
    // every listed entry threw ENOENT out of assertDestroyable, so a single
    // stale sibling produced an opaque 500 naming an unrelated directory for
    // every managed lane in the same repo.
    const victimDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-victim");
    await wt.addWorktree({ sourceRepo: SRC, dir: victimDir, branch: "feat/victim", base: "main" });
    const siblingDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-sibling");
    await wt.addWorktree({
      sourceRepo: SRC,
      dir: siblingDir,
      branch: "feat/sibling",
      base: "main",
    });
    fs.rmSync(siblingDir, { recursive: true, force: true });
    assert.ok(
      (await wt.listWorktrees(SRC)).some((w) => w.path === siblingDir),
      "git should still list the prunable sibling"
    );

    await wt.assertDestroyable(laneFor(victimDir, "feat/victim"));
    await wt.resetWorktree(laneFor(victimDir, "feat/victim"));
    await wt.removeWorktree(laneFor(victimDir, "feat/victim"));
    assert.equal(fs.existsSync(victimDir), false);

    // Clean up the stale record so later tests see a tidy list.
    await wt.removeWorktree(laneFor(siblingDir, "feat/sibling"));
  });

  it("removes a lane whose worktree's .git pointer is corrupt (unreadable), deregistering it without touching its directory", async () => {
    // `git worktree remove --force` (even --force --force) refuses outright
    // when the worktree's OWN .git file fails git's validation — this is what
    // `unreadable` in the preflight actually is. All three assertDestroyable
    // checks still pass (the directory exists, is inside LANES_ROOT, and is
    // still listed by the source repo), so removeWorktree must not get stuck.
    const dir = path.join(process.env.LANES_ROOT, "src-repo__corrupt");
    await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/corrupt", base: "main" });
    fs.writeFileSync(path.join(dir, "keep.txt"), "still here\n");
    fs.writeFileSync(path.join(dir, ".git"), "garbage\n");

    assert.throws(
      () => g(dir, "status"),
      "sanity: git itself must refuse to operate inside the corrupt worktree"
    );

    await wt.removeWorktree(laneFor(dir, "feat/corrupt"));

    assert.equal(
      (await wt.listWorktrees(SRC)).some((w) => w.path === dir),
      false,
      "git should no longer list the corrupt worktree"
    );
    assert.equal(g(SRC, "branch", "--list", "feat/corrupt").trim(), "");
    // The directory and its files were never touched.
    assert.equal(fs.existsSync(dir), true);
    assert.equal(fs.readFileSync(path.join(dir, "keep.txt"), "utf8"), "still here\n");
  });

  it("removes the worktree and its branch, leaving git's list clean", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    await wt.removeWorktree(laneFor(dir, "feat/alpha"));
    assert.equal(fs.existsSync(dir), false);
    const list = await wt.listWorktrees(SRC);
    assert.equal(
      list.some((w) => w.path === dir),
      false
    );
    const branches = g(SRC, "branch", "--list", "feat/alpha").trim();
    assert.equal(branches, "");
  });

  it("never deletes the base branch even if a lane claims it", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__beta");
    await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/beta", base: "main" });
    await wt.removeWorktree(laneFor(dir, "main")); // lane lies about its branch
    assert.match(g(SRC, "branch", "--list", "main"), /main/);
  });

  it("scrubs git hook environment variables from child processes", async () => {
    // Regression test: GIT_DIR, GIT_INDEX_FILE, etc. from git hooks must not
    // corrupt git operations on worktrees (where .git is a file, not a directory).
    // This test verifies that addWorktree and statusCounts work even when these
    // variables are set to bogus values.
    const oldGitDir = process.env.GIT_DIR;
    const oldGitIndexFile = process.env.GIT_INDEX_FILE;
    try {
      process.env.GIT_DIR = "/nonexistent/.git";
      process.env.GIT_INDEX_FILE = "/nonexistent/.git/index";

      const dir = path.join(process.env.LANES_ROOT, "src-repo__scrub-test");
      await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/scrub-test", base: "main" });
      const s = await wt.statusCounts(dir);
      assert.ok(s.head); // should have a commit hash
      assert.equal(s.dirty, 0); // should be clean

      // Clean up
      await wt.removeWorktree(laneFor(dir, "feat/scrub-test"));
    } finally {
      // Restore original values
      if (oldGitDir !== undefined) {
        process.env.GIT_DIR = oldGitDir;
      } else {
        delete process.env.GIT_DIR;
      }
      if (oldGitIndexFile !== undefined) {
        process.env.GIT_INDEX_FILE = oldGitIndexFile;
      } else {
        delete process.env.GIT_INDEX_FILE;
      }
    }
  });
});

describe("gitFacts", () => {
  it("reports branch, short head, subject and working-tree counts", async () => {
    const dir = path.join(ROOT, "facts-repo");
    fs.mkdirSync(dir, { recursive: true });
    g(dir, "init", "-b", "feat/facts");
    g(dir, "config", "user.email", "t@example.com");
    g(dir, "config", "user.name", "Test");
    fs.writeFileSync(path.join(dir, "tracked.txt"), "one\n");
    g(dir, "add", "-A");
    g(dir, "commit", "-m", "seed the facts fixture");

    // one modified tracked file, one file git has never seen
    fs.writeFileSync(path.join(dir, "tracked.txt"), "two\n");
    fs.writeFileSync(path.join(dir, "brand-new.txt"), "x\n");

    const facts = await wt.gitFacts(dir);
    assert.equal(facts.branch, "feat/facts");
    assert.equal(facts.head, g(dir, "rev-parse", "--short", "HEAD").trim());
    assert.equal(facts.subject, "seed the facts fixture");
    assert.equal(facts.dirty, 1);
    assert.equal(facts.untracked, 1);
  });

  it("reports the literal HEAD git gives for a detached checkout", async () => {
    const dir = path.join(ROOT, "facts-detached");
    fs.mkdirSync(dir, { recursive: true });
    g(dir, "init", "-b", "main");
    g(dir, "config", "user.email", "t@example.com");
    g(dir, "config", "user.name", "Test");
    fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
    g(dir, "add", "-A");
    g(dir, "commit", "-m", "only commit");
    g(dir, "checkout", "--detach", "HEAD");

    const facts = await wt.gitFacts(dir);
    assert.equal(facts.branch, "HEAD");
    assert.equal(facts.dirty, 0);
    assert.equal(facts.untracked, 0);
  });

  it("rejects rather than reporting facts for a directory that is not a repo", async () => {
    const dir = path.join(ROOT, "facts-plain");
    fs.mkdirSync(dir, { recursive: true });
    await assert.rejects(() => wt.gitFacts(dir));
  });
});

describe("listBranches", () => {
  it("lists local branches and names the current one", async () => {
    const dir = path.join(ROOT, "branches-repo");
    fs.mkdirSync(dir, { recursive: true });
    g(dir, "init", "-b", "main");
    g(dir, "config", "user.email", "t@example.com");
    g(dir, "config", "user.name", "Test");
    fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
    g(dir, "add", "-A");
    g(dir, "commit", "-m", "init");
    g(dir, "branch", "feat/one");
    g(dir, "branch", "feat/two");

    const { branches, current } = await wt.listBranches(dir);
    assert.deepEqual([...branches].sort(), ["feat/one", "feat/two", "main"]);
    assert.equal(current, "main");
  });

  it("reports no current branch for a detached HEAD, but still lists branches", async () => {
    const dir = path.join(ROOT, "branches-detached");
    fs.mkdirSync(dir, { recursive: true });
    g(dir, "init", "-b", "main");
    g(dir, "config", "user.email", "t@example.com");
    g(dir, "config", "user.name", "Test");
    fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
    g(dir, "add", "-A");
    g(dir, "commit", "-m", "init");
    g(dir, "checkout", "--detach", "HEAD");

    const { branches, current } = await wt.listBranches(dir);
    assert.deepEqual(branches, ["main"]);
    assert.equal(current, null);
  });

  it("returns an empty branch list for a repo with no commits yet", async () => {
    const dir = path.join(ROOT, "branches-empty");
    fs.mkdirSync(dir, { recursive: true });
    g(dir, "init", "-b", "main");

    const { branches } = await wt.listBranches(dir);
    assert.deepEqual(branches, []);
  });
});
