#!/usr/bin/env python3
"""Bootstrap Node + Python deps via curl (avoids broken Cursor sandbox npm/pip proxy)."""
from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
CURL = shutil.which("curl") or "curl"


def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
    print("+", " ".join(cmd), flush=True)
    return subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=check)


def curl_download(url: str, dest: Path, retries: int = 5) -> None:
    dest.parent.mkdir(parents=True, exist_ok=True)
    if dest.exists() and dest.stat().st_size > 0:
        return
    tmp = dest.with_suffix(dest.suffix + ".partial")
    for attempt in range(1, retries + 1):
        try:
            run(
                [CURL, "-fsSL", "--retry", "5", "--retry-delay", "2", "-o", str(tmp), url],
                check=True,
            )
            tmp.replace(dest)
            return
        except subprocess.CalledProcessError:
            print(f"  retry {attempt}/{retries} for {url}", flush=True)
            if attempt == retries:
                raise


def fetch_json(url: str) -> dict:
    req = urllib.request.Request(url, headers={"User-Agent": "tankfarm-bootstrap/1.0"})
    # Prefer curl for reliability outside sandbox quirks
    proc = subprocess.run(
        [CURL, "-fsSL", url],
        capture_output=True,
        text=True,
        check=True,
    )
    return json.loads(proc.stdout)


def collect_npm_urls(lock_path: Path) -> dict[str, str]:
    """Map package name@version -> resolved tarball URL from package-lock v2/v3."""
    data = json.loads(lock_path.read_text())
    urls: dict[str, str] = {}

    def walk_deps(deps: dict) -> None:
        for name, meta in deps.items():
            if not isinstance(meta, dict):
                continue
            resolved = meta.get("resolved")
            version = meta.get("version")
            if resolved and version and resolved.startswith("http"):
                urls[f"{name}@{version}"] = resolved
            if "dependencies" in meta:
                walk_deps(meta["dependencies"])

    # lockfileVersion 2/3 uses packages map
    packages = data.get("packages") or {}
    for key, meta in packages.items():
        if not key or not isinstance(meta, dict):
            continue
        resolved = meta.get("resolved")
        version = meta.get("version")
        if resolved and version and str(resolved).startswith("http"):
            name = key.split("node_modules/")[-1]
            urls[f"{name}@{version}"] = resolved

    if "dependencies" in data:
        walk_deps(data["dependencies"])

    return urls


def npm_install_via_curl(project: Path) -> None:
    lock = project / "package-lock.json"
    if not lock.exists():
        raise SystemExit(f"missing lockfile: {lock}")

    print(f"\n=== npm: {project} ===", flush=True)
    urls = collect_npm_urls(lock)
    print(f"  {len(urls)} packages to fetch", flush=True)

    tarball_dir = project / ".offline-tarballs"
    tarball_dir.mkdir(exist_ok=True)

    # Rewrite lockfile resolved URLs to local file:// paths
    lock_data = json.loads(lock.read_text())
    url_to_file: dict[str, str] = {}

    for key, url in sorted(urls.items()):
        safe = re.sub(r"[^a-zA-Z0-9._@+-]+", "_", key) + ".tgz"
        dest = tarball_dir / safe
        print(f"  fetch {key}", flush=True)
        curl_download(url, dest)
        url_to_file[url] = dest.resolve().as_uri()

    def rewrite(obj):
        if isinstance(obj, dict):
            if "resolved" in obj and obj["resolved"] in url_to_file:
                obj["resolved"] = url_to_file[obj["resolved"]]
            for v in obj.values():
                rewrite(v)
        elif isinstance(obj, list):
            for v in obj:
                rewrite(v)

    rewrite(lock_data)
    offline_lock = project / "package-lock.offline.json"
    offline_lock.write_text(json.dumps(lock_data, indent=2) + "\n")

    # Swap lockfile temporarily
    backup = project / "package-lock.json.bak-bootstrap"
    shutil.copy2(lock, backup)
    shutil.copy2(offline_lock, lock)

    node_modules = project / "node_modules"
    if node_modules.exists():
        shutil.rmtree(node_modules)

    env = os.environ.copy()
    # Force npm to not use network; file:// URLs only
    try:
        run(
            ["npm", "ci", "--offline", "--no-audit", "--no-fund"],
            cwd=project,
            check=True,
        )
    except subprocess.CalledProcessError:
        # Some npm versions dislike file:// in ci; fall back to install
        run(
            ["npm", "install", "--offline", "--no-audit", "--no-fund"],
            cwd=project,
            check=True,
        )
    finally:
        shutil.copy2(backup, lock)
        backup.unlink(missing_ok=True)

    print(f"  ✓ {project.name} node_modules ready", flush=True)


def python_install_via_curl(backend: Path) -> None:
    print(f"\n=== python: {backend} ===", flush=True)
    req = backend / "requirements.txt"
    packages = []
    for line in req.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        # strip extras: uvicorn[standard] -> uvicorn
        name = re.split(r"[<>=!\[]", line, maxsplit=1)[0].strip()
        packages.append((line, name))

    venv = backend / ".venv"
    if not venv.exists():
        run(["uv", "venv", str(venv)], cwd=backend)

    py = venv / "bin" / "python"
    wheel_dir = backend / ".offline-wheels"
    wheel_dir.mkdir(exist_ok=True)

    # Expand known extras into concrete deps
    extras = {
        "uvicorn": ["uvicorn", "httptools", "uvloop", "watchfiles", "websockets", "httptools"],
        "pydantic": ["pydantic", "email-validator"],
        "python-jose": ["python-jose", "cryptography", "ecdsa", "rsa", "pyasn1"],
    }

    to_fetch = set()
    for raw, name in packages:
        to_fetch.add(name)
        if name in extras:
            to_fetch.update(extras[name])

    # Always include transitive basics often needed
    to_fetch.update(
        [
            "starlette",
            "anyio",
            "sniffio",
            "typing-extensions",
            "annotated-types",
            "pydantic-core",
            "idna",
            "click",
            "h11",
            "pyyaml",
            "dnspython",
            "cffi",
            "pycparser",
            "six",
            "et-xmlfile",
            "charset-normalizer",
            "greenlet",
        ]
    )

    downloaded: list[Path] = []
    for name in sorted(to_fetch):
        print(f"  resolve {name}", flush=True)
        try:
            meta = fetch_json(f"https://pypi.org/pypi/{name}/json")
        except Exception as e:
            print(f"  ! skip {name}: {e}", flush=True)
            continue
        version = meta["info"]["version"]
        files = meta["releases"].get(version) or []
        # Score wheels for this host: x86_64 + cp312/abi3/py3, never foreign arches
        arch = os.uname().machine  # e.g. x86_64
        foreign = ("aarch64", "arm64", "armv7", "i686", "win32", "macosx", "musllinux")
        if arch == "x86_64":
            foreign = tuple(a for a in foreign if a != "x86_64")

        def score(fn: str) -> int:
            if not fn.endswith(".whl"):
                return -1
            low = fn.lower()
            if any(tag in low for tag in ("macosx", "win32", "win_amd64", "musllinux")):
                return -1
            # Accept only pure-python or this machine's linux arch
            is_pure = "none-any" in low
            is_native = ("manylinux" in low or "linux_" in low) and arch in low
            if not is_pure and not is_native:
                return -1
            s = 0
            if is_native:
                s += 100
            elif is_pure:
                s += 50
            if "cp312" in low:
                s += 30
            elif "abi3" in low:
                s += 20
            elif "py3" in low:
                s += 10
            return s

        best = None
        best_score = -1
        sdist = None
        for f in files:
            fn = f["filename"]
            url = f["url"]
            if fn.endswith((".tar.gz", ".zip")):
                sdist = (fn, url)
                continue
            sc = score(fn)
            if sc > best_score:
                best_score = sc
                best = (fn, url)
        chosen = best if best_score >= 0 else sdist
        if not chosen:
            print(f"  ! no artifact for {name}=={version}", flush=True)
            continue
        fn, url = chosen
        dest = wheel_dir / fn
        print(f"  fetch {fn}", flush=True)
        curl_download(url, dest)
        downloaded.append(dest)

    # Install with uv from local dir; allow resolving remaining from PyPI via curl-prefetched set
    # First try fully offline
    cmd = [
        "uv",
        "pip",
        "install",
        "--python",
        str(py),
        "--no-index",
        "--find-links",
        str(wheel_dir),
        "-r",
        str(req),
    ]
    result = run(cmd, cwd=backend, check=False)
    if result.returncode != 0:
        # Install everything we downloaded, then retry requirements
        if downloaded:
            run(
                [
                    "uv",
                    "pip",
                    "install",
                    "--python",
                    str(py),
                    "--no-index",
                    "--find-links",
                    str(wheel_dir),
                    *[str(p) for p in downloaded],
                ],
                cwd=backend,
                check=False,
            )
        result = run(cmd, cwd=backend, check=False)
        if result.returncode != 0:
            # Last resort: install each downloaded wheel
            run(
                [
                    "uv",
                    "pip",
                    "install",
                    "--python",
                    str(py),
                    "--no-index",
                    "--find-links",
                    str(wheel_dir),
                    *[str(p.name) for p in downloaded],
                ],
                cwd=backend,
                check=True,
            )

    run([str(py), "-c", "import fastapi, uvicorn, dotenv, sqlalchemy; print('SUPPLY DEPS OK')"])


def main() -> None:
    targets = sys.argv[1:] or ["all"]
    if "all" in targets or "python" in targets:
        python_install_via_curl(ROOT / "nmdpr_supply" / "backend")
    node_projects = [
        ROOT / "nmdpr_consumption" / "backend",
        ROOT / "nmdpr_consumption" / "frontend",
        ROOT / "nmdpr_supply" / "frontend",
        ROOT / "dashboard",
    ]
    if "all" in targets or "node" in targets:
        for p in node_projects:
            if (p / "package-lock.json").exists() or (p / "package.json").exists():
                if not (p / "package-lock.json").exists():
                    # dashboard may lack lock — generate one by copying consumption frontend deps approach
                    print(f"  skip lockless for now: {p}", flush=True)
                    continue
                npm_install_via_curl(p)
        # Link dashboard to consumption frontend modules if needed
        dash = ROOT / "dashboard"
        nm = dash / "node_modules"
        if not nm.exists() or not (nm / ".bin" / "vite").exists():
            if nm.is_symlink() or nm.exists():
                if nm.is_symlink():
                    nm.unlink()
                else:
                    shutil.rmtree(nm)
            src = ROOT / "nmdpr_consumption" / "frontend" / "node_modules"
            if src.exists():
                nm.symlink_to(src)
                print("  linked dashboard node_modules -> consumption frontend", flush=True)


if __name__ == "__main__":
    main()
