import UNITS_BY_NAME from "../constants/units_by_name"
import TRAINED_BY from "../constants/trained_by"
import UPGRADES_BY_NAME from "../constants/upgrade_by_name"
import RESEARCHED_BY from "../constants/researched_by"
import { BO_ITEMS, supplyUnitNameByRace } from "../constants/bo_items"
import { incomeMinerals, incomeVespene } from "./income"

import { cloneDeep, minBy, find, remove } from "lodash"
import Unit from "./unit"
import Event from "./event"
import Task from "./task"
import executeAction from "./execute_action"
import { defaultSettings, defaultOptimizeSettings } from "../constants/helper"
import {
    IBuildOrderElement,
    ISettingsElement,
    ICost,
    IAllRaces,
    IResourceHistory,
} from "../constants/interfaces"

/** Logic of this file:
Each frame
    Calculate and add income
    Update the state of each unit:
        Update energy available
        Update progress (if they are doing something)
    Check if the next build order item can be executed

Each build order index increment
    Add the current state to snapshots for cached view
*/

type IError = {
    message: string
    requirements: IBuildOrderElement[]
    neededEffort: number
}

let eventId = 0
let mineralIncomeCache = {}
let vespeneIncomeCache = {}
const workerTypes = new Set(["SCV", "Probe", "Drone"])

class GameLogic {
    race: IAllRaces
    bo: Array<IBuildOrderElement>
    boIndex: number
    minerals: number
    vespene: number
    supplyUsed: number
    supplyLeft: number
    supplyCap: number
    raceSpecificResource: number
    resourceHistory: IResourceHistory
    units: Set<Unit>
    idleUnits: Set<Unit>
    busyUnits: Set<Unit>
    workersMinerals: number
    workersVespene: number
    workersScouting: number
    muleCount: number
    upgrades: Set<string>
    baseCount: number
    gasCount: number
    freeTechlabs: number
    freeReactors: number
    frame: number
    waitTime: number
    eventLog: Array<Event>
    unitsCountArray: Array<{ [name: string]: number }>
    errorMessage: string
    requirements?: IBuildOrderElement[]
    settings: { [name: string]: number | string }
    customSettings: Array<ISettingsElement>
    optimizeSettings: { [name: string]: number | string }
    customOptimizeSettings: Array<ISettingsElement>

    constructor(
        race: IAllRaces = "terran",
        bo: Array<IBuildOrderElement> = [],
        customSettings: Array<ISettingsElement> = [],
        customOptimizeSettings: Array<ISettingsElement> = []
    ) {
        this.race = race
        this.bo = bo
        this.boIndex = 0
        this.minerals = 50
        this.vespene = 0
        this.supplyUsed = 12
        this.supplyLeft = this.race === "zerg" ? 2 : 3
        this.supplyCap = this.race === "zerg" ? 14 : 15
        this.raceSpecificResource = this.race === "protoss" ? 1 : this.race === "terran" ? 0 : 3
        this.resourceHistory = {
            minerals: [this.minerals],
            vespene: [this.vespene],
            supplyLeft: [this.supplyLeft],
            raceSpecificResource: [this.raceSpecificResource],
        }
        // All units that are alive
        this.units = new Set()
        // Units that have a slot open to do something, e.g. a barracks with reactor will be idle if it only trains one marine
        this.idleUnits = new Set()
        // Units that have a task, a barracks with reactor that only trains one marine will be busy
        this.busyUnits = new Set()
        this.workersMinerals = 12
        this.workersVespene = 0
        this.workersScouting = 0
        this.muleCount = 0
        // Researched upgrades
        this.upgrades = new Set()
        // Amount of bases
        this.baseCount = 1
        // Amount of refineries, extractors, assimilators
        this.gasCount = 0
        this.freeTechlabs = 0
        this.freeReactors = 0
        this.frame = 0
        // Keep track of time for action 'do_nothing_5_sec'
        this.waitTime = 0
        this.eventLog = []
        // Number of units, will only be saved after every build order index advances - only used for UI purpose on the right side of the website
        this.unitsCountArray = []
        // Error message appearing on GUI if build order is invalid
        this.errorMessage = ""
        this.requirements = undefined
        eventId = -1
        mineralIncomeCache = {}
        vespeneIncomeCache = {}

        // Custom settings from the settings page
        this.customSettings = customSettings
        this.settings = {}
        this.loadSettings(defaultSettings)
        // How many seconds the worker mining should be delayed at game start
        // this.workerStartDelay = 2
        // How many seconds a worker needs before starting to build a structure
        // this.workerBuildDelay = 1
        // How many seconds a worker needs to return back to mining after completing a structure
        // this.workerReturnDelay = 3
        // Allow max 40 seocnds frames for all units to be idle, before the game logic aborts and marks the build order as 'not valid' (cc off 12 workers can be started after 35 seconds)
        // this.idleLimit = 15
        // HTML element width factor
        // this.htmlElementWidthFactor = 0.3
        // How long it takes buildings to dettach from addons (lift, fly away and land)
        // this.addonSwapDelay = 3
        // Keep track of how long all units were idle (waiting for resources)
        // this.idleTime = 0

        // Update settings from customSettings object, see WebPage.js defaultSettings
        this.loadSettings(customSettings)

        this.customOptimizeSettings = customOptimizeSettings
        this.optimizeSettings = {}
        this.loadOptimizeSettings(defaultOptimizeSettings)
        this.loadOptimizeSettings(customOptimizeSettings)
    }

    /**
     * Prepares default properties for this.setStart()
     */
    reset(): void {
        this.boIndex = 0
        this.minerals = 50
        this.vespene = 0
        this.supplyUsed = 12
        this.supplyLeft = this.race === "zerg" ? 2 : 3
        this.supplyCap = this.race === "zerg" ? 14 : 15
        this.raceSpecificResource = this.race === "protoss" ? 1 : this.race === "terran" ? 0 : 3
        this.resourceHistory = {
            minerals: [this.minerals],
            vespene: [this.vespene],
            supplyLeft: [this.supplyLeft],
            raceSpecificResource: [this.raceSpecificResource],
        }
        this.units = new Set()
        this.idleUnits = new Set()
        this.busyUnits = new Set()
        this.workersMinerals = 0
        this.workersVespene = 0
        this.workersScouting = 0
        this.muleCount = 0
        this.upgrades = new Set()
        this.baseCount = 1
        this.gasCount = 0
        this.freeTechlabs = 0
        this.freeReactors = 0
        this.frame = 0
        this.waitTime = 0
        this.eventLog = []
        this.unitsCountArray = []
        eventId = -1
        mineralIncomeCache = {}
        vespeneIncomeCache = {}
    }

    /**
     *
     * @param {Object} customSettings - See helper.js for default settings
     */
    loadSettings(customSettings: Array<ISettingsElement>): void {
        for (const item of customSettings) {
            this.settings[item.variableName] = item.v
        }
    }

    loadOptimizeSettings(customSettings: Array<ISettingsElement>): void {
        for (const item of customSettings) {
            this.optimizeSettings[item.variableName] = item.v
        }
    }

    exportSettings(): ISettingsElement[] {
        // Update default settings from gamelogic.settings object, then return it
        const settingsObject = cloneDeep(defaultSettings)
        settingsObject.forEach((item) => {
            item.v = this.settings[item.variableName]
        })
        return settingsObject
    }

    exportOptimizeSettings(): ISettingsElement[] {
        // Update default settings from gamelogic.settings object, then return it
        const optimizeSettingsObject = cloneDeep(defaultOptimizeSettings)
        optimizeSettingsObject.forEach((item) => {
            item.v = this.optimizeSettings[item.variableName]
        })
        return optimizeSettingsObject
    }

    /**
     *
     */
    getEventId(): number {
        eventId += 1
        return eventId
    }

    /**
     * Sets the start conditions: spawns all start structures, starting resources and values will be set in this.reset()
     */
    setStart(): void {
        this.reset()
        let townhallName = ""
        let workerName = ""
        if (this.race === "terran") {
            townhallName = "CommandCenter"
            workerName = "SCV"
        }
        if (this.race === "protoss") {
            townhallName = "Nexus"
            workerName = "Probe"
        }
        if (this.race === "zerg") {
            townhallName = "Hatchery"
            workerName = "Drone"
            this.units.add(new Unit("Overlord"))
        }
        const townhall = new Unit(townhallName)
        townhall.energy = 50
        if (this.race === "zerg") {
            townhall.larvaCount = 3
        }
        this.units.add(townhall)
        this.idleUnits.add(townhall)
        for (let i = 0; i < 12; i++) {
            const unit = new Unit(workerName)
            // Add worker delay of 2 seconds before they start gathering minerals
            const workerStartDelayTask = new Task(
                22.4 * +this.settings.workerStartDelay,
                this.frame,
                this.supplyUsed
            )
            workerStartDelayTask.addMineralWorker = true
            unit.addTask(this, workerStartDelayTask)
            this.units.add(unit)
        }
        this.unitsCountArray.push(this.updateUnitsCount())
    }

    /**
     * Runs the simulation until a limit is reached (20 minutes)
     * or until the end of build order is reached and no unit is busy (= no unit has a task)
     * or if no unit was busy for a long time, then it is assumed that the build order cannot be executed completely and must be invalid
     */
    runUntilEnd(): void {
        // Runs until all units are idle
        while (this.frame < 22.4 * 20 * 60) {
            // Limit to 20 mins
            this.runFrame()
            this.frame += 1
            // Abort once all units are idle and end of build order is reached
            if (this.boIndex >= this.bo.length && this.busyUnits.size === 0) {
                break
            }

            // Abort if units idle for too long (waiting for resources), e.g. when making all workers scout and only one more worker is mining, then build a cc will take forever
            if (this.busyUnits.size === 0) {
                this.settings.idleTime = +this.settings.idleTime + 1
                if (this.settings.idleTime >= +this.settings.idleLimit * 22.4) {
                    break
                }
            } else {
                this.settings.idleTime = 0
            }
        }
        // TODO better assertion statements
        // if bo is not valid and current item in bo requires more supply than we have left: tell user that we are out of supply
        // if bo item costs gas but no gas mining: tell user that we dont mine gas
        // console.assert(this.boIndex >= this.bo.length, cloneDeep(this))
        // console.assert(this.boIndex >= this.bo.length, JSON.stringify(cloneDeep(this), undefined, 4))

        // Sort eventList by item.start, but perhaps the reverse is the desired behavior?
        this.eventLog.sort((a, b) => {
            if (a.id < b.id) {
                return -1
            }
            if (a.id > b.id) {
                return 1
            }
            return 0
        })
    }

    /**
     * Executes one frame of the simulation
     * Adds income
     * Steps each unit by 1 frame (task progress, energy generation etc.)
     * Tries to execute next build order elements until it is no longer possible
     */
    runFrame(): void {
        // Run one frame in game logic
        this.raceSpecificResource = 0
        this.addIncome()
        this.updateUnitsProgress()

        let endOfActions = false
        // Each frame could theoretically have multiple actions
        while (!endOfActions && this.boIndex < this.bo.length) {
            endOfActions = true
            const boElement = this.bo[this.boIndex]
            // Check requirements
            // Check idle unit who can train / produce / research / execute action

            // Train unit
            if (["worker", "unit"].includes(boElement.type)) {
                const trained = this.trainUnit(boElement)

                if (trained) {
                    endOfActions = false
                }
            }

            // Build structures
            else if (boElement.type === "structure") {
                const built = this.trainUnit(boElement)
                if (built) {
                    endOfActions = false
                }
            }

            // Research upgrade
            else if (boElement.type === "upgrade") {
                const researched = this.researchUpgrade(boElement)
                if (researched) {
                    endOfActions = false
                }
            }

            // Run action
            else if (boElement.type === "action") {
                const executed = executeAction(this, boElement)
                if (executed) {
                    endOfActions = false
                }
            }

            if (!endOfActions) {
                this.boIndex += 1
                this.errorMessage = ""
                this.requirements = undefined
                this.unitsCountArray.push(this.updateUnitsCount())
                // Each time the boIndex gets incremented, take a snapshot of the current state - this way i can cache the gamelogic and reload it from the state
                // e.g. bo = [scv, depot, scv]
                // and i want to remove depot, i can resume from cached state of index 0
            }
        }

        this.resourceHistory.minerals.push(this.minerals)
        this.resourceHistory.vespene.push(this.vespene)
        this.resourceHistory.supplyLeft.push(this.supplyLeft)
        this.resourceHistory.raceSpecificResource.push(this.raceSpecificResource)
    }

    /**
     * Updates the statistics of available actions, how many units and structures we have, and what upgrades are researched
     */
    updateUnitsCount(): { [p: string]: number } {
        // Counts all available actions and how many units, structures we have and if an upgrade is researched
        const unitsCount: { [name: string]: number } = {}
        const incrementUnitName = (item: string, amount = 1) => {
            if (!unitsCount[item]) {
                unitsCount[item] = amount
            } else {
                unitsCount[item] += amount
            }
        }
        // Count of HT and DT for archon
        let htCount = 0
        let dtCount = 0
        this.units.forEach((unit, _index) => {
            // Reduce drone count by 1 if it received a task to build a structure
            // Reduce HT or DT count by 1 if it is busy morphing to archon
            if (
                !["Drone", "HighTemplar", "DarkTemplar"].includes(unit.name) ||
                unit.tasks.length === 0 ||
                !unit.tasks[unit.tasks.length - 1].morphToUnit
            ) {
                incrementUnitName(unit.name)
            }
            if (unit.hasTechlab) {
                incrementUnitName(`${unit.name}Techlab`)
            } else if (unit.hasReactor) {
                incrementUnitName(`${unit.name}Reactor`)
            }
            if (unit.larvaCount > 0) {
                incrementUnitName("Larva", unit.larvaCount)
            }
            if (unit.isMiningMinerals()) {
                // Amount of workers mining minerals
                incrementUnitName("worker_to_mins")
            }
            if (unit.isMiningGas) {
                // Amount of workers mining gas
                incrementUnitName("worker_to_gas")
            }
            if (unit.isScouting) {
                // Amount of scouting workers
                incrementUnitName("worker_to_scout")
            }
            // Count actions by dividing energy through action energy cost, e.g. Math.floor(OC / 50) for amount of mule calldown available
            // Protoss

            if (unit.name === "Nexus") {
                const amount = Math.floor(unit.energy / 50)
                incrementUnitName("chronoboost_busy_nexus", amount)
                incrementUnitName("chronoboost_busy_gateway", amount)
                incrementUnitName("chronoboost_busy_warpgate", amount)
                incrementUnitName("chronoboost_busy_cybercore", amount)
                incrementUnitName("chronoboost_busy_forge", amount)
                incrementUnitName("chronoboost_busy_robo", amount)
                incrementUnitName("chronoboost_busy_stargate", amount)
                incrementUnitName("chronoboost_busy_twilight", amount)
                incrementUnitName("chronoboost_busy_dark_shrine", amount)
                incrementUnitName("chronoboost_busy_templar_archive", amount)
                incrementUnitName("chronoboost_busy_fleet_beacon", amount)
                incrementUnitName("chronoboost_busy_robotics_bay", amount)
            }
            if (unit.name === "Gateway" && this.upgrades.has("WarpGate")) {
                incrementUnitName("convert_gateway_to_warpgate")
            }
            if (unit.name === "WarpGate") {
                incrementUnitName("convert_warpgate_to_gateway")
            }
            if (unit.name === "HighTemplar") {
                htCount += 1
            }
            if (unit.name === "DarkTemplar") {
                dtCount += 1
            }
            // Terran
            if (unit.name === "OrbitalCommand") {
                const amount = Math.floor(unit.energy / 50)
                incrementUnitName("call_down_mule", amount)
                incrementUnitName("call_down_supply", amount)
            }
            if (this.freeTechlabs > 0) {
                if (unit.name === "Barracks" && !unit.hasAddon()) {
                    incrementUnitName("attach_barracks_to_free_techlab")
                }
                if (unit.name === "Factory" && !unit.hasAddon()) {
                    incrementUnitName("attach_factory_to_free_techlab")
                }
                if (unit.name === "Starport" && !unit.hasAddon()) {
                    incrementUnitName("attach_starport_to_free_techlab")
                }
            }
            if (this.freeReactors > 0) {
                if (unit.name === "Barracks" && !unit.hasAddon()) {
                    incrementUnitName("attach_barracks_to_free_reactor")
                }
                if (unit.name === "Factory" && !unit.hasAddon()) {
                    incrementUnitName("attach_factory_to_free_reactor")
                }
                if (unit.name === "Starport" && !unit.hasAddon()) {
                    incrementUnitName("attach_starport_to_free_reactor")
                }
            }
            if (unit.name === "Barracks" && unit.hasTechlab) {
                incrementUnitName("dettach_barracks_from_techlab")
            }
            if (unit.name === "Barracks" && unit.hasReactor) {
                incrementUnitName("dettach_barracks_from_reactor")
            }
            if (unit.name === "Factory" && unit.hasTechlab) {
                incrementUnitName("dettach_factory_from_techlab")
            }
            if (unit.name === "Factory" && unit.hasReactor) {
                incrementUnitName("dettach_factory_from_reactor")
            }
            if (unit.name === "Starport" && unit.hasTechlab) {
                incrementUnitName("dettach_starport_from_techlab")
            }
            if (unit.name === "Starport" && unit.hasReactor) {
                incrementUnitName("dettach_starport_from_reactor")
            }
            if (unit.name === "Bunker") {
                incrementUnitName("salvage_bunker")
            }
            // Zerg
            if (unit.name === "Queen") {
                const amount = Math.floor(unit.energy / 25)
                incrementUnitName("inject", amount)
                incrementUnitName("creep_tumor", amount)
            }
        })
        incrementUnitName("morph_archon_from_dt_dt", Math.floor(dtCount / 2))
        incrementUnitName("morph_archon_from_ht_ht", Math.floor(htCount / 2))
        incrementUnitName("morph_archon_from_ht_dt", Math.min(htCount, dtCount))
        this.upgrades.forEach((upgrade, _index) => {
            incrementUnitName(upgrade)
        })

        // Also add minerals, vespene and supply as this acts like a 'snapshot' when selecting at which index to insert a new build order item
        incrementUnitName("minerals", this.minerals)
        incrementUnitName("vespene", this.vespene)
        incrementUnitName("supplyused", this.supplyUsed)
        incrementUnitName("supplycap", this.supplyCap)
        incrementUnitName("frame", this.frame)

        return unitsCount
    }

    /**
     * Checks if what an item requires is present or not
     */
    addRequirements(itemName: string, requires: string[][]): boolean {
        this.requirements = this.requirements || []
        const itemPresence: { [unitName: string]: boolean } = {}
        for (const item of this.units) {
            let itemName = item.name
            if (item.hasTechlab) {
                itemName += "TechLab"
            }
            if (item.hasReactor) {
                itemName += "Reactor"
            }
            itemPresence[itemName] = true
        }
        for (const upgradeName of this.upgrades) {
            itemPresence[upgradeName] = true
        }

        const errorList: IError[] = []
        for (const requirementList of requires) {
            const error: IError = {
                message: "",
                requirements: [],
                neededEffort: 0,
            }
            errorList.push(error)
            for (const requiredItem of requirementList) {
                if (!itemPresence[requiredItem]) {
                    error.message = `Required ${requiredItem} for ${itemName} could not be found.`
                    error.requirements.push(BO_ITEMS[requiredItem])
                    error.neededEffort += 1 / requirementList.length
                }
            }
        }

        const leastEffortError = minBy(errorList, "neededEffort")
        if (leastEffortError && leastEffortError.neededEffort) {
            this.errorMessage = leastEffortError.message
            this.requirements.push(...leastEffortError.requirements)
            return false
        }
        return true
    }

    /**
     * The simulation tries to train a unit, builds a structure or morphs a unit
     */
    trainUnit(unit: IBuildOrderElement): boolean {
        // Issue train command of unit type
        console.assert(unit.name, JSON.stringify(unit, null, 4))
        console.assert(unit.type, JSON.stringify(unit, null, 4))

        // Get unit type / structure type that can train this unit
        const trainInfo = TRAINED_BY[unit.name]
        this.requirements = []
        let morphCondition = trainInfo.isMorph || trainInfo.consumesUnit
        console.assert(trainInfo, unit.name)
        // Check if requirement is met
        if (trainInfo.requires.length) {
            const requirements = this.addRequirements(unit.name, trainInfo.requires)
            if (!requirements) {
                return false
            }
        }

        // Get cost (mineral, vespene, supply)
        const cost = GameLogic.getCost(unit.name)
        if (!this._canAfford(cost) && !morphCondition) {
            // Generate error message if not able to afford (missing minerals, vespene or free supply)
            this.setCostErrorMessage(cost, unit.name)
            return false
        }

        // The unit/structure that is training the target unit or structure
        // TODO Sort units, e.g. try to train marines first from reactor barracks, then normal barracks, then techlab barracks
        // ^ The same for warpgate first, then gateway
        for (const trainerUnit of this.idleUnits) {
            // Unit might no longer be idle while iterating over idleUnits
            if (!trainerUnit.isIdle()) {
                continue
            }

            // If target is an addon but building structure already has addon: skip
            if (
                (unit.name.includes("TechLab") || unit.name.includes("Reactor")) &&
                trainerUnit.hasAddon()
            ) {
                this.errorMessage = `Could not find structure without addon to build '${unit.name}'.`
                continue
            }

            // Loop over all idle units and check if they match unit type

            const trainerCanTrainThisUnit =
                trainInfo.trainedBy.has(trainerUnit.name) &&
                (!trainInfo.requiresTechlab || trainerUnit.hasTechlab)
            const trainerCanTrainThroughReactor =
                trainInfo.trainedBy.has(trainerUnit.name) &&
                !trainInfo.requiresTechlab &&
                trainerUnit.hasReactor &&
                trainerUnit.addonTasks.length === 0
            // TODO Rename this task as 'background task' as probes are building structures in the background aswell as hatcheries are building stuff with their larva
            const trainerCanTrainThroughLarva =
                (trainInfo.trainedBy.has("Larva") && trainerUnit.larvaCount > 0) ||
                (unit.type === "structure" && trainerUnit.name === "Probe")
            morphCondition = morphCondition && !trainerCanTrainThroughLarva

            // Unit has to be produced through main unit queue, but if unit is busy: dont train
            if (!trainerCanTrainThroughLarva && !trainerCanTrainThroughReactor) {
                if (trainerUnit.tasks.length > 0) {
                    continue
                }
            }

            if (
                !trainerCanTrainThisUnit &&
                !trainerCanTrainThroughReactor &&
                !trainerCanTrainThroughLarva
            ) {
                this.errorMessage = `Could not find unit to produce '${unit.name}'.`
                if (trainInfo.consumesUnit) {
                    if (unit.type === "structure") {
                        this.requirements = [
                            {
                                name: "Drone",
                                type: "worker",
                            },
                        ]
                    } else {
                        this.errorMessage += ` Didn't know which requirement to insert here for ${unit.name}`
                    }
                }
                continue
            }

            if (unit.name === "Zergling") {
                // Was 0.5 and 25
                cost.supply = 1
                cost.minerals = 50
            }
            if (!this._canAfford(cost)) {
                // This is nearly the same error as above, but this is for a morph
                // Generate error message if not able to afford (missing minerals, vespene or free supply)
                this.setCostErrorMessage(cost, unit.name)
                return false
            }
            // The trainerUnit can train the target unit

            // Add task to unit
            // If trained unit is made by worker: add worker move delay
            let buildTime = this.getTime(unit.name)

            // Create the build start delay task
            let buildStartDelay = 0
            if (workerTypes.has(trainerUnit.name)) {
                this.workersMinerals -= 1
                // Worker moving to location delay
                const workerMovingToConstructionSite = new Task(
                    +this.settings.workerBuildDelay * 22.4,
                    this.frame,
                    this.supplyUsed
                )
                trainerUnit.addTask(this, workerMovingToConstructionSite)
                buildStartDelay = +this.settings.workerBuildDelay * 22.4
                // Since this task is run immediately, it needs to end later when made by probes
                if (trainerUnit.name === "Probe") {
                    buildTime += buildStartDelay
                }
            }

            const taskId = this.getEventId()
            // Create the new task
            const newTask = new Task(
                buildTime,
                this.frame + buildStartDelay,
                this.supplyUsed,
                taskId
            )
            newTask.morphToUnit = morphCondition || trainInfo.consumesUnit ? unit.name : null
            if (newTask.morphToUnit === null) {
                if (unit.type === "worker") {
                    newTask.newWorker = unit.name
                } else if (unit.type === "unit") {
                    newTask.newUnit = unit.name
                } else if (unit.type === "structure") {
                    newTask.newStructure = unit.name
                }
            }

            if (trainerUnit.name === "WarpGate") {
                // Training through warpgate reduces train time to 4 seconds
                newTask.totalFramesRequired = 3.6 * 22.4
                trainerUnit.addTask(this, newTask, trainerCanTrainThroughReactor, true)
                // Add the warpgate recover time which can be sped up through chrono
                trainerUnit.addTask(
                    this,
                    new Task(
                        this.warpgateRecoverTime(unit.name),
                        this.frame,
                        this.supplyUsed,
                        taskId
                    ),
                    trainerCanTrainThroughReactor,
                    trainerCanTrainThroughLarva
                )
            } else {
                // Add normal task to unit, add to reactor if unit has reactor, add to larva if unit has larva
                trainerUnit.addTask(
                    this,
                    newTask,
                    trainerCanTrainThroughReactor,
                    trainerCanTrainThroughLarva
                )
            }

            // Create the builder return task
            if (["Probe", "SCV"].includes(trainerUnit.name)) {
                // Probe and SCV return to mining after they are done with their task
                const workerReturnToMinerals = new Task(
                    +this.settings.workerReturnDelay * 22.4,
                    this.frame,
                    this.supplyUsed
                )
                workerReturnToMinerals.addMineralWorker = true
                trainerUnit.addTask(this, workerReturnToMinerals)
            }
            if (trainerCanTrainThroughLarva) {
                trainerUnit.larvaCount -= 1
            }

            this.minerals -= cost.minerals
            this.vespene -= cost.vespene
            if (cost.supply > 0) {
                this.supplyUsed += cost.supply
                this.supplyLeft -= cost.supply
            }
            if (trainerUnit.name === "Drone") {
                this.supplyUsed -= 1
                this.supplyLeft += 1
            }

            return true
        }
        return false
    }

    /**
     * The simulation tries to research an upgrade
     * Nearly the same as trainUnit(unit)
     */
    researchUpgrade(upgrade: IBuildOrderElement): boolean {
        this.requirements = []
        // Issue research command of upgrade type
        console.assert(upgrade.name, JSON.stringify(upgrade, null, 4))
        console.assert(upgrade.type, JSON.stringify(upgrade, null, 4))

        // Get unit type / structure type that can train this unit
        const researchInfo = RESEARCHED_BY[upgrade.name]

        // Check if requirement is met
        const requiredStructure = researchInfo.requiredStructure
        let requiredStructureMet = requiredStructure === null ? true : false
        if (!requiredStructureMet) {
            for (const structure of this.units) {
                if (structure.name === requiredStructure) {
                    requiredStructureMet = true
                    break
                }
            }
            if (
                !requiredStructureMet &&
                !this.addRequirements(upgrade.name, researchInfo.requires)
            ) {
                return false
            }
        }
        const requiredUpgrade = researchInfo.requiredUpgrade
        let requiredUpgradeMet = requiredUpgrade === null ? true : false
        if (requiredUpgrade && !requiredUpgradeMet) {
            if (this.upgrades.has(requiredUpgrade)) {
                requiredUpgradeMet = true
            }
            if (!requiredUpgradeMet) {
                this.errorMessage = `Required upgrade '${requiredUpgrade}' to research upgrade '${upgrade.name}' could not be found.`
                this.requirements.push({
                    name: requiredUpgrade,
                    type: "upgrade",
                })
                return false
            }
        }

        if (!this.addRequirements(upgrade.name, researchInfo.requires)) {
            return false
        }

        // Get cost (mineral, vespene, supply)
        const cost = GameLogic.getCost(upgrade.name, true)
        if (!this._canAfford(cost)) {
            this.setCostErrorMessage(cost, upgrade.name)
            return false
        }

        // The unit/structure that is training the target unit or structure

        for (const researcherStructure of this.idleUnits) {
            const structureCanResearchUpgrade = researchInfo.researchedBy.has(
                researcherStructure.name
            )

            const canBeResearchedByAddon =
                researcherStructure.hasTechlab &&
                researchInfo.researchedBy.has(`${researcherStructure.name}TechLab`)

            if (!structureCanResearchUpgrade && !canBeResearchedByAddon) {
                continue
            }

            // Unit is busy researching / building stuff
            if (structureCanResearchUpgrade && researcherStructure.tasks.length !== 0) {
                continue
            }

            if (canBeResearchedByAddon && researcherStructure.addonTasks.length !== 0) {
                // Addon is busy researching
                continue
            }

            // All requirement checks complete, start the task

            const researchTime = this.getTime(upgrade.name, true)
            const newTask = new Task(researchTime, this.frame, this.supplyUsed, this.getEventId())
            newTask.newUpgrade = upgrade.name

            researcherStructure.addTask(this, newTask, canBeResearchedByAddon)

            const cost = GameLogic.getCost(upgrade.name, true)
            this.minerals -= cost.minerals
            this.vespene -= cost.vespene
            return true
        }
        return false
    }

    /**
     * Updates the unit state and progress of all living units
     */
    updateUnitsProgress(): void {
        // Updates the energy on each unit and their task progress

        this.units.forEach((unit) => {
            unit.updateUnitState(this)
        })
        this.units.forEach((unit) => {
            const isAlive = unit.isAlive(this.frame)
            if (!isAlive) {
                if (unit.name === "MULE") {
                    this.muleCount -= 1
                }
                this.killUnit(unit)
            }
        })
        this.units.forEach((unit) => {
            unit.updateUnit(this)
        })
    }

    // UTILITY FUNCTIONS

    /**
     * Kills a unit and removes it from the game logic
     */
    killUnit(unit: Unit): void {
        this.units.delete(unit)
        this.idleUnits.delete(unit)
        this.busyUnits.delete(unit)
    }

    setCostErrorMessage(cost: ICost, unitName: string): void {
        if (cost.supply > this.supplyLeft) {
            this.errorMessage = `Missing ${Math.ceil(
                cost.supply - this.supplyLeft
            )} supply to produce '${unitName}'.`
            this.requirements = [supplyUnitNameByRace[this.race] as IBuildOrderElement]
        } else if (cost.vespene > this.vespene) {
            this.errorMessage = `Unable to afford '${unitName}', missing ${Math.ceil(
                cost.vespene - this.vespene
            )} vespene.`
            if (this.workersVespene === 0) {
                this.requirements = [
                    {
                        name: "3worker_to_gas",
                        type: "action",
                    },
                ]
            }
        } else if (cost.minerals > this.minerals) {
            this.errorMessage = `Unable to afford '${unitName}', missing ${Math.ceil(
                cost.minerals - this.minerals
            )} minerals.`
        }
    }

    increaseMaxSupply(amount: number): void {
        const remainingTillMaxSupply = 200 - this.supplyCap
        const increaseAmount = Math.min(amount, remainingTillMaxSupply)
        this.supplyCap += increaseAmount
        this.supplyLeft += increaseAmount
    }

    /**
     * Gets the cost of a unit, structure, morph or upgrade
     */
    static getCost(unitName: string, isUpgrade = false): ICost {
        // Gets cost of unit, structure or upgrade
        if (isUpgrade) {
            console.assert(UPGRADES_BY_NAME[unitName], `${unitName}`)
            return {
                minerals: UPGRADES_BY_NAME[unitName].cost.minerals,
                vespene: UPGRADES_BY_NAME[unitName].cost.gas,
                supply: 0,
            }
        }
        // Fixes unit cost for morphing units and structures (e.g. Hatchery to Lair, Roach to Ravager, Drone to Hatchery)
        if (unitName in TRAINED_BY) {
            const trained_by = TRAINED_BY[unitName]
            if (trained_by.isMorph || trained_by.consumesUnit) {
                return {
                    minerals: trained_by.morphCostMinerals,
                    vespene: trained_by.morphCostGas,
                    supply: trained_by.morphCostSupply,
                }
            }
        }
        console.assert(UNITS_BY_NAME[unitName], `${unitName}`)
        return {
            minerals: UNITS_BY_NAME[unitName].minerals,
            vespene: UNITS_BY_NAME[unitName].gas,
            supply: UNITS_BY_NAME[unitName].supply,
        }
    }

    /**
     * Gets build or research time of a unit, structure, morph or upgrade
     */
    getTime(unitName: string, isUpgrade = false): number {
        // Get build time of unit or structure, or research time of upgrade (in frames)
        if (isUpgrade) {
            console.assert(UPGRADES_BY_NAME[unitName], `${unitName}`)
            return UPGRADES_BY_NAME[unitName].cost.time
        }
        console.assert(UNITS_BY_NAME[unitName], `${unitName}`)
        return UNITS_BY_NAME[unitName].time
    }

    /**
     * Recover time of warp gates when creating a specific unit, how long the warp gate will be on cooldown after warping in a specific unit
     */
    warpgateRecoverTime(unitName: string): number {
        const recoverTimes: { [name: string]: number } = {
            Zealot: 20 * 22.4,
            Adept: 20 * 22.4,
            Stalker: 23 * 22.4,
            Sentry: 23 * 22.4,
            DarkTemplar: 32 * 22.4,
            HighTemplar: 32 * 22.4,
        }
        return recoverTimes[unitName]
    }

    /**
     * Calculates and caches the income based on how many workers (and mules), bases and gas structures we have
     */
    addIncome(): void {
        // Calculate income based on mineral and gas workers
        let minerals =
            mineralIncomeCache[
                // TODO Fix me: array[number] cannot be used as index type
                // @ts-ignore
                [this.workersMinerals, this.baseCount, this.muleCount]
            ]
        if (minerals === undefined) {
            minerals =
                incomeMinerals(this.workersMinerals, this.baseCount, this.muleCount) /
                +this.settings.incomeFactor
            mineralIncomeCache[
                // TODO Fix me: array[number] cannot be used as index type
                // @ts-ignore
                [this.workersMinerals, this.baseCount, this.muleCount]
            ] = minerals
        }

        // TODO Fix me: array[number] cannot be used as index type
        // @ts-ignore
        let vespene = vespeneIncomeCache[[this.workersVespene, this.gasCount]]
        if (vespene === undefined) {
            vespene =
                incomeVespene(this.workersVespene, this.gasCount, this.baseCount) /
                +this.settings.incomeFactor
            // TODO Fix me: array[number] cannot be used as index type
            // @ts-ignore
            vespeneIncomeCache[[this.workersVespene, this.gasCount]] = vespene
        }
        this.minerals += minerals
        this.vespene += vespene
        // this.minerals += incomeMinerals(this.workersMinerals, this.baseCount, this.muleCount) / 22.4
        // this.vespene +=  incomeVespene(this.workersVespene, this.gasCount) / 22.4
    }

    canAfford(unit: IBuildOrderElement): boolean {
        // Input: unit or upgrade object {name: "SCV", type: "worker"}
        console.assert(unit.name, JSON.stringify(unit, null, 4))
        console.assert(unit.type, JSON.stringify(unit, null, 4))
        if (unit.type === "upgrade") {
            return this._canAfford(GameLogic.getCost(unit.name, true))
        } else {
            return this._canAfford(GameLogic.getCost(unit.name, false))
        }
    }

    _canAfford(cost: ICost): boolean {
        // Input: cost object
        console.assert(cost.minerals, JSON.stringify(cost, null, 4))
        return (
            cost.minerals <= this.minerals &&
            cost.vespene <= this.vespene &&
            (cost.supply < 0 || cost.supply <= this.supplyLeft)
        )
    }

    canRequirementBeDuplicated(requirementName: string, itemName: string): boolean {
        const itemInfo = TRAINED_BY[itemName]
        const isMorphedFromAnotherUnit =
            itemInfo && itemInfo.requiresUnits && itemInfo.requiresUnits.includes(requirementName)
        const isArchonMaterial =
            !itemInfo &&
            ((itemName === "morph_archon_from_ht_ht" && requirementName === "HighTemplar") ||
                (itemName === "morph_archon_from_dt_dt" && requirementName === "DarkTemplar") ||
                (itemName === "morph_archon_from_ht_dt" &&
                    (requirementName === "HighTemplar" || requirementName === "DarkTemplar")))
        const isSupplyProvider = requirementName === supplyUnitNameByRace[this.race].name
        return isMorphedFromAnotherUnit || isArchonMaterial || isSupplyProvider
    }

    static simulatedBuildOrder(
        prevGamelogic: GameLogic,
        buildOrder: Array<IBuildOrderElement>
    ): GameLogic {
        const gamelogic = new GameLogic(
            prevGamelogic.race,
            buildOrder,
            prevGamelogic.customSettings,
            prevGamelogic.customOptimizeSettings
        )
        gamelogic.setStart()
        gamelogic.runUntilEnd()

        return gamelogic
    }

    static addItemToBO(
        prevGamelogic: GameLogic,
        item: IBuildOrderElement,
        insertIndex: number
    ): [GameLogic, number] {
        const bo = prevGamelogic.bo
        const initialBOLength = bo.length

        if (item.type === "upgrade" && prevGamelogic.upgrades.has(item.name)) {
            // upgrade already researched, don't do anything.
            return [prevGamelogic, insertIndex]
        }

        bo.splice(insertIndex, 0, item)
        // Re-calculate build order

        // // Caching using snapshots - idk why this isnt working properly
        // const latestSnapshot = gamelogic.getLastSnapshot()
        // if (latestSnapshot) {
        //     gamelogic.loadFromSnapshotObject(latestSnapshot)
        // }
        // gamelogic.bo = cloneDeep(bo)
        // gamelogic.runUntilEnd()

        // Non cached:
        // Fill up with missing items
        let gamelogic = GameLogic.simulatedBuildOrder(prevGamelogic, bo)
        let fillingLoop = 0
        // Add required items if need be
        if (insertIndex === bo.length - 1 && !prevGamelogic.errorMessage) {
            do {
                if (fillingLoop > 0) {
                    // Simulation is done already, the first time
                    gamelogic = GameLogic.simulatedBuildOrder(prevGamelogic, bo)
                }
                if (gamelogic.errorMessage && gamelogic.requirements) {
                    fillingLoop++
                    const duplicatesToRemove: IBuildOrderElement[] = []
                    for (const req of gamelogic.requirements) {
                        let duplicateItem: IBuildOrderElement | undefined
                        if (!gamelogic.canRequirementBeDuplicated(req.name, item.name)) {
                            duplicateItem = find(bo, req)
                        }
                        // Add item if absent, or present later in the bo
                        if (!duplicateItem || bo.indexOf(duplicateItem) >= insertIndex) {
                            bo.splice(insertIndex, 0, req)
                            if (duplicateItem) {
                                duplicatesToRemove.push(duplicateItem)
                            }
                        }
                    }
                    for (const duplicate of duplicatesToRemove) {
                        remove(bo, (item) => item === duplicate) // Specificaly remove the later one
                    }
                }
            } while (gamelogic.errorMessage && gamelogic.requirements && fillingLoop < 25)
        }
        const insertedItems = bo.length - initialBOLength
        return [gamelogic, insertedItems]
    }
}

export { Event, GameLogic }