import Document from "../abstract/document.mjs";
import * as fields from "../data/fields.mjs";
import {isPlainObject, mergeObject, parseUuid} from "../utils/helpers.mjs";

/**
 * @import {ActiveEffectData, EffectDurationData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "../abstract/_types.mjs";
 * @import {SerializableBuiltin} from "../_types.mjs";
 */

/**
 * The ActiveEffect Document.
 * Defines the DataSchema and common behaviors for an ActiveEffect which are shared between both client and server.
 * @extends {Document<ActiveEffectData>}
 * @mixes ActiveEffectData
 * @category Documents
 */
export default class BaseActiveEffect extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "ActiveEffect",
    collection: "effects",
    hasTypeData: true,
    baseTypeAllowed: true,
    indexed: true,
    compendiumIndexFields: ["_id", "name", "img", "type", "sort", "folder"],
    label: "DOCUMENT.ActiveEffect",
    labelPlural: "DOCUMENT.ActiveEffects",
    schemaVersion: "14.353",
    permissions: {
      create: BaseActiveEffect.#canCreate,
      delete: "OWNER"
    }
  }, {inplace: false}));

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

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new fields.DocumentIdField(),
      name: new fields.StringField({required: true, blank: false, textSearch: true}),
      img: new fields.FilePathField({categories: ["IMAGE"], initial: () => this.implementation.DEFAULT_ICON}),
      type: new fields.DocumentTypeField(this, {initial: CONST.BASE_DOCUMENT_TYPE}),
      system: new fields.TypeDataField(this),
      disabled: new fields.BooleanField(),
      start: new fields.SchemaField({
        combat: new fields.ForeignDocumentField(foundry.documents.BaseCombat),
        combatant: new fields.ForeignDocumentField(foundry.documents.BaseCombatant, {idOnly: true}),
        initiative: new fields.NumberField({required: true}),
        round: new fields.NumberField({required: true, integer: true, min: 0}),
        turn: new fields.NumberField({required: true, integer: true, min: 0}),
        time: new fields.NumberField({required: true, nullable: false, integer: true})
      }, {nullable: true}),
      duration: new fields.SchemaField({
        value: new fields.NumberField({required: true, nullable: true, integer: true, min: 0}),
        units: new fields.StringField({required: true, choices: CONST.ACTIVE_EFFECT_DURATION_UNITS, initial: "seconds"}),
        expiry: new fields.StringField({required: true, blank: false, nullable: true,
          initial: d => typeof d?.duration?.value === "number" ? "turnStart" : null
        }),
        expired: new fields.BooleanField()
      }),
      description: new fields.HTMLField({textSearch: true}),
      origin: new fields.DocumentUUIDField({relative: true}),
      tint: new fields.ColorField({nullable: false, initial: "#ffffff"}),
      transfer: new fields.BooleanField({initial: true}),
      statuses: new fields.SetField(new fields.StringField({required: true, blank: false})),
      showIcon: new fields.NumberField({
        required: true,
        nullable: false,
        choices: Object.values(CONST.ACTIVE_EFFECT_SHOW_ICON),
        initial: CONST.ACTIVE_EFFECT_SHOW_ICON.CONDITIONAL
      }),
      folder: new fields.ForeignDocumentField(foundry.documents.BaseFolder),
      sort: new fields.IntegerSortField(),
      flags: new fields.DocumentFlagsField(),
      _stats: new fields.DocumentStatsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "EFFECT"];

  /**
   * The default icon used for newly created ActiveEffect documents
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/aura.svg";

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

  /** @override */
  static canUserCreate(user) {
    return user.isGM;
  }

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

  /**
   * Is a User able to create a new ActiveEffect?
   * Embedded ActiveEffects depend on parent (viz. Actor or Item) ownership.
   * Otherwise, the User must be at least an Assistant Gamemaster.
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( doc.parent ) return doc.parent.testUserPermission(user, "OWNER");
    return user.isGM;
  }

  /* -------------------------------------------- */
  /*  Database Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    const updates = {};
    if ( !this.parent || (this.parent instanceof foundry.documents.BaseActor) ) {
      updates.transfer = false;
      if ( !this.parent ) updates.start = null;
    }
    this.updateSource(updates);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  static #MODES_TO_TYPES = {
    0: "custom",
    1: "multiply",
    2: "add",
    3: "downgrade",
    4: "upgrade",
    5: "override"
  };

  static #TYPES_TO_MODES = Object.entries(BaseActiveEffect.#MODES_TO_TYPES).reduce((types, [mode, type]) => {
    types[type] = Number(mode);
    return types;
  }, {});

  /** @inheritDoc */
  static migrateData(source, options) {

    /**
     * Migrate origin
     * @deprecated since v14
     */
    if ( typeof source.origin === "string" ) {
      const parseOptions = source.origin.startsWith(".") ? {relative: new BaseActiveEffect({name: "ABC"})} : {};
      const parsed = parseUuid(source.origin, parseOptions);
      if ( !parsed || (parsed.type && !CONST.ALL_DOCUMENT_TYPES.includes(parsed.type)) ) {
        mergeObject(source.flags ??= {}, {core: {originText: source.origin}});
        source.origin = null;
      }
    }

    /**
     * Migrate changes
     * @deprecated since v14
     */
    if ( Array.isArray(source.changes) ) {
      source.system ??= {};
      this._addDataFieldMigration(source, "changes", "system.changes");
      if ( Array.isArray(source.system.changes) ) {
        for ( const change of source.system.changes ) {
          if ( !Object.hasOwn(change, "type") && (typeof change.mode === "number") ) {
            change.type = BaseActiveEffect.#MODES_TO_TYPES[change.mode] ?? `custom.${change.mode}`;
            delete change.mode;
          }
          if ( isPlainObject(change) && (typeof change.value === "string") ) {
            change.value = BaseActiveEffect.#migrateChangeValue(change.value);
          }
        }
      }
    }

    /**
     * Migrate start data
     * @deprecated since v14
     */
    const duration = source.duration;
    if ( isPlainObject(duration) && Object.hasOwn(duration, "startTime") && !Object.hasOwn(source, "start") ) {
      source.start = typeof duration.startTime === "number" ? {} : null;
      if ( source.start ) {
        this._addDataFieldMigration(source, "duration.combat", "start.combat");
        this._addDataFieldMigration(source, "duration.startRound", "start.round");
        this._addDataFieldMigration(source, "duration.startTime", "start.time");
        this._addDataFieldMigration(source, "duration.startTurn", "start.turn");
      }
    }
    BaseActiveEffect.#migrateDuration(source);

    return super.migrateData(source, options);
  }

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

  /**
   * Migrate a single change value
   * @param {unknown} value
   * @returns {SerializableBuiltin}
   */
  static #migrateChangeValue(value) {
    if ( (typeof value !== "string") || (value === "") ) return value;
    try {
      return BaseActiveEffect.#migrateChangeValue(JSON.parse(value));
    }
    catch{
      return value;
    }
  }

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

  /**
   * Migrate legacy duration data into its current schema-defined structure.
   * @param {object} source Possibly unmigrated source data
   */
  static #migrateDuration(source) {
    const duration = source.duration;
    if ( !isPlainObject(duration) ) return;
    for ( const unit of ["seconds", "turns", "rounds"] ) {
      const hasRealProperty = Object.hasOwn(duration, unit) && !Object.getOwnPropertyDescriptor(duration, unit)?.get;
      if ( hasRealProperty && (typeof duration[unit] === "number") ) {
        if ( !Object.hasOwn(duration, "value") ) duration.value = duration[unit];
        if ( !Object.hasOwn(duration, "units") ) duration.units = unit;
        break;
      }
    }
  }

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

  /** @inheritDoc */
  static shimData(data, options) {
    if ( Object.isSealed(data) ) return super.shimData(data, options);
    if ( !Object.hasOwn(data, "changes") && Object.hasOwn(data.system ?? {}, "changes") ) {
      Object.defineProperty(data, "changes", {
        get: () => data.system.changes,
        set: changes => {
          data.system.changes = changes;
        },
        configurable: true,
        enumerable: false
      });
    }
    const changes = Array.isArray(data.system?.changes) ? data.system.changes : [];
    this._shimChanges(changes);

    if ( !isPlainObject(data.duration) ) return super.shimData(data, options);
    if ( isPlainObject(data.start) ) {
      this._addDataFieldShim(data, "duration.combat", "start.combat", {since: 14, until: 16, once: true});
    }
    for ( const key of ["startTime", "startRound", "startTurn"] ) {
      const newKey = key.split(/(?=[A-Z])/).map(k => k.toLocaleLowerCase("en")).join(".");
      this._addDataFieldShim(data, `duration.${key}`, newKey, {since: 14, until: 16, once: true});
    }
    const getSeconds = () => {
      if ( typeof data.duration.value !== "number" ) return null;
      if ( data.duration.units === "seconds" ) return data.duration.value;
      if ( (game.view === "game") && CONST.ACTIVE_EFFECT_TIME_DURATION_UNITS.includes(data.duration.units) ) {
        const componentUnit = data.duration.units.replace(/s$/, "");
        return game.time.calendar.componentsToTime({[componentUnit]: data.duration.value});
      }
      return null;
    };
    BaseActiveEffect.#shimDurationField(data.duration, "seconds", getSeconds);
    BaseActiveEffect.#shimDurationField(data.duration, "rounds");
    BaseActiveEffect.#shimDurationField(data.duration, "turns");
    return super.shimData(data, options);
  }

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

  /**
   * Add shims to the changes array.
   * @param {EffectChangeData[]} changes
   * @internal
   */
  static _shimChanges(changes) {
    for ( const change of changes ) {
      if ( Object.getOwnPropertyDescriptor(change, "mode")?.get ) continue;
      Object.defineProperty(change, "mode", {
        get: () => {
          const message = "You are accessing the numeric #mode of an ActiveEffect change. Use the string #type instead.";
          foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
          return BaseActiveEffect.#TYPES_TO_MODES[change.type]
            ?? (Number(/^custom\.(-?\d+)$/.exec(change.type)?.[1]) || 0);
        },
        set: mode => {
          mode = Number(mode);
          if ( Number.isInteger(mode) ) change.type = BaseActiveEffect.#MODES_TO_TYPES[mode] ?? `custom.${mode}`;
        },
        configurable: true,
        enumerable: false
      });
    }
  }

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

  /**
   * Shim several legacy duration properties for backward compatibility
   * @param {Partial<EffectDurationData>} duration
   * @param {string} oldKey The legacy property name
   * @param {() => unknown} [get] A getter function for special cases
   */
  static #shimDurationField(duration, oldKey, get) {
    if ( Object.hasOwn(duration, oldKey) ) return;
    get ??= () => duration.units === oldKey ? duration.value : null;
    const propertyPath = oldKey.replaceAll(".", "#");
    const message = `You are accessing ${this.name}#${propertyPath}. Duration data now has value and units fields.`;
    Object.defineProperty(duration, oldKey, {
      get: () => {
        foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
        return get();
      },
      configurable: true,
      enumerable: false
    });
  }
}
