"""
Module: sandbox.proxy
Description: Local development sandbox for testing pack commands.

Implements:
    - docs/guides/developer-environment.md#development-session-flow
    - docs/cli/reference.md#huitzo-dev
    - docs/cli/reference.md#interactive-repl
"""

import hmac
import logging
import os
import shutil
import tempfile
import threading
import time
import uuid
from importlib.metadata import entry_points
from pathlib import Path
from typing import Any

import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
from pydantic import BaseModel

logger = logging.getLogger(__name__)


class ExecuteRequest(BaseModel):
    """Request body for command execution."""

    args: dict[str, Any] = {}


class InMemoryStorage:
    """Simple in-memory storage backend for sandbox Context."""

    def __init__(self) -> None:
        self._data: dict[str, Any] = {}

    async def save(self, key: str, data: Any) -> None:
        self._data[key] = data

    async def get(self, key: str) -> Any:
        return self._data.get(key)

    async def delete(self, key: str) -> None:
        self._data.pop(key, None)

    async def list_keys(self, prefix: str = "") -> list[str]:
        return [k for k in self._data if k.startswith(prefix)]


class StubLog:
    """Stub logger for sandbox Context."""

    def info(self, message: str) -> None:
        logger.info("[sandbox] %s", message)

    def warning(self, message: str) -> None:
        logger.warning("[sandbox] %s", message)

    def error(self, message: str) -> None:
        logger.error("[sandbox] %s", message)


class StubContext:
    """Minimal Context for sandbox command execution.

    Provides standard Context identity fields (user_id, tenant_id, etc.)
    alongside stub implementations of storage and log, so pack commands
    that access standard Context properties don't crash in the sandbox.
    """

    def __init__(self) -> None:
        from uuid import uuid4

        self.user_id = uuid4()
        self.tenant_id = uuid4()
        self.session_id = uuid4()
        self.correlation_id = str(uuid4())
        self.command_name = ""
        self.namespace = ""
        self.command_version = "1.0.0"
        self.storage = InMemoryStorage()
        self.log = StubLog()


def _generate_self_signed_cert(tmpdir: str) -> tuple[str, str]:
    """Generate a self-signed TLS certificate for the sandbox session.

    Returns:
        Tuple of (certfile_path, keyfile_path)
    """
    try:
        from cryptography import x509
        from cryptography.hazmat.primitives import hashes, serialization
        from cryptography.hazmat.primitives.asymmetric import rsa
        from cryptography.x509.oid import NameOID
        import datetime

        key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
        subject = issuer = x509.Name(
            [
                x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
            ]
        )
        cert = (
            x509.CertificateBuilder()
            .subject_name(subject)
            .issuer_name(issuer)
            .public_key(key.public_key())
            .serial_number(x509.random_serial_number())
            .not_valid_before(datetime.datetime.now(datetime.timezone.utc))
            .not_valid_after(
                datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24)
            )
            .add_extension(
                x509.SubjectAlternativeName([x509.DNSName("localhost")]),
                critical=False,
            )
            .sign(key, hashes.SHA256())
        )

        cert_path = f"{tmpdir}/sandbox.crt"
        key_path = f"{tmpdir}/sandbox.key"

        with open(cert_path, "wb") as f:
            f.write(cert.public_bytes(serialization.Encoding.PEM))
        # Write key with restrictive permissions from the start (no TOCTOU window)
        fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "wb") as f:
            f.write(
                key.private_bytes(
                    serialization.Encoding.PEM,
                    serialization.PrivateFormat.TraditionalOpenSSL,
                    serialization.NoEncryption(),
                )
            )
        return cert_path, key_path
    except ImportError:
        # Fall back to openssl CLI if cryptography is not available
        import subprocess

        cert_path = f"{tmpdir}/sandbox.crt"
        key_path = f"{tmpdir}/sandbox.key"
        # Pre-create key file with restrictive permissions before openssl writes to it
        fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        os.close(fd)
        subprocess.run(
            [
                "openssl",
                "req",
                "-x509",
                "-newkey",
                "rsa:2048",
                "-keyout",
                key_path,
                "-out",
                cert_path,
                "-days",
                "1",
                "-nodes",
                "-subj",
                "/CN=localhost",
            ],
            capture_output=True,
            check=True,
        )
        return cert_path, key_path


class LocalSandbox:
    """Local development sandbox that runs pack commands in a FastAPI server."""

    def __init__(self, auth_token: str | None = None) -> None:
        self._commands: dict[str, Any] = {}
        self._metadata: dict[str, dict[str, Any]] = {}
        self._server: uvicorn.Server | None = None
        self._thread: threading.Thread | None = None
        self._discovery_failures: list[str] = []
        self._auth_token: str | None = auth_token
        self._tmpdir: tempfile.TemporaryDirectory[str] | None = None

    def discover_commands(self) -> dict[str, dict[str, Any]]:
        """Scan installed entry_points for huitzo.commands group.

        Returns:
            Dict mapping command name to metadata
        """
        self._commands.clear()
        self._metadata.clear()
        self._discovery_failures.clear()

        eps: Any = entry_points()
        if hasattr(eps, "select"):
            group = eps.select(group="huitzo.commands")
        else:
            group = eps.get("huitzo.commands", [])

        for ep in group:
            try:
                cmd = ep.load()
                meta = getattr(cmd, "_huitzo_command", None)
                if meta:
                    name: str = meta.name
                    self._commands[name] = cmd
                    self._metadata[name] = {
                        "name": meta.name,
                        "namespace": ep.name,
                        "timeout": meta.timeout,
                        "queue": meta.queue,
                        "description": meta.description or cmd.__doc__ or "",
                    }
            except Exception as e:
                logger.exception("Failed to load command %s", ep.name)
                self._discovery_failures.append(self._classify_discovery_failure(ep.name, e))

        # Also scan huitzo.packs group
        if hasattr(eps, "select"):
            packs_group = eps.select(group="huitzo.packs")
        else:
            packs_group = eps.get("huitzo.packs", [])

        for ep in packs_group:
            try:
                module = ep.load()
                for attr_name in dir(module):
                    obj = getattr(module, attr_name)
                    if not callable(obj):
                        continue
                    meta = getattr(obj, "_huitzo_command", None)
                    if meta is None:
                        continue
                    name = meta.name
                    if name not in self._commands:
                        self._commands[name] = obj
                        self._metadata[name] = {
                            "name": meta.name,
                            "namespace": f"@{ep.name}/{meta.name}",
                            "timeout": meta.timeout,
                            "queue": meta.queue,
                            "description": meta.description or obj.__doc__ or "",
                        }
            except Exception as e:
                logger.exception("Failed to load pack module %s", ep.name)
                self._discovery_failures.append(self._classify_discovery_failure(ep.name, e))

        return dict(self._metadata)

    def get_discovery_failures(self) -> list[str]:
        """Return list of failures encountered during command discovery."""
        return list(self._discovery_failures)

    def _classify_discovery_failure(self, ep_name: str, error: Exception) -> str:
        """Classify a discovery failure and provide actionable guidance.

        Detects stale editable installs and provides cleanup instructions.

        Args:
            ep_name: Entry point name that failed
            error: The exception that was raised

        Returns:
            Enhanced error message with actionable guidance
        """
        error_msg = str(error)

        # Detect ModuleNotFoundError from stale editable install
        if isinstance(error, ModuleNotFoundError):
            # Extract module name from error message
            # Example: "No module named 'storecheck_claims.commands'"
            import re

            match = re.search(r"No module named '([^']+)'", error_msg)
            if match:
                module_path = match.group(1)
                base_module = module_path.split(".")[0]

                # Try to find the .pth file for this package
                import site

                for site_dir in site.getsitepackages() + [site.getusersitepackages()]:
                    if not site_dir or not os.path.exists(site_dir):
                        continue

                    # Look for easy-install.pth or package-specific .pth files
                    for pth_file in Path(site_dir).glob("*.pth"):
                        try:
                            with open(pth_file) as f:
                                for line in f:
                                    line = line.strip()
                                    if not line or line.startswith("#"):
                                        continue
                                    # Check if this path might contain the missing module
                                    if base_module in line.lower() or ep_name in line.lower():
                                        source_dir = Path(line)
                                        expected_file = (
                                            source_dir
                                            / base_module.replace("-", "_")
                                            / "commands.py"
                                        )

                                        # Convert ep_name (e.g., "storecheck") to package name
                                        # by checking installed packages
                                        package_name = ep_name.replace("_", "-")

                                        return (
                                            f"{ep_name}: {error}\n\n"
                                            f"   [yellow]This appears to be a stale editable install:[/yellow]\n"
                                            f"   - Package: {package_name}\n"
                                            f"   - Expected: {expected_file}\n"
                                            f"   - Status: {'File exists' if expected_file.exists() else 'File does not exist'}\n\n"
                                            f"   [bold]To fix:[/bold] pip uninstall {package_name}"
                                        )
                        except (OSError, UnicodeDecodeError):
                            continue

        # Default: return basic error message
        return f"{ep_name}: {error}"

    async def execute(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
        """Execute a command with stub Context.

        Args:
            name: Command name (short name, not full namespace)
            args: Command arguments

        Returns:
            Command result dict

        Raises:
            KeyError: If command not found
        """
        cmd = self._commands.get(name)
        if cmd is None:
            raise KeyError(f"Command not found: {name}")

        ctx = StubContext()
        result: dict[str, Any] = await cmd(args, ctx)
        return result

    def _verify_token(self, request: Request) -> None:
        """Validate Authorization header against the sandbox auth token."""
        if not self._auth_token:
            return
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            raise HTTPException(status_code=401, detail="Missing authentication token")
        if not hmac.compare_digest(auth_header[7:], self._auth_token):
            raise HTTPException(status_code=401, detail="Invalid authentication token")

    def _create_app(self) -> FastAPI:
        """Create the sandbox FastAPI application."""
        sandbox_app = FastAPI(title="Huitzo Dev Sandbox")
        sandbox = self

        async def require_auth(request: Request) -> None:
            sandbox._verify_token(request)

        @sandbox_app.get("/commands", dependencies=[Depends(require_auth)])
        async def list_commands() -> dict[str, Any]:
            return {"commands": list(sandbox._metadata.values())}

        @sandbox_app.post("/commands/{name}", dependencies=[Depends(require_auth)])
        async def execute_command(name: str, body: ExecuteRequest) -> dict[str, Any]:
            try:
                result = await sandbox.execute(name, body.args)
                return {"data": result}
            except KeyError as e:
                raise HTTPException(status_code=404, detail=str(e))
            except Exception:  # Catch-all: top-level request handler
                logger.exception("Command execution failed: %s", name)
                raise HTTPException(status_code=500, detail="Execution failed")

        @sandbox_app.get("/health")
        async def health() -> dict[str, str]:
            return {"status": "ok"}

        def _uploads_dir() -> Path:
            """Return the uploads directory, creating it if needed."""
            if sandbox._tmpdir is None:
                sandbox._tmpdir = tempfile.TemporaryDirectory(prefix="huitzo-sandbox-")
            uploads = Path(sandbox._tmpdir.name) / "uploads"
            uploads.mkdir(exist_ok=True)
            return uploads

        @sandbox_app.post("/files", dependencies=[Depends(require_auth)])
        async def upload_file(file: UploadFile) -> dict[str, Any]:
            uploads = _uploads_dir()
            original_name = file.filename or "unnamed"
            stem = Path(original_name).stem
            ext = Path(original_name).suffix
            unique_name = f"{stem}_{uuid.uuid4().hex[:8]}{ext}"
            dest = uploads / unique_name

            content = await file.read()
            dest.write_bytes(content)

            return {
                "path": f"uploads/{unique_name}",
                "size": len(content),
                "original_name": original_name,
            }

        @sandbox_app.get("/files", dependencies=[Depends(require_auth)])
        async def list_files() -> dict[str, list[dict[str, Any]]]:
            uploads = _uploads_dir()
            files = []
            for f in sorted(uploads.iterdir()):
                if f.is_file():
                    files.append(
                        {
                            "name": f.name,
                            "size": f.stat().st_size,
                            "path": f"uploads/{f.name}",
                        }
                    )
            return {"files": files}

        @sandbox_app.delete("/files", dependencies=[Depends(require_auth)])
        async def clear_files() -> dict[str, int]:
            uploads = _uploads_dir()
            count = sum(1 for f in uploads.iterdir() if f.is_file())
            shutil.rmtree(uploads)
            uploads.mkdir()
            return {"deleted": count}

        return sandbox_app

    def start(self, port: int = 8080, use_tls: bool = True) -> str:
        """Start the sandbox HTTP server in a background thread.

        Args:
            port: Port to listen on (use 0 for dynamic port allocation)
            use_tls: Whether to use HTTPS with self-signed certificates

        Returns:
            The base URL of the running sandbox
        """
        app = self._create_app()

        ssl_kwargs: dict[str, Any] = {}
        scheme = "http"

        if use_tls:
            try:
                self._tmpdir = tempfile.TemporaryDirectory(prefix="huitzo-sandbox-")
                cert_path, key_path = _generate_self_signed_cert(self._tmpdir.name)
                ssl_kwargs["ssl_certfile"] = cert_path
                ssl_kwargs["ssl_keyfile"] = key_path
                scheme = "https"
            except Exception:
                logger.warning("Could not generate TLS certificate, falling back to HTTP")

        config = uvicorn.Config(
            app,
            host="127.0.0.1",
            port=port,
            log_level="warning",
            **ssl_kwargs,
        )
        self._server = uvicorn.Server(config)

        self._thread = threading.Thread(target=self._server.run, daemon=True)
        self._thread.start()

        # Wait for server to be ready before returning
        deadline = time.monotonic() + 5.0
        while not self._server.started and time.monotonic() < deadline:
            if not self._thread.is_alive():
                raise RuntimeError("Sandbox server crashed during startup")
            time.sleep(0.1)

        if not self._server.started:
            raise RuntimeError("Sandbox server did not start within 5 seconds")

        # Get the actual port if dynamic allocation was used
        actual_port = port
        if port == 0 and self._server.servers:
            # Extract port from the first server socket
            server_socket = self._server.servers[0].sockets[0]
            actual_port = server_socket.getsockname()[1]

        return f"{scheme}://localhost:{actual_port}"

    def stop(self) -> None:
        """Shut down the sandbox server."""
        if self._server:
            self._server.should_exit = True
            self._server = None
        if self._thread:
            self._thread.join(timeout=5)
            self._thread = None
        if self._tmpdir:
            self._tmpdir.cleanup()
            self._tmpdir = None
