------------------------------------------------------------------
-- VEAF Air Waves for DCS World
-- By Zip (2023)
--
-- Features:
-- ---------
-- * Define zones that are defended by waves of AI flights
--
-- See the documentation : https://veaf.github.io/documentation/
------------------------------------------------------------------

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Global settings. Stores the script constants
-------------------------------------------------------------------------------------------------------------------------------------------------------------

--- Identifier. All output in DCS.log will start with this.
veafAirWaves = {}

--- Identifier. All output in the log will start with this.
veafAirWaves.Id = "AIRWAVES - "

--- Version.
veafAirWaves.Version = "1.7.7"

-- trace level, specific to this module
--veafAirWaves.LogLevel = "trace"

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Do not change anything below unless you know what you are doing!
-------------------------------------------------------------------------------------------------------------------------------------------------------------

veaf.loggers.new(veafAirWaves.Id, veafAirWaves.LogLevel)

veafAirWaves.zones = {}

veafAirWaves.WATCHDOG_DELAY = 1

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- AirWave class methods
-------------------------------------------------------------------------------------------------------------------------------------------------------------

AirWaveZone = {}
function AirWaveZone.init(object)
  -- technical name (AirWave instance name)
  object.name = nil
  -- description for the messages
  object.description = nil
  -- trigger zone name (if set, we'll use a DCS trigger zone)
  object.triggerZoneName = nil
  -- center (point in the center of the circle, when not using a DCS trigger zone)
  object.zoneCenter = nil
  -- radius (size of the circle, when not using a zone) - in meters
  object.zoneRadius = nil
  -- draw the zone on screen
  object.drawZone = false
  -- default position for respawns (im meters, lat/lon, relative to the zone center)
  object.respawnDefaultOffset = {latDelta=0, lonDelta=0}
  -- radius of the waves groups spawn
  object.respawnRadius = 250
  -- coalitions of the players (only human units from these coalitions will be monitored)
  object.playerCoalitions = {}
  -- player units (if they die, reset the zone)
  object.playerUnitsNames = {}
  -- aircraft groups forming the waves
  object.waves = {}
  -- groups that have been spawned (the current wave)
  object.spawnedGroupsNames = {}
  -- silent means no message is emitted
  object.silent = false
  -- message when the zone is activated
  object.messageStart = veafAirWaves.DEFAULT_MESSAGE_START
  -- event when the zone is activated
  object.onStart = nil
  -- message when the zone is waiting for more players
  object.messageWaitForHumans = veafAirWaves.DEFAULT_MESSAGE_WAIT_FOR_HUMANS
  -- event when the zone is waiting for more players
  object.onWaitForHumans = nil
  -- message when a wave will be triggered
  object.messageWaitToDeploy = veafAirWaves.DEFAULT_MESSAGE_WAIT_TO_DEPLOY
  -- event when a wave will be triggered
  object.onWaitToDeploy = nil
  -- message when a wave is triggered
  object.messageDeploy = veafAirWaves.DEFAULT_MESSAGE_DEPLOY
  -- message to each players in the zone when a wave is triggered
  object.messageDeployPlayers = veafAirWaves.DEFAULT_MESSAGE_DEPLOY_PLAYERS
  -- event when a wave is triggered
  object.onDeploy = nil
  -- message when a player is outside of zone
  object.messageOutsideOfZone = veafAirWaves.DEFAULT_MESSAGE_OUTSIDE_OF_ZONE_PLAYERS
  -- event when a player is outside of zone
  object.onOutsideOfZone = nil
  -- message when a wave is destroyed
  object.messageDestroyed = veafAirWaves.DEFAULT_MESSAGE_DESTROYED
  -- event when a wave is destroyed
  object.onDestroyed = nil
  -- message when all waves are finished
  object.messageWon = veafAirWaves.DEFAULT_MESSAGE_WON
  -- event when all waves are finished
  object.onWon = nil
  -- message when the zone is lost
  object.messageLost = veafAirWaves.DEFAULT_MESSAGE_LOST
  -- event when all players are dead
  object.onLost = nil
  -- message when the zone is deactivated
  object.messageStop = veafAirWaves.DEFAULT_MESSAGE_STOP
  -- event when the zone is deactivated
  object.onStop = nil
  -- default delay in seconds between waves of enemy planes
  object.delayBetweenWaves = 0
  -- the delay after this wave, and before the next one (either set in the wave definition, or it's the default delayBetweenWaves)
  object.delayBeforeNextWave = nil
  -- the time when the next wave is supposed to spawn (used to know when to actually spawn when in the STATUS_WAITING_FOR_NEXTWAVE state)
  object.timeOfNextWave = nil
  -- delay in seconds between the first human in zone and the actual activation of the zone
  object.delayBeforeActivation = 0
  -- the time when the zones is supposed to be activated (used to know when to actually activate when in the STATUS_WAITING_FOR_MORE_HUMANS state)
  object.timeOfActivation = nil
  -- if true, the zone will reset when player dies
  object.resetWhenDying = true
  -- human units that are being watched
  object.playerHumanUnits = nil
  -- names of the human units that are being watched
  object.playerHumanUnitsNames = nil
  -- IA units that are being watched
  object.unitsInZone = {}
  -- players in the zone will only be detected below this altitude (in feet)
  object.minimumAltitude = -999999
  -- players in the zone will only be detected above this altitude (in feet)
  object.maximumAltitude = 999999
  -- players staying out of the zone for more that this number of seconds will be destroyed
  object.maxSecondsOutsideOfZonePlayers = veafAirWaves.MAX_SECONDS_OUTSIDE_OF_ZONE_PLAYERS
  -- IA staying out of the zone for more that this number of seconds will be destroyed
  object.maxSecondsOutsideOfZoneIA = veafAirWaves.MAX_SECONDS_OUTSIDE_OF_ZONE_IA
  -- the function that decides if a wave is dead or not (as a set of groups and units)
  object.isEnemyWaveDeadCallback = AirWaveZone.isEnemyWaveDead
  -- the function that decides if IA ennemy groups are dead (individually)
  object.isEnemyGroupDeadCallback = AirWaveZone.isEnemyGroupDead
  -- the minimum percentage of life that an AI unit is supposed to have to be considered alive
  object.minimumLifeForAiInPercent = veafAirWaves.MINIMUM_LIFE_FOR_AI_IN_PERCENT
  -- the function that handles crippled enemy units
  object.handleCrippledEnemyUnitCallback = AirWaveZone.handleCrippledEnemyUnit
  -- current wave number
  object.currentWaveIndex = 0
  -- the drawing object that has been used to draw the zone
  object.zoneDrawing = nil
  -- the scheduled state of the :check() function
  object.checkFunctionSchedule = nil
  -- the time humans exited the zone
  object.timestampsOutOfZone = {}

end

function veafAirWaves.statusToString(status)
  if status == veafAirWaves.STATUS_READY then return "STATUS_READY" end
  if status == veafAirWaves.STATUS_WAITING_FOR_MORE_HUMANS then return "STATUS_WAITING_FOR_MORE_HUMANS" end
  if status == veafAirWaves.STATUS_ACTIVE then return "STATUS_ACTIVE" end
  if status == veafAirWaves.STATUS_WAITING_FOR_NEXTWAVE then return "STATUS_WAITING_FOR_NEXTWAVE" end
  if status == veafAirWaves.STATUS_NEXTWAVE then return "STATUS_NEXTWAVE" end
  if status == veafAirWaves.STATUS_OVER then return "STATUS_OVER" end
  return ""
end
veafAirWaves.STATUS_READY = 1
veafAirWaves.STATUS_WAITING_FOR_MORE_HUMANS = 1.5
veafAirWaves.STATUS_ACTIVE = 2
veafAirWaves.STATUS_WAITING_FOR_NEXTWAVE = 2.5
veafAirWaves.STATUS_NEXTWAVE = 3
veafAirWaves.STATUS_OVER = 4

veafAirWaves.MINIMUM_LIFE_FOR_AI_IN_PERCENT = 0

veafAirWaves.MAX_SECONDS_OUTSIDE_OF_ZONE_PLAYERS = nil -- no outside of zone mechanism by default for players
veafAirWaves.MAX_SECONDS_OUTSIDE_OF_ZONE_IA = 30
veafAirWaves.DEFAULT_MESSAGE_START = "%s - online"
veafAirWaves.DEFAULT_MESSAGE_WAIT_FOR_HUMANS = "%s - waiting %s seconds for more players"
veafAirWaves.DEFAULT_MESSAGE_WAIT_TO_DEPLOY = "%s - waiting %s seconds before next wave"
veafAirWaves.DEFAULT_MESSAGE_DEPLOY = "%s - deploying wave %s"
veafAirWaves.DEFAULT_MESSAGE_DEPLOY_PLAYERS = "Wave %s deploying, %s"
veafAirWaves.DEFAULT_MESSAGE_OUTSIDE_OF_ZONE_PLAYERS = "%s - you've been outside of the zone for %s seconds; go back inside, or you'll be destroyed after %s seconds."
veafAirWaves.DEFAULT_MESSAGE_DESTROYED = "%s - wave %s has been destroyed"
veafAirWaves.DEFAULT_MESSAGE_WON = "%s - won (no more waves)"
veafAirWaves.DEFAULT_MESSAGE_LOST = "%s - lost (no more players)"
veafAirWaves.DEFAULT_MESSAGE_STOP = "%s - offline"

function AirWaveZone:new(objectToCopy)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWave:new()")
  local objectToCreate = objectToCopy or {} -- create object if user does not provide one
  setmetatable(objectToCreate, self)
  self.__index = self

  -- init the new object
  AirWaveZone.init(objectToCreate)

  return objectToCreate
end

function AirWaveZone:setName(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[]:setName(%s)", veaf.p(value))
  self.name = value
  return veafAirWaves.add(self) -- add the zone to the list as soon as a name is available to index it
end

function AirWaveZone:getName()
  return self.name or self.description
end

function AirWaveZone:setTriggerZone(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setTriggerZone(%s)", veaf.p(self.name), veaf.p(value))
  self.triggerZoneName = value
  local triggerZone = veaf.getTriggerZone(value)
  if triggerZone then
    self:setZoneCenter({ x=triggerZone.x, y=triggerZone.y})
    self:setZoneRadius(triggerZone.radius)
  else
    veaf.loggers.get(veafAirWaves.Id):error("AirWaveZone[%s]:setTriggerZone(): trigger zone [%s] does not exist", veaf.p(self.name), veaf.p(value))
  end
  return self
end


function AirWaveZone:setZoneCenter(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setZoneCenter(%s)", veaf.p(self.name), veaf.p(value))
  self.zoneCenter = value
  return self
end

function AirWaveZone:setZoneCenterFromCoordinates(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setZoneCenterFromCoordinates(%s)", veaf.p(self.name), veaf.p(value))
  local _lat, _lon = veaf.computeLLFromString(value)
  local vec3 = coord.LLtoLO(_lat, _lon)
  return self:setZoneCenter(vec3)
end

function AirWaveZone:setZoneRadius(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setZoneRadius(%s)", veaf.p(self.name), veaf.p(value))
  self.zoneRadius = value
  return self
end

function AirWaveZone:setDrawZone(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setDrawZone(%s)", veaf.p(self.name), veaf.p(value))
  self.drawZone = value or false
  return self
end

function AirWaveZone:setDescription(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setDescription(%s)", veaf.p(self.name), veaf.p(value))
  self.description = value
  return veafAirWaves.add(self) -- add the zone to the list as soon as a description is available to index it
end

function AirWaveZone:getDescription()
  return self.description or self.name
end

---adds a wave of enemy planes
---parameters are very flexible: they can be:
--- a table containing the following fields:
---     - groups a list of groups or VEAF commands; VEAF commands can be prefixed with [lat, lon], specifying the location of their spawn relative to the center of the zone; default value is set with "setRespawnDefaultOffset"
---     - number how many of these groups will actually be spawned (can be multiple times the same group!); it can be a "randomizable number", e.g., "2-6" for "between 2 and 6"
---     - bias shifts the random generator to the right of the list; it can be a "randomizable number" too
---     - delay the delay between this wave and the next one - if negative, then the next wave is spawned instantaneously (no waiting for this wave to be completed); it can be a "randomizable number" too
--- or a list of strings (the groups or VEAF commands)
--- or almost anything in between; we'll take a string as if it were a table containing one string, anywhere
--- examples:
---   :addWave("group1")
---   :addWave("group1", "group2")
---   :addWave({"group1", "group2"})
---   :addWave({ groups={"group1", "group2"}, number = 2})
---   :addWave({ groups="group1", number = 2})
---returns self
function AirWaveZone:addWave(...)
  veaf.loggers.get(veafAirWaves.Id):debug(string.format("AirWaveZone[%s]:addWave() : %s", veaf.p(self.name), veaf.p(arg)))
---@diagnostic disable-next-line: undefined-field this is a field defined in the vararg api
  local nArgs = arg.n or 0
  if arg and nArgs > 0 then
    local groups = {}
    local number = 1
    local bias = 0
    local delay = nil
    for i = 1, nArgs, 1 do
      local parameter = arg[i]
      if type(parameter) == "string" then
        table.insert(groups, parameter)
      elseif type(parameter) == "table" then
        if parameter.groups then
          -- this is a parameters table, let's use it
          if type(parameter.groups) == "string" then
            -- we need a table
            groups = { parameter.groups }
          else
            groups = parameter.groups
          end
          number = parameter.number
          bias = parameter.bias
          delay = parameter.delay
          break
        else
          for j = 1, #parameter, 1 do
            local s = parameter[j]
            if type(s) == "string" then
              table.insert(groups, parameter)
            end
          end
          break
        end
      end
    end
    if not self.waves then
      self.waves = {}
    end
    table.insert(self.waves, {groups=groups, number=number or 1, bias=bias or 0, delay=delay})
  end
  return self
end

---reset the waves table to zero; useful when deep copying a zone to reset the waves and set something different
---@return table self
function AirWaveZone:resetWaves()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:resetWaves()", veaf.p(self.name))
  self.waves = {}
  return self
end

function AirWaveZone:setMessageStart(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageStart()", veaf.p(self.name))
  self.messageStart = value
  return self
end

---Set the onStart callback
---@param value function takes 2 parameters: the zone name (string), the monitored player units (table)
---@return table self
function AirWaveZone:setOnStart(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnStart()", veaf.p(self.name))
  self.onStart = value
  return self
end

function AirWaveZone:setMessageWaitForHumans(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageWaitForHumans()", veaf.p(self.name))
  self.messageWaitForHumans = value
  return self
end

---Set the onWaitForHumans callback
---@param value function takes 3 parameters: the zone name (string), the wave index (int), the monitored player units (table)
---@return table self
function AirWaveZone:setOnWaitForHumans(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnWaitForHumans()", veaf.p(self.name))
  self.onWaitForHumans = value
  return self
end

function AirWaveZone:setMessageWaitToDeploy(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageWaitToDeploy()", veaf.p(self.name))
  self.messageWaitToDeploy = value
  return self
end

---Set the onWaitToDeploy callback
---@param value function takes 3 parameters: the zone name (string), the wave index (int), the monitored player units (table)
---@return table self
function AirWaveZone:setOnWaitToDeploy(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnWaitToDeploy()", veaf.p(self.name))
  self.onWaitToDeploy = value
  return self
end

function AirWaveZone:setMessageDeploy(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageDeploy()", veaf.p(self.name))
  self.messageDeploy = value
  return self
end

function AirWaveZone:setMessageDeployPlayers(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageDeployPlayers()", veaf.p(self.name))
  self.messageDeployPlayers = value
  return self
end

---Set the onDeploy callback
---@param value function takes 3 parameters: the zone name (string), the wave index (int), the monitored player units (table)
---@return table self
function AirWaveZone:setOnDeploy(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnDeploy()", veaf.p(self.name))
  self.onDeploy = value
  return self
end

function AirWaveZone:setMessageDestroyed(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageDestroyed()", veaf.p(self.name))
  self.messageDestroyed = value
  return self
end

---Set the onDestroyed callback
---@param value function takes 3 parameters: the zone name (string), the wave index (int), the monitored player units (table)
---@return table self
function AirWaveZone:setOnDestroyed(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnDestroyed()", veaf.p(self.name))
  self.onDestroyed = value
  return self
end

function AirWaveZone:setMessageOutsideOfZone(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageOutsideOfZone()", veaf.p(self.name))
  self.messageOutsideOfZone = value
  return self
end

---Set the onOutsideOfZone callback
---@param value function takes 3 parameters: the zone name (string), the wave index (int), the monitored player units (table)
---@return table self
function AirWaveZone:setOnOutsideOfZone(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnOutsideOfZone()", veaf.p(self.name))
  self.onOutsideOfZone = value
  return self
end

function AirWaveZone:setMessageWon(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageWon()", veaf.p(self.name))
  self.messageWon = value
  return self
end

---Set the onWon callback
---@param value function takes 2 parameters: the zone name (string), the monitored player units (table)
---@return table self
function AirWaveZone:setOnWon(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnWon()", veaf.p(self.name))
  self.onWon = value
  return self
end

function AirWaveZone:setMessageLost(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageLost()", veaf.p(self.name))
  self.messageLost = value
  return self
end

---Set the onLost callback
---@param value function takes 2 parameters: the zone name (string), the monitored player units (table)
---@return table self
function AirWaveZone:setOnLost(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnLost()", veaf.p(self.name))
  self.onLost = value
  return self
end

function AirWaveZone:setMessageStop(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMessageStop()", veaf.p(self.name))
  self.messageStop = value
  return self
end

---Set the onStop callback
---@param value function takes 2 parameters: the zone name (string), the monitored player units (table)
---@return table self
function AirWaveZone:setOnStop(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setOnStop()", veaf.p(self.name))
  self.onStop = value
  return self
end

function AirWaveZone:setSilent(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setSilent(%s)", veaf.p(self.name), veaf.p(value))
  self.silent = value or false
  return self
end

function AirWaveZone:setRespawnRadius(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setRespawnRadius(%s)", veaf.p(self.name), veaf.p(value))
  self.respawnRadius = value
  if self.respawnRadius < 250 then self.respawnRadius = 250 end
  return self
end

---set the default respawn offset (in meters, relative to the zone center)
---@param defaultOffsetLatitude any in meters
---@param defaultOffsetLongitude any in meters
---@return table self
function AirWaveZone:setRespawnDefaultOffset(defaultOffsetLatitude, defaultOffsetLongitude)
    veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setRespawnDefaultOffset(%s, %s)", veaf.p(self.name), veaf.p(defaultOffsetLatitude), veaf.p(defaultOffsetLongitude))
    self.respawnDefaultOffset = { latDelta = defaultOffsetLatitude, lonDelta = defaultOffsetLongitude}
    return self
end

function AirWaveZone:addPlayerCoalition(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:addPlayerCoalition(%s)", veaf.p(self.name), veaf.p(value))
  self.playerCoalitions[value] = value
  return self
end

function AirWaveZone:getPlayerCoalition()
  local result = nil
  for coalition, _ in pairs(self.playerCoalitions) do
    result = coalition
    break
  end
  return result
end

---Sets the default delay in seconds between waves of enemy planes
---@param value number a delay in seconds
---@return table self
function AirWaveZone:setDelayBetweenWaves(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setDelayBetweenWaves(%s)", veaf.p(self.name), veaf.p(value))
  self.delayBetweenWaves = value
  return self
end

---Sets the delay in seconds between the first human in zone and the actual activation of the zone
---@param value number a delay in seconds
---@return table self
function AirWaveZone:setDelayBeforeActivation(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setDelayBeforeActivation(%s)", veaf.p(self.name), veaf.p(value))
  self.delayBeforeActivation = value
  return self
end

function AirWaveZone:setResetWhenDying(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setResetWhenDying(%s)", veaf.p(self.name), veaf.p(value))
  self.resetWhenDying = value
  return self
end

function AirWaveZone:setMinimumAltitudeInFeet(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMinimumAltitudeInFeet(%s)", veaf.p(self.name), veaf.p(value))
  self.minimumAltitude = value * 0.3048 -- convert from feet
  return self
end

function AirWaveZone:getMinimumAltitudeInMeters()
  return self.minimumAltitude
end

function AirWaveZone:setMaximumAltitudeInFeet(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMaximumAltitudeInFeet(%s)", veaf.p(self.name), veaf.p(value))
  self.maximumAltitude = value * 0.3048 -- convert from feet
  return self
end

function AirWaveZone:getMaximumAltitudeInMeters()
  return self.maximumAltitude
end

---Sets the maximum number of seconds an IA can stay out of its zone before being destroyed
---@param value number a delay in seconds
---@return table self
function AirWaveZone:setMaxSecondsOutsideOfZoneIA(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMaxSecondsOutsideOfZoneIA(%s)", veaf.p(self.name), veaf.p(value))
  self.maxSecondsOutsideOfZoneIA = value
  return self
end

---Disables the check for IA out of zone.
---@return table self
function AirWaveZone:disableOutsideOfZoneIA()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:disableOutsideOfZoneIA()", veaf.p(self.name))
  self.maxSecondsOutsideOfZoneIA = nil
  return self
end

---Sets the maximum number of seconds a player can stay out of its zone before being destroyed; players will be messaged as soon as they exit the zone, and every check
---@param value number a delay in seconds
---@return table self
function AirWaveZone:setMaxSecondsOutsideOfZonePlayers(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMaxSecondsOutsideOfZonePlayers(%s)", veaf.p(self.name), veaf.p(value))
  self.maxSecondsOutsideOfZonePlayers = value
  return self
end

---Disables the check for players out of zone.
---@return table self
function AirWaveZone:disableOutsideOfZonePlayers()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:disableOutsideOfZonePlayers()", veaf.p(self.name))
  self.maxSecondsOutsideOfZonePlayers = nil
  return self
end

function AirWaveZone:_setState(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:_setState(%s)", veaf.p(self.name), veaf.p(veafAirWaves.statusToString(value)))
  self.state = value
  return self
end

---the function that decides if a wave is dead or not (as a set of groups and units)
---@param callback function the callback function will be called with 3 parameters: a zone, the wave index number, the spawned groups names list; it must return a boolean
function AirWaveZone:setIsEnemyWaveDeadCallback(callback)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setIsEnemyWaveDeadCallback()", veaf.p(self.name))
  self.isEnemyWaveDeadCallback = callback
  return self
end

---the function that decides if a group is dead or not (individually)
---@param callback function the callback function will be called with 3 parameters: a zone, the wave index number, a DCS group table; it must return a boolean
function AirWaveZone:setIsEnemyGroupDeadCallback(callback)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setIsEnemyGroupDeadCallback()", veaf.p(self.name))
  self.isEnemyGroupDeadCallback = callback
  return self
end

---Sets the minimum percentage of life that an AI unit is supposed to have to be considered alive
---@param value number percentage
---@return table
function AirWaveZone:setMinimumLifeForAiInPercent(value)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setMinimumLifeForAiInPercent(%s)", veaf.p(self.name), veaf.p(value))
  self.minimumLifeForAiInPercent = value
  return self
end

--- the function that handles crippled enemy units
---@param callback function the callback function will be called with 3 parameters: a zone, the wave index number, a DCS unit table; it must do what it wants with the unit
function AirWaveZone:setHandleCrippledEnemyUnitCallback(callback)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:setHandleCrippledEnemyUnitCallback()", veaf.p(self.name))
  self.handleCrippledEnemyUnitCallback = callback
  return self
end

function AirWaveZone:reset()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:reset()", veaf.p(self.name))

  -- despawn the ennemies
  self:destroyCurrentWave()

  -- reset all the zone properties

  -- player units (if they die, reset the zone)
  self.playerUnitsNames = {}
  -- groups that have been spawned (the current wave)
  self.spawnedGroupsNames = {}
  -- the delay after this wave, and before the next one (either set in the wave definition, or it's the default delayBetweenWaves)
  self.delayBeforeNextWave = nil
  -- the time when the next wave is supposed to spawn (used to know when to actually spawn when in the STATUS_WAITING_FOR_NEXTWAVE state)
  self.timeOfNextWave = nil
  -- the time when the zones is supposed to be activated (used to know when to actually activate when in the STATUS_WAITING_FOR_MORE_HUMANS state)
  self.timeOfActivation = nil
  -- human units that are being watched
  self.playerHumanUnits = nil
  -- names of the human units that are being watched
  self.playerHumanUnitsNames = nil
  -- IA units that are being watched
  self.unitsInZone = {}
  -- current wave number
  self.currentWaveIndex = 0
  -- the drawing object that has been used to draw the zone
  self.zoneDrawing = nil
  -- the time humans exited the zone
  self.timestampsOutOfZone = {}

  -- deschedule the check() function
  if self.checkFunctionSchedule then
    mist.removeFunction(self.checkFunctionSchedule)
    self.checkFunctionSchedule = nil
  end

  return self
end

-- the function that decides if a wave is dead or not (as a set of groups and units)
function AirWaveZone:isEnemyWaveDead(waveNumber, waveGroupsNames)
  --veaf.loggers.get(veafAirWaves.Id):trace("AirWaveZone[%s]:isEnemyWaveDead(%s)", veaf.p(self.name), veaf.p(waveNumber))
  --veaf.loggers.get(veafAirWaves.Id):trace("waveGroupsNames=%s", veaf.p(waveGroupsNames))

  local currentWaveAlive = false
  for _, groupName in pairs(waveGroupsNames) do
    local group = Group.getByName(groupName)
    if group then
      local groupIsDead = self.isEnemyGroupDeadCallback(self, self.currentWaveIndex, group)
      if not groupIsDead then
        currentWaveAlive = true
      end
    end
  end
  return not currentWaveAlive
end

-- the function that decides if IA ennemy groups are dead (individually)
function AirWaveZone:isEnemyGroupDead(waveNumber, group)
  --veaf.loggers.get(veafAirWaves.Id):trace("AirWaveZone[%s]:isEnemyGroupDead(%s)", veaf.p(self.name), veaf.p(waveNumber))
  if not group then return true end
  --veaf.loggers.get(veafAirWaves.Id):trace("group:getName()=%s", veaf.p(group:getName()))

  local groupAtLeastOneUnitAlive = false
  local category = group:getCategory()
  local units = group:getUnits()
  if units then
    for _,unit in pairs(units) do
      local unitAlive = false
      local unitLife = unit:getLife()
      local unitLife0 = 0
      if unit.getLife0 then -- statics have no life0
        unitLife0 = unit:getLife0()
      end
      local unitLifePercent = unitLife
      if unitLife0 > 0 then
        unitLifePercent = 100 * unitLife / unitLife0
      end
      if unitLifePercent > self.minimumLifeForAiInPercent then
        if category == 0 --[[airplanes]] or category == 1 --[[helicopters]] then
          if unit:inAir() then
            unitAlive = true
          end
        else
          unitAlive = true
        end
      end
      if not unitAlive then
        self.handleCrippledEnemyUnitCallback(self, self.currentWaveIndex, unit)
      else
        groupAtLeastOneUnitAlive = true
      end
    end
  end
  return not groupAtLeastOneUnitAlive
end

-- the function that handles crippled enemy units
function AirWaveZone:handleCrippledEnemyUnit(waveNumber, unit)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:handleCrippledEnemyUnit(%s)", veaf.p(self.name), veaf.p(waveNumber))
  if not unit then return end
  veaf.loggers.get(veafAirWaves.Id):debug("unit:getName()=%s", veaf.p(unit:getName()))
  -- simply despawn the unit
  unit:destroy()
end

function AirWaveZone:getPlayerUnitsNames()
  if not self.playerHumanUnitsNames then
    self.playerHumanUnitsNames = {}
    for _, unit in pairs(mist.DBs.humansByName) do
      local coalitionId = 0
      if unit.coalition then
        if unit.coalition:lower() == "red" then
          coalitionId = coalition.side.RED
        elseif unit.coalition:lower() == "blue" then
          coalitionId = coalition.side.BLUE
        end
      end
      if self.playerCoalitions[coalitionId] then
        if unit.category then
          if unit.category == "plane" then
            table.insert(self.playerHumanUnitsNames, unit.unitName)
          end
        end
      end
    end
  end
  return self.playerHumanUnitsNames
end

function AirWaveZone:check()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:check() -> self.state=%s", veaf.p(self.name), veaf.p(veafAirWaves.statusToString(self.state)))
  veaf.loggers.get(veafAirWaves.Id):trace("AirWaveZone[%s]:check() -> timer.getTime()=%s", veaf.p(self.name), veaf.p(timer.getTime()))

  local function getHumansInZone()
    local resultUnitsByName = {}
    local resultUnitsNames = {}
    local resultUnits = {}
    local unitNames = self:getPlayerUnitsNames()
    local triggerZone = veaf.getTriggerZone(self.triggerZoneName)
    local humanUnits = nil
    if triggerZone then
      if triggerZone.type == 0 then -- circular
        humanUnits = mist.getUnitsInZones(unitNames, {self.triggerZoneName})
      elseif triggerZone.type == 2 then -- quad point
        humanUnits = mist.getUnitsInPolygon(unitNames, triggerZone.verticies)
      end
    elseif self.zoneCenter then
      humanUnits = veaf.findUnitsInCircle(self.zoneCenter, self.zoneRadius, false, unitNames)
    else
      veaf.loggers.get(veafAirWaves.Id):error("No triggerzone, and no zone center/radius defined!")
    end
    for _, unit in pairs(humanUnits) do
      -- check the unit altitude against the ceiling and floor
      if unit:inAir() then -- never count a landed aircraft
        local alt = unit:getPoint().y
        if alt >= self:getMinimumAltitudeInMeters() and alt <= self:getMaximumAltitudeInMeters() then
          -- add the unit to the player units list, so that we can monitor it
          local unitName = unit:getName()
          table.insert(resultUnitsNames, unitName)
          table.insert(resultUnits, unit)
          resultUnitsByName[unitName] = unit
        end
      end
    end
    return resultUnits, resultUnitsNames, resultUnitsByName
  end

  local humansInZone, humansInZoneNames, humansInZoneByName = getHumansInZone()

  -- whatever the state, monitor the player units if they're defined
  if self.playerUnitsNames and #self.playerUnitsNames > 0 then
    local atLeastOnePlayerAlive = false
    local atLeastOnePlayerAirborne = false
    for _, unitName in pairs(self.playerUnitsNames) do
      local unit = Unit.getByName(unitName)
      if unit then
        -- check alive
        atLeastOnePlayerAlive = true
        if unit:inAir() then
          atLeastOnePlayerAirborne = true
        end
        -- check in zone
        if humansInZoneByName[unitName] then
          self.timestampsOutOfZone[unitName] = nil
        elseif self.maxSecondsOutsideOfZonePlayers then
          local timestampOutOfZone = timer.getTime()
          if self.timestampsOutOfZone[unitName] then
            timestampOutOfZone = self.timestampsOutOfZone[unitName]
          else
            self.timestampsOutOfZone[unitName] = timestampOutOfZone
          end
          local seconds = timer.getTime() - timestampOutOfZone
          self:signalOutsideOfZone(unitName, seconds)
          local secondsOffend = seconds - self.maxSecondsOutsideOfZonePlayers
          if secondsOffend > 0 then
            -- destroy the player
            if secondsOffend > self.maxSecondsOutsideOfZonePlayers then
              veaf.loggers.get(veafAirWaves.Id):debug("destroy out of zone player unitName=%s", veaf.p(unitName))
              unit:destroy()
            else
              veaf.loggers.get(veafAirWaves.Id):debug("flak out of zone player unitName=%s", veaf.p(unitName))
              local point = unit:getPoint()
              local positionForFlak1 = mist.vec.add(point, mist.vec.scalarMult(unit:getVelocity(), 1))
              local positionForFlak2 = mist.vec.add(point, mist.vec.scalarMult(unit:getVelocity(), 2))
              local positionForFlak3 = mist.vec.add(point, mist.vec.scalarMult(unit:getVelocity(), 3))
              veafSpawn.spawnBomb(positionForFlak1, 50, 5, 25 + seconds - self.maxSecondsOutsideOfZonePlayers, positionForFlak1.y, 50)
              veafSpawn.spawnBomb(positionForFlak2, 50, 5, 25 + seconds - self.maxSecondsOutsideOfZonePlayers, positionForFlak2.y, 50)
              veafSpawn.spawnBomb(positionForFlak3, 50, 5, 25 + seconds - self.maxSecondsOutsideOfZonePlayers, positionForFlak3.y, 50)
            end
          end
        end
      end
    end
    if not (atLeastOnePlayerAlive and atLeastOnePlayerAirborne) then
      veaf.loggers.get(veafAirWaves.Id):debug("player is dead or despawned in %s", veaf.p(self:getName()))
      if self.state ~= veafAirWaves.STATUS_OVER then
        -- signal that all players have been destroyed
        self:signalLost()
      end
      if self.resetWhenDying then
        -- reset the zone
        self:stop()
        self:start()
      end
    end
  end

  if self.state == veafAirWaves.STATUS_READY then
    if humansInZone and #humansInZone > 0 then
      -- store the human units that we're going to monitor
      self.unitsInZone = humansInZone
      self.playerUnitsNames = humansInZoneNames
      self:_setState(veafAirWaves.STATUS_WAITING_FOR_MORE_HUMANS)
      if self.delayBeforeActivation and self.delayBeforeActivation > 0 then
        self:signalWaitForHumans()
      end
      self.timeOfActivation = timer.getTime() + self.delayBeforeActivation
      veaf.loggers.get(veafAirWaves.Id):debug("waiting %s seconds before activation", veaf.p(self.delayBeforeActivation))
      veaf.loggers.get(veafAirWaves.Id):trace("self.timeOfActivation=%s", veaf.p(self.timeOfActivation))
      veaf.loggers.get(veafAirWaves.Id):debug("restart the check immediately")
      -- restart the check immediately (we don't want to wait for the next state to be processed)
      self:check()
    end
  elseif self.state == veafAirWaves.STATUS_WAITING_FOR_MORE_HUMANS then
    -- wait until the delay has passed
    if self.timeOfActivation and timer.getTime() >= self.timeOfActivation then
      -- zone is ready, check for players entering
      local humansInZone, humanInZoneNames = getHumansInZone()
      if humansInZone and #humansInZone > 0 then
        -- store the human units that we're going to monitor
        self.unitsInZone = humansInZone
        self.playerUnitsNames = humanInZoneNames
        -- reset wave index
        self.currentWaveIndex = 0
        self:_setState(veafAirWaves.STATUS_NEXTWAVE)
        -- restart the check immediately (we don't want to wait for the next state to be processed)
        self:check()
      end
    end
  elseif self.state == veafAirWaves.STATUS_NEXTWAVE then
    -- wave has been destroyed, or it's the first time a wave has to be deployed; check if there is a next one and deploy it
    if self.currentWaveIndex < #self.waves then
      if not self.delayBeforeNextWave then
        self.delayBeforeNextWave = self.delayBetweenWaves
      end
      self:_setState(veafAirWaves.STATUS_WAITING_FOR_NEXTWAVE)
      if self.delayBeforeNextWave and self.delayBeforeNextWave > 0 then
        self:signalWaitToDeploy()
      end
      self.timeOfNextWave = timer.getTime() + self.delayBeforeNextWave
      veaf.loggers.get(veafAirWaves.Id):debug("waiting %s seconds before spawning next wave(s)", veaf.p(self.delayBeforeNextWave))
      veaf.loggers.get(veafAirWaves.Id):trace("self.timeOfNextWave=%s", veaf.p(self.timeOfNextWave))
      -- restart the check immediately (we don't want to wait for the next state to be processed)
      self:check()
    else
      self:signalWon()
      self:_setState(veafAirWaves.STATUS_OVER)
    end
  elseif self.state == veafAirWaves.STATUS_WAITING_FOR_NEXTWAVE then
    -- wait until the delay has passed
    if self.timeOfNextWave and timer.getTime() >= self.timeOfNextWave then
      -- deploy the next wave
      local spawnedGroups, delayBeforeNextWave = self:deployWaves()
      if spawnedGroups then
        self.delayBeforeNextWave = delayBeforeNextWave or self.delayBetweenWaves
        self:_setState(veafAirWaves.STATUS_ACTIVE)
      end
    end
  elseif self.state == veafAirWaves.STATUS_ACTIVE then
    -- zone is active
    
    -- check if the current wave is still alive
    local waveIsDead = self.isEnemyWaveDeadCallback(self, self.currentWaveIndex, self.spawnedGroupsNames)
    if waveIsDead then
      -- clean up any eventual remaining group of the wave
      self:destroyCurrentWave()
      -- signal that wave has been destroyed
      self:signalDestroyed()
      -- prepare next wave
      self:_setState(veafAirWaves.STATUS_NEXTWAVE)
      -- restart the check immediately (we don't want to wait for the next state to be processed)
      self:check()
    else
      -- check if any IA wandered out of the zone for longer than it should have (maxSecondsOutsideOfZoneIA)
      local triggerZone = veaf.getTriggerZone(self.triggerZoneName)
      for _, groupName in pairs(self.spawnedGroupsNames) do
        local group = Group.getByName(groupName)
        if group then
          local units = group:getUnits()
          if units then
            for _, unit in pairs(units) do
              local unitName = unit:getName()
              local outOfZone = false
              if self.maxSecondsOutsideOfZoneIA then -- no need to check if feature is disabled
                if triggerZone then
                  outOfZone = not(veaf.isUnitInZone(unit, triggerZone))
                else
                  local pos = unit:getPosition().p
                  if pos then -- you never know O.o
                      local distanceFromCenter = ((pos.x - self.zoneCenter.x)^2 + (pos.z - self.zoneCenter.z)^2)^0.5
                      outOfZone = (distanceFromCenter > self.zoneRadius)
                  end
                end
                if outOfZone then
                  local timestampOutOfZone = timer.getTime()
                  if self.timestampsOutOfZone[unitName] then
                    timestampOutOfZone = self.timestampsOutOfZone[unitName]
                  else
                    self.timestampsOutOfZone[unitName] = timestampOutOfZone
                  end
                  local seconds = timer.getTime() - timestampOutOfZone
                  local secondsOffend = seconds - self.maxSecondsOutsideOfZoneIA
                  if secondsOffend > 0 then
                    -- destroy the IA
                    veaf.loggers.get(veafAirWaves.Id):debug("destroy out of zone AI unitName=%s", veaf.p(unitName))
                    unit:destroy()
                  end
                else
                  self.timestampsOutOfZone[unitName] = nil
                end
              end
            end
          end
        end
      end
    end
  elseif self.state == veafAirWaves.STATUS_OVER then
    -- zone has still to be reset to restart
  end
  if self.checkFunctionSchedule then
    -- deschedule if needed
    mist.removeFunction(self.checkFunctionSchedule)
    self.checkFunctionSchedule = nil
  end
  self.checkFunctionSchedule = mist.scheduleFunction(AirWaveZone.check, {self}, timer.getTime() + veafAirWaves.WATCHDOG_DELAY + math.random(0, 2)) -- randomize reschedules so not all zones are working at the same time
end

function AirWaveZone:chooseGroupsToDeploy()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:chooseGroupsToDeploy()", veaf.p(self.name))

  if self.currentWaveIndex <= #self.waves then
    local nextWave = self.waves[self.currentWaveIndex]
    if nextWave then
      -- process a random group definition
      local groupsToChooseFrom = nextWave.groups
      local numberOfGroups = nextWave.number
      local bias = nextWave.bias
      local delay = nextWave.delay
      local result = {}
      if type(numberOfGroups) == "string" then
        -- convert randomizable numeric to number
        numberOfGroups = veaf.getRandomizableNumeric(numberOfGroups)
      end
      if type(bias) == "string" then
        -- convert randomizable numeric to number
        bias = veaf.getRandomizableNumeric(bias)
      end
      if delay ~= nil and type(delay) == "string" then
        -- convert randomizable numeric to number
        delay = veaf.getRandomizableNumeric(delay)
      end
      if groupsToChooseFrom and type(groupsToChooseFrom) == "table" and numberOfGroups and type(numberOfGroups) == "number" and bias and type(bias) == "number" then
        for _ = 1, numberOfGroups do
          local group = veaf.randomlyChooseFrom(groupsToChooseFrom, bias)
          table.insert(result, group)
        end
      end
      return result, delay
    end
  end
end

function AirWaveZone:deployWaves()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:deployWaves()", veaf.p(self.name))
  self.spawnedGroupsNames = {}
  local groupsToDeployForTheseWaves = {}
  local lastDelay
  repeat
    self.currentWaveIndex = self.currentWaveIndex + 1
    local groupsToDeploy, delay = self:chooseGroupsToDeploy()
    veaf.loggers.get(veafAirWaves.Id):debug("groupsToDeploy=%s", veaf.p(groupsToDeploy))
    veaf.loggers.get(veafAirWaves.Id):debug("delay=%s", veaf.p(delay))
    lastDelay = delay
    for _, group in pairs(groupsToDeploy) do
      table.insert(groupsToDeployForTheseWaves, group)
    end
  until not lastDelay or lastDelay >= 0 or self.currentWaveIndex >= #self.waves
  if groupsToDeployForTheseWaves then
    local zoneCenter = {}
    if self.triggerZoneName then
      local triggerZone = veaf.getTriggerZone(self.triggerZoneName)
      zoneCenter.x = triggerZone.x
      zoneCenter.z = triggerZone.y
      zoneCenter.y = 0
    elseif self.zoneCenter then
      zoneCenter = self.zoneCenter
    end
    for _, groupNameOrCommand in pairs(groupsToDeployForTheseWaves) do
      -- check if this is a DCS group or a VEAF command
      if veaf.startsWith(groupNameOrCommand, "[") or veaf.startsWith(groupNameOrCommand, "-") then
        -- this is a command
        local command = groupNameOrCommand
        local latDelta = self.respawnDefaultOffset.latDelta
        local lonDelta = self.respawnDefaultOffset.lonDelta
        if veaf.startsWith(groupNameOrCommand, "[") then
          -- extract relative coordinates and the actual command
          local coords
          coords, command = groupNameOrCommand:match("%[(.*)%](.*)")
          if coords then
            latDelta, lonDelta = coords:match("([%+-%d]+),%s*([%+-%d]+)")
          end
        end
        veaf.loggers.get(veafAirWaves.Id):debug("running command [%s]", veaf.p(command))
        local position = {x = zoneCenter.x - lonDelta, y = zoneCenter.y, z = zoneCenter.z + latDelta}
        local randomPosition = mist.getRandPointInCircle(position, self.respawnRadius)
        local spawnedGroupsNames = {}
        veafInterpreter.execute(command, randomPosition, self.coalition, nil, spawnedGroupsNames)
        for _, newGroupName in pairs(spawnedGroupsNames) do
          table.insert(self.spawnedGroupsNames, newGroupName)
        end
      else
        -- this is a DCS group
        local groupName = groupNameOrCommand
        veaf.loggers.get(veafAirWaves.Id):debug("spawning group [%s]", veaf.p(groupName))
        local groupData = mist.getGroupData(groupName)
        veaf.loggers.get(veafAirWaves.Id):trace("groupData=%s", veaf.p(groupData))
        if not groupData then
          veaf.loggers.get(veafAirWaves.Id):error("group [%s] does not exist in the mission!", veaf.p(groupName))
        else
          local spawnSpot = {x = zoneCenter.x - self.respawnDefaultOffset.lonDelta, y = zoneCenter.y, z = zoneCenter.z + self.respawnDefaultOffset.latDelta}
          -- Try and set the spawn spot at the place the group has been set in the Mission Editor.
          -- Unfortunately this is sometimes not possible because DCS is not returning the group units for some reason.
          -- When this happens we'll default to the default spawn offset (same as spawning with VEAF commands)
          if not groupData.units[1] then
            veaf.loggers.get(veafAirWaves.Id):warn("group [%s] does not have any unit!", veaf.p(groupName))
          else
            spawnSpot =  { x = groupData.units[1].x, y = groupData.units[1].alt, z = groupData.units[1].y }
          end
          veaf.loggers.get(veafAirWaves.Id):trace("spawnSpot=%s", veaf.p(spawnSpot))
          local vars = {}
          vars.point = mist.getRandPointInCircle(spawnSpot, self.respawnRadius)
          vars.point.z = vars.point.y
          vars.point.y = spawnSpot.y
          vars.gpName = groupName
          vars.action = 'clone'
          vars.route = mist.getGroupRoute(groupName, 'task')
          veaf.loggers.get(veafAirWaves.Id):trace("vars=%s", veaf.p(vars))
          local newGroup = mist.teleportToPoint(vars) -- respawn with radius
          if newGroup then
            table.insert(self.spawnedGroupsNames, newGroup.name)
          end
        end
      end
    end
    veaf.loggers.get(veafAirWaves.Id):trace("self.spawnedGroupsNames=%s", veaf.p(self.spawnedGroupsNames))
    self:_setState(veafAirWaves.STATUS_ACTIVE)
  end
  self:signalDeploy()
  return (self.spawnedGroupsNames and #self.spawnedGroupsNames > 0), lastDelay
end

---Sends a message to all players in the zone, but only once per group (because we're actually messaging whole groups, thanks DCS)
---@param msg string the message to be sent
function AirWaveZone:signalToPlayers(msg)
  if self.unitsInZone then
    for _, unitInZone in pairs(self.unitsInZone) do
      local unitName = unitInZone:getName()
      veaf.outTextForUnit(unitName, msg, 15)
    end
  end
end

function AirWaveZone:signalStart()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalStart()", veaf.p(self.name))
  if not self.silent then
    local msg = string.format(self.messageStart, self:getDescription())
    for coalition, _ in pairs(self.playerCoalitions) do
      trigger.action.outTextForCoalition(coalition, msg, 15)
    end
  end
  if self.onStart then
    self.onStart(self.name, self.playerUnitsNames)
  end
end

function AirWaveZone:signalWaitForHumans()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalWaitForHumans()", veaf.p(self.name))
  if not self.silent then
    self:signalToPlayers(string.format(self.messageWaitForHumans, self:getDescription(), self.delayBeforeActivation))
  end
  if self.onWaitForHumans then
    self.onWaitForHumans(self.name, self.playerUnitsNames)
  end
end

function AirWaveZone:signalWaitToDeploy()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalWaitToDeploy()", veaf.p(self.name))
  if not self.silent then
    self:signalToPlayers(string.format(self.messageWaitToDeploy, self:getDescription(), self.delayBeforeNextWave))
  end
  if self.onWaitToDeploy then
    self.onWaitToDeploy(self.name, self.playerUnitsNames)
  end
end

function AirWaveZone:signalDeploy()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalDeploy()", veaf.p(self.name))
  if not self.silent then
    -- messages to all
    local msg = string.format(self.messageDeploy, self:getDescription(), self.currentWaveIndex)
    for coalition, _ in pairs(self.playerCoalitions) do
      trigger.action.outTextForCoalition(coalition, msg, 15)
    end
    -- messages to players with BRAA
    if self.unitsInZone then
      local groupsAlreadyMessaged = {}
      for _, unitInZone in pairs(self.unitsInZone) do
        -- compute BRAA of closest group
        local braa = { bearing = -1, distance = 9999}
        for _, spawnedGroupName in pairs(self.spawnedGroupsNames) do
          local spawnedGroupPosition = mist.getAvgGroupPos(spawnedGroupName)
          local unitPosition = nil
          if unitInZone and unitInZone:getPosition() then
            unitPosition = unitInZone:getPosition().p
          end
          if spawnedGroupPosition and unitPosition then
            local bearing, _, _, distanceInNm = veaf.getBearingAndRangeFromTo(unitPosition, spawnedGroupPosition)
            if braa.distance > distanceInNm then
              -- this is closer than the group we had before
              braa.distance = math.floor(distanceInNm)
              braa.bearing = math.floor(bearing)
            end
          end
        end
        if braa.bearing > -1 then
          -- found a group
          local braaS = string.format("BRA %03d/%02d", braa.bearing, braa.distance)
          if braa.distance < 5 then
            braaS = "MERGED"
          end
          local group = unitInZone:getGroup()
          local groupId = nil
          if group then
              groupId = group:getID()
          end
          if groupId and not groupsAlreadyMessaged[groupId] then
            groupsAlreadyMessaged[groupId] = true
            trigger.action.outTextForGroup(groupId, string.format(self.messageDeployPlayers, self.currentWaveIndex, braaS), 15)
          end
        end
      end
    end
  end
  if self.onDeploy then
    self.onDeploy(self.name, self.currentWaveIndex, self.playerUnitsNames)
  end
end

function AirWaveZone:signalDestroyed()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalDestroyed()", veaf.p(self.name))
  if not self.silent then
    self:signalToPlayers(string.format(self.messageDestroyed, self:getDescription(), self.currentWaveIndex))
  end
  if self.onDestroyed then
    self.onDestroyed(self.name, self.currentWaveIndex, self.playerUnitsNames)
  end
end

function AirWaveZone:signalOutsideOfZone(playerUnitName, seconds)
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalOutsideOfZone(player=%s, seconds=%s)", veaf.p(self.name), veaf.p(playerUnitName), veaf.p(seconds))
  if not self.silent then
    veaf.outTextForUnit(playerUnitName, string.format(self.messageOutsideOfZone, self:getDescription(), seconds, self.maxSecondsOutsideOfZonePlayers), 15)
  end
  if self.onOutsideOfZone then
    self.onOutsideOfZone(self.name, playerUnitName, seconds)
  end
end

function AirWaveZone:signalWon()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalWon()", veaf.p(self.name))
  if not self.silent then
    self:signalToPlayers(string.format(self.messageWon, self:getDescription()))
  end
  if self.onWon then
    self.onWon(self.name, self.playerUnitsNames)
  end
end

function AirWaveZone:signalLost()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalLost()", veaf.p(self.name))
  if not self.silent then
    local msg = string.format(self.messageLost, self:getDescription())
    for coalition, _ in pairs(self.playerCoalitions) do
      trigger.action.outTextForCoalition(coalition, msg, 15)
    end
  end
  if self.onLost then
    self.onLost(self.name, self.playerUnitsNames)
  end
end

function AirWaveZone:signalStop()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:signalStop()", veaf.p(self.name))
  if not self.silent then
    local msg = string.format(self.messageStop, self:getDescription())
    for coalition, _ in pairs(self.playerCoalitions) do
      trigger.action.outTextForCoalition(coalition, msg, 15)
    end
  end
  if self.onStop then
    self.onStop(self.name, self.playerUnitsNames)
  end
end

function AirWaveZone:start()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:start()", veaf.p(self.name))
  self:reset()
  self:_setState(veafAirWaves.STATUS_READY)
  self:check()

  -- draw the zone
  if self.drawZone then
    if self.triggerZoneName then
      self.zoneDrawing = mist.marker.drawZone(self.triggerZoneName, {message=self:getDescription(), readOnly=true})
    else
      self.zoneDrawing = VeafCircleOnMap:new()
      :setName(self:getName())
      :setCoalition(self:getPlayerCoalition())
      :setCenter(self.zoneCenter)
      :setRadius(self.zoneRadius)
      :setLineType("dashed")
      :setColor("white")
      :setFillColor("transparent")
      :draw()
    end
  end

  self:signalStart()
  return self
end

function AirWaveZone:stop()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:stop()", veaf.p(self.name))
  self:reset()
  self:_setState(veafAirWaves.STATUS_STOP)

  -- erase the zone
  if self.zoneDrawing then
    if self.triggerZoneName then
      mist.marker.remove(self.zoneDrawing.markId)
    else
      self.zoneDrawing:erase()
    end
    self.zoneDrawing = nil
  end

  self:signalStop()
  return self
end

function AirWaveZone:destroyCurrentWave()
  veaf.loggers.get(veafAirWaves.Id):debug("AirWaveZone[%s]:destroyCurrentWave()", veaf.p(self.name))
  if self.spawnedGroupsNames then
    for _, _groupName in pairs(self.spawnedGroupsNames) do
      local _group = Group.getByName(_groupName)
      if _group then
        _group:destroy()
      end
    end
  end
  self.spawnedGroupsNames = {}
  return self
end



-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Utility methods
-------------------------------------------------------------------------------------------------------------------------------------------------------------

function veafAirWaves.add(aWaveZone, aName)
  local name = aName or aWaveZone:getName()
  veafAirWaves.zones[name] = aWaveZone
  return aWaveZone
end

function veafAirWaves.get(aNameString)
  return veafAirWaves.zones[aNameString]
end

veaf.loggers.get(veafAirWaves.Id):info(string.format("Loading version %s", veafAirWaves.Version))