#!/usr/bin/python3
# calendar_match.py: name conversations from your calendar. OPTIONAL and off by default — only runs
# when the user connects Apple Calendar (main.swift reads events via EventKit and passes them here).
# Reading the calendar is a local operation; this module never makes a network call.
#
# Given the day's events and the organizer's diary.json, it proposes the overlapping meeting's title
# as the name for each conversation (highest time-overlap wins), and can tell the live recorder which
# meeting is happening right now (--at) so a capture can be named as it starts.
#
#   calendar_match.py --rec-dir DIR --events events.json     # -> naming proposals for the diary
#   calendar_match.py --rec-dir DIR --events events.json --apply  # -> write the proposed titles in
#   calendar_match.py --events events.json --at 2026-06-21T14:30:00   # -> the meeting active then
#
# events.json: [{"title","start","end","location","attendees":[...],"calendar"}], ISO8601 times.
# Pure stdlib.
import sys, os, json, re, datetime

HOME = os.path.expanduser("~")
DEFAULT_REC = os.path.join(HOME, "Recordings/Epiphani")


def load_diary(rec_dir):
    try:
        with open(os.path.join(rec_dir, "diary.json"), encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError):
        return None


def _dt(s):
    if not s:
        return None
    try:
        return datetime.datetime.fromisoformat(s.replace("Z", "+00:00")).replace(tzinfo=None)
    except (ValueError, AttributeError):
        return None


def _clip_dt(fname):
    m = re.match(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})", fname or "")
    return datetime.datetime(*[int(x) for x in m.groups()]) if m else None


def conv_window(conv):
    """[(start, end)] for a conversation from its clips' filenames + durations, or (None, None)."""
    starts, ends = [], []
    for c in conv.get("clips") or []:
        dt = _clip_dt(c.get("file", ""))
        if not dt:
            continue
        starts.append(dt)
        ends.append(dt + datetime.timedelta(minutes=(c.get("dur_min") or 0) or 1))
    return (min(starts), max(ends)) if starts else (None, None)


def _overlap_minutes(a0, a1, b0, b1):
    if not all([a0, a1, b0, b1]):
        return 0.0
    lo, hi = max(a0, b0), min(a1, b1)
    return max(0.0, (hi - lo).total_seconds() / 60.0)


def best_event(window, events):
    """The event with the most overlap with a (start, end) window, plus the overlap in minutes."""
    a0, a1 = window
    if not a0:
        return None, 0.0
    best, best_ov = None, 0.0
    for e in events:
        ov = _overlap_minutes(a0, a1, _dt(e.get("start")), _dt(e.get("end")))
        if ov > best_ov:
            best, best_ov = e, ov
    return best, best_ov


def active_event(events, at):
    """The event covering instant `at` (start <= at < end); the most specific (shortest) if several."""
    hits = []
    for e in events:
        s, en = _dt(e.get("start")), _dt(e.get("end"))
        if s and en and s <= at < en:
            hits.append((((en - s).total_seconds()), e))
    if not hits:
        return None
    hits.sort(key=lambda x: x[0])
    return hits[0][1]


def proposals(rec_dir, events):
    diary = load_diary(rec_dir)
    if diary is None:
        return {"ok": False, "error": "No diary to name yet."}
    out = []
    for day in diary.get("days", []):
        for conv in day.get("conversations", []):
            win = conv_window(conv)
            ev, ov = best_event(win, events)
            if not ev or ov < 1.0:
                continue
            dur = (win[1] - win[0]).total_seconds() / 60.0 if win[0] else 0
            conf = "high" if (dur and ov / dur >= 0.5) else "medium"
            cur = conv.get("title", "")
            proposed = (ev.get("title") or "").strip()
            if not proposed or proposed == cur:
                continue
            out.append({
                "when": win[0].strftime("%Y-%m-%d %H:%M") if win[0] else "",
                "current_title": cur, "proposed_title": proposed,
                "overlap_min": round(ov, 1), "confidence": conf,
                "with": ", ".join(ev.get("attendees", []) or []) or ev.get("location", ""),
            })
    return {"ok": True, "proposals": out, "count": len(out)}


def apply_names(rec_dir, events):
    """Write each accepted proposal's title into its clips' sidecars AND into diary.json, in place.

    The organizer skips clips that already have a sidecar, so the calendar title survives future
    sweeps; patching diary.json too makes the rename show immediately without re-organizing (no API
    call). A clip whose sidecar is missing or unreadable is skipped, not fatal. Idempotent: once
    applied, the conversation's title equals the event title, so proposals() no longer suggests it.
    """
    diary = load_diary(rec_dir)
    if diary is None:
        return {"ok": False, "error": "No diary to name yet."}
    org_dir = os.path.join(rec_dir, ".organized")
    applied, touched = 0, 0
    for day in diary.get("days", []):
        for conv in day.get("conversations", []):
            win = conv_window(conv)
            ev, ov = best_event(win, events)
            if not ev or ov < 1.0:
                continue
            proposed = (ev.get("title") or "").strip()
            if not proposed or proposed == conv.get("title", ""):
                continue
            conv["title"] = proposed   # patch the in-memory diary so the rewrite below shows it now
            applied += 1
            for clip in conv.get("clips") or []:
                fname = clip.get("file")
                if not fname:
                    continue
                sc = os.path.join(org_dir, fname + ".json")
                try:
                    with open(sc, encoding="utf-8") as fh:
                        obj = json.load(fh)
                except (OSError, ValueError):
                    continue
                obj.setdefault("ai", {})["title"] = proposed
                obj["ai"]["calendar_named"] = True   # audit trail: this title came from the calendar
                try:
                    with open(sc, "w", encoding="utf-8") as fh:
                        json.dump(obj, fh, ensure_ascii=False)
                    touched += 1
                except OSError:
                    continue
    if applied:
        try:
            with open(os.path.join(rec_dir, "diary.json"), "w", encoding="utf-8") as fh:
                json.dump(diary, fh, ensure_ascii=False)
        except OSError as e:
            return {"ok": False, "error": "could not write diary: %s" % (str(e)[:120]), "applied": applied}
    return {"ok": True, "applied": applied, "clips_named": touched}


def main(argv):
    rec_dir = os.path.expanduser(argv[argv.index("--rec-dir") + 1]) if "--rec-dir" in argv else DEFAULT_REC
    events = []
    if "--events" in argv:
        p = argv[argv.index("--events") + 1]
        try:
            with open(os.path.expanduser(p), encoding="utf-8") as f:
                events = json.load(f)
        except (OSError, ValueError) as e:
            sys.stdout.write(json.dumps({"ok": False, "error": "bad events file: %s" % (str(e)[:160])}))
            return
    if "--at" in argv:
        at = _dt(argv[argv.index("--at") + 1])
        ev = active_event(events, at) if at else None
        sys.stdout.write(json.dumps({"ok": True, "active": (ev or {}).get("title", ""), "event": ev}))
        return
    if "--apply" in argv:
        sys.stdout.write(json.dumps(apply_names(rec_dir, events), ensure_ascii=False))
        return
    sys.stdout.write(json.dumps(proposals(rec_dir, events), ensure_ascii=False))


if __name__ == "__main__":
    main(sys.argv[1:])
