/**
 * Quarters, and the one shape everything dated turns into.
 *
 * PIG's argument is that one ledger answers the question, so the calendar is a
 * PROJECTION over records that already carry dates — contracts, obligations,
 * deals, commitments, allocations, compliance artefacts — not a second store
 * that would immediately drift from them. The only rows that live in their own
 * table are the ones with no other home: a meeting, a QBR, a reminder.
 *
 * Kept in @pig/core because the browser and the API must agree on the event
 * shape and on where a quarter begins.
 *
 * Two decisions are load-bearing here and are pinned by tests.
 *
 * **A quarter is half-open, [from, to).** Every temporal column in PIG is
 * `timestamp with time zone`; there is not a single `date` column. So a
 * quarter is an interval of instants, and consecutive quarters must tile
 * without overlapping. An inclusive upper bound puts a contract expiring at
 * exactly midnight on 1 October into both Q3 and Q4, and a GTM lead adding up
 * two quarters then counts it twice.
 *
 * **A fiscal year is named for the calendar year it ENDS in.** This is the
 * dominant convention among the companies whose paper PIG holds — a fiscal
 * year beginning April 2026 and ending March 2027 is FY2027. The alternative
 * (naming for the starting year) is also in use, which is exactly why the
 * choice is stated here once rather than assumed at each call site.
 */

export type QuarterNumber = 1 | 2 | 3 | 4;

/** A fiscal or calendar quarter label, e.g. `2026-Q3`. */
export type Quarter = `${number}-Q${QuarterNumber}`;

/** Half-open interval of instants: `from` is included, `to` is not. */
export interface QuarterBounds {
  from: Date;
  to: Date;
  quarter: Quarter;
}

/**
 * Kinds of dated thing the projection can emit.
 *
 * Every one of these is derived from a record that already carries the date,
 * except `calendar_entry`, which is the only row type the calendar owns.
 */
export const CALENDAR_EVENT_KINDS = [
  'expected_close',
  'contract_effective',
  'contract_expiry',
  'contract_executed',
  'renewal_notice',
  'obligation_due',
  'capacity_window',
  'allocation_window',
  'hold_expiry',
  'supply_available_from',
  'authorization_expiry',
  'artifact_expiry',
  'calendar_entry',
] as const;
export type CalendarEventKind = (typeof CALENDAR_EVENT_KINDS)[number];

export function isCalendarEventKind(value: string): value is CalendarEventKind {
  return (CALENDAR_EVENT_KINDS as readonly string[]).includes(value);
}

/** The kinds a human-owned `calendar_entries` row may take. */
export const CALENDAR_ENTRY_KINDS = [
  'meeting',
  'qbr',
  'reminder',
  'campaign',
  'internal',
] as const;
export type CalendarEntryKind = (typeof CALENDAR_ENTRY_KINDS)[number];

/**
 * `done` is set by a completion column, never by the clock — an obligation
 * whose due date has passed is `overdue`, not finished, and conflating the two
 * is how a missed renewal notice disappears from a screen.
 */
export type CalendarEventState = 'upcoming' | 'due' | 'overdue' | 'done';

export interface CalendarEvent {
  /**
   * `${recordType}:${recordId}:${field}` — synthesised, never stored. A
   * derived projection has no primary key of its own, and inventing one in a
   * table would mean the same expiry date living in two places.
   */
  id: string;
  kind: CalendarEventKind;
  title: string;
  /**
   * ISO-8601. Strings rather than `Date` because this interface crosses the
   * wire: the browser receives JSON, and a shape that only typechecks before
   * serialisation is a shape the front end cannot honestly claim to hold.
   */
  startsAt: string;
  /** Null for a point in time. */
  endsAt: string | null;
  isSpan: boolean;
  state: CalendarEventState;
  accountId: string | null;
  accountName: string | null;
  ownerUserId: string | null;
  /** Integer cents, per the money rule. Null where the event has no value. */
  amountCents: number | null;
  currency: string | null;
  /** The table the event was derived from, e.g. `contract`. */
  recordType: string;
  recordId: string;
  href: string;
  meta: Record<string, unknown>;
}

/** Stable, storage-free identity for a projected event. */
export function calendarEventId(
  recordType: string,
  recordId: string,
  field: string,
): string {
  return `${recordType}:${recordId}:${field}`;
}

/**
 * How soon before its date an event counts as `due` rather than `upcoming`.
 * Seven days is the shortest horizon in which a renewal notice or an export
 * authorisation can still realistically be acted on.
 */
export const CALENDAR_DUE_HORIZON_DAYS = 7;

const DAY_MS = 86_400_000;

export function eventState(input: {
  at: Date;
  now: Date;
  completedAt?: Date | null;
  dueWithinDays?: number;
}): CalendarEventState {
  if (input.completedAt) return 'done';
  const at = input.at.getTime();
  const now = input.now.getTime();
  if (at < now) return 'overdue';
  const horizon = (input.dueWithinDays ?? CALENDAR_DUE_HORIZON_DAYS) * DAY_MS;
  return at - now <= horizon ? 'due' : 'upcoming';
}

/**
 * A span's state reads from its END, because a window that has started is not
 * late — it is running. Only a window that has closed is behind us.
 *
 * `done` is right here only because these spans have no completion column: a
 * capacity or allocation window IS the fact, and it is over when the clock says
 * so. A span nobody could fail to do cannot be overdue. For anything a person
 * was supposed to do inside the window, use `completableSpanState`.
 */
export function spanState(input: {
  startsAt: Date;
  endsAt: Date;
  now: Date;
}): CalendarEventState {
  if (input.endsAt.getTime() < input.now.getTime()) return 'done';
  if (input.startsAt.getTime() <= input.now.getTime()) return 'due';
  return eventState({ at: input.startsAt, now: input.now });
}

/**
 * The same reading of a span for a row that CAN record completion — today only
 * `calendar_entries`, which carries `completed_at`.
 *
 * The end still decides running versus past, but a closed window with nothing
 * in the completion column is `overdue`, not `done`. Reusing `spanState` here
 * made a missed QBR read as finished purely because its author had typed an end
 * time — the byte-identical entry without one read `overdue` — which is exactly
 * the conflation the note above `CalendarEventState` forbids.
 */
export function completableSpanState(input: {
  startsAt: Date;
  endsAt: Date;
  now: Date;
  completedAt?: Date | null;
}): CalendarEventState {
  if (input.completedAt) return 'done';
  if (input.endsAt.getTime() < input.now.getTime()) return 'overdue';
  if (input.startsAt.getTime() <= input.now.getTime()) return 'due';
  return eventState({ at: input.startsAt, now: input.now });
}

// ---------------------------------------------------------------- time zones

interface ZonedParts {
  year: number;
  month: number;
  day: number;
  hour: number;
  minute: number;
  second: number;
}

/**
 * Constructing an `Intl.DateTimeFormat` is expensive enough to be worth
 * keeping, but the key is a caller-supplied string that reaches here from a
 * query parameter, so the map is capped: an unbounded one lets a loop over
 * distinct values grow a long-lived API process without limit. The IANA
 * database has well under 500 zones, so a real deployment never evicts;
 * insertion-order eviction only ever bites junk.
 */
const FORMATTER_CACHE_LIMIT = 512;

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

/** Whether the runtime's ICU data recognises `timeZone` as an IANA zone. */
export function isValidTimeZone(timeZone: string): boolean {
  try {
    new Intl.DateTimeFormat('en-US', { timeZone });
    return true;
  } catch {
    return false;
  }
}

/**
 * Falls back to UTC rather than throwing. `users.timezone` is free text set
 * through a preferences endpoint that does not validate it, so a stale or
 * mistyped zone must degrade to a defensible answer instead of 500ing the
 * whole calendar. Request-supplied zones are rejected at the route boundary
 * instead, where a 400 can still tell the caller what was wrong.
 */
function formatterFor(timeZone: string): Intl.DateTimeFormat {
  const cached = formatterCache.get(timeZone);
  if (cached) return cached;
  let formatter: Intl.DateTimeFormat;
  try {
    formatter = new Intl.DateTimeFormat('en-US', {
      timeZone,
      hourCycle: 'h23',
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
    });
  } catch {
    formatter = formatterFor('UTC');
  }
  if (formatterCache.size >= FORMATTER_CACHE_LIMIT) {
    const oldest = formatterCache.keys().next();
    if (!oldest.done) formatterCache.delete(oldest.value);
  }
  formatterCache.set(timeZone, formatter);
  return formatter;
}

function zonedParts(instant: Date, timeZone: string): ZonedParts {
  const parts = formatterFor(timeZone).formatToParts(instant);
  const read = (type: Intl.DateTimeFormatPartTypes): number =>
    Number(parts.find((part) => part.type === type)?.value ?? '0');
  return {
    year: read('year'),
    month: read('month'),
    day: read('day'),
    hour: read('hour'),
    minute: read('minute'),
    second: read('second'),
  };
}

function offsetMsAt(instant: Date, timeZone: string): number {
  const parts = zonedParts(instant, timeZone);
  const asIfUtc = Date.UTC(
    parts.year,
    parts.month - 1,
    parts.day,
    parts.hour,
    parts.minute,
    parts.second,
  );
  return asIfUtc - instant.getTime();
}

/**
 * The instant at which local midnight begins on a given day in a given zone.
 *
 * Iterated rather than solved because the offset depends on the instant we are
 * trying to find. Two passes settle every real zone including the daylight
 * transitions; the third is insurance and costs nothing.
 */
function startOfZonedDay(
  year: number,
  month: number,
  day: number,
  timeZone: string,
): Date {
  const wallClock = Date.UTC(year, month - 1, day);
  let instant = wallClock;
  for (let pass = 0; pass < 3; pass += 1) {
    instant = wallClock - offsetMsAt(new Date(instant), timeZone);
  }
  return new Date(instant);
}

// ------------------------------------------------------------------ quarters

function assertFiscalStart(fiscalYearStartMonth: number): void {
  if (
    !Number.isInteger(fiscalYearStartMonth) ||
    fiscalYearStartMonth < 0 ||
    fiscalYearStartMonth > 11
  ) {
    throw new RangeError(
      `fiscalYearStartMonth must be an integer month index 0–11, got ${fiscalYearStartMonth}.`,
    );
  }
}

/**
 * Which quarter an instant falls in.
 *
 * `fiscalYearStartMonth` is a zero-based month index: 0 for calendar quarters
 * (the default), 3 for an April start, 9 for an October start. `timeZone`
 * decides the boundary, and it matters: 31 December 23:00 in New York is
 * already Q1 in London.
 */
export function quarterOf(
  date: Date,
  fiscalYearStartMonth = 0,
  timeZone = 'UTC',
): Quarter {
  assertFiscalStart(fiscalYearStartMonth);
  const local = zonedParts(date, timeZone);
  const monthIndex = local.month - 1;
  // Months elapsed since the fiscal year began, 0–11.
  const sinceStart = (monthIndex - fiscalYearStartMonth + 12) % 12;
  const quarter = (Math.floor(sinceStart / 3) + 1) as QuarterNumber;
  // Named for the year the fiscal year ends in — see the note at the top.
  const startedThisCalendarYear = monthIndex >= fiscalYearStartMonth;
  const fiscalYear =
    fiscalYearStartMonth === 0
      ? local.year
      : startedThisCalendarYear
        ? local.year + 1
        : local.year;
  return `${fiscalYear}-Q${quarter}`;
}

/**
 * The half-open bounds of a quarter, as instants.
 *
 * `year` is the fiscal year label, not the calendar year in which the quarter
 * starts — those differ for every non-calendar fiscal offset, which is the
 * mistake this signature exists to make hard to write.
 */
export function quarterBounds(
  year: number,
  quarter: QuarterNumber,
  fiscalYearStartMonth = 0,
  timeZone = 'UTC',
): QuarterBounds {
  assertFiscalStart(fiscalYearStartMonth);
  if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
    throw new RangeError(`quarter must be 1–4, got ${quarter}.`);
  }

  // Inverse of the labelling rule in quarterOf: a non-calendar fiscal year
  // labelled `year` began in the previous calendar year.
  const fiscalStartCalendarYear = fiscalYearStartMonth === 0 ? year : year - 1;
  const startMonthIndex = fiscalYearStartMonth + (quarter - 1) * 3;

  const from = startOfZonedDay(
    fiscalStartCalendarYear + Math.floor(startMonthIndex / 12),
    (startMonthIndex % 12) + 1,
    1,
    timeZone,
  );
  const endMonthIndex = startMonthIndex + 3;
  const to = startOfZonedDay(
    fiscalStartCalendarYear + Math.floor(endMonthIndex / 12),
    (endMonthIndex % 12) + 1,
    1,
    timeZone,
  );
  return { from, to, quarter: `${year}-Q${quarter}` };
}

/** Splits `2026-Q3` back into its parts. Null when the label is malformed. */
export function parseQuarter(
  label: string,
): { year: number; quarter: QuarterNumber } | null {
  const match = /^(\d{4})-Q([1-4])$/.exec(label);
  if (!match) return null;
  return {
    year: Number(match[1]),
    quarter: Number(match[2]) as QuarterNumber,
  };
}

/** The bounds of the quarter containing `date`. */
export function quarterBoundsFor(
  date: Date,
  fiscalYearStartMonth = 0,
  timeZone = 'UTC',
): QuarterBounds {
  const parsed = parseQuarter(quarterOf(date, fiscalYearStartMonth, timeZone));
  if (!parsed) throw new Error('quarterOf produced an unparseable label.');
  return quarterBounds(parsed.year, parsed.quarter, fiscalYearStartMonth, timeZone);
}
