"""Document indexing service functions."""

from __future__ import annotations

import logging
from typing import Any

from icv_search.backends import get_search_backend
from icv_search.exceptions import SearchBackendError
from icv_search.models import IndexSyncLog, SearchIndex
from icv_search.services._utils import resolve_index
from icv_search.signals import documents_indexed, documents_removed
from icv_search.types import TaskResult

logger = logging.getLogger(__name__)


def index_documents(
    name_or_index: str | SearchIndex,
    documents: list[dict[str, Any]],
    tenant_id: str = "",
    primary_key: str = "id",
) -> TaskResult:
    """Add or update documents in a search index.

    Args:
        name_or_index: Index name or SearchIndex instance.
        documents: List of document dicts to index.
        tenant_id: Tenant identifier (only needed if passing a name).
        primary_key: Document field used as primary key.

    Returns:
        Normalised TaskResult for the engine operation.
    """
    index = resolve_index(name_or_index, tenant_id)
    backend = get_search_backend()
    log = IndexSyncLog.objects.create(index=index, action="documents_added", status="pending")

    try:
        raw_result = backend.add_documents(
            uid=index.engine_uid,
            documents=documents,
            primary_key=primary_key,
        )
        task_result = TaskResult.from_engine(raw_result)
        log.task_uid = task_result.task_uid
        log.mark_complete(status="success", detail=f"Indexed {len(documents)} documents.")

        document_ids = [str(doc.get(primary_key, "")) for doc in documents]
        documents_indexed.send(
            sender=SearchIndex,
            instance=index,
            count=len(documents),
            document_ids=document_ids,
        )
        logger.info("Indexed %d documents in '%s'.", len(documents), index.name)
        return task_result

    except SearchBackendError as exc:
        log.mark_complete(status="failed", detail=str(exc))
        logger.exception("Failed to index documents in '%s'.", index.name)
        raise


def remove_documents(
    name_or_index: str | SearchIndex,
    document_ids: list[str],
    tenant_id: str = "",
) -> TaskResult:
    """Remove documents from a search index.

    Args:
        name_or_index: Index name or SearchIndex instance.
        document_ids: List of document IDs to remove.
        tenant_id: Tenant identifier (only needed if passing a name).

    Returns:
        Normalised TaskResult for the engine operation.
    """
    index = resolve_index(name_or_index, tenant_id)
    backend = get_search_backend()
    log = IndexSyncLog.objects.create(index=index, action="documents_deleted", status="pending")

    try:
        raw_result = backend.delete_documents(uid=index.engine_uid, document_ids=document_ids)
        task_result = TaskResult.from_engine(raw_result)
        log.task_uid = task_result.task_uid
        log.mark_complete(status="success", detail=f"Removed {len(document_ids)} documents.")

        documents_removed.send(
            sender=SearchIndex,
            instance=index,
            count=len(document_ids),
            document_ids=document_ids,
        )
        logger.info("Removed %d documents from '%s'.", len(document_ids), index.name)
        return task_result

    except SearchBackendError as exc:
        log.mark_complete(status="failed", detail=str(exc))
        logger.exception("Failed to remove documents from '%s'.", index.name)
        raise


def index_model_instances(
    model_class: type,
    queryset=None,
    batch_size: int = 1000,
) -> int:
    """Index model instances using their SearchableMixin configuration.

    Args:
        model_class: A Django model class using SearchableMixin.
        queryset: Optional queryset to use (defaults to get_search_queryset()).
        batch_size: Number of documents per batch.

    Returns:
        Total number of documents indexed.
    """
    if not hasattr(model_class, "search_index_name") or not model_class.search_index_name:
        raise ValueError(f"{model_class.__name__} does not define search_index_name.")

    qs = queryset if queryset is not None else model_class.get_search_queryset()
    total = 0
    batch: list[dict[str, Any]] = []

    for instance in qs.iterator(chunk_size=batch_size):
        batch.append(instance.to_search_document())
        if len(batch) >= batch_size:
            index_documents(model_class.search_index_name, batch)
            total += len(batch)
            batch = []

    if batch:
        index_documents(model_class.search_index_name, batch)
        total += len(batch)

    logger.info("Indexed %d %s instances.", total, model_class.__name__)
    return total


def reindex_all(
    name_or_index: str | SearchIndex,
    model_class: type,
    tenant_id: str = "",
    batch_size: int = 1000,
) -> int:
    """Full reindex: clear all documents, then re-index from the model queryset.

    Args:
        name_or_index: Index name or SearchIndex instance.
        model_class: A Django model class using SearchableMixin.
        tenant_id: Tenant identifier (only needed if passing a name).
        batch_size: Number of documents per batch.

    Returns:
        Total number of documents indexed.
    """
    index = resolve_index(name_or_index, tenant_id)
    backend = get_search_backend()

    # Clear existing documents
    try:
        backend.clear_documents(uid=index.engine_uid)
    except (SearchBackendError, NotImplementedError):
        logger.warning("Could not clear documents before reindex for '%s'.", index.name)

    log = IndexSyncLog.objects.create(index=index, action="reindexed", status="pending")

    try:
        total = index_model_instances(model_class, batch_size=batch_size)
        log.mark_complete(status="success", detail=f"Reindexed {total} documents.")
        return total
    except SearchBackendError as exc:
        log.mark_complete(status="failed", detail=str(exc))
        raise


def reindex_zero_downtime(
    name_or_index: str | SearchIndex,
    model_class: type,
    tenant_id: str = "",
    batch_size: int = 1000,
) -> int:
    """Zero-downtime reindex using index swap.

    Creates a temporary index, populates it fully from the model queryset,
    then atomically swaps it with the live index. The old index is deleted
    after the swap.

    Falls back to ``reindex_all`` (clear + re-index) if the backend does not
    support index swaps.

    Args:
        name_or_index: Index name or SearchIndex instance.
        model_class: A Django model class using SearchableMixin.
        tenant_id: Tenant identifier (only needed if passing a name).
        batch_size: Number of documents per batch.

    Returns:
        Total number of documents indexed.
    """
    index = resolve_index(name_or_index, tenant_id)
    backend = get_search_backend()

    # Check if backend supports swap
    try:
        backend.swap_indexes  # noqa: B018 — attribute access check
    except AttributeError:
        logger.info("Backend does not support swap — falling back to reindex_all.")
        return reindex_all(index, model_class, tenant_id=tenant_id, batch_size=batch_size)

    temp_uid = f"{index.engine_uid}_reindex_tmp"
    log = IndexSyncLog.objects.create(index=index, action="reindexed", status="pending")

    try:
        # 1. Create temporary index with same settings
        backend.create_index(uid=temp_uid, primary_key=index.primary_key_field)
        if index.settings:
            backend.update_settings(uid=temp_uid, settings=index.settings)

        # 2. Populate temporary index
        if not hasattr(model_class, "search_index_name") or not model_class.search_index_name:
            raise ValueError(f"{model_class.__name__} does not define search_index_name.")

        qs = model_class.get_search_queryset()
        total = 0
        batch: list[dict[str, Any]] = []

        for instance in qs.iterator(chunk_size=batch_size):
            batch.append(instance.to_search_document())
            if len(batch) >= batch_size:
                backend.add_documents(uid=temp_uid, documents=batch, primary_key=index.primary_key_field)
                total += len(batch)
                batch = []

        if batch:
            backend.add_documents(uid=temp_uid, documents=batch, primary_key=index.primary_key_field)
            total += len(batch)

        # 3. Swap indexes atomically
        try:
            backend.swap_indexes([(index.engine_uid, temp_uid)])
        except NotImplementedError:
            # Backend claimed to have swap but doesn't — clean up and fall back
            logger.warning("swap_indexes raised NotImplementedError — falling back.")
            try:
                backend.delete_index(uid=temp_uid)
            except Exception:
                pass
            return reindex_all(index, model_class, tenant_id=tenant_id, batch_size=batch_size)

        # 4. Delete the old index (now under the temp name after swap)
        try:
            backend.delete_index(uid=temp_uid)
        except Exception:
            logger.warning("Failed to delete temporary index '%s' after swap.", temp_uid)

        log.mark_complete(status="success", detail=f"Zero-downtime reindex: {total} documents.")
        logger.info("Zero-downtime reindex of '%s' complete: %d documents.", index.name, total)
        return total

    except Exception as exc:
        # Clean up temporary index on failure
        try:
            backend.delete_index(uid=temp_uid)
        except Exception:
            pass
        log.mark_complete(status="failed", detail=str(exc))
        logger.exception("Zero-downtime reindex failed for '%s'.", index.name)
        raise
