"""Schema versioning + one-time import of a legacy config.json deployment.

Hand-rolled on purpose (vs alembic): single worker, single SQLite file,
~30 lines of runner. Each migration is (version, fn(connection)); v1 is
just create_all. DDL stays dialect-neutral so a future move to Postgres
is a DATABASE_URL change, not a rewrite.

Run at import time from main.py, before any request is served.
"""

from __future__ import annotations

import logging
import secrets
import shutil
import time

from sqlalchemy import select, text

from . import config, grid
from .db import SessionLocal, engine
from .models import (
    Base,
    BatteryLog,
    CalendarWidgetConfig,
    Frame,
    FrameButtonAction,
    PhotoWidgetConfig,
    ServerSettings,
    WhiteboardWidgetConfig,
    Widget,
)

logger = logging.getLogger(__name__)


def _migration_1(conn) -> None:
    Base.metadata.create_all(bind=conn)


def _migration_2(conn) -> None:
    """Adds email (users) and battery-alert threshold (frames) columns,
    plus the new server_settings/password_reset_tokens tables. ALTER
    TABLE ADD COLUMN with a default is safe on SQLite against a live,
    already-populated database -- existing rows just get the default."""
    conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
    Base.metadata.create_all(bind=conn)  # creates the two new tables only; existing ones untouched


def _migration_3(conn) -> None:
    """Replaces the STARTTLS-or-nothing smtp_use_tls boolean with a
    three-way smtp_encryption ("none"/"starttls"/"ssl") -- implicit TLS
    (port 465 typically) is a different handshake entirely, not just a
    skipped starttls() call, so it needs its own connection path in
    app/mail.py."""
    conn.execute(text("ALTER TABLE server_settings ADD COLUMN smtp_encryption TEXT NOT NULL DEFAULT 'starttls'"))
    conn.execute(text(
        "UPDATE server_settings SET smtp_encryption = CASE WHEN smtp_use_tls THEN 'starttls' ELSE 'none' END"
    ))
    conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))


def _migration_4(conn) -> None:
    """Advanced configuration: a per-frame color palette override. NULL
    for every existing row -- exactly "use the default", no behavior
    change until a frame's Configuration tab sets one."""
    conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))


def _migration_5(conn) -> None:
    """Replaces the smart_crop_faces boolean with display_mode (see
    image_pipeline.DISPLAY_MODES) -- crop_faces/crop_fill are exactly
    the old True/False behavior, stretch_fill/letterbox are new."""
    conn.execute(text("ALTER TABLE frames ADD COLUMN display_mode TEXT NOT NULL DEFAULT 'crop_faces'"))
    conn.execute(text(
        "UPDATE frames SET display_mode = CASE WHEN smart_crop_faces THEN 'crop_faces' ELSE 'crop_fill' END"
    ))
    conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces"))


def _migration_6(conn) -> None:
    """Advanced configuration: color/contrast enhancement + dithering
    strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0)
    reproduce the exact previous rendering -- no behavior change until a
    frame's Configuration tab adjusts one."""
    conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))


def _migration_7(conn) -> None:
    """Calendar frame mode: a personal ICS subscription per user
    (users.calendar_ics_url), an explicit per-(user,frame) opt-in into a
    frame's merged calendar (user_frames.calendar_included, default off
    -- linking to a frame does not auto-include your calendar there),
    and the frame-level view/inlay/browse-offset/cache settings calendar
    mode needs (see calendar_feed.py, calendar_render.py,
    routers/device.py's RENDERERS["calendar"]). Every new column has a
    behavior-preserving default -- no existing frame's behavior changes
    until its mode is actually switched to "calendar"."""
    conn.execute(text("ALTER TABLE users ADD COLUMN calendar_ics_url TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE user_frames ADD COLUMN calendar_included INTEGER NOT NULL DEFAULT 0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_view TEXT NOT NULL DEFAULT 'agenda'"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_photo_inlay INTEGER NOT NULL DEFAULT 0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_browse_offset INTEGER NOT NULL DEFAULT 0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_checked_at REAL NOT NULL DEFAULT 0.0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_cached_events TEXT"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))


def _migration_8(conn) -> None:
    """Configurable week-start day for calendar mode's week/month views
    (0=Monday..6=Sunday, matching Python's date.weekday()/calendar.Calendar
    convention exactly -- no translation needed at render time). Default 0
    (Monday) matches calendar_render.py's previous hardcoded behavior, so
    this is a no-op for every existing frame until changed."""
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start INTEGER NOT NULL DEFAULT 0"))



def _migration_9(conn) -> None:
    """CalDAV support alongside the plain ICS subscription (see
    caldav_client.py), and the frame_calendars table that replaces
    user_frames.calendar_included now that one account (CalDAV) can
    expose more than one calendar -- see models.py's FrameCalendar.
    Existing single-calendar opt-ins are carried forward as "ics" rows
    before the old column is dropped, so nobody's frame goes silently
    calendar-less after this migration."""
    conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
    conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))

    conn.execute(text(
        "CREATE TABLE frame_calendars ("
        "id INTEGER PRIMARY KEY, "
        "frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
        "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
        "calendar_key TEXT NOT NULL, "
        "calendar_label TEXT NOT NULL DEFAULT '', "
        "included INTEGER NOT NULL DEFAULT 1)"
    ))
    conn.execute(text(
        "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
    ))
    conn.execute(text(
        "INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
        "SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
        "FROM user_frames uf JOIN users u ON u.id = uf.user_id "
        "WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
    ))
    conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))


def _migration_10(conn) -> None:
    """Optional weather strip for calendar mode (agenda/today & tomorrow/
    week views -- never month, see calendar_render.py's _BUILDERS).
    Multiple cities per frame (calendar_weather_cities), each geocoded
    once via weather.py's Open-Meteo lookup (no API key) and their daily
    forecasts refreshed on their own throttle, same shape idiom as
    calendar_checked_at/calendar_cached_events. Off by default -- no
    existing frame's render changes until its Calendar tab turns it on."""
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_enabled INTEGER NOT NULL DEFAULT 0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cities TEXT"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))


def _migration_11(conn) -> None:
    """Manual per-calendar color choice (frame_calendars.color_index,
    2-5 into image_pipeline.DEFAULT_PALETTE_RGB -- Yellow/Red/Blue/
    Green). calendar_render.py's event color bar/dot used to auto-cycle
    through those same four colors in whatever order calendars happened
    to appear; this lets a household pin a specific one instead so it
    stays stable and recognizable. NULL (the default) keeps the old
    auto-cycle behavior -- no existing frame's render changes until
    someone actually picks a color."""
    conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER"))


def _migration_12(conn) -> None:
    """Week view flexibility: a configurable day count (2-10, default 7
    -- the original fixed behavior) and a horizontal/vertical layout
    choice, plus an optional CalDAV task list that takes the space of
    one day slot when enabled (see calendar_render.py's _build_week/
    _draw_tasks). Every new column has a behavior-preserving default --
    no existing frame's render changes until its Calendar tab touches
    one of these."""
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_days INTEGER NOT NULL DEFAULT 7"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_calendar_key TEXT"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_cached TEXT"))


def _migration_13(conn) -> None:
    """A day-count-relative start offset for the week view
    (calendar_week_start_offset), used instead of calendar_week_start's
    fixed-weekday anchor once the view isn't a literal 7-day week --
    "start on the most recent Monday" stops meaning much for e.g. a
    5-day view. Default 0 (starts today) is a behavior-preserving no-op
    until someone changes the day count away from 7."""
    conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))


def _migration_14(conn) -> None:
    """Whiteboard frame mode: generic WebDAV credentials per user
    (webdav_username/password, plus webdav_reuse_caldav_creds as a
    convenience when it's the same Nextcloud account as an already-
    configured CalDAV one -- see models.py's User docstring), and the
    frame-level whiteboard source (whiteboard_user_id/url) + rendered-
    PNG cache (see webdav_client.py, whiteboard.py,
    routers/device.py's RENDERERS["whiteboard"]). Every new column has a
    behavior-preserving default -- no existing frame's render changes
    until its mode is actually switched to "whiteboard"."""
    conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
    conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))


def _migration_15(conn) -> None:
    """Optional starting folder for the whiteboard file-picker (see
    models.py's User.webdav_base_url docstring) -- purely a browsing
    convenience, never used for actual fetch/render."""
    conn.execute(text("ALTER TABLE users ADD COLUMN webdav_base_url TEXT NOT NULL DEFAULT ''"))


def _migration_16(conn) -> None:
    """Widget system: a frame can now hold N independently placed/sized
    widgets (photos/calendar/whiteboard) instead of exactly one mode-wide
    renderer -- see models.py's Widget/PhotoWidgetConfig/
    CalendarWidgetConfig/WhiteboardWidgetConfig/FrameButtonAction,
    app/grid.py, app/widgets/.

    This migration only creates the new (empty) tables -- it does NOT
    backfill a widget per existing frame here. That backfill (reading
    each frame's current mode/settings to build a widget that reproduces
    its exact current display, including the calendar_photo_inlay ->
    two-widgets special case) is real per-mode branching logic that's
    much less error-prone written as typed ORM object construction than
    as hand-written column-by-column SQL -- see _ensure_widgets_backfilled,
    called unconditionally at the end of run_migrations() for both this
    upgrade path AND the from-scratch _ensure_frame_one() path, so both
    produce the same default-widget invariant from one place rather than
    two separately-maintained ones. Every existing frame is briefly
    widget-less between this migration and that call within the same
    startup, not across restarts -- nothing reads these tables yet at
    that point regardless."""
    conn.execute(text(
        "CREATE TABLE widgets ("
        "id INTEGER PRIMARY KEY, "
        "frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
        "widget_type TEXT NOT NULL, "
        "x INTEGER NOT NULL, "
        "y INTEGER NOT NULL, "
        "w INTEGER NOT NULL, "
        "h INTEGER NOT NULL, "
        "sort_order INTEGER NOT NULL DEFAULT 0, "
        "created_at REAL NOT NULL DEFAULT 0.0)"
    ))
    conn.execute(text("CREATE INDEX ix_widgets_frame ON widgets (frame_id)"))

    conn.execute(text(
        "CREATE TABLE photo_widget_configs ("
        "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
        "album_id TEXT NOT NULL DEFAULT '', "
        "photo_order TEXT NOT NULL DEFAULT 'sequential', "
        "display_mode TEXT NOT NULL DEFAULT 'crop_faces', "
        "queue_target_len INTEGER NOT NULL DEFAULT 20, "
        "current_asset_id TEXT NOT NULL DEFAULT '', "
        "current_asset_set_at REAL NOT NULL DEFAULT 0.0, "
        "queue TEXT NOT NULL DEFAULT '[]', "
        "queue_cursor INTEGER NOT NULL DEFAULT 0, "
        "history TEXT NOT NULL DEFAULT '[]', "
        "excluded_asset_ids TEXT NOT NULL DEFAULT '[]')"
    ))

    conn.execute(text(
        "CREATE TABLE calendar_widget_configs ("
        "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
        "view TEXT NOT NULL DEFAULT 'agenda', "
        "week_start INTEGER NOT NULL DEFAULT 0, "
        "browse_offset INTEGER NOT NULL DEFAULT 0, "
        "checked_at REAL NOT NULL DEFAULT 0.0, "
        "cached_events TEXT, "
        "fetch_summary TEXT NOT NULL DEFAULT '', "
        "weather_enabled INTEGER NOT NULL DEFAULT 0, "
        "weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
        "weather_cities TEXT, "
        "weather_checked_at REAL NOT NULL DEFAULT 0.0, "
        "weather_cached TEXT, "
        "week_days INTEGER NOT NULL DEFAULT 7, "
        "week_layout TEXT NOT NULL DEFAULT 'horizontal', "
        "week_start_offset INTEGER NOT NULL DEFAULT 0, "
        "tasks_enabled INTEGER NOT NULL DEFAULT 0, "
        "tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
        "tasks_calendar_key TEXT, "
        "tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
        "tasks_cached TEXT)"
    ))

    conn.execute(text(
        "CREATE TABLE whiteboard_widget_configs ("
        "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
        "user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
        "url TEXT NOT NULL DEFAULT '', "
        "checked_at REAL NOT NULL DEFAULT 0.0, "
        "cached_image BLOB)"
    ))

    conn.execute(text(
        "CREATE TABLE frame_button_actions ("
        "id INTEGER PRIMARY KEY, "
        "frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
        "button TEXT NOT NULL, "
        "widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
        "action TEXT NOT NULL, "
        "sort_order INTEGER NOT NULL DEFAULT 0, "
        "created_at REAL NOT NULL DEFAULT 0.0)"
    ))
    conn.execute(text(
        "CREATE INDEX ix_frame_button_actions_frame_button ON frame_button_actions (frame_id, button, sort_order)"
    ))


MIGRATIONS = [
    (1, _migration_1),
    (2, _migration_2),
    (3, _migration_3),
    (4, _migration_4),
    (5, _migration_5),
    (6, _migration_6),
    (7, _migration_7),
    (8, _migration_8),
    (9, _migration_9),
    (10, _migration_10),
    (11, _migration_11),
    (12, _migration_12),
    (13, _migration_13),
    (14, _migration_14),
    (15, _migration_15),
    (16, _migration_16),
]


def run_migrations() -> None:
    with engine.begin() as conn:
        conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
        row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
        if row is None:
            # Brand new database: _migration_1's create_all() already
            # produces today's full schema straight from models.py.
            # Every migration after it is an incremental ALTER/UPDATE
            # meant to bring an *existing* install forward -- replaying
            # those here would just collide with columns create_all
            # already added (e.g. "duplicate column name"). Jump
            # straight to the latest version instead.
            _migration_1(conn)
            latest = MIGRATIONS[-1][0]
            conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
        else:
            current = row[0]
            for version, fn in MIGRATIONS:
                if version > current:
                    logger.info("Applying schema migration %d", version)
                    fn(conn)
                    conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
    _ensure_frame_one()
    _ensure_server_settings()
    _ensure_widgets_backfilled()


def new_device_token() -> str:
    return secrets.token_urlsafe(32)


def new_manage_token() -> str:
    return secrets.token_urlsafe(16)


def _ensure_frame_one() -> None:
    """First boot only (frames table empty): create frame #1 -- imported
    verbatim from a legacy config.json if one exists, otherwise fresh
    defaults. Either way it's the legacy-token frame: the deployed
    firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
    and require_device resolves those requests here. The frames-nonempty
    guard makes this idempotent; config.json is left untouched as the
    rollback path."""
    with SessionLocal() as db:
        if db.scalars(select(Frame).limit(1)).first() is not None:
            return

        cfg = config.load()  # all defaults if the file doesn't exist
        had_file = config.CONFIG_PATH.exists()

        frame = Frame(
            name="Frame 1",
            device_id=None,
            device_token=new_device_token(),
            manage_token=new_manage_token(),
            legacy_token_enabled=True,
            created_at=time.time(),
            immich_url=cfg.immich_url,
            immich_api_key=cfg.immich_api_key,
            album_id=cfg.album_id,
            order=cfg.order,
            refresh_interval_s=cfg.refresh_interval_s,
            quiet_hours_enabled=cfg.quiet_hours_enabled,
            quiet_hours_start=cfg.quiet_hours_start,
            quiet_hours_end=cfg.quiet_hours_end,
            timezone=cfg.timezone,
            display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
            orientation=cfg.orientation,
            queue_target_len=cfg.queue_target_len,
            current_asset_id=cfg.current_asset_id,
            current_asset_set_at=cfg.current_asset_set_at,
            queue=list(cfg.queue),
            queue_cursor=cfg.queue_cursor,
            history=list(cfg.history),
            excluded_asset_ids=list(cfg.excluded_asset_ids),
            battery_percent=cfg.battery_percent,
            battery_as_of=cfg.battery_as_of,
            battery_history=[list(pair) for pair in cfg.battery_history],
            last_seen=cfg.last_seen,
            device_firmware_version=cfg.device_firmware_version,
            device_board_variant=cfg.device_board_variant,
            firmware_available_version=cfg.firmware_available_version,
            firmware_update_repo_url=cfg.firmware_update_repo_url,
            firmware_auto_update=cfg.firmware_auto_update,
            firmware_update_token=cfg.firmware_update_token,
            firmware_update_checked_at=cfg.firmware_update_checked_at,
            firmware_gitea_latest_version=cfg.firmware_gitea_latest_version,
            stats_first_seen=cfg.stats.first_seen,
            stats_device_wakes=cfg.stats.device_wakes,
            stats_photos_displayed=cfg.stats.photos_displayed,
            stats_photos_removed=cfg.stats.photos_removed,
            stats_battery_reports=cfg.stats.battery_reports,
            stats_recharge_cycles=cfg.stats.recharge_cycles,
            stats_ota_updates_applied=cfg.stats.ota_updates_applied,
            stats_config_saves=cfg.stats.config_saves,
        )
        db.add(frame)
        db.flush()  # assign frame.id for the battery log rows

        for pair in cfg.battery_log:
            db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))

        db.commit()

        # The single legacy firmware slot becomes frame #1's per-frame slot.
        legacy_bin = config.CONFIG_PATH.parent / "firmware.bin"
        if legacy_bin.exists():
            per_frame_dir = config.CONFIG_PATH.parent / "firmware"
            per_frame_dir.mkdir(parents=True, exist_ok=True)
            shutil.copy2(legacy_bin, per_frame_dir / f"{frame.id}.bin")

        if had_file:
            logger.info(
                "Imported legacy config.json as frame #%d (%d battery log entries)",
                frame.id,
                len(cfg.battery_log),
            )
        else:
            logger.info("Fresh install: created default frame #%d", frame.id)


def _ensure_server_settings() -> None:
    """The SMTP config singleton (id=1) -- created with everything blank
    (email sending disabled) the first time this runs; /admin edits it in
    place from then on."""
    with SessionLocal() as db:
        if db.get(ServerSettings, 1) is None:
            db.add(ServerSettings(id=1))
            db.commit()


def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
    return PhotoWidgetConfig(
        widget_id=widget_id,
        album_id=frame.album_id,
        order=frame.order,
        display_mode=frame.display_mode,
        queue_target_len=frame.queue_target_len,
        current_asset_id=frame.current_asset_id,
        current_asset_set_at=frame.current_asset_set_at,
        queue=list(frame.queue),
        queue_cursor=frame.queue_cursor,
        history=list(frame.history),
        excluded_asset_ids=list(frame.excluded_asset_ids),
    )


def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
    return CalendarWidgetConfig(
        widget_id=widget_id,
        view=frame.calendar_view,
        week_start=frame.calendar_week_start,
        browse_offset=frame.calendar_browse_offset,
        checked_at=frame.calendar_checked_at,
        cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
        fetch_summary=frame.calendar_fetch_summary,
        weather_enabled=frame.calendar_weather_enabled,
        weather_units=frame.calendar_weather_units,
        weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
        weather_checked_at=frame.calendar_weather_checked_at,
        weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
        week_days=frame.calendar_week_days,
        week_layout=frame.calendar_week_layout,
        week_start_offset=frame.calendar_week_start_offset,
        tasks_enabled=frame.calendar_tasks_enabled,
        tasks_user_id=frame.calendar_tasks_user_id,
        tasks_calendar_key=frame.calendar_tasks_calendar_key,
        tasks_checked_at=frame.calendar_tasks_checked_at,
        tasks_cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
    )


def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
    return WhiteboardWidgetConfig(
        widget_id=widget_id,
        user_id=frame.whiteboard_user_id,
        url=frame.whiteboard_url,
        checked_at=frame.whiteboard_checked_at,
        cached_image=frame.whiteboard_cached_image,
    )


def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
    """NEXT/BACK -> whatever this widget's own advance/back concept is
    (see app/widgets/ for the actual action registry, built in a later
    phase) -- reproduces each mode's exact old button behavior for the
    one auto-migrated widget, so upgrading changes nothing about what the
    physical buttons do until someone deliberately reassigns them."""
    if widget_type == "whiteboard":
        # No real "next"/"back" concept for a static board -- both
        # buttons already meant "check now" before this migration (see
        # the old _advance_whiteboard_mode/_back_whiteboard_mode).
        return [
            FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
            FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
        ]
    return [
        FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
        FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
    ]


def _backfill_frame_widgets(db, frame: Frame) -> None:
    cols, rows = grid.grid_dims(frame.orientation)
    mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"

    if mode == "calendar" and frame.calendar_photo_inlay:
        # Reproduces the old fixed 50/50 inlay split as two independent
        # widgets instead of silently dropping half of what the frame was
        # showing -- see models.py's CalendarWidgetConfig docstring on why
        # "photo inlay" isn't a widget-system concept anymore otherwise.
        half = cols // 2
        cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
                            x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
        photo_widget = Widget(frame_id=frame.id, widget_type="photos",
                              x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
        db.add_all([cal_widget, photo_widget])
        db.flush()  # assign ids before the FK'd config rows reference them
        db.add(_calendar_config_from_frame(frame, cal_widget.id))
        db.add(_photo_config_from_frame(frame, photo_widget.id))
        db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
        return

    widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
                    sort_order=0, created_at=time.time())
    db.add(widget)
    db.flush()
    if mode == "photos":
        db.add(_photo_config_from_frame(frame, widget.id))
    elif mode == "calendar":
        db.add(_calendar_config_from_frame(frame, widget.id))
    elif mode == "whiteboard":
        db.add(_whiteboard_config_from_frame(frame, widget.id))
    db.add_all(_default_button_actions(frame.id, widget.id, mode))


def _ensure_widgets_backfilled() -> None:
    """Every frame needs at least one Widget once the widget system is
    live -- runs unconditionally after every startup (both a from-scratch
    _ensure_frame_one() install and an existing-install upgrade past
    _migration_16 land here) and is a no-op for any frame that already
    has one. Builds a widget that reproduces the frame's current mode/
    settings/state exactly, so upgrading never changes what a frame
    displays or what its physical buttons do on its own."""
    with SessionLocal() as db:
        for frame in db.scalars(select(Frame)).all():
            has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
            if has_widget is not None:
                continue
            _backfill_frame_widgets(db, frame)
        db.commit()
