import { describe, expect, it, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve, basename, dirname } from 'node:path';
import {
  parseWorktreePorcelain,
  isValidBranchName,
  isSafeAbsolutePath,
  isSafeRelativePath,
  isRepo,
  listWorktrees,
  worktreeStatus,
  addWorktree,
  removeWorktree,
  pruneWorktrees,
  switchBranch,
  isDirtyWorktreeError,
  branchExists,
  currentBranch,
  listBranches,
  commitAll,
  parsePorcelainV2,
  parseNumstatZ,
  listChanges,
  fileDiff,
  stageFiles,
  unstageFiles,
  restoreFiles,
  cleanFiles,
  commitStaged,
  amendCommit,
  lastCommit,
  isUnpushed,
} from '../src/core/git.js';
import { appendFileSync } from 'node:fs';

const dirs: string[] = [];
afterEach(() => {
  for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});

function makeTmpRepo(): string {
  const dir = mkdtempSync(join(tmpdir(), 'arb-git-'));
  dirs.push(dir);
  const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
  run('init', '-b', 'main');
  run('config', 'user.email', 'test@arboretum.dev');
  run('config', 'user.name', 'Test');
  writeFileSync(join(dir, 'README.md'), '# test\n');
  run('add', '-A');
  run('commit', '-m', 'init');
  return dir;
}

describe('parseWorktreePorcelain', () => {
  it('parse le bloc principal, détaché, locked et prunable', () => {
    const out = [
      'worktree /repo',
      'HEAD abc123',
      'branch refs/heads/main',
      '',
      'worktree /repo-wt-x',
      'HEAD def456',
      'detached',
      'locked reason here',
      '',
      'worktree /repo-wt-gone',
      'HEAD 000',
      'prunable gitdir file points to non-existent location',
      '', // bloc final terminé par une ligne vide
    ].join('\n');
    const wts = parseWorktreePorcelain(out);
    expect(wts).toHaveLength(3);
    expect(wts[0]).toMatchObject({ path: '/repo', branch: 'main', detached: false });
    expect(wts[1]).toMatchObject({ path: '/repo-wt-x', detached: true, locked: true, branch: null });
    expect(wts[2]).toMatchObject({ path: '/repo-wt-gone', prunable: true });
  });

  it('tolère un bloc final sans ligne vide', () => {
    const wts = parseWorktreePorcelain('worktree /a\nHEAD x\nbranch refs/heads/dev');
    expect(wts).toEqual([
      { path: '/a', head: 'x', branch: 'dev', detached: false, locked: false, prunable: false, bare: false },
    ]);
  });
});

describe('validation', () => {
  it('isValidBranchName', () => {
    expect(isValidBranchName('feature/foo-1.2')).toBe(true);
    expect(isValidBranchName('-foo')).toBe(false);
    expect(isValidBranchName('a..b')).toBe(false);
    expect(isValidBranchName('a/')).toBe(false);
    expect(isValidBranchName('x.lock')).toBe(false);
    expect(isValidBranchName('a b')).toBe(false);
  });
  it('isSafeAbsolutePath', () => {
    expect(isSafeAbsolutePath('/home/u/proj-wt-x')).toBe(true);
    expect(isSafeAbsolutePath('relative/x')).toBe(false);
    expect(isSafeAbsolutePath('/home/u/../etc')).toBe(false);
  });
});

describe('opérations git (repo tmp réel)', () => {
  it('isRepo : racine vs non-repo', async () => {
    const repo = makeTmpRepo();
    expect(await isRepo(repo)).toBe(true);
    const notRepo = mkdtempSync(join(tmpdir(), 'arb-nogit-'));
    dirs.push(notRepo);
    expect(await isRepo(notRepo)).toBe(false);
  });

  it('add → list → status (dirty) → remove (refus dirty puis force)', async () => {
    const repo = makeTmpRepo();
    const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
    dirs.push(wtPath);
    await addWorktree(repo, { path: wtPath, branch: 'feat', mode: 'create' });

    const list = await listWorktrees(repo);
    const wt = list.find((w) => resolve(w.path) === resolve(wtPath));
    expect(wt?.branch).toBe('feat');

    // worktree propre
    expect((await worktreeStatus(wtPath)).dirtyCount).toBe(0);
    // un fichier non suivi → dirty
    writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n');
    expect((await worktreeStatus(wtPath)).dirtyCount).toBeGreaterThan(0);

    // remove sans --force refusé (worktree sale)
    let dirtyErr: unknown;
    await removeWorktree(repo, wtPath, false).catch((e) => (dirtyErr = e));
    expect(dirtyErr).toBeDefined();
    expect(isDirtyWorktreeError(dirtyErr)).toBe(true);

    // remove --force réussit
    await removeWorktree(repo, wtPath, true);
    expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
  });

  it('switchBranch : create=true crée une branche, create=false bascule sur une existante', async () => {
    const repo = makeTmpRepo();
    const cur = (): string => execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim();

    await switchBranch(repo, { branch: 'feature/new', create: true });
    expect(cur()).toBe('feature/new');

    await switchBranch(repo, { branch: 'main', create: false });
    expect(cur()).toBe('main');

    // créer une branche déjà existante échoue (git refuse).
    await expect(switchBranch(repo, { branch: 'feature/new', create: true })).rejects.toBeDefined();
  });

  it('prune retire un worktree dont le dossier a disparu', async () => {
    const repo = makeTmpRepo();
    const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);
    await addWorktree(repo, { path: wtPath, branch: 'gone', mode: 'create' });
    rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main"
    await pruneWorktrees(repo);
    expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
  });
});

describe('branche : existence, liste, courante, commit', () => {
  it('branchExists / currentBranch / listBranches', async () => {
    const repo = makeTmpRepo();
    expect(await currentBranch(repo)).toBe('main');
    expect(await branchExists(repo, 'main')).toEqual({ local: true, remote: false });
    expect(await branchExists(repo, 'nope')).toEqual({ local: false, remote: false });

    execFileSync('git', ['branch', 'dev'], { cwd: repo });
    const b = await listBranches(repo);
    expect(b.local).toContain('main');
    expect(b.local).toContain('dev');
    expect(b.remote).toEqual([]);
  });

  it('commitAll : add -A + commit, fait baisser dirtyCount à 0', async () => {
    const repo = makeTmpRepo();
    writeFileSync(join(repo, 'new.txt'), 'hello\n');
    expect((await worktreeStatus(repo)).dirtyCount).toBeGreaterThan(0);
    await commitAll(repo, 'add new.txt');
    expect((await worktreeStatus(repo)).dirtyCount).toBe(0);
  });
});

describe('P7 · parseurs purs (status v2 / numstat)', () => {
  it('parsePorcelainV2 : modifié, untracked, renommé', () => {
    const z =
      '1 .M N... 100644 100644 100644 h1 h2 file.txt\x00' +
      '1 A. N... 000000 100644 100644 0000 h3 added.txt\x00' +
      '? new.txt\x00' +
      '2 R. N... 100644 100644 100644 h4 h5 R100 newname.txt\x00oldname.txt\x00';
    const e = parsePorcelainV2(z);
    expect(e).toHaveLength(4);
    expect(e[0]).toMatchObject({ path: 'file.txt', indexStatus: '.', worktreeStatus: 'M', staged: false, unstaged: true });
    expect(e[1]).toMatchObject({ path: 'added.txt', indexStatus: 'A', staged: true, unstaged: false });
    expect(e[2]).toMatchObject({ path: 'new.txt', untracked: true, unstaged: true, staged: false });
    expect(e[3]).toMatchObject({ path: 'newname.txt', renamedFrom: 'oldname.txt', staged: true });
  });

  it('parseNumstatZ : normal, binaire, renommé', () => {
    const z = '5\t2\tfile.txt\x00-\t-\timg.png\x003\t1\t\x00old.txt\x00new.txt\x00';
    const m = parseNumstatZ(z);
    expect(m.get('file.txt')).toEqual({ insertions: 5, deletions: 2, binary: false });
    expect(m.get('img.png')).toEqual({ insertions: null, deletions: null, binary: true });
    expect(m.get('new.txt')).toEqual({ insertions: 3, deletions: 1, binary: false });
  });

  it('isSafeRelativePath', () => {
    expect(isSafeRelativePath('src/a.ts')).toBe(true);
    expect(isSafeRelativePath('/abs')).toBe(false);
    expect(isSafeRelativePath('../escape')).toBe(false);
    expect(isSafeRelativePath('-flag')).toBe(false);
    expect(isSafeRelativePath('.git/config')).toBe(false);
    expect(isSafeRelativePath('')).toBe(false);
  });
});

describe('P7 · changes / diff / staging / commit sélectif (repo tmp)', () => {
  it('listChanges : untracked + modifié non indexé + indexé', async () => {
    const repo = makeTmpRepo();
    writeFileSync(join(repo, 'untracked.txt'), 'x\n');
    appendFileSync(join(repo, 'README.md'), 'more\n');
    writeFileSync(join(repo, 'staged.txt'), 'y\n');
    await stageFiles(repo, ['staged.txt']);

    const { changes } = await listChanges(repo);
    const byPath = Object.fromEntries(changes.map((c) => [c.path, c]));
    expect(byPath['untracked.txt']).toMatchObject({ untracked: true, staged: false });
    expect(byPath['README.md']).toMatchObject({ unstaged: true, staged: false });
    expect(byPath['staged.txt']).toMatchObject({ staged: true });
    expect(byPath['staged.txt'].insertions).toBeGreaterThan(0);
  });

  it('fileDiff : fichier suivi modifié, refus binaire implicite, untracked via --no-index', async () => {
    const repo = makeTmpRepo();
    appendFileSync(join(repo, 'README.md'), 'a new line\n');
    const d = await fileDiff(repo, 'README.md');
    expect(d.binary).toBe(false);
    expect(d.diff).toContain('+a new line');

    writeFileSync(join(repo, 'fresh.txt'), 'brand new\n');
    const u = await fileDiff(repo, 'fresh.txt', { untracked: true });
    expect(u.diff).toContain('+brand new');
  });

  it('stage / unstage / restore / clean', async () => {
    const repo = makeTmpRepo();
    appendFileSync(join(repo, 'README.md'), 'edit\n');
    await stageFiles(repo, ['README.md']);
    expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(true);
    await unstageFiles(repo, ['README.md']);
    expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(false);
    await restoreFiles(repo, ['README.md']);
    expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')).toBeUndefined();

    writeFileSync(join(repo, 'junk.txt'), 'junk\n');
    await cleanFiles(repo, ['junk.txt']);
    expect((await listChanges(repo)).changes.some((c) => c.path === 'junk.txt')).toBe(false);
  });

  it('commitStaged : ne commite QUE l’index', async () => {
    const repo = makeTmpRepo();
    writeFileSync(join(repo, 'a.txt'), 'a\n');
    writeFileSync(join(repo, 'b.txt'), 'b\n');
    await stageFiles(repo, ['a.txt']);
    await commitStaged(repo, 'add a only');
    const { changes } = await listChanges(repo);
    // a.txt committé (disparu), b.txt toujours non suivi.
    expect(changes.some((c) => c.path === 'a.txt')).toBe(false);
    expect(changes.some((c) => c.path === 'b.txt' && c.untracked)).toBe(true);
    expect((await lastCommit(repo))?.subject).toBe('add a only');
  });

  it('amendCommit + isUnpushed : HEAD local non poussé', async () => {
    const repo = makeTmpRepo();
    expect(await isUnpushed(repo)).toBe(true);
    await amendCommit(repo, 'init (amended)');
    expect((await lastCommit(repo))?.subject).toBe('init (amended)');
  });

  it('worktreeStatus : compteurs détaillés + dernier commit', async () => {
    const repo = makeTmpRepo();
    writeFileSync(join(repo, 'staged.txt'), 's\n');
    await stageFiles(repo, ['staged.txt']);
    appendFileSync(join(repo, 'README.md'), 'unstaged\n');
    const st = await worktreeStatus(repo);
    expect(st.stagedCount).toBeGreaterThanOrEqual(1);
    expect(st.unstagedCount).toBeGreaterThanOrEqual(1);
    expect(st.conflictCount).toBe(0);
    expect(st.lastCommitSubject).toBe('init');
    expect(st.lastCommitHash).toBeTruthy();
  });
});

describe('addWorktree : résolution auto (créer / réutiliser)', () => {
  it('auto crée la branche si absente, la réutilise si présente', async () => {
    const repo = makeTmpRepo();
    // branche absente → création (renvoie "created")
    const wt1 = join(dirname(repo), `${basename(repo)}-wt-feat`);
    dirs.push(wt1);
    expect(await addWorktree(repo, { path: wt1, branch: 'feat', mode: 'auto' })).toBe('created');
    expect((await listWorktrees(repo)).find((w) => resolve(w.path) === resolve(wt1))?.branch).toBe('feat');

    // la même branche existe désormais ; un AUTRE worktree dessus → checkout (renvoie "reused")
    await removeWorktree(repo, wt1, true); // libère la branche
    const wt2 = join(dirname(repo), `${basename(repo)}-wt-feat2`);
    dirs.push(wt2);
    expect(await addWorktree(repo, { path: wt2, branch: 'feat', mode: 'auto' })).toBe('reused');
  });

  it('mode create échoue si la branche existe déjà', async () => {
    const repo = makeTmpRepo();
    execFileSync('git', ['branch', 'dup'], { cwd: repo });
    const wt = join(dirname(repo), `${basename(repo)}-wt-dup`);
    dirs.push(wt);
    await expect(addWorktree(repo, { path: wt, branch: 'dup', mode: 'create' })).rejects.toBeDefined();
  });
});
