import asyncio
import errno
import json
import os
import secrets
import signal
import socket
import threading
import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import get_settings

import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.zone as zone
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import wifi_ws_clients as tcp_client_registry
from util.device_status_broadcaster import (
    broadcast_device_tcp_snapshot_to,
    broadcast_device_tcp_status,
    register_device_status_ws,
    unregister_device_status_ws,
)
from util.audio_detector import AudioBeatDetector

_tcp_device_lock = threading.Lock()

DISCOVERY_UDP_PORT = 8766


def _live_reload_enabled() -> bool:
    v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
    return v not in ("", "0", "false", "no")


def _register_udp_device_sync(
    device_name: str, peer_ip: str, mac, device_type=None
) -> None:
    with _tcp_device_lock:
        try:
            d = Device()
            did, persisted = d.upsert_wifi_tcp_client(
                device_name, peer_ip, mac, device_type=device_type
            )
            if did and persisted:
                print(
                    f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
                )
        except Exception as e:
            print(f"UDP device registry failed: {e}")
            traceback.print_exception(type(e), e, e.__traceback__)


async def _handle_udp_discovery(sock, udp_holder=None) -> None:
    while True:
        try:
            data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
        except asyncio.CancelledError:
            raise
        except OSError as e:
            if udp_holder and udp_holder.get("closing"):
                break
            print(f"[UDP] recv failed: {e!r}")
            continue
        except Exception as e:
            print(f"[UDP] recv failed: {e!r}")
            continue
        peer_ip = addr[0] if addr else ""
        line = data.split(b"\n", 1)[0].strip()
        if line:
            try:
                parsed = json.loads(line.decode("utf-8"))
                if isinstance(parsed, dict):
                    dns = str(parsed.get("device_name") or "").strip()
                    mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
                        "sta_mac"
                    )
                    device_type = parsed.get("type") or parsed.get("device_type")
                    if dns and normalize_mac(mac):
                        _register_udp_device_sync(dns, peer_ip, mac, device_type)
                        if str(parsed.get("v") or "") == "1":
                            tcp_client_registry.ensure_driver_connection(peer_ip)
            except (UnicodeError, ValueError, TypeError):
                pass
        try:
            await asyncio.get_running_loop().sock_sendto(sock, data, addr)
        except Exception as e:
            print(f"[UDP] echo send failed: {e!r}")


def _prime_wifi_outbound_driver_connections() -> None:
    """On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
    n = 0
    try:
        dev = Device()
        for mac_key, doc in list(dev.items()):
            if not isinstance(doc, dict):
                continue
            if doc.get("transport") != "wifi":
                continue
            ip = _ipv4_address(str(doc.get("address") or ""))
            if not ip:
                continue
            tcp_client_registry.ensure_driver_connection(ip)
            n += 1
    except Exception as e:
        print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
        traceback.print_exception(type(e), e, e.__traceback__)
        return
    if n:
        print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")


def _ipv4_address(addr: str) -> str | None:
    """Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
    s = (addr or "").strip()
    if not s:
        return None
    parts = s.split(".")
    if len(parts) != 4:
        return None
    try:
        nums = [int(p) for p in parts]
    except ValueError:
        return None
    if not all(0 <= n <= 255 for n in nums):
        return None
    return s


async def _run_udp_discovery_server(udp_holder=None) -> None:
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setblocking(False)
    try:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    except (AttributeError, OSError):
        pass
    try:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    except (AttributeError, OSError):
        pass
    sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
    if udp_holder is not None:
        udp_holder["sock"] = sock
    print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
    try:
        await _handle_udp_discovery(sock, udp_holder)
    finally:
        if udp_holder is not None:
            udp_holder.pop("sock", None)
        try:
            sock.close()
        except Exception:
            pass


async def _send_bridge_wifi_channel(settings, sender):
    """Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
    try:
        ch = int(settings.get("wifi_channel", 6))
    except (TypeError, ValueError):
        ch = 6
    ch = max(1, min(11, ch))
    payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
    try:
        await sender.send(payload, addr="ffffffffffff")
        print(f"[startup] bridge Wi-Fi channel -> {ch}")
    except Exception as e:
        print(f"[startup] bridge channel message failed: {e}")


async def main(port=80):
    settings = get_settings()
    print(settings)
    print("Starting")

    # Initialize transport (serial to ESP32 bridge)
    sender = get_sender(settings)
    set_sender(sender)

    app = Microdot()
    audio_detector = AudioBeatDetector()
    try:
        from util import audio_detector as audio_detector_module

        audio_detector_module.set_shared_beat_detector(audio_detector)
    except Exception as e:
        print(f"[startup] audio detector shared registration skipped: {e!r}")
    try:
        from util.audio_run_persist import coerce_audio_device, read_audio_run_state

        persisted = read_audio_run_state()
        if persisted.get("enabled"):
            dev = coerce_audio_device(persisted.get("device"))
            audio_detector.start(device=dev)
            print("[startup] audio beat detector started from saved run state")
    except Exception as e:
        print(f"[startup] audio auto-start skipped: {e!r}")
    from util import beat_driver_route

    beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
    from util import sequence_playback as seq_pb

    seq_pb.ensure_beat_consumer_started()
    
    # Initialize sessions with a secret key from settings
    secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
    Session(app, secret_key=secret_key)
    
    # Mount model controllers as subroutes
    # Verify controllers are Microdot instances before mounting
    controllers_to_mount = [
        ('/presets', preset, 'preset'),
        ('/profiles', profile, 'profile'),
        ('/groups', group, 'group'),
        ('/sequences', sequence, 'sequence'),
        ('/zones', zone, 'zone'),
        ('/palettes', palette, 'palette'),
        ('/scenes', scene, 'scene'),
    ]
    
    # Mount model controllers as subroutes
    app.mount(preset.controller, '/presets')
    app.mount(profile.controller, '/profiles')
    app.mount(group.controller, '/groups')
    app.mount(sequence.controller, '/sequences')
    app.mount(zone.controller, '/zones')
    app.mount(palette.controller, '/palettes')
    app.mount(scene.controller, '/scenes')
    app.mount(pattern.controller, '/patterns')
    app.mount(settings_controller.controller, '/settings')
    app.mount(device_controller.controller, '/devices')
    app.mount(led_tool_controller.controller, '/led-tool')

    tcp_client_registry.set_settings(settings)
    tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)

    live_reload = _live_reload_enabled()
    dev_build_id = secrets.token_hex(12) if live_reload else None
    if live_reload:
        print(
            "[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
        )

    if dev_build_id:

        @app.route("/__dev/build-id")
        def dev_build_id_route(request):
            _ = request
            return (
                dev_build_id,
                200,
                {
                    "Content-Type": "text/plain; charset=utf-8",
                    "Cache-Control": "no-store",
                },
            )

    # Serve index.html at root (cwd is src/ when run via pipenv run run)
    @app.route("/")
    def index(request):
        """Serve the main web UI."""
        if dev_build_id:
            try:
                with open("templates/index.html", encoding="utf-8") as f:
                    html = f.read()
                tag = '<script src="/static/dev-live-reload.js" defer></script>'
                if "</body>" in html:
                    html = html.replace("</body>", tag + "\n</body>", 1)
                    return html, 200, {"Content-Type": "text/html; charset=utf-8"}
            except OSError:
                pass
        return send_file("templates/index.html")

    # Favicon: avoid 404 in browser console (no file needed)
    @app.route('/favicon.ico')
    def favicon(request):
        return '', 204

    @app.route('/api/audio/devices')
    async def audio_devices(request):
        _ = request
        try:
            return {
                "devices": audio_detector.list_input_devices(),
                "diagnostics": audio_detector.diagnostics(),
            }
        except Exception as e:
            return {"error": str(e)}, 500

    @app.route('/api/audio/start', methods=['POST'])
    async def audio_start(request):
        payload = request.json if isinstance(request.json, dict) else {}
        device = payload.get("device", None)
        if device in ("", None):
            device = None
        else:
            try:
                device = int(device)
            except (TypeError, ValueError):
                pass
        try:
            audio_detector.start(device=device)
            from util.audio_run_persist import write_audio_run_state

            write_audio_run_state(
                enabled=True,
                device=device,
                device_override=str(payload.get("device_override") or ""),
                device_select=str(payload.get("device_select") or ""),
            )
            return {"ok": True, "status": audio_detector.status()}
        except Exception as e:
            return {"ok": False, "error": str(e)}, 500

    @app.route('/api/audio/stop', methods=['POST'])
    async def audio_stop(request):
        _ = request
        audio_detector.stop()
        from util.audio_run_persist import write_audio_run_state

        write_audio_run_state(enabled=False)
        return {"ok": True, "status": audio_detector.status()}

    @app.route('/api/audio/reset', methods=['POST'])
    async def audio_reset(request):
        """Clear beat/BPM tracking state without stopping the detector."""
        _ = request
        ok = audio_detector.reset_tracking()
        if not ok:
            return {"ok": False, "error": "Audio detector is not running"}, 409
        return {"ok": True, "status": audio_detector.status()}

    @app.route('/api/audio/anchor-bar', methods=['POST'])
    async def audio_anchor_bar(request):
        """Mark the current moment as bar beat 1 (downbeat)."""
        _ = request
        ok = audio_detector.anchor_bar_phase()
        if not ok:
            return {"ok": False, "error": "Audio detector is not running"}, 409
        return {"ok": True, "status": audio_detector.status()}

    @app.route('/api/audio/status')
    async def audio_status(request):
        _ = request
        from util import beat_driver_route
        from util import sequence_playback

        st = audio_detector.status()
        st["sequence"] = sequence_playback.playback_status()
        st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
        seq = st.get("sequence")
        beat_readout = ""
        if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
            beat_readout = str(seq.get("beat_readout") or "").strip()
        elif st.get("running"):
            mb = st.get("manual_beat_stride")
            if isinstance(mb, dict) and mb.get("active"):
                try:
                    n = int(mb.get("stride_n") or 1)
                except (TypeError, ValueError):
                    n = 1
                n = max(1, min(64, n))
                try:
                    bi = int(mb.get("beat_in_stride") or 1)
                except (TypeError, ValueError):
                    bi = 1
                pos = min(n, max(1, bi))
                beat_readout = f"{pos}/{n}"
            else:
                try:
                    bs = int(st.get("beat_seq") or 0)
                except (TypeError, ValueError):
                    bs = 0
                if bs > 0:
                    beat_readout = str(bs)
        st["beat_readout"] = beat_readout
        from util.audio_run_persist import read_audio_run_state

        st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
        seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
        if seq_wait not in ("beat", "downbeat"):
            seq_wait = "beat"
        st["sequence_switch_wait"] = seq_wait
        st["audio_run"] = read_audio_run_state()
        return {"status": st}

    # Static file route
    @app.route("/static/<path:path>")
    def static_handler(request, path):
        """Serve static files."""
        if '..' in path:
            # Directory traversal is not allowed
            return 'Not found', 404
        return send_file('static/' + path)

    @app.route('/ws')
    @with_websocket
    async def ws(request, ws):
        await register_device_status_ws(ws)
        await broadcast_device_tcp_snapshot_to(ws)
        try:
            while True:
                data = await ws.receive()
                print(data)
                if data:
                    try:
                        parsed = json.loads(data)
                        print("WS received JSON:", parsed)
                        # Optional "to": 12-char hex MAC; rest is payload (sent with that address).
                        addr = parsed.pop("to", None)
                        payload = json.dumps(parsed) if parsed else data
                        await sender.send(payload, addr=addr)
                    except json.JSONDecodeError:
                        # Not JSON: send raw with default address
                        try:
                            await sender.send(data)
                        except Exception:
                            try:
                                await ws.send(json.dumps({"error": "Send failed"}))
                            except Exception:
                                pass
                    except Exception:
                        try:
                            await ws.send(json.dumps({"error": "Send failed"}))
                        except Exception:
                            pass
                else:
                    break
        finally:
            await unregister_device_status_ws(ws)



    # Touch Device singleton early so db/device.json exists before first UDP hello.
    Device()
    await _send_bridge_wifi_channel(settings, sender)
    _prime_wifi_outbound_driver_connections()

    udp_holder = {"closing": False, "shutting_down": False}
    loop = asyncio.get_running_loop()
    server_tasks: list[asyncio.Task] = []

    def _graceful_shutdown(*_args):
        if udp_holder.get("shutting_down"):
            raise SystemExit(0)
        udp_holder["shutting_down"] = True
        print("[server] shutting down...")
        udp_holder["closing"] = True
        try:
            audio_detector.stop()
        except Exception:
            pass
        try:
            from util import sequence_playback as seq_pb

            seq_pb.stop()
            for attr in ("_pending_beat_task", "_sim_beat_task"):
                t = getattr(seq_pb, attr, None)
                if t is not None and not t.done():
                    t.cancel()
        except Exception:
            pass
        u = udp_holder.get("sock")
        if u is not None:
            try:
                u.close()
            except OSError:
                pass
        tcp_client_registry.cancel_all_driver_tasks()
        if getattr(app, "server", None) is not None:
            try:
                app.shutdown()
            except Exception:
                pass
        for t in server_tasks:
            if not t.done():
                t.cancel()

    shutdown_handlers_registered = False
    try:
        try:
            for sig in (signal.SIGINT, signal.SIGTERM):
                loop.add_signal_handler(sig, _graceful_shutdown)
            shutdown_handlers_registered = True
        except (NotImplementedError, RuntimeError):
            pass

        # Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
        try:
            server_tasks[:] = [
                asyncio.create_task(
                    app.start_server(host="0.0.0.0", port=port), name="http"
                ),
                asyncio.create_task(
                    _run_udp_discovery_server(udp_holder), name="udp"
                ),
            ]
            await asyncio.gather(*server_tasks)
        except asyncio.CancelledError:
            pass
        except OSError as e:
            if e.errno == errno.EADDRINUSE:
                print(
                    f"[server] bind failed (address already in use): {e!s}\n"
                    f"[server] HTTP is configured for port {port} (env PORT). "
                    f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
                )
            raise
    finally:
        try:
            audio_detector.stop()
        except Exception:
            pass
        srv = getattr(app, "server", None)
        if srv is not None:
            try:
                srv.close()
                await srv.wait_closed()
            except Exception:
                pass
            try:
                app.server = None
            except Exception:
                pass
        udp_holder["closing"] = True
        for t in list(server_tasks):
            if not t.done():
                t.cancel()
        if server_tasks:
            await asyncio.gather(*server_tasks, return_exceptions=True)
        pending = [
            t
            for t in asyncio.all_tasks(loop)
            if t is not asyncio.current_task() and not t.done()
        ]
        for t in pending:
            t.cancel()
        if pending:
            await asyncio.gather(*pending, return_exceptions=True)
        if shutdown_handlers_registered:
            for sig in (signal.SIGINT, signal.SIGTERM):
                try:
                    loop.remove_signal_handler(sig)
                except (NotImplementedError, OSError, ValueError):
                    pass

if __name__ == "__main__":
    import os

    port = int(os.environ.get("PORT", 80))
    try:
        asyncio.run(main(port=port))
    except KeyboardInterrupt:
        print("[server] interrupted")
