"""Server-side zone sequence playback (audio beats or simulated BPM).

Steps advance on each beat from the audio detector when it is running; otherwise the server
emits beats at the sequence ``simulated_bpm`` rate until playback stops or live audio starts.
"""

from __future__ import annotations

import asyncio
import json
import queue
import threading
from typing import Any, Dict, List, Optional, Tuple

from util.espnow_message import resolve_preset_background_hex

_thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256)
_beat_consumer_started = False
_beat_consumer_lock = threading.Lock()

_sim_beat_task: Optional[asyncio.Task] = None
_sim_beat_token = 0

_beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock()

_pending_play: Optional[Dict[str, Any]] = None
_pending_play_lock = threading.Lock()
_pending_beat_task: Optional[asyncio.Task] = None
_pending_beat_token = 0
_last_thread_beat_phase: Dict[str, Any] = {
    "is_downbeat": True,
    "bar_beat": 1,
}
_sim_beat_counter = 0


def _norm_mac(raw: Any) -> Optional[str]:
    from models.device import normalize_mac

    return normalize_mac(raw)


def _normalize_sequence_lanes(doc: Dict[str, Any]) -> List[List[Dict[str, Any]]]:
    lanes_raw = doc.get("lanes") if isinstance(doc.get("lanes"), list) else []
    lanes = [x for x in lanes_raw if isinstance(x, list)]
    has_any = any(len(x) > 0 for x in lanes)
    steps = doc.get("steps")
    if (not lanes or not has_any) and isinstance(steps, list) and steps:
        lanes = [list(steps)]
    if not lanes:
        lanes = [[]]
    out: List[List[Dict[str, Any]]] = []
    for lane in lanes:
        row: List[Dict[str, Any]] = []
        for s in lane:
            if not isinstance(s, dict):
                continue
            pid = s.get("preset_id", s.get("presetId"))
            try:
                b_raw = s.get("beats")
                b_n = int(b_raw) if b_raw is not None else 1
            except (TypeError, ValueError):
                b_n = 1
            row.append(
                {
                    "preset_id": str(pid).strip() if pid is not None else "",
                    "beats": max(1, b_n),
                    "group_ids": [
                        str(x).strip()
                        for x in (s.get("group_ids") or [])
                        if x is not None and str(x).strip()
                    ],
                }
            )
        out.append(row)
    return out


def _group_ids_for_lane_step(
    sequence_doc: Dict[str, Any],
    step: Dict[str, Any],
    lane_index: int,
    num_lanes: int,
    zone_doc: Optional[Dict[str, Any]] = None,
) -> List[str]:
    _ = zone_doc
    lgs = sequence_doc.get("lanes_group_ids")
    if isinstance(lgs, list) and lane_index < len(lgs):
        for_lane = lgs[lane_index]
        if isinstance(for_lane, list):
            return [str(x).strip() for x in for_lane if x is not None and str(x).strip()]
    # Multi-lane doc with a shorter ``lanes_group_ids``: do not fall back to ``group_ids``
    # (editor stores lane 0's groups there; applying it to other lanes targets the wrong groups).
    if num_lanes > 1 and isinstance(lgs, list) and lane_index >= len(lgs):
        return []
    shared = sequence_doc.get("group_ids")
    if isinstance(shared, list) and shared:
        return [str(x).strip() for x in shared if x is not None and str(x).strip()]
    if num_lanes == 1:
        sg = step.get("group_ids")
        if isinstance(sg, list) and sg:
            return [str(x).strip() for x in sg if x is not None and str(x).strip()]
    return []


def _compute_zone_targets(
    zone_doc: Dict[str, Any], devices: Any, groups: Any
) -> Tuple[List[str], List[str]]:
    names: List[str] = []
    macs: List[str] = []
    gids = zone_doc.get("group_ids")
    if isinstance(gids, list) and gids:
        seen: set = set()
        for gid in gids:
            s = str(gid).strip()
            if not s:
                continue
            g = groups.read(s) if hasattr(groups, "read") else None
            if not isinstance(g, dict):
                continue
            for raw in g.get("devices") or []:
                m = _norm_mac(raw)
                if not m or m in seen:
                    continue
                seen.add(m)
                doc = devices.read(m) if hasattr(devices, "read") else None
                nm = ""
                if isinstance(doc, dict):
                    nm = str(doc.get("name") or "").strip()
                names.append(nm or m)
                macs.append(m)
        return names, macs
    zone_names = zone_doc.get("names")
    if not isinstance(zone_names, list):
        zone_names = []
    name_to_mac: Dict[str, str] = {}
    for did in devices.list():
        m = _norm_mac(did)
        if not m:
            continue
        doc = devices.read(did) or {}
        nm = str(doc.get("name") or "").strip()
        if nm:
            name_to_mac[nm] = m
    for zn in zone_names:
        z = str(zn).strip()
        if not z:
            continue
        m = name_to_mac.get(z)
        if m and m not in macs:
            names.append(z)
            macs.append(m)
    return names, macs


def _sequence_referenced_group_ids(sequence_doc: Dict[str, Any]) -> List[str]:
    """Group ids mentioned on the sequence (shared, per-lane, per-step, legacy steps)."""
    seen: set = set()
    out: List[str] = []

    def add(raw: Any) -> None:
        if raw is None:
            return
        s = str(raw).strip()
        if not s or s in seen:
            return
        seen.add(s)
        out.append(s)

    g0 = sequence_doc.get("group_ids")
    if isinstance(g0, list):
        for x in g0:
            add(x)
    lgs = sequence_doc.get("lanes_group_ids")
    if isinstance(lgs, list):
        for row in lgs:
            if isinstance(row, list):
                for x in row:
                    add(x)
    for lane_key in ("lanes", "steps"):
        lanes_raw = sequence_doc.get(lane_key)
        if not isinstance(lanes_raw, list):
            continue
        for lane in lanes_raw:
            if lane_key == "steps":
                step = lane if isinstance(lane, dict) else None
                if step:
                    sg = step.get("group_ids")
                    if isinstance(sg, list):
                        for x in sg:
                            add(x)
                continue
            if not isinstance(lane, list):
                continue
            for step in lane:
                if not isinstance(step, dict):
                    continue
                sg = step.get("group_ids")
                if isinstance(sg, list):
                    for x in sg:
                        add(x)
    return out


def _extend_mac_scope_for_sequence_groups(
    zone_mac_set: set,
    zone_name_by_mac: Dict[str, str],
    sequence_doc: Dict[str, Any],
    devices: Any,
    groups: Any,
) -> None:
    """Include MACs from any group the sequence references so per-lane groups can differ from the zone tab."""
    for gid in _sequence_referenced_group_ids(sequence_doc):
        g = groups.read(gid) if hasattr(groups, "read") else None
        if not isinstance(g, dict):
            continue
        for raw in g.get("devices") or []:
            m = _norm_mac(raw)
            if not m:
                continue
            zone_mac_set.add(m)
            if m not in zone_name_by_mac:
                doc = devices.read(m) if hasattr(devices, "read") else None
                if isinstance(doc, dict):
                    nm = str(doc.get("name") or "").strip() or m
                else:
                    nm = m
                zone_name_by_mac[m] = nm


def _resolve_step_device_names(
    zone_doc: Dict[str, Any],
    step_group_ids: List[str],
    devices: Any,
    groups: Any,
    *,
    sequence_doc: Optional[Dict[str, Any]] = None,
) -> List[str]:
    z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups)
    if not step_group_ids:
        return []
    zone_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m}
    zone_name_by_mac: Dict[str, str] = {}
    for i, m in enumerate(z_macs):
        mn = _norm_mac(m)
        if mn and mn not in zone_name_by_mac:
            zone_name_by_mac[mn] = z_names[i] if i < len(z_names) else mn
    if sequence_doc is not None:
        _extend_mac_scope_for_sequence_groups(
            zone_mac_set, zone_name_by_mac, sequence_doc, devices, groups
        )
    step_macs: set = set()
    for gid in step_group_ids:
        g = groups.read(gid) if hasattr(groups, "read") else None
        if not isinstance(g, dict):
            continue
        for raw in g.get("devices") or []:
            m = _norm_mac(raw)
            if m and m in zone_mac_set:
                step_macs.add(m)
    out: List[str] = []
    for m in step_macs:
        n = zone_name_by_mac.get(m)
        if n:
            out.append(n)
    return out


def _lane_has_non_empty_lanes_group_ids(sequence_doc: Dict[str, Any], lane_index: int) -> bool:
    """True when this lane's targets come from ``lanes_group_ids[lane]`` (already lane-scoped)."""
    lgs = sequence_doc.get("lanes_group_ids")
    if not isinstance(lgs, list) or lane_index < 0 or lane_index >= len(lgs):
        return False
    for_lane = lgs[lane_index]
    if not isinstance(for_lane, list) or not for_lane:
        return False
    return any(x is not None and str(x).strip() for x in for_lane)


def _partition_devices_for_lane(
    num_lanes: int,
    *,
    lane_has_own_groups: bool,
    step_group_ids: List[str],
) -> bool:
    """Split zone devices across lanes only when lanes lack explicit group targeting."""
    if num_lanes <= 1:
        return False
    if lane_has_own_groups:
        return False
    # No lane groups (whole zone): every lane uses all zone / zone-group devices.
    if not step_group_ids:
        return False
    return False


def _split_device_names_for_lane(
    all_names: List[str],
    lane_index: int,
    num_lanes: int,
    *,
    partition_shared_zone: bool = False,
) -> List[str]:
    names = [n for n in all_names if n and str(n).strip()]
    if num_lanes <= 1 or not partition_shared_zone:
        return names
    if len(names) >= num_lanes:
        n = names[lane_index]
        return [n] if n else []
    return names


def _resolve_colors_with_palette_refs(
    colors: Any, palette_refs: Any, palette_colors: List[Any]
) -> List[Any]:
    base = list(colors) if isinstance(colors, list) else []
    refs = list(palette_refs) if isinstance(palette_refs, list) else []
    pal = list(palette_colors) if isinstance(palette_colors, list) else []
    out: List[Any] = []
    for idx, color in enumerate(base):
        ref_raw = refs[idx] if idx < len(refs) else None
        try:
            ref = int(ref_raw) if ref_raw is not None else None
        except (TypeError, ValueError):
            ref = None
        if isinstance(ref, int) and 0 <= ref < len(pal) and pal[ref]:
            out.append(pal[ref])
        else:
            out.append(color)
    return out


def _display_preset_for_step(
    preset_id: str,
    presets_map: Dict[str, Any],
    palette_colors: List[Any],
) -> Optional[Dict[str, Any]]:
    preset = presets_map.get(preset_id)
    if not isinstance(preset, dict):
        return None
    base_colors = preset.get("colors") or preset.get("c") or ["#FFFFFF"]
    colors = _resolve_colors_with_palette_refs(
        base_colors if isinstance(base_colors, list) else [base_colors],
        preset.get("palette_refs"),
        palette_colors,
    )
    resolved_bg = resolve_preset_background_hex(preset, palette_colors)
    return {**preset, "colors": colors, "background": resolved_bg}


def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[str, Any]:
    from util.espnow_message import build_preset_dict

    body = dict(display_preset)
    inner = build_preset_dict(body)
    mb = body.get("manual_beat_n", body.get("manualBeatN"))
    if mb is not None:
        try:
            n = int(mb)
            if 1 <= n <= 64:
                inner["manual_beat_n"] = n
        except (TypeError, ValueError):
            pass
    return inner


def _ordered_unique_preset_ids_in_lane(lane: List[Dict[str, Any]]) -> List[str]:
    seen: set = set()
    out: List[str] = []
    for step in lane:
        if not isinstance(step, dict):
            continue
        pid = str(step.get("preset_id") or "").strip()
        if not pid or pid in seen:
            continue
        seen.add(pid)
        out.append(pid)
    return out


def _resolve_lane_device_names(lane_index: int, ctx: Dict[str, Any]) -> List[str]:
    """Device names for one lane (lane groups / whole zone), after lane partition."""
    lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
    sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
    zone_doc: Dict[str, Any] = ctx["zone_doc"]
    devices = ctx["devices"]
    groups = ctx["groups"]
    num_lanes = int(ctx["num_lanes"])
    lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
    if not lane:
        return []
    gids = _group_ids_for_lane_step(
        sequence_doc, lane[0], lane_index, num_lanes, zone_doc=zone_doc
    )
    device_names = _resolve_step_device_names(
        zone_doc, gids, devices, groups, sequence_doc=sequence_doc
    )
    lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
    return _split_device_names_for_lane(
        device_names,
        lane_index,
        num_lanes,
        partition_shared_zone=_partition_devices_for_lane(
            num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
        ),
    )


def _build_lane_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
    """All preset wire bodies for one lane, keyed by preset id."""
    lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
    presets_map: Dict[str, Any] = ctx["presets_map"]
    palette_colors: List[Any] = ctx["palette_colors"]
    lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
    inner_by_wire: Dict[str, Any] = {}
    for pid in _ordered_unique_preset_ids_in_lane(lane):
        disp = _display_preset_for_step(pid, presets_map, palette_colors)
        if not disp:
            continue
        inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
    return inner_by_wire


async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
    """Upload all lane presets and select step 0 in one message (driver applies presets before select)."""
    from models.transport import get_current_sender
    from util.beat_driver_route import (
        clear_sequence_manual_lane_route,
        mark_sequence_manual_lane_select_sent,
        set_sequence_manual_lane_route,
    )
    from util.driver_delivery import deliver_json_messages

    lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
    presets_map: Dict[str, Any] = ctx["presets_map"]
    palette_colors: List[Any] = ctx["palette_colors"]
    lane_steps = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
    if not lane_steps:
        return

    inner_by_wire = _build_lane_wire_presets_map(lane_index, ctx)
    if not inner_by_wire:
        return

    step0 = lane_steps[0]
    preset_id = str(step0.get("preset_id") or "").strip()
    if not preset_id:
        return
    display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
    if not display_preset:
        return

    device_names = _resolve_lane_device_names(lane_index, ctx)
    macs = _device_names_to_macs(device_names, ctx["devices"])
    if not macs:
        return

    sender = get_current_sender()
    if not sender:
        raise RuntimeError("Transport not configured")

    zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
    devices_model = ctx["devices"]
    wire = str(preset_id)
    auto = _coerce_auto(display_preset)
    sel: Dict[str, Any] = {}
    for n in device_names:
        if n:
            sel[str(n)] = [wire]

    delay_s = 0.05
    for mac in macs:
        body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
        if sel:
            body["select"] = sel
        msg = json.dumps(body, separators=(",", ":"))
        await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)

    if auto:
        clear_sequence_manual_lane_route(lane_index)
    else:
        inner = _preset_inner_from_display_preset(display_preset)
        set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
        mark_sequence_manual_lane_select_sent(lane_index)


async def _prime_all_lanes(ctx: Dict[str, Any]) -> None:
    """One-shot preset upload + first-step select per lane (to each lane's groups)."""
    for i in range(int(ctx["num_lanes"])):
        await _prime_lane(i, ctx)
    ctx["_presets_delivered"] = True
    ctx["_sequence_primed"] = True


def _parse_zone_brightness_value(zone_doc: Any) -> int:
    """Zone slider value stored on the zone row (0–255); default 255 if unset."""
    from util.brightness_combine import clamp255

    if not isinstance(zone_doc, dict):
        return 255
    raw = zone_doc.get("brightness")
    if raw is None or raw == "":
        return 255
    try:
        return clamp255(int(raw))
    except (TypeError, ValueError):
        return 255


async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
    """Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
    from models.transport import get_current_sender
    from util.brightness_combine import effective_brightness_for_mac
    from util.driver_delivery import deliver_json_messages

    sender = get_current_sender()
    if not sender:
        return
    macs = _union_macs_for_sequence(ctx)
    if not macs:
        return
    zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
    zb = _parse_zone_brightness_value(zone_doc)
    settings_obj = ctx.get("settings")
    groups_model = ctx.get("groups")
    devices_model = ctx.get("devices")
    for mac in macs:
        eff = effective_brightness_for_mac(
            settings_obj,
            groups_model,
            devices_model,
            mac,
            zone_brightness=zb,
        )
        msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
        await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)


def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
    macs: List[str] = []
    seen: set = set()
    for nm in device_names:
        key = str(nm).strip()
        if not key:
            continue
        m = None
        for did in devices.list():
            doc = devices.read(did) or {}
            if str(doc.get("name") or "").strip() == key:
                m = _norm_mac(did)
                break
        if not m and key.startswith("led-"):
            m = _norm_mac(key[4:])
        if m and m not in seen:
            seen.add(m)
            macs.append(m)
    return macs


def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
    """MACs that appear on any lane/step (union); falls back to full zone targets."""
    lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
    sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
    zone_doc: Dict[str, Any] = ctx["zone_doc"]
    devices = ctx["devices"]
    groups = ctx["groups"]
    num_lanes = int(ctx["num_lanes"])
    seen: set = set()
    out: List[str] = []
    for lane_index, lane in enumerate(lanes):
        for step in lane:
            if not isinstance(step, dict):
                continue
            gids = _group_ids_for_lane_step(
                sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
            )
            device_names = _resolve_step_device_names(
                zone_doc, gids, devices, groups, sequence_doc=sequence_doc
            )
            lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
            device_names = _split_device_names_for_lane(
                device_names,
                lane_index,
                num_lanes,
                partition_shared_zone=_partition_devices_for_lane(
                    num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
                ),
            )
            if gids and not device_names:
                continue
            for m in _device_names_to_macs(device_names, devices):
                if m and m not in seen:
                    seen.add(m)
                    out.append(m)
    return out


def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool:
    raw = sequence_doc.get("loop", sequence_doc.get("sequence_loop", True))
    if isinstance(raw, bool):
        return raw
    if raw is None:
        return True
    if isinstance(raw, int):
        return raw != 0
    if isinstance(raw, str):
        lo = raw.strip().lower()
        if lo in ("false", "0", "no", "off"):
            return False
        if lo in ("true", "1", "yes", "on"):
            return True
    return True


def _coerce_auto(preset: Dict[str, Any]) -> bool:
    raw = preset.get("auto", preset.get("a", True))
    if isinstance(raw, bool):
        return raw
    if raw is None:
        return True
    if isinstance(raw, int):
        return raw != 0
    if isinstance(raw, str):
        lo = raw.strip().lower()
        if lo in ("false", "0", "no", "off"):
            return False
        if lo in ("true", "1", "yes", "on"):
            return True
    return True


def _load_palette_colors(profile_id: str) -> List[Any]:
    from models.profile import Profile
    from models.pallet import Palette

    prof = Profile().read(profile_id)
    if not isinstance(prof, dict):
        return []
    pid = prof.get("palette_id") or prof.get("paletteId")
    if not pid:
        return []
    return Palette().read(str(pid)) or []


async def _send_lane(
    lane_index: int,
    st: Dict[str, Any],
    ctx: Dict[str, Any],
) -> None:
    """Apply the current step (select or manual route). Presets must already be on devices."""
    lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
    sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
    presets_map: Dict[str, Any] = ctx["presets_map"]
    palette_colors: List[Any] = ctx["palette_colors"]
    devices = ctx["devices"]

    if st.get("done"):
        return
    lane_steps = lanes[lane_index]
    idx = int(st.get("stepIdx", 0))
    if idx < 0 or idx >= len(lane_steps):
        return
    step = lane_steps[idx]
    preset_id = str(step.get("preset_id") or "").strip()
    if not preset_id:
        return
    display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
    if not display_preset:
        return
    num_lanes = int(ctx["num_lanes"])
    zone_doc = ctx["zone_doc"]
    gids = _group_ids_for_lane_step(
        sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
    )
    device_names = _resolve_step_device_names(
        zone_doc, gids, devices, ctx["groups"], sequence_doc=sequence_doc
    )
    lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
    device_names = _split_device_names_for_lane(
        device_names,
        lane_index,
        num_lanes,
        partition_shared_zone=_partition_devices_for_lane(
            num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
        ),
    )
    if gids and not device_names:
        return

    from models.transport import get_current_sender
    from util.beat_driver_route import (
        clear_sequence_manual_lane_route,
        mark_sequence_manual_lane_select_sent,
        set_sequence_manual_lane_route,
    )
    from util.driver_delivery import deliver_json_messages

    sender = get_current_sender()
    if not sender:
        raise RuntimeError("Transport not configured")

    macs = _device_names_to_macs(device_names, devices)
    if not macs:
        return

    wire = str(preset_id)
    auto = _coerce_auto(display_preset)
    if auto:
        clear_sequence_manual_lane_route(lane_index)
        sel: Dict[str, Any] = {}
        for n in device_names:
            if n:
                sel[str(n)] = [wire]
        if not sel:
            return
        msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
        await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
    else:
        inner = _preset_inner_from_display_preset(display_preset)
        set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
        sel: Dict[str, Any] = {}
        for n in device_names:
            if n:
                sel[str(n)] = [wire]
        if sel:
            msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
            await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
            mark_sequence_manual_lane_select_sent(lane_index)


async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
    lane_states: List[Dict[str, Any]] = ctx["lane_states"]
    num_lanes = ctx["num_lanes"]
    for i in range(num_lanes):
        if lane_states[i].get("done"):
            continue
        await _send_lane(i, lane_states[i], ctx)


def _build_ctx(
    sequence_doc: Dict[str, Any],
    zone_doc: Dict[str, Any],
    presets_map: Dict[str, Any],
    profile_id: str,
) -> Optional[Dict[str, Any]]:
    from models.device import Device
    from models.group import Group
    from settings import get_settings

    lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
    if not lanes:
        return None
    devices = Device()
    groups = Group()
    palette_colors = _load_palette_colors(profile_id)
    num_lanes = len(lanes)
    lane_states = [{"stepIdx": 0, "beatCount": 0, "done": False} for _ in range(num_lanes)]
    return {
        "lanes": lanes,
        "lane_states": lane_states,
        "num_lanes": num_lanes,
        "sequence_doc": sequence_doc,
        "zone_doc": zone_doc,
        "presets_map": presets_map,
        "devices": devices,
        "groups": groups,
        "settings": get_settings(),
        "palette_colors": palette_colors,
        "loop": _coerce_loop(sequence_doc),
        "advance_mode": "beats",
    }


def playback_status() -> Dict[str, Any]:
    """Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
    with _beat_run_lock:
        ctx = _beat_run
    if not ctx:
        return {"active": False, "beat_readout": ""}
    lanes: List[List[Dict[str, Any]]] = ctx.get("lanes") or []
    lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
    num_lanes = int(ctx.get("num_lanes") or 0)
    total_steps = sum(len(l) for l in lanes)
    lane0_steps = len(lanes[0]) if lanes else 0
    beat_count = 0
    beats_per_step = 1
    step_1based = 0
    lane0 = lanes[0] if lanes else []
    sequence_beats_per_pass = 0
    for step in lane0:
        sequence_beats_per_pass += max(1, int((step or {}).get("beats") or 1))
    sequence_beat_at = 0
    if lane_states and lane0_steps > 0:
        st0 = lane_states[0]
        idx = int(st0.get("stepIdx", 0))
        if st0.get("done"):
            step_1based = lane0_steps
            sequence_beat_at = sequence_beats_per_pass
        else:
            step_1based = idx + 1
            if 0 <= idx < len(lanes[0]):
                step = lanes[0][idx]
                beats_per_step = max(1, int(step.get("beats") or 1))
            beat_count_raw = int(st0.get("beatCount", 0))
            bt = max(1, int(beats_per_step))
            beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
            for j in range(min(idx, len(lane0))):
                sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
            sequence_beat_at += beat_count
    lane0_preset_id = ""
    lane0_preset_name = ""
    pm_raw = ctx.get("presets_map")
    presets_map_status: Dict[str, Any] = pm_raw if isinstance(pm_raw, dict) else {}
    if lane_states and lane0_steps > 0 and lane0:
        st_preset = lane_states[0]
        if not st_preset.get("done"):
            ix = int(st_preset.get("stepIdx", 0))
            if 0 <= ix < len(lane0):
                stp = lane0[ix] or {}
                pid = str(stp.get("preset_id") or "").strip()
                lane0_preset_id = pid
                if pid:
                    pdoc = presets_map_status.get(pid)
                    if isinstance(pdoc, dict):
                        nm = str(pdoc.get("name") or "").strip()
                        lane0_preset_name = nm or pid
                    else:
                        lane0_preset_name = pid
    beat_readout = ""
    if (
        sequence_beats_per_pass > 0
        and lane_states
        and lane0_steps > 0
        and lane_states[0]
        and not lane_states[0].get("done")
    ):
        tot = max(1, int(sequence_beats_per_pass))
        at = int(sequence_beat_at)
        # Pass position within this run: inclusive 1..tot
        sp = min(tot, max(1, at if at > 0 else 1))
        beat_readout = f"{sp}/{tot}"
    return {
        "active": True,
        "advance_mode": ctx.get("advance_mode"),
        "sequence_id": ctx.get("sequence_id"),
        "zone_id": ctx.get("zone_id"),
        "num_lanes": num_lanes,
        "total_sequence_steps": total_steps,
        "lane0_current_step": step_1based,
        "lane0_lane_length": lane0_steps,
        "lane0_beat_in_step": beat_count,
        "lane0_beats_per_step": beats_per_step,
        "lane0_preset_id": lane0_preset_id,
        "lane0_preset_name": lane0_preset_name,
        "sequence_beat_at": sequence_beat_at,
        "sequence_beats_per_pass": sequence_beats_per_pass,
        "sequence_loop_beat": int(ctx.get("sequence_loop_beat", 0)),
        "beat_readout": beat_readout,
    }


async def process_active_beat_advance() -> None:
    with _beat_run_lock:
        ctx = _beat_run
    if not ctx:
        return
    lane_states: List[Dict[str, Any]] = ctx["lane_states"]
    lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
    loop = bool(ctx.get("loop"))
    lane0_looped = False
    for i in range(ctx["num_lanes"]):
        st = lane_states[i]
        if st.get("done"):
            continue
        lane_steps = lanes[i]
        if not lane_steps:
            continue
        st["beatCount"] = int(st.get("beatCount", 0)) + 1
        step = lane_steps[int(st.get("stepIdx", 0))]
        need = max(1, int(step.get("beats") or 1))
        if int(st["beatCount"]) >= need:
            st["beatCount"] = 0
            if int(st.get("stepIdx", 0)) + 1 >= len(lane_steps):
                if loop:
                    if i == 0:
                        lane0_looped = True
                    st["stepIdx"] = 0
                    await _send_lane(i, st, ctx)
                else:
                    st["done"] = True
            else:
                st["stepIdx"] = int(st.get("stepIdx", 0)) + 1
                await _send_lane(i, st, ctx)
    if lane0_looped:
        # First beat of the next loop (was 0 here so single-step / first wrap never left 0).
        ctx["sequence_loop_beat"] = 1
    else:
        ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
    if all(s.get("done") for s in lane_states):
        await stop_playback(clear_devices=True)
        return


async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
    """Stop beat routing and clear driver presets for devices used by this sequence run."""
    from models.transport import get_current_sender
    from util.beat_driver_route import clear_sequence_manual_lane_route, update_beat_route
    from util.driver_delivery import deliver_json_messages

    num_lanes = int(ctx.get("num_lanes") or 0)
    for i in range(num_lanes):
        clear_sequence_manual_lane_route(i)
    update_beat_route({"enabled": False})

    sender = get_current_sender()
    if not sender:
        return
    devices = ctx.get("devices")
    macs = _union_macs_for_sequence(ctx)
    if not macs:
        return
    msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":"))
    await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)


def _halt_playback_state() -> Optional[Dict[str, Any]]:
    """Drop active run state and cancel simulated beats; return the previous ctx."""
    global _beat_run, _sim_beat_task, _sim_beat_token
    ctx: Optional[Dict[str, Any]] = None
    with _beat_run_lock:
        ctx = _beat_run
        _beat_run = None
        _sim_beat_token += 1
    st = _sim_beat_task
    _sim_beat_task = None
    if st and not st.done():
        st.cancel()
    return ctx


async def stop_playback(*, clear_devices: bool = True) -> None:
    """Stop sequence playback; optionally clear presets on targeted devices."""
    clear_pending_play()
    ctx = _halt_playback_state()
    if clear_devices and ctx:
        await _clear_devices_after_sequence(ctx)


def apply_beat_phase_sync(ctx: Dict[str, Any], mode: str) -> Tuple[bool, bool]:
    """Align beat counters to music.

    ``step`` (default): beat 1 of the current step on the next counted beat.
    ``pass``: restart from step 1 of the sequence pass and re-apply presets.

    Returns ``(ok, resend_lanes)`` — caller should ``await _send_all_lanes(ctx)`` when resend is true.
    """
    if not ctx:
        return False, False
    mode_norm = str(mode or "step").strip().lower()
    lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
    if mode_norm in ("pass", "sequence", "restart"):
        for st in lane_states:
            st["stepIdx"] = 0
            st["beatCount"] = 0
            st["done"] = False
        ctx["sequence_loop_beat"] = 0
        return True, True
    for st in lane_states:
        if not st.get("done"):
            st["beatCount"] = 0
    return True, False


async def sync_beat_phase(mode: str = "step") -> bool:
    """Public entry: align active sequence playback to a musical phase."""
    with _beat_run_lock:
        ctx = _beat_run
        if not ctx:
            return False
        ok, resend = apply_beat_phase_sync(ctx, mode)
    if not ok:
        return False
    if resend:
        await _send_all_lanes(ctx)
    return True


def _drain_beat_queue() -> None:
    try:
        while True:
            _thread_beat_queue.get_nowait()
    except queue.Empty:
        pass


def _reset_beat_side_effects() -> None:
    """Clear manual routes and queued beats so startup cannot select before presets land."""
    from util.beat_driver_route import update_beat_route

    update_beat_route({"enabled": False})
    _drain_beat_queue()


def _sequence_switch_wait_from_settings() -> str:
    try:
        from settings import get_settings

        raw = get_settings().get("sequence_switch_wait", "beat")
        mode = _normalize_wait_for({"wait_for": raw}) or "beat"
        if mode == "phrase":
            return "beat"
        return mode
    except Exception:
        return "beat"


def _normalize_wait_for(play_options: Optional[Dict[str, Any]]) -> Optional[str]:
    """``beat`` | ``downbeat`` | None (immediate)."""
    if not isinstance(play_options, dict):
        return None
    raw = play_options.get("wait_for")
    if raw is None:
        raw = play_options.get("start_on")
    if raw is None:
        return None
    s = str(raw).strip().lower()
    if s in ("beat", "next_beat"):
        return "beat"
    if s in ("downbeat", "next_downbeat"):
        return "downbeat"
    return None


def _play_options_without_wait(play_options: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
    if not isinstance(play_options, dict):
        return play_options
    out = dict(play_options)
    out.pop("wait_for", None)
    out.pop("start_on", None)
    return out


def _cancel_pending_beat_waiter() -> None:
    global _pending_beat_task, _pending_beat_token
    _pending_beat_token += 1
    t = _pending_beat_task
    _pending_beat_task = None
    if t and not t.done():
        t.cancel()


def clear_pending_play() -> None:
    """Drop a queued sequence start (e.g. user stop)."""
    global _pending_play
    with _pending_play_lock:
        _pending_play = None
    _cancel_pending_beat_waiter()


def pending_play_status() -> Dict[str, Any]:
    with _pending_play_lock:
        p = _pending_play
    if not p:
        return {"pending": False}
    return {
        "pending": True,
        "wait_for": p.get("wait_for"),
        "sequence_id": p.get("sequence_id"),
        "zone_id": p.get("zone_id"),
    }


def _beat_phase_from_sources() -> Dict[str, Any]:
    from util import audio_detector as ad_mod

    if ad_mod.shared_beat_detector_running():
        st = ad_mod.shared_beat_status_snapshot()
        if st:
            return dict(st)
    return dict(_last_thread_beat_phase)


def _beat_is_downbeat_from_sources() -> bool:
    return bool(_beat_phase_from_sources().get("is_downbeat"))


def _mark_simulated_beat_phase(*, beats_per_bar: int = 4) -> None:
    global _sim_beat_counter, _last_thread_beat_phase
    bpb = max(1, int(beats_per_bar))
    _sim_beat_counter += 1
    bar_beat = ((_sim_beat_counter - 1) % bpb) + 1
    is_downbeat = bar_beat == 1
    _last_thread_beat_phase = {
        "bar_beat": bar_beat,
        "is_downbeat": is_downbeat,
    }


def _queue_pending_start(
    zone_id: str,
    sequence_id: str,
    profile_id: str,
    play_options: Optional[Dict[str, Any]],
    wait_for: str,
    *,
    bpm: float,
) -> None:
    global _pending_play
    clear_pending_play()
    with _pending_play_lock:
        _pending_play = {
            "zone_id": str(zone_id),
            "sequence_id": str(sequence_id),
            "profile_id": str(profile_id),
            "play_options": _play_options_without_wait(play_options),
            "wait_for": wait_for,
        }
    _ensure_pending_beat_waiter(bpm)


def _ensure_pending_beat_waiter(bpm: float) -> None:
    """When nothing is playing and audio is off, emit synthetic beats until pending starts."""
    from util import audio_detector as ad_mod

    if ad_mod.shared_beat_detector_running():
        return
    with _beat_run_lock:
        if _beat_run:
            return
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        return
    global _pending_beat_task, _pending_beat_token
    t = _pending_beat_task
    if t and not t.done():
        t.cancel()
    _pending_beat_token += 1
    my_tok = _pending_beat_token
    _pending_beat_task = loop.create_task(_pending_beat_wait_loop(bpm, my_tok))


async def _pending_beat_wait_loop(bpm: float, my_token: int) -> None:
    from util import audio_detector as ad_mod

    interval = 60.0 / max(30.0, min(300.0, float(bpm)))
    while True:
        with _pending_play_lock:
            if _pending_beat_token != my_token or _pending_play is None:
                return
        if ad_mod.shared_beat_detector_running():
            return
        await asyncio.sleep(interval)
        with _pending_play_lock:
            if _pending_beat_token != my_token or _pending_play is None:
                return
        if ad_mod.shared_beat_detector_running():
            return
        _mark_simulated_beat_phase()
        push_thread_beat()


async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
    global _pending_play
    with _pending_play_lock:
        pending = _pending_play
        if not pending:
            return False
        wait_for = str(pending.get("wait_for") or "beat").strip().lower()
        if wait_for == "downbeat" and not is_downbeat:
            return False
        _pending_play = None
    _cancel_pending_beat_waiter()
    await _start_immediate(
        pending["zone_id"],
        pending["sequence_id"],
        pending["profile_id"],
        pending.get("play_options"),
    )
    return True


def stop() -> None:
    """Stop server playback state without sending device clear (e.g. before starting another run)."""
    clear_pending_play()
    _halt_playback_state()
    _reset_beat_side_effects()


def push_thread_beat() -> None:
    try:
        _thread_beat_queue.put_nowait(1)
    except queue.Full:
        pass


async def beat_consumer_loop() -> None:
    while True:
        n = 0
        try:
            while True:
                _thread_beat_queue.get_nowait()
                n += 1
        except queue.Empty:
            pass
        if n:
            from util.beat_driver_route import notify_beat_detected

            for _ in range(n):
                phase = _beat_phase_from_sources()
                is_down = bool(phase.get("is_downbeat"))
                try:
                    await _try_consume_pending_play(is_downbeat=is_down)
                except Exception as e:
                    print(f"[sequence-playback] pending start: {e}")
                try:
                    await process_active_beat_advance()
                except Exception as e:
                    print(f"[sequence-playback] beat advance: {e}")
                try:
                    notify_beat_detected()
                except Exception as e:
                    print(f"[sequence-playback] notify_beat_detected: {e}")
        else:
            await asyncio.sleep(0.012)


def ensure_beat_consumer_started() -> None:
    global _beat_consumer_started
    with _beat_consumer_lock:
        if _beat_consumer_started:
            return
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            return
        _beat_consumer_started = True
    loop.create_task(beat_consumer_loop())


def _coerce_simulated_bpm(sequence_doc: Dict[str, Any], play_options: Optional[Dict[str, Any]]) -> float:
    raw = None
    if isinstance(play_options, dict):
        o = play_options.get("simulated_bpm")
        if o is not None:
            raw = o
    if raw is None and isinstance(sequence_doc, dict):
        raw = sequence_doc.get("simulated_bpm")
    try:
        v = float(raw) if raw is not None else 120.0
    except (TypeError, ValueError):
        v = 120.0
    return max(30.0, min(300.0, v))


async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -> None:
    from util import audio_detector as ad_mod

    interval = 60.0 / max(30.0, min(300.0, float(bpm)))
    while True:
        with _beat_run_lock:
            cur_tok = _sim_beat_token
            active = _beat_run
        if cur_tok != my_token or active is None or active is not ctx:
            return
        if ad_mod.shared_beat_detector_running():
            await asyncio.sleep(0.12)
            continue
        await asyncio.sleep(interval)
        with _beat_run_lock:
            cur_tok = _sim_beat_token
            active = _beat_run
        if cur_tok != my_token or active is None or active is not ctx:
            return
        if ad_mod.shared_beat_detector_running():
            continue
        _mark_simulated_beat_phase()
        push_thread_beat()


def stop_if_playing_sequence(sequence_id: str) -> bool:
    """If zone sequence playback is running this sequence id, stop it (e.g. after save/delete)."""
    sid = str(sequence_id).strip()
    if not sid:
        return False
    with _beat_run_lock:
        ctx = _beat_run
        if not ctx:
            return False
        cur = ctx.get("sequence_id")
        if cur is None or str(cur).strip() != sid:
            return False
    try:
        loop = asyncio.get_running_loop()
        loop.create_task(stop_playback(clear_devices=True))
    except RuntimeError:
        stop()
    return True


async def start(
    zone_id: str,
    sequence_id: str,
    profile_id: str,
    play_options: Optional[Dict[str, Any]] = None,
) -> None:
    """Start immediately, or queue until the next beat / downbeat (``wait_for`` in *play_options*)."""
    from models.sequence import Sequence

    seq_m = Sequence()
    sequence_doc = seq_m.read(sequence_id)
    if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
        raise ValueError("sequence not found")
    wait_for = _sequence_switch_wait_from_settings()
    if wait_for:
        bpm = _coerce_simulated_bpm(sequence_doc, play_options)
        _queue_pending_start(
            zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm
        )
        return
    await _start_immediate(zone_id, sequence_id, profile_id, play_options)


async def _start_immediate(
    zone_id: str,
    sequence_id: str,
    profile_id: str,
    play_options: Optional[Dict[str, Any]] = None,
) -> None:
    global _beat_run, _sim_beat_task, _sim_beat_token
    from models.preset import Preset
    from models.profile import Profile
    from models.sequence import Sequence
    from models.zone import Zone

    stop()
    seq_m = Sequence()
    zone_m = Zone()
    prof_m = Profile()
    sequence_doc = seq_m.read(sequence_id)
    zone_doc = zone_m.read(zone_id)
    if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
        raise ValueError("sequence not found")
    if not zone_doc:
        raise ValueError("zone not found")
    prof = prof_m.read(profile_id)
    if not prof:
        raise ValueError("profile not found")

    presets_map: Dict[str, Any] = {}
    pr = Preset()
    for pid in pr.list():
        doc = pr.read(pid)
        if isinstance(doc, dict) and str(doc.get("profile_id")) == str(profile_id):
            presets_map[str(pid)] = doc

    ctx = _build_ctx(sequence_doc, zone_doc, presets_map, profile_id)
    if not ctx:
        raise ValueError("sequence has no steps")

    ctx["sequence_id"] = str(sequence_id)
    ctx["zone_id"] = str(zone_id)
    ctx["sequence_loop_beat"] = 0

    _reset_beat_side_effects()
    await _prime_all_lanes(ctx)
    await _deliver_zone_brightness_for_sequence(ctx)
    with _beat_run_lock:
        _beat_run = ctx

    bpm = _coerce_simulated_bpm(sequence_doc, play_options)
    loop = asyncio.get_running_loop()
    _sim_beat_token += 1
    my_tok = _sim_beat_token
    _sim_beat_task = loop.create_task(_simulated_beat_loop(ctx, my_tok, bpm))

