"""Server-side routing of audio beats to LED drivers (no browser required)."""

from __future__ import annotations

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

_route_lock = threading.Lock()
# Per-lane manual routes: key ``-1`` = legacy single-route (preset push / UI); keys ``0..n`` =
# zone sequence lanes so every manual lane gets its own stride counter and wire.
_lane_manual: Dict[int, Dict[str, Any]] = {}
# Public mirror for ``get_beat_route`` / header UI (derived from lane table).
_beat_route: Dict[str, Any] = {
    "enabled": False,
    "device_names": [],
    "wire_preset_id": "2",
    "is_manual": False,
    "pattern": "",
    "manual_beat_n": 1,
}
_beat_counter: int = 0
_preset_session_beats: int = 0
_main_loop: Optional[asyncio.AbstractEventLoop] = None


def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
    global _main_loop
    _main_loop = loop


def _pick_display_lane_key() -> Optional[int]:
    """Lane key used for header stride readout (prefer sequence lane 0)."""
    if not _lane_manual:
        return None
    if 0 in _lane_manual:
        return 0
    seq_keys = [k for k in _lane_manual if isinstance(k, int) and k >= 0]
    if seq_keys:
        return min(seq_keys)
    if -1 in _lane_manual:
        return -1
    return min(_lane_manual.keys())


def _sync_public_beat_route_from_lane_table() -> None:
    """Mirror ``_lane_manual`` into legacy ``_beat_route`` shape for API consumers."""
    global _beat_route, _beat_counter
    pick = _pick_display_lane_key()
    if pick is None:
        _beat_route = {
            "enabled": False,
            "device_names": [],
            "wire_preset_id": "2",
            "is_manual": False,
            "pattern": "",
            "manual_beat_n": 1,
        }
        _beat_counter = 0
        return
    e = _lane_manual[pick]
    _beat_route = {
        "enabled": True,
        "device_names": list(e.get("device_names") or []),
        "wire_preset_id": str(e.get("wire_preset_id") or "2"),
        "is_manual": True,
        "pattern": str(e.get("pattern") or ""),
        "manual_beat_n": int(e.get("manual_beat_n") or 1),
    }
    _beat_counter = int(e.get("beat_counter", 0))


def update_beat_route(payload: Dict[str, Any]) -> None:
    """Internal: set or clear routing from explicit fields (tests / future APIs)."""
    global _lane_manual, _beat_route, _beat_counter, _preset_session_beats
    if not isinstance(payload, dict):
        return
    with _route_lock:
        if payload.get("enabled") is False:
            _lane_manual.clear()
            _beat_route = {
                **_beat_route,
                "enabled": False,
                "is_manual": False,
                "device_names": [],
            }
            _beat_counter = 0
            _preset_session_beats = 0
            return
        old = dict(_beat_route)
        names = payload.get("device_names")
        if not isinstance(names, list):
            names = []
        try:
            n_raw = int(payload.get("manual_beat_n", 1))
        except (TypeError, ValueError):
            n_raw = 1
        manual_n = max(1, min(64, n_raw))
        new_wire = str(payload.get("wire_preset_id") or "2")
        old_wire = str(old.get("wire_preset_id") or "2")
        if not old.get("enabled") or old_wire != new_wire:
            _preset_session_beats = 0
        clean_names = [str(n).strip() for n in names if str(n).strip()]
        _lane_manual.clear()
        _lane_manual[-1] = {
            "device_names": clean_names,
            "wire_preset_id": new_wire,
            "pattern": str(payload.get("pattern") or "").strip(),
            "manual_beat_n": manual_n,
            "beat_counter": 0,
        }
        _sync_public_beat_route_from_lane_table()


def get_beat_route() -> Dict[str, Any]:
    with _route_lock:
        return dict(_beat_route)


def manual_beat_stride_status() -> Dict[str, Any]:
    """Audio-beat stride for a live manual preset (not sequence). For UI readout with BPM.

    ``beat_in_stride`` is always in ``1..stride_n`` when ``active`` (1-based within the stride).
    With multiple sequence manual lanes, reflects lane 0 (or the smallest lane index).
    """
    with _route_lock:
        pick = _pick_display_lane_key()
        if pick is None or pick not in _lane_manual:
            wid = str(_beat_route.get("wire_preset_id") or "").strip()
            return {"active": False, "preset_session_beats": 0, "wire_preset_id": wid}
        e = _lane_manual[pick]
        c = int(e.get("beat_counter", 0))
        psb = int(_preset_session_beats)
        wid = str(e.get("wire_preset_id") or "").strip()
        try:
            n = int(e.get("manual_beat_n") or 1)
        except (TypeError, ValueError):
            n = 1
        n = max(1, min(64, n))
        if c <= 0:
            return {
                "active": True,
                "beat_in_stride": 1,
                "stride_n": n,
                "preset_session_beats": psb,
                "wire_preset_id": wid,
            }
        beat_in_stride = ((c - 1) % n) + 1
        return {
            "active": True,
            "beat_in_stride": beat_in_stride,
            "stride_n": n,
            "preset_session_beats": psb,
            "wire_preset_id": wid,
        }


def _coerce_manual_beat_n(body: Any) -> int:
    """Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
    if not isinstance(body, dict):
        return 1
    raw = body.get("manual_beat_n")
    if raw is None:
        return 1
    try:
        n = int(raw)
    except (TypeError, ValueError):
        return 1
    return max(1, min(64, n))


def _coerce_auto_from_body(body: Any) -> bool:
    """Match JS ``coercePresetAuto`` / ``build_preset_dict`` (default: auto-run)."""
    if not isinstance(body, dict):
        return True
    raw = body.get("auto", body.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):
        lowered = raw.strip().lower()
        if lowered in ("false", "0", "no", "off"):
            return False
        if lowered in ("true", "1", "yes", "on"):
            return True
    return True


def _registry_names_for_macs(macs: Optional[List[str]]) -> List[str]:
    """Resolve push ``targets`` MAC list to registry device names (order preserved, de-duplicated)."""
    if not macs:
        return []
    from models.device import Device, normalize_mac

    devices = Device()
    out: List[str] = []
    seen: Set[str] = set()
    for raw in macs:
        m = normalize_mac(str(raw))
        if not m:
            continue
        doc = devices.read(m) or {}
        nm = str(doc.get("name") or "").strip()
        if nm and nm not in seen:
            seen.add(nm)
            out.append(nm)
    return out


def _single_manual_wire_preset(
    merged_presets: Dict[str, Any],
) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
    """If exactly one manual (non-auto) preset is present, return its wire id and body."""
    manual: List[tuple[str, Dict[str, Any]]] = []
    for wid, body in merged_presets.items():
        if not isinstance(body, dict):
            continue
        if _coerce_auto_from_body(body):
            continue
        manual.append((str(wid).strip(), body))
    if len(manual) != 1:
        return None, None
    return manual[0][0], manual[0][1]


def _apply_manual_beat_route(
    device_names: List[str],
    wire_preset_id: str,
    preset_body: Any,
) -> None:
    """Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
    global _lane_manual
    if not device_names:
        with _route_lock:
            _lane_manual.clear()
            _sync_public_beat_route_from_lane_table()
        return
    if not isinstance(preset_body, dict):
        with _route_lock:
            _lane_manual.clear()
            _sync_public_beat_route_from_lane_table()
        return
    if _coerce_auto_from_body(preset_body):
        with _route_lock:
            _lane_manual.clear()
            _sync_public_beat_route_from_lane_table()
        return
    pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
    if pattern and not _pattern_supports_manual(pattern):
        with _route_lock:
            _lane_manual.clear()
            _sync_public_beat_route_from_lane_table()
        return
    names = [str(n).strip() for n in device_names if str(n).strip()]
    with _route_lock:
        _lane_manual.clear()
        _lane_manual[-1] = {
            "device_names": names,
            "wire_preset_id": str(wire_preset_id).strip(),
            "pattern": pattern,
            "manual_beat_n": _coerce_manual_beat_n(preset_body),
            "beat_counter": 0,
        }
        _sync_public_beat_route_from_lane_table()


def _apply_manual_beat_route_standalone_overlay(
    device_names: List[str],
    wire_preset_id: str,
    preset_body: Any,
) -> None:
    """Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
    global _lane_manual
    if not device_names:
        with _route_lock:
            _lane_manual.pop(-1, None)
            _sync_public_beat_route_from_lane_table()
        return
    if not isinstance(preset_body, dict):
        with _route_lock:
            _lane_manual.pop(-1, None)
            _sync_public_beat_route_from_lane_table()
        return
    if _coerce_auto_from_body(preset_body):
        with _route_lock:
            _lane_manual.pop(-1, None)
            _sync_public_beat_route_from_lane_table()
        return
    pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
    if pattern and not _pattern_supports_manual(pattern):
        with _route_lock:
            _lane_manual.pop(-1, None)
            _sync_public_beat_route_from_lane_table()
        return
    names = [str(n).strip() for n in device_names if str(n).strip()]
    with _route_lock:
        if _sequence_lane_covers_standalone_overlay(names, str(wire_preset_id).strip()):
            _lane_manual.pop(-1, None)
            _sync_public_beat_route_from_lane_table()
            return
        _lane_manual[-1] = {
            "device_names": names,
            "wire_preset_id": str(wire_preset_id).strip(),
            "pattern": pattern,
            "manual_beat_n": _coerce_manual_beat_n(preset_body),
            "beat_counter": 0,
        }
        _sync_public_beat_route_from_lane_table()


def set_sequence_manual_lane_route(
    lane_index: int,
    device_names: List[str],
    wire_preset_id: str,
    preset_body: Any,
) -> None:
    """Register or update one sequence lane's manual beat route (parallel lanes, independent strides)."""
    global _lane_manual
    names = [str(n).strip() for n in (device_names or []) if str(n).strip()]
    if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
        with _route_lock:
            if lane_index in _lane_manual:
                del _lane_manual[lane_index]
            _sync_public_beat_route_from_lane_table()
        return
    pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
    if pattern and not _pattern_supports_manual(pattern):
        with _route_lock:
            if lane_index in _lane_manual:
                del _lane_manual[lane_index]
            _sync_public_beat_route_from_lane_table()
        return
    mn = _coerce_manual_beat_n(preset_body)
    wid = str(wire_preset_id).strip()
    with _route_lock:
        old = _lane_manual.get(lane_index)
        bc = 0
        if (
            old
            and str(old.get("wire_preset_id") or "") == wid
            and int(old.get("manual_beat_n") or 1) == mn
            and set(old.get("device_names") or []) == set(names)
        ):
            bc = int(old.get("beat_counter", 0))
        _lane_manual[lane_index] = {
            "device_names": names,
            "wire_preset_id": wid,
            "pattern": pattern,
            "manual_beat_n": mn,
            "beat_counter": bc,
        }
        overlay = _lane_manual.get(-1)
        if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key(
            overlay.get("device_names") or [], str(overlay.get("wire_preset_id") or "")
        ):
            _lane_manual.pop(-1, None)
        _sync_public_beat_route_from_lane_table()


def clear_sequence_manual_lane_route(lane_index: int) -> None:
    """Remove beat routing for one sequence lane (e.g. step switched to auto)."""
    global _lane_manual
    with _route_lock:
        if lane_index in _lane_manual:
            del _lane_manual[lane_index]
        _sync_public_beat_route_from_lane_table()


def _lane_route_targets_key(device_names: List[str], wire_preset_id: str) -> Tuple[Tuple[str, ...], str]:
    names = tuple(sorted({str(n).strip() for n in (device_names or []) if str(n).strip()}))
    return names, str(wire_preset_id or "").strip()


def _sequence_lane_covers_standalone_overlay(device_names: List[str], wire_preset_id: str) -> bool:
    """True when a sequence lane (0..n) already routes the same device(s) and wire preset."""
    key = _lane_route_targets_key(device_names, wire_preset_id)
    for lane_key, entry in _lane_manual.items():
        if not isinstance(lane_key, int) or lane_key < 0:
            continue
        other = _lane_route_targets_key(
            entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
        )
        if other == key:
            return True
    return False


def mark_manual_select_sent_for_targets(
    device_names: List[str], wire_preset_id: str
) -> None:
    """A ``select`` was just sent for these targets; skip one duplicate on the next beat."""
    key = _lane_route_targets_key(device_names, wire_preset_id)
    with _route_lock:
        for entry in _lane_manual.values():
            if not isinstance(entry, dict):
                continue
            other = _lane_route_targets_key(
                entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
            )
            if other == key:
                entry["suppress_next_notify"] = True


def mark_sequence_manual_lane_select_sent(lane_index: int) -> None:
    """A ``select`` was just sent for this lane; skip one duplicate on the next beat."""
    with _route_lock:
        e = _lane_manual.get(lane_index)
        if e is not None:
            e["suppress_next_notify"] = True


def sync_beat_route_from_push_sequence(
    sequence: List[Any],
    target_macs: Optional[List[str]] = None,
    *,
    preserve_parallel_lane_routes: bool = False,
) -> None:
    """
    Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).

    With a ``select`` map: use its keys as device names (existing behaviour).

    Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
    is set and the merged ``presets`` contain exactly one manual preset, enable routing using
    registry names for those MACs so the first advance is on the next audio beat.

    When ``preserve_parallel_lane_routes`` is true (e.g. zone sequence playback is active), an
    auto preset in ``select`` does not clear manual routing — other lanes still receive
    ``notify_beat_detected``. A manual preset in ``select`` is applied on lane ``-1`` only so
    sequence lanes ``0..n`` keep their stride counters and wire ids.
    """
    merged_presets: Dict[str, Any] = {}
    last_select: Optional[Dict[str, Any]] = None
    for item in sequence:
        if isinstance(item, str):
            try:
                item = json.loads(item)
            except (TypeError, ValueError):
                continue
        if not isinstance(item, dict) or item.get("v") != "1":
            continue
        pr = item.get("presets")
        if isinstance(pr, dict):
            merged_presets.update(pr)
        sel = item.get("select")
        if isinstance(sel, dict) and sel:
            last_select = sel

    if last_select:
        device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
        if not device_names:
            if not preserve_parallel_lane_routes:
                update_beat_route({"enabled": False})
            return

        wire_ids: Set[str] = set()
        for name in device_names:
            val = last_select.get(name)
            if isinstance(val, list) and val:
                wire_ids.add(str(val[0]).strip())
            elif val is not None:
                wire_ids.add(str(val).strip())
        if len(wire_ids) != 1:
            if not preserve_parallel_lane_routes:
                update_beat_route({"enabled": False})
            return
        wire_preset_id = wire_ids.pop()
        preset_body = merged_presets.get(wire_preset_id)
        if preset_body is None:
            for k, v in merged_presets.items():
                if str(k).strip() == wire_preset_id:
                    preset_body = v
                    break
        if preset_body is None:
            if not preserve_parallel_lane_routes:
                update_beat_route({"enabled": False})
            return
        if _coerce_auto_from_body(preset_body):
            if not preserve_parallel_lane_routes:
                update_beat_route({"enabled": False})
            return
        if preserve_parallel_lane_routes:
            _apply_manual_beat_route_standalone_overlay(
                device_names, wire_preset_id, preset_body
            )
        else:
            _apply_manual_beat_route(device_names, wire_preset_id, preset_body)
        mark_manual_select_sent_for_targets(device_names, wire_preset_id)
        return

    wire_id, body = _single_manual_wire_preset(merged_presets)
    if wire_id and body is not None:
        names = _registry_names_for_macs(target_macs)
        if preserve_parallel_lane_routes:
            _apply_manual_beat_route_standalone_overlay(names, wire_id, body)
        else:
            _apply_manual_beat_route(names, wire_id, body)
        return

    if not preserve_parallel_lane_routes:
        update_beat_route({"enabled": False})


def _pattern_supports_manual(pattern_key: str) -> bool:
    if not pattern_key:
        return True
    try:
        here = os.path.dirname(os.path.abspath(__file__))
        root = os.path.abspath(os.path.join(here, "..", ".."))
        path = os.path.join(root, "db", "pattern.json")
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        meta = data.get(pattern_key)
        if meta is None:
            meta = data.get(pattern_key.lower())
        if not isinstance(meta, dict):
            return True
        return meta.get("supports_manual") is not False
    except OSError:
        return True


def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
    """Update cached audio-beat target names after a device registry rename."""
    global _lane_manual
    o = str(old_name or "").strip()
    n = str(new_name or "").strip()
    if not o or not n or o == n:
        return
    with _route_lock:
        any_changed = False
        for e in _lane_manual.values():
            names = e.get("device_names") or []
            if not isinstance(names, list):
                continue
            new_list: List[str] = []
            row_changed = False
            for item in names:
                if str(item).strip() == o:
                    new_list.append(n)
                    row_changed = True
                else:
                    new_list.append(str(item))
            if row_changed:
                e["device_names"] = new_list
                any_changed = True
        if any_changed:
            _sync_public_beat_route_from_lane_table()


async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
    from models.device import Device
    from models.device import resolve_device_mac_for_select_routing
    from models.transport import get_current_sender
    from util.driver_delivery import deliver_json_messages

    sender = get_current_sender()
    if not sender:
        return
    devices = Device()
    seen_macs: List[str] = []
    seen_set: Set[str] = set()
    for n in device_names:
        mac = resolve_device_mac_for_select_routing(devices, n)
        if mac and mac not in seen_set:
            seen_set.add(mac)
            seen_macs.append(mac)
    if not seen_macs:
        return
    select: Dict[str, Any] = {}
    for mac in seen_macs:
        doc = devices.read(mac) or {}
        nm = str(doc.get("name") or "").strip()
        if nm:
            select[nm] = [wire_preset_id]
    if not select:
        return
    msg = json.dumps({"v": "1", "select": select}, separators=(",", ":"))
    try:
        await deliver_json_messages(sender, [msg], seen_macs, devices, delay_s=0.05)
    except Exception as e:
        print(f"[beat-route] deliver failed: {e}")


async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None:
    for names, pid in pairs:
        await _deliver_select(names, pid)


def notify_beat_detected() -> None:
    """Invoked from the audio thread when a beat is detected."""
    global _preset_session_beats
    work: List[Tuple[List[str], str]] = []
    with _route_lock:
        if not _lane_manual:
            return
        work = []
        seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
        for key in sorted(_lane_manual.keys()):
            e = _lane_manual[key]
            names = e.get("device_names") or []
            if not isinstance(names, list) or not names:
                continue
            pattern = str(e.get("pattern") or "")
            if pattern and not _pattern_supports_manual(pattern):
                continue
            if e.pop("suppress_next_notify", False):
                continue
            try:
                n = int(e.get("manual_beat_n") or 1)
            except (TypeError, ValueError):
                n = 1
            n = max(1, min(64, n))
            e["beat_counter"] = int(e.get("beat_counter", 0)) + 1
            c = int(e["beat_counter"])
            if (c - 1) % n != 0:
                continue
            wire = str(e.get("wire_preset_id") or "2")
            target_key = _lane_route_targets_key(names, wire)
            if target_key in seen_targets:
                continue
            seen_targets.add(target_key)
            work.append((list(names), wire))
        if work:
            _preset_session_beats += 1
    if not work:
        return
    loop = _main_loop
    if loop is None:
        return
    try:
        asyncio.run_coroutine_threadsafe(_deliver_select_batch(work), loop)
    except Exception as e:
        print(f"[beat-route] schedule failed: {e}")
