"""Shared router dependencies: the per-request Session, the resolved
Actor (04: every router dependency-injects the actor; no inline
permission logic), the §1 filter-param parser, and the structured-error
HTTPException the whole API speaks."""

from __future__ import annotations

import uuid
from datetime import datetime
from typing import Annotated

from fastapi import Depends, Query, Request
from fastapi.exceptions import HTTPException
from sqlalchemy.orm import Session

from app.db import get_session
from app.services.authz.context import Actor, resolve_actor
from app.services.reporting.filters import EntryFilters


class ApiError(HTTPException):
    """HTTPException carrying the app's structured error triple
    {error_code, summary, detail} (srv-gw db-query convention)."""

    def __init__(self, status: int, code: str, summary: str, detail: str = ""):
        super().__init__(
            status_code=status,
            detail={"error_code": code, "summary": summary, "detail": detail},
        )


SessionDep = Annotated[Session, Depends(get_session)]

# NO_TENANT gate (ADR #015): a user with ZERO allowed tenants gets a
# structured 403 on tenant-scoped /api data routes, so the SPA can show a
# friendly "no books here yet" state instead of empty tables. A few routes
# MUST still answer for such a user: /api/me (their identity + empty tenant
# list), /api/tenant (the switch endpoint itself), /api/view-as (a
# zero-tenant super admin must still be able to exit), /api/saved-views
# (a per-USER nav preference, not tenant data), and /api/hub (the
# super-admin-only CROSS-tenant read surface — deliberately not tenant-
# scoped, so it must not trip this gate; its own super-admin gate is the
# access control).
_TENANT_EXEMPT_PATHS = frozenset({"/api/me", "/api/tenant", "/api/view-as"})
_TENANT_EXEMPT_PREFIXES = ("/api/saved-views", "/api/hub")


def _needs_tenant(path: str) -> bool:
    if path in _TENANT_EXEMPT_PATHS:
        return False
    return not any(path.startswith(prefix) for prefix in _TENANT_EXEMPT_PREFIXES)


def get_actor(request: Request, session: SessionDep) -> Actor:
    actor = resolve_actor(request, session)
    if actor.tenant_id is None and _needs_tenant(request.url.path):
        raise ApiError(
            403,
            "NO_TENANT",
            "No books available",
            "Your account isn't a member of any tenant yet. Ask an owner to "
            "add you to a set of books.",
        )
    return actor


ActorDep = Annotated[Actor, Depends(get_actor)]


def _uuid_or_none(raw: str | None, param: str) -> uuid.UUID | None:
    """'' and 'all' mean filter-off (legacy URL vocabulary); anything
    else must be a UUID."""
    if raw is None or raw in ("", "all"):
        return None
    try:
        return uuid.UUID(raw)
    except ValueError as exc:
        raise ApiError(
            422,
            "BAD_FILTER_VALUE",
            f"{param} must be a UUID",
            f"Got {raw!r}. Clear the filter or pick a value from the dropdown.",
        ) from exc


def _text_or_none(raw: str | None) -> str | None:
    if raw is None:
        return None
    trimmed = raw.strip()
    return trimmed or None


def parse_naive_timestamp(raw: str | None, param: str) -> datetime | None:
    """Naive 'YYYY-MM-DD[ HH:MM[:SS]]' bounds — the timestamps are
    stored timezone-naive (07 #17), so bounds are too."""
    if raw is None or raw.strip() == "":
        return None
    value = raw.strip().replace("T", " ")
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
        try:
            return datetime.strptime(value, fmt)
        except ValueError:
            continue
    raise ApiError(
        422,
        "BAD_FILTER_VALUE",
        f"{param} must be a naive timestamp",
        f"Got {raw!r}; expected YYYY-MM-DD[ HH:MM[:SS]].",
    )


def build_entry_filters(
    *,
    start: str | None = None,
    end: str | None = None,
    status: str | None = "all",
    batch: str | None = None,
    q: str | None = None,
    project: str | None = None,
    user: str | None = None,
    source_email: str | None = None,
    client: str | None = None,
    operator: str | None = None,
    imported_by: str | None = None,
    missing_info: bool = False,
    invoice: str | None = None,
) -> EntryFilters:
    """THE §1 filter parser — used by the GET dependency below AND by
    the bulk-mutation bodies, so display scope and mutation scope are
    parsed by one code path with one set of defaults (T-ENT-16 /
    entries.md B40 — the ADR #034 bug class is structural now)."""
    return EntryFilters(
        start=parse_naive_timestamp(start, "start"),
        end=parse_naive_timestamp(end, "end"),
        status=(status or "all").strip() or "all",
        batch=_uuid_or_none(batch, "batch"),
        q=_text_or_none(q),
        project=_text_or_none(project),
        user=_text_or_none(user),
        source_email=_text_or_none(source_email),
        client=_text_or_none(client),
        operator=_text_or_none(operator),
        imported_by=_uuid_or_none(imported_by, "imported_by"),
        missing_info=missing_info,
        invoice=_uuid_or_none(invoice, "invoice"),
    )


def entry_filters(
    start: str | None = None,
    end: str | None = None,
    status: str = Query("all"),
    batch: str | None = None,
    q: str | None = None,
    project: str | None = None,
    user: str | None = None,
    source_email: str | None = None,
    client: str | None = None,
    operator: str | None = None,
    imported_by: str | None = None,
    missing_info: bool = False,
    invoice: str | None = None,
) -> EntryFilters:
    """The §1 filter contract as query params. `transferred` stays a
    back-compat alias of `applied`; unknown status values match nothing
    (parity — no error)."""
    return build_entry_filters(
        start=start,
        end=end,
        status=status,
        batch=batch,
        q=q,
        project=project,
        user=user,
        source_email=source_email,
        client=client,
        operator=operator,
        imported_by=imported_by,
        missing_info=missing_info,
        invoice=invoice,
    )


FiltersDep = Annotated[EntryFilters, Depends(entry_filters)]


def contain_view_as(actor: Actor, f: EntryFilters) -> EntryFilters:
    """B65 parity: while a super admin views-as a non-owner, the
    imported_by param is FORCED to the effective user — the URL cannot
    widen the scope. (scope() already contains reads; this keeps the
    filter semantics honest too.)"""
    if actor.is_viewing_as and actor.role != "owner" and not actor.is_super_admin:
        return EntryFilters(**{**f.__dict__, "imported_by": actor.user_id})
    return f


def tenant_currency(actor: Actor) -> str:
    # R1: all-CAD default (07 #21); becomes tenant settings post-R1.
    return "CAD"
