const MODULE_ID = 'pf2e-modifiers-matter'
// TODO - figure out how to notice effects on the target that change their Ref/Fort/Will DC, e.g. when trying to Tumble Through against targeted enemy
// TODO - also effects from "rules" in general
// so far:  got Cover to work (flat modifier to ac)

// Helpful for testing - replace random dice roller with 1,2,3,4....19,20 by putting this in the console:
/*
NEXT_RND_ROLLS_D20 = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
rndIndex = -1
CONFIG.Dice.randomUniform = () => {rndIndex = (rndIndex + 1) % NEXT_RND_ROLLS_D20.length; return NEXT_RND_ROLLS_D20[rndIndex] / 20 - 0.001}
 */

// this file has a ton of math (mostly simple).
// I did my best to make it all easily understandable math, but there are limits to what I can do.

// strong green = this condition was necessary to achieve this result (others were potentially also necessary).  this
// means the one who caused this condition should definitely be congratulated/thanked.
// weak green = this condition was not necessary to achieve this result, but degree of success did change due to
// something in this direction, through a collection of weak green and/or strong green conditions.  for example,
// if you rolled a 14, had +1 & +2, and needed a 15, both the +1 and +2 are weak green because neither is necessary on
// its own but they were necessary together.
// if you had rolled a 13 in this case, the +2 would be strong green but the +1 would still be weak green, simply
// because it's difficult to come up with an algorithm that would solve complex cases.
// note, by the way, that in case of multiple non-stacking conditions, PF2e hides some of them from the chat card.
const POSITIVE_COLOR = '#008000'
const WEAK_POSITIVE_COLOR = '#91a82a'
const NO_CHANGE_COLOR = '#000000'
const NEGATIVE_COLOR = '#ff0000'
const WEAK_NEGATIVE_COLOR = '#ff852f'
let IGNORED_MODIFIER_LABELS = []

let warnedAboutLocalization = false
const tryLocalize = (key, defaultValue) => {
  const localized = game.i18n.localize(key)
  if (localized === key) {
    if (!warnedAboutLocalization) {
      console.warn(`${MODULE_ID}: failed to localize ${key}`)
      warnedAboutLocalization = true
    }
    return defaultValue
  }
  return localized
}

const initializeIgnoredModifiers = () => {
  const IGNORED_MODIFIERS_I18N = [
    'PF2E.BaseModifier',
    'PF2E.ModifierTitle',
    'PF2E.MultipleAttackPenalty',
    'PF2E.ProficiencyLevel0',
    'PF2E.ProficiencyLevel1',
    'PF2E.ProficiencyLevel2',
    'PF2E.ProficiencyLevel3',
    'PF2E.ProficiencyLevel4',
    'PF2E.AbilityStr',
    'PF2E.AbilityCon',
    'PF2E.AbilityDex',
    'PF2E.AbilityInt',
    'PF2E.AbilityWis',
    'PF2E.AbilityCha',
    'PF2E.PotencyRuneLabel',
    'PF2E.AutomaticBonusProgression.attackPotency',
    'PF2E.AutomaticBonusProgression.defensePotency',
    'PF2E.AutomaticBonusProgression.savePotency',
    'PF2E.AutomaticBonusProgression.perceptionPotency',
    'PF2E.NPC.Adjustment.EliteLabel',
    'PF2E.NPC.Adjustment.WeakLabel',
    'PF2E.MasterSavingThrow.fortitude',
    'PF2E.MasterSavingThrow.reflex',
    'PF2E.MasterSavingThrow.will',
    `${MODULE_ID}.IgnoredModifiers.DeviseAStratagem`, // Investigator
    `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry1`, // Ranger, replaces multiple attack penalty
    `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry2`, // same
    `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry3`, // same, Ranger's companion
    // NOTE: all spells that end in "form" are also ignored for the attack bonus; e.g. Ooze Form
    // also some battle form spells with different names:
    `${MODULE_ID}.IgnoredModifiers.BattleForm1`, // battle form
    `${MODULE_ID}.IgnoredModifiers.BattleForm2`, // battle form
    `${MODULE_ID}.IgnoredModifiers.BattleForm3`, // battle form
    `${MODULE_ID}.IgnoredModifiers.BattleForm4`, // battle form
    // also effects that replace your AC item bonus and dex cap - super hard to calculate their "true" bonus
    `${MODULE_ID}.IgnoredModifiers.DrakeheartMutagen`,
  ]
  IGNORED_MODIFIER_LABELS = IGNORED_MODIFIERS_I18N.map(str => tryLocalize(str, str)).
    concat(getSetting('additional-ignored-labels').split(';'))
}

const sumReducerMods = (accumulator, curr) => accumulator + curr.modifier
const sumReducerAcConditions = (accumulator, curr) => accumulator + curr.value
const isAcMod = m => m.group === 'ac' || m.group === 'all'
const valuePositive = m => m.value > 0
const valueNegative = m => m.value < 0
const modifierPositive = m => m.modifier > 0
const modifierNegative = m => m.modifier < 0
const acModOfCon = i => i.modifiers?.find(isAcMod)
const convertAcModifier = m => {
  if (!m.enabled && m.ignored) return m
  return {
    name: m.label,
    modifiers: [
      {
        group: 'ac',
        type: m.type,
        value: m.modifier,
      }],
  }
}
const getShieldAcCondition = (targetedToken) => {
  const raisedShieldModifier = targetedToken.actor.getShieldBonus()
  if (raisedShieldModifier) return {
    name: raisedShieldModifier.label,
    modifiers: [
      {
        group: 'ac',
        type: raisedShieldModifier.type,
        value: raisedShieldModifier.modifier,
      },
    ],
  }
}

const getFlankingAcCondition = () => {
  const systemFlanking = game.pf2e.ConditionManager.getCondition('flat-footed')
  return {
    name: systemFlanking.name,
    modifiers: [
      {
        group: 'ac',
        type: 'circumstance',
        value: -2,
      },
    ],
  }
}
const acConsOfToken = (targetedToken, isFlanking) => {
  const nameOfArmor = targetedToken.actor.attributes.ac.dexCap?.source || 'Modifier' // "Modifier" for NPCs
  return [].concat(targetedToken.actor.attributes.ac.modifiers.map(convertAcModifier))
    // shield - calculated by the system. a 'effect-raise-a-shield' condition will also exist on the token but get filtered out
    .concat(targetedToken.actor.getShieldBonus() ? [getShieldAcCondition(targetedToken)] : [])
    // flanking - calculated by the system
    .concat(isFlanking ? [getFlankingAcCondition()] : [])
    // remove all non-AC conditions and irrelevant items
    .filter(i => acModOfCon(i) !== undefined)
    // ignore armor because it's a passive constant (dex and prof are already in IGNORED_MODIFIER_LABELS)
    .filter(i => i.name !== nameOfArmor)
    // remove duplicates where name is identical
    .filter((i1, idx, a) => a.findIndex(i2 => (i2.name === i1.name)) === idx)
    // remove items where condition can't stack;  by checking if another item has equal/higher mods of same type
    .filter((i1, idx1, a) => {
      const m1 = acModOfCon(i1)
      if (m1.type === 'untyped') return true // untyped always stacks
      // keeping if there isn't another mod item that this won't stack with
      return a.find((i2, idx2) => {
        const m2 = acModOfCon(i2)
        // looking for something with a different index
        return i1 !== i2
          // of the same type
          && m2.type === m1.type
          // with the same sign (-1 and -2 don't stack, but -1 and +2 do)
          && Math.sign(m2.value) === Math.sign(m1.value)
          && (
            // with higher value (if higher index)
            (Math.abs(m2.value) >= Math.abs(m1.value) && idx1 > idx2)
            // or equal-to-higher value (if lower index)
            || (Math.abs(m2.value) > Math.abs(m1.value) && idx1 < idx2)
          )
      }) === undefined
    })
    // remove everything that should be ignored (including user-defined)
    .filter(i => !IGNORED_MODIFIER_LABELS.includes(i.name))
}

const acModsFromCons = (acConditions) => acConditions.map(c => c.modifiers).deepFlatten().filter(isAcMod)

const DEGREES = Object.freeze({
  CRIT_SUCC: 'CRIT_SUCC',
  SUCCESS: 'SUCCESS',
  FAILURE: 'FAILURE',
  CRIT_FAIL: 'CRIT_FAIL',
})

// REMEMBER:  in Pf2e, delta 0-9 means SUCCESS, delta 10+ means CRIT SUCCESS, delta -1-9 is FAIL, delta -10- is CRIT FAIL
const calcDegreeOfSuccess = (deltaFromDc) => {
  switch (true) {
    case deltaFromDc >= 10:
      return DEGREES.CRIT_SUCC
    case deltaFromDc <= -10:
      return DEGREES.CRIT_FAIL
    case deltaFromDc >= 1:
      return DEGREES.SUCCESS
    case deltaFromDc <= -1:
      return DEGREES.FAILURE
    case deltaFromDc === 0:
      return DEGREES.SUCCESS
  }
  // impossible
  console.error(`${MODULE_ID} | calcDegreeOfSuccess got wrong number: ${deltaFromDc}`)
  return DEGREES.CRIT_FAIL
}
const calcDegreePlusRoll = (deltaFromDc, dieRoll) => {
  const degree = calcDegreeOfSuccess(deltaFromDc)
  if (dieRoll === 20) {
    switch (degree) {
      case 'CRIT_SUCC':
        return DEGREES.CRIT_SUCC
      case 'SUCCESS':
        return DEGREES.CRIT_SUCC
      case 'FAILURE':
        return DEGREES.SUCCESS
      case 'CRIT_FAIL':
        return DEGREES.FAILURE
    }
  }
  if (dieRoll === 1) {
    switch (degree) {
      case 'CRIT_SUCC':
        return DEGREES.SUCCESS
      case 'SUCCESS':
        return DEGREES.FAILURE
      case 'FAILURE':
        return DEGREES.CRIT_FAIL
      case 'CRIT_FAIL':
        return DEGREES.CRIT_FAIL
    }
  }
  return degree
}

/**
 * acFlavorSuffix will be e.g. 'Flatfooted -2, Frightened -1'
 */
const insertAcFlavorSuffix = ($flavorText, acFlavorSuffix) => {
  const showDefenseHighlightsToEveryone = getSetting('show-defense-highlights-to-everyone')
  const dataVisibility = showDefenseHighlightsToEveryone ? 'all' : 'gm'
  $flavorText.find('div.degree-of-success').before(
    `<div data-visibility="${dataVisibility}">
${tryLocalize(`${MODULE_ID}.Message.TargetHas`, 'Target has:')} <b>(${acFlavorSuffix})</b>
</div>`)
}

const hook_preCreateChatMessage = async (chatMessage, data) => {
  // continue only if message is a PF2e roll message
  if (
    !data.flags
    || !data.flags.pf2e
    || data.flags.pf2e.modifiers === undefined
    || data.flags.pf2e.context.dc === undefined
    || data.flags.pf2e.context.dc === null
  ) return true

  // potentially include modifiers that apply to enemy AC (it's hard to do the same with ability/spell DCs though)
  const targetedToken = Array.from(game.user.targets)[0]
  const dcObj = data.flags.pf2e.context.dc
  const attackIsAgainstAc = dcObj.slug === 'ac'
  const isFlanking = chatMessage.flags.pf2e.context.options.includes('self:flanking')
  const targetAcConditions = (attackIsAgainstAc && targetedToken !== undefined) ? acConsOfToken(targetedToken,
    isFlanking) : []

  const conMods = data.flags.pf2e.modifiers
    // enabled is false for one of the conditions if it can't stack with others
    .filter(m => m.enabled && !m.ignored && !IGNORED_MODIFIER_LABELS.includes(m.label))
    // ignoring all "form" spells that replace your attack bonus
    .filter(m => !(attackIsAgainstAc && m.slug.endsWith('-form')))
    // ignoring Doubling Rings which are basically a permanent item bonus
    .filter(m => !m.slug.startsWith('doubling-rings'))
  const conModsPositiveTotal = conMods.filter(modifierPositive).reduce(sumReducerMods, 0)
    - acModsFromCons(targetAcConditions).filter(valueNegative).reduce(sumReducerAcConditions, 0)
  const conModsNegativeTotal = conMods.filter(modifierNegative).reduce(sumReducerMods, 0)
    - acModsFromCons(targetAcConditions).filter(valuePositive).reduce(sumReducerAcConditions, 0)

  const shouldIgnoreThisDegreeOfSuccess = (oldDOS, newDOS) => {
    // only ignore in this somewhat common edge case:
    return (
      // fail changed to crit fail, or vice versa
      ((oldDOS === DEGREES.FAILURE && newDOS === DEGREES.CRIT_FAIL)
        || (oldDOS === DEGREES.CRIT_FAIL && newDOS === DEGREES.FAILURE))
      // and this game setting is enabled
      && getSetting('ignore-crit-fail-over-fail-on-attacks')
      // and it was a Strike attack
      && data.flavor.includes(`${tryLocalize('PF2E.WeaponStrikeLabel', 'Strike')}:`)
    )
  }

  const roll = chatMessage.rolls[0]  // I hope the main roll is always the first one!
  const rollTotal = parseInt(data.content || roll.total.toString())
  const rollDc = data.flags.pf2e.context.dc.value
  const deltaFromDc = rollTotal - rollDc
  // technically DoS can be higher or lower through nat 1 and nat 20, but it doesn't matter with this calculation
  const dieRoll = roll.terms[0].results[0].result
  const currentDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc, dieRoll)
  // wouldChangeOutcome(x) returns true if a bonus of x ("penalty" if x is negative) changes the degree of success
  const wouldChangeOutcome = (extra) => {
    const newDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc + extra, dieRoll)
    return newDegreeOfSuccess !== currentDegreeOfSuccess &&
      !shouldIgnoreThisDegreeOfSuccess(currentDegreeOfSuccess, newDegreeOfSuccess)
  }
  const positiveConditionsChangedOutcome = wouldChangeOutcome(-conModsPositiveTotal)
  const negativeConditionsChangedOutcome = wouldChangeOutcome(-conModsNegativeTotal)
  // sum of condition modifiers that were necessary to reach the current outcome - these are the biggest bonuses/penalties.
  const conModsNecessaryPositiveTotal = conMods.filter(m => modifierPositive(m) && wouldChangeOutcome(-m.modifier)).
      reduce(sumReducerMods, 0)
    - acModsFromCons(targetAcConditions).
      filter(m => valueNegative(m) && wouldChangeOutcome(m.value)).
      reduce(sumReducerAcConditions, 0)
  const conModsNecessaryNegativeTotal = conMods.filter(m => modifierNegative(m) && wouldChangeOutcome(-m.modifier)).
      reduce(sumReducerMods, 0)
    - acModsFromCons(targetAcConditions).
      filter(m => valuePositive(m) && wouldChangeOutcome(m.value)).
      reduce(sumReducerAcConditions, 0)
// sum of all other condition modifiers.  if this sum's changing does not affect the outcome it means conditions were unnecessary
  const remainingPositivesChangedOutcome = wouldChangeOutcome(-(conModsPositiveTotal - conModsNecessaryPositiveTotal))
  const remainingNegativesChangedOutcome = wouldChangeOutcome(-(conModsNegativeTotal - conModsNecessaryNegativeTotal))

  // utility, because this calculation is done multiple times but requires a bunch of calculated variables
  const calcOutcomeChangeColor = (modifier) => {
    const isNegativeMod = modifier < 0
    const changedOutcome = wouldChangeOutcome(-modifier)
    // return (not marking condition modifier at all) if this condition modifier was absolutely not necessary
    if (
      (!isNegativeMod && !positiveConditionsChangedOutcome)
      || (isNegativeMod && !negativeConditionsChangedOutcome)
      || (!isNegativeMod && !remainingPositivesChangedOutcome && !changedOutcome)
      || (isNegativeMod && !remainingNegativesChangedOutcome && !changedOutcome)
    )
      return undefined
    return isNegativeMod
      ? (changedOutcome ? NEGATIVE_COLOR : WEAK_NEGATIVE_COLOR)
      : (changedOutcome ? POSITIVE_COLOR : WEAK_POSITIVE_COLOR)
  }

  const oldFlavor = chatMessage.flavor
  // adding an artificial div to have a single parent element, enabling nicer editing of html
  const $editedFlavor = $(`<div>${oldFlavor}</div>`)
  conMods.forEach(m => {
    const mod = m.modifier
    const outcomeChangeColor = calcOutcomeChangeColor(mod)
    if (!outcomeChangeColor) return
    const modifierValue = (mod < 0 ? '' : '+') + mod
    // edit background color for full tags
    $editedFlavor.find(`span.tag:contains(${m.label} ${modifierValue}).tag_alt`).
      css('background-color', outcomeChangeColor)
    // edit background+text colors for transparent tags, which have dark text by default
    $editedFlavor.find(`span.tag:contains(${m.label} ${modifierValue}).tag_transparent`).
      css('color', outcomeChangeColor).
      css('font-weight', 'bold')
  })
  const acFlavorSuffix = targetAcConditions.map(c => {
    const conditionAcMod = c.modifiers.filter(isAcMod).reduce(sumReducerAcConditions, -0)
    let outcomeChangeColor = calcOutcomeChangeColor(-conditionAcMod)
    if (!outcomeChangeColor) {
      if (getSetting('always-show-defense-conditions', false)) {
        outcomeChangeColor = NO_CHANGE_COLOR
      } else {
        return undefined
      }
    }
    const modifierValue = (conditionAcMod < 0 ? '' : '+') + conditionAcMod
    const modifierName = c.name
    return `<span style="color: ${outcomeChangeColor}">${modifierName} ${modifierValue}</span>`
  }).filter(s => s !== undefined).join(', ')
  if (acFlavorSuffix) {
    insertAcFlavorSuffix($editedFlavor, acFlavorSuffix)
  }
  // newFlavor will be the inner HTML without the artificial div
  const newFlavor = $editedFlavor.html()
  if (newFlavor !== oldFlavor) {
    data.flavor = newFlavor

    await chatMessage.updateSource({ 'flavor': newFlavor })
    CONFIG.compatibility.excludePatterns.pop()
  }
  return true
}

const getSetting = (settingName) => game.settings.get(MODULE_ID, settingName)

Hooks.on('init', function () {
  game.settings.register(MODULE_ID, 'show-defense-highlights-to-everyone', {
    name: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.name`,
    hint: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.hint`,
    scope: 'world',
    config: true,
    default: true,
    type: Boolean,
  })
  game.settings.register(MODULE_ID, 'ignore-crit-fail-over-fail-on-attacks', {
    name: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.name`,
    hint: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.hint`,
    scope: 'client',
    config: true,
    default: false,
    type: Boolean,
  })
  game.settings.register(MODULE_ID, 'additional-ignored-labels', {
    name: `${MODULE_ID}.Settings.additional-ignored-labels.name`,
    hint: `${MODULE_ID}.Settings.additional-ignored-labels.hint`,
    scope: 'world',
    config: true,
    default: 'Example;Skill Potency',
    type: String,
    onChange: initializeIgnoredModifiers,
  })
  game.settings.register(MODULE_ID, 'always-show-defense-conditions', {
    name: `${MODULE_ID}.Settings.always-show-defense-conditions.name`,
    hint: `${MODULE_ID}.Settings.always-show-defense-conditions.hint`,
    scope: 'world',
    config: true,
    default: false,
    type: Boolean,
  })
})

Hooks.once('setup', function () {
  Hooks.on('preCreateChatMessage', hook_preCreateChatMessage)
  initializeIgnoredModifiers()
  console.info(`${MODULE_ID} | initialized`)
})

