"""DMS search — services.

Public API
----------
Indexing:
    index_document(document)          -> DocumentSearchIndex
    deindex_document(document_id)     -> None
    reindex_tenant(tenant_id)         -> int   (count of indexed documents)

Search:
    search_documents(tenant_id, params) -> QuerySet[DocumentSearchIndex]

Saved-search CRUD:
    create_saved_search(...)          -> SavedSearch
    update_saved_search(ss, ...)      -> SavedSearch
    delete_saved_search(ss)           -> None
    execute_saved_search(ss, tenant_id) -> QuerySet[DocumentSearchIndex]

Design notes
------------
* ``SearchParams`` is a plain dataclass whose fields mirror an OpenSearch /
  Elasticsearch query DSL.  When migrating to OpenSearch, only the body of
  ``search_documents()`` needs to change — the params struct, the API layer,
  and the serializers all stay the same.

* ``index_document()`` always re-fetches the document from the DB with all
  needed related objects selected.  This is intentional: it ensures index
  entries are always built from the latest DB state regardless of how the
  function is called (signal, Celery task, management command).

* ``tags`` in the index are seeded from ``document.extra.get("tags", [])``.
  Phase-4 (Metadata Engine) and Phase-11 (AI) will write richer tags; they
  call ``index_document()`` after updating their own data.

* ``reindex_tenant()`` is designed to be called from a Celery task in
  production.  The function signature is intentionally simple so it can be
  wrapped with ``@shared_task`` without modification.
"""

from __future__ import annotations

import uuid as _uuid
from dataclasses import dataclass, field
from datetime import datetime

from django.utils import timezone


# ---------------------------------------------------------------------------
# Domain errors
# ---------------------------------------------------------------------------

class SearchError(Exception):
    """Base search domain error."""


# ---------------------------------------------------------------------------
# Search params
# ---------------------------------------------------------------------------

_SEARCH_SORT_FIELDS: frozenset[str] = frozenset(
    {"title", "indexed_at", "status", "content_type", "version_label"}
)


@dataclass
class SearchParams:
    """Structured search parameters.

    OpenSearch field mapping
    ------------------------
    q               → multi_match on title / search_text
    repository_id   → term filter on repository_public_id
    folder_id       → term filter on folder_public_id
    document_type   → term filter on document_type_code
    status          → term filter on status
    content_type    → term / prefix filter on content_type
    indexed_after   → range on indexed_at (gte)
    indexed_before  → range on indexed_at (lte)
    tags            → terms filter on tags (any-match)
    sort / order    → sort clause
    """

    q: str | None = None
    repository_id: str | None = None
    folder_id: str | None = None
    document_type: str | None = None
    status: str | None = None
    content_type: str | None = None
    indexed_after: datetime | None = None
    indexed_before: datetime | None = None
    tags: list[str] = field(default_factory=list)
    sort: str = "indexed_at"
    order: str = "desc"

    def validated_sort(self) -> str:
        """Return a Django ORM sort expression (prefixed with '-' for desc)."""
        s = self.sort if self.sort in _SEARCH_SORT_FIELDS else "indexed_at"
        return f"-{s}" if self.order == "desc" else s


# ---------------------------------------------------------------------------
# Indexing helpers
# ---------------------------------------------------------------------------

def _build_search_text(document, current_ver, file_asset) -> str:
    """Assemble the FTS blob from document fields."""
    parts: list[str] = [document.title, document.code or ""]
    if document.document_type:
        parts.extend([document.document_type.name, document.document_type.code])
    if current_ver is not None:
        parts.append(current_ver.label or "")
        parts.append(current_ver.version_label or "")
    if file_asset is not None:
        parts.append(getattr(file_asset, "filename", "") or "")
    return " ".join(p for p in parts if p).strip()


def _extract_tags(document) -> list[str]:
    """Pull tags from document.extra, returning a list of lowercase strings."""
    raw = document.extra.get("tags", []) if isinstance(document.extra, dict) else []
    if not isinstance(raw, list):
        return []
    return [str(t).lower() for t in raw if t]


# ---------------------------------------------------------------------------
# Indexing services
# ---------------------------------------------------------------------------

def index_document(document) -> "DocumentSearchIndex":
    """Create or update the search index entry for a document.

    Idempotent — safe to call multiple times.  Each call increments
    ``index_version`` so external search replicas can detect staleness.

    Returns the (created or updated) ``DocumentSearchIndex`` instance.
    """
    from simorgh.apps.dms.documents.models import Document
    from simorgh.apps.dms.search.models import DocumentSearchIndex

    # Always refresh with a consistent select_related to avoid N+1 issues
    # and ensure we build the index from the latest committed state.
    document = (
        Document.objects.select_related(
            "document_type",
            "current_version__file_asset",
            "repository",
            "folder",
        )
        .get(pk=document.pk)
    )

    current_ver = document.current_version
    file_asset = None
    if current_ver is not None:
        try:
            file_asset = current_ver.file_asset
        except Exception:
            file_asset = None

    search_text = _build_search_text(document, current_ver, file_asset)
    tags = _extract_tags(document)

    defaults = {
        "organization_node_id": document.organization_node_id,
        "title": document.title,
        "code": document.code or "",
        "document_type_code": document.document_type.code if document.document_type else "",
        "document_type_name": document.document_type.name if document.document_type else "",
        "repository_public_id": document.repository.public_id if document.repository_id else None,
        "folder_public_id": document.folder.public_id if document.folder_id else None,
        "status": document.status,
        "workflow_status": document.workflow_status or "",
        # File info from the mirrored fields on DocumentVersion (no extra join)
        "file_name": (
            getattr(file_asset, "filename", "") if file_asset else ""
        ),
        "content_type": current_ver.content_type if current_ver else "",
        "file_size": current_ver.file_size_bytes if current_ver else None,
        "version_label": current_ver.version_label if current_ver else "",
        "search_text": search_text,
        "tags": tags,
    }

    idx, created = DocumentSearchIndex.objects.get_or_create(
        document=document,
        tenant_id=document.tenant_id,
        defaults=defaults,
    )
    if not created:
        for attr, val in defaults.items():
            setattr(idx, attr, val)
        idx.index_version = idx.index_version + 1
        idx.save()

    return idx


def deindex_document(document_id: int) -> None:
    """Remove the search index entry for a soft-deleted document.

    Called by the signal handler and directly during hard-delete scenarios.
    Safe to call even if no index entry exists.
    """
    from simorgh.apps.dms.search.models import DocumentSearchIndex

    DocumentSearchIndex.objects.filter(document_id=document_id).delete()


def reindex_tenant(tenant_id: int) -> int:
    """Rebuild the complete search index for a tenant.

    Designed to be wrapped with ``@shared_task`` for Celery execution::

        @shared_task
        def async_reindex_tenant(tenant_id):
            return reindex_tenant(tenant_id)

    Returns the number of documents successfully indexed.
    """
    from simorgh.apps.dms.documents.models import Document

    docs = Document.objects.filter(
        tenant_id=tenant_id, is_deleted=False
    ).only("pk")

    count = 0
    for doc in docs:
        try:
            index_document(doc)
            count += 1
        except Exception:
            import logging
            logging.getLogger(__name__).exception(
                "reindex_tenant: failed to index document pk=%s", doc.pk
            )
    return count


# ---------------------------------------------------------------------------
# Search query builder
# ---------------------------------------------------------------------------

def search_documents(tenant_id: int, params: SearchParams):
    """Execute a structured search and return a QuerySet of index entries.

    All filters are applied in-order to the ``DocumentSearchIndex`` table.
    The caller is responsible for pagination.

    OpenSearch migration path
    -------------------------
    Replace the body of this function with an ES client call.  The
    ``SearchParams`` dataclass maps 1-to-1 to an ES query DSL document.
    The return value contract (iterable of index-like objects with the same
    field names) should be preserved.
    """
    from simorgh.apps.dms.search.models import DocumentSearchIndex

    qs = DocumentSearchIndex.objects.filter(tenant_id=tenant_id).select_related(
        "document"
    )

    # Full-text ----------------------------------------------------------------
    if params.q:
        qs = qs.filter(search_text__icontains=params.q)

    # Structured filters -------------------------------------------------------
    if params.repository_id:
        try:
            qs = qs.filter(repository_public_id=_uuid.UUID(str(params.repository_id)))
        except (ValueError, AttributeError):
            pass

    if params.folder_id:
        try:
            qs = qs.filter(folder_public_id=_uuid.UUID(str(params.folder_id)))
        except (ValueError, AttributeError):
            pass

    if params.document_type:
        qs = qs.filter(document_type_code=params.document_type)

    if params.status:
        qs = qs.filter(status=params.status)

    if params.content_type:
        qs = qs.filter(content_type__icontains=params.content_type)

    if params.indexed_after:
        qs = qs.filter(indexed_at__gte=params.indexed_after)

    if params.indexed_before:
        qs = qs.filter(indexed_at__lte=params.indexed_before)

    if params.tags:
        # Any-match tag semantics: a document matches if it has ANY of the
        # requested tags.
        #
        # JSONField.__contains with a list is only supported on PostgreSQL.
        # We use a JSON-string substring match (e.g. '"hr"' inside '["hr","policy"]')
        # which is portable across SQLite and PostgreSQL.  Because tags are stored
        # as double-quoted JSON string values, searching for '"<tag>"' avoids
        # partial-word false positives.
        from django.db.models import Q
        tag_q = Q()
        for tag in params.tags:
            tag_q |= Q(tags__icontains=f'"{tag}"')
        qs = qs.filter(tag_q)

    # Sorting ------------------------------------------------------------------
    qs = qs.order_by(params.validated_sort())

    return qs


# ---------------------------------------------------------------------------
# Saved search CRUD
# ---------------------------------------------------------------------------

def create_saved_search(
    *,
    tenant_id: int,
    organization_node_id: int,
    owner,
    name: str,
    description: str = "",
    query_params: dict | None = None,
    is_shared: bool = False,
) -> "SavedSearch":
    from simorgh.apps.dms.search.models import SavedSearch

    return SavedSearch.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        owner=owner,
        name=name,
        description=description,
        query_params=query_params or {},
        is_shared=is_shared,
    )


def update_saved_search(
    ss: "SavedSearch",
    *,
    name: str | None = None,
    description: str | None = None,
    query_params: dict | None = None,
    is_shared: bool | None = None,
) -> "SavedSearch":
    if name is not None:
        ss.name = name
    if description is not None:
        ss.description = description
    if query_params is not None:
        ss.query_params = query_params
    if is_shared is not None:
        ss.is_shared = is_shared
    ss.save()
    return ss


def delete_saved_search(ss: "SavedSearch") -> None:
    """Soft-delete a saved search."""
    ss.is_deleted = True
    ss.save(update_fields=["is_deleted", "updated_at"])


def execute_saved_search(ss: "SavedSearch", tenant_id: int):
    """Run the saved search and update its usage statistics.

    Returns a QuerySet of ``DocumentSearchIndex`` rows.
    """
    params = _params_from_dict(ss.query_params)
    result = search_documents(tenant_id, params)
    ss.last_used_at = timezone.now()
    ss.use_count += 1
    ss.save(update_fields=["last_used_at", "use_count", "updated_at"])
    return result


def _params_from_dict(d: dict) -> SearchParams:
    """Deserialise a raw dict into a SearchParams (permissive — ignores unknown keys)."""
    from dataclasses import fields as dc_fields

    allowed = {f.name for f in dc_fields(SearchParams) if not f.name.startswith("_")}
    clean = {k: v for k, v in d.items() if k in allowed}
    return SearchParams(**clean)
