/**
 * fs-git.js — pure git operations behind the FS-MCP git verbs.
 *
 * No dependency on the vault, MCP transport, or mount resolution. The serve.js
 * handlers resolve the mount + ACL, fetch the push token from the vault, then
 * call these functions. Keeping the git logic here makes it directly testable
 * against real repositories (see test-fs-git.mjs).
 *
 * Each high-level function returns { error } (→ caller emits an MCP error) or
 * { result } (→ caller emits the JSON result). Fatal/unexpected git failures
 * throw and are caught by the caller.
 *
 * NON-BLOCKING: every git invocation uses async spawn, never spawnSync, so the
 * single-threaded clauth daemon keeps serving requests during a network push.
 */
import { spawn } from "child_process";
import fs from "fs";
import path from "path";

// Branches the git verbs refuse to push to directly — humans promote these.
export const FS_GIT_PROTECTED_BRANCHES = new Set(["main", "master", "production", "prod"]);

// Async git runner. opts.label replaces args in error text (to hide auth
// headers); opts.scrub redacts a substring from error detail before throwing.
export function runGitAsync(cwd, args, opts = {}) {
  return new Promise((resolve, reject) => {
    const proc = spawn("git", args, { cwd, windowsHide: true });
    let out = "", err = "";
    proc.stdout.on("data", (d) => { out += d.toString(); });
    proc.stderr.on("data", (d) => { err += d.toString(); });
    proc.on("error", reject);
    proc.on("close", (code) => {
      if (code !== 0) {
        let detail = (err || out).trim();
        if (opts.scrub) detail = detail.split(opts.scrub).join("***");
        const what = opts.label || `git ${args.join(" ")}`;
        return reject(new Error(`${what} failed${detail ? `: ${detail}` : ""}`));
      }
      resolve(out.trim());
    });
  });
}

// Push with an inline GitHub token injected as a one-shot Authorization header.
// Token is NEVER persisted to git config, NEVER placed in the remote URL, and
// is scrubbed from any error text.
export function runGitPushAuthed(cwd, token, remote, refspec, extraArgs = []) {
  const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
  const args = ["-c", `http.extraheader=AUTHORIZATION: basic ${basic}`, "push", ...extraArgs, remote, refspec];
  return runGitAsync(cwd, args, { label: `git push ${remote} ${refspec}`, scrub: basic });
}

// Detect an in-progress merge or rebase in the repo at cwd.
export async function gitInProgressState(cwd) {
  let gitDir;
  try { gitDir = await runGitAsync(cwd, ["rev-parse", "--git-dir"]); } catch { return { merge: false, rebase: false }; }
  const gd = path.isAbsolute(gitDir) ? gitDir : path.join(cwd, gitDir);
  const merge = fs.existsSync(path.join(gd, "MERGE_HEAD"));
  const rebase = fs.existsSync(path.join(gd, "rebase-merge")) || fs.existsSync(path.join(gd, "rebase-apply"));
  return { merge, rebase };
}

function normalizeRepoPath(p) {
  if (!p || typeof p !== "string") return null;
  const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
  const parts = normalized.split("/").filter(Boolean);
  if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
  return parts.join("/");
}

async function aheadBehind(repoRoot) {
  try {
    const counts = await runGitAsync(repoRoot, ["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
    const [b, a] = counts.split(/\s+/);
    return { behind: Number(b), ahead: Number(a) };
  } catch {
    return { behind: null, ahead: null };
  }
}

/** Current git state of the repo in one call. */
export async function repoStatus(repoRoot) {
  const topLevel = path.normalize(await runGitAsync(repoRoot, ["rev-parse", "--show-toplevel"]));
  const branch = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
  const head = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
  const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
  let subject = ""; try { subject = await runGitAsync(repoRoot, ["log", "-1", "--pretty=%s"]); } catch {}
  const porcelain = await runGitAsync(repoRoot, ["status", "--porcelain"]);
  const dirty = porcelain ? porcelain.split("\n").map((l) => l.trim()).filter(Boolean) : [];
  const stagedRaw = await runGitAsync(repoRoot, ["diff", "--cached", "--name-only"]);
  let upstream = null;
  try { upstream = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); } catch {}
  const { ahead, behind } = await aheadBehind(repoRoot);
  const prog = await gitInProgressState(repoRoot);
  return {
    result: {
      branch, head, head_short: headShort, subject,
      clean: dirty.length === 0,
      dirty_count: dirty.length,
      dirty_paths: dirty.slice(0, 100),
      staged_paths: stagedRaw ? stagedRaw.split("\n").filter(Boolean) : [],
      upstream, ahead, behind,
      merge_in_progress: prog.merge,
      rebase_in_progress: prog.rebase,
      repo_root: topLevel,
    },
  };
}

/** Safely switch to (or create) a branch. */
export async function useBranch(repoRoot, branch, create) {
  branch = (branch || "").trim();
  if (!branch) return { error: "branch is required" };
  const prog = await gitInProgressState(repoRoot);
  if (prog.merge || prog.rebase) return { error: "Refused: a merge or rebase is in progress. Finish or abort it before switching branches." };
  let exists = true;
  try { await runGitAsync(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); } catch { exists = false; }
  if (create && exists) return { error: `Branch already exists: ${branch}. Call again with create=false to switch to it.` };
  if (!create && !exists) return { error: `Branch does not exist: ${branch}. Call again with create=true to create it from HEAD.` };
  if (create) {
    await runGitAsync(repoRoot, ["switch", "-c", branch]);
  } else {
    try {
      await runGitAsync(repoRoot, ["switch", branch]);
    } catch (e) {
      return { error: `Cannot switch to ${branch}: ${e.message}. Commit your changes with fs_commit first — your working tree was left untouched.` };
    }
  }
  const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
  return { result: { status: "ok", branch, created: !!create, head_short: headShort } };
}

/**
 * Stage + commit (+ push by default).
 * opts: { message, paths?, push=true, remote="origin", token?, tokenError?, authorName?, authorEmail? }
 * token/tokenError are supplied by the caller after a vault lookup; commit
 * happens first, then push, so the no-token case still preserves the commit.
 */
export async function commit(repoRoot, opts = {}) {
  const message = (opts.message || "").trim();
  if (!message && !opts.dryRun) return { error: "message is required" };
  const push = opts.push !== false;
  const remote = opts.remote || "origin";

  const topLevel = path.normalize(await runGitAsync(repoRoot, ["rev-parse", "--show-toplevel"]));
  if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
    return { error: `Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})` };
  }
  const prog = await gitInProgressState(repoRoot);
  if (prog.merge || prog.rebase) return { error: "Refused: a merge or rebase is in progress. Resolve conflicts and finish it before committing." };
  const branch = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
  if (branch === "HEAD") return { error: "Refused: detached HEAD. Use fs_use_branch to get on a branch first." };

  // Optional optimistic-concurrency guard: refuse if the base moved under us.
  if (opts.expectedHead) {
    const cur = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
    if (cur !== opts.expectedHead) {
      return { error: `Base moved: expected HEAD ${opts.expectedHead} but current is ${cur}. Re-read the files and retry so you don't commit on top of a base that changed.` };
    }
  }

  // Normalize explicit paths once (used by dry-run preview and staging).
  let pathArgs = [];
  if (Array.isArray(opts.paths) && opts.paths.length > 0) {
    if (opts.paths.length > 100) return { error: "Too many paths: max 100 per commit" };
    for (const p of opts.paths) {
      const n = normalizeRepoPath(p);
      if (!n) return { error: `Invalid repo path: ${p}` };
      pathArgs.push(n);
    }
  }

  // Dry run: report what WOULD be committed/pushed without mutating anything.
  if (opts.dryRun) {
    const tail = pathArgs.length ? ["--", ...pathArgs] : [];
    // Mirror `git add -A`: tracked changes (diff vs HEAD) ∪ untracked files.
    const tracked = await runGitAsync(repoRoot, ["diff", "--name-only", "HEAD", ...tail]);
    const untracked = await runGitAsync(repoRoot, ["ls-files", "--others", "--exclude-standard", ...tail]);
    const files = [...new Set(
      [...(tracked ? tracked.split("\n") : []), ...(untracked ? untracked.split("\n") : [])]
        .map((s) => s.trim()).filter(Boolean)
    )];
    const { ahead, behind } = await aheadBehind(repoRoot);
    const isProtected = FS_GIT_PROTECTED_BRANCHES.has(branch.toLowerCase());
    return { result: { status: "dry_run", branch, would_commit: files, file_count: files.length, would_push: push && !isProtected, protected_branch: isProtected, target: push && !isProtected ? `${remote}/${branch}` : null, ahead, behind } };
  }

  // Stage
  if (pathArgs.length) {
    await runGitAsync(repoRoot, ["add", "--", ...pathArgs]);
  } else {
    await runGitAsync(repoRoot, ["add", "-A"]);
  }
  const stagedRaw = await runGitAsync(repoRoot, ["diff", "--cached", "--name-only"]);
  if (!stagedRaw) {
    const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
    return { result: { status: "nothing_to_commit", branch, head_short: headShort, message: "No changes in the requested scope; nothing was committed." } };
  }
  const committedFiles = stagedRaw.split("\n").filter(Boolean);

  await runGitAsync(repoRoot, [
    "-c", `user.name=${opts.authorName || "clauth-fs"}`,
    "-c", `user.email=${opts.authorEmail || "fs@clauth.local"}`,
    "commit", "-m", message,
  ]);
  const commitSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
  const commitShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
  const base = { branch, commit: commitSha, commit_short: commitShort, files: committedFiles };

  if (!push) {
    return { result: { status: "committed_local", ...base, pushed: false, message: `Committed ${committedFiles.length} file(s) locally as ${commitShort}.` } };
  }
  if (FS_GIT_PROTECTED_BRANCHES.has(branch.toLowerCase())) {
    return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "protected_branch", message: `Commit saved locally as ${commitShort}. Push refused: '${branch}' is protected — a human promotes it. Use fs_use_branch to move to a feature/develop branch to push directly.` } };
  }
  if (!opts.token) {
    return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "no_token", message: `Commit saved locally as ${commitShort}. Push blocked: ${opts.tokenError || "no github token available"}.` } };
  }
  const token = opts.token.trim();

  try {
    await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
  } catch (e1) {
    // Branch likely diverged — integrate then retry once.
    try { await runGitAsync(repoRoot, ["fetch", "--no-tags", remote, branch]); } catch { /* branch may not exist on remote yet */ }
    try {
      await runGitAsync(repoRoot, ["rebase", `${remote}/${branch}`]);
    } catch {
      try { await runGitAsync(repoRoot, ["rebase", "--abort"]); } catch {}
      return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "diverged", message: `Commit saved locally as ${commitShort}. Push blocked: '${branch}' diverged from ${remote} and auto-rebase hit conflicts (rebase aborted, tree restored). A human should reconcile.` } };
    }
    try {
      await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
    } catch (e2) {
      const sha2 = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
      return { result: { status: "committed_local_not_pushed", ...base, commit_short: sha2, pushed: false, push_blocked: "push_failed", message: `Commit saved and rebased locally (${sha2}) but the push still failed: ${e2.message}` } };
    }
  }

  const finalSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
  const finalShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
  const { ahead, behind } = await aheadBehind(repoRoot);
  return { result: { status: "committed_and_pushed", branch, commit: finalSha, commit_short: finalShort, files: committedFiles, pushed: true, remote, ahead, behind, message: `Committed and pushed ${committedFiles.length} file(s) to ${remote}/${branch} as ${finalShort}.` } };
}

/**
 * Unified diff of the working tree (or index) — self-verify before pushing, or
 * compare against a remote ref to catch staleness.
 * opts: { paths?, ref?, staged? }
 *   - default:      working tree vs HEAD (everything uncommitted)
 *   - ref:"origin/x": working tree vs that ref (remote compare / staleness)
 *   - staged:true:  index vs HEAD (only what's staged)
 * Patch is capped; `truncated` flags when the cap was hit.
 */
export async function diff(repoRoot, opts = {}) {
  const MAX = 60000;
  let pathArgs = [];
  if (Array.isArray(opts.paths) && opts.paths.length > 0) {
    for (const p of opts.paths) {
      const n = normalizeRepoPath(p);
      if (!n) return { error: `Invalid repo path: ${p}` };
      pathArgs.push(n);
    }
  }
  const flags = [];
  if (opts.staged) flags.push("--cached");
  const ref = opts.ref || (opts.staged ? null : "HEAD");
  if (ref) flags.push(ref);
  const tail = pathArgs.length ? ["--", ...pathArgs] : [];

  // Validate ref up front so a typo yields a clean error, not a raw git failure.
  if (ref && ref !== "HEAD") {
    try { await runGitAsync(repoRoot, ["rev-parse", "--verify", "--quiet", ref + "^{commit}"]); }
    catch { return { error: `Unknown ref: ${opts.ref} (try fetching it first, e.g. origin/<branch>)` }; }
  }

  const stat = await runGitAsync(repoRoot, ["diff", ...flags, "--stat", ...tail]);
  let patch = await runGitAsync(repoRoot, ["diff", ...flags, ...tail]);
  let truncated = false;
  if (patch.length > MAX) { patch = patch.slice(0, MAX) + "\n... (diff truncated at 60KB)"; truncated = true; }
  return { result: { compared_to: ref || "index", staged: !!opts.staged, stat: stat || "(no differences)", patch: patch || "(no differences)", truncated } };
}
