import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mergeFileChanges, parseNumstat, parseStatus, readWorkspaceChanges, readWorkspaceDiff } from "./workspace-diff.js";

test("parses git porcelain status", () => {
  assert.deepEqual(parseStatus(" M README.md\nA  src/new.js\n?? notes.txt\n"), [
    { path: "README.md", status: "modified" },
    { path: "src/new.js", status: "added" },
    { path: "notes.txt", status: "untracked" }
  ]);
});

test("adds line counts to changed files", () => {
  const files = parseStatus(" M README.md\n?? notes.txt\n");
  const counts = parseNumstat("3\t1\tREADME.md\n");

  assert.deepEqual(mergeFileChanges(files, counts), [
    { path: "README.md", status: "modified", additions: 3, deletions: 1 },
    { path: "notes.txt", status: "untracked", additions: 0, deletions: 0 }
  ]);
});

test("rejects unsafe diff paths before running git", async () => {
  const calls = [];
  for (const unsafePath of ["..\\secret.txt", "../secret.txt", "C:\\Users\\person\\secret.txt", "/tmp/secret.txt"]) {
    await assert.rejects(
      readWorkspaceDiff("C:\\Project\\Codex-remote", unsafePath, async (...input) => {
        calls.push(input);
        return "";
      }),
      (error) => error.message === "invalid_file_path" && error.status === 400
    );
  }
  assert.deepEqual(calls, []);
});

test("keeps normal workspace-relative file diff requests", async () => {
  const calls = [];
  const result = await readWorkspaceDiff("C:\\Project\\Codex-remote", "src/app.js", async (_cwd, args) => {
    calls.push(args);
    if (args[0] === "status") return " M src/app.js\n";
    if (args.includes("--numstat")) return "2\t1\tsrc/app.js\n";
    return "diff --git a/src/app.js b/src/app.js\n";
  });

  assert.equal(result.path, "src/app.js");
  assert.match(result.diff, /diff --git/);
  assert.deepEqual(calls.at(-1), ["diff", "HEAD", "--no-ext-diff", "--", "src/app.js"]);
});

test("renders untracked files as a new-file diff", async () => {
  const cwd = await mkdtemp(join(tmpdir(), "code-remote-manager-diff-"));
  try {
    await writeFile(join(cwd, "notes.txt"), "first\nsecond\n", "utf8");
    const result = await readWorkspaceDiff(cwd, "notes.txt", async (_cwd, args) => {
      if (args[0] === "status") return "?? notes.txt\n";
      if (args.includes("--numstat")) return "";
      return "";
    });

    assert.equal(result.path, "notes.txt");
    assert.match(result.diff, /new file mode 100644/);
    assert.match(result.diff, /\+first\n\+second/);
  } finally {
    await rm(cwd, { recursive: true, force: true });
  }
});

test("returns clear reasons instead of reading binary or oversized untracked files", async () => {
  const cwd = await mkdtemp(join(tmpdir(), "code-remote-manager-diff-limits-"));
  try {
    await writeFile(join(cwd, "binary.dat"), Buffer.from([0, 1, 2, 3]));
    await writeFile(join(cwd, "large.txt"), "x".repeat(600 * 1024), "utf8");
    const gitRunner = async (_cwd, args) => {
      if (args[0] === "status") return "?? binary.dat\n?? large.txt\n";
      return "";
    };

    assert.deepEqual(await readWorkspaceDiff(cwd, "binary.dat", gitRunner), {
      path: "binary.dat",
      unavailableReason: "binary_file"
    });
    assert.deepEqual(await readWorkspaceDiff(cwd, "large.txt", gitRunner), {
      path: "large.txt",
      unavailableReason: "diff_too_large"
    });
  } finally {
    await rm(cwd, { recursive: true, force: true });
  }
});

test("bounds tracked diffs and identifies Git binary output", async () => {
  const status = " M src/large.js\n M image.png\n";
  const gitRunner = async (_cwd, args) => {
    if (args[0] === "status") return status;
    if (args.includes("--numstat")) return "1\t0\tsrc/large.js\n-\t-\timage.png\n";
    if (args.at(-1) === "src/large.js") return `diff --git a/src/large.js b/src/large.js\n+${"x".repeat(600 * 1024)}`;
    return "Binary files a/image.png and b/image.png differ\n";
  };

  assert.deepEqual(await readWorkspaceDiff("C:\\Project\\Codex-remote", "src/large.js", gitRunner), {
    path: "src/large.js",
    unavailableReason: "diff_too_large"
  });
  assert.deepEqual(await readWorkspaceDiff("C:\\Project\\Codex-remote", "image.png", gitRunner), {
    path: "image.png",
    unavailableReason: "binary_file"
  });
});

test("hides changed files whose actual filesystem location leaves the workspace", async () => {
  const result = await readWorkspaceChanges("C:\\Project\\Codex-remote", async (_cwd, args) => {
    if (args[0] === "status") return " M README.md\n M linked\\secret.txt\n";
    if (args.includes("--numstat")) return "1\t0\tREADME.md\n2\t0\tlinked\\secret.txt\n";
    return "";
  }, fakeFileSystem({
    "C:\\Project\\Codex-remote": "C:\\Project\\Codex-remote",
    "C:\\Project\\Codex-remote\\README.md": "C:\\Project\\Codex-remote\\README.md",
    "C:\\Project\\Codex-remote\\linked": "C:\\Outside\\linked",
    "C:\\Project\\Codex-remote\\linked\\secret.txt": "C:\\Outside\\linked\\secret.txt"
  }));

  assert.deepEqual(result.files, [
    { path: "README.md", status: "modified", additions: 1, deletions: 0 }
  ]);
});

test("rejects diff requests whose actual filesystem location leaves the workspace", async () => {
  const calls = [];
  await assert.rejects(
    readWorkspaceDiff("C:\\Project\\Codex-remote", "linked\\secret.txt", async (...input) => {
      calls.push(input);
      return "";
    }, fakeFileSystem({
      "C:\\Project\\Codex-remote": "C:\\Project\\Codex-remote",
      "C:\\Project\\Codex-remote\\linked": "C:\\Outside\\linked",
      "C:\\Project\\Codex-remote\\linked\\secret.txt": "C:\\Outside\\linked\\secret.txt"
    })),
    (error) => error.message === "invalid_file_path" && error.status === 400
  );
  assert.deepEqual(calls, []);
});

function fakeFileSystem(realpaths) {
  const normalized = new Map(Object.entries(realpaths).map(([path, realpath]) => [
    path.toLowerCase(),
    realpath
  ]));
  return {
    existsSync(path) {
      return normalized.has(path.toLowerCase());
    },
    realpathSync(path) {
      const realpath = normalized.get(path.toLowerCase());
      if (!realpath) throw new Error("ENOENT");
      return realpath;
    }
  };
}
