import fc from 'fast-check'
import { DateTime } from 'luxon'
import type { DateOverride, WeeklyRule } from '../../schemas/availability-schedule.schema.js'
import { computeSlots, SlotComputeInput } from './slot-computer.js'

// Property-based invariants for the pure slot computer. Rather than asserting a
// hand-picked output, these generate many randomised configurations and assert
// the laws every output MUST obey, regardless of input:
//   1. no returned slot, padded by its buffers, overlaps any busy interval
//   2. every slot start lands on the slotIntervalMinutes grid inside an
//      availability window (in the schedule zone)
//   3. nothing starts before now + minimumNotice, nor on/after the booking
//      horizon (now + maximumDaysInFuture) or the requested window end
// Each run uses several IANA zones (incl. ones that observe DST) so the offset
// math is exercised, not just a fixed UTC offset.

const ZONES = [
  'Europe/Copenhagen',
  'America/New_York',
  'Asia/Tokyo',
  'Australia/Sydney',
  'UTC',
]

// A single interval in minutes-from-midnight, non-empty and within a day.
const intervalArb = fc
  .tuple(fc.integer({ min: 0, max: 1380 }), fc.integer({ min: 15, max: 1440 }))
  .map(([a, len]) => {
    const startMinute = a
    const endMinute = Math.min(1440, a + len)
    return { startMinute, endMinute }
  })
  .filter((i) => i.endMinute > i.startMinute)

const weeklyRulesArb: fc.Arbitrary<WeeklyRule[]> = fc.array(
  fc.record({
    dayOfWeek: fc.integer({ min: 0, max: 6 }),
    intervals: fc.array(intervalArb, { minLength: 0, maxLength: 3 }),
  }),
  { minLength: 0, maxLength: 7 },
)

const dateOverridesArb: fc.Arbitrary<DateOverride[]> = fc.array(
  fc.record({
    // Dates within the generated planning window (June 2026).
    date: fc
      .integer({ min: 1, max: 28 })
      .map((d) => `2026-06-${String(d).padStart(2, '0')}`),
    isUnavailable: fc.boolean(),
    intervals: fc.array(intervalArb, { minLength: 0, maxLength: 3 }),
  }),
  { minLength: 0, maxLength: 4 },
)

// A busy interval, anchored near the planning window, lasting up to 8h.
const busyArb = fc
  .tuple(fc.integer({ min: 0, max: 20 }), fc.integer({ min: 0, max: 1440 }), fc.integer({ min: 15, max: 480 }))
  .map(([dayOffset, minuteOfDay, lenMin]) => {
    const start = new Date(Date.UTC(2026, 5, 1, 0, 0, 0) + dayOffset * 86_400_000 + minuteOfDay * 60_000)
    const end = new Date(start.getTime() + lenMin * 60_000)
    return { startUtc: start, endUtc: end }
  })

const inputArb: fc.Arbitrary<SlotComputeInput> = fc
  .record({
    durationMinutes: fc.constantFrom(15, 30, 45, 60, 90),
    slotIntervalMinutes: fc.constantFrom(15, 30, 60),
    bufferBeforeMinutes: fc.constantFrom(0, 5, 10, 15, 30),
    bufferAfterMinutes: fc.constantFrom(0, 5, 10, 15, 30),
    minimumNoticeMinutes: fc.constantFrom(0, 60, 120, 720),
    maximumDaysInFuture: fc.integer({ min: 1, max: 21 }),
    scheduleTimezone: fc.constantFrom(...ZONES),
    weeklyRules: weeklyRulesArb,
    dateOverrides: dateOverridesArb,
    busy: fc.array(busyArb, { minLength: 0, maxLength: 6 }),
  })
  .map((cfg) => ({
    ...cfg,
    now: new Date('2026-06-01T00:00:00Z'),
    fromUtc: new Date('2026-06-01T00:00:00Z'),
    toUtc: new Date('2026-06-22T00:00:00Z'),
  }))

// Reconstruct, for a given slot, the local availability intervals that applied
// to its date (override wins over weekly rule), mirroring the computer's logic.
function intervalsForSlot(input: SlotComputeInput, startUtc: Date): { startMinute: number; endMinute: number }[] {
  const local = DateTime.fromJSDate(startUtc, { zone: input.scheduleTimezone })
  const isoDate = local.toFormat('yyyy-MM-dd')
  // Mirror the computer's `new Map(...)` semantics exactly: on duplicate dates,
  // the LAST override wins (the Map keeps the final entry for a key).
  const byDate = new Map(input.dateOverrides.map((o) => [o.date, o]))
  const override = byDate.get(isoDate)
  if (override) return override.isUnavailable ? [] : override.intervals ?? []
  const jsDow = local.weekday % 7
  return input.weeklyRules.filter((r) => r.dayOfWeek === jsDow).flatMap((r) => r.intervals ?? [])
}

describe('computeSlots — properties', () => {
  it('never returns a slot whose buffered window overlaps a busy interval', () => {
    fc.assert(
      fc.property(inputArb, (input) => {
        const slots = computeSlots(input)
        for (const s of slots) {
          const padStart = s.startUtc.getTime() - input.bufferBeforeMinutes * 60_000
          const padEnd = s.endUtc.getTime() + input.bufferAfterMinutes * 60_000
          for (const b of input.busy) {
            // Overlap iff padStart < busyEnd && padEnd > busyStart (touching is OK).
            expect(padStart < b.endUtc.getTime() && padEnd > b.startUtc.getTime()).toBe(false)
          }
        }
      }),
      { numRuns: 300 },
    )
  })

  it('aligns every slot start to the interval grid inside an availability window', () => {
    fc.assert(
      fc.property(inputArb, (input) => {
        const slots = computeSlots(input)
        for (const s of slots) {
          const local = DateTime.fromJSDate(s.startUtc, { zone: input.scheduleTimezone })
          const minuteOfDay = local.hour * 60 + local.minute
          const intervals = intervalsForSlot(input, s.startUtc)
          // The slot must sit fully inside some window, on the interval grid
          // measured from that window's start, and last exactly durationMinutes.
          const fitsSome = intervals.some((intv) => {
            const aligned = (minuteOfDay - intv.startMinute) % input.slotIntervalMinutes === 0
            const within = minuteOfDay >= intv.startMinute && minuteOfDay + input.durationMinutes <= intv.endMinute
            return aligned && within
          })
          expect(local.second).toBe(0)
          expect(local.millisecond).toBe(0)
          expect(fitsSome).toBe(true)
          expect(s.endUtc.getTime() - s.startUtc.getTime()).toBe(input.durationMinutes * 60_000)
        }
      }),
      { numRuns: 300 },
    )
  })

  it('keeps every slot within [now+minNotice, horizon) and the requested window', () => {
    fc.assert(
      fc.property(inputArb, (input) => {
        const slots = computeSlots(input)
        const earliest = Math.max(
          input.now.getTime() + input.minimumNoticeMinutes * 60_000,
          input.fromUtc.getTime(),
        )
        const horizon = input.now.getTime() + input.maximumDaysInFuture * 86_400_000
        const latest = Math.min(horizon, input.toUtc.getTime())
        for (const s of slots) {
          expect(s.startUtc.getTime()).toBeGreaterThanOrEqual(earliest)
          expect(s.startUtc.getTime()).toBeLessThan(latest)
        }
      }),
      { numRuns: 300 },
    )
  })

  it('returns strictly sorted, de-duplicated slot starts', () => {
    fc.assert(
      fc.property(inputArb, (input) => {
        const starts = computeSlots(input).map((s) => s.startUtc.getTime())
        for (let i = 1; i < starts.length; i++) {
          expect(starts[i]).toBeGreaterThan(starts[i - 1])
        }
      }),
      { numRuns: 200 },
    )
  })
})
