from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.request import urlopen
from urllib.parse import parse_qs, urlparse
from datetime import datetime, timezone
import json
import ipaddress
import os
import re
import socket
import time
import uuid


ROOT = Path(__file__).resolve().parents[1]
DASHBOARD_DIR = ROOT / "dashboard"
CACHE_DIR = ROOT / "dashboard" / "data"
CACHE_FILE = CACHE_DIR / "last_webhook_requests.json"
LOCAL_INGEST_FILE = CACHE_DIR / "local_ingest_requests.json"
ACCESS_FILE = CACHE_DIR / "oem_access.json"
WEBHOOK_TOKEN = os.environ.get("RMS_WEBHOOK_TOKEN", "90365bac-b234-452c-8cee-03e24f6aecae")
WEBHOOK_API = f"https://webhook.site/token/{WEBHOOK_TOKEN}/requests?sorting=newest"
OFFLINE_AFTER_SECONDS = int(os.environ.get("RMS_OFFLINE_AFTER_SECONDS", "180"))
LAN_STATUS_FALLBACK = os.environ.get("RMS_LAN_STATUS_FALLBACK", "0") == "1"
LAN_PROBE_CACHE_SECONDS = 5
LAN_PROBE_CACHE = {}

DEFAULT_ACCESS = {
    "users": [
        {
            "username": "admin",
            "password": "etpl@123",
            "role": "admin",
            "display_name": "Admin",
            "oem_id": "",
            "oem_name": "Admin",
        },
    ],
    "assignments": {},
    "device_meta": {
        "02:E8:26:C4:27:13": {
            "name": "Inverter 2713",
            "location": "Main Solar Site",
            "ip": "192.168.31.235"
        }
    },
}

FAULT_REGISTERS = {
    "inverter_status_fault": "Inverter",
    "pfc_charger_status_fault": "PFC Charger",
    "mppt_charger_status_fault": "MPPT Charger",
    "mains_grid_status_fault": "Mains Grid",
    "battery_status_fault": "Battery",
}


def read_json_file(path, fallback):
    if not path.exists():
        return fallback
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return fallback


def write_json_file(path, payload):
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def load_access():
    access = read_json_file(ACCESS_FILE, DEFAULT_ACCESS)
    if not ACCESS_FILE.exists():
        write_json_file(ACCESS_FILE, access)
    access.setdefault("users", DEFAULT_ACCESS["users"])
    access.setdefault("assignments", {})
    access.setdefault("device_meta", {})
    changed = False
    if migrate_default_demo_oems(access):
        changed = True
    if normalize_access_device_ids(access):
        changed = True
    if changed:
        save_access(access)
    return access


def save_access(access):
    write_json_file(ACCESS_FILE, access)


def migrate_default_demo_oems(access):
    original_users = list(access.get("users", []))
    original_assignments = dict(access.get("assignments", {}))
    demo_oem_ids = {"oem-alpha", "oem-beta"}
    demo_usernames = {"oem1", "oem2"}
    access["users"] = [
        user for user in access.get("users", [])
        if not (
            user.get("role") == "oem"
            and user.get("username") in demo_usernames
            and user.get("oem_id") in demo_oem_ids
        )
    ]
    valid_oem_ids = {
        user.get("oem_id") for user in access.get("users", [])
        if user.get("role") == "oem" and user.get("oem_id")
    }
    access["assignments"] = {
        device_id: oem_id
        for device_id, oem_id in access.get("assignments", {}).items()
        if oem_id in valid_oem_ids
    }
    return access["users"] != original_users or access["assignments"] != original_assignments


def normalize_access_device_ids(access):
    changed = False

    normalized_meta = {}
    for device_id, meta in access.get("device_meta", {}).items():
        normalized_id = normalize_device_id(device_id)
        if not normalized_id:
            changed = True
            continue
        if normalized_id != device_id:
            changed = True
        normalized_meta.setdefault(normalized_id, {}).update(meta if isinstance(meta, dict) else {})
    if normalized_meta != access.get("device_meta", {}):
        access["device_meta"] = normalized_meta
        changed = True

    normalized_assignments = {}
    for device_id, oem_id in access.get("assignments", {}).items():
        normalized_id = normalize_device_id(device_id)
        if normalized_id:
            normalized_assignments[normalized_id] = oem_id
            if normalized_id != device_id:
                changed = True
        else:
            changed = True
    if normalized_assignments != access.get("assignments", {}):
        access["assignments"] = normalized_assignments
        changed = True

    for user in access.get("users", []):
        if user.get("role") != "customer":
            continue
        original_ids = list(user.get("device_ids", []))
        normalized_ids = []
        for device_id in original_ids:
            normalized_id = normalize_device_id(device_id)
            if normalized_id and normalized_id not in normalized_ids:
                normalized_ids.append(normalized_id)
        if original_ids != normalized_ids:
            user["device_ids"] = normalized_ids
            changed = True

    return changed


def public_user(user):
    payload = {
        "username": user.get("username", ""),
        "role": user.get("role", "customer"),
        "display_name": user.get("display_name", user.get("username", "")),
        "oem_id": user.get("oem_id", ""),
        "oem_name": user.get("oem_name", ""),
    }
    if user.get("role") == "customer":
        payload["device_ids"] = user.get("device_ids", [])
    return payload


def slugify(value):
    base = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
    return base or "oem"


def normalize_device_id(value):
    raw = str(value or "").strip().upper()
    compact = re.sub(r"[^0-9A-F]", "", raw)
    if len(compact) == 12:
        return ":".join(compact[index:index + 2] for index in range(0, 12, 2))
    return raw


def find_user(username):
    access = load_access()
    for user in access.get("users", []):
        if user.get("username") == username:
            return user
    return None


def oem_lookup(access):
    return {
        user.get("oem_id"): user.get("oem_name", user.get("display_name", user.get("username", "")))
        for user in access.get("users", [])
        if user.get("role") == "oem" and user.get("oem_id")
    }


def username_exists(access, username):
    return any(user.get("username") == username for user in access.get("users", []))


def can_access_device(user, device_id, access):
    if not user:
        return False
    if user.get("role") == "admin":
        return True
    if user.get("role") == "customer":
        allowed_devices = {normalize_device_id(item) for item in user.get("device_ids", [])}
        if user.get("device_id"):
            allowed_devices.add(normalize_device_id(user.get("device_id")))
        return normalize_device_id(device_id) in allowed_devices
    return access.get("assignments", {}).get(device_id) == user.get("oem_id")


def fault_bits(value):
    number = int(value or 0)
    return [index for index in range(16) if number & (1 << index)]


def fault_summary(data):
    faults = []
    for key, label in FAULT_REGISTERS.items():
        raw_value = int(data.get(key) or 0)
        bits = fault_bits(raw_value)
        if bits:
            faults.append({
                "key": key,
                "label": label,
                "value": raw_value,
                "hex": f"0x{raw_value:04X}",
                "bits": bits,
                "description": ", ".join(f"Bit {bit}" for bit in bits),
            })
    return faults


def parse_server_time(value):
    if not value:
        return None
    try:
        normalized = value.replace("Z", "+00:00")
        parsed = datetime.fromisoformat(normalized)
    except ValueError:
        try:
            parsed = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
        except ValueError:
            return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def freshness(received_at):
    parsed = parse_server_time(received_at)
    if not parsed:
        return {
            "online": False,
            "status": "offline",
            "age_seconds": None,
            "offline_after_seconds": OFFLINE_AFTER_SECONDS,
        }
    age_seconds = max(0, int((datetime.now(timezone.utc) - parsed).total_seconds()))
    online = age_seconds <= OFFLINE_AFTER_SECONDS
    return {
        "online": online,
        "status": "online" if online else "offline",
        "age_seconds": age_seconds,
        "offline_after_seconds": OFFLINE_AFTER_SECONDS,
    }


def save_cache(requests):
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    CACHE_FILE.write_text(json.dumps({"data": requests}, indent=2), encoding="utf-8")


def load_local_ingest():
    return read_json_file(LOCAL_INGEST_FILE, {"data": []}).get("data", [])


def save_local_ingest(requests):
    write_json_file(LOCAL_INGEST_FILE, {"data": requests[:500]})


def load_cache():
    if not CACHE_FILE.exists():
        raise RuntimeError("Webhook unavailable and no saved server data cache exists yet")
    return json.loads(CACHE_FILE.read_text(encoding="utf-8")).get("data", [])


def fetch_requests():
    try:
        with urlopen(WEBHOOK_API, timeout=15) as response:
            webhook_requests = json.loads(response.read().decode("utf-8")).get("data", [])
        save_cache(webhook_requests)
    except Exception:
        webhook_requests = load_cache()
    requests = webhook_requests + load_local_ingest()
    return sorted(requests, key=lambda item: item.get("sorting", 0), reverse=True)


def parse_request_payload(request):
    payload = json.loads(request.get("content") or "{}")
    if not payload.get("device_id") and isinstance(payload.get("data"), dict):
        wrapped_payload = payload.get("data")
        if isinstance(wrapped_payload, dict) and wrapped_payload.get("device_id"):
            payload = wrapped_payload
    received_at = request.get("created_at") or request.get("date") or ""
    payload["server"] = {
        "source": "Webhook.site",
        "request_uuid": request.get("uuid"),
        "public_ip": request.get("ip"),
        "received_at": received_at,
        **freshness(received_at),
    }
    return payload


def empty_device_payload(device_id, meta=None):
    meta = meta or {}
    return {
        "device_id": device_id,
        "ip": meta.get("ip", ""),
        "firmware": "",
        "ok": False,
        "data": {},
        "server": {
            "source": "Manual device entry",
            "public_ip": "",
            "received_at": "",
            "online": False,
            "status": "offline",
            "age_seconds": None,
            "offline_after_seconds": OFFLINE_AFTER_SECONDS,
        },
    }


def local_device_online(ip_address):
    if not LAN_STATUS_FALLBACK or not ip_address:
        return False
    try:
        if not ipaddress.ip_address(ip_address).is_private:
            return False
    except ValueError:
        return False

    now = time.monotonic()
    cached = LAN_PROBE_CACHE.get(ip_address)
    if cached and now - cached[0] < LAN_PROBE_CACHE_SECONDS:
        return cached[1]

    try:
        with socket.create_connection((ip_address, 80), timeout=0.3):
            online = True
    except OSError:
        online = False
    LAN_PROBE_CACHE[ip_address] = (now, online)
    return online


def latest_payloads_by_device():
    devices = {}
    for request in fetch_requests():
        try:
            payload = parse_request_payload(request)
        except Exception:
            continue
        device_id = normalize_device_id(payload.get("device_id"))
        payload["device_id"] = device_id
        if device_id and device_id not in devices:
            devices[device_id] = payload
    return devices


def device_summary(payload, access):
    device_id = payload.get("device_id", "")
    data = payload.get("data", {})
    server = dict(payload.get("server", {}))
    assignments = access.get("assignments", {})
    oems = oem_lookup(access)
    meta = access.get("device_meta", {}).get(device_id, {})
    faults = fault_summary(data)
    oem_id = assignments.get(device_id, "")
    internet_online = bool(server.get("online"))
    lan_online = local_device_online(payload.get("ip", ""))
    online = internet_online or lan_online
    if lan_online and not internet_online:
        server["online"] = True
        server["status"] = "online"
        server["connectivity_source"] = "Local LAN fallback"
        server["internet_upload_online"] = False
    else:
        server["connectivity_source"] = "Internet server"
        server["internet_upload_online"] = internet_online
    has_inverter_data = bool(payload.get("ok")) and bool(data)
    if has_inverter_data:
        data_status = "live" if online else "saved"
    else:
        data_status = "no_data"
    return {
        "device_id": device_id,
        "name": meta.get("name") or f"Inverter {device_id.replace(':', '')[-4:]}",
        "location": meta.get("location") or "Location not set",
        "ip": payload.get("ip", ""),
        "firmware": payload.get("firmware", ""),
        "ok": bool(payload.get("ok")),
        "online": online,
        "status": server.get("status", "offline"),
        "connectivity_source": server.get("connectivity_source", "Internet server"),
        "has_inverter_data": has_inverter_data,
        "data_status": data_status,
        "age_seconds": server.get("age_seconds"),
        "received_at": server.get("received_at", ""),
        "public_ip": server.get("public_ip", ""),
        "server": server,
        "packet": payload.get("packet", ""),
        "data": data,
        "oem_id": oem_id,
        "oem_name": oems.get(oem_id, "Unassigned"),
        "output_power": data.get("output_power", 0),
        "solar_power": data.get("solar_power", 0),
        "battery_voltage": data.get("battery_voltage", 0),
        "temperature": data.get("temperature", 0),
        "fault_count": sum(len(item.get("bits", [])) for item in faults),
        "has_faults": bool(faults),
        "faults": faults,
    }


def visible_device_summaries(username=""):
    access = load_access()
    user = find_user(username) if username else None
    payloads = latest_payloads_by_device()
    known_device_ids = set(access.get("device_meta", {}).keys()) | set(access.get("assignments", {}).keys())
    for known_device_id in sorted(known_device_ids):
        if known_device_id and known_device_id not in payloads:
            payloads[known_device_id] = empty_device_payload(
                known_device_id,
                access.get("device_meta", {}).get(known_device_id, {}),
            )
    devices = []
    for payload in payloads.values():
        device_id = payload.get("device_id", "")
        if user and not can_access_device(user, device_id, access):
            continue
        devices.append(device_summary(payload, access))
    return devices, access, user


def fleet_summary(devices):
    total = len(devices)
    online = sum(1 for device in devices if device.get("online"))
    offline = total - online
    unassigned = sum(1 for device in devices if not device.get("oem_id"))
    assigned = total - unassigned
    faulty = sum(1 for device in devices if device.get("has_faults"))
    no_data = sum(1 for device in devices if not device.get("has_inverter_data"))
    total_power = sum(float(device.get("output_power") or 0) for device in devices)
    return {
        "total_devices": total,
        "online_devices": online,
        "offline_devices": offline,
        "faulty_devices": faulty,
        "no_data_devices": no_data,
        "assigned_devices": assigned,
        "unassigned_devices": unassigned,
        "total_output_power": total_power,
    }


class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(DASHBOARD_DIR), **kwargs)

    def end_headers(self):
        self.send_header("Cache-Control", "no-store")
        super().end_headers()

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/api/latest":
            params = parse_qs(parsed.query)
            self.send_latest((params.get("device_id") or [""])[0],
                             (params.get("username") or [""])[0])
            return
        if parsed.path == "/api/devices":
            params = parse_qs(parsed.query)
            self.send_devices((params.get("username") or [""])[0])
            return
        if parsed.path == "/api/fleet":
            params = parse_qs(parsed.query)
            self.send_fleet((params.get("username") or [""])[0])
            return
        if parsed.path == "/api/oems":
            self.send_oems()
            return
        super().do_GET()

    def do_POST(self):
        parsed = urlparse(self.path)
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8", errors="replace") if length else "{}"
        try:
            payload = json.loads(body or "{}")
        except json.JSONDecodeError:
            self.send_json({"ok": False, "error": "Invalid JSON"}, 400)
            return
        if parsed.path == "/api/login":
            self.login(payload)
            return
        if parsed.path == "/api/signup":
            self.create_customer(payload)
            return
        if parsed.path == "/api/ingest":
            self.ingest_device_payload(payload)
            return
        if parsed.path == "/api/assign":
            self.assign_device(payload)
            return
        if parsed.path == "/api/devices":
            self.create_device(payload)
            return
        if parsed.path == "/api/oems":
            self.create_oem(payload)
            return
        if parsed.path == "/api/device-meta":
            self.update_device_meta(payload)
            return
        self.send_json({"ok": False, "error": "Unknown API route"}, 404)

    def send_json(self, payload, status=200):
        body = json.dumps(payload, indent=2).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def send_latest(self, device_id="", username=""):
        try:
            access = load_access()
            user = find_user(username) if username else None
            for request in fetch_requests():
                payload = parse_request_payload(request)
                if not device_id or payload.get("device_id") == device_id:
                    if user and not can_access_device(user, payload.get("device_id", ""), access):
                        self.send_json({"ok": False, "error": "Device is not assigned to this OEM"}, 403)
                        return
                    self.send_json(payload)
                    return
            self.send_json({"ok": False, "error": "No data found for selected device"}, 404)
        except Exception as exc:
            self.send_json({"ok": False, "error": str(exc)}, 502)

    def send_devices(self, username=""):
        try:
            devices, _access, _user = visible_device_summaries(username)
            self.send_json({"devices": devices, "summary": fleet_summary(devices)})
        except Exception as exc:
            self.send_json({"ok": False, "error": str(exc), "devices": []}, 502)

    def send_fleet(self, username=""):
        try:
            devices, _access, user = visible_device_summaries(username)
            self.send_json({
                "user": public_user(user) if user else None,
                "summary": fleet_summary(devices),
                "devices": devices,
            })
        except Exception as exc:
            self.send_json({"ok": False, "error": str(exc), "devices": []}, 502)

    def send_oems(self):
        access = load_access()
        oems = [
            public_user(user)
            for user in access.get("users", [])
            if user.get("role") == "oem"
        ]
        self.send_json({"oems": oems})

    def login(self, payload):
        username = str(payload.get("username", "")).strip()
        password = str(payload.get("password", ""))
        user = find_user(username)
        if not user or user.get("password") != password:
            self.send_json({"ok": False, "error": "Wrong login ID or password"}, 401)
            return
        self.send_json({"ok": True, "user": public_user(user)})

    def assign_device(self, payload):
        username = str(payload.get("username", "")).strip()
        user = find_user(username)
        if not user or user.get("role") != "admin":
            self.send_json({"ok": False, "error": "Only admin can assign OEM devices"}, 403)
            return
        device_id = str(payload.get("device_id", "")).strip()
        oem_id = str(payload.get("oem_id", "")).strip()
        if not device_id:
            self.send_json({"ok": False, "error": "device_id is required"}, 400)
            return
        access = load_access()
        if oem_id:
            valid_oems = oem_lookup(access)
            if oem_id not in valid_oems:
                self.send_json({"ok": False, "error": "Unknown OEM"}, 400)
                return
            access.setdefault("assignments", {})[device_id] = oem_id
        else:
            access.setdefault("assignments", {}).pop(device_id, None)
        save_access(access)
        self.send_json({"ok": True, "assignments": access.get("assignments", {})})

    def create_oem(self, payload):
        admin_username = str(payload.get("username", "")).strip()
        admin = find_user(admin_username)
        if not admin or admin.get("role") != "admin":
            self.send_json({"ok": False, "error": "Only admin can create OEM login"}, 403)
            return

        new_username = str(payload.get("new_username", "")).strip()
        password = str(payload.get("password", "")).strip()
        oem_name = str(payload.get("oem_name", "")).strip()
        display_name = str(payload.get("display_name", "")).strip() or oem_name or new_username
        if not new_username or not password or not oem_name:
            self.send_json({"ok": False, "error": "OEM name, login ID and password are required"}, 400)
            return

        access = load_access()
        if username_exists(access, new_username):
            self.send_json({"ok": False, "error": "Login ID already exists"}, 400)
            return

        existing_oem_ids = {user.get("oem_id") for user in access.get("users", [])}
        base_oem_id = f"oem-{slugify(oem_name)}"
        oem_id = base_oem_id
        suffix = 2
        while oem_id in existing_oem_ids:
            oem_id = f"{base_oem_id}-{suffix}"
            suffix += 1

        user = {
            "username": new_username,
            "password": password,
            "role": "oem",
            "display_name": display_name,
            "oem_id": oem_id,
            "oem_name": oem_name,
        }
        access.setdefault("users", []).append(user)
        save_access(access)
        self.send_json({"ok": True, "user": public_user(user)})

    def create_customer(self, payload):
        username = str(payload.get("username", "")).strip()
        password = str(payload.get("password", "")).strip()
        display_name = str(payload.get("display_name", "")).strip() or username
        device_id = normalize_device_id(payload.get("device_id", ""))
        if not username or not password or not device_id:
            self.send_json({"ok": False, "error": "Login ID, password and inverter device ID are required"}, 400)
            return

        access = load_access()
        if username_exists(access, username):
            self.send_json({"ok": False, "error": "Login ID already exists. Please login instead."}, 400)
            return

        user = {
            "username": username,
            "password": password,
            "role": "customer",
            "display_name": display_name,
            "oem_id": "",
            "oem_name": "Customer",
            "device_ids": [device_id],
        }
        access.setdefault("users", []).append(user)
        save_access(access)
        self.send_json({"ok": True, "user": public_user(user)})

    def ingest_device_payload(self, payload):
        if not payload.get("device_id") and isinstance(payload.get("data"), dict):
            wrapped_payload = payload.get("data")
            if isinstance(wrapped_payload, dict) and wrapped_payload.get("device_id"):
                payload = wrapped_payload

        device_id = normalize_device_id(payload.get("device_id", ""))
        if not device_id:
            self.send_json({"ok": False, "error": "device_id is required"}, 400)
            return

        payload["device_id"] = device_id
        now = datetime.now(timezone.utc)
        now_label = now.strftime("%Y-%m-%d %H:%M:%S")
        request = {
            "uuid": str(uuid.uuid4()),
            "type": "web",
            "token_id": "local-dashboard",
            "ip": self.client_address[0],
            "country": "",
            "country_code": "",
            "region": "",
            "city": "",
            "hostname": self.headers.get("host", ""),
            "protocol": self.request_version.lower(),
            "method": "POST",
            "user_agent": self.headers.get("user-agent", ""),
            "content": json.dumps(payload, separators=(",", ":")),
            "query": None,
            "headers": {},
            "url": "/api/ingest",
            "size": len(json.dumps(payload)),
            "files": [],
            "created_at": now_label,
            "updated_at": now_label,
            "sorting": int(time.time() * 1000000),
            "custom_action_output": [],
            "custom_action_errors": [],
            "time": 0,
        }
        local_requests = [request] + [
            item for item in load_local_ingest()
            if not (item.get("content", "").find(f'"device_id":"{device_id}"') >= 0 and item.get("created_at") == now_label)
        ]
        save_local_ingest(local_requests)

        access = load_access()
        access.setdefault("device_meta", {}).setdefault(device_id, {
            "name": f"Inverter {device_id.replace(':', '')[-4:]}",
            "location": "Location not set",
        })
        save_access(access)
        self.send_json({"ok": True, "device_id": device_id})

    def create_device(self, payload):
        username = str(payload.get("username", "")).strip()
        user = find_user(username)
        if not user or user.get("role") != "admin":
            self.send_json({"ok": False, "error": "Only admin can add devices"}, 403)
            return

        device_id = normalize_device_id(payload.get("device_id", ""))
        if not device_id:
            self.send_json({"ok": False, "error": "Device ID is required"}, 400)
            return

        access = load_access()
        if device_id in access.get("device_meta", {}):
            self.send_json({"ok": False, "error": "Device already exists. Use edit option."}, 400)
            return

        name = str(payload.get("name", "")).strip() or f"Inverter {device_id.replace(':', '')[-4:]}"
        location = str(payload.get("location", "")).strip() or "Location not set"
        access.setdefault("device_meta", {})[device_id] = {
            "name": name,
            "location": location,
        }

        oem_id = str(payload.get("oem_id", "")).strip()
        if oem_id:
            valid_oems = oem_lookup(access)
            if oem_id not in valid_oems:
                self.send_json({"ok": False, "error": "Unknown OEM"}, 400)
                return
            access.setdefault("assignments", {})[device_id] = oem_id

        save_access(access)
        self.send_json({
            "ok": True,
            "device": device_summary(
                empty_device_payload(device_id, access.get("device_meta", {}).get(device_id, {})),
                access,
            ),
        })

    def update_device_meta(self, payload):
        username = str(payload.get("username", "")).strip()
        user = find_user(username)
        if not user or user.get("role") != "admin":
            self.send_json({"ok": False, "error": "Only admin can edit device details"}, 403)
            return

        device_id = normalize_device_id(payload.get("device_id", ""))
        if not device_id:
            self.send_json({"ok": False, "error": "device_id is required"}, 400)
            return

        access = load_access()
        device_meta = access.setdefault("device_meta", {}).setdefault(device_id, {})
        device_meta.update({
            "name": str(payload.get("name", "")).strip() or f"Inverter {device_id.replace(':', '')[-4:]}",
            "location": str(payload.get("location", "")).strip() or "Location not set",
        })

        if "oem_id" in payload:
            oem_id = str(payload.get("oem_id", "")).strip()
            if oem_id:
                valid_oems = oem_lookup(access)
                if oem_id not in valid_oems:
                    self.send_json({"ok": False, "error": "Unknown OEM"}, 400)
                    return
                access.setdefault("assignments", {})[device_id] = oem_id
            else:
                access.setdefault("assignments", {}).pop(device_id, None)

        save_access(access)
        self.send_json({"ok": True, "device": device_meta})

    def log_message(self, fmt, *args):
        print("%s - %s" % (self.address_string(), fmt % args))


def local_ipv4_addresses():
    addresses = []
    try:
        hostname = socket.gethostname()
        for item in socket.getaddrinfo(hostname, None, socket.AF_INET):
            address = item[4][0]
            if address and not address.startswith("127.") and address not in addresses:
                addresses.append(address)
    except OSError:
        pass
    return addresses


if __name__ == "__main__":
    port = int(os.environ.get("RMS_DASHBOARD_PORT", "8088"))
    server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
    print(f"RMS dashboard:       http://127.0.0.1:{port}")
    for address in local_ipv4_addresses():
        print(f"LAN dashboard:       http://{address}:{port}")
        print(f"LAN ingest endpoint: http://{address}:{port}/api/ingest")
    print(f"Latest data proxy:  http://127.0.0.1:{port}/api/latest")
    print(f"Device list proxy:  http://127.0.0.1:{port}/api/devices")
    print(f"Fleet summary:      http://127.0.0.1:{port}/api/fleet")
    server.serve_forever()
