import { jalaliToIsoDate } from '../../common/jalali';
import { civilDateJsWeekday } from '../../common/zoned-civil-time';
import type { DueIntent, UnresolvedItem, Weekday } from './voice.types';

/**
 * Spoken deadline → ISO civil date.
 *
 * The model never does calendar arithmetic; it says what it heard and this decides what
 * that means. Jalali conversion in particular is arithmetic here, not inference — an LLM
 * asked to turn "۲۵ مهر" into ISO answers confidently and is often wrong, and
 * `@IsDateString()` would accept the wrong answer.
 *
 * Works entirely in civil dates. The caller derives `todayIso` from the actor's IANA zone
 * (see `civilDateInZone`) rather than passing a zone in here, so nothing in this file has
 * to reason about instants.
 */

const WEEKDAY_TO_JS: Record<Weekday, number> = {
  saturday: 6,
  sunday: 0,
  monday: 1,
  tuesday: 2,
  wednesday: 3,
  thursday: 4,
  friday: 5,
};

const MAX_DAYS_AHEAD = 365 * 5;

export type DueResolution = {
  dueDate: string | null;
  unresolved: UnresolvedItem | null;
};

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;

function isRealCivilDate(iso: string): boolean {
  if (!ISO_DATE.test(iso)) return false;
  const [y, m, d] = iso.split('-').map(Number);
  if (m < 1 || m > 12 || d < 1 || d > 31) return false;
  const probe = new Date(Date.UTC(y, m - 1, d));
  return (
    probe.getUTCFullYear() === y &&
    probe.getUTCMonth() === m - 1 &&
    probe.getUTCDate() === d
  );
}

function toIso(utcMs: number): string {
  return new Date(utcMs).toISOString().slice(0, 10);
}

function utcMsOf(iso: string): number {
  const [y, m, d] = iso.split('-').map(Number);
  return Date.UTC(y, m - 1, d);
}

function addDays(iso: string, days: number): string {
  return toIso(utcMsOf(iso) + days * 86_400_000);
}

/** Calendar-month addition with end-of-month clamping (31 Jan + 1 month = 28/29 Feb). */
function addMonths(iso: string, months: number): string {
  const [y, m, d] = iso.split('-').map(Number);
  const targetMonthIndex = m - 1 + months;
  const targetYear = y + Math.floor(targetMonthIndex / 12);
  const targetMonth = ((targetMonthIndex % 12) + 12) % 12;
  const lastDay = new Date(
    Date.UTC(targetYear, targetMonth + 1, 0),
  ).getUTCDate();
  return toIso(Date.UTC(targetYear, targetMonth, Math.min(d, lastDay)));
}

function unresolved(spoken: string): DueResolution {
  return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } };
}

/**
 * What to quote back when a deadline could not be resolved. Every field is nullable on the
 * wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim —
 * so a half-classified deadline must fall back to '', not to `"null null"`.
 */
function describe(intent: DueIntent): string {
  const usable = (value: unknown): value is number =>
    typeof value === 'number' && Number.isFinite(value);

  switch (intent?.kind) {
    case 'weekday':
      // `which` is legitimately null (it means "this"), the weekday is not.
      return [intent.which, intent.weekday]
        .filter((part) => typeof part === 'string')
        .join(' ');
    case 'offset':
      return usable(intent.amount)
        ? `+${intent.amount} ${intent.unit ?? ''}`.trim()
        : '';
    case 'jalali':
      return [intent.jy, intent.jm, intent.jd].every(usable)
        ? `${intent.jy}/${intent.jm}/${intent.jd}`
        : '';
    case 'gregorian':
      return [intent.y, intent.m, intent.d].every(usable)
        ? `${intent.y}-${intent.m}-${intent.d}`
        : '';
    default: {
      // An unrecognised `kind`, already established as a string — echo what was heard.
      const kind = (intent as { kind?: unknown })?.kind;
      return typeof kind === 'string' ? kind : '';
    }
  }
}

/**
 * "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
 * Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's
 * deadline a week out.
 */
const WEEK_START_BY_LOCALE: Record<string, number> = {
  fa: WEEKDAY_TO_JS.saturday,
  en: WEEKDAY_TO_JS.monday,
  nl: WEEKDAY_TO_JS.monday,
};

const DEFAULT_WEEK_START = WEEKDAY_TO_JS.monday;

export function weekStartForLocale(locale: string): number {
  return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
}

function startOfWeek(iso: string, weekStartJs: number): string {
  const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
  return addDays(iso, -back);
}

/**
 * `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can
 * never resolve into the past.
 *
 * `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots
 * by seven days whenever `'this'` has already rolled into next week. The two legitimately
 * coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day.
 */
function resolveWeekday(
  intent: Extract<DueIntent, { kind: 'weekday' }>,
  todayIso: string,
  weekStartJs: number,
) {
  const targetJs = WEEKDAY_TO_JS[intent.weekday];
  if (targetJs === undefined) return null;

  // A bare weekday ("پنجشنبه") carries no qualifier, and the model may leave `which`
  // null. Treat that as 'this' rather than failing an utterance that named a real day.
  const which = intent.which === 'next' ? 'next' : 'this';

  if (which === 'this') {
    const todayJs = civilDateJsWeekday(todayIso);
    let delta = (targetJs - todayJs + 7) % 7;
    if (delta === 0) delta = 7;
    return addDays(todayIso, delta);
  }

  if (which === 'next') {
    const offsetInWeek = (targetJs - weekStartJs + 7) % 7;
    return addDays(startOfWeek(todayIso, weekStartJs), 7 + offsetInWeek);
  }

  return null;
}

function resolveOffset(
  intent: Extract<DueIntent, { kind: 'offset' }>,
  todayIso: string,
) {
  const { amount, unit } = intent;
  if (!Number.isInteger(amount) || amount < 0) return null;
  if (unit === 'day')
    return amount <= MAX_DAYS_AHEAD ? addDays(todayIso, amount) : null;
  if (unit === 'week')
    return amount <= 260 ? addDays(todayIso, amount * 7) : null;
  if (unit === 'month')
    return amount <= 60 ? addMonths(todayIso, amount) : null;
  return null;
}

export function resolveDueDate(
  intent: DueIntent | null | undefined,
  todayIso: string,
  weekStartJs: number = DEFAULT_WEEK_START,
): DueResolution {
  // Absent is not an error — most utterances carry no deadline.
  if (intent === null || intent === undefined) {
    return { dueDate: null, unresolved: null };
  }
  if (typeof intent !== 'object') {
    return unresolved(String(intent).slice(0, 120));
  }
  // No `kind` at all says nothing about a deadline, so it is not "heard but lost". An
  // *unrecognised* kind did try to say something, and is flagged below.
  if (typeof (intent as { kind?: unknown }).kind !== 'string') {
    return { dueDate: null, unresolved: null };
  }
  if (!isRealCivilDate(todayIso)) {
    return unresolved(describe(intent));
  }

  let resolved: string | null = null;
  switch (intent.kind) {
    case 'weekday':
      resolved = resolveWeekday(intent, todayIso, weekStartJs);
      break;
    case 'offset':
      resolved = resolveOffset(intent, todayIso);
      break;
    case 'jalali':
      resolved = jalaliToIsoDate(intent.jy, intent.jm, intent.jd);
      break;
    case 'gregorian': {
      const candidate = `${String(intent.y).padStart(4, '0')}-${String(intent.m).padStart(2, '0')}-${String(intent.d).padStart(2, '0')}`;
      resolved = isRealCivilDate(candidate) ? candidate : null;
      break;
    }
    default:
      resolved = null;
  }

  if (!resolved) return unresolved(describe(intent));

  // A date the model invented can land anywhere; past or decades away is not a deadline.
  const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000;
  if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD)
    return unresolved(describe(intent));

  return { dueDate: resolved, unresolved: null };
}
