import { describe, expect, it } from "vitest";
import {
  describeRecurrence,
  expandOccurrences,
  formatRecurrenceRule,
  fromWallClock,
  parseRecurrenceRule,
  toWallClock,
} from "./recurrence";

/** Renders an occurrence as the local wall-clock time a user would read on
 * the calendar — the assertion that actually catches DST drift, which an
 * ISO/UTC comparison hides. */
function localTimes(occurrences: Array<{ start: Date }>, timeZone: string) {
  return occurrences.map((o) => {
    const w = toWallClock(o.start, timeZone);
    const pad = (n: number) => String(n).padStart(2, "0");
    return `${w.year}-${pad(w.month)}-${pad(w.day)} ${pad(w.hour)}:${pad(w.minute)}`;
  });
}

describe("parseRecurrenceRule", () => {
  it("parses a full weekly rule", () => {
    expect(parseRecurrenceRule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=6")).toEqual({
      freq: "weekly",
      interval: 2,
      byDay: ["MO", "WE"],
      count: 6,
    });
  });

  it("defaults interval to 1 and ignores junk", () => {
    expect(parseRecurrenceRule("FREQ=DAILY;INTERVAL=nonsense;BYDAY=XX")).toEqual({ freq: "daily", interval: 1 });
  });

  it("returns null rather than throwing for unusable input", () => {
    expect(parseRecurrenceRule(null)).toBeNull();
    expect(parseRecurrenceRule("")).toBeNull();
    expect(parseRecurrenceRule("EVERY=TUESDAY")).toBeNull();
    expect(parseRecurrenceRule("FREQ=YEARLY")).toBeNull();
  });

  it("round-trips through formatRecurrenceRule", () => {
    const text = "FREQ=WEEKLY;INTERVAL=3;BYDAY=TU,TH;COUNT=4";
    expect(formatRecurrenceRule(parseRecurrenceRule(text)!)).toBe(text);
  });
});

describe("wall-clock conversion", () => {
  it("round-trips an instant through a DST-observing zone", () => {
    const instant = new Date("2026-07-04T13:30:00.000Z");
    const wall = toWallClock(instant, "America/New_York");
    expect(wall).toMatchObject({ year: 2026, month: 7, day: 4, hour: 9, minute: 30 });
    expect(fromWallClock(wall, "America/New_York").toISOString()).toBe(instant.toISOString());
  });

  it("resolves the same wall clock to different instants either side of a DST change", () => {
    const march = fromWallClock({ year: 2026, month: 3, day: 2, hour: 9, minute: 0, second: 0 }, "America/New_York");
    const january = fromWallClock({ year: 2026, month: 1, day: 2, hour: 9, minute: 0, second: 0 }, "America/New_York");
    expect(january.toISOString()).toBe("2026-01-02T14:00:00.000Z"); // EST, UTC-5
    expect(march.toISOString()).toBe("2026-03-02T14:00:00.000Z"); // still EST in early March
    const april = fromWallClock({ year: 2026, month: 4, day: 2, hour: 9, minute: 0, second: 0 }, "America/New_York");
    expect(april.toISOString()).toBe("2026-04-02T13:00:00.000Z"); // EDT, UTC-4
  });

  it("handles a half-hour offset zone", () => {
    const wall = { year: 2026, month: 6, day: 1, hour: 9, minute: 0, second: 0 };
    expect(fromWallClock(wall, "Asia/Kolkata").toISOString()).toBe("2026-06-01T03:30:00.000Z");
  });
});

describe("expandOccurrences", () => {
  const tz = "Asia/Karachi"; // no DST — the simple baseline

  it("yields a single occurrence for a non-recurring event in window", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-08-05T04:00:00.000Z", endsAt: "2026-08-05T05:00:00.000Z", timezone: tz },
      new Date("2026-08-03T00:00:00.000Z"),
      new Date("2026-08-10T00:00:00.000Z"),
    );
    expect(occ).toHaveLength(1);
    expect(occ[0]!.occurrenceDate).toBe("2026-08-05");
  });

  it("excludes a non-recurring event outside the window", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-09-05T04:00:00.000Z", endsAt: "2026-09-05T05:00:00.000Z", timezone: tz },
      new Date("2026-08-03T00:00:00.000Z"),
      new Date("2026-08-10T00:00:00.000Z"),
    );
    expect(occ).toHaveLength(0);
  });

  it("includes an event that started before the window but overlaps into it", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-08-02T22:00:00.000Z", endsAt: "2026-08-03T02:00:00.000Z", timezone: tz },
      new Date("2026-08-03T00:00:00.000Z"),
      new Date("2026-08-10T00:00:00.000Z"),
    );
    expect(occ).toHaveLength(1);
  });

  it("expands a daily series across the window only", () => {
    const occ = expandOccurrences(
      {
        startsAt: "2026-08-03T04:00:00.000Z",
        endsAt: "2026-08-03T04:30:00.000Z",
        timezone: tz,
        rule: "FREQ=DAILY",
      },
      new Date("2026-08-03T00:00:00.000Z"),
      new Date("2026-08-07T00:00:00.000Z"),
    );
    expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-08-03", "2026-08-04", "2026-08-05", "2026-08-06"]);
  });

  it("respects INTERVAL on a daily series", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-08-03T04:00:00.000Z", endsAt: "2026-08-03T05:00:00.000Z", timezone: tz, rule: "FREQ=DAILY;INTERVAL=3" },
      new Date("2026-08-01T00:00:00.000Z"),
      new Date("2026-08-15T00:00:00.000Z"),
    );
    expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-08-03", "2026-08-06", "2026-08-09", "2026-08-12"]);
  });

  it("expands weekly BYDAY onto every named weekday", () => {
    // 2026-08-03 is a Monday.
    const occ = expandOccurrences(
      {
        startsAt: "2026-08-03T04:00:00.000Z",
        endsAt: "2026-08-03T04:30:00.000Z",
        timezone: tz,
        rule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
      },
      new Date("2026-08-03T00:00:00.000Z"),
      new Date("2026-08-15T00:00:00.000Z"),
    );
    expect(occ.map((o) => o.occurrenceDate)).toEqual([
      "2026-08-03",
      "2026-08-05",
      "2026-08-07",
      "2026-08-10",
      "2026-08-12",
      "2026-08-14",
    ]);
  });

  it("does not emit BYDAY days that precede the series start in the seed week", () => {
    // Series starts Wednesday; Monday of that same week must not appear.
    const occ = expandOccurrences(
      {
        startsAt: "2026-08-05T04:00:00.000Z",
        endsAt: "2026-08-05T04:30:00.000Z",
        timezone: tz,
        rule: "FREQ=WEEKLY;BYDAY=MO,WE",
      },
      new Date("2026-08-01T00:00:00.000Z"),
      new Date("2026-08-13T00:00:00.000Z"),
    );
    expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-08-05", "2026-08-10", "2026-08-12"]);
  });

  it("derives the seed weekday in the event's zone, not UTC", () => {
    // 2026-08-03T21:00Z is Tuesday 09:00 in Auckland but still MONDAY in UTC.
    // A weekly series with no BYDAY must repeat on Tuesdays (the local
    // weekday), and BYDAY=TU must match the seed rather than skipping a week.
    const auckland = "Pacific/Auckland";
    const implicit = expandOccurrences(
      { startsAt: "2026-08-03T21:00:00.000Z", endsAt: "2026-08-03T21:30:00.000Z", timezone: auckland, rule: "FREQ=WEEKLY" },
      new Date("2026-08-01T00:00:00.000Z"),
      new Date("2026-08-20T00:00:00.000Z"),
    );
    expect(localTimes(implicit, auckland)).toEqual([
      "2026-08-04 09:00",
      "2026-08-11 09:00",
      "2026-08-18 09:00",
    ]);

    const explicit = expandOccurrences(
      { startsAt: "2026-08-03T21:00:00.000Z", endsAt: "2026-08-03T21:30:00.000Z", timezone: auckland, rule: "FREQ=WEEKLY;BYDAY=TU" },
      new Date("2026-08-01T00:00:00.000Z"),
      new Date("2026-08-20T00:00:00.000Z"),
    );
    expect(explicit.map((o) => o.occurrenceDate)).toEqual(["2026-08-04", "2026-08-11", "2026-08-18"]);
  });

  it("keeps the local time fixed across a spring-forward DST boundary", () => {
    // US DST starts 2026-03-08. A 9am weekly meeting must stay 9am local,
    // even though its UTC instant shifts by an hour.
    const occ = expandOccurrences(
      {
        startsAt: "2026-03-02T14:00:00.000Z", // 09:00 EST
        endsAt: "2026-03-02T14:30:00.000Z",
        timezone: "America/New_York",
        rule: "FREQ=WEEKLY;BYDAY=MO",
      },
      new Date("2026-03-01T00:00:00.000Z"),
      new Date("2026-03-31T00:00:00.000Z"),
    );
    expect(localTimes(occ, "America/New_York")).toEqual([
      "2026-03-02 09:00",
      "2026-03-09 09:00",
      "2026-03-16 09:00",
      "2026-03-23 09:00",
      "2026-03-30 09:00",
    ]);
    // Proof the instants really did shift: pre-DST is 14:00Z, post is 13:00Z.
    expect(occ[0]!.start.toISOString()).toBe("2026-03-02T14:00:00.000Z");
    expect(occ[1]!.start.toISOString()).toBe("2026-03-09T13:00:00.000Z");
  });

  it("keeps the local time fixed across a fall-back DST boundary", () => {
    // US DST ends 2026-11-01.
    const occ = expandOccurrences(
      {
        startsAt: "2026-10-26T13:00:00.000Z", // 09:00 EDT
        endsAt: "2026-10-26T13:30:00.000Z",
        timezone: "America/New_York",
        rule: "FREQ=WEEKLY;BYDAY=MO",
      },
      new Date("2026-10-25T00:00:00.000Z"),
      new Date("2026-11-16T00:00:00.000Z"),
    );
    expect(localTimes(occ, "America/New_York")).toEqual([
      "2026-10-26 09:00",
      "2026-11-02 09:00",
      "2026-11-09 09:00",
    ]);
    expect(occ[1]!.start.toISOString()).toBe("2026-11-02T14:00:00.000Z");
  });

  it("preserves duration as an instant delta across a DST boundary", () => {
    const occ = expandOccurrences(
      {
        startsAt: "2026-03-02T14:00:00.000Z",
        endsAt: "2026-03-02T14:45:00.000Z",
        timezone: "America/New_York",
        rule: "FREQ=WEEKLY;BYDAY=MO",
      },
      new Date("2026-03-01T00:00:00.000Z"),
      new Date("2026-03-20T00:00:00.000Z"),
    );
    for (const o of occ) {
      expect(o.end.getTime() - o.start.getTime()).toBe(45 * 60 * 1000);
    }
  });

  it("clamps a monthly series to short months", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-01-31T09:00:00.000Z", endsAt: "2026-01-31T10:00:00.000Z", timezone: "UTC", rule: "FREQ=MONTHLY" },
      new Date("2026-01-01T00:00:00.000Z"),
      new Date("2026-05-01T00:00:00.000Z"),
    );
    expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30"]);
  });

  it("stops at COUNT even when the window extends further", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-08-03T04:00:00.000Z", endsAt: "2026-08-03T05:00:00.000Z", timezone: tz, rule: "FREQ=DAILY;COUNT=3" },
      new Date("2026-08-01T00:00:00.000Z"),
      new Date("2026-08-20T00:00:00.000Z"),
    );
    expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-08-03", "2026-08-04", "2026-08-05"]);
  });

  it("counts COUNT from the series start, not from the window start", () => {
    // COUNT=3 means occurrences 1-3 exist; a window over days 4-6 is empty.
    const occ = expandOccurrences(
      { startsAt: "2026-08-03T04:00:00.000Z", endsAt: "2026-08-03T05:00:00.000Z", timezone: tz, rule: "FREQ=DAILY;COUNT=3" },
      new Date("2026-08-07T00:00:00.000Z"),
      new Date("2026-08-20T00:00:00.000Z"),
    );
    expect(occ).toHaveLength(0);
  });

  it("stops at UNTIL", () => {
    const occ = expandOccurrences(
      {
        startsAt: "2026-08-03T04:00:00.000Z",
        endsAt: "2026-08-03T05:00:00.000Z",
        timezone: tz,
        rule: "FREQ=DAILY;UNTIL=2026-08-05T23:59:59.000Z",
      },
      new Date("2026-08-01T00:00:00.000Z"),
      new Date("2026-08-20T00:00:00.000Z"),
    );
    expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-08-03", "2026-08-04", "2026-08-05"]);
  });

  it("caps runaway expansion", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-01-01T00:00:00.000Z", endsAt: "2026-01-01T00:30:00.000Z", timezone: "UTC", rule: "FREQ=DAILY" },
      new Date("2026-01-01T00:00:00.000Z"),
      new Date("2030-01-01T00:00:00.000Z"),
      10,
    );
    expect(occ).toHaveLength(10);
  });

  it("survives a malformed rule by treating the event as one-off", () => {
    const occ = expandOccurrences(
      { startsAt: "2026-08-05T04:00:00.000Z", endsAt: "2026-08-05T05:00:00.000Z", timezone: tz, rule: "FREQ=FORTNIGHTLY" },
      new Date("2026-08-01T00:00:00.000Z"),
      new Date("2026-08-20T00:00:00.000Z"),
    );
    expect(occ).toHaveLength(1);
  });
});

describe("describeRecurrence", () => {
  it("labels the cases the modal offers", () => {
    expect(describeRecurrence(null)).toBe("Does not repeat");
    expect(describeRecurrence(parseRecurrenceRule("FREQ=DAILY"))).toBe("Daily");
    expect(describeRecurrence(parseRecurrenceRule("FREQ=WEEKLY;BYDAY=MO,WE"))).toBe("Weekly on Mon, Wed");
    expect(describeRecurrence(parseRecurrenceRule("FREQ=WEEKLY;INTERVAL=2"))).toBe("Every 2 weeks");
    expect(describeRecurrence(parseRecurrenceRule("FREQ=MONTHLY;COUNT=5"))).toBe("Monthly, 5 times");
    expect(describeRecurrence(parseRecurrenceRule("FREQ=DAILY;UNTIL=2026-09-01T00:00:00.000Z"))).toBe(
      "Daily, until 2026-09-01",
    );
  });
});
