from __future__ import annotations

import asyncio
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from typing import Any
from uuid import uuid4

import httpx
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token, get_context, get_http_headers

from .visibility_policy import VisibilityPolicy

logger = logging.getLogger("brp_mcp.tools")
_AUTH0_REFRESH_BUFFER_SECONDS = 60
_AUTH0_TIMEOUT_SECONDS = 15.0
_auth0_token_cache: dict[str, tuple[str, float]] = {}
_auth0_cache_lock = asyncio.Lock()


def _auth0_cache_key(domain: str, client_id: str, audience: str) -> str:
    return f"{domain.strip().lower()}|{client_id.strip()}|{audience.strip()}"


async def _get_auth0_access_token(
    *,
    domain: str,
    client_id: str,
    client_secret: str,
    audience: str,
) -> str:
    """Fetch and cache M2M access token for Auth0 client credentials flow."""
    cache_key = _auth0_cache_key(domain, client_id, audience)
    now = time.time()
    cached = _auth0_token_cache.get(cache_key)
    if cached:
        token, expires_at = cached
        if expires_at - _AUTH0_REFRESH_BUFFER_SECONDS > now:
            return token

    async with _auth0_cache_lock:
        now = time.time()
        cached = _auth0_token_cache.get(cache_key)
        if cached:
            token, expires_at = cached
            if expires_at - _AUTH0_REFRESH_BUFFER_SECONDS > now:
                return token

        token_url = f"https://{domain.strip()}/oauth/token"
        payload = {
            "client_id": client_id,
            "client_secret": client_secret,
            "audience": audience,
            "grant_type": "client_credentials",
        }
        async with httpx.AsyncClient(timeout=_AUTH0_TIMEOUT_SECONDS) as client:
            response = await client.post(
                token_url,
                headers={"content-type": "application/json"},
                json=payload,
            )
            response.raise_for_status()
            data = response.json()

        access_token = str(data.get("access_token") or "").strip()
        if not access_token:
            raise ValueError("Auth0 token response did not include access_token.")

        expires_in = int(data.get("expires_in") or 3600)
        expires_at = time.time() + max(expires_in, 60)
        _auth0_token_cache[cache_key] = (access_token, expires_at)
        return access_token


@dataclass(frozen=True)
class Operation:
    method: str
    path: str
    tag: str
    operation_id: str
    summary: str
    description: str
    parameters: list[dict[str, Any]]
    request_body: dict[str, Any] | None
    responses: dict[str, Any]
    security: list[dict[str, list[Any]]]


@dataclass(frozen=True)
class RegistrationStats:
    total_operations: int
    filtered_by_service_visibility: int
    filtered_by_operation_visibility: int
    registered_tools: int


def _sanitize_tool_name(name: str) -> str:
    cleaned = re.sub(r"[^a-zA-Z0-9_]+", "_", name).strip("_").lower()
    return re.sub(r"_+", "_", cleaned) or "tool"


def _coerce_visible(value: Any) -> bool | None:
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        lowered = value.strip().lower()
        if lowered in {"true", "1", "yes"}:
            return True
        if lowered in {"false", "0", "no"}:
            return False
    return None


def _lookup_policy_bool(entries: dict[str, bool], key: str) -> bool | None:
    if key in entries:
        return entries[key]
    lowered = key.lower()
    for candidate, value in entries.items():
        if candidate.lower() == lowered:
            return value
    return None


def _extract_tag_visibility(spec: dict[str, Any]) -> dict[str, bool | None]:
    tag_visibility: dict[str, bool | None] = {}
    for tag_entry in spec.get("tags", []):
        if not isinstance(tag_entry, dict):
            continue
        tag_name = tag_entry.get("name")
        if not tag_name:
            continue
        tag_visibility[str(tag_name)] = _coerce_visible(tag_entry.get("x-mcp-visible"))
    return tag_visibility


def _required_params(parameters: list[dict[str, Any]]) -> list[dict[str, Any]]:
    required: list[dict[str, Any]] = []
    for p in parameters:
        if p.get("required"):
            required.append(p)
    return required


def _format_param(parameter: dict[str, Any]) -> str:
    param_type = parameter.get("schema", {}).get("type", "any")
    description = parameter.get("description")
    base = f"{parameter.get('in', 'query')}:{parameter.get('name', 'unknown')} ({param_type})"
    if description:
        return f"{base} - {description}"
    return base


def build_tool_description(op: Operation) -> str:
    required = _required_params(op.parameters)
    required_text = "; ".join(_format_param(p) for p in required) if required else "none"

    optional = [p for p in op.parameters if not p.get("required")]
    optional_text = ", ".join(
        f"{p.get('in', 'query')}:{p.get('name', 'unknown')}" for p in optional[:8]
    ) if optional else "none"

    body_required = False
    body_types = "none"
    if op.request_body:
        body_required = bool(op.request_body.get("required"))
        body_content = op.request_body.get("content", {})
        if isinstance(body_content, dict) and body_content:
            body_types = ", ".join(sorted(body_content.keys()))

    response_codes = ", ".join(sorted(op.responses.keys())) if op.responses else "none"
    response_details = ", ".join(
        f"{code}:{resp.get('description', 'n/a')}"
        for code, resp in list(op.responses.items())[:6]
        if isinstance(resp, dict)
    ) or "none"
    summary = op.summary or f"{op.method.upper()} {op.path}"
    long_desc = op.description or "No description provided."
    security_schemes = sorted({scheme for sec in op.security for scheme in sec.keys()})
    security_text = ", ".join(security_schemes) if security_schemes else "inherited or none"

    return (
        f"{summary}. "
        f"Endpoint: {op.method.upper()} {op.path}. "
        f"OperationId: {op.operation_id}. "
        f"Details: {long_desc}. "
        f"Authentication/security: {security_text}. "
        f"Required params: {required_text}. "
        f"Optional params: {optional_text}. "
        f"Body required: {'yes' if body_required else 'no'}; accepted content types: {body_types}. "
        f"Returns HTTP codes: {response_codes}. "
        f"Response notes: {response_details}. "
        "Inputs are split into path_params, query_params, and body."
    )


def _iter_operations(
    spec: dict[str, Any],
    visibility_policy: VisibilityPolicy,
    default_visibility: bool,
) -> tuple[list[Operation], RegistrationStats]:
    paths = spec.get("paths", {})
    tag_visibility = _extract_tag_visibility(spec)
    operations: list[Operation] = []
    total_operations = 0
    filtered_by_service_visibility = 0
    filtered_by_operation_visibility = 0

    for path, path_item in paths.items():
        if not isinstance(path_item, dict):
            continue
        for method in ["get", "post", "put", "patch", "delete", "options", "head"]:
            op = path_item.get(method)
            if not op:
                continue
            total_operations += 1
            tag = (op.get("tags") or ["general"])[0]
            operation_id = op.get("operationId") or f"{method}_{path}"

            service_visible = tag_visibility.get(tag)
            operation_visible = _coerce_visible(op.get("x-mcp-visible"))

            resolved_service_visible = service_visible if service_visible is not None else default_visibility

            policy_default = visibility_policy.default_visible
            if policy_default is not None:
                resolved_service_visible = service_visible if service_visible is not None else policy_default

            policy_service_visible = _lookup_policy_bool(visibility_policy.services, tag)
            if policy_service_visible is not None:
                resolved_service_visible = policy_service_visible

            resolved_operation_visible = operation_visible if operation_visible is not None else resolved_service_visible

            operation_method_path = f"{method.upper()} {path}"
            policy_operation_visible = _lookup_policy_bool(visibility_policy.operations, operation_id)
            if policy_operation_visible is None:
                policy_operation_visible = _lookup_policy_bool(visibility_policy.operations, operation_method_path)
            if policy_operation_visible is not None:
                resolved_operation_visible = policy_operation_visible

            if resolved_operation_visible:
                pass
            elif not resolved_service_visible:
                filtered_by_service_visibility += 1
                continue
            elif not resolved_operation_visible:
                filtered_by_operation_visibility += 1
                continue

            operations.append(
                Operation(
                    method=method,
                    path=path,
                    tag=tag,
                    operation_id=operation_id,
                    summary=op.get("summary", ""),
                    description=op.get("description", ""),
                    parameters=op.get("parameters", []),
                    request_body=op.get("requestBody"),
                    responses=op.get("responses", {}),
                    security=op.get("security", []),
                )
            )
    stats = RegistrationStats(
        total_operations=total_operations,
        filtered_by_service_visibility=filtered_by_service_visibility,
        filtered_by_operation_visibility=filtered_by_operation_visibility,
        registered_tools=len(operations),
    )
    return operations, stats


def _replace_path_params(path: str, path_params: dict[str, Any]) -> str:
    resolved = path
    for key, value in path_params.items():
        resolved = resolved.replace("{" + key + "}", str(value))
    return resolved


def register_openapi_tools(
    mcp: FastMCP,
    spec: dict[str, Any],
    gateway_base_url: str,
    timeout_seconds: float,
    visibility_policy: VisibilityPolicy,
    default_visibility: bool,
    default_auth0_audience: str | None = None,
) -> RegistrationStats:
    operations, stats = _iter_operations(
        spec=spec,
        visibility_policy=visibility_policy,
        default_visibility=default_visibility,
    )
    tool_names_seen: set[str] = set()

    for op in operations:
        base_name = _sanitize_tool_name(f"{op.tag}_{op.method}_{op.path}")
        base_name = base_name[:40].rstrip("_")
        tool_name = base_name
        count = 1
        while tool_name in tool_names_seen:
            count += 1
            tool_name = f"{base_name}_{count}"
        tool_names_seen.add(tool_name)
        description = build_tool_description(op)

        def _make_handler(_op: Operation, _tool_name: str):
            def _extract_auth_from_meta() -> str | None:
                try:
                    ctx = get_context()
                except RuntimeError:
                    return None

                request_context = getattr(ctx, "request_context", None)
                meta = getattr(request_context, "meta", None)
                if meta is None:
                    return None

                if isinstance(meta, dict):
                    auth_value = meta.get("authorization") or meta.get("Authorization")
                    if isinstance(auth_value, str) and auth_value.strip():
                        return auth_value.strip()

                    jwt_value = meta.get("jwt") or meta.get("token")
                    if isinstance(jwt_value, str) and jwt_value.strip():
                        return f"Bearer {jwt_value.strip()}"
                    return None

                auth_value = getattr(meta, "authorization", None) or getattr(meta, "Authorization", None)
                if isinstance(auth_value, str) and auth_value.strip():
                    return auth_value.strip()

                jwt_value = getattr(meta, "jwt", None) or getattr(meta, "token", None)
                if isinstance(jwt_value, str) and jwt_value.strip():
                    return f"Bearer {jwt_value.strip()}"
                return None

            async def _handler(
                path_params: dict[str, Any] | None = None,
                query_params: dict[str, Any] | None = None,
                body: dict[str, Any] | None = None,
            ) -> dict[str, Any]:
                headers = dict(get_http_headers() or {})
                auth = headers.get("authorization") or headers.get("Authorization")
                if not auth:
                    access_token = get_access_token()
                    if access_token and access_token.token:
                        auth = f"Bearer {access_token.token}"
                if not auth:
                    auth = _extract_auth_from_meta()
                if not auth:
                    env_token = os.getenv("BRP_API_TOKEN", "").strip()
                    if env_token:
                        auth = f"Bearer {env_token}"
                if not auth:
                    auth0_domain = os.getenv("BRP_AUTH0_DOMAIN", "").strip()
                    auth0_client_id = os.getenv("BRP_AUTH0_CLIENT_ID", "").strip()
                    auth0_client_secret = os.getenv("BRP_AUTH0_CLIENT_SECRET", "").strip()
                    auth0_audience = (
                        os.getenv("BRP_AUTH0_AUDIENCE", "").strip()
                        or (default_auth0_audience or "").strip()
                    )

                    has_any_auth0_value = any([auth0_domain, auth0_client_id, auth0_client_secret, auth0_audience])
                    has_required_auth0_values = all([auth0_domain, auth0_client_id, auth0_client_secret, auth0_audience])
                    if has_any_auth0_value and not has_required_auth0_values:
                        return {
                            "error": (
                                "Incomplete Auth0 configuration. Set BRP_AUTH0_DOMAIN, "
                                "BRP_AUTH0_CLIENT_ID, BRP_AUTH0_CLIENT_SECRET, and BRP_AUTH0_AUDIENCE."
                            ),
                            "errorCode": "AUTHENTICATION_ERROR",
                        }
                    if has_required_auth0_values:
                        try:
                            token = await _get_auth0_access_token(
                                domain=auth0_domain,
                                client_id=auth0_client_id,
                                client_secret=auth0_client_secret,
                                audience=auth0_audience,
                            )
                            auth = f"Bearer {token}"
                        except httpx.HTTPStatusError as exc:
                            logger.warning(
                                "Auth0 token exchange failed: status=%s domain=%s client_id_prefix=%s",
                                exc.response.status_code,
                                auth0_domain,
                                f"{auth0_client_id[:4]}***" if auth0_client_id else "(missing)",
                            )
                            return {
                                "error": "Auth0 token exchange failed. Verify Auth0 credentials and audience.",
                                "errorCode": "AUTHENTICATION_ERROR",
                            }
                        except Exception:
                            logger.warning(
                                "Auth0 token exchange failed: domain=%s client_id_prefix=%s",
                                auth0_domain,
                                f"{auth0_client_id[:4]}***" if auth0_client_id else "(missing)",
                            )
                            return {
                                "error": "Auth0 token exchange failed. Verify Auth0 credentials and audience.",
                                "errorCode": "AUTHENTICATION_ERROR",
                            }
                if not auth or not auth.lower().startswith("bearer "):
                    return {
                        "error": "Missing or invalid Authorization bearer token.",
                        "errorCode": "AUTHENTICATION_ERROR",
                    }

                correlation_id = headers.get("x-correlation-id") or headers.get("X-Correlation-ID") or str(uuid4())
                request_headers = {
                    "Authorization": auth,
                    "Content-Type": "application/json",
                    "X-Correlation-ID": correlation_id,
                }

                final_path = _replace_path_params(_op.path, path_params or {})
                url = f"{gateway_base_url.rstrip('/')}{final_path}"
                started_ms = time.time() * 1000
                try:
                    async with httpx.AsyncClient(timeout=timeout_seconds) as client:
                        response = await client.request(
                            _op.method.upper(),
                            url,
                            params=query_params or None,
                            json=body if body is not None else None,
                            headers=request_headers,
                        )
                except httpx.TimeoutException:
                    return {
                        "error": "Upstream request timed out.",
                        "errorCode": "TIMEOUT",
                        "correlationId": correlation_id,
                        "tool": _tool_name,
                    }
                except httpx.HTTPError as exc:
                    return {
                        "error": f"HTTP transport error: {exc}",
                        "errorCode": "UPSTREAM_ERROR",
                        "correlationId": correlation_id,
                        "tool": _tool_name,
                    }

                elapsed_ms = int((time.time() * 1000) - started_ms)
                log_payload = {
                    "event": "mcp_tool_call",
                    "tool": _tool_name,
                    "method": _op.method.upper(),
                    "path": _op.path,
                    "status": response.status_code,
                    "latencyMs": elapsed_ms,
                    "correlationId": correlation_id,
                }
                logger.info("mcp_tool_call %s", json.dumps(log_payload, separators=(",", ":")))

                content_type = response.headers.get("content-type", "")
                if "application/json" in content_type.lower():
                    data: Any = response.json()
                else:
                    data = response.text

                return {
                    "status": response.status_code,
                    "ok": response.is_success,
                    "data": data,
                    "correlationId": correlation_id,
                }

            return _handler

        mcp.tool(name=tool_name, description=description)(_make_handler(op, tool_name))

    return RegistrationStats(
        total_operations=stats.total_operations,
        filtered_by_service_visibility=stats.filtered_by_service_visibility,
        filtered_by_operation_visibility=stats.filtered_by_operation_visibility,
        registered_tools=len(tool_names_seen),
    )
