"""UKO Indexer — Real-time Index Synchronization service.

Based on ``docs/specification.md`` > ACMS > Real-time Index
Synchronization and Forgejo issue #578.
"""

from __future__ import annotations

import threading
from datetime import UTC, datetime

import structlog

from cleveragents.application.services.uko_indexer_internals import (
    attach_provenance,
    fire_on_error,
    fire_on_indexed,
    fire_on_removed,
    index_graph,
    index_text,
    index_vector,
)
from cleveragents.application.services.uko_indexer_protocols import (
    ContentReader,
    DefaultLifecycleHook,
    IndexLifecycleHook,
    LocationContentReader,
)
from cleveragents.domain.models.acms.analyzers import AnalyzerRegistry
from cleveragents.domain.models.acms.index_backends import (
    GraphIndexBackend,
    TextIndexBackend,
    VectorIndexBackend,
)
from cleveragents.domain.models.acms.provenance import IndexResult
from cleveragents.domain.models.core.resource import Resource

logger = structlog.get_logger(__name__)

#: Default maximum number of triples an analyzer may produce per resource.
DEFAULT_MAX_TRIPLES: int = 50_000


# ---------------------------------------------------------------------------
# UKOIndexer
# ---------------------------------------------------------------------------


class UKOIndexer:
    """Analyse resources into UKO triples and index into backends.

    Pipeline: analyzer -> content read -> triples -> provenance ->
    graph -> text -> vector.  Graceful degradation when optional
    backends are ``None``.

    All public methods are thread-safe: a global lock guards tracking
    dicts while per-resource locks allow concurrent indexing.
    """

    def __init__(
        self,
        *,
        analyzer_registry: AnalyzerRegistry,
        graph_backend: GraphIndexBackend,
        text_backend: TextIndexBackend | None = None,
        vector_backend: VectorIndexBackend | None = None,
        content_reader: ContentReader | None = None,
        lifecycle_hook: IndexLifecycleHook | None = None,
        max_triples: int = DEFAULT_MAX_TRIPLES,
    ) -> None:
        if not isinstance(analyzer_registry, AnalyzerRegistry):
            raise TypeError(
                "analyzer_registry must be an AnalyzerRegistry, "
                f"got {type(analyzer_registry).__name__}"
            )
        if not isinstance(graph_backend, GraphIndexBackend):
            raise TypeError(
                "graph_backend must satisfy GraphIndexBackend, "
                f"got {type(graph_backend).__name__}"
            )
        self._analyzer_registry = analyzer_registry
        self._graph_backend = graph_backend
        self._text_backend = text_backend
        self._vector_backend = vector_backend
        self._content_reader: ContentReader = content_reader or LocationContentReader()
        self._lifecycle_hook: IndexLifecycleHook = (
            lifecycle_hook or DefaultLifecycleHook()
        )
        if max_triples < 1:
            raise ValueError("max_triples must be positive")
        self._max_triples = max_triples
        self._lock = threading.Lock()
        self._resource_locks: dict[str, threading.Lock] = {}
        self._indexed_resources: dict[str, str] = {}
        self._resource_subjects: dict[str, set[str]] = {}
        self._resource_analyzer: dict[str, str] = {}

        logger.debug(
            "indexer.initialized",
            text_available=text_backend is not None,
            vector_available=vector_backend is not None,
            analyzer_count=len(analyzer_registry),
        )

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def analyzer_registry(self) -> AnalyzerRegistry:
        """The analyzer registry used for resource analysis."""
        return self._analyzer_registry

    @property
    def indexed_resource_count(self) -> int:
        """Number of resources currently indexed."""
        with self._lock:
            return len(self._indexed_resources)

    @property
    def has_text_backend(self) -> bool:
        """Whether a text index backend is available."""
        return self._text_backend is not None

    @property
    def has_vector_backend(self) -> bool:
        """Whether a vector index backend is available."""
        return self._vector_backend is not None

    def _resource_lock(self, resource_id: str) -> threading.Lock:
        """Return (or create) the per-resource lock for *resource_id*."""
        with self._lock:
            lock = self._resource_locks.get(resource_id)
            if lock is None:
                lock = threading.Lock()
                self._resource_locks[resource_id] = lock
            return lock

    def index_resource(
        self,
        resource: Resource,
        *,
        project: str,
    ) -> IndexResult:
        """Index a resource into all available backends.

        Implements the spec's ``UKOIndexer.index_resource()`` pipeline:
        analyze -> provenance -> graph -> text -> vector.

        Args:
            resource: The resource to index.
            project: Namespaced project name (e.g. ``"local/my-app"``).

        Returns:
            :class:`IndexResult` summarizing the operation.

        Raises:
            ValueError: If *project* is empty or whitespace-only.
        """
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")

        log = logger.bind(
            resource_id=resource.resource_id,
            project=project,
        )
        log.debug("indexer.index_resource.start")

        res_lock = self._resource_lock(resource.resource_id)
        prior_errors: list[str] = []
        with res_lock:
            # Idempotency: if already indexed, clean up old data first
            # so triples don't accumulate (spec: "Indices reflect latest
            # state").  Release global lock before calling
            # _remove_resource_internal (it acquires the lock itself).
            with self._lock:
                if resource.resource_id in self._indexed_resources:
                    old_project = self._indexed_resources[resource.resource_id]
                else:
                    old_project = None
            if old_project is not None:
                log.debug(
                    "indexer.removing_stale_data",
                    old_project=old_project,
                )
                prior_errors = self._remove_resource_internal(
                    resource.resource_id, project=old_project
                )

            return self._index_resource_core(
                resource, project, log, prior_errors=prior_errors
            )

    def _index_resource_core(
        self,
        resource: Resource,
        project: str,
        log: structlog.stdlib.BoundLogger,
        *,
        prior_errors: list[str] | None = None,
    ) -> IndexResult:
        """Core indexing logic — caller MUST hold the per-resource lock."""
        errors: list[str] = list(prior_errors) if prior_errors else []

        # 1. Determine the best analyzer
        analyzer = self._analyzer_registry.get_for_resource(resource)
        if analyzer is None:
            log.debug("indexer.no_analyzer", location=resource.location)
            # No analyzer matched — nothing was indexed, so we do NOT
            # fire on_indexed (its contract is "successfully indexed").
            return IndexResult(
                resource_id=resource.resource_id,
                analyzer_domain="none",
            )

        # 2. Read resource content (I/O — outside global lock)
        try:
            content = self._content_reader.read_content(resource)
        except (OSError, ValueError) as exc:
            error_msg = f"Failed to read resource content: {type(exc).__name__}"
            log.warning("indexer.read_failed", error=str(exc))
            fire_on_error(self._lifecycle_hook, resource.resource_id, error_msg)
            return IndexResult(
                resource_id=resource.resource_id,
                analyzer_domain=analyzer.domain,
                errors=(error_msg,),
            )

        # 3. Produce UKO triples (CPU — outside global lock)
        resource_uri = f"uko://resource/{resource.resource_id}"
        try:
            triples = analyzer.analyze(content, resource_uri)
        except Exception as exc:
            error_msg = f"Analyzer error ({analyzer.domain}): {type(exc).__name__}"
            log.warning("indexer.analyze_failed", error=str(exc), exc_info=True)
            errors.append(error_msg)
            fire_on_error(self._lifecycle_hook, resource.resource_id, error_msg)
            return IndexResult(
                resource_id=resource.resource_id,
                analyzer_domain=analyzer.domain,
                errors=tuple(errors),
            )

        # Cap triple output to prevent OOM from pathological analyzers.
        if len(triples) > self._max_triples:
            cap_msg = (
                f"Triples capped: analyzer produced {len(triples)}, "
                f"limit is {self._max_triples}"
            )
            log.warning(
                "indexer.triples_capped",
                produced=len(triples),
                max_triples=self._max_triples,
            )
            errors.append(cap_msg)
            triples = triples[: self._max_triples]

        log.debug(
            "indexer.triples_produced",
            triple_count=len(triples),
            analyzer=analyzer.domain,
        )

        # 4. Attach provenance
        now = datetime.now(UTC)
        provenanced = attach_provenance(triples, resource, now)

        # 5. Store in graph backend (backend I/O — outside global lock)
        graph_count, subjects = index_graph(
            self._graph_backend,
            project,
            resource,
            provenanced,
            errors,
            resource_uri,
        )

        # 6. Index text content
        text_count = index_text(
            self._text_backend,
            project,
            resource,
            content,
            errors,
            resource_uri,
        )

        # 7. Index embeddings
        vector_count = index_vector(
            self._vector_backend,
            project,
            resource,
            content,
            errors,
            resource_uri,
        )

        # 8. Update tracking dicts only if at least one backend stored
        #    data, so indexed_resource_count stays accurate.
        if graph_count > 0 or text_count > 0 or vector_count > 0:
            with self._lock:
                self._indexed_resources[resource.resource_id] = project
                self._resource_analyzer[resource.resource_id] = analyzer.domain
                if subjects:
                    self._resource_subjects[resource.resource_id] = subjects

        result = IndexResult(
            resource_id=resource.resource_id,
            triple_count=graph_count,
            text_docs_indexed=text_count,
            embeddings_indexed=vector_count,
            analyzer_domain=analyzer.domain,
            errors=tuple(errors),
        )
        fire_on_indexed(self._lifecycle_hook, result)

        log.debug(
            "indexer.index_resource.done",
            triple_count=graph_count,
            text_docs=text_count,
            vectors=vector_count,
            error_count=len(errors),
        )
        return result

    def remove_resource(
        self,
        resource_id: str,
        *,
        project: str,
    ) -> None:
        """Remove a resource from all indices.

        Uses the caller's *project* for backend cleanup.  If the
        resource was indexed under a different project, the stored
        project is used instead (cross-project safety).

        Args:
            resource_id: ULID of the resource to remove.
            project: Namespaced project name.

        Raises:
            ValueError: If *resource_id* or *project* is empty
                or whitespace-only.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id must be a non-empty string")
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")

        log = logger.bind(resource_id=resource_id, project=project)
        log.debug("indexer.remove_resource.start")

        res_lock = self._resource_lock(resource_id)
        with res_lock:
            with self._lock:
                if resource_id not in self._indexed_resources:
                    log.debug("indexer.remove_resource.not_tracked")
                    return
                stored_project = self._indexed_resources[resource_id]

            # Use stored_project for backend cleanup (cross-project
            # safety) and rebind the logger to reflect the actual
            # project used for removal.
            if stored_project != project:
                log = log.bind(stored_project=stored_project)
            self._remove_resource_internal(resource_id, project=stored_project)

        fire_on_removed(self._lifecycle_hook, resource_id, stored_project)
        log.debug("indexer.remove_resource.done")

    def _remove_resource_internal(
        self,
        resource_id: str,
        *,
        project: str,
    ) -> list[str]:
        """Remove resource data from backends (no lifecycle events).

        Caller MUST hold the per-resource lock.  Backend calls run
        outside the global lock; tracking-dict cleanup acquires the
        global lock briefly at the end.

        Returns:
            List of error messages from partial backend failures
            (empty on full success).
        """
        resource_uri = f"uko://resource/{resource_id}"

        with self._lock:
            tracked_subjects = self._resource_subjects.get(resource_id, set()).copy()

        # Each backend call is individually guarded so a failure in
        # one does not skip cleanup of others.
        errors: list[str] = []
        for subj in tracked_subjects:
            try:
                self._graph_backend.remove_triples(
                    project,
                    subject=subj,
                    predicate="uko:sourceResource",
                    obj=resource_uri,
                )
            except Exception as exc:
                errors.append(
                    f"graph remove provenance {subj}: {type(exc).__name__}: {exc}"
                )
            try:
                self._graph_backend.remove_triples(
                    project,
                    subject=subj,
                    predicate=None,
                    obj=None,
                )
            except Exception as exc:
                errors.append(f"graph remove bulk {subj}: {type(exc).__name__}: {exc}")

        if self._text_backend is not None:
            try:
                self._text_backend.remove_document(project, resource_uri)
            except Exception as exc:
                errors.append(f"text remove: {type(exc).__name__}: {exc}")
        if self._vector_backend is not None:
            try:
                self._vector_backend.remove_embedding(project, resource_uri)
            except Exception as exc:
                errors.append(f"vector remove: {type(exc).__name__}: {exc}")

        if errors:
            logger.warning(
                "indexer.remove_resource.partial_failure",
                resource_id=resource_id,
                errors=errors,
            )

        # Clean up tracking dicts.  Per-resource locks are intentionally
        # retained — popping while held creates a race condition.
        with self._lock:
            self._resource_subjects.pop(resource_id, None)
            self._indexed_resources.pop(resource_id, None)
            self._resource_analyzer.pop(resource_id, None)

        return errors

    def reindex_resource(
        self,
        resource: Resource,
        *,
        project: str,
    ) -> IndexResult:
        """Re-index a resource (remove old data, then re-index).

        Note: ``indexed_resource_count`` may transiently dip by one
        during the remove-then-reindex window — this is expected.

        Args:
            resource: The resource to re-index.
            project: Namespaced project name.

        Returns:
            :class:`IndexResult` summarizing the operation.

        Raises:
            ValueError: If *project* is empty or whitespace-only.
        """
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")

        log = logger.bind(
            resource_id=resource.resource_id,
            project=project,
        )
        log.debug("indexer.reindex_resource.start")

        res_lock = self._resource_lock(resource.resource_id)
        with res_lock:
            with self._lock:
                if resource.resource_id in self._indexed_resources:
                    old_project = self._indexed_resources[resource.resource_id]
                else:
                    old_project = None
            reindex_errors: list[str] = []
            if old_project is not None:
                reindex_errors = self._remove_resource_internal(
                    resource.resource_id, project=old_project
                )
            return self._index_resource_core(
                resource, project, log, prior_errors=reindex_errors
            )


# ---------------------------------------------------------------------------
# Module exports
# ---------------------------------------------------------------------------

__all__: list[str] = [
    "DEFAULT_MAX_TRIPLES",
    "UKOIndexer",
]
