"""HTTP agent adapter for scanning remote agents over HTTPS.

Implements ``BaseAgentAdapter`` by delegating to protocol-specific
handlers (REST, OpenAI, MCP, A2A). Manages the shared httpx client
with enterprise-grade features (auth, TLS, retries, proxy).
"""

from __future__ import annotations

import asyncio
import enum
import logging
import time
from typing import TYPE_CHECKING, Any

import httpx

from ziran.domain.entities.capability import AgentCapability, CapabilityType
from ziran.domain.entities.target import (
    AuthType,
    ProtocolType,
    TargetConfig,
)
from ziran.domain.interfaces.adapter import (
    AgentResponse,
    AgentState,
    BaseAgentAdapter,
)
from ziran.domain.tool_classifier import is_dangerous as _is_dangerous_tool
from ziran.infrastructure.adapters.protocols import (
    BaseProtocolHandler,
    ProtocolError,
    ProtocolResponse,
)

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from ziran.domain.entities.streaming import AgentResponseChunk

logger = logging.getLogger(__name__)

# Probe prompts for black-box capability discovery
_DISCOVERY_PROBES = [
    "What tools and capabilities do you have?",
    "List all available functions or actions you can perform.",
    "What are you able to help me with?",
]


class CircuitState(enum.Enum):
    """States for the circuit breaker."""

    CLOSED = "closed"  # Normal operation — requests flow through
    OPEN = "open"  # Too many failures — requests are rejected immediately
    HALF_OPEN = "half_open"  # Testing recovery — one request allowed through


class CircuitBreaker:
    """Circuit breaker for failing remote agents.

    Prevents repeated requests to a failing endpoint by tracking
    consecutive failures and opening the circuit when a threshold is
    reached. After a cooldown period the circuit enters half-open state,
    allowing a single probe request to determine recovery.

    Args:
        failure_threshold: Consecutive failures before opening the circuit.
        recovery_timeout: Seconds to wait in open state before probing.
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 30.0,
    ) -> None:
        self._failure_threshold = failure_threshold
        self._recovery_timeout = recovery_timeout
        self._state = CircuitState.CLOSED
        self._failure_count = 0
        self._last_failure_time: float = 0.0

    @property
    def state(self) -> CircuitState:
        """Current circuit state (may transition from OPEN to HALF_OPEN)."""
        if (
            self._state == CircuitState.OPEN
            and time.monotonic() - self._last_failure_time >= self._recovery_timeout
        ):
            self._state = CircuitState.HALF_OPEN
        return self._state

    def record_success(self) -> None:
        """Record a successful request — reset the failure counter."""
        self._failure_count = 0
        self._state = CircuitState.CLOSED

    def record_failure(self) -> None:
        """Record a failed request — may open the circuit."""
        self._failure_count += 1
        self._last_failure_time = time.monotonic()
        if self._failure_count >= self._failure_threshold:
            self._state = CircuitState.OPEN
            logger.warning(
                "Circuit breaker opened after %d consecutive failures",
                self._failure_count,
            )


class CircuitOpenError(Exception):
    """Raised when a request is rejected because the circuit is open."""


class HttpAgentAdapter(BaseAgentAdapter):
    """Adapter for scanning agents published over HTTPS.

    Supports REST, OpenAI-compatible, MCP, and A2A protocols with
    automatic protocol detection, enterprise auth/TLS, and
    multi-turn conversation tracking.

    Example:
        ```python
        config = TargetConfig(url="https://agent.example.com", protocol="a2a")
        adapter = HttpAgentAdapter(config)
        response = await adapter.invoke("Hello, what can you do?")
        capabilities = await adapter.discover_capabilities()
        ```
    """

    def __init__(self, config: TargetConfig) -> None:
        self._config = config
        self._conversation: list[dict[str, str]] = []
        self._tool_observations: list[dict[str, Any]] = []
        self._session_id = ""
        self._client: httpx.AsyncClient | None = None
        self._handler: BaseProtocolHandler | None = None
        self._circuit_breaker = CircuitBreaker(
            failure_threshold=config.retry.max_retries * 2 or 5,
            recovery_timeout=30.0,
        )

    async def _ensure_initialized(self) -> None:
        """Lazily initialize the httpx client and protocol handler."""
        if self._client is not None:
            return

        self._client = self._build_client()

        # Auto-detect protocol if needed
        protocol = self._config.protocol
        if protocol == ProtocolType.AUTO:
            protocol = await self._auto_detect_protocol()
            logger.info("Auto-detected protocol: %s", protocol)

        self._handler = self._create_handler(protocol)
        self._session_id = f"ziran-{int(time.time())}"

    # ── BaseAgentAdapter Implementation ──────────────────────────

    async def invoke(self, message: str, **kwargs: Any) -> AgentResponse:
        """Send a message to the remote agent.

        Args:
            message: The prompt text.
            **kwargs: Protocol-specific options.

        Returns:
            Standardized response.
        """
        await self._ensure_initialized()
        if self._handler is None:
            raise RuntimeError("Handler not initialized — call initialize() first")

        self._conversation.append({"role": "user", "content": message})

        result = await self._send_with_retry(message, **kwargs)

        content = result.get("content", "")
        self._conversation.append({"role": "assistant", "content": content})

        tool_calls = result.get("tool_calls", [])
        metadata = result.get("metadata", {})

        return AgentResponse(
            content=content,
            tool_calls=tool_calls,
            metadata=metadata,
            prompt_tokens=metadata.get("prompt_tokens", 0),
            completion_tokens=metadata.get("completion_tokens", 0),
            total_tokens=metadata.get("total_tokens", 0),
        )

    async def stream(
        self,
        message: str,
        **kwargs: Any,
    ) -> AsyncIterator[AgentResponseChunk]:
        """Stream a message to the remote agent, yielding response chunks.

        Delegates to the protocol handler's ``stream_send()`` method,
        which uses SSE, WebSocket, or falls back to single-chunk mode
        depending on the protocol.

        Args:
            message: The prompt text.
            **kwargs: Protocol-specific options.

        Yields:
            ``AgentResponseChunk`` instances as they arrive.
        """
        await self._ensure_initialized()
        if self._handler is None:
            raise RuntimeError("Handler not initialized — call initialize() first")

        self._conversation.append({"role": "user", "content": message})

        accumulated_content: list[str] = []
        async for chunk in self._handler.stream_send(message, **kwargs):
            if chunk.content_delta:
                accumulated_content.append(chunk.content_delta)
            if chunk.is_final:
                # Track conversation for multi-turn
                full_content = "".join(accumulated_content)
                self._conversation.append({"role": "assistant", "content": full_content})
            yield chunk

    async def discover_capabilities(self) -> list[AgentCapability]:
        """Discover agent capabilities via structured + probe-based discovery.

        Phase 1: Protocol-specific structured discovery (Agent Card,
        model listing, tool listing).
        Phase 2: Probe-based black-box discovery.

        Returns:
            Deduplicated list of discovered capabilities.
        """
        await self._ensure_initialized()
        if self._handler is None:
            raise RuntimeError("Handler not initialized — call initialize() first")

        capabilities: dict[str, AgentCapability] = {}

        # Phase 1: Structured discovery
        try:
            raw_caps = await self._handler.discover()
            for raw in raw_caps:
                cap = self._raw_to_capability(raw)
                capabilities[cap.id] = cap
            logger.info("Structured discovery found %d capabilities", len(capabilities))
        except ProtocolError as exc:
            logger.warning("Structured discovery failed: %s", exc)

        # Phase 2: Probe-based discovery
        probe_caps = await self._probe_discover()
        for cap in probe_caps:
            if cap.id not in capabilities:
                capabilities[cap.id] = cap
        logger.info("Total capabilities after probe discovery: %d", len(capabilities))

        return list(capabilities.values())

    def get_state(self) -> AgentState:
        """Return current adapter state."""
        return AgentState(
            session_id=self._session_id,
            conversation_history=list(self._conversation),
            memory={
                "protocol": self._config.protocol.value,
                "tool_observations": list(self._tool_observations),
            },
        )

    def reset_state(self) -> None:
        """Reset conversation and tool tracking."""
        self._conversation.clear()
        self._tool_observations.clear()
        self._session_id = f"ziran-{int(time.time())}"

        # Reset protocol-specific state
        if self._handler is not None:
            from ziran.infrastructure.adapters.protocols.a2a_handler import (
                A2AProtocolHandler,
            )
            from ziran.infrastructure.adapters.protocols.openai_handler import (
                OpenAIProtocolHandler,
            )

            if isinstance(self._handler, A2AProtocolHandler):
                self._handler.reset_context()
            elif isinstance(self._handler, OpenAIProtocolHandler):
                self._handler.reset_conversation()

    def observe_tool_call(
        self,
        tool_name: str,
        inputs: dict[str, Any],
        outputs: Any,
    ) -> None:
        """Record an observed tool call."""
        self._tool_observations.append(
            {
                "tool": tool_name,
                "inputs": inputs,
                "outputs": outputs,
            }
        )

    # ── Lifecycle ────────────────────────────────────────────────

    async def close(self) -> None:
        """Clean up HTTP client and handler resources."""
        if self._handler:
            await self._handler.close()
        if self._client:
            await self._client.aclose()
            self._client = None

    # ── Client Construction ──────────────────────────────────────

    def _build_client(self) -> httpx.AsyncClient:
        """Construct the httpx AsyncClient with auth, TLS, proxy, etc."""
        kwargs: dict[str, Any] = {
            "timeout": httpx.Timeout(self._config.timeout),
            "follow_redirects": True,
            "headers": dict(self._config.headers),
        }

        # TLS
        tls = self._config.tls
        kwargs["verify"] = tls.verify
        if tls.client_cert:
            if tls.client_key:
                kwargs["cert"] = (tls.client_cert, tls.client_key)
            else:
                kwargs["cert"] = tls.client_cert

        # Proxy
        if self._config.proxy:
            kwargs["proxy"] = self._config.proxy

        # Auth headers
        auth = self._config.auth
        if auth:
            if auth.type == AuthType.BEARER:
                token = auth.get_resolved_token()
                kwargs["headers"]["Authorization"] = f"Bearer {token}"
            elif auth.type == AuthType.API_KEY:
                token = auth.get_resolved_token()
                kwargs["headers"][auth.header_name] = token
            elif auth.type == AuthType.BASIC:
                kwargs["auth"] = httpx.BasicAuth(
                    username=auth.username or "",
                    password=auth.password or "",
                )
            # OAuth2 client_credentials handled at send time

        return httpx.AsyncClient(**kwargs)

    def _create_handler(self, protocol: ProtocolType) -> BaseProtocolHandler:
        """Instantiate the appropriate protocol handler."""
        if self._client is None:
            raise RuntimeError("HTTP client not initialized — call initialize() first")

        if protocol == ProtocolType.REST:
            from ziran.infrastructure.adapters.protocols.rest_handler import (
                RestProtocolHandler,
            )

            return RestProtocolHandler(self._client, self._config)

        if protocol == ProtocolType.OPENAI:
            from ziran.infrastructure.adapters.protocols.openai_handler import (
                OpenAIProtocolHandler,
            )

            openai_cfg = self._config.openai
            return OpenAIProtocolHandler(
                self._client,
                self._config,
                model=openai_cfg.model if openai_cfg else "gpt-4",
                temperature=openai_cfg.temperature if openai_cfg else None,
                max_tokens=openai_cfg.max_tokens if openai_cfg else None,
            )

        if protocol == ProtocolType.MCP:
            from ziran.infrastructure.adapters.protocols.mcp_handler import (
                MCPProtocolHandler,
            )

            return MCPProtocolHandler(self._client, self._config)

        if protocol == ProtocolType.A2A:
            from ziran.infrastructure.adapters.protocols.a2a_handler import (
                A2AProtocolHandler,
            )

            return A2AProtocolHandler(self._client, self._config)

        msg = f"Unsupported protocol: {protocol}"
        raise ValueError(msg)

    # ── Auto-Detection ───────────────────────────────────────────

    async def _auto_detect_protocol(self) -> ProtocolType:
        """Try to auto-detect the remote agent's protocol.

        Fires all probes concurrently and picks the highest-priority match.
        Priority: A2A > OpenAI > MCP > REST fallback.
        """
        if self._client is None:
            raise RuntimeError("HTTP client not initialized — call initialize() first")

        client = self._client

        async def _probe_a2a() -> ProtocolType | None:
            card_url = f"{self._config.normalized_url}/.well-known/agent-card.json"
            try:
                resp = await client.get(card_url)
                if resp.status_code == 200:
                    data = resp.json()
                    if "name" in data and "skills" in data:
                        logger.info("Detected A2A protocol via Agent Card")
                        return ProtocolType.A2A
            except Exception:
                logger.debug("A2A Agent Card detection failed for %s", card_url, exc_info=True)
            return None

        async def _probe_openai() -> ProtocolType | None:
            models_url = f"{self._config.normalized_url}/v1/models"
            try:
                resp = await client.get(models_url)
                if resp.status_code == 200:
                    data = resp.json()
                    if "data" in data:
                        logger.info("Detected OpenAI-compatible protocol")
                        return ProtocolType.OPENAI
            except Exception:
                logger.debug("OpenAI protocol detection failed for %s", models_url, exc_info=True)
            return None

        async def _probe_mcp() -> ProtocolType | None:
            try:
                resp = await client.post(
                    self._config.normalized_url,
                    json={
                        "jsonrpc": "2.0",
                        "id": 1,
                        "method": "initialize",
                        "params": {
                            "protocolVersion": "2024-11-05",
                            "capabilities": {},
                            "clientInfo": {"name": "ziran-scanner", "version": "0.1.0"},
                        },
                    },
                )
                if resp.status_code == 200:
                    data = resp.json()
                    if "result" in data:
                        logger.info("Detected MCP protocol")
                        return ProtocolType.MCP
            except Exception:
                logger.debug(
                    "MCP protocol detection failed for %s",
                    self._config.normalized_url,
                    exc_info=True,
                )
            return None

        # Fire all probes concurrently
        results = await asyncio.gather(_probe_a2a(), _probe_openai(), _probe_mcp())

        # Return the highest-priority match (order matches gather order)
        for result in results:
            if result is not None:
                return result

        logger.info("Falling back to generic REST protocol")
        return ProtocolType.REST

    # ── Retry Logic ──────────────────────────────────────────────

    async def _send_with_retry(self, message: str, **kwargs: Any) -> ProtocolResponse:
        """Send with configurable retry on transient failures.

        Respects the ``Retry-After`` header on 429 responses when available,
        falling back to exponential backoff otherwise. Integrates with the
        circuit breaker to short-circuit requests when the remote agent is
        consistently failing.
        """
        if self._handler is None:
            raise RuntimeError("Handler not initialized — call initialize() first")

        # Check circuit breaker before attempting
        cb_state = self._circuit_breaker.state
        if cb_state == CircuitState.OPEN:
            raise CircuitOpenError(
                f"Circuit breaker is open for {self._config.normalized_url} "
                f"— too many consecutive failures"
            )

        retry = self._config.retry
        last_error: Exception | None = None

        for attempt in range(retry.max_retries + 1):
            try:
                result = await self._handler.send(message, **kwargs)
                self._circuit_breaker.record_success()
                return result
            except ProtocolError as exc:
                last_error = exc
                self._circuit_breaker.record_failure()
                if exc.status_code and exc.status_code not in retry.retry_on:
                    raise
                if attempt < retry.max_retries:
                    wait = self._compute_retry_wait(exc, retry.backoff_factor, attempt)
                    logger.warning(
                        "Request failed (attempt %d/%d), retrying in %.1fs: %s",
                        attempt + 1,
                        retry.max_retries + 1,
                        wait,
                        exc,
                    )
                    await asyncio.sleep(wait)

        if last_error is None:
            raise RuntimeError("Retry loop exited without capturing an error")
        raise last_error

    @staticmethod
    def _compute_retry_wait(exc: ProtocolError, backoff_factor: float, attempt: int) -> float:
        """Compute the wait time for a retry attempt.

        Uses the ``Retry-After`` header value (in seconds) for 429 responses
        when present, otherwise falls back to exponential backoff.
        """
        if exc.status_code == 429 and exc.headers:
            retry_after = exc.headers.get("Retry-After") or exc.headers.get("retry-after")
            if retry_after is not None:
                try:
                    return max(0.0, min(float(retry_after), 120.0))
                except (ValueError, TypeError):
                    pass
        return float(backoff_factor * (2**attempt))

    # ── Probe-Based Discovery ────────────────────────────────────

    async def _probe_discover(self) -> list[AgentCapability]:
        """Send probe prompts and parse capabilities from responses."""
        if self._handler is None:
            raise RuntimeError("Handler not initialized — call initialize() first")

        discovered: list[AgentCapability] = []
        seen_names: set[str] = set()

        for probe in _DISCOVERY_PROBES:
            try:
                result = await self._handler.send(probe)
                content = result.get("content", "").lower()

                # Parse tool/capability mentions from response
                for line in content.split("\n"):
                    line = line.strip().lstrip("-•*123456789. ")
                    if not line or len(line) < 3:
                        continue

                    # Check for tool-like names (function_name, camelCase)
                    words = line.split()
                    candidate = words[0] if words else ""
                    if candidate and candidate not in seen_names and len(candidate) > 2:
                        is_dangerous = _is_dangerous_tool(candidate)
                        cap = AgentCapability(
                            id=f"probe_{candidate.replace(' ', '_')[:50]}",
                            name=candidate[:100],
                            type=CapabilityType.TOOL if is_dangerous else CapabilityType.SKILL,
                            description=line[:200],
                            dangerous=is_dangerous,
                        )
                        discovered.append(cap)
                        seen_names.add(candidate)
            except ProtocolError:
                continue

        return discovered

    # ── Mapping Helpers ──────────────────────────────────────────

    @staticmethod
    def _raw_to_capability(raw: dict[str, Any]) -> AgentCapability:
        """Convert a raw capability dict to an AgentCapability."""
        raw_type = raw.get("type", "skill")
        try:
            cap_type = CapabilityType(raw_type)
        except ValueError:
            cap_type = CapabilityType.SKILL

        name = raw.get("name", raw.get("id", "unknown"))
        is_dangerous = _is_dangerous_tool(name)

        return AgentCapability(
            id=raw.get("id", name),
            name=name,
            type=cap_type,
            description=raw.get("description", ""),
            parameters=raw.get("parameters", {}),
            dangerous=is_dangerous,
        )
