"""Repository indexing service for full and incremental repo scans."""

from __future__ import annotations

import functools
import logging
import re
import threading
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

from ulid import ULID

from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

if TYPE_CHECKING:
    from cleveragents.infrastructure.events.protocol import EventBus

from cleveragents.application.services.repo_indexing_persistence import (
    load_index,
    load_index_status,
    persist_index,
    persist_status,
)
from cleveragents.application.services.repo_indexing_utils import (
    detect_language,
    detect_primary_language,
    estimate_tokens,
    walk_and_index,
)
from cleveragents.domain.models.core.repo_index import (
    ULID_PATTERN,
    FileRecord,
    IndexMetadata,
    IndexStatus,
    RepoIndex,
)
from cleveragents.infrastructure.database.models import (
    IndexedFileModel,
    RepoIndexModel,
)

__all__ = [
    "RepoIndexingService",
    "detect_language",
    "estimate_tokens",
]

logger = logging.getLogger(__name__)


def _serialize_on_resource[F: Callable[..., object]](method: F) -> F:
    """Serialize concurrent calls for the same *resource_id*."""

    @functools.wraps(method)
    def _locked(self: Any, resource_id: str, *a: Any, **kw: Any) -> Any:
        with self._resource_lock(resource_id):
            return method(self, resource_id, *a, **kw)

    return cast(F, _locked)


class RepoIndexingService:
    """Repository indexing service using the session-factory pattern.

    It walks trees with glob and size filtering, persists the resulting records,
    and serializes concurrent operations per resource within a process.
    """

    def __init__(self, session_factory: Any, event_bus: EventBus | None = None) -> None:
        """Initialise with *session_factory* (callable → SQLAlchemy Session).

        Args:
            session_factory: Callable that produces SQLAlchemy sessions.
            event_bus: Optional EventBus for domain event emission.
        """
        self._session_factory = session_factory
        self._event_bus = event_bus
        self._resource_locks: dict[str, threading.RLock] = {}
        self._locks_guard = threading.Lock()
        self.cleanup_stale_indexing()

    def _resource_lock(self, resource_id: str) -> threading.RLock:
        """Return a per-*resource_id* reentrant lock."""
        with self._locks_guard:
            return self._resource_locks.setdefault(resource_id, threading.RLock())

    @staticmethod
    def _validate_resource_id(resource_id: str) -> None:
        """Validate that *resource_id* is a non-empty ULID string."""
        if not resource_id:
            raise ValueError("resource_id must be non-empty")
        if not re.match(ULID_PATTERN, resource_id):
            raise ValueError(f"resource_id must be a valid ULID, got: {resource_id!r}")

    # -- Public API ----------------------------------------------------------

    @_serialize_on_resource
    def index_resource(
        self,
        resource_id: str,
        root_path: str | Path,
        *,
        include_globs: tuple[str, ...] = (),
        exclude_globs: tuple[str, ...] = (),
        max_file_size: int | None = None,
        max_total_size: int | None = None,
        timeout_seconds: float | None = None,
    ) -> RepoIndex:
        """Index a resource by walking its file tree.

        Replaces any existing index (full re-index).  For incremental
        refresh use :meth:`refresh_index`.

        Raises:
            FileNotFoundError: If *root_path* does not exist.
            ValueError: If *resource_id* is empty or not a ULID.
        """
        self._validate_resource_id(resource_id)
        if timeout_seconds is not None and timeout_seconds <= 0:
            raise ValueError(f"timeout_seconds must be positive, got {timeout_seconds}")

        root = Path(root_path)
        if not root.exists():
            raise FileNotFoundError(f"Resource root path does not exist: {root}")
        if not root.is_dir():
            raise ValueError(f"Resource root path is not a directory: {root}")

        index_id = str(ULID())

        # Check whether a previous *good* (READY) index exists.  If so,
        # we do NOT overwrite it with an INDEXING placeholder — that
        # would destroy the previous index if the walk fails (NEW-1).
        # Stale INDEXING or ERROR rows from prior crashes are treated
        # as "no good index" and re-initialised (F11/N1 fix).
        existing_index = self.get_index(resource_id)
        has_good_index = (
            existing_index is not None
            and existing_index.metadata.status == IndexStatus.READY
        )
        if not has_good_index:
            persist_status(
                self._session_factory,
                index_id=index_id,
                resource_id=resource_id,
                status=IndexStatus.INDEXING,
            )

        logger.info(
            "Indexing resource",
            extra={
                "resource_id": resource_id,
                "root_path": str(root),
                "include_globs": include_globs,
                "exclude_globs": exclude_globs,
                "timeout_seconds": timeout_seconds,
            },
        )

        try:
            file_records = walk_and_index(
                root=root,
                include_globs=include_globs,
                exclude_globs=exclude_globs,
                max_file_size=max_file_size,
                max_total_size=max_total_size,
                timeout_seconds=timeout_seconds,
            )
        except Exception as exc:
            if not has_good_index:
                try:
                    persist_status(
                        self._session_factory,
                        index_id=index_id,
                        resource_id=resource_id,
                        status=IndexStatus.ERROR,
                        error_message=f"{type(exc).__name__}: {str(exc)[:200]}",
                    )
                except Exception:
                    logger.warning(
                        "Failed to persist ERROR status after walk failure",
                        extra={"resource_id": resource_id},
                    )
            else:
                logger.warning(
                    "Indexing failed; preserving previous good index",
                    extra={
                        "resource_id": resource_id,
                        "error": str(exc),
                    },
                )
            raise

        # Compute summary
        file_count = len(file_records)
        token_estimate = sum(fr.token_count for fr in file_records)
        primary_language = detect_primary_language(file_records)

        now = datetime.now(tz=UTC)

        metadata = IndexMetadata(
            index_id=index_id,
            resource_id=resource_id,
            indexed_at=now,
            file_count=file_count,
            token_estimate=token_estimate,
            primary_language=primary_language,
            status=IndexStatus.READY,
        )

        repo_index = RepoIndex(metadata=metadata, files=tuple(file_records))

        # Persist (replace existing if any).
        try:
            persist_index(self._session_factory, repo_index)
        except Exception as exc:
            if not has_good_index:
                try:
                    persist_status(
                        self._session_factory,
                        index_id=index_id,
                        resource_id=resource_id,
                        status=IndexStatus.ERROR,
                        error_message=(
                            f"persist failed: {type(exc).__name__}: {str(exc)[:200]}"
                        ),
                    )
                except Exception:
                    logger.warning(
                        "Failed to persist ERROR status after persist failure",
                        extra={"resource_id": resource_id},
                    )
            raise

        logger.info(
            "Indexing complete",
            extra={
                "resource_id": resource_id,
                "file_count": file_count,
                "token_estimate": token_estimate,
                "primary_language": primary_language,
            },
        )
        # Emit RESOURCE_INDEXED event
        if self._event_bus is not None:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.RESOURCE_INDEXED,
                        details={
                            "resource_id": resource_id,
                            "file_count": file_count,
                            "token_estimate": token_estimate,
                        },
                    )
                )
            except Exception:
                logger.warning(
                    "event_bus_emit_failed: RESOURCE_INDEXED for %s",
                    resource_id,
                )

        return repo_index

    @_serialize_on_resource
    def refresh_index(
        self,
        resource_id: str,
        root_path: str | Path,
        *,
        include_globs: tuple[str, ...] = (),
        exclude_globs: tuple[str, ...] = (),
        max_file_size: int | None = None,
        max_total_size: int | None = None,
        timeout_seconds: float | None = None,
    ) -> RepoIndex:
        """Incrementally refresh an existing index.

        Changed or new files are reprocessed, deleted files are dropped, and a
        missing prior index falls back to a full index.
        """
        self._validate_resource_id(resource_id)
        if timeout_seconds is not None and timeout_seconds <= 0:
            raise ValueError(f"timeout_seconds must be positive, got {timeout_seconds}")

        existing = self.get_index(resource_id)
        if existing is None:
            return self.index_resource(
                resource_id,
                root_path,
                include_globs=include_globs,
                exclude_globs=exclude_globs,
                max_file_size=max_file_size,
                max_total_size=max_total_size,
                timeout_seconds=timeout_seconds,
            )

        root = Path(root_path)
        if not root.exists():
            raise FileNotFoundError(f"Resource root path does not exist: {root}")
        if not root.is_dir():
            raise ValueError(f"Resource root path is not a directory: {root}")

        logger.info(
            "Refreshing index incrementally",
            extra={"resource_id": resource_id},
        )

        try:
            current_files = walk_and_index(
                root=root,
                include_globs=include_globs,
                exclude_globs=exclude_globs,
                max_file_size=max_file_size,
                max_total_size=max_total_size,
                timeout_seconds=timeout_seconds,
            )
        except Exception as exc:
            logger.warning(
                "Incremental refresh failed; preserving previous good index",
                extra={
                    "resource_id": resource_id,
                    "error": str(exc),
                },
            )
            raise

        # Merge: reuse existing records for unchanged files.
        existing_by_path: dict[str, FileRecord] = {fr.path: fr for fr in existing.files}
        current_paths: set[str] = set()
        merged: list[FileRecord] = []
        changed_count = 0
        for new_fr in current_files:
            current_paths.add(new_fr.path)
            old_record = existing_by_path.get(new_fr.path)
            if (
                old_record is not None
                and old_record.content_hash == new_fr.content_hash
            ):
                merged.append(old_record)
            else:
                merged.append(new_fr)
                changed_count += 1

        # Count files present in old index but absent on disk (N12 fix).
        deleted_count = sum(1 for p in existing_by_path if p not in current_paths)

        file_count = len(merged)
        token_estimate = sum(fr.token_count for fr in merged)
        primary_language = detect_primary_language(merged)

        now = datetime.now(tz=UTC)

        metadata = IndexMetadata(
            index_id=existing.metadata.index_id,
            resource_id=resource_id,
            indexed_at=now,
            file_count=file_count,
            token_estimate=token_estimate,
            primary_language=primary_language,
            status=IndexStatus.READY,
        )

        repo_index = RepoIndex(metadata=metadata, files=tuple(merged))

        # Persist with error handling (G2 fix).
        try:
            persist_index(self._session_factory, repo_index)
        except Exception as exc:
            logger.warning(
                "Failed to persist refreshed index; preserving previous",
                extra={
                    "resource_id": resource_id,
                    "error": str(exc),
                },
            )
            raise

        logger.info(
            "Incremental refresh complete",
            extra={
                "resource_id": resource_id,
                "file_count": file_count,
                "changed_count": changed_count,
                "deleted_count": deleted_count,
            },
        )

        return repo_index

    def get_index(self, resource_id: str) -> RepoIndex | None:
        """Retrieve the persisted index for a resource.

        Args:
            resource_id: ULID of the resource.

        Returns:
            The :class:`RepoIndex` if it exists, or ``None``.

        Raises:
            ValueError: If *resource_id* is empty.
        """
        self._validate_resource_id(resource_id)
        return load_index(self._session_factory, resource_id)

    def get_index_status(self, resource_id: str) -> IndexMetadata | None:
        """Retrieve only the index metadata (no file records).

        Lightweight query for status display (e.g., ``agents project show``).

        Args:
            resource_id: ULID of the resource.

        Returns:
            The :class:`IndexMetadata` if it exists, or ``None``.

        Raises:
            ValueError: If *resource_id* is empty.
        """
        self._validate_resource_id(resource_id)
        return load_index_status(self._session_factory, resource_id)

    @_serialize_on_resource
    def remove_index(self, resource_id: str) -> bool:
        """Remove the index for a resource.

        Args:
            resource_id: ULID of the resource.

        Returns:
            ``True`` if an index was removed, ``False`` if none existed.

        Raises:
            ValueError: If *resource_id* is empty.
        """
        self._validate_resource_id(resource_id)
        session = self._session_factory()
        try:
            row = (
                session.query(RepoIndexModel).filter_by(resource_id=resource_id).first()
            )
            if row is None:
                return False

            session.query(IndexedFileModel).filter_by(index_id=row.index_id).delete()
            session.delete(row)
            session.commit()

            # Evict per-resource lock — no further ops expected.
            with self._locks_guard:
                self._resource_locks.pop(resource_id, None)
            logger.info(
                "Removed index",
                extra={
                    "resource_id": resource_id,
                    "index_id": row.index_id,
                },
            )
            return True
        except Exception:
            session.rollback()
            raise
        finally:
            session.close()

    def cleanup_stale_indexing(self) -> int:
        """Remove orphan ``INDEXING`` rows left by crashed processes.

        Should be called at application startup.  Any row with
        ``status='indexing'`` is a remnant of an incomplete index
        operation (the process crashed before writing ``READY`` or
        ``ERROR``).

        Returns:
            Number of stale rows removed.
        """
        session = self._session_factory()
        try:
            stale_rows = (
                session.query(RepoIndexModel)
                .filter_by(status=IndexStatus.INDEXING.value)
                .all()
            )
            count = len(stale_rows)
            for row in stale_rows:
                session.query(IndexedFileModel).filter_by(
                    index_id=row.index_id
                ).delete()
                session.delete(row)
            if count:
                session.commit()
                logger.info(
                    "Cleaned up stale INDEXING rows",
                    extra={"count": count},
                )
            return count
        except Exception:
            session.rollback()
            raise
        finally:
            session.close()
