"""services/saved_views — per-user named views over a filtered table
(M8; 09-build-plan §M8 / §UX program; ADR #009).

A saved view is a personal navigation shortcut: {page, name, search}
where `search` is the URL filter/sort query string of one filtered
table. This is the R1 "easier to navigate" centerpiece and retires the
hardcoded quick-chips.

Design rules:
  - Views are PER-USER and key on the REAL user (`actor.real_user_id`).
    They are a personal nav preference, never impersonated: while a
    super admin views-as someone, they still see and manage THEIR OWN
    views (parity with a browser's own bookmarks). This deliberately
    diverges from the effective-user identity that gates tenant data.
  - Views are NOT tenant data and are NOT written to the tenant audit
    trail (they hold only a saved filter string the owner chose). The
    security route-audit meta-test allowlists them explicitly.
  - Name uniqueness is per (user, page), case-insensitive — enforced by
    the DB index AND pre-checked here for a friendly message; a
    concurrent insert race maps to the same "already exists" error.
"""

from __future__ import annotations

import uuid

from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.models import SAVED_VIEW_PAGES, SavedView, User
from app.services.errors import ServiceError

MAX_NAME_LEN = 80
MAX_SEARCH_LEN = 2000

#: rian's recurring views, seeded idempotently (NOT migration data — the
#: seed is code so it re-asserts after any rebuild-from-legacy rehearsal
#: that TRUNCATEs the user graph). These REPLACE the retired quick-chips:
#: the entries trio the M8 brief names, plus the three projects chips the
#: old page hardcoded — including the load-bearing "Hourly blocks" view
#: (date=all is required: the all-time total IS the support-block
#: remaining-balance math, projects-and-entities B17).
_DEFAULT_VIEWS: dict[str, list[dict]] = {
    "rian": [
        {
            "page": "entries",
            "name": "Last month's PlusROI",
            "search": "date=last-month&status=all&operator=PlusROI",
            "pinned": True,
        },
        {
            "page": "entries",
            "name": "Pending backlog",
            "search": "date=all&status=pending",
            "pinned": True,
        },
        {
            "page": "entries",
            "name": "Blocked triage",
            "search": "date=all&status=blocked",
            "pinned": True,
        },
        {
            "page": "projects",
            "name": "Hourly blocks",
            "search": "date=all&project=hour&sort=billout_hrs&dir=desc",
            "pinned": True,
        },
        {
            "page": "projects",
            "name": "Missing info",
            "search": "date=all&missing_info=1",
            "pinned": True,
        },
        {
            "page": "projects",
            "name": "Last month admin",
            "search": "date=last-month&project=admin",
            "pinned": False,
        },
    ]
}


# ------------------------------------------------------------- identity


def _owner_id(actor) -> uuid.UUID:
    """THE owner key: the REAL user (personal pref, never impersonated).
    A request with no resolved users row cannot own views."""
    if actor.real_user_id is None:
        raise ServiceError(
            401, "NOT_AUTHENTICATED", "You must be signed in to save views."
        )
    return actor.real_user_id


# ---------------------------------------------------------- validation


def _clean_page(raw: str | None) -> str:
    page = (raw or "").strip()
    if page not in SAVED_VIEW_PAGES:
        raise ServiceError(
            422,
            "BAD_VIEW_PAGE",
            "Unknown view page.",
            f"page must be one of {', '.join(SAVED_VIEW_PAGES)}.",
        )
    return page


def _clean_name(raw: str | None) -> str:
    name = (raw or "").strip()
    if not name:
        raise ServiceError(422, "VIEW_NAME_REQUIRED", "Give the view a name.")
    if len(name) > MAX_NAME_LEN:
        raise ServiceError(
            422,
            "VIEW_NAME_TOO_LONG",
            f"View names are limited to {MAX_NAME_LEN} characters.",
        )
    return name


def _clean_search(raw: str | None) -> str:
    # Store the query string without a leading '?'; whitespace-only -> ''.
    search = (raw or "").strip().lstrip("?")
    if len(search) > MAX_SEARCH_LEN:
        raise ServiceError(
            422,
            "VIEW_SEARCH_TOO_LONG",
            "That view's filter state is too large to save.",
        )
    return search


# ------------------------------------------------------------ serialize


def _serialize(view: SavedView) -> dict:
    return {
        "id": str(view.id),
        "page": view.page,
        "name": view.name,
        "search": view.search or "",
        "pinned": bool(view.pinned),
        "sort_order": view.sort_order,
        "created_at": view.created_at.isoformat() if view.created_at else None,
    }


def _name_taken(
    session: Session,
    user_id: uuid.UUID,
    page: str,
    name: str,
    *,
    exclude: uuid.UUID | None = None,
) -> bool:
    stmt = select(SavedView.id).where(
        SavedView.user_id == user_id,
        SavedView.page == page,
        func.lower(SavedView.name) == name.lower(),
    )
    if exclude is not None:
        stmt = stmt.where(SavedView.id != exclude)
    return session.scalar(stmt) is not None


def _get_owned(session: Session, user_id: uuid.UUID, view_id: uuid.UUID) -> SavedView:
    """A view id not owned by this user is a 404 (never reveal another
    user's view exists)."""
    view = session.scalar(
        select(SavedView).where(
            SavedView.id == view_id, SavedView.user_id == user_id
        )
    )
    if view is None:
        raise ServiceError(404, "VIEW_NOT_FOUND", "That saved view no longer exists.")
    return view


# --------------------------------------------------------------- reads


def list_views(session: Session, actor, page: str | None = None) -> dict:
    """The signed-in user's saved views. Pinned first, then by
    sort_order, then case-insensitive name. `page` optionally narrows to
    one filtered table; omitted returns every page (the nav's pinned
    strip reads the full list)."""
    user_id = _owner_id(actor)
    stmt = select(SavedView).where(SavedView.user_id == user_id)
    if page is not None:
        stmt = stmt.where(SavedView.page == _clean_page(page))
    stmt = stmt.order_by(
        SavedView.pinned.desc(),
        SavedView.sort_order.asc(),
        func.lower(SavedView.name).asc(),
    )
    views = list(session.scalars(stmt).all())
    return {"views": [_serialize(v) for v in views]}


# -------------------------------------------------------------- writes


def create_view(
    session: Session,
    actor,
    *,
    page: str,
    name: str,
    search: str,
    pinned: bool = False,
) -> dict:
    """Name the current URL state as a view. New views sort after the
    user's existing views for that page."""
    user_id = _owner_id(actor)
    page = _clean_page(page)
    name = _clean_name(name)
    search = _clean_search(search)

    if _name_taken(session, user_id, page, name):
        raise ServiceError(
            409,
            "VIEW_NAME_EXISTS",
            f'You already have a view named "{name}" on this page.',
        )

    max_order = (
        session.scalar(
            select(func.coalesce(func.max(SavedView.sort_order), 0)).where(
                SavedView.user_id == user_id, SavedView.page == page
            )
        )
        or 0
    )
    view = SavedView(
        id=uuid.uuid4(),
        user_id=user_id,
        page=page,
        name=name,
        search=search,
        pinned=bool(pinned),
        sort_order=int(max_order) + 1,
    )
    session.add(view)
    try:
        session.flush()
    except IntegrityError as exc:
        # Lost the race against a concurrent insert of the same name.
        session.rollback()
        raise ServiceError(
            409,
            "VIEW_NAME_EXISTS",
            f'You already have a view named "{name}" on this page.',
        ) from exc
    session.commit()
    return _serialize(view)


def update_view(
    session: Session,
    actor,
    view_id: uuid.UUID,
    *,
    name: str | None = None,
    search: str | None = None,
    pinned: bool | None = None,
    sort_order: int | None = None,
) -> dict:
    """Rename / re-point / pin / reorder one of the user's own views.
    Only the provided fields change (payload-absent guard)."""
    user_id = _owner_id(actor)
    view = _get_owned(session, user_id, view_id)

    if name is not None:
        new_name = _clean_name(name)
        if _name_taken(session, user_id, view.page, new_name, exclude=view.id):
            raise ServiceError(
                409,
                "VIEW_NAME_EXISTS",
                f'You already have a view named "{new_name}" on this page.',
            )
        view.name = new_name
    if search is not None:
        view.search = _clean_search(search)
    if pinned is not None:
        view.pinned = bool(pinned)
    if sort_order is not None:
        view.sort_order = int(sort_order)

    view.updated_at = func.now()
    try:
        session.flush()
    except IntegrityError as exc:
        session.rollback()
        raise ServiceError(
            409,
            "VIEW_NAME_EXISTS",
            "You already have a view with that name on this page.",
        ) from exc
    session.commit()
    return _serialize(view)


def delete_view(session: Session, actor, view_id: uuid.UUID) -> dict:
    user_id = _owner_id(actor)
    view = _get_owned(session, user_id, view_id)
    session.delete(view)
    session.commit()
    return {"ok": True, "deleted": str(view_id)}


# ---------------------------------------------------------------- seed


def seed_default_saved_views(session: Session) -> int:
    """Idempotently seed the known recurring views for each named user
    (rian). Inserts only views absent by (user, page, lower(name)); an
    already-seeded DB is a no-op. Returns the number inserted.

    Code, not migration data (09-build-plan §M8): re-asserts after a
    rebuild-from-legacy rehearsal that TRUNCATEs the user graph."""
    inserted = 0
    for username, specs in _DEFAULT_VIEWS.items():
        user_id = session.scalar(
            select(User.id).where(User.idauth_username == username)
        )
        if user_id is None:
            continue  # user not provisioned yet — seed re-runs next boot
        order_by_page: dict[str, int] = {}
        for spec in specs:
            order_by_page[spec["page"]] = order_by_page.get(spec["page"], 0) + 1
            if _name_taken(session, user_id, spec["page"], spec["name"]):
                continue
            session.add(
                SavedView(
                    id=uuid.uuid4(),
                    user_id=user_id,
                    page=spec["page"],
                    name=spec["name"],
                    search=spec["search"],
                    pinned=spec.get("pinned", False),
                    sort_order=order_by_page[spec["page"]],
                )
            )
            inserted += 1
    if inserted:
        session.commit()
    return inserted
