/* global */
/* ============================================================
   converters.jsx — pure conversion functions
   - CLI → Markdown
   - Markdown rendering (to HTML)
   - PDF text extraction (uses pdf.js, loaded lazily)
   - PDF table extraction
   - DOCX → Markdown (uses mammoth, loaded lazily)
   - Excel → HTML/PDF (uses SheetJS)
   - OCR (uses tesseract.js, loaded lazily)
   ============================================================ */

// ---------- CLI → MARKDOWN ----------

const CLI_EMOJI_RE = /^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u{2300}-\u{23FF}\u{2B00}-\u{2BFF}]\uFE0F?)\s+/u;
const CLI_BOX_CHARS = /[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║┏┓┗┛┣┫┳┻╋━┃]/;
const CLI_HSEP_CHARS = /[─━═]/;

function _isTableContentRow(line) { return /[│║┃]/.test(line) && !CLI_HSEP_CHARS.test(line); }
function _isAnyTableLine(line)    { return CLI_BOX_CHARS.test(line); }

function _splitTableRow(line) {
  const cells = line.split(/[│║┃]/).map(c => c.trim());
  if (cells.length && cells[0] === '') cells.shift();
  if (cells.length && cells[cells.length - 1] === '') cells.pop();
  return cells;
}

function _convertCliTable(tableLines) {
  const contentRows = tableLines.filter(_isTableContentRow).map(_splitTableRow);
  if (contentRows.length === 0) return tableLines.join('\n');
  const cols = Math.max(...contentRows.map(r => r.length));
  contentRows.forEach(r => { while (r.length < cols) r.push(''); });
  const [header, ...body] = contentRows;
  const lines = [];
  lines.push('| ' + header.join(' | ') + ' |');
  lines.push('|' + header.map(() => ' --- ').join('|') + '|');
  for (const row of body) lines.push('| ' + row.join(' | ') + ' |');
  return lines.join('\n');
}

function _looksLikeCode(t) {
  if (!t) return false;
  if (/^[a-zA-Z_][\w.]*__[\w]+/.test(t)) return true;
  if (/^[a-zA-Z_][\w.]*\s*\(.*\)\s*$/.test(t)) return true;
  if (/^(npm|pnpm|yarn|bun|git|cd|ls|cat|curl|cargo|python|node|pip|brew|docker|gitnexus|mcp__|sudo|rm|cp|mv)\b/.test(t)) return true;
  if (/^\$\s+/.test(t)) return true;
  return false;
}

function _stripIndent(line) {
  const m = line.match(/^(\s*)(.*)$/);
  return { indent: m[1].length, body: m[2] };
}

function convertCliToMarkdown(input, opts = {}) {
  const {
    headerStyle = 'auto',
    stripEmojis = false,
    fenceCode = true,
    tightLists = true,
  } = opts;

  if (!input || !input.trim()) return '';

  const rawLines = input.replace(/\r\n?/g, '\n').split('\n');
  const indents = rawLines.filter(l => l.trim().length > 0).map(l => l.match(/^\s*/)[0].length);
  const minIndent = indents.length ? Math.min(...indents) : 0;
  const lines = rawLines.map(l => l.length >= minIndent ? l.slice(minIndent) : l);

  const out = [];
  let i = 0;
  let firstHeaderUsed = false;

  const pushBlank = () => {
    if (out.length === 0) return;
    if (out[out.length - 1] === '') return;
    out.push('');
  };

  while (i < lines.length) {
    const line = lines[i];
    const { body } = _stripIndent(line);

    if (!body) { pushBlank(); i++; continue; }

    // Table block
    if (_isAnyTableLine(line)) {
      const tableLines = [];
      while (i < lines.length && _isAnyTableLine(lines[i])) {
        if (lines[i].trim()) tableLines.push(lines[i]);
        i++;
      }
      pushBlank();
      out.push(_convertCliTable(tableLines));
      pushBlank();
      continue;
    }

    const emojiMatch = body.match(CLI_EMOJI_RE);
    const isShortLine = body.length < 80;
    const looksLikeHeader = emojiMatch && isShortLine && !body.endsWith('.') && !body.includes('```');

    if (looksLikeHeader && headerStyle !== 'none') {
      const text = stripEmojis ? body.replace(CLI_EMOJI_RE, '') : body;
      pushBlank();
      if (headerStyle === 'bold') {
        out.push(`**${text}**`);
      } else {
        const level = firstHeaderUsed ? 3 : 2;
        out.push('#'.repeat(level) + ' ' + text);
        firstHeaderUsed = true;
      }
      pushBlank();
      i++;
      continue;
    }

    if (/^[-*•·]\s+/.test(body)) {
      const cleaned = body.replace(/^[•·]\s+/, '- ').replace(/^\*\s+/, '- ');
      out.push(cleaned);
      i++;
      if (tightLists) {
        while (i < lines.length && !lines[i].trim() && i + 1 < lines.length && /^\s*[-*•·]\s+/.test(lines[i + 1])) i++;
      }
      continue;
    }

    if (fenceCode && _looksLikeCode(body)) {
      const codeLines = [];
      while (i < lines.length) {
        const cur = lines[i];
        const t = cur.trim();
        if (!t) {
          if (i + 1 < lines.length && _looksLikeCode(lines[i + 1].trim())) {
            codeLines.push(''); i++; continue;
          }
          break;
        }
        if (_looksLikeCode(t)) { codeLines.push(t); i++; } else break;
      }
      pushBlank();
      out.push('```');
      out.push(...codeLines);
      out.push('```');
      pushBlank();
      continue;
    }

    let text = body;
    if (stripEmojis) text = text.replace(CLI_EMOJI_RE, '');
    out.push(text);
    i++;
  }

  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}

// ---------- CHAT → MARKDOWN ----------
//
// Designed for "free-form chat text with structural cues" — e.g. pasted from
// claude.ai web chat, ChatGPT, or anywhere people write prose with bare-line
// section titles, bullets like "key: value →", and inline <placeholder>
// patterns. Different from CLI→Markdown (which expects emoji-led headers,
// box-drawing tables, and tool calls).
//
// Heuristics:
//   1. Strip common leading indentation across the whole input.
//   2. First non-empty short bare line (no terminal punctuation, < 40 chars,
//      no bullet/marker prefix) → `# H1` (if h1FromFirstLine).
//   3. Subsequent short bare lines isolated by blank lines and followed by
//      another block → `## H2` (if h2FromBareLines).
//   4. Bullets (`-` / `*` / `•` / `·`) → normalized to `- `.
//   5. Inline `key: value →` or `key = value` patterns → wrap the key (or
//      key:value) in backticks (if inlineCodePairs).
//   6. Standalone non-bullet line of the form `<prose>：<placeholder>...`
//      where `<placeholder>` contains `<…>` segments → split into prose-line
//      ending with `：` + a ```text fenced block (if fencePlaceholders).
//   7. Tight lists: collapse blanks between consecutive bullets.

const CHAT_KV_ARROW_RE = /^([A-Za-z_][A-Za-z0-9_.-]*\s*:\s*[^\s→][^→]*?)\s*(→|->|=>)\s*(.+)$/;
const CHAT_IDENT_ARROW_RE = /^([A-Za-z_][A-Za-z0-9_.-]*)\s+(→|->|=>)\s*(.+)$/;
const CHAT_KV_EQUALS_RE = /^(\S{1,15})\s*=\s*(.+)$/;
// Detects "<prose> <placeholder-pattern>：" — placeholder comes BEFORE the colon
const CHAT_PLACEHOLDER_TAIL_RE = /^(.+?)\s+((?:[\w-]*<[^>\s]+>)+[\w-]*)\s*[：:]\s*$/;
const CHAT_HEADING_TERMINAL = /[.。!！?？:：;；,，]$/;

function _chatLooksLikeHeading(text) {
  const t = text.trim();
  if (!t) return false;
  if (t.length > 40) return false;
  if (/^[-*•·]\s+/.test(t)) return false;
  if (/^#{1,6}\s/.test(t)) return false;
  if (CHAT_HEADING_TERMINAL.test(t)) return false;
  return true;
}

function _chatTransformInline(text, opts) {
  let s = text;
  if (opts.inlineCodePairs !== false) {
    // Priority: key:value → / identifier → / key = value
    let m = s.match(CHAT_KV_ARROW_RE);
    if (m) { s = '`' + m[1].trim() + '` ' + m[2] + ' ' + m[3]; return s; }
    m = s.match(CHAT_IDENT_ARROW_RE);
    if (m) { s = '`' + m[1] + '` ' + m[2] + ' ' + m[3]; return s; }
    m = s.match(CHAT_KV_EQUALS_RE);
    if (m) { s = '`' + m[1] + '` = ' + m[2]; return s; }
  }
  return s;
}

function _chatSplitToBlocks(lines) {
  const blocks = [];
  let cur = [];
  for (const l of lines) {
    if (l.trim() === '') {
      if (cur.length) { blocks.push(cur); cur = []; }
    } else {
      cur.push(l);
    }
  }
  if (cur.length) blocks.push(cur);
  return blocks;
}

function convertChatToMarkdown(input, opts = {}) {
  const {
    h1FromFirstLine = true,
    h2FromBareLines = true,
    inlineCodePairs = true,
    fencePlaceholders = true,
    tightLists = true,
  } = opts;
  if (!input || !input.trim()) return '';

  const raw = input.replace(/\r\n?/g, '\n').split('\n');
  const indents = raw.filter(l => l.trim().length > 0).map(l => l.match(/^\s*/)[0].length);
  const minIndent = indents.length ? Math.min(...indents) : 0;
  const lines = raw.map(l => l.length >= minIndent ? l.slice(minIndent) : l);

  const blocks = _chatSplitToBlocks(lines);
  const out = [];
  let h1Used = false;

  const pushBlank = () => { if (out.length && out[out.length - 1] !== '') out.push(''); };

  for (let bi = 0; bi < blocks.length; bi++) {
    const block = blocks[bi];

    // Single-line block — candidate for heading or placeholder split
    if (block.length === 1) {
      const raw = block[0];
      const text = raw.trim();

      if (/^#{1,6}\s/.test(text) || text.startsWith('```')) {
        pushBlank(); out.push(text); pushBlank(); continue;
      }

      // "<prose> <placeholder-pattern>：" split → prose ends with "：" then fence
      if (fencePlaceholders) {
        const pm = text.match(CHAT_PLACEHOLDER_TAIL_RE);
        if (pm) {
          pushBlank();
          out.push(pm[1].trim() + '：');
          out.push('');
          out.push('```text');
          out.push(pm[2].trim());
          out.push('```');
          pushBlank();
          continue;
        }
      }

      // First-line H1
      if (h1FromFirstLine && !h1Used && _chatLooksLikeHeading(text)) {
        out.push('# ' + text); pushBlank(); h1Used = true; continue;
      }
      // Subsequent isolated short line → H2 (only if not the last block)
      if (h2FromBareLines && _chatLooksLikeHeading(text) && bi + 1 < blocks.length) {
        pushBlank(); out.push('## ' + text); pushBlank(); continue;
      }
      // Otherwise plain paragraph
      out.push(_chatTransformInline(text, opts));
      pushBlank();
      continue;
    }

    // Multi-line block: classify line by line
    let inList = false;
    for (const rawLine of block) {
      const t = rawLine.trim();
      if (/^#{1,6}\s/.test(t) || t.startsWith('```')) {
        if (inList && !tightLists) out.push('');
        out.push(t);
        inList = false;
        continue;
      }
      if (/^[-*•·]\s+/.test(t)) {
        const body = t.replace(/^[-*•·]\s+/, '');
        out.push('- ' + _chatTransformInline(body, opts));
        inList = true;
        continue;
      }
      // Indented continuation of a previous bullet — emit as continuation
      if (inList && /^\s{2,}\S/.test(rawLine)) {
        out.push('  ' + _chatTransformInline(t, opts));
        continue;
      }
      // Plain prose line inside multi-line block
      if (inList) { pushBlank(); inList = false; }
      // Try the placeholder-tail split here too
      if (fencePlaceholders) {
        const pm = t.match(CHAT_PLACEHOLDER_TAIL_RE);
        if (pm) {
          out.push(pm[1].trim() + '：');
          out.push('');
          out.push('```text');
          out.push(pm[2].trim());
          out.push('```');
          pushBlank();
          continue;
        }
      }
      out.push(_chatTransformInline(t, opts));
    }
    pushBlank();
  }

  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}

// ---------- Markdown rendering (mini renderer + GFM-ish output style) ----------

function escapeHtml(str) {
  return str.replace(/[&<>"']/g, c => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  }[c]));
}

function renderInline(text) {
  let s = escapeHtml(text);
  // links [text](url)
  s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
  // images ![alt](url)
  s = s.replace(/!<a[^>]*>([^<]*)<\/a>/g, (m) => m); // no-op; placeholder if needed
  // code
  s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
  // bold
  s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
  // italic
  s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
  // strikethrough
  s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
  return s;
}

function renderMarkdown(md) {
  if (!md) return '';
  const lines = md.split('\n');
  const out = [];
  let i = 0;
  while (i < lines.length) {
    const line = lines[i];
    // code fence
    if (/^```/.test(line)) {
      const code = []; i++;
      while (i < lines.length && !/^```/.test(lines[i])) { code.push(escapeHtml(lines[i])); i++; }
      i++;
      out.push(`<pre><code>${code.join('\n')}</code></pre>`);
      continue;
    }
    // hr
    if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) { out.push('<hr />'); i++; continue; }
    // table
    if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s\-:|]+\|\s*$/.test(lines[i + 1])) {
      const header = line.split('|').slice(1, -1).map(c => c.trim());
      i += 2;
      const rows = [];
      while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) {
        rows.push(lines[i].split('|').slice(1, -1).map(c => c.trim()));
        i++;
      }
      let html = '<table><thead><tr>';
      header.forEach(h => html += `<th>${renderInline(h)}</th>`);
      html += '</tr></thead><tbody>';
      rows.forEach(r => {
        html += '<tr>';
        r.forEach(c => html += `<td>${renderInline(c)}</td>`);
        html += '</tr>';
      });
      html += '</tbody></table>';
      out.push(html);
      continue;
    }
    // headings
    const h = line.match(/^(#{1,6})\s+(.*)$/);
    if (h) { out.push(`<h${h[1].length}>${renderInline(h[2])}</h${h[1].length}>`); i++; continue; }
    // blockquote
    if (/^>\s*/.test(line)) {
      const block = [];
      while (i < lines.length && /^>\s*/.test(lines[i])) {
        block.push(lines[i].replace(/^>\s?/, '')); i++;
      }
      out.push(`<blockquote>${renderInline(block.join(' '))}</blockquote>`);
      continue;
    }
    // unordered list
    if (/^[-*+]\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^[-*+]\s+/.test(lines[i])) {
        items.push(lines[i].replace(/^[-*+]\s+/, '')); i++;
      }
      out.push('<ul>' + items.map(it => `<li>${renderInline(it)}</li>`).join('') + '</ul>');
      continue;
    }
    // ordered list
    if (/^\d+\.\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
        items.push(lines[i].replace(/^\d+\.\s+/, '')); i++;
      }
      out.push('<ol>' + items.map(it => `<li>${renderInline(it)}</li>`).join('') + '</ol>');
      continue;
    }
    if (!line.trim()) { i++; continue; }
    const para = [line]; i++;
    while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|[-*+]\s|\d+\.\s|>|\|)/.test(lines[i])) {
      para.push(lines[i]); i++;
    }
    out.push(`<p>${renderInline(para.join(' '))}</p>`);
  }
  return out.join('\n');
}

// ---------- HTML → Markdown (for DOCX conversion) ----------

function htmlToMarkdown(html) {
  const container = document.createElement('div');
  container.innerHTML = html;

  function walk(node, ctx = {}) {
    if (node.nodeType === Node.TEXT_NODE) {
      return node.nodeValue.replace(/\s+/g, ' ');
    }
    if (node.nodeType !== Node.ELEMENT_NODE) return '';
    const tag = node.tagName.toLowerCase();
    const kids = Array.from(node.childNodes).map(n => walk(n, ctx)).join('');
    switch (tag) {
      case 'h1': return `\n\n# ${kids.trim()}\n\n`;
      case 'h2': return `\n\n## ${kids.trim()}\n\n`;
      case 'h3': return `\n\n### ${kids.trim()}\n\n`;
      case 'h4': return `\n\n#### ${kids.trim()}\n\n`;
      case 'h5': return `\n\n##### ${kids.trim()}\n\n`;
      case 'h6': return `\n\n###### ${kids.trim()}\n\n`;
      case 'p':  return `\n\n${kids.trim()}\n\n`;
      case 'br': return '\n';
      case 'hr': return '\n\n---\n\n';
      case 'strong':
      case 'b':  return `**${kids}**`;
      case 'em':
      case 'i':  return `*${kids}*`;
      case 'u':  return `<u>${kids}</u>`;
      case 'del':
      case 's':  return `~~${kids}~~`;
      case 'code': return `\`${kids}\``;
      case 'pre': return `\n\n\`\`\`\n${node.textContent.trimEnd()}\n\`\`\`\n\n`;
      case 'a': {
        const href = node.getAttribute('href') || '';
        return `[${kids}](${href})`;
      }
      case 'img': {
        const src = node.getAttribute('src') || '';
        const alt = node.getAttribute('alt') || '';
        return `![${alt}](${src})`;
      }
      case 'blockquote': {
        const inner = kids.trim().split('\n').map(l => `> ${l}`).join('\n');
        return `\n\n${inner}\n\n`;
      }
      case 'ul':
      case 'ol': {
        const isOrdered = tag === 'ol';
        const items = Array.from(node.children).filter(c => c.tagName.toLowerCase() === 'li');
        const lines = items.map((li, idx) => {
          const itemText = walk(li, { ...ctx, inList: true }).trim().replace(/\n+/g, ' ');
          return `${isOrdered ? `${idx + 1}.` : '-'} ${itemText}`;
        });
        return `\n${lines.join('\n')}\n\n`;
      }
      case 'li': return kids;
      case 'table': {
        const rows = Array.from(node.querySelectorAll('tr'));
        if (!rows.length) return '';
        const headerCells = Array.from(rows[0].children).map(c => c.textContent.trim().replace(/\s+/g, ' '));
        const bodyRows = rows.slice(1).map(tr => Array.from(tr.children).map(c => c.textContent.trim().replace(/\s+/g, ' ')));
        let md = '\n\n| ' + headerCells.join(' | ') + ' |\n';
        md += '|' + headerCells.map(() => ' --- ').join('|') + '|\n';
        bodyRows.forEach(r => { md += '| ' + r.join(' | ') + ' |\n'; });
        return md + '\n';
      }
      case 'tr':
      case 'td':
      case 'th':
      case 'thead':
      case 'tbody': return kids;
      default: return kids;
    }
  }

  let md = walk(container);
  md = md.replace(/\n{3,}/g, '\n\n').trim() + '\n';
  return md;
}

// ---------- PDF parsing (pdf.js) ----------

let _pdfjsReady = null;
const PDFJS_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
const PDFJS_WORKER = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';

function loadPdfJs() {
  if (_pdfjsReady) return _pdfjsReady;
  _pdfjsReady = loadScript(PDFJS_URL).then(() => {
    if (window.pdfjsLib) {
      window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER;
    }
    return window.pdfjsLib;
  });
  return _pdfjsReady;
}

async function extractPdfTextItems(file, onProgress) {
  const pdfjsLib = await loadPdfJs();
  const buf = await readFileAsArrayBuffer(file);
  const doc = await pdfjsLib.getDocument({ data: buf }).promise;
  const pages = [];
  for (let p = 1; p <= doc.numPages; p++) {
    if (onProgress) onProgress(p, doc.numPages);
    const page = await doc.getPage(p);
    const content = await page.getTextContent();
    // Each item: { str, transform: [a,b,c,d,e,f], width, height, fontName }
    const items = content.items.map(it => ({
      str: it.str,
      x: it.transform[4],
      y: it.transform[5],
      width: it.width,
      height: it.height || Math.abs(it.transform[3]),
      fontName: it.fontName,
    }));
    pages.push({ pageNum: p, items });
  }
  return pages;
}

// Group text items into lines by Y coordinate
function groupItemsIntoLines(items) {
  if (!items.length) return [];
  // Sort by y descending (pdf coordinates increase upward), then x
  const sorted = [...items].sort((a, b) => (b.y - a.y) || (a.x - b.x));
  const lines = [];
  const Y_TOL = 3;
  for (const it of sorted) {
    const last = lines[lines.length - 1];
    if (last && Math.abs(last.y - it.y) <= Y_TOL) {
      last.items.push(it);
      last.y = (last.y + it.y) / 2;
    } else {
      lines.push({ y: it.y, items: [it] });
    }
  }
  // Sort each line's items by x
  lines.forEach(l => l.items.sort((a, b) => a.x - b.x));
  // Join with spaces (adding spaces if x gap is large)
  return lines.map(l => {
    let text = '';
    let lastEnd = null;
    for (const it of l.items) {
      if (lastEnd != null) {
        const gap = it.x - lastEnd;
        if (gap > it.height * 0.5) text += ' ';
      }
      text += it.str;
      lastEnd = it.x + it.width;
    }
    return { y: l.y, text: text.trim(), heightAvg: l.items.reduce((s, x) => s + x.height, 0) / l.items.length };
  }).filter(l => l.text);
}

async function pdfToMarkdown(file, opts = {}, onProgress) {
  const { pageBreaks = false, detectHeadings = true } = opts;
  const pages = await extractPdfTextItems(file, onProgress);

  // Compute heading threshold across all pages
  let allHeights = [];
  for (const p of pages) {
    const lines = groupItemsIntoLines(p.items);
    p._lines = lines;
    allHeights.push(...lines.map(l => l.heightAvg));
  }
  allHeights.sort((a, b) => a - b);
  const median = allHeights.length ? allHeights[Math.floor(allHeights.length / 2)] : 12;
  const h2thresh = median * 1.25;
  const h1thresh = median * 1.6;

  const out = [];
  for (let pi = 0; pi < pages.length; pi++) {
    const p = pages[pi];
    if (pi > 0 && pageBreaks) out.push('', '---', '');
    for (const line of p._lines) {
      const t = line.text;
      if (!t) continue;
      if (detectHeadings && line.heightAvg >= h1thresh) {
        out.push('', '# ' + t, '');
      } else if (detectHeadings && line.heightAvg >= h2thresh) {
        out.push('', '## ' + t, '');
      } else if (/^[•·●]\s/.test(t)) {
        out.push('- ' + t.replace(/^[•·●]\s*/, ''));
      } else if (/^\d+[\.、]\s/.test(t) && t.length < 80 && detectHeadings) {
        out.push('', '## ' + t, '');
      } else {
        out.push(t);
      }
    }
  }
  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}

// PDF → table data (heuristic)
async function pdfToTables(file, opts = {}, onProgress) {
  const { joinLines = true } = opts;
  const pages = await extractPdfTextItems(file, onProgress);
  const sheets = [];
  pages.forEach((p, idx) => {
    const lines = groupItemsIntoLines(p.items);
    // Try to detect columns by clustering X positions across items
    const allX = [];
    for (const l of lines) for (const it of l.items) allX.push(Math.round(it.x));
    if (!allX.length) return;
    // Cluster X positions
    allX.sort((a, b) => a - b);
    const clusters = [];
    const GAP = 12;
    for (const x of allX) {
      const last = clusters[clusters.length - 1];
      if (last && x - last.max <= GAP) {
        last.xs.push(x); last.max = x;
      } else {
        clusters.push({ xs: [x], max: x });
      }
    }
    // Use cluster medians as column starts
    const cols = clusters.map(c => c.xs[Math.floor(c.xs.length / 2)]).sort((a, b) => a - b);
    if (cols.length < 2) return; // not table-shaped
    // Build rows: for each line, assign items to nearest column
    const rows = lines.map(l => {
      const row = new Array(cols.length).fill('');
      for (const it of l.items) {
        // find best column
        let bestI = 0, bestD = Infinity;
        for (let i = 0; i < cols.length; i++) {
          const d = Math.abs(it.x - cols[i]);
          if (d < bestD) { bestD = d; bestI = i; }
        }
        row[bestI] = (row[bestI] ? row[bestI] + ' ' : '') + it.str;
      }
      return row.map(c => c.trim());
    }).filter(r => r.some(c => c));

    if (rows.length < 2) return;
    sheets.push({ name: `Page ${p.pageNum}`, rows });
  });
  return sheets;
}

function tableToCsv(rows) {
  return rows.map(r => r.map(cell => {
    const s = String(cell ?? '');
    if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
    return s;
  }).join(',')).join('\n');
}

// ---------- DOCX → MARKDOWN (mammoth) ----------

let _mammothReady = null;
function loadMammoth() {
  if (_mammothReady) return _mammothReady;
  _mammothReady = loadScript('https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.7.0/mammoth.browser.min.js')
    .then(() => window.mammoth);
  return _mammothReady;
}

async function docxToMarkdown(file) {
  const mammoth = await loadMammoth();
  const buf = await readFileAsArrayBuffer(file);
  const result = await mammoth.convertToHtml(
    { arrayBuffer: buf },
    {
      styleMap: [
        "p[style-name='Heading 1'] => h1:fresh",
        "p[style-name='Heading 2'] => h2:fresh",
        "p[style-name='Heading 3'] => h3:fresh",
        "p[style-name='Heading 4'] => h4:fresh",
        "p[style-name='Quote'] => blockquote:fresh",
        "p[style-name='Code'] => pre:fresh",
      ],
    }
  );
  return htmlToMarkdown(result.value);
}

async function docxToHtml(file) {
  const mammoth = await loadMammoth();
  const buf = await readFileAsArrayBuffer(file);
  const result = await mammoth.convertToHtml({ arrayBuffer: buf });
  return result.value;
}

// ---------- Excel (SheetJS) ----------

let _sheetjsReady = null;
function loadSheetJs() {
  if (_sheetjsReady) return _sheetjsReady;
  _sheetjsReady = loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js')
    .then(() => window.XLSX);
  return _sheetjsReady;
}

async function excelToSheets(file) {
  const XLSX = await loadSheetJs();
  const buf = await readFileAsArrayBuffer(file);
  const wb = XLSX.read(buf, { type: 'array' });
  const sheets = wb.SheetNames.map(name => {
    const ws = wb.Sheets[name];
    const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', raw: false });
    return { name, rows };
  });
  return sheets;
}

async function sheetsToXlsxBlob(sheets) {
  const XLSX = await loadSheetJs();
  const wb = XLSX.utils.book_new();
  for (const s of sheets) {
    const ws = XLSX.utils.aoa_to_sheet(s.rows);
    XLSX.utils.book_append_sheet(wb, ws, (s.name || 'Sheet').slice(0, 31));
  }
  const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
  return new Blob([out], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}

// ---------- OCR (Tesseract.js) ----------

let _tesseractReady = null;
function loadTesseract() {
  if (_tesseractReady) return _tesseractReady;
  _tesseractReady = loadScript('https://cdn.jsdelivr.net/npm/tesseract.js@5.0.5/dist/tesseract.min.js')
    .then(() => window.Tesseract);
  return _tesseractReady;
}

async function ocrImage(file, lang, onProgress) {
  const Tesseract = await loadTesseract();
  const worker = await Tesseract.createWorker(lang, 1, {
    logger: m => {
      if (onProgress && m.status === 'recognizing text') {
        onProgress(m.progress);
      }
    },
  });
  const dataUrl = await readFileAsDataURL(file);
  const { data } = await worker.recognize(dataUrl);
  await worker.terminate();
  return data.text;
}

// ---------- Export ----------

Object.assign(window, {
  convertCliToMarkdown,
  convertChatToMarkdown,
  renderMarkdown,
  htmlToMarkdown,
  pdfToMarkdown,
  pdfToTables,
  docxToMarkdown,
  docxToHtml,
  excelToSheets,
  sheetsToXlsxBlob,
  tableToCsv,
  ocrImage,
  loadPdfJs, loadMammoth, loadSheetJs, loadTesseract,
});
