"""Search-related MCP tools: hybrid ``search`` and ``get_chunk`` for full text.

Authorization for every call is expressed as ``resolved_libraries`` —
the Library UID list the auth middleware attached to the FastMCP
``Context``. Tools never consult token claim shapes; they read
:func:`mcp_server.context.get_mcp_resolved_libraries` and pass the
result straight through to the search layer.

See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3 for the unified
auth model.
"""

from __future__ import annotations

from dataclasses import asdict
from typing import Any

from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.files.storage import default_storage
from fastmcp.server.context import Context

from ..context import get_mcp_resolved_libraries, get_mcp_user
from ..metrics import record_tool_call

DEFAULT_SEARCH_TYPES = ["vector", "fulltext", "graph"]


def register_search_tools(mcp):
    @mcp.tool
    async def search(
        query: str,
        library_uid: str | None = None,
        library_type: str | None = None,
        collection_uid: str | None = None,
        limit: int = 20,
        rerank: bool = True,
        include_images: bool = True,
        search_types: list[str] | None = None,
        ctx: Context | None = None,
    ) -> dict[str, Any]:
        """Hybrid retrieval over Mnemosyne: vector + full-text + concept-graph
        candidates fused by RRF and optionally re-ranked by Synesis.

        Filters: library_uid (exact library), library_type (one of fiction,
        nonfiction, technical, music, film, art, journal, business, finance),
        or collection_uid.
        Set rerank=False to skip re-ranking. search_types defaults to all three.

        Returns ranked candidates with chunk_uid (use get_chunk for full text),
        item_uid/item_title for citation, library_type, text_preview (~500 chars),
        score, and source. Also returns matching images when include_images=True.
        """
        types = search_types or DEFAULT_SEARCH_TYPES
        resolved_libraries = await get_mcp_resolved_libraries(ctx)
        with record_tool_call("search"):
            user = await get_mcp_user(ctx)
            return await sync_to_async(_run_search, thread_sensitive=True)(
                user=user,
                query=query,
                library_uid=library_uid,
                library_type=library_type,
                collection_uid=collection_uid,
                resolved_libraries=resolved_libraries,
                limit=limit,
                rerank=rerank,
                include_images=include_images,
                search_types=types,
            )

    @mcp.tool
    async def get_chunk(
        chunk_uid: str,
        ctx: Context | None = None,
    ) -> dict[str, Any]:
        """Fetch the full text of a chunk by its uid (typically obtained from `search`).

        Returns the chunk text plus parent item context: chunk_uid, chunk_index,
        item_uid, item_title, library_type, text. Use this when the 500-character
        text_preview from `search` isn't enough.
        """
        resolved_libraries = await get_mcp_resolved_libraries(ctx)
        with record_tool_call("get_chunk"):
            return await sync_to_async(_load_chunk, thread_sensitive=True)(
                chunk_uid, resolved_libraries
            )


def _run_search(
    *,
    user,
    query,
    library_uid,
    library_type,
    collection_uid,
    resolved_libraries,
    limit,
    rerank,
    include_images,
    search_types,
) -> dict[str, Any]:
    from library.services.search import SearchRequest, SearchService

    req = SearchRequest(
        query=query,
        library_uid=library_uid,
        library_type=library_type,
        collection_uid=collection_uid,
        resolved_libraries=resolved_libraries,
        search_types=search_types,
        limit=limit,
        vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),
        fulltext_top_k=getattr(settings, "SEARCH_FULLTEXT_TOP_K", 30),
        rerank=rerank,
        include_images=include_images,
    )
    service = SearchService(user=user)
    response = service.search(req)
    return {
        "query": response.query,
        "candidates": [asdict(c) for c in response.candidates],
        "images": [asdict(i) for i in response.images],
        "total_candidates": response.total_candidates,
        "search_time_ms": response.search_time_ms,
        "reranker_used": response.reranker_used,
        "reranker_model": response.reranker_model,
        "search_types_used": response.search_types_used,
    }


def _load_chunk(
    chunk_uid: str,
    resolved_libraries: list[str] | None,
) -> dict[str, Any]:
    """Load a single chunk's full text, subject to the caller's library scope.

    ``resolved_libraries`` is enforced at the Cypher layer: ``None`` allows
    any library (only used for trusted in-process callers — MCP middleware
    never passes ``None`` for authenticated clients), ``[]`` matches zero
    rows (fail-closed), a non-empty list restricts to those UIDs.
    """
    from neomodel import db

    rows, _ = db.cypher_query(
        "MATCH (l:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->"
        "(i:Item)-[:HAS_CHUNK]->(c:Chunk {uid: $uid}) "
        "WHERE ($resolved_libraries IS NULL OR l.uid IN $resolved_libraries) "
        "RETURN c.uid, c.chunk_index, c.chunk_s3_key, "
        "i.uid, i.title, l.library_type LIMIT 1",
        {
            "uid": chunk_uid,
            "resolved_libraries": resolved_libraries,
        },
    )
    if not rows:
        raise ValueError(f"Chunk not found: {chunk_uid}")

    c_uid, chunk_index, chunk_s3_key, item_uid, item_title, library_type = rows[0]

    text = ""
    if chunk_s3_key:
        with default_storage.open(chunk_s3_key, "rb") as fh:
            text = fh.read().decode("utf-8", errors="replace")

    return {
        "chunk_uid": c_uid,
        "chunk_index": chunk_index,
        "item_uid": item_uid,
        "item_title": item_title,
        "library_type": library_type,
        "text": text,
    }
