"""UKO analyzer plugin framework — protocol, triple model, and registry.

Defines the core types for domain analyzers that parse resources into
UKO (Universal Knowledge Ontology) triples:

- ``UKOTriple`` — Immutable Pydantic model representing a single
  subject-predicate-object statement with optional confidence score.
- ``AnalyzerProtocol`` — ``typing.Protocol`` that all analyzers must
  satisfy (``supported_extensions``, ``domain``, ``analyze``).
- ``AnalyzerRegistry`` — In-memory registry mapping file extensions to
  analyzer instances with registration, lookup, and listing.

Analyzers are registered by file extension and invoked by the
``UKOIndexer`` to produce triples from resource content.  Custom
analyzers are registered via ``config.toml``
``context.uko.analyzers.custom.*``.

Based on ``docs/specification.md`` ACMS Extensions — Domain and
Language-Specific Analyzers, and configuration key
``context.uko.default-analyzers``.
"""

from __future__ import annotations

import re
from pathlib import PurePosixPath
from typing import Protocol, runtime_checkable

import structlog
from pydantic import BaseModel, ConfigDict, Field, model_validator

from cleveragents.domain.models.core.resource import Resource

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# UKOTriple
# ---------------------------------------------------------------------------


class UKOTriple(BaseModel):
    """A single subject-predicate-object statement in the UKO graph.

    Represents either an object-property triple (``object_uri`` set) or
    a data-property triple (``object_value`` set).  At least one of the
    two must be provided; both may be set when the object is
    simultaneously a URI-identified resource with a literal label.

    Attributes:
        subject_uri: URI of the subject node (e.g.
            ``uko://code/module/mypackage``).
        predicate: URI or prefixed name of the predicate (e.g.
            ``uko:contains``, ``rdf:type``).
        object_uri: URI of the object node (for object-property triples).
        object_value: Literal value (for data-property triples).
        confidence: Confidence score in ``[0.0, 1.0]``.  Defaults to
            ``1.0`` for deterministic extractions.
    """

    subject_uri: str = Field(..., min_length=1, description="URI of the subject node.")
    predicate: str = Field(
        ..., min_length=1, description="Predicate URI or prefixed name."
    )
    object_uri: str = Field(
        default="", description="URI of the object node (object-property)."
    )
    object_value: str = Field(default="", description="Literal value (data-property).")
    confidence: float = Field(
        default=1.0,
        ge=0.0,
        le=1.0,
        description="Confidence in [0.0, 1.0].",
    )

    model_config = ConfigDict(frozen=True, str_strip_whitespace=True)

    # -- Validators -----------------------------------------------------------
    # NOTE: subject_uri and predicate are validated by ``min_length=1``
    # on their ``Field()`` definitions, so explicit ``@field_validator``
    # methods are unnecessary.

    @model_validator(mode="after")
    def _validate_object(self) -> UKOTriple:
        """Enforce that at least one of object_uri/object_value is set."""
        if not self.object_uri and not self.object_value:
            raise ValueError(
                "At least one of object_uri or object_value must be non-empty."
            )
        return self


# ---------------------------------------------------------------------------
# URI segment sanitisation (shared by all analyzers)
# ---------------------------------------------------------------------------

_SAFE_URI_RE = re.compile(r"[^a-zA-Z0-9_.-]")


def safe_uri_segment(text: str) -> str:
    """Sanitise *text* for use in a UKO URI path segment.

    Replaces non-alphanumeric characters (except ``_``, ``.``, ``-``)
    with underscores, truncates to 120 characters, then strips
    leading/trailing underscores from the truncated result.  Returns
    ``"_unknown_"`` if the result is empty.
    """
    result = _SAFE_URI_RE.sub("_", text)[:120].strip("_")
    return result if result else "_unknown_"


# ---------------------------------------------------------------------------
# AnalyzerProtocol
# ---------------------------------------------------------------------------


@runtime_checkable
class AnalyzerProtocol(Protocol):
    """Protocol for domain analyzers that parse resources into UKO triples.

    Implementations must expose:

    * ``supported_extensions`` — frozen set of file extensions (with
      leading dot, e.g. ``{".py"}``).
    * ``domain`` — human-readable domain label (e.g. ``"python"``).
    * ``analyze(content, resource_uri)`` — produce a list of
      ``UKOTriple`` instances from the given content.
    """

    @property
    def supported_extensions(self) -> frozenset[str]:
        """File extensions this analyzer handles (e.g. ``{".py"}``)."""
        raise NotImplementedError

    @property
    def domain(self) -> str:
        """Human-readable domain label (e.g. ``"python"``)."""
        raise NotImplementedError

    def analyze(self, content: str, resource_uri: str) -> list[UKOTriple]:
        """Parse *content* and return UKO triples.

        Args:
            content: Raw textual content of the resource.
            resource_uri: Canonical URI for the resource being analyzed.

        Returns:
            List of ``UKOTriple`` instances extracted from *content*.
        """
        raise NotImplementedError


# ---------------------------------------------------------------------------
# AnalyzerRegistry
# ---------------------------------------------------------------------------


class AnalyzerRegistry:
    """In-memory registry mapping file extensions to analyzer instances.

    Analyzers are registered via :meth:`register` and looked up by file
    extension via :meth:`get_for_extension`.  When multiple analyzers
    handle the same extension, the one with the highest *priority* wins
    (higher numeric value = higher priority).  Equal-priority ties are
    resolved by registration order (first registered wins).

    Example::

        registry = AnalyzerRegistry()
        registry.register(PythonAnalyzer(), priority=10)
        analyzer = registry.get_for_extension(".py")
    """

    def __init__(self) -> None:
        # Maps extension -> (priority, analyzer); highest priority wins.
        self._by_extension: dict[str, tuple[int, AnalyzerProtocol]] = {}
        self._all: list[AnalyzerProtocol] = []

    # -- Registration ---------------------------------------------------------

    def register(self, analyzer: AnalyzerProtocol, *, priority: int = 0) -> None:
        """Register an analyzer for all its supported extensions.

        Args:
            analyzer: An object satisfying ``AnalyzerProtocol``.
            priority: Numeric priority (higher wins).  When two
                analyzers compete for the same extension, the one
                with the greater *priority* is kept.  Equal-priority
                ties preserve the first-registered analyzer.

        Raises:
            TypeError: If *analyzer* does not satisfy the protocol.
            ValueError: If *analyzer* declares no supported extensions.
        """
        if not isinstance(analyzer, AnalyzerProtocol):
            raise TypeError(f"Expected AnalyzerProtocol, got {type(analyzer).__name__}")
        extensions = analyzer.supported_extensions
        if not extensions:
            raise ValueError(
                f"Analyzer '{type(analyzer).__name__}' declares no "
                "supported extensions."
            )
        for ext in extensions:
            if not ext.startswith("."):
                raise ValueError(
                    f"Extension '{ext}' must start with a leading dot "
                    f"(e.g. '.py'), got from {type(analyzer).__name__}"
                )
            existing = self._by_extension.get(ext)
            if existing is None:
                self._by_extension[ext] = (priority, analyzer)
                logger.debug(
                    "analyzer.registered",
                    analyzer=type(analyzer).__name__,
                    extension=ext,
                    priority=priority,
                )
            elif priority > existing[0]:
                logger.debug(
                    "analyzer.superseded",
                    new_analyzer=type(analyzer).__name__,
                    new_priority=priority,
                    old_analyzer=type(existing[1]).__name__,
                    old_priority=existing[0],
                    extension=ext,
                )
                self._by_extension[ext] = (priority, analyzer)
            else:
                logger.debug(
                    "analyzer.skipped",
                    extension=ext,
                    existing_analyzer=type(existing[1]).__name__,
                    existing_priority=existing[0],
                    skipped_analyzer=type(analyzer).__name__,
                    skipped_priority=priority,
                )
        self._all.append(analyzer)

    # -- Lookup ---------------------------------------------------------------

    def get_for_extension(self, extension: str) -> AnalyzerProtocol | None:
        """Return the analyzer registered for *extension*, or ``None``.

        When multiple analyzers were registered for the same extension,
        the one with the highest priority is returned.

        Args:
            extension: File extension including leading dot (e.g.
                ``".py"``).

        Returns:
            The registered ``AnalyzerProtocol`` or ``None``.
        """
        if not extension:
            return None
        entry = self._by_extension.get(extension)
        return entry[1] if entry is not None else None

    def get_for_resource(self, resource: Resource) -> AnalyzerProtocol | None:
        """Return the best analyzer for *resource*, or ``None``.

        Determines the file extension from ``resource.location`` and
        delegates to :meth:`get_for_extension`.  If the resource has no
        location, returns ``None``.

        This method implements the spec's
        ``analyzers.get_for_resource(resource)`` pattern
        (``specification.md`` ~line 43214).

        Args:
            resource: A :class:`Resource` domain model instance.

        Returns:
            The registered ``AnalyzerProtocol`` for the resource's
            file extension, or ``None`` if no analyzer matches.
        """
        location = resource.location
        if not location:
            return None
        ext = PurePosixPath(location).suffix
        if not ext:
            return None
        return self.get_for_extension(ext)

    # -- Listing --------------------------------------------------------------

    def list_extensions(self) -> frozenset[str]:
        """Return all registered file extensions."""
        return frozenset(self._by_extension.keys())

    def list_analyzers(self) -> list[AnalyzerProtocol]:
        """Return all registered analyzers (in registration order)."""
        return list(self._all)

    def __len__(self) -> int:
        """Return the number of registered analyzers."""
        return len(self._all)


# ---------------------------------------------------------------------------
# Module exports
# ---------------------------------------------------------------------------

__all__: list[str] = [
    "AnalyzerProtocol",
    "AnalyzerRegistry",
    "UKOTriple",
    "safe_uri_segment",
]
