/**
 * Tests for expandOccurrences() — GREEN state.
 *
 * Contracts verified here:
 *  1. DST wall-clock correctness: occurrences in America/New_York must show 10:00 local time
 *     on BOTH sides of the March 2026 EST→EDT boundary (not shifted ±1h by UTC fallback).
 *  2. All-day events return allDay:true and start as 'YYYY-MM-DD' with no time component.
 *  3. EXDATE exclusions reduce the returned array by exactly one occurrence.
 *  4. Timed event start/end strings are IANA-annotated ('...±HH:MM[IANA/Zone]') so
 *     Temporal.ZonedDateTime.from() can parse them without throwing (cross-contract test).
 *  5. hasRrule is true for recurring events, false for non-recurring (D-08).
 *  6. Bounded RRULE (COUNT=3) expands to exactly 3 occurrences; each occurrence's
 *     duration derives from DTSTART→DTEND (not the recurrence span) (D-06 invariant).
 */

import 'temporal-polyfill/global';
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

import { expandOccurrences } from '../../src/broker/expand.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURES = join(__dirname, '../fixtures');

function loadFixture(name: string): string {
  return readFileSync(join(FIXTURES, name), 'utf8');
}

describe('expandOccurrences', () => {
  describe('DST correctness — weekly-dst.ics', () => {
    it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => {
      // The fixture has DTSTART;TZID=America/New_York:20260301T100000 RRULE:FREQ=WEEKLY.
      // March 8 2026 is the DST transition (clocks spring forward at 02:00).
      // Occurrences on 2026-03-01 (EST, UTC-5) and 2026-03-08 (DST transition day) and
      // 2026-03-15 (EDT, UTC-4) must ALL show hour === 10 in America/New_York.
      // A broken implementation that falls back to UTC would show hour === 10 UTC before
      // transition and hour === 11 local after transition — off by one DST hour.
      const rawVevent = loadFixture('weekly-dst.ics');
      const windowStart = new Date('2026-03-01T00:00:00Z');
      const windowEnd = new Date('2026-04-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1, // calendarId
        'My Calendar', // calendarName
        1, // ownerUserId
        'Alice', // ownerName
        '#4A90D9', // color
        false, // isShared
      );

      // Should return several weekly occurrences in March
      expect(occurrences.length).toBeGreaterThan(0);

      // The key contract: every occurrence must have local hour === 10 in America/New_York.
      // We verify this by checking the ISO string — before DST: '...T10:00:00-05:00[America/New_York]'
      // after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket.
      for (const occ of occurrences) {
        expect(occ.allDay).toBe(false);
        // ownerName must be threaded through to every occurrence
        expect(occ.ownerName).toBe('Alice');
        // start must be IANA-annotated: '2026-03-01T10:00:00-05:00[America/New_York]'
        expect(occ.start).toMatch(/T10:00:00/);
        // Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from()
        expect(occ.start).toContain('[America/New_York]');
      }

      // Explicitly check one pre-transition occurrence (EST) and one post-transition (EDT)
      const preTransition = occurrences.find((o) => o.start.includes('2026-03-01'));
      const postTransition = occurrences.find((o) => o.start.includes('2026-03-15'));

      expect(preTransition).toBeDefined();
      expect(postTransition).toBeDefined();

      // Pre-transition occurrence: EST — '2026-03-01T10:00:00-05:00[America/New_York]'
      expect(preTransition!.start).toContain('T10:00:00');
      expect(preTransition!.start).toContain('-05:00[America/New_York]');
      // Post-transition occurrence: EDT — '2026-03-15T10:00:00-04:00[America/New_York]'
      expect(postTransition!.start).toContain('T10:00:00');
      expect(postTransition!.start).toContain('-04:00[America/New_York]');
    });
  });

  describe('All-day events — allday-birthday.ics', () => {
    it('returns allDay:true with start as YYYY-MM-DD and no time component', () => {
      // The fixture has DTSTART;VALUE=DATE:20260615 with RRULE:FREQ=YEARLY.
      // The all-day occurrence should have allDay:true and start === '2026-06-15' (DATE format).
      // A broken implementation returning '2026-06-15T00:00:00Z' would fail on date-shift.
      const rawVevent = loadFixture('allday-birthday.ics');
      const windowStart = new Date('2026-06-01T00:00:00Z');
      const windowEnd = new Date('2026-07-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null, // ownerName
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBe(1);

      const occ = occurrences[0];
      expect(occ.allDay).toBe(true);
      // start must be plain date string 'YYYY-MM-DD' — no 'T' time component
      expect(occ.start).toBe('2026-06-15');
      expect(occ.start).not.toContain('T');
    });
  });

  describe('EXDATE exclusions — exdate-series.ics', () => {
    it('omits the EXDATE-excluded occurrence (array length is one fewer than un-excluded)', () => {
      // The fixture has RRULE:FREQ=WEEKLY;COUNT=5 with EXDATE for the June 15 occurrence.
      // Without EXDATE: 5 occurrences (Jun 1, Jun 8, Jun 15, Jun 22, Jun 29).
      // With EXDATE on Jun 15: 4 occurrences returned.
      // ICAL.RecurExpansion handles EXDATE internally — no manual filtering needed.
      const rawVevent = loadFixture('exdate-series.ics');
      const windowStart = new Date('2026-06-01T00:00:00Z');
      const windowEnd = new Date('2026-07-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null, // ownerName
        '#4A90D9',
        false,
      );

      // 5 total occurrences minus 1 EXDATE = 4
      expect(occurrences.length).toBe(4);

      // The June 15 occurrence must be absent
      const june15 = occurrences.find((o) => o.start.includes('2026-06-15'));
      expect(june15).toBeUndefined();
    });
  });

  describe('Non-recurring DURATION-only event — single-duration.ics', () => {
    it('BUG-1 regression: non-recurring event with DURATION but no DTEND has end strictly after start', () => {
      // Real Fastmail events use DURATION (not DTEND). Before the fix, the NON-RECURRING branch
      // used getFirstPropertyValue('dtend') ?? dtstart, which returned dtstart when DTEND was absent,
      // producing zero-duration occurrences (invisible in week/day views).
      const rawVevent = loadFixture('single-duration.ics');
      const windowStart = new Date('2026-06-01T00:00:00Z');
      const windowEnd = new Date('2026-07-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null, // ownerName
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBe(1);

      const occ = occurrences[0];
      expect(occ.allDay).toBe(false);

      // Parse both via Temporal.ZonedDateTime and assert end > start
      const startZdt = Temporal.ZonedDateTime.from(occ.start);
      const endZdt = Temporal.ZonedDateTime.from(occ.end);
      expect(Temporal.ZonedDateTime.compare(endZdt, startZdt)).toBeGreaterThan(0);

      // Verify the actual duration is correct: DURATION:PT1H → end is 1 hour after start
      expect(endZdt.hour - startZdt.hour).toBe(1);
      expect(endZdt.minute).toBe(startZdt.minute);
    });
  });

  describe('hasRrule field — D-08 (recurring series detection)', () => {
    it('recurring event occurrence has hasRrule === true', () => {
      // weekly-dst.ics has RRULE:FREQ=WEEKLY — all occurrences must have hasRrule === true
      const rawVevent = loadFixture('weekly-dst.ics');
      const windowStart = new Date('2026-03-01T00:00:00Z');
      const windowEnd = new Date('2026-04-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null,
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBeGreaterThan(0);
      for (const occ of occurrences) {
        expect(occ.hasRrule).toBe(true);
      }
    });

    it('non-recurring event occurrence has hasRrule === false', () => {
      // single-duration.ics has no RRULE — the single occurrence must have hasRrule === false
      const rawVevent = loadFixture('single-duration.ics');
      const windowStart = new Date('2026-06-01T00:00:00Z');
      const windowEnd = new Date('2026-07-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null,
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBe(1);
      expect(occurrences[0].hasRrule).toBe(false);
    });
  });

  describe('Bounded RRULE (COUNT=3) — D-06 invariant', () => {
    it('expands to exactly 3 occurrences within a wide window (COUNT terminates expansion)', () => {
      // weekly-count3.ics has RRULE:FREQ=WEEKLY;COUNT=3 starting 2026-06-01.
      // A 6-month window must return exactly 3 occurrences — not more.
      const rawVevent = loadFixture('weekly-count3.ics');
      const windowStart = new Date('2026-06-01T00:00:00Z');
      const windowEnd = new Date('2026-12-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null,
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBe(3);
    });

    it('each bounded occurrence duration derives from DTSTART→DTEND (1 hour), not recurrence span', () => {
      // Each occurrence must have end exactly 1 hour after start.
      // The recurrence span is many weeks; duration must be per-event, not recurrence-level.
      const rawVevent = loadFixture('weekly-count3.ics');
      const windowStart = new Date('2026-06-01T00:00:00Z');
      const windowEnd = new Date('2026-12-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null,
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBe(3);
      for (const occ of occurrences) {
        const startZdt = Temporal.ZonedDateTime.from(occ.start);
        const endZdt = Temporal.ZonedDateTime.from(occ.end);
        // Each occurrence must be exactly 1 hour (3 600 000 ms).
        // Use epochMilliseconds which is a regular number in the polyfill.
        const durationMs = endZdt.epochMilliseconds - startZdt.epochMilliseconds;
        expect(durationMs).toBe(3_600_000);
      }
    });

    it('bounded occurrences have hasRrule === true', () => {
      const rawVevent = loadFixture('weekly-count3.ics');
      const windowStart = new Date('2026-06-01T00:00:00Z');
      const windowEnd = new Date('2026-12-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null,
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBe(3);
      for (const occ of occurrences) {
        expect(occ.hasRrule).toBe(true);
      }
    });
  });

  describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => {
    it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => {
      // This is the integration test that was missing. It takes the actual serializeTime output
      // (from expandOccurrences) and feeds each start/end through Temporal.ZonedDateTime.from()
      // to prove the expand→hydrate contract holds end-to-end.
      //
      // Previously, serializeTime emitted offset-only strings like '2026-03-01T10:00:00-05:00'
      // which caused Temporal.ZonedDateTime.from() to throw RangeError: Cannot parse.
      // Now it emits IANA-annotated strings like '2026-03-01T10:00:00-05:00[America/New_York]'.
      const rawVevent = loadFixture('weekly-dst.ics');
      const windowStart = new Date('2026-03-01T00:00:00Z');
      const windowEnd = new Date('2026-04-01T00:00:00Z');

      const occurrences = expandOccurrences(
        rawVevent,
        windowStart,
        windowEnd,
        1,
        'My Calendar',
        1,
        null, // ownerName
        '#4A90D9',
        false,
      );

      expect(occurrences.length).toBeGreaterThan(0);

      for (const occ of occurrences) {
        // These must not throw — this is the cross-service contract
        expect(() => Temporal.ZonedDateTime.from(occ.start)).not.toThrow();
        expect(() => Temporal.ZonedDateTime.from(occ.end)).not.toThrow();

        // Parsed ZonedDateTime must round-trip the wall-clock hour
        const startZdt = Temporal.ZonedDateTime.from(occ.start);
        expect(startZdt.hour).toBe(10);
        expect(startZdt.timeZoneId).toBe('America/New_York');
      }
    });
  });
});
