"""Helpers shared by the device and browser routers."""

from __future__ import annotations

import io
import logging
import os
import statistics
import time
from datetime import datetime, timedelta
from urllib.parse import urlparse

import httpx
from fastapi import HTTPException
from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session

from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
from ..db import widget_locked
from ..image_pipeline import logical_render_size
from ..immich_client import ImmichClient
from ..models import (
    BatteryLog,
    CalendarWidgetConfig,
    Frame,
    FrameButtonAction,
    FrameCalendar,
    PhotoWidgetConfig,
    User,
    Widget,
    WhiteboardWidgetConfig,
)

logger = logging.getLogger(__name__)

FRAME_MODES = ("photos", "calendar", "whiteboard")

# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
BATTERY_HISTORY_MAX = 500  # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000  # ~2 years at hourly reports -- cap on the battery_log table per frame
RECHARGE_JUMP_PCT = 5  # a report this much above the recent baseline = battery was recharged
# How many of the most recent reports make up that baseline. A lone noisy
# reading (ADC/regulator glitch -- see firmware/main/battery.c) can still
# dip or spike a single report; comparing against just the one immediately
# previous report meant that a normal reading right after a noisy dip
# looked like a 5%+ jump and falsely registered as a recharge. Comparing
# against the max of the last few reports instead means an actual recharge
# still needs to clear all of them, while a single stray low one doesn't
# get to set the bar.
RECHARGE_LOOKBACK = 3
BATTERY_ESTIMATE_SAMPLE_COUNT = 100  # most recent battery_log rows considered
MIN_ESTIMATE_SAMPLES = 5  # discharge steps needed before trusting the average
# Modified z-score cutoff (Iglewicz & Hoaglin's standard figure) for
# _reject_outlier_drops -- see that function's docstring for why a
# single noisy reading needs rejecting at the per-wake-drop level, not
# just at the recharge-detection level.
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5

# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
OVERDUE_FACTOR = 1.5


def immich_creds(frame: Frame) -> tuple[str, str]:
    """Which Immich this frame renders from. Owner's creds once the frame
    is claimed (Phase B+); env vars as the operator-level fallback (the
    pre-redesign source of truth); the frame's own staging columns last
    (populated by the config.json migration for exactly the case where
    the old file held creds but the env no longer does)."""
    owner = frame.owner
    if owner is not None and owner.immich_url and owner.immich_api_key:
        return owner.immich_url, owner.immich_api_key
    env_url = os.environ.get("IMMICH_URL", "")
    env_key = os.environ.get("IMMICH_API_KEY", "")
    if env_url and env_key:
        return env_url, env_key
    return frame.immich_url, frame.immich_api_key


def immich_client_for(frame: Frame) -> ImmichClient:
    url, key = immich_creds(frame)
    return ImmichClient(url, key)


def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
    try:
        assets = client.list_album_assets(album_id)
    except httpx.HTTPError as e:
        raise HTTPException(502, f"Could not reach Immich: {e}") from e
    if not assets:
        raise HTTPException(404, "Album has no photos")
    return assets


def fetch_source_and_faces(
    client: ImmichClient, display_mode: str, asset_id: str
) -> tuple[Image.Image, list[dict] | None]:
    """The shared first half of rendering: download the Immich preview
    and (only if display_mode needs it) its detected faces. Used by both
    render_asset (device-facing) and the web UI's rendered-preview
    endpoint (routers/api_frames.py) so they can't drift apart. Takes
    display_mode directly (a photos widget's own setting, see
    PhotoWidgetConfig) rather than a whole Frame -- this function only
    ever needed that one attribute off it."""
    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

    faces = None
    if display_mode == "crop_faces":
        try:
            faces = client.get_asset_faces(asset_id)
        except httpx.HTTPError as e:
            # A faces lookup hiccup shouldn't block showing a photo at
            # all -- just fall back to a plain center-crop this cycle.
            logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)

    return Image.open(io.BytesIO(jpeg_bytes)), faces


def _avg_wake_interval_s(frame: Frame) -> float:
    """Average wall-clock seconds between wakes: refresh_interval_s
    scaled up for however much of each day quiet hours removes from the
    wake schedule entirely -- fewer wakes/day, not a cheaper wake. This
    is what lets battery_estimate_s convert a per-wake drop rate into a
    remaining-time estimate that reacts to both settings immediately,
    rather than only after enough new history accumulates under them."""
    active_day_s = max(1, 86400 - quiet_hours.quiet_span_s(frame))
    interval_s = max(1, frame.refresh_interval_s)
    wakes_per_day = max(1, active_day_s // interval_s)
    return 86400 / wakes_per_day


def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, float]]:
    """Drops (weight, drop_pct) pairs whose drop is a wild outlier
    relative to the rest of the recent steps. A single noisy ADC/
    regulator glitch (see firmware/main/battery.c) corrupts one of the
    two steps around it, whichever way it reads: a glitch that dips low
    then recovers makes the step INTO it a spurious huge drop (the step
    back out is an increase, already excluded above as a "recharge");
    one that spikes high then settles makes the step OUT OF it the
    spurious one instead (the step into it is the excluded "recharge").
    Either way, one bad reading survives the recharge filter looking
    like an ordinary, legitimately huge drop and swings the whole
    remaining-time estimate on its own.

    Uses a MAD-based modified z-score (robust to a small number of
    extreme values in a way a plain mean/stdev z-score isn't -- a single
    huge outlier inflates the stdev itself, which just hides the outlier
    from a stdev-based test) rather than a fixed percent-point cutoff, so
    it adapts to how noisy a given frame's own sensor actually is
    instead of guessing one global threshold for every install."""
    drops = [drop for _, drop in steps]
    median = statistics.median(drops)
    abs_devs = [abs(d - median) for d in drops]
    mad = statistics.median(abs_devs)
    if mad == 0:
        # The standard median-based MAD degenerates to exactly 0 as soon
        # as more than half the steps share the median exactly -- and
        # real battery data is small integer percents, so "most wakes
        # cost exactly 1%" ties are the norm, not an edge case. That's
        # precisely the shape a single spliced-in glitch among a steady
        # discharge rate has (18 steps at "1", one at "26"), so treating
        # MAD==0 as "no spread, nothing to reject" would let exactly the
        # outlier this function exists for sail straight through. Fall
        # back to mean absolute deviation instead, which only reaches 0
        # when every single step is identical.
        mad = statistics.mean(abs_devs)
        if mad == 0:
            return steps  # every step really is identical -- nothing to reject
    kept = [
        (weight, drop) for weight, drop in steps
        if abs(0.6745 * (drop - median) / mad) <= OUTLIER_MODIFIED_Z_THRESHOLD
    ]
    return kept or steps  # never filter down to nothing


def battery_estimate_s(frame: Frame, db: Session) -> int | None:
    """Remaining-time estimate from a recency-weighted average of the
    *per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
    rows of the permanent battery_log table -- not just the current
    discharge cycle's battery_history, which resets to empty on every
    recharge and so often doesn't hold enough signal on its own even
    though the frame has plenty of history overall.

    Consecutive reports are assumed to be consecutive wakes (firmware
    reports battery on every wake while on battery), so each step's
    (prev_percent - next_percent) is that wake's cost. A step where
    percent went *up* is a recharge, not negative drain, and is skipped
    entirely rather than folded in as a weird outlier; a flat step
    (0% change) still counts as a real, cheap wake -- excluding those
    would systematically overstate the per-wake cost by only counting
    the wakes that happened to tick the percentage down. The remaining
    steps then get one more pass, _reject_outlier_drops, to catch the
    single-noisy-reading case that "percent went up" alone can't (see
    that function's docstring). Steps are weighted linearly by recency
    (step i of n gets weight i, 1-indexed) so a recent change in usage
    pattern shows up quickly instead of being washed out by a long flat
    history.

    The resulting %/wake rate is then converted to wall-clock time using
    the frame's *current* refresh_interval_s and quiet-hours settings
    (see _avg_wake_interval_s), not whatever cadence produced the
    historical data -- so halving refresh_interval_s roughly halves the
    estimate immediately (not exactly halves: quiet hours removes a
    fixed wake-free window from every day regardless of interval, which
    is the "other things going on" that keeps the scaling sublinear)."""
    if frame.battery_percent < 0:
        return None
    rows = db.execute(
        select(BatteryLog.percent)
        .where(BatteryLog.frame_id == frame.id)
        .order_by(BatteryLog.ts.desc())
        .limit(BATTERY_ESTIMATE_SAMPLE_COUNT)
    ).scalars().all()
    if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
        return None
    percents = list(reversed(rows))  # chronological order

    steps: list[tuple[int, float]] = []  # (recency_weight, drop_pct)
    for i in range(1, len(percents)):
        prev_pct, next_pct = percents[i - 1], percents[i]
        if next_pct > prev_pct:
            continue  # recharge (or a swap) -- not a discharge sample
        steps.append((i, prev_pct - next_pct))  # later steps (larger i) weigh more

    if len(steps) < MIN_ESTIMATE_SAMPLES:
        return None
    steps = _reject_outlier_drops(steps)

    weight_total = sum(weight for weight, _ in steps)
    if weight_total <= 0:
        return None
    avg_drop_per_wake = sum(weight * drop for weight, drop in steps) / weight_total
    if avg_drop_per_wake <= 0:
        return None  # flat -- no honest rate to extrapolate

    remaining_wakes = frame.battery_percent / avg_drop_per_wake
    return int(remaining_wakes * _avg_wake_interval_s(frame))


def shell_context(request, db: Session, user, active_frame: Frame | None = None,
                  active_nav: str | None = None) -> dict:
    """Template context every app-shell (sidebar) page needs: the user's
    frame list with an online indicator, the active highlights, and the
    session's CSRF token. Import here (not auth) keeps the router
    modules' template plumbing in one place."""
    import time as _time

    from .. import quiet_hours
    from ..auth import current_session, user_frames

    session = current_session(request, db)
    frames = user_frames(db, user)
    now = _time.time()
    for f in frames:
        # Same "not overdue" definition the Device panel uses.
        gap = quiet_hours.max_expected_gap_s(f) * OVERDUE_FACTOR
        f.recently_seen = bool(f.last_seen and now - f.last_seen <= gap)
    return {
        "request": request,
        "user": user,
        "csrf_token": session.csrf_token if session else None,
        "sidebar_frames": frames,
        "active_frame": active_frame,
        "active_nav": active_nav,
    }


def valid_http_url(url: str) -> bool:
    """http(s)-only URL check -- generalized from what was api_frames.py's
    frame-specific _valid_repo_url, now shared by two call sites (the
    Gitea firmware repo URL, and a user's personal calendar ICS URL)."""
    parsed = urlparse(url)
    return parsed.scheme in ("http", "https") and bool(parsed.netloc)


# --- Location/date-taken text for the manage overlay (see build_manage_content) ---

LOCATION_LINE_MAX_LEN = 14

US_STATE_ABBR = {
    "alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
    "colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
    "hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
    "kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
    "massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
    "montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
    "new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
    "oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
    "south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
    "virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
    "district of columbia": "DC",
}

CA_PROVINCE_ABBR = {
    "alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
    "newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
    "nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
    "saskatchewan": "SK", "yukon": "YT",
}

US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}


def _truncate(text: str, max_len: int) -> str:
    if len(text) <= max_len:
        return text
    return text[: max_len - 3] + "..."


def _format_location(exif: dict) -> tuple[str, str] | None:
    """Returns (city_line, region_line), each independently truncated to
    fit its own corner-overlay line, or None if Immich hasn't geocoded
    this photo. region_line is the abbreviated state/province for US/CAN
    locations (e.g. "CA", "ON"), else the full country name."""
    city = exif.get("city")
    if not city:
        return None

    state = exif.get("state")
    country = exif.get("country")
    country_key = (country or "").strip().lower()

    if state and country_key in US_COUNTRY_NAMES:
        region = US_STATE_ABBR.get(state.strip().lower(), state)
    elif state and country_key in CA_COUNTRY_NAMES:
        region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
    elif country:
        region = country
    elif state:
        region = state
    else:
        region = ""

    return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)


def _format_taken_at(exif: dict) -> str | None:
    raw = exif.get("dateTimeOriginal")
    if not raw:
        return None
    try:
        return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
    except ValueError:
        return None


def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
    """The frame's first widget of this type, by placement order. Until
    the placement UI (a later phase) ships, every frame has at most one
    widget per type -- the auto-migrated default -- so callers needing
    "the photo widget" / "the calendar widget" / "the whiteboard widget"
    for what's still effectively a single-widget-per-type frame use this
    rather than querying Widget directly. None if the frame has no widget
    of this type."""
    return db.scalars(
        select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == widget_type)
        .order_by(Widget.sort_order)
    ).first()


def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]:
    """The frame's photo widget + its config, or a 400 if Immich creds or
    an album aren't set up yet. Immich creds are frame/owner-level, but
    album_id lives on PhotoWidgetConfig. Shared by api_frames.py and
    manage.py, whose photo-related endpoints both need exactly this."""
    url, key = immich_creds(frame)
    if not url or not key:
        raise HTTPException(400, "Immich URL/API key not configured yet")
    widget = widget_of_type(db, frame, "photos")
    cfg = db.get(PhotoWidgetConfig, widget.id) if widget else None
    if widget is None or not cfg.album_id:
        raise HTTPException(400, "No album configured yet")
    return widget, cfg


def photo_widgets_for_frame(db: Session, frame: Frame) -> list[Widget]:
    return db.scalars(
        select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
        .order_by(Widget.sort_order)
    ).all()


def _primary_photo_widget(db: Session, frame: Frame, photo_widgets: list[Widget]) -> Widget | None:
    """The one photo widget the manage overlay's location/date/share-link
    boxes show info for -- unlike face labels (which generalize to every
    photo widget on screen, see build_manage_content), there's only one
    of each of these fixed panel corners to go around, so with more than
    one photo widget some single one has to be picked. Resolution rule:
    whichever photo widget the NEXT button's first assigned action
    targets, falling back to the first photo widget by placement order
    if none is button-assigned."""
    if not photo_widgets:
        return None
    next_actions = db.scalars(
        select(FrameButtonAction)
        .where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == "next")
        .order_by(FrameButtonAction.sort_order)
    ).all()
    photo_widget_ids = {w.id for w in photo_widgets}
    for action in next_actions:
        if action.widget_id in photo_widget_ids:
            return next(w for w in photo_widgets if w.id == action.widget_id)
    return photo_widgets[0]


def build_manage_content(db: Session, frame: Frame, request) -> dict:
    """Gathers everything manage_overlay.compose() needs -- what used to
    be two separate device-facing endpoints (/frame/photo-info,
    /frame/face-labels, both removed -- see the module docstring in
    manage_overlay.py) are now just internal calls made here, once,
    server-side, since compositing itself also moved server-side.
    management_url and battery_percent always apply. location/date/
    share-URL come from one "primary" photo widget (see
    _primary_photo_widget -- there's only one of each of those fixed
    panel corners, so with more than one photo widget on screen some
    single one has to be picked); face labels generalize more simply,
    since manage_overlay.compose() already takes a flat list and draws
    each one independently -- every photo widget's own named faces get
    concatenated in, each positioned within that widget's own region
    (see face_labels.compute_face_labels' region param) rather than as
    if a photo filled the whole panel."""
    base = str(request.base_url).rstrip("/")
    content: dict = {
        "management_url": f"{base}/m/{frame.manage_token}",
        "battery_percent": frame.battery_percent,
    }

    photo_widgets = photo_widgets_for_frame(db, frame)
    if not photo_widgets:
        return content

    primary = _primary_photo_widget(db, frame, photo_widgets)
    primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
    if primary_cfg and primary_cfg.current_asset_id:
        client = immich_client_for(frame)
        try:
            asset = client.get_asset(primary_cfg.current_asset_id)
        except httpx.HTTPError as e:
            logger.warning(
                "Could not fetch manage-overlay photo info for asset %s: %s", primary_cfg.current_asset_id, e
            )
        else:
            exif = asset.get("exifInfo") or {}
            content["location_lines"] = _format_location(exif)
            content["taken_at"] = _format_taken_at(exif)
            content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"

    panel_w, panel_h = logical_render_size(frame.orientation)
    face_labels: list[dict] = []
    for widget in photo_widgets:
        cfg = db.get(PhotoWidgetConfig, widget.id)
        if not cfg.current_asset_id:
            continue
        client = immich_client_for(frame)
        try:
            preview_bytes = client.download_asset_preview(cfg.current_asset_id)
            faces = client.get_asset_faces(cfg.current_asset_id)
        except httpx.HTTPError as e:
            logger.warning("Could not fetch manage-overlay face info for asset %s: %s", cfg.current_asset_id, e)
            continue
        if not any((face.get("person") or {}).get("name") for face in faces):
            continue  # no Immich-identified person on this widget's current photo -- nothing to label

        from ..face_labels import compute_face_labels

        region = grid.cell_to_pixels(frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h))
        face_labels.extend(compute_face_labels(preview_bytes, faces, cfg.display_mode, frame.orientation,
                                               region=region))

    if face_labels:
        content["face_labels"] = face_labels
    return content


def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
    """Every calendar included on this frame (FrameCalendar.included) --
    the exact set calendar_feed.merge_events needs. A calendar_key of
    "ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
    resolves against the href itself, authenticated with the owner's
    CalDAV account credentials (see caldav_client.py)."""
    rows = db.execute(
        select(FrameCalendar, User)
        .join(User, User.id == FrameCalendar.user_id)
        .where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True)  # noqa: E712
    ).all()
    sources = []
    for fc, u in rows:
        name = u.display_name or u.username
        if fc.calendar_key == "ics":
            if u.calendar_ics_url:
                sources.append(calendar_feed.CalendarSource(
                    name, "ics", u.calendar_ics_url, color_index=fc.color_index
                ))
        elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
            href = fc.calendar_key[len("caldav:"):]
            sources.append(calendar_feed.CalendarSource(
                name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password,
                color_index=fc.color_index,
            ))
    return sources


def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
    """Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
    -- same shape as the Gitea release-check throttle in api_frames.py's
    api_firmware_check -- reading/writing CalendarWidgetConfig (see
    app/widgets/calendar.py, which this backs). One shared cache for the
    whole merged result (every included user's events together), not
    per-user -- ICS feeds are small and this refetches at most every ~20
    minutes regardless of how many are included, so per-user cache
    columns would add bookkeeping for a marginal benefit.
    calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's
    own docstring) until a later phase re-keys it to widget_id, so every
    calendar widget on a frame currently shares the same "included
    calendars" set -- not a real limitation yet since nothing supports
    more than one calendar widget per frame end to end until that phase
    lands."""
    cfg = db.get(CalendarWidgetConfig, widget.id)
    now = time.time()
    if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
        return cfg.cached_events, cfg.fetch_summary

    sources = calendar_sources_for_frame(db, frame)
    today = quiet_hours.local_date(frame)
    events, summary = calendar_feed.merge_events(
        sources,
        today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
        today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
    )
    with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
        locked_cfg.cached_events = events
        locked_cfg.fetch_summary = summary
        locked_cfg.checked_at = now
    return events, summary


def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
    """Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much
    longer than calendar_feed's -- weather doesn't need to be that
    fresh), reading/writing CalendarWidgetConfig (see
    app/widgets/calendar.py). [] if weather's off or no cities are
    configured. A city whose refetch fails keeps its last-known days
    rather than going blank for one bad cycle -- calendar_render.py
    would otherwise show a real city as having no forecast at all just
    because one refresh hit a network hiccup."""
    cfg = db.get(CalendarWidgetConfig, widget.id)
    if not cfg.weather_enabled or not cfg.weather_cities:
        return []
    now = time.time()
    if cfg.weather_cached is not None and now - cfg.weather_checked_at < weather.CHECK_INTERVAL_S:
        return cfg.weather_cached

    previous_days = {c["label"]: c.get("days", {}) for c in (cfg.weather_cached or [])}
    result = []
    for city in cfg.weather_cities:
        try:
            days = weather.fetch_daily_forecast(city["latitude"], city["longitude"], cfg.weather_units)
        except weather.WeatherFetchError as e:
            logger.warning("Could not refresh weather for %s: %s", city["label"], e)
            days = previous_days.get(city["label"], {})
        result.append({"label": city["label"], "days": days})

    with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
        locked_cfg.weather_cached = result
        locked_cfg.weather_checked_at = now
    return result


def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
    """Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
    cadence as event merging), reading/writing CalendarWidgetConfig (see
    app/widgets/calendar.py). [] if tasks are off, no source is set, or
    the source user's CalDAV credentials/calendar_key have gone missing
    (e.g. they unlinked their account). A refetch failure keeps the
    last-known list rather than going blank for one bad cycle, same
    reasoning as get_or_refresh_weather_for_widget."""
    cfg = db.get(CalendarWidgetConfig, widget.id)
    if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
        return []
    now = time.time()
    if cfg.tasks_cached is not None and now - cfg.tasks_checked_at < calendar_feed.CHECK_INTERVAL_S:
        return cfg.tasks_cached

    user = db.get(User, cfg.tasks_user_id)
    key = cfg.tasks_calendar_key
    if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
        return cfg.tasks_cached or []
    href = key[len("caldav:"):]
    try:
        tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
    except caldav_client.CalDavError as e:
        logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
        return cfg.tasks_cached or []

    with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
        locked_cfg.tasks_cached = tasks
        locked_cfg.tasks_checked_at = now
    return tasks


def webdav_creds_for(user: User) -> tuple[str, str] | None:
    """(username, password) for `user`'s WebDAV access -- their own
    dedicated webdav_username/password, or (if they opted in)
    calendar_caldav_username/password reused from their CalDAV account
    (see models.py's User docstring on webdav_reuse_caldav_creds). None
    if neither is actually set up."""
    if user.webdav_reuse_caldav_creds:
        if user.calendar_caldav_username:
            return user.calendar_caldav_username, user.calendar_caldav_password
        return None
    if user.webdav_username:
        return user.webdav_username, user.webdav_password
    return None


def get_or_refresh_whiteboard_for_widget(
    db: Session, frame: Frame, widget: Widget, force: bool = False
) -> bytes | None:
    """Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/
    writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) --
    None if no whiteboard source is configured, credentials are missing
    (e.g. the owning user unlinked their WebDAV/CalDAV account), or the
    most recent fetch/render failed and nothing was ever cached yet. A
    failure after a previous success keeps showing the last good render
    rather than going blank for one bad refresh cycle, same reasoning as
    get_or_refresh_weather_for_widget/get_or_refresh_tasks_for_widget.
    force=True (the web UI's "Refresh now" button) skips the throttle
    entirely -- unlike a device's normal wake, a person clicking a
    button means do it right now, not eventually once the cache goes
    stale."""
    cfg = db.get(WhiteboardWidgetConfig, widget.id)
    if not cfg.url or not cfg.user_id:
        return None
    now = time.time()
    if not force and cfg.cached_image is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
        return cfg.cached_image

    user = db.get(User, cfg.user_id)
    creds = webdav_creds_for(user) if user else None
    if creds is None:
        return cfg.cached_image

    try:
        png = whiteboard.fetch_and_render(cfg.url, creds[0], creds[1])
    except whiteboard.WhiteboardRenderError as e:
        logger.warning("Could not refresh whiteboard for widget %d: %s", widget.id, e)
        return cfg.cached_image

    with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
        locked_cfg.cached_image = png
        locked_cfg.checked_at = now
    return png
