import {
  resolveConnectedSpans,
  resolveProsthesis,
  resolveVoiceIntent,
  type ResolveContext,
} from './extraction.resolver';
import type { ToothIntent, VoiceIntent } from './voice.types';

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

const CTX: ResolveContext = {
  todayIso: '2025-10-11',
  weekStartJs: 6, // Saturday — the fa week

  treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
  prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
  linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
};

describe('resolveConnectedSpans', () => {
  it('selects the teeth between the endpoints, which were never named', () => {
    // "a bridge from 14 to 16" must select 15 too.
    const result = resolveConnectedSpans(
      [{ from: tooth('14'), to: tooth('16') }],
      [],
    );
    expect(result.teeth).toEqual(['14', '15', '16']);
    expect(result.groups).toEqual([
      { groupId: 'voice-c1', kind: 'connected', teeth: ['16', '15', '14'] },
    ]);
  });

  it('orders group teeth along the arch, not lexically', () => {
    const result = resolveConnectedSpans(
      [{ from: tooth('16'), to: tooth('14') }],
      [],
    );
    expect(result.groups[0].teeth).toEqual(['16', '15', '14']);
  });

  it('spans the midline', () => {
    const result = resolveConnectedSpans(
      [{ from: tooth('12'), to: tooth('22') }],
      [],
    );
    expect(result.groups[0].teeth).toEqual(['12', '11', '21', '22']);
  });

  it('merges overlapping spans into one bridge', () => {
    const result = resolveConnectedSpans(
      [
        { from: tooth('14'), to: tooth('16') },
        { from: tooth('15'), to: tooth('17') },
      ],
      [],
    );
    const connected = result.groups.filter((g) => g.kind === 'connected');
    expect(connected).toHaveLength(1);
    expect(connected[0].teeth).toEqual(['17', '16', '15', '14']);
  });

  it('gives loose teeth their own single groups', () => {
    const result = resolveConnectedSpans(
      [{ from: tooth('14'), to: tooth('15') }],
      ['26'],
    );
    expect(result.groups).toEqual([
      { groupId: 'voice-c1', kind: 'connected', teeth: ['15', '14'] },
      { groupId: 'voice-s-26', kind: 'single', teeth: ['26'] },
    ]);
  });

  it('never produces a one-tooth connected group', () => {
    const result = resolveConnectedSpans(
      [{ from: tooth('14'), to: tooth('14') }],
      [],
    );
    expect(result.groups).toEqual([
      { groupId: 'voice-s-14', kind: 'single', teeth: ['14'] },
    ]);
    expect(result.unresolved).toEqual([]);
  });

  it('reports a cross-arch span rather than guessing', () => {
    const result = resolveConnectedSpans(
      [{ from: tooth('14', 'چهارده'), to: tooth('44', 'چهل و چهار') }],
      [],
    );
    expect(result.groups).toEqual([]);
    expect(result.unresolved).toEqual([
      { spoken: 'چهارده → چهل و چهار', reason: 'span_not_same_arch' },
    ]);
  });

  it('reports a span with an unresolvable endpoint', () => {
    const result = resolveConnectedSpans(
      [{ from: tooth('14'), to: tooth('99') }],
      [],
    );
    expect(result.unresolved[0].reason).toBe('malformed');
  });

  it('survives a non-array', () => {
    expect(resolveConnectedSpans(undefined as never, ['14']).teeth).toEqual([
      '14',
    ]);
  });
});

describe('resolveProsthesis', () => {
  const allowed = CTX.prosthesisTypeCodes;

  it('expands the default across every tooth', () => {
    const result = resolveProsthesis(
      { defaultType: 'monolithic_zirconia', overrides: [] },
      ['14', '15'],
      allowed,
    );
    expect(result.prosthesis?.byTooth).toEqual({
      '14': 'monolithic_zirconia',
      '15': 'monolithic_zirconia',
    });
    expect(result.prosthesis?.complete).toBe(true);
  });

  it('applies per-tooth overrides on top of the default', () => {
    const result = resolveProsthesis(
      {
        defaultType: 'monolithic_zirconia',
        overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
      },
      ['14', '26'],
      allowed,
    );
    expect(result.prosthesis?.byTooth).toEqual({
      '14': 'monolithic_zirconia',
      '26': 'pfm_crown',
    });
    expect(result.prosthesis?.complete).toBe(true);
  });

  it('marks the map incomplete when a tooth ends up untyped', () => {
    // Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
    const result = resolveProsthesis(
      {
        defaultType: null,
        overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
      },
      ['14', '15'],
      allowed,
    );
    expect(result.prosthesis?.complete).toBe(false);
    expect(result.prosthesis?.missingTeeth).toEqual(['15']);
  });

  it('rejects a catalog code the clinic does not have', () => {
    const result = resolveProsthesis(
      { defaultType: 'gold_foil', overrides: [] },
      ['14'],
      allowed,
    );
    // Nothing usable was said, so there is no prosthesis to show — not an empty one.
    expect(result.prosthesis).toBeNull();
    expect(result.unresolved).toEqual([
      { spoken: 'gold_foil', reason: 'unknown_catalog_code' },
    ]);
  });

  it('ignores an override for a tooth that is not selected', () => {
    const result = resolveProsthesis(
      {
        defaultType: 'monolithic_zirconia',
        overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
      },
      ['14'],
      allowed,
    );
    expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
    expect(result.unresolved).toEqual([
      { spoken: 'سی و هفت', reason: 'tooth_not_selected' },
    ]);
  });

  it('reports no prosthesis at all when the object carries nothing usable', () => {
    // An empty-but-present map would paint a plain restoration with a fabricated
    // "incomplete, cannot ship" warning.
    for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
      expect(
        resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
      ).toBeNull();
    }
  });

  it('reports no prosthesis when there are no teeth to type', () => {
    const result = resolveProsthesis(
      { defaultType: 'monolithic_zirconia', overrides: [] },
      [],
      allowed,
    );
    expect(result.prosthesis).toBeNull();
  });

  it('distinguishes a tooth it could not understand from one that is not selected', () => {
    // Different corrective actions: add the tooth, versus repeat yourself.
    const result = resolveProsthesis(
      {
        defaultType: 'monolithic_zirconia',
        overrides: [
          {
            tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
            type: 'pfm_crown',
          },
        ],
      },
      ['14'],
      allowed,
    );
    expect(result.unresolved).toEqual([
      { spoken: 'نود و نه', reason: 'malformed' },
    ]);
  });

  it('returns null when no prosthesis was spoken', () => {
    expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
  });
});

describe('resolveVoiceIntent', () => {
  const base: VoiceIntent = {
    treatmentType: 'restoration',
    teeth: [tooth('14'), tooth('15')],
    connectedSpans: [],
    comment: '  حساسیت به سرما  ',
    prosthesis: null,
    labId: null,
    labMatchExact: false,
    due: null,
  };

  it('composes a plain restoration', () => {
    const result = resolveVoiceIntent(base, CTX);
    expect(result.treatmentType).toBe('restoration');
    expect(result.teeth).toEqual(['14', '15']);
    expect(result.comment).toBe('حساسیت به سرما');
    expect(result.prosthesis).toBeNull();
    expect(result.unresolved).toEqual([]);
  });

  it('rejects a treatment type outside the catalog', () => {
    const result = resolveVoiceIntent(
      { ...base, treatmentType: 'teeth_whitening' },
      CTX,
    );
    expect(result.treatmentType).toBeNull();
    expect(result.unresolved).toContainEqual({
      spoken: 'teeth_whitening',
      reason: 'unknown_catalog_code',
    });
  });

  it('drops a lab id the clinic is not linked to', () => {
    // Shipping to a lab the clinic never named is worse than shipping nowhere.
    const result = resolveVoiceIntent(
      { ...base, labId: 'lab-elsewhere', labMatchExact: true },
      CTX,
    );
    expect(result.labId).toBeNull();
    expect(result.labMatchExact).toBe(false);
  });

  it('keeps a linked lab and its exactness flag', () => {
    const result = resolveVoiceIntent(
      { ...base, labId: 'lab-sina', labMatchExact: true },
      CTX,
    );
    expect(result.labId).toBe('lab-sina');
    expect(result.labMatchExact).toBe(true);
  });

  it('reports a hallucinated lab rather than dropping it silently', () => {
    // A near-miss lab id must not look identical to "no lab was spoken".
    const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX);
    // The id is not what the clinician said — quoting it back shows them a raw UUID.
    expect(result.unresolved).toContainEqual({
      spoken: '',
      reason: 'unknown_catalog_code',
    });
  });

  it('never reports an inexact match as exact when the lab was dropped', () => {
    const result = resolveVoiceIntent(
      { ...base, labId: null, labMatchExact: true },
      CTX,
    );
    expect(result.labMatchExact).toBe(false);
  });

  it('applies prosthesis over the span-expanded tooth set', () => {
    const result = resolveVoiceIntent(
      {
        ...base,
        treatmentType: 'prosthesis',
        teeth: [tooth('14')],
        connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
        prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
      },
      CTX,
    );
    // 15 was never spoken but is part of the bridge, so it must carry a type too.
    expect(result.teeth).toEqual(['14', '15', '16']);
    expect(result.prosthesis?.complete).toBe(true);
    expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
      '14',
      '15',
      '16',
    ]);
  });

  it('resolves a due date through the same context', () => {
    const result = resolveVoiceIntent(
      { ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
      CTX,
    );
    expect(result.dueDate).toBe('2025-10-16');
  });

  it('collects unresolved items from every stage', () => {
    const result = resolveVoiceIntent(
      {
        ...base,
        treatmentType: 'nope',
        teeth: [tooth('51', 'شیری')],
        connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
        due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
      },
      CTX,
    );
    const reasons = result.unresolved.map((u) => u.reason).sort();
    expect(reasons).toEqual([
      'invalid_date',
      'not_permanent_tooth',
      'span_not_same_arch',
      'unknown_catalog_code',
    ]);
  });

  it('treats an empty comment as absent', () => {
    expect(
      resolveVoiceIntent({ ...base, comment: '   ' }, CTX).comment,
    ).toBeNull();
  });
});
