import {escapeHTML, isEmpty} from "./helpers.mjs";

/**
 * A class that behaves like a lightweight DOM node, allowing children to be appended. Serializes to an HTML string.
 */
export default class StringNode {
  /**
   * @param {string} [tag]            The tag name. If none is provided, this node's children will not be wrapped in an
   *                                  outer tag.
   * @param {Record<string, string>} [attrs]  The tag attributes.
   * @param {boolean} [inline=false]  Whether the node appears inline or as a block.
   */
  constructor(tag, attrs={}, inline=true) {
    /**
     * The tag name.
     * @type {string}
     */
    Object.defineProperty(this, "tag", {value: tag, writable: false});

    /**
     * The tag attributes.
     * @type {Record<string, string>}
     */
    Object.defineProperty(this, "attrs", {value: attrs, writable: false});

    this.#inline = inline;
  }

  /* -------------------------------------------- */

  /**
   * A list of HTML void elements that do not have a closing tag.
   * @type {Set<string>}
   */
  static #VOID = new Set([
    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"
  ]);

  /* -------------------------------------------- */

  /**
   * A list of children. Either other StringNodes, or plain strings.
   * @type {Array<StringNode|string>}
   */
  #children = [];

  /* -------------------------------------------- */

  /**
   * Whether the node appears inline or as a block.
   * @type {boolean}
   */
  #inline;

  /**
   * Whether the node appears inline or as a block.
   * @type {boolean}
   */
  get inline() {
    if ( !this.tag || StringNode.#VOID.has(this.tag) || !this.#children.length ) return true;
    return this.#inline;
  }

  /* -------------------------------------------- */

  /**
   * Append a child to this string node.
   * @param {StringNode|string} child  The child node or string.
   * @throws {Error}                   If attempting to append a child to a void element.
   */
  appendChild(child) {
    if ( StringNode.#VOID.has(this.tag) ) throw new Error("Void elements cannot contain children.");
    this.#children.push(child);
  }

  /* -------------------------------------------- */

  /**
   * Serialize the StringNode structure into a single string.
   * @param {string|number} spaces  The number of spaces to use for indentation (maximum 10). If this value is a string,
   *                                that string is used as indentation instead (or the first 10 characters if it is
   *                                longer).
   * @param {object} [internal]
   * @param {number} [internal._depth]
   * @param {boolean} [internal._inlineParent]
   */
  toString(spaces=0, {_depth=0, _inlineParent=false}={}) {
    let indent = "";
    const isRoot = _depth < 1;
    if ( !_inlineParent ) {
      if ( typeof spaces === "number" ) indent = " ".repeat(Math.min(10, spaces));
      else if ( typeof spaces === "string" ) indent = spaces.substring(0, 10);
      indent = indent.repeat(Math.max(0, _depth - 1));
    }
    const attrs = isEmpty(this.attrs) ? "" : ` ${Object.entries(this.attrs).map(([k, v]) => `${k}="${escapeHTML(v)}"`).join(" ")}`;
    const open = this.tag ? `${indent}<${this.tag}${attrs}>` : "";
    if ( StringNode.#VOID.has(this.tag) ) return open;
    const close = this.tag ? `${this.inline && !isRoot ? "" : indent}</${this.tag}>` : "";
    const children = this.#children.map(c => {
      let content = c.toString(spaces, {_depth: _depth + 1, _inlineParent: this.inline});
      if ( !isRoot && !this.tag ) content = StringNode.#escapeHTML(content);
      return content;
    });
    const lineBreak = (this.inline && !isRoot) || !spaces ? "" : "\n";
    return [open, ...children, close].filterJoin(lineBreak);
  }

  /* -------------------------------------------- */

  /**
   * Escape HTML tags within string content.
   * @param {string} content  The string content.
   * @returns {string}
   */
  static #escapeHTML(content) {
    return content.replace(/[<>]/g, char => {
      switch ( char ) {
        case "<": return "&lt;";
        case ">": return "&gt;";
      }
      return char;
    });
  }

  /* -------------------------------------------- */
  /*  Parsing                                     */
  /* -------------------------------------------- */

  /**
   * Convert an HTMLCollection to a StringNode.
   * @param {StringNode} parent          The parent StringNode to append to.
   * @param {HTMLCollection} collection  The child collection to convert and append.
   * @returns {StringNode}               Returns the parent StringNode.
   */
  static #convertCollectionToStringNode(parent, collection) {
    collection.forEach(node => {
      const stringNode = StringNode.#convertNodeToStringNode(node);
      if ( stringNode ) parent.appendChild(stringNode);
    });
    return parent;
  }

  /* -------------------------------------------- */

  /**
   * Convert an HTML node to a StringNode.
   * @param {Node} node                 The HTML Node.
   * @returns {StringNode|string|null}  Returns null if the node cannot be converted.
   */
  static #convertNodeToStringNode(node) {
    if ( node.nodeType === Node.TEXT_NODE ) return node.textContent;
    if ( node.nodeType !== Node.ELEMENT_NODE ) return null;
    const attrs = Object.fromEntries(Array.from(node.attributes).map(attr => [attr.name, attr.value]));
    const inline = Array.from(node.childNodes).some(child => child.nodeType === Node.TEXT_NODE);
    const stringNode = new foundry.utils.StringNode(node.tagName.toLowerCase(), attrs, inline);
    return StringNode.#convertCollectionToStringNode(stringNode, node.childNodes);
  }

  /* -------------------------------------------- */

  /**
   * Convert an HTML string to a StringNode.
   * @param {string} html  The HTML string.
   * @returns {StringNode}
   * @throws {Error}       If unable to perform conversion.
   */
  static fromString(html) {
    if ( !globalThis.DOMParser ) throw new Error("StringNode.fromString is not available in this context.");
    const root = new this();
    const doc = new DOMParser().parseFromString(html, "text/html");
    return StringNode.#convertCollectionToStringNode(root, doc.body.childNodes);
  }
}
