"""Protocols and default implementations for UKO Indexer.

Defines the dependency-injection protocols used by :class:`UKOIndexer`:

- :class:`ContentReader` — reads resource content (decouples from FS)
- :class:`IndexLifecycleHook` — callbacks for index lifecycle events
- :class:`DefaultLifecycleHook` — structlog-based default hook
- :class:`LocationContentReader` — filesystem-based default reader

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

from __future__ import annotations

import os
import stat
from pathlib import Path
from typing import Protocol, runtime_checkable

import structlog

from cleveragents.domain.models.acms.provenance import IndexResult
from cleveragents.domain.models.core.resource import Resource

logger = structlog.get_logger(__name__)


# ---------------------------------------------------------------------------
# Content reader protocol (DI — decouples UKOIndexer from filesystem)
# ---------------------------------------------------------------------------


@runtime_checkable
class ContentReader(Protocol):
    """Protocol for reading resource content.

    Implementations may read from the local filesystem, a sandbox,
    a VCS checkout, or an in-memory buffer.
    """

    def read_content(self, resource: Resource) -> str:
        """Read the textual content of *resource*.

        Args:
            resource: The resource to read.

        Returns:
            The resource content as a string.

        Raises:
            OSError: If the resource cannot be read.
            ValueError: If the resource location is invalid, the
                resolved path escapes the base directory, or content
                exceeds the maximum size.
        """
        raise NotImplementedError


# ---------------------------------------------------------------------------
# Index lifecycle hook protocol
# ---------------------------------------------------------------------------


@runtime_checkable
class IndexLifecycleHook(Protocol):
    """Callback protocol for index lifecycle events.

    Implementations can react to indexing events for logging,
    metrics, or triggering downstream processing.
    """

    def on_indexed(self, result: IndexResult) -> None:
        """Called after a resource is successfully indexed.

        Args:
            result: Summary of the indexing operation.
        """
        raise NotImplementedError

    def on_removed(self, resource_id: str, project: str) -> None:
        """Called after a resource is removed from indices.

        Args:
            resource_id: ULID of the removed resource.
            project: Namespaced project name.
        """
        raise NotImplementedError

    def on_error(
        self,
        resource_id: str,
        error: str,
    ) -> None:
        """Called when an indexing operation encounters an error.

        .. note::

            **Design choice**: ``on_error`` is only fired for errors
            that abort the *pipeline* (e.g. content-read or analyzer
            failures).  Individual backend failures (graph, text,
            vector) are recorded in :attr:`IndexResult.errors` but do
            **not** trigger ``on_error``, because the pipeline
            continues with graceful degradation.

        Args:
            resource_id: ULID of the resource.
            error: Error description.
        """
        raise NotImplementedError


class DefaultLifecycleHook:
    """Default lifecycle hook that logs events via structlog."""

    def on_indexed(self, result: IndexResult) -> None:
        """Log successful indexing."""
        logger.info(
            "indexer.resource_indexed",
            resource_id=result.resource_id,
            triple_count=result.triple_count,
            analyzer=result.analyzer_domain,
        )

    def on_removed(self, resource_id: str, project: str) -> None:
        """Log resource removal."""
        logger.info(
            "indexer.resource_removed",
            resource_id=resource_id,
            project=project,
        )

    def on_error(self, resource_id: str, error: str) -> None:
        """Log indexing error."""
        logger.warning(
            "indexer.error",
            resource_id=resource_id,
            error=error,
        )


# ---------------------------------------------------------------------------
# LocationContentReader — default filesystem reader
# ---------------------------------------------------------------------------


#: Default maximum content size in **characters** (≈10M characters).
#: For multi-byte UTF-8 content the actual RAM footprint may exceed
#: 10 MB.  Use a smaller value for CJK / emoji-heavy corpora.
DEFAULT_MAX_CONTENT_SIZE: int = 10 * 1024 * 1024


class LocationContentReader:
    """Reads resource content from the filesystem via ``resource.location``.

    This is the default :class:`ContentReader` implementation.  It reads
    the file at ``resource.location`` and returns its text content.

    Security: validates the resolved (symlink-followed) path against
    a configurable *base_dir*.  Rejects paths that escape the base
    directory via symlinks, ``..`` components, or absolute paths.
    Enforces a maximum content size (measured in **characters**, not
    bytes) to prevent OOM on unexpectedly large files.  Rejects
    non-regular files (named pipes, device files).
    """

    def __init__(
        self,
        *,
        max_content_size: int = DEFAULT_MAX_CONTENT_SIZE,
        base_dir: Path | None = None,
    ) -> None:
        if max_content_size < 1:
            raise ValueError("max_content_size must be positive")
        self._max_content_size = max_content_size
        self._base_dir = base_dir.resolve() if base_dir is not None else None
        if self._base_dir is None:
            logger.warning(
                "content_reader.no_base_dir",
                hint="No base_dir set — reader can access any file the "
                "process can read. Set base_dir in production.",
            )

    def read_content(self, resource: Resource) -> str:
        """Read file content from ``resource.location``.

        Args:
            resource: Must have a non-``None`` ``location``.

        Returns:
            File content as a UTF-8 string.

        Raises:
            ValueError: If ``resource.location`` is ``None``, the
                resolved path escapes the base directory, or content
                exceeds max size.
            OSError: If the file cannot be read.
        """
        if resource.location is None:
            raise ValueError(f"Resource {resource.resource_id} has no location")

        raw = Path(resource.location)
        # Reject path traversal attempts (pre-resolution check)
        if ".." in raw.parts:
            raise ValueError(f"Path traversal rejected: {resource.location}")

        resolved = raw.resolve()

        # Validate resolved path is under base_dir (post-resolution check
        # catches symlink escapes and absolute paths).
        if self._base_dir is not None:
            try:
                resolved.relative_to(self._base_dir)
            except ValueError:
                raise ValueError(
                    f"Path escapes base directory: {resource.location}"
                ) from None

        # Open with O_NONBLOCK then fstat — eliminates the TOCTOU
        # window between a pre-open is_file() check and the actual
        # open(), and avoids blocking on FIFOs/named pipes.
        fd = -1
        try:
            fd = os.open(str(resolved), os.O_RDONLY | os.O_NONBLOCK)
            mode = os.fstat(fd).st_mode
            if not stat.S_ISREG(mode):
                raise ValueError(f"Not a regular file: {resource.location}")
        finally:
            if fd >= 0:
                os.close(fd)
                fd = -1

        # Re-open in text mode for proper UTF-8 decoding (the raw fd
        # was opened non-blocking only for the fstat check).
        with open(resolved, encoding="utf-8") as fh:
            content = fh.read(self._max_content_size + 1)

        if len(content) > self._max_content_size:
            raise ValueError(
                f"Resource {resource.resource_id} exceeds max content "
                f"size ({self._max_content_size} characters)"
            )
        return content


# ---------------------------------------------------------------------------
# Protocol compliance assertions (Pyright static verification)
# ---------------------------------------------------------------------------

_assert_hook: type[IndexLifecycleHook] = DefaultLifecycleHook
_assert_reader: type[ContentReader] = LocationContentReader


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

__all__: list[str] = [
    "DEFAULT_MAX_CONTENT_SIZE",
    "ContentReader",
    "DefaultLifecycleHook",
    "IndexLifecycleHook",
    "LocationContentReader",
]
