import { describe, it, expect } from 'vitest'
import { expandEventOccurrences, type RecurrenceRule } from './eventRecurrence'

// Local-time Date factory (matches scheduleOccurrences.test.ts). Months
// are 0-indexed in the Date constructor, 1-indexed here.
const dt = (
  year: number,
  month1to12: number,
  day: number,
  hours = 0,
  minutes = 0,
): Date => new Date(year, month1to12 - 1, day, hours, minutes, 0, 0)

// Reference calendar:
// April 2026 — Wed 1; Thu 2, 9, 16, 23, 30; Tue 7, 14, 21, 28; Mon 6, 13, 20, 27; Sun 5, 12, 19, 26
// May 2026   — Tue 5, 12, 19, 26; Thu 7, 14, 21, 28; Mon 4, 11, 18, 25
// June 2026  — Tue 2, 9, 16, 23, 30; Thu 4, 11, 18, 25; Mon 1, 8, 15, 22, 29
// March 2026 — Mon 2, 9, 16, 23, 30 (five Mondays)

describe('expandEventOccurrences', () => {
  it('returns [] for recurrenceType = none', () => {
    const result = expandEventOccurrences(
      { date: dt(2026, 5, 1, 18, 30).toISOString(), recurrenceType: 'none' },
      { now: dt(2026, 4, 1), horizon: dt(2026, 7, 1) },
    )
    expect(result).toEqual([])
  })

  it('returns [] when event.date is missing or invalid', () => {
    expect(
      expandEventOccurrences(
        { recurrenceType: 'weekly' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) },
      ),
    ).toEqual([])
    expect(
      expandEventOccurrences(
        { date: 'not-a-date', recurrenceType: 'weekly' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) },
      ),
    ).toEqual([])
  })

  describe('daily', () => {
    it('yields every day from event.date to horizon at the event HH:mm', () => {
      const eventDate = dt(2026, 4, 10, 18, 30) // Fri
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'daily' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 4, 14, 23, 59) },
      )
      expect(result.map((d) => d.getDate())).toEqual([10, 11, 12, 13, 14])
      for (const d of result) {
        expect(d.getHours()).toBe(18)
        expect(d.getMinutes()).toBe(30)
      }
    })

    it('starts from now when event.date is before now', () => {
      // Past event.date — enumeration should start at `now`, not backfill.
      const eventDate = dt(2026, 4, 1, 9, 0)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'daily' },
        { now: dt(2026, 4, 10, 8, 0), horizon: dt(2026, 4, 12, 23, 59) },
      )
      // `after` is now=04-10 08:00; daily occurrences at 09:00 fall on
      // the 10th, 11th, 12th.
      expect(result.map((d) => `${d.getDate()} ${d.getHours()}:${d.getMinutes()}`))
        .toEqual(['10 9:0', '11 9:0', '12 9:0'])
    })

    it('returns [] when horizon < after', () => {
      const eventDate = dt(2026, 4, 10, 18, 30)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'daily' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 4, 9) },
      )
      expect(result).toEqual([])
    })
  })

  describe('weekly', () => {
    it('derives the weekday from event.date', () => {
      // 2026-04-07 is a Tuesday
      const eventDate = dt(2026, 4, 7, 19, 0)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'weekly' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) },
      )
      expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28])
      for (const d of result) {
        expect(d.getDay()).toBe(2) // Tuesday
        expect(d.getHours()).toBe(19)
        expect(d.getMinutes()).toBe(0)
      }
    })
  })

  describe('biweekly', () => {
    it('anchors parity on event.date', () => {
      const eventDate = dt(2026, 4, 7, 19, 0) // Tuesday
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'biweekly' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30, 23, 59) },
      )
      // From 04-07: 04-07, 04-21, 05-05, 05-19, 06-02, 06-16, 06-30
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '4-7',
        '4-21',
        '5-5',
        '5-19',
        '6-2',
        '6-16',
        '6-30',
      ])
    })
  })

  describe('monthlyByDate', () => {
    it('uses event.date.getDate() as the day-of-month', () => {
      const eventDate = dt(2026, 4, 13, 18, 30)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) },
      )
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '4-13',
        '5-13',
        '6-13',
        '7-13',
      ])
      for (const d of result) {
        expect(d.getHours()).toBe(18)
        expect(d.getMinutes()).toBe(30)
      }
    })

    it('skips months without the required day (the 31st)', () => {
      const eventDate = dt(2026, 1, 31, 20, 0)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
        { now: dt(2026, 1, 1), horizon: dt(2026, 5, 31, 23, 59) },
      )
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '1-31',
        '3-31',
        '5-31',
      ])
    })
  })

  describe('monthlyByWeekday', () => {
    it('derives weekday + weekOfMonth from event.date', () => {
      // 2026-04-13 is the 2nd Monday of April
      const eventDate = dt(2026, 4, 13, 20, 0)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) },
      )
      // 2nd Monday of each month: Apr 13, May 11, Jun 8, Jul 13
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '4-13',
        '5-11',
        '6-8',
        '7-13',
      ])
      for (const d of result) {
        expect(d.getDay()).toBe(1) // Monday
      }
    })

    it('clamps a 5th-occurrence event.date to "fourth"', () => {
      // 2026-03-30 is the 5th Monday of March.
      // Expected derivation: 'fourth' Monday (not 'last') — see weekOfMonthOf rationale.
      const eventDate = dt(2026, 3, 30, 20, 0)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' },
        { now: dt(2026, 3, 1), horizon: dt(2026, 6, 30) },
      )
      // 4th Monday of each month: Mar 23 (before now? no — now is Mar 1),
      // Apr 27, May 25, Jun 22. March 23 is before event.date though.
      // Since `after = max(now, eventDate) = 2026-03-30`, March's 23rd is
      // excluded. So: Apr 27, May 25, Jun 22.
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '4-27',
        '5-25',
        '6-22',
      ])
    })
  })

  describe('custom', () => {
    it('merges two monthly rules, sorted and deduplicated', () => {
      // 3rd Tuesday AND 4th Thursday of each month Apr-Jun 2026.
      // Apr: Tue 21, Thu 23  →  4-21, 4-23
      // May: Tue 19, Thu 28  →  5-19, 5-28
      // Jun: Tue 16, Thu 25  →  6-16, 6-25
      const rules: RecurrenceRule[] = [
        { frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
        { frequency: 'monthlyByWeekday', weekday: 'thursday', weekOfMonth: 'fourth' },
      ]
      const eventDate = dt(2026, 4, 21, 18, 30) // seed
      const result = expandEventOccurrences(
        {
          date: eventDate.toISOString(),
          recurrenceType: 'custom',
          recurrenceRules: rules,
        },
        { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
      )
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '4-21',
        '4-23',
        '5-19',
        '5-28',
        '6-16',
        '6-25',
      ])
      for (const d of result) {
        expect(d.getHours()).toBe(18)
        expect(d.getMinutes()).toBe(30)
      }
    })

    it('deduplicates when rules overlap', () => {
      // Every Tuesday AND 3rd Tuesday: the 3rd-Tuesday dates are subsumed
      // by weekly, so the output equals the weekly sequence alone.
      const rules: RecurrenceRule[] = [
        { frequency: 'weekly', weekday: 'tuesday' },
        { frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
      ]
      const eventDate = dt(2026, 4, 7, 18, 30)
      const result = expandEventOccurrences(
        {
          date: eventDate.toISOString(),
          recurrenceType: 'custom',
          recurrenceRules: rules,
        },
        { now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) },
      )
      expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28])
    })

    it('drops rule rows with missing dependent fields rather than throwing', () => {
      const rules: RecurrenceRule[] = [
        { frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
        // Incomplete: weekOfMonth missing — should be dropped.
        { frequency: 'monthlyByWeekday', weekday: 'friday' },
        // Incomplete: dayOfMonth missing — should be dropped.
        { frequency: 'monthlyByDate' },
      ]
      const eventDate = dt(2026, 4, 21, 18, 30)
      const result = expandEventOccurrences(
        {
          date: eventDate.toISOString(),
          recurrenceType: 'custom',
          recurrenceRules: rules,
        },
        { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
      )
      // Only the first (valid) rule contributes.
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '4-21',
        '5-19',
        '6-16',
      ])
    })

    it('returns [] when recurrenceType=custom but recurrenceRules is empty or missing', () => {
      const eventDate = dt(2026, 4, 13, 18, 30)
      expect(
        expandEventOccurrences(
          {
            date: eventDate.toISOString(),
            recurrenceType: 'custom',
            recurrenceRules: [],
          },
          { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
        ),
      ).toEqual([])
      expect(
        expandEventOccurrences(
          { date: eventDate.toISOString(), recurrenceType: 'custom' },
          { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
        ),
      ).toEqual([])
    })
  })

  describe('horizon truncation (caller-supplied endDate clipping)', () => {
    it('stops at horizon', () => {
      const eventDate = dt(2026, 4, 13, 18, 30)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
        { now: dt(2026, 4, 1), horizon: dt(2026, 5, 31, 23, 59) },
      )
      expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
        '4-13',
        '5-13',
      ])
    })
  })

  describe('time-of-day invariant', () => {
    it('every occurrence has the same local HH:mm as event.date', () => {
      const eventDate = dt(2026, 4, 7, 15, 45)
      const result = expandEventOccurrences(
        { date: eventDate.toISOString(), recurrenceType: 'weekly' },
        { now: dt(2026, 3, 1), horizon: dt(2026, 11, 30) },
      )
      expect(result.length).toBeGreaterThan(20) // spans the DST transitions
      for (const d of result) {
        expect(d.getHours()).toBe(15)
        expect(d.getMinutes()).toBe(45)
      }
    })
  })
})
