import { describe, it, expect } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import {
  normalizePackageName,
  parseAndValidateSpec,
  assertNotShadowing,
  assertOverlayDoesNotShadow,
  computeLockHash,
  buildPipInstallArgv,
  buildInstallerBwrapArgs,
  spaceKeyFor,
  spaceCurrentDir,
  resolveSandboxOverlay,
  resolvePythonPackagesConfig,
  installSpacePackages,
  SANDBOX_PY_PACKAGES_DEST,
} from './python-packages.js';

describe('parseAndValidateSpec', () => {
  it('accepts a bare name and normalizes it', () => {
    const p = parseAndValidateSpec('Requests');
    expect(p.normalizedName).toBe('requests');
    expect(p.spec).toBe('requests');
  });

  it('accepts an exact == pin', () => {
    const p = parseAndValidateSpec('requests==2.32.3');
    expect(p.spec).toBe('requests==2.32.3');
  });

  it('normalizes underscores/dots/dashes per PEP 503', () => {
    expect(parseAndValidateSpec('Flask_SQLAlchemy').normalizedName).toBe('flask-sqlalchemy');
    expect(normalizePackageName('Zope.Interface')).toBe('zope-interface');
  });

  it('rejects range operators (only == pins allowed)', () => {
    expect(() => parseAndValidateSpec('requests>=2.0')).toThrow();
    expect(() => parseAndValidateSpec('requests~=2.0')).toThrow();
  });

  it('rejects shell/argument-injection metacharacters', () => {
    for (const bad of ['requests; curl evil', 'a && b', 'x`id`', 'x|y', 'a b', 'pkg$(whoami)', '../etc']) {
      expect(() => parseAndValidateSpec(bad), bad).toThrow();
    }
  });

  it('rejects empty and over-long specs', () => {
    expect(() => parseAndValidateSpec('')).toThrow();
    expect(() => parseAndValidateSpec('a'.repeat(200))).toThrow();
  });
});

describe('assertNotShadowing', () => {
  it('rejects stdlib module names (both dash and underscore forms)', () => {
    expect(() => assertNotShadowing('json')).toThrow(/standard library/);
    expect(() => assertNotShadowing('os')).toThrow(/standard library/);
  });

  it('rejects preinstalled import names', () => {
    expect(() => assertNotShadowing('numpy')).toThrow(/preinstalled/);
    expect(() => assertNotShadowing('openpyxl')).toThrow(/preinstalled/);
  });

  it('allows a normal third-party package', () => {
    expect(() => assertNotShadowing('requests')).not.toThrow();
    expect(() => assertNotShadowing('httpx')).not.toThrow();
  });
});

describe('assertOverlayDoesNotShadow', () => {
  it('rejects an overlay whose top-level module shadows the stdlib', () => {
    const dir = mkdtempSync(path.join(tmpdir(), 'ovl-'));
    try {
      writeFileSync(path.join(dir, 'json.py'), '# evil');
      expect(() => assertOverlayDoesNotShadow(dir)).toThrow(/shadows the Python standard library/);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it('rejects a native extension module that shadows the stdlib (json.cpython-*.so)', () => {
    const dir = mkdtempSync(path.join(tmpdir(), 'ovl-'));
    try {
      writeFileSync(path.join(dir, 'json.cpython-311-x86_64-linux-gnu.so'), '');
      expect(() => assertOverlayDoesNotShadow(dir)).toThrow(/standard library/);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it('rejects an abi3 native extension that shadows a preinstalled package (numpy.abi3.so)', () => {
    const dir = mkdtempSync(path.join(tmpdir(), 'ovl-'));
    try {
      writeFileSync(path.join(dir, 'numpy.abi3.so'), '');
      expect(() => assertOverlayDoesNotShadow(dir)).toThrow(/preinstalled/);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it('rejects an overlay whose module shadows a PREINSTALLED package (PYTHONPATH wins)', () => {
    const dir = mkdtempSync(path.join(tmpdir(), 'ovl-'));
    try {
      mkdirSync(path.join(dir, 'numpy'));
      expect(() => assertOverlayDoesNotShadow(dir)).toThrow(/shadows a preinstalled package/);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it('passes a clean overlay (dist-info + non-stdlib module ignored/allowed)', () => {
    const dir = mkdtempSync(path.join(tmpdir(), 'ovl-'));
    try {
      mkdirSync(path.join(dir, 'requests'));
      mkdirSync(path.join(dir, 'requests-2.32.3.dist-info'));
      expect(() => assertOverlayDoesNotShadow(dir)).not.toThrow();
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});

describe('computeLockHash', () => {
  it('is stable and independent of caller ordering (input is pre-sorted)', () => {
    const a = computeLockHash(['a==1', 'b==2'], 'https://idx', 'py3.12-linux');
    const b = computeLockHash(['a==1', 'b==2'], 'https://idx', 'py3.12-linux');
    expect(a).toBe(b);
    expect(a).toMatch(/^[0-9a-f]{16}$/);
  });

  it('changes when index url or python tag changes (ABI safety)', () => {
    const base = computeLockHash(['a==1'], 'https://idx', 'py3.12-linux');
    expect(computeLockHash(['a==1'], 'https://other', 'py3.12-linux')).not.toBe(base);
    expect(computeLockHash(['a==1'], 'https://idx', 'py3.11-linux')).not.toBe(base);
  });
});

describe('buildPipInstallArgv', () => {
  it('is wheels-only, fixed-index, targeted — never builds sdist', () => {
    const argv = buildPipInstallArgv(['requests==2.32.3'], 'https://pypi.org/simple', '/tmp/t');
    expect(argv).toContain('--only-binary=:all:');
    expect(argv).toContain('--index-url');
    expect(argv[argv.indexOf('--index-url') + 1]).toBe('https://pypi.org/simple');
    expect(argv).toContain('--target');
    expect(argv[argv.indexOf('--target') + 1]).toBe('/tmp/t');
    expect(argv[argv.length - 1]).toBe('requests==2.32.3');
    // No flag that would ever allow arbitrary code from an sdist.
    expect(argv.join(' ')).not.toMatch(/--no-binary|--prefer-binary/);
  });
});

describe('buildInstallerBwrapArgs (isolation invariants)', () => {
  const args = buildInstallerBwrapArgs('/tmp/target', buildPipInstallArgv(['x'], 'https://idx', '/tmp/target'));
  const joined = args.join(' ');

  it('keeps network ON (no --unshare-net) so pip can fetch', () => {
    expect(joined).not.toContain('--unshare-net');
  });

  it('unshares every other namespace', () => {
    for (const ns of ['--unshare-user', '--unshare-ipc', '--unshare-pid', '--unshare-uts', '--unshare-cgroup']) {
      expect(joined).toContain(ns);
    }
  });

  it('binds ONLY the wheel target writable (no workspace, no HOME, no secrets)', () => {
    const bindIdx = args.indexOf('--bind');
    expect(bindIdx).toBeGreaterThanOrEqual(0);
    expect(args[bindIdx + 1]).toBe('/tmp/target');
    // exactly one --bind (the target). Everything else is --ro-bind.
    expect(args.filter((a) => a === '--bind')).toHaveLength(1);
    expect(joined).toContain('--clearenv');
    expect(joined).toContain('HOME /tmp');
  });

  it('execs python3 directly — no shell (`sh -c`) that could word-split args', () => {
    expect(joined).not.toContain('/bin/sh');
    expect(joined).not.toContain(' -c ');
    // python3 -m pip ... appears as discrete trailing tokens.
    const py = args.lastIndexOf('python3');
    expect(py).toBeGreaterThanOrEqual(0);
    expect(args[py + 1]).toBe('-m');
    expect(args[py + 2]).toBe('pip');
  });
});

describe('spaceKeyFor', () => {
  it('uses the space id when present', () => {
    expect(spaceKeyFor('space-abc', 'user-1')).toBe('space-abc');
  });
  it('falls back to user- prefix for personal (no space)', () => {
    expect(spaceKeyFor(null, 'u1')).toBe('user-u1');
    expect(spaceKeyFor(undefined, null)).toBe('user-local');
  });
  it('sanitizes unsafe characters', () => {
    expect(spaceKeyFor('../../etc', null)).toBe('______etc');
  });
});

describe('resolveSandboxOverlay', () => {
  it('returns null for missing/undefined/dangling current', () => {
    expect(resolveSandboxOverlay(undefined)).toBeNull();
    expect(resolveSandboxOverlay('/nonexistent/current')).toBeNull();
  });

  it('resolves a real current symlink → fixed dest + PYTHONPATH', () => {
    const root = mkdtempSync(path.join(tmpdir(), 'pk-'));
    try {
      const real = path.join(root, 'abcdef0123456789');
      mkdirSync(real);
      const current = spaceCurrentDir(root, '');
      // spaceCurrentDir joins root/''/current → normalize by building manually
      const link = path.join(root, 'current');
      symlinkSync(real, link);
      const ov = resolveSandboxOverlay(link);
      expect(ov).not.toBeNull();
      expect(ov!.bind.dest).toBe(SANDBOX_PY_PACKAGES_DEST);
      expect(ov!.bind.src).toBe(real);
      expect(ov!.sandboxPythonPath).toBe(SANDBOX_PY_PACKAGES_DEST);
      expect(current).toContain('current');
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });
});

describe('installSpacePackages fail-closed without bwrap', () => {
  const cfg = resolvePythonPackagesConfig({ enabled: true });

  it('refuses to install packages when bwrap is unavailable (no host-pip fallback)', async () => {
    const root = mkdtempSync(path.join(tmpdir(), 'pk-'));
    try {
      const r = await installSpacePackages({
        pkgRoot: root,
        spaceKey: 'space-x',
        specs: [{ raw: 'requests', normalizedName: 'requests', spec: 'requests' }],
        cfg,
        bwrapAvailable: false,
      });
      expect(r.ok).toBe(false);
      expect(r.error).toMatch(/sandbox is required|unavailable/i);
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('still publishes an EMPTY overlay without bwrap (removing all packages needs no installer)', async () => {
    const root = mkdtempSync(path.join(tmpdir(), 'pk-'));
    try {
      const r = await installSpacePackages({
        pkgRoot: root,
        spaceKey: 'space-y',
        specs: [],
        cfg,
        bwrapAvailable: false,
      });
      expect(r.ok).toBe(true);
      // `current` now points at an existing (empty) overlay dir.
      const ov = resolveSandboxOverlay(spaceCurrentDir(root, 'space-y'));
      expect(ov).not.toBeNull();
      // The manifest is bound into the sandbox — it must NOT carry the index URL
      // (which can embed private-index credentials).
      const manifest = readFileSync(path.join(ov!.bind.src, '.maestro-overlay.json'), 'utf-8');
      expect(manifest).not.toMatch(/indexUrl|index_url|pypi\.org/i);
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });
});

describe('resolvePythonPackagesConfig', () => {
  it('defaults to disabled with sane values', () => {
    const c = resolvePythonPackagesConfig(undefined);
    expect(c.enabled).toBe(false);
    expect(c.indexUrl).toContain('pypi.org');
    expect(c.installTimeoutSec).toBe(180);
    expect(c.maxPackagesPerSpace).toBe(30);
  });
  it('honors overrides and clamps timeout', () => {
    const c = resolvePythonPackagesConfig({ enabled: true, installTimeoutSec: 99999, maxPackagesPerSpace: 5 });
    expect(c.enabled).toBe(true);
    expect(c.installTimeoutSec).toBe(1800);
    expect(c.maxPackagesPerSpace).toBe(5);
  });
});
