import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { realpath } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
  parseNameStatus,
  splitDiffByFile,
  classifyDiffBody,
  autoSelectMode,
  detectInProgress,
  resolveCommittedBase,
  canCommit,
  getGitDiff,
} from '../git_diff.js';
import type { GitDiffFile } from '../git_diff.js';

const execFileAsync = promisify(execFile);

// ── T1: parseNameStatus ────────────────────────────────────────────────────

describe('parseNameStatus', () => {
  it('parses modified file', () => {
    const files = parseNameStatus('M\tsrc/foo.ts\n');
    expect(files).toHaveLength(1);
    expect(files[0]).toMatchObject({ path: 'src/foo.ts', change_type: 'modified', old_path: null });
  });

  it('parses added file', () => {
    const files = parseNameStatus('A\tnewfile.ts\n');
    expect(files).toHaveLength(1);
    expect(files[0]).toMatchObject({ path: 'newfile.ts', change_type: 'added' });
  });

  it('parses deleted file', () => {
    const files = parseNameStatus('D\tremoved.ts\n');
    expect(files).toHaveLength(1);
    expect(files[0]).toMatchObject({ path: 'removed.ts', change_type: 'deleted' });
  });

  it('parses renamed file with similarity score', () => {
    const files = parseNameStatus('R100\told.ts\tnew.ts\n');
    expect(files).toHaveLength(1);
    expect(files[0]).toMatchObject({ path: 'new.ts', old_path: 'old.ts', change_type: 'renamed' });
  });

  it('parses type-changed file as modified', () => {
    const files = parseNameStatus('T\tsymlink.ts\n');
    expect(files).toHaveLength(1);
    expect(files[0]).toMatchObject({ path: 'symlink.ts', change_type: 'modified' });
  });

  it('parses multiple files from multiline output', () => {
    const output = 'M\ta.ts\nA\tb.ts\nD\tc.ts\n';
    const files = parseNameStatus(output);
    expect(files).toHaveLength(3);
    expect(files.map((f) => f.change_type)).toEqual(['modified', 'added', 'deleted']);
  });

  it('ignores blank lines', () => {
    const files = parseNameStatus('\n\nM\ta.ts\n\n');
    expect(files).toHaveLength(1);
  });

  it('returns empty array for empty input', () => {
    expect(parseNameStatus('')).toHaveLength(0);
    expect(parseNameStatus('\n')).toHaveLength(0);
  });
});

// ── T2: splitDiffByFile ────────────────────────────────────────────────────

describe('splitDiffByFile', () => {
  const FIXTURE = `diff --git a/src/a.ts b/src/a.ts
index abc1234..def5678 100644
--- a/src/a.ts
+++ b/src/a.ts
@@ -1,3 +1,4 @@
 context
-old line
+new line
 more context
diff --git a/src/b.ts b/src/b.ts
index 1111111..2222222 100644
--- a/src/b.ts
+++ b/src/b.ts
@@ -10,2 +10,3 @@
 ctx
+added
`;

  it('splits two-file diff into two entries', () => {
    const map = splitDiffByFile(FIXTURE);
    expect(map.size).toBe(2);
    expect(map.has('src/a.ts')).toBe(true);
    expect(map.has('src/b.ts')).toBe(true);
  });

  it('each segment starts with diff --git header', () => {
    const map = splitDiffByFile(FIXTURE);
    expect(map.get('src/a.ts')).toMatch(/^diff --git a\/src\/a\.ts/);
    expect(map.get('src/b.ts')).toMatch(/^diff --git a\/src\/b\.ts/);
  });

  it('handles deleted file (no +++ b/ line)', () => {
    const deleted = `diff --git a/gone.ts b/gone.ts
deleted file mode 100644
--- a/gone.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-line1
-line2
`;
    const map = splitDiffByFile(deleted);
    expect(map.size).toBe(1);
    expect(map.has('gone.ts')).toBe(true);
  });

  it('returns empty map for empty input', () => {
    expect(splitDiffByFile('').size).toBe(0);
    expect(splitDiffByFile('\n').size).toBe(0);
  });
});

// ── T3: resolveCommittedBase (integration with temp git repo) ──────────────

describe('resolveCommittedBase', () => {
  let tmp: string;

  beforeAll(async () => {
    tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gitdiff-base-')));
    await execFileAsync('git', ['init'], { cwd: tmp });
    await execFileAsync('git', ['-c', 'user.email=test@test.com', '-c', 'user.name=Test',
      'commit', '--allow-empty', '-m', 'init'], { cwd: tmp });
  });

  afterAll(async () => {
    await rm(tmp, { recursive: true, force: true });
  });

  it('returns null base when no upstream and no origin', async () => {
    const { base, label } = await resolveCommittedBase(tmp);
    expect(base).toBeNull();
    expect(label).toBeTruthy(); // still has a descriptive label
  });
});

// ── T4: autoSelectMode ────────────────────────────────────────────────────

describe('autoSelectMode', () => {
  it('returns uncommitted when dirty', () => {
    expect(autoSelectMode(true)).toBe('uncommitted');
  });

  it('returns committed when clean', () => {
    expect(autoSelectMode(false)).toBe('committed');
  });
});

// ── T5: classifyDiffBody ──────────────────────────────────────────────────

describe('classifyDiffBody', () => {
  it('classifies a normal diff as diff', () => {
    const body = `diff --git a/foo b/foo
--- a/foo
+++ b/foo
@@ -1 +1 @@
-old
+new
`;
    expect(classifyDiffBody(body)).toBe('diff');
  });

  it('classifies binary diff as binary', () => {
    const body = `diff --git a/image.png b/image.png
index abc..def 100644
Binary files a/image.png and b/image.png differ
`;
    expect(classifyDiffBody(body)).toBe('binary');
  });

  it('classifies oversized diff as too_large', () => {
    const big = 'a'.repeat(600 * 1024); // 600KB > default cap
    expect(classifyDiffBody(big)).toBe('too_large');
  });

  it('respects custom cap', () => {
    const body = 'a'.repeat(100);
    expect(classifyDiffBody(body, 50)).toBe('too_large');
    expect(classifyDiffBody(body, 200)).toBe('diff');
  });
});

// ── T6: detectInProgress ──────────────────────────────────────────────────

describe('detectInProgress', () => {
  let tmp: string;

  beforeAll(async () => {
    tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-inprogress-')));
    await mkdir(join(tmp, '.git'));
  });

  afterAll(async () => {
    await rm(tmp, { recursive: true, force: true });
  });

  it('returns null when no sentinel files present', async () => {
    expect(await detectInProgress(tmp)).toBeNull();
  });

  it('detects merge via MERGE_HEAD', async () => {
    await writeFile(join(tmp, '.git', 'MERGE_HEAD'), 'abc');
    expect(await detectInProgress(tmp)).toBe('merge');
    await rm(join(tmp, '.git', 'MERGE_HEAD'));
  });

  it('detects cherry-pick via CHERRY_PICK_HEAD', async () => {
    await writeFile(join(tmp, '.git', 'CHERRY_PICK_HEAD'), 'abc');
    expect(await detectInProgress(tmp)).toBe('cherry-pick');
    await rm(join(tmp, '.git', 'CHERRY_PICK_HEAD'));
  });

  it('detects bisect via BISECT_LOG', async () => {
    await writeFile(join(tmp, '.git', 'BISECT_LOG'), 'abc');
    expect(await detectInProgress(tmp)).toBe('bisect');
    await rm(join(tmp, '.git', 'BISECT_LOG'));
  });

  it('detects rebase via rebase-merge directory', async () => {
    await mkdir(join(tmp, '.git', 'rebase-merge'));
    expect(await detectInProgress(tmp)).toBe('rebase');
    await rm(join(tmp, '.git', 'rebase-merge'), { recursive: true });
  });

  it('detects rebase via rebase-apply directory', async () => {
    await mkdir(join(tmp, '.git', 'rebase-apply'));
    expect(await detectInProgress(tmp)).toBe('rebase');
    await rm(join(tmp, '.git', 'rebase-apply'), { recursive: true });
  });
});

// ── T7: canCommit ─────────────────────────────────────────────────────────

describe('canCommit', () => {
  const stagedFile: GitDiffFile = {
    path: 'a.ts',
    old_path: null,
    change_type: 'modified',
    added_lines: 1,
    removed_lines: 0,
    staged: true,
    diff_body: '+new',
    is_binary: false,
    is_too_large: false,
  };
  const unstagedFile: GitDiffFile = { ...stagedFile, staged: false };

  it('returns true when at least one file is staged', () => {
    expect(canCommit([stagedFile, unstagedFile])).toBe(true);
  });

  it('returns false when no files are staged', () => {
    expect(canCommit([unstagedFile])).toBe(false);
    expect(canCommit([])).toBe(false);
  });
});

// ── T8: getGitDiff integration test ───────────────────────────────────────

describe('getGitDiff integration (temp repo)', () => {
  let tmp: string;

  beforeAll(async () => {
    tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gitdiff-int-')));

    // Init repo + initial commit
    await execFileAsync('git', ['init'], { cwd: tmp });
    await execFileAsync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmp });
    await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: tmp });
    await writeFile(join(tmp, 'existing.ts'), 'const x = 1;\n');
    await execFileAsync('git', ['add', '.'], { cwd: tmp });
    await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });

    // Modify existing file (unstaged)
    await writeFile(join(tmp, 'existing.ts'), 'const x = 2;\n');
    // Add new untracked file
    await writeFile(join(tmp, 'untracked.ts'), 'export {};\n');
    // Stage a new file
    await writeFile(join(tmp, 'staged.ts'), 'export const y = 1;\n');
    await execFileAsync('git', ['add', 'staged.ts'], { cwd: tmp });
  });

  afterAll(async () => {
    await rm(tmp, { recursive: true, force: true });
  });

  it('getGitDiff returns git_repo true for a git repo', async () => {
    const result = await getGitDiff(tmp, 'uncommitted');
    expect(result).not.toBeNull();
    expect(result!.mode).toBe('uncommitted');
  });

  it('includes modified file in uncommitted mode', async () => {
    const result = await getGitDiff(tmp, 'uncommitted');
    const paths = result!.files.map((f: GitDiffFile) => f.path);
    expect(paths).toContain('existing.ts');
  });

  it('includes staged file with staged=true', async () => {
    const result = await getGitDiff(tmp, 'uncommitted');
    const staged = result!.files.find((f: GitDiffFile) => f.path === 'staged.ts');
    expect(staged).toBeDefined();
    expect(staged!.staged).toBe(true);
    expect(staged!.change_type).toBe('added');
  });

  it('includes untracked file with change_type=untracked', async () => {
    const result = await getGitDiff(tmp, 'uncommitted');
    const untracked = result!.files.find((f: GitDiffFile) => f.path === 'untracked.ts');
    expect(untracked).toBeDefined();
    expect(untracked!.change_type).toBe('untracked');
  });

  it('returns null for a non-git directory', async () => {
    const nonGit = await realpath(await mkdtemp(join(tmpdir(), 'boocode-nongit-')));
    try {
      const result = await getGitDiff(nonGit, 'uncommitted');
      expect(result).toBeNull();
    } finally {
      await rm(nonGit, { recursive: true, force: true });
    }
  });

  it('returns in_progress_op when MERGE_HEAD exists', async () => {
    await writeFile(join(tmp, '.git', 'MERGE_HEAD'), 'abc\n');
    try {
      const result = await getGitDiff(tmp, 'uncommitted');
      expect(result!.in_progress_op).toBe('merge');
    } finally {
      await rm(join(tmp, '.git', 'MERGE_HEAD'));
    }
  });
});
