/* ============================================================
   AliothEditor — self-hosted rich text editor for AliothPress
   ------------------------------------------------------------
   Replaces TinyMCE. Three synchronized tabs:

     Visual   — contenteditable pane themed by the LIVE --adm-*
                admin palette (no iframe, so light/dark "just
                works" and always matches the admin theme)
     Markdown — plain textarea; converted to HTML via marked.js
     HTML     — plain textarea; the canonical storage format

   HTML is the single source of truth (it is what the <textarea>
   posts and what sanitize_html() stores in the DB — unchanged
   from the TinyMCE days). Conversions happen on tab switch:

     Visual  -> HTML : innerHTML (cleaned)
     MD      -> HTML : marked.parse()
     HTML    -> MD   : TurndownService (+ GFM plugin: tables,
                       strikethrough, task lists)

   Dependencies (all vendored in static/js/vendor/, MIT, zero
   runtime network access):
     marked.min.js            -> window.marked
     turndown.js              -> window.TurndownService
     turndown-plugin-gfm.js   -> window.turndownPluginGfm

   Public API (mirrors the subset of TinyMCE the templates used,
   so template diffs stay minimal):

     AliothEditor.init({ selector, height, readonly,
                         imagePicker, colors, defaultTab })
     AliothEditor.get(id)      -> instance | null
       .getContent({format: 'html'|'md'|'text'})
       .setContent(html)
       .save()                 -> sync current tab to <textarea>
     AliothEditor.triggerSave()-> save() on every instance
                                  (called from admin base.html
                                  before form submits)

   Translations come via window.AE_I18N (built in the Jinja
   partial templates/admin/editor_assets.html) with English
   fallbacks below, so this file stays free of Jinja.

   Security note: the client-side paste/HTML cleaning here is a
   UX convenience (keeps junk out of the visual pane). The REAL
   sanitization is server-side in app.py:sanitize_html() (bleach)
   on every save — same as before. Content styling always comes
   from the surrounding stylesheets (admin palette while editing,
   the active public theme when rendered) because inline styles
   from pasted content are stripped.
   ============================================================ */
(function (global) {
  'use strict';

  var DEFAULT_I18N = {
    tab_visual: 'Visual',
    tab_markdown: 'Markdown',
    tab_html: 'HTML',
    undo: 'Undo',
    redo: 'Redo',
    paragraph: 'Paragraph',
    heading: 'Heading',
    blockquote: 'Quote',
    bold: 'Bold',
    italic: 'Italic',
    underline: 'Underline',
    strikethrough: 'Strikethrough',
    inline_code: 'Inline code',
    align_left: 'Align left',
    align_center: 'Align center',
    align_right: 'Align right',
    bullet_list: 'Bullet list',
    numbered_list: 'Numbered list',
    link: 'Insert link',
    link_prompt: 'Link URL:',
    unlink: 'Remove link',
    image: 'Insert image',
    image_url_prompt: 'Image URL:',
    image_alt_prompt: 'Alt text (for accessibility):',
    table: 'Insert table',
    table_size_prompt: 'Table size (columns x rows):',
    code_block: 'Code block',
    code_lang_prompt: 'Language (e.g. python, js — empty for plain):',
    hr: 'Horizontal rule',
    clear_format: 'Clear formatting',
    fullscreen: 'Fullscreen',
    words: 'words',
    chars: 'characters',
    text_color: 'Text color',
    bg_color: 'Highlight color',
    default_color: 'Default',
    readonly_hint: 'Read-only',
    ok: 'OK',
    cancel: 'Cancel',
    columns: 'Columns',
    rows: 'Rows'
  };

  function t(key) {
    var i18n = global.AE_I18N || {};
    return i18n[key] || DEFAULT_I18N[key] || key;
  }

  function esc(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  /* ── Client-side HTML cleaner ─────────────────────────────
     Two profiles:

     STRICT (paste): new content arriving from the clipboard.
       Whitelist mirrors the spirit of app.py:sanitize_html()
       but additionally drops inline styles and foreign classes
       — pasted content must inherit its look from the active
       theme, never carry Word/Docs/website styling with it.

     SOFT (load / tab sync): content that is ALREADY in the DB
       (published posts, rendered builder pages). The server
       allows more than the strict profile (ap-* block classes,
       iframes for video embeds; rendered builder HTML also
       carries the renderer's own inline styles), so re-cleaning
       it strictly would silently change published content on
       the first re-save. Soft mode only removes what is
       actively dangerous (scripts, event handlers, js: URLs)
       and leaves the rest to the server-side bleach pass. */
  var ALLOWED_TAGS = {
    P: 1, BR: 1, STRONG: 1, B: 1, EM: 1, I: 1, U: 1, S: 1, DEL: 1, INS: 1,
    A: 1, UL: 1, OL: 1, LI: 1, BLOCKQUOTE: 1,
    H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1,
    CODE: 1, PRE: 1, IMG: 1, HR: 1,
    TABLE: 1, THEAD: 1, TBODY: 1, TFOOT: 1, TR: 1, TH: 1, TD: 1,
    FIGURE: 1, FIGCAPTION: 1, SUP: 1, SUB: 1, ABBR: 1, MARK: 1,
    DIV: 1, SPAN: 1, INPUT: 1 /* task-list checkboxes only, see below */
  };
  /* Extra tags the SERVER whitelist allows (sanitize_html):
     kept in soft mode so existing content round-trips intact. */
  var SOFT_EXTRA_TAGS = {
    SECTION: 1, ARTICLE: 1, ASIDE: 1, HEADER: 1, FOOTER: 1,
    DETAILS: 1, SUMMARY: 1, PICTURE: 1, SOURCE: 1,
    VIDEO: 1, AUDIO: 1, IFRAME: 1, KBD: 1, SAMP: 1, FORM: 1,
    LABEL: 1, TEXTAREA: 1, SELECT: 1, OPTION: 1, BUTTON: 1
  };
  var REMOVE_WITH_CONTENT = {
    SCRIPT: 1, STYLE: 1, OBJECT: 1, EMBED: 1,
    LINK: 1, META: 1, TITLE: 1, HEAD: 1, NOSCRIPT: 1, TEMPLATE: 1
  };
  /* class="language-xxx" on <code> is how Prism finds the
     highlight language; ae-align-* provide theme-independent
     alignment (the old TinyMCE used inline text-align, which we
     no longer allow for NEW content). */
  var CLASS_RE = /^(language-[a-z0-9+#-]+|ae-align-(left|center|right))$/i;
  var BAD_URL_RE = /^\s*(javascript|vbscript|data\s*:\s*text\/html)/i;

  function cleanNode(el, strict) {
    var tag = el.tagName;
    if (REMOVE_WITH_CONTENT[tag] || (strict && (tag === 'IFRAME' || tag === 'FORM'))) {
      el.parentNode && el.parentNode.removeChild(el);
      return;
    }
    if (!ALLOWED_TAGS[tag] && !(!strict && SOFT_EXTRA_TAGS[tag])) {
      // Unknown wrapper -> unwrap (keep children)
      var parent = el.parentNode;
      if (!parent) return;
      while (el.firstChild) parent.insertBefore(el.firstChild, el);
      parent.removeChild(el);
      return;
    }
    if (strict && tag === 'INPUT') {
      // <input> allowed only as a GFM task-list checkbox
      if ((el.getAttribute('type') || '').toLowerCase() !== 'checkbox') {
        el.parentNode && el.parentNode.removeChild(el);
        return;
      }
      var checked = el.checked || el.hasAttribute('checked');
      while (el.attributes.length) el.removeAttribute(el.attributes[0].name);
      el.setAttribute('type', 'checkbox');
      el.setAttribute('disabled', '');
      if (checked) el.setAttribute('checked', '');
      return;
    }

    if (!strict) {
      // SOFT: strip only on* handlers and js: URLs; keep the rest
      // for the server-side bleach pass to arbitrate.
      for (var i = el.attributes.length - 1; i >= 0; i--) {
        var attr = el.attributes[i];
        if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
        else if ((attr.name === 'href' || attr.name === 'src') && BAD_URL_RE.test(attr.value)) {
          el.removeAttribute(attr.name);
        } else if (attr.name === 'srcdoc') el.removeAttribute(attr.name);
      }
      return;
    }

    // STRICT: attribute whitelist per tag, no style, no foreign classes
    var keep = {};
    if (tag === 'A') {
      var href = el.getAttribute('href') || '';
      if (href && !BAD_URL_RE.test(href)) keep.href = href;
      if (el.getAttribute('title')) keep.title = el.getAttribute('title');
      if (el.getAttribute('target') === '_blank') {
        keep.target = '_blank';
        keep.rel = 'noopener noreferrer';
      }
    } else if (tag === 'IMG') {
      var src = el.getAttribute('src') || '';
      if (BAD_URL_RE.test(src)) src = '';
      if (src) keep.src = src;
      keep.alt = el.getAttribute('alt') || '';
      if (el.getAttribute('width')) keep.width = el.getAttribute('width');
      if (el.getAttribute('height')) keep.height = el.getAttribute('height');
      keep.loading = 'lazy';
    } else if (tag === 'TD' || tag === 'TH') {
      if (el.getAttribute('colspan')) keep.colspan = el.getAttribute('colspan');
      if (el.getAttribute('rowspan')) keep.rowspan = el.getAttribute('rowspan');
      if (tag === 'TH' && el.getAttribute('scope')) keep.scope = el.getAttribute('scope');
    } else if (tag === 'ABBR') {
      if (el.getAttribute('title')) keep.title = el.getAttribute('title');
    }
    var classes = (el.getAttribute('class') || '').split(/\s+/)
      .filter(function (c) { return CLASS_RE.test(c); });
    while (el.attributes.length) el.removeAttribute(el.attributes[0].name);
    for (var k in keep) el.setAttribute(k, keep[k]);
    if (classes.length) el.setAttribute('class', classes.join(' '));
  }

  function cleanHtml(html, opts) {
    var strict = !opts || opts.strict !== false; // default: strict
    var tmp = document.createElement('div');
    tmp.innerHTML = html || '';
    // Walk bottom-up so unwrapping doesn't skip nodes
    var els = tmp.querySelectorAll('*');
    for (var i = els.length - 1; i >= 0; i--) cleanNode(els[i], strict);
    // Drop HTML comments (Word paste is full of them)
    var walker = document.createTreeWalker(tmp, NodeFilter.SHOW_COMMENT, null, false);
    var comments = [];
    while (walker.nextNode()) comments.push(walker.currentNode);
    comments.forEach(function (c) { c.parentNode.removeChild(c); });
    return tmp.innerHTML;
  }

  /* ── Converters ─────────────────────────────────────────── */
  var _turndown = null;
  function getTurndown() {
    if (_turndown) return _turndown;
    _turndown = new global.TurndownService({
      headingStyle: 'atx',
      codeBlockStyle: 'fenced',
      fence: '```',
      emDelimiter: '*',
      bulletListMarker: '-',
      hr: '---'
    });
    if (global.turndownPluginGfm) _turndown.use(global.turndownPluginGfm.gfm);
    // Preserve alignment helper classes as raw HTML — Markdown has
    // no alignment syntax, so aligned paragraphs pass through as-is.
    _turndown.keep(function (node) {
      return node.nodeType === 1 && /ae-align-(center|right)/.test(node.className || '');
    });
    return _turndown;
  }

  function htmlToMd(html) {
    try { return getTurndown().turndown(html || ''); }
    catch (e) { return html || ''; }
  }

  function mdToHtml(md) {
    try {
      var html = global.marked.parse(md || '', { gfm: true, breaks: false });
      return cleanHtml(html, { strict: false });
    } catch (e) { return '<p>' + esc(md) + '</p>'; }
  }

  function htmlToText(html) {
    var tmp = document.createElement('div');
    tmp.innerHTML = html || '';
    return tmp.textContent || '';
  }

  /* Light pretty-print for the HTML tab: newline between block
     tags so hand-editing is bearable. Not a full formatter. */
  var BLOCK_TAGS = 'p|div|h[1-6]|ul|ol|li|table|thead|tbody|tfoot|tr|blockquote|pre|figure|figcaption|hr';
  function prettyHtml(html) {
    return (html || '')
      .replace(new RegExp('><(' + BLOCK_TAGS + ')', 'gi'), '>\n<$1')
      .replace(new RegExp('></(' + BLOCK_TAGS + ')>', 'gi'), '>\n</$1>')
      .trim();
  }

  /* ── Toolbar definition ─────────────────────────────────── */
  // Feather-style inline SVGs (stroke = currentColor), same visual
  // language as the Page Builder toolbar.
  var SVG = {
    undo: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-15-6.7L3 13"/>',
    redo: '<path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0115-6.7L21 13"/>',
    bold: '<path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6z"/><path d="M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6z"/>',
    italic: '<line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/>',
    underline: '<path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/>',
    strike: '<line x1="4" y1="12" x2="20" y2="12"/><path d="M17.3 4.9C16.2 4 14.7 3.5 13 3.5c-3.6 0-5.5 2-5.5 4 0 .7.2 1.4.5 2"/><path d="M8 14.5c0 3 2.5 5 6 5 1.8 0 3.3-.5 4.3-1.4"/>',
    code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
    codeblock: '<rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="9 9 6 12 9 15"/><polyline points="15 9 18 12 15 15"/>',
    alignL: '<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="18" y2="18"/>',
    alignC: '<line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="18" y2="12"/><line x1="5" y1="18" x2="19" y2="18"/>',
    alignR: '<line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="6" y1="18" x2="21" y2="18"/>',
    ul: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/>',
    ol: '<line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
    link: '<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>',
    unlink: '<path d="M18.84 12.25l1.72-1.71a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M5.17 11.75l-1.72 1.71a5 5 0 007.07 7.07l1.71-1.71"/><line x1="8" y1="2" x2="8" y2="5"/><line x1="2" y1="8" x2="5" y2="8"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="19" y1="16" x2="22" y2="16"/>',
    image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
    table: '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="12" y1="3" x2="12" y2="21"/>',
    hr: '<line x1="3" y1="12" x2="21" y2="12"/><line x1="7" y1="7" x2="17" y2="7" opacity=".35"/><line x1="7" y1="17" x2="17" y2="17" opacity=".35"/>',
    clear: '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/><line x1="18" y1="15" x2="22" y2="19"/><line x1="22" y1="15" x2="18" y2="19"/>',
    expand: '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>',
    color: '<path d="M12 3l5 12H7z"/><line x1="4" y1="20" x2="20" y2="20"/>',
    highlight: '<path d="M9 11l6-6 4 4-6 6z"/><path d="M9 11l-4 8 8-4"/><line x1="3" y1="21" x2="7" y2="21"/>'
  };

  function btn(cmd, icon, title) {
    return '<button type="button" class="ae-btn" data-ae-cmd="' + cmd + '" title="' + esc(title) + '" aria-label="' + esc(title) + '">' +
      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + SVG[icon] + '</svg></button>';
  }
  function sep() { return '<span class="ae-sep" aria-hidden="true"></span>'; }

  /* Small fixed palette for the newsletter editor (colors:true).
     Emails travel outside the site so a color there is genuinely
     content, not styling — everywhere else this stays OFF and
     colors always come from the active theme. */
  var PALETTE = ['#1e293b', '#64748b', '#b91c1c', '#c2410c', '#a16207',
                 '#15803d', '#0e7490', '#1d4ed8', '#7e22ce', '#be185d'];

  /* ── Themed dialog ────────────────────────────────────────
     Replaces native window.prompt(): browser dialogs ignore the
     admin theme entirely (white chrome popups over a dark admin).
     This modal is styled by --adm-* tokens and reuses the admin's
     own .btn classes, so it looks native to the panel in both
     light and dark mode.

     dialog({ title, fields: [{name, label, value, type,
              placeholder, min, max}], okLabel })
       -> Promise resolving to {name: value, ...} or null on
          cancel / Esc / backdrop click.

     Exposed as AliothEditor.dialog for plugins (the Pop-up
     Manager theme-button prompts use it too).

     When the shared AdminDialog (admin-dialog.js, loaded from
     base.html) is present, delegate to it so the whole admin
     uses literally one modal implementation; the local version
     below stays as a fallback for embedders without base.html. */
  function dialog(opts) {
    if (global.AdminDialog && global.AdminDialog.dialog) {
      return global.AdminDialog.dialog(opts);
    }
    return dialogLocal(opts);
  }
  function dialogLocal(opts) {
    return new Promise(function (resolve) {
      var overlay = document.createElement('div');
      overlay.className = 'ae-modal-overlay';
      var fieldsHtml = (opts.fields || []).map(function (fld, i) {
        return '<div class="ae-modal-field">' +
          '<label for="ae-dlg-f' + i + '">' + esc(fld.label) + '</label>' +
          '<input id="ae-dlg-f' + i + '" type="' + esc(fld.type || 'text') + '"' +
          ' value="' + esc(fld.value || '') + '"' +
          (fld.placeholder ? ' placeholder="' + esc(fld.placeholder) + '"' : '') +
          (fld.min != null ? ' min="' + fld.min + '"' : '') +
          (fld.max != null ? ' max="' + fld.max + '"' : '') + '>' +
          '</div>';
      }).join('');
      overlay.innerHTML =
        '<div class="ae-modal" role="dialog" aria-modal="true" aria-label="' + esc(opts.title) + '">' +
          '<div class="ae-modal-head">' +
            '<h3>' + esc(opts.title) + '</h3>' +
            '<button type="button" class="ae-modal-x" aria-label="' + esc(t('cancel')) + '">\u00d7</button>' +
          '</div>' +
          fieldsHtml +
          '<div class="ae-modal-actions">' +
            '<button type="button" class="btn btn-secondary btn-sm" data-ae-cancel>' + esc(t('cancel')) + '</button>' +
            '<button type="button" class="btn btn-primary btn-sm" data-ae-ok>' + esc(opts.okLabel || t('ok')) + '</button>' +
          '</div>' +
        '</div>';
      document.body.appendChild(overlay);
      var inputs = overlay.querySelectorAll('input');
      function collect() {
        var out = {};
        (opts.fields || []).forEach(function (fld, i) { out[fld.name] = inputs[i].value; });
        return out;
      }
      function close(result) {
        document.removeEventListener('keydown', onKey, true);
        overlay.parentNode && overlay.parentNode.removeChild(overlay);
        resolve(result);
      }
      function onKey(e) {
        if (e.key === 'Escape') { e.stopPropagation(); close(null); }
        else if (e.key === 'Enter' && e.target.tagName === 'INPUT' && overlay.contains(e.target)) {
          e.preventDefault(); close(collect());
        }
      }
      overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) close(null); });
      overlay.querySelector('.ae-modal-x').addEventListener('click', function () { close(null); });
      overlay.querySelector('[data-ae-cancel]').addEventListener('click', function () { close(null); });
      overlay.querySelector('[data-ae-ok]').addEventListener('click', function () { close(collect()); });
      document.addEventListener('keydown', onKey, true);
      if (inputs[0]) { inputs[0].focus(); inputs[0].select(); }
    });
  }

  /* ── Editor instance ────────────────────────────────────── */
  var _instances = {};

  function Editor(textarea, opts) {
    this.textarea = textarea;
    this.opts = opts || {};
    this.id = textarea.id || ('ae_' + Math.random().toString(36).slice(2));
    this.tab = 'visual';
    this.readonly = !!opts.readonly;
    this._build();
    this._bind();
    this.setContent(textarea.value || '');
    _instances[this.id] = this;
  }

  Editor.prototype._build = function () {
    var o = this.opts;
    var root = document.createElement('div');
    root.className = 'ae-editor' + (this.readonly ? ' ae-readonly' : '');
    root.setAttribute('data-ae-for', this.id);

    var tabs =
      '<div class="ae-tabs" role="tablist">' +
      ['visual', 'markdown', 'html'].map(function (name) {
        return '<button type="button" class="ae-tab" data-ae-tab="' + name + '" role="tab">' +
          esc(t('tab_' + (name === 'visual' ? 'visual' : name))) + '</button>';
      }).join('') +
      (this.readonly ? '<span class="ae-readonly-badge">🔒 ' + esc(t('readonly_hint')) + '</span>' : '') +
      '<span class="ae-tabs-spacer"></span>' +
      btn('fullscreen', 'expand', t('fullscreen')) +
      '</div>';

    var toolbar = '';
    if (!this.readonly) {
      /* Toolbar is configurable per surface: opts.toolbar is an array of
         item names (plus 'sep'), so a plugin whose server-side whitelist
         is narrower than the core one (e.g. pop-ups: no tables, no
         images) can show only what will actually survive sanitization.
         Default = the full core layout, so core templates pass nothing. */
      var self = this;
      var ITEMS = {
        undo: function () { return btn('undo', 'undo', t('undo')); },
        redo: function () { return btn('redo', 'redo', t('redo')); },
        blocks: function () {
          var tags = o.blockOptions || ['p', 'h1', 'h2', 'h3', 'h4', 'blockquote'];
          var opts_html = tags.map(function (tag) {
            if (tag === 'p') return '<option value="p">' + esc(t('paragraph')) + '</option>';
            if (tag === 'blockquote') return '<option value="blockquote">' + esc(t('blockquote')) + '</option>';
            return '<option value="' + tag + '">' + esc(t('heading')) + ' ' + tag.slice(1) + '</option>';
          }).join('');
          return '<select class="ae-block-select" data-ae-cmd="formatBlock" title="' + esc(t('paragraph')) + '">' + opts_html + '</select>';
        },
        bold: function () { return btn('bold', 'bold', t('bold')); },
        italic: function () { return btn('italic', 'italic', t('italic')); },
        underline: function () { return btn('underline', 'underline', t('underline')); },
        strikeThrough: function () { return btn('strikeThrough', 'strike', t('strikethrough')); },
        inlineCode: function () { return btn('inlineCode', 'code', t('inline_code')); },
        colors: function () {
          return '<span class="ae-color-wrap">' + btn('foreColor', 'color', t('text_color')) + self._paletteHtml('foreColor') + '</span>' +
                 '<span class="ae-color-wrap">' + btn('hiliteColor', 'highlight', t('bg_color')) + self._paletteHtml('hiliteColor') + '</span>';
        },
        align: function () {
          return btn('alignLeft', 'alignL', t('align_left')) +
                 btn('alignCenter', 'alignC', t('align_center')) +
                 btn('alignRight', 'alignR', t('align_right'));
        },
        lists: function () {
          return btn('insertUnorderedList', 'ul', t('bullet_list')) +
                 btn('insertOrderedList', 'ol', t('numbered_list'));
        },
        createLink: function () { return btn('createLink', 'link', t('link')); },
        unlink: function () { return btn('unlink', 'unlink', t('unlink')); },
        insertImage: function () { return btn('insertImage', 'image', t('image')); },
        insertTable: function () { return btn('insertTable', 'table', t('table')); },
        codeBlock: function () { return btn('codeBlock', 'codeblock', t('code_block')); },
        insertHr: function () { return btn('insertHr', 'hr', t('hr')); },
        removeFormat: function () { return btn('removeFormat', 'clear', t('clear_format')); }
      };
      var layout = o.toolbar || ['undo', 'redo', 'sep', 'blocks', 'sep',
        'bold', 'italic', 'underline', 'strikeThrough', 'inlineCode']
        .concat(o.colors ? ['sep', 'colors'] : [])
        .concat(['sep', 'align', 'sep', 'lists', 'sep',
          'createLink', 'unlink', 'insertImage', 'insertTable', 'codeBlock', 'insertHr',
          'sep', 'removeFormat']);
      var html_parts = layout.map(function (name) {
        if (name === 'sep') return sep();
        return ITEMS[name] ? ITEMS[name]() : '';
      });
      /* opts.custom: plugin-defined buttons, e.g. the pop-up theme-button
         inserters. Each: { name, label, title, onClick(editorInstance) }. */
      (o.custom || []).forEach(function (c) {
        html_parts.push('<button type="button" class="ae-btn ae-btn--custom" data-ae-custom="' +
          esc(c.name) + '" title="' + esc(c.title || c.label) + '">' + esc(c.label) + '</button>');
      });
      toolbar = '<div class="ae-toolbar" role="toolbar">' + html_parts.join('') + '</div>';
    }

    var height = o.height || 500;
    var panes =
      '<div class="ae-panes" style="height:' + parseInt(height, 10) + 'px">' +
        '<div class="ae-pane ae-pane-visual" data-ae-pane="visual">' +
          '<div class="ae-visual ae-content" ' + (this.readonly ? '' : 'contenteditable="true"') + ' spellcheck="true"></div>' +
        '</div>' +
        '<div class="ae-pane ae-pane-markdown" data-ae-pane="markdown">' +
          '<textarea class="ae-source" ' + (this.readonly ? 'readonly' : '') + ' spellcheck="false"></textarea>' +
        '</div>' +
        '<div class="ae-pane ae-pane-html" data-ae-pane="html">' +
          '<textarea class="ae-source" ' + (this.readonly ? 'readonly' : '') + ' spellcheck="false"></textarea>' +
        '</div>' +
      '</div>';

    var footer =
      '<div class="ae-footer">' +
        '<span class="ae-count"></span>' +
      '</div>';

    root.innerHTML = tabs + toolbar + panes + footer;

    // Hide the original textarea but keep it in the form flow
    this.textarea.style.display = 'none';
    this.textarea.parentNode.insertBefore(root, this.textarea);

    this.root = root;
    this.visual = root.querySelector('.ae-visual');
    this.mdArea = root.querySelector('.ae-pane-markdown .ae-source');
    this.htmlArea = root.querySelector('.ae-pane-html .ae-source');
    this.countEl = root.querySelector('.ae-count');
    this.toolbarEl = root.querySelector('.ae-toolbar');

    /* Activate the initial tab explicitly. The panes are display:none
       until they carry .is-active, and only switchTab() hands that
       class out — but switchTab('visual') at init returns early
       ("already the current tab"), so on first open NO pane was
       visible: the editor looked empty even though the model held the
       page content, until the user clicked any tab. */
    var initialTabBtn = root.querySelector('[data-ae-tab="' + this.tab + '"]');
    var initialPane = root.querySelector('[data-ae-pane="' + this.tab + '"]');
    if (initialTabBtn) {
      initialTabBtn.classList.add('is-active');
      initialTabBtn.setAttribute('aria-selected', 'true');
    }
    if (initialPane) initialPane.classList.add('is-active');
  };

  Editor.prototype._paletteHtml = function (cmd) {
    var html = '<span class="ae-palette" data-ae-palette="' + cmd + '">';
    html += '<button type="button" class="ae-swatch ae-swatch-none" data-ae-color="" title="' + esc(t('default_color')) + '">✕</button>';
    PALETTE.forEach(function (c) {
      html += '<button type="button" class="ae-swatch" data-ae-color="' + c + '" style="background:' + c + '"></button>';
    });
    return html + '</span>';
  };

  /* ── Sync model ──────────────────────────────────────────
     this.html is the canonical value. _pull() reads the active
     tab into this.html; _push(tab) writes this.html into a tab.

     The _dirty flag matters: HTML -> Markdown is LOSSY (inline
     styles, embeds, unknown attrs have no MD representation),
     so we only re-derive this.html when the pane was actually
     edited. Merely LOOKING at the Markdown tab and switching
     back must never change published content. */
  Editor.prototype._pull = function () {
    if (this.readonly || !this._dirty) return;
    if (this.tab === 'visual') this.html = cleanHtml(this.visual.innerHTML, { strict: false });
    else if (this.tab === 'markdown') this.html = mdToHtml(this.mdArea.value);
    else this.html = cleanHtml(this.htmlArea.value, { strict: false });
    this._dirty = false;
  };

  Editor.prototype._push = function (tab) {
    if (tab === 'visual') this.visual.innerHTML = this.html || '<p><br></p>';
    else if (tab === 'markdown') this.mdArea.value = htmlToMd(this.html);
    else this.htmlArea.value = prettyHtml(this.html);
    this._dirty = false;
  };

  Editor.prototype.switchTab = function (name) {
    if (name === this.tab) return;
    this._pull();
    this.tab = name;
    this._push(name);
    var root = this.root;
    root.querySelectorAll('.ae-tab').forEach(function (b) {
      b.classList.toggle('is-active', b.dataset.aeTab === name);
      b.setAttribute('aria-selected', b.dataset.aeTab === name ? 'true' : 'false');
    });
    root.querySelectorAll('.ae-pane').forEach(function (p) {
      p.classList.toggle('is-active', p.dataset.aePane === name);
    });
    /* display (not visibility): collapse the toolbar's space entirely in
       markdown/html tabs. visibility:hidden kept an invisible dead zone the
       size of the toolbar, which looked clickable but wasn't — users clicked
       into it expecting to focus the textarea. */
    if (this.toolbarEl) this.toolbarEl.style.display = (name === 'visual') ? '' : 'none';
    this.save();
  };

  /* ── Public API (TinyMCE-compatible subset) ─────────────── */
  Editor.prototype.save = function () {
    this._pull();
    this.textarea.value = this.html || '';
    this._updateCount();
  };

  Editor.prototype.getContent = function (args) {
    this._pull();
    var format = (args && args.format) || 'html';
    if (format === 'text') return htmlToText(this.html);
    if (format === 'md' || format === 'markdown') return htmlToMd(this.html);
    return this.html || '';
  };

  Editor.prototype.setContent = function (html) {
    this.html = this.readonly ? (html || '') : cleanHtml(html || '', { strict: false });
    this._push(this.tab);
    this.textarea.value = this.html;
    this._updateCount();
  };

  Editor.prototype._updateCount = function () {
    if (!this.countEl) return;
    var text = htmlToText(this.html).trim();
    var words = text ? text.split(/\s+/).length : 0;
    this.countEl.textContent = words + ' ' + t('words') + ' · ' + text.length + ' ' + t('chars');
  };

  /* ── Commands ───────────────────────────────────────────── */
  Editor.prototype._exec = function (cmd, value) {
    this.visual.focus();
    try { document.execCommand(cmd, false, value || null); } catch (e) {}
    this._dirty = true;
    this._debouncedSave();
  };

  Editor.prototype._runCommand = function (cmd) {
    var self = this;
    switch (cmd) {
      case 'undo': case 'redo': case 'bold': case 'italic': case 'underline':
      case 'strikeThrough': case 'insertUnorderedList': case 'insertOrderedList':
      case 'removeFormat': case 'unlink':
        this._exec(cmd);
        if (cmd === 'removeFormat') this._clearAlign();
        break;
      case 'alignLeft': this._align('left'); break;
      case 'alignCenter': this._align('center'); break;
      case 'alignRight': this._align('right'); break;
      case 'createLink':
        var linkRange = this._saveRange();
        dialog({ title: t('link'), fields: [
          { name: 'url', label: t('link_prompt'), value: 'https://' }
        ] }).then(function (v) {
          if (!v || !v.url || v.url === 'https://') return;
          self._restoreRange(linkRange);
          self._exec('createLink', v.url.trim());
        });
        break;
      case 'insertImage':
        /* Resolve the picker at CALL time, not init time: templates pass
           window.openImagePicker, but if admin-image-picker.js loads
           after the init snippet, that binding captured undefined and
           the ugly URL fallback fired instead of the media library. */
        var picker = this.opts.imagePicker || global.openImagePicker;
        var imgRange = this._saveRange();
        if (typeof picker === 'function') {
          picker(function (imgUrl, meta) {
            self._restoreRange(imgRange);
            self._exec('insertHTML',
              '<img src="' + esc(imgUrl) + '" alt="' + esc(meta && meta.alt || '') + '" loading="lazy">');
          });
        } else {
          dialog({ title: t('image'), fields: [
            { name: 'url', label: t('image_url_prompt') },
            { name: 'alt', label: t('image_alt_prompt') }
          ] }).then(function (v) {
            if (!v || !v.url || !v.url.trim()) return;
            self._restoreRange(imgRange);
            self._exec('insertHTML', '<img src="' + esc(v.url.trim()) + '" alt="' + esc(v.alt || '') + '" loading="lazy">');
          });
        }
        break;
      case 'insertTable': this._insertTable(); break;
      case 'codeBlock': this._insertCodeBlock(); break;
      case 'inlineCode': this._toggleInlineCode(); break;
      case 'insertHr': this._exec('insertHTML', '<hr><p><br></p>'); break;
      case 'fullscreen':
        this.root.classList.toggle('ae-fullscreen');
        document.body.classList.toggle('ae-body-lock', this.root.classList.contains('ae-fullscreen'));
        break;
    }
    this._updateToolbarState();
  };

  /* Alignment via classes instead of inline text-align, so the
     server-side sanitizer / theme keep full control of styling. */
  Editor.prototype._align = function (dir) {
    var block = this._closestBlock();
    if (!block) return;
    block.classList.remove('ae-align-left', 'ae-align-center', 'ae-align-right');
    if (dir !== 'left') block.classList.add('ae-align-' + dir);
    if (!block.className) block.removeAttribute('class');
    this._dirty = true;
    this._debouncedSave();
  };

  Editor.prototype._clearAlign = function () {
    var block = this._closestBlock();
    if (block) {
      block.classList.remove('ae-align-center', 'ae-align-right');
      if (!block.className) block.removeAttribute('class');
    }
  };

  Editor.prototype._closestBlock = function () {
    var sel = window.getSelection();
    if (!sel.rangeCount) return null;
    var node = sel.getRangeAt(0).startContainer;
    if (node.nodeType === 3) node = node.parentNode;
    while (node && node !== this.visual) {
      if (/^(P|H[1-6]|BLOCKQUOTE|LI|DIV|PRE|FIGURE)$/.test(node.tagName)) return node;
      node = node.parentNode;
    }
    return null;
  };

  Editor.prototype._insertTable = function () {
    var self = this;
    var range = this._saveRange();
    dialog({ title: t('table'), fields: [
      { name: 'cols', label: t('columns'), value: '3', type: 'number', min: 1, max: 12 },
      { name: 'rows', label: t('rows'), value: '3', type: 'number', min: 1, max: 30 }
    ] }).then(function (v) {
      if (!v) return;
      var cols = Math.min(Math.max(parseInt(v.cols, 10) || 3, 1), 12);
      var rows = Math.min(Math.max(parseInt(v.rows, 10) || 3, 1), 30);
      self._restoreRange(range);
      self._insertTableAt(cols, rows);
    });
  };

  Editor.prototype._insertTableAt = function (cols, rows) {
    var html = '<table><thead><tr>';
    for (var c = 0; c < cols; c++) html += '<th><br></th>';
    html += '</tr></thead><tbody>';
    for (var r = 0; r < rows; r++) {
      html += '<tr>';
      for (c = 0; c < cols; c++) html += '<td><br></td>';
      html += '</tr>';
    }
    html += '</tbody></table><p><br></p>';
    this._exec('insertHTML', html);
  };

  Editor.prototype._insertCodeBlock = function () {
    var self = this;
    var sel = window.getSelection();
    var text = sel && sel.rangeCount ? sel.toString() : '';
    var range = this._saveRange();
    dialog({ title: t('code_block'), fields: [
      { name: 'lang', label: t('code_lang_prompt'), placeholder: 'python, js, css, \u2026' }
    ] }).then(function (v) {
      if (v === null) return;
      var lang = (v.lang || '').trim();
      var cls = (lang && /^[a-z0-9+#-]+$/i.test(lang))
        ? ' class="language-' + lang.toLowerCase() + '"' : '';
      self._restoreRange(range);
      self._exec('insertHTML',
        '<pre><code' + cls + '>' + (text ? esc(text) : '<br>') + '</code></pre><p><br></p>');
    });
  };

  Editor.prototype._toggleInlineCode = function () {
    var sel = window.getSelection();
    if (!sel.rangeCount) return;
    var node = sel.getRangeAt(0).startContainer;
    if (node.nodeType === 3) node = node.parentNode;
    var codeEl = node.closest ? node.closest('code') : null;
    if (codeEl && this.visual.contains(codeEl) && codeEl.parentNode.tagName !== 'PRE') {
      // Unwrap existing inline code
      var parent = codeEl.parentNode;
      while (codeEl.firstChild) parent.insertBefore(codeEl.firstChild, codeEl);
      parent.removeChild(codeEl);
      this._dirty = true;
      this._debouncedSave();
    } else {
      var text = sel.toString();
      if (text) this._exec('insertHTML', '<code>' + esc(text) + '</code>');
    }
  };

  /* Public: insert raw HTML at the caret in the Visual pane.
     Intended for plugin custom buttons (see opts.custom); the caller is
     responsible for the markup being server-whitelist-friendly. */
  Editor.prototype.insertHtml = function (html) {
    if (this.readonly) return;
    if (this.tab !== 'visual') this.switchTab('visual');
    this._exec('insertHTML', html);
  };

  Editor.prototype._saveRange = function () {
    var sel = window.getSelection();
    return sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
  };
  Editor.prototype._restoreRange = function (range) {
    if (!range) return;
    this.visual.focus();
    var sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  };

  Editor.prototype._updateToolbarState = function () {
    if (!this.toolbarEl) return;
    var map = { bold: 'bold', italic: 'italic', underline: 'underline',
                strikeThrough: 'strikeThrough',
                insertUnorderedList: 'insertUnorderedList',
                insertOrderedList: 'insertOrderedList' };
    this.toolbarEl.querySelectorAll('.ae-btn[data-ae-cmd]').forEach(function (b) {
      var cmd = map[b.dataset.aeCmd];
      if (!cmd) return;
      var active = false;
      try { active = document.queryCommandState(cmd); } catch (e) {}
      b.classList.toggle('is-active', !!active);
    });
    // Reflect current block in the select
    var block = this._closestBlock();
    var select = this.toolbarEl.querySelector('.ae-block-select');
    if (select && block) {
      var tag = block.tagName.toLowerCase();
      select.value = /^(h[1-4]|blockquote)$/.test(tag) ? tag : 'p';
    }
  };

  /* ── Events ─────────────────────────────────────────────── */
  Editor.prototype._bind = function () {
    var self = this;
    var root = this.root;

    this._debouncedSave = (function () {
      var timer = null;
      return function () {
        clearTimeout(timer);
        timer = setTimeout(function () { self.save(); }, 400);
      };
    })();

    // Tabs
    root.querySelectorAll('.ae-tab').forEach(function (b) {
      b.addEventListener('click', function () { self.switchTab(b.dataset.aeTab); });
    });

    // Fullscreen works in readonly too (it lives in the tab bar)
    root.querySelector('[data-ae-cmd="fullscreen"]')
      .addEventListener('click', function () { self._runCommand('fullscreen'); });

    if (!this.readonly) {
      // Toolbar: prevent focus loss, dispatch commands
      this.toolbarEl.addEventListener('mousedown', function (e) {
        if (e.target.closest('.ae-btn, .ae-swatch')) e.preventDefault();
      });
      this.toolbarEl.addEventListener('click', function (e) {
        var swatch = e.target.closest('.ae-swatch');
        if (swatch) {
          var paletteCmd = swatch.closest('.ae-palette').dataset.aePalette;
          if (swatch.dataset.aeColor) self._exec(paletteCmd, swatch.dataset.aeColor);
          else self._exec('removeFormat');
          return;
        }
        var b = e.target.closest('.ae-btn');
        if (!b) return;
        if (b.dataset.aeCustom) {
          var c = (self.opts.custom || []).filter(function (x) { return x.name === b.dataset.aeCustom; })[0];
          if (c && typeof c.onClick === 'function') c.onClick(self);
          return;
        }
        if (b.dataset.aeCmd !== 'fullscreen') self._runCommand(b.dataset.aeCmd);
      });
      this.toolbarEl.addEventListener('change', function (e) {
        var s = e.target.closest('.ae-block-select');
        if (s) self._exec('formatBlock', '<' + s.value + '>');
      });

      // Visual pane
      this.visual.addEventListener('input', function () {
        self._dirty = true;
        self._debouncedSave();
      });
      ['keyup', 'mouseup', 'focus'].forEach(function (ev) {
        self.visual.addEventListener(ev, function () { self._updateToolbarState(); });
      });
      this.visual.addEventListener('paste', function (e) {
        e.preventDefault();
        var cb = e.clipboardData || window.clipboardData;
        var html = cb.getData('text/html');
        if (html) {
          document.execCommand('insertHTML', false, cleanHtml(html));
        } else {
          document.execCommand('insertText', false, cb.getData('text/plain'));
        }
        self._dirty = true;
        self._debouncedSave();
      });
      // Keyboard shortcuts: Tab inside code blocks, Ctrl/Cmd+S passthrough
      this.visual.addEventListener('keydown', function (e) {
        if (e.key === 'Tab' && self._closestBlock() && self._closestBlock().tagName === 'PRE') {
          e.preventDefault();
          document.execCommand('insertText', false, '    ');
        }
      });

      // Source panes sync
      [this.mdArea, this.htmlArea].forEach(function (area) {
        area.addEventListener('input', function () {
          self._dirty = true;
          self._debouncedSave();
        });
      });
    }
  };

  /* ── Module API ─────────────────────────────────────────── */
  var AliothEditor = {
    init: function (opts) {
      opts = opts || {};
      var nodes = document.querySelectorAll(opts.selector || '#content');
      var made = [];
      nodes.forEach(function (ta) {
        if (ta.tagName !== 'TEXTAREA') return;
        if (_instances[ta.id]) return; // already initialized
        made.push(new Editor(ta, opts));
      });
      return made.length === 1 ? made[0] : made;
    },
    get: function (id) { return _instances[id] || null; },
    triggerSave: function () {
      for (var id in _instances) {
        try { _instances[id].save(); } catch (e) {}
      }
    },
    // Exposed for reuse (e.g. plugins)
    dialog: dialog,
    cleanHtml: cleanHtml,
    htmlToMd: htmlToMd,
    mdToHtml: mdToHtml
  };

  global.AliothEditor = AliothEditor;
})(window);
