"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/...
Per-widget settings (album, calendar view/inclusion, whiteboard source,
etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/
{widget_id}/... -- split out once a frame could hold more than one
widget of the same type.

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

Config saves are PARTIAL updates: only provided fields are touched.
Checkboxes are sent explicitly as "true"/"false" strings by the page JS
-- an absent field means "not this form's field", never "unchecked".
"""

from __future__ import annotations

import logging
import time

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

from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
from .device import render_frame_preview_png

logger = logging.getLogger(__name__)

router = APIRouter()

MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400

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


def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
    """A widget's x/y/w/h are grid cells relative to the OLD orientation's
    cols x rows (see grid.grid_dims) -- landscape and portrait use a
    transposed grid (8x5 vs 5x8), so an existing placement is often
    literally out of bounds on the new grid, not just visually wrong.
    There's no sensible coordinate remap between two differently-shaped
    grids, so instead: keep whichever widget was first by placement
    order, resized to fill the new full panel, and delete the rest --
    cascading to their own config rows and any FrameButtonAction
    bindings via ondelete="CASCADE" (see models.py). The frontend is
    expected to confirm this with the user before submitting an
    orientation change (see frame_config.js) -- this always executes
    unconditionally once called, same posture as every other
    confirm-on-the-client / act-unconditionally-on-the-server action in
    this codebase."""
    widgets = db.scalars(
        select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
    ).all()
    if not widgets:
        return
    keep, *rest = widgets
    for widget in rest:
        db.delete(widget)
    keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)


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


@router.post("/api/frames/{frame_id}/config")
def api_config_save(
    name: str | None = Form(None),
    refresh_interval_s: int | None = Form(None),
    orientation: str | None = Form(None),
    quiet_hours_enabled: bool | None = Form(None),
    quiet_hours_start: str | None = Form(None),
    quiet_hours_end: str | None = Form(None),
    timezone: str | None = Form(None),
    firmware_update_repo_url: str | None = Form(None),
    firmware_auto_update: bool | None = Form(None),
    battery_alert_threshold_pct: int | None = Form(None),
    palette: list[str] | None = Form(None),
    palette_reset: bool | None = Form(None),
    color_boost: float | None = Form(None),
    contrast_boost: float | None = Form(None),
    dither_strength: float | None = Form(None),
    frame: Frame = Depends(require_frame_control),
    db: Session = Depends(get_db),
):
    """Partial update of frame-wide settings only -- per-widget settings
    (album, calendar view/inclusion, whiteboard source, etc.) live on
    routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead,
    since a frame can hold more than one widget of the same type and
    "the frame's calendar settings" stopped being unambiguous the moment
    that became possible. `mode` and `calendar_photo_inlay` are no
    longer accepted here either: mode no longer governs anything (a
    frame's widgets do), and photo inlay has no widget-system equivalent
    (place an independent photo widget alongside instead). All three are
    harmless no-ops if an old cached page still POSTs them -- FastAPI
    silently ignores form fields with no matching parameter.

    An actual orientation *change* resets the frame's widget layout (see
    _reset_widget_layout_for_new_orientation) -- widget placement is
    grid-cell-relative to the panel's long/short axis, which swaps on a
    landscape<->portrait change, so an old placement is usually not just
    visually wrong but literally out of bounds on the new grid."""
    with frame_locked(db, frame.id) as cfg:
        if name is not None:
            cfg.name = name.strip()[:64] or cfg.name
        if refresh_interval_s is not None:
            cfg.refresh_interval_s = max(
                MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
            )
        if orientation is not None:
            new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
            if new_orientation != cfg.orientation:
                _reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
            cfg.orientation = new_orientation
        if quiet_hours_enabled is not None:
            cfg.quiet_hours_enabled = quiet_hours_enabled
        if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
            cfg.quiet_hours_start = quiet_hours_start
        if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
            cfg.quiet_hours_end = quiet_hours_end
        if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
            cfg.timezone = timezone
        if firmware_update_repo_url is not None:
            stripped = firmware_update_repo_url.strip()
            if stripped and not valid_http_url(stripped):
                raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
            cfg.firmware_update_repo_url = stripped
        if firmware_auto_update is not None:
            cfg.firmware_auto_update = firmware_auto_update
        if battery_alert_threshold_pct is not None:
            cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
            # A changed threshold should be able to fire again immediately,
            # not stay suppressed by a flag set under the old value.
            cfg.battery_alert_sent = False
        if palette_reset:
            cfg.palette_rgb = None
        elif palette is not None:
            if len(palette) != len(PALETTE_LABELS):
                raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
            parsed = [hex_to_rgb(h) for h in palette]
            if any(rgb is None for rgb in parsed):
                raise HTTPException(400, "Palette colors must be #rrggbb hex values")
            cfg.palette_rgb = [list(rgb) for rgb in parsed]
        if color_boost is not None:
            cfg.color_boost = max(0.0, min(2.0, color_boost))
        if contrast_boost is not None:
            cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
        if dither_strength is not None:
            cfg.dither_strength = max(0.0, min(1.0, dither_strength))
        cfg.stats_config_saves += 1

    return {"status": "saved"}


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


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


@router.get("/api/frames/{frame_id}/status")
def api_status(
    request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
    """Device liveness + control-lock info -- frame-level facts (battery,
    last-seen, firmware, who has control), not tied to any particular
    widget. Powers static/device_status_bar.js, shown on every per-frame
    page regardless of which widgets that frame has. Used to piggyback on
    the photo queue endpoint (back when a frame had at most one widget,
    always photos-shaped); split out once that stopped being true, so the
    status bar isn't blank on a frame with no photo widget."""
    user = require_user_api(request, db)
    now = time.time()
    overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
    return {
        "control": {
            "controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
            "you": frame.controlled_by_user_id == user.id,
        },
        "device": {
            "last_seen": frame.last_seen or None,
            "overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
            "firmware_version": frame.device_firmware_version or None,
            "firmware_available": frame.firmware_available_version or None,
            "battery": (
                {"percent": frame.battery_percent, "as_of": frame.battery_as_of}
                if frame.battery_percent >= 0 else None
            ),
            "battery_estimate_s": battery_estimate_s(frame, db),
        },
    }


@router.get("/api/frames/{frame_id}/preview")
def api_frame_preview(
    request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
    """A small PNG of exactly what the frame is currently displaying --
    the same widget compositor /frame/image uses (see routers/device.py's
    render_frame_preview_png), just handed back upright and unpacked for
    the dashboard header's live thumbnail instead of the device's packed
    native format. Not cached: cheap enough for an on-demand header image,
    and each widget's own render is already idempotent between a device's
    real wakes (see photo_queue.get_current, calendar widget's browse
    reset), so an extra read here doesn't skip or duplicate anything."""
    png = render_frame_preview_png(db, frame, request)
    return Response(content=png, media_type="image/png")


BUTTONS = ("next", "back")


@router.get("/api/frames/{frame_id}/buttons")
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
    """Everything the button-assignment UI needs in one call: every
    widget on the frame with the actions its type supports (see
    app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
    ordered list of (widget, action) bindings.

    Includes each widget's placement (x/y/w/h) and the frame's grid
    dimensions -- two widgets of the same type otherwise look identical
    in the assignment UI's dropdowns (both just say "Photos"); the
    client derives a position label ("top-left" etc.) from this to tell
    them apart, the same way you'd tell them apart by eye on the Layout
    canvas."""
    cols, rows = grid.grid_dims(frame.orientation)
    widgets = db.scalars(
        select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
    ).all()
    widget_options = [
        {
            "id": w.id,
            "widget_type": w.widget_type,
            "x": w.x, "y": w.y, "w": w.w, "h": w.h,
            "actions": [
                {"action": action, "label": label}
                for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
            ],
        }
        for w in widgets
    ]
    result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
    for button in BUTTONS:
        rows = db.scalars(
            select(FrameButtonAction)
            .where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
            .order_by(FrameButtonAction.sort_order)
        ).all()
        result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
    return result


class ButtonActionItem(BaseModel):
    widget_id: int
    action: str


class ButtonActionsRequest(BaseModel):
    actions: list[ButtonActionItem]


@router.put("/api/frames/{frame_id}/buttons/{button}")
def api_buttons_save(
    button: str, body: ButtonActionsRequest,
    frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
    """Replaces the whole ordered action list for one button in a single
    call -- simpler and more atomic than separate add/remove/reorder
    endpoints for what's normally a list of one to a handful of entries,
    and the UI always has the full list in hand anyway (see
    static/frame_config.js)."""
    if button not in BUTTONS:
        raise HTTPException(404, "No such button")
    widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
    for item in body.actions:
        widget = widgets_by_id.get(item.widget_id)
        if widget is None:
            raise HTTPException(400, f"No such widget: {item.widget_id}")
        module = WIDGET_TYPES.get(widget.widget_type)
        if module is None or item.action not in module.ACTIONS:
            raise HTTPException(
                400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
            )
    with frame_locked(db, frame.id):
        db.execute(
            delete(FrameButtonAction).where(
                FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
            )
        )
        for i, item in enumerate(body.actions):
            db.add(FrameButtonAction(
                frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
                sort_order=i, created_at=time.time(),
            ))
        db.commit()
    return {"status": "saved"}


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



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


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


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


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

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

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

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

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


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