import {
  sameArch,
  sortInArchOrder,
  teethBetweenInclusive,
} from '../../common/fdi';
import { resolveDueDate } from './due-date.resolver';
import {
  resolveToothIntent,
  resolveToothIntents,
} from './tooth-intent.resolver';
import type {
  ConnectedSpanIntent,
  ProsthesisIntent,
  ToothIntent,
  UnresolvedItem,
  VoiceIntent,
} from './voice.types';

export type ResolvedToothGroup = {
  groupId: string;
  kind: 'connected' | 'single';
  teeth: string[];
};

export type ResolvedProsthesis = {
  /** FDI code → prosthesis type code. */
  byTooth: Record<string, string>;
  /**
   * True when every selected tooth carries a code. A prosthesis detail cannot be shipped
   * otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap
   * here rather than letting it fail at dispatch.
   */
  complete: boolean;
  missingTeeth: string[];
};

export type ResolvedExtraction = {
  treatmentType: string | null;
  teeth: string[];
  toothSelectionGroups: ResolvedToothGroup[];
  comment: string | null;
  prosthesis: ResolvedProsthesis | null;
  labId: string | null;
  labMatchExact: boolean;
  dueDate: string | null;
  unresolved: UnresolvedItem[];
};

export type ResolveContext = {
  todayIso: string;
  /** JS weekday index the clinician's week starts on — see weekStartForLocale. */
  weekStartJs: number;
  treatmentTypeCodes: ReadonlySet<string>;
  prosthesisTypeCodes: ReadonlySet<string>;
  linkedLabIds: ReadonlySet<string>;
};

function spokenOf(intent: ToothIntent): string {
  const spoken = (intent as { spoken?: unknown })?.spoken;
  return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}

/** A code the model returned is only usable if it exists in the catalog we supplied it. */
function resolveCatalogCode(
  value: unknown,
  allowed: ReadonlySet<string>,
): string | null {
  if (typeof value !== 'string') return null;
  const trimmed = value.trim();
  return trimmed && allowed.has(trimmed) ? trimmed : null;
}

/** Merge any span sets that share a tooth, so overlapping bridges become one group. */
function mergeOverlapping(sets: string[][]): string[][] {
  const merged: string[][] = [];
  for (const candidate of sets) {
    let current = [...candidate];
    let index = 0;
    while (index < merged.length) {
      if (merged[index].some((tooth) => current.includes(tooth))) {
        current = [...new Set([...merged[index], ...current])];
        merged.splice(index, 1);
        index = 0;
        continue;
      }
      index += 1;
    }
    merged.push(current);
  }
  return merged;
}

/**
 * Span teeth join the selection: "a bridge from 14 to 16" selects 15 though it was never
 * named. A cross-arch span is reported rather than guessed at, and a span collapsing to one
 * tooth degrades to a single — there is no one-tooth bridge.
 */
export function resolveConnectedSpans(
  spans: readonly ConnectedSpanIntent[],
  selectedTeeth: readonly string[],
): {
  groups: ResolvedToothGroup[];
  teeth: string[];
  unresolved: UnresolvedItem[];
} {
  const unresolved: UnresolvedItem[] = [];
  const connectedSets: string[][] = [];
  // A span that collapses to one tooth still selected that tooth — it must not vanish.
  const loneSpanTeeth: string[] = [];
  const list: readonly ConnectedSpanIntent[] = Array.isArray(spans)
    ? (spans as readonly ConnectedSpanIntent[])
    : [];

  for (const span of list) {
    const from = resolveToothIntent(span?.from);
    const to = resolveToothIntent(span?.to);
    const spoken = [spokenOf(span?.from), spokenOf(span?.to)]
      .filter(Boolean)
      .join(' → ');

    if (!from || !to) {
      unresolved.push({ spoken, reason: 'malformed' });
      continue;
    }
    if (!sameArch(from, to)) {
      unresolved.push({ spoken, reason: 'span_not_same_arch' });
      continue;
    }
    const between = teethBetweenInclusive(from, to);
    if (!between || between.length === 0) {
      unresolved.push({ spoken, reason: 'malformed' });
      continue;
    }
    if (between.length === 1) {
      loneSpanTeeth.push(between[0]);
      continue; // degrades to a single, below
    }
    connectedSets.push(between);
  }

  const groups: ResolvedToothGroup[] = [];
  const claimed = new Set<string>();
  mergeOverlapping(connectedSets).forEach((set, i) => {
    const teeth = sortInArchOrder(set);
    teeth.forEach((tooth) => claimed.add(tooth));
    groups.push({ groupId: `voice-c${i + 1}`, kind: 'connected', teeth });
  });

  const spanTeeth = [...groups.flatMap((g) => g.teeth), ...loneSpanTeeth];
  const singles = [...new Set([...selectedTeeth, ...spanTeeth])]
    .filter((tooth) => !claimed.has(tooth))
    .sort();
  for (const tooth of singles) {
    groups.push({
      groupId: `voice-s-${tooth}`,
      kind: 'single',
      teeth: [tooth],
    });
  }

  return {
    groups,
    teeth: [...new Set([...selectedTeeth, ...spanTeeth])].sort(),
    unresolved,
  };
}

/**
 * A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is
 * how clinicians actually speak.
 */
export function resolveProsthesis(
  intent: ProsthesisIntent | null | undefined,
  teeth: readonly string[],
  allowed: ReadonlySet<string>,
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
  if (!intent || typeof intent !== 'object')
    return { prosthesis: null, unresolved: [] };

  const unresolved: UnresolvedItem[] = [];
  const defaultType = resolveCatalogCode(intent.defaultType, allowed);
  if (intent.defaultType != null && !defaultType) {
    unresolved.push({
      spoken: String(intent.defaultType),
      reason: 'unknown_catalog_code',
    });
  }

  const byTooth: Record<string, string> = {};
  const selected = new Set(teeth);
  if (defaultType) {
    for (const tooth of teeth) byTooth[tooth] = defaultType;
  }

  const overrides: ProsthesisIntent['overrides'] = Array.isArray(
    intent.overrides,
  )
    ? intent.overrides
    : [];
  for (const override of overrides) {
    const tooth = resolveToothIntent(override?.tooth);
    const type = resolveCatalogCode(override?.type, allowed);
    const spoken = spokenOf(override?.tooth) || String(override?.type ?? '');
    if (!tooth) {
      unresolved.push({ spoken, reason: 'malformed' });
      continue;
    }
    // A tooth we understood perfectly well but which is not part of this detail. Saying
    // so is actionable ("add tooth 37, or drop it"); calling it malformed is not.
    if (!selected.has(tooth)) {
      unresolved.push({ spoken, reason: 'tooth_not_selected' });
      continue;
    }
    if (!type) {
      unresolved.push({ spoken, reason: 'unknown_catalog_code' });
      continue;
    }
    byTooth[tooth] = type;
  }

  // Nothing usable was said about prosthesis. Returning an empty-but-present map would
  // paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
  if (Object.keys(byTooth).length === 0) {
    return { prosthesis: null, unresolved };
  }

  const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
  return {
    prosthesis: {
      byTooth,
      complete: missingTeeth.length === 0,
      missingTeeth,
    },
    unresolved,
  };
}

/** Compose every resolver into the payload the review sheet renders. */
export function resolveVoiceIntent(
  intent: VoiceIntent,
  ctx: ResolveContext,
): ResolvedExtraction {
  const unresolved: UnresolvedItem[] = [];

  const toothResult = resolveToothIntents(intent?.teeth ?? []);
  unresolved.push(...toothResult.unresolved);

  const spanResult = resolveConnectedSpans(
    intent?.connectedSpans ?? [],
    toothResult.teeth,
  );
  unresolved.push(...spanResult.unresolved);

  const treatmentType = resolveCatalogCode(
    intent?.treatmentType,
    ctx.treatmentTypeCodes,
  );
  if (intent?.treatmentType != null && !treatmentType) {
    unresolved.push({
      spoken: String(intent.treatmentType),
      reason: 'unknown_catalog_code',
    });
  }

  const prosthesisResult = resolveProsthesis(
    intent?.prosthesis,
    spanResult.teeth,
    ctx.prosthesisTypeCodes,
  );
  unresolved.push(...prosthesisResult.unresolved);

  const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
  if (due.unresolved) unresolved.push(due.unresolved);

  const comment =
    typeof intent?.comment === 'string' && intent.comment.trim()
      ? intent.comment.trim()
      : null;

  // An invented lab id would ship a case to a lab the clinic never named. A rejected one is
  // reported, so it cannot look identical to "no lab was spoken".
  const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
  if (intent?.labId != null && !labId) {
    // `spoken` is what the clinician said — quoting an invented id back would put a raw
    // UUID in front of the user.
    unresolved.push({ spoken: '', reason: 'unknown_catalog_code' });
  }

  return {
    treatmentType,
    teeth: spanResult.teeth,
    toothSelectionGroups: spanResult.groups,
    comment,
    prosthesis: prosthesisResult.prosthesis,
    labId,
    labMatchExact: labId ? intent?.labMatchExact === true : false,
    dueDate: due.dueDate,
    unresolved,
  };
}
