/**
 * Recurrence: an RFC 5545 (iCalendar RRULE) subset, hand-rolled.
 *
 * No dependency, for the same reason the icon set is inlined rather than
 * pulling lucide-react — this needs `FREQ`, `INTERVAL`, `BYDAY`, `COUNT` and
 * `UNTIL`, which is a fraction of what a full RRULE library carries, and
 * the behaviour is worth owning outright because it is where the subtle bugs
 * live.
 *
 * ## Why the timezone matters
 *
 * A weekly 9am standup is not "every 604800000 ms". Cross a DST boundary and
 * the UTC offset changes, so instant arithmetic silently drifts the meeting
 * to 8am or 10am. Occurrences are therefore stepped in **wall-clock** terms
 * inside the event's own IANA zone and only then resolved back to an instant.
 * `Intl.DateTimeFormat` is the offset oracle — available everywhere this code
 * runs (Node ≥20 and every browser target), and the only one that doesn't
 * require shipping a tz database.
 *
 * Occurrences are expanded on read, never materialised, so a series is one
 * row no matter how far out it repeats — but that means every read pays for
 * expansion, which is why the window and occurrence count are both capped.
 */

export type RecurrenceFreq = "daily" | "weekly" | "monthly";

/** Two-letter iCalendar weekday codes, in `Date#getDay` order. */
export const WEEKDAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const;
export type WeekdayCode = (typeof WEEKDAY_CODES)[number];

export interface RecurrenceRule {
  freq: RecurrenceFreq;
  /** Every N periods. Always ≥ 1. */
  interval: number;
  /** Weekly only: which weekdays the series lands on. Empty = the start's own weekday. */
  byDay?: WeekdayCode[];
  /** Stop after N occurrences (mutually exclusive with `until` in practice). */
  count?: number;
  /** Stop at or before this instant (ISO 8601). */
  until?: string;
}

/** Guard rails so an unbounded `FREQ=DAILY` can't produce an unbounded response. */
export const MAX_WINDOW_DAYS = 92;
export const MAX_OCCURRENCES = 400;

const FREQ_BY_KEYWORD: Record<string, RecurrenceFreq> = {
  DAILY: "daily",
  WEEKLY: "weekly",
  MONTHLY: "monthly",
};

/**
 * Parses the stored rule string. Returns null for anything unrecognised —
 * a malformed rule degrades to "this is a one-off event" rather than
 * throwing on read, since the row is already persisted by then and a broken
 * rule must not be able to take down a whole calendar query.
 */
export function parseRecurrenceRule(text: string | null | undefined): RecurrenceRule | null {
  if (!text) return null;
  const parts = new Map<string, string>();
  for (const chunk of text.split(";")) {
    const [rawKey, rawValue] = chunk.split("=");
    if (!rawKey || rawValue === undefined) continue;
    parts.set(rawKey.trim().toUpperCase(), rawValue.trim());
  }

  const freq = FREQ_BY_KEYWORD[(parts.get("FREQ") ?? "").toUpperCase()];
  if (!freq) return null;

  const interval = Number(parts.get("INTERVAL") ?? 1);
  const rule: RecurrenceRule = {
    freq,
    interval: Number.isFinite(interval) && interval >= 1 ? Math.floor(interval) : 1,
  };

  const byDay = (parts.get("BYDAY") ?? "")
    .split(",")
    .map((code) => code.trim().toUpperCase())
    .filter((code): code is WeekdayCode => (WEEKDAY_CODES as readonly string[]).includes(code));
  if (byDay.length > 0) rule.byDay = byDay;

  const count = Number(parts.get("COUNT"));
  if (Number.isFinite(count) && count >= 1) rule.count = Math.floor(count);

  const until = parts.get("UNTIL");
  if (until) {
    const parsed = new Date(until);
    if (!Number.isNaN(parsed.getTime())) rule.until = parsed.toISOString();
  }

  return rule;
}

/** Serialises back to the stored form. Round-trips with parseRecurrenceRule. */
export function formatRecurrenceRule(rule: RecurrenceRule): string {
  const parts = [`FREQ=${rule.freq.toUpperCase()}`];
  if (rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`);
  if (rule.byDay?.length) parts.push(`BYDAY=${rule.byDay.join(",")}`);
  if (rule.count) parts.push(`COUNT=${rule.count}`);
  if (rule.until) parts.push(`UNTIL=${rule.until}`);
  return parts.join(";");
}

const WEEKDAY_LABELS: Record<WeekdayCode, string> = {
  SU: "Sun",
  MO: "Mon",
  TU: "Tue",
  WE: "Wed",
  TH: "Thu",
  FR: "Fri",
  SA: "Sat",
};

/** Human summary for the UI ("Weekly on Mon, Wed"). */
export function describeRecurrence(rule: RecurrenceRule | null): string {
  if (!rule) return "Does not repeat";
  const every = rule.interval > 1 ? `Every ${rule.interval} ` : "";
  let base: string;
  if (rule.freq === "daily") {
    base = rule.interval > 1 ? `${every}days` : "Daily";
  } else if (rule.freq === "weekly") {
    const days = rule.byDay?.length ? ` on ${rule.byDay.map((d) => WEEKDAY_LABELS[d]).join(", ")}` : "";
    base = (rule.interval > 1 ? `${every}weeks` : "Weekly") + days;
  } else {
    base = rule.interval > 1 ? `${every}months` : "Monthly";
  }
  if (rule.count) return `${base}, ${rule.count} times`;
  if (rule.until) return `${base}, until ${rule.until.slice(0, 10)}`;
  return base;
}

// --- wall-clock ↔ instant -------------------------------------------------

export interface WallClock {
  year: number;
  month: number; // 1-12
  day: number;
  hour: number;
  minute: number;
  second: number;
}

const formatterCache = new Map<string, Intl.DateTimeFormat>();

function formatterFor(timeZone: string): Intl.DateTimeFormat {
  let cached = formatterCache.get(timeZone);
  if (!cached) {
    // Cached because constructing a DateTimeFormat is comparatively costly and
    // expansion calls this once per candidate occurrence.
    cached = new Intl.DateTimeFormat("en-US", {
      timeZone,
      hour12: false,
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
    });
    formatterCache.set(timeZone, cached);
  }
  return cached;
}

/** The wall-clock reading of an instant in a given zone. */
export function toWallClock(instant: Date, timeZone: string): WallClock {
  const parts = formatterFor(timeZone).formatToParts(instant);
  const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
  return {
    year: get("year"),
    month: get("month"),
    day: get("day"),
    // Intl renders midnight as hour 24 in some ICU versions; normalise.
    hour: get("hour") % 24,
    minute: get("minute"),
    second: get("second"),
  };
}

/**
 * The instant at which the given wall-clock time occurs in `timeZone`.
 *
 * Solved by iteration rather than a tz table: guess the instant as if the
 * wall clock were UTC, measure how far off the zone's rendering is, correct,
 * and repeat. Converges in one step normally and two across an offset change;
 * the third is belt-and-braces for zones with sub-hour offsets.
 *
 * Nonexistent local times (the DST spring-forward gap) land on the instant
 * just after the jump, and ambiguous ones (the autumn repeat) resolve to the
 * first pass — the same pragmatic choices RFC 5545 implementations make.
 */
export function fromWallClock(wall: WallClock, timeZone: string): Date {
  const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
  let instant = asUtc;
  for (let attempt = 0; attempt < 3; attempt++) {
    const rendered = toWallClock(new Date(instant), timeZone);
    const renderedUtc = Date.UTC(
      rendered.year,
      rendered.month - 1,
      rendered.day,
      rendered.hour,
      rendered.minute,
      rendered.second,
    );
    const drift = asUtc - renderedUtc;
    if (drift === 0) break;
    instant += drift;
  }
  return new Date(instant);
}

/** Weekday index (0=Sunday) of a wall-clock date, computed from the calendar
 * date alone so no timezone or instant is involved. */
function wallClockWeekday(wall: WallClock): number {
  return new Date(Date.UTC(wall.year, wall.month - 1, wall.day)).getUTCDay();
}

function daysInMonth(year: number, month: number): number {
  return new Date(Date.UTC(year, month, 0)).getUTCDate();
}

/** Adds whole days in wall-clock space (never instant arithmetic). */
function addDays(wall: WallClock, days: number): WallClock {
  const shifted = new Date(Date.UTC(wall.year, wall.month - 1, wall.day + days));
  return {
    ...wall,
    year: shifted.getUTCFullYear(),
    month: shifted.getUTCMonth() + 1,
    day: shifted.getUTCDate(),
  };
}

/** Adds whole months, clamping to the target month's length (Jan 31 → Feb 28). */
function addMonths(wall: WallClock, months: number, anchorDay: number): WallClock {
  const total = (wall.year * 12 + (wall.month - 1)) + months;
  const year = Math.floor(total / 12);
  const month = (total % 12) + 1;
  return { ...wall, year, month, day: Math.min(anchorDay, daysInMonth(year, month)) };
}

// --- expansion ------------------------------------------------------------

export interface SeriesInput {
  /** First occurrence, as a stored instant. */
  startsAt: Date | string;
  endsAt: Date | string;
  timezone: string;
  /** Parsed rule, or the raw string — null/absent means a single occurrence. */
  rule?: RecurrenceRule | string | null;
}

export interface Occurrence {
  start: Date;
  end: Date;
  /** The occurrence's own start date (YYYY-MM-DD) in the series timezone —
   * the stable key an override or cancellation is filed under. */
  occurrenceDate: string;
}

function isoDate(wall: WallClock): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}`;
}

/**
 * Expands a series into the concrete occurrences overlapping
 * [windowFrom, windowTo). A non-recurring series yields at most its single
 * occurrence. Duration is preserved as an instant delta: a 30-minute meeting
 * stays 30 minutes even when it crosses a DST boundary.
 */
export function expandOccurrences(
  series: SeriesInput,
  windowFrom: Date,
  windowTo: Date,
  maxOccurrences: number = MAX_OCCURRENCES,
): Occurrence[] {
  const start = series.startsAt instanceof Date ? series.startsAt : new Date(series.startsAt);
  const end = series.endsAt instanceof Date ? series.endsAt : new Date(series.endsAt);
  if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return [];

  const durationMs = Math.max(0, end.getTime() - start.getTime());
  const timeZone = series.timezone || "UTC";
  const rule = typeof series.rule === "string" ? parseRecurrenceRule(series.rule) : series.rule ?? null;

  const overlaps = (occStart: Date) =>
    occStart.getTime() + durationMs > windowFrom.getTime() && occStart.getTime() < windowTo.getTime();

  if (!rule) {
    if (!overlaps(start)) return [];
    return [{ start, end, occurrenceDate: isoDate(toWallClock(start, timeZone)) }];
  }

  const untilMs = rule.until ? new Date(rule.until).getTime() : Number.POSITIVE_INFINITY;
  const firstWall = toWallClock(start, timeZone);
  const anchorDay = firstWall.day;
  const out: Occurrence[] = [];

  // `emitted` counts every occurrence the rule produces from its start — not
  // just the ones inside the window — because COUNT is defined over the whole
  // series. Getting this wrong makes a COUNT=5 series appear to restart on
  // every page of the calendar.
  let emitted = 0;
  let guard = 0;
  const guardLimit = maxOccurrences * 8 + 400;

  const consider = (wall: WallClock): boolean => {
    const occStart = fromWallClock(wall, timeZone);
    if (occStart.getTime() > untilMs) return false;
    if (rule.count !== undefined && emitted >= rule.count) return false;
    emitted++;
    if (occStart.getTime() >= windowTo.getTime()) return false;
    if (overlaps(occStart)) {
      out.push({
        start: occStart,
        end: new Date(occStart.getTime() + durationMs),
        occurrenceDate: isoDate(wall),
      });
    }
    return out.length < maxOccurrences;
  };

  if (rule.freq === "weekly") {
    // The seed's weekday has to be read off its *wall-clock* date in the
    // event's zone, not the instant's UTC day — a Monday 9am meeting in
    // Auckland is still Sunday in UTC, and expanding from that would slide
    // the whole series a day.
    const seedDow = wallClockWeekday(firstWall);
    const wanted = new Set(
      (rule.byDay?.length ? rule.byDay : [WEEKDAY_CODES[seedDow]!]).map((code) => WEEKDAY_CODES.indexOf(code)),
    );
    // Walk from the start of the seed's week so BYDAY entries that fall
    // earlier in the week than the seed still land on the right days.
    let weekStart = addDays(firstWall, -seedDow);
    for (;;) {
      if (guard++ > guardLimit) break;
      let anyFuture = false;
      for (let dow = 0; dow < 7; dow++) {
        if (!wanted.has(dow)) continue;
        const wall = addDays(weekStart, dow);
        const occStart = fromWallClock(wall, timeZone);
        // Days in the seed's own week that precede the series start aren't
        // occurrences — the series begins at startsAt, not at week start.
        if (occStart.getTime() < start.getTime()) continue;
        anyFuture = true;
        if (!consider(wall)) return out;
      }
      const nextWeek = addDays(weekStart, 7 * rule.interval);
      const nextInstant = fromWallClock(nextWeek, timeZone);
      if (nextInstant.getTime() > untilMs) break;
      if (nextInstant.getTime() >= windowTo.getTime() && (anyFuture || out.length > 0)) break;
      if (rule.count !== undefined && emitted >= rule.count) break;
      weekStart = nextWeek;
    }
    return out;
  }

  let wall = firstWall;
  for (;;) {
    if (guard++ > guardLimit) break;
    if (!consider(wall)) return out;
    const next =
      rule.freq === "daily" ? addDays(wall, rule.interval) : addMonths(wall, rule.interval, anchorDay);
    const nextInstant = fromWallClock(next, timeZone);
    if (nextInstant.getTime() > untilMs) break;
    if (nextInstant.getTime() >= windowTo.getTime()) break;
    if (rule.count !== undefined && emitted >= rule.count) break;
    wall = next;
  }
  return out;
}
