import { execFile } from "node:child_process";
import { open } from "node:fs/promises";
import { isAbsolute, normalize } from "node:path";
import { promisify } from "node:util";
import { MAX_PUBLIC_DIFF_BYTES, projectPublicDiff } from "./public-diff.js";
import { safeWorkspaceRelativePath } from "./safe-change-paths.js";

const execFileAsync = promisify(execFile);

export async function readWorkspaceChanges(cwd, gitRunner = git, fileSystem) {
  try {
    const [statusOutput, numstatOutput] = await Promise.all([
      gitRunner(cwd, ["status", "--porcelain=v1", "--untracked-files=all"]),
      gitDiff(cwd, ["--numstat"], gitRunner)
    ]);
    const files = mergeFileChanges(
      safeWorkspaceFiles(parseStatus(statusOutput), cwd, fileSystem),
      parseNumstat(numstatOutput)
    );
    return { available: true, files };
  } catch (error) {
    if (/not a git repository/i.test(`${error.stderr ?? ""}${error.message ?? ""}`)) {
      return { available: false, reason: "not_git_repository", files: [] };
    }
    throw error;
  }
}

export async function readWorkspaceDiff(cwd, filePath, gitRunner = git, fileSystem) {
  assertWorkspaceRelativePath(filePath);
  if (!safeWorkspaceRelativePath(filePath, cwd, cwd, fileSystem)) {
    throw invalidFilePath();
  }
  const changes = await readWorkspaceChanges(cwd, gitRunner);
  const file = changes.files.find((item) => item.path === filePath);
  if (!file) {
    const error = new Error("changed_file_not_found");
    error.status = 404;
    throw error;
  }
  if (file.status === "untracked") {
    return { path: filePath, ...await untrackedFileDiff(cwd, filePath) };
  }
  return { path: filePath, ...projectPublicDiff(await gitDiff(cwd, ["--", filePath], gitRunner)) };
}

export function parseStatus(output) {
  return output.split(/\r?\n/).filter(Boolean).map((line) => {
    const code = line.slice(0, 2);
    const path = line.slice(3).replace(/^"|"$/g, "");
    return { path, status: statusName(code) };
  });
}

export function parseNumstat(output) {
  return new Map(output.split(/\r?\n/).filter(Boolean).map((line) => {
    const [additions, deletions, path] = line.split("\t");
    return [path, {
      additions: additions === "-" ? 0 : Number(additions),
      deletions: deletions === "-" ? 0 : Number(deletions)
    }];
  }));
}

export function mergeFileChanges(files, counts) {
  return files.map((file) => ({
    ...file,
    additions: counts.get(file.path)?.additions ?? 0,
    deletions: counts.get(file.path)?.deletions ?? 0
  }));
}

function safeWorkspaceFiles(files, cwd, fileSystem) {
  return files.filter((file) => safeWorkspaceRelativePath(file.path, cwd, cwd, fileSystem));
}

function statusName(code) {
  if (code === "??") return "untracked";
  if (code.includes("A")) return "added";
  if (code.includes("D")) return "deleted";
  if (code.includes("R")) return "renamed";
  return "modified";
}

function assertWorkspaceRelativePath(filePath) {
  if (typeof filePath !== "string" || filePath.length === 0 || isAbsolute(filePath)) {
    throw invalidFilePath();
  }
  const normalized = normalize(filePath);
  if (normalized === ".." || normalized.startsWith(`..\\`) || normalized.startsWith("../")) {
    throw invalidFilePath();
  }
}

function invalidFilePath() {
  const error = new Error("invalid_file_path");
  error.status = 400;
  return error;
}

async function gitDiff(cwd, args, gitRunner = git) {
  try {
    return await gitRunner(cwd, ["diff", "HEAD", "--no-ext-diff", ...args]);
  } catch (error) {
    if (/unknown revision|bad revision|ambiguous argument 'HEAD'/i.test(`${error.stderr ?? ""}`)) {
      return gitRunner(cwd, ["diff", "--no-ext-diff", ...args]);
    }
    throw error;
  }
}

async function git(cwd, args) {
  const { stdout } = await execFileAsync("git", args, {
    cwd,
    encoding: "utf8",
    maxBuffer: 10 * 1024 * 1024,
    windowsHide: true
  });
  return stdout;
}

async function untrackedFileDiff(cwd, filePath) {
  const target = resolveSafe(cwd, filePath);
  const source = await readBoundedFile(target);
  if (typeof source.diff !== "string") return source;
  const text = source.diff;
  const lines = text.split(/\r?\n/);
  if (lines.at(-1) === "") lines.pop();
  return projectPublicDiff([
    `diff --git a/${filePath} b/${filePath}`,
    "new file mode 100644",
    "--- /dev/null",
    `+++ b/${filePath}`,
    `@@ -0,0 +1,${lines.length} @@`,
    ...lines.map((line) => `+${line}`)
  ].join("\n"));
}

async function readBoundedFile(path) {
  const handle = await open(path, "r");
  try {
    if ((await handle.stat()).size > MAX_PUBLIC_DIFF_BYTES) return { unavailableReason: "diff_too_large" };
    const buffer = Buffer.alloc(MAX_PUBLIC_DIFF_BYTES + 1);
    const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
    if (bytesRead > MAX_PUBLIC_DIFF_BYTES) return { unavailableReason: "diff_too_large" };
    return projectPublicDiff(buffer.subarray(0, bytesRead));
  } finally {
    await handle.close();
  }
}

function resolveSafe(cwd, filePath) {
  return normalize(`${cwd}\\${filePath}`);
}
