"""
Calendar (.ics) event-generation tests.

Verifies the two behaviours required by the spec:

    1. The event date/time is the LOWER value between the will-settings locktime
       and the lowest-locktime valid will transaction.
    2. The alarms are generated according to the iCalendar (RFC-5545) standard
       understood by the major online calendars (Google / Apple / Outlook):
       ``BEGIN:VALARM`` ... ``END:VALARM`` blocks with a ``TRIGGER;RELATED=END``
       negative duration, and the number of alarms matches the configured count,
       spread evenly across the window.

The alarm/event logic was extracted into pure ``BalCalendar`` static methods so
it can be tested without instantiating any Qt widget.
"""

import os
import re
import sys
from datetime import datetime, timedelta, timezone

sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))

from bal.gui.qt.calendar import BalCalendar

DAY = 86400


# ------------------------------------------------------------------ #
# Event date/time selection
# ------------------------------------------------------------------ #

def test_event_timestamp_uses_will_settings_when_no_tx():
    """With no valid transaction the will-settings locktime is used."""
    ws_locktime = 2_000_000_000
    assert BalCalendar.compute_event_timestamp(ws_locktime, None) == ws_locktime


def test_event_timestamp_uses_lower_tx_locktime():
    """A valid tx with an EARLIER locktime wins over the will-settings one."""
    ws_locktime = 2_000_000_000
    tx_locktime = 1_900_000_000  # earlier
    assert (
        BalCalendar.compute_event_timestamp(ws_locktime, tx_locktime)
        == tx_locktime
    )


def test_event_timestamp_uses_lower_will_settings_locktime():
    """A valid tx with a LATER locktime does not push the event back."""
    ws_locktime = 1_800_000_000  # earlier
    tx_locktime = 1_900_000_000
    assert (
        BalCalendar.compute_event_timestamp(ws_locktime, tx_locktime)
        == ws_locktime
    )


def test_event_timestamp_equal():
    ws_locktime = 1_850_000_000
    assert (
        BalCalendar.compute_event_timestamp(ws_locktime, ws_locktime)
        == ws_locktime
    )


# ------------------------------------------------------------------ #
# Alarm generation – count and structure
# ------------------------------------------------------------------ #

def _parse_blocks(lines):
    """Group flat .ics lines into VALARM blocks (list of line-lists)."""
    blocks, current = [], None
    for line in lines:
        if line == "BEGIN:VALARM":
            current = []
        elif line == "END:VALARM":
            blocks.append(current)
            current = None
        elif current is not None:
            current.append(line)
    return blocks


def test_build_alarms_default_count_is_three():
    start = datetime(2025, 1, 1, tzinfo=timezone.utc)
    end = start + timedelta(days=40)
    blocks = _parse_blocks(BalCalendar.build_alarms(3, start, end))
    assert len(blocks) == 3, "default 3 alarms expected"


def test_build_alarms_zero():
    start = datetime(2025, 1, 1, tzinfo=timezone.utc)
    end = start + timedelta(days=40)
    assert BalCalendar.build_alarms(0, start, end) == []


def test_build_alarms_custom_count():
    start = datetime(2025, 1, 1, tzinfo=timezone.utc)
    end = start + timedelta(days=40)
    for n in (1, 2, 5, 10):
        blocks = _parse_blocks(BalCalendar.build_alarms(n, start, end))
        assert len(blocks) == n, f"expected {n} alarms"


def test_build_alarms_structure_is_ics_compliant():
    """Each VALARM has a RELATED=END negative-duration trigger + ACTION."""
    start = datetime(2025, 1, 1, tzinfo=timezone.utc)
    end = start + timedelta(days=30)
    lines = BalCalendar.build_alarms(3, start, end)
    blocks = _parse_blocks(lines)
    duration_re = re.compile(r"^TRIGGER;RELATED=END:-P(T?\d+[DHMS]|\d+D)$")
    for block in blocks:
        joined = "\n".join(block)
        assert any(l.startswith("ACTION:") for l in block), \
            "VALARM missing ACTION"
        trigger = [l for l in block if l.startswith("TRIGGER")]
        assert len(trigger) == 1, "VALARM needs exactly one TRIGGER"
        assert duration_re.match(trigger[0]), \
            f"trigger not RFC-5545 negative duration: {trigger[0]}"


def test_build_alarms_evenly_spread_and_ordered():
    """Alarms divide the window into (n+1) slices and are ordered toward the end.

    With n=3 over 40 days the division points (back from the end) are at
    30, 20 and 10 days before the deadline.
    """
    start = datetime(2025, 1, 1, tzinfo=timezone.utc)
    end = start + timedelta(days=40)
    lines = BalCalendar.build_alarms(3, start, end)
    triggers = [l for l in lines if l.startswith("TRIGGER")]
    # Extract the day offsets from "TRIGGER;RELATED=END:-P30D" etc.
    offsets = []
    for t in triggers:
        m = re.search(r"-P(\d+)D", t)
        assert m, f"expected a day-based trigger, got {t}"
        offsets.append(int(m.group(1)))
    # 40 / (3+1) = 10-day slices -> first alarm 30d before, last 10d before.
    assert offsets == [30, 20, 10], f"unexpected offsets {offsets}"


def test_build_alarms_short_window_uses_finer_units():
    """A short window falls back to hours/minutes rather than zero-day offsets."""
    start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
    end = start + timedelta(hours=4)
    lines = BalCalendar.build_alarms(3, start, end)
    triggers = [l for l in lines if l.startswith("TRIGGER")]
    assert len(triggers) == 3
    for t in triggers:
        # 4h / 4 = 1h slices -> offsets 3h, 2h, 1h (all hour-based).
        assert re.search(r"-PT\d+H", t), f"expected hour trigger, got {t}"


# ------------------------------------------------------------------ #
# Full ICS document assembly (event + alarms)
# ------------------------------------------------------------------ #

def _build_full_ics(ws_locktime, min_tx_locktime, num_alarms,
                    now=None, threshold=None):
    """Assemble a complete VCALENDAR exactly like the widget does."""
    if now is None:
        now = datetime(2025, 1, 1, tzinfo=timezone.utc)
    if threshold is None:
        threshold = now + timedelta(days=10)
    alarm_start_dt = max(now, threshold)
    event_ts = BalCalendar.compute_event_timestamp(ws_locktime, min_tx_locktime)
    event_dt = datetime.fromtimestamp(event_ts, tz=timezone.utc)
    alarm_end_dt = event_dt
    lines = [
        "BEGIN:VCALENDAR",
        "VERSION:2.0",
        "PRODID:-//Bitcoin After Life//Electrum Plugin//EN",
        "BEGIN:VEVENT",
        "UID:bal-test",
        f"DTSTAMP:{BalCalendar.format_time(now)}",
        f"DTSTART:{BalCalendar.format_time(event_dt)}",
        f"DTEND:{BalCalendar.format_time(event_dt)}",
        "SUMMARY:BAL will execution",
        "DESCRIPTION:test",
    ]
    lines.extend(BalCalendar.build_alarms(num_alarms, alarm_start_dt, alarm_end_dt))
    lines.extend(["END:VEVENT", "END:VCALENDAR"])
    return "\r\n".join(lines) + "\r\n", event_dt


def test_full_ics_event_time_matches_lower_locktime():
    ws_locktime = int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp())
    tx_locktime = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
    ics, event_dt = _build_full_ics(ws_locktime, tx_locktime, 3)
    # The earlier tx locktime must drive the event start.
    expected = BalCalendar.format_time(
        datetime.fromtimestamp(tx_locktime, tz=timezone.utc)
    )
    assert f"DTSTART:{expected}" in ics
    assert "BEGIN:VCALENDAR" in ics and "END:VCALENDAR" in ics
    assert ics.count("BEGIN:VALARM") == 3
    assert ics.endswith("\r\n")


def test_full_ics_is_well_formed():
    """Sanity-check VCALENDAR/VEVENT/VALARM nesting balances out."""
    ws_locktime = int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp())
    ics, _ = _build_full_ics(ws_locktime, None, 3)
    assert ics.count("BEGIN:VCALENDAR") == ics.count("END:VCALENDAR") == 1
    assert ics.count("BEGIN:VEVENT") == ics.count("END:VEVENT") == 1
    assert ics.count("BEGIN:VALARM") == ics.count("END:VALARM") == 3


# ------------------------------------------------------------------ #

if __name__ == "__main__":
    import inspect
    for name, obj in sorted(globals().items()):
        if name.startswith("test_") and inspect.isfunction(obj):
            obj()
            print(f"  [OK] {name}")
    print("[OK] All calendar-event tests passed")
