"""SQLAlchemy models: users, sessions, frames, links, claims, battery log.

One deliberately WIDE `frames` row per frame (settings + state + telemetry
+ stats together): every device request touches exactly one row, so the
per-frame lock in db.frame_locked() keeps the old whole-config-lock
semantics trivially correct, and SQLite doesn't care about row width.

The queue/history/excluded/battery_history columns are MutableList-mapped
JSON: photo_queue.py mutates them in place (pop/append/insert), which a
plain JSON column would silently not persist -- MutableList marks the row
dirty on in-place changes.

The ORM attribute for the photo ordering setting is `order` (matching the
old FrameConfig field name so photo_queue.py ports unchanged) but the
column is named photo_order to stay clear of the SQL keyword.
"""

from __future__ import annotations

import time

from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    # Normalized to lowercase in code before insert/lookup -- portable
    # case-insensitive uniqueness without SQLite-only COLLATE NOCASE.
    username: Mapped[str] = mapped_column(String, unique=True)
    display_name: Mapped[str] = mapped_column(String, default="")
    # Pluggable identity: "local" now; an OIDC provider later would set
    # provider_subject and leave password_hash NULL.
    identity_provider: Mapped[str] = mapped_column(String, default="local")
    provider_subject: Mapped[str] = mapped_column(String, default="")
    password_hash: Mapped[str | None] = mapped_column(String, nullable=True)
    is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
    immich_url: Mapped[str] = mapped_column(String, default="")
    immich_api_key: Mapped[str] = mapped_column(String, default="")
    # Password-reset emails and battery-threshold alerts (frames.owner's
    # email -- see routers/device.py's frame_battery) go here; blank = no
    # email configured, both features silently no-op for this user.
    email: Mapped[str] = mapped_column(String, default="")
    # Personal ICS subscription URL (no OAuth) for calendar frame mode --
    # see calendar_feed.py. Setting this alone shows up nowhere: a linked
    # frame only pulls this user's events in once they've also added it
    # on that frame's own Calendar tab (FrameCalendar below).
    calendar_ics_url: Mapped[str] = mapped_column(String, default="")
    # A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
    # plain ICS subscription above -- see caldav_client.py. calendar_url
    # is the server's CalDAV entry point the user pasted in, not any one
    # calendar's own URL; the individual calendars it exposes are
    # discovered and cached below.
    calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
    calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
    calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
    # [{"href", "display_name"}, ...] from the last successful
    # caldav_client.discover_calendars() call, refreshed by Settings'
    # "Discover calendars" button -- NULL until discovery has ever
    # succeeded. This is what a frame's Calendar tab offers the user to
    # add, without hitting the CalDAV server on every page load.
    calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
    calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    # WebDAV credentials for whiteboard frame mode (see webdav_client.py,
    # whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but
    # webdav_reuse_caldav_creds is a convenience for the common case
    # where it IS the same Nextcloud account as calendar_caldav_*: skip
    # re-entering the same username/password, since Nextcloud's CalDAV
    # and general-file-WebDAV both sit under the one account. Doesn't
    # try to be clever and derive the reuse automatically -- an explicit
    # opt-in, same as everywhere else in this project defaults features
    # off rather than silently inferring them.
    webdav_username: Mapped[str] = mapped_column(String, default="")
    webdav_password: Mapped[str] = mapped_column(String, default="")
    webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
    # Optional starting folder for the file-picker on a frame's Whiteboard
    # tab (see routers/api_frames.py's whiteboard-browse) -- purely a
    # convenience for browsing to a file rather than typing its full URL.
    # Never used for fetching/rendering itself, which always uses the
    # frame's own saved whiteboard_url regardless of whether this is set.
    webdav_base_url: Mapped[str] = mapped_column(String, default="")
    created_at: Mapped[float] = mapped_column(Float, default=time.time)

    __table_args__ = (
        Index(
            "ix_users_provider_subject",
            "identity_provider",
            "provider_subject",
            unique=True,
            sqlite_where=provider_subject != "",
        ),
    )


class UserSession(Base):
    __tablename__ = "sessions"

    id: Mapped[int] = mapped_column(primary_key=True)
    token_hash: Mapped[str] = mapped_column(String, unique=True)  # sha256 hex of cookie value
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
    csrf_token: Mapped[str] = mapped_column(String)
    created_at: Mapped[float] = mapped_column(Float, default=time.time)
    expires_at: Mapped[float] = mapped_column(Float, index=True)

    user: Mapped[User] = relationship()


class Frame(Base):
    __tablename__ = "frames"

    id: Mapped[int] = mapped_column(primary_key=True)
    # 12 lowercase hex chars of the device's full STA MAC. NULL only for
    # the migrated legacy frame until its device first reports an id.
    device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
    name: Mapped[str] = mapped_column(String, default="")
    # Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
    # (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
    # and routers/common.py FRAME_MODES).
    mode: Mapped[str] = mapped_column(String, default="photos")
    # Whose Immich library this frame pulls from; NULL = unclaimed.
    owner_user_id: Mapped[int | None] = mapped_column(
        ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    # "Take control" soft lock -- only this user may mutate settings/queue.
    controlled_by_user_id: Mapped[int | None] = mapped_column(
        ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    device_token: Mapped[str] = mapped_column(String)
    # Device has authenticated with device_token at least once -- stop
    # pushing it in /frame/config responses.
    device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
    manage_token: Mapped[str] = mapped_column(String, unique=True)
    # Migration window: this frame also accepts the legacy shared
    # MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
    # migrated frame #1; cleared from /admin once the device is on
    # per-frame auth.
    legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
    claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
    created_at: Mapped[float] = mapped_column(Float, default=time.time)

    # Migration staging only: Immich creds imported from the legacy
    # config.json/env live here until /setup copies them to admin #1.
    # Runtime resolution prefers owner creds, then env, then these (see
    # routers/common.py immich_creds()).
    immich_url: Mapped[str] = mapped_column(String, default="")
    immich_api_key: Mapped[str] = mapped_column(String, default="")

    # -- settings (attribute names match the old FrameConfig fields) --
    album_id: Mapped[str] = mapped_column(String, default="")
    order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
    refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
    quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
    quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
    quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
    timezone: Mapped[str] = mapped_column(String, default="UTC")
    # How a photo's aspect ratio is reconciled with the panel's -- see
    # image_pipeline.DISPLAY_MODES.
    display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
    orientation: Mapped[str] = mapped_column(String, default="landscape")
    queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
    # Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
    # blue/green, matching image_pipeline.PANEL_CODES order) overriding
    # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
    # default -- most frames never touch this.
    palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
    # Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged
    # (see image_pipeline.render_frame).
    color_boost: Mapped[float] = mapped_column(Float, default=1.0)
    contrast_boost: Mapped[float] = mapped_column(Float, default=1.0)
    # 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
    # original always-on full-strength Floyd-Steinberg dithering.
    dither_strength: Mapped[float] = mapped_column(Float, default=1.0)

    # -- calendar mode (see calendar_feed.py, calendar_render.py,
    # routers/device.py's RENDERERS["calendar"]) --
    calendar_view: Mapped[str] = mapped_column(String, default="agenda")  # "agenda" | "week" | "month"
    # 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
    # which day week/month views start their grid on.
    calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
    # Agenda view only; reuses this frame's existing photos-mode album/
    # queue, not a separate photo setup.
    calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
    # How many periods (unit depends on calendar_view: days/weeks/months)
    # NEXT/BACK have browsed from "today". Reset to 0 by the next normal
    # (non-button) /frame/image request, and whenever calendar_view
    # itself changes -- a stale offset means something different in a
    # different view's units.
    calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
    # Throttled merge-fetch cache (see routers/common.py's
    # get_or_refresh_calendar_events) -- same shape as the
    # firmware_update_checked_at/firmware_gitea_latest_version pattern
    # below. One shared cache for every included user's merged events,
    # not per-user.
    calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
    # "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
    # calendars unavailable" -- never names which user's feed failed, a
    # shared household display shouldn't call out a specific person's
    # outage to everyone who looks at it.
    calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")

    # Optional weather strip, agenda/today & tomorrow/week views only --
    # never month, there's no room (see calendar_render.py's _BUILDERS).
    # Off by default.
    calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
    calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit")  # "fahrenheit" | "celsius"
    # [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
    # weather.geocode_city() when added from the Calendar tab.
    calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
    # Throttled per-city forecast cache (see routers/common.py's
    # get_or_refresh_weather) -- same shape idiom as
    # calendar_checked_at/calendar_cached_events above.
    # [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
    calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)

    # Week view: how many days to show (2-10, default 7 -- the original
    # fixed behavior) and whether they're laid out as side-by-side
    # columns or stacked bands (see calendar_render.py's _build_week).
    calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
    calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal")  # "horizontal" | "vertical"
    # Only used when calendar_week_days != 7 -- calendar_week_start's
    # fixed-weekday anchor ("start on the most recent Monday") stops
    # making sense once the view isn't a literal calendar week, so a
    # non-7-day view instead starts this many days from today (0 =
    # starts today, negative = starts in the past, positive = starts in
    # the future). Ignored (calendar_week_start governs instead) at the
    # default 7 days, so this has no effect until someone actually
    # changes the day count.
    calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)

    # Optional task list, week view only -- takes the space of one day
    # slot rather than adding an extra one (see calendar_render.py's
    # _draw_tasks). CalDAV only (a task list is a VTODO collection, not
    # something a plain ICS subscription meaningfully has); source is
    # one specific linked user's own CalDAV calendar, same
    # owner-controls-their-own-data permission split as FrameCalendar --
    # see routers/api_frames.py's api_tasks_source. calendar_tasks_user_id
    # SET NULL on the user's deletion clears the source rather than
    # leaving a dangling reference (checked_at isn't reset by that, but
    # the next refresh attempt finds no source and just returns []).
    calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
    calendar_tasks_user_id: Mapped[int | None] = mapped_column(
        ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
    calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    # [{"summary", "due" (ISO date/datetime string or None)}, ...],
    # already filtered to outstanding (not-completed) tasks and sorted
    # by due date -- see caldav_client.fetch_tasks.
    calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)

    # -- whiteboard mode (see webdav_client.py, whiteboard.py,
    # routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
    # setting like calendar mode's own frame_calendars source, not
    # personal data, but still owner-gated the same way: only
    # whiteboard_user_id may point the frame at their own account (see
    # routers/api_frames.py's api_whiteboard_source), since it's their
    # credentials being used to fetch it. --
    whiteboard_user_id: Mapped[int | None] = mapped_column(
        ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    # The specific .whiteboard file's WebDAV URL -- pasted directly, same
    # idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
    # account-has-several-calendars case, a WebDAV account doesn't need
    # a picker step here since the user already knows which one file).
    whiteboard_url: Mapped[str] = mapped_column(String, default="")
    whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    # Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
    # BLOB rather than the JSON columns the rest of this cache-pattern
    # family uses, since this is binary image data, not JSON-shaped.
    whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)

    # -- state --
    current_asset_id: Mapped[str] = mapped_column(String, default="")
    current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
    queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
    queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
    history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
    excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)

    # -- telemetry --
    battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
    battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
    # Current discharge cycle only (reset on recharge detection); the
    # permanent record is the battery_log table.
    battery_history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
    last_seen: Mapped[float] = mapped_column(Float, default=0.0)
    device_firmware_version: Mapped[str] = mapped_column(String, default="")
    device_board_variant: Mapped[str] = mapped_column(String, default="")

    # Battery-low email alert (see routers/device.py's frame_battery).
    # -1 = disabled. Sent to the owner's email once per discharge cycle
    # (battery_alert_sent resets alongside battery_history whenever a
    # recharge is detected, same trigger as stats_recharge_cycles).
    battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
    battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)

    # -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
    firmware_available_version: Mapped[str] = mapped_column(String, default="")
    firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
    firmware_auto_update: Mapped[bool] = mapped_column(Boolean, default=False)
    firmware_update_token: Mapped[str] = mapped_column(String, default="")
    firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")

    # -- stats (flattened from the old nested FrameStats) --
    stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
    stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
    stats_photos_displayed: Mapped[int] = mapped_column(Integer, default=0)
    stats_photos_removed: Mapped[int] = mapped_column(Integer, default=0)
    stats_battery_reports: Mapped[int] = mapped_column(Integer, default=0)
    stats_recharge_cycles: Mapped[int] = mapped_column(Integer, default=0)
    stats_ota_updates_applied: Mapped[int] = mapped_column(Integer, default=0)
    stats_config_saves: Mapped[int] = mapped_column(Integer, default=0)

    owner: Mapped[User | None] = relationship(foreign_keys=[owner_user_id])
    controlled_by: Mapped[User | None] = relationship(foreign_keys=[controlled_by_user_id])


class UserFrame(Base):
    """A user linked to a frame: sees it in their sidebar, may view its
    pages, and may take control. Ownership (whose Immich creds the frame
    renders from) is frames.owner_user_id, separate from linking."""

    __tablename__ = "user_frames"

    user_id: Mapped[int] = mapped_column(
        ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
    )
    frame_id: Mapped[int] = mapped_column(
        ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
    )
    created_at: Mapped[float] = mapped_column(Float, default=time.time)


class FrameCalendar(Base):
    """One calendar included on one frame -- calendar_key is "ics" (the
    owner's single calendar_ics_url) or "caldav:<href>" (one of the
    owner's CalDAV collections; href matches an entry in
    User.calendar_caldav_calendars). Replaces the old single
    UserFrame.calendar_included boolean now that a CalDAV account can
    expose more than one calendar.

    A row only ever gets created by its own owner (adding a calendar to
    a frame is each person's own data-sharing choice, not something a
    frame's controller decides on their behalf) -- but once it exists,
    ANY user linked to the frame may flip included back to False, muting
    a calendar they'd rather not see on a shared display even though
    they don't own it. Only the owner may flip it back to True. See
    routers/api_frames.py's api_calendar_select."""

    __tablename__ = "frame_calendars"

    id: Mapped[int] = mapped_column(primary_key=True)
    frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
    calendar_key: Mapped[str] = mapped_column(String)
    # Snapshot label for display -- so the list still reads sensibly even
    # if the owner's CalDAV account later stops offering this calendar.
    calendar_label: Mapped[str] = mapped_column(String, default="")
    included: Mapped[bool] = mapped_column(Boolean, default=True)
    # Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5:
    # Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's
    # text/background) pinning this calendar's events to a specific
    # panel color rather than calendar_render.py's old owner-name
    # auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
    # owner may set this -- see routers/api_frames.py's api_calendar_color.
    color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

    __table_args__ = (
        Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
    )


class Widget(Base):
    """One placed/sized content item on a frame's panel -- the unit the
    widget system replaces the old single Frame.mode with (see
    app/grid.py for the grid this x/y/w/h is measured in, and app/widgets/
    for the widget_type -> render/action dispatch registry). Widgets never
    overlap (enforced server-side in routers/api_widgets.py), which is
    what keeps compositing simple: no z-order, no blending, just N
    independent regions pasted onto one shared canvas before a single
    shared dither/quantize pass (see image_pipeline.render_panel).

    widget_type selects which of the three per-type extension tables below
    (PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig) holds
    this widget's actual settings/state -- a 1:1 relational split rather
    than one wide table with every type's columns, matching how
    FrameCalendar/BatteryLog are already their own tables in this
    codebase rather than crammed onto Frame."""

    __tablename__ = "widgets"

    id: Mapped[int] = mapped_column(primary_key=True)
    frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
    widget_type: Mapped[str] = mapped_column(String)  # "photos" | "calendar" | "whiteboard"
    x: Mapped[int] = mapped_column(Integer)
    y: Mapped[int] = mapped_column(Integer)
    w: Mapped[int] = mapped_column(Integer)
    h: Mapped[int] = mapped_column(Integer)
    # Display/tie-break ordering only (e.g. listing widgets in a UI) --
    # NOT a z-order, since widgets never overlap. Named sort_order, not
    # order, to sidestep the SQL-keyword dance Frame.order needed
    # (mapped to a differently-named column) -- nothing outside this
    # table needs to match a specific attribute name here.
    sort_order: Mapped[int] = mapped_column(Integer, default=0)
    created_at: Mapped[float] = mapped_column(Float, default=time.time)

    __table_args__ = (Index("ix_widgets_frame", "frame_id"),)


class PhotoWidgetConfig(Base):
    """One photo widget's settings + queue state. Attribute names match
    Frame's old photo-queue columns exactly (down to `order`'s same
    photo_order column-name dodge) -- app/photo_queue.py's 5 functions
    are duck-typed against these exact names (never isinstance-checked
    against Frame), so they port unchanged onto this table."""

    __tablename__ = "photo_widget_configs"

    widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
    album_id: Mapped[str] = mapped_column(String, default="")
    order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
    display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
    queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
    current_asset_id: Mapped[str] = mapped_column(String, default="")
    current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
    queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
    queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
    history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
    excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)


class CalendarWidgetConfig(Base):
    """One calendar widget's settings + cached-fetch state -- the same
    fields that used to live as calendar_* columns directly on Frame,
    minus calendar_photo_inlay (dropped: arbitrary widget placement
    subsumes what a fixed 50/50 inlay split did, so it's not a special
    case anymore, just place a photo widget alongside). "Included
    calendars" stays on FrameCalendar (frame_id-keyed for now; re-keyed
    to widget_id in a later phase once more than one calendar widget per
    frame is actually supported end to end)."""

    __tablename__ = "calendar_widget_configs"

    widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
    view: Mapped[str] = mapped_column(String, default="agenda")
    week_start: Mapped[int] = mapped_column(Integer, default=0)
    browse_offset: Mapped[int] = mapped_column(Integer, default=0)
    checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
    fetch_summary: Mapped[str] = mapped_column(String, default="")
    weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
    weather_units: Mapped[str] = mapped_column(String, default="fahrenheit")
    weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
    weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
    week_days: Mapped[int] = mapped_column(Integer, default=7)
    week_layout: Mapped[str] = mapped_column(String, default="horizontal")
    week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
    tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
    tasks_user_id: Mapped[int | None] = mapped_column(
        ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
    tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)


class WhiteboardWidgetConfig(Base):
    """One whiteboard widget's source + rendered-PNG cache -- the same
    fields that used to live as whiteboard_* columns directly on Frame."""

    __tablename__ = "whiteboard_widget_configs"

    widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
    user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
    url: Mapped[str] = mapped_column(String, default="")
    checked_at: Mapped[float] = mapped_column(Float, default=0.0)
    cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)


# widget_type -> its per-type extension table, keyed by widget_id. Used
# by db.widget_locked() to resolve the right config row without importing
# app/widgets/'s heavier render/action registry just for this lookup.
WIDGET_CONFIG_MODELS: dict[str, type] = {
    "photos": PhotoWidgetConfig,
    "calendar": CalendarWidgetConfig,
    "whiteboard": WhiteboardWidgetConfig,
}


class FrameButtonAction(Base):
    """One (widget, action) binding for one of a frame's two physical
    buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
    "advance"}. A button can have several of these (sort_order gives
    execution order); on a press, every row for that (frame, button) runs
    -- see routers/device.py's frame_advance/frame_back. Deliberately
    unconstrained about which widget/action pairs with which button (the
    user's own idea for resolving "what does NEXT even mean with several
    widgets on screen": let them assign literally anything to either
    button, including mismatched combinations, rather than the server
    guessing a sensible default)."""

    __tablename__ = "frame_button_actions"

    id: Mapped[int] = mapped_column(primary_key=True)
    frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
    button: Mapped[str] = mapped_column(String)  # "next" | "back"
    widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
    action: Mapped[str] = mapped_column(String)  # e.g. "advance", "back", "check_now" -- see app/widgets/
    sort_order: Mapped[int] = mapped_column(Integer, default=0)
    created_at: Mapped[float] = mapped_column(Float, default=time.time)

    __table_args__ = (
        Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
    )


class PendingClaim(Base):
    """A claim submitted before the frame's first check-in (the user beat
    the device to the server after provisioning). Attached automatically
    when a device with this id self-registers; expired rows are pruned
    opportunistically."""

    __tablename__ = "pending_claims"

    device_id: Mapped[str] = mapped_column(String, primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
    created_at: Mapped[float] = mapped_column(Float, default=time.time)
    expires_at: Mapped[float] = mapped_column(Float)


class ServerSettings(Base):
    """Singleton row (id always 1) holding operator-level SMTP config, set
    from /admin -- not env vars, since this is infrastructure a household
    admin configures once through the UI rather than at container
    deploy time. Used for password-reset emails and battery-threshold
    alerts (see app/mail.py). smtp_host empty = email sending disabled;
    every send site checks that and no-ops rather than erroring."""

    __tablename__ = "server_settings"

    id: Mapped[int] = mapped_column(primary_key=True)
    smtp_host: Mapped[str] = mapped_column(String, default="")
    smtp_port: Mapped[int] = mapped_column(Integer, default=587)
    smtp_username: Mapped[str] = mapped_column(String, default="")
    smtp_password: Mapped[str] = mapped_column(String, default="")
    smtp_from_address: Mapped[str] = mapped_column(String, default="")
    # "none" (plaintext, port 25 typically), "starttls" (upgrades a
    # plaintext connection, port 587 typically), or "ssl" (TLS from the
    # first byte -- a different handshake entirely, not just starttls()
    # skipped; port 465 typically). See app/mail.py.
    smtp_encryption: Mapped[str] = mapped_column(String, default="starttls")


class PasswordResetToken(Base):
    """A single-use, time-limited "forgot password" link. token is the
    URL-safe secret itself (not hashed, like PendingClaim/manage_token --
    it's a short-lived bearer credential emailed once, not a long-lived
    session secret)."""

    __tablename__ = "password_reset_tokens"

    token: Mapped[str] = mapped_column(String, primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
    created_at: Mapped[float] = mapped_column(Float, default=time.time)
    expires_at: Mapped[float] = mapped_column(Float)


class BatteryLog(Base):
    """Every battery report ever, per frame -- the permanent record behind
    the battery history chart (was a 20k-entry JSON array in config.json)."""

    __tablename__ = "battery_log"

    id: Mapped[int] = mapped_column(primary_key=True)
    frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
    ts: Mapped[float] = mapped_column(Float)
    percent: Mapped[int] = mapped_column(Integer)

    __table_args__ = (Index("ix_battery_log_frame_ts", "frame_id", "ts"),)
