"""RAG (Retrieval-Augmented Generation) search endpoints.

Provides semantic search over crawled documentation:
- Vector similarity search on document chunks
- Source-filtered search
- Code example search
- Full page retrieval

All queries are scoped to the user's organization for multi-tenant security.
"""

import hashlib
import re
from uuid import UUID

import structlog
from fastapi import APIRouter, Depends, HTTPException

from sibyl.api.schemas import (
    CodeExampleRequest,
    CodeExampleResponse,
    CodeExampleResult,
    CrawlDocumentResponse,
    DocumentRelatedEntitiesResponse,
    DocumentRelatedEntity,
    DocumentUpdateRequest,
    FullPageResponse,
    RAGChunkResult,
    RAGPageResult,
    RAGSearchRequest,
    RAGSearchResponse,
    SourcePagesResponse,
)
from sibyl.auth.context import AuthContext
from sibyl.auth.dependencies import get_auth_context, require_org_role
from sibyl.auth.errors import NoOrgContextError
from sibyl.crawler.embedder import embed_text
from sibyl.persistence.auth_runtime import list_accessible_project_graph_ids
from sibyl.persistence.content_runtime import (
    get_content_read_session,
    get_crawled_document_for_org,
    get_document_by_url_for_org,
    get_org_crawl_source,
    hybrid_search_chunks,
    list_rag_source_documents_page as list_source_documents_page,
    save_crawled_document_record,
    search_code_example_chunks,
    search_rag_chunks,
)
from sibyl_core.auth import OrganizationRole

log = structlog.get_logger()


async def get_entity_graph_runtime(group_id: str):
    from sibyl.persistence.graph_runtime import get_entity_graph_runtime as service

    return await service(group_id)


router = APIRouter(
    prefix="/rag",
    tags=["rag"],
    dependencies=[
        Depends(
            require_org_role(
                OrganizationRole.OWNER,
                OrganizationRole.ADMIN,
                OrganizationRole.MEMBER,
                OrganizationRole.VIEWER,
            )
        ),
    ],
)


def _parse_uuid_or_400(value: str, field_name: str) -> UUID:
    """Parse UUID input and raise a user-facing 400 on invalid format."""
    try:
        return UUID(value)
    except ValueError:
        raise HTTPException(
            status_code=400, detail=f"Invalid {field_name} format: {value}"
        ) from None


# =============================================================================
# RAG Search - Vector Similarity on Chunks
# =============================================================================


@router.post("/search", response_model=RAGSearchResponse)
async def rag_search(
    request: RAGSearchRequest,
    auth: AuthContext = Depends(get_auth_context),
) -> RAGSearchResponse:
    """Semantic search over document chunks.

    Uses the active content search runtime for similarity search with optional source filtering.
    Supports returning chunks or grouping by page.
    Results are scoped to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    # Generate query embedding
    try:
        query_embedding = await embed_text(request.query)
    except Exception as e:
        log.exception("Failed to generate query embedding", error=str(e))
        raise HTTPException(status_code=500, detail="Failed to generate query embedding") from e

    async with get_content_read_session() as session:
        source_filter_name = None
        if request.source_id:
            source_uuid = _parse_uuid_or_400(request.source_id, "source ID")
            source_filter_name = request.source_id
        elif request.source_name:
            source_uuid = None
            source_filter_name = request.source_name
        else:
            source_uuid = None

        rows = await search_rag_chunks(
            session,
            query_embedding=query_embedding,
            organization_id=auth.organization_id,
            source_id=source_uuid,
            source_name=request.source_name if source_uuid is None else None,
            similarity_threshold=request.similarity_threshold,
            match_count=request.match_count,
        )

        if request.return_mode == "pages":
            # Group by document, return best chunk per doc
            page_results: dict[str, RAGPageResult] = {}
            for chunk, doc, source_name, source_id, similarity in rows:
                doc_id = str(doc.id)
                if (
                    doc_id not in page_results
                    or similarity > page_results[doc_id].best_chunk_similarity
                ):
                    page_results[doc_id] = RAGPageResult(
                        document_id=doc_id,
                        source_id=str(source_id),
                        source_name=source_name,
                        url=doc.url,
                        title=doc.title,
                        content=doc.content,
                        word_count=doc.word_count,
                        has_code=doc.has_code,
                        headings=doc.headings or [],
                        code_languages=doc.code_languages or [],
                        best_chunk_similarity=similarity,
                    )
            results: list[RAGChunkResult | RAGPageResult] = list(page_results.values())
        else:
            # Return individual chunks
            results = [
                RAGChunkResult(
                    chunk_id=str(chunk.id),
                    document_id=str(doc.id),
                    source_id=str(source_id),
                    source_name=source_name,
                    url=doc.url,
                    title=doc.title,
                    content=chunk.content,
                    context=chunk.context if request.include_context else None,
                    similarity=similarity,
                    chunk_type=chunk.chunk_type.value
                    if hasattr(chunk.chunk_type, "value")
                    else str(chunk.chunk_type),
                    chunk_index=chunk.chunk_index,
                    heading_path=chunk.heading_path or [],
                    language=chunk.language,
                )
                for chunk, doc, source_name, source_id, similarity in rows
            ]

    log.debug(
        "RAG search completed",
        query=request.query[:50],
        results=len(results),
        mode=request.return_mode,
    )

    return RAGSearchResponse(
        results=results,
        total=len(results),
        query=request.query,
        source_filter=source_filter_name,
        return_mode=request.return_mode,
    )


# =============================================================================
# Code Example Search
# =============================================================================


@router.post("/code-examples", response_model=CodeExampleResponse)
async def search_code_examples(
    request: CodeExampleRequest,
    auth: AuthContext = Depends(get_auth_context),
) -> CodeExampleResponse:
    """Search for code examples with optional language filtering.

    Only searches chunks with chunk_type = 'code'.
    Results are scoped to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    # Generate query embedding
    try:
        query_embedding = await embed_text(request.query)
    except Exception as e:
        log.exception("Failed to generate query embedding", error=str(e))
        raise HTTPException(status_code=500, detail="Failed to generate query embedding") from e

    async with get_content_read_session() as session:
        if request.source_id:
            source_uuid = _parse_uuid_or_400(request.source_id, "source ID")
        else:
            source_uuid = None

        rows = await search_code_example_chunks(
            session,
            query_embedding=query_embedding,
            organization_id=auth.organization_id,
            match_count=request.match_count,
            source_id=source_uuid,
            language=request.language,
        )

        examples = [
            CodeExampleResult(
                chunk_id=str(chunk.id),
                document_id=str(doc.id),
                source_id=str(source_id),
                source_name=source_name,
                url=doc.url,
                title=doc.title,
                code=chunk.content,
                context=chunk.context,
                language=chunk.language,
                similarity=similarity,
                heading_path=chunk.heading_path or [],
            )
            for chunk, doc, source_id, source_name, similarity in rows
        ]

    log.debug(
        "Code example search completed",
        query=request.query[:50],
        language=request.language,
        results=len(examples),
    )

    return CodeExampleResponse(
        examples=examples,
        total=len(examples),
        query=request.query,
        language_filter=request.language,
    )


# =============================================================================
# Page Listing and Full Page Retrieval
# =============================================================================


@router.get("/sources/{source_id}/pages", response_model=SourcePagesResponse)
async def list_source_pages(
    source_id: str,
    limit: int = 50,
    offset: int = 0,
    has_code: bool | None = None,
    is_index: bool | None = None,
    auth: AuthContext = Depends(get_auth_context),
) -> SourcePagesResponse:
    """List all pages for a source with optional filtering.

    Source must belong to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    try:
        source_uuid = UUID(source_id)
    except ValueError:
        raise HTTPException(
            status_code=400, detail=f"Invalid source ID format: {source_id}"
        ) from None

    organization_uuid = UUID(auth.organization_id)

    async with get_content_read_session() as session:
        source = await get_org_crawl_source(
            session,
            source_id=source_uuid,
            organization_id=organization_uuid,
        )
        if source is None:
            raise HTTPException(status_code=404, detail=f"Source not found: {source_id}")

        documents, total = await list_source_documents_page(
            session,
            source_id=source_uuid,
            limit=limit,
            offset=offset,
            has_code=has_code,
            is_index=is_index,
        )

        pages = [
            CrawlDocumentResponse(
                id=str(doc.id),
                source_id=source_id,
                url=doc.url,
                title=doc.title,
                word_count=doc.word_count,
                has_code=doc.has_code,
                is_index=doc.is_index,
                depth=doc.depth,
                crawled_at=doc.crawled_at,
                headings=doc.headings or [],
                code_languages=doc.code_languages or [],
            )
            for doc in documents
        ]

    return SourcePagesResponse(
        source_id=source_id,
        source_name=source.name,
        pages=pages,
        total=total,
        has_more=offset + len(pages) < total,
    )


@router.get("/pages/{document_id}", response_model=FullPageResponse)
async def get_full_page(
    document_id: str,
    auth: AuthContext = Depends(get_auth_context),
) -> FullPageResponse:
    """Get full page content by document ID.

    Document's source must belong to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    try:
        doc_uuid = UUID(document_id)
    except ValueError:
        raise HTTPException(
            status_code=400, detail=f"Invalid document ID format: {document_id}"
        ) from None

    organization_uuid = UUID(auth.organization_id)

    async with get_content_read_session() as session:
        doc = await get_crawled_document_for_org(
            session,
            document_id=doc_uuid,
            organization_id=organization_uuid,
        )
        if doc is None:
            raise HTTPException(status_code=404, detail=f"Document not found: {document_id}")

        source = await get_org_crawl_source(
            session,
            source_id=doc.source_id,
            organization_id=organization_uuid,
        )
        if source is None:
            raise HTTPException(status_code=404, detail=f"Document not found: {document_id}")
        source_name = source.name

    return FullPageResponse(
        document_id=str(doc.id),
        source_id=str(doc.source_id),
        source_name=source_name,
        url=doc.url,
        title=doc.title,
        content=doc.content,
        raw_content=doc.raw_content if len(doc.raw_content) < 100000 else None,
        word_count=doc.word_count,
        token_count=doc.token_count,
        has_code=doc.has_code,
        headings=doc.headings or [],
        code_languages=doc.code_languages or [],
        links=doc.links or [],
        crawled_at=doc.crawled_at,
    )


@router.get("/pages/by-url")
async def get_page_by_url(
    url: str,
    auth: AuthContext = Depends(get_auth_context),
) -> FullPageResponse:
    """Get full page content by URL.

    Document's source must belong to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    organization_uuid = UUID(auth.organization_id)

    async with get_content_read_session() as session:
        doc = await get_document_by_url_for_org(
            session,
            url=url,
            organization_id=organization_uuid,
        )

        if not doc:
            raise HTTPException(status_code=404, detail=f"Document not found for URL: {url}")

        source = await get_org_crawl_source(
            session,
            source_id=doc.source_id,
            organization_id=organization_uuid,
        )
        source_name = source.name if source else "Unknown"

    return FullPageResponse(
        document_id=str(doc.id),
        source_id=str(doc.source_id),
        source_name=source_name,
        url=doc.url,
        title=doc.title,
        content=doc.content,
        raw_content=doc.raw_content if len(doc.raw_content) < 100000 else None,
        word_count=doc.word_count,
        token_count=doc.token_count,
        has_code=doc.has_code,
        headings=doc.headings or [],
        code_languages=doc.code_languages or [],
        links=doc.links or [],
        crawled_at=doc.crawled_at,
    )


# =============================================================================
# Hybrid Search (Vector + Full-Text)
# =============================================================================


@router.post("/hybrid-search", response_model=RAGSearchResponse)
async def hybrid_search(
    request: RAGSearchRequest,
    auth: AuthContext = Depends(get_auth_context),
) -> RAGSearchResponse:
    """Hybrid search combining vector similarity and full-text search.

    Uses RRF (Reciprocal Rank Fusion) to combine results from:
    - Vector similarity
    - Keyword search

    Results are scoped to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    # Generate query embedding
    try:
        query_embedding = await embed_text(request.query)
    except Exception as e:
        log.exception("Failed to generate query embedding", error=str(e))
        raise HTTPException(status_code=500, detail="Failed to generate query embedding") from e

    async with get_content_read_session() as session:
        source_filter_name = None
        if request.source_id:
            source_uuid = _parse_uuid_or_400(request.source_id, "source ID")
            source_filter_name = request.source_id
        elif request.source_name:
            source_uuid = None
            source_filter_name = request.source_name
        else:
            source_uuid = None

        rows = await hybrid_search_chunks(
            session,
            query_text=request.query,
            query_embedding=query_embedding,
            organization_id=auth.organization_id,
            similarity_threshold=request.similarity_threshold,
            match_count=request.match_count,
            source_id=source_uuid,
            source_name=request.source_name if source_uuid is None else None,
        )

        results: list[RAGChunkResult | RAGPageResult] = [
            RAGChunkResult(
                chunk_id=str(chunk.id),
                document_id=str(doc.id),
                source_id=str(source_id),
                source_name=source_name,
                url=doc.url,
                title=doc.title,
                content=chunk.content,
                context=chunk.context if request.include_context else None,
                similarity=similarity,
                chunk_type=chunk.chunk_type.value
                if hasattr(chunk.chunk_type, "value")
                else str(chunk.chunk_type),
                chunk_index=chunk.chunk_index,
                heading_path=chunk.heading_path or [],
                language=chunk.language,
            )
            for chunk, doc, source_name, source_id, similarity, fts_rank in rows
        ]

    log.debug(
        "Hybrid search completed",
        query=request.query[:50],
        results=len(results),
    )

    return RAGSearchResponse(
        results=results,
        total=len(results),
        query=request.query,
        source_filter=source_filter_name,
        return_mode="chunks",
    )


# =============================================================================
# Document Update
# =============================================================================


def _extract_headings(content: str) -> list[str]:
    """Extract markdown headings from content."""
    headings: list[str] = []
    for line in content.split("\n"):
        match = re.match(r"^(#{1,6})\s+(.+)$", line.strip())
        if match:
            headings.append(match.group(2).strip())
    return headings


def _detect_code_presence(content: str) -> bool:
    """Check if content contains code blocks."""
    return "```" in content or content.count("    ") > 5


def _estimate_token_count(content: str) -> int:
    """Rough token estimate (~4 chars per token)."""
    return len(content) // 4


@router.patch("/pages/{document_id}", response_model=FullPageResponse)
async def update_document(
    document_id: str,
    request: DocumentUpdateRequest,
    auth: AuthContext = Depends(get_auth_context),
) -> FullPageResponse:
    """Update a document's title and/or content.

    When content is updated, recalculates derived fields:
    - word_count, token_count, content_hash
    - has_code, headings

    Document's source must belong to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    if request.title is None and request.content is None:
        raise HTTPException(
            status_code=400, detail="At least one of title or content must be provided"
        )

    try:
        doc_uuid = UUID(document_id)
    except ValueError:
        raise HTTPException(
            status_code=400, detail=f"Invalid document ID format: {document_id}"
        ) from None

    organization_uuid = UUID(auth.organization_id)

    async with get_content_read_session() as session:
        doc = await get_crawled_document_for_org(
            session,
            document_id=doc_uuid,
            organization_id=organization_uuid,
        )
        if not doc:
            raise HTTPException(status_code=404, detail=f"Document not found: {document_id}")

        source = await get_org_crawl_source(
            session,
            source_id=doc.source_id,
            organization_id=organization_uuid,
        )
        if source is None:
            raise HTTPException(status_code=404, detail=f"Document not found: {document_id}")

        # Update title if provided
        if request.title is not None:
            doc.title = request.title

        # Update content and recalculate derived fields
        if request.content is not None:
            doc.content = request.content
            doc.word_count = len(request.content.split())
            doc.token_count = _estimate_token_count(request.content)
            doc.content_hash = hashlib.sha256(request.content.encode()).hexdigest()
            doc.has_code = _detect_code_presence(request.content)
            doc.headings = _extract_headings(request.content)

        doc = await save_crawled_document_record(session, document=doc)

        # Reuse source from ownership check
        source_name = source.name

    log.info(
        "Document updated",
        document_id=document_id,
        title_updated=request.title is not None,
        content_updated=request.content is not None,
    )

    return FullPageResponse(
        document_id=str(doc.id),
        source_id=str(doc.source_id),
        source_name=source_name,
        url=doc.url,
        title=doc.title,
        content=doc.content,
        raw_content=doc.raw_content if len(doc.raw_content) < 100000 else None,
        word_count=doc.word_count,
        token_count=doc.token_count,
        has_code=doc.has_code,
        headings=doc.headings or [],
        code_languages=doc.code_languages or [],
        links=doc.links or [],
        crawled_at=doc.crawled_at,
    )


# =============================================================================
# Document Related Entities
# =============================================================================


@router.get("/pages/{document_id}/entities", response_model=DocumentRelatedEntitiesResponse)
async def get_document_related_entities(
    document_id: str,
    auth: AuthContext = Depends(get_auth_context),
) -> DocumentRelatedEntitiesResponse:
    """Get knowledge graph entities related to a document.

    Uses semantic search to find entities (tasks, patterns, episodes, etc.)
    that are relevant to this document's content based on its title.
    Document's source must belong to the user's organization.
    """
    if not auth.organization_id:
        raise NoOrgContextError("access this resource")

    try:
        doc_uuid = UUID(document_id)
    except ValueError:
        raise HTTPException(
            status_code=400, detail=f"Invalid document ID format: {document_id}"
        ) from None

    accessible_projects: set[str] | None = None

    organization_uuid = UUID(auth.organization_id)

    async with get_content_read_session() as session:
        doc = await get_crawled_document_for_org(
            session,
            document_id=doc_uuid,
            organization_id=organization_uuid,
        )
        if doc is None:
            raise HTTPException(status_code=404, detail=f"Document not found: {document_id}")

        doc_title = doc.title

    accessible_projects = await list_accessible_project_graph_ids(auth)

    # Search the knowledge graph using document title as query
    entities: list[DocumentRelatedEntity] = []
    try:
        entity_runtime = await get_entity_graph_runtime(auth.organization_id)

        # Semantic search using document title
        search_results = await entity_runtime.entity_manager.search(
            query=doc_title,
            limit=15,
        )

        for entity, score in search_results:
            # Skip very low relevance matches
            if score < 0.1:
                continue

            # Enforce project RBAC for graph entities (unassigned is visible).
            project_id = (entity.metadata or {}).get("project_id")
            if (
                accessible_projects is not None
                and project_id is not None
                and project_id not in accessible_projects
            ):
                continue

            entities.append(
                DocumentRelatedEntity(
                    id=entity.id,
                    name=entity.name,
                    entity_type=entity.entity_type.value,
                    description=entity.description or "",
                    chunk_count=int(score * 100),  # Use score as relevance indicator
                )
            )

    except Exception as e:
        log.warning("graph_search_failed", error=str(e), document_id=document_id)
        # Return empty if graph is unavailable
        return DocumentRelatedEntitiesResponse(
            document_id=document_id,
            entities=[],
            total=0,
        )

    log.debug(
        "document_entities_found", document_id=document_id, title=doc_title, count=len(entities)
    )

    return DocumentRelatedEntitiesResponse(
        document_id=document_id,
        entities=entities,
        total=len(entities),
    )
