import { execFile } from 'child_process';
import {
  existsSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  realpathSync,
  renameSync,
  rmSync,
  statSync,
  symlinkSync,
  writeFileSync,
} from 'fs';
import { createHash } from 'crypto';
import path from 'path';
import { logger } from '../logger.js';

/**
 * Per-space Python package overlays.
 *
 * Agents run inside a bwrap sandbox with `--unshare-net`; `pip install` at
 * runtime is (and stays) blocked. Admins instead pre-install wheels into a
 * per-space overlay that is bind-mounted read-only into the sandbox and put on
 * PYTHONPATH. Installs run OUT OF BAND in a separate, network-enabled bwrap
 * that has NO access to the workspace or any secret — only the wheel target
 * dir. The agent's own sandbox never gets network.
 *
 * Layout:
 *   {root}/{spaceKey}/{lockHash}/   ← `pip install --target` output (content-addressed)
 *   {root}/{spaceKey}/current       ← symlink to the active {lockHash} dir (atomic swap)
 *
 * `current` is the only path the sandbox binds, so runtime never needs the DB.
 */

/** Fixed mount point + PYTHONPATH entry inside the agent sandbox. */
export const SANDBOX_PY_PACKAGES_DEST = '/opt/py-packages';

/** Read-only host dirs the isolated installer needs (python + its shared libs). */
const INSTALLER_RO_DIRS = ['/usr', '/bin', '/sbin', '/lib', '/etc'];
const INSTALLER_RO_TRY_DIRS = ['/lib64'];
const BWRAP_PATH = '/usr/bin/bwrap';

export interface PythonPackagesConfig {
  enabled: boolean;
  /** Overlay root. Default ./data/python-packages */
  dir: string;
  /** Fixed package index (installs never accept a caller-supplied index). */
  indexUrl: string;
  /** Wall-clock cap for a single install run. */
  installTimeoutSec: number;
  /** Max distinct packages a single space may pin. */
  maxPackagesPerSpace: number;
}

const DEFAULTS: PythonPackagesConfig = {
  enabled: false,
  dir: './data/python-packages',
  indexUrl: 'https://pypi.org/simple',
  installTimeoutSec: 180,
  maxPackagesPerSpace: 30,
};

/** Merge user config (snake→camel already applied by config loader) with defaults. */
export function resolvePythonPackagesConfig(
  raw: Partial<PythonPackagesConfig> | undefined,
): PythonPackagesConfig {
  return {
    enabled: raw?.enabled ?? DEFAULTS.enabled,
    dir: raw?.dir?.trim() ? raw.dir : DEFAULTS.dir,
    indexUrl: raw?.indexUrl?.trim() ? raw.indexUrl : DEFAULTS.indexUrl,
    installTimeoutSec:
      typeof raw?.installTimeoutSec === 'number' && raw.installTimeoutSec > 0
        ? Math.min(raw.installTimeoutSec, 1800)
        : DEFAULTS.installTimeoutSec,
    maxPackagesPerSpace:
      typeof raw?.maxPackagesPerSpace === 'number' && raw.maxPackagesPerSpace > 0
        ? raw.maxPackagesPerSpace
        : DEFAULTS.maxPackagesPerSpace,
  };
}

// ─── Spec parsing / validation ──────────────────────────────────────────────

/**
 * A deliberately narrow spec grammar: a PEP 508 distribution name, optionally
 * pinned with `==<version>`. No URLs, extras, environment markers, ranges, or
 * anything a shell could act on. Everything else is rejected so the spec can be
 * passed to pip as a single argv element with zero interpolation risk.
 */
const NAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9.*+!_-]*$/;

export interface ParsedSpec {
  /** As typed (trimmed), e.g. "Requests==2.32.3" */
  raw: string;
  /** PEP 503 normalized name, e.g. "requests" */
  normalizedName: string;
  /** Canonical argv passed to pip, e.g. "requests==2.32.3" */
  spec: string;
}

/** PEP 503 normalization: lowercase, runs of [-_.] collapse to a single '-'. */
export function normalizePackageName(name: string): string {
  return name.trim().toLowerCase().replace(/[-_.]+/g, '-');
}

export function parseAndValidateSpec(input: string): ParsedSpec {
  const raw = (input ?? '').trim();
  if (!raw) throw new Error('empty package spec');
  if (raw.length > 120) throw new Error('package spec too long');
  // Reject any shell/argument-injection surface up front.
  if (/[\s;&|`$(){}\[\]<>"'\\]/.test(raw)) {
    throw new Error(`invalid characters in package spec: ${raw}`);
  }
  let name = raw;
  let version: string | null = null;
  const eq = raw.indexOf('==');
  if (eq >= 0) {
    name = raw.slice(0, eq);
    version = raw.slice(eq + 2);
  } else if (/[<>=~!]/.test(raw)) {
    // Any other operator (>=, ~=, !=, ranges) is rejected: unpinned ranges make
    // the overlay non-reproducible and content-addressing meaningless.
    throw new Error(`only exact "name==version" pins are allowed: ${raw}`);
  }
  if (!NAME_RE.test(name)) throw new Error(`invalid package name: ${name}`);
  if (version !== null && !VERSION_RE.test(version)) {
    throw new Error(`invalid version: ${version}`);
  }
  const normalizedName = normalizePackageName(name);
  const spec = version !== null ? `${normalizedName}==${version}` : normalizedName;
  return { raw, normalizedName, spec };
}

// ─── Shadowing guard ────────────────────────────────────────────────────────

/**
 * Import names already baked into the runtime python (kept in sync with
 * PREINSTALLED_HINT in tools/core.ts). An overlay package must not shadow one.
 */
const PREINSTALLED_IMPORT_NAMES = new Set([
  'pypdf', 'fitz', 'pdfplumber', 'docx', 'pptx', 'openpyxl', 'xlsxwriter',
  'xlrd', 'odf', 'striprtf', 'bs4', 'lxml', 'markdownify', 'markdown', 'numpy',
  'pandas', 'tabulate', 'dateutil', 'matplotlib', 'PIL', 'charset_normalizer',
  'yaml', 'pip', 'setuptools', 'wheel', 'pkg_resources',
]);

/**
 * Python stdlib top-level names. Putting an overlay entry with one of these on
 * PYTHONPATH (searched before the stdlib site dirs) would let a wheel hijack
 * `json`, `os`, etc. Guard by name at spec time and by scanning the built
 * overlay before publish.
 */
export const PYTHON_STDLIB_TOPLEVEL = new Set([
  'abc', 'aifc', 'argparse', 'array', 'ast', 'asyncio', 'atexit', 'audioop',
  'base64', 'bdb', 'binascii', 'bisect', 'builtins', 'bz2', 'calendar', 'cgi',
  'cgitb', 'chunk', 'cmath', 'cmd', 'code', 'codecs', 'codeop', 'collections',
  'colorsys', 'compileall', 'concurrent', 'configparser', 'contextlib',
  'contextvars', 'copy', 'copyreg', 'cProfile', 'crypt', 'csv', 'ctypes',
  'curses', 'dataclasses', 'datetime', 'dbm', 'decimal', 'difflib', 'dis',
  'doctest', 'email', 'encodings', 'ensurepip', 'enum', 'errno', 'faulthandler',
  'fcntl', 'filecmp', 'fileinput', 'fnmatch', 'fractions', 'ftplib',
  'functools', 'gc', 'getopt', 'getpass', 'gettext', 'glob', 'graphlib',
  'gzip', 'hashlib', 'heapq', 'hmac', 'html', 'http', 'idlelib', 'imaplib',
  'imghdr', 'imp', 'importlib', 'inspect', 'io', 'ipaddress', 'itertools',
  'json', 'keyword', 'lib2to3', 'linecache', 'locale', 'logging', 'lzma',
  'mailbox', 'mailcap', 'marshal', 'math', 'mimetypes', 'mmap', 'modulefinder',
  'multiprocessing', 'netrc', 'nis', 'nntplib', 'numbers', 'operator',
  'optparse', 'os', 'ossaudiodev', 'pathlib', 'pdb', 'pickle', 'pickletools',
  'pipes', 'pkgutil', 'platform', 'plistlib', 'poplib', 'posix', 'posixpath',
  'pprint', 'profile', 'pstats', 'pty', 'pwd', 'py_compile', 'pyclbr',
  'pydoc', 'queue', 'quopri', 'random', 're', 'readline', 'reprlib',
  'resource', 'rlcompleter', 'runpy', 'sched', 'secrets', 'select',
  'selectors', 'shelve', 'shlex', 'shutil', 'signal', 'site', 'smtplib',
  'sndhdr', 'socket', 'socketserver', 'spwd', 'sqlite3', 'ssl', 'stat',
  'statistics', 'string', 'stringprep', 'struct', 'subprocess', 'sunau',
  'symtable', 'sys', 'sysconfig', 'syslog', 'tabnanny', 'tarfile', 'telnetlib',
  'tempfile', 'termios', 'textwrap', 'threading', 'time', 'timeit', 'tkinter',
  'token', 'tokenize', 'tomllib', 'trace', 'traceback', 'tracemalloc', 'tty',
  'turtle', 'turtledemo', 'types', 'typing', 'unicodedata', 'unittest',
  'urllib', 'uu', 'uuid', 'venv', 'warnings', 'wave', 'weakref', 'webbrowser',
  'wsgiref', 'xdrlib', 'xml', 'xmlrpc', 'zipapp', 'zipfile', 'zipimport',
  'zlib', 'zoneinfo', '__future__',
]);

/** Reject a spec whose name would shadow a stdlib or preinstalled import. */
export function assertNotShadowing(normalizedName: string): void {
  // Import form: PEP 503 uses '-', import names use '_'. Check both shapes.
  const importForm = normalizedName.replace(/-/g, '_');
  const candidates = new Set([normalizedName, importForm]);
  for (const c of candidates) {
    if (PYTHON_STDLIB_TOPLEVEL.has(c)) {
      throw new Error(`"${normalizedName}" would shadow the Python standard library module "${c}"`);
    }
    if (PREINSTALLED_IMPORT_NAMES.has(c)) {
      throw new Error(`"${normalizedName}" is already preinstalled — no overlay needed`);
    }
  }
}

/**
 * Scan a freshly-built overlay dir's top-level entries and reject if any would
 * shadow a stdlib module. Catches transitive deps + wheels whose import name
 * differs from the pip name (the name-based guard cannot see those).
 */
export function assertOverlayDoesNotShadow(overlayDir: string): void {
  let entries: string[];
  try {
    entries = readdirSync(overlayDir);
  } catch {
    return;
  }
  for (const entry of entries) {
    if (entry.endsWith('.dist-info') || entry.endsWith('.data') || entry === '__pycache__') continue;
    // The importable top-level name is everything before the FIRST dot: this
    // collapses `json.py`, `json.cpython-311-x86_64-linux-gnu.so`, `json.abi3.so`
    // and a bare package dir `json/` all to `json`. A naive `.so`/`.py` suffix
    // strip missed extension modules (`json.cpython-*.so`) entirely.
    const base = entry.split('.')[0];
    if (!base) continue;
    if (PYTHON_STDLIB_TOPLEVEL.has(base)) {
      throw new Error(`installed package provides module "${base}" which shadows the Python standard library`);
    }
    // PYTHONPATH is searched BEFORE the preinstalled site-packages, so an overlay
    // module named e.g. "numpy" would hijack the real preinstalled one. The
    // name-guard catches direct pip names; this catches transitive deps and
    // wheels whose import name differs from the pip name.
    if (PREINSTALLED_IMPORT_NAMES.has(base)) {
      throw new Error(`installed package provides module "${base}" which shadows a preinstalled package`);
    }
  }
}

// ─── Overlay locations ──────────────────────────────────────────────────────

/** Filesystem-safe key for a space (or a personal fallback keyed by user). */
export function spaceKeyFor(spaceId?: string | null, userId?: string | null): string {
  const clean = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80);
  if (spaceId && spaceId.trim()) return clean(spaceId.trim());
  return 'user-' + clean((userId ?? 'local').trim() || 'local');
}

/** `{root}/{spaceKey}/current` — the stable path the sandbox binds. */
export function spaceCurrentDir(pkgRoot: string, spaceKey: string): string {
  return path.join(path.resolve(pkgRoot), spaceKey, 'current');
}

export interface SandboxOverlay {
  /** Read-only bind for bwrap: real overlay dir → fixed sandbox dest. */
  bind: { src: string; dest: string };
  /** PYTHONPATH value to expose the packages (the fixed sandbox dest). */
  sandboxPythonPath: string;
}

/**
 * Resolve the sandbox overlay for a space's `current` dir, or null when the
 * space has no installed packages (or the symlink dangles). Used only on the
 * bwrap-sandboxed path — the overlay is never exposed to the hardened path.
 */
export function resolveSandboxOverlay(currentDir: string | undefined | null): SandboxOverlay | null {
  if (!currentDir) return null;
  let real: string;
  try {
    real = realpathSync(currentDir);
    if (!statSync(real).isDirectory()) return null;
  } catch {
    return null;
  }
  return {
    bind: { src: real, dest: SANDBOX_PY_PACKAGES_DEST },
    sandboxPythonPath: SANDBOX_PY_PACKAGES_DEST,
  };
}

// ─── pip preflight ──────────────────────────────────────────────────────────

export interface PreflightResult {
  ok: boolean;
  reason?: string;
}

function run(
  file: string,
  args: string[],
  opts: { timeoutMs: number; env?: NodeJS.ProcessEnv },
): Promise<{ code: number; stdout: string; stderr: string }> {
  return new Promise((resolve) => {
    execFile(
      file,
      args,
      { timeout: opts.timeoutMs, encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024, env: opts.env },
      (err, stdout, stderr) => {
        const code = err && typeof (err as { code?: unknown }).code === 'number'
          ? (err as { code: number }).code
          : err ? 1 : 0;
        resolve({ code, stdout: stdout ?? '', stderr: stderr ?? '' });
      },
    );
  });
}

/** Verify the host has a usable `python3 -m pip` (installs need it). */
export async function pipPreflight(): Promise<PreflightResult> {
  const r = await run('python3', ['-m', 'pip', '--version'], { timeoutMs: 10_000 });
  if (r.code !== 0) {
    return {
      ok: false,
      reason:
        'python3 -m pip is not available on the server. Install pip (e.g. `python3 -m ensurepip`) to enable package management.',
    };
  }
  return { ok: true };
}

let _pyTag: string | null = null;
/**
 * A tag identifying the target python's ABI so an overlay is invalidated when
 * python changes. Same interpreter the sandbox uses (host /usr python3).
 */
export async function probePyTag(): Promise<string> {
  if (_pyTag) return _pyTag;
  const r = await run(
    'python3',
    ['-c', 'import sys,sysconfig;print("py%d.%d-%s"%(sys.version_info[0],sys.version_info[1],sysconfig.get_platform()))'],
    { timeoutMs: 10_000 },
  );
  _pyTag = r.code === 0 && r.stdout.trim() ? r.stdout.trim().split('\n').pop()!.trim() : 'py-unknown';
  return _pyTag;
}

// ─── Install ────────────────────────────────────────────────────────────────

/** Content-addressed hash of the exact install inputs. */
export function computeLockHash(sortedSpecs: string[], indexUrl: string, pyTag: string): string {
  const payload = JSON.stringify({ specs: sortedSpecs, indexUrl, pyTag });
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

export function buildInstallerBwrapArgs(targetDir: string, pipArgv: string[]): string[] {
  const args: string[] = [];
  for (const dir of INSTALLER_RO_DIRS) args.push('--ro-bind', dir, dir);
  for (const dir of INSTALLER_RO_TRY_DIRS) if (existsSync(dir)) args.push('--ro-bind', dir, dir);
  args.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp');
  // Only writable path: the wheel target. No workspace, no HOME, no secrets.
  args.push('--bind', targetDir, targetDir);
  args.push('--chdir', '/tmp', '--die-with-parent');
  // Network stays ON (no --unshare-net) so pip can reach the index. Every other
  // namespace is unshared, exactly like the agent sandbox.
  args.push('--unshare-user', '--unshare-ipc', '--unshare-pid', '--unshare-uts', '--unshare-cgroup');
  args.push('--clearenv');
  args.push('--setenv', 'PATH', '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin');
  args.push('--setenv', 'HOME', '/tmp');
  args.push('--setenv', 'PIP_NO_CACHE_DIR', '1');
  args.push('--setenv', 'PIP_DISABLE_PIP_VERSION_CHECK', '1');
  // Exec python3 directly as discrete argv tokens (bwrap uses execvp, resolving
  // python3 via the --setenv PATH). No `sh -c` join → no shell parsing of the
  // pip args at all, so a spec/index value can never be word-split or
  // interpreted even if validation were somehow bypassed.
  args.push('python3', '-m', 'pip', ...pipArgv);
  return args;
}

/**
 * Build the pip argv (as discrete tokens) for a wheels-only, fixed-index,
 * targeted install. Exported for testing the exact flags.
 */
export function buildPipInstallArgv(specs: string[], indexUrl: string, targetDir: string): string[] {
  return [
    'install',
    '--only-binary=:all:', // NEVER build from sdist → no arbitrary setup.py execution
    '--no-input',
    '--no-cache-dir',
    '--disable-pip-version-check',
    '--index-url', indexUrl,
    '--target', targetDir,
    ...specs,
  ];
}

export interface InstallResult {
  ok: boolean;
  lockHash?: string;
  dir?: string;
  error?: string;
}

/**
 * Install the FULL desired spec set for a space into a fresh content-addressed
 * overlay and atomically repoint `current`. Rebuilding the whole set (rather
 * than mutating in place) keeps the overlay reproducible and the swap atomic;
 * a failed build never corrupts the currently-published overlay.
 */
export async function installSpacePackages(params: {
  pkgRoot: string;
  spaceKey: string;
  specs: ParsedSpec[];
  cfg: PythonPackagesConfig;
  bwrapAvailable: boolean;
}): Promise<InstallResult> {
  const { pkgRoot, spaceKey, specs, cfg, bwrapAvailable } = params;
  const sortedSpecs = [...specs.map((s) => s.spec)].sort();
  const pyTag = await probePyTag();
  const lockHash = computeLockHash(sortedSpecs, cfg.indexUrl, pyTag);
  const spaceRoot = path.join(path.resolve(pkgRoot), spaceKey);
  const finalDir = path.join(spaceRoot, lockHash);
  const currentLink = path.join(spaceRoot, 'current');

  mkdirSync(spaceRoot, { recursive: true });

  // Empty set → publish an empty overlay (removes all packages) and swap.
  // Reuse an already-built overlay for this exact lockHash (idempotent).
  if (!existsSync(finalDir)) {
    // Fail CLOSED: an actual install requires the isolated bwrap. Without it we
    // would run host pip with no mount/network isolation AND expose the overlay
    // to a non-sandboxed (hardened) agent — the exact fail-open codex flagged.
    // Removing all packages (empty set) needs no installer, so it is still
    // allowed so admins can always shrink an overlay back to nothing.
    if (sortedSpecs.length > 0 && !bwrapAvailable) {
      return {
        ok: false,
        error:
          'The bwrap sandbox is required to install packages but is unavailable on this server. Refusing to install without isolation.',
      };
    }
    const tmpDir = mkdtempSync(path.join(spaceRoot, '.build-'));
    try {
      if (sortedSpecs.length > 0) {
        const pipArgv = buildPipInstallArgv(sortedSpecs, cfg.indexUrl, tmpDir);
        const argv = buildInstallerBwrapArgs(tmpDir, pipArgv);
        logger.info(`[python-packages] install space=${spaceKey} lock=${lockHash} n=${sortedSpecs.length}`);
        const r = await run(BWRAP_PATH, argv, { timeoutMs: cfg.installTimeoutSec * 1000 });
        if (r.code !== 0) {
          rmSync(tmpDir, { recursive: true, force: true });
          const tail = (r.stderr || r.stdout).trim().split('\n').slice(-8).join('\n');
          return { ok: false, error: `pip install failed (exit ${r.code}):\n${tail}` };
        }
      }
      // Post-build stdlib-shadowing guard on the actual materialized modules.
      try {
        assertOverlayDoesNotShadow(tmpDir);
      } catch (e) {
        rmSync(tmpDir, { recursive: true, force: true });
        return { ok: false, error: (e as Error).message };
      }
      // Manifest is bind-mounted read-only INTO the agent sandbox, so it must
      // never contain the index URL (which can embed private-index credentials).
      writeManifest(tmpDir, { specs: sortedSpecs, pyTag, lockHash });
      // Publish: rename temp → content-addressed dir. If a concurrent build won
      // the race, drop ours and use the winner.
      try {
        renameSync(tmpDir, finalDir);
      } catch {
        rmSync(tmpDir, { recursive: true, force: true });
        if (!existsSync(finalDir)) throw new Error('overlay publish failed');
      }
    } catch (e) {
      rmSync(tmpDir, { recursive: true, force: true });
      return { ok: false, error: (e as Error).message };
    }
  }

  // Atomically repoint `current` → finalDir via a temp symlink + rename.
  try {
    const tmpLink = path.join(spaceRoot, `.current-${lockHash}-${process.pid}`);
    try { rmSync(tmpLink, { force: true }); } catch { /* noop */ }
    symlinkSync(finalDir, tmpLink);
    renameSync(tmpLink, currentLink);
  } catch (e) {
    return { ok: false, error: `failed to publish overlay: ${(e as Error).message}` };
  }

  pruneOldOverlays(spaceRoot, lockHash);
  return { ok: true, lockHash, dir: finalDir };
}

function writeManifest(dir: string, meta: Record<string, unknown>): void {
  try {
    writeFileSync(path.join(dir, '.maestro-overlay.json'), JSON.stringify(meta, null, 2), 'utf-8');
  } catch {
    // best-effort
  }
}

/** Keep the N most recently-modified overlay generations besides `current`. */
const OVERLAY_GENERATIONS_KEPT = 3;

/**
 * Best-effort GC that KEEPS the most recent N overlay generations (by mtime),
 * never just the active lockHash. A job that started before an admin edit is
 * still bound (via bwrap ro-bind) to the previous overlay dir; deleting it
 * immediately would break that job's imports mid-run. Retaining a few
 * generations gives in-flight jobs a grace window while still bounding growth.
 */
function pruneOldOverlays(spaceRoot: string, keepLockHash: string): void {
  let entries: string[];
  try {
    entries = readdirSync(spaceRoot);
  } catch {
    return;
  }
  const gens = entries
    .filter((e) => /^[0-9a-f]{16}$/.test(e))
    .map((e) => {
      let mtime = 0;
      try { mtime = statSync(path.join(spaceRoot, e)).mtimeMs; } catch { /* gone */ }
      return { e, mtime };
    })
    .sort((a, b) => b.mtime - a.mtime); // newest first

  const keep = new Set<string>([keepLockHash]);
  for (const g of gens) {
    if (keep.size >= OVERLAY_GENERATIONS_KEPT) break;
    keep.add(g.e);
  }
  for (const g of gens) {
    if (keep.has(g.e)) continue;
    try {
      rmSync(path.join(spaceRoot, g.e), { recursive: true, force: true });
    } catch {
      // best-effort
    }
  }
}
