/**
 * GitFlow Git Operations — Thin async wrappers around git commands.
 * Self-contained: only uses node built-ins.
 */

import { execFile } from 'child_process';
import type { AheadBehind, GitStatus } from './types.js';

export interface ExecGitResult {
  stdout: string;
  stderr: string;
  exitCode: number;
}

export function execGit(args: string[], cwd?: string): Promise<ExecGitResult> {
  return new Promise((resolve) => {
    execFile('git', args, {
      cwd,
      encoding: 'utf-8',
      timeout: 30_000,
      maxBuffer: 10 * 1024 * 1024,
    }, (error, stdout, stderr) => {
      resolve({
        // trimEnd() only — NEVER trim(). `git status --porcelain` (and other
        // column-significant output) encodes meaning in the FIRST character of
        // each line (porcelain XY columns: X=index/staged, Y=worktree). A full
        // trim() eats the leading space of the first line → the first entry is
        // misread as staged and its path (slice(3)) loses a char.
        stdout: (stdout || '').trimEnd(),
        stderr: (stderr || '').trim(),
        exitCode: error ? (typeof (error as NodeJS.ErrnoException).code === 'number' ? (error as NodeJS.ErrnoException).code as unknown as number : 1) : 0,
      });
    });
  });
}

export async function execGitOrFail(args: string[], cwd?: string): Promise<string> {
  const result = await execGit(args, cwd);
  if (result.exitCode !== 0) {
    throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`);
  }
  return result.stdout;
}

export async function getCurrentBranch(cwd?: string): Promise<string> {
  return (await execGit(['branch', '--show-current'], cwd)).stdout || 'HEAD';
}

export async function fetch(remote = 'origin', cwd?: string): Promise<ExecGitResult> {
  return execGit(['fetch', remote, '--quiet'], cwd);
}

export async function fetchAll(cwd?: string): Promise<ExecGitResult> {
  return execGit(['fetch', '--all', '--quiet'], cwd);
}

export async function push(branch: string, cwd?: string, setUpstream = false, force = false): Promise<ExecGitResult> {
  const args = ['push'];
  if (setUpstream) args.push('-u');
  if (force) args.push('--force-with-lease');
  args.push('origin', branch);
  return execGit(args, cwd);
}

/**
 * Ahead/behind of `branch` vs `remote` (e.g. develop vs origin/develop).
 *
 * Exit-code aware — the OLD version ignored it, so a failed `rev-list` (bad ref,
 * git error, or a worktree git wrongly treats as bare) silently parsed ''→0 and
 * reported `0 ahead / 0 behind` = "in sync": the false positive behind the
 * stale-base bugs. Now: both refs valid → real counts; `remote` missing (branch
 * never pushed) → all commits ahead / 0 behind (so callers push); any OTHER
 * failure → throw (never a fake "in sync").
 */
export async function getAheadBehind(branch: string, remote: string, cwd?: string): Promise<AheadBehind> {
  const aheadResult = await execGit(['rev-list', '--count', `${remote}..${branch}`], cwd);
  const behindResult = await execGit(['rev-list', '--count', `${branch}..${remote}`], cwd);
  if (aheadResult.exitCode === 0 && behindResult.exitCode === 0) {
    return {
      ahead: parseInt(aheadResult.stdout, 10) || 0,
      behind: parseInt(behindResult.stdout, 10) || 0,
    };
  }
  // A missing `remote` ref (branch never pushed) is legitimate, not an error:
  // everything on `branch` is "ahead", nothing is "behind".
  if (!(await remoteRefExists(remote, cwd))) {
    const total = await execGit(['rev-list', '--count', branch], cwd);
    if (total.exitCode === 0) return { ahead: parseInt(total.stdout, 10) || 0, behind: 0 };
  }
  throw new Error(
    `git rev-list failed comparing '${branch}' and '${remote}': ${aheadResult.stderr || behindResult.stderr || 'unknown error'}`,
  );
}

/**
 * Parse `git status --porcelain` (v1) into staged / modified / untracked path
 * lists. Pure — feed it raw stdout. Column-aware (X = index/staged,
 * Y = worktree). Rename/copy lines (`R  old -> new`, `C  old -> new`) carry BOTH
 * paths; the meaningful one is the NEW path (after ` -> `). Without that split,
 * `slice(3)` keeps "old -> new" verbatim → a renamed migration is
 * mis-identified and the EF guard's `existsSync` skips it, so a destructive
 * `Up()` could slip through. Relies on intact XY columns — see execGit
 * (`.trimEnd`, never `.trim`, which would eat the first line's leading space).
 */
export function parsePorcelainStatus(
  stdout: string,
): { staged: string[]; modified: string[]; untracked: string[] } {
  const staged: string[] = [];
  const modified: string[] = [];
  const untracked: string[] = [];

  for (const line of stdout.split('\n')) {
    if (line.length < 4) continue; // XY + space + ≥1 path char
    const index = line[0];
    const working = line[1];
    let file = line.slice(3);

    // Rename/copy: keep the worktree path that actually exists (after the arrow).
    if (index === 'R' || index === 'C' || working === 'R' || working === 'C') {
      const arrow = file.indexOf(' -> ');
      if (arrow !== -1) file = file.slice(arrow + 4);
    }

    if (index === '?') {
      untracked.push(file);
    } else {
      if (index !== ' ' && index !== '?') staged.push(file);
      if (working !== ' ' && working !== '?') modified.push(file);
    }
  }

  return { staged, modified, untracked };
}

export async function getStatus(cwd?: string): Promise<GitStatus> {
  const result = await execGit(['status', '--porcelain'], cwd);
  // Exit-code aware: a FAILED `git status` (e.g. a worktree git treats as bare —
  // "fatal: this operation must be run in a work tree", exit 128) yields EMPTY
  // stdout. The old code read that as `dirty:false` → a false "clean tree".
  // Surface the failure so callers never act on a phantom-clean working tree.
  if (result.exitCode !== 0) {
    throw new Error(`git status failed${cwd ? ` in '${cwd}'` : ''}: ${result.stderr || 'cannot read the working tree'}`);
  }
  const { staged, modified, untracked } = parsePorcelainStatus(result.stdout);
  const dirty = result.stdout.split('\n').filter(Boolean).length > 0;
  return { staged, modified, untracked, dirty };
}

/**
 * Count staged vs unstaged entries from `git status --porcelain` output.
 *
 * Parses by COLUMN (porcelain v1: X = index/staged, Y = worktree/unstaged), so
 * it is robust to combined states (`MM` = staged + unstaged, `AM`, …). Untracked
 * (`??`) counts as neither. Relies on the leading XY columns being intact — see
 * execGit (`.trimEnd`, never `.trim`, which would eat the first line's space).
 */
export function countWorkingTree(porcelain: string): { staged: number; unstaged: number } {
  let staged = 0;
  let unstaged = 0;
  for (const line of porcelain.split('\n')) {
    if (line.length < 2) continue;
    const x = line[0]; // index (staged) column
    const y = line[1]; // worktree (unstaged) column
    if (x === '?' && y === '?') continue; // untracked → neither
    if (x !== ' ' && x !== '?') staged++;
    if (y !== ' ' && y !== '?') unstaged++;
  }
  return { staged, unstaged };
}

export async function getLog(count: number, format?: string, cwd?: string): Promise<string[]> {
  const fmt = format || '%H|%aI|%s';
  const result = await execGit(['log', `--format=${fmt}`, `-${count}`], cwd);
  return result.stdout.split('\n').filter(Boolean);
}

export async function createTag(name: string, message: string, cwd?: string): Promise<void> {
  await execGitOrFail(['tag', '-a', name, '-m', message], cwd);
}

export async function pushTag(name: string, cwd?: string): Promise<void> {
  await execGitOrFail(['push', 'origin', name], cwd);
}

export async function tagExists(name: string, cwd?: string): Promise<boolean> {
  return (await execGit(['rev-parse', '-q', '--verify', `refs/tags/${name}`], cwd)).exitCode === 0;
}

/** True when `path` is a tracked file (so it can be safely `git add`ed/committed). */
export async function isTracked(path: string, cwd?: string): Promise<boolean> {
  return (await execGit(['ls-files', '--error-unmatch', path], cwd)).exitCode === 0;
}

/**
 * Annotated tag pointing at a SPECIFIC ref (e.g. `origin/main`) — worktree-safe,
 * no checkout required. Used by finish to tag the released main commit.
 */
export async function createTagAt(name: string, ref: string, message: string, cwd?: string): Promise<void> {
  await execGitOrFail(['tag', '-a', '-m', message, name, ref], cwd);
}

/**
 * Merge a ref (e.g. `origin/main`) with `--no-ff`. Non-throwing variant of
 * mergeNoFf: returns `{ ok:false, conflicts }` instead of throwing so the caller
 * can abort cleanly. Used by finish for the main → develop merge-back.
 */
export async function mergeRefNoFf(ref: string, message: string, cwd?: string): Promise<{ ok: boolean; conflicts: string[] }> {
  const result = await execGit(['merge', ref, '--no-ff', '-m', message], cwd);
  if (result.exitCode === 0) return { ok: true, conflicts: [] };
  const status = await execGit(['diff', '--name-only', '--diff-filter=U'], cwd);
  return { ok: false, conflicts: status.stdout.split('\n').filter(Boolean) };
}

export async function rebase(onto: string, cwd?: string): Promise<{ success: boolean; conflicts: string[] }> {
  const result = await execGit(['rebase', onto], cwd);
  if (result.exitCode === 0) {
    return { success: true, conflicts: [] };
  }
  const status = await execGit(['diff', '--name-only', '--diff-filter=U'], cwd);
  return { success: false, conflicts: status.stdout.split('\n').filter(Boolean) };
}

export async function abortRebase(cwd?: string): Promise<ExecGitResult> {
  return execGit(['rebase', '--abort'], cwd);
}

export async function abortMerge(cwd?: string): Promise<ExecGitResult> {
  return execGit(['merge', '--abort'], cwd);
}

export async function mergeNoFf(branch: string, message: string, cwd?: string): Promise<void> {
  await execGitOrFail(['merge', branch, '--no-ff', '-m', message], cwd);
}

export async function checkout(branch: string, cwd?: string): Promise<void> {
  await execGitOrFail(['checkout', branch], cwd);
}

/** Create AND check out a new branch from a base in one operation: `git checkout -b <branch> <base>`. */
export async function checkoutNew(branch: string, base: string, cwd?: string): Promise<void> {
  await execGitOrFail(['checkout', '-b', branch, base], cwd);
}

export async function pull(remote = 'origin', branch?: string, cwd?: string): Promise<ExecGitResult> {
  const args = ['pull', remote];
  if (branch) args.push(branch);
  return execGit(args, cwd);
}

export async function stash(cwd?: string): Promise<ExecGitResult> {
  return execGit(['stash'], cwd);
}

export async function stashPop(cwd?: string): Promise<ExecGitResult> {
  return execGit(['stash', 'pop'], cwd);
}

export async function branchExists(branch: string, remote = false, cwd?: string): Promise<boolean> {
  if (remote) {
    const result = await execGit(['ls-remote', '--heads', 'origin', branch], cwd);
    return result.stdout.length > 0;
  }
  const result = await execGit(['rev-parse', '--verify', branch], cwd);
  return result.exitCode === 0;
}

export async function isMerged(branch: string, into: string, cwd?: string): Promise<boolean> {
  // 1) Remote-tracking branches: origin/<branch> appears in the merged list.
  const result = await execGit(['branch', '-r', '--merged', `origin/${into}`], cwd);
  if (result.stdout.includes(`origin/${branch}`)) return true;

  // 2) Robust fallback for a LOCAL-only (but already-merged) branch — e.g. a
  //    release merged via the provider PR yet never pushed to origin, so step 1
  //    can't see it. If the branch tip is an ancestor of origin/<into>, every
  //    one of its commits is already in <into> ⇒ merged. `merge-base
  //    --is-ancestor` exits 0 = ancestor, 1 = not, ≠0/1 on a missing ref →
  //    treated as "not merged" (never a false positive). execGit returns the
  //    exit code without throwing, so a non-zero status is safe to read here.
  const ancestor = await execGit(['merge-base', '--is-ancestor', branch, `origin/${into}`], cwd);
  return ancestor.exitCode === 0;
}

export async function getRemoteUrl(cwd?: string): Promise<string | null> {
  const result = await execGit(['remote', 'get-url', 'origin'], cwd);
  return result.exitCode === 0 ? result.stdout : null;
}

export async function detectInProgressOp(cwd?: string): Promise<'rebase' | 'merge' | 'cherry-pick' | null> {
  const gitDir = (await execGit(['rev-parse', '--git-dir'], cwd)).stdout;
  if (!gitDir) return null;

  const { existsSync } = await import('fs');
  const { join, isAbsolute } = await import('path');
  // `--git-dir` is absolute in a linked worktree, relative (".git") at the root.
  const base = isAbsolute(gitDir) ? gitDir : join(cwd ?? process.cwd(), gitDir);

  if (existsSync(join(base, 'rebase-merge')) || existsSync(join(base, 'rebase-apply'))) return 'rebase';
  if (existsSync(join(base, 'MERGE_HEAD'))) return 'merge';
  if (existsSync(join(base, 'CHERRY_PICK_HEAD'))) return 'cherry-pick';
  return null;
}

export async function isGitRepo(cwd?: string): Promise<boolean> {
  return (await execGit(['rev-parse', '--git-dir'], cwd)).exitCode === 0;
}

/**
 * True only when `cwd` is a usable (non-bare) work tree. Distinguishes a real
 * working tree from a linked worktree git wrongly treats as bare (core.bare=true
 * inherited via extensions.worktreeConfig with no per-worktree config.worktree) —
 * the condition that makes status/merge/checkout fail with "must be run in a
 * work tree" while the files are present on disk.
 */
export async function isInsideWorkTree(cwd?: string): Promise<boolean> {
  const r = await execGit(['rev-parse', '--is-inside-work-tree'], cwd);
  return r.exitCode === 0 && r.stdout.trim() === 'true';
}

/** True when a ref (e.g. `origin/develop`) resolves. Quiet, exit-code only. */
export async function remoteRefExists(ref: string, cwd?: string): Promise<boolean> {
  return (await execGit(['rev-parse', '--verify', '--quiet', ref], cwd)).exitCode === 0;
}

/** Fast-forward the checked-out branch to `ref` (e.g. origin/develop). Non-throwing. */
export async function fastForwardOnly(ref: string, cwd?: string): Promise<ExecGitResult> {
  return execGit(['merge', '--ff-only', ref], cwd);
}

export async function getGitCommonDir(cwd?: string): Promise<string | null> {
  const result = await execGit(['rev-parse', '--git-common-dir'], cwd);
  return result.exitCode === 0 ? result.stdout : null;
}

export async function getDiffStats(base: string, cwd?: string): Promise<string> {
  return (await execGit(['diff', '--stat', base], cwd)).stdout;
}

export async function getDiffFiles(base: string, cwd?: string): Promise<string[]> {
  const result = await execGit(['diff', '--name-only', base], cwd);
  return result.stdout.split('\n').filter(Boolean);
}

export async function addFiles(files: string[], cwd?: string): Promise<void> {
  if (files.length === 0) return;
  await execGitOrFail(['add', ...files], cwd);
}

export async function commit(message: string, cwd?: string): Promise<ExecGitResult> {
  return execGit(['commit', '-m', message], cwd);
}

export async function deleteBranch(branch: string, remote = false, force = false, cwd?: string): Promise<ExecGitResult> {
  if (remote) {
    return execGit(['push', 'origin', '--delete', branch], cwd);
  }
  return execGit(['branch', force ? '-D' : '-d', branch], cwd);
}
