# test the download to calendar (.ics) button
# Derek Fujimoto
# Aug 2026

from fixtures import *
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
import datetime
import pathlib
import pytest

# the shift setup defaults these tests are written against, from setup_odb
# shiftid 1 OWL: -60 to 450 mins, 2 DAY: 420 to 930 mins, 3 EVE: 900 to 1410 mins

def download_ics(page):
    """Click the calendar button and return the file contents and its suggested name

    Returns:
        tuple: (str contents of the .ics file, str filename the browser was given)
    """
    with page.expect_download(timeout=WRITE_TIMEOUT_MS) as info:
        by_id(page, 'calendar_button').click()

    # read bytes and decode by hand: read_text translates the line endings, which is the
    # one thing about an iCalendar file worth checking
    download = info.value
    return pathlib.Path(download.path()).read_bytes().decode('utf-8'), download.suggested_filename

def viewed_start(page):
    """Date shown in the first calendar column, as a datetime.date"""
    return datetime.date.fromisoformat(by_id(page, 'start').input_value())

def ics_day(date):
    """Format a date the way an iCalendar DATE-TIME writes its date half"""
    return date.strftime('%Y%m%d')

# a shifter's own shifts, and only their own, come out as one event each with the times the
# shift offsets put them at
def test_ics_contents(page, users_added):

    day0 = viewed_start(page)

    # test0 takes the OWL shift in column 1 and the DAY shift in column 2, test1 takes the
    # same OWL shift so the export has something it has to leave out
    assign(page, 0, 1, 1)
    assign(page, 1, 1, 1)
    select_role(page, 'Exp')
    assign(page, 0, 2, 2, 'Exp')

    by_id(page, 'name_highlight_select').select_option('test0')
    text, filename = download_ics(page)

    assert filename.startswith('shifts_test0_') and filename.endswith('.ics'), \
        f"Expected the download to be named shifts_test0_<dates>.ics, got '{filename}'"

    # envelope
    for line in ('BEGIN:VCALENDAR', 'VERSION:2.0', 'CALSCALE:GREGORIAN', 'END:VCALENDAR'):
        assert line in text, f"Expected the calendar envelope line '{line}' in the file"

    assert text.count('BEGIN:VEVENT') == 2, \
        f"Expected 2 events for test0's 2 shifts, got {text.count('BEGIN:VEVENT')}"
    assert 'test1' not in text, \
        "Expected test1's assignment to be left out of test0's calendar, but their name is in the file"

    # OWL starts 60 mins before midnight of the day it is assigned to, so it lands on the
    # day before, and ends 450 mins after midnight
    assert f'DTSTART:{ics_day(day0 - datetime.timedelta(days=1))}T230000' in text, \
        f"Expected the OWL shift to start 23:00 the day before {day0}, file was:\n{text}"
    assert f'DTEND:{ics_day(day0)}T073000' in text, \
        f"Expected the OWL shift to end 07:30 on {day0}, file was:\n{text}"

    # DAY in column 2 is the day after the first column, 07:00 to 15:30
    day1 = day0 + datetime.timedelta(days=1)
    assert f'DTSTART:{ics_day(day1)}T070000' in text, \
        f"Expected the DAY shift to start 07:00 on {day1}, file was:\n{text}"
    assert f'DTEND:{ics_day(day1)}T153000' in text, \
        f"Expected the DAY shift to end 15:30 on {day1}, file was:\n{text}"

    # times are floating: a Z or a TZID would reintroduce the timezone the page cannot
    # track across a daylight savings change
    for line in text.splitlines():
        if line.startswith('DTSTART') or line.startswith('DTEND'):
            assert line.endswith('Z') is False and 'TZID' not in line, \
                f"Expected floating times with no timezone, got '{line}'"

    # shift names are stored as html and have to come out as plain text
    assert 'SUMMARY:OWL 23:00-07:30' in text, \
        f"Expected the OWL summary with its markup stripped, file was:\n{text}"
    assert '<br>' not in text, "Expected no html left in the calendar file"

    # the role is on the shift that has one, and absent from the shift that does not
    assert 'SUMMARY:DAY 07:00-15:30 (Exp)' in text, \
        f"Expected the DAY summary to name the Exp role, file was:\n{text}"

    # the uid is keyed on the day the shift was assigned to, so a re-import updates
    assert f'UID:test0-1-{ics_day(day0)}@shiftschedule.midas' in text, \
        f"Expected the OWL uid to be keyed on the assigned day {day0}, file was:\n{text}"
    assert f'UID:test0-2-{ics_day(day1)}@shiftschedule.midas' in text, \
        f"Expected the DAY uid to be keyed on the assigned day {day1}, file was:\n{text}"

    # every line ends CRLF, as the iCalendar grammar requires
    raw = text.replace('\r\n', '')
    assert '\n' not in raw and '\r' not in raw, \
        "Expected every line in the calendar file to end in CRLF"

# a shift name holding markup and a character iCalendar uses as a separator has to survive
# both the strip and the escape
def test_ics_escaping(page, client, users_added):

    names = client.odb_get('/Shifts/ShiftSetup/name')
    names[0] = 'OWL, late<br>23:00'
    client.odb_set('/Shifts/ShiftSetup/name', names)
    page.reload()
    expect(by_id(page, 'cell_1_1')).to_be_attached()

    assign(page, 0, 1, 1)
    by_id(page, 'name_highlight_select').select_option('test0')
    text, _ = download_ics(page)

    assert 'SUMMARY:OWL\\, late 23:00' in text, \
        f"Expected the comma escaped and the break turned into a space, file was:\n{text}"

# with no shifter picked there is nothing to export, so the page says so instead of
# downloading an empty calendar
def test_ics_no_selection(page, users_added):

    assign(page, 0, 1, 1)

    # assigning a shifter points the highlight select at them, so blank it explicitly
    by_id(page, 'name_highlight_select').select_option('')

    with pytest.raises(PlaywrightTimeoutError):
        with page.expect_download(timeout=2000):
            by_id(page, 'calendar_button').click()

    expect(by_id(page, 'dlgMessageString')).to_contain_text('Highlight cells')

# a shifter with no shifts in the dates being viewed gets told, not an empty file
def test_ics_no_shifts(page, users_added):

    # move the view off the assignment, then put the name back in the dropdown by hand.
    # populate_name_highlight rebuilds the options from whoever is assigned in the window,
    # so it drops test0 on the redraw - but it only runs once the assignment fetch comes
    # back, and the button is live before then. this is that window, without the race
    assign(page, 0, 1, 1)
    by_id(page, 'tstart_nextweek').click()
    expect(by_id(page, 'cell_1_1')).to_be_attached()

    page.evaluate("""() => {
        let select = document.getElementById('name_highlight_select');
        let option = document.createElement('option');
        option.value = 'test0';
        option.innerText = 'test0';
        select.appendChild(option);
        select.value = 'test0';
    }""")

    with pytest.raises(PlaywrightTimeoutError):
        with page.expect_download(timeout=2000):
            by_id(page, 'calendar_button').click()

    expect(by_id(page, 'dlgMessageString')).to_contain_text('no assigned shifts')
