"""v3 Actor registration logic extracted from ``ActorRegistry``.

This module handles the v3 ``ActorConfigSchema`` registration path,
including schema validation, provider inference, graph compilation,
and unsafe flag enforcement.  It is used by :class:`ActorRegistry`
to keep the main registry module under the 500-line limit.
"""

from __future__ import annotations

import copy
import logging
from typing import Any

from pydantic import ValidationError as PydanticValidationError

from cleveragents.actor.compiler import ActorCompilationError, compile_actor
from cleveragents.actor.config import _V3_ACTOR_TYPES
from cleveragents.actor.schema import ActorConfigSchema
from cleveragents.actor.utils import TOOL_ACTOR_SENTINEL, infer_provider_from_model
from cleveragents.application.services.actor_service import ActorService
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core import Actor

logger = logging.getLogger(__name__)


def is_v3_blob(blob: dict[str, Any]) -> bool:
    """Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``.

    A blob is considered v3 if it has:
    1. A top-level ``type`` field with a v3 value (llm/graph/tool), OR
    2. An ``actors:`` map containing at least one entry whose ``type`` field
       has a v3 value (nested actor format per spec line 21417).

    The nested format allows configurations like:

        name: local/my-actor
        actors:
          my_actor:
            type: llm
            config:
              provider: anthropic
              model: claude-3.5-sonnet

    Returns:
        True if the blob appears to be v3 format.
    """
    actor_type = blob.get("type")
    if isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES:
        return True

    actors_val = blob.get("actors")
    if isinstance(actors_val, dict) and actors_val:
        for _, entry in actors_val.items():
            if isinstance(entry, dict):
                nested_type = entry.get("type")
                if (
                    isinstance(nested_type, str)
                    and nested_type.lower() in _V3_ACTOR_TYPES
                ):
                    return True

    return False


def _extract_nested_v3_config(data: dict[str, Any]) -> None:
    """Extract provider/model/type/description/name from nested actors.<name> block.

    Per spec line 21417, v3 YAML may have provider/model at the top level
    OR nested inside ``actors.<actor-name>.config``. When found in the nested
    config, values are promoted to the top level.

    Similarly, ``type``, ``description``, and ``name`` may be at the top level
    OR nested inside ``actors.<actor-name>`` (for type/description) or
    ``actors.<actor-name>.config`` (for name). This function promotes all
    of these from their nested locations when not present at the top level.

    Args:
        data: The actor blob (modified in place).
    """
    actors_val = data.get("actors")
    if not isinstance(actors_val, dict) or not actors_val:
        return

    for _, entry in actors_val.items():
        if not isinstance(entry, dict):
            continue

        if "type" not in data:
            type_val = entry.get("type")
            if isinstance(type_val, str) and type_val:
                data["type"] = type_val

        if "description" not in data:
            desc_val = entry.get("description")
            if isinstance(desc_val, str) and desc_val:
                data["description"] = desc_val

        if "name" not in data:
            name_val = entry.get("name")
            if isinstance(name_val, str) and name_val:
                data["name"] = name_val

        config_block = entry.get("config")
        if isinstance(config_block, dict):
            if "model" not in data:
                mv = config_block.get("model")
                if isinstance(mv, str) and mv:
                    data["model"] = mv

            if "provider" not in data:
                pv = config_block.get("provider")
                if isinstance(pv, str) and pv:
                    data["provider"] = pv

            if "name" not in data:
                name_val = config_block.get("name")
                if isinstance(name_val, str) and name_val:
                    data["name"] = name_val

            if "description" not in data:
                desc_val = config_block.get("description")
                if isinstance(desc_val, str) and desc_val:
                    data["description"] = desc_val

        if (
            "type" in data
            and "description" in data
            and "model" in data
            and "provider" in data
        ):
            break


def add_v3(
    blob: dict[str, Any],
    *,
    yaml_text: str,
    update: bool,
    schema_version: str,
    compiled_metadata: dict[str, Any] | None,
    actor_service: ActorService,
    allow_unsafe: bool = False,
) -> Actor:
    """Register an actor from a v3 ``ActorConfigSchema`` blob.

    Validates the full v3 schema, infers ``provider`` from the model
    string, persists ``skills`` / ``lsp`` / ``description`` in the
    config blob, and compiles ``type: graph`` actors.

    Args:
        blob: Parsed YAML blob matching the v3 schema.
        yaml_text: Original YAML source text.
        update: When ``True`` allow overwriting an existing actor.
        schema_version: Schema version string.
        compiled_metadata: Optional pre-computed compilation metadata.
            When provided for graph actors, compilation is skipped.
        actor_service: The actor persistence service.
        allow_unsafe: When ``True`` permit actors marked ``unsafe``.

    Returns:
        The persisted ``Actor`` domain object.

    Raises:
        ValidationError: When the YAML is invalid, the actor already
            exists and *update* is ``False``, or the actor is unsafe
            and *allow_unsafe* is ``False``.
    """
    # m9: deep copy blob immediately to avoid mutating the caller's dict.
    data: dict[str, Any] = copy.deepcopy(blob)

    # Ensure name is namespaced before schema validation.
    name_raw = data.get("name", "")
    if isinstance(name_raw, str) and name_raw and "/" not in name_raw:
        data["name"] = f"local/{name_raw}"

    # Extract provider and model from nested actors.<name>.config block if present.
    # This handles the v3 nested format per spec line 21417.
    _extract_nested_v3_config(data)

    # Infer provider before schema validation — the schema now requires
    # provider for LLM and GRAPH actors (added by #5869).
    if not data.get("provider"):
        model_raw = data.get("model") or ""
        data["provider"] = (
            infer_provider_from_model(model_raw) if model_raw else "custom"
        )

    try:
        schema = ActorConfigSchema.model_validate(data)
    except PydanticValidationError as exc:
        field_errors: list[str] = []
        for err in exc.errors():
            path = ".".join(str(loc) for loc in err["loc"])
            field_errors.append(f"  {path}: {err['msg']}")
        raise ValidationError(
            "v3 Actor YAML schema validation failed:\n" + "\n".join(field_errors)
        ) from exc

    name = schema.name
    # C2: model is optional for TOOL actors — let validate_type_requirements
    # handle type-specific model requirements instead of rejecting here.
    # The Actor domain model requires min_length=1 for the model field,
    # so use TOOL_ACTOR_SENTINEL as an unambiguous sentinel for tool actors
    # without a model.  This avoids collision with the actor type name
    # "tool" which could confuse downstream LLM routing logic.
    model = schema.model or (TOOL_ACTOR_SENTINEL if schema.type.value == "tool" else "")

    # M11: enforce unsafe flag.
    unsafe_flag = bool(data.get("unsafe", False))
    if unsafe_flag and not allow_unsafe:
        raise ValidationError(
            "Actor configuration is marked unsafe; re-run with --unsafe to confirm."
        )

    # Provider was already inferred and injected into data above.
    provider = str(data.get("provider") or "custom")

    # Check for existing actor when not updating.
    # M3: catch only NotFoundError, not generic Exception.
    if not update:
        try:
            actor_service.get_actor(name)
            raise ValidationError(
                f"Actor '{name}' already exists. Pass update=True to overwrite."
            )
        except NotFoundError:
            pass  # Expected for new actors

    config_blob: dict[str, Any] = data
    config_blob.setdefault("source", "yaml")
    config_blob["provider"] = provider
    config_blob["model"] = model

    # Compile graph actors and capture metadata.
    # M10: skip compilation when compiled_metadata is already provided.
    graph_descriptor = data.get("graph_descriptor")
    if (
        schema.type.value == "graph"
        and schema.route is not None
        and compiled_metadata is None
    ):
        # M4: narrow exception to ActorCompilationError.
        try:
            compiled = compile_actor(schema)
            compiled_metadata = compiled.metadata.model_dump(mode="json")
            graph_descriptor = {
                "type": "graph",
                "route": schema.route.model_dump(mode="json"),
                "model": model,
                "entry_point": compiled.entry_point,
            }
        except ActorCompilationError as compile_exc:
            logger.warning(
                "v3 graph compilation failed: %s",
                compile_exc,
            )
            # Still persist the actor; compilation metadata is optional.

    return actor_service.upsert_actor(
        name=name,
        provider=provider,
        model=model,
        config_blob=config_blob,
        graph_descriptor=graph_descriptor,
        unsafe=unsafe_flag,
        set_default=False,
        yaml_text=yaml_text,
        schema_version=schema_version,
        compiled_metadata=compiled_metadata,
    )
