from __future__ import annotations

import json
from typing import Any

from app.llm.types import ToolCallResult, ToolSpec

__all__ = [
    "normalize_tools",
    "parse_anthropic_tool_use",
    "parse_gemini_function_calls",
    "parse_openai_tool_calls",
    "to_anthropic_tools",
    "to_gemini_tools",
    "to_openai_tools",
]


def normalize_tools(tools: list[ToolSpec] | list[dict[str, Any]] | None) -> list[ToolSpec]:
    """Normalize ToolSpec / OpenAI / Anthropic / neutral dict tools to ToolSpec."""
    if not tools:
        return []

    normalized: list[ToolSpec] = []
    for tool in tools:
        if isinstance(tool, ToolSpec):
            normalized.append(tool)
            continue
        if not isinstance(tool, dict):
            continue

        if _is_openai_tool(tool):
            fn = tool.get("function") or {}
            normalized.append(
                ToolSpec(
                    name=str(fn.get("name") or ""),
                    description=str(fn.get("description") or ""),
                    parameters=_as_object_schema(fn.get("parameters")),
                )
            )
            continue

        name = tool.get("name")
        if not name:
            continue
        parameters = tool.get("parameters")
        if parameters is None:
            parameters = tool.get("input_schema")
        if parameters is None:
            parameters = tool.get("parameters_json_schema")
        normalized.append(
            ToolSpec(
                name=str(name),
                description=str(tool.get("description") or ""),
                parameters=_as_object_schema(parameters),
            )
        )
    return normalized


def to_openai_tools(tools: list[ToolSpec] | list[dict[str, Any]] | None) -> list[dict[str, Any]]:
    """Convert tools to OpenAI chat.completions tools shape.

    Already-OpenAI-shaped dicts are passed through unchanged.
    """
    if not tools:
        return []

    out: list[dict[str, Any]] = []
    for tool in tools:
        if isinstance(tool, dict) and _is_openai_tool(tool):
            out.append(tool)
            continue
        spec = _coerce_tool_spec(tool)
        if spec is None or not spec.name:
            continue
        out.append(
            {
                "type": "function",
                "function": {
                    "name": spec.name,
                    "description": spec.description,
                    "parameters": spec.parameters,
                },
            }
        )
    return out


def to_anthropic_tools(tools: list[ToolSpec] | list[dict[str, Any]] | None) -> list[dict[str, Any]]:
    """Convert tools to Anthropic ``tools`` entries: name/description/input_schema."""
    return [
        {
            "name": spec.name,
            "description": spec.description,
            "input_schema": spec.parameters,
        }
        for spec in normalize_tools(tools)
        if spec.name
    ]


def to_gemini_tools(tools: list[ToolSpec] | list[dict[str, Any]] | None) -> list[dict[str, Any]]:
    """Convert tools to Gemini ``tools=[{function_declarations:[...]}]`` shape.

    Declarations use JSON-schema ``parameters`` (REST / dict form). Agent C may map
    these onto ``google.genai.types.Tool`` / ``FunctionDeclaration`` (which also
    accepts ``parameters_json_schema``).
    """
    declarations = [
        {
            "name": spec.name,
            "description": spec.description,
            "parameters": spec.parameters,
        }
        for spec in normalize_tools(tools)
        if spec.name
    ]
    if not declarations:
        return []
    return [{"function_declarations": declarations}]


def parse_openai_tool_calls(raw: Any) -> list[ToolCallResult]:
    """Parse OpenAI tool_calls from a completion, message, or raw list."""
    tool_calls = _extract_openai_tool_calls(raw)
    results: list[ToolCallResult] = []
    for item in tool_calls:
        call_id = str(_get(item, "id") or "")
        function = _get(item, "function") or {}
        name = str(_get(function, "name") or _get(item, "name") or "")
        arguments = _parse_arguments(_get(function, "arguments") or _get(item, "arguments"))
        if not name and not call_id:
            continue
        results.append(ToolCallResult(id=call_id, name=name, arguments=arguments))
    return results


def parse_anthropic_tool_use(content_blocks: Any) -> list[ToolCallResult]:
    """Parse Anthropic ``type=tool_use`` content blocks into ToolCallResult list."""
    blocks = _extract_anthropic_content_blocks(content_blocks)
    results: list[ToolCallResult] = []
    for block in blocks:
        block_type = _get(block, "type")
        if block_type != "tool_use":
            continue
        call_id = str(_get(block, "id") or "")
        name = str(_get(block, "name") or "")
        arguments = _parse_arguments(_get(block, "input"))
        if not name and not call_id:
            continue
        results.append(ToolCallResult(id=call_id, name=name, arguments=arguments))
    return results


def parse_gemini_function_calls(parts_or_candidates: Any) -> list[ToolCallResult]:
    """Parse Gemini function_call parts / candidates / response into ToolCallResult list.

    Gemini function calls often omit ids; ``ToolCallResult.id`` is ``""`` when absent.
    """
    parts = _extract_gemini_parts(parts_or_candidates)
    results: list[ToolCallResult] = []
    for index, part in enumerate(parts):
        function_call = _get(part, "function_call")
        if function_call is None and _get(part, "name") and (
            _get(part, "args") is not None or _get(part, "arguments") is not None
        ):
            # Already a FunctionCall-like object/dict.
            function_call = part
        if function_call is None:
            continue
        name = str(_get(function_call, "name") or "")
        if not name:
            continue
        call_id = str(_get(function_call, "id") or "")
        arguments = _parse_arguments(
            _get(function_call, "args")
            if _get(function_call, "args") is not None
            else _get(function_call, "arguments")
        )
        if not call_id:
            call_id = f"gemini_fn_{index}_{name}"
        results.append(ToolCallResult(id=call_id, name=name, arguments=arguments))
    return results


def _coerce_tool_spec(tool: ToolSpec | dict[str, Any] | Any) -> ToolSpec | None:
    if isinstance(tool, ToolSpec):
        return tool
    if isinstance(tool, dict):
        specs = normalize_tools([tool])
        return specs[0] if specs else None
    return None


def _is_openai_tool(tool: dict[str, Any]) -> bool:
    return tool.get("type") == "function" and isinstance(tool.get("function"), dict)


def _as_object_schema(parameters: Any) -> dict[str, Any]:
    if isinstance(parameters, dict):
        return parameters
    return {"type": "object", "properties": {}}


def _parse_arguments(raw: Any) -> dict[str, Any]:
    if raw is None:
        return {}
    if isinstance(raw, dict):
        return raw
    if isinstance(raw, str):
        try:
            parsed = json.loads(raw or "{}")
        except json.JSONDecodeError:
            return {}
        return parsed if isinstance(parsed, dict) else {}
    # SDK objects that expose model_dump / dict
    dumped = _maybe_dump(raw)
    if isinstance(dumped, dict):
        return dumped
    return {}


def _extract_openai_tool_calls(raw: Any) -> list[Any]:
    if raw is None:
        return []
    if isinstance(raw, list):
        return raw

    tool_calls = _get(raw, "tool_calls")
    if tool_calls is not None:
        return list(tool_calls or [])

    message = _get(raw, "message")
    if message is not None:
        tool_calls = _get(message, "tool_calls")
        if tool_calls is not None:
            return list(tool_calls or [])

    choices = _get(raw, "choices")
    if choices:
        first = choices[0]
        message = _get(first, "message")
        if message is not None:
            return list(_get(message, "tool_calls") or [])
    return []


def _extract_anthropic_content_blocks(content_blocks: Any) -> list[Any]:
    if content_blocks is None:
        return []
    if isinstance(content_blocks, list):
        return content_blocks
    content = _get(content_blocks, "content")
    if isinstance(content, list):
        return content
    return []


def _extract_gemini_parts(parts_or_candidates: Any) -> list[Any]:
    if parts_or_candidates is None:
        return []

    # Convenience: google-genai response.function_calls
    function_calls = _get(parts_or_candidates, "function_calls")
    if function_calls:
        return list(function_calls)

    if isinstance(parts_or_candidates, list):
        if not parts_or_candidates:
            return []
        first = parts_or_candidates[0]
        # candidates list
        content = _get(first, "content")
        if content is not None or _get(first, "finish_reason") is not None:
            parts: list[Any] = []
            for candidate in parts_or_candidates:
                content = _get(candidate, "content")
                parts.extend(list(_get(content, "parts") or []) if content is not None else [])
            return parts
        # parts list (or FunctionCall list)
        return parts_or_candidates

    # Single candidate / content / response
    candidates = _get(parts_or_candidates, "candidates")
    if candidates:
        return _extract_gemini_parts(list(candidates))

    content = _get(parts_or_candidates, "content")
    if content is not None:
        return list(_get(content, "parts") or [])

    parts = _get(parts_or_candidates, "parts")
    if parts is not None:
        return list(parts or [])

    return []


def _get(obj: Any, key: str) -> Any:
    if obj is None:
        return None
    if isinstance(obj, dict):
        return obj.get(key)
    return getattr(obj, key, None)


def _maybe_dump(obj: Any) -> Any:
    if hasattr(obj, "model_dump") and callable(obj.model_dump):
        try:
            return obj.model_dump()
        except Exception:  # noqa: BLE001 — best-effort SDK coercion
            return None
    if hasattr(obj, "dict") and callable(obj.dict):
        try:
            return obj.dict()
        except Exception:  # noqa: BLE001
            return None
    return None
