// SPDX-FileCopyrightText: 2021 Johannes Loher
// SPDX-FileCopyrightText: 2021 Oliver Rümpelein
// SPDX-FileCopyrightText: 2021 Gesina Schwalbe
// SPDX-FileCopyrightText: 2021 Siegfried Krug
//
// SPDX-License-Identifier: MIT

import { DS4 } from "../../config";
import { DS4ActiveEffect } from "../../documents/active-effect";
import { isCheck } from "../../documents/actor/actor-data-properties-base";
import { getDS4Settings } from "../../settings";
import { notifications } from "../../ui/notifications";
import { enforce, getCanvas, getGame } from "../../utils/utils";
import { disableOverriddenFields } from "../sheet-helpers";

/**
 * The base sheet class for all {@link DS4Actor}s.
 */
export class DS4ActorSheet extends ActorSheet {
  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "ds4-actor-sheet"],
      height: 645,
      scrollY: [".ds4-sheet-body"],
      tabs: [{ navSelector: ".ds4-sheet-tab-nav", contentSelector: ".ds4-sheet-body", initial: "values" }],
      dragDrop: [
        { dragSelector: ".item-list .item", dropSelector: null },
        { dragSelector: ".effect-list .effect", dropSelector: null },
        { dragSelector: ".ds4-check", dropSelector: null },
      ],
      width: 650,
    });
  }

  /** @override */
  get template() {
    const basePath = "systems/ds4/templates/sheets/actor";
    if (!getGame().user?.isGM && this.actor.limited) return `${basePath}/limited-sheet.hbs`;
    return `${basePath}/${this.actor.type}-sheet.hbs`;
  }

  /** @override */
  async getData(options = {}) {
    const itemsByType = Object.fromEntries(
      Object.entries(this.actor.itemTypes).map(([itemType, items]) => {
        return [itemType, [...items].sort((a, b) => (a.sort || 0) - (b.sort || 0))];
      }),
    );

    const enrichedEffects = [...this.actor.allApplicableEffects()].map((effect) => {
      return {
        ...effect.toObject(),
        sourceName: effect.parent instanceof Item ? effect.parent.name : effect.sourceName,
        factor: effect.factor,
        active: effect.active,
        uuid: effect.uuid,
      };
    });

    const context = {
      ...this.addTooltipsToData(await super.getData(options)),
      config: DS4,
      itemsByType,
      enrichedEffects,
      settings: getDS4Settings(),
    };
    return context;
  }

  /**
   * Adds tooltips to the attributes, traits, and combatValues of the given context object.
   * @param {object} context
   * @protected
   */
  addTooltipsToData(context) {
    const valueGroups = [context.data.system.attributes, context.data.system.traits, context.data.system.combatValues];

    valueGroups.forEach((valueGroup) => {
      Object.values(valueGroup).forEach((attribute) => {
        attribute.tooltip = this.getTooltipForValue(attribute);
      });
    });
    return context;
  }

  /**
   * Generates a tooltip for a given attribute, trait, or combatValue.
   * @param {import("../../documents/common/common-data").ModifiableDataBaseTotal<number>} value The value to get a tooltip for
   * @returns {string} The tooltip
   * @protected
   */
  getTooltipForValue(value) {
    return `${value.base} (${getGame().i18n.localize("DS4.TooltipBaseValue")}) + ${
      value.mod
    } (${getGame().i18n.localize("DS4.TooltipModifier")}) ➞ ${getGame().i18n.localize("DS4.TooltipEffects")} ➞ ${
      value.total
    }`;
  }

  /**
   * @param {JQuery} html
   * @override
   */
  activateListeners(html) {
    super.activateListeners(html);

    if (!this.options.editable) return;

    html.find(".control-item").on("click", this.onControlItem.bind(this));
    html.find(".change-item").on("change", this.onChangeItem.bind(this));

    html.find(".control-effect").on("click", this.onControlEffect.bind(this));
    html.find(".change-effect").on("change", this.onChangeEffect.bind(this));

    html.find(".rollable-item").on("click", this.onRollItem.bind(this));
    html.find(".rollable-check").on("click", this.onRollCheck.bind(this));

    html.find(".sort-items").on("click", this.onSortItems.bind(this));

    disableOverriddenFields(this.form, this.actor.overrides, (key) => `[name="${key}"]`);
    for (const item of this.actor.items) {
      disableOverriddenFields(
        this.form,
        item.overrides,
        (key) => `[data-item-uuid="${item.uuid}"] .change-item[data-property="${key}"]`,
      );
    }
  }

  /**
   * Handles a click on an element of this sheet to control an embedded item of the actor corresponding to this sheet.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onControlItem(event) {
    event.preventDefault();
    const a = event.currentTarget;
    switch (a.dataset["action"]) {
      case "create":
        return this.onCreateItem(event);
      case "edit":
        return this.onEditItem(event);
      case "delete":
        return this.onDeleteItem(event);
    }
  }

  /**
   * Creates a new embedded item using the initial data defined in the HTML dataset of the clicked element.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onCreateItem(event) {
    const { type } = foundry.utils.deepClone(event.currentTarget.dataset);
    const name = getGame().i18n.localize(`DS4.New${type.capitalize()}Name`);
    const itemData = { name, type };
    Item.create(itemData, { parent: this.actor });
  }

  /**
   * Opens the sheet of the embedded item corresponding to the clicked element.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  async onEditItem(event) {
    const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.Item.selector);
    const uuid = li.dataset[embeddedDocumentListEntryProperties.Item.uuidDataAttribute];
    const item = await fromUuid(uuid);
    enforce(
      item && item.parent === this.actor,
      getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }),
    );
    item.sheet?.render(true);
  }

  /**
   * Deletes the embedded item corresponding to the clicked element.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  async onDeleteItem(event) {
    const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.Item.selector);
    const uuid = li.dataset[embeddedDocumentListEntryProperties.Item.uuidDataAttribute];
    const item = await fromUuid(uuid);
    enforce(
      item && item.parent === this.actor,
      getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }),
    );
    item.delete();
    $(li).slideUp(200, () => this.render(false));
  }

  /**
   * Applies a change to a property of an embedded item depending on the `data-property` attribute of the
   * {@link HTMLInputElement} that has been changed and its new value.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onChangeItem(event) {
    return this.onChangeEmbeddedDocument(event, "Item");
  }

  /**
   * Handles a click on an element of this sheet to control an embedded effect of the actor corresponding to this
   * sheet.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onControlEffect(event) {
    event.preventDefault();
    const a = event.currentTarget;
    switch (a.dataset["action"]) {
      case "create":
        return this.onCreateEffect();
      case "edit":
        return this.onEditEffect(event);
      case "delete":
        return this.onDeleteEffect(event);
    }
  }

  /**
   * Creates a new embedded effect.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onCreateEffect() {
    DS4ActiveEffect.createDefault(this.actor);
  }

  /**
   * Opens the sheet of the embedded effect corresponding to the clicked element.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  async onEditEffect(event) {
    const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.ActiveEffect.selector);
    const uuid = li.dataset[embeddedDocumentListEntryProperties.ActiveEffect.uuidDataAttribute];
    const effect = await fromUuid(uuid);
    enforce(
      effect && (effect.parent === this.actor || effect.parent.parent === this.actor),
      getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { uuid, actor: this.actor.name }),
    );
    effect.sheet?.render(true);
  }

  /**
   * Deletes the embedded item corresponding to the clicked element.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  async onDeleteEffect(event) {
    const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.ActiveEffect.selector);
    const uuid = li.dataset[embeddedDocumentListEntryProperties.ActiveEffect.uuidDataAttribute];
    const effect = await fromUuid(uuid);
    enforce(
      effect && (effect.parent === this.actor || effect.parent.parent === this.actor),
      getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { uuid, actor: this.actor.name }),
    );
    effect.delete();
    $(li).slideUp(200, () => this.render(false));
  }

  /**
   * Applies a change to a property of an embedded effect depending on the `data-property` attribute of the
   * {@link HTMLInputElement} that has been changed and its new value.
   *
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onChangeEffect(event) {
    return this.onChangeEmbeddedDocument(event, "ActiveEffect");
  }

  /**
   * Applies a change to a property of an embedded document of the actor belonging to this sheet. The change depends
   * on the `data-property` attribute of the {@link HTMLInputElement} that has been changed and its new value.
   *
   * @param {JQuery.ChangeEvent} event                 The originating click event
   * @param {"Item" | "ActiveEffect"} documentName    The name of the embedded document to be changed.
   * @protected
   */
  async onChangeEmbeddedDocument(event, documentName) {
    event.preventDefault();
    const element = $(event.currentTarget).get(0);
    if (element.disabled) return;

    const documentElement = element.closest(embeddedDocumentListEntryProperties[documentName].selector);
    const uuid = documentElement.dataset[embeddedDocumentListEntryProperties[documentName].uuidDataAttribute];
    const property = element.dataset["property"];
    enforce(property !== undefined, TypeError("HTML element does not provide 'data-property' attribute"));

    const newValue = this.parseValue(element);

    const document = await fromUuid(uuid);

    if (documentName === "Item") {
      enforce(
        document && document.parent === this.actor,
        getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }),
      );
    } else {
      enforce(
        document && (document.parent === this.actor || document.parent.parent === this.actor),
        getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { uuid, actor: this.actor.name }),
      );
    }
    document.update({ [property]: newValue });
  }

  /**
   * Parses the value of the given {@link HTMLInputElement} depending on the element's type
   * The value is parsed to:
   * - checkbox: `boolean`, if the attribute `data-inverted` is set to a truthy value, the parsed value is inverted
   * - text input: `string`
   * - number: `number`
   *
   * @param  {HTMLInputElement} element   The input element to parse the value from
   * @returns {boolean | string | number} The parsed data
   * @protected
   */
  parseValue(element) {
    switch (element.type) {
      case "checkbox": {
        const inverted = Boolean(element.dataset["inverted"]);
        const value = element.checked;
        return inverted ? !value : value;
      }
      case "text": {
        const value = element.value;
        return value;
      }
      case "number": {
        const value = Number(element.value.trim());
        return value;
      }
      default: {
        throw new TypeError("Binding of item property to this type of HTML element not supported; given: " + element);
      }
    }
  }

  /**
   * Handle clickable item rolls.
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  async onRollItem(event) {
    event.preventDefault();
    const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.Item.selector);
    const uuid = li.dataset[embeddedDocumentListEntryProperties.Item.uuidDataAttribute];
    const item = await fromUuid(uuid);
    enforce(
      item && item.parent === this.actor,
      getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }),
    );
    item.roll().catch((e) => notifications.error(e, { log: true }));
  }

  /**
   * Handle clickable check rolls.
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onRollCheck(event) {
    event.preventDefault();
    event.currentTarget.blur();
    const check = event.currentTarget.dataset["check"];
    this.actor.rollCheck(check).catch((e) => notifications.error(e, { log: true }));
  }

  /**
   * @param {DragEvent} event
   * @override
   */
  _onDragStart(event) {
    const target = event.currentTarget;
    if (!(target instanceof HTMLElement)) return super._onDragStart(event);

    const check = target.dataset.check;
    if (!check) return super._onDragStart(event);

    enforce(isCheck(check), getGame().i18n.format("DS4.ErrorCannotDragMissingCheck", { check }));

    const dragData = {
      actorId: this.actor.id,
      sceneId: this.actor.isToken ? getCanvas().scene?.id : null,
      tokenId: this.actor.isToken ? this.actor.token?.id : null,
      type: "Check",
      data: check,
    };

    event.dataTransfer?.setData("text/plain", JSON.stringify(dragData));
  }

  /**
   * Sort items according to the item list header that has been clicked.
   * @param {JQuery.ClickEvent} event The originating click event
   * @protected
   */
  onSortItems(event) {
    event.preventDefault();
    const target = event.currentTarget;
    const type = target.parentElement?.dataset["type"];
    enforce(type !== undefined, `Could not find property 'type' in the dataset of the parent of ${target}`);
    const dataPath = target.dataset["dataPath"];
    enforce(dataPath !== undefined, `Could not find property 'dataPath' in the dataset of ${target}`);
    const dataPath2 = target.dataset["dataPath2"];
    /** @type {import("../../documents/item/item").DS4Item[]}*/
    const items = this.actor.items.filter((item) => item.type === type);
    items.sort((a, b) => a.sort - b.sort);

    /**
     * @param {boolean} invert  Whether or not to inverse the sort order
     * @returns {(a: import("../../documents/item/item").DS4Item, b: import("../../documents/item/item").DS4Item) => number} A function for sorting items
     */
    const sortFunction = (invert) => (a, b) => {
      const propertyA = getProperty(a, dataPath);
      const propertyB = getProperty(b, dataPath);
      const comparison =
        typeof propertyA === "string" || typeof propertyB === "string"
          ? compareAsStrings(propertyA, propertyB, invert)
          : compareAsNumbers(propertyA, propertyB, invert);

      if (comparison === 0 && dataPath2 !== undefined) {
        const propertyA = getProperty(a, dataPath);
        const propertyB = getProperty(b, dataPath);
        return typeof propertyA === "string" || typeof propertyB === "string"
          ? compareAsStrings(propertyA, propertyB, invert)
          : compareAsNumbers(propertyA, propertyB, invert);
      }

      return comparison;
    };

    const sortedItems = [...items].sort(sortFunction(false));
    const wasSortedAlready = !sortedItems.find((item, index) => item !== items[index]);

    if (wasSortedAlready) {
      sortedItems.sort(sortFunction(true));
    }

    const updates = sortedItems.map((item, i) => ({
      _id: item.id,
      sort: (i + 1) * CONST.SORT_INTEGER_DENSITY,
    }));

    this.actor.updateEmbeddedDocuments("Item", updates);
  }
}

/**
 * This object contains information about specific properties embedded document list entries for each different type.
 */
const embeddedDocumentListEntryProperties = Object.freeze({
  ActiveEffect: {
    selector: ".effect",
    uuidDataAttribute: "effectUuid",
  },
  Item: {
    selector: ".item",
    uuidDataAttribute: "itemUuid",
  },
});

/**
 * Compare two stringifiables as strings.
 * @param {{ toString(): string }} a    The thing to compare with
 * @param {{ toString(): string }} b    The thing to compare
 * @param {boolean} invert              Should the comparison be inverted?
 * @return {number} A number that indicates the result of the comparison
 */
const compareAsStrings = (a, b, invert) => {
  return invert ? b.toString().localeCompare(a.toString()) : a.toString().localeCompare(b.toString());
};

/**
 * Compare two number.
 * @param {number} a        The number to compare with
 * @param {number} b        The number to compare
 * @param {boolean} invert  Should the comparison be inverted?
 * @return {number} A number that indicates the result of the comparison
 */
const compareAsNumbers = (a, b, invert) => {
  return invert ? b - a : a - b;
};
