import asyncio
import errno
import json
import os
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 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 _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:
    """
    For each Wi‑Fi device in the registry with a usable IPv4, start (or keep) the
    outbound WebSocket task. The client loop reconnects automatically if the link
    drops. Presets are not pushed automatically; use Send Presets / profile apply.
    """
    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 _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
    """
    While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
    UDP discovery port so the device can announce itself and we can reconnect.
    """
    try:
        interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
    except (TypeError, ValueError):
        interval = 10.0
    if interval <= 0:
        return

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setblocking(False)
    loop = asyncio.get_running_loop()
    try:
        while True:
            await asyncio.sleep(interval)
            if udp_holder.get("closing"):
                break
            try:
                dev = Device()
            except Exception as e:
                print(f"[hello] device list failed: {e!r}")
                continue
            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
                if tcp_client_registry.tcp_client_connected(ip):
                    continue
                name = (doc.get("name") or "").strip()
                mac = normalize_mac(doc.get("id") or _mac_key)
                if not name or not mac:
                    continue
                line = (
                    json.dumps(
                        {"m": "hello", "device_name": name, "mac": mac},
                        separators=(",", ":"),
                    )
                    + "\n"
                )
                try:
                    await loop.sock_sendto(
                        sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
                    )
                except OSError as e:
                    print(f"[hello] UDP to {ip!r} failed: {e!r}")
    finally:
        try:
            sock.close()
        except OSError:
            pass


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 = Settings()
    print(settings)
    print("Starting")

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

    app = Microdot()
    audio_detector = AudioBeatDetector()
    from util import beat_driver_route

    beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
    
    # 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)

    # Serve index.html at root (cwd is src/ when run via pipenv run run)
    @app.route('/')
    def index(request):
        """Serve the main web UI."""
        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)
            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()
        return {"ok": True, "status": audio_detector.status()}

    @app.route('/api/audio/status')
    async def audio_status(request):
        _ = request
        return {"status": audio_detector.status()}

    # 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}
    loop = asyncio.get_running_loop()

    def _graceful_shutdown(*_args):
        print("[server] shutting down...")
        udp_holder["closing"] = True
        try:
            audio_detector.stop()
        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:
            app.shutdown()

    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:
            await asyncio.gather(
                app.start_server(host="0.0.0.0", port=port),
                _run_udp_discovery_server(udp_holder),
                _periodic_wifi_driver_hello_loop(settings, udp_holder),
            )
        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
        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))
    asyncio.run(main(port=port))
