"""Model administration: ordering, defaults, images, access and capabilities."""

from __future__ import annotations

import contextlib
import logging

from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession

from lembas.api.deps import AdminUser, Db, RequiredUser
from lembas.db.models import Connection, Group, Model
from lembas.services import chat as chat_service
from lembas.services import settings_store, uploads
from lembas.services.llm.openai_client import MAX_CONTEXT
from lembas.web.templating import render

log = logging.getLogger(__name__)

router = APIRouter(tags=["admin-models"])

# What the endpoint can do. Endpoints do not advertise any of this reliably, so
# these are an administrator's assertion.
# `embeddings` is the odd one out and is worth naming as such: the other three
# say what a model can do in a *chat*, and this one says it is not for chatting
# at all. It is what /admin/extraction picks from, and nothing else reads it.
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools", "embeddings")

# Which tools this model is given. Distinct from the above: `tools` is whether a
# tools array may be sent at all, these are what goes in it. Every one of them is
# meaningless unless `tools` is on.
#
# The last two are gates rather than single tools: one covers every custom HTTP
# tool an administrator has defined, the other every MCP server. Which of those
# a particular person gets is the tool's own group list, not a flag here -- a
# server can advertise forty tools, and a model page listing all of them is a
# page nobody can read.
TOOL_CAPABILITIES = (
    ("tool_web_search", "Web search"),
    ("tool_fetch", "Fetch a page"),
    ("tool_knowledge", "Knowledge"),
    ("tool_notes", "Notes"),
    ("tool_memory", "Memory"),
    ("tool_skills", "Skills"),
    ("tool_custom", "Custom tools"),
    ("tool_mcp", "MCP servers"),
    ("tool_ask", "Ask the reader"),
    ("tool_report", "Reports"),
    ("tool_image", "Image generation"),
    ("tool_scratch", "Canvas"),
    ("tool_schedule", "Scheduling"),
    ("tool_subagent", "Helpers"),
    ("tool_agent", "Agent execution"),
)

CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)


def _model(db: DBSession, model_id: str) -> Model:
    model = db.get(Model, model_id)
    if model is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
    return model


def _ordered(db: DBSession) -> list[Model]:
    return list(
        db.scalars(
            select(Model).join(Connection).order_by(Model.position, Model.model_id)
        )
    )


def _renumber(db: DBSession) -> None:
    """Rewrite positions to 0..n-1.

    Keeps the numbers dense so a move is always a swap with a neighbour, and
    stops repeated reordering drifting into large sparse values.
    """
    for index, model in enumerate(_ordered(db)):
        model.position = index
    db.commit()


# --- Listing -----------------------------------------------------------------
PAGE_SIZE = 40

# Filters offered as tabs above the list. Each is a predicate over a Model.
FILTERS: dict[str, tuple[str, object]] = {
    "all": ("All", lambda m: True),
    "enabled": ("Enabled", lambda m: m.enabled),
    "disabled": ("Disabled", lambda m: not m.enabled),
    "pinned": ("Pinned", lambda m: m.pinned),
    "restricted": ("Restricted", lambda m: not m.public),
}


@router.get("/admin/models")
async def models_page(
    request: Request,
    db: Db,
    user: AdminUser,
    saved: str = "",
    q: str = "",
    filter: str = "all",
    connection: str = "",
    page: int = 1,
):
    """The model list.

    Compact rows only -- editing happens on a page of its own. A connection can
    advertise a hundred models, and a list that renders a full form for each of
    them is unusable at that size.
    """
    everything = _ordered(db)

    predicate = FILTERS.get(filter, FILTERS["all"])[1]
    needle = q.strip().lower()

    matching = [
        model
        for model in everything
        if predicate(model)
        and (not connection or model.connection_id == connection)
        and (
            not needle
            or needle in model.model_id.lower()
            or needle in (model.display_name or "").lower()
        )
    ]

    pages = max(1, -(-len(matching) // PAGE_SIZE))
    page = max(1, min(page, pages))
    start = (page - 1) * PAGE_SIZE
    visible = matching[start : start + PAGE_SIZE]

    return render(
        request,
        "admin/models.html",
        {
            "models": visible,
            "total": len(everything),
            "matched": len(matching),
            "page": page,
            "pages": pages,
            "page_start": start,
            "connections": list(db.scalars(select(Connection).order_by(Connection.name))),
            "default_model": settings_store.get(db, "default_model") or "",
            "counts": {
                key: sum(1 for m in everything if test(m)) for key, (_, test) in FILTERS.items()
            },
            "filters": {key: label for key, (label, _) in FILTERS.items()},
            "active_filter": filter if filter in FILTERS else "all",
            "q": q,
            "connection_id": connection,
            "saved": saved,
        },
    )


@router.get("/admin/models/{model_id}/edit")
async def model_detail(
    request: Request, db: Db, user: AdminUser, model_id: str, saved: str = ""
):
    """Everything about one model, on its own page."""
    model = _model(db, model_id)
    ordered = _ordered(db)
    index = next((i for i, m in enumerate(ordered) if m.id == model.id), 0)

    return render(
        request,
        "admin/model_detail.html",
        {
            "model": model,
            "groups": list(db.scalars(select(Group).order_by(Group.name))),
            "capabilities": PROTOCOL_CAPABILITIES,
            "tool_capabilities": TOOL_CAPABILITIES,
            "efforts": chat_service.EFFORTS,
            # Rows predating the split have no tool_* keys at all. Showing them
            # unticked would be a lie: tools.enabled_tools treats absent as on
            # when `tools` is on, so that an upgrade does not silently take web
            # search away from every model already configured for it.
            "tool_default": bool((model.capabilities_json or {}).get("tools")),
            "default_model": settings_store.get(db, "default_model") or "",
            "instance_prompt": settings_store.get(db, "system_prompt") or "",
            "position_of": index + 1,
            "total": len(ordered),
            "previous": ordered[index - 1] if index > 0 else None,
            "next": ordered[index + 1] if index + 1 < len(ordered) else None,
            "saved": saved,
        },
    )


# Registered BEFORE /{model_id}: FastAPI matches in registration order, so
# with the parameterised route first, "bulk" is captured as a model id and
# the handler 404s on a model that does not exist.
@router.post("/admin/models/bulk")
async def bulk_models(
    db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[])
) -> Response:
    """Enable or disable several models at once.

    A freshly refreshed connection can advertise dozens of models; turning them
    off one at a time is not a reasonable way to spend an afternoon.
    """
    models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
    for model in models:
        if action == "enable":
            model.enabled = True
        elif action == "disable":
            model.enabled = False
        elif action == "public":
            model.public = True
            model.groups = []
        elif action == "private":
            model.public = False
    db.commit()
    _renumber(db)
    return RedirectResponse(
        f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303
    )


@router.post("/admin/models/{model_id}")
async def update_model(
    db: Db,
    user: AdminUser,
    model_id: str,
    display_name: str = Form(""),
    description: str = Form(""),
    system_prompt: str = Form(""),
    enabled: bool = Form(False),
    pinned: bool = Form(False),
    public: bool = Form(False),
    position: str = Form(""),
    context_length: str = Form(""),
    default_effort: str = Form(""),
    group_ids: list[str] = Form(default=[]),
    capability: list[str] = Form(default=[]),
) -> Response:
    model = _model(db, model_id)

    model.display_name = display_name.strip()[:300]
    model.description = description.strip()[:2000]
    model.system_prompt = system_prompt.strip()[:8000]
    # A string, so an emptied field is distinguishable and junk can be ignored
    # rather than becoming a 422 -- the same shape `position` uses below.
    if context_length.strip():
        with contextlib.suppress(ValueError):
            model.context_length = min(max(int(context_length), 0), MAX_CONTEXT)
    else:
        model.context_length = 0
    model.enabled = enabled
    model.pinned = pinned
    model.public = public

    # Merged rather than rebuilt, unlike the capabilities below: params_json
    # holds whatever sampling defaults an administrator has set and this form
    # only carries one of them.
    params = dict(model.params_json or {})
    wanted = default_effort.strip().lower()
    if wanted in chat_service.EFFORTS:
        params["reasoning_effort"] = wanted
    else:
        params.pop("reasoning_effort", None)
    model.params_json = params

    # Absent checkboxes are simply missing from a form post, so the submitted
    # list IS the complete new state -- rebuild rather than merge.
    model.capabilities_json = {name: (name in capability) for name in CAPABILITIES}

    if public:
        # Group rows would be dead weight and misleading in the UI.
        model.groups = []
    else:
        model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))

    db.commit()

    # Typing a position is the only workable way to reorder a long list; the
    # up/down buttons are for nudging a model one place.
    if position.strip():
        try:
            wanted = max(1, int(position)) - 1
        except ValueError:
            wanted = None
        if wanted is not None:
            ordered = [m for m in _ordered(db) if m.id != model.id]
            ordered.insert(min(wanted, len(ordered)), model)
            for index, item in enumerate(ordered):
                item.position = index
            db.commit()

    log.info("model %s updated by %s", model.model_id, user.email)
    return RedirectResponse(
        f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303
    )


@router.post("/admin/models/{model_id}/move")
async def move_model(
    db: Db,
    user: AdminUser,
    model_id: str,
    direction: str = Form(...),
    back: str = Form(""),
) -> Response:
    """Swap a model with its neighbour."""
    model = _model(db, model_id)
    ordered = _ordered(db)
    index = next((i for i, m in enumerate(ordered) if m.id == model.id), None)

    if index is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")

    target = index - 1 if direction == "up" else index + 1
    if 0 <= target < len(ordered):
        ordered[index], ordered[target] = ordered[target], ordered[index]
        for position, item in enumerate(ordered):
            item.position = position
        db.commit()

    # Back to whichever filtered, paginated view the button was pressed on.
    return RedirectResponse(back or "/admin/models", status_code=303)


@router.post("/admin/models/{model_id}/default")
async def set_default_model(
    db: Db, user: AdminUser, model_id: str, back: str = Form("")
) -> Response:
    """Make a model the instance default for new chats."""
    model = _model(db, model_id)
    settings_store.update(db, {"default_model": model.model_id})
    log.info("default model set to %s by %s", model.model_id, user.email)
    return RedirectResponse(
        back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.",
        status_code=303,
    )


@router.post("/admin/models/{model_id}/image")
async def upload_model_image(
    db: Db, user: AdminUser, model_id: str, image: UploadFile = File(...)
) -> Response:
    model = _model(db, model_id)
    payload = await image.read()

    try:
        filename = uploads.save_model_image(payload, image.content_type or "")
    except uploads.UploadError as exc:
        return RedirectResponse(
            f"/admin/models/{model.id}/edit?saved={exc}", status_code=303
        )

    # Remove the old file rather than orphaning it in the uploads directory.
    if model.image_path:
        uploads.delete_model_image(model.image_path)

    model.image_path = filename
    db.commit()
    return RedirectResponse(
        f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303
    )


@router.post("/admin/models/{model_id}/image/delete")
async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response:
    model = _model(db, model_id)
    if model.image_path:
        uploads.delete_model_image(model.image_path)
        model.image_path = ""
        db.commit()
    return RedirectResponse(
        f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303
    )


# --- Serving model images ----------------------------------------------------
@router.get("/uploads/models/{filename}")
async def model_image(user: RequiredUser, filename: str) -> Response:
    """Serve a stored model avatar.

    Behind the auth guard: these are instance assets, not public files, and
    the path resolution in uploads refuses anything outside the directory.
    """
    path = uploads.model_image_path(filename)
    if path is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.")
    return FileResponse(
        path,
        media_type=uploads.media_type_for(filename),
        # Filenames are random and content-addressed in practice, so a long
        # cache is safe: a new image gets a new name.
        headers={"Cache-Control": "private, max-age=604800"},
    )
