from __future__ import annotations

from pathlib import Path
from typing import Any, cast

from pydantic import BaseModel, Field

from cleveragents.actor.utils import infer_provider_from_model
from cleveragents.actor.yaml_loader import load_yaml_text

#: v3 actor types that signal the ``ActorConfigSchema`` format.
_V3_ACTOR_TYPES: frozenset[str] = frozenset({"llm", "graph", "tool"})


def _parse_combined_actor_field(data: dict[str, Any]) -> tuple[str | None, str | None]:
    """Extract (provider, model) from the combined ``config.actor`` shorthand.

    Looks for ``config.actor: "provider/model"`` inside the first entry of
    the nested ``actors:`` map.  Returns ``(None, None)`` when the shorthand
    is absent or malformed (empty provider or model half, missing delimiter).

    Args:
        data: Top-level actor YAML blob (may contain an ``actors:`` map).

    Returns:
        ``(provider_value, model_value)`` — both ``None`` when the combined
        field is not present or cannot be parsed.
    """
    actors_val = data.get("actors")
    if not isinstance(actors_val, dict) or not actors_val:
        return None, None

    for _, first_entry in actors_val.items():
        if not isinstance(first_entry, dict):
            continue
        config_block = first_entry.get("config")
        if isinstance(config_block, dict):
            combined = config_block.get("actor")
            if isinstance(combined, str) and "/" in combined and combined.strip():
                parts = combined.strip().split("/", 1)
                provider = parts[0].strip()
                model = parts[1].strip()
                if provider and model:
                    return provider, model
        break  # Only examine the first dict entry in the actors map.

    return None, None


_ALLOWED_LLM_OPTIONS: frozenset[str] = frozenset(
    {
        "openai_api_base",
        "temperature",
        "max_tokens",
        "timeout",
        "top_p",
        "frequency_penalty",
        "presence_penalty",
    }
)

_RESERVED_LLM_OPTIONS: frozenset[str] = frozenset({"provider_type", "model_id"})


def build_llm_kwargs_from_options(
    options: dict[str, Any] | None,
    *,
    logger: Any = None,
) -> dict[str, Any]:
    """Convert actor ``options`` into kwargs for :meth:`ProviderRegistry.create_llm`.

    Actors may declare an ``options`` block in their YAML configuration
    containing LLM constructor arguments such as ``openai_api_base`` and
    ``openai_api_key`` for custom/local backends.  This function extracts
    the permitted keys and applies the ``__api_key_sentinel`` pattern so
    that explicit actor-level API keys take precedence over
    environment/registry defaults.

    Args:
        options: Raw options dict from the actor config blob (may be
            ``None`` or empty).
        logger: Optional structlog-style logger for unrecognised/reserved
            key warnings.

    Returns:
        Merged kwargs dict suitable for ``**`` unpacking into
        ``create_llm()``.  Returns an empty dict when *options* is
        ``None`` or empty.
    """
    if not options:
        return {}
    kwargs: dict[str, Any] = {}
    opts = dict(options)
    if "openai_api_key" in opts:
        kwargs["__api_key_sentinel"] = opts.pop("openai_api_key")
    for key, value in opts.items():
        if key in _RESERVED_LLM_OPTIONS:
            if logger is not None:
                logger.warning(
                    "Actor options block contains reserved key '%s' "
                    "that conflicts with positional arguments; ignoring.",
                    key,
                )
            continue
        if key in _ALLOWED_LLM_OPTIONS:
            kwargs[key] = value
        elif logger is not None:
            logger.warning(
                "Actor options block contains unrecognized key '%s'; "
                "ignoring for security.  Allowed keys: %s.",
                key,
                sorted(_ALLOWED_LLM_OPTIONS),
            )
    return kwargs


class ActorConfiguration(BaseModel):
    """Canonical actor configuration parsed from user-provided blobs."""

    name: str | None = Field(default=None, description="Actor name (optional)")
    provider: str = Field(..., min_length=1, description="Provider identifier")
    model: str = Field(..., min_length=1, description="Model identifier")
    graph_descriptor: dict[str, Any] | None = Field(
        default=None, description="Graph descriptor emitted by actor config"
    )
    options: dict[str, Any] = Field(
        default_factory=dict, description="Opaque actor options stored in config blob"
    )
    unsafe: bool = Field(
        default=False, description="True when the actor config is marked unsafe"
    )

    @staticmethod
    def load_yaml_text(text: str) -> dict[str, Any]:
        """Parse YAML text into a configuration blob.

        Args:
            text: Raw YAML string.

        Returns:
            Parsed configuration dictionary.
        """
        return load_yaml_text(text)

    @staticmethod
    def load_blob_from_file(config_path: Path) -> dict[str, Any]:
        """Load a configuration blob from a JSON or YAML file."""
        path = config_path.expanduser()
        if not path.exists():
            raise ValueError(f"Config file not found: {path}")

        text = path.read_text()
        return load_yaml_text(text)

    @classmethod
    def from_file(
        cls,
        *,
        path: Path,
        name: str | None = None,
        provider: str | None = None,
        model: str | None = None,
        graph_descriptor: dict[str, Any] | None = None,
        unsafe: bool = False,
    ) -> ActorConfiguration:
        """Parse and validate a configuration file into an ActorConfiguration."""

        blob = cls.load_blob_from_file(path)
        return cls.from_blob(
            blob=blob,
            name=name,
            provider=provider,
            model=model,
            graph_descriptor=graph_descriptor,
            unsafe=unsafe,
        )

    @classmethod
    def from_blob(
        cls,
        *,
        blob: dict[str, Any] | None,
        name: str | None = None,
        provider: str | None = None,
        model: str | None = None,
        graph_descriptor: dict[str, Any] | None = None,
        unsafe: bool = False,
        default_options: dict[str, Any] | None = None,
        option_overrides: dict[str, Any] | None = None,
    ) -> ActorConfiguration:
        """Coerce an incoming config blob plus CLI overrides into a validated model.

        Supports the v3 ``ActorConfigSchema`` format (identified by a top-level
        ``type`` key whose value is one of ``llm``, ``graph``, or ``tool``).
        Provider and model may be at the top level, nested inside
        ``actors.<actor-name>.config``, **or** in the combined
        **config-actor** format where they live under
        ``config: { actor: {...} }`` (issue #11189).

        If the blob is in ``config.actor`` (combined) format the nesting
        is transparently flattened so downstream code can always work with
        a flat dictionary.
        """

        data: dict[str, Any] = dict(blob) if isinstance(blob, dict) else {}

        # ── Flatten nested config-actor / combined-format (issue #11189) ────
        if "config" in data and isinstance(data["config"], dict):
            config_block = data["config"]
            actor_inner = config_block.get("actor")
            if isinstance(actor_inner, str) and actor_inner:
                # Combined format: ``"<provider>/<model>"`` — split and merge.
                parts = actor_inner.split("/", 1)
                if len(parts) == 2:
                    data.setdefault("provider", parts[0])
                    data.setdefault("model", parts[1])
            elif isinstance(actor_inner, dict) and actor_inner:
                for key, value in actor_inner.items():
                    # Top-level keys already present take precedence.
                    data.setdefault(key, value)
            data.pop("config", None)

        v3_provider, v3_model, v3_graph, v3_unsafe = cls._extract_v3_actor(data)

        resolved_provider = (
            provider or data.get("provider") or data.get("provider_type") or v3_provider
        )
        resolved_model = model or data.get("model") or data.get("model_id") or v3_model
        resolved_graph = (
            graph_descriptor
            or data.get("graph_descriptor")
            or data.get("graph")
            or v3_graph
        )

        options_raw = data.get("options")
        options_value: dict[str, Any] | None = (
            cast(dict[str, Any], options_raw) if isinstance(options_raw, dict) else None
        )
        merged_options: dict[str, Any] = {}
        if default_options:
            merged_options.update(default_options)
        if options_value:
            merged_options.update(options_value)
        if option_overrides:
            merged_options.update(option_overrides)

        top_unsafe_raw = data.get("unsafe", False)
        resolved_unsafe = (
            (top_unsafe_raw is True or top_unsafe_raw == 1) or v3_unsafe or unsafe
        )

        if not resolved_provider:
            raise ValueError("provider is required")

        if not resolved_model:
            raise ValueError(
                "model is required. Tool actors must be registered via "
                "ActorRegistry.add(), not from_blob(). "
                "Use 'agents actor add' to register tool actors."
            )

        graph_blob = (
            cast(dict[str, Any], resolved_graph)
            if isinstance(resolved_graph, dict)
            else None
        )

        return cls(
            name=name,
            provider=str(resolved_provider),
            model=str(resolved_model),
            graph_descriptor=graph_blob,
            options=merged_options,
            unsafe=resolved_unsafe,
        )

    @staticmethod
    def _extract_v3_actor(
        data: dict[str, Any],
    ) -> tuple[str | None, str | None, dict[str, Any] | None, bool]:
        """Derive provider/model/graph from the v3 Actor YAML schema format.

        The v3 schema is identified by a top-level ``type`` key whose value
        is one of ``llm``, ``graph``, or ``tool`` — OR by a top-level
        ``actors:`` map containing at least one entry whose actor-entry level
        carries a ``type`` field with one of those values.

        Provider and model may appear at the top level OR nested inside
        ``actors.<actor-name>.config`` (either as separate ``provider``/``model``
        keys, or as a combined ``actor: "provider/model"`` shorthand).  Explicit
        ``config.provider`` and ``config.model`` take precedence over the combined
        shorthand when both are present.

        The combined ``config-actor`` format (``config: { actor: {...} }``,
        issue #11189) is flattened by ``from_blob`` before this method is
        called, so by the time we run, that nesting has already been
        translated to top-level fields.

        For ``type: graph`` actors the ``route`` block is wrapped into a
        graph descriptor.

        Returns:
            ``(provider, model, graph_descriptor, unsafe)`` — any component
            may be ``None`` if the data does not look like v3.
        """
        actor_type = data.get("type")

        model_value: str | None = None
        provider_value: str | None = None
        unsafe_flag: bool = False

        # ── Fallback: top-level type or "actors:" map ───────────────────────
        # ``from_blob`` flattens any ``config.actor`` nesting before invoking
        # this staticmethod (issue #11189), so by the time we run, ``data``
        # only carries top-level fields. We just need to backfill ``type``
        # from a nested ``actors:`` map when it isn't at the top level.
        if not isinstance(actor_type, str):
            actors_val = data.get("actors")
            if isinstance(actors_val, dict) and actors_val:
                for _, first_entry in actors_val.items():
                    if isinstance(first_entry, dict):
                        # The v3 spec places 'type' at the actor-entry level
                        # (sibling of 'config'), not inside the config block.
                        nested_type = first_entry.get("type")
                        is_v3_type = (
                            isinstance(nested_type, str)
                            and nested_type.lower() in _V3_ACTOR_TYPES
                        )
                        if is_v3_type:
                            actor_type = nested_type
                            break
                        # Fallback: some configs place ``type`` inside the
                        # config block instead of at the actor-entry level.
                        cb = first_entry.get("config")
                        if isinstance(cb, dict):
                            nested_type = cb.get("type")
                            is_v3_type = (
                                isinstance(nested_type, str)
                                and nested_type.lower() in _V3_ACTOR_TYPES
                            )
                            if is_v3_type:
                                actor_type = nested_type
                                break
            if not isinstance(actor_type, str):
                return None, None, None, False

        model_value = model_value or data.get("model")
        provider_value = provider_value or data.get("provider")
        unsafe_flag = unsafe_flag or bool(data.get("unsafe", False))

        # Derive provider/model from nested actors map when top-level fields
        # are absent.  Separate config.provider / config.model keys take
        # precedence over the combined config.actor shorthand.
        if (not model_value or not isinstance(model_value, str)) or (
            not provider_value or not isinstance(provider_value, str)
        ):
            combined_provider, combined_model = _parse_combined_actor_field(data)
            if not isinstance(model_value, str) or not model_value:
                # First, check for a separate config.model key in the nested map.
                model_from_separate: str | None = None
                actors_val = data.get("actors")
                if isinstance(actors_val, dict) and actors_val:
                    for _, first_entry in actors_val.items():
                        if isinstance(first_entry, dict):
                            cb = first_entry.get("config")
                            if isinstance(cb, dict):
                                mv = cb.get("model")
                                if isinstance(mv, str) and mv:
                                    model_from_separate = mv
                                    break
                            break
                if model_from_separate:
                    model_value = model_from_separate
                elif combined_model:
                    model_value = combined_model
            if not isinstance(provider_value, str) or not provider_value:
                # First, check for a separate config.provider key in the nested map.
                provider_from_separate: str | None = None
                actors_val = data.get("actors")
                if isinstance(actors_val, dict) and actors_val:
                    for _, first_entry in actors_val.items():
                        if isinstance(first_entry, dict):
                            cb = first_entry.get("config")
                            if isinstance(cb, dict):
                                pv = cb.get("provider")
                                if isinstance(pv, str) and pv:
                                    provider_from_separate = pv
                                    break
                            break
                if provider_from_separate:
                    provider_value = provider_from_separate
                elif combined_provider:
                    provider_value = combined_provider

            if not model_value:
                if not isinstance(actor_type, str) or actor_type.lower() != "tool":
                    return None, None, None, False
                model_value = ""

        if not provider_value:
            provider_value = (
                infer_provider_from_model(model_value) if model_value else "custom"
            )

        # Build graph descriptor for GRAPH actors.
        graph_descriptor: dict[str, Any] | None = None
        if isinstance(actor_type, str) and actor_type.lower() == "graph":
            route_raw = data.get("route")
            if isinstance(route_raw, dict):
                graph_descriptor = {
                    "type": "graph",
                    "route": route_raw,
                    "model": model_value or "",
                }

        return provider_value or "custom", model_value, graph_descriptor, unsafe_flag
