#!/usr/bin/env python3
"""
WashOS — Miele Move Poller v3
=================================
Hält einen Playwright-Browser offen und pollt die API direkt im Browser.
Die Session bleibt aktiv weil der Browser nie geschlossen wird.

Architektur:
  - Playwright Chromium bleibt im Hintergrund offen
  - API-Calls über page.evaluate(fetch(...)) — nutzt die Browser-Session
  - Kein Cookie-Export nötig — die Session lebt im Browser
  - Bei Session-Ablauf: Seite neu laden → automatischer Re-Login
"""

import json, os, time, threading, re
from datetime import datetime, timezone, timedelta

# ── KONFIGURATION ─────────────────────────────────────────────────
# Defaults — werden bei Bedarf aus data/settings.json überschrieben.
MIELE_EMAIL    = ""
MIELE_PASS     = ""
MIELE_BASE     = "https://www.miele-move.com"
POLL_INTERVAL  = 60


def load_credentials_from_settings(output_dir):
    """Lädt Miele-Credentials aus settings.json.
    Gibt True zurück wenn Credentials verfügbar sind, sonst False."""
    global MIELE_EMAIL, MIELE_PASS
    settings_file = os.path.join(output_dir, "settings.json")
    if not os.path.exists(settings_file):
        return bool(MIELE_EMAIL and MIELE_PASS)
    try:
        with open(settings_file, encoding="utf-8") as f:
            data = json.load(f)
        creds = (data.get("miele") or {})
        if creds.get("email"):
            MIELE_EMAIL = creds["email"]
        if creds.get("password"):
            MIELE_PASS = creds["password"]
    except Exception:
        pass
    return bool(MIELE_EMAIL and MIELE_PASS)

DEVICE_MAP = {
    "000178029100": "A", "000178029103": "B", "000178037973": "C",
    "000161261920": "D", "000161261922": "E", "000161261921": "F",
}


class MielePoller:
    def __init__(self, output_dir="."):
        self.output_dir = output_dir
        self.output_file = os.path.join(output_dir, "miele.json")
        self.water_log_file = os.path.join(output_dir, "water_log.json")
        self.logged_in = False
        self.browser = None
        self.context = None
        self.page = None
        self.pw = None
        # Zyklus-Tracker: pro Maschine Status + Max-Water-Volumen
        # { "A": {"status": "running", "program": "...", "started": "...", "max_water": 45.2, "max_temp": 40, "spins": 1200, "phases": ["main","rinse",...]} }
        self.cycle_state = {}
        self._load_water_log()

    def _load_water_log(self):
        """Lade bisheriges water_log (falls vorhanden) in self.cycle_state."""
        try:
            if os.path.exists(self.water_log_file):
                with open(self.water_log_file, encoding="utf-8") as f:
                    wl = json.load(f)
                # cycle_state nicht aus Datei laden - das ist Laufzeit-Status
                self.water_log = wl
            else:
                self.water_log = {"cycles": [], "meta": {}}
        except Exception:
            self.water_log = {"cycles": [], "meta": {}}

    def _save_water_log(self):
        """water_log.json atomar schreiben, alte Einträge (>90 Tage) filtern."""
        try:
            # Alte Zyklen rausfiltern
            cutoff = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
            self.water_log["cycles"] = [c for c in self.water_log.get("cycles", []) if c.get("ended_at", "") >= cutoff]

            # Aggregate für schnellen Dashboard-Access
            agg = self._aggregate_water()
            self.water_log["meta"] = {
                "last_update": datetime.now(timezone.utc).isoformat(),
                "cycle_count": len(self.water_log["cycles"]),
            }
            self.water_log["aggregates"] = agg

            tmp = self.water_log_file + ".tmp"
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(self.water_log, f, ensure_ascii=False, indent=2)
            try:
                os.replace(tmp, self.water_log_file)
            except OSError:
                import shutil
                shutil.move(tmp, self.water_log_file)
        except Exception as e:
            print(f"  [Water] Log-Fehler: {e}")

    def _aggregate_water(self):
        """Pro Maschine, pro Programm: Ø Liter, Zyklen, Summe."""
        cycles = self.water_log.get("cycles", [])
        now = datetime.now(timezone.utc)

        # Pro Maschine (letzte 90 Tage total)
        by_machine = {}
        # Pro Monat (aktueller + letzter)
        by_month_total = {}
        by_month_machine = {}

        for c in cycles:
            m = c.get("machine", "?")
            prog = c.get("program", "?")
            liters = c.get("water_liters", 0) or 0
            ts = c.get("ended_at", "")
            month = ts[:7] if ts else ""

            if m not in by_machine:
                by_machine[m] = {"machine": m, "cycles": 0, "total_liters": 0.0, "programs": {}}
            by_machine[m]["cycles"] += 1
            by_machine[m]["total_liters"] = round(by_machine[m]["total_liters"] + liters, 2)

            if prog not in by_machine[m]["programs"]:
                by_machine[m]["programs"][prog] = {"cycles": 0, "total_l": 0.0}
            by_machine[m]["programs"][prog]["cycles"] += 1
            by_machine[m]["programs"][prog]["total_l"] = round(by_machine[m]["programs"][prog]["total_l"] + liters, 2)

            if month:
                by_month_total[month] = round(by_month_total.get(month, 0) + liters, 2)
                key = month + "|" + m
                by_month_machine[key] = round(by_month_machine.get(key, 0) + liters, 2)

        # Durchschnitte je Maschine
        machines = []
        for m, d in sorted(by_machine.items()):
            avg_l = d["total_liters"] / d["cycles"] if d["cycles"] else 0
            progs_out = []
            for pname, pd in d["programs"].items():
                progs_out.append({
                    "program": pname,
                    "cycles": pd["cycles"],
                    "avg_l": round(pd["total_l"] / pd["cycles"], 2) if pd["cycles"] else 0,
                    "total_l": pd["total_l"],
                })
            progs_out.sort(key=lambda x: x["cycles"], reverse=True)
            machines.append({
                "machine": m,
                "cycles": d["cycles"],
                "total_liters": d["total_liters"],
                "avg_liters_per_cycle": round(avg_l, 2),
                "by_program": progs_out,
            })

        return {
            "by_machine": machines,
            "by_month_total": by_month_total,
            "by_month_machine": by_month_machine,
        }

    def _handle_cycle_tracking(self, dev):
        """Wasser- und Programm-Tracking pro Maschine. Erkennt Zyklus-Ende."""
        label = dev.get("label", "?")
        status = dev.get("status", "unknown")
        water = dev.get("water_volume")
        temp = dev.get("temp_current")
        spin = dev.get("spinning_current") or dev.get("spinning_target")
        program = dev.get("program_name") or ""
        phase = dev.get("program_phase") or ""
        now_iso = datetime.now(timezone.utc).isoformat()

        prev = self.cycle_state.get(label)

        # Zustands-Maschine:
        # Maschine ist running → State aktualisieren, water-max tracken
        # Maschine war running und ist nicht mehr running → Zyklus beenden, water_log schreiben
        if status == "running":
            if not prev or prev.get("status") != "running":
                # Neuer Zyklus beginnt
                self.cycle_state[label] = {
                    "status": "running",
                    "program": program,
                    "started_at": now_iso,
                    "max_water": water or 0,
                    "max_temp": temp or 0,
                    "max_spin": spin or 0,
                    "phases_seen": {phase: now_iso} if phase else {},
                    "last_water": water or 0,
                    "last_phase": phase,
                }
            else:
                # Zyklus läuft weiter → Max-Werte aktualisieren
                s = self.cycle_state[label]
                if water is not None and water > (s.get("max_water") or 0):
                    s["max_water"] = water
                if temp is not None and temp > (s.get("max_temp") or 0):
                    s["max_temp"] = temp
                if spin is not None and spin > (s.get("max_spin") or 0):
                    s["max_spin"] = spin
                if phase:
                    if phase not in s["phases_seen"]:
                        s["phases_seen"][phase] = now_iso
                    s["last_phase"] = phase
                s["last_water"] = water if water is not None else s.get("last_water", 0)
        else:
            # Maschine nicht mehr running
            if prev and prev.get("status") == "running":
                # Zyklus-Ende erkannt
                cycle = {
                    "machine": label,
                    "program": prev.get("program", "?"),
                    "started_at": prev.get("started_at"),
                    "ended_at": now_iso,
                    "water_liters": round(prev.get("max_water") or 0, 2),
                    "max_temp": prev.get("max_temp"),
                    "max_spin": prev.get("max_spin"),
                    "phases": list(prev.get("phases_seen", {}).keys()),
                }
                # Sanity: nur Zyklen mit Wasser > 0 loggen (Trockner/Abbruch filtern)
                if cycle["water_liters"] > 0:
                    self.water_log.setdefault("cycles", []).append(cycle)
                    print(f"  [Water] {label} Zyklus beendet: {cycle['water_liters']}l · {cycle['program']}")
                # Reset
                self.cycle_state[label] = {
                    "status": status,
                    "last_water": 0,
                }
            else:
                self.cycle_state[label] = {"status": status, "last_water": 0}

    def _start_browser(self):
        """Starte Playwright und öffne Browser."""
        from playwright.sync_api import sync_playwright
        self.pw = sync_playwright().start()
        self.browser = self.pw.chromium.launch(headless=True)
        self.context = self.browser.new_context(
            viewport={"width": 1280, "height": 900},
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                       "AppleWebKit/537.36 (KHTML, like Gecko) "
                       "Chrome/124.0.0.0 Safari/537.36",
        )
        self.page = self.context.new_page()
        self.page.set_default_timeout(60000)
        self.page.set_default_navigation_timeout(90000)

    def login(self) -> bool:
        """Login bei Miele Move über den Browser."""
        try:
            if not self.browser:
                self._start_browser()

            print("  [Miele] Navigiere zu /dashboard...")
            self.page.goto(
                MIELE_BASE + "/dashboard",
                wait_until="domcontentloaded",
            )
            self.page.wait_for_timeout(15000)

            url = self.page.url
            print(f"  [Miele] URL: {url[:80]}")

            if "/dashboard" in url and "login" not in url:
                print("  [Miele] Bereits eingeloggt!")
                self.logged_in = True
                return True

            # Cookie-Banner wegklicken
            print("  [Miele] Cookie-Banner suchen...")
            for sel in [
                "button:has-text('Accept all')",
                "button:has-text('Alle akzeptieren')",
                "button:has-text('Akzeptieren')",
                "button:has-text('OK')",
            ]:
                btn = self.page.query_selector(sel)
                if btn and btn.is_visible():
                    txt = btn.text_content().strip()
                    print(f"  [Miele] Cookie: '{txt}' klicken...")
                    btn.click(force=True)
                    self.page.wait_for_timeout(2000)
                    break

            # Modals per JS entfernen (Fallback)
            self.page.evaluate("""
                document.querySelectorAll(
                    'ngb-modal-window, .modal-backdrop'
                ).forEach(el => el.remove());
            """)
            self.page.wait_for_timeout(1000)

            # Login-Formular ausfüllen
            print("  [Miele] Login-Formular...")
            email_el = self.page.query_selector(
                "input[type='email'], input[name='username'], "
                "input[id*='email'], input[type='text'][name*='mail']"
            )
            if email_el:
                email_el.fill(MIELE_EMAIL)
                self.page.wait_for_timeout(500)

            pass_el = self.page.query_selector("input[type='password']")
            if pass_el:
                pass_el.fill(MIELE_PASS)
                self.page.wait_for_timeout(500)

            submit_el = self.page.query_selector(
                "button[type='submit'], input[type='submit'], "
                "button:has-text('Login'), button:has-text('Anmelden'), "
                "button:has-text('Sign in'), button.gigya-input-submit"
            )
            if submit_el:
                submit_el.click()
            elif pass_el:
                pass_el.press("Enter")

            print("  [Miele] Warte auf Login...")
            self.page.wait_for_timeout(15000)

            url = self.page.url
            print(f"  [Miele] URL: {url[:80]}")

            if "dashboard" in url or "appliances" in url:
                # API-Test
                test = self._api_call("/appliances/api/devices")
                if test and len(test) > 0:
                    names = [d.get("name", "?") for d in test]
                    print(f"  [Miele] Login OK! {names}")
                    self.logged_in = True
                    return True

            print("  [Miele] Login fehlgeschlagen")
            return False

        except Exception as e:
            print(f"  [Miele] Login-Fehler: {e}")
            return False

    def _api_call(self, path):
        """API-Call direkt im Browser via fetch()."""
        if not self.page:
            return None
        try:
            result = self.page.evaluate("""
                (path) => fetch(path, {credentials: 'include'})
                    .then(r => {
                        if (!r.ok) return {_error: r.status};
                        return r.json();
                    })
                    .catch(e => ({_error: e.message}))
            """, path)

            if isinstance(result, dict) and "_error" in result:
                err = result["_error"]
                if err in (401, 302, 403):
                    self.logged_in = False
                return None
            return result
        except Exception as e:
            print(f"  [Miele] API-Fehler: {e}")
            return None

    def poll_once(self) -> dict:
        """Einmal alle Miele-Daten abfragen."""
        if not self.logged_in:
            return {"error": "not_logged_in"}

        # Keepalive
        try:
            self.page.evaluate("""
                fetch('/alive', {method:'POST', credentials:'include'})
                    .catch(()=>{})
            """)
        except:
            pass

        devices_raw = self._api_call("/appliances/api/devices")
        if not devices_raw:
            return {"error": "no_data"}

        devices = []
        for d in devices_raw:
            fab_nr = d.get("fabNr", "")
            prog = d.get("program") or {}
            washing = d.get("washing") or {}

            remaining_min = None
            rt = prog.get("remainingTime", "")
            if rt:
                m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?", rt)
                if m:
                    remaining_min = (
                        int(m.group(1) or 0) * 60
                        + int(m.group(2) or 0)
                    )

            op_hours = None
            ot = d.get("operatingTime", "")
            if ot:
                m = re.match(r"PT(\d+)H(?:(\d+)M)?", ot)
                if m:
                    op_hours = round(
                        int(m.group(1))
                        + int(m.group(2) or 0) / 60, 1
                    )

            devices.append({
                "fab_nr": fab_nr,
                "name": d.get("name", "?"),
                "label": DEVICE_MAP.get(fab_nr, "?"),
                "tech_type": d.get("techType", "?"),
                "status": d.get("status", "unknown"),
                "program_name": prog.get("name"),
                "program_phase": prog.get("phaseName"),
                "remaining_min": remaining_min,
                "temp_current": washing.get("temperatureCurrent"),
                "spinning_current": washing.get(
                    "spinningSpeedCurrent"
                ),
                "spinning_target": washing.get(
                    "spinningSpeedTarget"
                ),
                "water_volume": washing.get("waterVolume"),
                "load_weight": washing.get("loadWeight"),
                "max_weight": washing.get("maxWeight"),
                "operating_hours": op_hours,
                "synced_at": d.get("syncedAt"),
            })

        # Wasser-Zyklus-Tracking pro Maschine
        for dev in devices:
            self._handle_cycle_tracking(dev)
        # water_log.json einmal pro Poll speichern
        self._save_water_log()

        # Debug: Live-Water für Dashboard
        live_water = {}
        for lbl, state in self.cycle_state.items():
            if state.get("status") == "running":
                live_water[lbl] = {
                    "current_water": state.get("last_water"),
                    "max_water": state.get("max_water"),
                    "max_temp": state.get("max_temp"),
                    "program": state.get("program"),
                    "started_at": state.get("started_at"),
                    "phases_seen": list(state.get("phases_seen", {}).keys()),
                    "last_phase": state.get("last_phase"),
                }

        return {
            "meta": {
                "last_update": datetime.now(
                    timezone.utc
                ).isoformat(),
                "device_count": len(devices),
                "source": "miele_move",
            },
            "devices": devices,
            "alerts": self._fetch_alerts(),
            "program_stats": self._fetch_program_stats(),
            "live_water_cycles": live_water,
        }

    def _fetch_alerts(self):
        """Letzte 5 abgebrochene/fehlgeschlagene Programme."""
        try:
            fabs = list(DEVICE_MAP.keys())
            result = self.page.evaluate("""
                (fabs) => {
                    var promises = fabs.map(fab =>
                        fetch('/appliances/api/devices/program/'
                            + 'executions?fabNr=' + fab
                            + '&size=3&page=0&status=cancelled',
                            {credentials: 'include'})
                        .then(r => r.ok ? r.json() : {items:[]})
                        .catch(() => ({items: []}))
                    );
                    return Promise.all(promises).then(results => {
                        var all = [];
                        results.forEach(r => {
                            (r.items || []).forEach(item => {
                                all.push({
                                    machine: item.device
                                        ? item.device.name : '?',
                                    label: item.device
                                        ? item.device.techType : '?',
                                    status: item.status,
                                    program: item.program
                                        ? item.program.name : '?',
                                    started: item.startedAt,
                                    stopped: item.stoppedAt,
                                    duration: item.duration,
                                });
                            });
                        });
                        all.sort((a, b) =>
                            b.started.localeCompare(a.started));
                        return all.slice(0, 5);
                    });
                }
            """, fabs)
            return result or []
        except:
            return []

    def _fetch_program_stats(self):
        """Programmrangliste über alle Maschinen aggregiert."""
        try:
            fabs = list(DEVICE_MAP.keys())
            result = self.page.evaluate("""
                (fabs) => {
                    var promises = fabs.map(fab =>
                        fetch('/appliances/api/devices/program/'
                            + 'executions/status/aggregation'
                            + '?fabNr=' + fab,
                            {credentials: 'include'})
                        .then(r => r.ok ? r.json() : [])
                        .catch(() => [])
                    );
                    return Promise.all(promises).then(results => {
                        var agg = {};
                        results.forEach(progs => {
                            (progs || []).forEach(p => {
                                var name = p.program
                                    ? p.program.name : '?';
                                var st = p.status || {};
                                var total = (st.completed || 0)
                                    + (st.cancelled || 0)
                                    + (st.failure || 0)
                                    + (st.unknown || 0);
                                if (!agg[name]) {
                                    agg[name] = {
                                        name: name,
                                        total: 0,
                                        ok: 0,
                                        cancelled: 0,
                                        failure: 0
                                    };
                                }
                                agg[name].total += total;
                                agg[name].ok += st.completed || 0;
                                agg[name].cancelled +=
                                    st.cancelled || 0;
                                agg[name].failure +=
                                    st.failure || 0;
                            });
                        });
                        var list = Object.values(agg);
                        list.sort((a, b) => b.total - a.total);
                        return list.slice(0, 15);
                    });
                }
            """, fabs)
            return result or []
        except:
            return []

    def write_output(self, data: dict):
        """Schreibe miele.json."""
        with open(self.output_file, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        ts = datetime.now().strftime("%H:%M:%S")
        running = [
            d for d in data.get("devices", [])
            if d["status"] == "running"
        ]
        if running:
            progs = ", ".join(
                d["label"] + ":" + str(d["program_name"])
                for d in running
            )
            print(f"[{ts}] MIELE | {len(running)} laufend ({progs})")
        else:
            total = len(data.get("devices", []))
            print(f"[{ts}] MIELE | Alle {total} Maschinen standby")

    def run_poller(self):
        """Endlos-Polling mit persistentem Browser."""
        print("  [Miele] Starte Browser...")
        try:
            from playwright.sync_api import sync_playwright
        except ImportError:
            print("  [Miele] playwright nicht installiert!")
            print("  pip install playwright")
            print("  python -m playwright install chromium")
            return

        if not self.login():
            print("  [Miele] Erster Login fehlgeschlagen.")
            print("  [Miele] Retry in 5 Min...")
            time.sleep(300)
            return self.run_poller()

        print(f"  [Miele] Polling gestartet (alle {POLL_INTERVAL}s)")
        fail_count = 0

        while True:
            try:
                data = self.poll_once()
                if "error" not in data:
                    self.write_output(data)
                    fail_count = 0
                else:
                    fail_count += 1
                    if fail_count >= 3:
                        print("  [Miele] 3x fehlgeschlagen, Re-Login...")
                        # Seite neu laden statt neuen Login
                        try:
                            self.page.goto(
                                MIELE_BASE + "/dashboard",
                                wait_until="domcontentloaded",
                            )
                            self.page.wait_for_timeout(10000)
                            url = self.page.url
                            if "login" in url:
                                self.login()
                            else:
                                self.logged_in = True
                            fail_count = 0
                        except:
                            self.login()
                            fail_count = 0
            except Exception as e:
                print(f"  [Miele] Fehler: {e}")
                fail_count += 1
            time.sleep(POLL_INTERVAL)


def start_miele_thread(output_dir="."):
    """Startet den Miele-Poller als Daemon-Thread.
    Wenn keine Credentials vorhanden: gibt None zurück und meldet 
    dies in der Konsole — Thread wird nicht gestartet."""
    has_creds = load_credentials_from_settings(output_dir)
    if not has_creds:
        print("  [Miele] Keine Credentials in settings.json — Poller pausiert.")
        print("  [Miele] Bitte in den Einstellungen eintragen.")
        return None
    poller = MielePoller(output_dir=output_dir)
    t = threading.Thread(
        target=poller.run_poller,
        daemon=True,
        name="MielePoller",
    )
    t.start()
    return poller


def try_login_with_settings(output_dir="."):
    """Einmaliger Login-Test für /api/miele/test.
    Lädt Credentials aus settings.json und versucht Login — ohne Poll.
    Gibt (ok: bool, message: str, devices: int|None) zurück."""
    load_credentials_from_settings(output_dir)
    poller = MielePoller(output_dir=output_dir)
    try:
        ok = poller.login()
        if not ok:
            return False, "Login fehlgeschlagen — E-Mail/Passwort prüfen", None
        # Ein Poll um Gerätezahl zu bekommen
        try:
            data = poller.poll_once()
            n = len((data or {}).get("devices", []))
        except Exception:
            n = None
        return True, "Verbunden", n
    except Exception as e:
        return False, str(e), None
    finally:
        try:
            if poller.browser:
                poller.browser.close()
            if poller.pw:
                poller.pw.stop()
        except Exception:
            pass


if __name__ == "__main__":
    print("Miele Move Poller — Standalone-Test")
    poller = MielePoller()
    if poller.login():
        data = poller.poll_once()
        poller.write_output(data)
        print(json.dumps(data, indent=2, ensure_ascii=False)[:2000])
