#!/usr/bin/env python3
"""
aria-deadline-reconcile.py  (KAR-684)

Deterministic deadline surfacing. Polling -> Interrupts.

Once a day this reads the source-of-truth (Linear due-dates <=7d + optional
deadlines.yaml) and rewrites the `## This Week` block in HEARTBEAT.md WHOLESALE
between two markers. Idempotent, self-correcting. The script OWNS that block;
the agent only ever reads it.

No LLM involved. Date arithmetic can't hallucinate "already handled" the way an
LLM polling loop can -- for deadlines, boring + deterministic is the feature.

FAIL-SAFE: if the markers are missing/clobbered, the script ABORTS and pings
Telegram rather than overwriting the block with garbage. Backup + atomic write.

Env (loaded by systemd EnvironmentFile chain):
  LINEAR_API_KEY      - read Linear issues
  TELEGRAM_BOT_TOKEN  - failure ping
  TELEGRAM_CHAT_ID    - failure ping target
"""
import os
import sys
import json
import shutil
import tempfile
import datetime
import urllib.request
import urllib.error
from pathlib import Path

try:
    from zoneinfo import ZoneInfo
    TZ = ZoneInfo("Europe/Berlin")
except Exception:
    TZ = None

HEARTBEAT = Path("/root/aria/brain/HEARTBEAT.md")
DEADLINES_YAML = Path("/root/aria/state/deadlines.yaml")
MARKER_START = "<!-- DEADLINES:START -->"
MARKER_END = "<!-- DEADLINES:END -->"
HORIZON_DAYS = 7
BACKUP_KEEP = 5

LINEAR_URL = "https://api.linear.app/graphql"


def today():
    if TZ:
        return datetime.datetime.now(TZ).date()
    return datetime.date.today()


def log(msg):
    print(f"[deadline-reconcile] {msg}", flush=True)


def telegram_ping(text):
    """Best-effort failure alert. Never raises."""
    token = os.environ.get("TELEGRAM_BOT_TOKEN") or os.environ.get("TG_TOKEN")
    chat_id = os.environ.get("TELEGRAM_CHAT_ID")
    if not token or not chat_id:
        log("telegram_ping skipped: token/chat_id missing")
        return
    try:
        data = json.dumps({"chat_id": chat_id, "text": text}).encode()
        req = urllib.request.Request(
            f"https://api.telegram.org/bot{token}/sendMessage",
            data=data,
            headers={"Content-Type": "application/json"},
        )
        urllib.request.urlopen(req, timeout=15).read()
    except Exception as e:  # noqa: BLE001
        log(f"telegram_ping failed: {e}")


def fetch_linear(horizon_date):
    """Linear issues with dueDate <= horizon, not completed/canceled."""
    key = os.environ.get("LINEAR_API_KEY")
    if not key:
        log("LINEAR_API_KEY missing -> skipping Linear source")
        return []
    query = """
    query($due: TimelessDateOrDuration!) {
      issues(first: 50, filter: {
        dueDate: { lte: $due },
        state: { type: { nin: ["completed", "canceled"] } }
      }) {
        nodes { identifier title dueDate url state { name } }
      }
    }
    """
    body = json.dumps({"query": query, "variables": {"due": horizon_date.isoformat()}}).encode()
    req = urllib.request.Request(
        LINEAR_URL, data=body,
        headers={"Authorization": key, "Content-Type": "application/json"},
    )
    try:
        resp = urllib.request.urlopen(req, timeout=30).read()
        payload = json.loads(resp)
    except (urllib.error.URLError, TimeoutError) as e:  # noqa: BLE001
        raise RuntimeError(f"Linear request failed: {e}")
    if "errors" in payload:
        raise RuntimeError(f"Linear GraphQL errors: {payload['errors']}")
    out = []
    for n in payload.get("data", {}).get("issues", {}).get("nodes", []):
        if not n.get("dueDate"):
            continue
        try:
            d = datetime.date.fromisoformat(n["dueDate"][:10])
        except ValueError:
            continue
        out.append({
            "date": d,
            "title": n["title"].strip(),
            "ref": n["identifier"],
            "url": n.get("url", ""),
        })
    return out


def parse_deadlines_yaml():
    """Optional non-Linear deadlines. Tries PyYAML, falls back to a minimal
    line parser. Accepted line form (no PyYAML needed):
        - 2026-06-10: Steuererklaerung abgeben
    """
    if not DEADLINES_YAML.exists():
        return []
    text = DEADLINES_YAML.read_text(encoding="utf-8")
    items = []
    try:
        import yaml  # type: ignore
        data = yaml.safe_load(text) or []
        for entry in data:
            if isinstance(entry, dict):
                for k, v in entry.items():
                    try:
                        d = datetime.date.fromisoformat(str(k)[:10])
                    except ValueError:
                        continue
                    items.append({"date": d, "title": str(v).strip(), "ref": "manuell", "url": ""})
        return items
    except ImportError:
        pass
    # fallback line parser
    for raw in text.splitlines():
        line = raw.strip().lstrip("-").strip()
        if not line or line.startswith("#") or ":" not in line:
            continue
        datepart, _, title = line.partition(":")
        try:
            d = datetime.date.fromisoformat(datepart.strip()[:10])
        except ValueError:
            continue
        items.append({"date": d, "title": title.strip(), "ref": "manuell", "url": ""})
    return items


def humanize(delta_days):
    if delta_days < 0:
        return f"UEBERFAELLIG ({abs(delta_days)}d)"
    if delta_days == 0:
        return "HEUTE"
    if delta_days == 1:
        return "MORGEN"
    return f"in {delta_days}d"


def render_block(items, ref_today):
    lines = [MARKER_START,
             "## This Week — Deadlines (auto, Cron-owned via aria-deadline-reconcile)",
             f"> Stand: {ref_today.isoformat()} · Horizont {HORIZON_DAYS} Tage · Quelle: Linear due-dates + deadlines.yaml",
             ""]
    if not items:
        lines.append("- _Keine Deadlines in den nächsten 7 Tagen._")
    else:
        items.sort(key=lambda x: x["date"])
        for it in items:
            delta = (it["date"] - ref_today).days
            tag = humanize(delta)
            ref = f" ({it['ref']})" if it["ref"] and it["ref"] != "manuell" else ""
            lines.append(f"- **{it['date'].isoformat()}** · {tag} — {it['title']}{ref}")
    lines.append("")
    lines.append(MARKER_END)
    return "\n".join(lines)


def backup(path):
    ts = today().strftime("%Y%m%d") + "-" + datetime.datetime.now().strftime("%H%M%S")
    bak = path.with_suffix(path.suffix + f".bak.{ts}")
    shutil.copy2(path, bak)
    # prune old backups
    baks = sorted(path.parent.glob(path.name + ".bak.*"))
    for old in baks[:-BACKUP_KEEP]:
        try:
            old.unlink()
        except OSError:
            pass
    return bak


def atomic_replace_block(path, new_block):
    content = path.read_text(encoding="utf-8")
    si = content.find(MARKER_START)
    ei = content.find(MARKER_END)
    if si == -1 or ei == -1 or ei < si:
        raise RuntimeError(
            f"FAIL-SAFE: markers missing/inverted in {path} "
            f"(start={si}, end={ei}). Refusing to write."
        )
    new_content = content[:si] + new_block + content[ei + len(MARKER_END):]
    if new_content == content:
        log("no change (idempotent) — skipping write")
        return False
    backup(path)
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".heartbeat-", suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(new_content)
        os.replace(tmp, path)
    finally:
        if os.path.exists(tmp):
            os.unlink(tmp)
    return True


def main():
    if not HEARTBEAT.exists():
        log(f"HEARTBEAT not found: {HEARTBEAT}")
        telegram_ping(f"⚠️ Deadline-Reconcile: {HEARTBEAT} fehlt — kein Write.")
        return 1

    ref_today = today()
    horizon = ref_today + datetime.timedelta(days=HORIZON_DAYS)

    items = []
    try:
        items += fetch_linear(horizon)
    except RuntimeError as e:
        log(str(e))
        telegram_ping(f"⚠️ Deadline-Reconcile: Linear-Fetch fehlgeschlagen, behalte alten Block.\n{e}")
        return 1

    items += parse_deadlines_yaml()
    # keep only within horizon (Linear lte already does; yaml may have far dates)
    items = [it for it in items if it["date"] <= horizon]

    block = render_block(items, ref_today)
    try:
        changed = atomic_replace_block(HEARTBEAT, block)
    except RuntimeError as e:
        log(str(e))
        telegram_ping(
            "🛑 Deadline-Reconcile FAIL-SAFE: Marker in HEARTBEAT.md weg/kaputt. "
            "Block NICHT überschrieben. Bitte HEARTBEAT.md prüfen."
        )
        return 2

    log(f"done: {len(items)} deadline(s), changed={changed}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
