#!/usr/bin/env python3
"""Data collector for the JARVIS morning briefing.

The cron job runs this script first and injects stdout into an LLM prompt.
The script intentionally returns compact JSON context, not polished prose.
"""
from __future__ import annotations

import json
import os
import re
import sqlite3
import subprocess
import sys
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo

TZ = ZoneInfo("Europe/Zurich")
HOME = Path.home()
HEALTH_DB = HOME / ".hermes/assets/Gesundheit/health_data.db"
TODOIST_TOKEN = HOME / ".todoist_token"
GOG_ACCOUNT = "friday.uplink@gmail.com"
GOG_SECRET_ENV = HOME / ".hermes/secrets/gog_keyring.env"
WINDISCH = {"name": "Windisch AG", "lat": 47.4784, "lon": 8.2183}
STEIN_AG = {"name": "Stein AG", "lat": 47.5446, "lon": 7.9529}


def now_local() -> datetime:
    return datetime.now(TZ)


def load_gog_keyring_password() -> str:
    value = os.environ.get("GOG_KEYRING_PASSWORD", "").strip()
    if value:
        return value
    if not GOG_SECRET_ENV.is_file():
        raise RuntimeError(f"Missing gog keyring secret environment file: {GOG_SECRET_ENV}")
    if GOG_SECRET_ENV.stat().st_mode & 0o077:
        raise RuntimeError(f"Unsafe permissions on gog keyring secret file: {GOG_SECRET_ENV}")
    for raw_line in GOG_SECRET_ENV.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[7:].strip()
        key, sep, raw_value = line.partition("=")
        if sep and key.strip() == "GOG_KEYRING_PASSWORD":
            secret = raw_value.strip().strip('"').strip("'")
            if secret:
                return secret
    raise RuntimeError(f"GOG_KEYRING_PASSWORD missing or empty in {GOG_SECRET_ENV}")


def http_json(url: str, timeout: int = 20, headers: dict | None = None):
    req = urllib.request.Request(url, headers=headers or {"User-Agent": "JARVIS-MorningBriefing/1.0"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8", errors="replace"))


def run(cmd: list[str], timeout: int = 45, env: dict | None = None) -> tuple[int, str, str]:
    try:
        p = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, env=env)
        return p.returncode, p.stdout, p.stderr
    except Exception as e:
        return 999, "", str(e)


def weather():
    params = urllib.parse.urlencode({
        "latitude": WINDISCH["lat"],
        "longitude": WINDISCH["lon"],
        "current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,precipitation",
        "hourly": "temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m",
        "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunrise,sunset",
        "timezone": "Europe/Zurich",
        "forecast_days": 1,
    })
    codes = {
        0: "sonnig", 1: "meist sonnig", 2: "teils bewölkt", 3: "bewölkt",
        45: "Nebel", 48: "Reifnebel", 51: "leichter Nieselregen", 53: "Nieselregen",
        55: "starker Nieselregen", 61: "leichter Regen", 63: "Regen", 65: "starker Regen",
        71: "leichter Schnee", 73: "Schnee", 75: "starker Schnee", 80: "leichte Schauer",
        81: "Schauer", 82: "starke Schauer", 95: "Gewitter", 96: "Gewitter/Hagel", 99: "starkes Gewitter/Hagel",
    }
    try:
        d = http_json("https://api.open-meteo.com/v1/forecast?" + params)
        cur = d.get("current", {})
        daily = d.get("daily", {})
        hourly = d.get("hourly", {})
        # morning commute window 06-09 and day window 06-22
        times = hourly.get("time", [])
        commute = []
        day = []
        today = now_local().date().isoformat()
        for i, t in enumerate(times):
            if not str(t).startswith(today):
                continue
            hour = int(str(t)[11:13])
            row = {
                "time": str(t)[11:16],
                "precip_prob": (hourly.get("precipitation_probability") or [None])[i],
                "precip_mm": (hourly.get("precipitation") or [None])[i],
                "wind": (hourly.get("wind_speed_10m") or [None])[i],
                "condition": codes.get((hourly.get("weather_code") or [None])[i], ""),
            }
            if 6 <= hour <= 9:
                commute.append(row)
            if 6 <= hour <= 22:
                day.append(row)
        max_rain_prob = max([x.get("precip_prob") or 0 for x in day] or [0])
        return {
            "location": WINDISCH["name"],
            "condition": codes.get(cur.get("weather_code"), "unbekannt"),
            "temperature_c": cur.get("temperature_2m"),
            "feels_like_c": cur.get("apparent_temperature"),
            "humidity_pct": cur.get("relative_humidity_2m"),
            "wind_kmh": cur.get("wind_speed_10m"),
            "today_min_c": (daily.get("temperature_2m_min") or [None])[0],
            "today_max_c": (daily.get("temperature_2m_max") or [None])[0],
            "precip_sum_mm": (daily.get("precipitation_sum") or [None])[0],
            "max_precip_probability_pct": max_rain_prob,
            "commute_window": commute,
            "sunrise": (daily.get("sunrise") or [""])[0][-5:],
            "sunset": (daily.get("sunset") or [""])[0][-5:],
        }
    except Exception as e:
        return {"error": str(e)}


def parse_dt(x: str | None):
    if not x:
        return None
    if x.endswith("Z"):
        x = x[:-1] + "+00:00"
    try:
        return datetime.fromisoformat(x).astimezone(TZ)
    except Exception:
        return None


def calendar_events(days: int = 2):
    env = os.environ.copy()
    try:
        env["GOG_KEYRING_PASSWORD"] = load_gog_keyring_password()
    except RuntimeError as exc:
        return {"error": str(exc), "today": [], "tomorrow": []}
    rc, out, err = run([
        "gog", "-a", GOG_ACCOUNT, "calendar", "events", "--all", "--days", str(days), "--max", "30", "--json", "--no-input"
    ], timeout=60, env=env)
    if rc != 0 or not out.strip():
        return {"error": err.strip() or f"gog exit {rc}", "today": [], "tomorrow": []}
    try:
        raw = json.loads(out)
    except Exception as e:
        return {"error": f"calendar json parse: {e}", "today": [], "tomorrow": []}
    evs = raw.get("events", raw if isinstance(raw, list) else [])
    today = now_local().date()
    tomorrow = today + timedelta(days=1)
    result = {"today": [], "tomorrow": []}
    for ev in evs:
        start_raw = (ev.get("start") or {}).get("dateTime") or (ev.get("start") or {}).get("date")
        end_raw = (ev.get("end") or {}).get("dateTime") or (ev.get("end") or {}).get("date")
        all_day = "dateTime" not in (ev.get("start") or {})
        start = parse_dt(start_raw) if not all_day else None
        if all_day:
            try:
                d = datetime.fromisoformat(start_raw).date()
            except Exception:
                d = today
            time_s = "ganztägig"
        else:
            d = start.date() if start else today
            time_s = start.strftime("%H:%M") if start else str(start_raw)
        item = {
            "time": time_s,
            "summary": ev.get("summary", "(ohne Titel)"),
            "location": ev.get("location", ""),
            "description": (ev.get("description") or "")[:300],
            "all_day": all_day,
            "start_iso": start.isoformat() if start else start_raw,
            "end_iso": parse_dt(end_raw).isoformat() if parse_dt(end_raw) else end_raw,
        }
        if d == today:
            result["today"].append(item)
        elif d == tomorrow:
            result["tomorrow"].append(item)
    for k in result:
        result[k].sort(key=lambda x: x.get("time", "99:99"))
    return result


def todoist():
    if not TODOIST_TOKEN.exists():
        return {"error": "Todoist token fehlt"}
    token = TODOIST_TOKEN.read_text().strip()
    req = urllib.request.Request("https://api.todoist.com/api/v1/tasks", headers={"Authorization": f"Bearer {token}"})
    try:
        with urllib.request.urlopen(req, timeout=25) as r:
            d = json.loads(r.read().decode())
    except Exception as e:
        return {"error": str(e)}
    tasks = d.get("results", d if isinstance(d, list) else [])
    today = now_local().date()
    tomorrow = today + timedelta(days=1)
    buckets = {"overdue": [], "today": [], "tomorrow": [], "next_7_days": [], "no_due_top": []}
    for t in tasks:
        if t.get("checked"):
            continue
        due = (t.get("due") or {}).get("date")
        item = {"content": t.get("content", ""), "priority": t.get("priority"), "due": due, "project_id": t.get("project_id")}
        if due:
            try:
                dd = datetime.fromisoformat(due[:10]).date()
            except Exception:
                dd = None
            if dd and dd < today:
                buckets["overdue"].append(item)
            elif dd == today:
                buckets["today"].append(item)
            elif dd == tomorrow:
                buckets["tomorrow"].append(item)
            elif dd and today < dd <= today + timedelta(days=7):
                buckets["next_7_days"].append(item)
        else:
            buckets["no_due_top"].append(item)
    for k in buckets:
        buckets[k].sort(key=lambda x: (x.get("due") or "9999", x.get("priority") or 9))
        buckets[k] = buckets[k][:8 if k in ("overdue", "today") else 5]
    buckets["total_open"] = len([t for t in tasks if not t.get("checked")])
    return buckets


def geocode(place: str):
    q = urllib.parse.urlencode({"q": place, "format": "json", "limit": 1, "countrycodes": "ch"})
    try:
        arr = http_json("https://nominatim.openstreetmap.org/search?" + q, timeout=15)
        if arr:
            return {"name": arr[0].get("display_name"), "lat": float(arr[0]["lat"]), "lon": float(arr[0]["lon"])}
    except Exception:
        pass
    return None


def osrm_route(origin, dest):
    try:
        url = f"https://router.project-osrm.org/route/v1/driving/{origin['lon']},{origin['lat']};{dest['lon']},{dest['lat']}?overview=false&alternatives=false&steps=false"
        d = http_json(url, timeout=20)
        route = (d.get("routes") or [{}])[0]
        return {"distance_km": round(route.get("distance", 0) / 1000, 1), "duration_min_no_traffic": round(route.get("duration", 0) / 60)}
    except Exception as e:
        return {"error": str(e)}


def choose_destination(events):
    # First non-homeoffice event with a useful location before noon; otherwise workplace Stein AG.
    for ev in events.get("today", []):
        loc = (ev.get("location") or "").strip()
        summary = (ev.get("summary") or "").lower()
        if not loc:
            continue
        bad = ["homeoffice", "teams", "zoom", "online", "telefon", "windisch"]
        if any(x in (loc + " " + summary).lower() for x in bad):
            continue
        if ev.get("time", "99:99") <= "12:00":
            g = geocode(loc)
            if g:
                g["reason"] = f"erster externer Termin: {ev.get('summary')}"
                return g
    d = dict(STEIN_AG)
    d["reason"] = "Standard Arbeitsweg"
    return d


def rss_items(url: str, max_items: int = 4):
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "JARVIS-MorningBriefing/1.0"})
        with urllib.request.urlopen(req, timeout=20) as r:
            xml = r.read()
        root = ET.fromstring(xml)
        items = []
        for item in root.findall(".//item")[:max_items]:
            title = (item.findtext("title") or "").strip()
            link = (item.findtext("link") or "").strip()
            pub = (item.findtext("pubDate") or "").strip()
            if title:
                items.append({"title": re.sub(r"\s+", " ", title), "link": link, "published": pub})
        return items
    except Exception:
        return []


def news():
    def google_news(query: str, n: int = 4):
        q = urllib.parse.quote_plus(query)
        return rss_items(f"https://news.google.com/rss/search?q={q}&hl=de-CH&gl=CH&ceid=CH:de", n)
    return {
        "local_brugg_windisch": google_news("Brugg Windisch Aargau heute", 4),
        "switzerland": rss_items("https://www.srf.ch/news/bnf/rss/1646", 5) or google_news("Schweiz Nachrichten heute", 4),
        "world": rss_items("https://www.srf.ch/news/bnf/rss/1003", 5) or google_news("Welt Nachrichten heute", 4),
        "ai": google_news("Künstliche Intelligenz AI News heute", 4),
        "crypto": google_news("Bitcoin Ethereum Krypto News heute", 4),
        "traffic_incidents": google_news("Stau Aargau A1 A3 Brugg Baden Frick heute", 5),
    }


def health_context():
    out = {"recent_symptoms": [], "open_lab_reviews": None}
    if not HEALTH_DB.exists():
        return out
    try:
        con = sqlite3.connect(HEALTH_DB)
        con.row_factory = sqlite3.Row
        since = (now_local().date() - timedelta(days=7)).isoformat()
        try:
            out["recent_symptoms"] = [dict(r) for r in con.execute(
                "SELECT datum, symptom, koerperbereich, intensitaet, notizen FROM symptom_log WHERE datum>=? ORDER BY datum DESC, id DESC LIMIT 6", (since,)
            )]
        except Exception:
            pass
        try:
            out["open_lab_reviews"] = con.execute("SELECT count(*) FROM laborwerte_staging WHERE status IN ('zur_pruefung','extrahiert')").fetchone()[0]
        except Exception:
            pass
        con.close()
    except Exception as e:
        out["error"] = str(e)
    return out


def main():
    t0 = now_local()
    cal = calendar_events(days=2)
    dest = choose_destination(cal)
    route = osrm_route(WINDISCH, dest)
    payload = {
        "generated_at": t0.isoformat(),
        "date": t0.date().isoformat(),
        "weekday_de": ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"][t0.weekday()],
        "weather_windisch": weather(),
        "calendar": cal,
        "todoist": todoist(),
        "commute": {
            "origin": WINDISCH,
            "destination": dest,
            "route": route,
            "note": "OSRM-Fahrzeit ohne Live-Verkehr; Stau-/Unfallhinweise werden aus News/Traffic-Meldungen abgeleitet.",
            "google_maps_url": "https://www.google.com/maps/dir/" + urllib.parse.quote_plus(WINDISCH["name"]) + "/" + urllib.parse.quote_plus(dest.get("name") or dest.get("reason") or "Stein AG"),
        },
        "health": health_context(),
        "news": news(),
        "briefing_requirements": [
            "Strukturiere als kompaktes deutsches Morgen-Briefing für Telegram.",
            "Berücksichtige Termine, Todoist-Pendenzen, Gesprächs-Follow-ups, Chancen/Risiken, Wetter, Arbeitsweg/Verkehr, News, KI/Crypto und proaktive Tipps.",
            "Wenn Datenquelle fehlt: kurz transparent markieren, nicht halluzinieren.",
        ],
    }
    print(json.dumps(payload, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
