from __future__ import annotations

import abc
import asyncio
import json
from dataclasses import dataclass, field
from typing import Any
from urllib import error, parse, request


JsonDict = dict[str, Any]


class ProviderError(RuntimeError):
    """Raised when a provider request or response handling fails."""


RETRYABLE_PROVIDER_HTTP_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
RETRYABLE_PROVIDER_ERROR_SNIPPETS = (
    "temporary failure in name resolution",
    "name or service not known",
    "network error for",
    "network is unreachable",
    "connection refused",
    "connection reset",
    "connection aborted",
    "request timeout",
    "timed out",
    "bad gateway",
    "service unavailable",
    "gateway timeout",
    "remote end closed connection",
)


def is_transient_provider_error(error_value: Exception | str) -> bool:
    text = str(error_value or "").strip().lower()
    if not text:
        return False

    if text.startswith("http "):
        parts = text.split()
        if len(parts) >= 2 and parts[1].isdigit():
            return int(parts[1]) in RETRYABLE_PROVIDER_HTTP_CODES

    if "requires an api key" in text:
        return False
    if "invalid json returned" in text:
        return False

    return any(snippet in text for snippet in RETRYABLE_PROVIDER_ERROR_SNIPPETS)


@dataclass(slots=True)
class ProviderHTTPResponse:
    status: int
    payload: JsonDict
    headers: dict[str, str] = field(default_factory=dict)


class AsyncJsonHttpClient:
    """Tiny async JSON client built on the standard library.

    It avoids extra dependencies so the provider layer stays friendly to
    Termux/proot environments.
    """

    def __init__(self, timeout: float = 60.0):
        self.timeout = timeout

    async def get_json(self, url: str, headers: dict[str, str] | None = None) -> ProviderHTTPResponse:
        return await asyncio.to_thread(self._request_json, "GET", url, headers, None)

    async def post_json(
        self,
        url: str,
        payload: JsonDict,
        headers: dict[str, str] | None = None,
    ) -> ProviderHTTPResponse:
        return await asyncio.to_thread(self._request_json, "POST", url, headers, payload)

    def _request_json(
        self,
        method: str,
        url: str,
        headers: dict[str, str] | None,
        payload: JsonDict | None,
    ) -> ProviderHTTPResponse:
        merged_headers = {"Accept": "application/json"}
        if headers:
            merged_headers.update(headers)

        data: bytes | None = None
        if payload is not None:
            merged_headers.setdefault("Content-Type", "application/json")
            data = json.dumps(payload, ensure_ascii=False).encode("utf-8")

        req = request.Request(url=url, data=data, headers=merged_headers, method=method)
        try:
            with request.urlopen(req, timeout=self.timeout) as response:
                raw = response.read().decode("utf-8")
                parsed = json.loads(raw) if raw else {}
                headers_dict = {key: value for key, value in response.headers.items()}
                return ProviderHTTPResponse(status=response.status, payload=parsed, headers=headers_dict)
        except error.HTTPError as exc:
            raw_body = exc.read().decode("utf-8", errors="replace")
            raise ProviderError(f"HTTP {exc.code} for {url}: {raw_body}") from exc
        except error.URLError as exc:
            raise ProviderError(f"Network error for {url}: {exc.reason}") from exc
        except OSError as exc:
            if isinstance(exc, TimeoutError):
                raise ProviderError(f"Request timeout for {url} after {self.timeout:.1f}s") from exc
            raise ProviderError(f"Network error for {url}: {exc}") from exc
        except json.JSONDecodeError as exc:
            raise ProviderError(f"Invalid JSON returned by {url}: {exc}") from exc


class BaseProvider(abc.ABC):
    provider_type: str = "base"

    def __init__(
        self,
        *,
        provider_name: str,
        timeout: float = 60.0,
        http_client: AsyncJsonHttpClient | None = None,
    ) -> None:
        self.provider_name = provider_name
        self.http = http_client or AsyncJsonHttpClient(timeout=timeout)

    @abc.abstractmethod
    async def generate(
        self,
        messages: list[JsonDict],
        tools: list[JsonDict] | None,
        model: str,
        system_prompt: str | None = None,
        temperature: float | None = None,
    ) -> JsonDict:
        raise NotImplementedError

    @abc.abstractmethod
    async def list_models(self) -> list[str]:
        raise NotImplementedError


def ensure_trailing_slashless(url: str) -> str:
    return url.rstrip("/")


def json_dumps_compact(value: Any) -> str:
    if isinstance(value, str):
        return value
    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))


def parse_tool_arguments(value: Any) -> Any:
    if isinstance(value, (dict, list, int, float, bool)) or value is None:
        return value
    if not isinstance(value, str):
        return value
    stripped = value.strip()
    if not stripped:
        return {}
    try:
        return json.loads(stripped)
    except json.JSONDecodeError:
        return value


def flatten_text_content(content: Any) -> str:
    if content is None:
        return ""
    if isinstance(content, str):
        return content
    if isinstance(content, (int, float, bool)):
        return str(content)
    if isinstance(content, dict):
        if "text" in content:
            return flatten_text_content(content["text"])
        if "content" in content:
            return flatten_text_content(content["content"])
        return json.dumps(content, ensure_ascii=False)
    if isinstance(content, list):
        chunks: list[str] = []
        for item in content:
            if isinstance(item, dict) and item.get("type") in {"text", "input_text", "output_text"}:
                chunks.append(flatten_text_content(item.get("text")))
            else:
                chunks.append(flatten_text_content(item))
        return "\n".join(chunk for chunk in chunks if chunk)
    return str(content)


def build_openai_tools(tools: list[JsonDict] | None) -> list[JsonDict]:
    prepared: list[JsonDict] = []
    for tool in tools or []:
        if tool.get("type") == "function" and isinstance(tool.get("function"), dict):
            function = tool["function"]
            prepared.append(
                {
                    "type": "function",
                    "function": {
                        "name": function["name"],
                        "description": function.get("description", ""),
                        "parameters": function.get("parameters", {"type": "object", "properties": {}}),
                    },
                }
            )
            continue

        prepared.append(
            {
                "type": "function",
                "function": {
                    "name": tool["name"],
                    "description": tool.get("description", ""),
                    "parameters": tool.get("parameters", {"type": "object", "properties": {}}),
                },
            }
        )
    return prepared


def build_openai_messages(messages: list[JsonDict], system_prompt: str | None = None) -> list[JsonDict]:
    prepared: list[JsonDict] = []
    if system_prompt:
        prepared.append({"role": "system", "content": system_prompt})

    for message in messages:
        role = message.get("role") or "user"
        item: JsonDict = {"role": role, "content": flatten_text_content(message.get("content"))}

        if role == "tool":
            if message.get("tool_call_id"):
                item["tool_call_id"] = message["tool_call_id"]
            if message.get("name"):
                item["name"] = message["name"]

        if role == "assistant" and message.get("tool_calls"):
            item["tool_calls"] = []
            for tool_call in message["tool_calls"]:
                item["tool_calls"].append(
                    {
                        "id": tool_call.get("id"),
                        "type": "function",
                        "function": {
                            "name": tool_call["name"],
                            "arguments": json_dumps_compact(tool_call.get("arguments", {})),
                        },
                    }
                )

        prepared.append(item)
    return prepared


def normalise_openai_response(response: JsonDict, *, fallback_model: str, provider_name: str) -> JsonDict:
    choices = response.get("choices") or []
    if not choices:
        raise ProviderError("OpenAI-compatible response did not include any choices")

    message = choices[0].get("message") or {}
    tool_calls: list[JsonDict] = []
    for index, tool_call in enumerate(message.get("tool_calls") or []):
        function = tool_call.get("function") or {}
        tool_calls.append(
            {
                "id": tool_call.get("id") or f"tool-call-{index + 1}",
                "name": function.get("name", "unknown"),
                "arguments": parse_tool_arguments(function.get("arguments", {})),
            }
        )

    text = flatten_text_content(message.get("content"))
    return {
        "text": text,
        "tool_calls": tool_calls,
        "usage": response.get("usage") or {},
        "model": response.get("model") or fallback_model,
        "provider": provider_name,
        "raw": response,
    }


def extract_openai_model_names(response: JsonDict) -> list[str]:
    names = []
    for item in response.get("data") or []:
        model_id = item.get("id")
        if model_id:
            names.append(model_id)
    return sorted(set(names))


def gemini_system_instruction(system_prompt: str | None) -> JsonDict | None:
    if not system_prompt:
        return None
    return {"parts": [{"text": system_prompt}]}


def build_gemini_tools(tools: list[JsonDict] | None) -> list[JsonDict]:
    declarations: list[JsonDict] = []
    for tool in tools or []:
        if tool.get("type") == "function" and isinstance(tool.get("function"), dict):
            function = tool["function"]
            declarations.append(
                {
                    "name": function["name"],
                    "description": function.get("description", ""),
                    "parameters": function.get("parameters", {"type": "object", "properties": {}}),
                }
            )
        else:
            declarations.append(
                {
                    "name": tool["name"],
                    "description": tool.get("description", ""),
                    "parameters": tool.get("parameters", {"type": "object", "properties": {}}),
                }
            )
    return [{"functionDeclarations": declarations}] if declarations else []


def build_gemini_contents(messages: list[JsonDict]) -> list[JsonDict]:
    contents: list[JsonDict] = []
    for message in messages:
        role = message.get("role") or "user"
        if role == "system":
            continue
        if role == "assistant":
            role = "model"

        if message.get("role") == "tool":
            tool_name = message.get("name") or message.get("tool_name") or "tool"
            response_payload = message.get("content")
            if isinstance(response_payload, str):
                response_payload = {"content": response_payload}
            contents.append(
                {
                    "role": "user",
                    "parts": [
                        {
                            "functionResponse": {
                                "name": tool_name,
                                "response": response_payload or {},
                            }
                        }
                    ],
                }
            )
            continue

        parts: list[JsonDict] = []
        text = flatten_text_content(message.get("content"))
        if text:
            parts.append({"text": text})

        for tool_call in message.get("tool_calls") or []:
            parts.append(
                {
                    "functionCall": {
                        "name": tool_call["name"],
                        "args": tool_call.get("arguments", {}),
                    }
                }
            )

        if parts:
            contents.append({"role": role, "parts": parts})
    return contents


def normalise_gemini_response(response: JsonDict, *, fallback_model: str, provider_name: str) -> JsonDict:
    candidates = response.get("candidates") or []
    if not candidates:
        raise ProviderError("Gemini response did not include any candidates")

    parts = ((candidates[0].get("content") or {}).get("parts") or [])
    text_chunks: list[str] = []
    tool_calls: list[JsonDict] = []
    for index, part in enumerate(parts):
        if "text" in part:
            text_chunks.append(part["text"])
        if "functionCall" in part:
            function_call = part["functionCall"]
            tool_calls.append(
                {
                    "id": function_call.get("id") or f"gemini-call-{index + 1}",
                    "name": function_call.get("name", "unknown"),
                    "arguments": function_call.get("args") or {},
                }
            )

    usage = response.get("usageMetadata") or {}
    model_name = response.get("modelVersion") or fallback_model
    return {
        "text": "\n".join(chunk for chunk in text_chunks if chunk).strip(),
        "tool_calls": tool_calls,
        "usage": usage,
        "model": model_name,
        "provider": provider_name,
        "raw": response,
    }


def extract_gemini_model_names(response: JsonDict) -> list[str]:
    names: list[str] = []
    for item in response.get("models") or []:
        methods = set(item.get("supportedGenerationMethods") or [])
        if "generateContent" not in methods:
            continue
        name = item.get("name")
        if not name:
            continue
        if name.startswith("models/"):
            name = name.split("/", 1)[1]
        names.append(name)
    return sorted(set(names))


def with_query_params(url: str, **params: str) -> str:
    encoded = parse.urlencode(params)
    separator = "&" if "?" in url else "?"
    return f"{url}{separator}{encoded}" if encoded else url
