from __future__ import annotations

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

from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext


SearchContextKind = Literal["global", "module", "resource"]
SearchVisibility = Literal["tenant", "restricted"]
SearchIndexChangeKind = Literal["upsert", "delete"]
SEARCH_SOURCE_CONTRACT_VERSION = "1"


@dataclass(frozen=True, slots=True)
class SearchQuery:
    text: str
    tenant_id: str
    module_ids: tuple[str, ...] = ()
    resource_types: tuple[str, ...] = ()
    context_kind: SearchContextKind = "global"
    context_id: str | None = None
    limit: int = 25
    offset: int = 0
    cursor: str | None = None
    language: str = "simple"

    def __post_init__(self) -> None:
        normalized_text = self.text.strip()
        if len(normalized_text) > 500:
            raise ValueError("Search text is limited to 500 characters.")
        if not self.tenant_id.strip():
            raise ValueError("Search queries require a tenant.")
        if not 1 <= self.limit <= 200:
            raise ValueError("Search result limits must be between 1 and 200.")
        if self.offset < 0:
            raise ValueError("Search offsets cannot be negative.")
        if self.cursor and self.offset:
            raise ValueError(
                "Search cursor and offset pagination cannot be combined."
            )
        if not self.language.replace("_", "").isalnum():
            raise ValueError("Search language configuration is invalid.")
        if len(self.module_ids) > 50 or len(self.resource_types) > 50:
            raise ValueError(
                "Search queries support at most 50 module and type filters."
            )
        object.__setattr__(self, "text", normalized_text)


@dataclass(frozen=True, slots=True)
class SearchResult:
    provider_id: str
    module_id: str
    resource_type: str
    resource_id: str
    title: str
    url: str
    summary: str | None = None
    score: float = 0.0
    highlights: tuple[str, ...] = ()
    breadcrumbs: tuple[str, ...] = ()
    external_reference: ExternalObjectReference | None = None
    metadata: Mapping[str, object] = field(default_factory=dict)
    source_revision: str | None = None
    provenance: Mapping[str, object] = field(default_factory=dict)


@dataclass(frozen=True, slots=True)
class SearchResourceReference:
    tenant_id: str
    module_id: str
    resource_type: str
    resource_id: str

    def __post_init__(self) -> None:
        for field_name in (
            "tenant_id",
            "module_id",
            "resource_type",
            "resource_id",
        ):
            if not str(getattr(self, field_name) or "").strip():
                raise ValueError(
                    f"Search resource reference {field_name} is required."
                )

    @property
    def key(self) -> str:
        values = (
            self.tenant_id,
            self.module_id,
            self.resource_type,
            self.resource_id,
        )
        return "|".join(
            f"{len(value)}:{value}"
            for value in values
        )


@dataclass(frozen=True, slots=True)
class SearchResourceType:
    provider_id: str
    module_id: str
    resource_type: str
    label: str
    index_version: int = 1
    language: str = "simple"
    requires_authorization_recheck: bool = True

    def __post_init__(self) -> None:
        if not all(
            value.strip()
            for value in (
                self.provider_id,
                self.module_id,
                self.resource_type,
                self.label,
            )
        ):
            raise ValueError(
                "Search resource type identifiers and label are required."
            )
        if self.index_version < 1:
            raise ValueError("Search index versions start at one.")


@dataclass(frozen=True, slots=True)
class SearchDocument:
    tenant_id: str
    module_id: str
    resource_type: str
    resource_id: str
    title: str
    url: str
    summary: str | None = None
    body: str | None = None
    keywords: tuple[str, ...] = ()
    visibility: SearchVisibility = "restricted"
    acl_tokens: tuple[str, ...] = ()
    external_reference: ExternalObjectReference | None = None
    metadata: Mapping[str, object] = field(default_factory=dict)
    provider_id: str | None = None
    source_revision: str = "1"
    change_cursor: str | None = None
    source_updated_at: datetime | None = None
    language: str = "simple"
    index_version: int = 1
    requires_authorization_recheck: bool = False

    def __post_init__(self) -> None:
        required = {
            "tenant_id": self.tenant_id,
            "module_id": self.module_id,
            "resource_type": self.resource_type,
            "resource_id": self.resource_id,
            "title": self.title,
            "url": self.url,
        }
        for field_name, value in required.items():
            if not str(value or "").strip():
                raise ValueError(f"Search document {field_name} is required.")
        limits = {
            "module_id": 100,
            "resource_type": 100,
            "resource_id": 255,
            "title": 500,
            "url": 1500,
            "summary": 4_000,
            "body": 200_000,
            "source_revision": 255,
            "change_cursor": 500,
            "language": 32,
        }
        for field_name, limit in limits.items():
            value = getattr(self, field_name)
            if value is not None and len(str(value)) > limit:
                raise ValueError(
                    f"Search document {field_name} is limited to "
                    f"{limit} characters."
                )
        if len(self.keywords) > 100 or any(
            len(keyword) > 200
            for keyword in self.keywords
        ):
            raise ValueError(
                "Search documents support at most 100 bounded keywords."
            )
        if len(self.acl_tokens) > 500 or any(
            len(token) > 500
            for token in self.acl_tokens
        ):
            raise ValueError(
                "Search documents support at most 500 bounded ACL tokens."
            )
        if self.visibility == "restricted" and not self.acl_tokens:
            raise ValueError(
                "Restricted search documents require at least one ACL token."
            )
        if not self.source_revision.strip():
            raise ValueError(
                "Search documents require a stable source revision."
            )
        if self.index_version < 1:
            raise ValueError("Search document index versions start at one.")
        if (
            self.requires_authorization_recheck
            and not str(self.provider_id or "").strip()
        ):
            raise ValueError(
                "Search documents requiring authorization rechecks must "
                "name their source provider."
            )
        if self.provider_id is not None and len(self.provider_id) > 200:
            raise ValueError(
                "Search document provider_id is limited to 200 characters."
            )

    @property
    def reference(self) -> SearchResourceReference:
        return SearchResourceReference(
            tenant_id=self.tenant_id,
            module_id=self.module_id,
            resource_type=self.resource_type,
            resource_id=self.resource_id,
        )


@dataclass(frozen=True, slots=True)
class SearchAuthorizationRequest:
    reference: SearchResourceReference
    source_revision: str


@dataclass(frozen=True, slots=True)
class SearchBackfillRequest:
    tenant_id: str
    provider_id: str
    resource_type: str
    rebuild_id: str
    cursor: str | None = None
    limit: int = 100

    def __post_init__(self) -> None:
        if not 1 <= self.limit <= 500:
            raise ValueError(
                "Search backfill page size must be between 1 and 500."
            )


@dataclass(frozen=True, slots=True)
class SearchBackfillPage:
    documents: tuple[SearchDocument, ...]
    next_cursor: str | None
    complete: bool
    high_watermark: str | None = None

    def __post_init__(self) -> None:
        if len(self.documents) > 500:
            raise ValueError(
                "Search backfill providers returned more than 500 documents."
            )
        if not self.complete and not self.next_cursor:
            raise ValueError(
                "Incomplete search backfill pages require a next cursor."
            )


@dataclass(frozen=True, slots=True)
class SearchIndexChange:
    change_id: str
    provider_id: str
    kind: SearchIndexChangeKind
    reference: SearchResourceReference
    source_revision: str
    cursor: str
    document: SearchDocument | None = None
    occurred_at: datetime | None = None

    def __post_init__(self) -> None:
        if not all(
            value.strip()
            for value in (
                self.change_id,
                self.provider_id,
                self.source_revision,
                self.cursor,
            )
        ):
            raise ValueError(
                "Search index changes require stable identity and revision."
            )
        if self.kind == "upsert":
            if (
                self.document is None
                or self.document.reference != self.reference
                or self.document.provider_id != self.provider_id
                or self.document.source_revision
                != self.source_revision
                or self.document.change_cursor != self.cursor
            ):
                raise ValueError(
                    "Search upserts require a matching document."
                )
        elif self.document is not None:
            raise ValueError(
                "Search deletes must not contain a document."
            )


@runtime_checkable
class SearchProvider(Protocol):
    def search(
        self,
        session: object,
        principal: object,
        *,
        query: SearchQuery,
    ) -> Sequence[SearchResult]:
        """Return only results the principal may currently read."""


@runtime_checkable
class SearchIndexWriter(Protocol):
    def upsert_document(
        self,
        session: object,
        principal: object,
        *,
        document: SearchDocument,
    ) -> None:
        ...

    def delete_document(
        self,
        session: object,
        principal: object,
        *,
        tenant_id: str,
        module_id: str,
        resource_type: str,
        resource_id: str,
    ) -> bool:
        ...

    def enqueue_change(
        self,
        session: object,
        *,
        change: SearchIndexChange,
    ) -> bool:
        ...


@runtime_checkable
class SearchSourceProvider(Protocol):
    def resource_types(self) -> Sequence[SearchResourceType]:
        ...

    def backfill(
        self,
        session: object,
        *,
        request: SearchBackfillRequest,
    ) -> SearchBackfillPage:
        ...

    def authorize(
        self,
        session: object,
        principal: object,
        *,
        requests: Sequence[SearchAuthorizationRequest],
    ) -> Mapping[str, bool]:
        """Return an explicit decision for every requested reference key."""


SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
SearchSourceProviderFactory = Callable[
    [ModuleContext],
    SearchSourceProvider,
]


@dataclass(frozen=True, slots=True)
class SearchProviderRegistration:
    id: str
    factory: SearchProviderFactory
    resource_types: tuple[str, ...] = ()
    order: int = 100

    def create(self, context: ModuleContext) -> SearchProvider:
        provider = self.factory(context)
        if not isinstance(provider, SearchProvider):
            raise TypeError(
                f"Search provider {self.id!r} does not implement SearchProvider."
            )
        return provider


@dataclass(frozen=True, slots=True)
class RegisteredSearchProvider:
    module_id: str
    registration: SearchProviderRegistration


@dataclass(frozen=True, slots=True)
class SearchSourceProviderRegistration:
    id: str
    factory: SearchSourceProviderFactory
    order: int = 100

    def create(self, context: ModuleContext) -> SearchSourceProvider:
        provider = self.factory(context)
        if not isinstance(provider, SearchSourceProvider):
            raise TypeError(
                f"Search source {self.id!r} does not implement "
                "SearchSourceProvider."
            )
        return provider


@dataclass(frozen=True, slots=True)
class RegisteredSearchSourceProvider:
    module_id: str
    registration: SearchSourceProviderRegistration


CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"


def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
    if (
        registry is None
        or not hasattr(registry, "has_capability")
        or not hasattr(registry, "capability")
        or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
    ):
        return None
    writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
    return writer if isinstance(writer, SearchIndexWriter) else None


__all__ = [
    "CAPABILITY_SEARCH_INDEX_WRITER",
    "SEARCH_SOURCE_CONTRACT_VERSION",
    "RegisteredSearchProvider",
    "RegisteredSearchSourceProvider",
    "SearchAuthorizationRequest",
    "SearchBackfillPage",
    "SearchBackfillRequest",
    "SearchContextKind",
    "SearchDocument",
    "SearchIndexChange",
    "SearchIndexChangeKind",
    "SearchIndexWriter",
    "SearchProvider",
    "SearchProviderFactory",
    "SearchProviderRegistration",
    "SearchQuery",
    "SearchResourceReference",
    "SearchResourceType",
    "SearchResult",
    "SearchSourceProvider",
    "SearchSourceProviderFactory",
    "SearchSourceProviderRegistration",
    "SearchVisibility",
    "search_index_writer",
]
