# platform/deskclaw/adapters/http_adapter.py
# ═══════════════════════════════════════════════════════════════
# Universal HTTP Adapter — 零代码接入任何 HTTP API 的 Bot
#
# 支持三种协议映射：
#   1. "openai"    — OpenAI Chat Completions API (/v1/chat/completions)
#   2. "anthropic" — Anthropic Messages API (/v1/messages)
#   3. "custom"    — 用户定义的 JSONPath 映射
#
# 通过 YAML 配置或 CLI 参数动态实例化，无需写任何 Python 代码。
# ═══════════════════════════════════════════════════════════════

from __future__ import annotations

import json
import re
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncIterator

import httpx

from deskclaw.adapters.protocol import (
    AgentAdapter,
    AgentCapability,
    AgentMessage,
    AgentResponse,
)


# ── Configuration ──


@dataclass
class HTTPAdapterConfig:
    """Configuration for a Universal HTTP Adapter instance."""

    name: str
    url: str
    protocol: str = "openai"  # "openai" | "anthropic" | "custom"
    capabilities: set[AgentCapability] = field(default_factory=set)

    # Auth
    auth_type: str = "none"  # "bearer" | "api_key_header" | "basic" | "none"
    auth_token: str = ""
    auth_header: str = "Authorization"

    # Custom protocol mapping (only used when protocol == "custom")
    request_mapping: dict[str, str] = field(default_factory=dict)
    response_mapping: dict[str, str] = field(default_factory=dict)

    # Timeouts
    connect_timeout_s: float = 10.0
    read_timeout_s: float = 300.0

    # Optional metadata
    context_window: int = 128_000
    cost_per_1k_input: float = 0.0
    cost_per_1k_output: float = 0.0

    # Extra headers (e.g. from curl import)
    extra_headers: dict[str, str] = field(default_factory=dict)


# ── Protocol Mappers ──


class ProtocolMapper(ABC):
    """
    Maps DeskClaw's internal message format to a specific API's
    request/response format.
    """

    @abstractmethod
    def build_request(
        self,
        messages: list[AgentMessage],
        *,
        max_tokens: int = 4096,
        temperature: float = 0.0,
        tools: list[dict] | None = None,
    ) -> dict[str, Any]:
        """Build the JSON payload for the target API."""
        ...

    @abstractmethod
    def parse_response(
        self,
        data: dict[str, Any],
    ) -> tuple[str, list | None, dict[str, int]]:
        """
        Parse the API response.

        Returns:
            Tuple of (content, tool_calls, usage_dict).
            usage_dict has keys: input_tokens, output_tokens, api_calls.
        """
        ...

    @abstractmethod
    def build_stream_request(
        self,
        messages: list[AgentMessage],
        **kwargs: Any,
    ) -> dict[str, Any]:
        """Build the JSON payload for streaming."""
        ...

    @abstractmethod
    def parse_stream_chunk(self, line: str) -> str | None:
        """
        Parse a single SSE line from the stream.
        Returns content text or None if the line is not a content chunk.
        """
        ...


class OpenAIProtocol(ProtocolMapper):
    """Maps to OpenAI Chat Completions API format."""

    def build_request(
        self,
        messages: list[AgentMessage],
        *,
        max_tokens: int = 4096,
        temperature: float = 0.0,
        tools: list[dict] | None = None,
    ) -> dict[str, Any]:
        payload: dict[str, Any] = {
            "messages": [{"role": m.role, "content": m.content} for m in messages],
            "max_tokens": max_tokens,
            "temperature": temperature,
        }
        if tools:
            payload["tools"] = tools
        return payload

    def parse_response(
        self,
        data: dict[str, Any],
    ) -> tuple[str, list | None, dict[str, int]]:
        choice = data["choices"][0]["message"]
        content = choice.get("content", "")
        tool_calls = choice.get("tool_calls")
        usage = data.get("usage", {})
        return (
            content,
            tool_calls,
            {
                "input_tokens": usage.get("prompt_tokens", 0),
                "output_tokens": usage.get("completion_tokens", 0),
                "api_calls": 1,
            },
        )

    def build_stream_request(
        self,
        messages: list[AgentMessage],
        **kwargs: Any,
    ) -> dict[str, Any]:
        payload = self.build_request(messages, **kwargs)
        payload["stream"] = True
        return payload

    def parse_stream_chunk(self, line: str) -> str | None:
        if not line.startswith("data: ") or line == "data: [DONE]":
            return None
        try:
            chunk = json.loads(line[6:])
            delta = chunk["choices"][0].get("delta", {})
            return delta.get("content")
        except (json.JSONDecodeError, KeyError, IndexError):
            return None


class AnthropicProtocol(ProtocolMapper):
    """Maps to Anthropic Messages API format."""

    def build_request(
        self,
        messages: list[AgentMessage],
        *,
        max_tokens: int = 4096,
        temperature: float = 0.0,
        tools: list[dict] | None = None,
    ) -> dict[str, Any]:
        # Separate system message from conversation
        system_msg = ""
        conversation: list[dict[str, str]] = []
        for m in messages:
            if m.role == "system":
                system_msg = m.content
            else:
                role = "user" if m.role in ("user", "tool_result") else "assistant"
                conversation.append({"role": role, "content": m.content})

        payload: dict[str, Any] = {
            "messages": conversation,
            "max_tokens": max_tokens,
            "temperature": temperature,
        }
        if system_msg:
            payload["system"] = system_msg
        if tools:
            payload["tools"] = tools
        return payload

    def parse_response(
        self,
        data: dict[str, Any],
    ) -> tuple[str, list | None, dict[str, int]]:
        # Anthropic returns content as a list of blocks
        content_blocks = data.get("content", [])
        text_parts: list[str] = []
        tool_calls: list[dict[str, Any]] = []

        for block in content_blocks:
            if block.get("type") == "text":
                text_parts.append(block["text"])
            elif block.get("type") == "tool_use":
                tool_calls.append(block)

        usage = data.get("usage", {})
        return (
            "\n".join(text_parts),
            tool_calls if tool_calls else None,
            {
                "input_tokens": usage.get("input_tokens", 0),
                "output_tokens": usage.get("output_tokens", 0),
                "api_calls": 1,
            },
        )

    def build_stream_request(
        self,
        messages: list[AgentMessage],
        **kwargs: Any,
    ) -> dict[str, Any]:
        payload = self.build_request(messages, **kwargs)
        payload["stream"] = True
        return payload

    def parse_stream_chunk(self, line: str) -> str | None:
        if not line.startswith("data: "):
            return None
        try:
            event = json.loads(line[6:])
            if event.get("type") == "content_block_delta":
                delta = event.get("delta", {})
                if delta.get("type") == "text_delta":
                    return delta.get("text")
        except (json.JSONDecodeError, KeyError):
            pass
        return None


class CustomProtocol(ProtocolMapper):
    """
    User-defined protocol using simple key mapping.

    request_mapping defines how to build the request payload:
        {"messages": "prompt", "max_tokens": "max_length"}
        → payload = {"prompt": [...], "max_length": 4096}

    response_mapping uses dot-notation to extract fields:
        {"content": "output.text", "usage_input": "meta.tokens_in"}
    """

    def __init__(
        self,
        request_mapping: dict[str, str],
        response_mapping: dict[str, str],
    ) -> None:
        self.request_map = request_mapping
        self.response_map = response_mapping

    def build_request(
        self,
        messages: list[AgentMessage],
        *,
        max_tokens: int = 4096,
        temperature: float = 0.0,
        tools: list[dict] | None = None,
    ) -> dict[str, Any]:
        source = {
            "messages": [{"role": m.role, "content": m.content} for m in messages],
            "max_tokens": max_tokens,
            "temperature": temperature,
        }
        if tools:
            source["tools"] = tools

        payload: dict[str, Any] = {}
        for src_key, dst_key in self.request_map.items():
            if src_key in source:
                payload[dst_key] = source[src_key]

        return payload

    def parse_response(
        self,
        data: dict[str, Any],
    ) -> tuple[str, list | None, dict[str, int]]:
        content = _extract_dotpath(data, self.response_map.get("content", "content"))
        tool_calls = _extract_dotpath(
            data, self.response_map.get("tool_calls", "tool_calls")
        )
        input_tokens = _extract_dotpath(
            data, self.response_map.get("usage_input", "usage.prompt_tokens")
        )
        output_tokens = _extract_dotpath(
            data, self.response_map.get("usage_output", "usage.completion_tokens")
        )

        return (
            str(content) if content else "",
            tool_calls if isinstance(tool_calls, list) else None,
            {
                "input_tokens": int(input_tokens) if input_tokens else 0,
                "output_tokens": int(output_tokens) if output_tokens else 0,
                "api_calls": 1,
            },
        )

    def build_stream_request(
        self,
        messages: list[AgentMessage],
        **kwargs: Any,
    ) -> dict[str, Any]:
        payload = self.build_request(messages, **kwargs)
        payload["stream"] = True
        return payload

    def parse_stream_chunk(self, line: str) -> str | None:
        if not line.startswith("data: "):
            return None
        try:
            data = json.loads(line[6:])
            content_path = self.response_map.get("content", "content")
            return str(_extract_dotpath(data, content_path) or "")
        except (json.JSONDecodeError, KeyError):
            return None


# ── Utility ──


def _extract_dotpath(data: Any, path: str) -> Any:
    """
    Extract a value from nested dicts/lists using dot notation.

    Examples:
        _extract_dotpath({"a": {"b": 1}}, "a.b") → 1
        _extract_dotpath({"choices": [{"msg": "hi"}]}, "choices.0.msg") → "hi"
    """
    current = data
    for part in path.split("."):
        if current is None:
            return None
        if isinstance(current, dict):
            current = current.get(part)
        elif isinstance(current, (list, tuple)):
            try:
                current = current[int(part)]
            except (ValueError, IndexError):
                return None
        else:
            return None
    return current


_PROTOCOL_MAP: dict[str, type[ProtocolMapper]] = {
    "openai": OpenAIProtocol,
    "anthropic": AnthropicProtocol,
}


def _build_mapper(config: HTTPAdapterConfig) -> ProtocolMapper:
    """Instantiate the correct ProtocolMapper from config."""
    if config.protocol == "custom":
        return CustomProtocol(
            request_mapping=config.request_mapping,
            response_mapping=config.response_mapping,
        )
    cls = _PROTOCOL_MAP.get(config.protocol)
    if cls is None:
        raise ValueError(
            f"Unknown protocol '{config.protocol}'. "
            f"Available: {sorted(_PROTOCOL_MAP.keys())} or 'custom'."
        )
    return cls()


# ── Core Adapter ──


class UniversalHTTPAdapter(AgentAdapter):
    """
    Universal HTTP Adapter — 零代码接入任何 HTTP API 的 Bot。

    核心能力：
    1. 内置 OpenAI / Anthropic 协议自动适配
    2. 支持 custom 键映射任意 API
    3. 从 HTTPAdapterConfig 动态实例化
    4. 健康检查 + 连通性探测
    """

    def __init__(self, config: HTTPAdapterConfig) -> None:
        self._config = config
        self._mapper = _build_mapper(config)

        # Build headers
        headers: dict[str, str] = {"Content-Type": "application/json"}
        headers.update(config.extra_headers)

        if config.auth_type == "bearer" and config.auth_token:
            headers[config.auth_header] = f"Bearer {config.auth_token}"
        elif config.auth_type == "api_key_header" and config.auth_token:
            headers[config.auth_header] = config.auth_token

        self._client = httpx.AsyncClient(
            base_url=config.url.rstrip("/"),
            headers=headers,
            timeout=httpx.Timeout(
                connect=config.connect_timeout_s,
                read=config.read_timeout_s,
                write=config.read_timeout_s,
                pool=config.connect_timeout_s,
            ),
        )

    @classmethod
    def from_config(cls, config: HTTPAdapterConfig) -> UniversalHTTPAdapter:
        """Factory method for clarity."""
        return cls(config)

    # ── AgentAdapter Protocol Implementation ──

    def name(self) -> str:
        return self._config.name

    def capabilities(self) -> set[AgentCapability]:
        return self._config.capabilities or {AgentCapability.TEXT_GENERATION}

    async def execute(
        self,
        messages: list[AgentMessage],
        *,
        max_tokens: int = 4096,
        temperature: float = 0.0,
        tools: list[dict] | None = None,
        timeout_s: int = 300,
    ) -> AgentResponse:
        start = time.monotonic()

        payload = self._mapper.build_request(
            messages,
            max_tokens=max_tokens,
            temperature=temperature,
            tools=tools,
        )

        resp = await self._client.post("", json=payload)
        resp.raise_for_status()
        data = resp.json()

        content, tool_calls, usage = self._mapper.parse_response(data)
        latency = int((time.monotonic() - start) * 1000)

        assistant_msg = AgentMessage(
            role="assistant",
            content=content,
            tool_calls=tool_calls,
            metadata={
                "adapter": self._config.name,
                "protocol": self._config.protocol,
            },
        )

        return AgentResponse(
            messages=list(messages) + [assistant_msg],
            final_answer=content,
            trace={"raw_response": data, "adapter": self._config.name},
            usage=usage,
            latency_ms=latency,
        )

    async def stream(
        self,
        messages: list[AgentMessage],
        **kwargs: Any,
    ) -> AsyncIterator[AgentMessage]:
        payload = self._mapper.build_stream_request(messages, **kwargs)

        async with self._client.stream("POST", "", json=payload) as resp:
            async for line in resp.aiter_lines():
                content = self._mapper.parse_stream_chunk(line)
                if content:
                    yield AgentMessage(role="assistant", content=content)

    def max_context_window(self) -> int:
        return self._config.context_window

    def cost_per_1k_tokens(self) -> dict[str, float]:
        return {
            "input": self._config.cost_per_1k_input,
            "output": self._config.cost_per_1k_output,
        }

    # ── Health Check ──

    async def health_check(self, timeout_s: float = 5.0) -> bool:
        """
        Attempt to reach the target API.

        Tries, in order:
        1. GET {base_url}/health
        2. GET {base_url}/
        3. A minimal POST with empty messages

        Returns True if any succeeds with a 2xx or known error response.
        """
        try:
            # Try /health endpoint first
            resp = await self._client.get("/health", timeout=httpx.Timeout(timeout_s))
            if resp.status_code < 500:
                return True
        except (httpx.ConnectError, httpx.TimeoutException):
            pass

        try:
            # Try root
            resp = await self._client.get("/", timeout=httpx.Timeout(timeout_s))
            if resp.status_code < 500:
                return True
        except (httpx.ConnectError, httpx.TimeoutException):
            pass

        return False


# ── curl Parser ──


def parse_curl_command(curl_cmd: str) -> HTTPAdapterConfig:
    """
    Parse a curl command string into an HTTPAdapterConfig.

    Supports:
        curl -X POST <url>
        -H "Header: Value"
        -d '{"json": "body"}'

    Example:
        parse_curl_command(
            'curl -X POST http://bot:8080/chat '
            '-H "Authorization: Bearer sk-xxx" '
            '-H "Content-Type: application/json" '
            '-d \'{"messages": [{"role": "user", "content": "hi"}]}\''
        )
    """
    # Normalize: remove line continuations and extra whitespace
    cmd = curl_cmd.replace("\\\n", " ").strip()
    if cmd.startswith("curl "):
        cmd = cmd[5:]

    url = ""
    headers: dict[str, str] = {}
    # Extract URL (first bare argument or after -X POST)
    url_match = re.search(r"(?:^|\s)(https?://\S+)", cmd)
    if url_match:
        url = url_match.group(1).strip("'\"")

    # Extract headers
    for header_match in re.finditer(r'-H\s+["\']([^"\']+)["\']', cmd):
        header_str = header_match.group(1)
        if ":" in header_str:
            key, value = header_str.split(":", 1)
            headers[key.strip()] = value.strip()

    # Determine auth
    auth_type = "none"
    auth_token = ""
    auth_header = "Authorization"

    for hdr_key, hdr_val in headers.items():
        if hdr_key.lower() == "authorization":
            auth_header = hdr_key
            if hdr_val.lower().startswith("bearer "):
                auth_type = "bearer"
                auth_token = hdr_val[7:]
            else:
                auth_type = "api_key_header"
                auth_token = hdr_val
            break

    # Remove auth and content-type from extra_headers
    extra = {
        k: v
        for k, v in headers.items()
        if k.lower() not in ("authorization", "content-type")
    }

    # Auto-detect protocol from URL
    protocol = "openai"
    if "anthropic" in url.lower():
        protocol = "anthropic"

    # Generate a name from the URL
    name_match = re.search(r"https?://([^/:]+)", url)
    generated_name = name_match.group(1).replace(".", "-") if name_match else "http-bot"

    return HTTPAdapterConfig(
        name=generated_name,
        url=url,
        protocol=protocol,
        auth_type=auth_type,
        auth_token=auth_token,
        auth_header=auth_header,
        extra_headers=extra,
        capabilities={AgentCapability.TEXT_GENERATION},
    )
