"""``Pillar`` — the main SDK class.

Manages tool registration, inbound request handling, signature verification,
and framework adapter wiring.
"""
from __future__ import annotations

import asyncio
import inspect
import json
import logging
from typing import Any, Callable

from pillar._context import ToolContext
from pillar._decorator import ToolRegistration, make_tool_decorator
from pillar._errors import PillarError, SignatureVerificationError, ToolNotFoundError
from pillar._registration import register_tools
from pillar._signature import verify_webhook_signature
from pillar._skill import SkillDefinition

logger = logging.getLogger("pillar")


class Pillar:
    """Customer-facing entry point for the Pillar backend SDK.

    Usage::

        from pillar import Pillar, ToolContext

        pillar = Pillar(secret="plr_...")

        @pillar.tool(description="Look up a customer by email")
        async def lookup_customer(email: str, ctx: ToolContext) -> dict:
            ...
    """

    def __init__(
        self,
        secret: str | None = None,
        *,
        endpoint_url: str | None = None,
        auto_register: bool = True,
        base_url: str = "https://api.trypillar.com",
        skills: list[SkillDefinition] | None = None,
        _logger: logging.Logger | None = None,
        api_key: str | None = None,
        signing_secret: str | None = None,
    ) -> None:
        resolved = secret or api_key
        if not resolved:
            raise ValueError("Pillar requires a `secret` (or deprecated `api_key`).")
        self._secret = resolved
        self._endpoint_url = endpoint_url
        self._auto_register = auto_register
        self._base_url = base_url.rstrip("/")
        self._logger = _logger or logger
        self._tools: dict[str, ToolRegistration] = {}
        self._skills: list[SkillDefinition] = list(skills) if skills else []
        self._registered = False

    # -- decorator -----------------------------------------------------------

    def tool(
        self,
        description: str,
        *,
        name: str | None = None,
        guidance: str | None = None,
        input_schema: dict[str, Any] | None = None,
        output_schema: dict[str, Any] | None = None,
        timeout_ms: int = 30_000,
        channel_compatibility: list[str] | None = None,
        tool_type: str = "server_side",
    ) -> Callable[..., Any]:
        """Decorator to register a function as a Pillar tool."""
        return make_tool_decorator(
            self._tools,
            description,
            name=name,
            guidance=guidance,
            input_schema=input_schema,
            output_schema=output_schema,
            timeout_ms=timeout_ms,
            channel_compatibility=channel_compatibility,
            tool_type=tool_type,
        )

    # -- programmatic tool registration --------------------------------------

    def register_tools(self, tools: list[dict[str, Any]]) -> None:
        """Register one or more tool definitions programmatically.

        Each dict must contain at least ``name``, ``description``, and
        ``execute`` (a callable).  Accepts the same keys as the ``@tool()``
        decorator: ``guidance``, ``input_schema``, ``output_schema``,
        ``timeout_ms``, ``channel_compatibility``, ``tool_type``.
        """
        for tool_def in tools:
            name = tool_def.get("name")
            if not name:
                raise ValueError("Tool definition requires a `name`.")
            description = tool_def.get("description")
            if not description:
                raise ValueError("Tool definition requires a `description`.")
            handler = tool_def.get("execute")
            if not callable(handler):
                raise ValueError("Tool definition requires an `execute` callable.")

            from pillar._decorator import TOOL_NAME_PATTERN, ToolRegistration
            from pillar._schema import extract_schema

            if not TOOL_NAME_PATTERN.match(name):
                raise ValueError(
                    f'Invalid tool name "{name}". '
                    f"Must match {TOOL_NAME_PATTERN.pattern}."
                )

            schema = tool_def.get("input_schema") or extract_schema(handler)

            reg = ToolRegistration(
                name=name,
                description=description,
                handler=handler,
                input_schema=schema,
                output_schema=tool_def.get("output_schema"),
                guidance=tool_def.get("guidance"),
                timeout_ms=tool_def.get("timeout_ms", 30_000),
                channel_compatibility=tool_def.get("channel_compatibility") or ["*"],
                tool_type=tool_def.get("tool_type", "server_side"),
            )
            self._tools[name] = reg

    # -- Cloud registration --------------------------------------------------

    def register(self) -> dict[str, Any] | None:
        """Register all tools with Pillar Cloud.

        Called automatically on the first ``handle()`` call when
        ``auto_register=True``, or call manually for deferred registration.

        Returns ``None`` and logs a warning on failure instead of raising,
        so the server can continue to operate. Tools are loaded locally and
        registration will be retried on the next ``handle()`` call.
        """
        if not self._endpoint_url:
            self._logger.warning(
                "endpoint_url is required for registration — skipping. "
                "Pass it to Pillar(...) or set pillar._endpoint_url before calling register()."
            )
            return None
        try:
            result = register_tools(
                secret=self._secret,
                base_url=self._base_url,
                endpoint_url=self._endpoint_url,
                tools=self._tools,
                skills=self._skills or None,
            )
            self._registered = True
            return result
        except Exception as exc:
            self._logger.warning(
                "Cloud registration failed — tools are loaded locally and "
                "will retry on next request: %s",
                exc,
            )
            return None

    # -- core dispatch -------------------------------------------------------

    async def handle(
        self,
        body: bytes | str,
        headers: dict[str, str] | None = None,
    ) -> tuple[dict[str, Any], int]:
        """Core dispatch: verify signature, find tool, call handler, return result.

        Framework adapters extract *body* and *headers* from the framework's
        request object and call this method.

        Returns ``(response_dict, http_status_code)``.
        """
        if isinstance(body, str):
            body = body.encode()

        try:
            payload = json.loads(body)
        except (json.JSONDecodeError, ValueError):
            return {"success": False, "error": "Invalid JSON"}, 400

        action = payload.get("action")

        # Ping — no signature check required
        if action == "ping":
            return {"status": "ok"}, 200

        # Signature verification
        if headers:
            sig_header = headers.get("X-Pillar-Signature") or headers.get(
                "x-pillar-signature", ""
            )
            if not sig_header or not verify_webhook_signature(
                sig_header, body, self._secret
            ):
                self._logger.warning("Signature verification failed")
                return {"success": False, "error": "Invalid signature"}, 401

        if action not in ("tool_call", "tool_confirm"):
            return {"success": False, "error": f"Unknown action: {action}"}, 400

        is_confirm = action == "tool_confirm"

        # Auto-register on first real call
        if self._auto_register and not self._registered and self._endpoint_url:
            try:
                self.register()
            except Exception:
                self._logger.exception("Auto-registration failed")

        call_id = payload.get("call_id", "")
        tool_name = payload.get("tool_name", "")

        if is_confirm:
            arguments = payload.get("confirm_payload", {})
            payload["confirmed"] = True
        else:
            arguments = payload.get("arguments", {})

        reg = self._tools.get(tool_name)
        if reg is None:
            self._logger.warning("Tool not found: %s", tool_name)
            return {
                "call_id": call_id,
                "success": False,
                "error": f"Tool '{tool_name}' not found",
            }, 404

        ctx = ToolContext.from_payload(payload)

        try:
            result = await self._invoke(reg.handler, arguments, ctx)
        except (ValueError, KeyError, TypeError) as exc:
            self._logger.info("Tool %s raised %s: %s", tool_name, type(exc).__name__, exc)
            return {"call_id": call_id, "success": False, "error": str(exc)}, 200
        except PermissionError as exc:
            self._logger.warning("Permission denied in tool %s: %s", tool_name, exc)
            return {"call_id": call_id, "success": False, "error": str(exc)}, 200
        except Exception as exc:
            err_msg = str(exc) or type(exc).__name__
            self._logger.exception("Unhandled exception in tool %s", tool_name)
            return {"call_id": call_id, "success": False, "error": err_msg}, 200

        return {"call_id": call_id, "success": True, "result": result}, 200

    # -- identity linking ----------------------------------------------------

    def confirm_link(self, code: str, external_user_id: str) -> dict[str, Any]:
        """Confirm an identity link code, creating an IdentityMapping on Pillar Cloud.

        Call this from your backend after a user authenticates and you have both
        the link code (from ``/pillar connect``) and the user's ID in your system.

        Returns the confirmed mapping details.

        Raises :class:`PillarError` on HTTP or network failure.
        """
        import urllib.error
        import urllib.request

        url = f"{self._base_url}/api/public/identity/link-confirm/"
        body = json.dumps({
            "code": code,
            "external_user_id": external_user_id,
        }).encode()

        req = urllib.request.Request(
            url,
            data=body,
            headers={
                "Authorization": f"Bearer {self._secret}",
                "Content-Type": "application/json",
            },
            method="POST",
        )

        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                return json.loads(resp.read().decode())
        except urllib.error.HTTPError as exc:
            resp_body = exc.read().decode(errors="replace")
            try:
                data = json.loads(resp_body)
                err_code = data.get("error", "link_failed")
                err_msg = data.get("message", err_code)
            except (json.JSONDecodeError, ValueError):
                err_code = "link_failed"
                err_msg = resp_body or f"HTTP {exc.code}"
            raise PillarError(
                f"confirm_link failed: {err_msg} ({err_code})"
            ) from exc
        except urllib.error.URLError as exc:
            raise PillarError(
                f"Could not reach Pillar Cloud at {url}: {exc.reason}"
            ) from exc

    # -- framework adapters (convenience methods) ----------------------------

    def django_view(self) -> Any:
        """Return a Django view function for use in ``urlpatterns``."""
        from pillar.adapters._django import make_django_view

        return make_django_view(self)

    def flask_handler(self) -> Any:
        """Return a Flask handler for use with ``app.route()``."""
        from pillar.adapters._flask import make_flask_handler

        return make_flask_handler(self)

    def fastapi_handler(self) -> Any:
        """Return a FastAPI/Starlette route handler.

        Usage::

            @app.post("/pillar")
            async def pillar_endpoint(request: Request):
                return await pillar.fastapi_handler()(request)

        Or more directly::

            from pillar.adapters._fastapi import handle_fastapi_request

            @app.post("/pillar")
            async def pillar_endpoint(request: Request):
                return await handle_fastapi_request(pillar, request)
        """
        from pillar.adapters._fastapi import handle_fastapi_request

        async def handler(request: Any) -> Any:
            return await handle_fastapi_request(self, request)

        return handler

    def serve(self, port: int = 8787, host: str = "0.0.0.0") -> None:
        """Run a standalone HTTP server for customers without an existing web app."""
        from pillar.adapters._standalone import run_server

        run_server(self, host=host, port=port)

    # -- internals -----------------------------------------------------------

    async def _invoke(
        self,
        handler: Callable[..., Any],
        arguments: dict[str, Any],
        ctx: ToolContext,
    ) -> Any:
        """Call *handler* with the right arguments, handling sync/async."""
        sig = inspect.signature(handler)
        kwargs: dict[str, Any] = {}

        for name, param in sig.parameters.items():
            if name in ("ctx", "context") or (
                param.annotation is not inspect.Parameter.empty
                and (
                    param.annotation is ToolContext
                    or (isinstance(param.annotation, type) and issubclass(param.annotation, ToolContext))
                )
            ):
                kwargs[name] = ctx
            elif name in arguments:
                kwargs[name] = arguments[name]
            elif param.default is not inspect.Parameter.empty:
                pass  # let the function use its default
            # else: missing required arg — Python will raise TypeError

        if inspect.iscoroutinefunction(handler):
            return await handler(**kwargs)
        else:
            return await asyncio.to_thread(handler, **kwargs)
