import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm, writeFile, mkdir, access, symlink } 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 {
  validateWritePath,
  checkSymlinkEscape,
  stageFiles,
  unstageFiles,
  commitFiles,
  discardFiles,
  deriveCommitIdentity,
  GitWriteError,
  getGitDiff,
} from '../git_diff.js';

const execFileAsync = promisify(execFile);

// ── T12: validateWritePath — pure validation ──────────────────────────────

describe('validateWritePath', () => {
  const root = '/repo/root';

  it('accepts a simple relative path', () => {
    expect(() => validateWritePath(root, 'src/foo.ts')).not.toThrow();
  });

  it('accepts a nested path', () => {
    expect(() => validateWritePath(root, 'a/b/c.ts')).not.toThrow();
  });

  it('rejects empty string', () => {
    expect(() => validateWritePath(root, '')).toThrow(GitWriteError);
  });

  it('rejects path starting with - (flag injection)', () => {
    expect(() => validateWritePath(root, '-flag')).toThrow(GitWriteError);
    expect(() => validateWritePath(root, '--option')).toThrow(GitWriteError);
  });

  it('rejects "." (repo root discard)', () => {
    expect(() => validateWritePath(root, '.')).toThrow(GitWriteError);
  });

  it('rejects absolute paths', () => {
    expect(() => validateWritePath(root, '/etc/passwd')).toThrow(GitWriteError);
    expect(() => validateWritePath(root, '/repo/root/file.ts')).toThrow(GitWriteError);
  });

  it('rejects ".." traversal escaping root', () => {
    expect(() => validateWritePath(root, '../outside/file.ts')).toThrow(GitWriteError);
    expect(() => validateWritePath(root, 'a/../../outside')).toThrow(GitWriteError);
  });

  it('rejects path resolving exactly to root', () => {
    // e.g. "a/.." resolves to /repo/root which is the root itself
    expect(() => validateWritePath(root, 'a/..')).toThrow(GitWriteError);
  });

  it('throws GitWriteError not just Error', () => {
    try {
      validateWritePath(root, '-bad');
    } catch (err) {
      expect(err).toBeInstanceOf(GitWriteError);
      expect((err as GitWriteError).busy).toBe(false);
    }
  });
});

// ── Integration tests (temp git repo) ─────────────────────────────────────

async function initRepo(dir: string) {
  await execFileAsync('git', ['init'], { cwd: dir });
  await execFileAsync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir });
  await execFileAsync('git', ['config', 'user.name', 'Test User'], { cwd: dir });
}

async function fileExists(path: string): Promise<boolean> {
  try {
    await access(path);
    return true;
  } catch {
    return false;
  }
}

// ── T9: stage / unstage round-trip ────────────────────────────────────────

describe('stageFiles / unstageFiles round-trip (temp repo)', () => {
  let tmp: string;

  beforeAll(async () => {
    tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-stage-')));
    await initRepo(tmp);
    await writeFile(join(tmp, 'initial.ts'), 'const a = 1;\n');
    await execFileAsync('git', ['add', '.'], { cwd: tmp });
    await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
  });

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

  it('staging an untracked file shows it as staged in diff', async () => {
    await writeFile(join(tmp, 'new.ts'), 'export const x = 1;\n');
    // Before staging
    const before = await getGitDiff(tmp, 'uncommitted');
    const untrackedBefore = before!.files.find((f) => f.path === 'new.ts');
    expect(untrackedBefore?.change_type).toBe('untracked');
    expect(untrackedBefore?.staged).toBe(false);

    // Stage
    await stageFiles(tmp, ['new.ts']);

    // After staging
    const after = await getGitDiff(tmp, 'uncommitted');
    const stagedAfter = after!.files.find((f) => f.path === 'new.ts');
    expect(stagedAfter?.staged).toBe(true);
    expect(stagedAfter?.change_type).toBe('added');
  });

  it('unstaging removes file from staged set', async () => {
    // new.ts is currently staged from the previous test
    await unstageFiles(tmp, ['new.ts']);
    const after = await getGitDiff(tmp, 'uncommitted');
    const f = after!.files.find((f) => f.path === 'new.ts');
    expect(f?.staged).toBe(false);
    expect(f?.change_type).toBe('untracked');
  });

  it('stageFiles rejects a path starting with -', async () => {
    await expect(stageFiles(tmp, ['-bad'])).rejects.toThrow(GitWriteError);
  });

  it('stageFiles rejects path traversal', async () => {
    await expect(stageFiles(tmp, ['../outside.ts'])).rejects.toThrow(GitWriteError);
  });
});

// ── T10: commit with server-derived identity ──────────────────────────────

describe('commitFiles with server-derived identity (temp repo)', () => {
  let tmp: string;

  beforeAll(async () => {
    tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-commit-')));
    await initRepo(tmp);
    await writeFile(join(tmp, 'base.ts'), 'export const a = 1;\n');
    await execFileAsync('git', ['add', '.'], { cwd: tmp });
    await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
  });

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

  it('deriveCommitIdentity falls back when no git config set', async () => {
    // New repo initialized without global user config — may or may not have local config.
    // The function should always return a non-empty name and email.
    const identity = await deriveCommitIdentity(tmp);
    expect(identity.name).toBeTruthy();
    expect(identity.email).toBeTruthy();
  });

  it('deriveCommitIdentity uses git config when set', async () => {
    const identity = await deriveCommitIdentity(tmp);
    // We set user.email/name in initRepo above
    expect(identity.name).toBe('Test User');
    expect(identity.email).toBe('test@test.com');
  });

  it('commit creates a new commit and the staged file is no longer in diff', async () => {
    await writeFile(join(tmp, 'newfile.ts'), 'export const b = 2;\n');
    await stageFiles(tmp, ['newfile.ts']);

    const before = await getGitDiff(tmp, 'uncommitted');
    expect(before!.files.find((f) => f.path === 'newfile.ts')).toBeDefined();

    await commitFiles(tmp, 'add newfile');

    const after = await getGitDiff(tmp, 'uncommitted');
    expect(after!.files.find((f) => f.path === 'newfile.ts')).toBeUndefined();
  });

  it('commit with specific files only commits those files', async () => {
    await writeFile(join(tmp, 'a.ts'), 'const a = 1;\n');
    await writeFile(join(tmp, 'b.ts'), 'const b = 2;\n');
    await stageFiles(tmp, ['a.ts', 'b.ts']);

    await commitFiles(tmp, 'partial commit', ['a.ts']);

    const after = await getGitDiff(tmp, 'uncommitted');
    const aFile = after!.files.find((f) => f.path === 'a.ts');
    const bFile = after!.files.find((f) => f.path === 'b.ts');
    // a.ts was committed — should not appear in uncommitted diff
    expect(aFile).toBeUndefined();
    // b.ts is still staged
    expect(bFile?.staged).toBe(true);
  });

  it('commit rejects a path starting with - in files list', async () => {
    await expect(commitFiles(tmp, 'msg', ['-bad'])).rejects.toThrow(GitWriteError);
  });
});

// ── T11: discard tracked vs untracked ─────────────────────────────────────

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

  beforeAll(async () => {
    tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-discard-')));
    await initRepo(tmp);
    await writeFile(join(tmp, 'tracked.ts'), 'const orig = 1;\n');
    await execFileAsync('git', ['add', '.'], { cwd: tmp });
    await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
  });

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

  it('discarding a modified tracked file reverts its content', async () => {
    await writeFile(join(tmp, 'tracked.ts'), 'const modified = 99;\n');
    const before = await getGitDiff(tmp, 'uncommitted');
    expect(before!.files.find((f) => f.path === 'tracked.ts')).toBeDefined();

    await discardFiles(tmp, [{ path: 'tracked.ts', change_type: 'modified', staged: false }]);

    const after = await getGitDiff(tmp, 'uncommitted');
    expect(after!.files.find((f) => f.path === 'tracked.ts')).toBeUndefined();
  });

  it('discarding an untracked file removes it from disk', async () => {
    await writeFile(join(tmp, 'untracked.ts'), 'orphan\n');
    const exists = await fileExists(join(tmp, 'untracked.ts'));
    expect(exists).toBe(true);

    await discardFiles(tmp, [{ path: 'untracked.ts', change_type: 'untracked', staged: false }]);

    expect(await fileExists(join(tmp, 'untracked.ts'))).toBe(false);
  });

  it('discarding a staged-addition file removes it from index and disk', async () => {
    await writeFile(join(tmp, 'staged-add.ts'), 'new file\n');
    await stageFiles(tmp, ['staged-add.ts']);

    const before = await getGitDiff(tmp, 'uncommitted');
    expect(before!.files.find((f) => f.path === 'staged-add.ts')?.staged).toBe(true);

    await discardFiles(tmp, [{ path: 'staged-add.ts', change_type: 'added', staged: true }]);

    const after = await getGitDiff(tmp, 'uncommitted');
    expect(after!.files.find((f) => f.path === 'staged-add.ts')).toBeUndefined();
    expect(await fileExists(join(tmp, 'staged-add.ts'))).toBe(false);
  });

  it('discardFiles rejects "." (repo root)', async () => {
    await expect(
      discardFiles(tmp, [{ path: '.', change_type: 'modified', staged: false }]),
    ).rejects.toThrow(GitWriteError);
  });

  it('discardFiles rejects path traversal', async () => {
    await expect(
      discardFiles(tmp, [{ path: '../outside', change_type: 'untracked', staged: false }]),
    ).rejects.toThrow(GitWriteError);
  });
});

// ── Index-lock → busy error ────────────────────────────────────────────────

describe('index-lock detection', () => {
  let tmp: string;

  beforeAll(async () => {
    tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-lock-')));
    await initRepo(tmp);
    await writeFile(join(tmp, 'file.ts'), 'const x = 1;\n');
    await execFileAsync('git', ['add', '.'], { cwd: tmp });
    await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
  });

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

  it('stageFiles throws GitWriteError with busy=true when index.lock exists', async () => {
    await writeFile(join(tmp, 'new.ts'), 'export {};\n');
    // Simulate a lock by creating .git/index.lock
    await mkdir(join(tmp, '.git'), { recursive: true });
    await writeFile(join(tmp, '.git', 'index.lock'), '');
    try {
      await stageFiles(tmp, ['new.ts']);
      // Should not reach here
      expect(true).toBe(false);
    } catch (err) {
      expect(err).toBeInstanceOf(GitWriteError);
      expect((err as GitWriteError).busy).toBe(true);
    } finally {
      try { await rm(join(tmp, '.git', 'index.lock')); } catch { /* already gone */ }
    }
  });
});

// ── Commit request schema: reject unknown author fields ───────────────────

describe('GitCommitBody schema strictness (unit)', () => {
  it('rejects extra author/email fields via Zod strict', () => {
    // We import Zod inline to mirror the route's schema
    const { z } = require('zod');
    const GitCommitBody = z
      .object({
        message: z.string().min(1),
        files: z.array(z.string().min(1)).optional(),
      })
      .strict();

    const result = GitCommitBody.safeParse({
      message: 'test commit',
      author: 'Evil <evil@hack.com>',
      email: 'evil@hack.com',
    });
    expect(result.success).toBe(false);
  });

  it('accepts valid commit body with message only', () => {
    const { z } = require('zod');
    const GitCommitBody = z
      .object({
        message: z.string().min(1),
        files: z.array(z.string().min(1)).optional(),
      })
      .strict();

    const result = GitCommitBody.safeParse({ message: 'add feature' });
    expect(result.success).toBe(true);
  });
});

// ── T13: checkSymlinkEscape (FIX 3) ──────────────────────────────────────────

describe('checkSymlinkEscape', () => {
  let repoDir: string;
  let outsideDir: string;

  beforeAll(async () => {
    repoDir = await realpath(await mkdtemp(join(tmpdir(), 'boocode-symlink-repo-')));
    outsideDir = await realpath(await mkdtemp(join(tmpdir(), 'boocode-symlink-outside-')));
    await writeFile(join(outsideDir, 'secret.ts'), 'secret data\n');
    // Symlink inside repo pointing to outside dir
    await symlink(outsideDir, join(repoDir, 'evil'));
  });

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

  it('rejects a path that escapes via a directory symlink', async () => {
    await expect(checkSymlinkEscape(repoDir, 'evil/secret.ts')).rejects.toThrow(GitWriteError);
  });

  it('rejects a path that resolves to the symlink itself (outside)', async () => {
    await expect(checkSymlinkEscape(repoDir, 'evil')).rejects.toThrow(GitWriteError);
  });

  it('accepts a path that resolves within the repo', async () => {
    await writeFile(join(repoDir, 'legit.ts'), 'export {};\n');
    await expect(checkSymlinkEscape(repoDir, 'legit.ts')).resolves.toBeUndefined();
  });

  it('accepts a non-existent path (new file being staged)', async () => {
    await expect(checkSymlinkEscape(repoDir, 'brand-new-file-not-yet-created.ts')).resolves.toBeUndefined();
  });
});
