#!/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)
    d, m = divmod(m, 1440)
    h, m = divmod(m, 60)
    return " ".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)} = {json.dumps(offs)}\n"
                           for uid, offs in sorted(ov.items())))
    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.
    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 valarms(comp, start, end):
    out = []
    for a in comp.walk("VALARM"):
        t = a.get("TRIGGER")
        if t is None:
            continue
        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.
    trig = [a["TRIGGER"].dt for a in comp.walk("VALARM") 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
        # 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]
    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 ----

# ---- picker ----


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


if __name__ == "__main__":
    main()
