"""Pro bundle lifecycle: download, verify, extract, load."""

from __future__ import annotations

import hashlib
import hmac
import importlib
import json
import logging
import os
import shutil
import sys
import uuid
import zipfile
from pathlib import Path
from typing import Callable

import httpx

from godotiq import __version__, _bundle_hashes
from godotiq.license import is_pro
from godotiq.paths import bundle_cache_dir

logger = logging.getLogger(__name__)

_DEFAULT_BUNDLE_URL = "https://pro.godotiq.com/api/pro-bundle"
_CONNECT_TIMEOUT = 5   # seconds
_READ_TIMEOUT = 15     # seconds

# Module-level state
_tool_impls: dict[str, Callable] | None = None
_status: str = "community"
_loaded_bundle_root: Path | None = None


def _reset() -> None:
    """Reset module state. For testing only."""
    global _tool_impls, _status, _loaded_bundle_root
    _tool_impls = None
    _status = "community"
    _loaded_bundle_root = None


def _bundle_url() -> str:
    """Return the Worker endpoint URL, overridable via env var."""
    return os.environ.get("GODOTIQ_BUNDLE_URL", _DEFAULT_BUNDLE_URL)


def _version_tuple(v: str) -> tuple[int, ...]:
    """Parse '0.4.0' into (0, 4, 0) for comparison."""
    clean = v.split("-")[0].split("+")[0]
    return tuple(int(x) for x in clean.split("."))


def _is_version_compatible(manifest: dict) -> bool:
    """Check if the current __version__ is within manifest bounds."""
    if __version__ == "0.0.0-dev":
        return True
    current = _version_tuple(__version__)
    min_v = _version_tuple(manifest.get("min_godotiq", "0.0.0"))
    max_v = _version_tuple(manifest.get("max_godotiq", "99.99.99"))
    return min_v <= current <= max_v


def _validate_bundle_structure(extract_dir: Path) -> bool:
    """Check for manifest.json, godotiq_pro/__init__.py, godotiq_pro/registry.py."""
    required = [
        extract_dir / "manifest.json",
        extract_dir / "godotiq_pro" / "__init__.py",
        extract_dir / "godotiq_pro" / "registry.py",
    ]
    return all(f.is_file() for f in required)


def _verify_bundle_hash(file_path: Path, header_hash: str) -> bool:
    """Verify downloaded bundle against pinned hash and header hash.

    Returns True only if:
    1. Computed SHA-256 matches a pinned hash in _bundle_hashes.PINNED_BUNDLES
    2. Computed SHA-256 matches the X-Content-SHA256 header
    """
    computed = hashlib.sha256(file_path.read_bytes()).hexdigest()
    pinned_match = any(
        hmac.compare_digest(entry["sha256"], computed)
        for entry in _bundle_hashes.PINNED_BUNDLES.values()
    )
    header_match = hmac.compare_digest(computed, header_hash)
    return pinned_match and header_match


def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
    """Extract zip contents, rejecting members that escape the destination."""
    dest = dest.resolve()
    for member in zf.infolist():
        target = (dest / member.filename).resolve()
        if not str(target).startswith(str(dest) + os.sep) and target != dest:
            raise ValueError(f"Zip member escapes destination: {member.filename}")
    zf.extractall(dest)


def _load_bundle(bundle_dir: Path) -> bool:
    """Load a verified bundle into the process.

    Handles sys.path/sys.modules cleanup and imports godotiq_pro.registry.
    Returns True on success. Does NOT set _status — caller owns lifecycle state.
    """
    global _tool_impls, _loaded_bundle_root

    # Clean sys.path: remove any existing bundle root
    if _loaded_bundle_root is not None:
        root_str = str(_loaded_bundle_root)
        sys.path[:] = [p for p in sys.path if p != root_str]

    # Clean sys.modules: remove all godotiq_pro.* entries
    for key in list(sys.modules.keys()):
        if key.startswith("godotiq_pro"):
            del sys.modules[key]

    # Add bundle dir to sys.path and import
    bundle_str = str(bundle_dir)
    sys.path.insert(0, bundle_str)
    _loaded_bundle_root = bundle_dir

    try:
        registry = importlib.import_module("godotiq_pro.registry")
        _tool_impls = registry.TOOL_IMPLS
        return True
    except Exception:
        logger.error("Failed to import godotiq_pro.registry from %s", bundle_dir)
        # Rollback sys.path on import failure
        sys.path[:] = [p for p in sys.path if p != bundle_str]
        _loaded_bundle_root = None
        _tool_impls = None
        return False


def _find_compatible_cached_bundle(cache_dir: Path) -> Path | None:
    """Find the best compatible cached bundle directory."""
    if not cache_dir.exists():
        return None

    best: Path | None = None
    best_version: tuple[int, ...] = (0,)

    for entry in cache_dir.iterdir():
        if not entry.is_dir() or not entry.name.startswith("godotiq_pro-"):
            continue
        manifest_path = entry / "manifest.json"
        if not manifest_path.is_file():
            continue
        try:
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
            if _is_version_compatible(manifest):
                v = _version_tuple(manifest.get("bundle_version", manifest.get("version", "0.0.0")))
                if v > best_version:
                    best = entry
                    best_version = v
        except Exception:
            continue

    return best


def _download_and_install_bundle(cache_dir: Path) -> None:
    """Download, verify, extract, and load the Pro bundle."""
    global _status

    url = _bundle_url()
    license_key = os.environ.get("GODOTIQ_LICENSE_KEY", "")

    for attempt in range(2):  # retry once on hash mismatch only
        tmp_zip: Path | None = None
        try:
            with httpx.Client(
                timeout=httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
            ) as client:
                resp = client.post(
                    url,
                    headers={"X-License-Key": license_key},
                    json={"godotiq_version": __version__},
                )

                if resp.status_code != 200:
                    logger.warning("Bundle download failed: HTTP %s", resp.status_code)
                    _status = "community"
                    return

                bundle_data = resp.content
                header_hash = resp.headers.get("X-Content-SHA256", "")

            # Write to temp file
            tmp_zip = cache_dir / f".tmp-{uuid.uuid4()}.zip"
            tmp_zip.write_bytes(bundle_data)

            # Verify hash
            if not _verify_bundle_hash(tmp_zip, header_hash):
                logger.warning("Bundle hash verification failed (attempt %d/2)", attempt + 1)
                tmp_zip.unlink(missing_ok=True)
                tmp_zip = None
                if attempt == 0:
                    continue  # retry once

                # Retry exhausted — try fallback
                fallback = _find_compatible_cached_bundle(cache_dir)
                if fallback is not None and _load_bundle(fallback):
                    _status = "degraded_integrity_warning"
                    logger.warning("Using fallback cached bundle: %s", fallback)
                else:
                    _status = "degraded_integrity_failure"
                    logger.error("Hash verification failed, no fallback available")
                return

            # Extract to temp dir
            tmp_extract = cache_dir / f".tmp-{uuid.uuid4()}"
            try:
                with zipfile.ZipFile(tmp_zip, "r") as zf:
                    _safe_extract(zf, tmp_extract)

                # Validate structure
                if not _validate_bundle_structure(tmp_extract):
                    logger.error("Bundle structure validation failed")
                    _status = "community"
                    return

                # Read manifest and check version
                manifest = json.loads(
                    (tmp_extract / "manifest.json").read_text(encoding="utf-8")
                )
                if not _is_version_compatible(manifest):
                    logger.error(
                        "Bundle version incompatible: requires %s-%s, have %s",
                        manifest.get("min_godotiq"),
                        manifest.get("max_godotiq"),
                        __version__,
                    )
                    _status = "community"
                    return

                # Promote to final location
                bundle_version = manifest.get("bundle_version", manifest.get("version", "unknown"))
                final_dir = cache_dir / f"godotiq_pro-{bundle_version}"
                if final_dir.exists():
                    shutil.rmtree(final_dir)
                tmp_extract.rename(final_dir)
                tmp_extract = None  # prevent cleanup

                # Load — caller (this function) sets status
                if _load_bundle(final_dir):
                    _status = "active"
                else:
                    _status = "community"
                return

            finally:
                if tmp_extract is not None and tmp_extract.exists():
                    shutil.rmtree(tmp_extract, ignore_errors=True)
                if tmp_zip is not None:
                    tmp_zip.unlink(missing_ok=True)

        # Network errors don't retry — fall back to cache or community immediately
        except (httpx.ConnectError, httpx.TimeoutException) as exc:
            logger.warning("Bundle download failed: %s", exc)
            fallback = _find_compatible_cached_bundle(cache_dir)
            if fallback is not None and _load_bundle(fallback):
                _status = "active"
                logger.info("Using cached bundle after network error: %s", fallback)
            else:
                _status = "community"
            return

        except Exception as exc:
            logger.error("Unexpected error during bundle download: %s", exc)
            _status = "community"
            return


def ensure_pro_bundle() -> None:
    """Called at startup. Downloads/loads Pro bundle if licensed. Never raises."""
    global _status

    try:
        if not is_pro():
            _status = "community"
            return

        cache_dir = bundle_cache_dir()

        # Try to find a compatible cached bundle
        cached = _find_compatible_cached_bundle(cache_dir)
        if cached is not None:
            if _load_bundle(cached):
                _status = "active"
            else:
                _status = "community"
            return

        # No compatible cache — download
        _download_and_install_bundle(cache_dir)

    except Exception as exc:
        logger.error("Failed to initialize Pro bundle: %s", exc)
        _status = "community"


def get_pro_implementation(tool_name: str) -> Callable | None:
    """Return the Pro implementation for a tool, or None if not available."""
    if _tool_impls is None:
        return None
    return _tool_impls.get(tool_name)


def pro_status() -> str:
    """Return current Pro bundle status.

    Possible values:
    - 'active': bundle loaded and verified
    - 'community': no license or no bundle needed
    - 'degraded_integrity_warning': using fallback cached bundle after hash failure
    - 'degraded_integrity_failure': hash failed, no fallback available
    """
    return _status
