from microdot import Microdot
from models.device import (
    Device,
    derive_device_mac,
    normalize_mac,
    validate_device_transport,
    validate_device_type,
)
from models.group import Group
from models.transport import get_current_sender
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from util.driver_patterns import driver_patterns_dir
from util.binary_driver_messages import v1_dict_to_cmd_packet
from util.espnow_message import build_message
import asyncio
import json
import os
import socket
from urllib.parse import quote

# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
_IDENTIFY_PRESET_KEY = "__identify"

# Short-key payload: 10 Hz full cycle = 50 ms on + 50 ms off (driver ``blink`` toggles each ``d`` ms).
_IDENTIFY_DRIVER_PRESET = {
    "p": "blink",
    "c": ["#ff0000"],
    "d": 50,
    "b": 128,
    "a": True,
    "n1": 0,
    "n2": 0,
    "n3": 0,
    "n4": 0,
    "n5": 0,
    "n6": 0,
}


def _compact_v1_json(*, presets=None, select=None, save=False):
    """Single-line v1 object; compact so serial/ESP-NOW stays small."""
    body = {"v": "1"}
    if presets is not None:
        body["presets"] = presets
        if save:
            body["save"] = True
    if select is not None:
        body["select"] = select
    return json.dumps(body, separators=(",", ":"))

# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0


def _validate_output_brightness(value):
    if value is None:
        return None
    try:
        b = int(value)
    except (TypeError, ValueError):
        raise ValueError("output_brightness must be an integer 0–255")
    if b < 0 or b > 255:
        raise ValueError("output_brightness must be between 0 and 255")
    return b


def _brightness_save_message_json(b_val: int) -> str:
    b_val = max(0, min(255, int(b_val)))
    return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))


controller = Microdot()
devices = Device()
_group_registry = Group()
_pi_settings = get_settings()


def _device_live_connected(dev_dict):
    """ESP-NOW has no live session flag on the Pi."""
    return None


def _device_json_with_live_status(dev_dict):
    row = dict(dev_dict)
    row["connected"] = _device_live_connected(dev_dict)
    return row


def _safe_pattern_filename(name):
    if not isinstance(name, str):
        return False
    if not name.endswith(".py"):
        return False
    if "/" in name or "\\" in name or ".." in name:
        return False
    return True


def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, timeout_s=10.0):
    """POST source to driver /patterns/upload?name=...&reload=...; return True on 2xx."""
    if not isinstance(ip, str) or not ip.strip():
        return False
    if not isinstance(filename, str) or not filename:
        return False
    if not isinstance(code_text, str):
        return False

    name_q = quote(filename, safe="")
    reload_q = "1" if reload_patterns else "0"
    path = "/patterns/upload?name=%s&reload=%s" % (name_q, reload_q)
    body = code_text.encode("utf-8")
    req = (
        "POST %s HTTP/1.1\r\n"
        "Host: %s\r\n"
        "Content-Type: text/plain; charset=utf-8\r\n"
        "Content-Length: %d\r\n"
        "Connection: close\r\n"
        "\r\n" % (path, ip, len(body))
    ).encode("utf-8") + body

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        sock.settimeout(timeout_s)
        sock.connect((ip.strip(), 80))
        sock.sendall(req)
        data = b""
        while True:
            chunk = sock.recv(1024)
            if not chunk:
                break
            data += chunk
    except OSError:
        return False
    finally:
        try:
            sock.close()
        except Exception:
            pass

    first_line = data.split(b"\r\n", 1)[0] if data else b""
    return b" 2" in first_line


async def _identify_send_off_after_delay(sender, dev_id, name):
    try:
        await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
        pkt = v1_dict_to_cmd_packet(
            {"v": "1", "select": {name: ["off"]}},
        )
        await sender.send(pkt, addr=dev_id)
    except Exception:
        pass


async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
    """
    Send the same identify blink as ``POST /devices/<id>/identify``.

    Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure
    (status matches the single-device route).
    """
    dev = devices.read(dev_id)
    if not dev:
        return 404, "Device not found"
    sender = get_current_sender()
    if not sender:
        return 503, "Transport not configured"
    name = str(dev.get("name") or "").strip()
    if not name:
        return 400, "Device must have a name to identify"

    try:
        pkt = v1_dict_to_cmd_packet(
            {
                "v": "1",
                "presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
                "select": {name: [_IDENTIFY_PRESET_KEY]},
            }
        )
        ok = await sender.send(pkt, addr=dev_id)
        if not ok:
            return 503, "Send failed"

        asyncio.create_task(
            _identify_send_off_after_delay(sender, dev_id, name)
        )
    except Exception as e:
        return 503, str(e)
    return 200, ""


async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]:
    """
    Identify every listed registry MAC in one delivery round: merged ``select`` and a single
    ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
    ``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
    ``deliver_json_messages``.
    """
    from util.driver_delivery import deliver_json_messages

    errors: list[dict] = []
    sender = get_current_sender()
    if not sender:
        return 0, [{"mac": "*", "error": "Transport not configured"}]

    merged_select: dict[str, list[str]] = {}
    valid_macs: list[str] = []
    for dev_id in macs:
        dev = devices.read(dev_id)
        if not dev:
            errors.append({"mac": dev_id, "error": "Device not found"})
            continue
        name = str(dev.get("name") or "").strip()
        if not name:
            errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
            continue
        merged_select[name] = [_IDENTIFY_PRESET_KEY]
        valid_macs.append(dev_id)

    if not merged_select:
        return 0, errors

    try:
        msg = _compact_v1_json(
            presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
            select=merged_select,
        )
        await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
    except Exception as e:
        return 0, errors + [{"mac": "*", "error": str(e)}]

    for dev_id in valid_macs:
        dev = devices.read(dev_id) or {}
        name = str(dev.get("name") or "").strip()
        asyncio.create_task(
            _identify_send_off_after_delay(sender, dev_id, name)
        )

    return len(valid_macs), errors


@controller.get("")
async def list_devices(request):
    """List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
    devices_data = {}
    for dev_id in devices.list():
        d = devices.read(dev_id)
        if d:
            devices_data[dev_id] = _device_json_with_live_status(d)
    return json.dumps(devices_data), 200, {"Content-Type": "application/json"}


@controller.post("/resolve-brightness")
async def resolve_brightness_batch(request):
    """
    POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0–255 }``.
    Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
    """
    try:
        data = request.json or {}
    except Exception:
        data = {}
    macs = data.get("macs")
    if not isinstance(macs, list):
        return json.dumps({"error": "macs must be an array"}), 400, {
            "Content-Type": "application/json",
        }
    zb = None
    if isinstance(data, dict) and data.get("zone_brightness") is not None:
        try:
            zb = _validate_output_brightness(data.get("zone_brightness"))
        except ValueError as e:
            return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
    values = {}
    for raw in macs:
        m = normalize_mac(str(raw))
        if not m:
            continue
        values[m] = effective_brightness_for_mac(
            _pi_settings,
            _group_registry,
            devices,
            m,
            zone_brightness=zb,
        )
    return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}


@controller.get("/<id>")
async def get_device(request, id):
    """Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
    dev = devices.read(id)
    if dev:
        return json.dumps(_device_json_with_live_status(dev)), 200, {
            "Content-Type": "application/json",
        }
    return json.dumps({"error": "Device not found"}), 404, {
        "Content-Type": "application/json",
    }


@controller.post("")
async def create_device(request):
    """Create a new device."""
    try:
        data = request.json or {}
        name = data.get("name", "").strip()
        if not name:
            return json.dumps({"error": "name is required"}), 400, {
                "Content-Type": "application/json",
            }
        try:
            device_type = validate_device_type(data.get("type", "led"))
            transport = validate_device_transport(data.get("transport", "espnow"))
        except ValueError as e:
            return json.dumps({"error": str(e)}), 400, {
                "Content-Type": "application/json",
            }
        address = data.get("address")
        mac = data.get("mac")
        if derive_device_mac(mac=mac, address=address, transport=transport) is None:
            return json.dumps(
                {
                    "error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
                }
            ), 400, {"Content-Type": "application/json"}
        default_pattern = data.get("default_pattern")
        zl = data.get("zones")
        if isinstance(zl, list):
            zl = [str(t) for t in zl]
        else:
            zl = []
        dev_id = devices.create(
            name=name,
            address=address,
            mac=mac,
            default_pattern=default_pattern,
            zones=zl,
            device_type=device_type,
            transport=transport,
        )
        dev = devices.read(dev_id)
        return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
    except ValueError as e:
        msg = str(e)
        code = 409 if "already exists" in msg.lower() else 400
        return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
    except Exception as e:
        return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}


@controller.put("/<id>")
async def update_device(request, id):
    """Update a device."""
    try:
        raw = request.json or {}
        data = dict(raw)
        data.pop("id", None)
        data.pop("addresses", None)
        data.pop("connected", None)
        if "name" in data:
            n = (data.get("name") or "").strip()
            if not n:
                return json.dumps({"error": "name cannot be empty"}), 400, {
                    "Content-Type": "application/json",
                }
            data["name"] = n
        if "type" in data:
            data["type"] = validate_device_type(data.get("type"))
        if "transport" in data:
            data["transport"] = validate_device_transport(data.get("transport"))
        if "zones" in data and isinstance(data["zones"], list):
            data["zones"] = [str(t) for t in data["zones"]]
        if "output_brightness" in data:
            data["output_brightness"] = _validate_output_brightness(data.get("output_brightness"))
        prev_doc = devices.read(id)
        if devices.update(id, data):
            if prev_doc and "name" in data:
                on = str(prev_doc.get("name") or "").strip()
                nn = str(data.get("name") or "").strip()
                if on and nn and on != nn:
                    from util.beat_driver_route import remap_beat_route_device_name

                    remap_beat_route_device_name(on, nn)
            return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
        return json.dumps({"error": "Device not found"}), 404, {
            "Content-Type": "application/json",
        }
    except ValueError as e:
        return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
    except Exception as e:
        return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}


@controller.delete("/<id>")
async def delete_device(request, id):
    """Delete a device."""
    if devices.delete(id):
        return (
            json.dumps({"message": "Device deleted successfully"}),
            200,
            {"Content-Type": "application/json"},
        )
    return json.dumps({"error": "Device not found"}), 404, {
        "Content-Type": "application/json",
    }


@controller.post("/<id>/identify")
async def identify_device(request, id):
    """
    One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
    this device name — same combined shape as profile sends the driver already accepts over TCP
    / ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
    """
    status, err = await send_identify_to_device(id)
    if status == 200:
        return json.dumps({"message": "Identify sent"}), 200, {
            "Content-Type": "application/json",
        }
    return json.dumps({"error": err}), status, {"Content-Type": "application/json"}


@controller.post("/<id>/brightness")
async def push_device_output_brightness(request, id):
    """
    Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
    in JSON body — single ``b`` (``v``/``b``/``save``). Wi‑Fi or ESP‑NOW.
    """
    dev = devices.read(id)
    if not dev:
        return json.dumps({"error": "Device not found"}), 404, {
            "Content-Type": "application/json",
        }
    body = request.json or {}
    zb = None
    if isinstance(body, dict) and body.get("zone_brightness") is not None:
        try:
            zb = _validate_output_brightness(body.get("zone_brightness"))
        except ValueError as e:
            return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
    b_val = effective_brightness_for_mac(
        _pi_settings,
        _group_registry,
        devices,
        id,
        zone_brightness=zb,
    )

    pkt = v1_dict_to_cmd_packet({"v": "1", "b": b_val, "save": True})
    sender = get_current_sender()
    if not sender:
        return json.dumps({"error": "Transport not configured"}), 503, {
            "Content-Type": "application/json",
        }
    try:
        ok = await sender.send(pkt, addr=id)
        if not ok:
            return json.dumps({"error": "Send failed"}), 503, {
                "Content-Type": "application/json",
            }
    except Exception as e:
        return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}

    return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
        "Content-Type": "application/json",
    }


@controller.post("/<id>/driver-config")
async def push_driver_config(request, id):
    """
    Push ``device_config`` to an ESP-NOW LED driver.
    Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
    """
    dev = devices.read(id)
    if not dev:
        return json.dumps({"error": "Device not found"}), 404, {
            "Content-Type": "application/json",
        }
    sender = get_current_sender()
    if not sender:
        return json.dumps({"error": "Transport not configured"}), 503, {
            "Content-Type": "application/json",
        }
    body = request.json or {}
    dc = {}
    if isinstance(body.get("name"), str) and body["name"].strip():
        dc["name"] = body["name"].strip()
    if "num_leds" in body:
        try:
            n = int(body["num_leds"])
            if 1 <= n <= 2048:
                dc["num_leds"] = n
        except (TypeError, ValueError):
            pass
    if isinstance(body.get("color_order"), str):
        co = body["color_order"].strip().lower()
        if co in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
            dc["color_order"] = co
    if isinstance(body.get("startup_mode"), str):
        sm = body["startup_mode"].strip().lower()
        if sm in ("default", "last", "off"):
            dc["startup_mode"] = sm
    if not dc:
        return json.dumps(
            {
                "error": "Provide at least one of name, num_leds, color_order, startup_mode"
            }
        ), 400, {"Content-Type": "application/json"}
    pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True})
    ok = await sender.send(pkt, addr=id)
    if not ok:
        return json.dumps({"error": "Send failed"}), 503, {
            "Content-Type": "application/json",
        }
    return json.dumps({"message": "driver-config sent"}), 200, {
        "Content-Type": "application/json",
    }


@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
    """
    Pattern OTA over HTTP is not available for ESP-NOW drivers.
    """
    dev = devices.read(id)
    if not dev:
        return json.dumps({"error": "Device not found"}), 404, {
            "Content-Type": "application/json",
        }
    return json.dumps(
        {"error": "Pattern OTA push is not supported for ESP-NOW devices"}
    ), 400, {"Content-Type": "application/json"}
