import {HTMLMultiCheckboxElement, HTMLMultiSelectElement} from "../../elements/multi-select.mjs";
import {createFontAwesomeIcon} from "../../forms/fields.mjs";
import HTMLFilePickerElement from "../../elements/file-picker.mjs";
import HTMLColorPickerElement from "../../elements/color-picker.mjs";
import FormDataExtended from "../../ux/form-data-extended.mjs";

/**
 * @import ApplicationV2 from "../../api/application.mjs";
 * @import {Constructor} from "@common/_types.mjs";
 * @import {PlaceablePaletteConfiguration, PlaceablePaletteRenderOptions} from "../../_types.mjs";
 */

/**
 * Augment a placeable config so that it can be used to bulk edit and set default creation data.
 * @param {Constructor<ApplicationV2>} BaseConfig
 */
export default function PlaceablePaletteMixin(BaseConfig) {
  /**
   * The mixed application class augmented with placeable palette functionality.
   * @extends {ApplicationV2<PlaceablePaletteConfiguration, PlaceablePaletteRenderOptions>}
   * @template {typeof Document} T
   */
  class PlaceablePalette extends BaseConfig {
    constructor(options, ...args) {
      const documentClass = getDocumentClass(new.target.documentName);
      const createData = new.target.createData ?? {};
      let { initialData={} } = new.target.DEFAULT_OPTIONS;
      initialData = foundry.utils.mergeObject(initialData, options.initialData ?? {}, { inplace: false });
      foundry.utils.mergeObject(initialData, createData);
      options.document ??= new documentClass(initialData, { parent: canvas.scene });
      super(options, ...args);
    }

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

    /** @override */
    static DEFAULT_OPTIONS = {
      classes: ["placeable-palette", "faded-ui"],
      initialData: {},
      position: {
        scale: .8,
        width: 375
      },
      form: {
        closeOnSubmit: false,
        submitOnChange: true
      },
      actions: {
        closeDetails: PlaceablePalette.#onCloseDetails,
        closePalette: PlaceablePalette.#onClosePalette,
        commit: PlaceablePalette.#onCommit,
        reset: PlaceablePalette.#onReset
      },
      preview: false
    };

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

    /**
     * The all controlled documents for this palette's placeables layer.
     * @type {InstanceType<T>[]}
     */
    get controlled() {
      return this.layer?.controlled.map(p => p.document) ?? [];
    }

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

    /**
     * The initial creation data for a new document.
     * @type {object}
     */
    get createData() {
      return game.settings.get("core", this.constructor.SETTING_KEY);
    }

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

    /**
     * The initial creation data for a new document, reading from the live palette instance if one is open, otherwise
     * falling back to the stored settings, with level and elevation synced to the currently viewed level.
     * @type {object}
     */
    static get createData() {
      if ( ui.placeablesPalette ) return ui.placeablesPalette.createData;
      const data = game.settings.get("core", this.SETTING_KEY);
      if ( !canvas.level ) return data;
      return foundry.utils.mergeObject(data, this._getDefaultLevelData(), { inplace: false });
    }

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

    /**
     * Get the default level and elevation data for a given level, used when syncing to the currently viewed level.
     * @returns {object}
     * @protected
     */
    static _getDefaultLevelData() {
      return {
        levels: [canvas.level.id],
        elevation: canvas.level.elevation.base
      };
    }

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

    /**
     * If defined, switch to this tool after committing settings so the user can immediately draw with them.
     * @type {string}
     */
    static COMMIT_TOOL;

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

    /**
     * The fields the user has modified from their default values.
     * @type {Set<string>}
     * @internal
     */
    _dirtyFields = new Set();

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

    /**
     * The fields that have differing values across the set of controlled documents.
     * @type {Set<string>}
     * @internal
     */
    _multiFields = new Set();

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

    /**
     * The class of the document that backs this form.
     * @type {T}
     */
    get documentClass() {
      return getDocumentClass(this.documentName);
    }

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

    /**
     * The placeable document.
     * @returns {string}
     */
    get documentName() {
      return this.constructor.documentName;
    }

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

    /**
     * Whether the palette is editing multiple placeables.
     * @type {boolean}
     */
    get isSelect() {
      return game.activeTool === "select";
    }

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

    /**
     * The canvas layer for this palette's placeable.
     * @type {PlaceablesLayer}
     */
    get layer() {
      return canvas.getLayerByEmbeddedName(this.documentName);
    }

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

    /**
     * The schema of the user's stored palette values.
     * @returns {SchemaField}
     */
    static get schema() {
      const schema = getDocumentClass(this.documentName).defineSchema();
      delete schema._id;

      // Remove internal fields.
      for ( const fieldName in schema ) {
        if ( fieldName.startsWith("_") ) delete schema[fieldName];
      }

      return new foundry.data.fields.SchemaField(schema);
    }

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

    /** @override */
    get title() {
      const label = _loc(this.documentClass.metadata.label);
      if ( this.isSelect ) {
        const count = this.layer?.controlled.length;
        if ( count ) return _loc("PLACEABLE_PALETTE.Selected", { document: label, count });
      }
      return _loc("PLACEABLE_PALETTE.Title", { document: label });
    }

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

    /** @override */
    get isEditable() {
      if ( this.isSelect && !this.layer?.controlled.length ) return false;
      return super.isEditable;
    }

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

    /**
     * Configure an appropriate preset to apply.
     * @param {PlaceablePaletteRenderOptions} [options]  Render options.
     * @returns {object|void}                            The preset to apply, if any.
     */
    #applyPreset(options={}) {
      if ( !this.rendered || !options.preset ) return;
      if ( !options.preservePlacement ) return options.preset;
      const formData = new FormDataExtended(this.form).object;
      return this._applyPreset(formData, options);
    }

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

    /**
     * Configure an appropriate preset to apply.
     * @param {object} formData                          The palette data.
     * @param {PlaceablePaletteRenderOptions} [options]  Render options.
     * @protected
     */
    _applyPreset(formData, { preset={} }={}) {
      preset = foundry.utils.deepClone(preset);
      preset.levels = formData.levels;
      if ( formData.elevation != null ) preset.elevation = formData.elevation;
      return preset;
    }

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

    /** @inheritDoc */
    _configureRenderOptions(options) {
      super._configureRenderOptions(options);
      options.window ??= {};
      options.window.title = this.title;
    }

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

    /** @inheritDoc */
    async _prepareContext(options) {
      const context = await super._prepareContext(options);
      context.rootId = this.id;
      context.isSelect = this.isSelect;
      context.buttons = [{
        type: "reset", action: "reset", icon: "fa-solid fa-arrow-rotate-left", label: "SETTINGS.UI.ACTIONS.Reset"
      }];
      if ( this.isSelect && this.layer?.controlled.length ) context.buttons.push({
        type: "button", action: "commit", icon: "fa-solid fa-palette", label: "PLACEABLE_PALETTE.CloneSettings"
      });
      if ( this.layer?.controlled.length ) {
        const disabled = this.layer.controlled.some(o => o.document.locked);
        context.buttons.push({
          type: "submit", icon: "fa-solid fa-check", label: "PLACEABLE_PALETTE.Apply",
          hidden: !this._dirtyFields.size, disabled, tooltip: disabled ? "PLACEABLE_PALETTE.CannotApplyToLocked" : ""
        });
      }
      return context;
    }

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

    /** @inheritDoc */
    async render(options, _options) {
      const preset = this.#applyPreset(options);
      this._dirtyFields = new Set();
      if ( this.isSelect ) {
        const { controlled } = this;
        if ( preset ) {
          this._multiFields = new Set();
          this.document.updateSource(preset);
          this._dirtyFields = new Set(Object.keys(foundry.utils.flattenObject(preset)));
        } else {
          this._multiFields = this._determineMultiFields(controlled);
          if ( controlled.length ) {
            const startValues = controlled[0].toObject();
            this.constructor.schema.clean(startValues);
            this.document.updateSource(startValues);
          }
          else this.document.updateSource(this.constructor.createData);
        }
      } else if ( preset ) {
        this.document.updateSource(preset);
        game.settings.set("core", this.constructor.SETTING_KEY, foundry.utils.mergeObject(this.createData, preset));
      }
      else this.document.updateSource(this.constructor.createData);
      this._preview = this.document;
      await super.render(options, _options);
    }

    /* -------------------------------------------- */
    /*  Lifecycle                                   */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _onClose(options) {
      super._onClose(options);
      ui.placeablesPalette = null;
    }

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

    /** @inheritDoc */
    async _onFirstRender(context, options) {
      await super._onFirstRender(context, options);
      ui.placeablesPalette = this;
      if ( !this.isSelect ) this.submit();
    }

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

    /** @inheritDoc */
    async _onRender(context, options) {
      await super._onRender(context, options);
      this.#renderInlineHints();
      if ( !this.isSelect || !this._multiFields.size ) return;
      // Apply placeholders to fields with divergent values.
      for ( const fieldPath of this._multiFields ) {
        const el = this.form.elements[fieldPath];
        if ( el ) this._setPlaceholder(el);
      }
    }

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

    /** @inheritDoc */
    _processFormData(event, form, formData) {
      const submitData = super._processFormData(event, form, formData);
      if ( !this.isSelect ) return submitData;
      const dirty = {};
      for ( const field of this._dirtyFields ) {
        const value = foundry.utils.getProperty(submitData, field);
        if ( value !== undefined ) foundry.utils.setProperty(dirty, field, value);
      }
      return dirty;
    }

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

    /** @override */
    async _processSubmitData(event, form, submitData, options) {
      if ( (event.type === "submit") && event.isTrusted ) {
        if ( !foundry.utils.isEmpty(submitData) ) {
          await canvas.scene.updateEmbeddedDocuments(this.documentName, this.controlled.reduce((updates, d) => {
            if ( !d.locked ) updates.push({ _id: d.id, ...submitData });
            return updates;
          }, []), options);
        }
        this._dirtyFields = new Set();
        return {};
      }

      if ( this.document.locked ) return {};
      this.document.updateSource(submitData, options);
      await game.settings.set("core", this.constructor.SETTING_KEY, this.document.toObject());
      ui.controls.render({ parts: ["tools"] });
      await this.render();
      return {};
    }

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

    /** @inheritDoc */
    async _renderFrame(options) {
      const frame = await super._renderFrame(options);
      this.window.close.dataset.action = "closePalette";
      return frame;
    }

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

    /** @inheritDoc */
    _onChangeForm(formConfig, event) {
      if ( this.isSelect ) {
        const name = event.target.name;
        if ( name ) {
          this._dirtyFields.add(name);
          PlaceablePalette.#clearPlaceholder(event.target);
          this.element.querySelector("button[type=submit]")?.removeAttribute("hidden");
        }
        // Allow the normal config to perform any onchange logic.
        return super._onChangeForm({ ...formConfig, submitOnChange: false }, event);
      }
      return super._onChangeForm(formConfig, event);
    }

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

    /**
     * Handle collapsing a details disclosure.
     * @this {PlaceablePalette}
     * @param {PointerEvent} event  The triggering event.
     * @param {HTMLElement} target  The action target.
     */
    static #onCloseDetails(event, target) {
      const details = target.closest("details");
      if ( details ) details.open = false;
    }

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

    /**
     * Handle closing the palette via the close button.
     * @this {PlaceablePalette}
     */
    static #onClosePalette() {
      return ui.controls.activate({ toggles: { togglePalette: false } });
    }

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

    /**
     * Handle committing the common values of the current selection as the palette defaults.
     * @this {PlaceablePalette}
     */
    static async #onCommit() {
      const { controlled, _dirtyFields, _multiFields } = this;
      if ( !controlled.length ) return;

      // Build the set of common values: all fields from the first document that don't differ across selection.
      const baseData = foundry.utils.flattenObject(controlled[0].toObject());
      const formData = new FormDataExtended(this.form).object;
      const toCommit = {};
      for ( let [path, value] of Object.entries(baseData) ) {
        if ( _dirtyFields.has(path) ) value = formData[path];
        else if ( _multiFields.has(path) ) continue;
        toCommit[path] = value;
      }

      // Merge committed values on top of current settings.
      const current = game.settings.get("core", this.constructor.SETTING_KEY) ?? {};
      const updated = foundry.utils.mergeObject(current, foundry.utils.expandObject(toCommit), { inplace: false });
      await game.settings.set("core", this.constructor.SETTING_KEY, updated);
      if ( this.constructor.COMMIT_TOOL ) ui.controls.activate({ tool: this.constructor.COMMIT_TOOL });
    }

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

    /**
     * Handle resetting to default values.
     * @this {PlaceablePalette}
     */
    static #onReset() {
      if ( this.isSelect ) return this.render();
      const preset = this.constructor.schema.clean(this.options.initialData);
      foundry.utils.mergeObject(preset, this.constructor._getDefaultLevelData());
      return this.render({ preset });
    }

    /* -------------------------------------------- */
    /*  Helpers                                     */
    /* -------------------------------------------- */

    /**
     * Clear a multi-value placeholder from the given element.
     * @param {HTMLElement} element
     */
    static #clearPlaceholder(element) {
      const { isElementInstanceOf } = foundry.utils;
      element.classList.remove("multiple-values");
      if ( isElementInstanceOf(element, HTMLSelectElement) ) {
        element.querySelector("option[data-multi-placeholder]")?.remove();
      } else if ( isElementInstanceOf(element, HTMLMultiSelectElement) ) {
        const blank = element.querySelector("option[data-multi-placeholder]");
        if ( blank ) {
          blank.textContent = "";
          delete blank.dataset.multiPlaceholder;
        }
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLMultiCheckboxElement) ) {
        for ( const cb of element.querySelectorAll("input[type=checkbox]") ) cb.indeterminate = false;
      } else if ( element.type === "checkbox" ) {
        element.indeterminate = false;
      }
      if ( !element._originalPlaceholder ) return;
      if ( isElementInstanceOf(element, HTMLFilePickerElement)
        || isElementInstanceOf(element, HTMLColorPickerElement) ) {
        const input = element.querySelector(":scope > input");
        input.placeholder = element._originalPlaceholder;
      } else if ( isElementInstanceOf(element, HTMLInputElement) ) {
        element.placeholder = element._originalPlaceholder || "";
      }
    }

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

    /**
     * Determine which fields have values that are not the same across all selected documents.
     * @param {InstanceType<T>[]} docs
     * @returns {Set<string>}
     * @protected
     */
    _determineMultiFields(docs) {
      const multiple = new Set();
      if ( docs.length < 2 ) return multiple;
      const fields = foundry.utils.flattenObject(docs[0]._source);
      for ( const [key, value] of Object.entries(fields) ) {
        const field = docs[0].getFieldForProperty(key);
        let cast;
        if ( field instanceof foundry.data.fields.SetField ) cast = v => Array.isArray(v) ? new Set(v) : v;
        else cast = v => v;
        fields[key] = {value: cast(value), cast};
      }
      for ( let i = 1; i < docs.length; i++ ) {
        const d = docs[i];
        for ( const [key, {value: v0, cast}] of Object.entries(fields) ) {
          const v1 = cast(foundry.utils.getProperty(d._source, key));
          if ( !foundry.utils.equals(v0, v1) ) multiple.add(key);
        }
      }
      return multiple;
    }

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

    /**
     * Determine whether the given preset creation data matches the currently stored palette settings.
     * @param {object} createData  The preset creation data to compare against.
     * @returns {boolean}
     */
    static isActivePreset(createData) {
      // Get the raw setting value prior to SchemaField initialization.
      const settings = JSON.parse(game.settings.storage.get("client").getItem(`core.${this.SETTING_KEY}`) ?? "{}");
      if ( foundry.utils.isEmpty(settings) ) return false;
      return foundry.utils.isEmpty(foundry.utils.diffObject(settings, createData));
    }

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

    /**
     * Render hints as inline as info bubbles.
     */
    #renderInlineHints() {
      for ( const hint of this.element.querySelectorAll("p.hint") ) {
        const group = hint.closest(".form-group");
        const label = group?.querySelector("label");
        if ( !label ) continue;
        label.classList.add("info");
        label.dataset.tooltipText = hint.textContent;
        label.insertAdjacentElement("beforeend", createFontAwesomeIcon("circle-info"));
        hint.hidden = true;
      }
    }

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

    /**
     * Set a multi-value placeholder on the given element.
     * @param {HTMLElement} element
     * @protected
     */
    _setPlaceholder(element) {
      const document = this.element.ownerDocument;
      const multiLabel = _loc("PLACEABLE_PALETTE.MultipleValues");
      element.classList.add("multiple-values");
      if ( foundry.utils.isElementInstanceOf(element, HTMLSelectElement) ) {
        const placeholder = document.createElement("option");
        Object.assign(placeholder, { textContent: multiLabel, disabled: true, selected: true });
        placeholder.dataset.multiPlaceholder = "";
        element.insertAdjacentElement("afterbegin", placeholder);
        element.selectedIndex = 0;
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLMultiSelectElement) ) {
        element._value.clear();
        element._refresh();
        const blank = element.querySelector('select > option[value=""]');
        blank.textContent = multiLabel;
        blank.dataset.multiPlaceholder = "";
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLFilePickerElement)
        || foundry.utils.isElementInstanceOf(element, HTMLColorPickerElement) ) {
        const input = element.querySelector(":scope > input");
        element._setValue("");
        element._refresh();
        element._originalPlaceholder = input.placeholder;
        input.placeholder = multiLabel;
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLMultiCheckboxElement) ) {
        for ( const cb of element.querySelectorAll("input[type=checkbox]") ) {
          cb.indeterminate = true;
          cb.checked = false;
        }
      } else if ( element.type === "checkbox" ) {
        element.indeterminate = true;
        element.checked = false;
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLInputElement) ) {
        element.value = "";
        element._originalPlaceholder = element.placeholder;
        element.placeholder = multiLabel;
      }
    }
  }
  return PlaceablePalette;
}
