"""Tool implementations. Pure Python, no MCP imports — see server.py for wiring.

Two conventions hold across every function here:

1. **Read-only.** Nothing in this module writes anything, anywhere: not to
   HA (the backend has no mutating call), not to the repo. The write path
   is repo + scripts/ha/deploy.sh (DESIGN.md, "Sync model").
2. **`unavailable` is never silent.** The 2026-07-23 audit found ~15
   automations dead because conditions sat on `unavailable` sensors that
   nobody noticed (docs/audyt-automatyzacji-2026-07-23.md, 1.2). Every
   entity view carries an explicit `unavailable` flag plus `last_changed`,
   and every list reports how many of its results are dead, so an agent
   reasoning about this instance cannot miss it.
"""
import difflib

import yaml

from . import match
from .backend import BackendError

#: Cap on list_entities results — the live instance has ~1650 entities and
#: dumping all of them into an agent's context is worse than useless.
DEFAULT_ENTITY_LIMIT = 200
FIND_LIMIT = 20

DEAD_STATES = ("unavailable", "unknown")


def _entity_view(state, area=None):
    attrs = state.get("attributes") or {}
    value = state.get("state")
    view = {
        "entity_id": state.get("entity_id"),
        "friendly_name": attrs.get("friendly_name"),
        "state": value,
        "area": area,
    }
    if value in DEAD_STATES:
        view["unavailable"] = True
        # How long it has been dead is the actionable part (audit 1.2).
        view["unavailable_since"] = state.get("last_changed")
    return view


def _domain(entity_id):
    return entity_id.split(".", 1)[0] if "." in entity_id else ""


def _resolve_area(index, wanted):
    """Map user input (name, area_id or registry alias) to the canonical area name.

    Aliases count: the `hall` area is called "Wejście" by everyone in the
    house, and that alias is in the registry — refusing it would be pedantry.
    Returns None when nothing matches.
    """
    needle = match.normalize(wanted)
    for area in index.areas:
        candidates = [area.get("name"), area.get("area_id")] + list(area.get("aliases") or [])
        if any(needle == match.normalize(c) for c in candidates if c):
            return area.get("name") or area.get("area_id")
    return None


def _count_dead(views):
    return sum(1 for v in views if v.get("unavailable"))


def _area_meta(index):
    meta = {"area_source": index.source}
    if index.note:
        meta["area_note"] = index.note
    return meta


def list_entities(backend, domain=None, area=None, limit=DEFAULT_ENTITY_LIMIT):
    """Entities with state and area, optionally filtered by domain and/or area."""
    states = backend.states()
    index = backend.area_index()

    wanted_area = None
    if area:
        wanted_area = _resolve_area(index, area)
        if not wanted_area:
            known = sorted(a.get("name") or a.get("area_id") for a in index.areas)
            raise BackendError(
                f"unknown area '{area}' (known: {', '.join(k for k in known if k) or 'none'})"
            )

    views = []
    for state in states:
        entity_id = state.get("entity_id") or ""
        if domain and _domain(entity_id) != domain.strip().lower():
            continue
        entity_area = index.area_of(entity_id)
        if wanted_area and entity_area != wanted_area:
            continue
        views.append(_entity_view(state, entity_area))

    views.sort(key=lambda v: v["entity_id"])
    result = {
        "instance": backend.instance,
        "filters": {"domain": domain, "area": wanted_area or area},
        "count": len(views),
        "unavailable_count": _count_dead(views),
        "entities": views[:limit],
    }
    if len(views) > limit:
        result["truncated"] = True
        result["truncated_note"] = (
            f"showing {limit} of {len(views)} — narrow with domain/area or raise limit"
        )
    result.update(_area_meta(index))
    return result


def get_state(backend, entity_id):
    """Full state object (all attributes) for one entity."""
    entity_id = (entity_id or "").strip()
    if not entity_id:
        raise BackendError("entity_id is required")
    states = backend.states()
    for state in states:
        if state.get("entity_id") == entity_id:
            index = backend.area_index()
            view = _entity_view(state, index.area_of(entity_id))
            view["attributes"] = state.get("attributes") or {}
            view["last_changed"] = state.get("last_changed")
            view["last_updated"] = state.get("last_updated")
            view["instance"] = backend.instance
            view.update(_area_meta(index))
            return view

    # Not found: a typo'd entity_id is the common case, so answer with the
    # nearest ids by edit distance (difflib, not the fuzzy matcher — this is
    # an id comparison, not a description).
    close = difflib.get_close_matches(
        entity_id, [s.get("entity_id") or "" for s in states], n=5, cutoff=0.7
    )
    raise BackendError(
        f"no entity '{entity_id}' on instance '{backend.instance}'"
        + (
            " — closest: " + ", ".join(close)
            if close
            else " (try find_entities_by_description for a description-based search)"
        )
    )


def get_areas(backend):
    """Areas from the registry, each with a live entity count."""
    index = backend.area_index()
    states = backend.states()

    counts = {}
    dead = {}
    unassigned = 0
    for state in states:
        entity_id = state.get("entity_id") or ""
        area = index.area_of(entity_id)
        if not area:
            unassigned += 1
            continue
        counts[area] = counts.get(area, 0) + 1
        if state.get("state") in DEAD_STATES:
            dead[area] = dead.get(area, 0) + 1

    areas = [
        {
            "area_id": a.get("area_id"),
            "name": a.get("name"),
            "aliases": a.get("aliases") or [],
            "entity_count": counts.get(a.get("name"), 0),
            "unavailable_count": dead.get(a.get("name"), 0),
        }
        for a in index.areas
    ]
    areas.sort(key=lambda a: (a["name"] or a["area_id"] or "").lower())
    result = {
        "instance": backend.instance,
        "count": len(areas),
        "areas": areas,
        "entities_without_area": unassigned,
    }
    result.update(_area_meta(index))
    return result


def find_entities_by_description(backend, text, limit=FIND_LIMIT):
    """Fuzzy lookup over entity_id / friendly_name / area (see match.py)."""
    tokens = match.tokenize(text)
    if not tokens:
        raise BackendError("give me something to search for (text was empty after normalization)")

    index = backend.area_index()
    scored = []
    for state in backend.states():
        entity_id = state.get("entity_id") or ""
        attrs = state.get("attributes") or {}
        area = index.area_of(entity_id)
        score, matched, reason = match.score_entity(
            tokens, entity_id, attrs.get("friendly_name"), area
        )
        if not score:
            continue
        view = _entity_view(state, area)
        view["score"] = score
        view["matched_tokens"] = f"{matched}/{len(tokens)}"
        view["why"] = reason
        scored.append((matched, score, entity_id, view))

    scored.sort(key=lambda row: (-row[0], -row[1], row[2]))
    matches = [row[3] for row in scored[:limit]]
    result = {
        "instance": backend.instance,
        "query": text,
        "tokens": tokens,
        "total_matches": len(scored),
        "returned": len(matches),
        "unavailable_count": _count_dead(matches),
        "matches": matches,
    }
    result.update(_area_meta(index))
    return result


# --- automations: repo is the source of truth, live state is annotation ---


def _automation_entries(backend):
    """[(id, alias, repo-relative path, absolute path, parsed dict)] from the repo."""
    entries = []
    for rel_path, abs_path in backend.automation_files():
        try:
            with open(abs_path, "r", encoding="utf-8") as f:
                parsed = yaml.safe_load(f)
        except (OSError, yaml.YAMLError):
            parsed = None
        parsed = parsed if isinstance(parsed, dict) else {}
        auto_id = str(parsed.get("id") or abs_path.stem)
        entries.append((auto_id, parsed.get("alias"), rel_path, abs_path, parsed))
    return entries


def _live_automations(backend):
    """{automation id: live state dict}, or ({}, error message) if unreachable."""
    try:
        states = backend.states()
    except BackendError as exc:
        return {}, str(exc)
    live = {}
    for state in states:
        entity_id = state.get("entity_id") or ""
        if not entity_id.startswith("automation."):
            continue
        attrs = state.get("attributes") or {}
        auto_id = attrs.get("id")
        record = {
            "entity_id": entity_id,
            "state": state.get("state"),
            "last_triggered": attrs.get("last_triggered"),
            "friendly_name": attrs.get("friendly_name"),
        }
        if auto_id:
            live[str(auto_id)] = record
    return live, None


def read_automation(backend, id_or_alias):
    """Automation YAML straight from the repo, annotated with live state.

    Repo, not `/api/config/automation/config/<id>`, on purpose: the repo is
    the reviewable source of truth (DESIGN.md, phase 1), and reading it works
    even when the instance is unreachable.
    """
    needle = (id_or_alias or "").strip()
    if not needle:
        raise BackendError("id_or_alias is required")
    entries = _automation_entries(backend)
    if not entries:
        raise BackendError(
            f"no automations in the repo for instance '{backend.instance}' "
            f"(expected services/home-assistant/config/{backend.instance}/automations/)"
        )

    needle_n = match.normalize(needle)
    exact = [e for e in entries if e[0] == needle or match.normalize(e[1]) == needle_n]
    if not exact:
        exact = [e for e in entries if needle_n and needle_n in match.normalize(e[1])]
    if not exact:
        exact = [e for e in entries if needle_n and needle_n in match.normalize(e[0])]

    if not exact:
        raise BackendError(
            f"no automation matching '{id_or_alias}' in the repo "
            f"(searched id and alias of {len(entries)} files)"
        )
    if len(exact) > 1:
        raise BackendError(
            f"'{id_or_alias}' matches {len(exact)} automations — be more specific: "
            + "; ".join(f"{e[0]} ({e[1]})" for e in exact[:10])
        )

    auto_id, alias, rel_path, abs_path, parsed = exact[0]
    with open(abs_path, "r", encoding="utf-8") as f:
        raw = f.read()

    live, live_error = _live_automations(backend)
    result = {
        "instance": backend.instance,
        "id": auto_id,
        "alias": alias,
        "path": rel_path,
        "automation": parsed,
        "yaml": raw,
        "source": "repo (services/home-assistant/config/) — not /api/config",
    }
    record = live.get(auto_id)
    if record:
        result["live"] = record
    elif live_error:
        result["live_error"] = live_error
    else:
        result["live"] = None
        result["live_note"] = (
            "no automation entity with this id on the live instance — "
            "present in the repo but not loaded (deploy pending, or disabled/removed in HA)"
        )
    return result


def list_automations(backend, filter=None):
    """id / alias / state / last_triggered for every automation, repo-joined."""
    entries = _automation_entries(backend)
    by_id = {e[0]: e for e in entries}
    live, live_error = _live_automations(backend)

    rows = []
    for auto_id in sorted(set(by_id) | set(live)):
        entry = by_id.get(auto_id)
        record = live.get(auto_id) or {}
        rows.append(
            {
                "id": auto_id,
                "alias": (entry[1] if entry else None) or record.get("friendly_name"),
                "state": record.get("state"),
                "last_triggered": record.get("last_triggered"),
                "entity_id": record.get("entity_id"),
                "path": entry[2] if entry else None,
                "in_repo": entry is not None,
                "on_instance": auto_id in live,
            }
        )

    if filter:
        needle = match.normalize(filter)
        rows = [
            r
            for r in rows
            if needle
            in match.normalize(" ".join(str(v) for v in (r["alias"], r["id"], r["entity_id"]) if v))
        ]

    result = {
        "instance": backend.instance,
        "filter": filter,
        "count": len(rows),
        "automations": rows,
    }
    if live_error:
        result["live_error"] = live_error
        result["live_note"] = "state/last_triggered unavailable — listing repo files only"
    return result


def instance_status(backend):
    """Reachability, HA version, entity counts — or the offline record from instances.yaml."""
    cfg = backend.cfg
    result = {
        "instance": backend.instance,
        "status": backend.status,
        "adapter": cfg.get("adapter"),
        "base_url": cfg.get("base_url"),
        "site": cfg.get("site"),
        "repo_automations": len(backend.automation_files()),
    }

    if backend.status == "offline":
        # instances.yaml already answers this; a 5s timeout would not add anything.
        result["reachable"] = False
        result["reason"] = (
            "status: offline in instances.yaml — not queried. This instance sits "
            "behind an intermittent LTE uplink (site chelsty) and is offline-first "
            "by design; see DESIGN.md and CLAUDE.md, 'CHELSTY-Specific Rules'."
        )
        return result

    try:
        config = backend.ha_config()
        states = backend.states()
    except BackendError as exc:
        result["reachable"] = False
        result["error"] = str(exc)
        return result

    dead = [s for s in states if s.get("state") in DEAD_STATES]
    result.update(
        {
            "reachable": True,
            "version": config.get("version"),
            "location_name": config.get("location_name"),
            "time_zone": config.get("time_zone"),
            "entity_count": len(states),
            "unavailable_count": len(dead),
            "automation_count": sum(
                1 for s in states if (s.get("entity_id") or "").startswith("automation.")
            ),
        }
    )
    index = backend.area_index()
    result["area_count"] = len(index.areas)
    result.update(_area_meta(index))
    if dead:
        result["unavailable_note"] = (
            f"{len(dead)} entities are unavailable/unknown — the 2026-07-23 audit "
            "traced ~15 silently dead automations to exactly this "
            "(docs/audyt-automatyzacji-2026-07-23.md, 1.2)"
        )
    return result
