// mojibake.jsx — encoding-corruption ("mojibake") decoder for catalog text
//
// Latin-music ops hits this constantly: a Spanish title "Eres Tú" round-trips
// through a system that decodes UTF-8 bytes as MacRoman (legacy iTunes XML)
// or as Windows-1252 (most Excel exports), and arrives as "Eres T√∫" or
// "Eres TÃº". This module detects, decodes, and scores confidence.
//
// Strategy:
//   1. Re-encode the corrupted string back to bytes using the WRONG codepage
//      (MacRoman / CP1252 / Latin-1).
//   2. Re-decode those bytes as UTF-8.
//   3. Score: penalize if result still has odd glyphs; reward if it lands on
//      Spanish/Portuguese/French letter sequences (ñ, á, é, í, ó, ú, ç, ã …).
//   4. Pick the highest-confidence candidate. If two candidates tie, fall
//      back to a known-pair lookup.
//
// EXPORT: window.MojibakeEngine = { detect, decode, candidates, scan, fix,
//                                   knownPairs, encodings, classify }
// ============================================================================
(() => {
  // ── codepage tables ────────────────────────────────────────────────
  // Each table maps codepoint 0x80–0xFF → unicode char. JS string.charCodeAt
  // gives us the unicode of each char; we reverse the table to "encode".

  // CP1252 (Windows Latin-1) — superset of ISO-8859-1 with curly quotes etc.
  const CP1252 = {
    0x80:'€',0x82:'‚',0x83:'ƒ',0x84:'„',0x85:'…',0x86:'†',0x87:'‡',
    0x88:'ˆ',0x89:'‰',0x8A:'Š',0x8B:'‹',0x8C:'Œ',0x8E:'Ž',
    0x91:'\u2018',0x92:'\u2019',0x93:'\u201C',0x94:'\u201D',0x95:'•',0x96:'–',0x97:'—',
    0x98:'˜',0x99:'™',0x9A:'š',0x9B:'›',0x9C:'œ',0x9E:'ž',0x9F:'Ÿ',
  };
  // 0xA0–0xFF in CP1252 == ISO-8859-1 == unicode 0xA0–0xFF (1:1)

  // MacRoman — Mac OS Roman, the silent killer of Latin music titles
  // because legacy iTunes / pre-2008 Mac DAW metadata tools use it.
  const MAC_ROMAN_HIGH = [
    'Ä','Å','Ç','É','Ñ','Ö','Ü','á','à','â','ä','ã','å','ç','é','è',
    'ê','ë','í','ì','î','ï','ñ','ó','ò','ô','ö','õ','ú','ù','û','ü',
    '†','°','¢','£','§','•','¶','ß','®','©','™','´','¨','≠','Æ','Ø',
    '∞','±','≤','≥','¥','µ','∂','∑','∏','π','∫','ª','º','Ω','æ','ø',
    '¿','¡','¬','√','ƒ','≈','∆','«','»','…','\u00A0','À','Ã','Õ','Œ','œ',
    '–','—','\u201C','\u201D','\u2018','\u2019','÷','◊','ÿ','Ÿ','⁄','€','‹','›','ﬁ','ﬂ',
    '‡','·','‚','„','‰','Â','Ê','Á','Ë','È','Í','Î','Ï','Ì','Ó','Ô',
    '\uF8FF','Ò','Ú','Û','Ù','ı','ˆ','˜','¯','˘','˙','˚','¸','˝','˛','ˇ',
  ];

  // Build encoder maps (unicode-char → byte)
  function buildEncoder(name) {
    const m = new Map();
    if (name === 'cp1252') {
      // 0x00-0x7F identity
      for (let b = 0; b < 0x80; b++) m.set(String.fromCharCode(b), b);
      // 0x80-0xFF
      for (let b = 0x80; b <= 0xFF; b++) {
        const ch = (CP1252[b]) || (b >= 0xA0 ? String.fromCharCode(b) : null);
        if (ch) m.set(ch, b);
      }
    } else if (name === 'macroman') {
      for (let b = 0; b < 0x80; b++) m.set(String.fromCharCode(b), b);
      for (let i = 0; i < MAC_ROMAN_HIGH.length; i++) {
        m.set(MAC_ROMAN_HIGH[i], 0x80 + i);
      }
    } else if (name === 'latin1') {
      for (let b = 0; b < 0x100; b++) m.set(String.fromCharCode(b), b);
    }
    return m;
  }
  const ENC = {
    cp1252: buildEncoder('cp1252'),
    macroman: buildEncoder('macroman'),
    latin1: buildEncoder('latin1'),
  };

  // Build decoder arrays (byte → unicode-char)
  function buildDecoder(name) {
    const arr = new Array(256);
    if (name === 'cp1252') {
      for (let b = 0; b < 0x80; b++) arr[b] = String.fromCharCode(b);
      for (let b = 0x80; b <= 0xFF; b++) arr[b] = CP1252[b] || String.fromCharCode(b);
    } else if (name === 'macroman') {
      for (let b = 0; b < 0x80; b++) arr[b] = String.fromCharCode(b);
      for (let i = 0; i < MAC_ROMAN_HIGH.length; i++) arr[0x80 + i] = MAC_ROMAN_HIGH[i];
    } else if (name === 'latin1') {
      for (let b = 0; b < 0x100; b++) arr[b] = String.fromCharCode(b);
    }
    return arr;
  }
  const DEC = {
    cp1252: buildDecoder('cp1252'),
    macroman: buildDecoder('macroman'),
    latin1: buildDecoder('latin1'),
  };

  // ── encode string → byte array using a legacy codepage ────────────
  function encodeAs(str, cp) {
    const map = ENC[cp];
    if (!map) return null;
    const bytes = [];
    for (const ch of str) {
      const b = map.get(ch);
      if (b === undefined) return null; // unrepresentable in this codepage
      bytes.push(b);
    }
    return bytes;
  }

  // ── decode byte array as UTF-8, returning string + valid flag ─────
  // We require strict validity to avoid scoring random-byte sequences high.
  function decodeUtf8(bytes) {
    let i = 0, out = '';
    while (i < bytes.length) {
      const b = bytes[i];
      if (b < 0x80) {
        out += String.fromCharCode(b);
        i++;
      } else if ((b & 0xE0) === 0xC0) { // 2-byte
        if (i + 1 >= bytes.length) return null;
        const b2 = bytes[i + 1];
        if ((b2 & 0xC0) !== 0x80) return null;
        const cp = ((b & 0x1F) << 6) | (b2 & 0x3F);
        if (cp < 0x80) return null; // overlong
        out += String.fromCodePoint(cp);
        i += 2;
      } else if ((b & 0xF0) === 0xE0) { // 3-byte
        if (i + 2 >= bytes.length) return null;
        const b2 = bytes[i + 1], b3 = bytes[i + 2];
        if ((b2 & 0xC0) !== 0x80 || (b3 & 0xC0) !== 0x80) return null;
        const cp = ((b & 0x0F) << 12) | ((b2 & 0x3F) << 6) | (b3 & 0x3F);
        if (cp < 0x800) return null;
        out += String.fromCodePoint(cp);
        i += 3;
      } else if ((b & 0xF8) === 0xF0) { // 4-byte
        if (i + 3 >= bytes.length) return null;
        const b2 = bytes[i + 1], b3 = bytes[i + 2], b4 = bytes[i + 3];
        if ((b2 & 0xC0) !== 0x80 || (b3 & 0xC0) !== 0x80 || (b4 & 0xC0) !== 0x80) return null;
        const cp = ((b & 0x07) << 18) | ((b2 & 0x3F) << 12) | ((b3 & 0x3F) << 6) | (b4 & 0x3F);
        if (cp < 0x10000) return null;
        out += String.fromCodePoint(cp);
        i += 4;
      } else return null;
    }
    return out;
  }

  // ── scoring ────────────────────────────────────────────────────────
  // Penalize "bad" glyphs (math, box, replacement) and reward valid Latin
  // diacritics that hint at a real Spanish/Portuguese/French/Italian title.
  const SUSPICIOUS = /[√∫≈≠≤≥∆∂∑∏π∞◊ÆØƒªº†‡•∫µ◌�]|Ã[\s\S]?|Â[\s\S]?|Â\s|â€[™œ\u009d\u009c]/;
  const ROMANCE_DIACRITIC = /[áéíóúñÁÉÍÓÚÑàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜçÇãõÃÕ]/;
  const HIGH_ASCII = /[\x80-\xFF\u0100-\uFFFF]/;

  function scoreStr(s) {
    if (s == null) return -Infinity;
    let score = 0;
    // Heavy reward for resolving to plain Latin diacritics
    const hits = (s.match(/[áéíóúñÁÉÍÓÚÑàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÇçÃãÕõ]/g) || []).length;
    score += hits * 8;
    // Heavy penalty for symbols that almost never appear in song titles
    const bad = (s.match(SUSPICIOUS) || []).length;
    score -= bad * 12;
    // Mild penalty per high-bit char (more = riskier)
    const high = (s.match(/[\u0080-\uFFFF]/g) || []).length;
    score -= high * 0.3;
    // Reward common Spanish/Portuguese sequences
    if (/\b(t[uú]|qu[eé]|m[aá]s|aqu[ií]|d[ií]a|coraz[oó]n|ni[ñn]o|a[nñ]o|m[aá]s|cami[oó]n)\b/i.test(s)) score += 6;
    // Penalize sequences that are signature mojibake markers
    if (/Ã[©£¡¨¬®°ºª]|â€™|â€œ|â€\x9D|Ã\x83/.test(s)) score -= 25;
    return score;
  }

  // ── known-pairs library ────────────────────────────────────────────
  // Hand-curated glyph→fix pairs that we trust as gospel for tie-breaking
  // and quick visual proof. Source: the most common UTF-8↔CP1252↔MacRoman
  // mis-decodings of Spanish, Portuguese, French, German, Italian text.
  const KNOWN_PAIRS = [
    // MacRoman (UTF-8 bytes interpreted as MacRoman)
    { bad: '√°', good: 'á', via: 'macroman' },
    { bad: '√©', good: 'é', via: 'macroman' },
    { bad: '√≠', good: 'í', via: 'macroman' },
    { bad: '√≥', good: 'ó', via: 'macroman' },
    { bad: '√∫', good: 'ú', via: 'macroman' },
    { bad: '√±', good: 'ñ', via: 'macroman' },
    { bad: '√º', good: 'ü', via: 'macroman' },
    { bad: '√Å', good: 'Á', via: 'macroman' },
    { bad: '√â', good: 'É', via: 'macroman' },
    { bad: '√ç', good: 'Í', via: 'macroman' },
    { bad: '√ì', good: 'Ó', via: 'macroman' },
    { bad: '√ö', good: 'Ú', via: 'macroman' },
    { bad: '√ë', good: 'Ñ', via: 'macroman' },
    { bad: '√ß', good: 'ß', via: 'macroman' },
    { bad: '√ß', good: 'ç', via: 'macroman' },
    { bad: '√£', good: 'ã', via: 'macroman' },
    { bad: '√ï', good: 'õ', via: 'macroman' },
    { bad: '√ß', good: 'ç', via: 'macroman' },
    { bad: '¬°', good: '¡', via: 'macroman' },
    { bad: '¬ø', good: '¿', via: 'macroman' },
    { bad: '√§', good: 'ä', via: 'macroman' },
    { bad: '√∂', good: 'ö', via: 'macroman' },
    // CP1252 (UTF-8 bytes interpreted as CP1252)
    { bad: 'Ã¡', good: 'á', via: 'cp1252' },
    { bad: 'Ã©', good: 'é', via: 'cp1252' },
    { bad: 'Ã­', good: 'í', via: 'cp1252' },
    { bad: 'Ã³', good: 'ó', via: 'cp1252' },
    { bad: 'Ãº', good: 'ú', via: 'cp1252' },
    { bad: 'Ã±', good: 'ñ', via: 'cp1252' },
    { bad: 'Ã¼', good: 'ü', via: 'cp1252' },
    { bad: 'Ã\u0081', good: 'Á', via: 'cp1252' },
    { bad: 'Ã‰', good: 'É', via: 'cp1252' },
    { bad: 'Ã\u008d', good: 'Í', via: 'cp1252' },
    { bad: 'Ã“', good: 'Ó', via: 'cp1252' },
    { bad: 'Ãš', good: 'Ú', via: 'cp1252' },
    { bad: 'Ã‘', good: 'Ñ', via: 'cp1252' },
    { bad: 'Ã§', good: 'ç', via: 'cp1252' },
    { bad: 'Ã¨', good: 'è', via: 'cp1252' },
    { bad: 'Ã ', good: 'à', via: 'cp1252' },
    { bad: 'Ã¢', good: 'â', via: 'cp1252' },
    { bad: 'Ãª', good: 'ê', via: 'cp1252' },
    { bad: 'Ã®', good: 'î', via: 'cp1252' },
    { bad: 'Ã´', good: 'ô', via: 'cp1252' },
    { bad: 'Ã»', good: 'û', via: 'cp1252' },
    { bad: 'Ã\u00ad', good: 'í', via: 'cp1252' }, // soft-hyphen variant
    { bad: 'Ã£', good: 'ã', via: 'cp1252' },
    { bad: 'Ãµ', good: 'õ', via: 'cp1252' },
    { bad: 'Ã\u0083', good: 'Ã', via: 'cp1252' }, // doubly-encoded
    // Smart quotes / dashes
    { bad: 'â€™', good: '\u2019', via: 'cp1252' },
    { bad: 'â€œ', good: '\u201C', via: 'cp1252' },
    { bad: 'â€\u009D', good: '\u201D', via: 'cp1252' },
    { bad: 'â€"', good: '—', via: 'cp1252' },
    { bad: 'â€\u0093', good: '–', via: 'cp1252' },
    { bad: 'â€¦', good: '…', via: 'cp1252' },
    { bad: '√Æ', good: '\u2019', via: 'macroman' },
  ];

  function knownFix(s) {
    let out = s, applied = [];
    // Sort by length desc so longer pairs match first
    const pairs = [...KNOWN_PAIRS].sort((a, b) => b.bad.length - a.bad.length);
    for (const p of pairs) {
      if (out.includes(p.bad)) {
        const before = out;
        out = out.split(p.bad).join(p.good);
        if (out !== before) applied.push(p);
      }
    }
    return { fixed: out, applied };
  }

  // ── candidate generation ───────────────────────────────────────────
  function candidates(s) {
    const out = [];
    out.push({ encoding: 'as-is', text: s, score: scoreStr(s) });

    // Try known-pair fix first
    const kf = knownFix(s);
    if (kf.fixed !== s) {
      out.push({ encoding: 'known-pairs', text: kf.fixed, score: scoreStr(kf.fixed) + 4, applied: kf.applied });
    }

    // Programmatic round-trips
    for (const cp of ['macroman', 'cp1252', 'latin1']) {
      const bytes = encodeAs(s, cp);
      if (!bytes) continue;
      const decoded = decodeUtf8(bytes);
      if (decoded == null || decoded === s) continue;
      out.push({ encoding: cp + ' → utf-8', text: decoded, score: scoreStr(decoded) + 2 });
    }

    // Double round-trip (occasionally needed)
    for (const cp of ['cp1252', 'macroman']) {
      const b1 = encodeAs(s, cp);
      if (!b1) continue;
      const d1 = decodeUtf8(b1);
      if (d1 == null) continue;
      const b2 = encodeAs(d1, cp);
      if (!b2) continue;
      const d2 = decodeUtf8(b2);
      if (d2 == null || d2 === d1 || d2 === s) continue;
      out.push({ encoding: `2× ${cp} → utf-8`, text: d2, score: scoreStr(d2) });
    }

    // Dedupe by text, keep highest score
    const map = new Map();
    for (const c of out) {
      const ex = map.get(c.text);
      if (!ex || ex.score < c.score) map.set(c.text, c);
    }
    return [...map.values()].sort((a, b) => b.score - a.score);
  }

  function detect(s) {
    if (!s || typeof s !== 'string') return { corrupted: false };
    // Quick pre-flight: any glyph our known-pair table catches?
    if (SUSPICIOUS.test(s)) return { corrupted: true, reason: 'suspicious-glyph' };
    if (/Ã[¡©­³º±¼‘“”]/.test(s)) return { corrupted: true, reason: 'cp1252-pattern' };
    if (/√[°©≠≥∫±]/.test(s) || /¬[°ø¡]/.test(s)) return { corrupted: true, reason: 'macroman-pattern' };
    if (/â€[™œ\u009d¦"]/.test(s)) return { corrupted: true, reason: 'utf8-as-cp1252-quotes' };
    return { corrupted: false };
  }

  function decode(s) {
    const det = detect(s);
    if (!det.corrupted) return { input: s, output: s, changed: false, encoding: 'clean', confidence: 1.0, candidates: [] };
    const cands = candidates(s);
    const best = cands[0];
    const second = cands[1];
    const margin = best && second ? Math.max(0, best.score - second.score) : 99;
    const confidence = Math.min(1, 0.5 + margin / 30);
    return {
      input: s,
      output: best?.text || s,
      changed: best?.text !== s,
      encoding: best?.encoding || 'unknown',
      confidence,
      reason: det.reason,
      candidates: cands.slice(0, 5),
    };
  }

  function classify(s) {
    const r = detect(s);
    if (!r.corrupted) return 'clean';
    return r.reason;
  }

  // ── catalog scan ───────────────────────────────────────────────────
  // Walk WORKS / RECORDINGS / RELEASES / RELEASE_GROUPS / PARTIES looking
  // for any text field that triggers detect(), return a flat list of
  // { entityKind, entityId, entityName, field, before, after, confidence,
  //   encoding, candidates }.
  const FIELDS = {
    works:     ['title', 'altTitle', 'subTitle'],
    recordings:['title', 'version', 'subTitle'],
    releases:  ['title', 'artist', 'label', 'version'],
    'release-groups':['title', 'artist'],
    parties:   ['name', 'legalName', 'altName', 'displayName'],
    videos:    ['title', 'Recording'],
    artists:   ['Name', 'name', 'Display Name'],
  };

  function scan() {
    const out = [];
    const W = window.WORKS || [];
    const R = window.RECORDINGS || [];
    const RL = window.RELEASES || [];
    const RG = window.RELEASE_GROUPS || [];
    const P = window.PARTIES || [];
    const V = (window.RS && window.RS.videos) || [];
    const A = (window.RS && window.RS.profiles) || [];

    function pushFrom(kind, list, fields, getId, getName) {
      list.forEach(item => {
        fields.forEach(f => {
          const v = item[f];
          if (typeof v !== 'string' || !v) return;
          const det = detect(v);
          if (!det.corrupted) return;
          const dec = decode(v);
          if (!dec.changed) return;
          out.push({
            kind,
            id: getId(item),
            name: getName(item),
            field: f,
            before: v,
            after: dec.output,
            encoding: dec.encoding,
            confidence: dec.confidence,
            candidates: dec.candidates,
            reason: dec.reason,
          });
        });
      });
    }

    pushFrom('work',         W,  FIELDS.works,            i => i.id || i.code,    i => i.title);
    pushFrom('recording',    R,  FIELDS.recordings,       i => i.id || i.isrc,    i => i.title);
    pushFrom('release',      RL, FIELDS.releases,         i => i.id || i.upc,     i => i.title);
    pushFrom('release-group',RG, FIELDS['release-groups'],i => i.id,              i => i.title);
    pushFrom('party',        P,  FIELDS.parties,          i => i.id,              i => i.name);
    pushFrom('video',        V,  FIELDS.videos,           i => i['Video ID'],     i => i['Recording'] || i.title);
    pushFrom('artist',       A,  FIELDS.artists,          i => i['Profile ID'] || i.id, i => i.Name || i.name);

    return out;
  }

  // ── batch fix application (in-memory; persists for session) ───────
  function applyFixes(fixes) {
    const W = window.WORKS || [];
    const R = window.RECORDINGS || [];
    const RL = window.RELEASES || [];
    const RG = window.RELEASE_GROUPS || [];
    const P = window.PARTIES || [];
    const V = (window.RS && window.RS.videos) || [];
    const A = (window.RS && window.RS.profiles) || [];
    const map = { work: W, recording: R, release: RL, 'release-group': RG, party: P, video: V, artist: A };
    let n = 0;
    for (const f of fixes) {
      const list = map[f.kind];
      if (!list) continue;
      const target = list.find(i => (i.id || i['Video ID'] || i['Profile ID']) === f.id);
      if (!target) continue;
      if (target[f.field] === f.before) {
        target[f.field] = f.after;
        n++;
      }
    }
    return n;
  }

  // ── string helper for highlighted diff ────────────────────────────
  function highlightDiff(before, after) {
    // Naive but effective: walk both, emit run-tagged chars.
    const segs = [];
    let i = 0, j = 0;
    while (i < before.length || j < after.length) {
      if (before[i] === after[j]) { segs.push({ t: 'same', c: after[j] || '' }); i++; j++; }
      else {
        // Emit a "fix" segment up to next sync. Find next matching char.
        let bnext = i, anext = j;
        while (bnext < before.length && anext < after.length && before[bnext] !== after[anext]) {
          bnext++; anext++;
        }
        segs.push({ t: 'before', c: before.slice(i, bnext) });
        segs.push({ t: 'after',  c: after.slice(j, anext) });
        i = bnext; j = anext;
      }
    }
    return segs;
  }

  Object.assign(window, {
    MojibakeEngine: {
      detect, decode, candidates, scan, applyFixes,
      knownPairs: KNOWN_PAIRS,
      classify,
      highlightDiff,
      encodings: ['cp1252', 'macroman', 'latin1'],
    },
  });
})();

// ============================================================================
// SCREEN — Mojibake decoder workshop
// ============================================================================
(() => {
  const { useState, useMemo, useEffect } = React;
  const E = () => window.MojibakeEngine;

  function ScreenMojibake({ go, payload }) {
    const Btn = window.Btn, Ic = window.Ic || {}, PageHeader = window.PageHeader;
    const [tab, setTab] = useState(payload?.tab || 'workshop');
    const [input, setInput] = useState(payload?.text ||
      'Eres T√∫\nNi√±o de mi Coraz√≥n\nQuiÃ©reme MÃ¡s\nLa CanciÃ³n del Maï¿½ana\nMontaña Rusa\nLa Vie en Rose\nÃä la prochaine');
    const [scanResults, setScanResults] = useState(null);
    const [selected, setSelected] = useState(new Set());
    const [filter, setFilter] = useState('all');

    const decodedLines = useMemo(() => {
      if (!E()) return [];
      return input.split('\n').map(line => ({ line, ...E().decode(line) }));
    }, [input]);

    function runScan() {
      if (!E()) return;
      const r = E().scan();
      setScanResults(r);
      setSelected(new Set());
    }

    useEffect(() => { if (tab === 'catalog' && !scanResults) runScan(); }, [tab]);

    function toggleSel(idx) {
      setSelected(s => {
        const n = new Set(s);
        if (n.has(idx)) n.delete(idx); else n.add(idx);
        return n;
      });
    }
    function selectAll() {
      if (!scanResults) return;
      setSelected(new Set(filtered.map(([_, idx]) => idx)));
    }
    function applySelected() {
      if (!scanResults || !E()) return;
      const fixes = [...selected].map(i => scanResults[i]);
      const n = E().applyFixes(fixes);
      const remaining = scanResults.filter((_, i) => !selected.has(i));
      setScanResults(remaining);
      setSelected(new Set());
      alert(`Applied ${n} fix${n === 1 ? '' : 'es'}. Catalog data updated for this session.`);
    }

    const filtered = useMemo(() => {
      if (!scanResults) return [];
      return scanResults
        .map((r, i) => [r, i])
        .filter(([r]) => filter === 'all' || r.kind === filter);
    }, [scanResults, filter]);

    const counts = useMemo(() => {
      if (!scanResults) return {};
      const c = { all: scanResults.length };
      for (const r of scanResults) c[r.kind] = (c[r.kind] || 0) + 1;
      return c;
    }, [scanResults]);

    const KINDS = [
      { v: 'all',          l: 'All' },
      { v: 'work',         l: 'Works' },
      { v: 'recording',    l: 'Recordings' },
      { v: 'release',      l: 'Releases' },
      { v: 'release-group',l: 'Release groups' },
      { v: 'artist',       l: 'Artists' },
      { v: 'party',        l: 'Parties' },
      { v: 'video',        l: 'Videos' },
    ];

    const TABS = [
      { id: 'workshop', l: '01 · Workshop' },
      { id: 'catalog',  l: '02 · Catalog scan' },
      { id: 'pairs',    l: '03 · Glyph reference' },
      { id: 'how',      l: '04 · How it works' },
    ];

    return (
      <div style={{ padding: '24px 32px 80px', minHeight: '100vh', background: 'var(--bg)' }}>
        <div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, marginBottom: 8 }}>
          <div style={{ flex: 1 }}>
            <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.12em', color: 'var(--ink-3)', fontWeight: 600 }}>TOOLS · QUALITY</div>
            <h1 style={{ fontSize: 28, fontWeight: 400, margin: '4px 0 4px', letterSpacing: '-.01em' }}>Mojibake decoder</h1>
            <div style={{ fontSize: 13, color: 'var(--ink-3)', maxWidth: 820 }}>
              Recover Spanish, Portuguese, French, German, and Italian titles whose accented characters were corrupted by a UTF-8 ↔ CP1252 / MacRoman round-trip.
              <span className="ff-mono" style={{ marginLeft: 8 }}>"Eres T√∫" → "Eres Tú"</span>
            </div>
          </div>
        </div>

        {/* tabs */}
        <div style={{ display: 'flex', gap: 0, borderBottom: '2px solid var(--ink)', margin: '24px 0 28px' }}>
          {TABS.map(t => (
            <button key={t.id} onClick={() => setTab(t.id)} className="ff-mono upper" style={{
              background: tab === t.id ? 'var(--ink)' : 'transparent',
              color: tab === t.id ? 'var(--bg)' : 'var(--ink-2)',
              border: 0, padding: '10px 18px', fontSize: 11, letterSpacing: '.08em',
              cursor: 'pointer', fontWeight: tab === t.id ? 600 : 500,
            }}>{t.l}</button>
          ))}
        </div>

        {tab === 'workshop' && (
          <Workshop input={input} setInput={setInput} decodedLines={decodedLines} />
        )}
        {tab === 'catalog' && (
          <Catalog scanResults={scanResults} filtered={filtered} counts={counts}
            selected={selected} setSelected={setSelected}
            toggleSel={toggleSel} selectAll={selectAll} applySelected={applySelected}
            filter={filter} setFilter={setFilter} kinds={KINDS} runScan={runScan} go={go} />
        )}
        {tab === 'pairs' && <PairsTable />}
        {tab === 'how' && <HowItWorks />}
      </div>
    );
  }

  // ── Workshop tab ────────────────────────────────────────────────────
  function Workshop({ input, setInput, decodedLines }) {
    const totalCorrupted = decodedLines.filter(d => d.changed).length;
    return (
      <div>
        {/* KPI strip */}
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', borderTop: '1px solid var(--rule)', borderBottom: '1px solid var(--rule)', marginBottom: 24 }}>
          <KpiCell label="LINES IN" value={decodedLines.length} />
          <KpiCell label="CORRUPTED" value={totalCorrupted} tone={totalCorrupted ? 'warn' : 'ok'} />
          <KpiCell label="DETECTED ENCODINGS" value={[...new Set(decodedLines.filter(d => d.changed).map(d => d.encoding))].length} />
          <KpiCell label="AVG CONFIDENCE" value={
            decodedLines.filter(d => d.changed).length
              ? Math.round(decodedLines.filter(d => d.changed).reduce((s, d) => s + d.confidence, 0) / decodedLines.filter(d => d.changed).length * 100) + '%'
              : '—'
          } />
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
          {/* INPUT */}
          <div>
            <div className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.1em', color: 'var(--ink-3)', marginBottom: 8 }}>
              PASTE TITLES · ONE PER LINE
            </div>
            <textarea value={input} onChange={e => setInput(e.target.value)} spellCheck={false}
              style={{
                width: '100%', minHeight: 400, padding: '12px 14px',
                background: 'var(--paper)', border: '1px solid var(--rule)',
                fontFamily: 'ui-monospace, monospace', fontSize: 13, lineHeight: 1.6,
                color: 'var(--ink)', resize: 'vertical',
              }} />
          </div>
          {/* DECODED */}
          <div>
            <div className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.1em', color: 'var(--ink-3)', marginBottom: 8 }}>
              DECODED
            </div>
            <div style={{ background: 'var(--paper)', border: '1px solid var(--rule)', minHeight: 400 }}>
              {decodedLines.map((d, i) => (
                <DecodedLine key={i} d={d} />
              ))}
            </div>
          </div>
        </div>
      </div>
    );
  }

  function DecodedLine({ d }) {
    if (!d.line.trim()) return <div style={{ height: 26 }} />;
    if (!d.changed) {
      return (
        <div style={{ padding: '6px 14px', fontSize: 13, fontFamily: 'ui-monospace, monospace', color: 'var(--ink-3)', borderBottom: '1px solid var(--rule-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
          <span className="ff-mono" style={{ fontSize: 9, color: '#3a8a52', letterSpacing: '.08em', flexShrink: 0 }}>CLEAN</span>
          <span style={{ color: 'var(--ink-2)' }}>{d.line}</span>
        </div>
      );
    }
    const conf = Math.round(d.confidence * 100);
    const tone = conf >= 85 ? '#3a8a52' : conf >= 60 ? '#c79538' : '#a04432';
    return (
      <div style={{ padding: '8px 14px', borderBottom: '1px solid var(--rule-soft)' }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
          <span className="ff-mono" style={{ fontSize: 9, color: tone, letterSpacing: '.08em', fontWeight: 600, flexShrink: 0 }}>FIXED</span>
          <span className="ff-mono" style={{ fontSize: 9, color: 'var(--ink-3)' }}>{d.encoding}</span>
          <span className="ff-mono num" style={{ fontSize: 9, color: tone, marginLeft: 'auto' }}>{conf}%</span>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 3, fontFamily: 'ui-monospace, monospace', fontSize: 13 }}>
          <div style={{ color: 'var(--ink-3)', textDecoration: 'line-through' }}>{d.line}</div>
          <div style={{ color: 'var(--ink)', fontWeight: 500 }}>{d.output}</div>
        </div>
        {d.candidates.length > 1 && (
          <details style={{ marginTop: 6 }}>
            <summary className="ff-mono upper" style={{ fontSize: 9, color: 'var(--ink-3)', letterSpacing: '.08em', cursor: 'pointer' }}>
              {d.candidates.length - 1} other candidate{d.candidates.length > 2 ? 's' : ''}
            </summary>
            <div style={{ marginTop: 4, fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>
              {d.candidates.slice(1).map((c, i) => (
                <div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', color: 'var(--ink-3)' }}>
                  <span style={{ flex: 1 }}>{c.text}</span>
                  <span className="ff-mono" style={{ fontSize: 9 }}>{c.encoding}</span>
                  <span className="ff-mono num" style={{ fontSize: 9, width: 30, textAlign: 'right' }}>{c.score.toFixed(0)}</span>
                </div>
              ))}
            </div>
          </details>
        )}
      </div>
    );
  }

  // ── Catalog scan tab ───────────────────────────────────────────────
  function Catalog({ scanResults, filtered, counts, selected, toggleSel, selectAll, applySelected, filter, setFilter, kinds, runScan, go }) {
    if (!scanResults) {
      return <div style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>Scanning catalog…</div>;
    }
    const totalConfidence = filtered.length
      ? Math.round(filtered.reduce((s, [r]) => s + r.confidence, 0) / filtered.length * 100)
      : 0;
    const selLoss = selected.size;
    return (
      <div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', borderTop: '1px solid var(--rule)', borderBottom: '1px solid var(--rule)', marginBottom: 18 }}>
          <KpiCell label="CORRUPTED FIELDS" value={scanResults.length} tone={scanResults.length ? 'warn' : 'ok'} />
          <KpiCell label="UNIQUE ENTITIES" value={new Set(scanResults.map(r => r.kind + ':' + r.id)).size} />
          <KpiCell label="HIGH-CONFIDENCE FIXES" value={scanResults.filter(r => r.confidence >= 0.85).length} tone="ok" />
          <KpiCell label="NEEDS REVIEW" value={scanResults.filter(r => r.confidence < 0.85).length} tone={scanResults.filter(r => r.confidence < 0.85).length ? 'warn' : null} />
        </div>

        {/* filter chips */}
        <div style={{ display: 'flex', gap: 8, marginBottom: 14, alignItems: 'center', flexWrap: 'wrap' }}>
          <span className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.1em', color: 'var(--ink-3)', marginRight: 4 }}>FILTER</span>
          {kinds.map(k => {
            const c = counts[k.v] || 0;
            if (c === 0 && k.v !== 'all') return null;
            return (
              <button key={k.v} onClick={() => setFilter(k.v)} className="ff-mono upper" style={{
                padding: '4px 10px', fontSize: 10, letterSpacing: '.06em',
                background: filter === k.v ? 'var(--ink)' : 'transparent',
                color: filter === k.v ? 'var(--bg)' : 'var(--ink-2)',
                border: '1px solid var(--rule)', cursor: 'pointer',
              }}>{k.l} · {c}</button>
            );
          })}
          <span style={{ flex: 1 }} />
          <button onClick={runScan} className="ff-mono upper" style={{ padding: '4px 10px', fontSize: 10, background: 'transparent', border: '1px solid var(--rule)', cursor: 'pointer', letterSpacing: '.06em' }}>RE-SCAN</button>
        </div>

        {/* bulk action bar */}
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          padding: '12px 16px', background: selected.size ? 'var(--ink)' : 'var(--bg-2)',
          color: selected.size ? 'var(--bg)' : 'var(--ink-2)',
          border: '1px solid var(--ink)', marginBottom: 0,
        }}>
          <div className="ff-mono upper" style={{ fontSize: 11, letterSpacing: '.08em' }}>
            {selected.size
              ? <>{selected.size} FIX{selected.size > 1 ? 'ES' : ''} SELECTED · AVG {totalConfidence}% CONFIDENT</>
              : <>{filtered.length} CORRUPTED FIELD{filtered.length === 1 ? '' : 'S'} · AVG {totalConfidence}% CONFIDENT</>}
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            {!selected.size && (
              <button onClick={selectAll} className="ff-mono upper" style={{ padding: '6px 12px', fontSize: 10, letterSpacing: '.06em', background: 'transparent', border: '1px solid var(--rule)', color: 'var(--ink-2)', cursor: 'pointer' }}>SELECT ALL VISIBLE</button>
            )}
            {selected.size > 0 && (
              <>
                <button onClick={selectAll} className="ff-mono upper" style={{ padding: '6px 12px', fontSize: 10, letterSpacing: '.06em', background: 'transparent', border: '1px solid var(--bg)', color: 'var(--bg)', cursor: 'pointer' }}>+ ALL VISIBLE</button>
                <button onClick={applySelected} className="ff-mono upper" style={{ padding: '6px 14px', fontSize: 10, letterSpacing: '.06em', background: 'var(--bg)', border: '1px solid var(--bg)', color: 'var(--ink)', cursor: 'pointer', fontWeight: 600 }}>APPLY {selLoss} FIX{selLoss > 1 ? 'ES' : ''}</button>
              </>
            )}
          </div>
        </div>

        {/* list */}
        <div style={{ border: '1px solid var(--rule)', borderTop: 0 }}>
          {/* header */}
          <div style={{ display: 'grid', gridTemplateColumns: '32px 110px 1.6fr 1.6fr 90px 110px 60px', alignItems: 'center', padding: '8px 14px', background: 'var(--bg-2)', borderBottom: '1px solid var(--rule)' }}>
            <div />
            <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>Entity</div>
            <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>Before</div>
            <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>After</div>
            <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>Encoding</div>
            <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>Confidence</div>
            <div />
          </div>
          {filtered.length === 0 && (
            <div style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)', fontSize: 13 }}>
              No corrupted fields found. Catalog is clean.
            </div>
          )}
          {filtered.map(([r, idx], i) => {
            const conf = Math.round(r.confidence * 100);
            const tone = conf >= 85 ? '#3a8a52' : conf >= 60 ? '#c79538' : '#a04432';
            const isSel = selected.has(idx);
            return (
              <div key={idx} onClick={() => toggleSel(idx)} style={{
                display: 'grid', gridTemplateColumns: '32px 110px 1.6fr 1.6fr 90px 110px 60px',
                alignItems: 'center', padding: '10px 14px',
                borderBottom: '1px solid var(--rule-soft)', cursor: 'pointer',
                background: isSel ? 'rgba(58,138,82,.06)' : (i % 2 ? 'var(--paper)' : 'var(--bg)'),
              }}>
                <div style={{ display: 'flex', alignItems: 'center' }}>
                  <input type="checkbox" checked={isSel} onChange={() => {}} style={{ accentColor: 'var(--ink)' }} />
                </div>
                <div>
                  <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>{r.kind}</div>
                  <div className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>· {r.field}</div>
                </div>
                <div style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12, color: 'var(--ink-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.before}</div>
                <div style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12, color: 'var(--ink)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.after}</div>
                <div className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{r.encoding}</div>
                <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                  <div style={{ flex: 1, height: 4, background: 'var(--bg-2)', position: 'relative' }}>
                    <div style={{ position: 'absolute', inset: 0, width: conf + '%', background: tone }} />
                  </div>
                  <span className="ff-mono num" style={{ fontSize: 10, color: tone, fontWeight: 600, minWidth: 28, textAlign: 'right' }}>{conf}%</span>
                </div>
                <div style={{ textAlign: 'right' }}>
                  <button onClick={e => { e.stopPropagation(); go && go(r.kind, { id: r.id }); }}
                    className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.06em', padding: '4px 8px', background: 'transparent', border: '1px solid var(--rule)', color: 'var(--ink-2)', cursor: 'pointer' }}>OPEN</button>
                </div>
              </div>
            );
          })}
        </div>
      </div>
    );
  }

  // ── Glyph reference tab ────────────────────────────────────────────
  function PairsTable() {
    const E2 = E();
    if (!E2) return null;
    const groups = { macroman: [], cp1252: [] };
    for (const p of E2.knownPairs) {
      if (groups[p.via]) groups[p.via].push(p);
    }
    return (
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 28 }}>
        {(['macroman', 'cp1252']).map(key => (
          <div key={key}>
            <div className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.12em', color: 'var(--ink-3)', marginBottom: 10 }}>
              {key === 'macroman' ? 'MAC ROMAN' : 'WINDOWS CP-1252'} · UTF-8 BYTES MIS-DECODED AS …
            </div>
            <div style={{ border: '1px solid var(--rule)', background: 'var(--paper)' }}>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 60px', padding: '8px 14px', background: 'var(--bg-2)', borderBottom: '1px solid var(--rule)' }}>
                <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>Corrupted</div>
                <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)' }}>Should be</div>
                <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.08em', color: 'var(--ink-3)', textAlign: 'right' }}>Hex</div>
              </div>
              {groups[key].map((p, i) => (
                <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 60px', padding: '7px 14px', borderBottom: '1px solid var(--rule-soft)', alignItems: 'center' }}>
                  <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 14, color: '#a04432' }}>{p.bad}</span>
                  <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 14, color: '#3a8a52', fontWeight: 500 }}>{p.good}</span>
                  <span className="ff-mono num" style={{ fontSize: 9, color: 'var(--ink-3)', textAlign: 'right' }}>
                    U+{p.good.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}
                  </span>
                </div>
              ))}
            </div>
          </div>
        ))}
      </div>
    );
  }

  // ── How it works tab ───────────────────────────────────────────────
  function HowItWorks() {
    return (
      <div style={{ maxWidth: 760, fontSize: 14, lineHeight: 1.65 }}>
        <h3 style={{ fontSize: 18, fontWeight: 500, marginTop: 0, marginBottom: 14 }}>The double-decode bug</h3>
        <p style={{ color: 'var(--ink-2)' }}>
          Almost every encoding-corruption case in the catalog comes from the same bug: a system
          serialized text as UTF-8 (correctly), then the next system in the chain interpreted
          those bytes as a single-byte legacy codepage — usually <strong>Mac OS Roman</strong>
          (legacy iTunes, pre-2010 Mac DAW metadata) or <strong>Windows CP-1252</strong> (Excel
          exports, ancient SQL Servers, FTP'd text files without a BOM).
        </p>
        <div style={{ background: 'var(--paper)', border: '1px solid var(--rule)', padding: 16, marginBottom: 16, fontFamily: 'ui-monospace, monospace', fontSize: 12, lineHeight: 1.7 }}>
          <div style={{ color: 'var(--ink-3)' }}>// "ú" in three encodings:</div>
          <div>UTF-8 bytes:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;0xC3 0xBA</div>
          <div>as CP-1252:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Ã + º&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;→ "Tú" becomes "Tú"</div>
          <div>as MacRoman:&nbsp;&nbsp;&nbsp;&nbsp;√ + ∫&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;→ "Tú" becomes "T√∫"</div>
        </div>
        <h3 style={{ fontSize: 18, fontWeight: 500, marginTop: 24, marginBottom: 14 }}>How we recover</h3>
        <ol style={{ color: 'var(--ink-2)', paddingLeft: 22 }}>
          <li>Re-encode the corrupted string as bytes using the codepage we suspect was used to mis-decode it (Mac Roman, CP-1252, Latin-1).</li>
          <li>Re-decode those bytes as UTF-8.</li>
          <li>Score the result: reward valid Romance-language diacritics (ñ, á, é, í, ó, ú, ç, ã); penalize math symbols, replacement chars, and signature mojibake markers (Ã©, â€™).</li>
          <li>Fall back to a hand-curated <strong>known-pairs</strong> table when scores tie, or when the corruption is too short to score reliably.</li>
        </ol>
        <h3 style={{ fontSize: 18, fontWeight: 500, marginTop: 24, marginBottom: 14 }}>Where it runs</h3>
        <ul style={{ color: 'var(--ink-2)', paddingLeft: 22 }}>
          <li><strong>Workshop</strong> — paste any text and inspect the candidate decodings side-by-side, with confidence scores.</li>
          <li><strong>Catalog scan</strong> — sweeps every title, artist, label, and party name in the loaded catalog and proposes batch fixes.</li>
          <li><strong>Bulk imports</strong> — pre-flight check on incoming statement and DSP-feed files surfaces corruption before it lands in the catalog.</li>
        </ul>
      </div>
    );
  }

  // ── shared KPI cell ────────────────────────────────────────────────
  function KpiCell({ label, value, sub, tone }) {
    const c = tone === 'ok' ? '#3a8a52' : tone === 'warn' ? '#c79538' : tone === 'bad' ? '#a04432' : 'var(--ink)';
    return (
      <div style={{ padding: '14px 18px', borderRight: '1px solid var(--rule-soft)' }}>
        <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.1em', color: 'var(--ink-3)', fontWeight: 600 }}>{label}</div>
        <div style={{ fontSize: 26, fontWeight: 300, marginTop: 4, color: c, fontVariantNumeric: 'tabular-nums' }}>{value}</div>
        {sub && <div className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 2 }}>{sub}</div>}
      </div>
    );
  }

  Object.assign(window, { ScreenMojibake });
})();
