from microdot import Microdot
from models.device import (
    Device,
    derive_device_mac,
    validate_device_transport,
    validate_device_type,
)
from models.transport import get_current_sender
from models.wifi_ws_clients import (
    normalize_tcp_peer_ip,
    send_json_line_to_ip,
    tcp_client_connected,
)
from util.driver_patterns import driver_patterns_dir
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

controller = Microdot()
devices = Device()


def _device_live_connected(dev_dict):
    """
    Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
    ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
    """
    tr = (dev_dict.get("transport") or "espnow").strip().lower()
    if tr != "wifi":
        return None
    ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
    if not ip:
        return False
    return tcp_client_connected(ip)


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, transport, wifi_ip, dev_id, name):
    try:
        await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
        off_msg = build_message(select={name: ["off"]})
        if transport == "wifi":
            await send_json_line_to_ip(wifi_ip, off_msg)
        else:
            await sender.send(off_msg, addr=dev_id)
    except Exception:
        pass


@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.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 devices.update(id, data):
            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``.
    """
    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",
        }
    name = str(dev.get("name") or "").strip()
    if not name:
        return json.dumps({"error": "Device must have a name to identify"}), 400, {
            "Content-Type": "application/json",
        }

    transport = dev.get("transport") or "espnow"
    wifi_ip = None
    if transport == "wifi":
        wifi_ip = dev.get("address")
        if not wifi_ip:
            return json.dumps({"error": "Device has no IP address"}), 400, {
                "Content-Type": "application/json",
            }

    try:
        msg = _compact_v1_json(
            presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
            select={name: [_IDENTIFY_PRESET_KEY]},
        )
        if transport == "wifi":
            ok = await send_json_line_to_ip(wifi_ip, msg)
            if not ok:
                return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
                    "Content-Type": "application/json",
                }
        else:
            await sender.send(msg, addr=id)

        asyncio.create_task(
            _identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
        )
    except Exception as e:
        return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
    return json.dumps({"message": "Identify sent"}), 200, {
        "Content-Type": "application/json",
    }


@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
    """
    Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
    """
    dev = devices.read(id)
    if not dev:
        return json.dumps({"error": "Device not found"}), 404, {
            "Content-Type": "application/json",
        }
    if (dev.get("transport") or "").lower() != "wifi":
        return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
            "Content-Type": "application/json",
        }
    wifi_ip = str(dev.get("address") or "").strip()
    if not wifi_ip:
        return json.dumps({"error": "Device has no IP address"}), 400, {
            "Content-Type": "application/json",
        }

    base_dir = driver_patterns_dir()
    try:
        names = sorted(os.listdir(base_dir))
    except OSError as e:
        return json.dumps({"error": str(e)}), 500, {
            "Content-Type": "application/json",
        }

    files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
    if not files:
        return json.dumps({"error": "No pattern files found"}), 404, {
            "Content-Type": "application/json",
        }

    sent = []
    failed = []
    total = len(files)
    for idx, filename in enumerate(files):
        path = os.path.join(base_dir, filename)
        try:
            with open(path, "r") as f:
                code = f.read()
        except OSError:
            failed.append(filename)
            continue
        reload_patterns = idx == (total - 1)
        ok = _http_post_pattern_source(
            wifi_ip,
            filename,
            code,
            reload_patterns=reload_patterns,
            timeout_s=10.0,
        )
        if ok:
            sent.append(filename)
        else:
            failed.append(filename)

    if not sent:
        return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
            "Content-Type": "application/json",
        }

    return json.dumps({
        "message": "Pattern files uploaded",
        "sent_count": len(sent),
        "sent": sent,
        "failed": failed,
    }), 200, {
        "Content-Type": "application/json",
    }
