import { describe, test, expect, afterEach, beforeAll, afterAll } from "bun:test";
import { existsSync, writeFileSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import type { SimpleGit } from "simple-git";
import { RepoManagerImpl, prewarmRepos } from "@/repo/manager.js";
import { RepoOperationError } from "@/core/errors.js";
import { mockLogger } from "../_helpers/mock-logger.js";

// ── git identity for CI ───────────────────────────────────────────────────────
// CI runners have no global user.email/user.name. Setting these env vars ensures
// ALL git subprocesses (gitExec AND simple-git inside RepoManagerImpl) inherit
// a valid identity without needing a global ~/.gitconfig.

const GIT_ENV_KEYS = [
  "GIT_AUTHOR_NAME",
  "GIT_AUTHOR_EMAIL",
  "GIT_COMMITTER_NAME",
  "GIT_COMMITTER_EMAIL",
] as const;
const savedGitEnv: Partial<Record<(typeof GIT_ENV_KEYS)[number], string>> = {};

beforeAll(() => {
  for (const key of GIT_ENV_KEYS) {
    savedGitEnv[key] = process.env[key];
  }
  process.env.GIT_AUTHOR_NAME = "Test";
  process.env.GIT_AUTHOR_EMAIL = "test@test.com";
  process.env.GIT_COMMITTER_NAME = "Test";
  process.env.GIT_COMMITTER_EMAIL = "test@test.com";
});

afterAll(() => {
  for (const key of GIT_ENV_KEYS) {
    const saved = savedGitEnv[key];
    if (saved === undefined) delete process.env[key];
    else process.env[key] = saved;
  }
});

// ── cleanup helpers ──────────────────────────────────────────────────────────

const cleanups: Array<() => Promise<void>> = [];

afterEach(async () => {
  for (const cleanup of cleanups.splice(0)) {
    await cleanup().catch(() => {});
  }
});

function makeTmpDir(): string {
  const d = mkdtempSync(join(tmpdir(), "manager-test-"));
  cleanups.push(async () => {
    const { rmSync } = await import("node:fs");
    rmSync(d, { recursive: true, force: true });
  });
  return d;
}

function gitExec(dir: string, args: string): string {
  return execSync(`git ${args}`, {
    cwd: dir,
    env: {
      ...process.env,
      GIT_AUTHOR_NAME: "Test",
      GIT_AUTHOR_EMAIL: "test@test.com",
      GIT_COMMITTER_NAME: "Test",
      GIT_COMMITTER_EMAIL: "test@test.com",
    },
    stdio: "pipe",
  })
    .toString()
    .trim();
}

// Creates a "bare" remote repo (allows push) and returns its path
async function makeRemoteAndClone(): Promise<{
  remoteDir: string;
  localDir: string;
  manager: RepoManagerImpl;
}> {
  // Create the "remote" as a bare repo, standardising on "main" regardless
  // of the system git default (avoids master/main ambiguity on CI).
  const remoteDir = makeTmpDir();
  gitExec(remoteDir, "init --bare");
  gitExec(remoteDir, "symbolic-ref HEAD refs/heads/main");

  // Create a separate "seed" dir to push an initial commit to remote.
  const seedDir = makeTmpDir();
  gitExec(seedDir, "init");
  // Switch to "main" unconditionally (portable pre-2.28 approach).
  gitExec(seedDir, "symbolic-ref HEAD refs/heads/main");
  writeFileSync(join(seedDir, "README.md"), "# hello");
  gitExec(seedDir, "add -A");
  gitExec(seedDir, 'commit -m "initial"');
  gitExec(seedDir, `remote add origin ${remoteDir}`);
  gitExec(seedDir, "push origin main:main");

  // Now create a local dir where we'll clone
  const localDir = makeTmpDir();
  const logger = mockLogger();
  const manager = new RepoManagerImpl(remoteDir, localDir, logger);

  return { remoteDir, localDir, manager };
}

// ── tests ────────────────────────────────────────────────────────────────────

describe("RepoManagerImpl", () => {
  describe("ensureCloned()", () => {
    test("clones a fresh repo from remote", async () => {
      const { localDir, manager } = await makeRemoteAndClone();
      await manager.ensureCloned();

      expect(existsSync(join(localDir, ".git"))).toBe(true);
      expect(existsSync(join(localDir, "README.md"))).toBe(true);
    });

    test("re-uses an already cloned repo (no second clone)", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      // Call again — should not throw, just log "repo_ready"
      await expect(manager.ensureCloned()).resolves.toBeUndefined();
    });

    test("throws RepoOperationError when remote URL is invalid", async () => {
      const localDir = makeTmpDir();
      const logger = mockLogger();
      const manager = new RepoManagerImpl("/nonexistent/path", localDir, logger);

      await expect(manager.ensureCloned()).rejects.toBeInstanceOf(RepoOperationError);
    });
  });

  describe("createBranch()", () => {
    test("creates and checks out a new branch", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      // Need a base branch first
      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);

      await manager.createBranch("fix/SQDP-1-my-bug");

      const branches = await git.branchLocal();
      expect(branches.current).toBe("fix/SQDP-1-my-bug");
    });

    test("recreates branch if it already exists locally", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);

      // Create it twice
      await manager.createBranch("fix/SQDP-1-my-bug");
      await git.checkout("main");
      await manager.createBranch("fix/SQDP-1-my-bug");

      const branches = await git.branchLocal();
      expect(branches.current).toBe("fix/SQDP-1-my-bug");
    });
  });

  describe("getLocalPath()", () => {
    test("returns the local path", async () => {
      const { localDir, manager } = await makeRemoteAndClone();
      expect(manager.getLocalPath()).toBe(localDir);
    });
  });

  describe("hasChanges()", () => {
    test("returns false when working tree is clean", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);

      const result = await manager.hasChanges();
      expect(result).toBe(false);
    });

    test("returns true when there are uncommitted changes", async () => {
      const { localDir, manager } = await makeRemoteAndClone();
      await manager.ensureCloned();

      // Modify a file
      writeFileSync(join(localDir, "new-file.txt"), "content");

      const result = await manager.hasChanges();
      expect(result).toBe(true);
    });
  });

  describe("workingTreeFingerprint()", () => {
    test("returns 'clean' for an unmodified working tree", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      expect(await manager.workingTreeFingerprint()).toBe("clean");
    });

    test("differs after a meaningful edit, ignores lockfiles and bot-managed paths", async () => {
      const { localDir, manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      const before = await manager.workingTreeFingerprint();

      // Lockfile and bot-managed paths must not change the fingerprint.
      writeFileSync(join(localDir, "package-lock.json"), '{"name":"noise"}');
      const { mkdirSync } = await import("node:fs");
      mkdirSync(join(localDir, ".cursor", "rules"), { recursive: true });
      writeFileSync(join(localDir, ".cursor", "rules", "stub.mdc"), "# rules");
      mkdirSync(join(localDir, ".hu-bot"), { recursive: true });
      writeFileSync(join(localDir, ".hu-bot", "scratch.txt"), "scratch");
      expect(await manager.workingTreeFingerprint()).toBe(before);

      // A real source-file change must shift the fingerprint.
      writeFileSync(join(localDir, "new-file.ts"), "export const x = 1;");
      const after = await manager.workingTreeFingerprint();
      expect(after).not.toBe(before);

      // Same file with same content called twice → identical fingerprint.
      expect(await manager.workingTreeFingerprint()).toBe(after);
    });
  });

  describe("commitAndPush()", () => {
    test("commits and pushes changes to remote", async () => {
      const { localDir, remoteDir, manager } = await makeRemoteAndClone();
      await manager.ensureCloned();

      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);
      await manager.createBranch("fix/test-push");

      // Make a change
      writeFileSync(join(localDir, "change.txt"), "hello from test");

      await manager.commitAndPush("fix/test-push", "test: add change");

      // Verify the branch was pushed to remote (bare repo)
      const result = gitExec(remoteDir, "rev-parse --verify refs/heads/fix/test-push");
      expect(result).toBeTruthy();
    });

    test("does not commit lock files when other files are also changed", async () => {
      const { localDir, manager } = await makeRemoteAndClone();
      await manager.ensureCloned();

      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);
      await manager.createBranch("fix/test-lockfile");

      // Add both a meaningful file and a lock file
      writeFileSync(join(localDir, "src.ts"), "export const x = 1;");
      writeFileSync(join(localDir, "package-lock.json"), '{"lockfileVersion": 3}');

      await manager.commitAndPush("fix/test-lockfile", "test: add src");

      // Verify lock file is not in committed files
      const log = await git.log({ maxCount: 1 });
      const diff = await git.show(["--name-only", "--format=", log.latest!.hash]);
      expect(diff).not.toContain("package-lock.json");
      expect(diff).toContain("src.ts");
    });

    test("throws RepoOperationError when getGit() called before ensureCloned()", async () => {
      const localDir = makeTmpDir();
      const logger = mockLogger();
      const manager = new RepoManagerImpl("/some/url", localDir, logger);

      await expect(manager.commitAndPush("branch", "msg")).rejects.toBeInstanceOf(
        RepoOperationError,
      );
    });
  });

  describe("validateBuild()", () => {
    test("succeeds with empty checkScripts when no build script defined", async () => {
      const { localDir, manager } = await makeRemoteAndClone();
      await manager.ensureCloned();

      // Write a minimal package.json with no scripts
      writeFileSync(
        join(localDir, "package.json"),
        JSON.stringify({ name: "test", version: "1.0.0", scripts: {} }),
      );

      const result = await manager.validateBuild([], undefined);
      expect(result.success).toBe(true);
    });

    test("skips scripts that don't exist in package.json", async () => {
      const { localDir, manager } = await makeRemoteAndClone();
      await manager.ensureCloned();

      writeFileSync(
        join(localDir, "package.json"),
        JSON.stringify({ name: "test", version: "1.0.0", scripts: {} }),
      );

      const result = await manager.validateBuild(["typecheck", "lint"], undefined);
      expect(result.success).toBe(true);
    });
  });

  describe("getDiffStats()", () => {
    test("returns a safe report with no changes", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);

      const result = await manager.getDiffStats();
      expect(result.passed).toBe(true);
      expect(result.violations).toHaveLength(0);
    });
  });

  describe("hasCommitsAhead()", () => {
    test("returns false when branch is not ahead of base", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);

      const result = await manager.hasCommitsAhead("main");
      expect(result).toBe(false);
    });
  });

  describe("cleanStaleBranches()", () => {
    test("removes stale local branches and stays on base", async () => {
      const { manager } = await makeRemoteAndClone();
      await manager.ensureCloned();
      const git = (manager as unknown as { git: SimpleGit }).git!;
      await git.checkout(["-B", "main", "origin/main"]);

      // Create a stale local branch
      await git.checkoutLocalBranch("stale-branch");
      await git.checkout("main");

      await manager.cleanStaleBranches("main");

      const branches = await git.branchLocal();
      expect(branches.all).not.toContain("stale-branch");
    });
  });
});

describe("checkoutBranch()", () => {
  // The pr-comment-pipeline path: check out an existing remote PR branch and
  // diff it against the base. Guards the regression igomezyaury flagged on PR
  // #77 — the PR branch forks several commits behind the tip, so checkoutBranch
  // + the three-dot getChangedFiles must both resolve (no "origin/<branch> is
  // not a commit", no silent [] from a missing merge-base).
  test("checks out a remote PR branch forked behind the tip and getChangedFiles resolves", async () => {
    const remoteDir = makeTmpDir();
    gitExec(remoteDir, "init --bare");
    gitExec(remoteDir, "symbolic-ref HEAD refs/heads/main");

    const seedDir = makeTmpDir();
    gitExec(seedDir, "init");
    gitExec(seedDir, "symbolic-ref HEAD refs/heads/main");
    writeFileSync(join(seedDir, "base.txt"), "base");
    gitExec(seedDir, "add -A");
    gitExec(seedDir, 'commit -m "base"');

    // Fork the PR branch off this commit, then keep advancing main so the
    // fork-point sits well behind the tip.
    gitExec(seedDir, "checkout -b pr/SQDP-1");
    writeFileSync(join(seedDir, "pr.txt"), "pr change");
    gitExec(seedDir, "add -A");
    gitExec(seedDir, 'commit -m "pr work"');
    gitExec(seedDir, "checkout main");
    for (let i = 0; i < 5; i++) {
      gitExec(seedDir, `commit -q --allow-empty -m "main c${i}"`);
    }
    gitExec(seedDir, `remote add origin ${remoteDir}`);
    gitExec(seedDir, "push origin main:main");
    gitExec(seedDir, "push origin pr/SQDP-1:pr/SQDP-1");

    const localDir = makeTmpDir();
    const manager = new RepoManagerImpl(remoteDir, localDir, mockLogger());
    await manager.ensureCloned();

    await manager.checkoutBranch("pr/SQDP-1");

    const git = (manager as unknown as { git: SimpleGit }).git!;
    expect((await git.branchLocal()).current).toBe("pr/SQDP-1");
    expect(await manager.getChangedFiles("main")).toContain("pr.txt");
  });
});

describe("needsInstall()", () => {
  test("false when no package.json", async () => {
    const { manager } = await makeRemoteAndClone(); // seed has only README.md
    await manager.ensureCloned();
    expect(await manager.needsInstall()).toBe(false);
  });

  test("true when package.json present but node_modules missing", async () => {
    const { localDir, manager } = await makeRemoteAndClone();
    await manager.ensureCloned();
    writeFileSync(join(localDir, "package.json"), JSON.stringify({ name: "x", version: "1.0.0" }));
    expect(await manager.needsInstall()).toBe(true);
  });
});

describe("prewarmRepos()", () => {
  test("clones each repo and leaves it on the base branch, no ticket branch", async () => {
    const remoteDir = makeTmpDir();
    gitExec(remoteDir, "init --bare");
    gitExec(remoteDir, "symbolic-ref HEAD refs/heads/main");
    const seedDir = makeTmpDir();
    gitExec(seedDir, "init");
    gitExec(seedDir, "symbolic-ref HEAD refs/heads/main");
    writeFileSync(join(seedDir, "README.md"), "# hi");
    gitExec(seedDir, "add -A");
    gitExec(seedDir, 'commit -m "init"');
    gitExec(seedDir, `remote add origin ${remoteDir}`);
    gitExec(seedDir, "push origin main:main");

    const workdir = makeTmpDir();
    const repos = [
      { name: "repo-a", url: `file://${remoteDir}`, defaultBranch: "main", keywords: [] },
    ] as unknown as Parameters<typeof prewarmRepos>[0];

    const warmed = await prewarmRepos(repos, workdir, mockLogger());

    expect(warmed).toBe(1);
    const localDir = join(workdir, "repo-a");
    expect(existsSync(join(localDir, ".git"))).toBe(true);
    expect(gitExec(localDir, "rev-parse --abbrev-ref HEAD")).toBe("main");
  });

  test("fail-open: returns 0 and does not throw when a repo is unreachable", async () => {
    const workdir = makeTmpDir();
    const repos = [
      { name: "bad", url: "file:///nonexistent/repo.git", defaultBranch: "main", keywords: [] },
    ] as unknown as Parameters<typeof prewarmRepos>[0];

    const warmed = await prewarmRepos(repos, workdir, mockLogger());
    expect(warmed).toBe(0);
  });
});
