"""
Orchestration API layer for remote metadata and invocation (TZ FR-2, FR-3, FR-5).

All framework code that needs remote metadata or remote invocation must call only
the methods of this layer. Validation is local and in-memory (NFR-2); no proxy
roundtrip in the validation path. invoke_remote_command validates first, then
performs HTTP POST to host:port/api/jsonrpc.

Author: Vasiliy Zdanovskiy
email: vasilyvz@gmail.com
"""

from __future__ import annotations

from typing import Any, Dict, List, Mapping, Tuple, Type, Union

import httpx

from mcp_proxy_adapter.core.orchestration.catalog_snapshot import CatalogSnapshot
from mcp_proxy_adapter.core.orchestration.orchestration_errors import (
    CommandNotFoundError,
    InvalidParameterError,
    MissingRequiredParametersError,
    OrchestrationError,
    SchemaUnavailableError,
    ServerNotFoundError,
    StaleCatalogError,
)
from mcp_proxy_adapter.core.orchestration.server_record import ServerRecord
from mcp_proxy_adapter.core.orchestration.snapshot_holder import SnapshotHolder

# JSON-RPC endpoint path (documented; no hardcode of full URLs beyond this).
JSONRPC_PATH = "/api/jsonrpc"

# Default HTTP scheme when building URL from host/port.
DEFAULT_SCHEME = "http"

# JSON Schema type name -> Python type(s) for validation.
_SCHEMA_TYPE_MAP: Dict[
    str, Union[Type[Any], Tuple[Type[Any], ...]]
] = {
    "string": str,
    "number": (int, float),
    "integer": int,
    "boolean": bool,
    "array": list,
    "object": dict,
    "null": type(None),
}


def _get_catalog(holder: SnapshotHolder) -> CatalogSnapshot:
    """Return current catalog from holder; raise StaleCatalogError if None."""
    catalog = holder.get_catalog()
    if catalog is None:
        raise StaleCatalogError(
            message="Stale or unavailable catalog state",
            reason="No snapshot has been set yet",
        )
    return catalog


def _validate_param_type(
    server_id: str,
    command_name: str,
    param_name: str,
    value: Any,
    schema: Dict[str, Any],
) -> None:
    """
    Validate a single parameter value against schema property type.
    Raise InvalidParameterError if type does not match.
    """
    properties = schema.get("properties") or {}
    prop_schema = properties.get(param_name)
    if not isinstance(prop_schema, dict):
        return
    type_name = prop_schema.get("type")
    if not type_name:
        return
    expected = _SCHEMA_TYPE_MAP.get(type_name)
    if expected is None:
        return
    if expected is type(None):
        if value is not None:
            raise InvalidParameterError(
                server_id=server_id,
                command_name=command_name,
                parameter_name=param_name,
                reason="Expected null",
            )
    elif not isinstance(value, expected):
        name = getattr(expected, "__name__", str(expected))
        raise InvalidParameterError(
            server_id=server_id,
            command_name=command_name,
            parameter_name=param_name,
            reason=f"Expected {name}, got {type(value).__name__}",
        )


def get_servers(holder: SnapshotHolder) -> List[Dict[str, Any]]:
    """
    Return list of server info dicts from current catalog snapshot.

    Each entry has at least: server_id, host, port; optionally base_url, status.
    Raises StaleCatalogError if no snapshot is available.
    """
    catalog = _get_catalog(holder)
    result: List[Dict[str, Any]] = []
    for s in catalog.get_servers().values():
        info: Dict[str, Any] = {
            "server_id": s.server_id,
            "host": s.host,
            "port": s.port,
        }
        if s.base_url is not None:
            info["base_url"] = s.base_url
        if s.status is not None:
            info["status"] = s.status
        result.append(info)
    return result


def get_server(holder: SnapshotHolder, server_id: str) -> ServerRecord:
    """
    Return ServerRecord for server_id.

    Raises StaleCatalogError if no snapshot. Raises ServerNotFoundError if
    server_id is not in the catalog.
    """
    catalog = _get_catalog(holder)
    server = catalog.get_server(server_id)
    if server is None:
        raise ServerNotFoundError(server_id=server_id)
    return server


def get_server_commands(holder: SnapshotHolder, server_id: str) -> List[Dict[str, Any]]:
    """
    Return list of command names and summary info for the given server.

    Each entry has: name (command_name), and optionally summary.
    Raises StaleCatalogError if no snapshot. Raises ServerNotFoundError if
    server is not in catalog. Returns empty list if server has no commands.
    """
    catalog = _get_catalog(holder)
    if catalog.get_server(server_id) is None:
        raise ServerNotFoundError(server_id=server_id)
    commands = catalog.get_commands(server_id)
    return [
        {"name": cmd.command_name, "summary": cmd.summary} for cmd in commands.values()
    ]


def get_command_schema(
    holder: SnapshotHolder, server_id: str, command_name: str
) -> Dict[str, Any]:
    """
    Return full JSON schema for the command (server_id, command_name).

    Raises StaleCatalogError, ServerNotFoundError, CommandNotFoundError, or
    SchemaUnavailableError if command has no schema.
    """
    catalog = _get_catalog(holder)
    cmd = catalog.get_command(server_id, command_name)
    if cmd is None:
        server = catalog.get_server(server_id)
        if server is None:
            raise ServerNotFoundError(server_id=server_id)
        raise CommandNotFoundError(server_id=server_id, command_name=command_name)
    if not cmd.schema:
        raise SchemaUnavailableError(server_id=server_id, command_name=command_name)
    return dict(cmd.schema)


def get_required_parameters(
    holder: SnapshotHolder, server_id: str, command_name: str
) -> List[str]:
    """
    Return list of required parameter names for the command.

    Raises StaleCatalogError, ServerNotFoundError, or CommandNotFoundError.
    """
    catalog = _get_catalog(holder)
    cmd = catalog.get_command(server_id, command_name)
    if cmd is None:
        server = catalog.get_server(server_id)
        if server is None:
            raise ServerNotFoundError(server_id=server_id)
        raise CommandNotFoundError(server_id=server_id, command_name=command_name)
    return list(cmd.required_parameters)


def validate_call(
    holder: SnapshotHolder,
    server_id: str,
    command_name: str,
    params: Mapping[str, Any],
) -> None:
    """
    Validate that params satisfy the command schema and required set (TZ FR-5, NFR-2).

    Uses in-memory schema and required set only; no network call.
    Raises StaleCatalogError, ServerNotFoundError, CommandNotFoundError,
    SchemaUnavailableError, MissingRequiredParametersError, or InvalidParameterError.
    Returns None on success.
    """
    catalog = _get_catalog(holder)
    cmd = catalog.get_command(server_id, command_name)
    if cmd is None:
        server = catalog.get_server(server_id)
        if server is None:
            raise ServerNotFoundError(server_id=server_id)
        raise CommandNotFoundError(server_id=server_id, command_name=command_name)
    if not cmd.schema:
        raise SchemaUnavailableError(server_id=server_id, command_name=command_name)

    required = set(cmd.required_parameters)
    params_dict = dict(params) if params is not None else {}
    missing = required - set(params_dict.keys())
    if missing:
        raise MissingRequiredParametersError(
            server_id=server_id,
            command_name=command_name,
            parameter_names=sorted(missing),
        )

    schema = cmd.schema
    for param_name, value in params_dict.items():
        _validate_param_type(server_id, command_name, param_name, value, schema)


async def invoke_remote_command(
    holder: SnapshotHolder,
    server_id: str,
    host: str,
    port: int,
    command_name: str,
    params: Mapping[str, Any],
    *,
    scheme: str = DEFAULT_SCHEME,
    timeout_seconds: float = 30.0,
) -> Any:
    """
    Validate call locally, then perform HTTP POST to host:port/api/jsonrpc (TZ FR-3).

    On validation failure, does not perform network call; raises the same errors
    as validate_call. On success, returns the JSON-RPC result payload (the
    "result" part of the response; type is dict or other JSON value).
    """
    validate_call(holder, server_id, command_name, params)
    url = f"{scheme}://{host}:{port}{JSONRPC_PATH}"
    body = {
        "jsonrpc": "2.0",
        "method": command_name,
        "params": dict(params) if params else {},
        "id": 1,
    }
    async with httpx.AsyncClient(timeout=timeout_seconds) as client:
        response = await client.post(url, json=body)
        response.raise_for_status()
        data = response.json()
    if "error" in data:
        err = data["error"]
        msg = err.get("message", "Remote JSON-RPC error")
        raise OrchestrationError(
            message=msg,
            data={
                "server_id": server_id,
                "command_name": command_name,
                **dict(err.get("data", {})),
            },
        )
    return data.get("result", data)
