#!/usr/bin/python3
# cal-notif: calendar notifications with local spoken announcements.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 2 as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
"""Usage: cal-notif daemon | pick | say TEXT

daemon  watch the calendars and notify (popup + voice) at each alarm
pick    rofi picker to set custom alarm offsets for one event
say     speak TEXT with the configured voice
"""

import json
import os
import re
import shutil
import subprocess
import sys
import time
import tomllib
from collections import namedtuple
from datetime import datetime, time as dtime, timedelta, timezone
from pathlib import Path

from dateutil.rrule import rrulestr
from icalendar import Calendar, vRecur

CONF_DIR = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "cal-notif"
DEFAULTS = {
    "calendars_dir": "~/.local/share/calendars",
    "snooze": "5m",
    "late": "10m",
    "rofi_theme": "~/.config/rofi/udt/list.rasi",
    "defaults": {"*": []},
    "voice": {"enabled": True,
              "model": "/data/voice-models/kokoro/kokoro-v1.0.onnx",
              "voices": "/data/voice-models/kokoro/voices-v1.0.bin",
              "lang": "it",
              "blend": {"if_sara": 0.8, "af_bella": 0.2},
              "lead_silence": 0.3,
              "say": "Tra {in}: {summary}",
              "say_now": "Adesso: {summary}"},
}
PICK_DAYS = 30

# ---- durations ----

DUR = re.compile(r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?")


def parse_duration(s):
    m = DUR.fullmatch(s.replace(" ", ""))
    if not s.strip() or not m:
        raise ValueError(f"bad duration {s!r}, want e.g. '1d 2h 15m'")
    d, h, mi = (int(x or 0) for x in m.groups())
    return timedelta(days=d, hours=h, minutes=mi)


def fmt_duration(td):
    m = round(td.total_seconds() / 60)
    sign = "-" if m < 0 else ""
    d, m = divmod(abs(m), 1440)
    h, m = divmod(m, 60)
    return sign + (" ".join(f"{n}{u}" for n, u in ((d, "d"), (h, "h"), (m, "m")) if n) or "0m")


UNITS = ["", "uno", "due", "tre", "quattro", "cinque", "sei", "sette", "otto", "nove",
         "dieci", "undici", "dodici", "tredici", "quattordici", "quindici", "sedici",
         "diciassette", "diciotto", "diciannove"]
TENS = ["", "", "venti", "trenta", "quaranta", "cinquanta", "sessanta", "settanta",
        "ottanta", "novanta"]


def number_it(n):
    # 1..99; ventuno, ventotto: the tens drop their vowel before uno/otto.
    if n < 20:
        return UNITS[n]
    t, u = divmod(n, 10)
    return (TENS[t][:-1] if u in (1, 8) else TENS[t]) + UNITS[u]


def spoken_it(td):
    m = round(td.total_seconds() / 60)
    d, m = divmod(m, 1440)
    h, m = divmod(m, 60)
    parts = []
    for n, one, many in ((d, "un giorno", "giorni"), (h, "un'ora", "ore"), (m, "un minuto", "minuti")):
        if n == 1:
            parts.append(one)
        elif n:
            # ponytail: number_it covers 1-99; bigger counts are read as digits.
            parts.append(f"{number_it(n) if n < 100 else n} {many}")
    if not parts:
        return ""
    return parts[0] if len(parts) == 1 else ", ".join(parts[:-1]) + " e " + parts[-1]

# ---- config ----

def load_config(path=None):
    path = path or CONF_DIR / "config.toml"
    cfg = {k: dict(v) if isinstance(v, dict) else v for k, v in DEFAULTS.items()}
    if path.exists():
        with open(path, "rb") as f:
            user = tomllib.load(f)
        for k, v in user.items():
            cfg[k] = {**cfg[k], **v} if k == "voice" else v
    for k in ("snooze", "late"):             # fail early on bad durations
        parse_duration(cfg[k])
    for v in cfg["defaults"].values():
        for o in (v.get("offsets", []) if isinstance(v, dict) else v):
            parse_duration(o)
    return cfg


def load_overrides(path=None):
    path = path or CONF_DIR / "overrides.toml"
    if not path.exists():
        return {}
    with open(path, "rb") as f:
        return tomllib.load(f)


def save_overrides(ov, path=None):
    path = path or CONF_DIR / "overrides.toml"
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    # JSON strings and string arrays are valid TOML, so no TOML writer is needed.
    tmp.write_text("".join(f"{json.dumps(uid, ensure_ascii=False)} = {json.dumps(offs, ensure_ascii=False)}\n"
                           for uid, offs in sorted(ov.items())), encoding="utf-8")
    os.replace(tmp, path)

# ---- calendars ----

Occ = namedtuple("Occ", "uid cal summary location start allday valarms")


def local(dt):
    # Aware local-or-own-zone datetime: dates start at local midnight,
    # floating (naive) times are local.
    if not isinstance(dt, datetime):
        dt = datetime.combine(dt, dtime())
    return dt if dt.tzinfo else dt.astimezone()


def rule_for(comp, start):
    # dateutil wants UNTIL to match DTSTART: naive for floating/all-day,
    # UTC for zoned. Clients do not always agree, so normalise it.
    # ponytail: a VEVENT with more than one RRULE line fails here and
    # load_events skips the whole file; upgrade path: merge into one rruleset.
    rec = vRecur(comp["RRULE"])
    if "UNTIL" in rec:
        u = rec["UNTIL"][0]
        if not isinstance(u, datetime):
            u = datetime.combine(u, dtime(23, 59, 59))
        if start.tzinfo:
            u = (u if u.tzinfo else u.replace(tzinfo=start.tzinfo)).astimezone(timezone.utc)
        elif u.tzinfo:
            u = u.astimezone().replace(tzinfo=None)
        rec["UNTIL"] = [u]
    return rrulestr(rec.to_ical().decode(), dtstart=start)


def _live_valarms(comp):
    # RFC 9074 ACTION:NONE is Apple/iCloud's "no alarm" placeholder, not a
    # real VALARM: skip it or it silences the calendar default.
    return [a for a in comp.walk("VALARM") if str(a.get("ACTION", "")).upper() != "NONE"]


def valarms(comp, start, end):
    out = []
    for a in _live_valarms(comp):
        t = a.get("TRIGGER")
        if t is None:
            continue
        # ponytail: offsets add in wall-clock time, so an alarm spanning a
        # DST jump is off by the jump; upgrade path: add the sub-day part in UTC.
        if isinstance(t.dt, timedelta):
            out.append((end if t.params.get("RELATED") == "END" else start) + t.dt)
        else:
            out.append(local(t.dt))
    return tuple(sorted(out))


def lead(comp):
    # The longest relative VALARM lead, so expansion reaches far enough.
    # ponytail: an absolute DATE-TIME trigger does not widen the window, so
    # an absolute alarm for an event beyond the horizon is dropped.
    trig = [a["TRIGGER"].dt for a in _live_valarms(comp) if "TRIGGER" in a]
    return max([-t for t in trig if isinstance(t, timedelta)] + [timedelta()])


def occurrence(comp, cal, start):
    s0 = comp["DTSTART"].dt
    allday = not isinstance(s0, datetime)
    if "DTEND" in comp:
        length = local(comp["DTEND"].dt) - local(s0)
    elif "DURATION" in comp:
        length = comp["DURATION"].dt
    else:
        length = timedelta(days=1) if allday else timedelta()
    start = local(start)
    return Occ(str(comp.get("UID", "")), cal, str(comp.get("SUMMARY", "")),
               str(comp.get("LOCATION", "")), start, allday,
               valarms(comp, start, start + length))


def expand(ics, cal, lo, hi):
    """Occurrences of every VEVENT in `ics` starting in [lo, hi] (aware).

    The window end grows per event by its largest VALARM lead, so an alarm
    a week before an event beyond `hi` is still produced."""
    comps = Calendar.from_ical(ics).walk("VEVENT")
    moved = {(str(c.get("UID")), local(c["RECURRENCE-ID"].dt)) for c in comps if "RECURRENCE-ID" in c}
    out = []
    for c in comps:
        if "DTSTART" not in c:
            continue
        s0 = c["DTSTART"].dt
        first = occurrence(c, cal, s0)
        top = hi + lead(c)
        if "RRULE" not in c or "RECURRENCE-ID" in c:
            # ponytail: a RECURRENCE-ID with RANGE=THISANDFUTURE is treated as
            # a single moved occurrence.
            if lo <= first.start <= top:
                out.append(first)
            continue
        # ponytail: RDATE is ignored; upgrade path: dateutil rruleset.rdate.
        # Expand in the event's own clock: naive for all-day/floating.
        start = s0 if isinstance(s0, datetime) else datetime.combine(s0, dtime())
        conv = (lambda d: d) if start.tzinfo else (lambda d: d.astimezone().replace(tzinfo=None))
        exd = c.get("EXDATE", [])
        ex = {local(d.dt) for g in (exd if isinstance(exd, list) else [exd]) for d in g.dts}
        uid = str(c.get("UID"))
        for s in rule_for(c, start).between(conv(lo), conv(top), inc=True):
            s = local(s if isinstance(s0, datetime) else s.date())
            if s not in ex and (uid, s) not in moved:
                out.append(occurrence(c, cal, s))
    return out


def calendars(root):
    root = Path(root).expanduser()
    for d in sorted(p for p in root.iterdir() if p.is_dir()) if root.is_dir() else []:
        name = d / "displayname"
        yield d, (name.read_text().strip() if name.exists() else d.name)


def load_events(root, lo, hi):
    out = []
    for d, cal in calendars(root):
        for f in sorted(d.glob("*.ics")):
            try:
                out += expand(f.read_bytes(), cal, lo, hi)
            except Exception as e:           # one broken file must not stop the rest
                print(f"cal-notif: skipping {f}: {e}", file=sys.stderr)
    return sorted(out, key=lambda o: o.start)

# ---- alarms ----

def cal_entry(cfg, cal):
    e = cfg["defaults"].get(cal, cfg["defaults"].get("*", []))
    return (e.get("offsets", []), e.get("voice", True)) if isinstance(e, dict) else (e, True)


def alarms_for(occ, cfg, overrides):
    if occ.uid in overrides:
        offs = overrides[occ.uid]
    elif occ.valarms:
        return list(occ.valarms)
    else:
        offs = cal_entry(cfg, occ.cal)[0]
    # ponytail: offset subtracted in wall-clock time, so an alarm spanning a
    # DST jump is off by the jump; upgrade path: apply the sub-day part in UTC.
    return [occ.start - parse_duration(o) for o in offs]


def current_offsets(occ, cfg, overrides):
    return [fmt_duration(occ.start - f) for f in alarms_for(occ, cfg, overrides)]


def horizon(cfg, overrides):
    offs = [o for v in overrides.values() for o in v]
    offs += [o for c in cfg["defaults"] for o in cal_entry(cfg, c)[0]]
    return max([parse_duration(o) for o in offs] + [timedelta()]) + timedelta(days=1)


def schedule(occs, cfg, overrides):
    return sorted(((f, o) for o in occs for f in alarms_for(o, cfg, overrides)),
                  key=lambda e: e[0])


def due(entries, last, now, late):
    """Entries firing in (last, now], at most `late` old, whose event has not
    started yet unless the alarm itself is at or after the start."""
    since = max(last, now - late)
    return [(f, o) for f, o in entries
            if since < f <= now and not (f < o.start <= now)]


def announce(occ, now, vcfg):
    left = occ.start - now
    if left < timedelta(minutes=1):
        return vcfg["say_now"].format(summary=occ.summary)
    return vcfg["say"].format(summary=occ.summary, **{"in": spoken_it(left)})

# ---- voice ----

def say(text, vcfg):
    import numpy as np
    from kokoro_onnx import Kokoro
    k = Kokoro(vcfg["model"], vcfg["voices"])
    style = sum(w * k.get_voice_style(n) for n, w in vcfg["blend"].items())
    audio, sr = k.create(text, voice=style, lang=vcfg["lang"])
    # A new playback stream wakes the suspended sink and loses its first
    # ~0.2 s: lead with silence so it eats that, not the first word.
    lead = bytes(int(sr * vcfg["lead_silence"]) * 2)
    pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes()
    subprocess.run(["aplay", "-q", "-r", str(sr), "-f", "S16_LE", "-c", "1"],
                   input=lead + pcm, check=True)


def voice_ready(vcfg):
    if not vcfg["enabled"]:
        return False
    missing = [p for p in (vcfg["model"], vcfg["voices"]) if not Path(p).exists()]
    if missing or not shutil.which("aplay"):
        print(f"cal-notif: voice off, missing {missing or 'aplay'}", file=sys.stderr)
        return False
    return True

# ---- daemon ----

def notify(occ, now, cfg, voice):
    when = f"{occ.start:%d.%m}" if occ.allday else f"{occ.start:%d.%m %H:%M}"
    body = "\n".join(x for x in (when, occ.location) if x)
    urgency = "critical" if now >= occ.start - timedelta(minutes=1) else "normal"
    kids = [subprocess.Popen(["notify-send", "--wait", "--app-name=cal-notif", "-u", urgency,
                              "--action=snooze=Snooze", "--action=dismiss=Dismiss",
                              occ.summary or "(senza titolo)", body],
                             stdout=subprocess.PIPE, text=True)]
    if voice and cal_entry(cfg, occ.cal)[1]:
        kids.append(subprocess.Popen([sys.executable, __file__, "say",
                                      announce(occ, now, cfg["voice"])]))
    return [(k, occ) for k in kids]


def signature(cfg):
    paths = [CONF_DIR / "config.toml", CONF_DIR / "overrides.toml"]
    paths += [d for d, _ in calendars(cfg["calendars_dir"])]
    # ponytail: directory mtimes catch vdirsyncer's rename-into-place writes;
    # an in-place edit of an .ics would go unnoticed until the next change.
    return tuple(p.stat().st_mtime if p.exists() else 0 for p in paths)


def daemon():
    if not shutil.which("notify-send"):
        sys.exit("cal-notif: notify-send not found")
    cfg, ov = load_config(), load_overrides()     # a bad config at startup exits
    voice = voice_ready(cfg["voice"])
    now = datetime.now().astimezone()
    last = now - parse_duration(cfg["late"])
    sig, entries, snoozed, kids = None, [], [], []
    while True:
        now = datetime.now().astimezone()
        if (s := signature(cfg)) != sig:
            if sig is not None:
                try:
                    cfg, ov = load_config(), load_overrides()
                except Exception as e:       # keep the previous config
                    print(f"cal-notif: config not reloaded: {e}", file=sys.stderr)
            occs = load_events(cfg["calendars_dir"], now - timedelta(days=1), now + horizon(cfg, ov))
            entries, sig = schedule(occs, cfg, ov), s
        # ponytail: fired alarms live in memory; a restart within `late`
        # repeats those popups once.
        fire = due(entries, last, now, parse_duration(cfg["late"]))
        fire += [e for e in snoozed if e[0] <= now]
        snoozed = [e for e in snoozed if e[0] > now]
        for _, o in fire:
            kids += notify(o, now, cfg, voice)
        for k, o in kids:
            if k.poll() is not None and k.stdout and k.stdout.read().strip() == "snooze":
                snoozed.append((now + parse_duration(cfg["snooze"]), o))
        kids = [(k, o) for k, o in kids if k.returncode is None]
        last = now
        time.sleep(30)

# ---- picker ----


def main():
    cmd = sys.argv[1:2]
    if cmd == ["daemon"]:
        daemon()
    elif cmd == ["say"] and len(sys.argv) > 2:
        say(" ".join(sys.argv[2:]), load_config()["voice"])
    else:
        sys.exit(__doc__)


if __name__ == "__main__":
    main()
