import utils from './utils'
import estimateGuesses from './estimate'
import { MIN_GUESSES_BEFORE_GROWING_SEQUENCE } from '../data/const'
import {
  MatchExtended,
  BruteForceMatch,
  MatchEstimated,
  LooseObject,
} from '../types'

const scoringHelper = {
  password: '',
  optimal: {} as any,
  excludeAdditive: false,
  separatorRegex: undefined as RegExp | null | undefined,
  fillArray(size: number, valueType: 'object' | 'array') {
    const result: typeof valueType extends 'array' ? string[] : LooseObject[] =
      []
    for (let i = 0; i < size; i += 1) {
      let value: [] | LooseObject = []
      if (valueType === 'object') {
        value = {}
      }
      result.push(value)
    }
    return result
  },
  // helper: make bruteforce match objects spanning i to j, inclusive.
  makeBruteforceMatch(i: number, j: number): BruteForceMatch {
    return {
      pattern: 'bruteforce',
      token: this.password.slice(i, +j + 1 || 9e9),
      i,
      j,
    }
  },
  // helper: considers whether a length-sequenceLength
  // sequence ending at match m is better (fewer guesses)
  // than previously encountered sequences, updating state if so.
  update(match: MatchExtended, sequenceLength: number) {
    const k = match.j
    const estimatedMatch = estimateGuesses(match, this.password)
    let pi = estimatedMatch.guesses as number
    if (sequenceLength > 1) {
      // we're considering a length-sequenceLength sequence ending with match m:
      // obtain the product term in the minimization function by multiplying m's guesses
      // by the product of the length-(sequenceLength-1)
      // sequence ending just before m, at m.i - 1.
      pi *= this.optimal.pi[estimatedMatch.i - 1][sequenceLength - 1]
    }
    // calculate the minimization func
    let g = utils.factorial(sequenceLength) * pi
    if (!this.excludeAdditive) {
      g += MIN_GUESSES_BEFORE_GROWING_SEQUENCE ** (sequenceLength - 1)
    }
    // update state if new best.
    // first see if any competing sequences covering this prefix,
    // with sequenceLength or fewer matches,
    // fare better than this sequence. if so, skip it and return.
    let shouldSkip = false
    Object.keys(this.optimal.g[k]).forEach((competingPatternLength) => {
      const competingMetricMatch = this.optimal.g[k][competingPatternLength]
      if (parseInt(competingPatternLength, 10) <= sequenceLength) {
        if (competingMetricMatch <= g) {
          shouldSkip = true
        }
      }
    })
    if (!shouldSkip) {
      // this sequence might be part of the final optimal sequence.
      this.optimal.g[k][sequenceLength] = g
      this.optimal.m[k][sequenceLength] = estimatedMatch
      this.optimal.pi[k][sequenceLength] = pi
    }
  },

  // helper: evaluate bruteforce matches ending at passwordCharIndex.
  bruteforceUpdate(passwordCharIndex: number) {
    // see if a single bruteforce match spanning the passwordCharIndex-prefix is optimal.
    let match = this.makeBruteforceMatch(0, passwordCharIndex)
    this.update(match, 1)

    for (let i = 1; i <= passwordCharIndex; i += 1) {
      // generate passwordCharIndex bruteforce matches, spanning from (i=1, j=passwordCharIndex) up to (i=passwordCharIndex, j=passwordCharIndex).
      // see if adding these new matches to any of the sequences in optimal[i-1]
      // leads to new bests.
      match = this.makeBruteforceMatch(i, passwordCharIndex)
      const tmp = this.optimal.m[i - 1]
      // eslint-disable-next-line no-loop-func
      Object.keys(tmp).forEach((sequenceLength) => {
        const lastMatch = tmp[sequenceLength]
        // corner: an optimal sequence will never have two adjacent bruteforce matches.
        // it is strictly better to have a single bruteforce match spanning the same region:
        // same contribution to the guess product with a lower length.
        // --> safe to skip those cases.
        if (lastMatch.pattern !== 'bruteforce') {
          // try adding m to this length-sequenceLength sequence.
          this.update(match, parseInt(sequenceLength, 10) + 1)
        }
      })
    }
  },

  // helper: step backwards through optimal.m starting at the end,
  // constructing the final optimal match sequence.
  unwind(passwordLength: number) {
    const optimalMatchSequence: MatchEstimated[] = []
    let k = passwordLength - 1
    // find the final best sequence length and score
    let sequenceLength = 0
    // eslint-disable-next-line no-loss-of-precision
    let g = 2e308
    const temp = this.optimal.g[k]
    // safety check for empty passwords
    if (temp) {
      Object.keys(temp).forEach((candidateSequenceLength) => {
        const candidateMetricMatch = temp[candidateSequenceLength]
        if (candidateMetricMatch < g) {
          sequenceLength = parseInt(candidateSequenceLength, 10)
          g = candidateMetricMatch
        }
      })
    }
    while (k >= 0) {
      const match: MatchEstimated = this.optimal.m[k][sequenceLength]
      optimalMatchSequence.unshift(match)
      k = match.i - 1
      sequenceLength -= 1
    }
    return optimalMatchSequence
  },
}

export default {
  // ------------------------------------------------------------------------------
  // search --- most guessable match sequence -------------------------------------
  // ------------------------------------------------------------------------------
  //
  // takes a sequence of overlapping matches, returns the non-overlapping sequence with
  // minimum guesses. the following is a O(l_max * (n + m)) dynamic programming algorithm
  // for a length-n password with m candidate matches. l_max is the maximum optimal
  // sequence length spanning each prefix of the password. In practice it rarely exceeds 5 and the
  // search terminates rapidly.
  //
  // the optimal "minimum guesses" sequence is here defined to be the sequence that
  // minimizes the following function:
  //
  //    g = sequenceLength! * Product(m.guesses for m in sequence) + D^(sequenceLength - 1)
  //
  // where sequenceLength is the length of the sequence.
  //
  // the factorial term is the number of ways to order sequenceLength patterns.
  //
  // the D^(sequenceLength-1) term is another length penalty, roughly capturing the idea that an
  // attacker will try lower-length sequences first before trying length-sequenceLength sequences.
  //
  // for example, consider a sequence that is date-repeat-dictionary.
  //  - an attacker would need to try other date-repeat-dictionary combinations,
  //    hence the product term.
  //  - an attacker would need to try repeat-date-dictionary, dictionary-repeat-date,
  //    ..., hence the factorial term.
  //  - an attacker would also likely try length-1 (dictionary) and length-2 (dictionary-date)
  //    sequences before length-3. assuming at minimum D guesses per pattern type,
  //    D^(sequenceLength-1) approximates Sum(D^i for i in [1..sequenceLength-1]
  //
  // ------------------------------------------------------------------------------
  mostGuessableMatchSequence(
    password: string,
    matches: MatchExtended[],
    excludeAdditive = false,
  ) {
    scoringHelper.password = password
    scoringHelper.excludeAdditive = excludeAdditive
    const passwordLength = password.length
    // partition matches into sublists according to ending index j
    let matchesByCoordinateJ = scoringHelper.fillArray(
      passwordLength,
      'array',
    ) as any[]

    matches.forEach((match) => {
      matchesByCoordinateJ[match.j].push(match)
    })
    // small detail: for deterministic output, sort each sublist by i.
    matchesByCoordinateJ = matchesByCoordinateJ.map((match) =>
      match.sort((m1: MatchExtended, m2: MatchExtended) => m1.i - m2.i),
    )

    scoringHelper.optimal = {
      // optimal.m[k][sequenceLength] holds final match in the best length-sequenceLength
      // match sequence covering the
      // password prefix up to k, inclusive.
      // if there is no length-sequenceLength sequence that scores better (fewer guesses) than
      // a shorter match sequence spanning the same prefix,
      // optimal.m[k][sequenceLength] is undefined.
      m: scoringHelper.fillArray(passwordLength, 'object'),
      // same structure as optimal.m -- holds the product term Prod(m.guesses for m in sequence).
      // optimal.pi allows for fast (non-looping) updates to the minimization function.
      pi: scoringHelper.fillArray(passwordLength, 'object'),
      // same structure as optimal.m -- holds the overall metric.
      g: scoringHelper.fillArray(passwordLength, 'object'),
    }

    for (let k = 0; k < passwordLength; k += 1) {
      matchesByCoordinateJ[k].forEach((match: MatchExtended) => {
        if (match.i > 0) {
          Object.keys(scoringHelper.optimal.m[match.i - 1]).forEach(
            (sequenceLength) => {
              scoringHelper.update(match, parseInt(sequenceLength, 10) + 1)
            },
          )
        } else {
          scoringHelper.update(match, 1)
        }
      })
      scoringHelper.bruteforceUpdate(k)
    }
    const optimalMatchSequence = scoringHelper.unwind(passwordLength)
    const optimalSequenceLength = optimalMatchSequence.length
    const guesses = this.getGuesses(password, optimalSequenceLength)
    return {
      password,
      guesses,
      guessesLog10: utils.log10(guesses),
      sequence: optimalMatchSequence,
    }
  },

  getGuesses(password: string, optimalSequenceLength: number) {
    const passwordLength = password.length
    let guesses = 0
    if (password.length === 0) {
      guesses = 1
    } else {
      guesses =
        scoringHelper.optimal.g[passwordLength - 1][optimalSequenceLength]
    }
    return guesses
  },
}
