import { resolveDueDate, weekStartForLocale } from './due-date.resolver';
import type { DueIntent } from './voice.types';

// 2025-10-11 is a Saturday — the first day of the Iranian week.
const FA_WEEK = 6; // Saturday
const EU_WEEK = 1; // Monday
const SATURDAY = '2025-10-11';
const THURSDAY = '2025-10-16';

describe('resolveDueDate', () => {
  describe('weekday intents', () => {
    it('resolves "this <weekday>" to the coming occurrence', () => {
      const result = resolveDueDate(
        { kind: 'weekday', weekday: 'thursday', which: 'this' },
        SATURDAY,
        FA_WEEK,
      );
      expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days
    });

    it('resolves "next <weekday>" to that weekday in the following week', () => {
      const result = resolveDueDate(
        { kind: 'weekday', weekday: 'thursday', which: 'next' },
        SATURDAY,
        FA_WEEK,
      );
      expect(result.dueDate).toBe('2025-10-23');
    });

    it('anchors "next" to the week, not to "this" plus seven', () => {
      // Said on Thursday 2025-10-16, the Iranian week runs Sat 10-18 .. Fri 10-24, so its
      // Thursday is 10-23. Adding a week to "this Thursday" (already 10-23) would
      // overshoot to 10-30 — a lab case a week late.
      expect(
        resolveDueDate(
          { kind: 'weekday', weekday: 'thursday', which: 'next' },
          THURSDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe('2025-10-23');
    });

    it('lets "this" and "next" coincide when they name the same day', () => {
      // On a Thursday, "the coming Saturday" and "Saturday next week" are both 10-18.
      for (const which of ['this', 'next'] as const) {
        expect(
          resolveDueDate(
            { kind: 'weekday', weekday: 'saturday', which },
            THURSDAY,
            FA_WEEK,
          ).dueDate,
        ).toBe('2025-10-18');
      }
    });

    it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
      // A deadline of today is almost never what was meant.
      expect(
        resolveDueDate(
          { kind: 'weekday', weekday: 'thursday', which: 'this' },
          THURSDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe('2025-10-23');
    });

    it('never resolves a weekday into the past', () => {
      for (const which of ['this', 'next'] as const) {
        const result = resolveDueDate(
          { kind: 'weekday', weekday: 'sunday', which },
          THURSDAY,
          FA_WEEK,
        );
        expect(result.dueDate).not.toBeNull();
        expect(result.dueDate! > THURSDAY).toBe(true);
      }
    });

    it('anchors "next" to a Monday week for en and nl', () => {
      // The same sentence means a different day depending on where the week starts.
      // Said on Saturday 10-11: the Monday-start week is 10-13..10-19, Thursday = 10-16.
      // The Saturday-start week is 10-18..10-24, Thursday = 10-23.
      const intent = {
        kind: 'weekday',
        weekday: 'thursday',
        which: 'next',
      } as const;
      expect(resolveDueDate(intent, SATURDAY, EU_WEEK).dueDate).toBe(
        '2025-10-16',
      );
      expect(resolveDueDate(intent, SATURDAY, FA_WEEK).dueDate).toBe(
        '2025-10-23',
      );
    });

    it('maps each locale to its week start', () => {
      expect(weekStartForLocale('fa')).toBe(FA_WEEK);
      expect(weekStartForLocale('en')).toBe(EU_WEEK);
      expect(weekStartForLocale('nl')).toBe(EU_WEEK);
      expect(weekStartForLocale('unknown')).toBe(EU_WEEK);
    });

    it('treats a missing qualifier as "this" rather than failing', () => {
      // A bare weekday carries no qualifier; failing would discard a real spoken deadline.
      expect(
        resolveDueDate(
          {
            kind: 'weekday',
            weekday: 'thursday',
            which: null,
          } as unknown as DueIntent,
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe(THURSDAY);
    });

    it('rejects an unknown weekday', () => {
      expect(
        resolveDueDate(
          {
            kind: 'weekday',
            weekday: 'caturday',
            which: 'this',
          } as unknown as DueIntent,
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBeNull();
    });
  });

  describe('offset intents', () => {
    it('adds days, weeks and months', () => {
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'day', amount: 1 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe('2025-10-12');
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'week', amount: 1 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe('2025-10-18');
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'month', amount: 1 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe('2025-11-11');
    });

    it('clamps to the end of a shorter month', () => {
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'month', amount: 1 },
          '2025-01-31',
        ).dueDate,
      ).toBe('2025-02-28');
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'month', amount: 1 },
          '2024-01-31',
        ).dueDate,
      ).toBe('2024-02-29');
    });

    it('crosses a year boundary', () => {
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'month', amount: 3 },
          '2025-11-15',
        ).dueDate,
      ).toBe('2026-02-15');
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'day', amount: 30 },
          '2025-12-20',
        ).dueDate,
      ).toBe('2026-01-19');
    });

    it('rejects negative, fractional and absurd amounts', () => {
      for (const amount of [-1, 1.5, 10_000, Number.NaN]) {
        expect(
          resolveDueDate(
            { kind: 'offset', unit: 'day', amount },
            SATURDAY,
            FA_WEEK,
          ).dueDate,
        ).toBeNull();
      }
    });
  });

  describe('jalali intents', () => {
    it('converts by arithmetic, not inference', () => {
      expect(
        resolveDueDate(
          { kind: 'jalali', jy: 1404, jm: 7, jd: 25 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe('2025-10-17');
    });

    it('handles the leap-year Esfand 30', () => {
      expect(
        resolveDueDate(
          { kind: 'jalali', jy: 1403, jm: 12, jd: 30 },
          '2025-03-01',
        ).dueDate,
      ).toBe('2025-03-20');
    });

    it('rejects Esfand 30 in a non-leap year', () => {
      const result = resolveDueDate(
        { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
        SATURDAY,
      );
      expect(result.dueDate).toBeNull();
      expect(result.unresolved?.reason).toBe('invalid_date');
    });
  });

  describe('gregorian intents', () => {
    it('accepts a real date and rejects an impossible one', () => {
      expect(
        resolveDueDate(
          { kind: 'gregorian', y: 2025, m: 10, d: 17 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe('2025-10-17');
      expect(
        resolveDueDate(
          { kind: 'gregorian', y: 2025, m: 2, d: 30 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBeNull();
      expect(
        resolveDueDate(
          { kind: 'gregorian', y: 2025, m: 13, d: 1 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBeNull();
    });
  });

  describe('guard rails', () => {
    it('treats a past date as unresolved', () => {
      const result = resolveDueDate(
        { kind: 'gregorian', y: 2020, m: 1, d: 1 },
        SATURDAY,
      );
      expect(result.dueDate).toBeNull();
      expect(result.unresolved?.reason).toBe('invalid_date');
    });

    it('treats a date decades away as unresolved', () => {
      expect(
        resolveDueDate(
          { kind: 'gregorian', y: 2099, m: 1, d: 1 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBeNull();
    });

    it('accepts today itself via a zero-day offset', () => {
      expect(
        resolveDueDate(
          { kind: 'offset', unit: 'day', amount: 0 },
          SATURDAY,
          FA_WEEK,
        ).dueDate,
      ).toBe(SATURDAY);
    });

    it('reports no due date at all when the model said nothing, without flagging it', () => {
      expect(resolveDueDate(null, SATURDAY)).toEqual({
        dueDate: null,
        unresolved: null,
      });
      expect(resolveDueDate(undefined, SATURDAY)).toEqual({
        dueDate: null,
        unresolved: null,
      });
    });

    it('treats an object with no kind as no deadline, not a lost one', () => {
      // A blank "heard but lost" row in front of a clinician who never mentioned a
      // deadline is worse than saying nothing.
      expect(resolveDueDate({} as never, SATURDAY)).toEqual({
        dueDate: null,
        unresolved: null,
      });
    });

    it('flags an unrecognised kind and names it', () => {
      const result = resolveDueDate({ kind: 'lunar_month' } as never, SATURDAY);
      expect(result.dueDate).toBeNull();
      expect(result.unresolved).toEqual({
        spoken: 'lunar_month',
        reason: 'invalid_date',
      });
    });

    it('flags a non-object deadline instead of silently dropping it', () => {
      // A bare string is a deadline we failed to parse, not an absent one — the clinician
      // must see that something was heard and lost.
      for (const bad of ['next thursday', 42, true]) {
        const result = resolveDueDate(bad as never, SATURDAY);
        expect(result.dueDate).toBeNull();
        expect(result.unresolved?.reason).toBe('invalid_date');
      }
      expect(
        resolveDueDate('next thursday' as never, SATURDAY).unresolved?.spoken,
      ).toBe('next thursday');
    });

    it('degrades rather than throwing on a malformed today or intent', () => {
      expect(
        resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, 'not-a-date')
          .dueDate,
      ).toBeNull();
      expect(
        resolveDueDate({ kind: 'nope' } as unknown as DueIntent, SATURDAY)
          .dueDate,
      ).toBeNull();
    });

    it('echoes what was heard so the review sheet can show it', () => {
      const result = resolveDueDate(
        { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
        SATURDAY,
      );
      expect(result.unresolved?.spoken).toBe('1404/12/30');
    });
  });
});

describe('what an unresolvable deadline quotes back', () => {
  // The wire shape allows nulls in every field and toVoiceIntent casts rather than
  // checks, so these reach the resolver intact. The sheet renders `spoken` verbatim.
  it('never puts "null" or "NaN" in front of the clinician', () => {
    const bad = [
      { kind: 'weekday', weekday: null, which: null },
      { kind: 'offset', unit: null, amount: null },
      { kind: 'offset', unit: 'day', amount: Number.NaN },
      { kind: 'jalali', jy: null, jm: 7, jd: 25 },
      { kind: 'gregorian', y: 2026, m: null, d: null },
    ];
    for (const intent of bad) {
      const result = resolveDueDate(
        intent as unknown as DueIntent,
        SATURDAY,
        FA_WEEK,
      );
      expect(result.dueDate).toBeNull();
      expect(result.unresolved?.spoken ?? '').not.toMatch(/null|NaN/);
    }
  });

  it('still quotes a deadline it did understand the words of', () => {
    const result = resolveDueDate(
      {
        kind: 'weekday',
        weekday: 'thursday',
        which: null,
      } as unknown as DueIntent,
      SATURDAY,
      FA_WEEK,
    );
    // A weekday with no "this/next" resolves, so nothing is quoted back at all.
    expect(result.dueDate).not.toBeNull();
  });
});
