"""What happens to a file between the upload and the model, and how it is found.

Two halves on one page because they are two ends of the same pipeline: what gets
extracted decides what there is to search, and the search settings decide what
becomes of it. Splitting them would mean an administrator setting a 300-page PDF
limit on one screen and wondering on another why half a book is missing from the
index.

Every save drops `files.forget()`, and this is the only module that calls it —
the same discipline `admin_branding` has with the branding snapshot, and for the
same reason: a process-level cache whose save does not drop it is a setting that
takes effect at the next restart.
"""

from __future__ import annotations

import logging

from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select

from lembas.api.deps import AdminUser, Db
from lembas.db.models import Connection, Model
from lembas.services import files as files_service
from lembas.services import settings_store
from lembas.services.library import indexing
from lembas.web.templating import render

log = logging.getLogger(__name__)

router = APIRouter(prefix="/admin/extraction", tags=["admin-extraction"])


def _embedding_models(db: Db) -> list[Model]:
    """Models an administrator has marked as producing embeddings.

    Filtered rather than listed in full, the same shape `/admin/images` uses for
    its reviewer: a chat model in this picker is a setting that looks configured
    and fails on the first request, which is the shape of failure this codebase
    keeps cataloguing.
    """
    return [
        model
        for model in db.scalars(
            select(Model).join(Connection).order_by(Model.position, Model.model_id)
        )
        if (model.capabilities_json or {}).get("embeddings")
    ]


def _lines(text: str) -> list[str]:
    return [line.strip() for line in (text or "").splitlines() if line.strip()]


@router.get("")
async def extraction_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
    values = settings_store.extraction(db)
    models = _embedding_models(db)
    return render(
        request,
        "admin/extraction.html",
        {
            "values": values,
            "extensions_text": "\n".join(values.get("extra_text_extensions") or []),
            "models": models,
            # A model that was chosen and has since lost its flag, or its
            # connection. Named rather than silently dropped from the picker:
            # a setting that vanishes is one nobody can tell from one that was
            # never made.
            "missing_model": (
                values["embedding_model_id"]
                if values["embedding_model_id"]
                and values["embedding_model_id"] not in {m.model_id for m in models}
                else ""
            ),
            "ready": indexing.enabled(db),
            "counts": indexing.counts(db),
            "progress": indexing.progress(),
            "saved": saved,
        },
    )


@router.post("")
async def save_extraction(
    db: Db,
    user: AdminUser,
    max_upload_mb: int = Form(20),
    max_image_edge: int = Form(1400),
    jpeg_quality: int = Form(85),
    max_pdf_pages: int = Form(300),
    max_extracted_chars: int = Form(120_000),
    orphan_hours: int = Form(24),
    extra_text_extensions: str = Form(""),
    reject_unreadable_pdf: bool = Form(False),
) -> Response:
    settings_store.update(
        db,
        {
            # Clamped here as well as on read, for the reason the agent settings
            # give: a number with no bound is a way to break the instance from
            # a form.
            "max_upload_mb": min(max(max_upload_mb, 1), 512),
            "max_image_edge": min(max(max_image_edge, 128), 8192),
            "jpeg_quality": min(max(jpeg_quality, 30), 100),
            "max_pdf_pages": min(max(max_pdf_pages, 1), 5000),
            "max_extracted_chars": min(max(max_extracted_chars, 1000), 5_000_000),
            "orphan_hours": min(max(orphan_hours, 1), 8760),
            "extra_text_extensions": _lines(extra_text_extensions),
            "reject_unreadable_pdf": reject_unreadable_pdf,
        },
        key=settings_store.EXTRACTION,
    )
    files_service.forget()
    log.info("extraction settings changed by %s", user.email)
    return RedirectResponse(
        "/admin/extraction?saved=Extraction+saved.", status_code=status.HTTP_303_SEE_OTHER
    )


@router.post("/search")
async def save_search(
    db: Db,
    user: AdminUser,
    embedding_model_id: str = Form(""),
    chunk_chars: int = Form(1200),
    chunk_overlap: int = Form(150),
    embed_batch: int = Form(16),
) -> Response:
    """The semantic half.

    Its own form and its own route, because the two halves have different
    consequences: changing a chunk size invalidates every vector already stored,
    and changing an upload limit does not. Keeping them apart is what lets the
    page say so beside the control that does it.
    """
    before = settings_store.extraction(db)
    settings_store.update(
        db,
        {
            "embedding_model_id": embedding_model_id.strip()[:300],
            "chunk_chars": min(max(chunk_chars, 200), 8000),
            "chunk_overlap": max(chunk_overlap, 0),
            "embed_batch": min(max(embed_batch, 1), 256),
        },
        key=settings_store.EXTRACTION,
    )
    files_service.forget()

    # Changing the model changes the vector space, so what is stored stops
    # meaning anything against a new query. Nothing is deleted -- the scorer
    # already skips a width that does not match the query's, so a stale index is
    # ignored rather than trusted -- but a rebuild is what makes it useful
    # again, and offering it here is cheaper than leaving somebody to notice.
    changed = before["embedding_model_id"] != embedding_model_id.strip()
    message = "Search+saved."
    if changed and embedding_model_id.strip():
        message = "Search+saved.+Rebuild+the+index+to+use+the+new+model."
    log.info("embedding model set to %r by %s", embedding_model_id, user.email)
    return RedirectResponse(
        f"/admin/extraction?saved={message}", status_code=status.HTTP_303_SEE_OTHER
    )


@router.post("/rebuild")
async def rebuild(request: Request, db: Db, user: AdminUser) -> Response:
    """Start a rebuild, and answer with the progress card.

    A background task rather than a request that waits: embedding a library of a
    few thousand records is minutes of HTTP round trips, and a page that hangs
    for that long is one somebody reloads, which starts a second one.
    """
    started = indexing.start_rebuild()
    if started:
        log.info("index rebuild started by %s", user.email)
    return render(
        request,
        "admin/_index_progress.html",
        {"progress": indexing.progress(), "counts": indexing.counts(db), "ready": True},
    )


@router.get("/progress")
async def rebuild_progress(request: Request, db: Db, user: AdminUser) -> Response:
    """Polled while a rebuild runs. Stops polling itself when it finishes.

    Polled rather than streamed for the reason `/api/chats/unread` is: this is
    one small fragment on one page, and an SSE stream for it would be a second
    streaming path to keep correct.
    """
    return render(
        request,
        "admin/_index_progress.html",
        {
            "progress": indexing.progress(),
            "counts": indexing.counts(db),
            "ready": indexing.enabled(db),
        },
    )
