/** MIT (c) 2021 DnD5e Helpers */

/** @typedef {import('./api.js').NoticeConfig} NoticeConfig */

import {
  logger
} from './logger.js';

const NAME = "warpgate";
const PATH = `/modules/${NAME}`;

export class MODULE {
  static data = {
      name: NAME,
      path: PATH,
      title: "Warp Gate"
    };

  static get isV10() {
    // @ts-ignore
    return game.release?.generation >= 10;
  }

  static async register() {
    logger.info("Initializing Module");
    MODULE.settings();
  }

  static async build() {
    
    logger.info("Module Data Built");
  }

  static setting(key) {
    return game.settings.get(MODULE.data.name, key);
  }

  /**
   * Returns the localized string for a given warpgate scoped i18n key
   *
   * @ignore
   * @static
   * @param {*} key
   * @returns {string}
   * @memberof MODULE
   */
  static localize(key) {
    return game.i18n.localize(`warpgate.${key}`);
  }

  static format(key, data) {
    return game.i18n.format(`warpgate.${key}`, data);
  }

  static canSpawn(user) {
    const reqs = [
      'TOKEN_CREATE',
      'TOKEN_CONFIGURE',
      'FILES_BROWSE',
    ]

    return MODULE.canUser(user, reqs);
  }

  static canMutate(user) {
    const reqs = [
      'TOKEN_CONFIGURE',
      'FILES_BROWSE',
    ]

    return MODULE.canUser(user, reqs);
  }

  /**
   * Handles notice request from spawns and mutations
   *
   * @static
   * @param {{x: Number, y: Number}} location
   * @param {string} sceneId
   * @param {NoticeConfig} config
   * @memberof MODULE
   */
  static async handleNotice({x, y}, sceneId, config) {

    /* can only operate if the user is on the scene requesting notice */
    if( canvas.ready && 
        !!sceneId && !!config &&
        config.receivers.includes(game.userId) &&
        canvas.scene?.id === sceneId ) {

      const panSettings = {};
      const hasLoc = x !== undefined && y !== undefined;
      const doPan = !!config.pan;
      const doZoom = !!config.zoom;
      const doPing = !!config.ping;

      if(hasLoc) {
        panSettings.x = x;
        panSettings.y = y;
      }

      if(doPan) {
        panSettings.duration = Number.isNumeric(config.pan) && config.pan !== true ? Number(config.pan) : CONFIG.Canvas.pings.pullSpeed;
      }

      if (doZoom) {
        panSettings.scale = Math.min(CONFIG.Canvas.maxZoom, config.zoom);
      }

      if (doPan) {
        await canvas.animatePan(panSettings);
      }

      if (doPing && hasLoc) {
        const user = game.users.get(config.sender);
        const location = {x: panSettings.x, y: panSettings.y};

        /* draw the ping, either onscreen or offscreen */
        canvas.isOffscreen(location) ?
          canvas.controls.drawOffscreenPing(location, {scene: sceneId, style: CONFIG.Canvas.pings.types.ARROW, user}) :
          canvas.controls.drawPing(location, {scene: sceneId, style: config.ping, user});
      }
    }
  }

  /**
   * @return {Array<String>} missing permissions for this operation
   */
  static canUser(user, requiredPermissions) {
    if(MODULE.setting('disablePermCheck')) return [];
    const {role} = user;
    const permissions = game.settings.get('core','permissions');
    return requiredPermissions.filter( req => !permissions[req].includes(role) ).map(missing => game.i18n.localize(CONST.USER_PERMISSIONS[missing].label));
  }

  /**
   * A helper functions that returns the first active GM level user.
   * @returns {User|undefined} First active GM User
   */
  static firstGM() {
    return game.users?.find(u => u.isGM && u.active);
  }

  /**
   * Checks whether the user calling this function is the user returned
   * by {@link warpgate.util.firstGM}. Returns true if they are, false if they are not.
   * @returns {boolean} Is the current user the first active GM user?
   */
  static isFirstGM() {
    return game.user?.id === MODULE.firstGM()?.id;
  }

  static emptyObject(obj){
    // @ts-ignore
    return foundry.utils.isEmpty(obj);
  }

  static removeEmptyObjects(obj) {
    let result = foundry.utils.flattenObject(obj);
    Object.keys(result).forEach( key => {
      if(typeof result[key] == 'object' && MODULE.emptyObject(result[key])) {
        delete result[key];
      } 
    });

    return foundry.utils.expandObject(result);
  }

  /**
   * Duplicates a compatible object (non-complex).
   *
   * @returns {Object}
   */
  static copy(source, errorString = 'error.unknown') {
    try {
      return foundry.utils.deepClone(source, {strict:true});
    } catch (err) {
      logger.catchThrow(err, MODULE.localize(errorString));
    }

    return;
  }

  /**
   * Removes top level empty objects from the provided object.
   *
   * @static
   * @param {object} obj
   * @memberof MODULE
   */
  static stripEmpty(obj, inplace = true) {
    const result = inplace ? obj : MODULE.copy(obj);

    Object.keys(result).forEach( key => {
      if(typeof result[key] == 'object' && MODULE.emptyObject(result[key])) {
        delete result[key];
      } 
    });

    return result;
  }

  static ownerSublist(docList) {

    /* break token list into sublists by first owner */
    const subLists = docList.reduce( (lists, doc) => {
      if(!doc) return lists;
      const owner = MODULE.firstOwner(doc)?.id ?? 'none';
      lists[owner] ??= [];
      lists[owner].push(doc);
      return lists;
    },{});

    return subLists;
  }
  
  /**
   * Returns the first active user with owner permissions for the given document, 
   * falling back to the firstGM should there not be any. Returns false if the 
   * document is falsey. In the case of token documents it checks the permissions 
   * for the token's actor as tokens themselves do not have a permission object.
   *
   * @param {{ actor: Actor } | { document: { actor: Actor } } | Actor} doc
   *
   * @returns {User|undefined}
   */
  static firstOwner(doc) {
    /* null docs could mean an empty lookup, null docs are not owned by anyone */
    if (!doc) return undefined;

    /* while conceptually correct, tokens derive permissions from their
     * (synthetic) actor data.
     */
    const corrected = doc instanceof TokenDocument ? doc.actor :
                      // @ts-ignore 2589
                      doc instanceof Token ? doc.document.actor : doc;
    
    const ownershipPath = MODULE.isV10 ? 'ownership' : 'data.permission';

    const permissionObject = getProperty(corrected ?? {}, ownershipPath) ?? {};

    const playerOwners = Object.entries(permissionObject)
      .filter(([id, level]) => (!game.users.get(id)?.isGM && game.users.get(id)?.active) && level === 3)
      .map(([id, ]) => id);
    
    if (playerOwners.length > 0) {
      return game.users.get(playerOwners[0]);
    }

    /* if no online player owns this actor, fall back to first GM */
    return MODULE.firstGM();
  }

  /**
   * Checks whether the user calling this function is the user returned by 
   * {@link warpgate.util.firstOwner} when the function is passed the 
   * given document. Returns true if they are the same, false if they are not.
   * 
   * As `firstOwner`, biases towards players first.
   *
   * @returns {boolean} the current user is the first player owner. If no owning player, first GM.
   */
  static isFirstOwner(doc) {
    return game.user.id === MODULE.firstOwner(doc).id;
  }

  /**
   * Helper function. Waits for a specified amount of time in milliseconds (be sure to await!). 
   * Useful for timings with animations in the pre/post callbacks.
   * 
   * @param {Number} ms Time to delay, in milliseconds
   * @returns Promise
   */
  static async wait(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms))
  }

  static async waitFor(fn, maxIter = 600, iterWaitTime = 100, i = 0) {
    const continueWait = (current, max) => {

      /* negative max iter means wait forever */
      if (maxIter < 0) return true;

      return current < max;
    }

    while (!fn(i, ((i * iterWaitTime) / 100)) && continueWait(i, maxIter)) {
      i++;
      await MODULE.wait(iterWaitTime);
    }
    return i === maxIter ? false : true;
  }

  static settings() {
    const data = {
      disablePermCheck: {
        config: true, scope: 'world', type: Boolean, default: false,
      }
    }

    MODULE.applySettings(data);
  }

  static applySettings(settingsData) {
    Object.entries(settingsData).forEach(([key, data]) => {
      game.settings.register(
        MODULE.data.name, key, {
          name: MODULE.localize(`setting.${key}.name`),
          hint: MODULE.localize(`setting.${key}.hint`),
          ...data
        }
      );
    });
  }

  /**
   * @param {string|Actor} actorNameDoc
   * @param {object} tokenUpdates
   *
   * @returns {Promise<TokenDocument|false>}
   */
  static async getTokenData(actorNameDoc, tokenUpdates) {

    let sourceActor = actorNameDoc;
    if(typeof actorNameDoc == 'string') {
      /* lookup by actor name */
      sourceActor = game.actors.getName(actorNameDoc);
    }

    //get source actor
    if (!sourceActor) {
      logger.error(`Could not find world actor named "${actorNameDoc}" or no souce actor document provided.`);
      return false;
    }

    //get prototoken data -- need to prepare potential wild cards for the template preview
    let protoData = await sourceActor.getTokenDocument(tokenUpdates);
    if (!protoData) {
      logger.error(`Could not find proto token data for ${sourceActor.name}`);
      return false;
    }

    await loadTexture(protoData.texture.src);

    return protoData;
  }

  static async updateProtoToken(protoToken, changes) {
    protoToken.updateSource(changes);  
    const img = getProperty(changes, 'texture.src'); 
    if (img) await loadTexture(img);
  }

  static getMouseStagePos() {
    const mouse = canvas.app.renderer.plugins.interaction.mouse;
    return mouse.getLocalPosition(canvas.app.stage);
  }

  /**
   * @returns {undefined} provided updates object modified in-place
   */
  static shimUpdate(updates) {

    updates.token = MODULE.shimClassData(TokenDocument.implementation, updates.token);
    updates.actor = MODULE.shimClassData(Actor.implementation, updates.actor);

    Object.keys(updates.embedded ?? {}).forEach( (embeddedName) => {
      const cls = CONFIG[embeddedName].documentClass;

      Object.entries(updates.embedded[embeddedName]).forEach( ([shortId, data]) => {
        updates.embedded[embeddedName][shortId] = (typeof data == 'string') ? data : MODULE.shimClassData(cls, data);
      });
    });

  }

  static shimClassData(cls, change) {

    if(!change) return change;

    if(!!change && !foundry.utils.isEmpty(change)) {
      /* shim data if needed */
      return cls.migrateData(foundry.utils.expandObject(change));
    }

    return foundry.utils.expandObject(change);
  }

  static getFeedbackSettings({alwaysAccept = false, suppressToast = false} = {}) {
    const acceptSetting = MODULE.setting('alwaysAcceptLocal') == 0 ? 
      MODULE.setting('alwaysAccept') :
      {1: true, 2: false}[MODULE.setting('alwaysAcceptLocal')];

    const accepted = !!alwaysAccept ? true : acceptSetting;

    const suppressSetting = MODULE.setting('suppressToastLocal') == 0 ? 
      MODULE.setting('suppressToast') :
      {1: true, 2: false}[MODULE.setting('suppressToastLocal')];

    const suppress = !!suppressToast ? true : suppressSetting;

    return {alwaysAccept: accepted, suppressToast: suppress};

  }

  /**
   * Collects the changes in 'other' compared to 'base'.
   * Also includes "delete update" keys for elements in 'base' that do NOT
   * exist in 'other'.
   */
  static strictUpdateDiff(base, other) {
    /* get the changed fields */
    const diff = foundry.utils.flattenObject(foundry.utils.diffObject(base, other, {inner: true}));

    /* get any newly added fields */
    const additions = MODULE.unique(flattenObject(base), flattenObject(other))

    /* set their data to null */
    Object.keys(additions).forEach( key => {
      if( typeof additions[key] != 'object' ) diff[key] = null
    });

    return foundry.utils.expandObject(diff);
  }

  static unique(object, remove) {
    // Validate input
    const ts = getType(object);
    const tt = getType(remove);
    if ((ts !== "Object") || (tt !== "Object")) throw new Error("One of source or template are not Objects!");

    // Define recursive filtering function
    const _filter = function (s, t, filtered) {
      for (let [k, v] of Object.entries(s)) {
        let has = t.hasOwnProperty(k);
        let x = t[k];

        // Case 1 - inner object
        if (has && (getType(v) === "Object") && (getType(x) === "Object")) {
          filtered[k] = _filter(v, x, {});
        }

        // Case 2 - inner key
        else if (!has) {
          filtered[k] = v;
        }
      }
      return filtered;
    };

    // Begin filtering at the outer-most layer
    return _filter(object, remove, {});
  }

  /**
   * Helper function for quickly creating a simple dialog with labeled buttons and associated data. 
   * Useful for allowing a choice of actors to spawn prior to `warpgate.spawn`.
   *
   * @param {Object} data 
   * @param {Array<{label: string, value:*}>} data.buttons
   * @param {string} [data.title]
   * @param {string} [data.content]
   * @param {Object} [data.options]
   *
   * @param {string} [direction = 'row'] 'column' or 'row' accepted. Controls layout direction of dialog.
   */
  static async buttonDialog(data, direction = 'row') {
    return await new Promise(async (resolve) => {
      /** @type Object<string, object> */
      let buttons = {},
        dialog;

      data.buttons.forEach((button) => {
        buttons[button.label] = {
          label: button.label,
          callback: () => resolve(button.value)
        }
      });

      dialog = new Dialog({
        title: data.title ?? '',
        content: data.content ?? '',
        buttons,
        close: () => resolve(false)
      }, {
        /*width: '100%',*/
        height: '100%',
        ...data.options
      });

      await dialog._render(true);
      dialog.element.find('.dialog-buttons').css({
        'flex-direction': direction
      });
    });
  }

  static dialogInputs = (data) => {

    /* correct legacy input data */
    data.forEach(inputData => {
      if (inputData.type === 'select') {
        inputData.options.forEach((e, i) => {
          switch (typeof e) {
            case 'string':
              /* if we are handed legacy string values, convert them to objects */
              inputData.options[i] = {value: e, html: e};
              /* fallthrough to tweak missing values from object */

            case 'object':
              /* if no HMTL provided, use value */
              inputData.options[i].html ??= inputData.options[i].value;

              /* sanity check */
              if(!!inputData.options[i].html && inputData.options[i].value != undefined) {
                break;
              }

              /* fallthrough to throw error if all else fails */
              
            default: {
              const emsg = MODULE.format('error.badSelectOpts', {fnName: 'menu'});
              logger.error(emsg);
              throw new Error(emsg);
            }
          }
        });
      }
    });

    const mapped = data.map(({type, label, value, options}, i) => {
      type = type.toLowerCase();
      switch (type) {
        case 'header': return `<tr><td colspan = "2"><h2>${label}</h2></td></tr>`;
        case 'button': return '';
        case 'info': return `<tr><td colspan="2">${label}</td></tr>`;
        case 'select': {

          const optionString = options.map((e, i) => {
            return `<option value="${i}">${e.html}</option>`
          }).join('');

          return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><select id="${i}qd">${optionString}</select></td></tr>`;
        }
        case 'radio': return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" ${(options instanceof Array ? options[1] : false ?? false) ? 'checked' : ''} value="${value ?? label}" name="${options instanceof Array ? options[0] : options ?? 'radio'}"/></td></tr>`;
        case 'checkbox': return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" ${(options instanceof Array ? options[0] : options ?? false) ? 'checked' : ''} value="${value ?? label}"/></td></tr>`;
        default: return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" value="${options instanceof Array ? options[0] : options}"/></td></tr>`;
      }
    }).join(``)


    const content = `
<table style="width:100%">
  ${mapped}
</table>`;

    return content;
  }

  static async dialog(data = {}, title = 'Prompt', submitLabel = 'Ok') {
    logger.warn(`'warpgate.dialog' is deprecated and will be removed in version 1.17.0. See 'warpgate.menu' as a replacement.`);
    data = data instanceof Array ? data : [data];

    const results = await warpgate.menu({inputs: data}, {title, defaultButton: submitLabel});
    if(results.buttons === false) return false;
    return results.inputs;
  }

  /**
   * Advanced dialog helper providing multiple input type options as well as user defined buttons.
   *
   * | `type` | `options` | Return Value | Notes |
   * |--|--|--|--|
   * | header | none | undefined | Shortcut for `info | <h2>text</h2>`. |
   * | info   | none | undefined | Inserts a line of text for display/informational purposes. |
   * | text | default value | {String} final value of text field | |
   * | password | (as `text`) | (as `text`) | Characters are obscured for security. |
   * | radio | [group name, default state (`false`)] {Array of String/Bool} | selected: {Class<Primitive>} `value`. un-selected: {Boolean} `false` | For a given group name, only one radio button can be selected. |
   * | checkbox | default state (`false`) {Boolean} | {Boolean} `value`/`false` checked/unchecked | `label` is used for the HTML element's `name` property |
   * | number | (as `text`) | {Number} final value of text field converted to a number |
   * | select | array of option labels or objects {value, html} | `value` property of selected option. If values not provided, numeric index of option in original list | | 
   * @static
   * @param {object} [prompts]  
   * @param {Array<{label: string, type: string, options: any|Array<any>} >} [prompts.inputs=[]] follow the same structure as dialog
   * @param {Array<{label: string, value: any, callback: Function }>} [prompts.buttons=[]] as buttonDialog
   * @param {object} [config] 
   * @param {string} [config.title='Prompt'] Title of dialog
   * @param {string} [config.defaultButton='Ok'] default button label if no buttons provided
   * @param {function(HTMLElement) : void} [config.render=undefined]
   * @param {Function} [config.close = (resolve) => resolve({buttons: false})]
   * @param {object} [config.options = {}] Options passed to the Dialog constructor
   *
   * @return {Promise<{ inputs: Array<any>, buttons: any}>} Object with `inputs` containing the chosen values for each provided input,
   *   in order, and the provided `value` of the pressed button, or `false` if closed.
   *
   * @example
   * await warpgate.menu({
   *  inputs: [{
   *    label: 'My Way',
   *    type: 'radio',
   *    options: 'group1'
   *  }, {
   *    label: 'The Highway',
   *    type: 'radio',
   *    options: 'group1'
   *  }],
   *  buttons: [{
   *    label: 'Yes',
   *    value: 1
   *  }, {
   *    label: 'No',
   *    value: 2
   *  }, {
   *    label: 'Maybe',
   *    value: 3
   *  }, {
   *    label: 'Eventually',
   *    value: 4
   *  }]
   * }, {
   *  options: {
   *    width: '100px',
   *    height: '100%'
   *  }
   * })
   *
   */
  static async menu(prompts = {}, config = {}) {

    /* apply defaults to optional params */
    const configDefaults = {
      title : 'Prompt',
      defaultButton : 'Ok',
      render:null,
      close : (resolve) => resolve({buttons: false}),
      options : {}
    }

    const {title, defaultButton, render, close, options} = foundry.utils.mergeObject(configDefaults, config);
    const {inputs, buttons} = foundry.utils.mergeObject({inputs: [], buttons: []}, prompts);

    return await new Promise((resolve) => {
      let content = MODULE.dialogInputs(inputs);
      /** @type Object<string, object> */
      let buttonData = {}

      buttons.forEach((button) => {
        buttonData[button.label] = {
          label: button.label,
          callback: async (html) => {
            const results = {
              inputs: MODULE._innerValueParse(inputs, html),
              buttons: button.value
            }
            if(button.callback instanceof Function) await button.callback(results, button, html); 
            resolve(results);
          }
        }
      });

      /* insert standard submit button if none provided */
      if (buttons.length < 1) {
        buttonData = {
          Ok: {
            label: defaultButton,
            callback: (html) => resolve({inputs: MODULE._innerValueParse(inputs, html), buttons: true})
          }
        }
      }

      new Dialog({
        title,
        content,
        close: (...args) => close(resolve, ...args),
        buttons: buttonData,
        render,
      }, {focus: true, ...options}).render(true);
    });
  }

  static _innerValueParse(data, html) {
    return Array(data.length).fill().map((e, i) => {
      let {
        type
      } = data[i];
      if (type.toLowerCase() === `select`) {
        return data[i].options[html.find(`select#${i}qd`).val()].value;
      } else {
        switch (type.toLowerCase()) {
          case `text`:
          case `password`:
            return html.find(`input#${i}qd`)[0].value;
          case `radio`:
          case `checkbox`:
            return html.find(`input#${i}qd`)[0].checked ? html.find(`input#${i}qd`)[0].value : false;
          case `number`:
            return html.find(`input#${i}qd`)[0].valueAsNumber;
        }
      }
    })
  }
}
