"""Session fact endpoints — the "What we know" CRUD surface for a FlowPilot session.

All routes are sub-resources of `/ai-sessions/{session_id}`. Tenant isolation is
enforced by RLS on `session_facts.account_id`; a user from another account
literally cannot see or write facts for this session.

Editability rule (per FLOWPILOT-MIGRATION.md Section 7.3):
- `user_note` and `ai_synthesis` facts are editable at the card level.
- `question` and `diagnostic_check` facts are read-only at the card level —
  edit the source question/check instead. PATCH returns 403 for those.

Fact promotion writes always bump `ai_sessions.state_version` so the
resolution-note preview cache invalidates (Section 5.5).
"""
import logging
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.models.ai_session import AISession
from app.models.session_fact import SessionFact
from app.models.user import User
from app.schemas.session_fact import (
    SessionFactCreateRequest,
    SessionFactListResponse,
    SessionFactPromoteRequest,
    SessionFactResponse,
    SessionFactUpdateRequest,
)
from app.services.fact_synthesis_service import (
    FactSynthesisService,
    list_facts_for_session,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-facts"])

# Source types whose facts can be edited at the card level (Section 7.3).
_EDITABLE_SOURCE_TYPES = frozenset({"user_note", "ai_synthesis"})


def _to_response(fact: SessionFact) -> SessionFactResponse:
    """Wrap an ORM SessionFact in the response model with the editable flag."""
    return SessionFactResponse(
        id=fact.id,
        session_id=fact.session_id,
        text=fact.text,
        source_type=fact.source_type,  # type: ignore[arg-type]
        source_ref=fact.source_ref,
        source_summary=fact.source_summary,
        created_by=fact.created_by,
        created_at=fact.created_at,
        updated_at=fact.updated_at,
        editable=fact.source_type in _EDITABLE_SOURCE_TYPES,
    )


async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession:
    """Load the session via RLS-scoped SELECT. Returns 404 if missing/cross-tenant.

    Tenant isolation: RLS on `ai_sessions` filters by current account, so a
    cross-tenant access returns no rows and we 404 (rather than 403, which
    would leak the row's existence).
    """
    result = await db.execute(select(AISession).where(AISession.id == session_id))
    session = result.scalar_one_or_none()
    if session is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
    return session


async def _load_fact_or_404(
    db: AsyncSession, session_id: UUID, fact_id: UUID
) -> SessionFact:
    """Load a non-deleted fact for the session. 404 if missing or already deleted."""
    result = await db.execute(
        select(SessionFact).where(
            SessionFact.id == fact_id,
            SessionFact.session_id == session_id,
            SessionFact.deleted_at.is_(None),
        )
    )
    fact = result.scalar_one_or_none()
    if fact is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fact not found")
    return fact


# ── List ──

@router.get("/facts", response_model=SessionFactListResponse)
async def list_facts(
    session_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
    _: None = Depends(require_engineer_or_admin),
) -> SessionFactListResponse:
    """List facts for a session, oldest first."""
    await _load_session_or_404(db, session_id)
    facts = await list_facts_for_session(db, session_id)
    return SessionFactListResponse(facts=[_to_response(f) for f in facts])


# ── Create (manual user note) ──

@router.post("/facts", response_model=SessionFactResponse, status_code=201)
async def create_fact(
    session_id: UUID,
    body: SessionFactCreateRequest,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
    _: None = Depends(require_engineer_or_admin),
) -> SessionFactResponse:
    """Create a manual fact (the "+ Add a note" UI affordance).

    Always recorded as `source_type=user_note`. Source-typed creation goes
    through `/facts/promote` so the originating item ID is captured.
    """
    session = await _load_session_or_404(db, session_id)
    service = FactSynthesisService(db)
    try:
        fact = await service.create_fact(
            session_id=session.id,
            account_id=session.account_id,
            user_id=current_user.id,
            source_type="user_note",
            text=body.text,
            summary=body.summary,
            source_ref=None,
        )
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
    await db.commit()
    await db.refresh(fact)
    return _to_response(fact)


# ── Update ──

@router.patch("/facts/{fact_id}", response_model=SessionFactResponse)
async def update_fact(
    session_id: UUID,
    fact_id: UUID,
    body: SessionFactUpdateRequest,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
    _: None = Depends(require_engineer_or_admin),
) -> SessionFactResponse:
    """Edit fact text or summary.

    Returns 403 for `question` and `diagnostic_check`-sourced facts: the
    source item is the canonical input, so editing the fact card would
    desync the two. Engineers edit the source instead.
    """
    fact = await _load_fact_or_404(db, session_id, fact_id)
    if fact.source_type not in _EDITABLE_SOURCE_TYPES:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=(
                f"Facts sourced from {fact.source_type!r} are read-only at the "
                "card level. Edit the originating question or diagnostic check instead."
            ),
        )

    if body.text is None and body.summary is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="At least one of `text` or `summary` must be provided",
        )

    service = FactSynthesisService(db)
    try:
        fact = await service.update_fact(fact, text=body.text, summary=body.summary)
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
    await db.commit()
    await db.refresh(fact)
    return _to_response(fact)


# ── Soft delete ──

@router.delete("/facts/{fact_id}", status_code=204)
async def delete_fact(
    session_id: UUID,
    fact_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
    _: None = Depends(require_engineer_or_admin),
) -> None:
    """Soft-delete a fact. All source types are deletable.

    Soft delete (rather than hard) preserves provenance for audit and lets
    accidental deletes be recovered if needed. The `editable` flag does NOT
    control deletion — even read-only facts can be removed when the
    underlying question/check turned out to be wrong.
    """
    fact = await _load_fact_or_404(db, session_id, fact_id)
    service = FactSynthesisService(db)
    await service.soft_delete_fact(fact)
    await db.commit()


# ── Promote (AI marker + engineer-driven) ──

@router.post("/facts/promote", response_model=SessionFactResponse, status_code=201)
async def promote_fact(
    session_id: UUID,
    body: SessionFactPromoteRequest,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
    _: None = Depends(require_engineer_or_admin),
) -> SessionFactResponse:
    """Convert a question answer / check result into a fact.

    Two modes:

    - `proposed_text` provided → persisted as-is.
    - `raw_input` provided → server drafts text/summary via FactSynthesisService.

    Exactly one of the two must be set. The engineer-facing UI typically uses
    `proposed_text` after letting the engineer review/edit a draft.
    """
    if (body.proposed_text is None) == (body.raw_input is None):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Exactly one of `proposed_text` or `raw_input` must be provided",
        )
    if body.source_type == "ai_synthesis" and body.source_ref is not None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="`source_ref` must be null for source_type=ai_synthesis",
        )

    session = await _load_session_or_404(db, session_id)
    service = FactSynthesisService(db)

    text = body.proposed_text
    summary = body.proposed_summary
    if text is None:
        # Synthesize via LLM. Caller must hint which task-lane item the input
        # came from so we can shape the prompt appropriately.
        raw = body.raw_input or ""
        if body.source_type == "question":
            draft = await service.synthesize_from_question(
                question_text=_lookup_task_lane_text(session, body.source_ref, "questions"),
                raw_answer=raw,
            )
        elif body.source_type == "diagnostic_check":
            draft = await service.synthesize_from_check(
                check_label=_lookup_task_lane_text(session, body.source_ref, "actions"),
                check_output=raw,
            )
        else:
            # ai_synthesis with raw_input: the raw input IS the synthesis.
            # Re-run through the question synthesizer with an empty question
            # so the conservative prompt still applies.
            draft = await service.synthesize_from_question(
                question_text="(none — synthesizing from engineer summary)",
                raw_answer=raw,
            )
        text = draft["text"]
        summary = summary or draft["summary"]
        if not text:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                detail=(
                    "Synthesizer found no substantive fact in the input. "
                    "Edit the input or supply `proposed_text` directly."
                ),
            )

    try:
        fact = await service.create_fact(
            session_id=session.id,
            account_id=session.account_id,
            user_id=current_user.id,
            source_type=body.source_type,
            text=text,
            summary=summary,
            source_ref=body.source_ref,
        )
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

    await db.commit()
    await db.refresh(fact)
    return _to_response(fact)


def _lookup_task_lane_text(
    session: AISession, source_ref: UUID | None, list_key: str
) -> str:
    """Find the originating question text / action label from pending_task_lane.

    Falls back to a generic placeholder if the source item is no longer in
    the lane (e.g., the AI dropped it from a later turn). The synthesizer is
    forgiving — an empty/generic question still produces a useful fact when
    the engineer's answer is substantive on its own.
    """
    if source_ref is None:
        return ""
    lane = session.pending_task_lane or {}
    items = lane.get(list_key) or []
    sref = str(source_ref)
    for item in items:
        if isinstance(item, dict) and str(item.get("id")) == sref:
            return str(item.get("text") or item.get("label") or "")
    return ""
