from __future__ import annotations

import json
import logging
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple

from backend.jsonsafe import json_safe_packet, now_epoch
from backend.sms_relay import SmsRelay

logger = logging.getLogger(__name__)


def _first_packet_value(packet: Any, *keys: str) -> Any:
    if not isinstance(packet, dict):
        return None
    for key in keys:
        if key in packet:
            value = packet.get(key)
            if value is not None:
                return value
    return None


def _payload_utf8_info(payload: Any) -> tuple[Optional[int], Optional[bool]]:
    if isinstance(payload, (bytes, bytearray)):
        try:
            payload.decode("utf-8")
            return len(payload), True
        except Exception:
            return len(payload), False
    if isinstance(payload, str):
        return len(payload), True
    return None, None


@dataclass
class MeshConfig:
    mesh_host: str
    mesh_port: int = 4403


class MeshService:
    """
    Maintains a Meshtastic TCP connection in a background thread.
    Stores:
      - nodeDB snapshot (raw dict from meshtastic)
      - JSON-safe message ring buffer (thin model)
    """

    def __init__(
        self,
        mesh_host: str,
        mesh_port: int = 4403,
        *,
        nodes_refresh_sec: int = 5,
        max_messages: int = 200,
        stats_db: Optional[Any] = None,
        mesh_http_port: int = 80,
        status_ttl_sec: int = 5,
        status_timeout_sec: float = 2.0,
        sms_enabled: bool = False,
        sms_api_url: Optional[str] = None,
        sms_api_key: Optional[str] = None,
        sms_phone: Optional[str] = None,
        sms_allow_from_ids: Optional[str] = None,
        sms_allow_types: Optional[str] = None,
        sms_timeout_sec: float = 4.0,
    ) -> None:
        mesh_host = str(mesh_host or "").strip()
        mesh_port_int = int(mesh_port)
        if mesh_port_int <= 0 or mesh_port_int > 65535:
            raise ValueError("meshPort must be 1..65535")

        self._cfg = MeshConfig(
            mesh_host=mesh_host,
            mesh_port=mesh_port_int,
        )
        self._nodes_refresh_sec = max(1, int(nodes_refresh_sec))
        self._max_messages = max(1, int(max_messages))
        self._stats_db = stats_db
        self._mesh_http_port = int(mesh_http_port) if int(mesh_http_port) > 0 else 80
        self._status_ttl_sec = max(1, int(status_ttl_sec))
        self._status_timeout_sec = max(0.5, float(status_timeout_sec))
        self._sms = SmsRelay(
            enabled=sms_enabled,
            api_url=sms_api_url,
            api_key=sms_api_key,
            phone=sms_phone,
            allow_from_ids=sms_allow_from_ids,
            allow_types=sms_allow_types,
            timeout_sec=sms_timeout_sec,
        )

        self._iface: Any = None
        self._iface_lock = threading.Lock()

        self._nodes_cache: Dict[str, Dict[str, Any]] = {}
        self._nodes_lock = threading.Lock()

        self._messages_cache: List[Dict[str, Any]] = []
        self._messages_lock = threading.Lock()

        self._channels_cache: List[Dict[str, Any]] = []
        self._channels_lock = threading.Lock()

        self._diag_cache: List[Dict[str, Any]] = []
        self._diag_lock = threading.Lock()
        self._max_diag = 200
        self._status_lock = threading.Lock()
        self._status_report: Optional[Dict[str, Any]] = None
        self._status_report_status: Optional[str] = None
        self._status_error: Optional[str] = None
        self._status_fetched_at: Optional[int] = None
        self._status_last_fetch: float = 0.0

        self._connected = False
        self._connected_lock = threading.Lock()
        self._last_error: Optional[str] = None
        self._last_error_lock = threading.Lock()

        self._stop = threading.Event()
        self._thread = threading.Thread(target=self._worker, daemon=True)

        self._pubsub_subscribed = False
        self._pubsub_lock = threading.Lock()

    # ---- lifecycle
    def start(self) -> None:
        if not self._thread.is_alive():
            self._thread.start()

    def stop(self, timeout: float = 2.0) -> None:
        self._stop.set()
        self._thread.join(timeout=timeout)
        self._disconnect()

    # ---- config
    def get_config(self) -> MeshConfig:
        cfg = self._cfg
        return MeshConfig(
            mesh_host=cfg.mesh_host,
            mesh_port=cfg.mesh_port,
        )

    def reconfigure(
        self,
        *,
        mesh_host: Optional[str] = None,
        mesh_port: Optional[int] = None,
    ) -> None:
        cfg = self._cfg

        next_mesh_host = cfg.mesh_host
        if mesh_host is not None:
            mesh_host_clean = str(mesh_host).strip()
            if not mesh_host_clean:
                raise ValueError("meshHost must be non-empty")
            next_mesh_host = mesh_host_clean

        next_mesh_port = cfg.mesh_port
        if mesh_port is not None:
            port_int = int(mesh_port)
            if port_int <= 0 or port_int > 65535:
                raise ValueError("meshPort must be 1..65535")
            next_mesh_port = port_int

        changed = (
            (next_mesh_host != cfg.mesh_host)
            or (next_mesh_port != cfg.mesh_port)
        )

        self._cfg = MeshConfig(
            mesh_host=next_mesh_host,
            mesh_port=next_mesh_port,
        )

        if changed:
            logger.info(
                "Reconfiguring mesh tcp=%s:%s",
                next_mesh_host,
                next_mesh_port,
            )
            self._record_mesh_event("disconnect", "reconfigure")
            self._disconnect()

    # ---- health/data accessors
    def is_connected(self) -> bool:
        with self._connected_lock:
            return self._connected

    def last_error(self) -> Optional[str]:
        with self._last_error_lock:
            return self._last_error

    def get_nodes_snapshot(self) -> Dict[str, Dict[str, Any]]:
        with self._nodes_lock:
            return dict(self._nodes_cache)

    def get_messages(self) -> List[Dict[str, Any]]:
        with self._messages_lock:
            return list(self._messages_cache)

    def get_channels_snapshot(self) -> List[Dict[str, Any]]:
        with self._channels_lock:
            return list(self._channels_cache)

    def get_diag_snapshot(self, *, limit: int = 50) -> List[Dict[str, Any]]:
        limit = max(1, int(limit))
        with self._diag_lock:
            return list(self._diag_cache[-limit:])

    def get_sms_config(self) -> Dict[str, Any]:
        return self._sms.get_config()

    def get_radio_snapshot(self) -> Optional[Dict[str, Any]]:
        iface = self._get_iface()
        if iface is None:
            return None
        try:
            getter = getattr(iface, "getMyNodeInfo", None)
            if callable(getter):
                return getter()
        except Exception:
            return None
        return None

    def get_device_config(self, *, include_secrets: bool = False) -> Optional[Dict[str, Any]]:
        iface = self._get_iface()
        if iface is None:
            return None

        try:
            from meshtastic.util import message_to_json  # type: ignore
        except Exception:
            return None

        local = getattr(iface, "localNode", None)
        if local is None:
            return None

        def pb_to_dict(pb: Any) -> Optional[Dict[str, Any]]:
            if pb is None:
                return None
            try:
                return json.loads(message_to_json(pb))
            except Exception:
                return None

        local_config = pb_to_dict(getattr(local, "localConfig", None))
        module_config = pb_to_dict(getattr(local, "moduleConfig", None))

        channels: List[Dict[str, Any]] = []
        chan_list = getattr(local, "channels", None)
        if isinstance(chan_list, (list, tuple)):
            for ch in chan_list:
                d = pb_to_dict(ch)
                if isinstance(d, dict):
                    channels.append(d)

        if not include_secrets:
            channels = [_redact_secrets(c) for c in channels]

        metadata = pb_to_dict(getattr(iface, "metadata", None))
        my_info = pb_to_dict(getattr(iface, "myInfo", None))

        return {
            "localConfig": local_config,
            "moduleConfig": module_config,
            "channels": channels,
            "metadata": metadata,
            "myInfo": my_info,
        }

    def update_sms_config(
        self,
        *,
        enabled: Optional[bool] = None,
        api_url: Optional[str] = None,
        api_key: Optional[str] = None,
        phone: Optional[str] = None,
        allow_from_ids: Optional[str] = None,
        allow_types: Optional[str] = None,
    ) -> None:
        self._sms.update_config(
            enabled=enabled,
            api_url=api_url,
            api_key=api_key,
            phone=phone,
            allow_from_ids=allow_from_ids,
            allow_types=allow_types,
        )

    def get_status_snapshot(self, *, force: bool = False) -> Dict[str, Any]:
        now = time.time()
        with self._status_lock:
            fresh = self._status_fetched_at is not None and (now - self._status_last_fetch) < self._status_ttl_sec
            if fresh and not force:
                return self._status_snapshot()

        report, status_text, err = self._fetch_status_report()
        fetched_at = now_epoch()
        ok = report is not None
        if ok and self._stats_db is not None:
            try:
                self._stats_db.record_status_report(report)
            except Exception:
                pass

        with self._status_lock:
            self._status_report = report
            self._status_report_status = status_text
            self._status_error = err
            self._status_fetched_at = fetched_at if ok else None
            self._status_last_fetch = now
            return self._status_snapshot()

    def _status_snapshot(self) -> Dict[str, Any]:
        return {
            "ok": self._status_report is not None,
            "report": self._status_report,
            "status": self._status_report_status,
            "error": self._status_error,
            "fetchedAt": self._status_fetched_at,
            "url": self._status_url(),
        }

    def _status_url(self) -> Optional[str]:
        host_raw = self._cfg.mesh_host
        if not host_raw:
            return None
        host, host_port = _split_host_port(host_raw)
        http_port = self._mesh_http_port
        if host_port and host_port != self._cfg.mesh_port:
            http_port = host_port
        if http_port == 80:
            return f"http://{host}/json/report"
        return f"http://{host}:{http_port}/json/report"

    def _fetch_status_report(self) -> Tuple[Optional[Dict[str, Any]], Optional[str], Optional[str]]:
        url = self._status_url()
        if not url:
            return None, None, "mesh host not configured"
        try:
            with urllib.request.urlopen(url, timeout=self._status_timeout_sec) as resp:
                payload = resp.read()
        except urllib.error.HTTPError as e:
            return None, None, f"HTTP {e.code}"
        except Exception as e:
            return None, None, f"{type(e).__name__}: {e}"
        try:
            decoded = json.loads(payload.decode("utf-8", errors="ignore"))
        except Exception as e:
            return None, None, f"invalid JSON: {type(e).__name__}: {e}"

        if not isinstance(decoded, dict):
            return None, None, "invalid report (not an object)"
        status_text = decoded.get("status")
        report = decoded.get("data") if isinstance(decoded.get("data"), dict) else None
        if report is None:
            report = decoded
        if not isinstance(report, dict):
            return None, status_text if isinstance(status_text, str) else None, "invalid report (no data)"
        return report, status_text if isinstance(status_text, str) else None, None

    # ---- actions
    def send_text(self, text: str, to: Optional[str] = None, channel: Optional[int] = None) -> None:
        text = str(text or "").strip()
        if not text:
            raise ValueError("text is required")

        iface = self._get_iface()
        if iface is None:
            raise RuntimeError("not connected to mesh")

        try:
            if channel is None:
                if to:
                    iface.sendText(text, destinationId=to)
                else:
                    iface.sendText(text)
                return

            ch_idx = int(channel)
            if to:
                iface.sendText(text, destinationId=to, channelIndex=ch_idx)
            else:
                iface.sendText(text, channelIndex=ch_idx)
            return
        except TypeError:
            # Some versions use a different param name
            if channel is None:
                if to:
                    iface.sendText(text, destination=to)
                else:
                    iface.sendText(text)
                return
            ch_idx = int(channel)
            if to:
                iface.sendText(text, destination=to, channel=ch_idx)
            else:
                iface.sendText(text, channel=ch_idx)
            return
        except Exception:
            # Let other errors bubble up
            raise

    # ---- internal
    def _set_connected(self, connected: bool) -> None:
        with self._connected_lock:
            self._connected = connected

    def _set_error(self, message: Optional[str]) -> None:
        with self._last_error_lock:
            self._last_error = message

    def _get_iface(self) -> Any:
        with self._iface_lock:
            return self._iface

    def _target_str(self) -> str:
        cfg = self._cfg
        return f"tcp {cfg.mesh_host}:{cfg.mesh_port}"

    def _connect(self) -> Any:
        with self._iface_lock:
            if self._iface is not None:
                return self._iface

            # Lazy imports so tests can run without meshtastic installed.
            try:
                from meshtastic.tcp_interface import TCPInterface  # type: ignore
                from pubsub import pub  # type: ignore
            except Exception as e:  # pragma: no cover
                raise RuntimeError(
                    "Meshtastic dependencies missing. Install backend/requirements.txt"
                ) from e

            with self._pubsub_lock:
                if not self._pubsub_subscribed:
                    pub.subscribe(self._on_receive, "meshtastic.receive")
                    self._pubsub_subscribed = True

            logger.info("Connecting to mesh (%s)", self._target_str())
            cfg = self._cfg
            try:
                iface = TCPInterface(hostname=cfg.mesh_host, portNumber=cfg.mesh_port)
            except TypeError:
                # Older meshtastic versions may not accept portNumber
                iface = TCPInterface(hostname=cfg.mesh_host)

            self._iface = iface
            return iface

    def _disconnect(self) -> None:
        with self._iface_lock:
            iface = self._iface
            self._iface = None

        if iface is not None:
            try:
                iface.close()
            except Exception:
                pass

        self._set_connected(False)

    def _record_mesh_event(self, event: str, detail: Optional[str] = None) -> None:
        if self._stats_db is None:
            return
        try:
            self._stats_db.record_mesh_event(event, detail)
        except Exception:
            pass

    def _on_receive(self, packet, interface) -> None:  # noqa: ANN001
        self._log_packet_diag(packet)
        try:
            msg = json_safe_packet(packet)
        except Exception as e:
            msg = {
                "rxTime": now_epoch(),
                "fromId": None,
                "toId": None,
                "snr": None,
                "rssi": None,
                "hopLimit": None,
                "channel": None,
                "portnum": None,
                "text": None,
                "payload_b64": None,
                "error": f"failed_to_decode_packet: {type(e).__name__}: {e}",
            }

        with self._messages_lock:
            self._messages_cache.append(msg)
            if len(self._messages_cache) > self._max_messages:
                self._messages_cache[:] = self._messages_cache[-self._max_messages :]

        if self._stats_db is not None:
            try:
                self._stats_db.record_message(msg)
            except Exception:
                pass
        try:
            self._sms.send_message(msg)
        except Exception:
            pass

    def _log_packet_diag(self, packet: Any) -> None:
        if not isinstance(packet, dict):
            logger.info(
                "RX msg id=%s from=%s to=%s chIndex=%s chHash=%s portnum=%s decoded=%s encrypted=%s hasPSK=%s textPreview=%r payloadLen=%s payloadUtf8=%s",
                None,
                None,
                None,
                None,
                None,
                None,
                False,
                None,
                None,
                "",
                None,
                None,
            )
            return

        decoded = packet.get("decoded")
        decoded_present = decoded is not None
        decoded_dict = decoded if isinstance(decoded, dict) else {}

        portnum = decoded_dict.get("portnum")
        text = decoded_dict.get("text")
        payload = decoded_dict.get("payload")

        payload_len, payload_utf8 = _payload_utf8_info(payload)
        text_preview = ""
        if isinstance(text, str) and text:
            text_preview = text[:20].replace("\n", " ").replace("\r", " ")

        msg_id = _first_packet_value(packet, "id")
        from_id = packet.get("fromId")
        to_id = packet.get("toId")
        ch_index = _first_packet_value(packet, "channelIndex", "channel", "channel_index")
        ch_hash = _first_packet_value(packet, "channelHash", "channel_hash")
        encrypted = _first_packet_value(packet, "encrypted")
        has_psk = _first_packet_value(packet, "hasPSK", "has_psk")

        logger.info(
            "RX msg id=%s from=%s to=%s chIndex=%s chHash=%s portnum=%s decoded=%s encrypted=%s hasPSK=%s textPreview=%r payloadLen=%s payloadUtf8=%s",
            msg_id,
            from_id,
            to_id,
            ch_index,
            ch_hash,
            portnum,
            decoded_present,
            encrypted,
            has_psk,
            text_preview,
            payload_len,
            payload_utf8,
        )

        diag = {
            "ts": now_epoch(),
            "id": msg_id,
            "fromId": from_id,
            "toId": to_id,
            "chIndex": ch_index,
            "chHash": ch_hash,
            "portnum": portnum,
            "decoded": decoded_present,
            "encrypted": encrypted,
            "hasPSK": has_psk,
            "textPreview": text_preview,
            "payloadLen": payload_len,
            "payloadUtf8": payload_utf8,
        }
        with self._diag_lock:
            self._diag_cache.append(diag)
            if len(self._diag_cache) > self._max_diag:
                self._diag_cache[:] = self._diag_cache[-self._max_diag :]

    # SMS relay lives in backend/sms_relay.py

    def _refresh_nodes(self, iface: Any) -> None:
        nodes = iface.nodes  # dict
        if not isinstance(nodes, dict):
            return
        with self._nodes_lock:
            self._nodes_cache.clear()
            self._nodes_cache.update(nodes)

        if self._stats_db is not None:
            try:
                self._stats_db.record_nodes_snapshot(nodes)
            except Exception:
                pass

    def _refresh_channels(self, iface: Any) -> None:
        channels_out: List[Dict[str, Any]] = []

        try:
            local = getattr(iface, "localNode", None)
            preset = _channel_preset_name(local)
            chan_list = getattr(local, "channels", None) if local is not None else None
            if isinstance(chan_list, (list, tuple)):
                for i, ch in enumerate(chan_list):
                    entry = _channel_entry(i, ch)
                    if entry is not None:
                        if preset and not entry.get("preset"):
                            entry["preset"] = preset
                        channels_out.append(entry)
        except Exception:
            channels_out = []

        with self._channels_lock:
            self._channels_cache = channels_out

    def _worker(self) -> None:
        backoff_sec = 1.0
        last_nodes_refresh = 0.0
        was_connected = False

        while not self._stop.is_set():
            try:
                cfg = self._cfg
                if not cfg.mesh_host:
                    self._set_connected(False)
                    self._set_error("not_configured: set meshHost")
                    time.sleep(0.5)
                    continue

                iface = self._connect()
                if not was_connected:
                    self._record_mesh_event("connect", self._target_str())
                    was_connected = True
                self._set_connected(True)
                self._set_error(None)
                backoff_sec = 1.0

                # Nodes refresh loop
                now = time.time()
                if now - last_nodes_refresh >= self._nodes_refresh_sec:
                    self._refresh_nodes(iface)
                    self._refresh_channels(iface)
                    last_nodes_refresh = now

                time.sleep(0.2)
            except Exception as e:
                self._set_connected(False)
                self._set_error(f"{type(e).__name__}: {e}")
                logger.warning("Mesh worker error: %s", self._last_error)
                if was_connected:
                    self._record_mesh_event("disconnect", self._last_error)
                    was_connected = False
                self._record_mesh_event("error", self._last_error)
                self._disconnect()

                time.sleep(backoff_sec)
                backoff_sec = min(30.0, backoff_sec * 1.7)


class FakeMeshService:
    """
    Small in-memory fake for tests and local UI dev without a Meshtastic node.
    """

    def __init__(self) -> None:
        self._cfg = MeshConfig(
            mesh_host="localhost",
            mesh_port=4403,
        )
        self._connected = False
        self._nodes: Dict[str, Dict[str, Any]] = {}
        self._messages: List[Dict[str, Any]] = []
        self._channels: List[Dict[str, Any]] = []
        self._radio: Optional[Dict[str, Any]] = None
        self._device_config: Optional[Dict[str, Any]] = None
        self._status_report: Optional[Dict[str, Any]] = None
        self._status_report_status: Optional[str] = None
        self._status_fetched_at: Optional[int] = None
        self._diag: List[Dict[str, Any]] = []
        self.sent: List[Tuple[str, Optional[str], Optional[int]]] = []
        self._sms_enabled = False
        self._sms_api_url = ""
        self._sms_api_key = ""
        self._sms_phone = ""
        self._sms_allow_from_ids = ""
        self._sms_allow_types = ""

    def start(self) -> None:  # noqa: D401
        self._connected = True

    def get_config(self) -> MeshConfig:
        return self._cfg

    def reconfigure(
        self,
        *,
        mesh_host: Optional[str] = None,
        mesh_port: Optional[int] = None,
    ) -> None:
        cfg = self._cfg
        self._cfg = MeshConfig(
            mesh_host=cfg.mesh_host if mesh_host is None else str(mesh_host),
            mesh_port=cfg.mesh_port if mesh_port is None else int(mesh_port),
        )

    def is_connected(self) -> bool:
        return self._connected

    def last_error(self) -> Optional[str]:
        return None

    def get_nodes_snapshot(self) -> Dict[str, Dict[str, Any]]:
        return dict(self._nodes)

    def get_messages(self) -> List[Dict[str, Any]]:
        return list(self._messages)

    def get_channels_snapshot(self) -> List[Dict[str, Any]]:
        return list(self._channels)

    def get_diag_snapshot(self, *, limit: int = 50) -> List[Dict[str, Any]]:
        limit = max(1, int(limit))
        return list(self._diag[-limit:])

    def get_sms_config(self) -> Dict[str, Any]:
        return {
            "enabled": self._sms_enabled,
            "apiUrl": self._sms_api_url or None,
            "phone": self._sms_phone or None,
            "apiKeySet": bool(self._sms_api_key),
            "allowFromIds": self._sms_allow_from_ids or "ALL",
            "allowTypes": self._sms_allow_types or "ALL",
        }

    def update_sms_config(
        self,
        *,
        enabled: Optional[bool] = None,
        api_url: Optional[str] = None,
        api_key: Optional[str] = None,
        phone: Optional[str] = None,
        allow_from_ids: Optional[str] = None,
        allow_types: Optional[str] = None,
    ) -> None:
        if enabled is not None:
            self._sms_enabled = bool(enabled)
        if api_url is not None:
            self._sms_api_url = str(api_url or "").strip()
        if api_key is not None:
            self._sms_api_key = str(api_key or "").strip()
        if phone is not None:
            self._sms_phone = str(phone or "").strip()
        if allow_from_ids is not None:
            self._sms_allow_from_ids = str(allow_from_ids or "").strip()
        if allow_types is not None:
            self._sms_allow_types = str(allow_types or "").strip()

    def get_radio_snapshot(self) -> Optional[Dict[str, Any]]:
        return dict(self._radio) if isinstance(self._radio, dict) else None

    def get_device_config(self, *, include_secrets: bool = False) -> Optional[Dict[str, Any]]:
        if not isinstance(self._device_config, dict):
            return None
        if include_secrets:
            return dict(self._device_config)
        return _redact_secrets(self._device_config)

    def get_status_snapshot(self, *, force: bool = False) -> Dict[str, Any]:
        return {
            "ok": self._status_report is not None,
            "report": self._status_report,
            "status": self._status_report_status,
            "error": None if self._status_report is not None else "status not available",
            "fetchedAt": self._status_fetched_at,
            "url": None,
        }

    def send_text(self, text: str, to: Optional[str] = None, channel: Optional[int] = None) -> None:
        if not text:
            raise ValueError("text is required")
        ch = int(channel) if channel is not None else None
        self.sent.append((text, to, ch))

    # helpers for tests
    def seed_nodes(self, nodes: Dict[str, Dict[str, Any]]) -> None:
        self._nodes = dict(nodes)

    def seed_messages(self, messages: List[Dict[str, Any]]) -> None:
        self._messages = list(messages)

    def seed_channels(self, channels: List[Dict[str, Any]]) -> None:
        self._channels = list(channels)

    def seed_radio(self, node: Dict[str, Any]) -> None:
        self._radio = dict(node)

    def seed_device_config(self, cfg: Dict[str, Any]) -> None:
        self._device_config = dict(cfg)

    def seed_status_report(self, report: Dict[str, Any], status: str = "ok") -> None:
        self._status_report = dict(report)
        self._status_report_status = status
        self._status_fetched_at = now_epoch()

    def seed_diag(self, items: List[Dict[str, Any]]) -> None:
        self._diag = list(items)


def _channel_entry(index: int, channel: Any) -> Optional[Dict[str, Any]]:
    """
    Create a small JSON-safe channel entry.

    Intentionally does NOT expose PSK/crypto material.
    """
    if channel is None:
        return None

    name = _get_path(channel, "settings.name", "name")
    role = _get_path(channel, "role")
    enabled = _get_path(channel, "enabled", "isEnabled")

    role_str: Optional[str]
    if role is None:
        role_str = None
    elif isinstance(role, str):
        role_str = role
    else:
        role_str = getattr(role, "name", None) or str(role)

    enabled_bool: Optional[bool]
    if isinstance(enabled, bool):
        enabled_bool = enabled
    elif isinstance(role_str, str) and role_str.strip().upper() == "DISABLED":
        enabled_bool = False
    else:
        enabled_bool = None

    name_str = str(name).strip() if isinstance(name, str) else None
    if name_str == "":
        name_str = None

    return {
        "index": int(index),
        "name": name_str,
        "role": role_str,
        "enabled": enabled_bool,
    }


def _channel_preset_name(local: Any) -> Optional[str]:
    if local is None:
        return None
    try:
        lc = getattr(local, "localConfig", None)
        if lc is None:
            return None
        if isinstance(lc, dict):
            val = lc.get("lora") or lc.get("loraConfig") or lc.get("lora_config")
            if isinstance(val, dict):
                preset = val.get("modemPreset") or val.get("modem_preset")
                if isinstance(preset, str):
                    return preset
                name = getattr(preset, "name", None)
                if isinstance(name, str):
                    return name
                return None
            preset = lc.get("modemPreset") or lc.get("modem_preset")
            if isinstance(preset, str):
                return preset
            name = getattr(preset, "name", None)
            if isinstance(name, str):
                return name
            return None

        lora = getattr(lc, "lora", None) or getattr(lc, "loraConfig", None) or getattr(lc, "lora_config", None)
        if lora is not None:
            preset = getattr(lora, "modemPreset", None) or getattr(lora, "modem_preset", None)
            name = getattr(preset, "name", None)
            if isinstance(preset, str):
                return preset
            if isinstance(name, str):
                return name
        preset = getattr(lc, "modemPreset", None) or getattr(lc, "modem_preset", None)
        if isinstance(preset, str):
            return preset
        name = getattr(preset, "name", None)
        if isinstance(name, str):
            return name
    except Exception:
        return None
    return None


def _redact_secrets(obj: Any) -> Any:
    if isinstance(obj, dict):
        out: Dict[str, Any] = {}
        for k, v in obj.items():
            if str(k).lower() == "psk":
                out[k] = "***redacted***"
            else:
                out[k] = _redact_secrets(v)
        return out
    if isinstance(obj, list):
        return [_redact_secrets(v) for v in obj]
    return obj


def _get_path(obj: Any, *paths: str) -> Any:
    for path in paths:
        cur = obj
        ok = True
        for part in path.split("."):
            if cur is None:
                ok = False
                break
            if isinstance(cur, dict):
                if part not in cur:
                    ok = False
                    break
                cur = cur.get(part)
            else:
                if not hasattr(cur, part):
                    ok = False
                    break
                cur = getattr(cur, part)
        if ok:
            return cur
    return None


def _split_host_port(host: str) -> tuple[str, Optional[int]]:
    host = (host or "").strip()
    if not host:
        return host, None
    if host.startswith("[") and "]" in host:
        return host, None
    if host.count(":") >= 2:
        return host, None
    if ":" not in host:
        return host, None
    base, port_str = host.rsplit(":", 1)
    if not port_str.isdigit():
        return host, None
    try:
        return base, int(port_str)
    except Exception:
        return host, None
