// SPDX-FileCopyrightText: 2021 Johannes Loher
// SPDX-FileCopyrightText: 2021 Oliver RÜmpelein
//
// SPDX-License-Identifier: MIT

import { DS4 } from "../../config";
import { createCheckRoll } from "../../dice/check-factory";
import { Evaluator } from "../../expression-evaluation/evaluator";
import { Validator } from "../../expression-evaluation/validator";
import { logger } from "../../utils/logger";
import { getGame } from "../../utils/utils";
import { DS4ActiveEffect } from "../active-effect";
import { isAttribute, isTrait } from "./actor-data-source-base";

/**
 * The Actor class for DS4
 */
export class DS4Actor extends Actor {
  /** @type {Set<string>} */
  newStatuses = new Set();

  /** @override */
  prepareData() {
    this.prepareBaseData();
    this.prepareEmbeddedDocuments();
    this.prepareIntermediateData();
    this.applyActiveEffectsToBaseData();
    this.prepareDerivedData();
    this.applyActiveEffectsToDerivedData();
    this.handleStatusChanges();
    this.prepareFinalDerivedData();
  }

  /** @override */
  prepareBaseData() {
    this.newStatuses = new Set();

    this.system.rolling = {
      minimumFumbleResult: 20,
      maximumCoupResult: 1,
    };

    Object.values(this.system.attributes).forEach((attribute) => (attribute.total = attribute.base + attribute.mod));

    Object.values(this.system.traits).forEach((trait) => (trait.total = trait.base + trait.mod));
  }

  /** @override */
  prepareEmbeddedDocuments() {
    super.prepareEmbeddedDocuments();
    this.applyActiveEffectsToItems();
  }

  /**
   * Apply transformations to the Actor data after embedded documents have been prepared, but before effects have been
   * applied to the Actor.
   */
  prepareIntermediateData() {
    this.system.armorValueSpellMalus = this.armorValueSpellMalusOfEquippedItems;
  }

  /**
   * The effects that should be applied to this actor.
   * @type {import("../active-effect").DS4ActiveEffect[]}
   * @protected
   */
  get actorEffects() {
    const effects = [];
    for (const effect of this.allApplicableEffects()) {
      if (!effect.flags.ds4?.itemEffectConfig?.applyToItems) {
        effects.push(effect);
      }
    }
    return effects;
  }

  /**
   * Get the effects of this actor that should be applied to the given item.
   * @param {import("../item/item").DS4Item} item The item for which to get effects
   * @returns {import("../active-effect").DS4ActiveEffect[]} The array of effects that are candidates to be applied to the item
   */
  itemEffects(item) {
    /** @type {(effect: DS4ActiveEffect) => boolean} */
    const shouldEffectBeAppliedToItem = (effect, item) => {
      const { applyToItems, itemName, condition } = effect.flags.ds4?.itemEffectConfig ?? {};

      if (!applyToItems || (itemName !== undefined && itemName !== "" && itemName !== item.name)) {
        return false;
      }

      if (condition !== undefined && condition !== "") {
        try {
          const replacedCondition = DS4Actor.replaceFormulaData(condition, { item, actor: this, effect });
          return replacedCondition !== undefined ? Boolean(DS4Actor.evaluator.evaluate(replacedCondition)) : false;
        } catch (error) {
          logger.warn(error);
          return false;
        }
      }

      return true;
    };

    const effects = [];
    for (const effect of this.allApplicableEffects()) {
      if (shouldEffectBeAppliedToItem(effect, item)) {
        effects.push(effect);
      }
    }
    return effects;
  }

  /**
   * Replace placholders in a formula with data.
   * @param {string} formula The formular to enricht with data
   * @param {object} data The data to use for enriching
   * @returns {string | undefined} The Enriched formula or undefined, if it contains placeholders that cannot be resolved
   * @protected
   */
  static replaceFormulaData(formula, data) {
    const dataRgx = new RegExp(/@([a-z.0-9_\-]+)/gi);
    try {
      return formula.replace(dataRgx, (_, term) => {
        const value = foundry.utils.getProperty(data, term);
        if (value == null) {
          throw new Error();
        }
        return String(value).trim();
      });
    } catch {
      return undefined;
    }
  }

  /**
   * We override this with an empty implementation because we have our own custom way of applying
   * {@link ActiveEffect}s and {@link Actor#prepareEmbeddedDocuments} calls this.
   * @override
   */
  applyActiveEffects() {
    return;
  }

  /**
   * Apply active effects to items.
   *
   * @remarks
   * Talents are handled before all other item types, because if the total rank of a talent is affected by any
   * effects, that affects how many times effects provided by this talent need to be applied. At the moment, there is
   * no special ordering among talents. This means that having a talents that provide effects that adjust the total
   * rank of talents can lead to nondeterministic behavior and thus must be avoided.
   */
  applyActiveEffectsToItems() {
    /* Handle talents before all other item types, because their rank might be affected, which in turn affects how
           many times effects provided by that talent are applied */
    for (const item of this.itemTypes.talent) {
      this.applyActiveEffectsToItem(item);
    }
    for (const item of this.items) {
      if (item.type === "talent") continue;
      this.applyActiveEffectsToItem(item);
    }
  }

  /**
   * Apply effects to the given item.
   * @param {import("../item/item").DS4Item} item The item to which to apply effects
   * @protected
   */
  applyActiveEffectsToItem(item) {
    item.overrides = {};
    item.reset();
    DS4ActiveEffect.applyEffetcs(item, this.itemEffects(item)).forEach(this.newStatuses.add.bind(this.newStatuses));
  }

  /**
   * Apply effects to base data
   * @protected
   */
  applyActiveEffectsToBaseData() {
    this.overrides = {};
    DS4ActiveEffect.applyEffetcs(
      this,
      this.actorEffects,
      (change) =>
        !this.derivedDataProperties.includes(change.key) && !this.finalDerivedDataProperties.includes(change.key),
    ).forEach(this.newStatuses.add.bind(this.newStatuses));
  }

  /**
   * Apply effects to derived data
   * @protected
   */
  applyActiveEffectsToDerivedData() {
    DS4ActiveEffect.applyEffetcs(this, this.actorEffects, (change) =>
      this.derivedDataProperties.includes(change.key),
    ).forEach(this.newStatuses.add.bind(this.newStatuses));
  }

  /**
   * Apply transformations to the Actor data after effects have been applied to the base data.
   * @override
   */
  prepareDerivedData() {
    this.system.armorValueSpellMalus = Math.max(this.system.armorValueSpellMalus, 0);
    this.prepareCombatValues();
    this.prepareChecks();
  }

  /**
   * The list of properties that are derived from others, given in dot notation.
   * @returns {string[]} The list of derived propertie
   */
  get derivedDataProperties() {
    const combatValueProperties = Object.keys(DS4.i18n.combatValues).map(
      (combatValue) => `system.combatValues.${combatValue}.total`,
    );
    const checkProperties = Object.keys(DS4.i18n.checks)
      .filter((check) => check !== "defend")
      .map((check) => `system.checks.${check}`);
    return combatValueProperties.concat(checkProperties);
  }

  handleStatusChanges() {
    this.statuses ??= new Set();

    // Identify which special statuses had been active
    const specialStatuses = new Map();
    for (const statusId of Object.values(CONFIG.specialStatusEffects)) {
      specialStatuses.set(statusId, this.statuses.has(statusId));
    }
    this.statuses.clear();

    // set new statuses
    this.newStatuses.forEach(this.statuses.add.bind(this.statuses));

    // Apply special statuses that changed to active tokens
    const tokens = this.getActiveTokens();
    for (const [statusId, wasActive] of specialStatuses) {
      const isActive = this.statuses.has(statusId);
      if (isActive === wasActive) continue;
      for (const token of tokens) {
        token._onApplyStatusEffect(statusId, isActive);
      }
    }
  }

  /**
   * Apply final transformations to the Actor data after all effects have been applied.
   */
  prepareFinalDerivedData() {
    Object.values(this.system.attributes).forEach((attribute) => (attribute.total = Math.ceil(attribute.total)));
    Object.values(this.system.traits).forEach((trait) => (trait.total = Math.ceil(trait.total)));
    Object.entries(this.system.combatValues)
      .filter(([key]) => key !== "movement")
      .forEach(([, combatValue]) => (combatValue.total = Math.ceil(combatValue.total)));
    Object.keys(this.system.checks).forEach((key) => {
      this.system.checks[key] = Math.ceil(this.system.checks[key]);
    });

    this.system.combatValues.hitPoints.max = this.system.combatValues.hitPoints.total;
    this.system.checks.defend = this.system.combatValues.defense.total;
  }

  /**
   * The list of properties that are completely derived (i.e. {@link ActiveEffect}s cannot be applied to them),
   * given in dot notation.
   * @type {string[]}
   */
  get finalDerivedDataProperties() {
    return ["system.combatValues.hitPoints.max", "system.checks.defend"];
  }

  /**
   * The list of item types that can be owned by this actor.
   * @type {import("../item/item-data-source").ItemType[]}
   */
  get ownableItemTypes() {
    return ["weapon", "armor", "shield", "equipment", "loot", "spell"];
  }

  /**
   * Checks whether or not the given item type can be owned by the actor.
   * @param {import("../item/item-data-source").ItemType} itemType    The item type to check
   * @returns {boolean} Whether or not this actor can own the given item type
   */
  canOwnItemType(itemType) {
    return this.ownableItemTypes.includes(itemType);
  }

  /**
   * Prepares the combat values of the actor.
   * @protected
   */
  prepareCombatValues() {
    const system = this.system;

    system.combatValues.hitPoints.base = system.attributes.body.total + system.traits.constitution.total + 10;
    system.combatValues.defense.base =
      system.attributes.body.total + system.traits.constitution.total + this.armorValueOfEquippedItems;
    system.combatValues.initiative.base = system.attributes.mobility.total + system.traits.agility.total;
    system.combatValues.movement.base = system.attributes.mobility.total / 2 + 1;
    system.combatValues.meleeAttack.base = system.attributes.body.total + system.traits.strength.total;
    system.combatValues.rangedAttack.base = system.attributes.mobility.total + system.traits.dexterity.total;
    system.combatValues.spellcasting.base =
      system.attributes.mind.total + system.traits.aura.total - system.armorValueSpellMalus;
    system.combatValues.targetedSpellcasting.base =
      system.attributes.mind.total + system.traits.dexterity.total - system.armorValueSpellMalus;

    Object.values(system.combatValues).forEach(
      (combatValue) => (combatValue.total = combatValue.base + combatValue.mod),
    );
  }

  /**
   * The total armor value of the equipped items.
   * @type {number}
   * @protected
   */
  get armorValueOfEquippedItems() {
    return this.equippedItemsWithArmor.map((item) => item.system.armorValue).reduce((a, b) => a + b, 0);
  }

  /**
   * The armor value spell malus from equipped items.
   * @type {number}
   * @protected
   */
  get armorValueSpellMalusOfEquippedItems() {
    return this.equippedItemsWithArmor
      .filter((item) => !(item.type === "armor" && ["cloth", "natural"].includes(item.system.armorMaterialType)))
      .reduce((sum, item) => sum + item.system.armorValue, 0);
  }

  /**
   * The equipped items of this actor that provide armor.
   * @type {(import("../item/armor/armor").DS4Armor | import("../item/shield/shield").DS4Shield)[]}
   * @protected
   */
  get equippedItemsWithArmor() {
    return this.items.filter((item) => (item.type === "armor" || item.type === "shield") && item.system.equipped);
  }

  /**
   * Prepares the check target numbers of checks for the actor.
   * @protected
   */
  prepareChecks() {
    const system = this.system;
    system.checks = {
      appraise: system.attributes.mind.total + system.traits.intellect.total,
      changeSpell: system.attributes.mind.total + system.traits.intellect.total,
      climb: system.attributes.mobility.total + system.traits.strength.total,
      communicate: system.attributes.mind.total + system.traits.dexterity.total + this.itemTypes.language.length,
      decipherScript: system.attributes.mind.total + system.traits.intellect.total,
      defend: 0, // assigned in prepareFinalDerivedData as it must always match data.combatValues.defense.total and is not changeable by effects
      defyPoison: system.attributes.body.total + system.traits.constitution.total,
      disableTraps: system.attributes.mind.total + system.traits.dexterity.total,
      featOfStrength: system.attributes.body.total + system.traits.strength.total,
      flirt: system.attributes.mind.total + system.traits.aura.total,
      haggle: system.attributes.mind.total + Math.max(system.traits.intellect.total, system.traits.intellect.total),
      hide: system.attributes.mobility.total + system.traits.agility.total,
      identifyMagic: system.attributes.mind.total + system.traits.intellect.total,
      jump: system.attributes.mobility.total + system.traits.agility.total,
      knowledge: system.attributes.mind.total + system.traits.intellect.total,
      openLock: system.attributes.mind.total + system.traits.dexterity.total,
      perception: Math.max(system.attributes.mind.total + system.traits.intellect.total, 8),
      pickPocket: system.attributes.mobility.total + system.traits.dexterity.total,
      readTracks: system.attributes.mind.total + system.traits.intellect.total,
      resistDisease: system.attributes.body.total + system.traits.constitution.total,
      ride: system.attributes.mobility.total + Math.max(system.traits.agility.total, system.traits.aura.total),
      search: Math.max(system.attributes.mind.total + system.traits.intellect.total, 8),
      senseMagic: system.attributes.mind.total + system.traits.aura.total,
      sneak: system.attributes.mobility.total + system.traits.agility.total,
      startFire: system.attributes.mind.total + system.traits.dexterity.total,
      swim: system.attributes.mobility.total + system.traits.strength.total,
      wakeUp: system.attributes.mind.total + system.traits.intellect.total,
      workMechanism:
        system.attributes.mind.total + Math.max(system.traits.dexterity.total, system.traits.intellect.total),
    };
  }

  /**
   * Handle how changes to a Token attribute bar are applied to the Actor.
   * This only differs from the base implementation by also allowing negative values.
   * @param {string} attribute        The attribute path
   * @param {number} value            The target attribute value
   * @param {boolean} [isDelta=false] Whether the number represents a relative change (true) or an absolute change (false)
   * @param {boolean} [isBar=true]    Whether the new value is part of an attribute bar, or just a direct value
   * @returns {Promise<DS4Actor>} The updated Actor document
   * @override
   */
  async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) {
    const current = foundry.utils.getProperty(this.system, attribute);

    // Determine the updates to make to the actor data
    /** @type {Record<string, number>} */
    let updates;
    if (isBar) {
      if (isDelta) value = Math.min(Number(current.value) + value, current.max);
      updates = { [`system.${attribute}.value`]: value };
    } else {
      if (isDelta) value = Number(current) + value;
      updates = { [`system.${attribute}`]: value };
    }

    // Call a hook to handle token resource bar updates
    const allowed = Hooks.call("modifyTokenAttribute", { attribute, value, isDelta, isBar }, updates);
    return allowed !== false ? this.update(updates) : this;
  }

  /**
   * Roll for a given check.
   * @param {import("./actor-data-properties-base").Check} check          The check to perform
   * @param {import("../common/roll-options").RollOptions} [options={}]   Additional options to customize the roll
   * @returns {Promise<void>} A promise that resolves once the roll has been performed
   */
  async rollCheck(check, options = {}) {
    const speaker = ChatMessage.getSpeaker({ actor: this, ...options.speaker });
    await createCheckRoll(this.system.checks[check], {
      rollMode: getGame().settings.get("core", "rollMode"),
      maximumCoupResult: this.system.rolling.maximumCoupResult,
      minimumFumbleResult: this.system.rolling.minimumFumbleResult,
      flavor: "DS4.ActorCheckFlavor",
      flavorData: { actor: speaker.alias ?? this.name, check: DS4.i18nKeys.checks[check] },
      speaker,
    });
  }

  /**
   * Roll a generic check. A dialog is presented to select the combination of
   * Attribute and Trait to perform the check against.
   * @param {import("../common/roll-options").RollOptions} [options={}]   Additional options to customize the roll
   * @returns {Promise<void>} A promise that resolves once the roll has been performed
   */
  async rollGenericCheck(options = {}) {
    const attributeAndTrait = await this.selectAttributeAndTrait();
    if (!attributeAndTrait) {
      return;
    }
    const { attribute, trait } = attributeAndTrait;
    const checkTargetNumber = this.system.attributes[attribute].total + this.system.traits[trait].total;
    const speaker = ChatMessage.getSpeaker({ actor: this, ...options.speaker });
    await createCheckRoll(checkTargetNumber, {
      rollMode: getGame().settings.get("core", "rollMode"),
      maximumCoupResult: this.system.rolling.maximumCoupResult,
      minimumFumbleResult: this.system.rolling.minimumFumbleResult,
      flavor: "DS4.ActorGenericCheckFlavor",
      flavorData: {
        actor: speaker.alias ?? this.name,
        attribute: DS4.i18n.attributes[attribute],
        trait: DS4.i18n.traits[trait],
      },
      speaker,
    });
  }

  /**
   * Prompt the use to select an attribute and a trait.
   * @returns {Promise<AttributeAndTrait | null>}
   * @protected
   */
  async selectAttributeAndTrait() {
    const attributeIdentifier = "attribute-trait-selection-attribute";
    const traitIdentifier = "attribute-trait-selection-trait";
    return Dialog.prompt({
      title: getGame().i18n.localize("DS4.DialogAttributeTraitSelection"),
      content: await renderTemplate("systems/ds4/templates/dialogs/simple-select-form.hbs", {
        selects: [
          {
            label: getGame().i18n.localize("DS4.Attribute"),
            identifier: attributeIdentifier,
            options: Object.fromEntries(
              Object.entries(DS4.i18n.attributes).map(([attribute, translation]) => [
                attribute,
                `${translation} (${this.system.attributes[attribute].total})`,
              ]),
            ),
          },
          {
            label: getGame().i18n.localize("DS4.Trait"),
            identifier: traitIdentifier,
            options: Object.fromEntries(
              Object.entries(DS4.i18n.traits).map(([trait, translation]) => [
                trait,
                `${translation} (${this.system.traits[trait].total})`,
              ]),
            ),
          },
        ],
      }),
      label: getGame().i18n.localize("DS4.GenericOkButton"),
      callback: (html) => {
        const selectedAttribute = html.find(`#${attributeIdentifier}`).val();
        if (!isAttribute(selectedAttribute)) {
          throw new Error(
            getGame().i18n.format("DS4.ErrorUnexpectedAttribute", {
              actualAttribute: selectedAttribute,
              expectedTypes: Object.keys(DS4.i18n.attributes)
                .map((attribute) => `'${attribute}'`)
                .join(", "),
            }),
          );
        }
        const selectedTrait = html.find(`#${traitIdentifier}`).val();
        if (!isTrait(selectedTrait)) {
          throw new Error(
            getGame().i18n.format("DS4.ErrorUnexpectedTrait", {
              actualTrait: selectedTrait,
              expectedTypes: Object.keys(DS4.i18n.traits)
                .map((attribute) => `'${attribute}'`)
                .join(", "),
            }),
          );
        }
        return {
          attribute: selectedAttribute,
          trait: selectedTrait,
        };
      },
      rejectClose: false,
    });
  }

  static evaluator = new Evaluator({
    context: Math,
    predicate: (identifier) =>
      Validator.defaultPredicate(identifier) || ["includes", "toLowerCase", "toUpperCase"].includes(identifier),
  });
}

/**
 * @typedef {object} AttributeAndTrait
 * @property {keyof typeof DS4.i18n.attributes} attribute
 * @property {keyof typeof DS4.i18n.traits} trait
 */
