// Covers git commit helper behavior in fake repositories.
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";

const tempDirs = createTrackedTempDirs();

async function makeTempDir(label: string): Promise<string> {
  return await tempDirs.make(`openclaw-${label}-`);
}

async function makeFakeGitRepo(
  root: string,
  options: {
    head: string;
    packedRefs?: Record<string, string>;
    refs?: Record<string, string>;
    gitdir?: string;
    commondir?: string;
  },
) {
  await fs.mkdir(root, { recursive: true });
  const gitdir = options.gitdir ?? path.join(root, ".git");
  if (options.gitdir) {
    await fs.writeFile(path.join(root, ".git"), `gitdir: ${options.gitdir}\n`, "utf-8");
  } else {
    await fs.mkdir(gitdir, { recursive: true });
  }
  await fs.mkdir(gitdir, { recursive: true });
  await fs.writeFile(path.join(gitdir, "HEAD"), options.head, "utf-8");
  const refsBase = options.commondir ? path.resolve(gitdir, options.commondir) : gitdir;
  await fs.mkdir(refsBase, { recursive: true });
  if (options.commondir) {
    await fs.writeFile(path.join(gitdir, "commondir"), options.commondir, "utf-8");
  }
  for (const [refPath, commit] of Object.entries(options.refs ?? {})) {
    const targetPath = path.join(refsBase, refPath);
    await fs.mkdir(path.dirname(targetPath), { recursive: true });
    await fs.writeFile(targetPath, `${commit}\n`, "utf-8");
  }
  const packedRefsEntries = Object.entries(options.packedRefs ?? {});
  if (packedRefsEntries.length > 0) {
    const packedRefsContents = [
      "# pack-refs with: peeled fully-peeled sorted",
      ...packedRefsEntries.map(([refPath, commit]) => `${commit} ${refPath}`),
    ].join("\n");
    await fs.writeFile(path.join(refsBase, "packed-refs"), `${packedRefsContents}\n`, "utf-8");
  }
}

async function makeFakeOpenClawPackage(root: string) {
  await fs.mkdir(path.join(root, "src"), { recursive: true });
  await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
}

function limitPositionalReads(maxBytes: number) {
  const realReadSync = fsSync.readSync.bind(fsSync);
  let totalBytesRead = 0;
  vi.spyOn(fsSync, "readSync").mockImplementation(((
    fd: number,
    buffer: NodeJS.ArrayBufferView,
    offset: number,
    length: number,
    position: number | null,
  ) => {
    const bytesRead = realReadSync(fd, buffer, offset, Math.min(length, maxBytes), position);
    totalBytesRead += bytesRead;
    return bytesRead;
  }) as typeof fsSync.readSync);
  return () => totalBytesRead;
}

describe("git commit resolution", () => {
  let resolveCommitHash: (typeof import("./git-commit.js"))["resolveCommitHash"];

  beforeEach(async () => {
    vi.restoreAllMocks();
    vi.doUnmock("node:fs");
    vi.doUnmock("node:module");
    vi.resetModules();
    ({ resolveCommitHash } = await import("./git-commit.js"));
  });

  afterEach(async () => {
    vi.restoreAllMocks();
    vi.doUnmock("node:fs");
    vi.doUnmock("node:module");
    await tempDirs.cleanup();
  });

  it("resolves commit metadata from the caller module root instead of the caller cwd", async () => {
    const temp = await makeTempDir("git-commit-cwd");
    const repoRoot = path.join(temp, "openclaw");
    const repoCommit = "abcdef0123456789abcdef0123456789abcdef01";
    await makeFakeGitRepo(repoRoot, { head: `${repoCommit}\n` });
    await makeFakeOpenClawPackage(repoRoot);

    const otherRepo = path.join(temp, "other");
    const otherCommit = "1234567890abcdef1234567890abcdef12345678";
    await makeFakeGitRepo(otherRepo, { head: otherCommit });

    const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href;
    vi.spyOn(process, "cwd").mockReturnValue(otherRepo);

    expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).toBe(repoCommit.slice(0, 7));
    expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).not.toBe(otherCommit.slice(0, 7));
  });

  it("prefers live git metadata over stale build info in a package checkout", async () => {
    const temp = await makeTempDir("git-commit-live-checkout");
    const repoRoot = path.join(temp, "openclaw");
    const repoCommit = "abcdef0123456789abcdef0123456789abcdef01";
    await makeFakeGitRepo(repoRoot, { head: `${repoCommit}\n` });
    await makeFakeOpenClawPackage(repoRoot);
    const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href;

    expect(
      resolveCommitHash({
        moduleUrl: entryModuleUrl,
        env: {},
        readers: {
          readBuildInfoCommit: () => "deadbee",
        },
      }),
    ).toBe(repoCommit.slice(0, 7));
  });

  it("caches build-info fallback results per resolved search directory", async () => {
    const temp = await makeTempDir("git-commit-build-info-cache");
    const readBuildInfoCommit = vi.fn(() => "deadbee");

    expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe(
      "deadbee",
    );
    const firstCallRequires = readBuildInfoCommit.mock.calls.length;
    expect(firstCallRequires).toBeGreaterThan(0);
    expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe(
      "deadbee",
    );
    expect(readBuildInfoCommit.mock.calls.length).toBe(firstCallRequires);
  });

  it("caches package.json fallback results per resolved search directory", async () => {
    const temp = await makeTempDir("git-commit-package-json-cache");
    const readPackageJsonCommit = vi.fn(() => "badc0ff");

    expect(
      resolveCommitHash({
        cwd: temp,
        env: {},
        readers: {
          readBuildInfoCommit: () => null,
          readPackageJsonCommit,
        },
      }),
    ).toBe("badc0ff");
    const firstCallRequires = readPackageJsonCommit.mock.calls.length;
    expect(firstCallRequires).toBeGreaterThan(0);
    expect(
      resolveCommitHash({
        cwd: temp,
        env: {},
        readers: {
          readBuildInfoCommit: () => null,
          readPackageJsonCommit,
        },
      }),
    ).toBe("badc0ff");
    expect(readPackageJsonCommit.mock.calls.length).toBe(firstCallRequires);
  });

  it("treats invalid moduleUrl inputs as a fallback hint instead of throwing", async () => {
    const temp = await makeTempDir("git-commit-invalid-module-url");
    const repoRoot = path.join(temp, "openclaw");
    const repoCommit = "abcdef0123456789abcdef0123456789abcdef01";
    await makeFakeGitRepo(repoRoot, { head: `${repoCommit}\n` });
    await makeFakeOpenClawPackage(repoRoot);

    expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} })).toBe(
      repoCommit.slice(0, 7),
    );
  });

  it("does not walk out of the openclaw package into a host repo", async () => {
    const temp = await makeTempDir("git-commit-package-boundary");
    const hostRepo = path.join(temp, "host");
    await makeFakeGitRepo(hostRepo, { head: "abcdef1234567890abcdef1234567890abcdef12" });

    const packageRoot = path.join(hostRepo, "node_modules", "openclaw");
    await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
    await fs.writeFile(
      path.join(packageRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.3.10" }),
      "utf-8",
    );
    const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href;

    expect(
      resolveCommitHash({
        moduleUrl,
        cwd: packageRoot,
        env: {},
        readers: {
          readBuildInfoCommit: () => "feedfac",
          readPackageJsonCommit: () => "badc0ff",
        },
      }),
    ).toBe("feedfac");
  });

  it("caches git lookups per resolved search directory", async () => {
    const temp = await makeTempDir("git-commit-cache");
    const repoA = path.join(temp, "repo-a");
    const repoB = path.join(temp, "repo-b");
    await makeFakeGitRepo(repoA, {
      head: "0123456789abcdef0123456789abcdef01234567\n",
    });
    await makeFakeGitRepo(repoB, {
      head: "89abcdef0123456789abcdef0123456789abcdef\n",
    });

    expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456");
    expect(resolveCommitHash({ cwd: repoB, env: {} })).toBe("89abcde");
    expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456");
  });

  it("reads packed refs after short commondir reads in worktree-style checkouts", async () => {
    const temp = await makeTempDir("git-commit-packed-refs");
    const checkoutRoot = path.join(temp, "checkout");
    const commonGitDir = path.join(temp, "git-common");
    const worktreeGitDir = path.join(commonGitDir, "worktrees", "checkout");

    await makeFakeGitRepo(checkoutRoot, {
      gitdir: worktreeGitDir,
      commondir: "../..",
      head: "ref: refs/heads/main\n",
      packedRefs: {
        "refs/heads/main": "0123456789abcdef0123456789abcdef01234567",
      },
    });
    limitPositionalReads(4);

    expect(resolveCommitHash({ cwd: checkoutRoot, env: {} })).toBe("0123456");
  });

  it("caches deterministic null results per resolved search directory", async () => {
    const temp = await makeTempDir("git-commit-null-cache");
    const repoRootEntry = path.join(temp, "repo");
    await makeFakeGitRepo(repoRootEntry, {
      head: "not-a-commit\n",
    });

    const readGitCommit = vi.fn(() => null);

    expect(
      resolveCommitHash({ cwd: repoRootEntry, env: {}, readers: { readGitCommit } }),
    ).toBeNull();
    const firstCallReads = readGitCommit.mock.calls.length;
    expect(firstCallReads).toBeGreaterThan(0);
    expect(
      resolveCommitHash({ cwd: repoRootEntry, env: {}, readers: { readGitCommit } }),
    ).toBeNull();
    expect(readGitCommit.mock.calls.length).toBe(firstCallReads);
  });

  it("caches caught null fallback results per resolved search directory", async () => {
    const temp = await makeTempDir("git-commit-caught-null-cache");
    const repoRootResult = path.join(temp, "repo");
    await makeFakeGitRepo(repoRootResult, {
      head: "0123456789abcdef0123456789abcdef01234567\n",
    });
    const readGitCommit = vi.fn(() => {
      const error = Object.assign(new Error(`EACCES: permission denied`), {
        code: "EACCES",
      });
      throw error;
    });

    expect(
      resolveCommitHash({
        cwd: repoRootResult,
        env: {},
        readers: {
          readGitCommit,
          readBuildInfoCommit: () => null,
          readPackageJsonCommit: () => null,
        },
      }),
    ).toBeNull();
    const firstCallReads = readGitCommit.mock.calls.length;
    expect(firstCallReads).toBe(2);
    expect(
      resolveCommitHash({
        cwd: repoRootResult,
        env: {},
        readers: {
          readGitCommit,
          readBuildInfoCommit: () => null,
          readPackageJsonCommit: () => null,
        },
      }),
    ).toBeNull();
    expect(readGitCommit.mock.calls.length).toBe(firstCallReads);
  });

  it.each([
    { name: "successful", result: "abcdef0" },
    { name: "failed", result: null },
  ])("bounds $name git probe entries with LRU eviction", async ({ result }) => {
    const temp = await makeTempDir(`git-commit-${result ? "success" : "failure"}-lru`);
    const coldDir = path.join(temp, "cold");
    const hotDir = path.join(temp, "hot");
    const newestDir = path.join(temp, "newest");
    const readGitCommit = vi.fn((_searchDir: string, _packageRoot: string | null) => result);
    const resolve = (cwd: string) =>
      resolveCommitHash({
        cwd,
        env: {},
        readers: {
          readGitCommit,
          readBuildInfoCommit: () => null,
          readPackageJsonCommit: () => null,
        },
      });
    const callsFor = (cwd: string) =>
      readGitCommit.mock.calls.filter(([searchDir]) => searchDir === cwd).length;

    expect(resolve(coldDir)).toBe(result);
    expect(resolve(hotDir)).toBe(result);
    for (let index = 0; index < 254; index += 1) {
      expect(resolve(path.join(temp, `filler-${index}`))).toBe(result);
    }

    expect(resolve(hotDir)).toBe(result);
    expect(resolve(newestDir)).toBe(result);
    expect(resolve(newestDir)).toBe(result);
    expect(resolve(hotDir)).toBe(result);
    expect(resolve(coldDir)).toBe(result);

    expect(callsFor(newestDir)).toBe(1);
    expect(callsFor(hotDir)).toBe(1);
    expect(callsFor(coldDir)).toBe(2);
  });

  it("formats env-provided commit strings consistently", async () => {
    const temp = await makeTempDir("git-commit-env");
    expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "ABCDEF0123456789" } })).toBe(
      "abcdef0",
    );
    expect(
      resolveCommitHash({ cwd: temp, env: { GIT_SHA: "commit abcdef0123456789 dirty" } }),
    ).toBe("abcdef0");
    expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "not-a-sha" } })).toBeNull();
    expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "" } })).toBeNull();
  });

  it("rejects unsafe HEAD refs and accepts valid refs", async () => {
    const temp = await makeTempDir("git-commit-refs");
    const absoluteRepo = path.join(temp, "absolute");
    await makeFakeGitRepo(absoluteRepo, { head: "ref: /tmp/evil\n" });
    expect(resolveCommitHash({ cwd: absoluteRepo, env: {} })).toBeNull();

    const traversalRepo = path.join(temp, "traversal");
    await makeFakeGitRepo(traversalRepo, { head: "ref: refs/heads/../evil\n" });
    expect(resolveCommitHash({ cwd: traversalRepo, env: {} })).toBeNull();

    const invalidPrefixRepo = path.join(temp, "invalid-prefix");
    await makeFakeGitRepo(invalidPrefixRepo, { head: "ref: heads/main\n" });
    expect(resolveCommitHash({ cwd: invalidPrefixRepo, env: {} })).toBeNull();

    const validRepo = path.join(temp, "valid");
    await makeFakeGitRepo(validRepo, {
      head: "ref: refs/heads/main\n",
      refs: {
        "refs/heads/main": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      },
    });
    expect(resolveCommitHash({ cwd: validRepo, env: {} })).toBe("aaaaaaa");
  });

  it("resolves refs from the git commondir in worktree layouts", async () => {
    const temp = await makeTempDir("git-commit-worktree");
    const repoRootValue = path.join(temp, "repo");
    const worktreeGitDir = path.join(temp, "worktree-git");
    const commonGitDir = path.join(temp, "common-git");
    await fs.mkdir(commonGitDir, { recursive: true });
    const refPath = path.join(commonGitDir, "refs", "heads", "main");
    await fs.mkdir(path.dirname(refPath), { recursive: true });
    await fs.writeFile(refPath, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n", "utf-8");
    await makeFakeGitRepo(repoRootValue, {
      gitdir: worktreeGitDir,
      head: "ref: refs/heads/main\n",
      commondir: "../common-git",
    });

    expect(resolveCommitHash({ cwd: repoRootValue, env: {} })).toBe("bbbbbbb");
  });

  it("fills short positional reads for loose refs", async () => {
    const temp = await makeTempDir("git-commit-short-ref");
    const repoRoot = path.join(temp, "repo");
    await makeFakeGitRepo(repoRoot, {
      head: "ref: refs/heads/main\n",
      refs: {
        "refs/heads/main": "abcdef0123456789abcdef0123456789abcdef01",
      },
    });
    limitPositionalReads(4);

    expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("abcdef0");
  });

  it("keeps short-read retries within the bounded metadata window", async () => {
    const temp = await makeTempDir("git-commit-bounded-ref");
    const repoRoot = path.join(temp, "repo");
    await makeFakeGitRepo(repoRoot, {
      head: "ref: refs/heads/main\n",
      refs: {
        "refs/heads/main": `${"x".repeat(256)}abcdef0123456789`,
      },
    });
    const totalBytesRead = limitPositionalReads(4);

    expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull();
    expect(totalBytesRead()).toBe(256);
  });

  it("falls back to baked metadata when a bounded Git metadata read errors", async () => {
    const temp = await makeTempDir("git-commit-read-error");
    const repoRoot = path.join(temp, "repo");
    await makeFakeGitRepo(repoRoot, {
      head: "ref: refs/heads/main\n",
      refs: {
        "refs/heads/main": "abcdef0123456789abcdef0123456789abcdef01",
      },
    });
    vi.spyOn(fsSync, "readSync").mockImplementationOnce((() => {
      throw Object.assign(new Error("EIO: forced read failure"), { code: "EIO" });
    }) as typeof fsSync.readSync);

    expect(
      resolveCommitHash({
        cwd: repoRoot,
        env: {},
        readers: { readBuildInfoCommit: () => "deadbee" },
      }),
    ).toBe("deadbee");
  });

  it("reads full HEAD refs before parsing long branch names", async () => {
    const temp = await makeTempDir("git-commit-long-head");
    const repoRootLocal = path.join(temp, "repo");
    const longRefName = `refs/heads/${"segment/".repeat(40)}main`;
    await makeFakeGitRepo(repoRootLocal, {
      head: `ref: ${longRefName}\n`,
      refs: {
        [longRefName]: "cccccccccccccccccccccccccccccccccccccccc",
      },
    });

    expect(resolveCommitHash({ cwd: repoRootLocal, env: {} })).toBe("ccccccc");
  });
});
