"""Tracks which photo is currently displayed, what's queued up next, and
what's already been shown.

`current_asset_id` only ever changes two ways: the configured refresh
interval elapsing (`get_current`, called on every `GET /frame/image` --
a no-op otherwise, so an unplanned device reboot just redisplays the same
photo instead of silently skipping ahead) or an explicit forced advance
(`advance_forced`, called from `POST /frame/advance` -- the next-photo
button -- ignoring elapsed time).

`queue` is a small reorderable lookahead the web UI can preview and
rearrange, topped up (or trimmed) automatically to match
`cfg.queue_target_len` (user-configurable from the web UI) as it's
consumed. `queue_cursor` is separate, internal-only bookkeeping for where
sequential top-up resumes in the album -- not shown or reordered in the UI.

`history` is the mirror image of `queue`: every time `advance_forced`
actually changes `current_asset_id`, the old one is pushed onto
`history`. `back_forced` (the back-photo button) is the exact reverse of
`advance_forced` -- it pops `history` back into `current_asset_id` and
pushes the photo it's replacing onto the *front* of `queue`, so pressing
next afterwards lands you right back where you were.

`excluded_asset_ids` is a permanent (until explicitly un-excluded, which
there's no UI for yet) block list -- `_top_up()` never selects an
excluded photo, and `remove_from_rotation()` scrubs one out of
`queue`/`history` too, so it can't resurface via "Show next" or the back
button either. This doesn't touch Immich at all -- the photo stays in
the album, it's just never chosen by this frame again.
"""

from __future__ import annotations

import random
import time

from .models import Frame

HISTORY_MAX_LEN = 20


def _top_up(cfg: Frame, assets: list[dict]) -> None:
    valid_ids = {a["id"] for a in assets}
    excluded_ids = set(cfg.excluded_asset_ids)
    cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids and asset_id not in excluded_ids]

    target = cfg.queue_target_len
    if len(cfg.queue) > target:
        # Target was lowered since this queue was built -- shrink it
        # immediately rather than waiting for enough advances to consume
        # the excess naturally.
        cfg.queue = cfg.queue[:target]
        return

    needed = target - len(cfg.queue)
    if needed <= 0 or not assets:
        return

    excluded = set(cfg.queue) | set(cfg.excluded_asset_ids)
    if cfg.current_asset_id:
        excluded.add(cfg.current_asset_id)

    if cfg.order == "shuffle":
        candidates = [a["id"] for a in assets if a["id"] not in excluded]
        cfg.queue.extend(random.sample(candidates, min(needed, len(candidates))))
        return

    # Sequential: walk the album starting at queue_cursor, at most one full
    # pass, wrapping around. queue_cursor resumes right after wherever this
    # pass stopped, whether or not it filled the queue (e.g. a small album
    # where everything's already queued/current -- next call is then a
    # cheap no-op scan until something's consumed).
    n = len(assets)
    cfg.queue_cursor %= n
    added = 0
    i = 0
    for i in range(n):
        if added >= needed:
            break
        asset_id = assets[(cfg.queue_cursor + i) % n]["id"]
        if asset_id not in excluded:
            cfg.queue.append(asset_id)
            excluded.add(asset_id)
            added += 1
    cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n


def advance_forced(cfg: Frame, assets: list[dict], frame: Frame) -> None:
    """Unconditionally moves to the next photo, ignoring elapsed time, and
    resets the interval clock from now. Used by the explicit next-photo
    action (POST /frame/advance) and by get_current() once the refresh
    interval has elapsed -- always mutates cfg.

    `frame` is a separate reference to the owning Frame, for fields that
    stay frame-level rather than moving onto a photo widget's own config
    (currently just stats_photos_displayed) -- once a photo widget's
    queue state lives on its own PhotoWidgetConfig row rather than
    directly on Frame (see models.py), `cfg` and `frame` stop being the
    same object; every existing caller today still passes the same Frame
    for both, which is also why this stays a required (not optional)
    param -- no implicit "guess which Frame owns this" fallback to get
    wrong later."""
    if cfg.current_asset_id:
        # Recorded regardless of *why* this advance happened (a manual
        # next-press or the timer just elapsing) -- back should be able
        # to undo either kind.
        cfg.history.append(cfg.current_asset_id)
        cfg.history = cfg.history[-HISTORY_MAX_LEN:]

    _top_up(cfg, assets)
    if cfg.queue:
        cfg.current_asset_id = cfg.queue.pop(0)
    elif assets:
        # Queue still empty after top-up (e.g. a single-photo album whose
        # only asset is already current) -- keep showing what we have.
        cfg.current_asset_id = assets[0]["id"]
    cfg.current_asset_set_at = time.time()
    frame.stats_photos_displayed += 1
    # Refill back up to queue_target_len now that current_asset_id has
    # changed -- otherwise the queue is left one short until the *next*
    # advance, since the pop above consumes one of the items _top_up just
    # added.
    _top_up(cfg, assets)


def back_forced(cfg: Frame, assets: list[dict], frame: Frame) -> bool:
    """Unconditionally moves to the previously-current photo, the mirror
    image of advance_forced() -- pops the most recent entry off history,
    pushes the photo it's replacing onto the front of queue (so pressing
    next afterwards returns to it), and resets the interval clock from
    now. Skips over any history entries no longer in the album (deleted
    since). Returns whether it actually moved -- False (history empty or
    entirely stale) is a no-op, callers should still just display
    whatever's current rather than treating it as an error. Used by the
    back-photo button (POST /frame/back). See advance_forced() on `frame`."""
    valid_ids = {a["id"] for a in assets}
    while cfg.history:
        previous_id = cfg.history.pop()
        if previous_id not in valid_ids:
            continue
        if cfg.current_asset_id:
            cfg.queue.insert(0, cfg.current_asset_id)
        cfg.current_asset_id = previous_id
        cfg.current_asset_set_at = time.time()
        frame.stats_photos_displayed += 1
        return True
    return False


def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str, frame: Frame) -> bool:
    """Permanently excludes asset_id from this frame's rotation (see the
    module docstring) -- doesn't touch Immich, just this frame's own
    selection. Scrubs it out of queue and history too, so it can't
    resurface via "Show next" or the back button either. If it was the
    current photo, immediately advances to a new one -- deliberately
    *not* through advance_forced(), since that would record the removed
    photo in history, and going back to a photo you just explicitly
    removed doesn't make sense. Returns whether the current photo
    changed as a result. See advance_forced() on `frame`."""
    if asset_id not in cfg.excluded_asset_ids:
        cfg.excluded_asset_ids.append(asset_id)
        frame.stats_photos_removed += 1
    cfg.queue = [a for a in cfg.queue if a != asset_id]
    cfg.history = [a for a in cfg.history if a != asset_id]

    if asset_id != cfg.current_asset_id:
        return False

    _top_up(cfg, assets)
    if cfg.queue:
        cfg.current_asset_id = cfg.queue.pop(0)
    else:
        # Queue empty even after top-up (e.g. everything else is also
        # excluded, or a tiny album) -- fall back to any remaining
        # non-excluded asset, or give up and show nothing.
        excluded_ids = set(cfg.excluded_asset_ids)
        remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
        cfg.current_asset_id = remaining[0] if remaining else ""
    cfg.current_asset_set_at = time.time()
    frame.stats_photos_displayed += 1
    _top_up(cfg, assets)
    return True


def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
    """Tops up or trims cfg.queue to match cfg.queue_target_len without
    otherwise touching current_asset_id. Used by GET /api/queue so a
    change to the "upcoming photos to show" setting takes effect on page
    load rather than waiting for the next natural advance."""
    _top_up(cfg, assets)


def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bool = False) -> bool:
    """Time-based, idempotent path used by GET /frame/image. Advances only
    if the current photo is unset/invalid or refresh_interval_s has
    elapsed since it was set. Returns whether it changed anything, so the
    caller knows whether to persist. Calling this repeatedly well within
    the interval is a no-op both times -- what makes an unplanned device
    reboot safe: it just re-reads the current photo instead of skipping
    ahead, while a wake that lands after the interval has elapsed still
    advances exactly once, even after a long time offline.

    refresh_interval_s is read off `frame`, not `cfg` -- it's a device
    wake-cadence setting shared by the whole panel, not something that
    becomes per-widget (see advance_forced() on the cfg/frame split).

    in_quiet_hours suppresses *only* the elapsed-time trigger -- an
    unset/invalid current photo still gets picked regardless, since
    showing nothing is worse than showing something even at 3am. This
    check runs independent of the device (also triggered by the web UI's
    /api/queue), so without this an open browser tab polling overnight
    would silently advance the current photo on raw elapsed time alone,
    even though the device itself is correctly asleep through the
    window (see main.py's _effective_refresh_interval_s)."""
    valid_ids = {a["id"] for a in assets}
    needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
    time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
    stale = needs_pick or (time_elapsed and not in_quiet_hours)
    if not stale:
        return False
    advance_forced(cfg, assets, frame)
    return True
