import ApplicationV2 from "../api/application.mjs";
import WordTree from "@common/utils/word-tree.mjs";

/**
 * @import {ApplicationConfiguration} from "../_types.mjs";
 * @import {RollParseNode, StringParseNode} from "../../dice/_types.mjs";
 */

/**
 * @typedef FormulaEditorConfiguration
 * @property {string} context  The formula editing context.
 * @property {string} formula  The formula being edited.
 */

/**
 * An application that provides improved formula editing capabilities.
 * @extends {ApplicationV2<ApplicationConfiguration & FormulaEditorConfiguration>}
 */
export default class FormulaEditor extends ApplicationV2 {
  constructor(options) {
    super(options);
    this.#context = CONFIG.formulaEditor.contexts[this.options.context] ?? {};
    const inverted = {};
    for ( const [k, v] of Object.entries(this.labels) ) {
      inverted[v.normalize("NFC").toLocaleLowerCase(game.i18n.lang)] = k;
    }
    this.#invertedLabels = Object.entries(inverted).sort(([a], [b]) => b.length - a.length);
    this.#tree = this.#buildWordTree(inverted);
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "formula-editor",
    tag: "dialog",
    classes: ["dialog", "formula-editor"],
    window: {
      icon: "fa-solid fa-lambda",
      contentClasses: ["flexcol"],
      resizable: true,
      minimizable: false
    },
    position: {
      width: 400,
      height: 200
    },
    context: "default",
    formula: ""
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The regular expression to match formula data identifiers.
   * @type {RegExp}
   */
  static IDENTIFIER = /^@[\w.-]+$/;

  /**
   * The auto-complete menu.
   * @type {Autocomplete}
   */
  #autocomplete = new foundry.applications.ux.Autocomplete({ onSelect: this.#onAutocomplete.bind(this) });

  /**
   * Get the configured context for this formula editor.
   * @type {FormulaEditorContext}
   */
  get context() {
    return this.#context;
  }

  #context;

  /**
   * The active ProseMirror editor instance.
   * @type {EditorView}
   */
  #editor;

  /**
   * The current formula value.
   * @type {string}
   */
  get formula() {
    return this.constructor.toFormula(this.#editor.state);
  }

  /**
   * The inverted context object entries sorted by descending label length.
   * @type {Array<[string, string]>}
   */
  #invertedLabels;

  /**
   * Get the labels mapping for this context.
   * @type {Record<string, string>}
   */
  get labels() {
    return this.#context.labels ?? {};
  }

  /**
   * A word tree for prefix lookup.
   * @type {WordTree}
   */
  #tree;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _canAttach() {
    return false;
  }

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

  /** @override */
  _canDetach() {
    return false;
  }

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

  /** @override */
  _canRender(options) {
    if ( this.rendered ) return false;
  }

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

  /** @override */
  async _onFirstRender(context, options) {
    this.element.showModal();
  }

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

  /** @override */
  _replaceHTML(result, content, options) {
    const { EditorView } = foundry.prosemirror;
    const { EditorState, Plugin, TextSelection } = foundry.prosemirror.state;
    content.replaceChildren(result);
    const state = EditorState.create({ doc: this.constructor.schema.nodes.doc.create() });
    let tr = state.tr;
    if ( this.options.formula ) {
      tr = state.tr.insert(0, state.schema.text(this.options.formula));
      tr = this._replaceTerms(tr, state.schema);
    }
    tr = tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size)); // Set cursor to the end.
    const { doc, selection } = tr;
    this.#editor = new EditorView({ mount: result }, {
      state: EditorState.create({
        doc, selection,
        plugins: [new Plugin({ appendTransaction: this.#onTransaction.bind(this) })]
      })
    });
  }

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

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    this.window.close.classList.remove("fa-xmark");
    this.window.close.classList.add("fa-floppy-disk");
    return frame;
  }

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

  /** @override */
  async _renderHTML(context, options) {
    const html = this.element.ownerDocument.createElement("div");
    html.autofocus = true;
    return html;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle selecting an auto-complete option.
   * @param {string} identifier        The selected entry's identifier.
   * @param {string} label             The selected entry's label.
   * @param {object} [options]
   * @param {string} [options.prefix]  The originally matched prefix.
   */
  #onAutocomplete(identifier, label, { prefix }={}) {
    const { state } = this.#editor;
    const { schema, selection } = state;
    const { data } = schema.marks;
    const { $cursor } = selection;
    if ( !$cursor || !prefix ) return;

    // Verify the text before the cursor matches the prefix.
    const end = $cursor.parentOffset;
    const textBefore = $cursor.parent.textBetween(0, end);
    const start = end - prefix.length;
    if ( start < 0 ) return;
    if ( textBefore.substring(start, end).toLocaleLowerCase(game.i18n.lang) !== prefix ) return;

    // Get the absolute position of the start of the prefix.
    const pos = $cursor.pos - prefix.length;

    // Fill in the rest of the autocomplete and wrap it in a data mark.
    let tr = state.tr
      .delete(pos, $cursor.pos)
      .insert(pos, schema.text(label, [data.create({ value: identifier })]));
    tr = tr.setSelection(foundry.prosemirror.state.Selection.near(tr.doc.resolve(pos + label.length)));
    this.#editor.dispatch(tr.setMeta("autocomplete", true));
  }

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

  /**
   * Handle state changes when editing the formula.
   * @param {Transaction[]} transactions  The editor transactions.
   * @param {EditorState} oldState        The editor's state before transactions were applied.
   * @param {EditorState} newState        The editor's state after transactions were applied.
   * @returns {Transaction|null|void}
   */
  #onTransaction(transactions, oldState, newState) {
    if ( transactions.some(tr => tr.getMeta("autocomplete")) ) return;
    let tr = newState.tr;
    const { schema } = newState;
    const { data } = schema.marks;

    // Mark formula data.
    tr = tr.removeMark(0, newState.doc.content.size);
    const formula = tr.doc.textContent.normalize("NFC").toLocaleLowerCase(game.i18n.lang);
    for ( const [label, identifier] of this.#invertedLabels ) {
      let pos = formula.indexOf(label);
      while ( pos > -1 ) {
        const end = pos + label.length;
        if ( !tr.doc.rangeHasMark(pos, end, data) ) {
          tr = tr.addMark(pos, end, data.create({ value: identifier }));
        }
        pos = formula.indexOf(label, pos + 1);
      }
    }

    // Handle auto-complete.
    const { $cursor } = newState.selection;
    if ( $cursor?.pos ) {
      let prefix;
      let pos = 0;
      for ( const node of newState.doc.content.content ) {
        const to = pos + node.nodeSize;
        if ( ($cursor.pos > pos) && ($cursor.pos <= to) ) {
          [prefix] = node.text.substring(0, $cursor.pos - pos).match(/[\p{L}\p{N}_ ]+$/u) ?? [];
          break;
        }
        pos = to;
      }
      prefix = prefix?.toLocaleLowerCase(game.i18n.lang).trim();
      const matches = prefix ? this.#tree.lookup(prefix.stripDiacritics()) : [];
      if ( matches.length ) {
        const coords = this.#editor.coordsAtPos($cursor.pos - 1);
        const entries = matches.map(identifier => ({ identifier, label: this.labels[identifier] }));
        this.#autocomplete.activate(this.element, entries, {
          prefix,
          position: { top: coords.bottom, left: coords.left }
        });
      }
      else this.#autocomplete.dismiss();
    }
    else this.#autocomplete.dismiss();

    return tr;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Build a word tree from the provided context.
   * @param {Record<string, string>} context  A mapping of human-readable terms to formula data paths.
   * @returns {WordTree}
   */
  #buildWordTree(context) {
    const tree = new WordTree();
    for ( const label in context ) tree.addLeaf(label.stripDiacritics(), context[label]);
    return tree;
  }

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

  /**
   * Parse the given ProseMirror document state and replace data terms with human-readable labels.
   * @param {Transaction} tr  The state transaction.
   * @param {Schema} schema   The ProseMirror document schema.
   * @returns {Transaction}
   * @protected
   */
  _replaceTerms(tr, schema) {
    let ast;
    const formula = tr.doc.textContent;
    try {
      ast = foundry.dice.RollGrammar.parse(formula);
    } catch {
      return tr;
    }
    const { data } = schema.marks;
    for ( const term of this.constructor._collectStringTerms(ast).reverse() ) {
      if ( (term.class !== "StringTerm") || !this.constructor.IDENTIFIER.test(term.term) ) continue;
      const identifier = term.term.slice(1);
      const label = this.labels[identifier.toLowerCase()];
      if ( !label ) continue;
      const { start, end } = term.offset;
      tr = tr.replaceWith(start, end, schema.text(label, [data.create({ value: identifier })]));
    }
    return tr;
  }

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

  /**
   * Collect every string term in a roll parse tree, in the order that they appear in the formula.
   * @param {RollParseNode} node         The node to search.
   * @param {StringParseNode[]} [terms]  The terms collected so far.
   * @returns {StringParseNode[]}
   * @protected
   */
  static _collectStringTerms(node, terms=[]) {
    if ( !node?.class ) return terms;
    switch ( node.class ) {
      case "StringTerm":
        terms.push(node);
        break;
      case "Node":
        for ( const operand of node.operands ) this._collectStringTerms(operand, terms);
        break;
      case "FunctionTerm":
      case "PoolTerm":
        for ( const term of node.terms ) this._collectStringTerms(term, terms);
        break;
      case "ParentheticalTerm":
        this._collectStringTerms(node.term, terms);
        break;
      case "DiceTerm":
        this._collectStringTerms(node.number, terms);
        this._collectStringTerms(node.faces, terms);
        break;
    }
    return terms;
  }

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

  /**
   * The ProseMirror schema.
   * @type {Schema}
   */
  static get schema() {
    return new foundry.prosemirror.Schema({
      nodes: {
        doc: { content: "text*" },
        text: { inline: true }
      },
      marks: {
        data: {
          attrs: {
            value: { default: "" }
          },
          parseDOM: [{ tag: "data", getAttrs: el => ({ value: el.value }) }],
          toDOM: node => ["data", { value: node.attrs.value }]
        }
      }
    });
  }

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

  /**
   * Convert a ProseMirror document back into a formula string.
   * @param {EditorState} state  The ProseMirror document state.
   * @returns {string}
   */
  static toFormula(state) {
    let formula = "";
    if ( !state?.doc?.content?.size ) return formula;
    state.doc.forEach(node => {
      if ( !node.isText ) return;
      const data = node.marks.find(m => m.type === state.schema.marks.data);
      formula += data ? `@${data.attrs.value}` : node.text;
    });
    return formula;
  }
}
