#!/usr/bin/env python3
"""Home Assistant 연동 — 엔티티 스위치 JSON + light/climate/TV/washer."""

from __future__ import annotations

import json
import os
import re
import urllib.error
import urllib.request
from pathlib import Path

HOOKS = Path(__file__).resolve().parent
BASE_DIR = HOOKS.parent
ENV_PATH = HOOKS / "ha.env"
ALIAS_PATH = HOOKS / "aliases.json"
ENTITIES_PATH = Path(
    os.environ.get("JARVIS_HA_ENTITIES", str(BASE_DIR / "jarvis_ha_entities.json"))
)

_entities_mtime: float = -1.0
_entities_cache: dict[str, dict] = {}

DEFAULT_ON_DOMAINS = frozenset({"light", "climate"})
DEFAULT_OFF_DOMAINS = frozenset(
    {
        "lock",
        "alarm_control_panel",
        "cover",
        "person",
        "device_tracker",
        "camera",
        "image",
        "update",
        "automation",
        "script",
        "scene",
        "button",
        "event",
        "notify",
        "tts",
        "stt",
        "conversation",
        "ai_task",
        "todo",
        "calendar",
        "zone",
    }
)
TV_NAME_KEYS = ("tv", "webos", "티비", "텔레비전", "television", "엘지티비")


def load_env() -> dict[str, str]:
    env: dict[str, str] = {}
    if not ENV_PATH.is_file():
        raise SystemExit(f"ha.env 없음: {ENV_PATH}")
    for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        env[k.strip()] = v.strip()
    if not env.get("HA_TOKEN"):
        raise SystemExit("HA_TOKEN 비어 있음 (ha.env 저장 확인)")
    env["HA_URL"] = env.get("HA_URL", "http://192.168.0.148:8123").rstrip("/")
    return env


def ha_request(method: str, path: str, data: dict | None = None) -> object:
    env = load_env()
    body = None if data is None else json.dumps(data).encode()
    req = urllib.request.Request(
        env["HA_URL"] + path,
        data=body,
        method=method,
        headers={
            "Authorization": f"Bearer {env['HA_TOKEN']}",
            "Content-Type": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            raw = resp.read()
            if not raw:
                return None
            return json.loads(raw.decode())
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"HA HTTP {e.code}: {e.read()[:200]!r}") from e


def get_states() -> list[dict]:
    data = ha_request("GET", "/api/states")
    if not isinstance(data, list):
        raise RuntimeError(f"states 이상: {data!r}"[:200])
    return data


def _compact(s: str) -> str:
    return re.sub(r"\s+", "", s)


def _looks_like_tv(entity_id: str, friendly_name: str) -> bool:
    blob = f"{entity_id} {friendly_name}".lower()
    return any(k in blob for k in TV_NAME_KEYS)


def _default_enabled(entity_id: str, friendly_name: str) -> bool:
    domain = entity_id.split(".", 1)[0]
    if domain in DEFAULT_ON_DOMAINS:
        return True
    if entity_id.startswith(("sensor.setaggi", "binary_sensor.setaggi")):
        return True
    if "setaggi" in entity_id:
        return True
    if domain == "media_player":
        return _looks_like_tv(entity_id, friendly_name)
    if domain in DEFAULT_OFF_DOMAINS:
        return False
    return False


def load_aliases() -> dict[str, str]:
    if not ALIAS_PATH.is_file():
        return {}
    try:
        data = json.loads(ALIAS_PATH.read_text(encoding="utf-8"))
        return {
            str(k): str(v)
            for k, v in (data or {}).items()
            if not str(k).startswith("_")
        }
    except (OSError, json.JSONDecodeError, TypeError):
        return {}


def load_entities_map(*, force: bool = False) -> dict[str, dict]:
    """jarvis_ha_entities.json — mtime 변경 시 재로드."""
    global _entities_mtime, _entities_cache
    if not ENTITIES_PATH.is_file():
        _entities_mtime = -1.0
        _entities_cache = {}
        return {}
    try:
        mtime = ENTITIES_PATH.stat().st_mtime
    except OSError:
        return _entities_cache
    if not force and mtime == _entities_mtime and _entities_cache:
        return _entities_cache
    try:
        data = json.loads(ENTITIES_PATH.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as e:
        print(f"[ha_entities] 읽기 실패: {e}")
        return _entities_cache
    raw = data.get("entities") if isinstance(data, dict) else None
    if not isinstance(raw, dict):
        return _entities_cache
    cleaned: dict[str, dict] = {}
    for eid, meta in raw.items():
        if not isinstance(meta, dict):
            continue
        cleaned[str(eid)] = {
            "name": str(meta.get("name") or eid),
            "enabled": bool(meta.get("enabled", False)),
        }
    _entities_cache = cleaned
    _entities_mtime = mtime
    return _entities_cache


def is_enabled(entity_id: str) -> bool:
    ents = load_entities_map()
    if not ents:
        domain = entity_id.split(".", 1)[0]
        return domain in DEFAULT_ON_DOMAINS or "setaggi" in entity_id
    meta = ents.get(entity_id)
    return bool(meta and meta.get("enabled"))


def assert_enabled(entity_id: str) -> str | None:
    """허용이면 None, 아니면 거부 메시지."""
    ents = load_entities_map()
    if not ents:
        return None
    if entity_id not in ents:
        return f"{entity_id} 는 엔티티 JSON에 없습니다. sync 후 enabled를 확인하세요."
    if not ents[entity_id].get("enabled"):
        name = ents[entity_id].get("name") or entity_id
        return f"{name}({entity_id}) 는 스위치가 꺼져 있어 조작할 수 없습니다."
    return None


def sync_entities_file(*, path: Path | None = None) -> Path:
    """HA 전 엔티티를 JSON에 넣는다. 기존 enabled는 유지, 신규만 기본값."""
    out = path or ENTITIES_PATH
    states = get_states()
    existing: dict[str, dict] = {}
    if out.is_file():
        try:
            prev = json.loads(out.read_text(encoding="utf-8"))
            raw = prev.get("entities") if isinstance(prev, dict) else {}
            if isinstance(raw, dict):
                for eid, meta in raw.items():
                    if isinstance(meta, dict):
                        existing[str(eid)] = {
                            "name": str(meta.get("name") or eid),
                            "enabled": bool(meta.get("enabled", False)),
                        }
        except (OSError, json.JSONDecodeError):
            pass

    entities: dict[str, dict] = {}
    for st in states:
        eid = str(st.get("entity_id") or "")
        if not eid or "." not in eid:
            continue
        fn = str((st.get("attributes") or {}).get("friendly_name") or eid)
        if eid in existing:
            entities[eid] = {
                "name": existing[eid].get("name") or fn,
                "enabled": bool(existing[eid].get("enabled")),
            }
        else:
            entities[eid] = {
                "name": fn,
                "enabled": _default_enabled(eid, fn),
            }

    for eid, meta in existing.items():
        if eid not in entities:
            entities[eid] = meta

    payload = {
        "_help": (
            "enabled true만 자비스가 읽고 조작. 저장 즉시 반영(재시작 불필요). "
            "동기화: python3 jarvis_hooks/ha_api.py sync"
        ),
        "entities": dict(sorted(entities.items())),
    }
    out.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    load_entities_map(force=True)
    enabled_n = sum(1 for m in entities.values() if m.get("enabled"))
    print(f"[ha_entities] {out} — 전체 {len(entities)} / enabled {enabled_n}")
    return out


def _display_name(entity_id: str, friendly_name: str) -> str:
    ents = load_entities_map()
    meta = ents.get(entity_id)
    if meta and meta.get("name"):
        return str(meta["name"])
    return friendly_name or entity_id


def _apply_aliases(utterance: str) -> str:
    c = _compact(utterance)
    aliases = load_aliases()
    for alias, target in sorted(aliases.items(), key=lambda x: -len(x[0])):
        if _compact(alias) and _compact(alias) in c:
            return target
    return utterance


def _match_by_name(
    utterance: str,
    candidates: list[tuple[str, str, str]],
    suffixes: tuple[str, ...] = (),
) -> tuple[str, str] | None:
    """candidates: (display_name, entity_id, state) — 긴 이름 우선."""
    text = _apply_aliases(utterance)
    c = _compact(text)
    scored: list[tuple[int, str, str]] = []
    for fn, eid, _ in candidates:
        cf = _compact(fn)
        if not cf:
            continue
        if cf in c or (len(c) >= 2 and c in cf):
            scored.append((len(cf), fn, eid))
            continue
        for suffix in suffixes:
            if cf + suffix in c or cf in c.replace(suffix, ""):
                scored.append((len(cf), fn, eid))
                break
        ce = _compact(eid.split(".", 1)[-1])
        if ce and len(ce) >= 3 and ce in c:
            scored.append((len(ce), fn, eid))
    if scored:
        scored.sort(reverse=True)
        return scored[0][1], scored[0][2]
    return None


def list_lights(states: list[dict] | None = None) -> list[tuple[str, str, str]]:
    """(friendly_name, entity_id, state) — enabled만."""
    states = states or get_states()
    out = []
    for st in states:
        eid = st.get("entity_id") or ""
        if not eid.startswith("light.") or not is_enabled(eid):
            continue
        fn = (st.get("attributes") or {}).get("friendly_name") or eid
        out.append((_display_name(eid, fn), eid, st.get("state") or "unknown"))
    return out


def resolve_light(
    utterance: str, states: list[dict] | None = None
) -> tuple[str, str] | None:
    states = states or get_states()
    lights = list_lights(states)
    hit = _match_by_name(
        utterance, lights, suffixes=("불", "등", "조명", "라이트")
    )
    if hit:
        return hit
    c = _compact(_apply_aliases(utterance))
    prefers: list[str] = []
    for room in ("거실", "아기방", "안방", "주방", "화장실"):
        if room in c:
            prefers.append(room)
    prefers.extend(("메인등", "거실", "아기방"))
    if any(
        k in c
        for k in ("불켜", "불꺼", "라이트온", "라이트오프", "조명켜", "조명꺼")
    ) or prefers:
        seen: set[str] = set()
        for prefer in prefers:
            if prefer in seen:
                continue
            seen.add(prefer)
            for fn, eid, _ in lights:
                if prefer in fn:
                    return fn, eid
        if lights:
            return lights[0][0], lights[0][1]
    return None


def light_service(entity_id: str, turn_on: bool) -> None:
    err = assert_enabled(entity_id)
    if err:
        raise RuntimeError(err)
    svc = "turn_on" if turn_on else "turn_off"
    ha_request("POST", f"/api/services/light/{svc}", {"entity_id": entity_id})


def list_climates(
    states: list[dict] | None = None,
) -> list[tuple[str, str, str, float | None]]:
    """(name, entity_id, state, temperature)."""
    states = states or get_states()
    out = []
    for st in states:
        eid = st.get("entity_id") or ""
        if not eid.startswith("climate.") or not is_enabled(eid):
            continue
        attrs = st.get("attributes") or {}
        fn = attrs.get("friendly_name") or eid
        temp = attrs.get("temperature")
        try:
            temp_f = float(temp) if temp is not None else None
        except (TypeError, ValueError):
            temp_f = None
        out.append(
            (_display_name(eid, fn), eid, st.get("state") or "unknown", temp_f)
        )
    return out


def resolve_climate(
    utterance: str, states: list[dict] | None = None
) -> tuple[str, str] | None:
    climates = [(n, e, s) for n, e, s, _ in list_climates(states)]
    hit = _match_by_name(
        utterance, climates, suffixes=("에어컨", "냉방", "공조")
    )
    if hit:
        return hit
    c = _compact(_apply_aliases(utterance))
    if any(k in c for k in ("에어컨", "더워", "추워", "냉방", "난방")):
        for prefer in ("스탠드", "거실", "이문동"):
            for fn, eid, _ in climates:
                if prefer in fn:
                    return fn, eid
        if climates:
            return climates[0][0], climates[0][1]
    return None


def climate_service(
    entity_id: str,
    action: str,
    temperature: int | float | None = None,
) -> str:
    err = assert_enabled(entity_id)
    if err:
        return err
    action = (action or "").strip().lower()
    if action in ("on", "turn_on", "켜", "켜줘"):
        ha_request(
            "POST",
            "/api/services/climate/turn_on",
            {"entity_id": entity_id},
        )
        temp = 24 if temperature is None else int(temperature)
        ha_request(
            "POST",
            "/api/services/climate/set_temperature",
            {"entity_id": entity_id, "temperature": temp},
        )
        return f"{entity_id} 를 {temp}도로 켰습니다."
    if action in ("off", "turn_off", "꺼", "꺼줘"):
        ha_request(
            "POST",
            "/api/services/climate/turn_off",
            {"entity_id": entity_id},
        )
        return f"{entity_id} 를 껐습니다."
    if action in ("set", "set_temperature", "온도"):
        if temperature is None:
            return "온도 숫자가 필요합니다."
        temp = int(temperature)
        ha_request(
            "POST",
            "/api/services/climate/set_temperature",
            {"entity_id": entity_id, "temperature": temp},
        )
        return f"{entity_id} 온도를 {temp}도로 맞췄습니다."
    return f"알 수 없는 climate action: {action}"


def list_tvs(states: list[dict] | None = None) -> list[tuple[str, str, str]]:
    states = states or get_states()
    out = []
    for st in states:
        eid = st.get("entity_id") or ""
        if not eid.startswith("media_player.") or not is_enabled(eid):
            continue
        fn = (st.get("attributes") or {}).get("friendly_name") or eid
        out.append((_display_name(eid, fn), eid, st.get("state") or "unknown"))
    return out


def resolve_tv(
    utterance: str = "", states: list[dict] | None = None
) -> tuple[str, str] | None:
    tvs = list_tvs(states)
    if not tvs:
        return None
    text = utterance or "티비"
    hit = _match_by_name(
        text, tvs, suffixes=("티비", "TV", "텔레비전", "티브이")
    )
    if hit:
        return hit
    for fn, eid, _ in tvs:
        if _looks_like_tv(eid, fn):
            return fn, eid
    return tvs[0][0], tvs[0][1]


def tv_service(entity_id: str, turn_on: bool) -> None:
    err = assert_enabled(entity_id)
    if err:
        raise RuntimeError(err)
    svc = "turn_on" if turn_on else "turn_off"
    ha_request(
        "POST", f"/api/services/media_player/{svc}", {"entity_id": entity_id}
    )


def home_snapshot(states: list[dict] | None = None) -> str:
    """enabled 기기만 짧게. 제미나이 컨텍스트용."""
    states = states or get_states()
    by_id = {st.get("entity_id"): st for st in states}
    lines: list[str] = []

    for fn, eid, st in list_lights(states):
        lines.append(f"light | {fn} | {eid} | {st}")
    for fn, eid, st, temp in list_climates(states):
        extra = f", set={temp}" if temp is not None else ""
        cur = (by_id.get(eid) or {}).get("attributes", {}).get(
            "current_temperature"
        )
        if cur is not None:
            extra += f", now={cur}"
        lines.append(f"climate | {fn} | {eid} | {st}{extra}")
    for fn, eid, st in list_tvs(states):
        lines.append(f"tv/media | {fn} | {eid} | {st}")

    washer_bits = []
    for eid, st in by_id.items():
        if not eid or "setaggi" not in eid:
            continue
        if not eid.startswith(("sensor.", "binary_sensor.")):
            continue
        if not is_enabled(eid):
            continue
        washer_bits.append(f"{eid}={st.get('state')}")
    if washer_bits:
        lines.append("washer | " + ", ".join(washer_bits[:12]))

    if not lines:
        return "enabled 기기가 없습니다. jarvis_ha_entities.json 을 확인하세요."
    return "\n".join(lines)


def washer_status(states: list[dict] | None = None) -> str:
    states = states or get_states()
    by_id = {st.get("entity_id"): st for st in states}

    remain = None
    power = None
    run_state = None
    pre = None
    completed = None

    if "sensor.setaggi" in by_id:
        st = by_id["sensor.setaggi"]
        attrs = st.get("attributes") or {}
        power = st.get("state")
        remain = attrs.get("remain_time")
        run_state = attrs.get("run_state")
        completed = attrs.get("run_completed")

    if "sensor.setaggi_remaining_time" in by_id:
        remain = remain or by_id["sensor.setaggi_remaining_time"].get("state")
    if "sensor.setaggi_remaining_time_2" in by_id:
        r2 = by_id["sensor.setaggi_remaining_time_2"].get("state")
        if r2 and r2 not in ("unknown", "unavailable", "none"):
            remain = remain or r2
    if "sensor.setaggi_current_status" in by_id:
        power = power or by_id["sensor.setaggi_current_status"].get("state")
    if "sensor.setaggi_run_state" in by_id:
        run_state = run_state or by_id["sensor.setaggi_run_state"].get("state")
    if "sensor.setaggi_pre_state" in by_id:
        pre = by_id["sensor.setaggi_pre_state"].get("state")
    if "binary_sensor.setaggi_run_completed" in by_id:
        completed = completed or by_id["binary_sensor.setaggi_run_completed"].get(
            "state"
        )

    def fmt_remain(v) -> str | None:
        if v is None:
            return None
        s = str(v).strip()
        if s in ("", "unknown", "unavailable", "none", "-", "0:00:00", "00:00:00"):
            return None
        m = re.match(r"^(\d+):(\d+):(\d+)$", s)
        if m:
            h, mi, sec = map(int, m.groups())
            total = h * 60 + mi + (1 if sec else 0)
            if total <= 0:
                return None
            if h > 0:
                return f"{h}시간 {mi}분"
            return f"{mi}분"
        return s

    remain_s = fmt_remain(remain)
    off_like = str(power).lower() in (
        "off",
        "power_off",
        "unavailable",
        "unknown",
        "",
    )

    if remain_s:
        extra = (
            f", 상태 {run_state}"
            if run_state and run_state not in ("-", "unknown")
            else ""
        )
        return f"세탁기 남은 시간은 약 {remain_s}입니다{extra}."

    if completed in ("on", "True", True) or (pre and "완료" in str(pre)):
        return (
            f"세탁기는 지금 꺼져 있고, 세탁은 끝난 상태입니다. "
            f"{('이전 상태: ' + str(pre)) if pre else ''}"
        ).strip()

    if off_like:
        return "세탁기는 지금 전원 꺼짐 상태라 남은 시간이 없습니다."

    return f"세탁기 상태: 전원 {power}, 남은 시간 정보 없음."


def tool_get_home_snapshot() -> str:
    """지금 집안에서 자비스가 조작 가능한(enabled) 기기 목록과 상태를 조회한다."""
    try:
        return home_snapshot()
    except Exception as e:
        return f"스냅샷 실패: {e}"


def tool_control_light(name: str, action: str) -> str:
    """조명을 켠다/끈다. name은 방/조명 이름(예: 거실, 메인등, 아기방). action은 on 또는 off."""
    try:
        turn_on = str(action).strip().lower() in (
            "on",
            "turn_on",
            "켜",
            "켜줘",
            "켜라",
        )
        hit = resolve_light(name or "")
        if not hit:
            return f"조명을 찾지 못했습니다: {name}"
        fn, eid = hit
        light_service(eid, turn_on)
        return f"{fn}을 {'켰습니다' if turn_on else '껐습니다'}."
    except Exception as e:
        return f"조명 제어 실패: {e}"


def tool_control_climate(
    name: str = "",
    action: str = "turn_on",
    temperature: int = 24,
) -> str:
    """에어컨/난방을 켠다/끈다/온도를 맞춘다. action: turn_on, turn_off, set. temperature 기본 24."""
    try:
        hit = resolve_climate(name or "에어컨")
        if not hit:
            return f"에어컨을 찾지 못했습니다: {name or '(기본)'}"
        fn, eid = hit
        msg = climate_service(eid, action, temperature)
        return f"{fn}: {msg}"
    except Exception as e:
        return f"에어컨 제어 실패: {e}"


def tool_control_tv(name: str = "", action: str = "turn_on") -> str:
    """티비를 켠다/끈다. name 생략 시 거실 LG 티비. action: on/off."""
    try:
        turn_on = str(action).strip().lower() in (
            "on",
            "turn_on",
            "켜",
            "켜줘",
            "켜라",
        )
        hit = resolve_tv(name or "티비")
        if not hit:
            return (
                "티비(media_player)를 찾지 못했습니다. "
                "entities JSON에서 enabled를 확인하세요."
            )
        fn, eid = hit
        tv_service(eid, turn_on)
        return f"{fn}을 {'켰습니다' if turn_on else '껐습니다'}."
    except Exception as e:
        return f"티비 제어 실패: {e}"


def tool_get_washer_status() -> str:
    """세탁기 남은 시간·전원·완료 여부를 조회한다."""
    try:
        return washer_status()
    except Exception as e:
        return f"세탁기 조회 실패: {e}"


if __name__ == "__main__":
    import sys

    cmd = sys.argv[1] if len(sys.argv) > 1 else "washer"
    if cmd == "sync":
        sync_entities_file()
    elif cmd == "snapshot":
        if not ENTITIES_PATH.is_file():
            sync_entities_file()
        print(home_snapshot())
    elif cmd == "washer":
        print(washer_status())
    elif cmd == "lights":
        for fn, eid, st in list_lights():
            print(f"{fn}\t{eid}\t{st}")
    elif cmd == "tvs":
        for fn, eid, st in list_tvs():
            print(f"{fn}\t{eid}\t{st}")
    elif cmd == "climates":
        for fn, eid, st, temp in list_climates():
            print(f"{fn}\t{eid}\t{st}\t{temp}")
    elif cmd in ("on", "off") and len(sys.argv) > 2:
        name = " ".join(sys.argv[2:])
        hit = resolve_light(name)
        if not hit:
            print("FAIL no light", name)
            sys.exit(1)
        fn, eid = hit
        light_service(eid, cmd == "on")
        print(f"OK {cmd} {fn} {eid}")
    elif cmd in ("tv_on", "tv_off"):
        name = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "티비"
        hit = resolve_tv(name)
        if not hit:
            print("FAIL no tv", name)
            sys.exit(1)
        fn, eid = hit
        tv_service(eid, cmd == "tv_on")
        print(f"OK {cmd} {fn} {eid}")
    else:
        print(
            "usage: ha_api.py sync|snapshot|washer|lights|tvs|climates|"
            "on <이름>|off <이름>|tv_on [이름]|tv_off [이름]"
        )
