/* rx-core.jsx — shared engine for the 처방전 분석 learning tool
   Exports to window: RX (theme), rxHelpers, RxForm, DrugPopup, Dropdown,
   SelectionBadge, RevealBlocks, AppTabs, SelectorBar, useRxSession  */

const RX = {
  teal: '#1a6c5b',
  tealDark: '#12473b',
  tealTint: '#eef5f2',
  tealLine: '#bfdcd4',
  ink: '#181c1a',
  sub: '#5f6b66',
  faint: '#8a948f',
  line: '#d9dcd8',
  lineSoft: '#e8ebe7',
  paper: '#ffffff',
  shell: '#eef0ee',
  mask: '#c9cec9',
  amber: '#9a6a14',
  amberBg: '#fbf3e2',
  rose: '#9c3b54',
  roseBg: '#fbeef1',
};

// ── helpers ──────────────────────────────────────────────────
const rxHelpers = (() => {
  const DATA = window.RX_DATA, ORDER = window.RX_ORDER;
  const CONTEXT_MAP = window.CV_CONTEXT_DRUG_MAP || { entries: [] };
  const DRUG_CLASSES = window.DRUG_CLASSES || [];
  const GENERAL_PRINCIPLES = window.GENERAL_PRINCIPLES || [];
  const DRUG_ROLE_MAP = window.DRUG_ROLE_MAP || [];
  const DOSE_COUNSELING_RULES = window.DOSE_COUNSELING_RULES || [];
  const OCR_SENSITIVE_RULES = window.OCR_SENSITIVE_RULES || { labels: [], regexes: [], rejectionMessage: '' };
  const TOKEN_ALIAS_MAP = window.TOKEN_ALIAS_MAP || { generic: {}, brand: {}, class: {} };
  const CV_DURATION_PRIORS = window.CV_DURATION_PRIORS || {};
  const CV_COMBO_LABELS = (() => {
    const raw = window.CV_COMBO_LABELS;
    if (!raw || !Array.isArray(raw.labels)) return {};
    const map = {};
    raw.labels.forEach(l => { if (l.combo_key) map[l.combo_key] = l; });
    return map;
  })();
  const OCR_ATC_CLASS_MAP = {
    'ARB+CCB 복합제': ['ARB (sartan)', 'DHP-CCB'],
    'ARB+Thiazide계 복합제': ['ARB (sartan)', 'Thiazide계 및 유사체'],
    'Micronized Flavonoid': ['정맥순환 개선'],
    'HMG-CoA+Ezetimibe 복합제': ['HMG-CoA', '콜레스테롤 흡수억제제'],
    'Statin+Ezetimibe 복합제': ['HMG-CoA', '콜레스테롤 흡수억제제'],
  };
  const OCR_BB_ALIASES = {
    'BB (선택성)': ['BB'],
    'BB (혈관확장성)': ['BB'],
    'BB (비선택성)': ['BB'],
    BB: ['BB (선택성)', 'BB (혈관확장성)', 'BB (비선택성)'],
  };
  const DIFF_DX_DISEASE_GROUPS = {
    CV_AF: ['심방세동', '부정맥 치료제'],
    CV_Arrhythmia: ['부정맥(비-AF)', '부정맥 치료제'],
    CV_HyperT: ['고혈압', '고혈압 치료제'],
    CV_CCS: ['만성 관상동맥 증후군', '협심증 치료제'],
    CV_HF: ['심부전', '심부전 치료제'],
  };
  const DIFF_DX_LABELS = {
    '고혈압 치료제': '고혈압',
    '협심증 치료제': '협심증',
    '부정맥 치료제': '부정맥',
    '심부전 치료제': '심부전',
  };

  const diseaseList = (major, plan) => {
    const allowed = isUnlocked(plan)
      ? ['CV', 'PM', 'ED']
      : ['CV'];
    return ORDER
      .filter(k => {
        const m = DATA[k].meta.major;
        if (!allowed.includes(m)) return false;
        if (major && major !== 'all' && m !== major) return false;
        return true;
      })
      .map(k => ({
        key: k,
        label: DATA[k].meta.diseaseLabel,
        major: DATA[k].meta.major,
        weight: DATA[k].meta.disease_match_pct || DATA[k].meta.disease_frequency_weight,
        actual_count: DATA[k].meta.actual_disease_count || 0,
        count: DATA[k].patterns.length,
      }))
      .sort((a, b) => b.actual_count - a.actual_count);
  };

  const patternsFor = (key) => {
    const sorted = [...DATA[key].patterns].sort((a, b) => {
      const countA = a.actual_match_count || 0;
      const countB = b.actual_match_count || 0;
      if (countA !== countB) return countB - countA;
      return a.frequency_rank - b.frequency_rank;
    });
    sorted.forEach((p, i) => {
      p.dynamic_rank = i + 1;
      p._diseaseKey = key;
      p._sourceFile = `${key}_rx.json`;
    });
    return sorted;
  };

  const getPattern = (key, idx) => patternsFor(key)[idx];

  // weighted random across all diseases & patterns (optionally within one 진료과)
  const weightedRandom = (major, plan) => {
    const allowed = isUnlocked(plan)
      ? ['CV', 'PM', 'ED']
      : ['CV'];
    const pool = [];
    for (const k of ORDER) {
      const m = DATA[k].meta.major;
      if (!allowed.includes(m)) continue;
      if (major && major !== 'all' && m !== major) continue;
      const w = DATA[k].meta.disease_frequency_weight || 1;
      DATA[k].patterns.forEach((p) => {
        const score = w * (1 / (p.frequency_rank || 1));
        pool.push({ key: k, patternId: p.patternId, score });
      });
    }
    const total = pool.reduce((s, x) => s + x.score, 0);
    let r = Math.random() * total;
    for (const x of pool) { r -= x.score; if (r <= 0) return x; }
    return pool[0];
  };

  const idxOf = (key, patternId) =>
    patternsFor(key).findIndex(p => p.patternId === patternId);

  const groupOf = (pattern, drug) =>
    (pattern.drugGroups || []).find(g => g.id === drug.groupId);

  const labelOf = (key) => DATA[key] && DATA[key].meta.diseaseLabel;

  // patterns for the current scope. diseaseKey '__all' = mix every disease in the subspec.
  const scopedPatterns = (subspec, diseaseKey, plan) => {
    if (diseaseKey === '__all') {
      const out = [];
      diseaseList(subspec, plan).forEach(d =>
        patternsFor(d.key).forEach(p => out.push({ pattern: p, key: d.key })));
      return out;
    }
    return patternsFor(diseaseKey).map(p => ({ pattern: p, key: diseaseKey }));
  };
  const scopedIdxOf = (subspec, diseaseKey, patternId, plan) =>
    scopedPatterns(subspec, diseaseKey, plan).findIndex(x => x.pattern.patternId === patternId);

  // **bold** + \n  → react nodes
  const fmt = (str) => {
    if (!str) return null;
    return str.split('\n').map((line, li) => {
      const parts = line.split(/(\*\*[^*]+\*\*)/g).filter(Boolean);
      return (
        <React.Fragment key={li}>
          {li > 0 && <br />}
          {parts.map((p, i) =>
            p.startsWith('**') && p.endsWith('**')
              ? <strong key={i} style={{ fontWeight: 700, color: RX.ink }}>{p.slice(2, -2)}</strong>
              : <React.Fragment key={i}>{p}</React.Fragment>
          )}
        </React.Fragment>
      );
    });
  };

  // split cautions string into list items (lines starting with ①②③ or ④⑤)
  const cautionItems = (str) => {
    if (!str) return [];
    return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
      const m = line.match(/^([①②③④⑤⑥⑦⑧⑨⑩])\s*/);
      return { marker: m ? m[1] : '•', text: m ? line.slice(m[0].length) : line };
    });
  };

  const normContextText = (text) => String(text || '')
    .replace(/\s*\d+[\d.]*\s*(mg|mcg|μg|g|ml|mL|IU|U|정|캡슐|시럽|주|%)?/gi, '')
    .trim()
    .toLowerCase();

  const entryMatchesPatternDrug = (entry, pattern) => {
    const needles = [...(entry.brand || []), ...(entry.generic || [])]
      .map(normContextText)
      .filter(Boolean);
    const haystacks = (pattern.drugs || []).flatMap(d => [
      normContextText(d.brand),
      normContextText(d.ingredient),
      normContextText(d.ingredient_en),
      normContextText(d.generic),
    ]).filter(Boolean);

    return needles.some(needle =>
      haystacks.some(haystack => needle === haystack || needle.includes(haystack) || haystack.includes(needle)));
  };

  const contextNotesForPattern = (pattern) => {
    if (!pattern) return [];
    if (Array.isArray(pattern.context_notes)) return pattern.context_notes;

    const entries = Array.isArray(CONTEXT_MAP.entries) ? CONTEXT_MAP.entries : [];
    const notes = [];
    const seen = new Set();

    entries.forEach(entry => {
      const policy = entry.anchorPolicy || {};
      if (policy.standaloneMatch === true) return;
      if (!entryMatchesPatternDrug(entry, pattern)) return;

      const allowedFiles = new Set(policy.allowedPatternFiles || []);
      const allowedDiseases = new Set(policy.allowedDiseaseIds || []);
      const diseaseIds = new Set([
        pattern._diseaseKey,
        pattern._sourceFile,
        ...(pattern.diseases || []),
      ].filter(Boolean));
      const anchoredByFile = allowedFiles.has(pattern._sourceFile);
      const anchoredByDisease = [...allowedDiseases].some(d => diseaseIds.has(d));
      if (entry.requiresAnchor !== false && !anchoredByFile && !anchoredByDisease) return;

      if (seen.has(entry.contextId)) return;
      seen.add(entry.contextId);
      notes.push({
        contextId: entry.contextId,
        targetDisease: entry.targetDisease,
        contextType: entry.contextType,
        clinicalContext: entry.clinicalContext || '',
        interpretation: entry.interpretation || '',
        confidence: entry.confidence || 'standard',
      });
    });

    return notes;
  };

  const normDrug = (str) => String(str || '')
    .replace(/\s*\d+[\d.]*\s*(mg|mcg|μg|ug|g|ml|mL|IU|U|정|캡슐|시럽|주|%)?/gi, '')
    .replace(/[()\[\]{}·,._\-\/]/g, '')
    .replace(/\s+/g, '')
    .trim()
    .toLowerCase();

  const ocrTextFromDrug = (drug) => {
    if (typeof drug === 'string') return drug;
    return drug && (drug.rawName || drug.name || drug.brand || drug.generic || drug.ingredient || '');
  };

  const normalizeOcrProduct = (str) => String(str || '')
    .replace(/^\s*\d+\s*[.)-]?\s*/, '')
    .replace(/밀리그램/gi, 'mg')
    .replace(/마이크로그램|㎍/gi, 'mcg')
    .replace(/㎎/gi, 'mg')
    .replace(/^[\s★☆□■●○ⓟⓅPp]+/g, '')
    .replace(/\([^)]*\)/g, '')
    .replace(/(\d+(?:\.\d+)?(?:\/\d+(?:\.\d+)?)?\s*(?:mg|mcg|μg|ug|g|ml|mL|IU|U|%))(?:[-_].*)$/i, '$1')
    .replace(/[-_](?:EL|EK)(?:[-_]?\d+일?)?$/i, '')
    .replace(/[-_](?:노랑|황색|갈색|백색|흰색|분홍|연두|녹색|초록|주황|빨강|파랑|청색|투명|흑색|검정|보라|원형|장방형|타원형|타원|팔각형|육각형|삼각형|마름모|사각형)[a-zA-Z0-9가-힣]*$/i, '')
    .replace(/[-_](?:\d+일?|\d+)$/i, '')
    .replace(/\s*\d+[\d.]*\s*(mg|mcg|μg|ug|g|ml|mL|IU|U|정|캡슐|캅셀|시럽|주|%)?/gi, '')
    .replace(/[()\[\]{}·,._\-\/]/g, '')
    .replace(/\s+/g, '')
    .trim()
    .toLowerCase();

  const isCvDrugRow = (row) => {
    if (!row) return false;
    if (row.major_code === 'CV' || row.major === '심혈관질환') return true;
    return (row.cross_refs || []).some(ref =>
      ref && (ref.major_code === 'CV' || ref.major === 'CV' || ref.major === '심혈관질환'));
  };

  const productIndex = (() => {
    const items = [];
    const seen = new Set();
    DRUG_CLASSES.forEach(row => {
      const add = (name, dose, source) => {
        const norm = normalizeOcrProduct(name);
        if (!norm || norm.length < 2) return;
        const key = `${norm}|${row.generic || ''}|${dose || ''}`;
        if (seen.has(key)) return;
        seen.add(key);
        items.push({ norm, name, dose: dose || '', source, row, isCv: isCvDrugRow(row) });
      };
      add(row.brand, '', 'brand');
      (row.strengths || []).forEach(strength => {
        add(strength.official_name, strength.dose, 'official_name');
        (strength.brand_aliases || []).forEach(alias => add(alias, strength.dose, 'brand_alias'));
      });
    });

    // non-CV 인덱스 편입 — isCv:false로 CV 분석과 완전 분리
    const existingNormSet = new Set(items.map(i => i.norm));
    const NON_CV = window.NON_CV_DRUG_INDEX || {};
    Object.entries(NON_CV).forEach(([, info]) => {
      const brandsToAdd = (info.brands && info.brands.length > 0) ? info.brands : [];
      brandsToAdd.forEach(brand => {
        const rtNorm = normalizeOcrProduct(brand);
        if (!rtNorm || rtNorm.length < 2) return;
        if (existingNormSet.has(rtNorm)) return;
        existingNormSet.add(rtNorm);
        items.push({
          norm: rtNorm,
          name: brand,
          dose: '',
          source: 'non_cv_index',
          isCv: false,
          row: {
            brand,
            generic: (info.generics || []).join(', '),
            drug_class: info.non_cv_class || '',
            major_code: info.major_code || 'NON_CV',
            major: info.major || info.category || '',
          },
        });
      });
    });

    return items;
  })();

  // Sonnet 텍스트 교정에 넘길 실제 상품명 목록 (brand/official_name, deduped)
  const productBrandList = (() => {
    const seen = new Set();
    const out = [];
    productIndex.forEach(item => {
      if (item.source === 'brand_alias') return;
      const name = String(item.name || '').trim();
      if (!name || seen.has(name)) return;
      seen.add(name);
      out.push(name);
    });
    return out;
  })();

  const simpleRatio = (a, b) => {
    if (!a || !b) return 0;
    const maxLen = Math.max(a.length, b.length);
    if (!maxLen) return 1;
    if (a === b) return 1;
    if (a.includes(b) || b.includes(a)) return Math.min(1, (Math.min(a.length, b.length) / maxLen) + 0.20);
    return Math.max(
      0,
      1 - (levenshtein(a, b) / maxLen),
      jamoSimilarity(a, b),
      brandSimilarity(a, b)
    );
  };

  const extractBrandOnly = (str) => normalizeOcrProduct(str)
    .replace(/[0-9a-zA-Z./]+/g, '')
    .replace(/정|캡슐|캅셀|밀리그램|시럽|주/g, '')
    .trim();

  const hangulToJamo = (str) => {
    const choseong = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
    const jungseong = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
    const jongseong = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
    return String(str || '').split('').map(ch => {
      const code = ch.charCodeAt(0);
      if (code < 0xac00 || code > 0xd7a3) return ch;
      const offset = code - 0xac00;
      const cho = Math.floor(offset / 588);
      const jung = Math.floor((offset % 588) / 28);
      const jong = offset % 28;
      return `${choseong[cho]}${jungseong[jung]}${jongseong[jong]}`;
    }).join('');
  };

  const jamoSimilarity = (a, b) => {
    const ja = hangulToJamo(a);
    const jb = hangulToJamo(b);
    const maxLen = Math.max(ja.length, jb.length);
    if (!maxLen) return 1;
    return Math.max(0, 1 - (levenshtein(ja, jb) / maxLen));
  };

  const brandSimilarity = (a, b) => {
    const ba = extractBrandOnly(a);
    const bb = extractBrandOnly(b);
    if (!ba || !bb) return 0;
    const syllables = s => [...s].filter(c => c >= '가' && c <= '힣');
    const sa = syllables(ba), sb = syllables(bb);
    const syllSim = (sa.length && sb.length)
      ? (1 - levenshtein(sa.join(''), sb.join('')) / Math.max(sa.length, sb.length)) * 1.3
      : 0;
    return Math.min(0.95, Math.max(jamoSimilarity(ba, bb), syllSim)) * 0.98;
  };

  const confidenceFromScore = (score) => {
    if (score >= 0.94) return 'high';
    if (score >= 0.86) return 'medium';
    return 'low';
  };

  // rawName과 유사한 상위 N개 상품명 반환 — Sonnet 힌트용
  const fuzzyCandidates = (rawName, limit = 15) => {
    if (!rawName) return [];
    const norm = normalizeOcrProduct(rawName);
    const seen = new Set();
    return productIndex
      .map(item => ({ name: item.name, score: Math.max(simpleRatio(norm, item.norm), jamoSimilarity(norm, item.norm)) }))
      .sort((a, b) => b.score - a.score)
      .filter(x => x.score > 0.1 && x.name && !seen.has(x.name) && seen.add(x.name))
      .slice(0, limit)
      .map(x => x.name);
  };

  const extractDoseText = (drug, candidate) => {
    const parts = [
      drug && drug.dosage,
      drug && drug.dose,
      drug && drug.name,
      candidate && candidate.dose,
    ].filter(Boolean).map(String);
    const joined = parts.join(' ');
    const match = joined.match(/(\d+[\d.]*)\s*(mg|mcg|μg|ug|g|ml|mL|IU|U)/i);
    if (!match) return candidate && candidate.dose ? candidate.dose : '';
    return `${match[1]}${match[2].replace(/μg|ug/i, 'mcg')}`;
  };

  const doseConfidenceFor = (drug, candidate) => {
    const text = [drug && drug.dosage, drug && drug.dose, drug && drug.name].filter(Boolean).join(' ');
    const hasDose = /\d+[\d.]*\s*(mg|mcg|μg|ug|g|ml|mL|IU|U)/i.test(text);
    if (hasDose) return 'high';
    return candidate && candidate.dose ? 'medium' : 'low';
  };

  const expandBasketTokens = (row, brand) => {
    const tokens = new Set();
    const drugClass = row && row.drug_class;
    const generic = row && row.generic;
    if (drugClass) {
      tokens.add(drugClass);
      (OCR_ATC_CLASS_MAP[drugClass] || []).forEach(t => tokens.add(t));
      (OCR_BB_ALIASES[drugClass] || []).forEach(t => tokens.add(t));
      ((TOKEN_ALIAS_MAP.class || {})[drugClass] || []).forEach(t => tokens.add(t));
    }
    if (generic) {
      ((TOKEN_ALIAS_MAP.generic || {})[String(generic).trim().toLowerCase()] || []).forEach(t => tokens.add(t));
    }
    if (brand) {
      ((TOKEN_ALIAS_MAP.brand || {})[String(brand).trim()] || []).forEach(t => tokens.add(t));
    }
    const expanded = new Set(tokens);
    tokens.forEach(token => (OCR_BB_ALIASES[token] || []).forEach(t => expanded.add(t)));
    return [...expanded];
  };

  const normalizationTypeForHit = (norm, hit) => {
    if (!hit || !hit.item) return 'unknown';
    if (norm === hit.item.norm) return hit.item.source === 'brand' ? 'exact' : 'alias';
    return 'fuzzy_correction';
  };

  const matchOcrDrugToProducts = (drug) => {
    const raw = ocrTextFromDrug(drug);
    const norm = normalizeOcrProduct(raw);
    if (!norm) return null;
    const candidates = productIndex
      .map(item => ({ item, score: simpleRatio(norm, item.norm) }))
      .filter(hit => hit.score >= 0.82)
      .sort((a, b) => b.score - a.score)
      .slice(0, 3)
      .map(hit => {
        const row = hit.item.row;
        const dose = extractDoseText(drug, hit.item);
        const doseConfidence = doseConfidenceFor(drug, hit.item);
        const normalizationType = normalizationTypeForHit(norm, hit);
        return {
          brand: hit.item.name || row.brand,
          displayName: hit.item.name || row.brand,
          representativeBrand: row.brand || hit.item.name,
          matchedProductName: hit.item.name,
          original_name: raw,
          generic: row.generic || raw,
          ingredient: row.generic || raw,
          drug_class: row.drug_class || '',
          major: row.major || '',
          major_code: row.major_code || '',
          disease: row.disease || '',
          isCv: hit.item.isCv,
          score: hit.score,
          ingredientConfidence: confidenceFromScore(hit.score),
          normalizationType,
          corrected: normalizationType === 'fuzzy_correction',
          dose,
          dosage: drug && drug.dosage,
          frequency: drug && drug.frequency,
          duration: drug && drug.duration,
          usage: drug && drug.usage,
          days: drug && drug.duration,
          timing: drug && drug.usage,
          doseConfidence,
          aiCorrection: drug && drug.aiCorrection,
          basket_tokens: hit.item.isCv ? expandBasketTokens(row, hit.item.name) : [],
          row,
        };
      });
    if (!candidates.length) return null;
    return {
      original: drug,
      original_name: raw,
      candidates,
      best: candidates[0],
      matched: candidates[0].ingredientConfidence !== 'low',
    };
  };

  const roleForToken = (disease, token, drugClass) =>
    DRUG_ROLE_MAP.find(role =>
      role.targetDisease === disease &&
      (role.basket_token === token || role.drug_class === token || role.drug_class === drugClass));

  const cvRoleKeys = [...new Set(
    DRUG_ROLE_MAP
      .map(role => role && role.targetDisease)
      .filter(key => key && key.startsWith('CV_'))
  )];

  const roleWeight = (role) => {
    if (!role) return 0;
    if (role.confidence === 'high') return 1;
    if (role.confidence === 'medium') return 0.65;
    return 0.35;
  };

  const signatureScore = (pattern, tokens) => {
    const include = (pattern.signature && pattern.signature.include) || [];
    const exclude = (pattern.signature && pattern.signature.exclude) || [];
    if (!include.length) return 0;
    if (exclude.some(token => tokens.has(token))) return 0;
    const matched = include.filter(token => tokens.has(token)).length;
    return matched / include.length;
  };

  // ── duration/dose 신호 보너스 (prescription/engine/ocr_adapter.py와 패리티) ──
  const RX_DUR_RE = /(\d+)\s*(?:일분|일치|일|Days|D)(?![0-9가-힣])(?!\s*\d+\s*(?:회|times?))/i;

  const durationDaysForDrug = (drug) => {
    const raw = drug && drug.duration;
    if (raw) {
      const m = String(raw).match(/\d+/);
      if (m) return parseInt(m[0], 10);
    }
    const m = String((drug && (drug.original_name || drug.name)) || '').match(RX_DUR_RE);
    return m ? parseInt(m[1], 10) : null;
  };

  const representativeDuration = (drugs) => {
    const days = drugs.map(durationDaysForDrug).filter(Boolean);
    if (!days.length) return null;
    const counts = {};
    days.forEach(d => { counts[d] = (counts[d] || 0) + 1; });
    const top = Math.max(...Object.values(counts));
    return Math.max(...Object.keys(counts).filter(d => counts[d] === top).map(Number));
  };

  const durationSignalBonuses = (diseaseKey, daysHint, repDuration) => {
    if (repDuration == null) return [];
    const bonuses = [];
    const chronic = CV_DURATION_PRIORS.chronicDiseases || [];
    if (repDuration >= (CV_DURATION_PRIORS.chronicMinDays || 28) && chronic.includes(diseaseKey)) {
      bonuses.push({ type: 'duration_chronic', value: CV_DURATION_PRIORS.chronicBonus || 0.05 });
    }
    const tol = CV_DURATION_PRIORS.daysHintTolerance || 3;
    if (daysHint != null && Math.abs(repDuration - daysHint) <= tol) {
      bonuses.push({ type: 'duration_days_hint', value: CV_DURATION_PRIORS.daysHintBonus || 0.05 });
    }
    return bonuses;
  };

  const doseSignalBonuses = (diseaseKey, drugs) => {
    const bonuses = [];
    DOSE_COUNSELING_RULES.forEach(rule => {
      (rule.doseRules || []).forEach(doseRule => {
        const hint = doseRule.diseaseHint;
        if (!hint || hint.disease !== diseaseKey) return;
        const pattern = String(doseRule.dosePattern || '').replace(/\s+/g, '').toLowerCase();
        const ruleGeneric = String(rule.generic || '').trim().toLowerCase();
        const hit = drugs.some(drug => {
          if (!drug || drug.doseConfidence !== 'high') return false;
          if (!ruleGeneric || !genericComponents(drug.generic).includes(ruleGeneric)) return false;
          const doseText = String(drug.dose || drug.dosage || '').replace(/\s+/g, '').toLowerCase();
          return pattern && doseText.includes(pattern);
        });
        if (hit) bonuses.push({ type: 'dose_hint', value: Math.min(Number(hint.bonus || 0.03), 0.03) });
      });
    });
    return bonuses;
  };

  const rankCvCandidateContexts = (matchedCvDrugs) => {
    if (!matchedCvDrugs.length) return [];
    const allTokens = new Set(matchedCvDrugs.flatMap(drug => drug.basket_tokens || []));
    const repDuration = representativeDuration(matchedCvDrugs);
    const bonusCap = CV_DURATION_PRIORS.bonusCap || 0.1;
    const byDisease = {};

    ORDER.filter(key => key.startsWith('CV_') && DATA[key]).forEach(key => {
      const explained = new Set();
      const roles = [];
      let scoreSum = 0;

      matchedCvDrugs.forEach(drug => {
        let bestRole = null;
        (drug.basket_tokens || []).forEach(token => {
          const role = roleForToken(key, token, drug.drug_class);
          if (role && roleWeight(role) > roleWeight(bestRole)) bestRole = role;
        });
        if (bestRole) {
          explained.add(drug.generic || drug.brand);
          roles.push({
            brand: drug.brand,
            generic: drug.generic,
            token: bestRole.basket_token || drug.drug_class,
            roleLabel: bestRole.roleLabel,
            roleDescription: bestRole.roleDescription,
            confidence: bestRole.confidence || 'standard',
          });
          scoreSum += roleWeight(bestRole);
        }
      });

      let bestPattern = null;
      let bestPatternScore = 0;
      (DATA[key].patterns || []).forEach(pattern => {
        const score = signatureScore(pattern, allTokens);
        if (score > bestPatternScore) {
          bestPatternScore = score;
          bestPattern = pattern;
        }
      });

      if (explained.size || bestPatternScore > 0) {
        const coverage = explained.size / Math.max(matchedCvDrugs.length, 1);
        const roleScore = scoreSum / Math.max(matchedCvDrugs.length, 1);
        const score = Math.min(1, (coverage * 0.45) + (roleScore * 0.35) + (bestPatternScore * 0.2));
        const daysHint = bestPattern && bestPattern.signature && bestPattern.signature.disambiguate
          ? bestPattern.signature.disambiguate.days_hint
          : null;
        const signalBonuses = durationSignalBonuses(key, daysHint, repDuration)
          .concat(doseSignalBonuses(key, matchedCvDrugs));
        const bonusTotal = Math.min(signalBonuses.reduce((s, b) => s + b.value, 0), bonusCap);
        byDisease[key] = {
          diseaseKey: key,
          diseaseLabel: labelOf(key) || key,
          score: score + bonusTotal,
          baseScore: score,
          signalBonuses,
          explainedDrugs: [...explained].sort(),
          roles,
          bestPattern: bestPattern ? {
            patternId: bestPattern.patternId,
            title: bestPattern.title,
            aiInterpretation: bestPattern.aiInterpretation || '',
          } : null,
        };
      }
    });

    return Object.values(byDisease)
      .sort((a, b) => b.score - a.score)
      .slice(0, 6);
  };

  const genericComponents = (generic) => String(generic || '')
    .split('+')
    .map(part => part.trim().toLowerCase())
    .filter(Boolean);

  const doseCounselingForDrug = (drug) => {
    if (!drug || drug.doseConfidence !== 'high') return [];
    const doseText = String(drug.dose || drug.dosage || '').replace(/\s+/g, '').toLowerCase();
    if (!doseText) return [];
    const genericParts = genericComponents(drug.generic);
    const notes = [];
    DOSE_COUNSELING_RULES.forEach(rule => {
      const ruleGeneric = String(rule.generic || '').trim().toLowerCase();
      const genericHit = ruleGeneric && genericParts.includes(ruleGeneric);
      const classHit = rule.drug_class && rule.drug_class === drug.drug_class;
      if (!genericHit && !classHit) return;
      (rule.doseRules || []).forEach(doseRule => {
        const pattern = String(doseRule.dosePattern || '').replace(/\s+/g, '').toLowerCase();
        if (!pattern || !doseText.includes(pattern)) return;
        notes.push({
          ruleId: rule.ruleId,
          brand: drug.brand,
          generic: drug.generic,
          meaning: doseRule.meaning,
          counseling: doseRule.counseling,
        });
      });
    });
    return notes;
  };

  const qualityFeedbackPreflight = (text) => {
    const payload = String(text || '');
    const labelHit = (OCR_SENSITIVE_RULES.labels || []).find(label => payload.includes(label));
    let regexHit = null;
    for (const pattern of (OCR_SENSITIVE_RULES.regexes || [])) {
      try {
        const re = new RegExp(pattern);
        if (re.test(payload)) {
          regexHit = pattern;
          break;
        }
      } catch (e) {
        // Ignore invalid local policy regexes; source JSON is validated in Python tests.
      }
    }
    const blocked = Boolean(labelHit || regexHit);
    return {
      allowed: !blocked,
      reason: labelHit ? `label:${labelHit}` : (regexHit ? `regex:${regexHit}` : ''),
      message: blocked
        ? (OCR_SENSITIVE_RULES.rejectionMessage || '사진에 민감정보가 포함되어 전송이 거절되었습니다.')
        : '',
    };
  };

  const safeOcrText = (value, max = 120) => String(value || '').slice(0, max);
  const ocrDrugLabel = (drug) => [
    drug && (drug.rawName || drug.original_name || drug.name || drug.brand || drug.displayName),
    drug && (drug.dosage || drug.dose),
  ].filter(Boolean).join(' ');
  const hasMedicationEvidence = (drug) => {
    const text = ocrDrugLabel(drug);
    if (!text.trim()) return false;
    if (drug && (drug.dosage || drug.dose || drug.frequency || drug.duration || drug.usage)) return true;
    return /(\d+(?:\.\d+)?\s*(?:mg|㎎|밀리그램|g|mcg|μg|ug|ml|mL|%|IU)|\d+\/\d+|정|캡슐|캅셀|시럽|액|서방|패치|패취|스프레이|흡입|점안|점이|연고|크림)/i.test(text);
  };

  const sanitizeTopCandidates = (items, limit = 3) => (items || []).slice(0, limit).map(c => ({
    brand: safeOcrText(c.brand || c.displayName, 80),
    generic: safeOcrText(c.generic, 80),
    drug_class: safeOcrText(c.drug_class, 60),
    score: c.score,
  }));

  const sanitizeMatchedOcrDrug = (drug) => ({
    displayName: safeOcrText(drug.displayName || drug.brand, 100),
    brand: safeOcrText(drug.brand || drug.displayName, 100),
    representativeBrand: safeOcrText(drug.representativeBrand, 100),
    original_name: safeOcrText(drug.original_name, 120),
    generic: safeOcrText(drug.generic, 100),
    drug_class: safeOcrText(drug.drug_class, 80),
    dose: safeOcrText(drug.dose || drug.dosage, 40),
    dosage: safeOcrText(drug.dosage || drug.dose, 40),
    frequency: safeOcrText(drug.frequency, 40),
    duration: safeOcrText(drug.duration, 40),
    usage: safeOcrText(drug.usage, 80),
    doseConfidence: safeOcrText(drug.doseConfidence, 20),
    ingredientConfidence: safeOcrText(drug.ingredientConfidence, 20),
    corrected: Boolean(drug.corrected),
    normalizationType: safeOcrText(drug.normalizationType, 40),
    isCv: Boolean(drug.isCv),
    aiCorrection: drug.aiCorrection ? {
      correctedBrand: safeOcrText(drug.aiCorrection.correctedBrand, 100),
      correctedGeneric: safeOcrText(drug.aiCorrection.correctedGeneric, 100),
      confidence: safeOcrText(drug.aiCorrection.confidence, 20),
      failureStage: safeOcrText(drug.aiCorrection.failureStage, 40),
      reason: safeOcrText(drug.aiCorrection.reason, 160),
      modelUsed: safeOcrText(drug.aiCorrection.modelUsed, 60),
      escalated: Boolean(drug.aiCorrection.escalated),
    } : null,
    basket_tokens: (drug.basket_tokens || []).slice(0, 12).map(t => safeOcrText(t, 60)),
    topCandidates: sanitizeTopCandidates(drug.topCandidates),
  });

  const sanitizeRawOcrDrug = (drug) => ({
    rawName: safeOcrText(drug.rawName || drug.original_name || drug.name, 120),
    name: safeOcrText(drug.name || drug.rawName || drug.original_name, 120),
    dosage: safeOcrText(drug.dosage || drug.dose, 40),
    frequency: safeOcrText(drug.frequency, 40),
    duration: safeOcrText(drug.duration, 40),
    usage: safeOcrText(drug.usage, 80),
  });

  const sanitizeUnmatchedOcrDrug = (drug) => ({
    original_name: safeOcrText(drug.original_name || drug.rawName || drug.name, 120),
    dosage: safeOcrText(drug.dosage || drug.dose, 40),
    frequency: safeOcrText(drug.frequency, 40),
    duration: safeOcrText(drug.duration, 40),
    usage: safeOcrText(drug.usage, 80),
    candidates: sanitizeTopCandidates(drug.candidates, 3),
  });

  const sanitizeOcrAnalysisForStorage = (analysis = {}) => ({
    rawDrugs: (analysis.rawDrugs || [])
      .filter(hasMedicationEvidence)
      .map(sanitizeRawOcrDrug),
    matchedCvDrugs: (analysis.matchedCvDrugs || []).map(sanitizeMatchedOcrDrug),
    recognizedNonCvDrugs: (analysis.recognizedNonCvDrugs || []).map(sanitizeMatchedOcrDrug),
    unmatchedDrugs: (analysis.unmatchedDrugs || [])
      .filter(hasMedicationEvidence)
      .map(sanitizeUnmatchedOcrDrug),
    candidateContexts: (analysis.candidateContexts || []).slice(0, 5).map(c => ({
      diseaseKey: safeOcrText(c.diseaseKey, 40),
      diseaseLabel: safeOcrText(c.diseaseLabel, 60),
      score: c.score,
      likelihood: safeOcrText(c.likelihood, 30),
      reason: safeOcrText(c.reason, 180),
      explainedDrugs: (c.explainedDrugs || []).slice(0, 20).map(d => safeOcrText(d, 80)),
      unexplainedCvDrugs: (c.unexplainedCvDrugs || []).slice(0, 20).map(d => safeOcrText(d, 80)),
      drugRoles: (c.drugRoles || []).slice(0, 20).map(r => ({
        brand: safeOcrText(r.brand, 80),
        generic: safeOcrText(r.generic, 80),
        roleLabel: safeOcrText(r.roleLabel, 80),
        roleDescription: safeOcrText(r.roleDescription, 180),
        confidence: safeOcrText(r.confidence, 20),
      })),
      bestPattern: c.bestPattern ? {
        patternId: safeOcrText(c.bestPattern.patternId, 60),
        title: safeOcrText(c.bestPattern.title, 120),
      } : null,
    })),
    combinationScenarios: (analysis.combinationScenarios || []).slice(0, 5).map(s => ({
      score: s.score,
      diseases: (s.diseases || []).slice(0, 6).map(d => ({
        diseaseKey: safeOcrText(d.diseaseKey, 40),
        diseaseLabel: safeOcrText(d.diseaseLabel, 60),
        drugs: (d.drugs || []).slice(0, 20).map(dr => ({
          brand: safeOcrText(dr.brand, 80),
          generic: safeOcrText(dr.generic, 80),
          roleLabel: safeOcrText(dr.roleLabel, 80),
          roleDescription: safeOcrText(dr.roleDescription, 180),
        })),
      })),
    })),
    doseCounselingNotes: (analysis.doseCounselingNotes || []).slice(0, 10).map(n => ({
      brand: safeOcrText(n.brand, 80),
      generic: safeOcrText(n.generic, 80),
      meaning: safeOcrText(n.meaning, 120),
      counseling: safeOcrText(n.counseling, 180),
    })),
  });

  const levenshtein = (a, b) => {
    if (!a) return b ? b.length : 0;
    if (!b) return a.length;
    const matrix = Array.from({ length: a.length + 1 }, () => []);
    for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
    for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
    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;
        matrix[i][j] = Math.min(
          matrix[i - 1][j] + 1,
          matrix[i][j - 1] + 1,
          matrix[i - 1][j - 1] + cost
        );
      }
    }
    return matrix[a.length][b.length];
  };

  const lookupDrugRow = (drug) => {
    if (!drug) return null;

    const brandNorm = normDrug(drug.brand);
    if (brandNorm) {
      const hit = DRUG_CLASSES.find(row => normDrug(row.brand) === brandNorm);
      if (hit) return hit;
    }

    const genericNeedles = [
      drug.generic,
      drug.ingredient,
      drug.ingredient_en,
    ].map(normDrug).filter(Boolean);

    for (const needle of genericNeedles) {
      const hit = DRUG_CLASSES.find(row => normDrug(row.generic) === needle);
      if (hit) return hit;
    }

    // Phase 2: Fuzzy Matching (Levenshtein Distance <= 2)
    if (brandNorm && brandNorm.length >= 2) {
      let bestMatch = null;
      let minDistance = 999;
      
      for (const row of DRUG_CLASSES) {
        const rowBrandNorm = normDrug(row.brand);
        if (!rowBrandNorm) continue;
        
        // Fast optimization: length difference constraint
        if (Math.abs(rowBrandNorm.length - brandNorm.length) > 2) continue;
        
        const dist = levenshtein(brandNorm, rowBrandNorm);
        if (dist <= 2 && dist < minDistance) {
          minDistance = dist;
          bestMatch = { ...row, corrected: true, original_brand: drug.brand };
        }
      }
      
      if (bestMatch) return bestMatch;
    }

    if (drug.drug_class) {
      const hits = DRUG_CLASSES.filter(row => row.drug_class === drug.drug_class && row.is_reference === true);
      if (hits.length === 1) return hits[0];
    }

    return null;
  };

  const diseaseTermsForPattern = (pattern) => {
    const terms = new Set(pattern.diseases || []);
    const diseaseKey = pattern._diseaseKey || '';
    const groupTerms = DIFF_DX_DISEASE_GROUPS[diseaseKey] || [];
    groupTerms.forEach(term => terms.add(term));
    return terms;
  };

  const principleForDisease = (disease) =>
    GENERAL_PRINCIPLES.find(principle =>
      Array.isArray(principle.targets) &&
      principle.targets.some(target => target && target.disease === disease));

  const differentialDxForPattern = (pattern) => {
    if (!pattern || !Array.isArray(pattern.drugs) || !DRUG_CLASSES.length) return [];

    const currentTerms = diseaseTermsForPattern(pattern);
    const diseaseMap = {};

    pattern.drugs.forEach(drug => {
      const row = lookupDrugRow(drug);
      if (!row) return;
      if (!Array.isArray(row.cross_refs) || row.cross_refs.length === 0) return;

      const candidates = [];
      if (row.disease) {
        candidates.push({
          disease: row.disease,
          drug_class: row.drug_class || drug.drug_class || '',
        });
      }
      row.cross_refs.forEach(ref => {
        if (ref && ref.disease) candidates.push(ref);
      });

      candidates.forEach(ref => {
        if (!ref || !ref.disease) return;
        if (currentTerms.has(ref.disease)) return;
        if ((pattern.diseases || []).includes(ref.disease)) return;

        if (!diseaseMap[ref.disease]) {
          const principle = principleForDisease(ref.disease);
          diseaseMap[ref.disease] = {
            disease: ref.disease,
            label: DIFF_DX_LABELS[ref.disease] || ref.disease,
            drugs: [],
            summary: principle ? (principle.summary || '') : '',
          };
        }

        const duplicate = diseaseMap[ref.disease].drugs.some(item =>
          item.generic === row.generic && item.brand === row.brand && item.role === (ref.drug_class || drug.drug_class || ''));
        if (!duplicate) {
          diseaseMap[ref.disease].drugs.push({
            generic: row.generic || drug.ingredient || drug.generic || '',
            brand: row.brand || drug.brand || '',
            role: ref.drug_class || drug.drug_class || '',
          });
        }
      });
    });

    return Object.values(diseaseMap);
  };
  // B-1. cross_refs 한글 질환명 → CV_* 키 역매핑
  const NAME_TO_DISEASE_KEY = (() => {
    const map = {};
    // DIFF_DX_DISEASE_GROUPS 기반
    Object.entries(DIFF_DX_DISEASE_GROUPS).forEach(([key, aliases]) => {
      aliases.forEach(alias => { map[alias] = key; });
    });
    // rx meta diseaseLabel 기반 (DATA가 로딩된 경우)
    if (DATA) {
      Object.keys(DATA).filter(k => k.startsWith('CV_')).forEach(key => {
        const label = DATA[key] && DATA[key].meta && DATA[key].meta.diseaseLabel;
        if (label) map[label] = key;
      });
    }
    // cross_refs에서 자주 쓰는 "...치료제" 형태 추가 매핑
    const extra = {
      '고지혈증 치료제': 'CV_DysL', '이상지질혈증': 'CV_DysL', '이상지질혈증 치료제': 'CV_DysL',
      '급성관상동맥증후군': 'CV_ACS', '급성관상동맥증후군 치료제': 'CV_ACS',
      '뇌졸중': 'CV_Stroke', '뇌졸중 치료제': 'CV_Stroke',
      '말초동맥질환': 'CV_PAD', '말초동맥질환 치료제': 'CV_PAD',
      '심방세동 치료제': 'CV_AF',
      '폐동맥고혈압': 'CV_PH', '폐동맥고혈압 치료제': 'CV_PH',
      '정맥질환': 'CV_VV', '정맥질환 치료제': 'CV_VV',
    };
    return { ...map, ...extra };
  })();

  // B-2. 단일 약물의 후보 CV 질환 목록 산출
  const drugDiseaseCandidates = (drug) => {
    const result = {};
    const addCandidate = (diseaseKey, roleLabel, roleDescription, weight) => {
      if (!diseaseKey || !diseaseKey.startsWith('CV_')) return;
      if (!result[diseaseKey] || result[diseaseKey].weight < weight) {
        result[diseaseKey] = { diseaseKey, roleLabel, roleDescription, weight };
      }
    };
    // cross_refs 기반
    ((drug.row && drug.row.cross_refs) || []).forEach(ref => {
      if (!ref || ref.major !== 'CV') return;
      const key = NAME_TO_DISEASE_KEY[ref.disease] || NAME_TO_DISEASE_KEY[ref.drug_class];
      if (key) addCandidate(key, ref.drug_class, ref.description || '', 0.8);
    });
    // DRUG_ROLE_MAP 기반 (basket_tokens × roleForToken)
    (drug.basket_tokens || []).forEach(token => {
      cvRoleKeys.forEach(key => {
        const role = roleForToken(key, token, drug.drug_class);
        if (role) addCandidate(key, role.roleLabel, role.roleDescription, roleWeight(role));
      });
    });
    // 주질환(drug.row.disease) 기반
    if (drug.row && drug.row.disease) {
      const key = NAME_TO_DISEASE_KEY[drug.row.disease];
      if (key && !result[key]) addCandidate(key, drug.drug_class || '', '', 0.6);
    }
    return Object.values(result);
  };

  // ── 콤보 라벨 DB 조회 헬퍼 ──────────────────────────────────────────
  // build_cv_combo_matrix.py 와 동일한 규칙: sorted(tokens).join('+')
  // SUBCLASS_BY_GENERIC 계열은 drug_class::generic 세분화
  const COMBO_SUBCLASS_CLASSES = new Set([
    'Antiplatelet (P2Y12)',
    'Direct Oral Anticoagulant (DOAC)',
    'Prostacyclin analogues',
    '말초혈액순환 개선',
    '정맥순환 개선',
  ]);
  const makeComboKey = (drugs) => {
    const tokens = drugs.map(d => {
      const dc = d.drug_class || '';
      if (COMBO_SUBCLASS_CLASSES.has(dc) && d.generic) return `${dc}::${d.generic}`;
      return dc;
    }).filter(Boolean);
    return [...new Set(tokens)].sort().join('+');
  };

  // 라벨 disease 문자열 → CV_ 키 배열 (greedy seed용)
  // "고혈압+이상지질혈증" → ["CV_HyperT", "CV_DysL"]
  // (비CV) 표기는 스킵, diseaseKeys 내에 없는 키도 스킵
  const labelDiseaseToKeys = (diseaseStr, validKeys) => {
    if (!diseaseStr) return [];
    return diseaseStr.split('+')
      .map(d => d.replace(/\(비CV\)|\（비CV\）/g, '').trim())
      .filter(d => d && !d.includes('비CV'))
      .map(d => NAME_TO_DISEASE_KEY[d])
      .filter(k => k && (!validKeys || validKeys.includes(k)));
  };

  // Stage 2: 최대 부분집합 탐색
  const findBestSubset = (comboKey) => {
    const inputTokens = comboKey.split('+');
    let bestLabel = null, bestSize = 0;
    Object.keys(CV_COMBO_LABELS).forEach(key => {
      const keyTokens = key.split('+');
      if (keyTokens.every(t => inputTokens.includes(t))) {
        if (keyTokens.length > bestSize) {
          bestSize = keyTokens.length;
          bestLabel = CV_COMBO_LABELS[key];
        }
      }
    });
    return bestLabel;
  };

  // B-3. 집합 커버 방식 시나리오 생성
  const buildCombinationScenarios = (matchedCvDrugs) => {
    // Guard clause
    if (!matchedCvDrugs || matchedCvDrugs.length === 0) return [];

    // ── Stage 3: 기존 greedy IDF — Stage 1/2 seed 삽입은 아래 tryScenario 호출부에서 ──
    const drugCandidates = matchedCvDrugs.map(drug => ({
      drug,
      candidates: drugDiseaseCandidates(drug),
    }));
    if (drugCandidates.every(d => d.candidates.length === 0)) return [];

    // 질환별 커버 가능 약물 집합
    const diseaseToRoles = {};
    drugCandidates.forEach(({ drug, candidates }) => {
      candidates.forEach(cand => {
        if (!diseaseToRoles[cand.diseaseKey]) diseaseToRoles[cand.diseaseKey] = [];
        diseaseToRoles[cand.diseaseKey].push({ drug, ...cand });
      });
    });
    const diseaseKeys = Object.keys(diseaseToRoles);
    if (!diseaseKeys.length) return [];

    // 그리디 집합 커버 실행 (시작 질환 강제 가능)
    const runGreedy = (forceFirst) => {
      const uncovered = new Set(matchedCvDrugs.map((_, i) => i));
      const chosen = [];
      let remaining = [...diseaseKeys];
      if (forceFirst) {
        remaining = [forceFirst, ...diseaseKeys.filter(k => k !== forceFirst)];
      }
      while (uncovered.size > 0 && remaining.length > 0) {
        let best = null, bestCount = -1, bestWeight = -1;
        remaining.forEach(key => {
          const coverable = (diseaseToRoles[key] || []).filter(r => {
            const idx = matchedCvDrugs.indexOf(r.drug);
            return uncovered.has(idx);
          });
          const w = coverable.reduce((s, r) => s + r.weight, 0);
          if (coverable.length > bestCount || (coverable.length === bestCount && w > bestWeight)) {
            best = key; bestCount = coverable.length; bestWeight = w;
          }
        });
        if (!best || bestCount === 0) break;
        chosen.push(best);
        remaining = remaining.filter(k => k !== best);
        (diseaseToRoles[best] || []).forEach(r => {
          const idx = matchedCvDrugs.indexOf(r.drug);
          uncovered.delete(idx);
        });
      }
      return chosen;
    };

    // 점수 계산 헬퍼
    const scoreScenario = (diseaseKeyList) => {
      const assignedDrugs = new Set();
      const diseases = diseaseKeyList.map(key => {
        const label = (DATA && DATA[key] && DATA[key].meta && DATA[key].meta.diseaseLabel) || key;
        const drugs = (diseaseToRoles[key] || [])
          .filter(r => !assignedDrugs.has(r.drug))
          .map(r => { assignedDrugs.add(r.drug); return r; })
          .map(r => ({ brand: r.drug.brand, generic: r.drug.generic, roleLabel: r.roleLabel, roleDescription: r.roleDescription }));
        return { diseaseKey: key, diseaseLabel: label, drugs };
      });
      const covered = [...assignedDrugs].length;
      const total = matchedCvDrugs.length;
      const coverage = covered / Math.max(total, 1);
      const allRoles = diseaseKeyList.flatMap(k => diseaseToRoles[k] || []);
      const avgRoleWeight = allRoles.length
        ? allRoles.reduce((s, r) => s + r.weight, 0) / allRoles.length : 0;
      const parsimony = 1 - (diseaseKeyList.length - 1) / Math.max(diseaseKeys.length, 1);
      const rawScore = coverage * 0.4 + avgRoleWeight * 0.35 + parsimony * 0.25;
      return { rawScore, diseases };
    };

    // 후보 시나리오 생성 (기본 + 대안 최대 4개)
    const candidates = [];
    const seenSigs = new Set();
    const tryScenario = (forceFirst) => {
      const chosen = runGreedy(forceFirst);
      if (!chosen.length) return;
      const sig = chosen.sort().join('|');
      if (seenSigs.has(sig)) return;
      seenSigs.add(sig);
      const { rawScore, diseases } = scoreScenario(chosen);
      candidates.push({ rawScore, diseases });
    };

    // ── Stage 1/2: 라벨 DB 기반 직접 반환 ──
    let trustLevel = 'low';
    if (Object.keys(CV_COMBO_LABELS).length > 0) {
      const comboKey = makeComboKey(matchedCvDrugs);
      const exactLabel = CV_COMBO_LABELS[comboKey];
      if (exactLabel && exactLabel.scenarios && exactLabel.scenarios.length) {
        // Stage 1: 정확 매칭 → 라벨 scenarios를 직접 후보로 추가 (greedy 없음)
        trustLevel = 'high';
        exactLabel.scenarios.forEach(sc => {
          const keys = labelDiseaseToKeys(sc.disease, diseaseKeys);
          if (!keys.length) return;
          const sig = [...keys].sort().join('|');
          if (seenSigs.has(sig)) return;
          seenSigs.add(sig);
          const { rawScore, diseases } = scoreScenario(keys);
          candidates.push({ rawScore, diseases });
        });
      } else {
        const subsetLabel = findBestSubset(comboKey);
        if (subsetLabel && subsetLabel.scenarios && subsetLabel.scenarios.length) {
          // Stage 2: 부분집합 매칭 → 1순위 scenario 직접 추가 + greedy 보완
          trustLevel = 'medium';
          const keys = labelDiseaseToKeys(subsetLabel.scenarios[0].disease, diseaseKeys);
          if (keys.length) {
            const sig = [...keys].sort().join('|');
            if (!seenSigs.has(sig)) {
              seenSigs.add(sig);
              const { rawScore, diseases } = scoreScenario(keys);
              candidates.push({ rawScore, diseases });
            }
          }
          tryScenario(keys[0] || null);
        }
      }
    }

    // 라벨 seed 없거나 추가 후보 탐색
    tryScenario(null);
    diseaseKeys.slice(0, 4).forEach(key => tryScenario(key));

    if (!candidates.length) return [];

    // 절대 점수 기준 내림차순 정렬 (역전 허용)
    return candidates
      .map(c => ({ ...c, score: Math.round(c.rawScore * 100), trustLevel }))
      .sort((a, b) => b.score - a.score)
      .slice(0, 3);
  };

  const analyzeOcrDrugsForCv = (ocrDrugs) => {
    const matches = (ocrDrugs || []).map(matchOcrDrugToProducts);
    const matchedCvDrugs = matches
      .filter(match => match && match.matched && match.best && match.best.isCv)
      .map(match => ({
        ...match.best,
        topCandidates: match.candidates,
      }));
    const recognizedNonCvDrugs = matches
      .filter(match => match && match.matched && match.best && !match.best.isCv)
      .map(match => ({
        ...match.best,
        topCandidates: match.candidates,
      }));
    const unmatchedDrugs = (ocrDrugs || [])
      .map((drug, idx) => ({ drug, match: matches[idx] }))
      .filter(item => !item.match || !item.match.matched || !item.match.best)
      .map(item => ({
        original_name: ocrTextFromDrug(item.drug),
        dosage: item.drug && item.drug.dosage,
        frequency: item.drug && item.drug.frequency,
        duration: item.drug && item.drug.duration,
        usage: item.drug && item.drug.usage,
        candidates: item.match ? item.match.candidates : [],
      }));
    const cvBasketTokens = [...new Set(matchedCvDrugs.flatMap(drug => drug.basket_tokens || []))].sort();
    const candidateContexts = rankCvCandidateContexts(matchedCvDrugs);
    const combinationScenarios = buildCombinationScenarios(matchedCvDrugs);
    const doseCounselingNotes = matchedCvDrugs.flatMap(doseCounselingForDrug);
    return {
      rawDrugs: ocrDrugs || [],
      matchedCvDrugs,
      recognizedNonCvDrugs,
      unmatchedDrugs,
      cvBasketTokens,
      candidateContexts,
      combinationScenarios,
      doseCounselingNotes,
    };
  };

  const createCustomPatternFromOcr = (ocrDrugs, existingAnalysis) => {
    const analysis = existingAnalysis || analyzeOcrDrugsForCv(ocrDrugs);
    const mappedDrugs = [
      ...analysis.matchedCvDrugs,
      ...analysis.unmatchedDrugs.map(drug => ({
        brand: drug.original_name,
        generic: drug.original_name,
        drug_class: '분류불명',
        dose: drug.dosage,
        dosage: drug.dosage,
        frequency: drug.frequency,
        duration: drug.duration,
        days: drug.duration,
        usage: drug.usage,
        timing: drug.usage,
      })),
    ];
    const topContext = analysis.candidateContexts[0];
    const bestPatternIdx = topContext && topContext.bestPattern
      ? idxOf(topContext.diseaseKey, topContext.bestPattern.patternId)
      : -1;
    const bestPattern = bestPatternIdx >= 0 ? getPattern(topContext.diseaseKey, bestPatternIdx) : null;
    return {
      ...(bestPattern || {}),
      patternId: bestPattern ? `OCR-Match-${bestPattern.patternId}` : 'OCR-Result',
      title: 'OCR 분석 기반 추정 처방전',
      diseases: topContext ? [topContext.diseaseLabel] : ['미상'],
      context: topContext
        ? `[OCR 기반 자동 매칭] 인식된 CV 약물 조합은 ${topContext.diseaseLabel} 맥락이 가장 높게 추정됩니다. 다른 가능성도 함께 확인해야 합니다.`
        : '처방전에서 인식된 약품 중 CV 표준 처방 조합과 충분히 매칭되는 항목을 찾지 못했습니다.',
      aiInterpretation: topContext
        ? `${topContext.diseaseLabel} 가능성이 가장 높지만, 동일 약물이 여러 CV 질환에서 쓰일 수 있어 아래 후보 맥락을 함께 검토해야 합니다.`
        : '약물명 교정 또는 비CV 약물 여부 확인이 필요합니다.',
      drugs: mappedDrugs.map((drug, i) => ({ ...drug, groupId: `g${i}` })),
      drugGroups: mappedDrugs.map((drug, i) => ({ id: `g${i}`, label: drug.drug_class || '분류불명' })),
      candidate_contexts: analysis.candidateContexts,
      dose_counseling_notes: analysis.doseCounselingNotes,
      ocr_analysis: analysis,
      _diseaseKey: topContext ? topContext.diseaseKey : '__all',
      _sourceFile: topContext ? `${topContext.diseaseKey}_rx.json` : 'unknown',
    };
  };

  return { DATA, ORDER, diseaseList, patternsFor, getPattern, weightedRandom, idxOf, groupOf, fmt, cautionItems, labelOf, scopedPatterns, scopedIdxOf, contextNotesForPattern, differentialDxForPattern, analyzeOcrDrugsForCv, createCustomPatternFromOcr, qualityFeedbackPreflight, sanitizeOcrAnalysisForStorage, fuzzyCandidates, productBrandList };
})();

// ── tiny custom dropdown ─────────────────────────────────────
function Dropdown({ value, items, onChange, placeholder, align = 'left', width }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);
  const cur = items.find(i => i.value === value);
  return (
    <div ref={ref} style={{ position: 'relative', width }}>
      <button onClick={() => setOpen(o => !o)} style={{
        display: 'flex', alignItems: 'center', gap: 6, width: '100%',
        background: RX.paper, border: `1px solid ${open ? RX.teal : RX.line}`,
        borderRadius: 8, padding: '8px 10px', cursor: 'pointer',
        font: 'inherit', color: RX.ink, fontSize: 13.5, fontWeight: 600,
        boxShadow: open ? `0 0 0 3px ${RX.tealTint}` : 'none', transition: 'all .12s',
      }}>
        <span style={{ flex: 1, textAlign: 'left', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
          {cur ? cur.label : <span style={{ color: RX.faint }}>{placeholder}</span>}
        </span>
        <svg width="11" height="7" viewBox="0 0 11 7" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>
          <path d="M1 1l4.5 4L10 1" fill="none" stroke={RX.sub} strokeWidth="1.5" strokeLinecap="round" />
        </svg>
      </button>
      {open && (
        <div style={{
          position: 'absolute', top: 'calc(100% + 5px)', [align]: 0, zIndex: 80,
          minWidth: '100%', maxHeight: 280, overflowY: 'auto',
          background: RX.paper, border: `1px solid ${RX.line}`, borderRadius: 10,
          boxShadow: '0 12px 32px rgba(20,40,34,0.16)', padding: 5,
        }}>
          {items.map(it => (
            <button key={it.value} onClick={() => { onChange(it.value); setOpen(false); }} style={{
              display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left',
              background: it.value === value ? RX.tealTint : 'transparent',
              border: 'none', borderRadius: 7, padding: '9px 10px', cursor: 'pointer',
              font: 'inherit', fontSize: 13.5, color: RX.ink,
              fontWeight: it.value === value ? 700 : 500,
            }}
              onMouseEnter={e => { if (it.value !== value) e.currentTarget.style.background = RX.shell; }}
              onMouseLeave={e => { if (it.value !== value) e.currentTarget.style.background = 'transparent'; }}>
              <span style={{ flex: 1, whiteSpace: 'nowrap' }}>{it.label}</span>
              {it.hint != null && <span style={{ fontSize: 11, color: RX.faint, fontWeight: 600 }}>{it.hint}</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// ── selection-basis badge ────────────────────────────────────
function SelectionBadge({ basis, small }) {
  const clin = basis === 'clinical';
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 4,
      fontSize: small ? 10 : 11, fontWeight: 700, letterSpacing: '.02em',
      padding: small ? '2px 6px' : '3px 8px', borderRadius: 5,
      color: clin ? RX.teal : RX.amber,
      background: clin ? RX.tealTint : RX.amberBg,
      border: `1px solid ${clin ? RX.tealLine : '#ecdcb6'}`,
    }}>
      {clin ? '임상적 선택' : '다빈도 선택'}
    </span>
  );
}

// ── 권한 체크 (단일 진실 공급원) ──────────────────────────────
function isUnlocked(plan) {
  const p = plan || 'free';
  return p === 'vip' || p === 'admin' || p === 'trial';
}

window.RX = RX;
window.rxHelpers = rxHelpers;
window.Dropdown = Dropdown;
window.SelectionBadge = SelectionBadge;
window.isUnlocked = isUnlocked;
