"""Meta routes: health (public), version, signed-in identity, SSE heartbeat.

The SSE heartbeat exists to verify streaming flushes end-to-end through
Caddy (and any proxy in front) — per standards this is verified in week
one, not discovered broken when chat lands.
"""
from __future__ import annotations

import asyncio
import json
from datetime import datetime, timezone

from fastapi import APIRouter, BackgroundTasks, Depends, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from sse_starlette.sse import EventSourceResponse

from ..app_session import OPTOUT_COOKIE, PROBED_COOKIE
from .. import bw_auth
from ..auth import get_authenticated_user
from ..config import get_settings
from ..db import get_db
from ..deps import current_user
from ..models.identity import User
from ..version import APP_VERSION, CHANGELOG

router = APIRouter(prefix="/api", tags=["meta"])


class HealthOut(BaseModel):
    ok: bool


class ChangelogEntry(BaseModel):
    version: str
    date: str
    note: str


class VersionOut(BaseModel):
    version: str
    changelog: list[ChangelogEntry]


class MeOut(BaseModel):
    authenticated: bool
    username: str | None = None
    display_name: str = ""
    email: str = ""
    # App-defined access level (also mirrored to the BW hub, display-only).
    access_level: str = ""
    # App-only profile field, outside BW Auth's scope.
    city: str = ""
    # The app's logout route (clears the session + full BW sign-out).
    logout_url: str = "/logout"
    domain_mode: str = "public"
    # PUBLIC mode: the client should fire the one-time silent SSO probe
    # (redirect to /auth/probe) — true only when anonymous, not opted out, and
    # not already probed. False once probed so there's no redirect loop.
    should_probe: bool = False


class ProfileUpdate(BaseModel):
    city: str | None = None
    display_name: str | None = None


@router.get("/health", response_model=HealthOut)
def health() -> HealthOut:
    """Public (allowlisted). Reveals liveness only — no data, no config."""
    return HealthOut(ok=True)


@router.get("/version", response_model=VersionOut)
def version() -> VersionOut:
    return VersionOut(
        version=APP_VERSION,
        changelog=[
            ChangelogEntry(version=v, date=d, note=n) for v, d, n in CHANGELOG
        ],
    )


def _me_out(row: User | None, username: str | None, mode: str) -> MeOut:
    if row is None and username is None:
        return MeOut(authenticated=False, domain_mode=mode)
    return MeOut(
        authenticated=True,
        username=username or (row.external_username if row else None),
        display_name=(row.display_name if row else "") or (username or ""),
        email=row.email if row else "",
        access_level=row.access_level if row else "",
        city=row.city if row else "",
        domain_mode=mode,
    )


def _client_ip(request: Request) -> str:
    """Real client IP behind Caddy (first X-Forwarded-For hop; falls back to
    the socket peer)."""
    return (
        request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
        or (request.client.host if request.client else "")
    )


def _report_presence(username: str, client_ip: str) -> None:
    """Identified-presence heartbeat to the BW hub (gate-free — the Pattern B way
    to show who's online, since id-auth isn't in this app's login loop). Runs in a
    BackgroundTask AFTER the response, so it adds ZERO latency to /api/me (which
    the SPA polls). Best-effort: report_presence swallows network errors, and this
    also swallows a missing BW config (tests/dev) — a presence hiccup must never
    surface. Records in the hub once presence tracking is enabled on the host."""
    try:
        bw_auth.report_presence(username, ip=client_ip or None)
    except Exception:
        pass


@router.get("/me", response_model=MeOut)
def me(
    request: Request,
    background_tasks: BackgroundTasks,
    db: Session = Depends(get_db),
) -> MeOut:
    """Session state for the SPA (PUBLIC): authenticated? who? profile? The SPA
    calls this on load to decide welcome page vs signed-in app. Public route —
    returns authenticated=False for an anonymous visitor rather than 401."""
    auth = get_authenticated_user(request)
    settings = get_settings()
    if auth is None:
        should_probe = (
            settings.domain_mode == "public"
            and bool(settings.bw_client_id)
            and not request.cookies.get(OPTOUT_COOKIE)
            and not request.cookies.get(PROBED_COOKIE)
        )
        return MeOut(
            authenticated=False,
            domain_mode=settings.domain_mode,
            should_probe=should_probe,
        )
    # Presence ping runs AFTER the response (BackgroundTask) — /api/me stays
    # instant; the ~0.5s round-trip to the hub never blocks the poll.
    background_tasks.add_task(_report_presence, auth.username, _client_ip(request))
    row = db.scalar(select(User).where(User.external_username == auth.username))
    return _me_out(row, auth.username, settings.domain_mode)


@router.patch("/me/profile", response_model=MeOut)
def update_profile(
    payload: ProfileUpdate,
    user: User = Depends(current_user),
    db: Session = Depends(get_db),
) -> MeOut:
    """Edit app-owned profile fields (gated — requires a session). `city` is
    app-only and never touches BW Auth. `display_name` is app-local too (the BW
    login refreshes it, but the user can override between logins)."""
    if payload.city is not None:
        user.city = payload.city.strip()[:120]
    if payload.display_name is not None and payload.display_name.strip():
        user.display_name = payload.display_name.strip()[:120]
    db.commit()
    return _me_out(user, user.external_username, get_settings().domain_mode)


@router.get("/sse-heartbeat")
async def sse_heartbeat(request: Request) -> EventSourceResponse:
    """Streams one event per second for 10 seconds.

    Verification: `curl -N` through Caddy must show events arriving ~1s
    apart. If they arrive in one burst at the end, a proxy is buffering
    and the streaming-chat contract is broken — fix Caddy/encoding first.
    """

    async def gen():
        for i in range(10):
            if await request.is_disconnected():
                break
            yield {
                "event": "tick",
                "data": json.dumps(
                    {"n": i, "at": datetime.now(timezone.utc).isoformat()}
                ),
            }
            await asyncio.sleep(1)
        yield {"event": "done", "data": "{}"}

    return EventSourceResponse(gen())
