/**
 * Centralised date formatting helpers.
 *
 * Replaces ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls
 * scattered across services and components. Backed by `Intl.DateTimeFormat`
 * which is built into Node 18+ and every supported browser - no extra
 * dependency. Timezone-aware via the `tz` option (passes through to
 * `Intl.DateTimeFormat`'s `timeZone` field - accepts any IANA name).
 *
 * Why a wrapper:
 *   1. One place to fix locale, timezone, format presets when the
 *      product needs to change them.
 *   2. Defensive: `new Date(null)` → "Invalid Date" - we return "-".
 *   3. Defensive: numeric epochs vs string dates handled uniformly.
 *   4. Default to en-GB so the audit-flagged inconsistency (some sites
 *      used en-US, some default-locale, some en-GB) is settled.
 *
 * Presets are named so call sites don't need to remember
 * `Intl.DateTimeFormatOptions` shape - `formatDate(x, 'date.short')`
 * instead of `formatDate(x, { day: '2-digit', month: 'short' })`.
 */

export type DatePreset =
  | 'date.short' // 12 May
  | 'date.medium' // 12 May 2026
  | 'date.long' // Monday, 12 May 2026
  | 'date.iso' // 2026-05-12
  | 'datetime.short' // 12 May 14:30
  | 'datetime.medium' // 12 May 2026 14:30
  | 'datetime.long' // Monday, 12 May 2026 at 14:30 UTC
  | 'datetime.iso' // 2026-05-12T14:30:00.000Z
  | 'time'; // 14:30

const PRESET_OPTIONS: Record<
  Exclude<DatePreset, 'date.iso' | 'datetime.iso'>,
  Intl.DateTimeFormatOptions
> = {
  'date.short': { day: '2-digit', month: 'short' },
  'date.medium': { day: '2-digit', month: 'short', year: 'numeric' },
  'date.long': { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' },
  'datetime.short': {
    day: '2-digit',
    month: 'short',
    hour: '2-digit',
    minute: '2-digit',
    hourCycle: 'h23',
  },
  'datetime.medium': {
    day: '2-digit',
    month: 'short',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    hourCycle: 'h23',
  },
  'datetime.long': {
    weekday: 'long',
    day: '2-digit',
    month: 'long',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    hourCycle: 'h23',
    timeZoneName: 'short',
  },
  time: { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' },
};

const DEFAULT_LOCALE = 'en-GB';
const DEFAULT_FALLBACK = '-';

export interface FormatDateOptions {
  /** IANA timezone - e.g. "Europe/London", "UTC". Defaults to the
   *  runtime's local zone (server: usually UTC; browser: user's TZ). */
  tz?: string;
  /** BCP-47 locale tag. Defaults to en-GB. */
  locale?: string;
  /** What to show when the input is null/undefined/Invalid Date. */
  fallback?: string;
}

function toDate(value: Date | string | number | null | undefined): Date | null {
  if (value === null || value === undefined) return null;
  if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
  const parsed = new Date(value);
  return Number.isNaN(parsed.getTime()) ? null : parsed;
}

/**
 * Format a Date / ISO string / epoch ms with one of the named presets.
 * Defaults to `date.medium` (12 May 2026) in en-GB at the runtime's
 * local zone. Pass `tz: 'UTC'` for stamps that should be tz-invariant.
 */
export function formatDate(
  value: Date | string | number | null | undefined,
  preset: DatePreset = 'date.medium',
  options: FormatDateOptions = {},
): string {
  const date = toDate(value);
  if (!date) return options.fallback ?? DEFAULT_FALLBACK;

  const locale = options.locale ?? DEFAULT_LOCALE;

  if (preset === 'date.iso') {
    if (options.tz && options.tz !== 'UTC') {
      // For non-UTC ISO date we have to compute the date components in
      // the target zone manually since toISOString is always UTC.
      const parts = new Intl.DateTimeFormat('en-CA', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        timeZone: options.tz,
      }).formatToParts(date);
      const y = parts.find((p) => p.type === 'year')?.value ?? '';
      const m = parts.find((p) => p.type === 'month')?.value ?? '';
      const d = parts.find((p) => p.type === 'day')?.value ?? '';
      return `${y}-${m}-${d}`;
    }
    // Faster path for UTC / default.
    return date.toISOString().slice(0, 10);
  }
  if (preset === 'datetime.iso') {
    // ISO datetime is always UTC by definition.
    return date.toISOString();
  }

  const intlOpts: Intl.DateTimeFormatOptions = { ...PRESET_OPTIONS[preset] };
  if (options.tz) intlOpts.timeZone = options.tz;
  return new Intl.DateTimeFormat(locale, intlOpts).format(date);
}

/**
 * Format a [start, end] date range. Smartly collapses repeated components
 * - e.g. same year shows "12 May → 14 Aug 2026" not "12 May 2026 → 14 Aug 2026".
 */
export function formatDateRange(
  start: Date | string | number | null | undefined,
  end: Date | string | number | null | undefined,
  options: FormatDateOptions = {},
): string {
  const s = toDate(start);
  const e = toDate(end);
  const fallback = options.fallback ?? DEFAULT_FALLBACK;
  if (!s && !e) return fallback;
  if (!s) return `→ ${formatDate(e, 'date.medium', options)}`;
  if (!e) return `${formatDate(s, 'date.medium', options)} →`;
  const sameYear = s.getUTCFullYear() === e.getUTCFullYear();
  if (sameYear) {
    return `${formatDate(s, 'date.short', options)} → ${formatDate(e, 'date.medium', options)}`;
  }
  return `${formatDate(s, 'date.medium', options)} → ${formatDate(e, 'date.medium', options)}`;
}

/**
 * Format a duration relative to now - "2 hours ago", "in 3 days", etc.
 * Uses `Intl.RelativeTimeFormat` for proper i18n; falls back to "just now"
 * for very small deltas.
 */
export function formatRelative(
  value: Date | string | number | null | undefined,
  options: { locale?: string; now?: Date } = {},
): string {
  const date = toDate(value);
  if (!date) return DEFAULT_FALLBACK;
  const now = options.now ?? new Date();
  const diffSec = (date.getTime() - now.getTime()) / 1000;

  const rtf = new Intl.RelativeTimeFormat(options.locale ?? DEFAULT_LOCALE, { numeric: 'auto' });
  const abs = Math.abs(diffSec);
  if (abs < 45) return 'just now';
  if (abs < 90) return rtf.format(Math.round(diffSec / 60), 'minute');
  if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
  if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
  if (abs < 86400 * 30) return rtf.format(Math.round(diffSec / 86400), 'day');
  if (abs < 86400 * 365) return rtf.format(Math.round(diffSec / (86400 * 30)), 'month');
  return rtf.format(Math.round(diffSec / (86400 * 365)), 'year');
}
