/* global IITC, log -- eslint */

/**
 * @file Draws an options page from its blocks. The page is drawn again as a whole after every change made
 *       on it, so a block whose title, visibility or value depends on other options follows them without
 *       any binding. Focus, the text cursor and text typed but not applied yet survive the redraw
 */

// the lists of About and the changelogs nest no deeper
const LIST_DEPTH = 3;

// properties shown on the page; any of them may be a function of the repeated item
const DISPLAY_PROPERTIES = ['title', 'description', 'button', 'icon', 'text', 'url', 'level', 'items', 'visible', 'enabled', 'value', 'selected'];

// handler a block cannot work without, by type
const HANDLERS = { choice: 'action', action: 'action', export: 'data', import: 'onData' };

// declared blocks already warned about, so a redraw does not repeat the warning
const reported = new WeakSet();

// the declared block behind an evaluated copy
const DECLARED = Symbol('declared');

// ids that tie labels to their fields
let lastId = 0;

// the name of the group of radios a declared choice draws
const choiceGroups = new WeakMap();

/**
 * What a container remembers between drawings
 *
 * @memberof IITC.options
 * @inner
 * @private
 * @typedef {Object} ContainerState
 * @property {string} pageId - Page drawn last
 * @property {Map<string, Object>} messages - Texts left by actions of that page, by control id
 * @property {Set<string>} pending - Actions still running, as `<pageId>#<control id>`
 * @property {boolean} scheduled - A redraw is waiting
 * @property {boolean} pointerDown - A mouse button or a finger is down in the container
 * @property {Function} [afterPointer] - The redraw waiting for the pointer to come up
 */
const states = new WeakMap();

const stateOf = (container) => {
  let state = states.get(container);
  if (state) return state;
  state = { pageId: undefined, query: '', messages: new Map(), pending: new Set(), scheduled: false, pointerDown: false };
  states.set(container, state);
  // a redraw between the press and the release would take the pressed control away, and its click with it
  container.addEventListener('pointerdown', () => {
    state.pointerDown = true;
    const released = () => {
      state.pointerDown = false;
      const redraw = state.afterPointer;
      state.afterPointer = undefined;
      // after the click that follows the release
      if (redraw) setTimeout(redraw);
    };
    const doc = container.ownerDocument;
    doc.addEventListener('pointerup', released, { once: true, capture: true });
    doc.addEventListener('pointercancel', released, { once: true, capture: true });
  });
  return state;
};

const warnOnce = (block, message) => {
  const declared = block[DECLARED] ?? block;
  if (reported.has(declared)) return;
  reported.add(declared);
  log.warn(message);
};

const el = (tag, props = {}, ...children) => {
  const element = document.createElement(tag);
  for (const [name, value] of Object.entries(props)) {
    if (value === undefined || value === null || value === false) continue;
    if (name === 'className') element.className = value;
    else if (name === 'dataset') Object.assign(element.dataset, value);
    else if (name in element) element[name] = value;
    else element.setAttribute(name, value === true ? '' : value);
  }
  element.append(...children.filter((child) => child !== undefined && child !== null && child !== false));
  return element;
};

// a line telling how an action or a change went, with an icon of success or of failure
const note = (text, error, role = 'status') =>
  el(
    'div',
    { className: error ? 'iitc-options-message iitc-options-error' : 'iitc-options-message', role },
    el('iitc-icon', { fallback: error ? '!' : '✓' }, error ? 'error' : 'check_circle'),
    el('span', { className: 'iitc-options-message-text' }, text)
  );

const asText = (value) => (value === undefined || value === null ? '' : String(value));

// the block with its display properties worked out for this drawing
const evaluate = (block, item) => {
  const result = { ...block, [DECLARED]: block };
  for (const name of DISPLAY_PROPERTIES) {
    if (typeof block[name] === 'function') result[name] = block[name](item);
  }
  return result;
};

const handlerOf = (block) => {
  const name = HANDLERS[block.type];
  if (!name || typeof block[name] === 'function') return block[name];
  warnOnce(block, `options: ${block.type} block "${block.title}" has no ${name} function`);
  return undefined;
};

/**
 * One drawing of a page into a container, or of a section an extension adds to the page
 *
 * @memberof IITC.options
 * @inner
 * @private
 */
class PageView {
  constructor(pageId, container, section = undefined) {
    this.pageId = pageId;
    this.container = container;
    this.page = IITC.options._pages[pageId];
    this.state = stateOf(container);
    // a section has keys of its own, and control ids apart from the ones of the page's blocks
    this.namespace = section ? section.namespace : this.page.namespace;
    this.prefix = section ? `+${encodeURIComponent(section.id)}/` : '';
  }

  // whether the container still shows this page
  get current() {
    return this.container.isConnected && this.state.pageId === this.pageId;
  }

  redraw() {
    if (this.current) renderPage(this.pageId, this.container);
  }

  // runs after the current task, and after the click when the pointer is down, so the control pressed or
  // tabbed to stays in place
  later(run) {
    if (!this.state.pointerDown) {
      setTimeout(run);
      return;
    }
    const before = this.state.afterPointer;
    this.state.afterPointer = before
      ? () => {
          before();
          run();
        }
      : run;
  }

  // a redraw after the current task, and after the click; changes before it share one redraw
  scheduleRedraw() {
    if (this.state.scheduled) return;
    this.state.scheduled = true;
    this.later(() => {
      this.state.scheduled = false;
      this.redraw();
    });
  }

  // runs the handler of an action and waits for it, then leaves the text it returns or the error it throws
  // next to the control and redraws; the control stays disabled meanwhile
  async act(id, handler) {
    const pending = `${this.pageId}#${id}`;
    if (this.state.pending.has(pending)) return;
    this.state.pending.add(pending);
    this.state.messages.clear();
    this.redraw();
    let message;
    try {
      const text = await handler();
      if (typeof text === 'string' && text) message = { text };
    } catch (e) {
      log.warn(`options: action on page "${this.pageId}" failed`, e);
      message = { text: e?.message ?? String(e), error: true };
    } finally {
      this.state.pending.delete(pending);
    }
    if (!this.current) return;
    if (message) this.state.messages.set(id, message);
    this.redraw();
  }

  isPending(id) {
    return this.state.pending.has(`${this.pageId}#${id}`);
  }

  // texts left by actions of the control `id`
  notes(id) {
    return [...this.state.messages].filter(([key]) => key === id || key.startsWith(`${id}:`)).map(([, message]) => note(message.text, message.error));
  }

  // full key and shown value of a field; inside a repeat the value is the item's entry of `map`
  field(block, item, map) {
    const key = `${this.namespace}.${block.key}`;
    if (block.value !== undefined) return { key, value: block.value };
    if (!item) return { key, value: IITC.options.get(key) };
    return { key, value: map?.[item.id] ?? IITC.options._entryDefault(key, item) };
  }

  // stores a value a field was changed to; a refused value stays in the field with the reason under its row
  change(block, item, value, input) {
    this.state.messages.clear();
    const key = `${this.namespace}.${block.key}`;
    const next = item ? { ...IITC.options._entries(key), [item.id]: value } : value;
    const check = IITC.options.check(key, next);
    if (check !== true || !IITC.options.set(key, next)) {
      input.setAttribute('aria-invalid', 'true');
      const box = input.closest('.iitc-options-row, td') ?? input.parentElement;
      box.querySelector('.iitc-options-error')?.remove();
      box.append(note(check === true ? 'The value could not be stored' : check, true, 'alert'));
      return;
    }
    this.scheduleRedraw();
  }

  // a running action keeps its button focusable, marked as unavailable; act ignores it meanwhile
  // with an icon, the text is the tooltip, and shows in place of the icon while the icon font is missing
  button(id, text, enabled, handler, icon) {
    return el(
      'button',
      {
        type: 'button',
        className: icon && 'iitc-options-icon-button',
        title: icon && text,
        'aria-label': icon && text,
        dataset: { focusId: id },
        disabled: !enabled || !handler,
        'aria-disabled': this.isPending(id) && 'true',
        onclick: () => this.act(id, handler),
      },
      icon ? el('iitc-icon', { fallback: text }, icon) : text
    );
  }

  control(block, item, id, map) {
    const enabled = block.enabled !== false;
    switch (block.type) {
      case 'boolean':
      case 'number':
      case 'string':
      case 'select':
      case 'color':
        return this.input(block, item, id, enabled, map);
      case 'action': {
        const action = handlerOf(block);
        // without a text of its own the button carries the title
        const text = asText(block.button ?? block.title);
        return this.button(id, text, enabled, action && (() => action(item)), block.icon);
      }
      case 'choice':
        return this.choice(block, item, id, enabled);
      case 'export': {
        const data = handlerOf(block);
        const title = asText(block.title);
        return el(
          'span',
          { className: 'iitc-options-buttons' },
          this.button(`${id}:copy`, 'Copy', enabled, data && (() => IITC.utils.exportText(data(item), { title }))),
          block.filename && this.button(`${id}:save`, 'Save file', enabled, data && (() => IITC.utils.exportFile(data(item), block.filename, block.dataType)))
        );
      }
      case 'import': {
        const onFiles = typeof block.onFiles === 'function' ? block.onFiles : undefined;
        // a block that reads files with onFiles needs no onData, and has no Paste button without it
        const onData = onFiles && typeof block.onData !== 'function' ? undefined : handlerOf(block);
        const title = asText(block.title);
        // the text onData returns tells what was imported
        const report = (text) => (typeof text === 'string' && text ? text : 'Imported');
        const paste = async () => {
          const text = await IITC.utils.importText({ title });
          if (text === undefined) return undefined;
          return report(await onData(text, undefined, item));
        };
        const read = async (files) => report(onFiles ? await onFiles(files, item) : await onData(files[0].text, files[0].name, item));
        // the button waits only for reading the files: importFile never settles when no file is chosen.
        // Read later, once the press that opened the file dialog is done
        const open = () => {
          IITC.utils.importFile({ accept: block.accept, multiple: Boolean(onFiles) }).then((files) => {
            if (files.length) this.later(() => this.act(`${id}:open`, () => read(files)));
          });
        };
        return el(
          'span',
          { className: 'iitc-options-buttons' },
          (onData || !onFiles) && this.button(`${id}:paste`, 'Paste', enabled, onData && paste),
          block.accept && this.button(`${id}:open`, 'Choose file', enabled, (onFiles || onData) && open)
        );
      }
      default:
        return undefined;
    }
  }

  input(block, item, id, enabled, map) {
    if (block.key === undefined) {
      warnOnce(block, `options: ${block.type} block "${block.title}" on page "${this.pageId}" has no key`);
      return undefined;
    }
    const { key, value } = this.field(block, item, map);
    // fields in a repeat have no label of their own
    const name = item ? asText(block.title) : undefined;
    const common = { dataset: { focusId: id }, disabled: !enabled, title: name, 'aria-label': name };
    const commit = (input, next) => this.change(block, item, next, input);

    switch (block.type) {
      case 'boolean': {
        const input = el('input', { ...common, type: 'checkbox', checked: value === true });
        input.addEventListener('change', () => commit(input, input.checked));
        return input;
      }
      case 'number': {
        const input = el('input', { ...common, type: 'number', min: block.min, max: block.max, step: block.step ?? 'any' });
        input.defaultValue = String(value ?? '');
        input.addEventListener('change', () => commit(input, input.value.trim() === '' ? NaN : Number(input.value)));
        return input;
      }
      case 'string': {
        const input = el('input', { ...common, type: 'text' });
        input.defaultValue = String(value ?? '');
        input.addEventListener('change', () => commit(input, input.value));
        return input;
      }
      case 'select': {
        const list = IITC.options._choices(key);
        const known = list.some((choice) => choice.value === value);
        // the index stands for the value, so values of any type survive the round trip through the DOM;
        // a value that is not among the options shows as such, so choosing any option is a change
        const select = el(
          'select',
          common,
          !known && el('option', { value: '', disabled: true, selected: true }, String(value ?? '')),
          ...list.map((choice, index) => el('option', { value: String(index), selected: choice.value === value }, asText(choice.label)))
        );
        select.addEventListener('change', () => commit(select, list[Number(select.value)]?.value));
        return select;
      }
      case 'color': {
        const rgb = typeof value === 'string' ? value.slice(0, 7) : '#000000';
        const input = el('input', { ...common, type: 'color', value: rgb });
        if (!block.alpha) {
          input.addEventListener('change', () => commit(input, input.value));
          return input;
        }
        // the alpha channel as a percentage next to the color, both forming one value
        const alpha = typeof value === 'string' && value.length === 9 ? Math.round((parseInt(value.slice(7), 16) / 255) * 100) : 100;
        const opacity = el('input', {
          dataset: { focusId: `${id}:alpha` },
          disabled: !enabled,
          type: 'range',
          min: 0,
          max: 100,
          value: alpha,
          title: 'Opacity',
          'aria-label': 'Opacity',
        });
        const combined = () => {
          const byte = Math.round((Number(opacity.value) / 100) * 255);
          return byte === 255 ? input.value : input.value + byte.toString(16).padStart(2, '0');
        };
        input.addEventListener('change', () => commit(input, combined()));
        opacity.addEventListener('change', () => commit(opacity, combined()));
        return el('span', { className: 'iitc-options-color' }, input, opacity);
      }
    }
    return undefined;
  }

  // a radio before the title of the item, checked on the selected one; choosing another runs the action
  // after the click, as a change does, since the action redraws the page
  choice(block, item, id, enabled) {
    if (!item) {
      warnOnce(block, `options: choice block "${block.title}" on page "${this.pageId}" works only inside a repeat`);
      return undefined;
    }
    const action = handlerOf(block);
    const declared = block[DECLARED];
    if (!choiceGroups.has(declared)) choiceGroups.set(declared, `iitc-options-choice-${++lastId}`);
    // named by the label of its row
    const input = el('input', {
      type: 'radio',
      name: choiceGroups.get(declared),
      checked: block.selected === true,
      disabled: !enabled || !action,
      dataset: { focusId: id },
    });
    input.addEventListener('change', () => this.later(() => this.act(id, () => action(item))));
    return input;
  }

  list(items, depth) {
    return el(
      'ul',
      { className: 'iitc-options-list' },
      ...(items ?? []).map((entry) => {
        // the tooltip belongs to the entry's own line, not to its nested entries
        const head = el(
          'div',
          { className: 'iitc-options-list-head', title: entry.tooltip },
          el('span', { className: 'iitc-options-list-title' }, String(entry.title ?? '')),
          entry.badge !== undefined && el('span', { className: 'iitc-options-badge' }, String(entry.badge))
        );
        const sub = entry.sub !== undefined && el('div', { className: 'iitc-options-sub' }, String(entry.sub));
        const nested = entry.items?.length && depth < LIST_DEPTH ? this.list(entry.items, depth + 1) : undefined;
        const className = entry.state ? `iitc-options-item-${entry.state}` : undefined;
        if (nested && entry.collapsed) {
          const summary = el('summary', {}, el('iitc-icon', { fallback: '▸' }, 'chevron_right'), head);
          return el('li', { className }, el('details', {}, summary, sub, nested));
        }
        return el('li', { className }, head, sub, nested);
      })
    );
  }

  // the block as a row of the page; undefined for a block that is not shown or fails to draw
  row(declared, index) {
    const id = this.prefix + index;
    try {
      return this.buildRow(declared, id);
    } catch (e) {
      log.warn(`options: block ${id} of page "${this.pageId}" failed to draw`, e);
      return undefined;
    }
  }

  buildRow(declared, id) {
    const block = evaluate(declared);
    if (block.visible === false) return undefined;

    const title = asText(block.title);
    const description = block.description !== undefined && el('div', { className: 'iitc-options-description' }, asText(block.description));

    switch (block.type) {
      case 'heading':
        return el('h3', { className: 'iitc-options-heading' }, title);
      case 'text':
        return el('p', { className: 'iitc-options-text' }, asText(block.text));
      case 'link':
        return el('a', { className: 'iitc-options-link', href: block.url, target: '_blank', rel: 'noopener', dataset: { focusId: id } }, title);
      case 'notice':
        return el('div', { className: `iitc-options-notice iitc-options-notice-${block.level ?? 'info'}` }, asText(block.text));
      case 'list':
        return this.list(block.items, 1);
      case 'repeat':
        return this.repeat(declared, id);
    }

    const control = this.control(block, undefined, id);
    if (!control) return undefined;
    if (block.type === 'action' && block.button === undefined) {
      // the button carries the title, so only the description is left on the label side
      return el(
        'div',
        { className: 'iitc-options-row iitc-options-action' },
        description && el('div', { className: 'iitc-options-label' }, description),
        el('div', { className: 'iitc-options-control' }, control),
        ...this.notes(id)
      );
    }
    // a field gets its title as a label, so clicking the title reaches the field
    const field = control.matches('input, select') ? control : control.querySelector('input, select');
    if (field) field.id = `iitc-options-field-${++lastId}`;
    const label = el(
      field ? 'label' : 'div',
      { className: 'iitc-options-label', htmlFor: field?.id },
      el('span', { className: 'iitc-options-title' }, title),
      description
    );
    return el(
      'div',
      { className: `iitc-options-row iitc-options-${block.type}`, dataset: block.key ? { key: `${this.namespace}.${block.key}` } : {} },
      label,
      el('div', { className: 'iitc-options-control' }, control),
      // under the label and the control, so they stay on one line
      ...this.notes(id)
    );
  }

  cell(inner, item, id, maps) {
    // buttons keep to the end of the row, the title taking the room left
    const props = { className: HANDLERS[inner.type] ? 'iitc-options-cell-buttons' : undefined };
    try {
      const block = evaluate(inner, item);
      if (block.visible === false) return el('td', props);
      return el('td', props, this.control(block, item, id, maps.get(inner)), ...this.notes(id));
    } catch (e) {
      log.warn(`options: block ${id} of page "${this.pageId}" failed to draw`, e);
      return el('td', props);
    }
  }

  // the radio of the choice of a repeat for one item; nothing while the choice is hidden or fails
  radio(inner, item, id) {
    try {
      const block = evaluate(inner, item);
      if (block.visible === false) return undefined;
      return this.choice(block, item, id, block.enabled !== false);
    } catch (e) {
      log.warn(`options: block ${id} of page "${this.pageId}" failed to draw`, e);
      return undefined;
    }
  }

  repeat(block, id) {
    const items = block.over?.() ?? [];
    // headers without rows tell nothing
    if (!items.length) return undefined;
    const blocks = block.blocks ?? [];
    // each map is read once for all items
    const maps = new Map(blocks.filter((inner) => inner.key !== undefined).map((inner) => [inner, IITC.options.get(`${this.namespace}.${inner.key}`)]));
    const choices = blocks.filter((inner) => inner.type === 'choice');
    for (const extra of choices.slice(1)) warnOnce(extra, `options: a repeat on page "${this.pageId}" takes one choice; "${extra.title}" is left out`);
    const choiceId = (item) => `${id}/${item.id}/${blocks.indexOf(choices[0])}`;
    const columns = blocks.map((inner, innerIndex) => [inner, innerIndex]).filter(([inner]) => inner.type !== 'choice');
    const rows = items.map((item) => {
      const cells = columns.map(([inner, innerIndex]) => this.cell(inner, item, `${id}/${item.id}/${innerIndex}`, maps));
      const title = String(item.title ?? item.id);
      // the radio of the choice stands before the title of its row, which is its label; the messages of its
      // action follow the title
      const radio = choices.length ? this.radio(choices[0], item, choiceId(item)) : undefined;
      const head = radio ? el('label', { className: 'iitc-options-choice' }, radio, title) : title;
      return el('tr', {}, el('th', { scope: 'row' }, head, ...(choices.length ? this.notes(choiceId(item)) : [])), ...cells);
    });
    // a button carries its own text, so its column needs no header; a table of buttons has no header row
    const headers = columns.map(([inner]) => (HANDLERS[inner.type] || typeof inner.title === 'function' ? '' : asText(inner.title)));
    const head = headers.some(Boolean) && el('thead', {}, el('tr', {}, el('th'), ...headers.map((header) => el('th', { scope: 'col' }, header))));
    return el('table', { className: 'iitc-options-repeat' }, head, el('tbody', {}, ...rows));
  }

  draw() {
    const rows = this.page.blocks.map((block, index) => this.row(block, index));
    // sections of extensions follow the page's own blocks, each under its title
    for (const section of IITC.options._sections(this.pageId)) {
      const view = new PageView(this.pageId, this.container, section);
      rows.push(
        el('h3', { className: 'iitc-options-heading', dataset: { extension: section.id } }, section.title),
        ...section.blocks.map((block, index) => view.row(block, index))
      );
    }
    return el('div', { className: 'iitc-options-page', dataset: { page: this.pageId } }, ...rows);
  }
}

// the focused control of the container, to give the focus back after a redraw
const saveFocus = (container) => {
  const active = container.ownerDocument.activeElement;
  if (!active || !container.contains(active) || !active.dataset?.focusId) return undefined;
  const state = { id: active.dataset.focusId };
  if (active.tagName === 'INPUT' && (active.type === 'text' || active.type === 'number')) {
    // text typed but not applied yet
    if (active.value !== active.defaultValue) state.value = active.value;
    try {
      state.selection = [active.selectionStart, active.selectionEnd];
    } catch {
      // number inputs have no selection
    }
  }
  return state;
};

const restoreFocus = (container, state) => {
  if (!state) return;
  const target = [...container.querySelectorAll('[data-focus-id]')].find((element) => element.dataset.focusId === state.id);
  if (!target) return;
  if (state.value !== undefined) target.value = state.value;
  target.focus();
  if (state.selection?.[0] !== null && state.selection?.[0] !== undefined) {
    try {
      target.setSelectionRange(...state.selection);
    } catch {
      // the control no longer takes a selection
    }
  }
};

// the elements whose text a search looks at
const SEARCHED_ELEMENTS =
  '.iitc-options-label, .iitc-options-text, .iitc-options-notice, .iitc-options-heading, .iitc-options-link, .iitc-options-action button';

// marks what contains the query, ignoring case: the whole row of a control, otherwise the element itself
const markMatches = (root, query) => {
  const needle = query.trim().toLowerCase();
  if (!needle) return;
  for (const element of root.querySelectorAll(SEARCHED_ELEMENTS)) {
    if (element.textContent.toLowerCase().includes(needle)) (element.closest('.iitc-options-row') ?? element).classList.add('iitc-options-match');
  }
};

/**
 * Draws an options page into a container, replacing what the container holds. Changes made on the page
 * draw it again; a change made elsewhere needs another call
 *
 * @alias IITC.options._renderPage
 * @private
 * @param {string} pageId - Id of a registered page
 * @param {HTMLElement} container - Element to draw into
 * @param {string} [query] - Search whose matches on the page are marked; stays in force for the redraws
 *                           until another is given
 * @returns {boolean} False when no such page is registered
 */
const renderPage = (pageId, container, query) => {
  if (!IITC.options._pages[pageId]) {
    log.warn(`options: no page "${pageId}" to draw`);
    return false;
  }
  const state = stateOf(container);
  // the focus and the messages belong to the page drawn before
  const samePage = state.pageId === pageId;
  if (query !== undefined) state.query = query;
  else if (!samePage) state.query = '';
  if (!samePage) state.messages.clear();
  state.pageId = pageId;
  const focus = samePage ? saveFocus(container) : undefined;
  container.replaceChildren(new PageView(pageId, container).draw());
  markMatches(container, state.query);
  restoreFocus(container, focus);
  return true;
};

// for the options window: draws the page a container shows again after the current task, and after
// the click when the pointer is down, as after a change made on the page
const redrawLater = (container) => {
  const pageId = states.get(container)?.pageId;
  if (pageId) new PageView(pageId, container).scheduleRedraw();
};

IITC.options._renderPage = renderPage;
IITC.options._redrawLater = redrawLater;
// shared with the options window
IITC.options._el = el;