import { describe, expect, it } from 'vitest';
import { DEFAULT_CHAMPIONSHIP_PLAN as ENGINE_PLAN } from '../../engine/src/types';
import {
  DEFAULT_CHAMPIONSHIP_PLAN,
  DEFAULT_VENUE_SETTINGS,
  businessDate,
  isWithinSchedule,
  nextOpening,
  parseEventConfig,
  parseVenueSettings,
  zonedDateTimeToEpoch,
  type ScheduleSlot,
  type VenueSettings,
} from '../src/settings';

const NY = 'America/New_York';
const at = (iso: string) => Date.parse(iso);

function settingsWith(schedule: ScheduleSlot[], enabled = true): VenueSettings {
  return parseVenueSettings({ alwaysOn: { enabled, schedule } });
}

describe('VenueSettings', () => {
  it('fills defaults for null, {} and partial objects', () => {
    expect(parseVenueSettings(null)).toEqual({
      alwaysOn: { enabled: false, packKey: 'originals-v1', schedule: [] },
      mergeSolos: false,
    });
    expect(parseVenueSettings({})).toEqual(DEFAULT_VENUE_SETTINGS);
    expect(parseVenueSettings({ alwaysOn: { enabled: true } }).alwaysOn).toEqual({
      enabled: true,
      packKey: 'originals-v1',
      schedule: [],
    });
    expect(parseVenueSettings({ mergeSolos: true }).mergeSolos).toBe(true);
  });

  it('accepts 24:00 as a closing time only', () => {
    expect(() => parseVenueSettings({ alwaysOn: { schedule: [{ day: 0, open: '00:00', close: '24:00' }] } })).not.toThrow();
    expect(() => parseVenueSettings({ alwaysOn: { schedule: [{ day: 0, open: '24:00', close: '02:00' }] } })).toThrow();
  });

  it('rejects malformed slots', () => {
    for (const slot of [
      { day: 7, open: '11:00', close: '02:00' },
      { day: -1, open: '11:00', close: '02:00' },
      { day: 1.5, open: '11:00', close: '02:00' },
      { day: 1, open: '9:00', close: '17:00' },
      { day: 1, open: '11:00', close: '24:30' },
      { day: 1, open: '11:60', close: '23:00' },
    ]) {
      expect(() => parseVenueSettings({ alwaysOn: { schedule: [slot] } }), JSON.stringify(slot)).toThrow();
    }
  });
});

describe('EventConfig', () => {
  it('defaults to the championship plan, identical to the engine contract', () => {
    const config = parseEventConfig({});
    expect(config.plan).toEqual(ENGINE_PLAN);
    expect(DEFAULT_CHAMPIONSHIP_PLAN).toEqual(ENGINE_PLAN);
    expect(config.mergeSolos).toBe(false);
    expect(config.timings).toBeUndefined();
    expect(parseEventConfig(null)).toEqual(config);
  });

  it('returns a fresh plan each time (mutating one never changes the default)', () => {
    const a = parseEventConfig({});
    a.plan[0]!.label = 'Changed';
    expect(parseEventConfig({}).plan[0]!.label).toBe('The Rush');
  });

  it('accepts partial timing overrides and validates them', () => {
    expect(parseEventConfig({ timings: { windowMs: 20_000 } }).timings).toEqual({ windowMs: 20_000 });
    expect(() => parseEventConfig({ timings: { windowMs: 500 } })).toThrow();
    expect(() => parseEventConfig({ timings: { windowMs: 5_000, graceMs: 5_000 } })).toThrow();
  });

  it('rejects unknown round kinds and empty plans', () => {
    expect(() => parseEventConfig({ plan: [] })).toThrow();
    expect(() => parseEventConfig({ plan: [{ kind: 'KARAOKE', packKey: 'originals-v1', label: 'X', segment: 0 }] })).toThrow();
  });
});

describe('businessDate', () => {
  it('counts the hours before 05:00 local as the previous date', () => {
    expect(businessDate(NY, at('2026-10-30T05:30:00Z'))).toBe('2026-10-29'); // Fri 01:30 EDT
    expect(businessDate(NY, at('2026-10-30T08:59:59Z'))).toBe('2026-10-29'); // 04:59:59 EDT
    expect(businessDate(NY, at('2026-10-30T09:00:00Z'))).toBe('2026-10-30'); // 05:00 EDT
    expect(businessDate(NY, at('2026-10-30T03:59:00Z'))).toBe('2026-10-29'); // Thu 23:59 EDT
  });

  it('respects a custom cutoff', () => {
    expect(businessDate(NY, at('2026-10-30T05:30:00Z'), 0)).toBe('2026-10-30');
    expect(businessDate(NY, at('2026-10-30T05:30:00Z'), 1)).toBe('2026-10-30'); // 01:30 is not before 01:00
    expect(businessDate(NY, at('2026-10-30T05:30:00Z'), 2)).toBe('2026-10-29');
  });

  it('crosses month and year boundaries', () => {
    expect(businessDate(NY, at('2027-01-01T06:00:00Z'))).toBe('2026-12-31'); // Jan 1 01:00 EST
    expect(businessDate(NY, at('2026-03-01T07:00:00Z'))).toBe('2026-02-28');
  });

  it('is DST-safe on the spring-forward night (2026-03-08, 23 h day)', () => {
    expect(businessDate(NY, at('2026-03-08T06:30:00Z'))).toBe('2026-03-07'); // 01:30 EST
    expect(businessDate(NY, at('2026-03-08T07:30:00Z'))).toBe('2026-03-07'); // 03:30 EDT
    expect(businessDate(NY, at('2026-03-08T08:59:00Z'))).toBe('2026-03-07'); // 04:59 EDT
    expect(businessDate(NY, at('2026-03-08T09:00:00Z'))).toBe('2026-03-08'); // 05:00 EDT
  });

  it('is DST-safe on the fall-back night (2026-11-01, 25 h day)', () => {
    expect(businessDate(NY, at('2026-11-01T05:30:00Z'))).toBe('2026-10-31'); // first 01:30 (EDT)
    expect(businessDate(NY, at('2026-11-01T06:30:00Z'))).toBe('2026-10-31'); // second 01:30 (EST)
    expect(businessDate(NY, at('2026-11-01T09:59:00Z'))).toBe('2026-10-31'); // 04:59 EST
    expect(businessDate(NY, at('2026-11-01T10:00:00Z'))).toBe('2026-11-01'); // 05:00 EST
  });

  it('works for zones ahead of UTC', () => {
    expect(businessDate('Asia/Tokyo', at('2026-10-29T17:00:00Z'))).toBe('2026-10-29'); // Oct 30 02:00 JST
    expect(businessDate('Asia/Tokyo', at('2026-10-29T20:00:00Z'))).toBe('2026-10-30'); // 05:00 JST
  });

  it('throws for an unknown time zone', () => {
    expect(() => businessDate('Mars/Olympus_Mons', Date.now())).toThrow();
  });
});

describe('isWithinSchedule', () => {
  const friday11to2 = settingsWith([{ day: 5, open: '11:00', close: '02:00' }]);

  it('covers the past-midnight part of a slot on the next day', () => {
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-30T14:59:00Z'))).toBe(false); // Fri 10:59
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-30T15:00:00Z'))).toBe(true); // Fri 11:00
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-31T03:59:00Z'))).toBe(true); // Fri 23:59
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-31T04:00:00Z'))).toBe(true); // Sat 00:00
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-31T05:30:00Z'))).toBe(true); // Sat 01:30
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-31T06:00:00Z'))).toBe(false); // Sat 02:00
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-31T15:30:00Z'))).toBe(false); // Sat 11:30 (no Sat slot)
    expect(isWithinSchedule(friday11to2, NY, at('2026-10-30T05:30:00Z'))).toBe(false); // Fri 01:30 (Thu not open)
  });

  it('wraps Saturday night into Sunday morning', () => {
    const s = settingsWith([{ day: 6, open: '20:00', close: '03:00' }]);
    expect(isWithinSchedule(s, NY, at('2026-11-01T06:30:00Z'))).toBe(true); // Sun 01:30 EST
    expect(isWithinSchedule(s, NY, at('2026-11-01T08:30:00Z'))).toBe(false); // Sun 03:30 EST
  });

  it('treats 00:00–24:00 every day as always open', () => {
    const always = settingsWith([0, 1, 2, 3, 4, 5, 6].map((day) => ({ day, open: '00:00', close: '24:00' })));
    for (const iso of ['2026-10-29T04:00:00Z', '2026-10-29T03:59:59Z', '2026-03-08T07:00:00Z', '2026-11-01T06:30:00Z']) {
      expect(isWithinSchedule(always, NY, at(iso)), iso).toBe(true);
    }
  });

  it('is false when always-on is disabled or there are no slots', () => {
    expect(isWithinSchedule(settingsWith([{ day: 5, open: '11:00', close: '02:00' }], false), NY, at('2026-10-30T16:00:00Z'))).toBe(false);
    expect(isWithinSchedule(settingsWith([]), NY, at('2026-10-30T16:00:00Z'))).toBe(false);
  });

  it('keeps local hours across DST changes', () => {
    const satNight = settingsWith([{ day: 6, open: '11:00', close: '02:00' }]);
    // Fall back: both 01:30s are inside, 02:00 EST (07:00Z) is closed.
    expect(isWithinSchedule(satNight, NY, at('2026-11-01T05:30:00Z'))).toBe(true);
    expect(isWithinSchedule(satNight, NY, at('2026-11-01T06:30:00Z'))).toBe(true);
    expect(isWithinSchedule(satNight, NY, at('2026-11-01T07:00:00Z'))).toBe(false);
    // Spring forward: 01:59 EST open, the next minute is 03:00 EDT and closed.
    expect(isWithinSchedule(satNight, NY, at('2026-03-08T06:59:00Z'))).toBe(true);
    expect(isWithinSchedule(satNight, NY, at('2026-03-08T07:00:00Z'))).toBe(false);
    // 11:00 local is 15:00Z in EDT and 16:00Z in EST.
    expect(isWithinSchedule(satNight, NY, at('2026-10-31T15:00:00Z'))).toBe(true);
    expect(isWithinSchedule(satNight, NY, at('2026-11-07T15:30:00Z'))).toBe(false);
    expect(isWithinSchedule(satNight, NY, at('2026-11-07T16:00:00Z'))).toBe(true);
  });
});

describe('local wall-clock conversion and nextOpening', () => {
  it('resolves DST gaps forward and overlaps to the first occurrence', () => {
    expect(zonedDateTimeToEpoch(NY, '2026-10-30', '21:00')).toBe(at('2026-10-31T01:00:00Z'));
    expect(zonedDateTimeToEpoch(NY, '2026-11-01', '01:30')).toBe(at('2026-11-01T05:30:00Z'));
    const gap = zonedDateTimeToEpoch(NY, '2026-03-08', '02:30');
    expect(gap).toBe(at('2026-03-08T07:00:00Z')); // the clock jumps 02:00 → 03:00 EDT
    expect(zonedDateTimeToEpoch(NY, '2026-10-30', '24:00')).toBe(at('2026-10-31T04:00:00Z'));
  });

  it('finds the next opening across days and DST', () => {
    const daily = settingsWith([0, 1, 2, 3, 4, 5, 6].map((day) => ({ day, open: '11:00', close: '02:00' })));
    // Thursday 10:00 EDT → today 11:00.
    expect(nextOpening(daily, NY, at('2026-10-29T14:00:00Z'))).toBe(at('2026-10-29T15:00:00Z'));
    // Already open → now.
    const open = at('2026-10-29T18:00:00Z');
    expect(nextOpening(daily, NY, open)).toBe(open);
    // Sunday 03:00 EST after fall-back → Sunday 11:00 EST (16:00Z).
    expect(nextOpening(daily, NY, at('2026-11-01T08:00:00Z'))).toBe(at('2026-11-01T16:00:00Z'));
    // Weekly slot only on Friday: from Saturday it is six days ahead.
    const fridays = settingsWith([{ day: 5, open: '18:00', close: '23:00' }]);
    expect(nextOpening(fridays, NY, at('2026-10-31T16:00:00Z'))).toBe(at('2026-11-06T23:00:00Z'));
  });

  it('is null when disabled or empty', () => {
    expect(nextOpening(settingsWith([{ day: 1, open: '11:00', close: '12:00' }], false), NY, Date.now())).toBeNull();
    expect(nextOpening(settingsWith([]), NY, Date.now())).toBeNull();
  });
});
