import {
  resolveToothIntent,
  resolveToothIntents,
} from './tooth-intent.resolver';
import type { ToothIntent } from './voice.types';

const positional = (
  arch: 'upper' | 'lower',
  side: 'patient_right' | 'patient_left',
  position: number,
  spoken = 'x',
): ToothIntent => ({ kind: 'positional', arch, side, position, spoken });

const explicit = (fdi: string, spoken = 'x'): ToothIntent => ({
  kind: 'explicit',
  fdi,
  spoken,
});

describe('resolveToothIntent', () => {
  describe('positional intents', () => {
    // "شش بالا راست" — upper right six — must be 16, not 26. A mirrored quadrant is a
    // valid code for the wrong tooth and reaches the lab unnoticed.
    it('resolves each quadrant from the patient perspective', () => {
      expect(resolveToothIntent(positional('upper', 'patient_right', 6))).toBe(
        '16',
      );
      expect(resolveToothIntent(positional('upper', 'patient_left', 6))).toBe(
        '26',
      );
      expect(resolveToothIntent(positional('lower', 'patient_left', 6))).toBe(
        '36',
      );
      expect(resolveToothIntent(positional('lower', 'patient_right', 6))).toBe(
        '46',
      );
    });

    it('returns null for an out-of-range position instead of clamping', () => {
      expect(
        resolveToothIntent(positional('upper', 'patient_right', 9)),
      ).toBeNull();
      expect(
        resolveToothIntent(positional('upper', 'patient_right', 0)),
      ).toBeNull();
    });

    it('returns null for a malformed arch or side', () => {
      expect(
        resolveToothIntent(
          positional('sideways' as 'upper', 'patient_right', 6),
        ),
      ).toBeNull();
      expect(
        resolveToothIntent(
          positional('upper', 'viewer_right' as 'patient_right', 6),
        ),
      ).toBeNull();
    });
  });

  describe('explicit intents', () => {
    it('accepts a permanent FDI code', () => {
      expect(resolveToothIntent(explicit('14'))).toBe('14');
      expect(resolveToothIntent(explicit('48'))).toBe('48');
    });

    it('rejects deciduous codes rather than snapping to a permanent tooth', () => {
      expect(resolveToothIntent(explicit('51'))).toBeNull();
      expect(resolveToothIntent(explicit('85'))).toBeNull();
    });

    it('rejects nonsense', () => {
      for (const value of ['', '1', '99', '140']) {
        expect(resolveToothIntent(explicit(value))).toBeNull();
      }
    });
  });

  it('returns null for a missing or unknown intent shape', () => {
    expect(resolveToothIntent(undefined as unknown as ToothIntent)).toBeNull();
    expect(
      resolveToothIntent({ kind: 'guess' } as unknown as ToothIntent),
    ).toBeNull();
  });
});

describe('resolveToothIntents', () => {
  it('resolves a mixed list and sorts the result', () => {
    const result = resolveToothIntents([
      positional('upper', 'patient_right', 5, 'پنج بالا راست'),
      explicit('14', 'یک چهار'),
    ]);
    expect(result.teeth).toEqual(['14', '15']);
    expect(result.unresolved).toEqual([]);
  });

  it('collapses a tooth named twice', () => {
    const result = resolveToothIntents([
      explicit('14', 'چهارده'),
      positional('upper', 'patient_right', 4, 'چهار بالا راست'),
    ]);
    expect(result.teeth).toEqual(['14']);
  });

  it('reports what it could not understand instead of dropping it', () => {
    const result = resolveToothIntents([
      explicit('14', 'یک چهار'),
      explicit('51', 'دندان شیری'),
      positional('upper', 'patient_right', 9, 'نه بالا راست'),
    ]);
    expect(result.teeth).toEqual(['14']);
    expect(result.unresolved).toEqual([
      { spoken: 'دندان شیری', reason: 'not_permanent_tooth' },
      { spoken: 'نه بالا راست', reason: 'position_out_of_range' },
    ]);
  });

  it('does not repeat an identical unresolved item', () => {
    const result = resolveToothIntents([
      explicit('51', 'شیری'),
      explicit('51', 'شیری'),
    ]);
    expect(result.unresolved).toHaveLength(1);
  });

  it('survives a non-array where the model should have sent a list', () => {
    // The model can return an object or a number here; that must degrade, not 500.
    for (const bad of [undefined, null, 5, 'teeth', { fdi: '14' }]) {
      expect(resolveToothIntents(bad as unknown as ToothIntent[])).toEqual({
        teeth: [],
        unresolved: [],
      });
    }
  });

  it('reads a spoken number as its FDI code, digits in any script', () => {
    // The product rule: the number the clinician says IS the tooth. 26 = quadrant 2
    // (patient's upper left) + position 6 = first molar.
    for (const raw of ['26', ' 26 ', '2 6', '\u06F2\u06F6', '\u0662\u0666']) {
      expect(resolveToothIntents([explicit(raw, 'x')]).teeth).toEqual(['26']);
    }
  });

  it('trims an explicit code, matching normalizeTeeth', () => {
    expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']);
  });

  it('distinguishes a deciduous tooth from nonsense in the reason it reports', () => {
    // '51' really is a (primary) tooth the chart cannot show; '99' is not a tooth at all.
    expect(
      resolveToothIntents([explicit('51', 'shiri')]).unresolved[0].reason,
    ).toBe('not_permanent_tooth');
    for (const junk of ['99', '19', '140', '', 'ab']) {
      expect(
        resolveToothIntents([explicit(junk, `j-${junk}`)]).unresolved[0].reason,
      ).toBe('malformed');
    }
  });

  it('says the quadrant is missing rather than blaming the words', () => {
    // Regression: "ترمیم برای دندون دو" reported "could not be read", sending the
    // clinician to look for a transcription fault. Position 2 was understood fine —
    // what is missing is the quadrant, and four teeth carry position 2.
    const bare = {
      kind: 'positional',
      arch: null,
      side: null,
      position: 2,
      spoken: 'دندون دو',
    } as unknown as ToothIntent;

    const result = resolveToothIntents([bare]);
    expect(result.teeth).toEqual([]);
    expect(result.unresolved).toEqual([
      {
        spoken: 'دندون دو',
        reason: 'tooth_missing_quadrant',
        // Every reading of "position 2", for the clinician to pick from.
        candidates: ['12', '22', '32', '42'],
      },
    ]);
  });

  it('narrows the candidates by whatever the clinician did say', () => {
    const half = (arch: string | null, side: string | null) =>
      resolveToothIntents([
        {
          kind: 'positional',
          arch,
          side,
          position: 2,
          spoken: 'دو',
        } as unknown as ToothIntent,
      ]).unresolved[0].candidates;

    expect(half('upper', null)).toEqual(['12', '22']);
    expect(half('lower', null)).toEqual(['32', '42']);
    // Quadrant 1 is the patient's upper right, 4 the lower right.
    expect(half(null, 'patient_right')).toEqual(['12', '42']);
    expect(half(null, 'patient_left')).toEqual(['22', '32']);
  });

  it('offers no candidates for a reason a choice cannot settle', () => {
    // Nothing to choose between when the position itself was wrong, or the tooth is
    // deciduous — offering chips there would invent options.
    for (const intent of [
      positional('upper', 'patient_right', 9, 'نه'),
      explicit('51', 'شیری'),
    ]) {
      expect(
        resolveToothIntents([intent]).unresolved[0].candidates,
      ).toBeUndefined();
    }
  });

  it('reports a missing quadrant for a half-specified tooth too', () => {
    // "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess.
    for (const half of [
      { arch: 'upper', side: null },
      { arch: null, side: 'patient_right' },
    ]) {
      const result = resolveToothIntents([
        {
          kind: 'positional',
          ...half,
          position: 2,
          spoken: 'دو',
        } as unknown as ToothIntent,
      ]);
      expect(result.unresolved[0].reason).toBe('tooth_missing_quadrant');
    }
  });

  it('still calls an out-of-range position out of range when the quadrant is missing', () => {
    // Position wins: "nine" is wrong however completely it was said.
    const result = resolveToothIntents([
      {
        kind: 'positional',
        arch: null,
        side: null,
        position: 9,
        spoken: 'نه',
      } as unknown as ToothIntent,
    ]);
    expect(result.unresolved[0].reason).toBe('position_out_of_range');
  });

  it('keeps unresolved items separate when the model omits the spoken span', () => {
    // Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth.
    const result = resolveToothIntents([
      { kind: 'explicit', fdi: '51' } as ToothIntent,
      { kind: 'explicit', fdi: '52' } as ToothIntent,
    ]);
    expect(result.unresolved).toHaveLength(2);
  });

  it('handles an empty or missing list', () => {
    expect(resolveToothIntents([])).toEqual({ teeth: [], unresolved: [] });
    expect(resolveToothIntents(undefined as unknown as ToothIntent[])).toEqual({
      teeth: [],
      unresolved: [],
    });
  });
});
