// Printer-compatibility matching for the SliceModal's process / filament
// dropdowns (#1325).
//
// Compatibility is resolved in this order, stopping on the first non-unknown
// answer:
//
//   1. Imported (local-tier) presets carry the slicer's own
//      `compatible_printers` list — an exact list of printer-preset names.
//   2. The `@<printer>` naming convention, in both shapes the slicer
//      writes: `@BBL <model>` on shipped cloud / standard presets, and
//      `@Bambu Lab <model> <size> nozzle` on presets a user saved for a
//      specific printer (#2628). The token → printer-fragment table is
//      derived from the backend's canonical PRINTER_MODEL_MAP (fetched via
//      /slicer/printer-models), not duplicated here.
//
// Only a definite 'mismatch' is acted on: the dropdown holds those back
// behind a "Show all" link. A preset no rule covers classifies as 'unknown'
// and always stays in the list — absence of evidence is not evidence of
// incompatibility, and hiding an untagged preset would make a user's own
// imported profiles disappear. That asymmetry is why every parse failure
// below returns 'unknown' rather than guessing a mismatch.

export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';

// Lookup tables consumed by `presetCompatibility`. `bambuModelByShortCode`
// is the @BBL token → printer-preset fragment map derived from the backend's
// PRINTER_MODEL_MAP — e.g. `X1C` → `X1 Carbon`. An empty map means the @BBL
// fallback still works when token and printer-name fragment match directly
// (raw-token comparison), and gracefully degrades otherwise.
export interface PrinterCompatibilityIndex {
  bambuModelByShortCode: Record<string, string>;
}

/** An empty index — used when the model map hasn't loaded yet. */
export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
  bambuModelByShortCode: {},
};

// Bambu cloud started shipping terse model codes in `@BBL <code>` suffixes
// mid-2026 — the most visible one is "A1 Mini" → "A1M" (#1649, reported by
// @technopaw). User-authored profiles still use the long display name, so
// both shapes have to match the same printer. The table is uppercase-normalised
// for case-insensitive lookups; add a row when a future rename is spotted via
// `/api/v1/cloud/settings`. Keep narrow on purpose — wide-net aliasing
// (e.g. "X1" ⇄ "X1C") would silently group truly distinct printers.
const PRINTER_MODEL_SUFFIX_ALIASES: Record<string, readonly string[]> = {
  'A1 MINI': ['A1M'],
};

/**
 * True when ``presetSuffix`` (the token extracted from a "@BBL <code>" or
 * preset-name suffix) refers to the same printer as ``printerModel``
 * (the display name selected in the picker). Case-insensitive; consults
 * the alias table for short codes Bambu introduced after the long forms
 * shipped (#1649).
 */
export function matchesPrinterModelSuffix(presetSuffix: string, printerModel: string): boolean {
  const p = presetSuffix.toUpperCase();
  const m = printerModel.toUpperCase();
  if (p === m) return true;
  const aliasesOfM = PRINTER_MODEL_SUFFIX_ALIASES[m];
  if (aliasesOfM && aliasesOfM.includes(p)) return true;
  const aliasesOfP = PRINTER_MODEL_SUFFIX_ALIASES[p];
  if (aliasesOfP && aliasesOfP.includes(m)) return true;
  return false;
}

/**
 * Invert the backend's PRINTER_MODEL_MAP into the shape the @BBL fallback
 * needs: short code → printer-preset fragment (the part of "Bambu Lab X1
 * Carbon" the user sees in a printer preset name, minus the "Bambu Lab "
 * brand prefix).
 *
 * Backend ships e.g. `{"Bambu Lab X1 Carbon": "X1C", "Bambu Lab A1 mini":
 * "A1 Mini", "Bambu Lab A1 Mini": "A1 Mini"}` — multiple long forms can map
 * to the same short. We pick the first long-form encountered for each short
 * code; case normalisation happens at match time so "A1 mini" vs "A1 Mini"
 * never matters.
 */
function buildShortCodeMap(
  printerModels: Record<string, string>,
): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [longName, shortCode] of Object.entries(printerModels)) {
    if (shortCode in out) continue;
    out[shortCode] = longName.replace(/^Bambu Lab\s+/, '');
  }
  return out;
}

/**
 * Build the compatibility index from the backend printer-model registry.
 */
export function buildCompatibilityIndex(
  printerModels: Record<string, string> = {},
): PrinterCompatibilityIndex {
  return {
    bambuModelByShortCode: buildShortCodeMap(printerModels),
  };
}

function normalizeModelFragment(s: string): string {
  return s.replace(/\s+/g, '').toLowerCase();
}

/**
 * Drop BambuStudio's ``"# "`` user-clone prefix.
 *
 * Editing a system preset saves a copy named ``"# Bambu Lab X1 Carbon 0.4
 * nozzle"``, and `.bbscfg` bundle exports use the same convention. Two places
 * need it off:
 *
 *   1. ``extractPrinterPresetModel`` — the prefix fails its "Bambu Lab …"
 *      test, so a cloned *printer* made every preset classify as 'unknown'
 *      and the dropdown filter silently did nothing.
 *   2. The ``compatible_printers`` comparison, where a prefix on one side
 *      alone reads as a mismatch against the very printer the preset was
 *      cloned from — and a mismatch now hides the preset.
 *
 * The ``@`` tag extractors need no such handling: they scan for "@BBL " or
 * the last "@", both of which skip a leading prefix already.
 *
 * The backend normalises the same prefix in ``_canonical_printer_model``.
 */
function stripUserClonePrefix(name: string): string {
  return name.replace(/^#\s*/, '').trim();
}

// Bambu Studio's naming convention for bundled presets: the 0.4 nozzle is
// the default and its variants drop the nozzle suffix; 0.2 / 0.6 / 0.8
// carry an explicit "<size> nozzle" segment. So a process with no suffix
// is implicitly a 0.4 process — required to compare correctly against a
// 0.4 printer preset, which DOES carry the suffix.
const DEFAULT_NOZZLE = '0.4';

// Strip a trailing "<size> nozzle" segment, returning the nozzle string
// (e.g. "0.6") or null when absent. Used by both BBL-token and printer-
// preset extractors so the suffix is parsed identically on both sides.
function takeNozzleSuffix(s: string): { stripped: string; nozzle: string | null } {
  const m = s.match(/^(.*?)\s+([\d.]+)\s*nozzle\s*$/i);
  if (!m) return { stripped: s.trim(), nozzle: null };
  return { stripped: m[1].trim(), nozzle: m[2] };
}

// Pull the model token and nozzle out of a "@BBL <token> [<size> nozzle]"
// suffix. The token may contain a space (e.g. "A1 mini"), so we strip a
// trailing nozzle segment rather than splitting on the first whitespace.
function extractBblToken(presetName: string): { token: string; nozzle: string | null } | null {
  const marker = '@BBL ';
  const idx = presetName.indexOf(marker);
  if (idx < 0) return null;
  const rest = presetName.slice(idx + marker.length).trim();
  const { stripped, nozzle } = takeNozzleSuffix(rest);
  return stripped ? { token: stripped, nozzle } : null;
}

// Pull the model fragment and nozzle out of a "Bambu Lab <model> [<size>
// nozzle]" printer preset name. Returns null for non-Bambu printer
// presets — there is no reliable name-based match against those.
function extractPrinterPresetModel(printerPresetName: string): { model: string; nozzle: string | null } | null {
  const m = stripUserClonePrefix(printerPresetName).match(/^Bambu Lab\s+(.+)$/i);
  if (!m) return null;
  const { stripped, nozzle } = takeNozzleSuffix(m[1]);
  return stripped ? { model: stripped, nozzle } : null;
}

// Trailing parenthetical the slicer appends to user-saved presets —
// "… @Bambu Lab H2D 0.4 nozzle (Custom)". Dropped before the nozzle suffix
// is parsed, or the tag would resolve to a nonsense model token and the
// preset would be branded a mismatch against its OWN printer.
function stripTrailingParenthetical(s: string): string {
  return s.replace(/\s*\([^)]*\)\s*$/, '').trim();
}

// Nozzle sizes Bambu ships run 0.2 – 0.8. The range guard keeps a tag that
// merely looks numeric ("PLA @2026") from being read as a nozzle and branded
// incompatible with every printer.
const MIN_NOZZLE_MM = 0.1;
const MAX_NOZZLE_MM = 2.0;

// Compare two nozzle strings numerically, so "0.20" and "0.2" are the same
// size. Unparseable values never match — a size we can't read is not evidence.
function sameNozzle(a: string, b: string): boolean {
  const x = Number.parseFloat(a);
  const y = Number.parseFloat(b);
  if (Number.isNaN(x) || Number.isNaN(y)) return false;
  return x === y;
}

// Pull the model token and nozzle out of a preset name's printer tag.
// Three shapes exist in the wild (#2628):
//
//   "0.20mm Standard @BBL X1C"                    — short code, the form
//      Bambu ships its own cloud / standard presets under.
//   "SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle"     — the full printer-preset
//      name, the form the slicer writes when a user saves their own preset
//      for a printer. Handling only the short form left these classified
//      'unknown', so an H2D-scoped filament was offered (and auto-picked)
//      for an A1 slice, which the CLI then rejected.
//   "Overture PLA Matte @0.2"                     — nozzle only, no model.
//      Returned with a null token: the size can rule a printer OUT, but
//      says nothing about which models the profile belongs to.
//
// The first two shapes are also parsed in ConfigureAmsSlotModal (#1623).
function extractPrinterTag(presetName: string): { token: string | null; nozzle: string | null } | null {
  const cleaned = stripTrailingParenthetical(presetName);
  const bbl = extractBblToken(cleaned);
  if (bbl) return bbl;
  // The printer tag is a suffix by convention, so read from the LAST '@' —
  // a stray earlier one ("My @work PLA @Bambu Lab H2D 0.4 nozzle") must not
  // swallow it. Anything that doesn't parse as a Bambu printer preset name
  // falls through to 'unknown', never to a guessed mismatch.
  const at = cleaned.lastIndexOf('@');
  if (at < 0) return null;
  const suffix = cleaned.slice(at + 1).trim();
  const longForm = extractPrinterPresetModel(suffix);
  if (longForm) return { token: longForm.model, nozzle: longForm.nozzle };
  const nozzleOnly = suffix.match(/^([\d.]+)\s*(?:mm)?\s*(?:nozzle)?$/i);
  if (nozzleOnly) {
    const size = Number.parseFloat(nozzleOnly[1]);
    if (!Number.isNaN(size) && size >= MIN_NOZZLE_MM && size <= MAX_NOZZLE_MM) {
      return { token: null, nozzle: nozzleOnly[1] };
    }
  }
  return null;
}

/**
 * Name-based fallback for presets carrying a printer tag — BambuStudio's own
 * `@BBL <model>` (#1325 follow-up), the full `@Bambu Lab <model> <size>
 * nozzle` form user-saved presets get, or a bare `@<size>` (#2628).
 * Used only after `compatible_printers` has returned `'unknown'`.
 *
 * Compares BOTH model AND nozzle. The nozzle filter is required because
 * Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 /
 * 0.8) — a 0.6-nozzle process is unusable on a 0.4-nozzle printer.
 * 0.4 is Bambu's default and its variants drop the nozzle suffix, so a
 * preset with no suffix counts as 0.4.
 */
function classifyByBambuName(
  presetName: string,
  selectedPrinterName: string,
  bambuModelByShortCode: Record<string, string>,
): PrinterCompatibility {
  const parsed = extractPrinterTag(presetName);
  if (!parsed) return 'unknown';
  const selectedParts = extractPrinterPresetModel(selectedPrinterName);
  if (!selectedParts) return 'unknown';
  if (parsed.token === null) {
    // Nozzle-only tag ("Overture PLA Matte @0.2"). The size can rule a
    // printer OUT, but a matching size proves nothing about the model, so
    // the best this can ever return is 'unknown' — never 'match'.
    if (
      selectedParts.nozzle !== null
      && parsed.nozzle !== null
      && !sameNozzle(parsed.nozzle, selectedParts.nozzle)
    ) {
      return 'mismatch';
    }
    return 'unknown';
  }
  // If the token isn't in the table (a brand-new Bambu model whose short
  // code the backend registry hasn't added yet, or the model map hasn't
  // loaded yet), fall back to comparing the raw token. That keeps the
  // matcher working when token and printer-name fragment happen to be
  // identical — e.g. "Q1" preset against "Bambu Lab Q1 0.4 nozzle" —
  // without us having to ship a code update. When they differ in form
  // (X1C vs "X1 Carbon"), the registry is what makes the match work.
  const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token;
  // The raw inferred model and the printer-preset fragment may differ only by
  // the Bambu short-code rename (e.g. preset token "A1M" vs printer "A1 Mini").
  // ``matchesPrinterModelSuffix`` consults the alias table before declaring a
  // mismatch — see #1649.
  if (
    normalizeModelFragment(selectedParts.model) !== normalizeModelFragment(inferredModel)
    && !matchesPrinterModelSuffix(parsed.token, selectedParts.model)
  ) {
    return 'mismatch';
  }
  // Nozzle compare — only when we have a usable size from the printer
  // side. A Bambu printer preset always carries one, so this branch is
  // taken in practice; the null path is defensive degrade for hand-typed
  // or non-Bambu printer names that happened to match the model.
  if (selectedParts.nozzle !== null) {
    const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE;
    if (!sameNozzle(presetNozzle, selectedParts.nozzle)) return 'mismatch';
  }
  return 'match';
}

/**
 * Classify a process / filament preset against the selected printer.
 *
 * - 'match'    — the preset is compatible with the selected printer.
 * - 'mismatch' — the preset resolves to a *different* printer.
 * - 'unknown'  — compatibility can't be determined (no `compatible_printers`,
 *                no recognizable `@BBL` tag, or no printer is selected);
 *                the caller must not hide it.
 */
export function presetCompatibility(
  preset: { name: string; compatible_printers?: string[] | null },
  _slot: 'process' | 'filament',
  selectedPrinterName: string | null,
  index: PrinterCompatibilityIndex,
): PrinterCompatibility {
  if (!selectedPrinterName) return 'unknown';
  // (1) Imported presets carry the slicer's own compatible_printers list —
  // authoritative when set.
  const compat = preset.compatible_printers;
  if (compat && compat.length > 0) {
    // Compared with the clone prefix off both sides: a preset cloned from a
    // system printer lists the *unprefixed* name, and comparing that raw
    // against a selected "# Bambu Lab …" reads as a mismatch — which now
    // hides the preset rather than merely demoting it.
    const selected = stripUserClonePrefix(selectedPrinterName);
    return compat.some((name) => stripUserClonePrefix(name) === selected) ? 'match' : 'mismatch';
  }
  // (2) BambuStudio's `@BBL <model>` name convention — covers cloud /
  // standard presets that don't carry compatible_printers.
  return classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
}
