/* global React */
/* ============================================================
   lib.jsx — shared utilities
   - i18n strings (zh / en)
   - theme (light / dark) with localStorage
   - history (recent conversions) with localStorage
   - hash router hook
   - dropzone hook
   - icons
   - file helpers
   ============================================================ */

const { useState, useEffect, useCallback, useRef, useMemo, createContext, useContext } = React;

/* ---------------------------- TOOLS METADATA ---------------------------- */

const TOOLS = [
  {
    id: 'cli-to-markdown',
    hue: 'cli',
    initials: 'CLI',
    nameKey: 'tool_cli_md',
    descKey: 'tool_cli_md_desc',
    badge: 'live',
    badgeKey: 'badge_live',
    inputs: ['paste'],
    accepts: '',
  },
  {
    id: 'chat-to-markdown',
    hue: 'chat',
    initials: 'CHAT',
    nameKey: 'tool_chat_md',
    descKey: 'tool_chat_md_desc',
    badge: 'live',
    badgeKey: 'badge_live',
    inputs: ['paste'],
    accepts: '',
  },
  {
    id: 'ai-to-markdown',
    hue: 'ai-md',
    initials: 'AI',
    nameKey: 'tool_ai_md',
    descKey: 'tool_ai_md_desc',
    badge: 'beta',
    badgeKey: 'badge_beta',
    inputs: ['paste', 'file'],
    accepts: '.txt,.md,.markdown,.log,text/plain',
  },
  {
    id: 'pdf-to-markdown',
    hue: 'pdf-md',
    initials: 'PDF',
    nameKey: 'tool_pdf_md',
    descKey: 'tool_pdf_md_desc',
    badge: 'live',
    badgeKey: 'badge_live',
    inputs: ['file'],
    accepts: 'application/pdf,.pdf',
  },
  {
    id: 'pdf-to-excel',
    hue: 'pdf-xlsx',
    initials: 'P→X',
    nameKey: 'tool_pdf_xlsx',
    descKey: 'tool_pdf_xlsx_desc',
    badge: 'live',
    badgeKey: 'badge_live',
    inputs: ['file'],
    accepts: 'application/pdf,.pdf',
  },
  {
    id: 'docx-to-markdown',
    hue: 'docx',
    initials: 'DOC',
    nameKey: 'tool_docx_md',
    descKey: 'tool_docx_md_desc',
    badge: 'live',
    badgeKey: 'badge_live',
    inputs: ['file'],
    accepts: '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  },
  {
    id: 'excel-to-pdf',
    hue: 'xlsx-pdf',
    initials: 'X→P',
    nameKey: 'tool_xlsx_pdf',
    descKey: 'tool_xlsx_pdf_desc',
    badge: 'live',
    badgeKey: 'badge_live',
    inputs: ['file'],
    accepts: '.xlsx,.xls,.csv',
  },
  {
    id: 'markdown-to-html',
    hue: 'md-html',
    initials: 'MD',
    nameKey: 'tool_md_html',
    descKey: 'tool_md_html_desc',
    badge: 'live',
    badgeKey: 'badge_live',
    inputs: ['paste', 'file'],
    accepts: '.md,.markdown,text/markdown,text/plain',
  },
  {
    id: 'image-to-text',
    hue: 'ocr',
    initials: 'OCR',
    nameKey: 'tool_ocr',
    descKey: 'tool_ocr_desc',
    badge: 'beta',
    badgeKey: 'badge_beta',
    inputs: ['file'],
    accepts: 'image/*',
  },
];

const TOOL_BY_ID = Object.fromEntries(TOOLS.map(t => [t.id, t]));

/* ---------------------------- i18n ---------------------------- */

const STRINGS = {
  zh: {
    brand: 'Convertly',
    brand_sub: 'DOC SUITE',
    nav_hub: '工具集',
    crumb_hub: '工具集',
    hub_eyebrow: '本地浏览器内运行 · 无需上传',
    hub_title_pre: '文档转换',
    hub_title_em: '工具套件',
    hub_sub: '把 Claude CLI 输出、PDF、Word、Excel、Markdown、图片之间互相转换。文件不离开你的设备 —— 一切在浏览器里完成。',
    hub_search_placeholder: '搜索工具…',
    section_tools: '所有工具',
    section_tools_meta: 'TOOLS',
    section_history: '最近转换',
    section_history_meta: 'HISTORY',
    history_empty: '还没有转换记录。从上方选一个工具开始吧。',
    history_clear: '清空',
    crumb_sep: '/',
    common_paste_input: '粘贴输入',
    common_input: '输入',
    common_output: '输出',
    common_preview: '预览',
    common_markdown: 'Markdown',
    common_html: 'HTML',
    common_copy: '复制',
    common_copied: '✓ 已复制',
    common_clear: '清空',
    common_download: '下载',
    common_load_sample: '加载示例',
    common_convert: '开始转换',
    common_options: '选项',
    common_ready: '就绪',
    common_busy: '处理中',
    common_error: '错误',
    common_chars: '字符',
    common_lines: '行',
    common_tables: '表格',
    common_pages: '页',
    common_files: '个文件',
    common_size: '大小',
    common_select_file: '选择文件',
    common_or_drop: '或拖拽到这里',
    common_drop_here: '松开以上传',
    common_browse: '浏览…',
    common_remove: '移除',
    common_run_again: '重新转换',
    common_rendered_preview: '渲染预览',
    common_source: '源码',
    common_in_notebook: '粘贴到笔记本中的效果',
    common_text: '文本',
    common_view_table: '表格',
    common_view_csv: 'CSV',

    // Tools — names & descriptions
    tool_cli_md: 'CLI → Markdown',
    tool_cli_md_desc: '把 Claude CLI 输出 (含表格、代码、emoji 标题) 转为干净 Markdown',
    tool_chat_md: 'Chat → Markdown',
    tool_chat_md_desc: '把 Claude/ChatGPT 网页版 chat 里的自然语言段落转为结构化 Markdown（识别标题、key:value、占位符）',
    opt_chat_h1: '首行 → H1',
    opt_chat_h2: '裸行 → H2',
    opt_chat_inline_code: 'key:value 自动 backtick',
    opt_chat_fence: '&lt;placeholder&gt; 自动 fence',
    chat_input_placeholder: '粘贴 chat 段落，例如带"Volterra"标题 + bullet list + &lt;placeholder&gt; 描述的文字…',

    // AI → Markdown
    tool_ai_md: 'AI → Markdown',
    tool_ai_md_desc: '让大模型（Ollama / OpenAI / Claude）理解你的文本，输出结构化 Markdown —— 突破固定规则',
    ai_input_placeholder: '粘贴任意文本，或拖入 .txt / .md / .log 文件，点「开始转换」让模型理解并补上 Markdown 结构…',
    ai_convert: '开始转换',
    ai_stop: '停止',
    ai_streaming: '生成中…',
    ai_provider: '供应商',
    ai_extra: '额外指令',
    ai_extra_ph: '可选，如「用中文标题」「保留 emoji」',
    ai_continue: '继续指示（基于上次结果精修）',
    ai_continue_ph: '如「把第二节改成表格」',
    ai_continue_btn: '继续精修',
    ai_settings: 'AI 设置',
    ai_tidy: 'AI 整理',
    ai_open_settings: '打开 AI 设置',
    ai_provider_ollama: 'Ollama 本地',
    ai_provider_openai: 'OpenAI 兼容',
    ai_provider_anthropic: 'Anthropic Claude',
    ai_set_title: 'AI 模型设置',
    ai_set_security: 'API Key 仅保存在你的浏览器本地（localStorage），不会发往 Convertly 服务器。托管使用时请视为已暴露。',
    ai_set_active: '当前使用',
    ai_set_transport: '连接方式',
    ai_set_transport_direct: '直连',
    ai_set_transport_proxy: '经服务器代理',
    ai_set_transport_proxy_na: '（代理仅在托管部署可用；Ollama 在本机，不适用）',
    ai_set_baseurl: 'Base URL',
    ai_set_apikey: 'API Key',
    ai_set_model: '模型',
    ai_set_loading_models: '加载模型列表…',
    ai_set_refresh_models: '刷新模型列表',
    ai_set_test: '测试连接',
    ai_set_testing: '测试中…',
    ai_set_test_ok: '✓ 连接成功',
    ai_set_test_fail: '✗ 失败',
    ai_set_save: '保存',
    ai_set_close: '关闭',
    ai_usage_tokens: 'tokens',
    ai_err_aborted: '已停止，保留已生成的部分。',
    ai_err_auth: 'API Key 无效或被拒绝（401/403），请检查 Key。',
    ai_err_http: '请求失败：',
    ai_err_cors: 'CORS / 连接失败。可尝试：换用中转站、本地运行 Convertly、或在「AI 设置」里改用「经服务器代理」。',
    ai_err_ollama: '连不上 Ollama。请确认 ollama serve 已运行；从线上 HTTPS 站访问需设 OLLAMA_ORIGINS=<本站域名>，且 Safari 不支持 localhost。',
    ai_err_network: '网络错误：',
    ai_err_nokey: '请先在「AI 设置」里填入该供应商的 API Key。',
    tool_pdf_md: 'PDF → Markdown',
    tool_pdf_md_desc: '提取文章型 PDF 的正文，保留标题层级和段落',
    tool_pdf_xlsx: 'PDF → Excel',
    tool_pdf_xlsx_desc: '识别 PDF 中的表格，导出为 Excel / CSV',
    tool_docx_md: 'Word → Markdown',
    tool_docx_md_desc: '把 DOCX 文档转换为 Markdown，保留标题、列表、表格',
    tool_xlsx_pdf: 'Excel → PDF',
    tool_xlsx_pdf_desc: 'Excel/CSV 渲染为可打印的 PDF',
    tool_md_html: 'Markdown → HTML/DOCX',
    tool_md_html_desc: '把 Markdown 渲染为 HTML 或导出为 DOCX',
    tool_ocr: '图片 → 文字 (OCR)',
    tool_ocr_desc: '从图片提取文字，支持中英文',

    badge_live: '可用',
    badge_beta: '测试',
    badge_soon: '即将',

    // CLI specific
    cli_input_placeholder: '粘贴 Claude CLI 输出…\n\n表格、emoji 标题、工具调用会自动识别为 Markdown。',
    opt_headers: '标题风格',
    opt_headers_auto: '自动 (## / ###)',
    opt_headers_bold: '仅加粗',
    opt_headers_none: '纯文本',
    opt_fence_code: '工具调用包代码块',
    opt_fence_code_hint: 'mcp__, npm, $ …',
    opt_tight_lists: '紧凑列表',
    opt_strip_emojis: '去除 emoji',

    // PDF
    pdf_drop_title: '拖入 PDF 文件',
    pdf_drop_sub: '在浏览器中提取文字 (不上传到任何服务器)',
    pdf_processing: '正在解析 PDF…',
    pdf_pages: '页',
    pdf_md_opt_pagebreaks: '保留分页',
    pdf_md_opt_pagebreaks_hint: '页之间插入 ---',
    pdf_md_opt_detect_headings: '智能识别标题',
    pdf_xlsx_opt_join_lines: '合并相邻行表格',

    // DOCX
    docx_drop_title: '拖入 Word 文档 (.docx)',
    docx_drop_sub: '保留标题、列表、表格和加粗格式',
    docx_processing: '正在解析 Word…',

    // Excel
    xlsx_drop_title: '拖入 Excel / CSV 文件',
    xlsx_drop_sub: '支持 .xlsx, .xls, .csv',
    xlsx_processing: '正在解析…',
    xlsx_sheets: '工作表',
    xlsx_rows: '行',
    xlsx_cols: '列',
    xlsx_pdf_print: '打印 / 保存为 PDF',

    // MD
    md_input_placeholder: '在此输入 Markdown…\n\n或者拖入 .md 文件。',
    md_export_html: '下载 HTML',
    md_export_docx: '下载 DOCX',

    // OCR
    ocr_drop_title: '拖入图片',
    ocr_drop_sub: '支持 PNG / JPG / WEBP，识别中英文',
    ocr_lang: '识别语言',
    ocr_lang_eng: '英文',
    ocr_lang_chi: '简体中文',
    ocr_lang_both: '中英混合',
    ocr_loading_engine: '正在加载 OCR 引擎 (首次较慢)…',
    ocr_processing: '正在识别…',
    ocr_progress: '进度',

    // Tweaks
    tweak_card_style: '工具卡片风格',
    tweak_card_style_soft: '柔和 (默认)',
    tweak_card_style_flat: '极简',
    tweak_card_style_tinted: '色调',
    tweak_card_style_bold: '醒目',
    tweak_layout: '转换页布局',
    tweak_layout_horiz: '左右分屏',
    tweak_layout_vert: '上下分屏',
    tweak_md_style: 'Markdown 风格',
    tweak_md_style_gfm: 'GitHub (GFM)',
    tweak_md_style_plain: '朴素',
    tweak_md_style_emoji: '保留 emoji',
    tweak_dropzone: '拖拽区视觉',
    tweak_dropzone_default: '默认',
    tweak_dropzone_minimal: '极简',
    tweak_dropzone_grid: '网格纸',
    tweak_dropzone_blueprint: '蓝图',
    tweak_density: '密度',
    tweak_density_comfy: '舒适',
    tweak_density_compact: '紧凑',

    untitled: '未命名',
    just_now: '刚刚',
    minutes_ago: '分钟前',
    hours_ago: '小时前',
    days_ago: '天前',

    placeholder_input_first: '从左侧粘贴或上传后，输出会出现在这里。',
    placeholder_paste_first: '粘贴内容后，转换结果会出现在这里。',
    placeholder_no_content: '尚无内容。',

    download_filename: '转换结果',
    error_unsupported: '不支持的文件类型',
    error_parse: '解析失败',
  },
  en: {
    brand: 'Convertly',
    brand_sub: 'DOC SUITE',
    nav_hub: 'Tools',
    crumb_hub: 'Tools',
    hub_eyebrow: 'Runs entirely in your browser · no uploads',
    hub_title_pre: 'Document conversion',
    hub_title_em: 'toolkit',
    hub_sub: 'Convert between Claude CLI output, PDFs, Word, Excel, Markdown and images — all locally in the browser. Files never leave your device.',
    hub_search_placeholder: 'Search tools…',
    section_tools: 'All tools',
    section_tools_meta: 'TOOLS',
    section_history: 'Recent conversions',
    section_history_meta: 'HISTORY',
    history_empty: 'No conversions yet. Pick a tool above to start.',
    history_clear: 'Clear',
    crumb_sep: '/',
    common_paste_input: 'Paste input',
    common_input: 'Input',
    common_output: 'Output',
    common_preview: 'Preview',
    common_markdown: 'Markdown',
    common_html: 'HTML',
    common_copy: 'Copy',
    common_copied: '✓ Copied',
    common_clear: 'Clear',
    common_download: 'Download',
    common_load_sample: 'Load sample',
    common_convert: 'Convert',
    common_options: 'Options',
    common_ready: 'Ready',
    common_busy: 'Working',
    common_error: 'Error',
    common_chars: 'chars',
    common_lines: 'lines',
    common_tables: 'tables',
    common_pages: 'pages',
    common_files: 'files',
    common_size: 'size',
    common_select_file: 'Select file',
    common_or_drop: 'or drop here',
    common_drop_here: 'Release to upload',
    common_browse: 'Browse…',
    common_remove: 'Remove',
    common_run_again: 'Convert again',
    common_rendered_preview: 'Rendered preview',
    common_source: 'Source',
    common_in_notebook: 'How it looks in your notebook',
    common_text: 'Text',
    common_view_table: 'Table',
    common_view_csv: 'CSV',

    tool_cli_md: 'CLI → Markdown',
    tool_cli_md_desc: 'Convert Claude CLI output (boxes, emoji headers, tool calls) into clean Markdown',
    tool_chat_md: 'Chat → Markdown',
    tool_chat_md_desc: 'Convert prose from Claude/ChatGPT web chat into structured Markdown — detects headings, key:value patterns, &lt;placeholder&gt; segments',
    opt_chat_h1: 'First line → H1',
    opt_chat_h2: 'Bare lines → H2',
    opt_chat_inline_code: 'Auto-backtick key:value',
    opt_chat_fence: 'Auto-fence &lt;placeholder&gt;',
    chat_input_placeholder: 'Paste chat-style text — e.g. with "Volterra" headings + bullets + &lt;placeholder&gt; segments…',

    // AI → Markdown
    tool_ai_md: 'AI → Markdown',
    tool_ai_md_desc: 'Let an LLM (Ollama / OpenAI / Claude) understand your text and output structured Markdown — beyond fixed rules',
    ai_input_placeholder: 'Paste any text, or drop a .txt / .md / .log file, then hit Convert to let the model understand it and add Markdown structure…',
    ai_convert: 'Convert',
    ai_stop: 'Stop',
    ai_streaming: 'Generating…',
    ai_provider: 'Provider',
    ai_extra: 'Extra instructions',
    ai_extra_ph: 'Optional, e.g. "use Chinese headings", "keep emojis"',
    ai_continue: 'Refine (based on last result)',
    ai_continue_ph: 'e.g. "turn section 2 into a table"',
    ai_continue_btn: 'Refine',
    ai_settings: 'AI settings',
    ai_tidy: 'AI tidy',
    ai_open_settings: 'Open AI settings',
    ai_provider_ollama: 'Ollama (local)',
    ai_provider_openai: 'OpenAI-compatible',
    ai_provider_anthropic: 'Anthropic Claude',
    ai_set_title: 'AI model settings',
    ai_set_security: 'Your API key is stored only in this browser (localStorage) and is never sent to Convertly\'s server. Treat it as exposed when using the hosted site.',
    ai_set_active: 'Active',
    ai_set_transport: 'Connection',
    ai_set_transport_direct: 'Direct',
    ai_set_transport_proxy: 'Via server proxy',
    ai_set_transport_proxy_na: '(Proxy only on hosted deploy; N/A for Ollama on your machine)',
    ai_set_baseurl: 'Base URL',
    ai_set_apikey: 'API Key',
    ai_set_model: 'Model',
    ai_set_loading_models: 'loading models…',
    ai_set_refresh_models: 'Refresh model list',
    ai_set_test: 'Test connection',
    ai_set_testing: 'Testing…',
    ai_set_test_ok: '✓ Connected',
    ai_set_test_fail: '✗ Failed',
    ai_set_save: 'Save',
    ai_set_close: 'Close',
    ai_usage_tokens: 'tokens',
    ai_err_aborted: 'Stopped. Kept the partial output.',
    ai_err_auth: 'API key invalid or rejected (401/403). Check your key.',
    ai_err_http: 'Request failed: ',
    ai_err_cors: 'CORS / connection failed. Try: a relay endpoint, running Convertly locally, or switch to "Via server proxy" in AI settings.',
    ai_err_ollama: 'Cannot reach Ollama. Make sure `ollama serve` is running; from a hosted HTTPS site set OLLAMA_ORIGINS=<this-site-origin>, and note Safari blocks localhost.',
    ai_err_network: 'Network error: ',
    ai_err_nokey: 'Set this provider\'s API key in AI settings first.',
    tool_pdf_md: 'PDF → Markdown',
    tool_pdf_md_desc: 'Extract body text from article-style PDFs with heading detection',
    tool_pdf_xlsx: 'PDF → Excel',
    tool_pdf_xlsx_desc: 'Detect tables in PDFs and export to Excel / CSV',
    tool_docx_md: 'Word → Markdown',
    tool_docx_md_desc: 'Convert DOCX documents to Markdown — preserves headings, lists, tables',
    tool_xlsx_pdf: 'Excel → PDF',
    tool_xlsx_pdf_desc: 'Render Excel/CSV files as printable PDFs',
    tool_md_html: 'Markdown → HTML/DOCX',
    tool_md_html_desc: 'Render Markdown to HTML or export as DOCX',
    tool_ocr: 'Image → Text (OCR)',
    tool_ocr_desc: 'Extract text from images, supports English and Chinese',

    badge_live: 'Live',
    badge_beta: 'Beta',
    badge_soon: 'Soon',

    cli_input_placeholder: 'Paste your Claude CLI output here…\n\nBox-drawing tables, emoji headers, and tool calls will be converted automatically.',
    opt_headers: 'Headers',
    opt_headers_auto: 'Auto (## / ###)',
    opt_headers_bold: 'Bold only',
    opt_headers_none: 'Plain text',
    opt_fence_code: 'Fence code calls',
    opt_fence_code_hint: 'mcp__, npm, $ …',
    opt_tight_lists: 'Tight lists',
    opt_strip_emojis: 'Strip emojis',

    pdf_drop_title: 'Drop a PDF here',
    pdf_drop_sub: 'Text is extracted locally — never uploaded.',
    pdf_processing: 'Parsing PDF…',
    pdf_pages: 'pages',
    pdf_md_opt_pagebreaks: 'Page breaks',
    pdf_md_opt_pagebreaks_hint: 'Insert --- between pages',
    pdf_md_opt_detect_headings: 'Detect headings',
    pdf_xlsx_opt_join_lines: 'Merge wrapped rows',

    docx_drop_title: 'Drop a Word document (.docx)',
    docx_drop_sub: 'Preserves headings, lists, tables and bold formatting.',
    docx_processing: 'Parsing Word…',

    xlsx_drop_title: 'Drop an Excel / CSV file',
    xlsx_drop_sub: 'Supports .xlsx, .xls, .csv',
    xlsx_processing: 'Parsing…',
    xlsx_sheets: 'sheets',
    xlsx_rows: 'rows',
    xlsx_cols: 'cols',
    xlsx_pdf_print: 'Print / Save as PDF',

    md_input_placeholder: 'Type or paste Markdown here…\n\nOr drop a .md file.',
    md_export_html: 'Download HTML',
    md_export_docx: 'Download DOCX',

    ocr_drop_title: 'Drop an image',
    ocr_drop_sub: 'PNG / JPG / WEBP — supports English & Chinese',
    ocr_lang: 'Language',
    ocr_lang_eng: 'English',
    ocr_lang_chi: 'Chinese',
    ocr_lang_both: 'English + Chinese',
    ocr_loading_engine: 'Loading OCR engine (slow first time)…',
    ocr_processing: 'Recognizing…',
    ocr_progress: 'Progress',

    tweak_card_style: 'Tool card style',
    tweak_card_style_soft: 'Soft (default)',
    tweak_card_style_flat: 'Flat',
    tweak_card_style_tinted: 'Tinted',
    tweak_card_style_bold: 'Bold',
    tweak_layout: 'Tool page layout',
    tweak_layout_horiz: 'Side-by-side',
    tweak_layout_vert: 'Stacked',
    tweak_md_style: 'Markdown style',
    tweak_md_style_gfm: 'GitHub (GFM)',
    tweak_md_style_plain: 'Plain',
    tweak_md_style_emoji: 'Keep emojis',
    tweak_dropzone: 'Dropzone visual',
    tweak_dropzone_default: 'Default',
    tweak_dropzone_minimal: 'Minimal',
    tweak_dropzone_grid: 'Graph paper',
    tweak_dropzone_blueprint: 'Blueprint',
    tweak_density: 'Density',
    tweak_density_comfy: 'Comfy',
    tweak_density_compact: 'Compact',

    untitled: 'Untitled',
    just_now: 'just now',
    minutes_ago: 'm ago',
    hours_ago: 'h ago',
    days_ago: 'd ago',

    placeholder_input_first: 'Paste or upload on the left to see output here.',
    placeholder_paste_first: 'Output appears here after you paste content.',
    placeholder_no_content: 'No content yet.',

    download_filename: 'converted',
    error_unsupported: 'Unsupported file type',
    error_parse: 'Parse failed',
  },
};

const I18nContext = createContext({ lang: 'zh', t: (k) => k, setLang: () => {} });

function I18nProvider({ children }) {
  const [lang, setLang] = useState(() => {
    return localStorage.getItem('cv_lang') || (navigator.language?.startsWith('zh') ? 'zh' : 'en');
  });
  useEffect(() => {
    localStorage.setItem('cv_lang', lang);
    document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh-CN' : 'en');
  }, [lang]);
  const t = useCallback((key) => {
    return STRINGS[lang]?.[key] ?? STRINGS.en[key] ?? key;
  }, [lang]);
  const value = useMemo(() => ({ lang, setLang, t }), [lang, t]);
  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

function useI18n() { return useContext(I18nContext); }

/* ---------------------------- Theme ---------------------------- */

const ThemeContext = createContext({ theme: 'light', toggle: () => {} });

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('cv_theme') ||
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  });
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('cv_theme', theme);
  }, [theme]);
  const toggle = useCallback(() => setTheme(t => t === 'light' ? 'dark' : 'light'), []);
  const value = useMemo(() => ({ theme, toggle, setTheme }), [theme, toggle]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

function useTheme() { return useContext(ThemeContext); }

/* ---------------------------- History ---------------------------- */

const HISTORY_KEY = 'cv_history_v1';
const HISTORY_MAX = 50;
// Skip storing a restore payload bigger than ~250KB (keeps the entry, drops the heavy body).
const HISTORY_PAYLOAD_MAX = 250000;

function getHistory() {
  try {
    const raw = localStorage.getItem(HISTORY_KEY);
    return raw ? JSON.parse(raw) : [];
  } catch (e) {
    return [];
  }
}

function getHistoryEntry(id) {
  if (!id) return null;
  return getHistory().find(e => e.id === id) || null;
}

// Strip the payload field from one entry (used to shed weight under quota pressure).
// NOTE: avoid object rest ({...rest}) here — @babel/standalone emits a top-level
// `_excluded` helper that would collide across the shared-scope script files.
function dropPayload(entry) {
  const copy = Object.assign({}, entry);
  delete copy.payload;
  return copy;
}

function saveHistory(items) {
  let list = items.slice(0, HISTORY_MAX);
  // Retry under quota: first shed payloads from the oldest entries, then drop oldest entries.
  for (let attempt = 0; attempt < 12; attempt++) {
    try {
      localStorage.setItem(HISTORY_KEY, JSON.stringify(list));
      return;
    } catch (e) {
      let trimmed = false;
      for (let i = list.length - 1; i >= 0; i--) {
        if (list[i].payload) { list = list.map((it, j) => (j === i ? dropPayload(it) : it)); trimmed = true; break; }
      }
      if (!trimmed) {
        list = list.slice(0, -1);
        if (!list.length) return;
      }
    }
  }
}

function useHistory() {
  const [items, setItems] = useState(getHistory);
  const reload = useCallback(() => setItems(getHistory()), []);
  useEffect(() => {
    const onStorage = () => reload();
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, [reload]);
  return { items, reload };
}

function addToHistory(entry) {
  const e = { ...entry, ts: Date.now(), id: Math.random().toString(36).slice(2, 9) };
  // Drop oversized payloads so a single huge conversion can't blow the quota.
  if (e.payload) {
    try {
      if (JSON.stringify(e.payload).length > HISTORY_PAYLOAD_MAX) delete e.payload;
    } catch (_) { delete e.payload; }
  }
  saveHistory([e, ...getHistory()]);
  // notify same-window listeners
  window.dispatchEvent(new Event('storage'));
}

function clearHistory() {
  localStorage.removeItem(HISTORY_KEY);
  window.dispatchEvent(new Event('storage'));
}

function formatRelativeTime(ts, t) {
  const diff = Date.now() - ts;
  const m = Math.floor(diff / 60000);
  const h = Math.floor(diff / 3600000);
  const d = Math.floor(diff / 86400000);
  if (m < 1) return t('just_now');
  if (m < 60) return `${m}${t('minutes_ago')}`;
  if (h < 24) return `${h}${t('hours_ago')}`;
  return `${d}${t('days_ago')}`;
}

/* ---------------------------- Hash router ---------------------------- */

function useHashRoute() {
  const [route, setRoute] = useState(() => parseHash(window.location.hash));
  useEffect(() => {
    const onHash = () => setRoute(parseHash(window.location.hash));
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);
  return route;
}

function parseHash(hash) {
  if (!hash || hash === '#' || hash === '#/') return { name: 'hub' };
  let path = hash.replace(/^#\/?/, '');
  let restoreId = null;
  const q = path.indexOf('?');
  if (q !== -1) {
    try { restoreId = new URLSearchParams(path.slice(q + 1)).get('h'); } catch (_) { /* ignore */ }
    path = path.slice(0, q);
  }
  if (TOOL_BY_ID[path]) return { name: 'tool', toolId: path, restoreId };
  return { name: 'hub' };
}

function navigate(path) {
  window.location.hash = path ? `#/${path}` : '#/';
}

/* ---------------------------- Dropzone ---------------------------- */

function useDropzone({ accept, multiple, onFiles }) {
  const [dragover, setDragover] = useState(false);
  const inputRef = useRef(null);
  const dragDepth = useRef(0);

  const onDragEnter = useCallback((e) => {
    e.preventDefault();
    dragDepth.current++;
    if (dragDepth.current === 1) setDragover(true);
  }, []);
  const onDragLeave = useCallback((e) => {
    e.preventDefault();
    dragDepth.current--;
    if (dragDepth.current <= 0) {
      dragDepth.current = 0;
      setDragover(false);
    }
  }, []);
  const onDragOver = useCallback((e) => { e.preventDefault(); }, []);
  const onDrop = useCallback((e) => {
    e.preventDefault();
    dragDepth.current = 0;
    setDragover(false);
    const files = Array.from(e.dataTransfer.files);
    if (files.length) onFiles(multiple ? files : [files[0]]);
  }, [onFiles, multiple]);

  const onClick = useCallback(() => inputRef.current?.click(), []);
  const onChange = useCallback((e) => {
    const files = Array.from(e.target.files || []);
    if (files.length) onFiles(multiple ? files : [files[0]]);
    e.target.value = '';
  }, [onFiles, multiple]);

  const bind = {
    onClick, onDragEnter, onDragLeave, onDragOver, onDrop,
  };
  const inputProps = {
    type: 'file',
    accept: accept || undefined,
    multiple: !!multiple,
    onChange,
    style: { display: 'none' },
    ref: inputRef,
  };
  return { dragover, bind, inputProps };
}

/* ---------------------------- Icons ---------------------------- */

const Icon = {
  Sun: () => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="12" r="4" />
      <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
    </svg>
  ),
  Moon: () => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
    </svg>
  ),
  Search: () => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="11" cy="11" r="7" />
      <path d="M21 21l-4.35-4.35" />
    </svg>
  ),
  Home: () => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M3 9.5L12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1V9.5z" />
    </svg>
  ),
  Upload: () => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
    </svg>
  ),
  X: () => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M18 6L6 18M6 6l12 12" />
    </svg>
  ),
  Sliders: () => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="4" y1="21" x2="4" y2="14" />
      <line x1="4" y1="10" x2="4" y2="3" />
      <line x1="12" y1="21" x2="12" y2="12" />
      <line x1="12" y1="8" x2="12" y2="3" />
      <line x1="20" y1="21" x2="20" y2="16" />
      <line x1="20" y1="12" x2="20" y2="3" />
      <line x1="1" y1="14" x2="7" y2="14" />
      <line x1="9" y1="8" x2="15" y2="8" />
      <line x1="17" y1="16" x2="23" y2="16" />
    </svg>
  ),
};

/* ---------------------------- File helpers ---------------------------- */

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 200);
}

function downloadText(text, filename, mime = 'text/plain') {
  downloadBlob(new Blob([text], { type: mime + ';charset=utf-8' }), filename);
}

function readFileAsText(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(r.result);
    r.onerror = reject;
    r.readAsText(file);
  });
}

function readFileAsArrayBuffer(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(r.result);
    r.onerror = reject;
    r.readAsArrayBuffer(file);
  });
}

function readFileAsDataURL(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(r.result);
    r.onerror = reject;
    r.readAsDataURL(file);
  });
}

function fmtBytes(n) {
  if (n < 1024) return `${n}B`;
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
  return `${(n / 1024 / 1024).toFixed(2)}MB`;
}

/* ---------------------------- Async script loader ---------------------------- */

const _scriptCache = {};
function loadScript(src) {
  if (_scriptCache[src]) return _scriptCache[src];
  _scriptCache[src] = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = src;
    s.async = true;
    s.onload = resolve;
    s.onerror = reject;
    document.head.appendChild(s);
  });
  return _scriptCache[src];
}

/* ---------------------------- Export to window for cross-script use ---------------------------- */

Object.assign(window, {
  // Data
  TOOLS, TOOL_BY_ID,
  // Context
  I18nProvider, ThemeProvider, useI18n, useTheme,
  // History
  useHistory, addToHistory, clearHistory, getHistoryEntry, formatRelativeTime,
  // Router
  useHashRoute, navigate,
  // Hooks
  useDropzone,
  // Icons
  Icon,
  // File utils
  downloadBlob, downloadText, readFileAsText, readFileAsArrayBuffer, readFileAsDataURL,
  fmtBytes, loadScript,
});
