#!/usr/bin/env python3
"""
스마트홈 서버 v2
- 웹앱 파일 서빙 (smart-home-ha.html)
- /api/config    GET/POST  : 설정 저장/불러오기
- /api/devices   GET       : 음성비서용 기기 매핑
- /api/states    GET       : HA 실제 기기 상태 프록시 (페이지 로드 시 실시간 반영)
- /api/schedules GET       : 서버 저장 타이머 목록 반환
- /api/timer     POST      : 일회성 타이머 등록 (브라우저 종료 후에도 서버에서 실행)
- /api/timer/cancel POST   : 타이머 취소
- /api/status    GET       : 서버/HA 연결 상태 + 최근 로그
- 백그라운드 예약 실행기: 요일별 규칙 + 일회성 타이머 (브라우저 무관)

실행: python server.py
"""

import http.server
import json
import os
import shutil
import socket
import socketserver, threading
import threading
import time
import urllib.request
import urllib.error
from collections import deque
from datetime import datetime, timedelta
from urllib.parse import urlparse

PORT           = 8080
CONFIG_FILE    = "config.json"
BACKUP_FILE    = "config.backup.json"
SCHEDULES_FILE = "schedules.json"
SCRIPT_DIR     = os.path.dirname(os.path.abspath(__file__))

# ── 서버 로그 버퍼 (최근 50개) ──────────────────────────
_log_buf  = deque(maxlen=50)
_log_lock = threading.Lock()

def slog(msg):
    ts = datetime.now().strftime("%H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line)
    with _log_lock:
        _log_buf.append(line)

# ── 설정 로드/저장 ─────────────────────────────────────
def load_config():
    path = os.path.join(SCRIPT_DIR, CONFIG_FILE)
    if os.path.exists(path):
        try:
            with open(path, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            pass
    return {}

def save_config(data):
    path   = os.path.join(SCRIPT_DIR, CONFIG_FILE)
    backup = os.path.join(SCRIPT_DIR, BACKUP_FILE)
    if os.path.exists(path):
        try:
            shutil.copy2(path, backup)
        except:
            pass
    with open(path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

# ── HA URL 선택 ─────────────────────────────────────
# server가 HA API 호출할 때는 같은局域网(haUrlLocal)을 사용
# (공인IP로 접근하면 hairpin NAT 문제로 실패할 수 있음)
def get_ha_url(cfg):
    return cfg.get("haUrlLocal") or cfg.get("haUrl") or ""

# ── 타이머 저장/불러오기 ───────────────────────────────
_sched_lock = threading.Lock()

def load_schedules():
    path = os.path.join(SCRIPT_DIR, SCHEDULES_FILE)
    if os.path.exists(path):
        try:
            with open(path, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            pass
    return {"timers": []}

def save_schedules(data):
    path = os.path.join(SCRIPT_DIR, SCHEDULES_FILE)
    with open(path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

# ── 도메인별 서비스 정규화 ─────────────────────────────
# turn_on/turn_off 대신 도메인에 맞는 실제 서비스로 변환
_SVC_MAP = {
    ("vacuum",       "turn_on"):  ("vacuum",       "start"),
    ("vacuum",       "turn_off"): ("vacuum",       "return_to_base"),
    ("vacuum",       "return_home"): ("vacuum",    "return_to_base"),
    ("vacuum",       "dock"):     ("vacuum",       "return_to_base"),
    ("cover",        "turn_on"):  ("cover",        "open_cover"),
    ("cover",        "turn_off"): ("cover",        "close_cover"),
    ("cover",        "open"):     ("cover",        "open_cover"),
    ("cover",        "close"):    ("cover",        "close_cover"),
    ("lock",         "turn_on"):  ("lock",         "unlock"),
    ("lock",         "turn_off"): ("lock",         "lock"),
    ("media_player", "turn_off"): ("media_player", "media_stop"),
    ("media_player", "play"):     ("media_player", "media_play"),
    ("media_player", "media_next"):     ("media_player", "media_next_track"),
    ("media_player", "media_previous"): ("media_player", "media_previous_track"),
    ("fan",          "set_speed"):("fan",          "set_percentage"),
}

def normalize_service(domain, service):
    """도메인에 맞게 서비스명 보정 (예: vacuum + turn_off → return_to_base)"""
    return _SVC_MAP.get((domain, service), (domain, service))

# ── HA API ────────────────────────────────────────────
HA_TIMEOUT   = 10   # 초 (네트워크 타임아웃 — 충분히 길게)
HA_MAX_RETRIES = 2  # 최대 재시도 횟수

def _ha_req(ha_url, ha_token, path, method="GET", body=None):
    url  = ha_url.rstrip('/') + path
    data = json.dumps(body).encode('utf-8') if body else None
    req  = urllib.request.Request(
        url, data=data,
        headers={"Authorization": f"Bearer {ha_token}", "Content-Type": "application/json"},
        method=method
    )
    with urllib.request.urlopen(req, timeout=HA_TIMEOUT) as resp:
        return json.loads(resp.read().decode('utf-8'))

def ha_call(ha_url, ha_token, domain, service, entity_id, extra=None):
    """HA 서비스 호출 — 재시도 2회, 10초 timeout, 네트워크 에러 즉시 재시도"""
    body = {"entity_id": entity_id}
    if extra:
        body.update(extra)

    # 1차: 도메인 전용 서비스
    d1, s1 = normalize_service(domain, service)
    for attempt in range(HA_MAX_RETRIES + 1):   # 0~2, 총 3회 시도
        try:
            _ha_req(ha_url, ha_token, f"/api/services/{d1}/{s1}", "POST", body)
            return True
        except urllib.error.HTTPError as e:
            # 400/404: 해당 도메인에 해당 서비스 없음 → homeassistant 폴백 시도 (1차에서만)
            if e.code in (400, 404) and service in ("turn_on", "turn_off") and attempt == 0:
                try:
                    _ha_req(ha_url, ha_token, f"/api/services/homeassistant/{service}", "POST", body)
                    slog(f"[HA] homeassistant/{service} 폴백 성공 → {entity_id}")
                    return True
                except Exception as e2:
                    slog(f"[HA 오류] homeassistant/{service} {entity_id}: {e2}")
                    return False
            slog(f"[HA 오류] {d1}/{s1} {entity_id}: HTTP {e.code}")
            return False
        except (urllib.error.URLError, socket.timeout, TimeoutError) as e:
            # 네트워크 에러/타임아웃: 재시도 (최대 3회까지)
            if attempt < HA_MAX_RETRIES:
                wait = (attempt + 1) * 3   # 1차실패→3초후 재시도, 2차실패→6초후
                slog(f"[HA] {d1}/{s1} {entity_id} — 네트워크 지연 ({wait}초 후 재시도 {attempt+1}/{HA_MAX_RETRIES})...")
                time.sleep(wait)
                continue
            slog(f"[HA 오류] {d1}/{s1} {entity_id}: {e}")
            return False
        except Exception as e:
            slog(f"[HA 오류] {d1}/{s1} {entity_id}: {e}")
            return False

    return False

def ha_get_states(ha_url, ha_token, entity_ids):
    """HA의 /api/states 벌크 엔드포인트로 한 번에 조회 (N번 요청 → 1번)"""
    if not entity_ids:
        return {}
    wanted = set(entity_ids)
    result = {eid: {"state": "unavailable", "attributes": {}} for eid in entity_ids}
    try:
        all_states = _ha_req(ha_url, ha_token, "/api/states")
        for item in all_states:
            eid = item.get("entity_id", "")
            if eid in wanted:
                result[eid] = {
                    "state":      item.get("state", "unknown"),
                    "attributes": item.get("attributes", {}),
                }
    except Exception as e:
        slog(f"[HA states 오류] {e}")
    return result

# ── 예약 실행기 (백그라운드) ──────────────────────────
_weekly_last_run = {}

def _exec_action(ha_url, ha_token, domain, service, entity_id, label, dev_id=None):
    d, s = normalize_service(domain, service)
    ok = ha_call(ha_url, ha_token, domain, service, entity_id)
    slog(f"[예약] {label} ({entity_id}) → {d}/{s} {'✓ 성공' if ok else '✗ 실패'}")
    if ok and dev_id:
        new_state = None
        if service in ('turn_on', 'start', 'open_cover', 'unlock'):
            new_state = 'on'
        elif service in ('turn_off', 'return_to_base', 'close_cover', 'lock'):
            new_state = 'off'
        if new_state:
            try:
                cfg = load_config()
                for dev in cfg.get('devices', []):
                    if dev.get('id') == dev_id:
                        dev['state'] = new_state
                        break
                save_config(cfg)
            except Exception as e:
                slog(f"[상태 저장 오류] {e}")

def scheduler_loop():
    slog("[예약기] 시작됨 (브라우저 종료 후에도 예약 유지)")
    while True:
        try:
            now     = datetime.now()
            cur_hm  = now.strftime("%H:%M")
            today   = now.strftime("%Y-%m-%d")
            weekday = now.weekday()   # 0=월 ~ 6=일

            cfg      = load_config()
            ha_url   = get_ha_url(cfg)
            ha_token = cfg.get("haToken", "")

            if not ha_url:
                slog("[예약기 경고] config.json에 haUrl/haUrlLocal이 없음 → HA 호출 불가")
            elif not ha_token:
                slog("[예약기 경고] config.json에 haToken이 없음 → HA 호출 불가")

            # ① 요일별 반복 예약
            if ha_url and ha_token:
                for dev in cfg.get("devices", []):
                    dev_id    = dev.get("id", "")
                    domain    = dev.get("domain", "")
                    entity_id = dev.get("entity_id", "")
                    if not (dev_id and entity_id):
                        continue
                    if not domain:
                        # entity_id에서 도메인 추출 (예: light.living → light)
                        domain = entity_id.split('.')[0] if '.' in entity_id else ''
                    if not domain:
                        continue
                    label = dev.get("customName") or dev.get("name", entity_id)

                    for ri, rule in enumerate(dev.get("schedRules", [])):
                        if weekday not in rule.get("days", []):
                            continue
                        for time_key, service in [("offTime", "turn_off"), ("onTime", "turn_on")]:
                            t = rule.get(time_key, "")
                            if not t or t != cur_hm:
                                continue
                            run_key = f"{dev_id}_{ri}_{today}_{time_key}"
                            if run_key in _weekly_last_run:
                                continue
                            _weekly_last_run[run_key] = True
                            slog(f"[요일예약] '{label}' {service} 실행 시작")
                            threading.Thread(
                                target=_exec_action,
                                args=(ha_url, ha_token, domain, service, entity_id, label, dev_id),
                                daemon=True
                            ).start()

            # ② 일회성 타이머
            with _sched_lock:
                schedules = load_schedules()
                remaining = []
                changed   = False
                grace_limit = now - timedelta(minutes=10)  # 10분 지나면 강제 삭제

                for timer in schedules.get("timers", []):
                    # run_at 파싱 실패 시 남겨두기 (파싱 오류로 소실 방지)
                    try:
                        run_dt = datetime.fromisoformat(timer.get("run_at", ""))
                    except Exception:
                        slog(f"[타이머 경고] run_at 파싱 실패: {timer.get('run_at')} → 유지")
                        remaining.append(timer)
                        continue

                    if now >= run_dt:
                        if ha_url and ha_token:
                            # HA 연결 가능 → 실행 후 삭제
                            for act in timer.get("actions", []):
                                eid  = act.get("entity_id", "")
                                svc  = act.get("service", "turn_off")
                                lbl  = act.get("label", timer.get("label", "타이머"))
                                did  = act.get("dev_id", "")
                                d    = act.get("domain", "")
                                if not eid:
                                    slog(f"[타이머 경고] entity_id 없음 → 건너뜀")
                                    continue
                                if not d:
                                    d = eid.split('.')[0] if '.' in eid else 'homeassistant'
                                    slog(f"[타이머] domain 없음 → '{d}' 자동 사용")
                                slog(f"[타이머] '{timer.get('label','?')}' 실행 → {d}/{svc} {eid}")
                                threading.Thread(
                                    target=_exec_action,
                                    args=(ha_url, ha_token, d, svc, eid, lbl, did),
                                    daemon=True
                                ).start()
                            changed = True
                        elif run_dt < grace_limit:
                            # HA 연결 없고 10분 이상 지남 → 포기하고 삭제
                            slog(f"[타이머] HA 미연결 + 10분 초과 → '{timer.get('label','?')}' 삭제")
                            changed = True
                        else:
                            # HA 연결 없지만 아직 유예 → 유지해서 나중에 재시도
                            slog(f"[타이머 대기] HA 미연결 → '{timer.get('label','?')}' 유지 (재시도 예정)")
                            remaining.append(timer)
                    else:
                        remaining.append(timer)

                if changed:
                    schedules["timers"] = remaining
                    save_schedules(schedules)

        except Exception as e:
            slog(f"[예약기 오류] {e}")

        sleep_sec = 60 - datetime.now().second
        time.sleep(max(2, sleep_sec))

# ── HTTP 핸들러 ────────────────────────────────────────
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    allow_reuse_address = True
    daemon_threads = True

class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=SCRIPT_DIR, **kwargs)

    def _send_json(self, data, status=200):
        body = json.dumps(data, ensure_ascii=False).encode('utf-8')
        try:
            self.send_response(status)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', len(body))
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            self.wfile.write(body)
        except (ConnectionResetError, BrokenPipeError):
            pass  # 클라이언트가 먼저 연결을 끊은 경우 — 정상적인 상황

    def _read_body(self):
        length = int(self.headers.get('Content-Length', 0))
        return self.rfile.read(length)

    def do_GET(self):
        parsed = urlparse(self.path)

        if parsed.path == '/api/config':
            self._send_json(load_config())

        elif parsed.path == '/api/devices':
            data    = load_config()
            result  = [
                {
                    'name':          dev.get('customName') or dev.get('name', ''),
                    'original_name': dev.get('name', ''),
                    'entity_id':     dev.get('entity_id', ''),
                    'domain':        dev.get('domain', ''),
                    'icon':          dev.get('icon', ''),
                }
                for dev in data.get('devices', []) if dev.get('entity_id')
            ]
            self._send_json(result)

        elif parsed.path == '/api/states':
            cfg        = load_config()
            ha_url     = get_ha_url(cfg)
            ha_token   = cfg.get('haToken', '')
            entity_ids = [d['entity_id'] for d in cfg.get('devices', []) if d.get('entity_id')]
            if not (ha_url and ha_token and entity_ids):
                self._send_json({})
                return
            self._send_json(ha_get_states(ha_url, ha_token, entity_ids))

        elif parsed.path == '/api/test-ha':
            cfg      = load_config()
            ha_url   = get_ha_url(cfg)
            ha_token = cfg.get('haToken','')
            result   = {"ha_url": ha_url, "has_token": bool(ha_token), "proxy_path": "/api/ha/api/states"}
            if ha_url and ha_token:
                try:
                    data = _ha_req(ha_url, ha_token, "/api/")
                    result["ha_status"] = "ok"
                    result["ha_message"] = data.get("message","")
                except Exception as e:
                    result["ha_status"] = "fail"
                    result["ha_error"]  = str(e)
            else:
                result["ha_status"] = "no_config"
            self._send_json(result)

        elif parsed.path == '/api/schedules':
            self._send_json(load_schedules())

        elif parsed.path == '/api/status':
            # 서버 상태 + 최근 로그 반환 (디버그용)
            cfg      = load_config()
            ha_url   = get_ha_url(cfg)
            ha_token = cfg.get('haToken', '')
            schedules = load_schedules()
            ha_ok = False
            ha_err = ''
            if ha_url and ha_token:
                try:
                    _ha_req(ha_url, ha_token, '/api/')
                    ha_ok = True
                except Exception as e:
                    ha_err = str(e)
            with _log_lock:
                logs = list(_log_buf)
            self._send_json({
                "server_time": datetime.now().isoformat(),
                "ha_url": ha_url,
                "ha_connected": ha_ok,
                "ha_error": ha_err,
                "active_timers": len(schedules.get('timers', [])),
                "timers": schedules.get('timers', []),
                "logs": logs[-20:],
            })

        elif parsed.path.startswith('/api/ha/'):
            # HA 프록시 — 브라우저가 HA 내부IP 대신 서버를 경유
            cfg      = load_config()
            ha_url   = get_ha_url(cfg)
            ha_token = cfg.get('haToken', '')
            if not (ha_url and ha_token):
                self._send_json({"error": "HA 미설정"}, 500)
                return
            ha_path = parsed.path[7:]   # /api/ha/api/states -> /api/states
            try:
                data = _ha_req(ha_url, ha_token, ha_path)
                self._send_json(data)
            except Exception as e:
                self._send_json({"error": str(e)}, 502)

        else:
            super().do_GET()

    def do_POST(self):
        parsed = urlparse(self.path)
        raw    = self._read_body()

        if parsed.path == '/api/config':
            try:
                data = json.loads(raw.decode('utf-8'))
                save_config(data)
                self._send_json({"ok": True})
                slog(f"[저장] rooms:{len(data.get('rooms',[]))}개 devices:{len(data.get('devices',[]))}개")
            except Exception as e:
                self._send_json({"ok": False, "error": str(e)}, 500)

        elif parsed.path == '/api/device/state':
            try:
                req       = json.loads(raw.decode('utf-8'))
                dev_id    = req.get('id', '')
                new_state = req.get('state')
                new_attrs = req.get('attrs')
                sub_ents  = req.get('subEntities')
                if not dev_id:
                    self._send_json({"ok": False, "error": "id 없음"}, 400)
                    return
                cfg = load_config()
                updated = False
                for dev in cfg.get('devices', []):
                    if dev.get('id') == dev_id:
                        if new_state is not None:
                            dev['state'] = new_state
                        if new_attrs is not None:
                            dev.setdefault('attrs', {}).update(new_attrs)
                        if sub_ents is not None:
                            dev['subEntities'] = sub_ents
                        updated = True
                        break
                if updated:
                    save_config(cfg)
                    self._send_json({"ok": True})
                else:
                    self._send_json({"ok": False, "error": "기기 없음"}, 404)
            except Exception as e:
                self._send_json({"ok": False, "error": str(e)}, 500)

        elif parsed.path == '/api/timer':
            try:
                req     = json.loads(raw.decode('utf-8'))
                hours   = int(req.get('hours',   0))
                minutes = int(req.get('minutes', 0))
                actions = req.get('actions', [])
                label   = req.get('label', '타이머')

                if not actions:
                    self._send_json({"ok": False, "error": "actions 없음"}, 400)
                    return

                # actions 검증 및 domain 자동 보완
                fixed_actions = []
                for act in actions:
                    eid = act.get('entity_id', '')
                    d   = act.get('domain', '')
                    if not eid:
                        continue
                    if not d:
                        d = eid.split('.')[0] if '.' in eid else 'homeassistant'
                    fixed_actions.append({**act, 'domain': d})

                if not fixed_actions:
                    self._send_json({"ok": False, "error": "유효한 action 없음"}, 400)
                    return

                run_at   = (datetime.now() + timedelta(hours=hours, minutes=minutes)).isoformat()
                timer_id = f"t_{int(time.time() * 1000)}"
                timer    = {"id": timer_id, "label": label, "run_at": run_at, "actions": fixed_actions}

                with _sched_lock:
                    s = load_schedules()
                    s.setdefault("timers", []).append(timer)
                    save_schedules(s)

                slog(f"[타이머 등록] '{label}' → {hours}h {minutes}m 후 ({run_at})")
                self._send_json({"ok": True, "id": timer_id, "run_at": run_at})

            except Exception as e:
                slog(f"[타이머 등록 오류] {e}")
                self._send_json({"ok": False, "error": str(e)}, 500)

        elif parsed.path == '/api/timer/cancel':
            try:
                req      = json.loads(raw.decode('utf-8'))
                timer_id = req.get('id', '')
                with _sched_lock:
                    s      = load_schedules()
                    before = len(s.get("timers", []))
                    s["timers"] = [t for t in s.get("timers", []) if t.get("id") != timer_id]
                    save_schedules(s)
                removed = before - len(s["timers"])
                slog(f"[타이머 취소] id={timer_id} ({'삭제됨' if removed else '없음'})")
                self._send_json({"ok": True, "removed": removed})
            except Exception as e:
                self._send_json({"ok": False, "error": str(e)}, 500)

        elif parsed.path.startswith('/api/ha/'):
            # HA POST 프록시
            cfg      = load_config()
            ha_url   = get_ha_url(cfg)
            ha_token = cfg.get('haToken', '')
            if not (ha_url and ha_token):
                self._send_json({"error": "HA 미설정"}, 500)
                return
            ha_path = parsed.path[7:]   # /api/ha/api/states -> /api/states
            body = json.loads(raw.decode('utf-8')) if raw else None
            try:
                data = _ha_req(ha_url, ha_token, ha_path, "POST", body)
                self._send_json(data)
            except Exception as e:
                self._send_json({"error": str(e)}, 502)

        else:
            self.send_response(404)
            self.end_headers()

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.end_headers()

    def log_message(self, format, *args):
        try:
            msg = str(args[0]) if args else ''
            if '/api/' in msg:
                pass
            elif '.html' in msg:
                parts = msg.split()
                path  = parts[1] if len(parts) > 1 else ''
                print(f"[접속] {self.address_string()} → {path}")
        except:
            pass

def get_local_ip():
    import socket
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except:
        return 'localhost'

def get_public_ip():
    try:
        return urllib.request.urlopen('https://api.ipify.org', timeout=3).read().decode()
    except:
        return None

if __name__ == '__main__':
    threading.Thread(target=scheduler_loop, daemon=True).start()

    ip     = get_local_ip()
    pub_ip = get_public_ip()
    ps     = f":{PORT}" if PORT != 80 else ""

    print("=" * 55)
    print("  땡구 스마트홈 서버 v2")
    print("=" * 55)
    print(f"  PC:     http://localhost{ps}/smart-home-ha.html")
    print(f"  로컬:   http://{ip}{ps}/smart-home-ha.html")
    if pub_ip:
        print(f"  외부:   http://{pub_ip}{ps}/smart-home-ha.html")
    print(f"  도메인: http://ddanggu.duckdns.org{ps}/smart-home-ha.html")
    print(f"  설정:   {os.path.join(SCRIPT_DIR, CONFIG_FILE)}")
    print(f"  타이머: {os.path.join(SCRIPT_DIR, SCHEDULES_FILE)}")
    print(f"  상태:   http://localhost{ps}/api/status  ← 디버그")
    print("=" * 55)
    print("  예약기 실행 중 — 브라우저 종료 후에도 예약 유지")
    print("  종료: Ctrl+C")
    print()

    with ThreadingTCPServer(("", PORT), Handler) as httpd:
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\n서버 종료됨")
