'use strict';

const TemplateEngine = (() => {
  /**
   * @typedef {import('./actions').Dat} Dat
   */

  /**
   * @typedef {import('./actions').Prop} Prop
   */

  /**
   * @typedef {Object} Options
   * @property {boolean} [showAll=false] - Whether to render all items, including those that are hidden. If false, only visible items will be rendered.
   * @property {boolean} [textOnly=false] - Whether to render only text content. If false, template will be rendered in its full form using HTML elements.
   * @property {boolean} [importantOnly=false] - Whether to render only important properties. If false, all possible items will be rendered.
   * @property {number} [symbolsLimit=100] - The maximum number of visible symbols for a property.
   * @property {number} [propertiesLimit=100] - The maximum number of visible properties for an action.
   */

  /**
   * Hash for mapping the props based on the action data.
   *
   * @type {Record<string, (dat: Dat) => Dat['d'][0]>}
   */
  const propDataMapping = {
    'checkbox-wait-element': (dat) => ({ data: dat.p.we, is_def: dat.p.we }),
    'checkbox-fail-action': (dat) => ({ data: dat.p.fa, is_def: dat.p.fa }),
    'use-timeout': (dat) => ({ data: dat.uti, is_def: dat.uti === '0' }),
    'use-waiter': (dat) => ({ data: dat.uw, is_def: dat.uw === '0' }),
    'CodeInfo': () => ({ data: 'data', is_def: false }),
    'PATH': () => ({ data: 'data', is_def: false }),
  };

  /**
   * Hash for final replacements during template processing.
   */
  const replacementsTable = {
    '%BAS_VARIABLE_START%': '<span class="textitemsignificant">',
    '%BAS_VARIABLE_END%': '</span>',
    '%BAS_OMISSION_START%': '<span class="param-omission">',
    '%BAS_OMISSION_END%': '</span>',
    '%BAS_CONTENT_START%': '<span class="param-content">',
    '%BAS_CONTENT_END%': '</span>',
    '%BAS_COUNTER_START%': '<span class="param-counter">',
    '%BAS_COUNTER_END%': '</span>',
    '%BAS_LABEL_START%': '<span class="param-info">',
    '%BAS_LABEL_END%': '</span>',
    '%BAS_PROP_START%': '<span class="param-prop" data-name>',
    '%BAS_PROP_END%': '</span>',
    '%BAS_PATH_START%': '<span class="param-path">',
    '%BAS_PATH_END%': '</span>',
    '%BAS_TEXT_START%': '<span class="param-text">',
    '%BAS_TEXT_END%': '</span>',
    '%BAS_WHITESPACE%': '\u00A0',
  };

  /**
   * Parse the template for the preparing process.
   *
   * @param {string} template - The original action template.
   */
  const parseTemplate = (template) => {
    // Parsing via a template is the fastest way:
    const root = document.createElement('template');
    return (root.innerHTML = template), [...root.content.childNodes];
  };

  /**
   * Prepare the template for the parsing process.
   *
   * @param {string} template - The original action template.
   */
  const cleanupTemplate = (template) => {
    return (
      template
        // Replace the self-closing tags, because the parser can incorrectly process them.
        .replace(/<(\w+)([^>/]*)\/\s*>/g, (_, tag, attrs) => `<${tag}${attrs}></${tag}>`)
        // Replace the bracket notation with the new HTML-like prop syntax.
        .replace(/{{(.*?)}}/g, '<prop name="$1"></prop>')
        // Replace the repeated whitespace characters with a single one.
        .replace(/\s+/g, ' ')
        // Remove all trailing/leading spaces and line terminators.
        .trim()
    );
  };

  /**
   * Prepare the template and parse the actual props.
   *
   * @param {string} template - The original action template.
   */
  const prepareTemplate = (template) => {
    if (!template) return { props: [], template: '' };
    const nodes = parseTemplate(cleanupTemplate(template));

    const parts = [];
    const props = [];
    let content = '';

    for (let i = 0; i < nodes.length; ++i) {
      const node = nodes[i];
      const isText = node.nodeType === Node.TEXT_NODE;
      const isElement = node.nodeType === Node.ELEMENT_NODE;

      if (isText || (isElement && node.tagName !== 'PROP')) {
        content = (content + node.textContent).replace(/\s+/g, ' ');
        continue;
      } else if (!isText && !isElement) {
        continue;
      }

      const name = node.getAttribute('name');
      const label = node.getAttribute('label');

      if (name) {
        const hasSeparator = content.includes('->');
        if (props.length === 0) parts.push(content);

        props.push({
          name,
          hasSeparator,
          isVisible: true,
          label: tr(label || name),
          showIf: node.getAttribute('show_if'),
          before: props.length === 0 ? '' : content,
          showLabel: node.getAttribute('show_label') || 'never',
          maxSize: parseInt(node.getAttribute('max_size')) || null,
          hideIfValueEquals: node.getAttribute('hide_if_value_equals'),
          hideIfDefault: node.getAttribute('hide_if_default') === 'true',
          isVariable: hasSeparator || node.getAttribute('is_variable') === 'true',
          priority: Math.min(Math.max(parseInt(node.getAttribute('priority')), 1), 100) || 1,
        });

        parts.push(`{{${name}}}`);
        content = '';
      }
    }

    return { props, template: parts.concat(content).join('') };
  };

  /**
   * Process the template and generate the actual text.
   *
   * @param {string} template - The prepared action template.
   * @param {Prop[]} props - The list of prepared props.
   * @param {Dat} dat - The information about the action.
   * @param {Options} options - The additional options for the template processing.
   */
  const processTemplate = (
    template,
    props,
    dat,
    { propertiesLimit = 100, importantOnly = false, textOnly = false, showAll = false }
  ) => {
    let isFirstVisibleProp = true;
    let visiblePropsCount = 0;
    let limitedPropsCount = 0;

    // Avoid mutation of the original props:
    const newProps = props.map((prop) => {
      if (importantOnly && prop.priority > 1) {
        return { ...prop, isVisible: false };
      }

      if (prop.showIf) {
        const [target, ...value] = prop.showIf.split(':');
        const targetProp = dat.d.find(({ id }) => id === target);
        if (targetProp) {
          const state = value.join(':');
          if (targetProp.data !== (targetProp.type === 'check' ? state === 'checked' : state)) {
            return { ...prop, isVisible: false };
          }
        }
      }

      if (showAll || !isHidden(prop, dat)) {
        if (visiblePropsCount >= propertiesLimit) {
          limitedPropsCount++;
          return { ...prop, isVisible: false };
        } else {
          visiblePropsCount++;
          return { ...prop, isVisible: true };
        }
      }

      return { ...prop, isVisible: false };
    });

    // No visible properties, always return an empty string:
    if (visiblePropsCount === 0 && limitedPropsCount === 0) {
      return '';
    }

    newProps.forEach(({ name, label, before, showLabel, isVisible, isVariable, hasSeparator }) => {
      const isPath = name === 'PATH';
      let replacement = hasSeparator ? '->' : '';

      if (isVisible) {
        const hasVisibleLabel = showLabel === 'always' || (showLabel === 'auto' && visiblePropsCount > 1);

        if (textOnly) {
          replacement = `${isFirstVisibleProp ? '' : before}${hasVisibleLabel ? `${label}=` : ''}{{${name}}}`;
        } else {
          replacement = `{{${name}}}`;

          if (isPath) {
            replacement = `%BAS_PATH_START%${replacement}%BAS_PATH_END%`;
          }

          if (isVariable) {
            replacement = `%BAS_VARIABLE_START%${replacement}%BAS_VARIABLE_END%`;
          }

          if (hasVisibleLabel) {
            replacement = `%BAS_LABEL_START%${label}=%BAS_LABEL_END%${replacement}`;
          }

          if (!isFirstVisibleProp) {
            const content = before.split('->').map((text) => {
              return `%BAS_TEXT_START%${text}%BAS_TEXT_END%`;
            });
            replacement = `%BAS_CONTENT_START%${content.join('->')}%BAS_CONTENT_END%${replacement}`;
          }

          replacement = `%BAS_PROP_START%${replacement}%BAS_PROP_END%`;
        }

        isFirstVisibleProp = false;
      }

      template = template.replace(new RegExp(`{{${name}}}`, 'g'), replacement);
    });

    let result = template.trim();

    // Remove the `->` if it's at the end of the template:
    if (result.endsWith('->')) {
      result = result.slice(0, -2).trimEnd();
    }

    // Remove the `->` if it's at the beginning of the template:
    if (result.startsWith('->')) {
      result = result.slice(2).trimStart();
    }

    // Add a notification that some properties are hidden due to limit:
    if (limitedPropsCount > 0) {
      if (textOnly) {
        result += ` + ${limitedPropsCount}`;
      } else {
        result += `%BAS_COUNTER_START% + ${limitedPropsCount}%BAS_COUNTER_END%`;
      }
    }

    return textOnly
      ? result.replace(/\s+/g, ' ')
      : result
          .replace(/\s+/g, '%BAS_WHITESPACE%')
          .replace(
            /(.*?)%BAS_PROP_END%%BAS_PROP_START%%BAS_CONTENT_START%(.*?)%BAS_CONTENT_END%/g,
            '$1%BAS_CONTENT_START%$2%BAS_CONTENT_END%%BAS_PROP_END%%BAS_PROP_START%'
          );
  };

  /**
   * Determine if a prop should be hidden based on its configuration and associated data.
   *
   * @param {Prop} prop - The prop object containing configuration for visibility.
   * @param {Dat} dat - The actual data associated with the prop to evaluate against.
   * @returns {boolean} Returns true if the prop should be hidden according to the given conditions, otherwise false.
   */
  const isHidden = (prop, dat) => {
    const propData = propDataMapping[prop.name]
      ? propDataMapping[prop.name](dat)
      : dat.d.find(({ id }) => id === prop.name);

    return (
      propData == null ||
      propData['data'] == null ||
      (prop.hideIfDefault && (propData['is_def'] || propData['is_def'] == null)) ||
      (typeof prop.hideIfValueEquals === 'string' && propData['data'].toString() === prop.hideIfValueEquals)
    );
  };

  /**
   * Escape and truncate the `value` string if its length exceeds the specified limits.
   *
   * If the string contains variables or resources, the `limit` is used instead of `length`.
   *
   * @param {string} value - The string to be processed.
   * @param {number} limit - The maximum allowed chars limit.
   * @param {number} length - The maximum allowed string length.
   * @returns {string} Returns the escaped and potentially truncated string.
   */
  const escapeValue = (value, limit, length) => {
    const bound = value.includes('{{') || value.includes('[[') ? limit : length;

    return _.escape(
      value.length > bound ? `${value.substring(0, bound - 3)}%BAS_OMISSION_START%...%BAS_OMISSION_END%` : value
    );
  };

  return {
    replace: (() => {
      const createReplacer = (propNames) => {
        let index = 0;
        return (match) => {
          const result = replacementsTable[match];
          return match === '%BAS_PROP_START%' && propNames.length
            ? result.replace('data-name', `data-name="${propNames[index++]}"`)
            : result;
        };
      };

      const pattern = new RegExp(Object.keys(replacementsTable).join('|'), 'g');
      return (text, remove = false, propNames = []) => {
        return text.replace(pattern, remove ? '' : createReplacer(propNames));
      };
    })(),
    escape: escapeValue,
    prepare: prepareTemplate,
    process: processTemplate,
  };
})();
