/**
 * @file Detects whether the dashboard git checkout is behind the canonical
 * remote default branch (origin/master or origin/main on a
 * direct clone) after a non-destructive fetch. Branch- and fork-aware:
 * picks the right remote, recognises feature-branch checkouts, and shapes
 * manual_command so it actually closes the gap for the user's situation.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");

const DEFAULT_ROOT = path.join(__dirname, "..", "..");

// This build tracks its OWN repository only: origin points at
// git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor. "upstream" is
// deliberately absent from the priority list — a stray upstream remote must
// never make the update checker report commits from somebody else's repo.
const REMOTE_PRIORITY = ["origin"];

// Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.) that
// leak from a parent git hook process — e.g. this repo's own pre-commit
// hook, which runs `npm run test:server` and therefore this module too.
// Without this, every git call below silently targets the OUTER repo (the
// hook's) instead of `cwd`, since GIT_DIR takes precedence over cwd-based
// discovery. Same scrub `server/lib/worktree.js` already applies.
const GIT_ENV = { ...process.env };
delete GIT_ENV.GIT_DIR;
delete GIT_ENV.GIT_WORK_TREE;
delete GIT_ENV.GIT_INDEX_FILE;
delete GIT_ENV.GIT_COMMON_DIR;
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete GIT_ENV.GIT_PREFIX;
delete GIT_ENV.GIT_NAMESPACE;
delete GIT_ENV.GIT_CONFIG_PARAMETERS;
for (const name of Object.keys(GIT_ENV)) {
  if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete GIT_ENV[name];
}

function execGit(cwd, args, opts = {}) {
  const timeout = opts.timeout ?? 120_000;
  return new Promise((resolve, reject) => {
    execFile(
      "git",
      args,
      { cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8", env: GIT_ENV },
      (err, stdout) => {
        if (err) reject(err);
        else resolve(String(stdout).trim());
      }
    );
  });
}

/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
 *  {@link applyUpdate} runs after fast-forwarding. */
function execNpm(cwd, args, opts = {}) {
  const timeout = opts.timeout ?? 300_000;
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
  return new Promise((resolve, reject) => {
    execFile(
      npmCmd,
      args,
      { cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
      (err, stdout, stderr) => {
        if (err) reject(new Error(stderr || err.message || String(err)));
        else resolve(String(stdout).trim());
      }
    );
  });
}

async function listRemotes(gitRoot) {
  try {
    const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
    return out
      .split(/\r?\n/)
      .map((l) => l.trim())
      .filter(Boolean);
  } catch {
    return [];
  }
}

async function pickCanonicalRemote(gitRoot) {
  const remotes = await listRemotes(gitRoot);
  for (const candidate of REMOTE_PRIORITY) {
    if (remotes.includes(candidate)) return candidate;
  }
  return remotes[0] || null;
}

async function resolveCompareRefForRemote(gitRoot, remote) {
  const tryRefs = [`${remote}/master`, `${remote}/main`];
  for (const ref of tryRefs) {
    try {
      await execGit(gitRoot, ["rev-parse", "--verify", ref], { timeout: 10_000 });
      return ref;
    } catch {
      // continue
    }
  }
  try {
    const sym = await execGit(gitRoot, ["symbolic-ref", `refs/remotes/${remote}/HEAD`], {
      timeout: 10_000,
    });
    const m = sym.match(/^refs\/remotes\/(.+)$/);
    if (m) return m[1];
  } catch {
    // ignore
  }
  return null;
}

async function getCurrentBranch(gitRoot) {
  try {
    const branch = await execGit(gitRoot, ["symbolic-ref", "--short", "HEAD"], {
      timeout: 10_000,
    });
    return branch || null;
  } catch {
    return null; // detached HEAD
  }
}

async function getBranchUpstream(gitRoot) {
  try {
    return await execGit(gitRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
      timeout: 10_000,
    });
  } catch {
    return null; // no tracking branch configured
  }
}

function stripRemotePrefix(ref) {
  // "upstream/master" -> "master"; "origin/feature/foo" -> "feature/foo"
  const idx = ref.indexOf("/");
  return idx === -1 ? ref : ref.slice(idx + 1);
}

/**
 * @param {string} [gitRoot]
 * @param {{ skipFetch?: boolean }} [options]
 * @returns {Promise<object>}
 */
async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
  const root = path.resolve(gitRoot);
  const gitDir = path.join(root, ".git");
  if (!fs.existsSync(gitDir)) {
    return {
      git_repo: false,
      update_available: false,
      repo_root: root,
      manual_command: null,
      message: "Install directory is not a git clone; check for updates manually.",
    };
  }

  const canonicalRemote = await pickCanonicalRemote(root);
  if (!canonicalRemote) {
    return {
      git_repo: true,
      update_available: false,
      repo_root: root,
      remote_ref: null,
      local_sha: null,
      remote_sha: null,
      commits_behind: 0,
      message: "No git remotes configured; automatic update check skipped.",
    };
  }

  if (!options.skipFetch) {
    try {
      await execGit(root, ["fetch", canonicalRemote, "--prune"], { timeout: 120_000 });
    } catch (err) {
      return {
        git_repo: true,
        update_available: false,
        repo_root: root,
        canonical_remote: canonicalRemote,
        fetch_error: err.message || String(err),
        message: `Could not reach ${canonicalRemote}; try again when online.`,
      };
    }
  }

  const remoteRef = await resolveCompareRefForRemote(root, canonicalRemote);
  if (!remoteRef) {
    return {
      git_repo: true,
      update_available: false,
      repo_root: root,
      canonical_remote: canonicalRemote,
      message: `Could not resolve ${canonicalRemote}/master, ${canonicalRemote}/main, or ${canonicalRemote}/HEAD.`,
    };
  }

  const currentBranch = await getCurrentBranch(root);
  const branchUpstream = await getBranchUpstream(root);
  const tracksCanonical = branchUpstream === remoteRef;

  let localSha;
  let remoteSha;
  let commitsBehind = 0;
  try {
    localSha = await execGit(root, ["rev-parse", "HEAD"], { timeout: 10_000 });
    remoteSha = await execGit(root, ["rev-parse", remoteRef], { timeout: 10_000 });
    const countStr = await execGit(root, ["rev-list", "--count", `HEAD..${remoteRef}`], {
      timeout: 30_000,
    });
    commitsBehind = Number.parseInt(countStr, 10);
    if (Number.isNaN(commitsBehind)) commitsBehind = 0;
  } catch (err) {
    return {
      git_repo: true,
      update_available: false,
      repo_root: root,
      canonical_remote: canonicalRemote,
      remote_ref: remoteRef,
      message: err.message || String(err),
    };
  }

  const updateAvailable = commitsBehind > 0;
  const isProd = process.env.NODE_ENV === "production";
  const installSteps = ["npm run setup"];
  if (isProd) installSteps.push("npm run build");

  // Branch-aware manual_command. Three situations:
  //   1. tracksCanonical: HEAD's tracked upstream IS the canonical ref. A
  //      plain `git pull --ff-only` does the right thing — typical clone on
  //      the default branch.
  //   2. Same branch *name* as canonical but different upstream (the fork
  //      case: local master tracking origin/master, canonical is
  //      upstream/master). Need to fetch the canonical remote and merge it
  //      into the local branch.
  //   3. Anything else (feature branch, detached HEAD): pulling the current
  //      branch wouldn't bring in canonical commits, so don't suggest it.
  //      Offer a fetch and let the user decide how to integrate.
  const canonicalBranchName = stripRemotePrefix(remoteRef);
  let manualParts;
  let situationNote;
  let situation;
  if (tracksCanonical) {
    situation = "tracking_canonical";
    manualParts = [`cd "${root}"`, "git pull --ff-only", ...installSteps];
    situationNote = null;
  } else if (currentBranch && currentBranch === canonicalBranchName) {
    situation = "fork_or_diverged_tracking";
    manualParts = [
      `cd "${root}"`,
      `git fetch ${canonicalRemote}`,
      `git merge --ff-only ${remoteRef}`,
      ...installSteps,
    ];
    situationNote = `You're on '${currentBranch}' tracking '${
      branchUpstream || "no upstream"
    }'. This command fast-forwards your branch from ${remoteRef} (the canonical default).`;
  } else {
    situation = currentBranch ? "feature_branch" : "detached_head";
    manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote}`];
    situationNote = currentBranch
      ? `You're on '${currentBranch}', not the canonical default branch (${remoteRef}). Fetched commits won't be pulled into your branch — rebase or merge ${remoteRef} when you're ready.`
      : `HEAD is detached. Fetched commits stay under ${remoteRef}; check out the canonical default branch when ready.`;
  }

  const manualCommand = manualParts.join(" && ");

  return {
    git_repo: true,
    update_available: updateAvailable,
    repo_root: root,
    remote_ref: remoteRef,
    canonical_remote: canonicalRemote,
    current_branch: currentBranch,
    tracking_upstream: branchUpstream,
    tracks_canonical: tracksCanonical,
    situation,
    local_sha: localSha,
    remote_sha: remoteSha,
    commits_behind: commitsBehind,
    manual_command: manualCommand,
    situation_note: situationNote,
    message: updateAvailable
      ? `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.`
      : "Your checkout includes the tip of the canonical default branch.",
  };
}

/**
 * Fast-forwards the checkout to the canonical remote and rebuilds, for the
 * "Update now" button — the one path in this dashboard that mutates the
 * working tree and restarts the process on its own initiative (everywhere
 * else, `server/routes/updates.js` only ever prints a command for the user
 * to run). Only acts when {@link getUpdatesStatus} reports a situation that
 * is actually fast-forwardable (`tracking_canonical` or
 * `fork_or_diverged_tracking`); a feature branch or detached HEAD is left
 * alone since there's no safe automatic move.
 *
 * @param {string} [gitRoot]
 * @returns {Promise<object>} the refreshed status, plus `applied` (and
 *   `reason` when `applied` is false).
 */
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
  const status = await getUpdatesStatus(gitRoot);
  if (!status.update_available) {
    return { ...status, applied: false, reason: "up_to_date" };
  }
  if (
    status.situation !== "tracking_canonical" &&
    status.situation !== "fork_or_diverged_tracking"
  ) {
    return { ...status, applied: false, reason: "not_fast_forwardable" };
  }

  const root = path.resolve(gitRoot);
  if (status.situation === "tracking_canonical") {
    await execGit(root, ["pull", "--ff-only"], { timeout: 120_000 });
  } else {
    await execGit(root, ["fetch", status.canonical_remote], { timeout: 120_000 });
    await execGit(root, ["merge", "--ff-only", status.remote_ref], { timeout: 30_000 });
  }

  await execNpm(root, ["run", "setup"]);
  if (process.env.NODE_ENV === "production") {
    await execNpm(root, ["run", "build"]);
  }

  const refreshed = await getUpdatesStatus(root, { skipFetch: true });
  return { ...refreshed, applied: true };
}

module.exports = { getUpdatesStatus, applyUpdate, DEFAULT_ROOT };
