"""Actor registry for virtual built-in and custom actor management.

Built-in actors (e.g. ``openai/gpt-4o``, ``anthropic/claude-sonnet-4-20250514``)
are **virtual** — resolved on-demand from the ``ProviderRegistry`` at query time
and merged with persisted custom actors in memory.  No database writes occur for
built-in actors.

**Actor resolution order** (when looking up an actor by name):

1. Search the database for a custom actor with the exact name.
2. If not found, resolve from configured providers as a virtual built-in actor.
3. If still not found, raise a ``NotFoundError``.

Custom actors (added via ``actor add``) continue to be persisted and listed as
usual alongside the virtual built-ins.
"""

from __future__ import annotations

import logging
from dataclasses import asdict
from datetime import UTC, datetime
from typing import Any

import pydantic
import yaml

from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
from cleveragents.actor.v3_registry import add_v3, is_v3_blob
from cleveragents.application.services.actor_service import ActorService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core import Actor
from cleveragents.providers.registry import (
    ProviderCapabilities,
    ProviderInfo,
    ProviderRegistry,
)

logger = logging.getLogger(__name__)


class ActorRegistry:
    """Coordinate actor configuration parsing and virtual built-in generation.

    Supports YAML-first actor persistence: each actor stores its original
    YAML text, a ``schema_version``, and optional ``compiled_metadata``
    alongside the canonical configuration blob.

    Built-in actors are never written to the database.  They are resolved
    from the ``ProviderRegistry`` in-memory each time they are needed.
    """

    #: Default actor config schema version applied to new entries.
    DEFAULT_SCHEMA_VERSION: str = "1.0"

    def __init__(
        self,
        *,
        actor_service: ActorService,
        provider_registry: ProviderRegistry,
        settings: Settings,
    ) -> None:
        self._actor_service = actor_service
        self._provider_registry = provider_registry
        self._settings = settings

    def _actor_name(self, provider_name: str, model_name: str) -> str:
        """Build a canonical ``namespace/identifier`` actor name.

        Provider and model names are sanitised to handle the most common
        violations (embedded slashes from providers like OpenRouter whose
        default model contains ``/``):

        * Forward slashes are replaced with ``-``.
        * The result is lowercased.
        """
        sanitized_provider = provider_name.replace("/", "-").lower()
        sanitized_model = model_name.replace("/", "-").lower()
        return f"{sanitized_provider}/{sanitized_model}"

    @staticmethod
    def _ensure_namespaced(name: str) -> str:
        """Ensure *name* follows the ``namespace/identifier`` convention.

        If the name contains no ``/`` it is prefixed with ``local/``.
        """
        if "/" not in name:
            return f"local/{name}"
        return name

    def _build_graph_descriptor(
        self,
        *,
        provider: str,
        model: str,
        source: str,
        capabilities: ProviderCapabilities | None = None,
    ) -> dict[str, Any]:
        return {
            "provider": provider,
            "model": model,
            "source": source,
            "capabilities": asdict(capabilities) if capabilities else None,
        }

    def _generate_builtin_actor_yaml(
        self,
        *,
        provider: str,
        model: str,
        capabilities: ProviderCapabilities | None = None,
    ) -> str:
        """Generate v3 YAML text for a built-in actor.

        Creates a minimal v3 ActorConfigSchema YAML representation for built-in
        actors, ensuring they include the required ``type`` and ``description``
        fields for proper v3 format recognition.

        Args:
            provider: Provider name (e.g., "openai", "anthropic").
            model: Model identifier (e.g., "gpt-4", "claude-3-opus").
            capabilities: Optional provider capabilities.

        Returns:
            YAML string in v3 format with type: llm.
        """
        yaml_dict: dict[str, Any] = {
            "name": self._actor_name(provider, model),
            "type": "llm",
            "model": model,
            "description": (
                f"Built-in actor from provider registry ({provider}/{model})"
            ),
            "provider": provider.lower(),
            "capabilities": (asdict(capabilities) if capabilities else None),
            "unsafe": False,
            "source": "provider-registry",
        }
        # Remove None values to keep YAML clean
        yaml_dict = {k: v for k, v in yaml_dict.items() if v is not None}
        return yaml.safe_dump(yaml_dict, default_flow_style=False, sort_keys=False)

    def _canonical_blob(
        self,
        config: ActorConfiguration,
        raw_blob: dict[str, Any] | None,
        *,
        source: str,
        default_graph: dict[str, Any] | None,
    ) -> dict[str, Any]:
        blob: dict[str, Any] = dict(raw_blob) if raw_blob else dict(config.options)
        blob.setdefault("provider", config.provider)
        blob.setdefault("model", config.model)
        graph = config.graph_descriptor or default_graph
        if graph is not None and "graph_descriptor" not in blob:
            blob["graph_descriptor"] = graph
        if "options" not in blob:
            if config.options:
                blob["options"] = dict(config.options)
        elif not isinstance(blob["options"], dict):
            blob["options"] = {}
        blob.setdefault("unsafe", config.unsafe)
        blob.setdefault("source", source)
        return blob

    # ------------------------------------------------------------------
    # Virtual built-in actor resolution
    # ------------------------------------------------------------------

    def _resolve_virtual_builtin_actors(self) -> list[Actor]:
        """Generate virtual ``Actor`` objects from configured providers.

        Reads ``ProviderRegistry.get_configured_providers()`` and returns
        in-memory ``Actor`` instances with ``is_built_in=True``.  No database
        writes occur.

        Returns:
            List of virtual ``Actor`` objects, one per configured provider.
        """
        configured: list[ProviderInfo] = (
            self._provider_registry.get_configured_providers()
        )
        if not configured:
            return []

        actors: list[Actor] = []
        for info in configured:
            provider_name = info.name or info.provider_type.value
            model_id = info.default_model
            name = self._actor_name(provider_name, model_id)
            graph_descriptor = self._build_graph_descriptor(
                provider=provider_name,
                model=model_id,
                source="provider-registry",
                capabilities=info.capabilities,
            )
            yaml_text = self._generate_builtin_actor_yaml(
                provider=provider_name,
                model=model_id,
                capabilities=info.capabilities,
            )
            config_blob: dict[str, Any] = {
                "provider": provider_name,
                "model": model_id,
                "capabilities": (
                    asdict(info.capabilities) if info.capabilities else None
                ),
                "unsafe": False,
                "source": "provider-registry",
                "graph_descriptor": graph_descriptor,
            }
            now = datetime.now(UTC)
            actor = Actor(
                id=None,
                name=name,
                provider=provider_name,
                model=model_id,
                config_blob=config_blob,
                config_hash=Actor.compute_hash(config_blob),
                graph_descriptor=graph_descriptor,
                yaml_text=yaml_text,
                schema_version=self.DEFAULT_SCHEMA_VERSION,
                compiled_metadata=None,
                unsafe=False,
                is_built_in=True,
                is_default=False,
                created_at=now,
                updated_at=now,
            )
            actors.append(actor)

        actors.sort(key=lambda a: a.name)
        return actors

    def _resolve_virtual_builtin_by_name(self, name: str) -> Actor | None:
        """Resolve a single virtual built-in actor by its canonical name.

        Args:
            name: Canonical actor name (e.g. ``openai/gpt-4o``).

        Returns:
            A virtual ``Actor`` if the name matches a configured provider,
            otherwise ``None``.
        """
        for actor in self._resolve_virtual_builtin_actors():
            if actor.name == name:
                return actor
        return None

    def _is_virtual_builtin_name(self, name: str) -> bool:
        """Return ``True`` when *name* matches a configured virtual built-in."""
        return self._resolve_virtual_builtin_by_name(name) is not None

    # ------------------------------------------------------------------
    # YAML-first add / update
    # ------------------------------------------------------------------

    def add(
        self,
        yaml_text: str,
        *,
        update: bool = False,
        unsafe: bool = False,
        allow_unsafe: bool = False,
        schema_version: str | None = None,
        compiled_metadata: dict[str, Any] | None = None,
    ) -> Actor:
        """Add (or update) an actor from raw YAML text.

        The YAML is parsed into an ``ActorConfiguration``, validated, and
        persisted alongside the original *yaml_text* and *schema_version*.

        When the YAML matches the v3 ``ActorConfigSchema`` (detected by a
        top-level ``type`` key of ``llm``, ``graph``, or ``tool``) the full
        schema is validated, ``description`` is enforced, and ``skills`` /
        ``lsp`` bindings are stored in the config blob.  For ``type: graph``
        actors the route is additionally compiled and the compilation
        metadata is persisted.

        .. note::

           Only single-actor YAML is accepted.  If the ``actors:`` or
           ``agents:`` map contains more than one entry, a
           ``ValidationError`` is raised.  Multi-actor configurations
           must be split and registered individually.

        .. note::

           The nested ``actors:``/``agents:`` map is **always** consulted
           for ``unsafe``, ``graph_descriptor``, and ``options`` extraction,
           even when top-level ``provider`` and ``model`` fields are present.
           This matches the behaviour of ``from_blob()``.

        Args:
            yaml_text: The original actor YAML source.
            update: When ``True`` allow overwriting an existing actor.
            unsafe: Asserts that the actor IS unsafe (maps to the spec's
                "CLI --unsafe flag").  This flag both marks the actor as
                unsafe *and* permits its registration.
            allow_unsafe: Permits registration of an already-unsafe actor
                without itself marking the actor as unsafe.  Use this when
                the YAML configuration declares ``unsafe: true`` and the
                caller merely wants to allow it through the safety gate.
            schema_version: Explicit schema version; defaults to
                ``DEFAULT_SCHEMA_VERSION``.
            compiled_metadata: Optional compiler-produced metadata dict.

        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 marked
                unsafe but neither *unsafe* nor *allow_unsafe* is set.
        """
        blob = ActorConfiguration.load_yaml_text(yaml_text)
        if not isinstance(blob, dict):
            raise ValidationError("Actor YAML must be a mapping.")

        version = schema_version or self.DEFAULT_SCHEMA_VERSION

        # ----------------------------------------------------------
        # v3 path: validate against ActorConfigSchema when detected
        # ----------------------------------------------------------
        if is_v3_blob(blob):
            effective_allow_unsafe = allow_unsafe or unsafe
            return add_v3(
                blob,
                yaml_text=yaml_text,
                update=update,
                schema_version=version,
                compiled_metadata=compiled_metadata,
                actor_service=self._actor_service,
                allow_unsafe=effective_allow_unsafe,
            )

        raise ValidationError(
            "Invalid actor configuration: no valid type field found. "
            "Expected a v3 YAML with type: llm, type: graph, or type: tool."
        )

    # ------------------------------------------------------------------
    # Legacy upsert (preserved for backward compatibility)
    # ------------------------------------------------------------------

    def upsert_actor(
        self,
        *,
        name: str,
        config_blob: dict[str, Any] | None,
        provider: str | None = None,
        model: str | None = None,
        graph_descriptor: dict[str, Any] | None = None,
        unsafe: bool = False,
        set_default: bool = False,
        allow_unsafe: bool = False,
        option_overrides: dict[str, Any] | None = None,
        yaml_text: str | None = None,
        schema_version: str | None = None,
        compiled_metadata: dict[str, Any] | None = None,
    ) -> Actor:
        """Parse, validate, and persist an actor configuration.

        For v3 YAML files (detected by any non-null ``type`` field or a
        ``version`` starting with ``'3'``), the configuration is validated
        using ``ActorConfigSchema`` to ensure proper schema compliance,
        including cycle detection for GRAPH actors.
        """
        # ── Validate v3 YAML via ActorConfigSchema if detected ──────────────────
        # This ensures v3 actors are validated against the full schema, including
        # cycle detection, required fields, and enum validation.
        if config_blob and is_v3_yaml(config_blob):
            try:
                ActorConfigSchema.model_validate(config_blob)
            except pydantic.ValidationError as exc:
                raise ValidationError(f"Invalid v3 actor configuration: {exc}") from exc

        # NOTE: v3 TOOL actors without 'provider' will be rejected here by the legacy
        # v2 canonicalization layer (ActorConfiguration.from_blob requires provider).
        # This is a known limitation tracked in follow-up issue:
        # https://git.cleverthis.com/cleveragents/cleveragents-core/issues/9971
        # v3 schema validation (above) already accepts provider-less TOOL actors
        # correctly; the gap is only in the legacy from_blob() canonicalization.
        config = ActorConfiguration.from_blob(
            blob=config_blob,
            name=name,
            provider=provider,
            model=model,
            graph_descriptor=graph_descriptor,
            unsafe=unsafe,
            option_overrides=option_overrides,
        )

        if config.unsafe and not (unsafe or allow_unsafe):
            raise ValidationError(
                "Actor configuration is marked unsafe; re-run with --unsafe to confirm."
            )

        default_graph = None
        if config.provider and config.model:
            default_graph = self._build_graph_descriptor(
                provider=config.provider,
                model=config.model,
                source=config_blob.get("source", "custom") if config_blob else "custom",
            )

        canonical_blob = self._canonical_blob(
            config,
            config_blob,
            source=config_blob.get("source", "custom") if config_blob else "custom",
            default_graph=default_graph,
        )

        return self._actor_service.upsert_actor(
            name=name,
            provider=config.provider,
            model=config.model,
            config_blob=canonical_blob,
            graph_descriptor=canonical_blob.get("graph_descriptor"),
            unsafe=config.unsafe,
            set_default=set_default,
            yaml_text=yaml_text,
            schema_version=schema_version or self.DEFAULT_SCHEMA_VERSION,
            compiled_metadata=compiled_metadata,
        )

    def update_actor(
        self,
        name: str,
        *,
        config_blob: dict[str, Any] | None,
        provider: str | None = None,
        model: str | None = None,
        graph_descriptor: dict[str, Any] | None = None,
        unsafe: bool = False,
        set_default: bool = False,
        allow_unsafe: bool = False,
        option_overrides: dict[str, Any] | None = None,
        yaml_text: str | None = None,
        schema_version: str | None = None,
        compiled_metadata: dict[str, Any] | None = None,
    ) -> Actor:
        """Update an existing actor by name.

        Rejects virtual built-in actors — they cannot be modified.  Only
        custom (database-persisted) actors may be updated.

        Args:
            name: Namespaced actor name to update.
            config_blob: New configuration blob.
            provider: Provider override.
            model: Model override.
            graph_descriptor: Graph descriptor override.
            unsafe: Whether the actor is unsafe.
            set_default: Whether to set this actor as the default.
            allow_unsafe: Permit unsafe actor without the ``--unsafe`` flag.
            option_overrides: Key-value option overrides.
            yaml_text: Original YAML source text.
            schema_version: Schema version string.
            compiled_metadata: Compiler-produced metadata.

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

        Raises:
            ValidationError: When *name* refers to a virtual built-in actor.
            NotFoundError: When *name* is not found in the database.
        """
        normalized = self._ensure_namespaced(name)
        if self._is_virtual_builtin_name(normalized):
            raise ValidationError(
                f"Actor '{normalized}' is a built-in actor and cannot be updated. "
                "Built-in actors are provided by the configured provider registry."
            )
        return self.upsert_actor(
            name=normalized,
            config_blob=config_blob,
            provider=provider,
            model=model,
            graph_descriptor=graph_descriptor,
            unsafe=unsafe,
            set_default=set_default,
            allow_unsafe=allow_unsafe,
            option_overrides=option_overrides,
            yaml_text=yaml_text,
            schema_version=schema_version,
            compiled_metadata=compiled_metadata,
        )

    # ------------------------------------------------------------------
    # CRUD helpers
    # ------------------------------------------------------------------

    def get(self, name: str) -> Actor:
        """Retrieve an actor by its namespaced name.

        Resolution order:
        1. Database (custom actors).
        2. Provider registry (virtual built-in actors).

        Raises:
            NotFoundError: When *name* is not found in either source.
        """
        normalized = self._ensure_namespaced(name)
        return self._get_by_name_or_virtual(normalized)

    def get_actor(self, name: str) -> Actor:
        """Retrieve an actor by name (legacy alias for :meth:`get`).

        Resolution order:
        1. Database (custom actors).
        2. Provider registry (virtual built-in actors).

        Raises:
            NotFoundError: When *name* is not found in either source.
        """
        return self._get_by_name_or_virtual(name)

    def _get_by_name_or_virtual(self, name: str) -> Actor:
        """Resolve *name* via DB → virtual built-in, raise if neither found.

        Args:
            name: Canonical namespaced actor name.

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

        Raises:
            NotFoundError: When *name* is not found in the database or the
                provider registry.
        """
        try:
            return self._actor_service.get_actor(name)
        except NotFoundError:
            pass
        # Fall through to virtual built-in resolution.
        virtual = self._resolve_virtual_builtin_by_name(name)
        if virtual is not None:
            return virtual
        raise NotFoundError(resource_type="actor", resource_id=name)

    def list(self, namespace: str | None = None) -> list[Actor]:
        """List actors, optionally filtered by namespace prefix.

        Merges virtual built-in actors (from the provider registry) with
        persisted custom actors (from the database).  The result is sorted
        alphabetically by name.  No database writes occur.

        Custom actors take precedence: if a custom actor shares a name with
        a virtual built-in, the custom actor wins (virtual built-in is
        excluded from the merged result).

        Args:
            namespace: When provided only actors whose name starts with
                ``namespace/`` are returned.

        Returns:
            Sorted list of ``Actor`` objects.
        """
        db_actors = self._actor_service.list_actors()
        db_names: set[str] = {a.name for a in db_actors}

        virtual_actors = [
            a for a in self._resolve_virtual_builtin_actors() if a.name not in db_names
        ]

        merged = db_actors + virtual_actors
        merged.sort(key=lambda a: a.name)

        if namespace is not None:
            prefix = f"{namespace}/"
            merged = [a for a in merged if a.name.startswith(prefix)]

        return merged

    def list_actors(self) -> list[Actor]:
        """Return all actors (legacy alias for :meth:`list`).

        Merges virtual built-in actors with persisted custom actors.
        No database writes occur.
        """
        return self.list()

    def remove(self, name: str) -> None:
        """Remove a custom actor by its namespaced name.

        Raises:
            ValidationError: When *name* refers to a virtual built-in actor
                (built-in actors cannot be removed).
        """
        normalized = self._ensure_namespaced(name)
        if self._is_virtual_builtin_name(normalized):
            raise ValidationError(
                f"Actor '{normalized}' is a built-in actor and cannot be removed. "
                "Built-in actors are provided by the configured provider registry."
            )
        self._actor_service.remove_actor(normalized)

    def remove_actor(self, name: str) -> None:
        """Remove a custom actor (legacy alias for :meth:`remove`).

        Raises:
            ValidationError: When *name* refers to a virtual built-in actor.
        """
        self.remove(name)

    def set_default_actor(self, name: str) -> Actor:
        """Set the default actor by name.

        The actor can be a custom (DB-persisted) actor or a virtual built-in.
        Only the actor name string is persisted as a preference; no actor row
        is created for virtual built-ins.

        Args:
            name: Namespaced actor name to set as default.

        Returns:
            The resolved ``Actor`` with ``is_default=True``.

        Raises:
            NotFoundError: When *name* cannot be resolved.
        """
        normalized = self._ensure_namespaced(name)
        actor = self._get_by_name_or_virtual(normalized)

        if actor.is_built_in:
            # Virtual built-in: just persist the name preference.
            self._actor_service.set_default_actor_name(normalized)
            return Actor(
                id=actor.id,
                name=actor.name,
                provider=actor.provider,
                model=actor.model,
                config_blob=actor.config_blob,
                config_hash=actor.config_hash,
                graph_descriptor=actor.graph_descriptor,
                yaml_text=actor.yaml_text,
                schema_version=actor.schema_version,
                compiled_metadata=actor.compiled_metadata,
                unsafe=actor.unsafe,
                is_built_in=True,
                is_default=True,
                created_at=actor.created_at,
                updated_at=actor.updated_at,
            )
        # Custom (DB) actor: use the full service path which also stores the
        # preference and sets is_default=True on the actor row.
        return self._actor_service.set_default_actor(normalized)

    def get_default_actor(self) -> Actor | None:
        """Return the current default actor.

        Resolution:
        1. Read the default actor name from the preferences table.
        2. Resolve the name via DB → virtual built-in.
        3. If the name no longer resolves (provider removed), return ``None``.

        Returns:
            The resolved default ``Actor`` with ``is_default=True``,
            or ``None`` when no default is configured.
        """
        default_name = self._actor_service.get_default_actor_name()
        if not default_name:
            return None

        try:
            actor = self._get_by_name_or_virtual(default_name)
        except NotFoundError:
            # Default preference points at an actor that no longer exists.
            return None

        return Actor(
            id=actor.id,
            name=actor.name,
            provider=actor.provider,
            model=actor.model,
            config_blob=actor.config_blob,
            config_hash=actor.config_hash,
            graph_descriptor=actor.graph_descriptor,
            yaml_text=actor.yaml_text,
            schema_version=actor.schema_version,
            compiled_metadata=actor.compiled_metadata,
            unsafe=actor.unsafe,
            is_built_in=actor.is_built_in,
            is_default=True,
            created_at=actor.created_at,
            updated_at=actor.updated_at,
        )
