"""
OpenAPI Adapter — Expose an OpenAPI spec as Brynq tools.

Accepts a URL (``https://…/openapi.json``) or a local file path
(``file:///path/to/spec.yaml`` or a bare filesystem path).  The spec is
parsed to discover endpoints which are surfaced as tools via
:meth:`introspect`, and individual endpoints can be called via
:meth:`execute`.

All stdlib — no external packages.
"""

from __future__ import annotations

import json
import os
import re
from typing import Any, Dict, List
from urllib.parse import urlencode, urlparse

from runtime.adapters.base import BaseAdapter

_VERSION = "0.1.0"

# HTTP methods that typically represent callable operations.
_SUPPORTED_METHODS = frozenset({"get", "post", "put", "patch", "delete"})


class OpenAPIAdapter(BaseAdapter):
    """Adapter that turns an OpenAPI spec into callable tools.

    Parameters
    ----------
    source:
        Either a URL pointing to an OpenAPI JSON/YAML spec
        (e.g. ``https://api.example.com/openapi.json``) or a local
        file path (absolute or relative).
    """

    def __init__(self, source: str) -> None:
        self._source = source
        self._spec: Dict[str, Any] | None = None
        self._tools: List[Dict[str, Any]] | None = None
        self._base_url: str = ""

    # ── BaseAdapter interface ────────────────────────────────────────────

    def introspect(self) -> Dict[str, Any]:
        """Parse the spec and return discovered tools."""
        self._ensure_loaded()
        return {
            "tools": list(self._tools),  # type: ignore[arg-type]
            "version": _VERSION,
            "source": self._source,
        }

    def execute(self, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
        """Call an endpoint described by the spec.

        Uses :meth:`_build_request` to resolve path, query, and body
        parameters, then fires the HTTP call with ``urllib.request``.

        Parameters
        ----------
        tool_name:
            Tool name in the form ``METHOD /path``
            (e.g. ``"GET /users/{id}"``) or an ``operationId``.
        args:
            Keys matching path/query/body parameters.
        """
        import urllib.request as _urllib_req  # noqa: E402

        self._ensure_loaded()

        try:
            req_info = self._build_request(tool_name, args)
        except ValueError as exc:
            return {"status": "error", "result": str(exc)}

        body_bytes: bytes | None = None
        if req_info["body"] is not None:
            body_bytes = json.dumps(req_info["body"]).encode("utf-8")

        try:
            req = _urllib_req.Request(
                req_info["url"],
                data=body_bytes,
                headers=req_info["headers"],
                method=req_info["method"],
            )
            with _urllib_req.urlopen(req, timeout=30) as resp:  # noqa: S310
                raw = resp.read().decode("utf-8")
            try:
                result = json.loads(raw)
            except json.JSONDecodeError:
                result = raw
            return {"status": "ok", "result": result}
        except Exception as exc:  # noqa: BLE001
            return {"status": "error", "result": str(exc)}

    # ── Spec loading ─────────────────────────────────────────────────────

    def _ensure_loaded(self) -> None:
        """Load and parse the spec lazily (once)."""
        if self._spec is not None:
            return
        self._spec = self._fetch_spec()
        self._base_url = self._resolve_base_url(self._spec)
        self._tools = self._extract_tools(self._spec)

    def _fetch_spec(self) -> Dict[str, Any]:
        """Download and parse a JSON or YAML OpenAPI 3.0 spec.

        Fetches the raw content from the configured source (URL or local
        file), then parses it as JSON or YAML.  JSON is attempted first;
        if that fails the ``yaml`` module (PyYAML) is used as a fallback.
        """
        raw = self._fetch_source()
        return self._parse_spec(raw)

    def _fetch_source(self) -> str:
        """Retrieve the raw spec content from URL or file path."""
        parsed = urlparse(self._source)

        # Local file — either file:// scheme or no scheme (bare path).
        if parsed.scheme in ("", "file"):
            path = parsed.path if parsed.scheme == "file" else self._source
            # On Windows, urlparse may leave a leading slash on drive paths
            # e.g. /C:/foo → strip it.
            if os.name == "nt" and re.match(r"^/[A-Za-z]:", path):
                path = path[1:]
            with open(path, encoding="utf-8") as fh:
                return fh.read()

        # Remote URL — use stdlib urllib.
        import urllib.request  # noqa: E402 — deferred to avoid import cost

        req = urllib.request.Request(
            self._source,
            headers={"Accept": "application/json, application/yaml;q=0.9"},
        )
        with urllib.request.urlopen(req, timeout=30) as resp:  # noqa: S310
            return resp.read().decode("utf-8")

    @staticmethod
    def _parse_spec(raw: str) -> Dict[str, Any]:
        """Parse a JSON or YAML OpenAPI spec into a dict."""
        raw = raw.strip()
        # Try JSON first — most common and fastest.
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            pass
        # Fall back to YAML.
        try:
            import yaml  # noqa: E402 — PyYAML, deferred import
        except ImportError:
            raise ValueError(
                "OpenAPI spec is not valid JSON and PyYAML is not installed. "
                "Install it with: pip install pyyaml"
            ) from None
        result = yaml.safe_load(raw)
        if isinstance(result, dict):
            return result
        raise ValueError(
            "OpenAPI spec did not parse to a JSON object / YAML mapping."
        )

    # ── URL / tool helpers ───────────────────────────────────────────────

    @staticmethod
    def _resolve_base_url(spec: Dict[str, Any]) -> str:
        """Extract a base URL from the spec (OpenAPI 3.x ``servers`` or Swagger 2.x fields)."""
        # OpenAPI 3.x
        servers = spec.get("servers")
        if isinstance(servers, list) and servers:
            return servers[0].get("url", "").rstrip("/")
        # Swagger 2.x
        host = spec.get("host", "")
        if host:
            scheme = (spec.get("schemes") or ["https"])[0]
            base_path = spec.get("basePath", "").rstrip("/")
            return f"{scheme}://{host}{base_path}"
        return ""

    @staticmethod
    def _extract_tools(spec: Dict[str, Any]) -> List[Dict[str, Any]]:
        """Walk ``paths`` and produce a tool descriptor per operation."""
        tools: List[Dict[str, Any]] = []
        paths = spec.get("paths") or {}
        for path, path_item in paths.items():
            if not isinstance(path_item, dict):
                continue
            for method in _SUPPORTED_METHODS:
                operation = path_item.get(method)
                if not isinstance(operation, dict):
                    continue
                name = operation.get("operationId") or f"{method.upper()} {path}"
                description = (
                    operation.get("summary")
                    or operation.get("description")
                    or name
                )
                parameters = _collect_parameters(operation, path_item)
                tools.append({
                    "name": name,
                    "description": description,
                    "parameters": parameters,
                    "method": method,
                    "path": path,
                })
        return tools

    def _find_tool(self, tool_name: str) -> Dict[str, Any] | None:
        """Look up a tool by name (case-insensitive method)."""
        assert self._tools is not None  # noqa: S101
        normalised = tool_name.strip()
        for tool in self._tools:
            if tool["name"].upper() == normalised.upper():
                return tool
        return None

    def _build_request(
        self,
        operation_id: str,
        args: Dict[str, Any],
    ) -> Dict[str, Any]:
        """Map *args* into path, query, and body based on the spec.

        Parameters
        ----------
        operation_id:
            The ``operationId`` (or ``METHOD /path`` name) that identifies the
            endpoint in the parsed spec.
        args:
            Flat dict of argument names to values supplied by the caller.

        Returns
        -------
        dict with keys ``method``, ``url``, ``query``, ``body``, ``headers``.
        ``query`` is a dict of query-string params, ``body`` is a dict of
        JSON-body fields (or ``None`` for methods that don't carry a body).
        """
        tool = self._find_tool(operation_id)
        if tool is None:
            raise ValueError(f"Unknown operation: {operation_id}")

        method: str = tool["method"]
        path_template: str = tool["path"]
        params: List[Dict[str, Any]] = tool.get("parameters") or []

        # Build lookup of parameter locations declared in the spec.
        param_locations: Dict[str, str] = {
            p["name"]: p["location"] for p in params
        }

        path_args: Dict[str, Any] = {}
        query_args: Dict[str, Any] = {}
        body_args: Dict[str, Any] = {}

        for key, value in args.items():
            location = param_locations.get(key, "")
            if location == "path" or "{" + key + "}" in path_template:
                path_args[key] = value
            elif location == "query":
                query_args[key] = value
            else:
                # "body", "header" (headers not yet wired), or unrecognised
                # params all fall into the body bucket.
                body_args[key] = value

        # Substitute path parameters.
        url = path_template
        for key, value in path_args.items():
            url = url.replace("{" + key + "}", str(value))
        url = f"{self._base_url}{url}"

        # Append query string.
        if query_args:
            url = f"{url}?{urlencode(query_args)}"

        # Body only for methods that support it.
        body: Dict[str, Any] | None = body_args if body_args else None

        headers: Dict[str, str] = {"Accept": "application/json"}
        if body is not None:
            headers["Content-Type"] = "application/json"

        return {
            "method": method.upper(),
            "url": url,
            "query": query_args,
            "body": body,
            "headers": headers,
        }

    def _build_url(self, path_template: str, args: Dict[str, Any]) -> str:
        """Substitute path parameters and return the full URL."""
        path = path_template
        for key, value in list(args.items()):
            placeholder = "{" + key + "}"
            if placeholder in path:
                path = path.replace(placeholder, str(value))
        return f"{self._base_url}{path}"

    @staticmethod
    def _http_request(method: str, url: str, args: Dict[str, Any]) -> Any:
        """Make an HTTP request using stdlib."""
        import urllib.request  # noqa: E402

        body_bytes: bytes | None = None
        headers: Dict[str, str] = {"Accept": "application/json"}

        if method.lower() in ("post", "put", "patch"):
            body_bytes = json.dumps(args).encode("utf-8")
            headers["Content-Type"] = "application/json"

        req = urllib.request.Request(
            url,
            data=body_bytes,
            headers=headers,
            method=method.upper(),
        )
        with urllib.request.urlopen(req, timeout=30) as resp:  # noqa: S310
            raw = resp.read().decode("utf-8")
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return raw


# ── helpers ──────────────────────────────────────────────────────────────

def _collect_parameters(
    operation: Dict[str, Any],
    path_item: Dict[str, Any],
) -> List[Dict[str, Any]]:
    """Merge path-level and operation-level parameters."""
    seen: Dict[str, Dict[str, Any]] = {}

    # Path-level parameters (lower priority).
    for param in path_item.get("parameters") or []:
        if isinstance(param, dict) and "name" in param:
            seen[param["name"]] = _normalise_param(param)

    # Operation-level parameters (override path-level).
    for param in operation.get("parameters") or []:
        if isinstance(param, dict) and "name" in param:
            seen[param["name"]] = _normalise_param(param)

    return list(seen.values())


def _normalise_param(param: Dict[str, Any]) -> Dict[str, Any]:
    """Return a minimal tool-parameter descriptor."""
    return {
        "name": param["name"],
        "description": param.get("description", ""),
        "required": param.get("required", False),
        "location": param.get("in", "query"),
    }
