/*
  Tags which contain arbitary non-parsed content
  For example: <script> JavaScript should not be parsed
*/
export const childlessTags = ['style', 'script', 'template'];

/*
  Tags which auto-close because they cannot be nested
  For example: <p>Outer<p>Inner is <p>Outer</p><p>Inner</p>
*/
export const closingTags = ['html', 'head', 'body', 'p', 'dt', 'dd', 'li', 'option', 'thead', 'th', 'tbody', 'tr', 'td', 'tfoot', 'colgroup'];

/*
  Closing tags which have ancestor tags which
  may exist within them which prevent the
  closing tag from auto-closing.
  For example: in <li><ul><li></ul></li>,
  the top-level <li> should not auto-close.
*/
export const closingTagAncestorBreakers = {
  li: ['ul', 'ol', 'menu'],
  dt: ['dl'],
  dd: ['dl'],
  tbody: ['table'],
  thead: ['table'],
  tfoot: ['table'],
  tr: ['table'],
  td: ['table'],
};

/*
  Tags which do not need the closing tag
  For example: <img> does not need </img>
*/
export const voidTags = ['!doctype', 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'];

export function uuidv4() {
  return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16));
}

export function isObject(val) {
  return val instanceof Object;
}

export function formatAttributes(attributes, options = {}) {
  return Object.keys(attributes).reduce((attrs, attrKey) => {
    const key = attrKey;
    var value = attributes[attrKey];

    if (value === null || (typeof value !== 'boolean' && !value)) {
      return `${attrs} ${key}`;
    }

    if (isObject(value)) {
      // For editor mode, just put raw JSON with single quotes around the attribute
      const jsonString = JSON.stringify(value);
      return `${attrs} ${key}='${jsonString}'`;
    }

    value = value.toString();
    // For simple strings, use double quotes
    return `${attrs} ${key}="${value}"`;
  }, '');
}

export function toHTML(tree, options) {
  return tree
    .map((node) => {
      if (!node.type) return;

      if (node.type === 'text') {
        return node.content;
      }
      if (node.type === 'comment') {
        return `<!--${node.content}-->`;
      }

      const escapeHtml = (unsafe) => {
        return unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;'); // or &apos;
      };

      var text = (node.attributes?.['xu-text'] && escapeHtml(node.attributes?.['xu-text'])) || node.attributes?.['xu-html'] || (node?.content && escapeHtml(node.content)) || '';
      // if (node.content) {
      //   text = node.content;
      // }

      var { tagName, attributes, children } = node;
      delete attributes.internal_tree_id;
      delete attributes.internal_path;
      attributes = JSON.parse(JSON.stringify(attributes));

      if (!attributes.internal_tree_id && !options.remove_tree_id) attributes.internal_tree_id = node.id;
      if (options.remove_tree_id) {
        delete attributes.internal_tree_id;
      }

      if (options.add_studio_meta) {
        attributes.internal_path = node?.path?.toString?.();
        attributes.xuda_hide = node.$hide;
      }

      const isSelfClosing = arrayIncludes(options.voidTags, tagName.toLowerCase());
      return isSelfClosing ? `<${tagName}${formatAttributes(attributes)}>` : `<${tagName}${formatAttributes(attributes)}>${text}${toHTML(children, options)}</${tagName}>`;
    })
    .join('');
}

export function parser(tokens, options) {
  const root = { tagName: null, children: [] };
  const state = { tokens, options, cursor: 0, stack: [root] };
  parserParse(state);
  return root.children;
}

export function hasTerminalParent(tagName, stack, terminals) {
  const tagParents = terminals[tagName];
  if (tagParents) {
    let currentIndex = stack.length - 1;
    while (currentIndex >= 0) {
      const parentTagName = stack[currentIndex].tagName;
      if (parentTagName === tagName) {
        break;
      }
      if (arrayIncludes(tagParents, parentTagName)) {
        return true;
      }
      currentIndex--;
    }
  }
  return false;
}

export function rewindStack(stack, newLength, childrenEndPosition, endPosition) {
  stack[newLength].position.end = endPosition;
  for (let i = newLength + 1, len = stack.length; i < len; i++) {
    stack[i].position.end = childrenEndPosition;
  }
  stack.splice(newLength);
}

export function parserParse(state) {
  const { tokens, options } = state;
  let { stack } = state;
  let nodes = stack[stack.length - 1].children;
  const len = tokens.length;
  let { cursor } = state;
  while (cursor < len) {
    const token = tokens[cursor];
    if (token.type !== 'tag-start') {
      nodes.push(token);
      cursor++;
      continue;
    }

    const tagToken = tokens[++cursor];
    cursor++;
    const tagName = tagToken.content.toLowerCase();
    if (token.close) {
      let index = stack.length;
      let shouldRewind = false;
      while (--index > -1) {
        if (stack[index].tagName === tagName) {
          shouldRewind = true;
          break;
        }
      }
      while (cursor < len) {
        const endToken = tokens[cursor];
        if (endToken.type !== 'tag-end') break;
        cursor++;
      }
      if (shouldRewind) {
        rewindStack(stack, index, token.position.start, tokens[cursor - 1].position.end);
        break;
      } else {
        continue;
      }
    }

    const isClosingTag = arrayIncludes(options.closingTags, tagName);
    let shouldRewindToAutoClose = isClosingTag;
    if (shouldRewindToAutoClose) {
      const { closingTagAncestorBreakers: terminals } = options;
      shouldRewindToAutoClose = !hasTerminalParent(tagName, stack, terminals);
    }

    if (shouldRewindToAutoClose) {
      // rewind the stack to just above the previous
      // closing tag of the same name
      let currentIndex = stack.length - 1;
      while (currentIndex > 0) {
        if (tagName === stack[currentIndex].tagName) {
          rewindStack(stack, currentIndex, token.position.start, token.position.start);
          const previousIndex = currentIndex - 1;
          nodes = stack[previousIndex].children;
          break;
        }
        currentIndex = currentIndex - 1;
      }
    }

    // let attributes = [];
    let attributes = {};
    let attrToken;
    while (cursor < len) {
      attrToken = tokens[cursor];
      if (attrToken.type === 'tag-end') break;
      // debugger;
      // attributes.push(attrToken.content);
      attributes[attrToken.content] = '';
      cursor++;
    }

    cursor++;
    const children = [];
    const position = {
      start: token.position.start,
      end: attrToken.position.end,
    };
    const elementNode = {
      type: 'element',
      tagName: tagToken.content,
      attributes,
      children,
      position,
    };
    nodes.push(elementNode);

    const hasChildren = !(attrToken.close || arrayIncludes(options.voidTags, tagName));
    if (hasChildren) {
      const size = stack.push({ tagName, children, position });
      const innerState = { tokens, options, cursor, stack };
      parserParse(innerState);
      cursor = innerState.cursor;
      const rewoundInElement = stack.length === size;
      if (rewoundInElement) {
        elementNode.position.end = tokens[cursor - 1].position.end;
      }
    }
  }
  state.cursor = cursor;
}

export function feedPosition(position, str, len) {
  const start = position.index;
  const end = (position.index = start + len);
  for (let i = start; i < end; i++) {
    const char = str.charAt(i);
    if (char === '\n') {
      position.line++;
      position.column = 0;
    } else {
      position.column++;
    }
  }
}

export function jumpPosition(position, str, end) {
  const len = end - position.index;
  return feedPosition(position, str, len);
}

export function makeInitialPosition() {
  return {
    index: 0,
    column: 0,
    line: 0,
  };
}

export function copyPosition(position) {
  return {
    index: position.index,
    line: position.line,
    column: position.column,
  };
}

export function lexer(str, options) {
  const state = {
    str,
    options,
    position: makeInitialPosition(),
    tokens: [],
  };
  lex(state);
  return state.tokens;
}

export function lex(state) {
  const {
    str,
    options: { childlessTags },
  } = state;
  const len = str.length;
  while (state.position.index < len) {
    const start = state.position.index;
    lexText(state);
    if (state.position.index === start) {
      const isComment = startsWith(str, '!--', start + 1);
      if (isComment) {
        lexComment(state);
      } else {
        const tagName = lexTag(state);
        const safeTag = tagName.toLowerCase();
        if (arrayIncludes(childlessTags, safeTag)) {
          lexSkipTag(tagName, state);
        }
      }
    }
  }
}

const alphanumeric = /[A-Za-z0-9]/;
export function findTextEnd(str, index) {
  while (true) {
    const textEnd = str.indexOf('<', index);
    if (textEnd === -1) {
      return textEnd;
    }
    const char = str.charAt(textEnd + 1);
    if (char === '/' || char === '!' || alphanumeric.test(char)) {
      return textEnd;
    }
    index = textEnd + 1;
  }
}

export function lexText(state) {
  const type = 'text';
  const { str, position } = state;

  let textEnd = findTextEnd(str, position.index);
  if (textEnd === position.index) return;
  if (textEnd === -1) {
    textEnd = str.length;
  }

  const start = copyPosition(position);
  const content = str.slice(position.index, textEnd)?.trim();
  jumpPosition(position, str, textEnd);
  const end = copyPosition(position);
  if (content) state.tokens.push({ type, content, position: { start, end } });
}

export function lexComment(state) {
  const { str, position } = state;
  const start = copyPosition(position);
  feedPosition(position, str, 4); // "<!--".length
  let contentEnd = str.indexOf('-->', position.index);
  let commentEnd = contentEnd + 3; // "-->".length
  if (contentEnd === -1) {
    contentEnd = commentEnd = str.length;
  }

  const content = str.slice(position.index, contentEnd);
  jumpPosition(position, str, commentEnd);
  state.tokens.push({
    type: 'comment',
    content,
    position: {
      start,
      end: copyPosition(position),
    },
  });
}

export function lexTag(state) {
  const { str, position } = state;
  {
    const secondChar = str.charAt(position.index + 1);
    const close = secondChar === '/';
    const start = copyPosition(position);
    feedPosition(position, str, close ? 2 : 1);
    state.tokens.push({ type: 'tag-start', close, position: { start } });
  }
  const tagName = lexTagName(state);
  lexTagAttributes(state);
  {
    const firstChar = str.charAt(position.index);
    const close = firstChar === '/';
    feedPosition(position, str, close ? 2 : 1);
    const end = copyPosition(position);
    state.tokens.push({ type: 'tag-end', close, position: { end } });
  }
  return tagName;
}

// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#special-white-space
const whitespace = /\s/;
export function isWhitespaceChar(char) {
  return whitespace.test(char);
}

export function lexTagName(state) {
  const { str, position } = state;
  const len = str.length;
  let start = position.index;
  while (start < len) {
    const char = str.charAt(start);
    const isTagChar = !(isWhitespaceChar(char) || char === '/' || char === '>');
    if (isTagChar) break;
    start++;
  }

  let end = start + 1;
  while (end < len) {
    const char = str.charAt(end);
    const isTagChar = !(isWhitespaceChar(char) || char === '/' || char === '>');
    if (!isTagChar) break;
    end++;
  }

  jumpPosition(position, str, end);
  const tagName = str.slice(start, end);
  state.tokens.push({
    type: 'tag',
    content: tagName,
  });
  return tagName;
}

export function lexTagAttributes(state) {
  const { str, position, tokens } = state;
  let cursor = position.index;
  let quote = null; // null, single-, or double-quote
  let wordBegin = cursor; // index of word start
  const words = []; // "key", "key=value", "key='value'", etc
  const len = str.length;
  while (cursor < len) {
    const char = str.charAt(cursor);
    if (quote) {
      const isQuoteEnd = char === quote;
      if (isQuoteEnd) {
        quote = null;
      }
      cursor++;
      continue;
    }

    const isTagEnd = char === '/' || char === '>';
    if (isTagEnd) {
      if (cursor !== wordBegin) {
        words.push(str.slice(wordBegin, cursor));
      }
      break;
    }

    const isWordEnd = isWhitespaceChar(char);
    if (isWordEnd) {
      if (cursor !== wordBegin) {
        words.push(str.slice(wordBegin, cursor));
      }
      wordBegin = cursor + 1;
      cursor++;
      continue;
    }

    const isQuoteStart = char === "'" || char === '"';
    if (isQuoteStart) {
      quote = char;
      cursor++;
      continue;
    }

    cursor++;
  }
  jumpPosition(position, str, cursor);

  const wLen = words.length;
  const type = 'attribute';
  for (let i = 0; i < wLen; i++) {
    const word = words[i];
    const isNotPair = word.indexOf('=') === -1;
    if (isNotPair) {
      const secondWord = words[i + 1];
      if (secondWord && startsWith(secondWord, '=')) {
        if (secondWord.length > 1) {
          const newWord = word + secondWord;
          tokens.push({ type, content: newWord });
          i += 1;
          continue;
        }
        const thirdWord = words[i + 2];
        i += 1;
        if (thirdWord) {
          const newWord = word + '=' + thirdWord;
          tokens.push({ type, content: newWord });
          i += 1;
          continue;
        }
      }
    }
    if (endsWith(word, '=')) {
      const secondWord = words[i + 1];
      if (secondWord && !stringIncludes(secondWord, '=')) {
        const newWord = word + secondWord;
        tokens.push({ type, content: newWord });
        i += 1;
        continue;
      }

      const newWord = word.slice(0, -1);
      tokens.push({ type, content: newWord });
      continue;
    }

    tokens.push({ type, content: word });
  }
}

const push = [].push;

export function lexSkipTag(tagName, state) {
  const { str, position, tokens } = state;
  const safeTagName = tagName.toLowerCase();
  const len = str.length;
  let index = position.index;
  while (index < len) {
    const nextTag = str.indexOf('</', index);
    if (nextTag === -1) {
      lexText(state);
      break;
    }

    const tagStartPosition = copyPosition(position);
    jumpPosition(tagStartPosition, str, nextTag);
    const tagState = { str, position: tagStartPosition, tokens: [] };
    const name = lexTag(tagState);
    if (safeTagName !== name.toLowerCase()) {
      index = tagState.position.index;
      continue;
    }

    if (nextTag !== position.index) {
      const textStart = copyPosition(position);
      jumpPosition(position, str, nextTag);

      tokens.push({
        type: 'text',
        content: str.slice(textStart.index, nextTag),
        position: {
          start: textStart,
          end: copyPosition(position),
        },
      });
    }

    push.apply(tokens, tagState.tokens);
    jumpPosition(position, str, tagState.position.index);
    break;
  }
}

export function startsWith(str, searchString, position) {
  return str.substr(position || 0, searchString.length) === searchString;
}

export function endsWith(str, searchString, position) {
  const index = (position || str.length) - searchString.length;
  const lastIndex = str.lastIndexOf(searchString, index);
  return lastIndex !== -1 && lastIndex === index;
}

export function stringIncludes(str, searchString, position) {
  return str.indexOf(searchString, position || 0) !== -1;
}

export function isRealNaN(x) {
  return typeof x === 'number' && isNaN(x);
}

export function arrayIncludes(array, searchElement, position) {
  const len = array.length;
  if (len === 0) return false;

  const lookupIndex = position | 0;
  const isNaNElement = isRealNaN(searchElement);
  let searchIndex = lookupIndex >= 0 ? lookupIndex : len + lookupIndex;
  while (searchIndex < len) {
    const element = array[searchIndex++];
    if (element === searchElement) return true;
    if (isNaNElement && isRealNaN(element)) return true;
  }

  return false;
}

export function splitHead(str, sep) {
  const idx = str.indexOf(sep);
  if (idx === -1) return [str];
  return [str.slice(0, idx), str.slice(idx + sep.length)];
}

export function unquote(str) {
  const car = str.charAt(0);
  const end = str.length - 1;
  const isQuoteStart = car === '"' || car === "'";
  if (isQuoteStart && car === str.charAt(end)) {
    return str.slice(1, end);
  }
  return str;
}

export function format(nodes, options) {
  return nodes.map((node) => {
    var outputNode = {};
    const type = node.type;

    if (node.children) {
      var textIndex = node.children?.findIndex((e) => {
        return e.type === 'text';
      });
      if (textIndex !== -1) {
        outputNode.content = node.children[textIndex]?.content?.trim();
        node.children.splice(textIndex, 1);
      }
    }

    switch (type) {
      case 'element':
        var ATTRS = renderFormatAttributes(node.attributes);
        delete ATTRS.internal_tree_id;

        outputNode = {
          ...outputNode,
          type,
          tagName: node.tagName.toLowerCase(),
          attributes: ATTRS,
          children: format(node.children, options),
          id: node.id || generateTreeId(),
        };
        break;

      default:
        outputNode = { type, content: node.content?.trim() };
        break;
    }

    if (options.includePositions) {
      outputNode.position = node.position;
    }

    return outputNode;
  });
}

export function renderFormatAttributes(attributes) {
  var ret = {};

  Object.entries(attributes).forEach(([attribute, val]) => {
    const parts = splitHead(attribute.trim(), '=');
    const key = parts[0];
    const value = typeof parts[1] === 'string' ? unquote(parts[1]) : null;

    var getValue = (value) => {
      if (!value) return value;

      // Check if it looks like JSON (starts with [, {, or ")
      const trimmed = value.trim();
      const looksLikeJSON = trimmed.startsWith('[') || trimmed.startsWith('{') || (trimmed.startsWith('"') && trimmed.endsWith('"'));

      if (looksLikeJSON) {
        try {
          return JSON.parse(value);
        } catch (error) {
          console.warn(`Failed to parse JSON attribute ${key}:`, value, error);
          return value; // Return as string if parsing fails
        }
      }

      // For non-JSON values, return as-is
      return value;
    };

    ret[key] = getValue(value);
  });

  return ret;
}

export const parseDefaults = {
  voidTags,
  closingTags,
  childlessTags,
  closingTagAncestorBreakers,
  includePositions: false,
};

export function generateTreeId() {
  return 'node-' + uuidv4();
}

export function xudaPrase(str, options = parseDefaults) {
  const tokens = lexer(str, options);
  const nodes = parser(tokens, options);

  return format(nodes, { ...parseDefaults, ...options });
}

export function xudaStringify(ast, options) {
  return toHTML(ast, { ...parseDefaults, ...options });
}
