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

Implements:
    - docs/guides/developer-environment.md#development-session-flow
    - docs/guides/developer-environment.md#error-handling-in-dev-mode
    - docs/sdk/error-handling.md#error-response-format
    - docs/cli/reference.md#huitzo-dev
"""

import hmac
import logging
import os
import secrets as stdlib_secrets
import shutil
import tempfile
import threading
import time
import uuid
from importlib.metadata import entry_points
from pathlib import Path
from typing import Any, NoReturn

import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from huitzo_sdk.errors import HuitzoError
from pydantic import BaseModel, ValidationError as PydanticValidationError
from rich.console import Console

logger = logging.getLogger(__name__)
_console = Console()

_MAX_UPLOAD_BYTES = 100 * 1024 * 1024  # 100 MB


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 StubService:
    """Proxy that raises a clear error when a platform service is used in sandbox.

    Commands that access ctx.llm, ctx.http, etc. get an actionable error instead
    of a raw AttributeError.
    """

    def __init__(self, service_name: str) -> None:
        self._service_name = service_name

    def __getattr__(self, name: str) -> NoReturn:
        raise AttributeError(
            f"{self._service_name} is not available in sandbox mode.\n"
            "Run with 'huitzo pack dev --use-platform' to use Huitzo platform services,\n"
            "or configure ANTHROPIC_API_KEY and other keys for local dev."
        )


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.

    Platform services (llm, http, email, telegram, files) are stubbed with
    helpful error messages — see StubService.
    """

    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()

        # TODO: Replace stubs with real platform service proxies when
        # backend integration for `huitzo pack dev --use-platform` is implemented.
        self.llm = StubService("ctx.llm")
        self.http = StubService("ctx.http")
        self.email = StubService("ctx.email")
        self.telegram = StubService("ctx.telegram")
        self.files = StubService("ctx.files")
        self.ssh = StubService("ctx.ssh")
        self.deployment_mode = "sandbox"


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


def _format_validation_error(error: PydanticValidationError, command_name: str) -> str:
    """Format a Pydantic ValidationError into a human-readable message.

    Returns a string suitable for HTTP 422 detail that lists each field error
    with its location, expected type, and what went wrong.
    """
    lines = [f"Missing or invalid arguments for '{command_name}':\n"]
    for err in error.errors():
        loc = " → ".join(str(part) for part in err["loc"])
        msg = err["msg"]
        lines.append(f"  • {loc}: {msg}")
    lines.append(f"\nHint: Run 'describe {command_name}' to see the full argument schema.")
    return "\n".join(lines)


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

    def __init__(self, auth_token: str | None = None, use_platform: bool = False) -> None:
        self._commands: dict[str, Any] = {}
        self._metadata: dict[str, dict[str, Any]] = {}
        self._collisions: dict[str, list[str]] = {}
        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
        self._ca_cert_path: str | None = None
        self._use_platform: bool = use_platform
        self._frozen: bool = False

    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._collisions.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
                    cmd_meta: dict[str, Any] = {
                        "name": meta.name,
                        "namespace": ep.name,
                        "timeout": meta.timeout,
                        "queue": meta.queue,
                        "description": meta.description or cmd.__doc__ or "",
                    }
                    args_type = getattr(cmd, "_huitzo_args_type", None)
                    if args_type is not None:
                        try:
                            cmd_meta["args_schema"] = args_type.model_json_schema()
                        except Exception:
                            pass
                    self._metadata[name] = cmd_meta
            except Exception as e:
                logger.error("Failed to load command %s: %s", ep.name, e)
                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
                    qualified = f"@{ep.name}/{meta.name}"
                    pack_cmd_meta: dict[str, Any] = {
                        "name": meta.name,
                        "namespace": qualified,
                        "timeout": meta.timeout,
                        "queue": meta.queue,
                        "description": meta.description or obj.__doc__ or "",
                    }
                    args_type = getattr(obj, "_huitzo_args_type", None)
                    if args_type is not None:
                        try:
                            pack_cmd_meta["args_schema"] = args_type.model_json_schema()
                        except Exception:
                            pass

                    # Always register the fully-qualified name
                    self._commands[qualified] = obj
                    self._metadata[qualified] = pack_cmd_meta

                    if name in self._commands:
                        # Collision — track it for user warning
                        existing_ns = self._metadata.get(name, {}).get("namespace", name)
                        if name not in self._collisions:
                            self._collisions[name] = [existing_ns]
                        self._collisions[name].append(qualified)
                    else:
                        self._commands[name] = obj
                        self._metadata[name] = pack_cmd_meta
            except Exception as e:
                logger.error("Failed to load pack module %s: %s", ep.name, e)
                self._discovery_failures.append(self._classify_discovery_failure(ep.name, e))

        # Warn about collisions
        for short_name, namespaces in self._collisions.items():
            active = namespaces[0]
            for shadowed in namespaces[1:]:
                _console.print(
                    f'[yellow]Warning: Command "{short_name}" exists in multiple packs:[/yellow]\n'
                    f"  [green]{active}[/green] (active)\n"
                    f"  [dim]{shadowed}[/dim] (shadowed)\n"
                    f"  Use [bold]{shadowed}[/bold] to run the shadowed version."
                )

        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 get_collisions(self) -> dict[str, list[str]]:
        """Return dict of command names that collide across packs.

        Keys are short command names, values are lists of qualified namespaces.
        """
        return dict(self._collisions)

    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}"

    def _get_files_dir(self) -> Path:
        """Return the pack files directory, creating tmpdir if needed."""
        if self._tmpdir is None:
            self._tmpdir = tempfile.TemporaryDirectory(prefix="huitzo-sandbox-")
        files_dir = Path(self._tmpdir.name) / "pack_files"
        files_dir.mkdir(exist_ok=True)
        return files_dir

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

        When use_platform is enabled, creates a real SDK Context wired
        with local backend implementations. Otherwise uses StubContext.

        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}")

        if self._use_platform:
            from huitzo_cli.sandbox.platform_context import create_platform_context

            meta = self._metadata.get(name, {})
            ctx: Any = create_platform_context(
                files_root=self._get_files_dir(),
                command_name=name,
                namespace=meta.get("namespace", ""),
            )
        else:
            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 freeze(self) -> None:
        """Freeze the sandbox — all requests return 503."""
        self._frozen = True

    def _create_app(self) -> FastAPI:
        """Create the sandbox FastAPI application."""
        sandbox_app = FastAPI(title="huitzo pack dev Sandbox")
        sandbox = self

        @sandbox_app.exception_handler(Exception)
        async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
            logger.error("Unhandled error: %s", exc)
            return JSONResponse(status_code=500, content={"detail": "Internal server error"})

        @sandbox_app.middleware("http")
        async def check_frozen(request: Request, call_next):  # type: ignore[no-untyped-def]
            if sandbox._frozen:
                return JSONResponse(
                    status_code=503,
                    content={
                        "error": "Session frozen",
                        "message": "Token refresh failed. Run 'huitzo login' then restart.",
                    },
                )
            return await call_next(request)

        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 PydanticValidationError as e:
                detail = _format_validation_error(e, name)
                raise HTTPException(status_code=422, detail=detail)
            except HuitzoError as exc:
                logger.warning("Command %s failed: [%s] %s", name, type(exc).__name__, exc)
                return JSONResponse(  # type: ignore[return-value]
                    status_code=exc.http_status,
                    content={
                        "error": type(exc).__name__,
                        "message": str(exc),
                        "code": exc.code,
                        "command": name,
                    },
                )
            except Exception:
                logger.debug("Unexpected error in command %s", name, exc_info=True)
                logger.error("Command %s failed unexpectedly", name)
                return JSONResponse(  # type: ignore[return-value]
                    status_code=500,
                    content={
                        "error": "InternalError",
                        "message": "Command failed unexpectedly. Check server logs with --log-level=debug for details.",
                        "command": name,
                    },
                )

        @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

            # Stream in chunks to reject oversized files before full buffer
            chunks: list[bytes] = []
            total = 0
            while chunk := await file.read(64 * 1024):
                total += len(chunk)
                if total > _MAX_UPLOAD_BYTES:
                    raise HTTPException(
                        status_code=413,
                        detail=f"File too large (max {_MAX_UPLOAD_BYTES // 1024 // 1024} MB)",
                    )
                chunks.append(chunk)
            content = b"".join(chunks)
            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.
                     TLS failure is a hard error; use --no-tls to opt out.

        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
                self._ca_cert_path = cert_path
                scheme = "https"
            except Exception as exc:
                raise RuntimeError(
                    "TLS certificate generation failed. "
                    "Use --no-tls to run without encryption.\n"
                    f"Error: {exc}"
                )
        else:
            _console.print(
                "[yellow]Running without TLS — auth tokens will not be enforced.[/yellow]"
            )
            # Disable auth in no-tls mode
            self._auth_token = None

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

        # Auto-generate auth token when none provided (and TLS is active)
        if not self._auth_token and use_tls:
            self._auth_token = stdlib_secrets.token_hex(32)
            truncated = self._auth_token[:8] + "..."
            _console.print(f"[bold]Auto-generated sandbox auth token:[/bold] {truncated}")
            _console.print("[dim]Full token printed to log at DEBUG level[/dim]")
            logger.debug("Auto-generated sandbox auth token: %s", self._auth_token)

        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
