// git-clone-sandbox.test.mjs — Tests for sandboxed git clone + fs-utils tmppath
// Zero external dependencies: node:test + node:assert only.

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, rmSync, readFileSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));
const LIB_DIR = join(__dirname, '..', '..', 'scanners', 'lib');
const GIT_CLONE = join(LIB_DIR, 'git-clone.mjs');
const FS_UTILS = join(LIB_DIR, 'fs-utils.mjs');

// ---------------------------------------------------------------------------
// Import sandbox exports for unit testing
// ---------------------------------------------------------------------------

const {
  GIT_SANDBOX_CONFIG, GIT_SANDBOX_ENV, buildSandboxProfile, buildBwrapArgs,
  buildSandboxedClone, MAX_CLONE_SIZE_MB,
} = await import('../../scanners/lib/git-clone.mjs');

// ---------------------------------------------------------------------------
// GIT_SANDBOX_CONFIG
// ---------------------------------------------------------------------------

describe('GIT_SANDBOX_CONFIG', () => {
  it('disables hooks', () => {
    const idx = GIT_SANDBOX_CONFIG.indexOf('core.hooksPath=/dev/null');
    assert.ok(idx > 0, 'core.hooksPath=/dev/null must be in config flags');
  });

  it('disables symlinks', () => {
    assert.ok(GIT_SANDBOX_CONFIG.includes('core.symlinks=false'));
  });

  it('disables fsmonitor', () => {
    assert.ok(GIT_SANDBOX_CONFIG.includes('core.fsmonitor=false'));
  });

  it('disables LFS filter drivers', () => {
    assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.process='));
    assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.smudge='));
    assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.clean='));
  });

  it('blocks local file protocol', () => {
    assert.ok(GIT_SANDBOX_CONFIG.includes('protocol.file.allow=never'));
  });

  it('enables fsck on transfer', () => {
    assert.ok(GIT_SANDBOX_CONFIG.includes('transfer.fsckObjects=true'));
  });

  it('has 8 -c flag pairs (16 elements)', () => {
    const cCount = GIT_SANDBOX_CONFIG.filter(f => f === '-c').length;
    assert.equal(cCount, 8, 'Should have exactly 8 -c flags');
  });
});

// ---------------------------------------------------------------------------
// GIT_SANDBOX_ENV
// ---------------------------------------------------------------------------

describe('GIT_SANDBOX_ENV', () => {
  it('sets GIT_CONFIG_NOSYSTEM', () => {
    assert.equal(GIT_SANDBOX_ENV.GIT_CONFIG_NOSYSTEM, '1');
  });

  it('sets GIT_CONFIG_GLOBAL to /dev/null', () => {
    assert.equal(GIT_SANDBOX_ENV.GIT_CONFIG_GLOBAL, '/dev/null');
  });

  it('sets GIT_ATTR_NOSYSTEM', () => {
    assert.equal(GIT_SANDBOX_ENV.GIT_ATTR_NOSYSTEM, '1');
  });

  it('sets GIT_TERMINAL_PROMPT to 0', () => {
    assert.equal(GIT_SANDBOX_ENV.GIT_TERMINAL_PROMPT, '0');
  });

  it('preserves existing PATH', () => {
    assert.ok(GIT_SANDBOX_ENV.PATH, 'PATH must be preserved from process.env');
  });
});

// ---------------------------------------------------------------------------
// buildSandboxProfile
// ---------------------------------------------------------------------------

describe('buildSandboxProfile', () => {
  it('returns a profile string on macOS', () => {
    if (process.platform !== 'darwin') return;
    // Use tmpdir() which always exists — realpathSync needs an existing path
    const profile = buildSandboxProfile(tmpdir());
    assert.ok(profile !== null, 'Should return a profile on macOS');
    assert.ok(profile.includes('(version 1)'), 'Profile must start with version');
    assert.ok(profile.includes('(deny file-write*)'), 'Must deny writes by default');
  });

  it('includes the resolved real path in the profile', () => {
    if (process.platform !== 'darwin') return;
    const realPath = realpathSync(tmpdir());
    const profile = buildSandboxProfile(tmpdir());
    assert.ok(profile.includes(realPath), `Profile must contain resolved path: ${realPath}`);
  });

  it('allows /dev/null and /dev/tty writes', () => {
    if (process.platform !== 'darwin') return;
    const profile = buildSandboxProfile(tmpdir());
    assert.ok(profile.includes('/dev/null'), 'Must allow /dev/null');
    assert.ok(profile.includes('/dev/tty'), 'Must allow /dev/tty');
  });
});

// ---------------------------------------------------------------------------
// buildBwrapArgs
// ---------------------------------------------------------------------------

describe('buildBwrapArgs', () => {
  it('returns null on non-Linux platforms', () => {
    if (process.platform === 'linux') return;
    const result = buildBwrapArgs('/tmp/test', ['git', 'clone']);
    assert.equal(result, null, 'Should return null on non-Linux');
  });

  it('on Linux: returns args array if bwrap is available', () => {
    if (process.platform !== 'linux') return;
    const check = spawnSync('which', ['bwrap'], { encoding: 'utf8' });
    if (check.status !== 0) return; // bwrap not installed, skip
    const result = buildBwrapArgs('/tmp/test-bwrap', ['git', 'clone']);
    if (result === null) return; // bwrap installed but fails (Ubuntu 24.04+)
    assert.ok(Array.isArray(result), 'Should return an array');
    assert.ok(result.includes('--ro-bind'), 'Should include --ro-bind');
    assert.ok(result.includes('--unshare-all'), 'Should include --unshare-all');
    assert.ok(result.includes('/tmp/test-bwrap'), 'Should include the allowed write path');
  });
});

// ---------------------------------------------------------------------------
// buildSandboxedClone
// ---------------------------------------------------------------------------

describe('buildSandboxedClone', () => {
  it('returns cmd, args, and sandbox properties', () => {
    const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
    assert.ok(result.cmd, 'Must have cmd');
    assert.ok(Array.isArray(result.args), 'args must be an array');
    assert.ok('sandbox' in result, 'Must have sandbox property');
  });

  it('uses sandbox-exec on macOS', () => {
    if (process.platform !== 'darwin') return;
    const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
    assert.equal(result.sandbox, 'sandbox-exec');
    assert.equal(result.cmd, 'sandbox-exec');
  });

  it('includes git config flags in args regardless of platform', () => {
    const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
    const argsStr = result.args.join(' ');
    assert.ok(argsStr.includes('core.hooksPath=/dev/null'), 'Must include hooksPath');
    assert.ok(argsStr.includes('core.symlinks=false'), 'Must include symlinks=false');
  });

  it('falls back gracefully with sandbox=null when no OS sandbox', () => {
    // This test verifies the structure — on macOS/Linux with sandbox available,
    // it will have a sandbox. The key assertion is structural.
    const result = buildSandboxedClone(tmpdir(), ['clone', 'url', tmpdir()]);
    if (result.sandbox === null) {
      assert.equal(result.cmd, 'git', 'Fallback must use git directly');
    }
  });
});

// ---------------------------------------------------------------------------
// MAX_CLONE_SIZE_MB
// ---------------------------------------------------------------------------

describe('MAX_CLONE_SIZE_MB', () => {
  it('is 100', () => {
    assert.equal(MAX_CLONE_SIZE_MB, 100);
  });
});

// ---------------------------------------------------------------------------
// fs-utils tmppath uniqueness
// ---------------------------------------------------------------------------

describe('fs-utils tmppath', () => {
  it('generates unique paths for the same base name', () => {
    const paths = new Set();
    for (let i = 0; i < 5; i++) {
      const result = spawnSync('node', [FS_UTILS, 'tmppath', 'content-extract.json'], {
        encoding: 'utf8',
      });
      assert.equal(result.status, 0, `tmppath should exit 0, got: ${result.stderr}`);
      paths.add(result.stdout.trim());
    }
    assert.equal(paths.size, 5, 'All 5 paths should be unique');
  });

  it('preserves file extension', () => {
    const result = spawnSync('node', [FS_UTILS, 'tmppath', 'test-file.json'], {
      encoding: 'utf8',
    });
    assert.ok(result.stdout.trim().endsWith('.json'), 'Should preserve .json extension');
  });

  it('preserves base name prefix', () => {
    const result = spawnSync('node', [FS_UTILS, 'tmppath', 'my-evidence.json'], {
      encoding: 'utf8',
    });
    assert.ok(result.stdout.trim().includes('my-evidence-'), 'Should contain base name prefix');
  });

  it('paths are under tmpdir', () => {
    const result = spawnSync('node', [FS_UTILS, 'tmppath', 'test.json'], {
      encoding: 'utf8',
    });
    const path = result.stdout.trim();
    assert.ok(path.startsWith(tmpdir()), `Path should be under tmpdir: ${path}`);
  });
});

// ---------------------------------------------------------------------------
// git-clone CLI: validate
// ---------------------------------------------------------------------------

describe('git-clone validate', () => {
  it('accepts valid HTTPS GitHub URL', () => {
    const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo'], {
      encoding: 'utf8',
    });
    assert.equal(result.status, 0);
  });

  it('accepts valid SSH GitHub URL', () => {
    const result = spawnSync('node', [GIT_CLONE, 'validate', 'git@github.com:org/repo.git'], {
      encoding: 'utf8',
    });
    assert.equal(result.status, 0);
  });

  it('rejects non-GitHub URL', () => {
    const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://evil.com/repo'], {
      encoding: 'utf8',
    });
    assert.equal(result.status, 1);
  });

  it('rejects URL with tree path', () => {
    const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo/tree/main/dir'], {
      encoding: 'utf8',
    });
    assert.equal(result.status, 1);
  });
});

// ---------------------------------------------------------------------------
// git-clone CLI: cleanup safety
// ---------------------------------------------------------------------------

describe('git-clone cleanup', () => {
  it('refuses to remove paths outside tmpdir', () => {
    const result = spawnSync('node', [GIT_CLONE, 'cleanup', '/home/user/important'], {
      encoding: 'utf8',
    });
    assert.equal(result.status, 1);
    assert.ok(result.stderr.includes('refusing to remove'));
  });

  it('handles non-existent tmpdir path gracefully', () => {
    const fakePath = join(tmpdir(), 'llm-sec-nonexistent-test-' + Date.now());
    const result = spawnSync('node', [GIT_CLONE, 'cleanup', fakePath], {
      encoding: 'utf8',
    });
    assert.equal(result.status, 0, 'Should exit 0 for non-existent path in tmpdir');
  });
});
