marmar-context / src / domain / checks / normalize.ts
normalize.ts
Raw
import { INGREDIENTS, INGREDIENT_BY_KEY, PRODUCTS, type DrugClass, type Ingredient } from './catalog.js';
import type { Matcher } from './rules.js';

/**
 * Turns what people type ("Panadol Extra 500mg", "agbo", "Coartem") into the
 * ingredients the rules talk about. Whole-word matching only: "Ginkgo" must
 * never become ginger and "Barkley" never bark. Longest names win, so
 * "Panadol Extra" is paracetamol + caffeine, not just paracetamol.
 */

export function normalizeText(value: string): string {
  return value
    .normalize('NFD').replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/['’]/g, '')
    // Hyphens become spaces so co-codamol, co codamol and cocodamol all match.
    .replace(/[^a-z0-9+/. ]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

interface NameEntry { name: string; ingredients: string[]; via: 'product' | 'generic' }

const NAME_ENTRIES: NameEntry[] = [
  ...PRODUCTS.flatMap((product) => product.names.map((name) => ({ name: normalizeText(name), ingredients: product.ingredients, via: 'product' as const }))),
  ...INGREDIENTS.flatMap((ingredient) => [ingredient.name, ...(ingredient.aliases ?? []), ingredient.key.replace(/-/g, ' ')]
    .map((name) => ({ name: normalizeText(name), ingredients: [ingredient.key], via: 'generic' as const }))),
].sort((a, b) => b.name.length - a.name.length);

const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const PATTERNS = NAME_ENTRIES.map((entry) => ({ entry, pattern: new RegExp(`(?<![a-z0-9])${escape(entry.name).replace(/ /g, ' ?')}(?![a-z0-9])`) }));

export interface MatchedMedicine {
  ingredients: string[];
  via: 'product' | 'generic' | 'none';
  matchedNames: string[];
  /** Words of the name that are neither a matched name nor a form, strength, salt or maker: they may be more ingredients (M-2). */
  unmatchedWords: string[];
}

/**
 * Medicine names the catalogue doesn't hold, by their usual endings
 * (pitavastatin, enoxaparin, nimesulide).
 */
export const DRUG_NAME_ENDING = /^[a-z]{3,}(pril|sartan|olol|statin|azole|cillin|cycline|mycin|floxacin|dipine|prazole|tidine|gliptin|gliflozin|glutide|parin|xaban|gatran|triptan|azepam|azolam|oxetine|triptyline|ipramine|codone|morphone|orphine|adone|profen|fenac|coxib|caine|olone|dronate|tinib|mab|vir|vudine|mectin|oquine|semide|thiazide|lukast|meterol|moterol|nterol|caterol|daterol|buterol|tropium|zosin|setron|peridol|apine|idone|barbital|pentin|gabalin|racetam|trigine|sone|sulide)$/;

/** Words in a medicine's name that never mean another ingredient: forms, strengths, salts, makers, ages. */
const NOT_AN_INGREDIENT = new Set(`tab tabs tablet tablets caplet caplets cap caps capsule capsules softgel softgels gelcap gelcaps liquicaps syrup syrups
suspension suspensions solution solutions liquid liquids elixir mixture drop drops injection injections inj vial vials ampoule ampoules amp
cream creams gel gels ointment ointments lotion lotions spray sprays inhaler inhalers puffer patch patches plaster plasters suppository
suppositories pessary pessaries sachet sachets powder powders granules lozenge lozenges pastille pastilles effervescent soluble dispersible
chewable chewables film coated enteric ec sr xl mr er la cr xr ir retard prolonged modified release extended slow oral eye ear nasal topical
emulgel shampoo shampoos evohaler accuhaler turbohaler diskus easyhaler liqui liquigel liquigels sugar free extra max maximum
od bd ds dt md sf ss jr hs qd tds bid tid qid om on po prn qds nocte mane stat uk ng fs
sublingual buccal infusion iv im mg mcg ug ml iu units unit strength forte low dose doses hcl hydrochloride hydrobromide sodium potassium
sulfate sulphate maleate besylate besilate mesylate mesilate tartrate succinate fumarate citrate phosphate acetate bromide chloride
trihydrate monohydrate dihydrate hemihydrate anhydrous medoxomil cilexetil axetil oxide carbonate gluconate lactate glycinate
bisglycinate picolinate orotate aspartate adult adults child children childrens kids kid junior
infant infants baby babies paediatric pediatric pack packs box strip blister bottle tube original regular advance advanced actifast rapid
fast express optizorb teva sandoz accord mylan viatris zentiva actavis aurobindo ranbaxy sun cipla zydus lupin glenmark torrent reddys
reddy emzor fidson swipha juhel hovid evans neimeth mopson pfizer gsk glaxo glaxosmithkline sanofi novartis bayer roche abbott merck msd
astrazeneca boehringer lilly janssen may baker bp usp eur generic brand and with of for in the as`.split(/\s+/));

/** Edits between two words, counting a swap of two neighbouring letters as one (optimal string alignment). */
export function editDistance(a: string, b: string): number {
  const rows = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
      if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
    }
  }
  return rows[a.length][b.length];
}

/** The drug-name ending of a word ("dipine" in felodipine), or null. */
export const drugNameEnding = (word: string): string | null => DRUG_NAME_ENDING.exec(word)?.[1] ?? null;

/** "penicillin" and "penicillins" are one word for correcting spelling (N10). */
const sameWord = (a: string, b: string) => a === b || `${a}s` === b || `${b}s` === a;

/**
 * Corrects likely misspellings ("Penicilin", "Pencillin", "Septrine") against
 * the given known words: a word of 6 or more letters is replaced by the one
 * known word within 1 edit (2 edits for 9 or more letters). A singular and its
 * plural count as one word ("Penicilline" is penicillin). Two different
 * equally close words leave it unchanged, and so does a word that reads as
 * another drug by its ending ("Felodipine" is never amlodipine), unless the
 * known word has the same ending (N9). Returns null when nothing was corrected.
 */
export function correctSpelling(text: string, known: ReadonlySet<string>): string | null {
  let changed = false;
  const words = normalizeText(text).split(' ').map((word) => {
    if (word.length < 6 || known.has(word) || !/^[a-z]+$/.test(word)) return word;
    const allowed = word.length >= 9 ? 2 : 1;
    const ending = drugNameEnding(word);
    let best: string | null = null;
    let bestDistance = Infinity;
    let tie = false;
    for (const candidate of known) {
      if (Math.abs(candidate.length - word.length) > allowed) continue;
      if (ending && drugNameEnding(candidate) !== ending) continue;
      const distance = editDistance(word, candidate);
      if (distance > allowed) continue;
      if (distance < bestDistance) { best = candidate; bestDistance = distance; tie = false; }
      else if (distance === bestDistance && best && !sameWord(candidate, best)) tie = true;
      else if (distance === bestDistance && best && candidate.length < best.length) best = candidate;
    }
    if (!best || tie) return word;
    changed = true;
    return best;
  });
  return changed ? words.join(' ') : null;
}

/**
 * Minerals that are also the salt in a drug's full name: "atorvastatin
 * calcium", "esomeprazole magnesium", "insulin zinc". Straight after another
 * drug's name they are part of that drug, not a supplement (L1).
 */
const SALT_MINERALS = new Set(['calcium', 'magnesium', 'zinc']);
const isMineralOrHerbal = (keys: string[]) => keys.every((key) => SALT_MINERALS.has(key) || key === 'iron' || Boolean(INGREDIENT_BY_KEY.get(key)?.herbal));

/** The word lists that decide whether a name is recognised, so answers built on the matcher can say which lists they used (medicine facts). */
export const MATCHER_WORDS = {
  notAnIngredient: [...NOT_AN_INGREDIENT].sort(),
  saltMinerals: [...SALT_MINERALS].sort(),
  drugNameEnding: DRUG_NAME_ENDING.source,
};

interface NameHit { entry: NameEntry; start: number; end: number }

/** Every catalogue name in a normalized text, longest first, each read once, with where it is. */
function namesIn(original: string): NameHit[] {
  let text = original;
  const hits: NameHit[] = [];
  for (const { entry, pattern } of PATTERNS) {
    const match = pattern.exec(text);
    if (!match) continue;
    hits.push({ entry, start: match.index, end: match.index + match[0].length });
    // Blank out the matched words so "panadol" isn't matched again inside "panadol extra".
    text = `${text.slice(0, match.index)}${' '.repeat(match[0].length)}${text.slice(match.index + match[0].length)}`;
  }
  return hits;
}

/** The minerals among the names that are another drug's salt (L1): "atorvastatin calcium" is atorvastatin. */
function saltsAmong(hits: NameHit[], original: string): Set<NameHit> {
  const byPosition = [...hits].sort((x, y) => x.start - y.start);
  return new Set(byPosition.filter((hit, index) => {
    if (hit.entry.via !== 'generic' || hit.entry.ingredients.length !== 1 || !SALT_MINERALS.has(hit.entry.ingredients[0])) return false;
    // "Atorvastatin calcium", "rosuvastatin (as calcium)": the mineral directly follows another drug's name.
    const before = byPosition[index - 1];
    if (before && !isMineralOrHerbal(before.entry.ingredients) && /^ (?:as )?$/.test(original.slice(before.end, hit.start))) return true;
    // "Pitavastatin calcium": or a word that reads as a drug name the catalogue doesn't hold (M-2).
    const previous = /([a-z]+) (?:as )?$/.exec(original.slice(0, hit.start));
    return Boolean(previous && DRUG_NAME_ENDING.test(previous[1]));
  }));
}

export function matchIngredients(name: string): MatchedMedicine {
  const original = normalizeText(name);
  const hits = namesIn(original);
  const salts = saltsAmong(hits, original);
  // What the matched names (and the salts read as part of them) don't cover.
  let uncovered = original;
  for (const hit of hits) uncovered = `${uncovered.slice(0, hit.start)}${' '.repeat(hit.end - hit.start)}${uncovered.slice(hit.end)}`;
  // Two-letter words count ("Tylenol PM", "Panadol CF"), and so does "D" ("Claritin-D"): each can be another ingredient (N11).
  // "x2" (how many) is never an ingredient (L3).
  const unmatchedWords = uncovered.split(/[^a-z0-9]+/).filter((word) => (word.length >= 2 || word === 'd') && !/^[0-9]/.test(word) && !/^x[0-9]+$/.test(word)
    && !NOT_AN_INGREDIENT.has(word));
  const found: string[] = [];
  const names: string[] = [];
  let via: MatchedMedicine['via'] = 'none';
  for (const hit of hits) {
    if (salts.has(hit)) continue;
    for (const key of hit.entry.ingredients) if (!found.includes(key)) found.push(key);
    names.push(hit.entry.name);
    if (via !== 'product') via = hit.entry.via;
  }
  return { ingredients: found, via, matchedNames: names, unmatchedWords };
}

/** A medicine name found in free text: what it contains, and whether it is a brand or a generic name. */
export interface MedicineMention { name: string; ingredients: string[]; via: 'product' | 'generic' }

/** Every medicine name the catalogue knows in a piece of free text ("Can she take Ponstan with her warfarin?"; N2). */
export function medicineMentions(text: string): MedicineMention[] {
  let normalized = normalizeText(text);
  const found: Array<MedicineMention & { at: number }> = [];
  for (const { entry, pattern } of PATTERNS) {
    const match = pattern.exec(normalized);
    if (!match) continue;
    found.push({ name: match[0], ingredients: entry.ingredients, via: entry.via, at: match.index });
    normalized = `${normalized.slice(0, match.index)}${' '.repeat(match[0].length)}${normalized.slice(match.index + match[0].length)}`;
  }
  // In the order they were written.
  return found.sort((a, b) => a.at - b.at).map(({ at, ...mention }) => { void at; return mention; });
}

/** A strength in milligrams: per tablet, capsule or other single unit, or per ml of a liquid. */
export type Strength = { per: 'unit'; mg: number } | { per: 'ml'; mg: number };

/** Weights as people write them (M2): "mgs", "mgm" and "milligramme" are mg, "gm" and "gms" are g, "mcgs" is mcg. */
const WEIGHT = 'mg|mgs|mgm|milligrams?|milligrammes?|g|gm|gms|grams?|grammes?|mcg|mcgs|micrograms?|microgrammes?|ug';
const MASS = `(\\d+(?:\\.\\d+)?)\\s*(${WEIGHT})(?![a-z])`;
const ML = '(?:ml|mls|millilitres?|milliliters?|cc)(?![a-z])';
/** Words a strength can be "per" and still mean one tablet, capsule or dose ("100 mcg/dose"). */
const SINGLE_UNIT_WORDS = new Set(['tab', 'tabs', 'tablet', 'tablets', 'cap', 'caps', 'capsule', 'capsules', 'caplet', 'caplets', 'dose', 'doses',
  'puff', 'puffs', 'actuation', 'actuations', 'spray', 'sprays', 'sachet', 'sachets', 'suppository', 'suppositories', 'lozenge', 'lozenges',
  'pastille', 'pastilles', 'pill', 'pills', 'vial', 'vials', 'ampoule', 'ampoules', 'ampule', 'ampules']);

export function toMg(value: string, unit: string): number {
  const amount = Number.parseFloat(value);
  if (unit.startsWith('mcg') || unit.startsWith('microgram') || unit === 'ug') return amount / 1000;
  if (unit === 'g' || unit === 'gm' || unit === 'gms' || unit.startsWith('gram')) return amount * 1000;
  return amount;
}

/** "µg" and "μg" are micrograms; normalizeText would otherwise drop the µ and leave grams. */
const microAsU = (text: string) => text.replace(/[µμ]/g, 'u');

const SPACE = '[ \\u00a0\\u202f]';
/**
 * Numbers as people type them (H-B). normalizeText() turns commas into spaces,
 * so "1,000 mg" would read as "000 mg": thousands separators are removed first.
 * "1,000" and, before a weight, "1 000" are a thousand. A decimal comma ("2,5")
 * could be 2.5 or 25, and a dot before exactly three digits ("1.000") could be
 * 1 or a thousand (as domain/labs/values.ts reads it; N12), so the text is not
 * read at all (null). A leading zero ("0.125") can only be a decimal.
 */
function plainNumbers(raw: string, spacedThousands: boolean): string | null {
  const text = microAsU(raw);
  if (/\d,\d{1,2}(?!\d)|\d,\d{4,}/.test(text)) return null;
  if (/(?<![\d.,])[1-9]\d{0,2}\.\d{3}(?![\d.,])/.test(text)) return null;
  const commas = text.replace(/\d{1,3}(?:,\d{3})+(?!\d)/g, (number) => number.replace(/,/g, ''));
  return spacedThousands
    ? commas.replace(new RegExp(`(?<![\\d.])\\d{1,3}(?:${SPACE}\\d{3})+(?=${SPACE}*(?:${WEIGHT})(?![a-z]))`, 'gi'),
      (number) => number.replace(new RegExp(SPACE, 'g'), ''))
    : commas;
}

/**
 * The strength as entered: "500 mg" (per unit), "120 mg/5 ml" or "24 mg per ml"
 * (per ml of a liquid), "100 mcg/dose". Null for combinations ("500/30",
 * "500 mg + 30 mg", "caffeine 65 mg, paracetamol 500 mg": two amounts are
 * never read as one, whichever comes first), percentages, strengths per gram
 * of a cream and anything else that is not a dose we can count. A
 * combination's amounts are read by readStrength (amounts.ts).
 */
export function parseStrength(strength: string | null | undefined): Strength | null {
  if (!strength) return null;
  const numbers = plainNumbers(strength, true);
  if (numbers === null) return null;
  const text = normalizeText(numbers);
  if (/\d\s*[/+]\s*\.?\d/.test(text) || (text.match(new RegExp(MASS, 'g')) ?? []).length > 1) return null;
  const perMl = new RegExp(`${MASS}\\s*(?:/|per|in|each)\\s*(\\d+(?:\\.\\d+)?)?\\s*${ML}`).exec(text);
  if (perMl) {
    const ml = perMl[3] ? Number.parseFloat(perMl[3]) : 1;
    const mg = toMg(perMl[1], perMl[2]);
    return ml > 0 && Number.isFinite(mg) ? { per: 'ml', mg: mg / ml } : null;
  }
  const single = new RegExp(MASS).exec(text);
  if (!single) return null;
  // "10 mg/g", "5 mg/24 hours" or "40 mg/100 tablets" are not per tablet.
  const per = /^\s*(?:\/|per\s)\s*([a-z]+|\d+)/.exec(text.slice(single.index + single[0].length));
  if (per && !SINGLE_UNIT_WORDS.has(per[1])) return null;
  return { per: 'unit', mg: toMg(single[1], single[2]) };
}

/** Milligrams per tablet or other single unit, from "500 mg", "1 g", "75 mcg". Null for liquids, combinations and units we can't convert. */
export function parseStrengthMg(strength: string | null | undefined): number | null {
  const parsed = parseStrength(strength);
  return parsed?.per === 'unit' ? parsed.mg : null;
}

/** One piece of a strength as written: an amount (its unit when one follows it), one medicine's name, or what joins them ("/", "+", "and"). */
export type StrengthPiece =
  | { kind: 'amount'; value: string; unit: string | null }
  | { kind: 'name'; ingredient: string }
  | { kind: 'join' };

/** The weights parseStrength reads (WEIGHT). */
const WEIGHT_WORDS = new Set(WEIGHT.split('|').flatMap((word) => (word.endsWith('s?') ? [word.slice(0, -2), `${word.slice(0, -2)}s`] : [word])));
/**
 * Words that change what the numbers beside them mean ("per day", "x 2",
 * "100 units", "every 4 hours", "15 or 30 mg"), and volumes parseStrength
 * doesn't read either ("5 ml" anywhere but at the end): a strength with any
 * of them isn't read as a combination's amounts.
 */
const CHANGES_THE_NUMBERS = new Set(['per', 'x', 'times', 'or', 'to', 'unit', 'units', 'iu', 'mmol', 'meq', 'kg',
  'ml', 'mls', 'cc', 'l', 'litre', 'litres', 'liter', 'liters', 'millilitre', 'millilitres', 'milliliter', 'milliliters',
  'h', 'hr', 'hrs', 'hour', 'hours', 'day', 'days', 'daily', 'week', 'weeks', 'weekly', 'min', 'mins', 'minute', 'minutes']);
const JOINING_WORDS = new Set(['and', 'with', 'plus']);
/** "per tablet", "/tab", "each capsule", "a sachet": one unit, which a strength is for anyway. */
const PER_UNIT = new RegExp(`(?:/|\\b(?:per|each|a|every)\\b) ?(?:${[...SINGLE_UNIT_WORDS].join('|')})(?![a-z])`, 'g');
/** "32 tablets", "2 caps": how many, never part of a strength (but "30/500 tablets" is the pack's 30/500). */
const UNIT_COUNT = new RegExp(`(?<![\\d.]|[/+] ?)\\d+ ?(?:${[...SINGLE_UNIT_WORDS].join('|')})(?![a-z])`, 'g');
/** "/5 ml", "per 5 ml", "in 5ml", "/ml" at the end: the amounts are in that much of a liquid. */
const PER_VOLUME = new RegExp(`(?:^|(?:^| )(?:per|in|each|every) | ?/ ?| )(\\d+(?:\\.\\d+)?)? ?${ML} ?$`);

/**
 * A combination's strength as written, piece by piece in order: its amounts,
 * the medicine names beside them and what joins them ("30/500 mg",
 * "500mg/30mg", "paracetamol 500 mg, codeine 30 mg"), and the ml a liquid's
 * amounts are in ("120/12 mg per 5 ml"). Forms, salts ("codeine phosphate"),
 * makers, the product's own name and a count of tablets ("32 tablets") say
 * nothing about which amount is which, so they are left out. Null when it has
 * anything that changes what its numbers mean (a percentage, units, "per
 * gram", "per day", hours, "x 2", "15 or 30") or a number that could be read
 * two ways ("2,5", "1.000").
 */
export function strengthPieces(strength: string): { pieces: StrengthPiece[]; perMl: number | null } | null {
  if (strength.includes('%')) return null;
  const numbers = plainNumbers(strength, true);
  if (numbers === null) return null;
  let text = normalizeText(numbers);
  let perMl: number | null = null;
  const volume = PER_VOLUME.exec(text);
  if (volume) {
    perMl = volume[1] ? Number.parseFloat(volume[1]) : 1;
    if (!(perMl > 0)) return null;
    text = text.slice(0, volume.index);
  }
  text = text.replace(PER_UNIT, ' ').replace(UNIT_COUNT, ' ');
  const pieces: Array<StrengthPiece & { at: number }> = [];
  // One medicine's name ("codeine", "acetaminophen", "Panadol") can say which amount is its own. A product's name with more
  // than one medicine in it ("co-codamol") can't, and a mineral after a drug's name is its salt ("atorvastatin calcium").
  const hits = namesIn(text);
  const salts = saltsAmong(hits, text);
  let rest = text;
  for (const hit of hits) {
    if (hit.entry.ingredients.length === 1 && !salts.has(hit)) pieces.push({ kind: 'name', ingredient: hit.entry.ingredients[0], at: hit.start });
    rest = `${rest.slice(0, hit.start)}${' '.repeat(hit.end - hit.start)}${rest.slice(hit.end)}`;
  }
  const tokens = [...rest.matchAll(/(\d+(?:\.\d+)?|\.\d+)|([a-z]+)|[/+]/g)];
  for (let index = 0; index < tokens.length; index++) {
    const [whole, number, word] = tokens[index];
    const at = tokens[index].index!;
    if (number !== undefined) {
      // "500mg", "500 mg": a weight straight after the number is its unit.
      const next = tokens[index + 1];
      const unit = next?.[2] !== undefined && WEIGHT_WORDS.has(next[2]) && !rest.slice(at + whole.length, next.index).trim() ? next[2] : null;
      if (unit) index++;
      pieces.push({ kind: 'amount', value: number.startsWith('.') ? `0${number}` : number, unit, at });
    } else if (word !== undefined) {
      // A weight with no number before it ("10 mg/g"), or a word that changes what the numbers mean.
      if (WEIGHT_WORDS.has(word) || CHANGES_THE_NUMBERS.has(word)) return null;
      if (JOINING_WORDS.has(word)) pieces.push({ kind: 'join', at });
    } else {
      pieces.push({ kind: 'join', at });
    }
  }
  return { pieces: pieces.sort((a, b) => a.at - b.at).map(({ at, ...piece }) => { void at; return piece; }), perMl };
}

/** How much is taken each time: tablets and the like, ml, mg, or a measure we can't convert (spoons, drops). */
export interface DoseAmount {
  amount: number;
  unit: 'unit' | 'ml' | 'mg' | 'unknown';
  /** Spoons or drops: they can't be converted, but they say the medicine is a liquid, whatever its saved form (R1). */
  liquid?: true;
}

const NUMBER_WORDS: Record<string, number> = { half: 0.5, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
const UNIT_WORDS = new Set([...SINGLE_UNIT_WORDS, 'patch', 'patches', 'injection', 'injections', 'shot', 'shots']);
/** Measures only a liquid is given in: spoons, droppers and drops. */
const LIQUID_MEASURES = new Set(['spoon', 'spoons', 'spoonful', 'spoonfuls', 'teaspoon', 'teaspoons', 'teaspoonful', 'teaspoonfuls', 'tablespoon', 'tablespoons',
  'tablespoonful', 'tablespoonfuls', 'tsp', 'tsps', 'tbsp', 'tbsps', 'drop', 'drops', 'dropper', 'droppers', 'dropperful', 'dropperfuls', 'capful', 'capfuls']);

/**
 * A plan amount such as "2 tablets", "10 ml", "2 x 5 ml", "500 mg" or "1".
 * Nothing entered counts as one tablet (or one of whatever the medicine
 * comes in). Spoons, drops, international units and anything we can't read
 * are `unknown`, so the dose is reported as not checked rather than guessed.
 */
export function parseDoseAmount(raw: string | null | undefined): DoseAmount {
  const unknown: DoseAmount = { amount: 1, unit: 'unknown' };
  // "1,000 mg" is a thousand; "2 500 mg" could be two 500 mg tablets, so spaced digits are not joined here.
  const numbers = plainNumbers(raw ?? '', false);
  if (numbers === null) return unknown;
  const text = normalizeText(numbers).replace(/\b1\/2\b/g, 'half');
  if (!text) return { amount: 1, unit: 'unit' };
  const times = new RegExp(`^(\\d+(?:\\.\\d+)?)\\s*(?:x|times)\\s*(\\d+(?:\\.\\d+)?)\\s*${ML}`).exec(text);
  if (times) return { amount: Number.parseFloat(times[1]) * Number.parseFloat(times[2]), unit: 'ml' };
  const lead = /^(\d+(?:\.\d+)?|half|one|two|three|four|five|six|seven|eight|nine|ten)(?:\s+(?:a|an|of a|of an))?\s*(.*)$/.exec(text);
  if (!lead) return unknown;
  let amount = NUMBER_WORDS[lead[1]] ?? Number.parseFloat(lead[1]);
  let rest = lead[2].trim();
  if (!Number.isFinite(amount) || amount <= 0) return unknown;
  // "2 5 ml spoons": two 5 ml spoons.
  const spoons = new RegExp(`^(\\d+(?:\\.\\d+)?)\\s*${ML}\\s*spoon`).exec(rest);
  if (spoons) return { amount: amount * Number.parseFloat(spoons[1]), unit: 'ml' };
  // A range ("1-2 tablets", "5 to 10 ml") counts as the larger amount. "2 500 mg" could be
  // a range or two 500 mg tablets, so a second number before a weight is not read.
  const range = /^(?:to |or )?(\d+(?:\.\d+)?)\s*(.*)$/.exec(rest);
  if (range) {
    if (/^(mg|milligrams?|g|grams?|mcg|micrograms?|ug)(?![a-z])/.test(range[2])) return unknown;
    amount = Math.max(amount, Number.parseFloat(range[1]));
    rest = range[2].trim();
  }
  if (new RegExp(`^${ML}`).test(rest)) return amount <= 100 ? { amount, unit: 'ml' } : unknown;
  const mass = /^(mg|milligrams?|g|grams?|mcg|micrograms?|ug)(?![a-z])/.exec(rest);
  if (mass) return { amount: toMg(String(amount), mass[1]), unit: 'mg' };
  const word = rest.split(' ')[0];
  if (!rest || UNIT_WORDS.has(word)) return amount <= 20 ? { amount, unit: 'unit' } : unknown;
  if (LIQUID_MEASURES.has(word)) return { amount, unit: 'unknown', liquid: true };
  return unknown;
}

/** Units per dose from a plan amount like "2 tablets" or "1". Defaults to 1. */
export function unitsPerDose(amount: string | null | undefined): number {
  const parsed = parseDoseAmount(amount);
  return parsed.unit === 'unit' ? parsed.amount : 1;
}

/** What "the most a day" counts: doses, tablets and the like, ml, or a weight (mg, g, mcg). null when not said; `unknown` when we can't tell. */
export function prnUnitKind(unit: string | null | undefined): 'dose' | 'unit' | 'ml' | 'mg' | 'g' | 'mcg' | 'unknown' | null {
  const text = normalizeText(microAsU(unit ?? ''));
  if (!text) return null;
  const word = text.split(' ')[0];
  if (['dose', 'doses', 'time', 'times'].includes(word)) return 'dose';
  if (UNIT_WORDS.has(word)) return 'unit';
  if (new RegExp(`^${ML}`).test(word)) return 'ml';
  if (/^(mg|milligrams?)$/.test(word)) return 'mg';
  if (/^(g|grams?|gm|gms)$/.test(word)) return 'g';
  if (/^(mcg|micrograms?|ug)$/.test(word)) return 'mcg';
  return 'unknown';
}

/** How many times a day a medicine is taken, from the saved frequency or reminder times. */
export function timesPerDay(frequency: string | null | undefined, reminderTimes: number | null | undefined): number | null {
  switch (frequency) {
    case 'once_daily': case 'every_morning': case 'every_evening': case 'every_night': return 1;
    case 'twice_daily': return 2;
    case 'three_times_daily': return 3;
    case 'four_times_daily': return 4;
    default: return reminderTimes && reminderTimes > 0 ? reminderTimes : null;
  }
}

/** Forms used on the skin. Their NSAIDs are not counted with NSAIDs taken by mouth. */
const SKIN_FORMS = new Set(['cream', 'gel', 'ointment']);
const SKIN_NAME = /(?<![a-z])(gel|emulgel|cream|ointment|lotion|foam|rub|balm|liniment|shampoo|skin|topical)(?![a-z])/;
const LOCAL_NAME = /(?<![a-z])(eye|eyes|ophthalmic|ophtha|ophth|ear|ears|otic|aural|nasal|nose|vaginal|pessary|pessaries)(?![a-z])/;
const PATCH_NAME = /(?<![a-z])(patch|patches|plaster|plasters)(?![a-z])/;
/** A name that says the medicine is taken by mouth: it wins over a saved form ("Ibuprofen 400mg tablets" saved as a cream; N21). */
const BY_MOUTH_NAME = /(?<![a-z])(oral|mouth|sublingual|buccal|tab|tabs|tablet|tablets|caplet|caplets|cap|caps|capsule|capsules|softgels?|gelcaps?|liqui ?gels?|liquicaps|syrup|suspension|elixir|sachets?|granules|effervescent|dispersible|chewables?|lozenges?)(?![a-z])/;

/**
 * Used on the skin, in the eye or ear, in the nose or vagina (LOW): from the
 * saved form or the name ("Voltaren Emulgel", "Diclofenac eye drops"). Rules
 * about medicines taken into the body don't apply to these. Not local: a gel
 * or spray for the mouth ("oral gel", GTN spray under the tongue), drops with
 * no eye, ear or nose in the name (they may be oral drops), inhalers, patches,
 * and anything whose name says tablets, capsules or syrup, whatever the form.
 */
export function isLocal(form: string | null | undefined, name: string): boolean {
  const text = normalizeText(name);
  if (/(?<![a-z])(oral|mouth|sublingual|buccal)(?![a-z])/.test(text)) return false;
  // An eye, ear, nose or vaginal name wins over "suspension" or "solution" ("eye drops suspension"; L4).
  if (LOCAL_NAME.test(text)) return true;
  if (BY_MOUTH_NAME.test(text)) return false;
  return SKIN_FORMS.has((form ?? '').toLowerCase()) || SKIN_NAME.test(text);
}

/**
 * A patch, plaster or spray. Most act on the whole body (a GTN patch or spray),
 * but an NSAID patch or spray acts where it is put, so its NSAID counts as
 * topical. Doses of these aren't counted either way.
 */
export function isPatchOrSpray(form: string | null | undefined, name: string): boolean {
  const kind = (form ?? '').toLowerCase();
  if (BY_MOUTH_NAME.test(normalizeText(name))) return false;
  return kind === 'patch' || kind === 'spray' || PATCH_NAME.test(normalizeText(name)) || /(?<![a-z])sprays?(?![a-z])/.test(normalizeText(name));
}

/**
 * An ingredient's classes for this medicine (H2).
 * - Aspirin up to 150 mg a day is an antiplatelet dose; above that it also
 *   counts as an NSAID. With no dose to go on (and no "low dose" in the name)
 *   it is both, plus aspirin_dose_unknown, so the rules never treat it as a
 *   painkiller that can simply be stopped.
 * - An NSAID used on the skin is topical_nsaid, not nsaid: rules about NSAIDs
 *   taken by mouth don't apply to it.
 */
export function classesFor(ingredientKey: string, context: { name: string; topical?: boolean; aspirinMg?: number | null }): DrugClass[] {
  const ingredient = INGREDIENT_BY_KEY.get(ingredientKey);
  if (!ingredient) return [];
  let classes = [...ingredient.classes];
  if (ingredientKey === 'aspirin') {
    const mg = context.aspirinMg ?? null;
    const lowDoseNamed = /(?<![a-z])(low dose|baby|cardiprin)(?![a-z])/.test(normalizeText(context.name));
    if (mg !== null) classes = mg <= 150 ? ['antiplatelet'] : ['antiplatelet', 'nsaid'];
    else classes = lowDoseNamed ? ['antiplatelet'] : ['antiplatelet', 'nsaid', 'aspirin_dose_unknown'];
  }
  if (context.topical && classes.includes('nsaid')) {
    classes = [...classes.filter((name) => name !== 'nsaid' && name !== 'aspirin_dose_unknown'), 'topical_nsaid'];
  }
  return classes;
}

export function matches(matcher: Matcher, ingredientKey: string, classes: DrugClass[]): boolean {
  if (matcher.ingredient) return matcher.ingredient === ingredientKey;
  if (matcher.class) return classes.includes(matcher.class) && !(matcher.except && classes.includes(matcher.except));
  return false;
}

export function ingredientName(key: string): string {
  return INGREDIENT_BY_KEY.get(key)?.name ?? key;
}

export function ingredient(key: string): Ingredient | undefined {
  return INGREDIENT_BY_KEY.get(key);
}