from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable


CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"

# Identity search is deliberately absent here. ``identity.search`` is an
# instance-wide canonical-identity directory and has no tenant/principal
# visibility contract. Ordinary task pickers must use principal-aware search
# providers instead.
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
    CAPABILITY_ACCESS_PEOPLE_SEARCH,
    CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)


class PeopleSearchError(ValueError):
    """Stable, non-diagnostic error raised by people-search providers."""


@dataclass(frozen=True, slots=True)
class PersonSearchCandidate:
    """A policy-filtered selection target returned by a directory provider."""

    selection_key: str
    kind: str
    reference_id: str
    display_name: str
    email: str | None = None
    source_module: str | None = None
    source_label: str | None = None
    source_ref: str | None = None
    source_revision: str | None = None
    description: str | None = None
    provenance: Mapping[str, object] = field(default_factory=dict)
    metadata: Mapping[str, object] = field(default_factory=dict)


@dataclass(frozen=True, slots=True)
class PeopleSearchGroup:
    key: str
    label: str
    candidates: tuple[PersonSearchCandidate, ...] = ()


@runtime_checkable
class PeopleSearchProvider(Protocol):
    """Server-side, principal-aware people/delivery-target search.

    Implementations must derive visibility from ``principal`` and may only
    return records the principal can discover in its active tenant and policy
    context. Callers remain responsible for authorizing the task for which the
    picker is used (for example, creating a scheduling request).
    """

    def search_people(
        self,
        session: object,
        principal: object,
        *,
        query: str,
        limit: int = 25,
    ) -> Sequence[PeopleSearchGroup]:
        ...


def people_search_providers(
    registry: object | None,
    *,
    capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchProvider, ...]:
    if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
        return ()

    providers: list[PeopleSearchProvider] = []
    for capability_name in capability_names:
        if not registry.has_capability(capability_name):
            continue
        capability = registry.capability(capability_name)
        if isinstance(capability, PeopleSearchProvider):
            providers.append(capability)
    return tuple(providers)


def search_visible_people(
    registry: object | None,
    session: object,
    principal: object,
    *,
    query: str,
    limit: int = 25,
    capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchGroup, ...]:
    """Collect grouped results without importing optional feature modules.

    ``limit`` is enforced per provider so one directory cannot starve another
    result group. Providers are expected to return at most that many candidates
    in total. Duplicate group keys are merged and candidate selection keys are
    de-duplicated while preserving provider order.
    """

    normalized_query = str(query or "").strip()
    normalized_limit = max(1, min(int(limit), 100))
    ordered_group_keys: list[str] = []
    labels: dict[str, str] = {}
    candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
    candidate_keys_by_group: dict[str, set[str]] = {}

    for provider in people_search_providers(registry, capability_names=capability_names):
        groups = provider.search_people(
            session,
            principal,
            query=normalized_query,
            limit=normalized_limit,
        )
        provider_candidate_count = 0
        for group in groups:
            if not group.key:
                continue
            if group.key not in candidates_by_group:
                ordered_group_keys.append(group.key)
                labels[group.key] = group.label
                candidates_by_group[group.key] = []
                candidate_keys_by_group[group.key] = set()
            for candidate in group.candidates:
                if provider_candidate_count >= normalized_limit:
                    break
                if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
                    continue
                candidate_keys_by_group[group.key].add(candidate.selection_key)
                candidates_by_group[group.key].append(candidate)
                provider_candidate_count += 1

    return tuple(
        PeopleSearchGroup(
            key=group_key,
            label=labels[group_key],
            candidates=tuple(candidates_by_group[group_key]),
        )
        for group_key in ordered_group_keys
        if candidates_by_group[group_key]
    )


def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
    normalized_kind = str(kind).strip().casefold()
    normalized_reference = str(reference_id).strip()
    normalized_email = str(email or "").strip().casefold()
    if not normalized_kind or not normalized_reference:
        raise ValueError("Person selection keys require a kind and reference id.")
    return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)


__all__ = [
    "CAPABILITY_ACCESS_PEOPLE_SEARCH",
    "CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
    "DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
    "PeopleSearchError",
    "PeopleSearchGroup",
    "PeopleSearchProvider",
    "PersonSearchCandidate",
    "people_search_providers",
    "person_selection_key",
    "search_visible_people",
]
