"""Unified cross-module search registry.

Each module registers a `SearchableSpec` that describes how to find
records of one entity. A single ``/api/v1/search/?q=...`` endpoint
queries every searchable the current actor is allowed to see and
returns a deduplicated, scored result set.

This is the cross-cutting layer the workspace shell uses to power
"⌘K"-style global search without each module needing its own endpoint.

Searchables are pure callables — they don't have to hit the DB. A
module can plug a search adapter into Elasticsearch, Meilisearch, or
even an external vendor by implementing the same protocol.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from simorgh.core.context import RequestContext


class SearchError(RuntimeError):
    """Raised on registry-level errors."""


@dataclass(frozen=True)
class SearchHit:
    """A single result row returned from a searchable."""

    entity: str                 # dotted entity key, e.g. "crm.lead"
    id: str                     # opaque public id
    title: str
    subtitle: str = ""
    url: str = ""
    icon: str = ""
    score: float = 0.0
    extra: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "entity": self.entity,
            "id": self.id,
            "title": self.title,
            "subtitle": self.subtitle,
            "url": self.url,
            "icon": self.icon,
            "score": self.score,
            "extra": self.extra,
        }


SearchFunc = Callable[["RequestContext", str, int], Iterable[SearchHit]]


@dataclass(frozen=True)
class SearchableSpec:
    """Declarative description of a module's search adapter."""

    entity: str                                 # dotted entity key
    label_key: str
    search: SearchFunc                          # (ctx, query, limit) -> hits
    permission: str = ""                        # optional gate codename
    description: str = ""

    def __post_init__(self) -> None:
        if not self.entity:
            raise SearchError("searchable: entity is required")
        if not callable(self.search):
            raise SearchError(f"searchable {self.entity!r}: search must be callable")


_SEARCHABLES: dict[str, SearchableSpec] = {}


def register_searchable(spec: SearchableSpec) -> SearchableSpec:
    """Register a searchable. Idempotent on identical specs."""
    existing = _SEARCHABLES.get(spec.entity)
    if existing is not None and existing != spec:
        raise SearchError(
            f"searchable {spec.entity!r} already registered with a different definition",
        )
    _SEARCHABLES[spec.entity] = spec
    return spec


def get_searchable(entity: str) -> SearchableSpec:
    try:
        return _SEARCHABLES[entity]
    except KeyError as exc:
        raise SearchError(f"unknown searchable {entity!r}") from exc


def list_searchables() -> list[SearchableSpec]:
    return sorted(_SEARCHABLES.values(), key=lambda s: s.entity)


def reset_for_tests() -> None:
    _SEARCHABLES.clear()


def run_search(
    ctx: RequestContext,
    query: str,
    *,
    entities: tuple[str, ...] = (),
    limit_per_entity: int = 10,
) -> list[SearchHit]:
    """Run `query` across every allowed searchable.

    * ``entities`` — optional whitelist; empty = all registered.
    * Searchables whose ``permission`` is not in ``ctx.permissions`` are
      silently skipped (unless the actor is a superuser).
    * Results are sorted by ``score`` desc, then ``title`` for stability.
    """
    if not query or not query.strip():
        return []

    target = list_searchables()
    if entities:
        wanted = set(entities)
        target = [s for s in target if s.entity in wanted]

    is_super = ctx.is_superuser
    hits: list[SearchHit] = []
    for spec in target:
        if spec.permission and not is_super and spec.permission not in ctx.permissions:
            continue
        try:
            produced = list(spec.search(ctx, query, limit_per_entity))
        except Exception:
            # A real implementation would structlog this; we keep search resilient.
            continue
        hits.extend(produced)

    hits.sort(key=lambda h: (-h.score, h.title))
    return hits
