#!/usr/bin/env python3
"""
venvdrop.core — Offline Python Project Bundler & Deployer
==========================================================
Bundles a Python project and its pip dependencies into a single portable
archive for offline deployment on a target machine (the "Offline Wheelhouse"
method).

This module is the entry-point for the `venvdrop` CLI command installed by
the `venvdrop` package.  It can also be run directly:

    python -m venvdrop.core pack  --requirements req.txt --source ./src
    python -m venvdrop.core unpack --archive my_app.zip --dest ./deployed_app

Commands
--------
  pack    : Read requirements.txt, download wheels, zip everything into a
            deployment archive with embedded platform metadata.
  unpack  : Unzip archive, validate platform parity, create a fresh venv,
            bootstrap pip, and install dependencies entirely offline.
"""

import argparse
import ast
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path

# ──────────────────────────────────────────────────────────────────────────────
# Exclusion constants — applied to BOTH the AST scanner and the ZIP archiver
# ──────────────────────────────────────────────────────────────────────────────

# Directories that are never traversed (virtual envs, caches, VCS, IDEs, etc.)
EXCLUDE_DIRS: frozenset[str] = frozenset(
    {
        "venv",
        ".venv",
        "env",
        ".env",
        "__pycache__",
        ".git",
        ".hg",
        ".svn",
        ".idea",
        ".vscode",
        ".pytest_cache",
        ".mypy_cache",
        ".ruff_cache",
        "bundle_cache",  # VenvDrop's own wheel cache
        "node_modules",  # in case of hybrid projects
        "dist",
        "build",  # common build output folders
        ".tox",
    }
)

# File extensions that are compiled / binary artifacts and never useful to pack
EXCLUDE_EXTS: frozenset[str] = frozenset(
    {
        ".pyc",
        ".pyo",
        ".pyd",
        ".so",
        ".dll",  # compiled extensions
        ".egg-info",  # setuptools metadata (directory suffix)
    }
)

# ──────────────────────────────────────────────────────────────────────────────
# Colour helpers (gracefully degraded on Windows < Win10 without ANSI support)
# ──────────────────────────────────────────────────────────────────────────────

RESET = "\033[0m"
RED = "\033[91m"
YELLOW = "\033[93m"
GREEN = "\033[92m"
CYAN = "\033[96m"
BOLD = "\033[1m"


def _supports_color() -> bool:
    """Return True when the terminal likely supports ANSI escape codes."""
    if sys.platform == "win32":
        # Windows 10+ (build 14393+) supports ANSI in cmd/powershell after
        # enabling via ENABLE_VIRTUAL_TERMINAL_PROCESSING; check env flag.
        return (
            os.environ.get("TERM") is not None or os.environ.get("ANSICON") is not None
        )
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()


_COLOR = _supports_color()


def _c(color: str, msg: str) -> str:
    return f"{color}{msg}{RESET}" if _COLOR else msg


def info(msg: str) -> None:
    print(_c(CYAN, f"[INFO]  {msg}"))


def ok(msg: str) -> None:
    print(_c(GREEN, f"[OK]    {msg}"))


def warn(msg: str) -> None:
    print(_c(YELLOW, f"[WARN]  {msg}"))


def error(msg: str) -> None:
    print(_c(RED, f"[ERROR] {msg}"), file=sys.stderr)


def fatal(msg: str) -> None:
    error(msg)
    sys.exit(1)


def banner(title: str) -> None:
    width = 70
    print()
    print(_c(BOLD, "─" * width))
    print(_c(BOLD, f"  {title}"))
    print(_c(BOLD, "─" * width))


# ──────────────────────────────────────────────────────────────────────────────
# Platform metadata helpers
# ──────────────────────────────────────────────────────────────────────────────


def get_platform_meta() -> dict:
    """Collect a snapshot of the current machine's platform information."""
    return {
        "python_version": list(sys.version_info[:3]),  # [major, minor, patch]
        "python_impl": platform.python_implementation(),
        "platform": sys.platform,  # 'win32', 'linux', 'darwin'
        "os_name": os.name,  # 'nt', 'posix'
        "machine": platform.machine(),  # 'AMD64', 'x86_64', 'arm64'
        "processor": platform.processor(),
        "architecture": platform.architecture()[0],  # '64bit', '32bit'
        "packed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
    }


def check_platform_parity(meta: dict) -> list[str]:
    """
    Validate that the current machine is compatible with the archive's origin.
    Returns the command list for a compatible Python executable.

    Raises SystemExit on critical mismatches (OS, arch) or if a compatible
    Python interpreter cannot be found.
    """
    current = get_platform_meta()

    # ── Operating system ─────────────────────────────────────────────────────
    if meta["platform"] != current["platform"]:
        fatal(
            f"OS mismatch! Archive packed on '{meta['platform']}', "
            f"but current platform is '{current['platform']}'. "
            f"Pre-built wheels are not portable across OS families."
        )
    ok(f"Platform: {current['platform']} ✓")

    # ── CPU architecture ─────────────────────────────────────────────────────
    # Normalise common aliases (AMD64 vs x86_64 are the same on Windows vs Linux)
    def normalise_arch(arch: str) -> str:
        arch = arch.lower()
        if arch in ("amd64", "x86_64"):
            return "x86_64"
        if arch in ("aarch64", "arm64"):
            return "arm64"
        return arch

    src_arch = normalise_arch(meta.get("machine", ""))
    cur_arch = normalise_arch(current["machine"])
    if src_arch and cur_arch and src_arch != cur_arch:
        fatal(
            f"Architecture mismatch! Archive packed on '{src_arch}', "
            f"current machine is '{cur_arch}'. "
            f"Wheels compiled for one architecture will not run on another."
        )
    ok(f"Architecture: {current['machine']} ✓")

    # ── Python version (major + minor must match) ────────────────────────────
    src_ver = meta["python_version"][:2]
    cur_ver = current["python_version"][:2]

    candidate_cmd = [sys.executable]

    if src_ver != cur_ver:
        req_major, req_minor = src_ver[0], src_ver[1]
        warn(
            f"Python version mismatch. "
            f"Archive requires Python {req_major}.{req_minor}, "
            f"but host default is {cur_ver[0]}.{cur_ver[1]}."
        )
        info("Attempting to auto-discover a compatible Python executable...")

        discovered_cmd = None
        if sys.platform == "win32":
            # Windows Python Launcher (py.exe) can select a specific version
            py_launcher = shutil.which("py")
            if py_launcher:
                discovered_cmd = [py_launcher, f"-{req_major}.{req_minor}"]
        else:
            # Unix: try pythonX.Y binary directly
            py_path = shutil.which(f"python{req_major}.{req_minor}")
            if py_path:
                discovered_cmd = [py_path]

        # Subprocess validation helper — confirms the exe reports the right version
        def check_exe(exe_cmd: list[str]) -> bool:
            try:
                cmd = exe_cmd + [
                    "-c",
                    "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
                ]
                result = subprocess.run(
                    cmd, capture_output=True, text=True, check=False
                )
                return (
                    result.returncode == 0
                    and result.stdout.strip() == f"{req_major}.{req_minor}"
                )
            except Exception:
                return False

        if discovered_cmd and check_exe(discovered_cmd):
            ok(f"Auto-discovered compatible Python: {' '.join(discovered_cmd)}")
            candidate_cmd = discovered_cmd
        else:
            warn("Auto-discovery failed. Please provide the path manually or abort.")
            while True:
                user_exe = input(
                    f"Enter absolute path to Python {req_major}.{req_minor} "
                    f"executable (or 'a' to abort): "
                ).strip()
                if user_exe.lower() == "a":
                    fatal("Aborted by user.")
                if not user_exe:
                    continue
                if check_exe([user_exe]):
                    ok(f"Validated user-provided Python: {user_exe}")
                    candidate_cmd = [user_exe]
                    break
                else:
                    error(
                        f"Provided executable does not appear to be "
                        f"Python {req_major}.{req_minor}. Try again."
                    )

    ok(f"Python version: {src_ver[0]}.{src_ver[1]} ✓")
    return candidate_cmd


# ──────────────────────────────────────────────────────────────────────────────
# Static analysis helpers
# ──────────────────────────────────────────────────────────────────────────────


def _is_absolute_path_string(node: ast.Constant) -> bool:
    """Return True if a constant string looks like an absolute filesystem path."""
    if not isinstance(node.value, str):
        return False
    value = node.value
    # POSIX absolute paths start with '/'
    if value.startswith("/"):
        return True
    # Windows absolute paths: C:\, D:/, \\server\share, etc.
    if re.match(r"^[A-Za-z]:[/\\]", value):
        return True
    if value.startswith(r"\\"):
        return True
    return False


def scan_for_absolute_paths(source_dir: Path) -> list[tuple[str, int, str]]:
    """
    Walk *source_dir* and parse every .py file with the AST module.
    Returns a list of (relative_file_path, line_number, offending_string)
    for every hardcoded absolute path constant found.

    Uses os.walk with in-place dirs pruning so the traversal never descends
    into virtual-environment or cache directories (see EXCLUDE_DIRS).
    """
    hits = []
    for root, dirs, files in os.walk(source_dir):
        # ── Prune traversal in-place — critical for speed ────────────────────
        # Modifying dirs[:] tells os.walk not to recurse into those sub-dirs
        # at all, rather than simply filtering the results afterward.
        dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]

        for filename in files:
            if not filename.endswith(".py"):
                continue
            py_file = Path(root) / filename
            try:
                tree = ast.parse(py_file.read_text(encoding="utf-8", errors="replace"))
            except SyntaxError:
                warn(f"Could not parse {py_file} (syntax error) — skipping AST scan.")
                continue
            for node in ast.walk(tree):
                if isinstance(node, ast.Constant) and _is_absolute_path_string(node):
                    rel = str(py_file.relative_to(source_dir))
                    hits.append((rel, node.lineno, repr(node.value)))
    return hits


# ──────────────────────────────────────────────────────────────────────────────
# Bundle cache helpers
# ──────────────────────────────────────────────────────────────────────────────


def check_for_sdists(bundle_cache: Path) -> None:
    """
    Scan *bundle_cache* for source distributions (.tar.gz / .zip sdists).
    Emit a critical warning listing every affected package, because sdists
    require a compiler on the target machine and will break a purely offline
    wheel-based install.
    """
    sdists = []
    for f in bundle_cache.glob("*.tar.gz"):
        sdists.append(f.name)
    for f in bundle_cache.glob("*.zip"):
        # Distinguish genuine sdist .zip from wheel .zip (wheels end in .whl)
        if not f.stem.endswith(".whl"):
            sdists.append(f.name)

    if sdists:
        warn(
            "CRITICAL — Source distributions (sdists) detected in bundle_cache!\n"
            "  These packages do NOT have a pre-built binary wheel and will require\n"
            "  a C/C++ compiler (MSVC, GCC, Clang) on the target machine to build:\n"
        )
        for name in sdists:
            print(f"    • {name}")
        warn(
            "\n  ► Recommendation: Re-run pip download with --only-binary=:all:\n"
            "    pip download --only-binary=:all: -r requirements.txt -d ./bundle_cache\n"
            "  If a binary wheel genuinely does not exist for your platform you will\n"
            "  need to cross-compile or build on a matching machine first."
        )


# ──────────────────────────────────────────────────────────────────────────────
# Venv / pip helpers
# ──────────────────────────────────────────────────────────────────────────────


def _venv_python(venv_dir: Path) -> Path:
    """Return the path to the Python executable inside a venv."""
    if sys.platform == "win32":
        return venv_dir / "Scripts" / "python.exe"
    return venv_dir / "bin" / "python"


def _venv_pip(venv_dir: Path) -> Path:
    """Return the path to the pip executable inside a venv."""
    if sys.platform == "win32":
        return venv_dir / "Scripts" / "pip.exe"
    return venv_dir / "bin" / "pip"


def run_cmd(
    cmd: list[str], description: str, capture: bool = False
) -> subprocess.CompletedProcess:
    """
    Run an external command, stream output to the console, and raise on failure.
    """
    info(f"{description}…")
    info(f"  $ {' '.join(str(c) for c in cmd)}")

    result = subprocess.run(
        cmd,
        capture_output=capture,
        text=True,
        check=False,
    )

    if result.returncode != 0:
        if capture:
            error(result.stderr or result.stdout)
        fatal(f"Command failed with exit code {result.returncode}.")
    return result


# ──────────────────────────────────────────────────────────────────────────────
# Venv-reuse helpers (for --from-venv)
# ──────────────────────────────────────────────────────────────────────────────


def _get_pip_cache_dir(python_exe: Path) -> Path | None:
    """
    Ask the given Python interpreter where pip stores its wheel cache.
    Returns the Path to the cache directory, or None if the query fails.
    """
    try:
        result = subprocess.run(
            [str(python_exe), "-m", "pip", "cache", "dir"],
            capture_output=True,
            text=True,
            check=False,
        )
        if result.returncode == 0:
            cache_path = Path(result.stdout.strip())
            if cache_path.is_dir():
                return cache_path
    except Exception:
        pass
    return None


def _reuse_venv_wheels(
    venv_dir: Path,
    requirements: Path,
    cache_dir: Path,
) -> None:
    """
    Populate *cache_dir* with wheels for the packages listed in *requirements*
    by reusing what is already installed in *venv_dir* — no fresh internet
    download needed for packages that are already cached.

    Strategy
    --------
    1. Locate the venv's Python and pip executables.
    2. Ask pip where its wheel cache lives on this machine.
    3. Run ``pip download --find-links <pip_cache> --no-index`` so pip resolves
       everything from the local cache only (fast, offline).
    4. For any package that could *not* be satisfied from the cache, fall back
       to a targeted network download (only the missing packages).

    Parameters
    ----------
    venv_dir:
        Root of an existing virtual environment (must contain Scripts/python.exe
        on Windows or bin/python on Unix).
    requirements:
        Path to the requirements file listing packages to bundle.
    cache_dir:
        Destination directory for collected wheel files.
    """
    venv_python = _venv_python(venv_dir)
    if not venv_python.is_file():
        fatal(
            f"Cannot find Python executable in the supplied venv: {venv_python}\n"
            "  Ensure you passed the venv *root* directory (the folder that "
            "contains Scripts/ or bin/).  Example: --from-venv .venv"
        )

    ok(f"Found venv Python: {venv_python}")

    # ── Locate pip wheel cache ───────────────────────────────────────────────
    pip_cache = _get_pip_cache_dir(venv_python)
    if pip_cache:
        ok(f"pip wheel cache: {pip_cache}")
    else:
        warn(
            "Could not determine pip's wheel cache directory from the venv. "
            "All packages will be downloaded from the internet."
        )

    # ── Step 1: try to satisfy everything from local cache (no internet) ─────
    cache_dir.mkdir(parents=True, exist_ok=True)

    # pip stores pre-built wheels under <cache>/wheels/ (NOT http-v2/).
    # We copy every .whl found there into a single flat staging directory so
    # we can pass exactly ONE --find-links argument.  This avoids the Windows
    # WinError 206 "command line too long" error that occurs when recursively
    # adding thousands of pip cache sub-directories as separate --find-links
    # arguments.
    with tempfile.TemporaryDirectory(prefix="venvdrop_flat_cache_") as flat_tmp:
        flat_dir = Path(flat_tmp)

        if pip_cache:
            wheel_subdir = pip_cache / "wheels"
            if wheel_subdir.is_dir():
                copied = 0
                for whl in wheel_subdir.rglob("*.whl"):
                    dest_whl = flat_dir / whl.name
                    # If two cache entries have the same basename, keep the first
                    if not dest_whl.exists():
                        shutil.copy2(whl, dest_whl)
                        copied += 1
                info(f"Staged {copied} cached wheel(s) into flat staging directory.")
            else:
                info("pip wheels sub-directory not found — cache may be empty.")

        offline_cmd = [
            str(venv_python),
            "-m",
            "pip",
            "download",
            "--no-index",   # never hit the network
            "--find-links", str(flat_dir),  # single argument — no length limit
            "-r",
            str(requirements),
            "-d",
            str(cache_dir),
        ]

        info("Attempting to collect wheels from pip's local cache (no internet)…")
        info(f"  $ {' '.join(str(c) for c in offline_cmd)}")
        offline_result = subprocess.run(
            offline_cmd, capture_output=True, text=True, check=False
        )

    if offline_result.returncode == 0:
        ok("All packages collected from local pip cache — no download needed ✓")
        return

    # ── Step 2: parse which packages failed, fall back to targeted download ──
    warn(
        "Some packages were not found in pip's local cache and will be "
        "downloaded from the internet."
    )

    # pip prints lines like:
    #   ERROR: Could not find a version that satisfies the requirement <pkg>
    # Extract package names so we can report them clearly.
    missing: list[str] = []
    for line in (offline_result.stderr + offline_result.stdout).splitlines():
        m = re.search(
            r"Could not find a version that satisfies the requirement (\S+)", line
        )
        if m:
            missing.append(m.group(1))

    if missing:
        warn(f"Packages not found in cache ({len(missing)}):")
        for pkg in missing:
            print(f"    • {pkg}")

    # Fall back: regular pip download (with internet) for *all* requirements.
    # This is a best-effort attempt to collect any wheels not found in cache.
    info("Falling back to internet download for missing packages…")
    run_cmd(
        [
            str(venv_python),
            "-m",
            "pip",
            "download",
            "-r",
            str(requirements),
            "-d",
            str(cache_dir),
        ],
        "Downloading missing packages from PyPI",
    )
    ok("All packages collected (mix of cache + fresh download) ✓")


# ──────────────────────────────────────────────────────────────────────────────
# PACK command
# ──────────────────────────────────────────────────────────────────────────────


def cmd_pack(args: argparse.Namespace) -> None:
    banner("VenvDrop — PACK")

    requirements = Path(args.requirements).resolve()
    source_dir = Path(args.source).resolve()
    output = Path(args.output).resolve()
    cache_name = "bundle_cache"

    # ── Validate inputs ──────────────────────────────────────────────────────
    if not requirements.is_file():
        fatal(f"requirements.txt not found: {requirements}")
    if not source_dir.is_dir():
        fatal(f"Source directory not found: {source_dir}")

    info(f"Requirements : {requirements}")
    info(f"Source dir   : {source_dir}")
    info(f"Output file  : {output}")

    # ── Static analysis: absolute paths ─────────────────────────────────────
    info("Scanning Python source files for hardcoded absolute paths…")
    abs_hits = scan_for_absolute_paths(source_dir)
    if abs_hits:
        warn(
            f"Found {len(abs_hits)} hardcoded absolute path(s) in source files.\n"
            "  These will likely break on the target machine:\n"
        )
        for rel_file, lineno, value in abs_hits:
            print(f"    {rel_file}:{lineno}  →  {value}")
        warn(
            "\n  ► Consider replacing absolute paths with relative paths or\n"
            "    environment-variable-based configuration before packing."
        )
    else:
        ok("No hardcoded absolute paths detected.")

    # ── Create a temporary working directory ─────────────────────────────────
    with tempfile.TemporaryDirectory(prefix="venvdrop_") as tmpdir:
        tmp = Path(tmpdir)
        cache_dir = tmp / cache_name

        # ── Collect dependency wheels ────────────────────────────────────────
        if args.from_venv:
            # Reuse wheels from an existing venv — no fresh download needed
            # for packages already in pip's local cache.
            from_venv_dir = Path(args.from_venv).resolve()
            if not from_venv_dir.is_dir():
                fatal(f"--from-venv path is not a directory: {from_venv_dir}")
            info(f"Reusing existing venv: {from_venv_dir}")
            _reuse_venv_wheels(from_venv_dir, requirements, cache_dir)
        else:
            # Default: download everything fresh from PyPI
            run_cmd(
                [
                    sys.executable,
                    "-m",
                    "pip",
                    "download",
                    "-r",
                    str(requirements),
                    "-d",
                    str(cache_dir),
                ],
                "Downloading pip dependencies into bundle_cache",
            )

        # ── Detect sdists ────────────────────────────────────────────────────
        check_for_sdists(cache_dir)

        # ── Embed platform metadata ──────────────────────────────────────────
        meta = get_platform_meta()
        meta_file = tmp / "bundle_meta.json"
        meta_file.write_text(json.dumps(meta, indent=2), encoding="utf-8")
        info(
            f"Platform metadata written: Python {meta['python_version'][:2]}, "
            f"{meta['platform']}, {meta['machine']}"
        )

        # ── Generate a SHA-256 manifest for the cache ────────────────────────
        manifest: dict[str, str] = {}
        for f in sorted(cache_dir.rglob("*")):
            if f.is_file():
                rel = str(f.relative_to(tmp))
                sha = hashlib.sha256(f.read_bytes()).hexdigest()
                manifest[rel] = sha
        manifest_file = tmp / "manifest.json"
        manifest_file.write_text(json.dumps(manifest, indent=2), encoding="utf-8")

        # ── Write the output ZIP ─────────────────────────────────────────────
        info(f"Creating archive: {output}")
        with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf:

            # 1. bundle_cache (wheels)
            for f in sorted(cache_dir.rglob("*")):
                if f.is_file():
                    zf.write(f, f.relative_to(tmp))

            # 2. Source code — traverse with in-place directory pruning so
            #    venvs, caches, and VCS folders are never entered.
            src_root = Path("source")
            for root, dirs, files in os.walk(source_dir):
                dirs[:] = sorted(d for d in dirs if d not in EXCLUDE_DIRS)
                for filename in sorted(files):
                    if any(filename.endswith(ext) for ext in EXCLUDE_EXTS):
                        continue
                    f = Path(root) / filename
                    # Never include the output archive itself
                    if f.resolve() == output:
                        continue
                    zf.write(f, src_root / f.relative_to(source_dir))

            # 3. requirements.txt at archive root (needed for offline install)
            zf.write(requirements, "requirements.txt")

            # 4. Metadata & manifest
            zf.write(meta_file, "bundle_meta.json")
            zf.write(manifest_file, "manifest.json")

        size_mb = output.stat().st_size / 1_048_576
        ok(f"Archive created: {output}  ({size_mb:.1f} MB)")

    banner("PACK complete")
    print()
    info("To deploy on the target machine, run:")
    print(f"  venvdrop unpack --archive {output.name} --dest ./deployed_app")
    print()


# ──────────────────────────────────────────────────────────────────────────────
# UNPACK command
# ──────────────────────────────────────────────────────────────────────────────


def cmd_unpack(args: argparse.Namespace) -> None:
    banner("VenvDrop — UNPACK")

    archive = Path(args.archive).resolve()
    dest = Path(args.dest).resolve()

    # ── Validate inputs ──────────────────────────────────────────────────────
    if not archive.is_file():
        fatal(f"Archive not found: {archive}")

    info(f"Archive : {archive}")
    info(f"Dest    : {dest}")

    # ── Windows long-path guard ──────────────────────────────────────────────
    if sys.platform == "win32":
        dest_len = len(str(dest))
        if dest_len > 200:
            warn(
                f"Target path length is {dest_len} characters (> 200).\n"
                "  Nested venv paths may exceed Windows' 260-character MAX_PATH limit,\n"
                "  causing cryptic installation failures.\n"
                "  ► Recommendation: Choose a shorter destination path, e.g.  C:\\app"
            )
        else:
            ok(f"Windows path length: {dest_len} chars ✓")

    # ── Load and validate archive metadata ──────────────────────────────────
    info("Reading archive metadata…")
    try:
        with zipfile.ZipFile(archive, "r") as zf:
            if "bundle_meta.json" not in zf.namelist():
                fatal(
                    "bundle_meta.json not found in archive. "
                    "This archive may not have been created by VenvDrop."
                )
            meta = json.loads(zf.read("bundle_meta.json").decode())
    except zipfile.BadZipFile:
        fatal(f"'{archive}' is not a valid ZIP archive.")

    # ── Platform parity checks ───────────────────────────────────────────────
    python_cmd = check_platform_parity(meta)

    # ── Venv collision detection ─────────────────────────────────────────────
    venv_dir = dest / "venv"
    if venv_dir.exists():
        warn(
            f"An existing virtual environment was found at: {venv_dir}\n"
            "  Overwriting it may corrupt cached state.\n"
        )
        print("  Choose an action:")
        print("    [o] Overwrite   — delete existing venv and create a fresh one")
        print("    [b] Backup      — rename existing venv to venv_backup_<timestamp>")
        print("    [a] Abort       — exit without making any changes")
        while True:
            choice = input("  Enter choice [o/b/a]: ").strip().lower()
            if choice == "o":
                info("Removing existing venv…")
                shutil.rmtree(venv_dir)
                break
            elif choice == "b":
                ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
                backup = dest / f"venv_backup_{ts}"
                info(f"Renaming {venv_dir.name} → {backup.name}…")
                venv_dir.rename(backup)
                ok(f"Backed up to: {backup}")
                break
            elif choice == "a":
                info("Aborting unpack at user request.")
                sys.exit(0)
            else:
                warn("Invalid choice — please enter 'o', 'b', or 'a'.")

    # ── Unzip archive ────────────────────────────────────────────────────────
    info(f"Extracting archive to {dest}…")
    dest.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(archive, "r") as zf:
        zf.extractall(dest)
    ok("Archive extracted.")

    # ── Verify manifest (integrity check) ────────────────────────────────────
    manifest_path = dest / "manifest.json"
    if manifest_path.is_file():
        info("Verifying bundle integrity…")
        manifest: dict = json.loads(manifest_path.read_text())
        failures = []
        for rel_path, expected_sha in manifest.items():
            full_path = dest / rel_path
            if not full_path.is_file():
                failures.append(f"MISSING: {rel_path}")
                continue
            actual_sha = hashlib.sha256(full_path.read_bytes()).hexdigest()
            if actual_sha != expected_sha:
                failures.append(f"CORRUPTED: {rel_path}")
        if failures:
            warn(f"{len(failures)} integrity check failure(s):")
            for f in failures:
                print(f"    • {f}")
            warn("The archive may be incomplete or tampered. Proceeding with caution.")
        else:
            ok(f"All {len(manifest)} bundle files verified ✓")
    else:
        warn("manifest.json not found — integrity check skipped.")

    # ── Create fresh virtual environment ────────────────────────────────────
    info(f"Creating virtual environment at {venv_dir}…")
    run_cmd(
        python_cmd + ["-m", "venv", str(venv_dir)],
        "Creating venv",
    )
    ok("Virtual environment created.")

    venv_python = _venv_python(venv_dir)
    if not venv_python.is_file():
        fatal(f"venv Python executable not found: {venv_python}")

    # ── Bootstrap pip (PEP 517 / modern wheel compat) ───────────────────────
    info("Bootstrapping pip inside venv (required for modern wheel compatibility)…")
    run_cmd(
        [str(venv_python), "-m", "pip", "install", "--upgrade", "pip"],
        "Upgrading pip",
    )
    ok("pip bootstrapped.")

    # ── Install dependencies offline ─────────────────────────────────────────
    cache_dir = dest / "bundle_cache"
    requirements = dest / "requirements.txt"

    if not cache_dir.is_dir():
        fatal(f"bundle_cache directory not found in extracted archive: {cache_dir}")
    if not requirements.is_file():
        fatal(f"requirements.txt not found in extracted archive: {requirements}")

    info("Installing dependencies offline from bundle_cache…")
    run_cmd(
        [
            str(venv_python),
            "-m",
            "pip",
            "install",
            "--no-index",
            "--find-links",
            str(cache_dir),
            "-r",
            str(requirements),
        ],
        "Offline pip install",
    )
    ok("All dependencies installed offline ✓")

    # ── System-level dependency warnings ────────────────────────────────────
    banner("Post-Install Warnings — External System Dependencies")
    warn(
        "The following system-level dependencies are NOT included in this bundle.\n"
        "  If any installed package requires them, you must install them manually\n"
        "  on the target machine:\n"
    )
    system_deps = [
        (
            "NVIDIA CUDA Toolkit",
            "Required by packages like torch, tensorflow-gpu, cupy.\n"
            "    Download: https://developer.nvidia.com/cuda-downloads",
        ),
        (
            "Visual C++ Redistributable (Windows)",
            "Required by many compiled Python extensions on Windows.\n"
            "    Download: https://aka.ms/vs/17/release/vc_redist.x64.exe",
        ),
        (
            "OpenSSL / libssl",
            "Required by cryptography, pyOpenSSL, and similar packages.\n"
            "    Install via OS package manager (apt, yum, brew, etc.).",
        ),
        (
            "ODBC / database drivers",
            "Required by pyodbc, psycopg2-binary, cx_Oracle, etc.\n"
            "    Install the vendor-specific driver matching your server.",
        ),
    ]
    for name, description in system_deps:
        print(f"  {_c(YELLOW, '▸')} {_c(BOLD, name)}")
        print(f"    {description}\n")

    banner("UNPACK complete")
    print()
    ok("Deployment finished. Activate your environment with:")
    if sys.platform == "win32":
        print(f"  {venv_dir}\\Scripts\\activate")
    else:
        print(f"  source {venv_dir}/bin/activate")
    print()


# ──────────────────────────────────────────────────────────────────────────────
# CLI entry-point
# ──────────────────────────────────────────────────────────────────────────────


def build_parser() -> argparse.ArgumentParser:
    from venvdrop import __version__

    parser = argparse.ArgumentParser(
        prog="venvdrop",
        description=(
            "VenvDrop — Offline Python Project Bundler & Deployer\n"
            "\n"
            "Bundle a Python project and all its pip dependencies into a single\n"
            "portable .zip archive, then deploy it on any offline / air-gapped\n"
            "machine without internet access — using the Offline Wheelhouse method."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Commands:\n"
            "  pack     Bundle source + wheels into a .zip archive (run on internet machine)\n"
            "  unpack   Deploy archive on the offline/air-gapped target machine\n"
            "\n"
            "Examples:\n"
            "  # Pack your project (internet machine):\n"
            "  venvdrop pack -r requirements.txt -s ./src -o my_app.zip\n"
            "\n"
            "  # Pack reusing an existing venv (faster, no re-download):\n"
            "  venvdrop pack -r requirements.txt -s ./src -o my_app.zip --from-venv .venv\n"
            "\n"
            "  # Deploy on offline machine:\n"
            "  venvdrop unpack -a my_app.zip -d ./deployed_app\n"
            "\n"
            "Documentation: https://github.com/ashtharthasarathi/venvdrop\n"
            "Report bugs:   https://github.com/ashtharthasarathi/venvdrop/issues"
        ),
    )

    parser.add_argument(
        "-V",
        "--version",
        action="version",
        version=f"venvdrop {__version__}",
        help="Show program version and exit.",
    )

    sub = parser.add_subparsers(dest="command", metavar="COMMAND")
    sub.required = True

    # ── pack ──────────────────────────────────────────────────────────────────
    pack_p = sub.add_parser(
        "pack",
        help="Bundle source code + wheels into a portable .zip archive.",
        description=(
            "VenvDrop PACK — Create a portable deployment archive\n"
            "\n"
            "Downloads all pip dependencies as pre-built binary wheels, scans\n"
            "your source code for portability issues, embeds platform metadata,\n"
            "generates a SHA-256 integrity manifest, and zips everything into a\n"
            "single self-contained archive ready for offline deployment."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            "  # Standard pack (downloads fresh from PyPI):\n"
            "  venvdrop pack -r requirements.txt -s . -o my_app.zip\n"
            "\n"
            "  # Fast pack reusing an existing venv's cached wheels (no download):\n"
            "  venvdrop pack -r requirements.txt -s . -o my_app.zip --from-venv .venv\n"
            "\n"
            "  # Custom source directory and output name:\n"
            "  venvdrop pack -r requirements.txt -s ./src -o releases/v1.2.zip"
        ),
    )
    pack_p.add_argument(
        "-r",
        "--requirements",
        default="requirements.txt",
        metavar="FILE",
        help=(
            "Path to the pip requirements file listing all dependencies to bundle. "
            "(default: requirements.txt)"
        ),
    )
    pack_p.add_argument(
        "-s",
        "--source",
        default=".",
        metavar="DIR",
        help=(
            "Root directory of the project source code to include in the archive. "
            "Virtual environments, caches (.git, __pycache__, node_modules, etc.) "
            "are excluded automatically. (default: current directory)"
        ),
    )
    pack_p.add_argument(
        "-o",
        "--output",
        default="app_bundle.zip",
        metavar="FILE",
        help=(
            "Output filename for the generated .zip archive. "
            "(default: app_bundle.zip)"
        ),
    )
    pack_p.add_argument(
        "--from-venv",
        metavar="DIR",
        default=None,
        help=(
            "Reuse an existing virtual environment's pip wheel cache instead of "
            "downloading packages from PyPI. VenvDrop copies all cached .whl files "
            "into a flat staging directory and tries to satisfy every requirement "
            "offline first (pip download --no-index). Only packages genuinely absent "
            "from the local cache fall back to a network download. "
            "Pass the venv ROOT directory (the folder containing Scripts/ or bin/). "
            "Example: --from-venv .venv"
        ),
    )
    pack_p.set_defaults(func=cmd_pack)

    # ── unpack ────────────────────────────────────────────────────────────────
    unpack_p = sub.add_parser(
        "unpack",
        help="Deploy a VenvDrop archive on the target (offline) machine.",
        description=(
            "VenvDrop UNPACK — Deploy an archive on an offline machine\n"
            "\n"
            "Validates OS, CPU architecture, and Python version compatibility,\n"
            "verifies bundle integrity via SHA-256 manifest, extracts the archive,\n"
            "creates a fresh virtual environment, and installs all dependencies\n"
            "entirely offline from the bundled wheel cache."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            "  # Deploy to the default ./deployed_app directory:\n"
            "  venvdrop unpack -a my_app.zip\n"
            "\n"
            "  # Deploy to a custom location:\n"
            "  venvdrop unpack -a my_app.zip -d C:\\myapp\n"
            "  venvdrop unpack -a my_app.zip -d /opt/myapp\n"
            "\n"
            "After unpacking, activate the environment:\n"
            "  Windows : .\\deployed_app\\venv\\Scripts\\activate\n"
            "  Unix    : source ./deployed_app/venv/bin/activate"
        ),
    )
    unpack_p.add_argument(
        "-a",
        "--archive",
        required=True,
        metavar="FILE",
        help=(
            "Path to the .zip bundle archive previously created by 'venvdrop pack'. "
            "Must be a valid VenvDrop archive containing bundle_meta.json."
        ),
    )
    unpack_p.add_argument(
        "-d",
        "--dest",
        default="./deployed_app",
        metavar="DIR",
        help=(
            "Destination directory to deploy the application into. "
            "The directory is created if it does not exist. "
            "On Windows, keep this path short (under 180 chars) to avoid MAX_PATH issues. "
            "(default: ./deployed_app)"
        ),
    )
    unpack_p.set_defaults(func=cmd_unpack)

    return parser



def main() -> None:
    """CLI entry-point registered by pyproject.toml [project.scripts]."""
    parser = build_parser()
    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
