import { test, expect, describe, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, chmodSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
  GIT_SSRF_FLAGS,
  GIT_SSRF_SUBCOMMAND_FLAGS,
  parseRemoteUrl,
  RemoteUrlError,
  cloneRepo,
  pullRepo,
  GitOperationError,
  validateRepoState,
} from '../src/core/git-remote.ts';
import { withEnv } from './helpers/with-env.ts';

// ---------------------------------------------------------------------------
// Fake-git harness: write a shell script that records its argv to a log file,
// then prepend its dir to PATH for the test. Lets us assert exact argv shape
// without invoking real git.
// ---------------------------------------------------------------------------

const FAKE_GIT_DIR = join(tmpdir(), `gbrain-git-remote-test-${process.pid}`);
const FAKE_GIT_LOG = join(FAKE_GIT_DIR, 'argv.log');
const FAKE_GIT_MODE = join(FAKE_GIT_DIR, 'mode');

function writeFakeGit(): void {
  mkdirSync(FAKE_GIT_DIR, { recursive: true });
  // Mode file controls fake-git behavior: "ok" = exit 0, "fail" = exit 1.
  writeFileSync(FAKE_GIT_MODE, 'ok');
  // Per-invocation argv goes into argv.log (one JSON array per line).
  writeFileSync(FAKE_GIT_LOG, '');
  const script = `#!/usr/bin/env bash
# Fake git for git-remote.test.ts
{ printf '['; for arg in "$@"; do printf '%s,' "$(printf '%s' "$arg" | jq -Rs .)"; done; printf 'null]\\n'; } >> "${FAKE_GIT_LOG}"
mode=$(cat "${FAKE_GIT_MODE}" 2>/dev/null || echo ok)
case "$mode" in
  fail) exit 1 ;;
  url-drift) echo "https://github.com/different/url" ;;
  url-match) echo "https://github.com/expected/url" ;;
  *) ;;
esac
exit 0
`;
  const path = join(FAKE_GIT_DIR, 'git');
  writeFileSync(path, script);
  chmodSync(path, 0o755);
}

function readArgvLog(): string[][] {
  const raw = readFileSync(FAKE_GIT_LOG, 'utf8');
  return raw
    .split('\n')
    .filter(Boolean)
    .map(line => {
      const arr = JSON.parse(line) as (string | null)[];
      return arr.filter((x): x is string => x !== null);
    });
}

function clearArgvLog(): void {
  writeFileSync(FAKE_GIT_LOG, '');
}

function setMode(mode: 'ok' | 'fail' | 'url-drift' | 'url-match'): void {
  writeFileSync(FAKE_GIT_MODE, mode);
}

beforeAll(() => writeFakeGit());
afterAll(() => rmSync(FAKE_GIT_DIR, { recursive: true, force: true }));
beforeEach(() => {
  clearArgvLog();
  setMode('ok');
});

const fakePath = (): string => `${FAKE_GIT_DIR}:${process.env.PATH ?? ''}`;

// ---------------------------------------------------------------------------
// GIT_SSRF_FLAGS — pinned shape (snapshot test). If a future flag is added,
// update the expected list here AND verify both cloneRepo + pullRepo pick it
// up via the GIT_SSRF_FLAGS spread (the codex finding that motivated this).
// ---------------------------------------------------------------------------

describe('GIT_SSRF_FLAGS', () => {
  test('exact shape — global -c config flags only (spread BEFORE the verb)', () => {
    expect([...GIT_SSRF_FLAGS]).toEqual([
      '-c', 'http.followRedirects=false',
      '-c', 'protocol.file.allow=never',
      '-c', 'protocol.ext.allow=never',
    ]);
  });
});

describe('GIT_SSRF_SUBCOMMAND_FLAGS', () => {
  test('exact shape — subcommand-level flags only (spread AFTER the verb)', () => {
    // v0.34 fix wave: --no-recurse-submodules is a clone/pull subcommand
    // flag, not a global flag. Real git exits 129 with "unknown option"
    // when it appears before the verb. The pre-v0.34 single-constant
    // spread baked the bug in.
    expect([...GIT_SSRF_SUBCOMMAND_FLAGS]).toEqual([
      '--no-recurse-submodules',
    ]);
  });
});

// ---------------------------------------------------------------------------
// parseRemoteUrl
// ---------------------------------------------------------------------------

describe('parseRemoteUrl — happy path', () => {
  test('accepts plain https URL', () => {
    const r = parseRemoteUrl('https://github.com/garrytan/dummy.git');
    expect(r.url).toBe('https://github.com/garrytan/dummy.git');
    expect(r.hostname).toBe('github.com');
  });
});

describe('parseRemoteUrl — rejection cases', () => {
  test('rejects empty input', () => {
    expect(() => parseRemoteUrl('')).toThrow(RemoteUrlError);
  });
  test('rejects malformed URL', () => {
    expect(() => parseRemoteUrl('not a url')).toThrow(/malformed|invalid_url/i);
  });
  test('rejects ssh:// scheme', () => {
    try {
      parseRemoteUrl('ssh://git@github.com/foo/bar.git');
      throw new Error('expected throw');
    } catch (e) {
      expect(e).toBeInstanceOf(RemoteUrlError);
      expect((e as RemoteUrlError).code).toBe('unsupported_scheme');
    }
  });
  test('rejects git:// scheme', () => {
    expect(() => parseRemoteUrl('git://github.com/foo/bar')).toThrow(/scheme not supported/i);
  });
  test('rejects file:// scheme', () => {
    expect(() => parseRemoteUrl('file:///etc/passwd')).toThrow(/scheme not supported/i);
  });
  test('rejects embedded credentials', () => {
    try {
      parseRemoteUrl('https://user:pass@github.com/foo');
      throw new Error('expected throw');
    } catch (e) {
      expect(e).toBeInstanceOf(RemoteUrlError);
      expect((e as RemoteUrlError).code).toBe('embedded_credentials');
    }
  });
  test('rejects path traversal (..)', () => {
    try {
      parseRemoteUrl('https://github.com/foo/../etc/passwd');
      throw new Error('expected throw');
    } catch (e) {
      expect(e).toBeInstanceOf(RemoteUrlError);
      expect((e as RemoteUrlError).code).toBe('path_traversal');
    }
  });
  test('rejects RFC1918 192.168.x.x', () => {
    try {
      parseRemoteUrl('https://192.168.1.1/repo.git');
      throw new Error('expected throw');
    } catch (e) {
      expect(e).toBeInstanceOf(RemoteUrlError);
      expect((e as RemoteUrlError).code).toBe('internal_target');
    }
  });
  test('rejects loopback 127.0.0.1', () => {
    expect(() => parseRemoteUrl('https://127.0.0.1/repo')).toThrow(/internal/i);
  });
  test('rejects localhost', () => {
    expect(() => parseRemoteUrl('https://localhost/repo')).toThrow(/internal/i);
  });
  test('rejects metadata.google.internal', () => {
    expect(() => parseRemoteUrl('https://metadata.google.internal/foo')).toThrow(
      /internal/i,
    );
  });
  test('rejects 169.254.x.x AWS metadata range', () => {
    expect(() => parseRemoteUrl('https://169.254.169.254/foo')).toThrow(/internal/i);
  });

  // Codex v0.28.1 finding: IPv6 ULA + link-local were not blocked.
  test('rejects IPv6 ULA fc00::/7 (fd-prefix)', () => {
    expect(() => parseRemoteUrl('https://[fd00:1234::1]/repo')).toThrow(/internal/i);
  });
  test('rejects IPv6 ULA fc00::/7 (fc-prefix)', () => {
    expect(() => parseRemoteUrl('https://[fc01:2345::abcd]/repo')).toThrow(/internal/i);
  });
  test('rejects IPv6 link-local fe80::/10', () => {
    expect(() => parseRemoteUrl('https://[fe80::1]/repo')).toThrow(/internal/i);
  });
  test('does NOT reject public IPv6', () => {
    // 2606:4700:4700::1111 is Cloudflare DNS — public IPv6
    const r = parseRemoteUrl('https://[2606:4700:4700::1111]/repo');
    expect(r.hostname).toBe('[2606:4700:4700::1111]');
  });
});

// T3 — Tailscale CGNAT regression cases.
describe('parseRemoteUrl — CGNAT 100.64/10 (Tailscale)', () => {
  test('rejected by default', async () => {
    await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
      try {
        parseRemoteUrl('https://100.64.0.1/repo.git');
        throw new Error('expected throw');
      } catch (e) {
        expect(e).toBeInstanceOf(RemoteUrlError);
        expect((e as RemoteUrlError).code).toBe('internal_target');
      }
    });
  });
  test('accepted with GBRAIN_ALLOW_PRIVATE_REMOTES=1', async () => {
    await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: '1' }, async () => {
      const r = parseRemoteUrl('https://100.64.0.1/repo.git');
      expect(r.hostname).toBe('100.64.0.1');
    });
  });
  test('also covers 100.127.x (upper end of CGNAT range)', async () => {
    await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
      expect(() => parseRemoteUrl('https://100.127.255.1/x')).toThrow(/internal/i);
    });
  });
  test('does NOT reject 100.0.x (just below CGNAT range)', () => {
    // 100.0.0.0/8 is regular public IP space outside CGNAT
    const r = parseRemoteUrl('https://100.63.255.1/repo');
    expect(r.hostname).toBe('100.63.255.1');
  });
});

// ---------------------------------------------------------------------------
// cloneRepo — fake-git harness
// ---------------------------------------------------------------------------

describe('cloneRepo', () => {
  test('happy path: invokes git with GIT_SSRF_FLAGS + --depth=1 + url + dest', async () => {
    const dest = join(FAKE_GIT_DIR, 'clone-target');
    rmSync(dest, { recursive: true, force: true });
    await withEnv({ PATH: fakePath() }, async () => {
      cloneRepo('https://example.com/repo', dest);
    });
    const calls = readArgvLog();
    expect(calls.length).toBe(1);
    const argv = calls[0];
    // Global -c config flags must appear BEFORE the 'clone' verb.
    expect(argv.slice(0, GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
    expect(argv).toContain('clone');
    expect(argv).toContain('--depth=1');
    expect(argv).toContain('https://example.com/repo');
    expect(argv[argv.length - 1]).toBe(dest);
    // v0.34 fix wave: subcommand flags MUST appear after the verb. Real
    // git rejects `git --no-recurse-submodules clone ...` with exit 129.
    // The fake-git harness returned 0 for any argv shape, so this
    // position-anchored assertion is the structural regression test.
    const cloneIdx = argv.indexOf('clone');
    expect(cloneIdx).toBeGreaterThan(-1);
    for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
      const flagIdx = argv.indexOf(subFlag);
      expect(flagIdx).toBeGreaterThan(cloneIdx);
    }
  });

  test('depth=0 means no --depth flag (full clone)', async () => {
    const dest = join(FAKE_GIT_DIR, 'clone-full');
    rmSync(dest, { recursive: true, force: true });
    await withEnv({ PATH: fakePath() }, async () => {
      cloneRepo('https://example.com/repo', dest, { depth: 0 });
    });
    const argv = readArgvLog()[0];
    expect(argv.find(a => a.startsWith('--depth'))).toBeUndefined();
  });

  test('passes --branch when provided', async () => {
    const dest = join(FAKE_GIT_DIR, 'clone-branch');
    rmSync(dest, { recursive: true, force: true });
    await withEnv({ PATH: fakePath() }, async () => {
      cloneRepo('https://example.com/repo', dest, { branch: 'main' });
    });
    const argv = readArgvLog()[0];
    const branchIdx = argv.indexOf('--branch');
    expect(branchIdx).toBeGreaterThan(-1);
    expect(argv[branchIdx + 1]).toBe('main');
  });

  test('refuses non-empty destDir', async () => {
    const dest = join(FAKE_GIT_DIR, 'clone-nonempty');
    mkdirSync(dest, { recursive: true });
    writeFileSync(join(dest, 'sentinel'), 'hi');
    await withEnv({ PATH: fakePath() }, async () => {
      try {
        cloneRepo('https://example.com/repo', dest);
        throw new Error('expected throw');
      } catch (e) {
        expect(e).toBeInstanceOf(GitOperationError);
        expect((e as GitOperationError).op).toBe('clone');
      }
    });
    expect(readArgvLog().length).toBe(0); // never invoked git
    rmSync(dest, { recursive: true, force: true });
  });

  test('throws GitOperationError when git exits non-zero', async () => {
    const dest = join(FAKE_GIT_DIR, 'clone-fails');
    rmSync(dest, { recursive: true, force: true });
    setMode('fail');
    await withEnv({ PATH: fakePath() }, async () => {
      try {
        cloneRepo('https://example.com/repo', dest);
        throw new Error('expected throw');
      } catch (e) {
        expect(e).toBeInstanceOf(GitOperationError);
        expect((e as GitOperationError).op).toBe('clone');
      }
    });
  });
});

// ---------------------------------------------------------------------------
// pullRepo — fake-git harness
// ---------------------------------------------------------------------------

describe('pullRepo', () => {
  test('happy path: invokes git -C path with GIT_SSRF_FLAGS + pull --ff-only', async () => {
    const repo = join(FAKE_GIT_DIR, 'pull-target');
    mkdirSync(repo, { recursive: true });
    await withEnv({ PATH: fakePath() }, async () => {
      pullRepo(repo);
    });
    const argv = readArgvLog()[0];
    expect(argv[0]).toBe('-C');
    expect(argv[1]).toBe(repo);
    expect(argv.slice(2, 2 + GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
    expect(argv).toContain('pull');
    expect(argv).toContain('--ff-only');
    // v0.34 fix wave: subcommand flag position assertion.
    const pullIdx = argv.indexOf('pull');
    expect(pullIdx).toBeGreaterThan(-1);
    for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
      const flagIdx = argv.indexOf(subFlag);
      expect(flagIdx).toBeGreaterThan(pullIdx);
    }
    rmSync(repo, { recursive: true, force: true });
  });

  test('throws GitOperationError when git exits non-zero', async () => {
    const repo = join(FAKE_GIT_DIR, 'pull-fails');
    mkdirSync(repo, { recursive: true });
    setMode('fail');
    await withEnv({ PATH: fakePath() }, async () => {
      expect(() => pullRepo(repo)).toThrow(GitOperationError);
    });
    rmSync(repo, { recursive: true, force: true });
  });
});

// ---------------------------------------------------------------------------
// validateRepoState — 6-state decision tree
// ---------------------------------------------------------------------------

describe('validateRepoState', () => {
  const fixtureDir = join(FAKE_GIT_DIR, 'state-fixtures');

  beforeEach(() => {
    rmSync(fixtureDir, { recursive: true, force: true });
    mkdirSync(fixtureDir, { recursive: true });
  });

  test("returns 'missing' for nonexistent path", () => {
    expect(validateRepoState(join(fixtureDir, 'nope'))).toBe('missing');
  });

  test("returns 'not-a-dir' when path is a file", () => {
    const p = join(fixtureDir, 'a-file');
    writeFileSync(p, 'hi');
    expect(validateRepoState(p)).toBe('not-a-dir');
  });

  test("returns 'no-git' for directory without .git/", () => {
    const p = join(fixtureDir, 'no-git-dir');
    mkdirSync(p, { recursive: true });
    expect(validateRepoState(p)).toBe('no-git');
  });

  test("returns 'corrupted' when git remote get-url fails", async () => {
    const p = join(fixtureDir, 'corrupted-repo');
    mkdirSync(join(p, '.git'), { recursive: true });
    setMode('fail');
    await withEnv({ PATH: fakePath() }, async () => {
      expect(validateRepoState(p)).toBe('corrupted');
    });
  });

  test("returns 'url-drift' when remote differs from expected", async () => {
    const p = join(fixtureDir, 'drift-repo');
    mkdirSync(join(p, '.git'), { recursive: true });
    setMode('url-drift');
    await withEnv({ PATH: fakePath() }, async () => {
      expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('url-drift');
    });
  });

  test("returns 'healthy' when remote matches expected", async () => {
    const p = join(fixtureDir, 'healthy-repo');
    mkdirSync(join(p, '.git'), { recursive: true });
    setMode('url-match');
    await withEnv({ PATH: fakePath() }, async () => {
      expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('healthy');
    });
  });

  test("returns 'healthy' when no expected URL provided (just probe)", async () => {
    const p = join(fixtureDir, 'healthy-no-expect');
    mkdirSync(join(p, '.git'), { recursive: true });
    setMode('ok');
    await withEnv({ PATH: fakePath() }, async () => {
      expect(validateRepoState(p)).toBe('healthy');
    });
  });
});
