/**
 * @file Git worktree management for lanes: creation, reset, removal, and the
 * three-check destroy guard that stands between a mis-click and a user's real
 * project directory. Every destructive function (resetWorktree, removeWorktree)
 * verifies the lane against all three safety checks before touching git.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");

const execFileAsync = promisify(execFile);

const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "ccam-lanes");
const PROTECTED_BRANCHES = new Set(["main", "master"]);

/**
 * Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
 * Treats zero exit code as success even if stderr has hints.
 *
 * CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
 * that leak from parent processes. Without this, git operations on a worktree (where
 * .git is a file, not a directory) fail with ".git/index: index file open failed:
 * Not a directory" when run from within a git hook or from a shell that inherited
 * these variables. This module's whole job is to run git safely against repos other
 * than the one enclosing the current working directory.
 */
async function git(cwd, args) {
  // Build a clean environment: copy process.env but scrub git hook variables
  // that could point to the outer repo's git directory or index.
  const env = { ...process.env };
  delete env.GIT_DIR;
  delete env.GIT_WORK_TREE;
  delete env.GIT_INDEX_FILE;
  delete env.GIT_COMMON_DIR;
  delete env.GIT_OBJECT_DIRECTORY;
  delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
  delete env.GIT_PREFIX;
  delete env.GIT_NAMESPACE;
  delete env.GIT_CONFIG_PARAMETERS;
  // GIT_CONFIG_COUNT + GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n inject arbitrary git
  // config into every invocation — including core.hooksPath, which would make an
  // untrusted repo run our git commands' hooks. GIT_CONFIG_GLOBAL/SYSTEM do the
  // same by redirecting which config files are read. All are scrubbed.
  for (const name of Object.keys(env)) {
    if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name];
  }
  // Prevent credential prompts from hanging a background provisioning job
  env.GIT_TERMINAL_PROMPT = "0";

  try {
    const result = await execFileAsync("git", args, {
      cwd,
      env,
      maxBuffer: 8 * 1024 * 1024,
    });
    return { stdout: result.stdout, stderr: result.stderr };
  } catch (err) {
    const error = new Error(`git ${args[0]} failed`);
    error.code = err.code;
    error.git = { args, code: err.code, stderr: err.stderr };
    throw error;
  }
}

/**
 * Check if a directory is a git repository.
 */
async function isGitRepo(dir) {
  try {
    await git(dir, ["rev-parse", "--git-dir"]);
    return true;
  } catch {
    return false;
  }
}

/**
 * Whether `name` is a legal git branch name, per git's own rules rather than
 * a hand-rolled regex. `cwd` need not be `sourceRepo` specifically — the
 * check is not repo-dependent — but `git()` requires a directory to run in.
 */
async function isValidBranchName(cwd, name) {
  if (typeof name !== "string" || !name) return false;
  try {
    await git(cwd, ["check-ref-format", "--branch", name]);
    return true;
  } catch {
    return false;
  }
}

/**
 * List a repo's local branches plus its current HEAD branch, so a caller can
 * offer a real picker instead of asking someone to remember a branch name.
 * Local branches only (not `origin/*` refs) — those are what `addWorktree`
 * can actually check a new worktree out onto without a fetch first.
 */
async function listBranches(sourceRepo) {
  const result = await git(sourceRepo, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
  const branches = result.stdout
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean);

  let current = null;
  try {
    const head = await git(sourceRepo, ["symbolic-ref", "--short", "HEAD"]);
    current = head.stdout.trim();
  } catch {
    // Detached HEAD: no current branch, and that's fine - the caller still
    // gets the full branch list to choose a base from.
  }

  return { branches, current };
}

/**
 * Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
 */
async function resolveBase(sourceRepo, wanted) {
  // Try origin/<wanted>
  try {
    await git(sourceRepo, ["rev-parse", "--verify", "--quiet", `origin/${wanted}`]);
    return wanted;
  } catch {
    // Fall through to next attempt
  }

  // Try <wanted>
  try {
    await git(sourceRepo, ["rev-parse", "--verify", "--quiet", wanted]);
    return wanted;
  } catch {
    // Fall through to next attempt
  }

  // Fall back to current HEAD
  const result = await git(sourceRepo, ["rev-parse", "--abbrev-ref", "HEAD"]);
  return result.stdout.trim();
}

/**
 * Slugify a title into a safe branch-name segment: lowercase, non-alphanumerics to `-`,
 * collapsed, trimmed, max 40 chars. Throws EBADSLUG if result is empty.
 */
function slugify(text) {
  const result = text
    .toLowerCase()
    .normalize("NFKD")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 40);

  if (!result) {
    const err = new Error("slug is empty");
    err.code = "EBADSLUG";
    throw err;
  }

  return result;
}

/**
 * Parse `git worktree list --porcelain` output.
 * Records are separated by blank lines, with keys like:
 * - worktree <path>
 * - branch refs/heads/<name>
 * - locked (optional, bare line)
 */
async function listWorktrees(sourceRepo) {
  const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
  const lines = result.stdout.split("\n");
  const worktrees = [];
  let current = {};

  for (const line of lines) {
    if (!line.trim()) {
      if (current.path) {
        worktrees.push(current);
        current = {};
      }
      continue;
    }

    if (line.startsWith("worktree ")) {
      current.path = line.slice("worktree ".length);
    } else if (line.startsWith("branch ")) {
      const branchPath = line.slice("branch ".length);
      // Strip refs/heads/ prefix
      current.branch = branchPath.replace(/^refs\/heads\//, "");
    } else if (line === "locked") {
      current.locked = true;
    }
  }

  if (current.path) {
    worktrees.push(current);
  }

  return worktrees;
}

/**
 * Find which worktree (if any) has a given branch checked out.
 */
async function branchCheckedOutAt(sourceRepo, branch) {
  const worktrees = await listWorktrees(sourceRepo);
  const found = worktrees.find((w) => w.branch === branch);
  return found ? found.path : null;
}

/**
 * Create a new worktree, or add an existing branch to a new worktree.
 * - If branch is already checked out elsewhere, throw EBRANCHBUSY.
 * - If branch doesn't exist, create it with -b from base.
 * - If branch exists, add without -b (reuse existing).
 */
async function addWorktree({ sourceRepo, dir, branch, base }) {
  // Check if branch is already checked out elsewhere
  const checkedOutAt = await branchCheckedOutAt(sourceRepo, branch);
  if (checkedOutAt) {
    const err = new Error(`branch ${branch} already checked out at ${checkedOutAt}`);
    err.code = "EBRANCHBUSY";
    err.checkedOutAt = checkedOutAt;
    throw err;
  }

  // Ensure parent directory exists
  fs.mkdirSync(path.dirname(dir), { recursive: true });

  // Check if branch already exists
  let branchExists = false;
  try {
    await git(sourceRepo, ["rev-parse", "--verify", "--quiet", branch]);
    branchExists = true;
  } catch {
    // Branch doesn't exist, we'll create it with -b
  }

  if (branchExists) {
    // Branch exists, use it
    await git(sourceRepo, ["worktree", "add", dir, branch]);
    return { dir, branch, created: false };
  } else {
    // Branch doesn't exist, create it from base
    await git(sourceRepo, ["worktree", "add", "-b", branch, dir, base]);
    return { dir, branch, created: true };
  }
}

/**
 * Delete a branch safely: never delete protected branches or falsy branch.
 */
async function deleteBranchSafely(sourceRepo, branch, baseBranch) {
  if (!branch) {
    return; // Branch is falsy, don't delete
  }

  if (PROTECTED_BRANCHES.has(branch)) {
    return; // Protected branch
  }

  if (baseBranch && branch === baseBranch) {
    return; // Never delete the base branch
  }

  try {
    await git(sourceRepo, ["branch", "-D", branch]);
  } catch {
    // Ignore deletion failures
  }
}

/**
 * Check 1 on its own: only a dashboard-provisioned worktree may ever be
 * destroyed. Shared with removeWorktree's prune path, which cannot run checks 2
 * and 3 as written (there is no directory left to resolve) but must still refuse
 * an adopted lane outright.
 */
function assertManaged(lane) {
  if (lane.kind !== "managed") {
    const err = new Error(`lane kind is ${lane.kind}, not managed`);
    err.code = "ENOTMANAGED";
    throw err;
  }
}

/**
 * Check 2 for a path that may no longer exist: is this lane's RECORDED cwd
 * inside LANES_ROOT on a path boundary? Purely lexical after resolving
 * LANES_ROOT itself, because a hand-deleted worktree cannot be realpath'd.
 * assertDestroyable still realpaths a live cwd, which additionally defeats
 * symlinks; this weaker form only ever gates operations that touch git
 * bookkeeping, never a directory.
 */
function isInsideLanesRoot(cwd) {
  let resolvedRoot;
  try {
    resolvedRoot = fs.realpathSync(LANES_ROOT);
  } catch {
    return false;
  }
  const relativePath = path.relative(resolvedRoot, path.resolve(cwd));
  return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}

/**
 * Three checks for whether a lane can be safely destroyed:
 * 1. kind must be "managed" (not "adopted")
 * 2. cwd must resolve to a path inside LANES_ROOT
 * 3. The path must be listed in git worktree list for the source repo
 */
async function assertDestroyable(lane) {
  // Check 1: kind must be "managed"
  assertManaged(lane);

  // Check 2: cwd must be inside LANES_ROOT on a path boundary
  let resolvedCwd;
  let resolvedRoot;
  try {
    resolvedCwd = fs.realpathSync(lane.cwd);
  } catch {
    // Path doesn't exist, which means it's not a live worktree
    const err = new Error(`lane cwd does not exist: ${lane.cwd}`);
    err.code = "EOUTSIDEROOT";
    throw err;
  }

  try {
    resolvedRoot = fs.realpathSync(LANES_ROOT);
  } catch {
    // LANES_ROOT doesn't exist, so cwd can't be inside it
    const err = new Error(`LANES_ROOT does not exist: ${LANES_ROOT}`);
    err.code = "EOUTSIDEROOT";
    throw err;
  }

  // Check that cwd is inside LANES_ROOT on a path boundary
  const relativePath = path.relative(resolvedRoot, resolvedCwd);
  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
    const err = new Error(`lane cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`);
    err.code = "EOUTSIDEROOT";
    throw err;
  }

  // Check 3: path must be in worktree list.
  // git keeps listing a hand-deleted worktree (as `prunable`), so realpath must
  // be tolerated per entry: an entry we cannot resolve is simply not this lane.
  // Throwing here failed reset/remove for every OTHER lane in the same repo with
  // an ENOENT naming an unrelated directory.
  const worktrees = await listWorktrees(lane.source_repo);
  const exists = worktrees.some((w) => {
    try {
      return fs.realpathSync(w.path) === resolvedCwd;
    } catch {
      return false;
    }
  });
  if (!exists) {
    const err = new Error(`lane is not listed as a worktree in ${lane.source_repo}`);
    err.code = "ENOTWORKTREE";
    throw err;
  }
}

/**
 * Reset a worktree to its base branch: checkout base, reset hard, clean files,
 * then reset the feature branch to the base. This uses git branch -f from a
 * separate working directory context to avoid worktree association restrictions.
 *
 * Verifies the base branch exists BEFORE any mutations, and verifies the final
 * state ends on the feature branch (not left on base or detached).
 */
async function resetWorktree(lane) {
  // Safety check
  await assertDestroyable(lane);

  const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane;

  // CRITICAL: Verify base branch exists BEFORE any mutations.
  // If base doesn't exist, we can't safely reset anything.
  try {
    await git(cwd, ["rev-parse", "--verify", baseBranch]);
  } catch {
    const err = new Error(`base branch does not exist: ${baseBranch}`);
    err.code = "ENOBASE";
    throw err;
  }

  // Fetch and prune (tolerate failure if no remote)
  try {
    await git(cwd, ["fetch", "origin", "--prune"]);
  } catch {
    // Ignore: may not have a remote
  }

  // Checkout base. Should succeed now that we've verified it exists.
  try {
    await git(cwd, ["checkout", baseBranch]);
  } catch {
    // Doesn't exist locally, try to create from remote (may fail if no remote)
    try {
      await git(cwd, ["checkout", "-b", baseBranch, `origin/${baseBranch}`]);
    } catch {
      // If both failed but rev-parse passed, the branch exists but we can't check it out
      // Try the reset anyway - it might work even if checkout failed
    }
  }

  // Reset hard to base
  await git(cwd, ["reset", "--hard", baseBranch]);

  // Clean untracked files (but NOT ignored files, so -x is omitted)
  await git(cwd, ["clean", "-fd"]);

  // Reset the feature branch. Git worktrees prevent deletion/force-update of
  // "their" branch, so we reset it in-place instead: checkout → reset hard.
  // ponytail: worktree association blocks deletion, reset-in-place instead
  try {
    // Try to checkout the feature branch
    await git(cwd, ["checkout", branch]);
    // Reset the current branch (feat/branch) to base
    await git(cwd, ["reset", "--hard", baseBranch]);
  } catch {
    // If checkout fails, the branch might not exist. Create it.
    try {
      await git(cwd, ["checkout", "-b", branch, baseBranch]);
    } catch (err) {
      // If both checkout and create failed, we're in trouble
      throw err;
    }
  }

  // CRITICAL: Verify we actually ended on the feature branch.
  // If this fails, the reset succeeded but left us on the wrong branch.
  const currentBranch = (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
  if (currentBranch !== branch) {
    const err = new Error(`reset ended on wrong branch: expected ${branch}, got ${currentBranch}`);
    err.code = "ERESETBRANCH";
    throw err;
  }

  // Try to clean up by deleting the branch from source repo (may fail if still in use)
  await deleteBranchSafely(sourceRepo, branch, baseBranch);
}

/**
 * Locate a worktree's administrative directory under the source repo's common
 * dir (`<common>/worktrees/<name>`) by matching the `gitdir` file each entry
 * points at against the worktree's cwd. That file's content is the absolute
 * path to the worktree's OWN `.git` file, so its dirname is the worktree path
 * — this still works when that `.git` file is corrupt, since we only ever
 * read it from the source repo's side. Returns null if no entry matches.
 */
async function findWorktreeAdminDir(sourceRepo, cwd) {
  const common = (await git(sourceRepo, ["rev-parse", "--git-common-dir"])).stdout.trim();
  const worktreesDir = path.join(path.resolve(sourceRepo, common), "worktrees");
  let entries;
  try {
    entries = fs.readdirSync(worktreesDir);
  } catch {
    return null;
  }
  const resolvedCwd = path.resolve(cwd);
  for (const name of entries) {
    let pointer;
    try {
      pointer = fs.readFileSync(path.join(worktreesDir, name, "gitdir"), "utf8").trim();
    } catch {
      continue;
    }
    if (path.resolve(path.dirname(pointer)) === resolvedCwd) {
      return path.join(worktreesDir, name);
    }
  }
  return null;
}

/**
 * Remove a worktree completely: unlock, remove, prune, delete branch.
 *
 * When the directory was deleted by hand there is nothing on disk to destroy,
 * but git still registers the worktree and the branch — so that case takes the
 * prune path instead of the full three checks, which cannot resolve a path that
 * no longer exists. Checks 1 and 2 still hold there (an adopted lane is refused
 * outright; the recorded cwd must still be inside LANES_ROOT), and the operation
 * touches only git bookkeeping in the source repo. Check 3 is what the prune
 * replaces: a worktree git no longer lists needs no removal at all.
 */
async function removeWorktree(lane) {
  const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane;

  if (!fs.existsSync(cwd)) {
    assertManaged(lane);
    if (!isInsideLanesRoot(cwd)) {
      const err = new Error(`lane cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`);
      err.code = "EOUTSIDEROOT";
      throw err;
    }
    // Drops git's record of the vanished worktree. A no-op when git never knew
    // it, which leaves only the branch to clean up.
    await git(sourceRepo, ["worktree", "prune"]);
    const stillListed = (await listWorktrees(sourceRepo)).some(
      (w) => path.resolve(w.path) === path.resolve(cwd)
    );
    if (stillListed) {
      await git(sourceRepo, ["worktree", "remove", "--force", cwd]);
    }
    await deleteBranchSafely(sourceRepo, branch, baseBranch);
    return;
  }

  // Safety check
  await assertDestroyable(lane);

  // Unlock (ignore failure)
  try {
    await git(sourceRepo, ["worktree", "unlock", cwd]);
  } catch {
    // Ignore
  }

  // Remove the worktree (force). Git validates the worktree's OWN `.git`
  // pointer before it will touch it, and refuses outright (even with a
  // second --force) when that pointer is corrupt — the three checks above
  // already proved this is a real, managed worktree of this repo, so fall
  // back to deregistering it directly from the source repo's bookkeeping
  // rather than leaving the lane permanently stuck. This never touches the
  // worktree directory itself — only `<sourceRepo>/.git/worktrees/<name>`.
  try {
    await git(sourceRepo, ["worktree", "remove", "--force", cwd]);
  } catch (removeErr) {
    const adminDir = await findWorktreeAdminDir(sourceRepo, cwd);
    if (!adminDir) throw removeErr;
    fs.rmSync(adminDir, { recursive: true, force: true });
  }

  // Prune dead worktree entries
  await git(sourceRepo, ["worktree", "prune"]);

  // Delete the branch safely
  await deleteBranchSafely(sourceRepo, branch, baseBranch);
}

/**
 * Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
 * Lines starting with ?? are untracked; others are dirty.
 */
async function statusCounts(dir) {
  const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
  let dirty = 0;
  let untracked = 0;

  for (const line of result.stdout.split("\n")) {
    if (!line.trim()) continue;
    if (line.startsWith("??")) {
      untracked++;
    } else {
      dirty++;
    }
  }

  // Get short commit hash
  const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
  const head = headResult.stdout.trim();

  return { dirty, untracked, head };
}

/**
 * What a lane's working copy looks like right now: which branch it is on, the
 * short HEAD, that commit's subject, and how much is uncommitted.
 *
 * Read-only and cheap, but it is three subprocesses, which is why it lives
 * behind its own endpoint rather than inside the polled `GET /api/lanes`
 * payload. A detached HEAD reports the literal `HEAD` that git returns — the
 * caller shows what git says rather than inventing a nicer word for it.
 */
async function gitFacts(dir) {
  const { dirty, untracked, head } = await statusCounts(dir);
  const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
  const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
  return { branch, head, subject, dirty, untracked };
}

/**
 * Count the commits a destructive action would actually discard.
 *
 * With remotes configured: commits on no remote (`--not --remotes HEAD`).
 *
 * With NO remotes: the commits ahead of the lane's base branch
 * (`<base>..HEAD`) — the work that belongs to this lane. Counting the whole
 * history instead made a freshly provisioned worktree in a local-only repo
 * report every commit in the repo as unpushed and demand Force to discard
 * commits a `reset --hard <base>` would never touch. The `no-remote` warning is
 * what tells the user nothing is backed up.
 *
 * Falls back to the total commit count only when there is no usable base to
 * measure against (an adopted lane has no `base_branch` at all).
 * Returns 0 if the repository is corrupt or unborn.
 *
 * @param {string} dir - Working directory to count in.
 * @param {string|null} [baseBranch] - The lane's base branch, when it has one.
 */
async function unpushedCount(dir, baseBranch = null) {
  try {
    // First check if there are any remotes
    const remotesResult = await git(dir, ["remote"]);
    const hasRemotes = !!remotesResult.stdout.trim();

    if (hasRemotes) {
      // Has remotes: count commits not on any remote
      const result = await git(dir, ["rev-list", "--count", "--not", "--remotes", "HEAD"]);
      return parseInt(result.stdout.trim(), 10);
    }

    if (baseBranch) {
      try {
        const result = await git(dir, ["rev-list", "--count", `${baseBranch}..HEAD`]);
        return parseInt(result.stdout.trim(), 10);
      } catch {
        // Base branch is gone or never existed — fall through to the total.
      }
    }

    // No remote and no usable base: every commit is at risk, so count them all.
    const result = await git(dir, ["rev-list", "--count", "HEAD"]);
    return parseInt(result.stdout.trim(), 10);
  } catch {
    // Repository error (unborn HEAD, corrupt, etc.)
    return 0;
  }
}

/**
 * Check if the repository has no remotes configured at all.
 * Returns true if `git remote` output is empty, false otherwise.
 */
async function hasNoRemotes(dir) {
  try {
    const result = await git(dir, ["remote"]);
    return !result.stdout.trim();
  } catch {
    // Assume remotes exist if we can't query
    return false;
  }
}

module.exports = {
  LANES_ROOT,
  git,
  isGitRepo,
  isValidBranchName,
  listBranches,
  resolveBase,
  slugify,
  listWorktrees,
  branchCheckedOutAt,
  addWorktree,
  assertManaged,
  isInsideLanesRoot,
  assertDestroyable,
  resetWorktree,
  removeWorktree,
  statusCounts,
  gitFacts,
  unpushedCount,
  hasNoRemotes,
};
