"""Home Assistant bot command handler.

Phase 2 of the HA integration. Each command opens a fresh WebSocket
connection to HA — same approach used by ``HomeAssistantServiceProvider.
list_collections`` — so the handler does not need to coordinate with the
long-lived subscription supervisor.

Commands:

* ``/status`` — connection health, subscribed area / entity counts.
* ``/entities [glob]`` — list matching entities with their current state.
* ``/state <entity_id>`` — full state + attributes for one entity.
* ``/areas`` — area registry summary with entity counts per area.
"""

from __future__ import annotations

import logging
from fnmatch import fnmatchcase
from typing import Any

import aiohttp

from notify_bridge_core.providers.home_assistant import (
    HomeAssistantApiError,
    HomeAssistantAuthError,
    HomeAssistantWSClient,
    redact_ha_message,
)

from ..database.models import (
    CommandConfig,
    CommandTracker,
    CommandTrackerListener,
    ServiceProvider,
    TelegramBot,
)
from ..services.http_session import get_http_session
from .base import CommandResponse, ProviderCommandHandler
from .handler import _render_cmd_template

_LOGGER = logging.getLogger(__name__)


_HA_COMMANDS = {"status", "entities", "state", "areas"}


# HA exposes credentials and tokens through state attributes for some
# integrations — most notably ``camera.*`` entities surface a working
# ``access_token`` for the camera proxy URL, and ``entity_picture`` can
# carry signed URLs. Filtering keys by substring blocklist before rendering
# protects the chat user from seeing those values in /state output.
#
# Match is case-insensitive substring; tokens are intentionally generic so
# custom integrations that follow the obvious naming conventions are also
# covered. Anything not matched still renders.
_SENSITIVE_ATTR_TOKENS: tuple[str, ...] = (
    "access_token",
    "token",
    "secret",
    "password",
    "passwd",
    "api_key",
    "apikey",
    "private_key",
    "session_id",
    "authorization",
    "bearer",
    "cookie",
    # ``entity_picture`` is a URL that often embeds a signed token in its
    # query string (HA generates these for camera and media_player entities).
    # The key itself doesn't match the credential token blocklist, so it
    # gets its own explicit entry.
    "entity_picture",
)

# Attributes already rendered as top-level fields by the state template; no
# point repeating them in the "Attributes" iteration.
_TOP_LEVEL_ATTRS: frozenset[str] = frozenset({
    "friendly_name", "unit_of_measurement", "device_class",
})

# Hard cap on the number of attributes shown in /state to prevent message
# truncation when an entity has dozens (e.g. weather hourly forecasts,
# light supported features). After the cap, an "and N more" line is added
# by the template logic.
_MAX_ATTRIBUTES_RENDERED = 30


def _is_sensitive_attr(key: str) -> bool:
    lowered = str(key).lower()
    return any(tok in lowered for tok in _SENSITIVE_ATTR_TOKENS)


def _filter_attributes(attrs: dict[str, Any]) -> tuple[dict[str, Any], int]:
    """Drop sensitive keys, cap count, return ``(visible_attrs, hidden_count)``.

    Hidden count covers both the security filter (blocklisted keys) and the
    size cap (entries beyond ``_MAX_ATTRIBUTES_RENDERED``). The template can
    surface "and N more hidden" so users know the view is incomplete.
    """
    if not isinstance(attrs, dict):
        return {}, 0
    safe: dict[str, Any] = {}
    redacted = 0
    for key, value in attrs.items():
        if not isinstance(key, str):
            continue
        if key in _TOP_LEVEL_ATTRS:
            continue
        if _is_sensitive_attr(key):
            redacted += 1
            continue
        safe[key] = value
    overflow = max(0, len(safe) - _MAX_ATTRIBUTES_RENDERED)
    if overflow > 0:
        # Stable order — sort by key so the truncation point is deterministic.
        capped = dict(sorted(safe.items())[:_MAX_ATTRIBUTES_RENDERED])
        return capped, redacted + overflow
    return safe, redacted


def _make_ws_client(provider: ServiceProvider, session: aiohttp.ClientSession) -> HomeAssistantWSClient:
    """Build a one-shot WS client from the provider row."""
    config = provider.config or {}
    return HomeAssistantWSClient(
        session=session,
        base_url=config.get("url", ""),
        access_token=config.get("access_token", ""),
        verify_tls=bool(config.get("verify_tls", True)),
    )


def _domain_of(entity_id: str) -> str:
    return entity_id.split(".", 1)[0] if "." in entity_id else ""


def _normalize_state(state_row: dict[str, Any]) -> dict[str, Any]:
    """Flatten an HA state dict into the shape templates consume.

    ``attributes`` is filtered through ``_filter_attributes`` to drop
    credential-like keys (e.g. ``camera.access_token``) and cap the rendered
    count. ``hidden_attr_count`` is exposed so the template can surface
    "and N more hidden" if the user wants to see everything they need to
    use a different tool (or the HA UI itself).
    """
    entity_id = state_row.get("entity_id") or ""
    raw_attrs = state_row.get("attributes") or {}
    visible_attrs, hidden_count = _filter_attributes(raw_attrs)
    return {
        "entity_id": entity_id,
        "friendly_name": raw_attrs.get("friendly_name") or entity_id,
        "domain": _domain_of(entity_id),
        "state": state_row.get("state"),
        "attributes": visible_attrs,
        "hidden_attr_count": hidden_count,
        "device_class": raw_attrs.get("device_class"),
        "unit_of_measurement": raw_attrs.get("unit_of_measurement"),
        "last_changed": state_row.get("last_changed"),
        "last_updated": state_row.get("last_updated"),
    }


# ---------------------------------------------------------------------------
# Command implementations
# ---------------------------------------------------------------------------


async def _cmd_status(provider: ServiceProvider) -> dict[str, Any]:
    """``/status`` — connection health + counts.

    Health is derived from a live connection rather than the supervisor's
    in-memory state so the user sees what's happening *right now* if they
    just edited the token / URL. Connection + entity-count + area-count run
    on a single WS session so a healthy /status costs one TCP + TLS + WS +
    auth handshake instead of three.
    """
    session = await get_http_session()
    client = _make_ws_client(provider, session)
    ok = True
    message = "OK"
    entity_count = 0
    area_count = 0

    try:
        async with client.session() as sess:
            # Reaching here proves connect + auth succeeded.
            try:
                entity_count = len(await sess.get_states())
            except HomeAssistantApiError as err:
                _LOGGER.debug("HA /status get_states failed: %s", err)
            try:
                area_count = len(await sess.get_area_registry())
            except HomeAssistantApiError as err:
                _LOGGER.debug("HA /status get_area_registry failed: %s", err)
    except HomeAssistantAuthError as err:
        ok = False
        message = f"Auth failed: {redact_ha_message(str(err))}"
    except (aiohttp.ClientError, HomeAssistantApiError) as err:
        ok = False
        message = redact_ha_message(str(err)) or "Connection failed"

    return {
        "ok": ok,
        "message": message,
        "provider_name": provider.name or "",
        "url": (provider.config or {}).get("url", ""),
        "entity_count": entity_count,
        "area_count": area_count,
    }


async def _cmd_entities(provider: ServiceProvider, args: str, count: int) -> dict[str, Any]:
    """``/entities [glob]`` — entities filtered by glob, capped at ``count``.

    Empty args returns the first ``count`` entities in entity_id order. A
    glob pattern is matched against the entity_id (case-insensitive). The
    normalization step (which walks the attribute dict to redact secrets)
    runs **only on the survivors** — sorting and slicing happen on raw
    state rows first, so an HA install with 1000+ entities doesn't
    materialize 1000 normalized dicts just to discard most of them.
    """
    session = await get_http_session()
    client = _make_ws_client(provider, session)
    try:
        async with client.session() as sess:
            states = await sess.get_states()
    except (HomeAssistantApiError, aiohttp.ClientError, HomeAssistantAuthError) as err:
        redacted = redact_ha_message(str(err))
        _LOGGER.warning("HA /entities failed: %s", redacted)
        return {"entities": [], "glob": args.strip(), "total": 0, "error": redacted}

    glob = args.strip()
    if glob:
        lower_glob = glob.lower()
        raw_matches = [
            s for s in states
            if isinstance(s.get("entity_id"), str)
            and fnmatchcase(s["entity_id"].lower(), lower_glob)
        ]
    else:
        raw_matches = [s for s in states if isinstance(s.get("entity_id"), str)]

    total = len(raw_matches)
    raw_matches.sort(key=lambda s: s.get("entity_id", ""))
    return {
        "entities": [_normalize_state(s) for s in raw_matches[:count]],
        "glob": glob,
        "total": total,
        "shown": min(count, total),
    }


async def _cmd_state(provider: ServiceProvider, args: str) -> dict[str, Any]:
    """``/state <entity_id>`` — single-entity drill-down.

    Returns ``found=False`` when the entity_id is missing or not present.
    Templates render the no-results fallback in that case. Uses the session
    context manager for consistency with the other commands even though
    there's only one underlying WS call today — leaves the door open for
    Phase 3 (service calls) to chain a follow-up on the same socket.
    """
    target = args.strip()
    if not target:
        return {"found": False, "entity_id": "", "reason": "missing_arg"}

    session = await get_http_session()
    client = _make_ws_client(provider, session)
    try:
        async with client.session() as sess:
            states = await sess.get_states()
    except (HomeAssistantApiError, aiohttp.ClientError, HomeAssistantAuthError) as err:
        redacted = redact_ha_message(str(err))
        _LOGGER.warning("HA /state failed: %s", redacted)
        return {"found": False, "entity_id": target, "reason": "api_error", "error": redacted}

    for s in states:
        if s.get("entity_id") == target:
            normalized = _normalize_state(s)
            return {"found": True, **normalized}

    return {"found": False, "entity_id": target, "reason": "not_found"}


async def _cmd_areas(provider: ServiceProvider) -> dict[str, Any]:
    """``/areas`` — area registry with per-area entity counts.

    Areas without entities are still listed so users can see which areas
    exist in HA but haven't been assigned anything. The entity counts come
    from the entity registry, not the state list — the registry includes
    disabled entities, which matches what users see in the HA UI. Both
    registry calls share a single WS session so /areas costs one handshake.
    """
    session = await get_http_session()
    client = _make_ws_client(provider, session)
    try:
        async with client.session() as sess:
            areas = await sess.get_area_registry()
            # Entity registry failure is non-fatal — areas can still be
            # listed without per-area counts.
            try:
                entities = await sess.get_entity_registry()
            except HomeAssistantApiError:
                entities = []
    except (HomeAssistantApiError, aiohttp.ClientError, HomeAssistantAuthError) as err:
        redacted = redact_ha_message(str(err))
        _LOGGER.warning("HA /areas failed: %s", redacted)
        return {"areas": [], "total": 0, "error": redacted}

    counts: dict[str, int] = {}
    for ent in entities:
        area_id = ent.get("area_id")
        if isinstance(area_id, str):
            counts[area_id] = counts.get(area_id, 0) + 1

    rows: list[dict[str, Any]] = []
    for a in areas:
        area_id = a.get("area_id")
        if not isinstance(area_id, str):
            continue
        rows.append({
            "area_id": area_id,
            "name": a.get("name") or area_id,
            "entity_count": counts.get(area_id, 0),
        })
    rows.sort(key=lambda r: r.get("name", "").lower())
    return {"areas": rows, "total": len(rows)}


# ---------------------------------------------------------------------------
# Handler class
# ---------------------------------------------------------------------------


class HomeAssistantCommandHandler(ProviderCommandHandler):
    """Routes ``/status``, ``/entities``, ``/state``, ``/areas`` to the WS client."""

    provider_type = "home_assistant"

    def get_provider_commands(self) -> set[str]:
        return _HA_COMMANDS

    def get_rate_categories(self) -> dict[str, str]:
        # All HA commands hit the WS API and share an "api" rate-limit bucket.
        return {cmd: "api" for cmd in _HA_COMMANDS}

    async def handle(
        self,
        cmd: str,
        args: str,
        count: int,
        locale: str,
        response_mode: str,  # noqa: ARG002 — HA has no media commands; always text
        provider: ServiceProvider,
        cmd_templates: dict[str, dict[str, str]],
        bot: TelegramBot,  # noqa: ARG002
        tracker: CommandTracker,  # noqa: ARG002
        config: CommandConfig,  # noqa: ARG002
        *,
        listener: CommandTrackerListener | None = None,  # noqa: ARG002
        allowed_album_ids: set[str] | None = None,  # noqa: ARG002 — HA has no album scope
        page: int = 1,  # noqa: ARG002 — no pagination in v1
    ) -> CommandResponse | None:
        if cmd == "status":
            ctx = await _cmd_status(provider)
        elif cmd == "entities":
            ctx = await _cmd_entities(provider, args, count)
        elif cmd == "state":
            ctx = await _cmd_state(provider, args)
        elif cmd == "areas":
            ctx = await _cmd_areas(provider)
        else:
            return None

        return CommandResponse(text=_render_cmd_template(cmd_templates, cmd, locale, ctx))
