"""Data sources behind the tools: live HA instance + repo files.

Everything the tools need is behind one small interface so the offline test
suite can substitute a fake (see tests/test_tools_offline.py) without any
network or HA instance:

    states()          -> list of /api/states dicts
    ha_config()       -> /api/config dict
    area_index()      -> AreaIndex (entity_id -> area name, area -> entity count)
    automation_files()-> list of (repo-relative path, absolute path)

Read-only by construction:

* REST goes through scripts/ha/lib/ha_api.py, whose Client exposes `get`
  and `get_raw_text` and nothing else — there is no POST/PUT/DELETE method
  to call by accident.
* WebSocket goes through scripts/ha/lib/ha_ws.py, and every command this
  module sends must be in READ_ONLY_WS_COMMANDS — an allowlist, checked at
  call time, so a future edit cannot quietly introduce a mutating command.
"""
import sys
from pathlib import Path

import yaml

from . import config as cfg

#: Every WebSocket command this server is allowed to send. HA's mutating
#: registry commands (`config/*_registry/create|update|delete`) are absent
#: deliberately and a non-listed command raises before it hits the socket.
READ_ONLY_WS_COMMANDS = frozenset(
    {
        "config/area_registry/list",
        "config/entity_registry/list",
        "config/device_registry/list",
    }
)

#: Short by policy: the MCP server is interactive tooling — a hung tool call
#: blocks the agent, so a fast, clear failure beats a slow success.
DEFAULT_TIMEOUT = 5


class BackendError(RuntimeError):
    """Instance unreachable / token missing / instance not live — reportable."""


def _import_ha_lib(root=None):
    """Import scripts/ha/lib/{ha_api,ha_ws} — reuse, don't reimplement."""
    lib_dir = str(cfg.ha_lib_dir(root))
    if lib_dir not in sys.path:
        sys.path.insert(0, lib_dir)
    import ha_api  # noqa: E402  (path set up above)

    return ha_api


class AreaIndex:
    """entity_id -> area name, plus the area list, plus where it came from."""

    def __init__(self, areas, entity_area, source, note=None):
        #: [{"area_id":..., "name":..., "aliases": [...]}]
        self.areas = areas
        #: {entity_id: area name}
        self.entity_area = entity_area
        #: "websocket" | "storage-export" | "none"
        self.source = source
        self.note = note

    def area_of(self, entity_id):
        return self.entity_area.get(entity_id)

    @classmethod
    def empty(cls, note):
        return cls([], {}, "none", note)


def _index_from_registries(area_reg, entity_reg, device_reg, source, note=None):
    """Build an AreaIndex from raw registry lists (WS or storage-export shape).

    An entity's area is its own `area_id` when set, else its device's — the
    same precedence HA itself uses. `device_reg` may be None (the offline
    storage-export has no device registry), in which case device-inherited
    areas are simply unresolved rather than wrong.
    """
    areas = [
        {
            "area_id": a.get("area_id") or a.get("id"),
            "name": a.get("name"),
            "aliases": list(a.get("aliases") or []),
        }
        for a in (area_reg or [])
    ]
    names = {a["area_id"]: a["name"] for a in areas if a["area_id"]}
    device_area = {
        d.get("id"): d.get("area_id") for d in (device_reg or []) if d.get("id")
    }
    entity_area = {}
    for ent in entity_reg or []:
        entity_id = ent.get("entity_id")
        if not entity_id:
            continue
        area_id = ent.get("area_id") or device_area.get(ent.get("device_id"))
        name = names.get(area_id)
        if name:
            entity_area[entity_id] = name
    return AreaIndex(areas, entity_area, source, note)


def load_offline_area_index(instance, root=None):
    """Fallback area map from storage-export/<instance>/core.*_registry.yaml.

    Used when the WebSocket path is unavailable (dependency missing,
    instance unreachable). Incomplete by nature: the curated export has no
    `core.device_registry`, so only entities carrying an explicit `area_id`
    resolve — the note says so rather than pretending the rest are area-less.
    """
    export_dir = cfg.ha_service_dir(root) / "storage-export" / instance

    def _load(name, key):
        path = export_dir / name
        if not path.is_file():
            return None
        with open(path, "r", encoding="utf-8") as f:
            data = yaml.safe_load(f) or {}
        return ((data.get("data") or {}).get(key)) or []

    try:
        area_reg = _load("core.area_registry.yaml", "areas")
        entity_reg = _load("core.entity_registry.yaml", "entities")
    except (OSError, yaml.YAMLError) as exc:
        return AreaIndex.empty(f"storage-export unreadable: {exc}")
    if area_reg is None and entity_reg is None:
        return AreaIndex.empty(
            f"no storage-export for instance '{instance}' and no live websocket"
        )
    return _index_from_registries(
        area_reg,
        entity_reg,
        None,
        "storage-export",
        "offline fallback: storage-export has no device registry, so entities "
        "that inherit their area from a device are reported without one",
    )


class LiveBackend:
    """One HA instance, read through the REST + WebSocket clients in scripts/ha/lib.

    Everything is fetched lazily and cached for the lifetime of the object
    (one MCP tool call), so a tool that needs states and areas pays for one
    REST GET and one WS session, not several.
    """

    def __init__(self, instance, instance_cfg, root=None, timeout=DEFAULT_TIMEOUT):
        self.instance = instance
        self.cfg = instance_cfg
        self.root = root or cfg.repo_root()
        self.timeout = timeout
        self._states = None
        self._config = None
        self._area_index = None
        self._token = None

    # --- plumbing -----------------------------------------------------

    @property
    def status(self):
        return self.cfg.get("status") or "unknown"

    def _require_live(self):
        """Refuse network I/O for instances instances.yaml calls offline.

        `chelsty-ha` sits behind an intermittent LTE uplink and is marked
        `status: offline`; hitting it would burn the timeout on every call
        and tell the caller nothing instances.yaml did not already say.
        """
        if self.status == "offline":
            raise BackendError(
                f"instance '{self.instance}' is marked status: offline in "
                "instances.yaml — no live query attempted (intermittent LTE "
                "uplink; see DESIGN.md). Repo-backed tools (read_automation) "
                "still work."
            )
        if not self.cfg.get("base_url"):
            raise BackendError(f"instance '{self.instance}' has no base_url in instances.yaml")

    def _client(self):
        self._require_live()
        ha_api = _import_ha_lib(self.root)
        if self._token is None:
            try:
                self._token = ha_api.read_token(self.cfg.get("token_path"))
            except ha_api.HaApiError as exc:
                raise BackendError(str(exc)) from exc
        return ha_api.Client(self.cfg["base_url"], self._token, timeout=self.timeout)

    def _get(self, path):
        import requests

        client = self._client()
        try:
            return client.get(path)
        except requests.RequestException as exc:
            raise BackendError(
                f"instance '{self.instance}' unreachable at "
                f"{self.cfg.get('base_url')} ({type(exc).__name__}: {exc})"
            ) from exc

    # --- data ---------------------------------------------------------

    def states(self):
        if self._states is None:
            data = self._get("/api/states")
            if not isinstance(data, list):
                raise BackendError(f"/api/states returned {type(data).__name__}, expected a list")
            self._states = data
        return self._states

    def ha_config(self):
        if self._config is None:
            data = self._get("/api/config")
            if not isinstance(data, dict):
                raise BackendError(f"/api/config returned {type(data).__name__}, expected an object")
            self._config = data
        return self._config

    def _ws_registries(self):
        """One WS session, three allowlisted `*_list` reads."""
        self._require_live()
        ha_api = _import_ha_lib(self.root)
        token = self._token or ha_api.read_token(self.cfg.get("token_path"))
        self._token = token
        import ha_ws  # noqa: E402  (sys.path set up by _import_ha_lib)

        out = {}
        with ha_ws.HaWsClient(self.cfg["base_url"], token, timeout=self.timeout) as conn:
            for command in (
                "config/area_registry/list",
                "config/entity_registry/list",
                "config/device_registry/list",
            ):
                if command not in READ_ONLY_WS_COMMANDS:  # pragma: no cover - guard
                    raise BackendError(f"refusing non-allowlisted WS command '{command}'")
                out[command] = conn.command(command)
        return out

    def area_index(self):
        """Live area map via WebSocket, falling back to storage-export."""
        if self._area_index is not None:
            return self._area_index
        try:
            reg = self._ws_registries()
            self._area_index = _index_from_registries(
                reg["config/area_registry/list"],
                reg["config/entity_registry/list"],
                reg["config/device_registry/list"],
                "websocket",
            )
        except Exception as exc:  # noqa: BLE001 — any WS failure degrades, never crashes
            index = load_offline_area_index(self.instance, self.root)
            reason = f"{type(exc).__name__}: {exc}"
            index.note = f"live websocket unavailable ({reason}); {index.note or ''}".strip("; ")
            self._area_index = index
        return self._area_index

    def automation_files(self):
        """[(repo-relative path, absolute path)] for config/<instance>/automations/."""
        return automation_files(self.instance, self.root)


def automation_files(instance, root=None):
    root = root or cfg.repo_root()
    directory = cfg.ha_service_dir(root) / "config" / instance / "automations"
    if not directory.is_dir():
        return []
    return [
        (str(Path(p).relative_to(root)), p)
        for p in sorted(directory.glob("*.yaml"))
    ]
