"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...

Auth: session-only (require_frame_view for reads, require_frame_control
for mutations -- the "take control" soft lock). The limited manage-QR
surface lives separately under /api/m/ (routers/manage.py), and device
traffic under /frame/* (routers/device.py).

Config saves are PARTIAL updates: each page's form posts only its own
fields (the old single Settings form split across the Photos and
Configuration tabs), so every field is optional and only provided ones
are touched. Checkboxes are sent explicitly as "true"/"false" strings by
the page JS -- an absent field means "not this form's field", never
"unchecked".
"""

from __future__ import annotations

import logging
import time

import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session

from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
    DEFAULT_DISPLAY_MODE,
    DISPLAY_MODES,
    PALETTE_LABELS,
    hex_to_rgb,
    render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig
from .common import (
    OVERDUE_FACTOR,
    battery_estimate_s,
    calendar_sources_for_frame,
    fetch_source_and_faces,
    get_or_refresh_calendar_events_for_widget,
    get_or_refresh_tasks_for_widget,
    get_or_refresh_weather_for_widget,
    get_or_refresh_whiteboard_for_widget,
    immich_client_for,
    immich_creds,
    list_assets,
    photo_widget_config_or_404,
    valid_http_url,
    webdav_creds_for,
    widget_of_type,
)

logger = logging.getLogger(__name__)

router = APIRouter()

MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000

ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")


@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
    url, key = immich_creds(frame)
    if not url or not key:
        raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)")
    try:
        albums = immich_client_for(frame).list_albums()
    except httpx.HTTPError as e:
        raise HTTPException(502, f"Could not reach Immich at {url}: {e}") from e
    return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]


@router.post("/api/frames/{frame_id}/config")
def api_config_save(
    name: str | None = Form(None),
    album_id: str | None = Form(None),
    order: str | None = Form(None),
    refresh_interval_s: int | None = Form(None),
    display_mode: str | None = Form(None),
    queue_target_len: int | None = Form(None),
    orientation: str | None = Form(None),
    quiet_hours_enabled: bool | None = Form(None),
    quiet_hours_start: str | None = Form(None),
    quiet_hours_end: str | None = Form(None),
    timezone: str | None = Form(None),
    firmware_update_repo_url: str | None = Form(None),
    firmware_auto_update: bool | None = Form(None),
    battery_alert_threshold_pct: int | None = Form(None),
    palette: list[str] | None = Form(None),
    palette_reset: bool | None = Form(None),
    color_boost: float | None = Form(None),
    contrast_boost: float | None = Form(None),
    dither_strength: float | None = Form(None),
    calendar_view: str | None = Form(None),
    calendar_week_start: int | None = Form(None),
    calendar_week_days: int | None = Form(None),
    calendar_week_layout: str | None = Form(None),
    calendar_week_start_offset: int | None = Form(None),
    calendar_weather_enabled: bool | None = Form(None),
    calendar_weather_units: str | None = Form(None),
    calendar_tasks_enabled: bool | None = Form(None),
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    """Partial update, split across up to three sequential lock spans --
    frame-level settings, the frame's photo widget, the frame's calendar
    widget -- rather than one, now that those settings live on separate
    rows (see models.Widget's per-type extension tables). Never nested
    (see db.widget_locked's own docstring on why that would deadlock).

    `mode` and `calendar_photo_inlay` are no longer accepted here: mode
    no longer governs anything (a frame's widgets do), and photo inlay
    has no widget-system equivalent (place an independent photo widget
    alongside instead -- see CalendarWidgetConfig's docstring). Both are
    harmless no-ops if an old cached page still POSTs them -- FastAPI
    silently ignores form fields with no matching parameter.

    Until the widget-placement UI (a later phase) lets a frame have more
    than one widget of a type, "the photo widget" / "the calendar
    widget" below unambiguously means the frame's single auto-migrated
    one (see widget_of_type) -- these fields are silent no-ops if the
    frame doesn't have one yet, same posture as any other partial update
    whose target doesn't exist."""
    with frame_locked(db, frame.id) as cfg:
        if name is not None:
            cfg.name = name.strip()[:64] or cfg.name
        if refresh_interval_s is not None:
            cfg.refresh_interval_s = max(
                MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
            )
        if orientation is not None:
            cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
        if quiet_hours_enabled is not None:
            cfg.quiet_hours_enabled = quiet_hours_enabled
        if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
            cfg.quiet_hours_start = quiet_hours_start
        if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
            cfg.quiet_hours_end = quiet_hours_end
        if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
            cfg.timezone = timezone
        if firmware_update_repo_url is not None:
            stripped = firmware_update_repo_url.strip()
            if stripped and not valid_http_url(stripped):
                raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
            cfg.firmware_update_repo_url = stripped
        if firmware_auto_update is not None:
            cfg.firmware_auto_update = firmware_auto_update
        if battery_alert_threshold_pct is not None:
            cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
            # A changed threshold should be able to fire again immediately,
            # not stay suppressed by a flag set under the old value.
            cfg.battery_alert_sent = False
        if palette_reset:
            cfg.palette_rgb = None
        elif palette is not None:
            if len(palette) != len(PALETTE_LABELS):
                raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
            parsed = [hex_to_rgb(h) for h in palette]
            if any(rgb is None for rgb in parsed):
                raise HTTPException(400, "Palette colors must be #rrggbb hex values")
            cfg.palette_rgb = [list(rgb) for rgb in parsed]
        if color_boost is not None:
            cfg.color_boost = max(0.0, min(2.0, color_boost))
        if contrast_boost is not None:
            cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
        if dither_strength is not None:
            cfg.dither_strength = max(0.0, min(1.0, dither_strength))
        cfg.stats_config_saves += 1

    photo_widget = widget_of_type(db, frame, "photos")
    photo_fields_present = any(v is not None for v in (album_id, order, display_mode, queue_target_len))
    if photo_widget and photo_fields_present:
        with widget_locked(db, frame.id, photo_widget.id) as (_, _, pcfg):
            if album_id is not None and album_id != pcfg.album_id:
                # A newly selected album starts clean -- the old current
                # photo and queue don't mean anything in the new album's
                # context.
                pcfg.current_asset_id = ""
                pcfg.current_asset_set_at = 0.0
                pcfg.queue = []
                pcfg.queue_cursor = 0
                pcfg.history = []
                pcfg.excluded_asset_ids = []
                pcfg.album_id = album_id
            if order is not None:
                pcfg.order = order if order in ("sequential", "shuffle") else "sequential"
            if display_mode is not None:
                pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
            if queue_target_len is not None:
                pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))

    calendar_widget = widget_of_type(db, frame, "calendar")
    calendar_fields_present = any(v is not None for v in (
        calendar_view, calendar_week_start, calendar_week_days, calendar_week_layout,
        calendar_week_start_offset, calendar_weather_enabled, calendar_weather_units, calendar_tasks_enabled,
    ))
    if calendar_widget and calendar_fields_present:
        with widget_locked(db, frame.id, calendar_widget.id) as (_, _, ccfg):
            if calendar_view is not None:
                new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
                if new_view != ccfg.view:
                    # A stale offset means something different in a
                    # different view's units (days vs. weeks vs. months).
                    ccfg.browse_offset = 0
                ccfg.view = new_view
            if calendar_week_start is not None:
                ccfg.week_start = max(0, min(6, calendar_week_start))
            if calendar_week_days is not None:
                new_days = max(2, min(10, calendar_week_days))
                if new_days != ccfg.week_days:
                    # A stale offset counts a different-sized page under
                    # the old day count.
                    ccfg.browse_offset = 0
                ccfg.week_days = new_days
            if calendar_week_layout is not None:
                ccfg.week_layout = (
                    calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
                )
            if calendar_week_start_offset is not None:
                new_offset = max(-30, min(30, calendar_week_start_offset))
                if new_offset != ccfg.week_start_offset:
                    ccfg.browse_offset = 0
                ccfg.week_start_offset = new_offset
            if calendar_weather_enabled is not None:
                ccfg.weather_enabled = calendar_weather_enabled
            if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
                if calendar_weather_units != ccfg.weather_units:
                    # Cached forecasts are in the old unit -- force a
                    # refetch rather than showing stale numbers under a
                    # new unit label.
                    ccfg.weather_checked_at = 0.0
                ccfg.weather_units = calendar_weather_units
            if calendar_tasks_enabled is not None:
                ccfg.tasks_enabled = calendar_tasks_enabled

    return {"status": "saved"}


@router.post("/api/frames/{frame_id}/take-control")
def api_take_control(
    request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
    """Always succeeds for any linked user -- the lock is deliberately
    soft. The previous holder just sees who has it now."""
    user = require_user_api(request, db)
    previous = frame.controlled_by
    frame.controlled_by_user_id = user.id
    db.commit()
    logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
                previous.username if previous else "nobody")
    return {"status": "saved", "controller": user.display_name or user.username}


@router.get("/api/frames/{frame_id}/stats")
def api_stats(frame: Frame = Depends(require_frame_view)):
    return {
        "first_seen": frame.stats_first_seen,
        "device_wakes": frame.stats_device_wakes,
        "photos_displayed": frame.stats_photos_displayed,
        "photos_removed": frame.stats_photos_removed,
        "battery_reports": frame.stats_battery_reports,
        "recharge_cycles": frame.stats_recharge_cycles,
        "ota_updates_applied": frame.stats_ota_updates_applied,
        "config_saves": frame.stats_config_saves,
    }


@router.get("/api/frames/{frame_id}/queue")
def api_queue(
    request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
    user = require_user_api(request, db)
    photo_widget, pcfg = photo_widget_config_or_404(db, frame)

    client = immich_client_for(frame)
    assets = list_assets(client, pcfg.album_id)

    with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
        photo_queue.get_current(locked_pcfg, assets, locked_frame,
                                in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
        photo_queue.sync_queue_length(locked_pcfg, assets)
        snapshot = {
            "current_asset_id": locked_pcfg.current_asset_id,
            "queue": list(locked_pcfg.queue),
            "last_seen": locked_frame.last_seen,
            "overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR,
            "firmware_version": locked_frame.device_firmware_version,
            "firmware_available": locked_frame.firmware_available_version,
            "battery_percent": locked_frame.battery_percent,
            "battery_as_of": locked_frame.battery_as_of,
            "battery_estimate_s": battery_estimate_s(locked_frame, db),
            "controller_id": locked_frame.controlled_by_user_id,
            "controller": (
                (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
                if locked_frame.controlled_by
                else None
            ),
        }

    def entry(asset_id: str) -> dict:
        return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}

    now = time.time()
    return {
        "current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
        "upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
        "control": {
            "controller": snapshot["controller"],
            "you": snapshot["controller_id"] == user.id,
        },
        "device": {
            "last_seen": snapshot["last_seen"] or None,
            "overdue": bool(
                snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
            ),
            "firmware_version": snapshot["firmware_version"] or None,
            "firmware_available": snapshot["firmware_available"] or None,
            "battery": (
                {"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
                if snapshot["battery_percent"] >= 0
                else None
            ),
            "battery_estimate_s": snapshot["battery_estimate_s"],
        },
    }


@router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
    rows = db.execute(
        select(BatteryLog.ts, BatteryLog.percent)
        .where(BatteryLog.frame_id == frame.id)
        .order_by(BatteryLog.ts)
    ).all()
    return {"log": [[ts, percent] for ts, percent in rows]}


class QueueReorderRequest(BaseModel):
    queue: list[str]


@router.post("/api/frames/{frame_id}/queue/reorder")
def api_queue_reorder(
    body: QueueReorderRequest,
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    """Applies the client's requested order, tolerating drift between the
    browser's last-fetched snapshot and the server's current queue (e.g.
    a top-up/trim landed in between) instead of hard-rejecting: any ID
    the client sent that's no longer actually queued is dropped, and any
    ID the server has that the client didn't know about is appended
    rather than lost."""
    photo_widget = widget_of_type(db, frame, "photos")
    if photo_widget is None:
        raise HTTPException(404, "No photo widget on this frame")
    with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
        current_set = set(cfg.queue)
        reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
        reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
        cfg.queue = reordered
    return {"status": "saved"}


class QueuePromoteRequest(BaseModel):
    asset_id: str


@router.post("/api/frames/{frame_id}/queue/promote")
def api_queue_promote(
    body: QueuePromoteRequest,
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    """Moves a single photo to the front of the queue -- "Show next".
    Unlike reorder, doesn't depend on the client knowing the queue's
    exact current order, so it can't fail from staleness."""
    photo_widget = widget_of_type(db, frame, "photos")
    if photo_widget is None:
        raise HTTPException(404, "No photo widget on this frame")
    with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
        if body.asset_id not in cfg.queue:
            raise HTTPException(400, "That photo is no longer in the upcoming queue")
        cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
    return {"status": "saved"}


class QueueRemoveRequest(BaseModel):
    asset_id: str


@router.post("/api/frames/{frame_id}/queue/remove")
def api_queue_remove(
    body: QueueRemoveRequest,
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    """Permanently removes a photo from this frame's rotation. Does NOT
    touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
    photo_widget, pcfg = photo_widget_config_or_404(db, frame)
    client = immich_client_for(frame)
    assets = list_assets(client, pcfg.album_id)

    with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
        photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
    return {"status": "removed"}


@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
def api_thumbnail(
    asset_id: str, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
    """Scoped to what this frame is actually showing/queuing -- a user
    merely linked to view this frame shouldn't be able to pull thumbnails
    for arbitrary asset ids in the owner's Immich library, only the
    frame's own curated album. Same rule device.frame_share and
    manage.manage_thumbnail already enforce."""
    _, pcfg = photo_widget_config_or_404(db, frame)
    if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
        raise HTTPException(404, "Not on this frame")
    client = immich_client_for(frame)
    try:
        content, content_type = client.download_asset_thumbnail(asset_id)
    except httpx.HTTPError as e:
        raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
    return Response(content=content, media_type=content_type)


def _current_asset_id(frame: Frame, db: Session) -> tuple[str, PhotoWidgetConfig]:
    """Same idempotent get_current() dance /api/frames/{id}/queue uses --
    picks a current photo if none is set yet, otherwise just reads it,
    never advances early. Returns the photo widget's own config
    alongside the asset id, since callers (api_preview_rendered) also
    need its display_mode."""
    photo_widget, pcfg = photo_widget_config_or_404(db, frame)
    client = immich_client_for(frame)
    assets = list_assets(client, pcfg.album_id)
    with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
        photo_queue.get_current(locked_pcfg, assets, locked_frame,
                                in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
        asset_id = locked_pcfg.current_asset_id
    if not asset_id:
        raise HTTPException(404, "No current photo")
    return asset_id, pcfg


@router.get("/api/frames/{frame_id}/preview/original")
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
    """The Immich preview image behind the currently-displayed photo,
    unprocessed -- the "now displaying" side of the Configuration tab's
    before/after comparison."""
    asset_id, _ = _current_asset_id(frame, db)
    client = immich_client_for(frame)
    try:
        jpeg_bytes = client.download_asset_preview(asset_id)
    except httpx.HTTPError as e:
        raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
    return Response(content=jpeg_bytes, media_type="image/jpeg")


@router.get("/api/frames/{frame_id}/preview/rendered")
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
    """The same photo run through this frame's actual saved rendering
    pipeline (display mode, palette, color/contrast/dithering) and
    exported as a PNG -- the "how it will look on the frame" side of the
    comparison. Not a live preview of unsaved slider values; reflects
    whatever's currently saved. display_mode comes from the photo
    widget's own config now (palette/color/contrast/dither stay
    frame-level -- one physical panel, one set of those)."""
    asset_id, pcfg = _current_asset_id(frame, db)
    client = immich_client_for(frame)
    source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id)
    png = render_preview_png(
        source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
        display_mode=pcfg.display_mode, color_boost=frame.color_boost,
        contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
    )
    return Response(content=png, media_type="image/png")


class CalendarSelectRequest(BaseModel):
    user_id: int
    calendar_key: str
    calendar_label: str = ""
    included: bool


@router.post("/api/frames/{frame_id}/calendar-select")
def api_calendar_select(
    body: CalendarSelectRequest,
    request: Request,
    frame: Frame = Depends(require_frame_view),  # view access only -- NOT require_frame_control
    db: Session = Depends(get_db),
):
    """Include/exclude one calendar (calendar_key "ics" or
    "caldav:<href>", see FrameCalendar) on this frame. Deliberately not
    require_frame_control: adding your own calendar, or muting anyone's
    (including your own), is each viewer's own call, not something a
    frame's controller manages on someone else's behalf. The one-sided
    permission split lives here: turning a calendar ON requires being its
    owner (nobody can add someone else's calendar to a shared frame for
    them); turning one OFF only requires being linked to the frame at
    all, so anyone sharing the display can mute a calendar they'd rather
    not see there even if they don't own it."""
    user = require_user_api(request, db)
    if body.included and body.user_id != user.id:
        raise HTTPException(403, "Only a calendar's owner can add it to a frame")
    row = db.execute(
        select(FrameCalendar).where(
            FrameCalendar.frame_id == frame.id,
            FrameCalendar.user_id == body.user_id,
            FrameCalendar.calendar_key == body.calendar_key,
        )
    ).scalar_one_or_none()
    if row is None:
        if not body.included:
            raise HTTPException(404, "Not currently included on this frame")
        row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key)
        db.add(row)
    row.included = body.included
    if body.calendar_label:
        row.calendar_label = body.calendar_label
    # Force the frame's calendar widget's merged cache to pick up the
    # change promptly rather than waiting out the throttle.
    calendar_widget = widget_of_type(db, frame, "calendar")
    if calendar_widget is not None:
        db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
    db.commit()
    return {"status": "saved", "included": row.included}


CALENDAR_COLOR_INDEX_RANGE = range(2, 6)  # Yellow/Red/Blue/Green -- see PALETTE_LABELS


class CalendarColorRequest(BaseModel):
    calendar_key: str
    color_index: int | None  # None clears the pin, reverting to auto-cycle


@router.post("/api/frames/{frame_id}/calendar-color")
def api_calendar_color(
    body: CalendarColorRequest,
    request: Request,
    frame: Frame = Depends(require_frame_view),
    db: Session = Depends(get_db),
):
    """Pins a specific panel color to one of your own included calendars
    (models.FrameCalendar.color_index) -- always owner-only, unlike
    calendar-select's included=False, since recoloring someone else's
    calendar isn't the same kind of "I'd rather not see this" veto as
    muting it. None clears the pin, reverting calendar_render.py to its
    old auto-cycle-by-owner-name behavior for this calendar."""
    user = require_user_api(request, db)
    if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
        raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
    row = db.execute(
        select(FrameCalendar).where(
            FrameCalendar.frame_id == frame.id,
            FrameCalendar.user_id == user.id,
            FrameCalendar.calendar_key == body.calendar_key,
        )
    ).scalar_one_or_none()
    if row is None:
        raise HTTPException(404, "Not included on this frame")
    row.color_index = body.color_index
    calendar_widget = widget_of_type(db, frame, "calendar")
    if calendar_widget is not None:
        db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
    db.commit()
    return {"status": "saved", "color_index": row.color_index}


@router.get("/api/frames/{frame_id}/preview/calendar")
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
    """The same merged, cached event set a live device render would use
    -- not a live preview of an unsaved calendar_view choice, same
    "reflects what's currently saved" convention as preview/rendered.
    No photo_inlay parameter anymore -- that's not a widget-system
    concept (see CalendarWidgetConfig's docstring); place an independent
    photo widget alongside instead."""
    calendar_widget = widget_of_type(db, frame, "calendar")
    if calendar_widget is None:
        raise HTTPException(400, "No calendar widget on this frame yet")
    if not calendar_sources_for_frame(db, frame):
        raise HTTPException(400, "No calendars included on this frame yet")
    ccfg = db.get(CalendarWidgetConfig, calendar_widget.id)
    events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget)
    weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None
    tasks = (
        get_or_refresh_tasks_for_widget(db, frame, calendar_widget)
        if (ccfg.view == "week" and ccfg.tasks_enabled) else None
    )
    png = calendar_render.render_calendar_preview_png(
        events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
        palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
        week_start=ccfg.week_start,
        weather_cities=weather_cities, weather_units=ccfg.weather_units,
        week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
        week_start_offset=ccfg.week_start_offset,
    )
    return Response(content=png, media_type="image/png")


class TasksSourceRequest(BaseModel):
    calendar_key: str | None  # None clears the source


@router.post("/api/frames/{frame_id}/tasks-source")
def api_tasks_source(
    body: TasksSourceRequest,
    request: Request,
    frame: Frame = Depends(require_frame_view),
    db: Session = Depends(get_db),
):
    """Points this frame's week-view task list at one of the calling
    user's own CalDAV calendars -- same owner-controls-their-own-data
    permission split as calendar-select's included=True, since this is
    volunteering personal calendar data, not a frame-wide display
    setting a controller should get to pick on someone else's behalf.
    None clears the source; clearing (unlike setting) isn't
    ownership-gated -- like muting a shared calendar, anyone linked to
    the frame can turn off a task list they'd rather not see, but only
    its owner can point the frame at one of their calendars to begin
    with."""
    user = require_user_api(request, db)
    calendar_widget = widget_of_type(db, frame, "calendar")
    if calendar_widget is None:
        raise HTTPException(404, "No calendar widget on this frame yet")
    with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
        if body.calendar_key is None:
            cfg.tasks_user_id = None
            cfg.tasks_calendar_key = None
            cfg.tasks_cached = None
        else:
            cfg.tasks_user_id = user.id
            cfg.tasks_calendar_key = body.calendar_key
        cfg.tasks_checked_at = 0.0  # pick up the change promptly
    return {"status": "saved", "calendar_key": body.calendar_key}


class WhiteboardSourceRequest(BaseModel):
    url: str | None  # None clears the source


@router.post("/api/frames/{frame_id}/whiteboard-source")
def api_whiteboard_source(
    body: WhiteboardSourceRequest,
    request: Request,
    frame: Frame = Depends(require_frame_view),
    db: Session = Depends(get_db),
):
    """Points this frame's whiteboard at one of the calling user's own
    WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds)
    credentials -- same owner-controls-their-own-data permission split
    as api_tasks_source: only the account owner can set the frame to use
    it, but anyone linked to the frame can clear it, same as muting a
    shared calendar."""
    user = require_user_api(request, db)
    whiteboard_widget = widget_of_type(db, frame, "whiteboard")
    if whiteboard_widget is None:
        raise HTTPException(404, "No whiteboard widget on this frame yet")
    with widget_locked(db, frame.id, whiteboard_widget.id) as (_, _, cfg):
        if body.url is None:
            cfg.user_id = None
            cfg.url = ""
            cfg.cached_image = None
        else:
            stripped = body.url.strip()
            if not valid_http_url(stripped):
                raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
            cfg.user_id = user.id
            cfg.url = stripped
        cfg.checked_at = 0.0  # pick up the change promptly
    return {"status": "saved", "url": body.url}


@router.get("/api/frames/{frame_id}/whiteboard-browse")
def api_whiteboard_browse(
    request: Request,
    url: str | None = None,
    frame: Frame = Depends(require_frame_view),
    db: Session = Depends(get_db),
):
    """One level of a WebDAV directory listing, using the calling user's
    own credentials (never the frame's saved whiteboard_user_id -- this
    is "help me find a file in MY account", same person as whoever would
    go on to Save it, before that's even happened) -- powers the file
    picker on the Whiteboard tab as an alternative to pasting a URL.
    `url` omitted/None starts from the user's webdav_base_url (see
    models.py's User docstring); passing back a previous response's
    `entries[].url` (for a folder) descends into it."""
    user = require_user_api(request, db)
    creds = webdav_creds_for(user)
    if creds is None:
        raise HTTPException(400, "Set up WebDAV credentials in Settings first")
    target = url or user.webdav_base_url
    if not target:
        raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly")
    if not valid_http_url(target):
        raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL")
    try:
        entries = webdav_client.list_directory(target, creds[0], creds[1])
    except webdav_client.WebDavError as e:
        raise HTTPException(502, f"Could not browse: {e}")
    base = user.webdav_base_url or target
    parent_url = webdav_client.parent_directory_url(base, target)
    return {"current_url": target, "parent_url": parent_url, "entries": entries}


@router.get("/api/frames/{frame_id}/preview/whiteboard")
def api_preview_whiteboard(
    force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
    """The same throttled fetch/render cache a live device request would
    use, run through the same panel composition/quantization pipeline
    (see routers/device.py's _render_whiteboard_mode) -- "how it will
    look on the frame" (dithered, letterboxed), not just the raw
    Excalidraw export, same convention as preview/rendered and
    preview/calendar. force=True (the "Refresh now" button, as opposed
    to just reopening this tab) bypasses the fetch throttle."""
    whiteboard_widget = widget_of_type(db, frame, "whiteboard")
    if whiteboard_widget is None:
        raise HTTPException(400, "No whiteboard widget on this frame yet")
    wcfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id)
    png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, whiteboard_widget, force=force)
    if png_bytes is None:
        if not wcfg.url:
            raise HTTPException(400, "No whiteboard configured on this frame yet")
        raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
    import io

    from PIL import Image

    source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
    png = render_preview_png(
        source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
        display_mode="letterbox",
    )
    return Response(content=png, media_type="image/png")


class WeatherCityAddRequest(BaseModel):
    name: str


@router.post("/api/frames/{frame_id}/weather-cities/add")
def api_weather_city_add(
    body: WeatherCityAddRequest,
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    """Geocodes a free-text city name (e.g. "Portland, OR") and adds it
    to this frame's weather strip -- a frame-wide display setting (like
    calendar_view), not personal data, so this is gated the same way as
    api_config_save rather than the calendar-select owner/mute split."""
    calendar_widget = widget_of_type(db, frame, "calendar")
    if calendar_widget is None:
        raise HTTPException(404, "No calendar widget on this frame yet")
    try:
        city = weather.geocode_city(body.name)
    except weather.WeatherFetchError as e:
        raise HTTPException(400, str(e)) from e
    with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
        cities = list(cfg.weather_cities or [])
        if any(c["label"] == city["label"] for c in cities):
            raise HTTPException(400, f"{city['label']} is already on this frame's list")
        cities.append(city)
        cfg.weather_cities = cities
        cfg.weather_checked_at = 0.0  # pick up the new city promptly
    return {"status": "saved", "city": city}


class WeatherCityRemoveRequest(BaseModel):
    label: str


@router.post("/api/frames/{frame_id}/weather-cities/remove")
def api_weather_city_remove(
    body: WeatherCityRemoveRequest,
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    calendar_widget = widget_of_type(db, frame, "calendar")
    if calendar_widget is None:
        raise HTTPException(404, "No calendar widget on this frame yet")
    with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
        cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label]
        cfg.weather_cities = cities
        cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label]
        cfg.weather_cached = cached
    return {"status": "saved"}


@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
    file: UploadFile = File(...),
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    """Uploads a firmware image for OTA. The version is parsed out of the
    image itself (esp_app_desc_t) rather than trusted from a filename or
    form field, and the project name is checked so an unrelated .bin
    can't be pushed to the frame by mistake."""
    data = file.file.read()
    version = parse_app_version(data)
    path = firmware_path(frame.id)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(data)
    with frame_locked(db, frame.id) as cfg:
        cfg.firmware_available_version = version
    return {"status": "saved", "version": version, "size": len(data)}


def _fetch_latest_release(frame: Frame) -> dict | None:
    try:
        return gitea_releases.fetch_latest_release(
            frame.firmware_update_repo_url, frame.firmware_update_token
        )
    except httpx.HTTPError as e:
        raise HTTPException(502, f"Could not reach Gitea at {frame.firmware_update_repo_url}: {e}") from e


def _apply_gitea_update(db: Session, frame: Frame) -> str:
    """Downloads the configured Gitea repo's latest release asset for this
    frame's board variant (learned from the device's X-Frame-Board
    header, never picked by hand) and stages it exactly like a manual
    upload. Network I/O happens before the lock is taken."""
    if not frame.device_board_variant:
        raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
    release = _fetch_latest_release(frame)
    if not release:
        raise HTTPException(404, "No releases found in the configured Gitea repo")
    asset_name = gitea_releases.asset_name_for_board(frame.device_board_variant)
    asset_url = release["assets"].get(asset_name)
    if not asset_url:
        raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
    try:
        data = gitea_releases.download_asset(asset_url, frame.firmware_update_token)
    except httpx.HTTPError as e:
        raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
    version = parse_app_version(data)  # same validation the manual upload path applies
    path = firmware_path(frame.id)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(data)
    with frame_locked(db, frame.id) as cfg:
        cfg.firmware_available_version = version
        cfg.firmware_gitea_latest_version = version
        cfg.firmware_update_checked_at = time.time()
    return version


@router.post("/api/frames/{frame_id}/firmware/check")
def api_firmware_check(
    force: bool = False, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
    """Throttled check of the configured Gitea repo's latest release
    (gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
    on and a newer version is found, applies it immediately; otherwise
    just reports it so the UI can offer the "Update frame" button.
    force=true (the "Check now" button) bypasses the throttle.

    require_frame_control (not view), and POST (not GET): this can
    silently stage new firmware as a side effect (the auto-apply path
    below) exactly like /firmware/apply-latest, so it needs the same
    guard that route has -- a linked viewer without control shouldn't be
    able to trigger that, and as a GET it would've been exempt from the
    CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS."""
    if not frame.firmware_update_repo_url:
        return {"enabled": False}

    now = time.time()
    if force or now - frame.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
        # checked_at only advances on a successful reach, so a Gitea
        # outage gets retried every poll instead of waiting out the full
        # throttle interval.
        release = _fetch_latest_release(frame)
        with frame_locked(db, frame.id) as cfg:
            cfg.firmware_update_checked_at = now
            if release:
                cfg.firmware_gitea_latest_version = release["version"]

    update_available = (
        bool(frame.firmware_gitea_latest_version)
        and frame.firmware_gitea_latest_version != frame.firmware_available_version
        and bool(frame.device_board_variant)
    )
    if update_available and frame.firmware_auto_update:
        _apply_gitea_update(db, frame)
        update_available = False

    return {
        "enabled": True,
        "board": frame.device_board_variant or None,
        "latest_version": frame.firmware_gitea_latest_version or None,
        "staged_version": frame.firmware_available_version or None,
        "update_available": update_available,
    }


@router.post("/api/frames/{frame_id}/firmware/apply-latest")
def api_firmware_apply_latest(
    frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
    """The "Update frame" button: applies the latest Gitea release right
    now, bypassing the check throttle -- an explicit user action, not a
    background poll."""
    if not frame.firmware_update_repo_url:
        raise HTTPException(400, "No Gitea firmware repo configured")
    version = _apply_gitea_update(db, frame)
    return {"status": "saved", "version": version}