/* global $ $create messageBoxProxy */// dom.js
/* global CodeMirror */
/* global MozSectionFinder */
/* global colorMimicry */
/* global editor */
/* global msg */
/* global prefs */
/* global t */// localization.js
/* global tryCatch */// toolbox.js
'use strict';

/* exported MozSectionWidget */
function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
  let TPL, EVENTS, CLICK_ROUTE;
  const KEY = 'MozSectionWidget';
  const C_CONTAINER = '.applies-to';
  const C_LABEL = 'label';
  const C_LIST = '.applies-to-list';
  const C_ITEM = '.applies-to-item';
  const C_TYPE = '.applies-type';
  const C_VALUE = '.applies-value';
  /** @returns {MarkedFunc} */
  const getFuncFor = el => el.closest(C_ITEM)[KEY];
  /** @returns {MarkedFunc[]} */
  const getFuncsFor = el => el.closest(C_LIST)[KEY];
  /** @returns {MozSection} */
  const getSectionFor = el => el.closest(C_CONTAINER)[KEY];
  const {cmpPos} = CodeMirror;
  let enabled = false;
  let funcHeight = 0;
  /** @type {HTMLStyleElement} */
  let actualStyle;

  return {
    toggle(enable) {
      if (Boolean(enable) !== enabled) {
        (enable ? init : destroy)();
      }
    },
  };

  function init() {
    const hint = {title: t('appliesHelp')};
    enabled = true;
    TPL = {
      container:
        $create('div' + C_CONTAINER, [
          $create(C_LABEL, hint, t('appliesLabel')),
          $create('ul' + C_LIST),
        ]),
      listItem:
        t.template.appliesTo.cloneNode(true),
      appliesToEverything:
        $create('li.applies-to-everything', t('appliesToEverything')),
    };

    Object.assign($(C_TYPE, TPL.listItem), hint);
    $(C_VALUE, TPL.listItem).after(
      $create('button.test-regexp', t('genericTest')));

    CLICK_ROUTE = {
      '.test-regexp': showRegExpTester,
      /**
       * @param {HTMLElement} elItem
       * @param {MarkedFunc} func
       */
      '.remove-applies-to'(elItem, func) {
        const funcs = getFuncsFor(elItem);
        if (funcs.length < 2) {
          messageBoxProxy.show({
            contents: t('appliesRemoveError'),
            buttons: [t('confirmClose')],
          });
          return;
        }
        const i = funcs.indexOf(func);
        const next = funcs[i + 1];
        const from = i ? funcs[i - 1].item.find(1) : func.item.find(-1);
        const to = next ? next.item.find(-1) : func.item.find(1);
        cm.replaceRange(i && next ? ', ' : '', from, to);
      },
      /**
       * @param {HTMLElement} elItem
       * @param {MarkedFunc} func
       */
      '.add-applies-to'(elItem, func) {
        const pos = func.item.find(1);
        cm.replaceRange(`, ${func.typeText}("")`, pos, pos);
      },
    };

    EVENTS = {
      onchange({target: el}) {
        EVENTS.oninput({target: el.closest(C_TYPE) || el});
      },
      oninput({target: el}) {
        const part =
          el.matches(C_VALUE) && 'value' ||
          el.matches(C_TYPE) && 'type';
        if (!part) return;
        const func = getFuncFor(el);
        const pos = func[part].find();
        if (part === 'type' && el.value !== func.typeText) {
          func.typeText = func.item[KEY].dataset.type = el.value;
        }
        if (part === 'value' && func === getFuncsFor(el)[0]) {
          const sec = getSectionFor(el);
          sec.tocEntry.target = el.value;
          if (!sec.tocEntry.label) editor.updateToc([sec]);
        }
        cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
      },
      onclick(event) {
        const {target} = event;
        for (const selector in CLICK_ROUTE) {
          const routed = target.closest(selector);
          if (routed) {
            const elItem = routed.closest(C_ITEM);
            CLICK_ROUTE[selector](elItem, elItem[KEY], event);
            return;
          }
        }
      },
    };

    actualStyle = $create('style');

    cm.on('optionChange', onCmOption);
    msg.onExtension(onRuntimeMessage);
    if (finder.sections.length) {
      update(finder.sections, []);
    }
    finder.on(update);
    updateWidgetStyle(); // updating in this paint frame to avoid FOUC for dark themes
    cm.display.wrapper.style.setProperty('--cm-bar-width', cm.display.barWidth + 'px');
  }

  function destroy() {
    enabled = false;
    cm.off('optionChange', onCmOption);
    msg.off(onRuntimeMessage);
    actualStyle.remove();
    actualStyle = null;
    cm.operation(() => finder.sections.forEach(killWidget));
    finder.off(update);
  }

  function onCmOption(cm, option) {
    if (option === 'theme') {
      updateWidgetStyle();
    }
  }

  function onRuntimeMessage(msg) {
    if (msg.reason === 'editPreview' && !$(`#stylus-${msg.style.id}`)) {
      // no style element with this id means the style doesn't apply to the editor URL
      return;
    }
    if (msg.style || msg.styles ||
        msg.prefs && 'disableAll' in msg.prefs ||
        msg.method === 'colorScheme' ||
        msg.method === 'styleDeleted') {
      requestAnimationFrame(updateWidgetStyle);
    }
  }

  function updateWidgetStyle() {
    funcHeight = 0;
    if (prefs.get('editor.theme') !== 'default' &&
        !tryCatch(() => $('#cm-theme').sheet.cssRules)) {
      requestAnimationFrame(updateWidgetStyle);
      return;
    }
    const MIN_LUMA = .05;
    const MIN_LUMA_DIFF = .4;
    const color = {
      wrapper: colorMimicry(cm.display.wrapper),
      gutter: colorMimicry(cm.display.gutters, {
        bg: 'backgroundColor',
        border: 'borderRightColor',
      }),
      line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
      comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
    };
    const hasBorder =
      color.gutter.style.borderRightWidth !== '0px' &&
      !/transparent|\b0\)/g.test(color.gutter.style.borderRightColor);
    const diff = {
      wrapper: Math.abs(color.gutter.bgLuma - color.wrapper.foreLuma),
      border: hasBorder ? Math.abs(color.gutter.bgLuma - color.gutter.borderLuma) : 0,
      line: Math.abs(color.gutter.bgLuma - color.line.foreLuma),
    };
    const preferLine = diff.line > diff.wrapper || diff.line > MIN_LUMA_DIFF;
    const fore = preferLine ? color.line.fore : color.wrapper.fore;

    const border = fore.replace(/[\d.]+(?=\))/, MIN_LUMA_DIFF / 2);
    const borderStyleForced = `1px ${hasBorder ? color.gutter.style.borderRightStyle : 'solid'} ${border}`;

    actualStyle.textContent = `
      ${C_CONTAINER} {
        background-color: ${color.gutter.bg};
        border-top: ${borderStyleForced};
        border-bottom: ${borderStyleForced};
      }
      ${C_CONTAINER} ${C_LABEL} {
        color: ${fore};
      }
      ${C_CONTAINER} input,
      ${C_CONTAINER} select {
        background: rgba(255, 255, 255, ${
          Math.max(MIN_LUMA, Math.pow(Math.max(0, color.gutter.bgLuma - MIN_LUMA * 2), 2)).toFixed(2)
        });
        border: ${borderStyleForced};
        transition: none;
        color: ${fore};
      }
      ${C_CONTAINER} .svg-icon.select-arrow {
        fill: ${fore};
        transition: none;
      }
    `;
    $.root.appendChild(actualStyle);
  }

  /**
   * @param {MozSection[]} added
   * @param {MozSection[]} removed
   * @param {number} cutAt
   */
  function update(added, removed, cutAt = finder.sections.indexOf(added[0])) {
    const isDelayed = added.isDelayed && (cm.startOperation(), true);
    const toDelay = [];
    const t0 = performance.now();
    let {viewFrom, viewTo} = cm.display;
    for (const sec of added) {
      const i = removed.findIndex(isReusableWidget, sec);
      const old = removed[i];
      if (isDelayed || old || sec.end.line >= viewFrom && sec.start.line < viewTo) {
        renderWidget(sec, old);
        viewTo -= (sec.funcs.length || 1) * 1.25;
        if (old) removed[i] = null;
        if (performance.now() - t0 > 50) {
          toDelay.push(...added.slice(added.indexOf(sec) + 1));
          break;
        }
      } else {
        toDelay.push(sec);
      }
    }
    // renumber
    for (let i = Math.max(0, cutAt), {sections} = finder, sec; (sec = sections[i++]);) {
      if (!toDelay.includes(sec)) {
        const data = $(C_LABEL, sec.widget.node).dataset;
        if (data.index !== `${i}`) data.index = `${i}`;
      }
    }
    if (toDelay.length) {
      toDelay.isDelayed = true;
      setTimeout(update, 0, toDelay, removed);
    } else {
      removed.forEach(killWidget);
    }
    if (isDelayed) cm.endOperation();
  }

  /** @this {MozSection} */
  function isReusableWidget(r) {
    return r &&
      r.widget &&
      r.widget.line.parent &&
      r.start &&
      !cmpPos(r.start, this.start);
  }

  function renderWidget(sec, old) {
    let widget = old && old.widget;
    const height = Math.round(funcHeight * (sec.funcs.length || 1)) || undefined;
    const node = renderContainer(sec, widget);
    if (widget && widget.line.lineNo() === sec.start.line) {
      widget.node = node;
      if (height && height !== widget.height) {
        widget.height = height;
        widget.changed();
      }
    } else {
      if (widget) widget.clear();
      widget = cm.addLineWidget(sec.start.line, node, {
        coverGutter: true,
        noHScroll: true,
        above: true,
        height,
      });
      widget.on('redraw', () => {
        const value = cm.display.barWidth + 'px';
        if (widget[KEY] !== value) {
          widget[KEY] = value;
          node.style.setProperty('--cm-bar-width', value);
        }
      });
    }
    if (!funcHeight) {
      funcHeight = node.offsetHeight / (sec.funcs.length || 1);
    }
    setProp(sec, 'widget', widget);
    return widget;
  }

  /**
   * @param {MozSection} sec
   * @param {LineWidget} oldWidget
   * @returns {Node}
   */
  function renderContainer(sec, oldWidget) {
    const container = oldWidget ? oldWidget.node : TPL.container.cloneNode(true);
    const elList = $(C_LIST, container);
    const {funcs} = sec;
    const oldItems = elList[KEY] || false;
    const items = funcs.map((f, i) => renderFunc(f, oldItems[i]));
    let slot = elList.firstChild;
    for (const {item} of items) {
      const el = item[KEY];
      if (el !== slot) {
        elList.insertBefore(el, slot);
        if (slot) slot.remove();
        slot = el;
      }
      slot = slot.nextSibling;
    }
    for (let i = funcs.length; oldItems && i < oldItems.length; i++) {
      killFunc(oldItems[i]);
      if (slot) {
        const el = slot.nextSibling;
        slot.remove();
        slot = el;
      }
    }
    if (!funcs.length && (!oldItems || oldItems.length)) {
      TPL.appliesToEverything.cloneNode(true);
    }
    setProp(sec, 'widgetFuncs', items);
    elList[KEY] = items;
    container[KEY] = sec;
    container.classList.toggle('error', !sec.funcs.length);
    return Object.assign(container, EVENTS);
  }

  /**
   * @param {MozSectionFunc} func
   * @param {MarkedFunc} old
   * @returns {MarkedFunc}
   */
  function renderFunc(func, old = {}) {
    const {
      type,
      value,
      isQuoted = false,
      start,
      start: {line},
      typeEnd = {line, ch: start.ch + type.length},
      valuePos = {line, ch: typeEnd.ch + 1 + Boolean(isQuoted)},
      valueEnd = {line, ch: valuePos.ch + value.length},
      end = {line, ch: valueEnd.ch + Boolean(isQuoted) + 1},
    } = func;
    const el = (old.item || {})[KEY] || TPL.listItem.cloneNode(true);
    /** @namespace MarkedFunc */
    const res = el[KEY] = {
      typeText: type,
      item: markFuncPart(start, end, old.item, el),
      type: markFuncPart(start, typeEnd, old.type, $(C_TYPE, el), type, toLowerCase),
      value: markFuncPart(valuePos, valueEnd, old.value, $(C_VALUE, el), value, fromDoubleslash),
    };
    if (el.dataset.type !== type) {
      el.dataset.type = type;
    }
    return res;
  }

  /**
   * @param {CodeMirror.Pos} start
   * @param {CodeMirror.Pos} end
   * @param {TextMarker} marker
   * @param {HTMLElement} el
   * @param {string} [text]
   * @param {function} [textTransform]
   * @returns {TextMarker}
   */
  function markFuncPart(start, end, marker, el, text, textTransform) {
    if (marker) {
      const pos = marker.find();
      if (!pos ||
          cmpPos(pos.from, start) ||
          cmpPos(pos.to, end) ||
          text != null && text !== cm.getRange(start, end)) {
        marker.clear();
        marker = null;
      }
    }
    if (!marker) {
      marker = cm.markText(start, end, {
        clearWhenEmpty: false,
        inclusiveLeft: true,
        inclusiveRight: true,
        [KEY]: el,
      });
    }
    if (text != null) {
      text = textTransform(text);
      if (el.value !== text) el.value = text;
    }
    return marker;
  }

  /** @type {MozSection} sec */
  function killWidget(sec) {
    const w = sec && sec.widget;
    if (w) {
      w.clear();
      w.node[KEY].widgetFuncs.forEach(killFunc);
    }
  }

  /** @type {MarkedFunc} f */
  function killFunc(f) {
    f.item.clear();
    f.type.clear();
    f.value.clear();
  }

  async function showRegExpTester(el) {
    /* global regexpTester */
    await require(['/edit/regexp-tester']);
    const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
    regexpTester.toggle(true);
    regexpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
  }

  function fromDoubleslash(s) {
    return /([^\\]|^)\\([^\\]|$)/.test(s) ? s : s.replace(/\\\\/g, '\\');
  }

  function toDoubleslash(s) {
    return fromDoubleslash(s).replace(/\\/g, '\\\\');
  }

  function toLowerCase(s) {
    return s.toLowerCase();
  }

  /** Adds a non-enumerable property so it won't be seen by deepEqual */
  function setProp(obj, name, value) {
    return Object.defineProperty(obj, name, {value, configurable: true});
  }
}
