------------------------------------------------------------------
-- VEAF Ground AI (a.k.a. Slightly Less Dumb Ground AI) for DCS World
-- By Zip (2024-25)
--
-- Features:
-- ---------
-- * DCS groups can be managed by the mission maker (API calls, radio menus) and by the pilots (radio menus, markers, remote commands)
--
-- See the documentation : https://veaf.github.io/documentation/mission-maker/groundAI.html
------------------------------------------------------------------

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Global settings. Stores the script constants
-------------------------------------------------------------------------------------------------------------------------------------------------------------

--- Identifier. All output in DCS.log will start with this.
veafGroundAI = {}

--- Identifier. All output in the log will start with this.
veafGroundAI.Id = "GROUNDAI - "

--- Version.
veafGroundAI.Version = "1.0.0"

-- trace level, specific to this module
--veafGroundAI.LogLevel = "trace"

--- Key phrase to look for in the mark text which triggers the spawn command.
veafGroundAI.MarkerKeyphrase = "_ground"

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Do not change anything below unless you know what you are doing!
-------------------------------------------------------------------------------------------------------------------------------------------------------------

veaf.loggers.new(veafGroundAI.Id, veafGroundAI.LogLevel)

veafGroundAI.handlers = {}

veafGroundAI.WATCHDOG_DELAY = 1

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- GroundUnitHandler class
-------------------------------------------------------------------------------------------------------------------------------------------------------------

GroundUnitHandler = {}
GroundUnitHandler.CLASS_NAME = "GroundUnitHandler"

GroundUnitHandler.DEFAULT_MESSAGE_STOP = "Ground unit %s has stopped executing and awaiting orders."
GroundUnitHandler.DEFAULT_MESSAGE_START = "Ground unit %s is executing or awaiting orders."

function GroundUnitHandler.init(object)
  -- technical name (GroundUnitHandler instance name)
  object.name = nil
  -- draw the position and orders of the unit on screen
  object.draw = false
  -- player units (only they are concerned by the messages)
  object.playerUnitsNames = {}
  -- DCS group
  object.dcsGroup = nil
  -- orders for the ground unit
  object.orders = {}
  -- index of the currently executed order
  object.currentOrderIndex = 1
  -- silent means no message is emitted
  object.silent = false
  -- the drawing objects that has been used to draw the situation
  object.zoneDrawings = {}
  -- the scheduled state of the :check() function
  object.checkFunctionSchedule = nil
  -- status, from one of the GroundUnitHandler.STATUS_xxx constants
  object.status = GroundUnitHandler.STATUS_READY
  -- message when the ground unit starts executing orders
  object.messageStart = GroundUnitHandler.DEFAULT_MESSAGE_START
  -- event when the ground unit starts executing orders
  object.onStart = nil
  -- message when the ground unit stops executing orders
  object.messageStop = GroundUnitHandler.DEFAULT_MESSAGE_STOP
  -- event when the ground unit stops executing orders
  object.onStop = nil
end

function GroundUnitHandler.statusToString(status)
  if status == GroundUnitHandler.STATUS_READY then return "STATUS_READY" end
  if status == GroundUnitHandler.STATUS_ACTIVE then return "STATUS_ACTIVE" end
  if status == GroundUnitHandler.STATUS_OVER then return "STATUS_OVER" end
  return ""
end

GroundUnitHandler.STATUS_READY = 1
GroundUnitHandler.STATUS_ACTIVE = 2
GroundUnitHandler.STATUS_OVER = 4

function GroundUnitHandler:new(objectToCopy)
  veaf.loggers.get(veafGroundAI.Id):debug(GroundUnitHandler.CLASS_NAME .. ":new()")
  local objectToCreate = objectToCopy or {} -- create object if user does not provide one
  setmetatable(objectToCreate, self)
  self.__index = self

  -- init the new object
  GroundUnitHandler.init(objectToCreate)

  return objectToCreate
end

-- technical name (GroundUnitHandler instance name)
function GroundUnitHandler:setName(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[]:setName(%s)", veaf.p(value))
  self.name = value
  return veafGroundAI.add(self) -- add the handler to the list as soon as a name is available to index it
end

-- technical name (GroundUnitHandler instance name)
function GroundUnitHandler:getName()
  return self.name or self.description
end

-- description for the messages
function GroundUnitHandler:getDescription()
  local result = self:getName()
  if self:getDcsGroup() then
    result = result .. " is handling DCS group " .. self:getDcsGroup():getName() .. ")"
  end
  return result
end

-- draw the position and orders of the unit on screen
function GroundUnitHandler:setDraw(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:setDraw(%s)", veaf.p(self:getName()), veaf.p(value))
  self.draw = value
  return self
end

-- draw the position and orders of the unit on screen
function GroundUnitHandler:getDraw()
  return self.draw
end

-- coalitions of the players (only human units from these coalitions will be monitored)
function GroundUnitHandler:setPlayerCoalitions(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:setPlayerCoalitions(%s)", veaf.p(self:getName()), veaf.p(value))
  self.playerCoalitions = value
  return self
end

-- player units (only they are concerned by the messages)
function GroundUnitHandler:setPlayerUnitsNames(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:setPlayerUnitsNames(%s)", veaf.p(self:getName()), veaf.p(value))
  self.playerUnitsNames = value
  return self
end

-- player units (only they are concerned by the messages)
function GroundUnitHandler:getPlayerUnitsNames()
  return self.playerUnitsNames
end

-- DCS group
function GroundUnitHandler:setDcsGroup(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:setDcsGroup(%s)", veaf.p(self:getName()), veaf.p(value))
  self.dcsGroup = value
  return self
end

-- DCS group
function GroundUnitHandler:getDcsGroup()
  return self.dcsGroup
end

-- current orders for the ground unit
function GroundUnitHandler:setOrders(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:setOrders(%s)", veaf.p(self:getName()), veaf.p(value))
  self.orders = value
  return self
end

-- orders for the ground unit
function GroundUnitHandler:addOrder(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:addOrder(%s)", veaf.p(self:getName()), veaf.p(value))
  if value then
    table.insert(self.orders, value)
  end
  return self
end

-- orders for the ground unit
function GroundUnitHandler:getOrders()
  return self.orders
end

-- orders for the ground unit
function GroundUnitHandler:clearOrders()
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:clearOrders()", veaf.p(self:getName()))
  self.orders = {}
  return self
end

-- get the current order
function GroundUnitHandler:getCurrentOrder()
  if self.orders then
    return self.orders[1]
  else
    return nil
  end
end

-- complete an order (pop it from the start of the list)
function GroundUnitHandler:completeOrder()
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:completeOrder()", veaf.p(self:getName()))
  if self.orders and #self.orders > 0 then
    table.remove(self.orders, 1)
  end
  return self
end

-- silent means no message is emitted
function GroundUnitHandler:setSilent(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:setSilent(%s)", veaf.p(self:getName()), veaf.p(value))
  self.silent = value
  return self
end

-- silent means no message is emitted
function GroundUnitHandler:getSilent()
  return self.silent
end

-- the drawing objects that has been used to draw the situation
function GroundUnitHandler:setZoneDrawings(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:setZoneDrawings(%s)", veaf.p(self:getName()), veaf.p(value))
  self.zoneDrawings = value
  return self
end

-- the drawing objects that has been used to draw the situation
function GroundUnitHandler:getZoneDrawings()
  return self.zoneDrawings
end

-- the scheduled state of the :check() function
function GroundUnitHandler:setCheckFunctionSchedule(value)
  --veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME.."[%s]:setCheckFunctionSchedule(%s)", veaf.p(self:getName()), veaf.p(value))
  self.checkFunctionSchedule = value
  return self
end

-- the scheduled state of the :check() function
function GroundUnitHandler:getCheckFunctionSchedule()
  return self.checkFunctionSchedule
end

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- METHODS

function GroundUnitHandler:handleOrder(order)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:handleOrder(%s)", veaf.p(self:getName()),veaf.p(order))
  -- do nothing clever, all is done in the inheriting classes
  self:completeOrder()
end

function GroundUnitHandler:check()
  --veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME.."[%s]:check()", veaf.p(self:getName()))

  -- consider the orders in the orders list
  local currentOrder = self:getCurrentOrder()
  if currentOrder then
    -- do something with the order
    self:handleOrder(currentOrder)
  end

  -- reschedule the check function
  self:setCheckFunctionSchedule(mist.scheduleFunction(GroundUnitHandler.check, { self },
    timer.getTime() + veafGroundAI.WATCHDOG_DELAY))
end

function GroundUnitHandler:start()
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:start()", veaf.p(self:getName()))
  self.status = GroundUnitHandler.STATUS_ACTIVE
  if not self.silent then
    trigger.action.outText(string.format(self.messageStart, self:getName()), 10)
  end
  if self.onStart then
    self.onStart(self)
  end
  self:check()
end

function GroundUnitHandler:stop()
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:stop()", veaf.p(self:getName()))
  self.status = GroundUnitHandler.STATUS_READY
  if not self.silent then
    trigger.action.outText(string.format(self.messageStop, self:getName()), 10)
  end
  if self.onStop then
    self.onStop(self)
  end
  if self.checkFunctionSchedule then
    mist.removeFunction(self.checkFunctionSchedule)
    self.checkFunctionSchedule = nil
  end
  if self:getCheckFunctionSchedule() then
    mist.removeFunction(self:getCheckFunctionSchedule())
    self:setCheckFunctionSchedule(nil)
  end
end

function GroundUnitHandler:orderTextAnalysis(value)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:orderTextAnalysis(%s)", veaf.p(self:getName()), veaf.p(value))
  -- do nothing clever, all is done in the inheriting classes
end

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- ArtilleryUnitHandler class
-------------------------------------------------------------------------------------------------------------------------------------------------------------

ArtilleryUnitHandler = GroundUnitHandler:new()
ArtilleryUnitHandler.CLASS_NAME = "ArtilleryUnitHandler"

-- fire for aim constants
ArtilleryUnitHandler.FIREFORAIM_SHELLS = 2
ArtilleryUnitHandler.FIREFORAIM_RADIUS = 10

-- fire for effect constants
ArtilleryUnitHandler.FIREFOREFFECT_SHELLS = 40
ArtilleryUnitHandler.FIREFOREFFECT_RADIUS = 100

ArtilleryUnitHandler.ORDER_STOP = 0
ArtilleryUnitHandler.ORDER_FIRE = 1
ArtilleryUnitHandler.ORDER_ADVANCE = 2

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- CTOR

function ArtilleryUnitHandler.init(object)
  -- status, from one of the ArtilleryUnitHandler.STATUS_xxx constants
  object.status = ArtilleryUnitHandler.STATUS_READY
end

function ArtilleryUnitHandler:new(objectToCopy)
  veaf.loggers.get(veafGroundAI.Id):debug(ArtilleryUnitHandler.CLASS_NAME .. ":new()")
  local objectToCreate = objectToCopy or {} -- create object if user does not provide one
  setmetatable(objectToCreate, self)
  self.__index = self

  -- init the new object
  ArtilleryUnitHandler.init(objectToCreate)

  return objectToCreate
end

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- PROPERTIES

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- COMPUTED PROPERTIES

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- METHODS

function ArtilleryUnitHandler:orderTextAnalysis(text)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:orderTextAnalysis(%s)", veaf.p(self:getName()), veaf.p(text))

  -- analyze the string for an acceptable order
  ArtilleryUnitHandler.VERB_FIRE_FORAIM = 1
  ArtilleryUnitHandler.VERB_FIRE_FOREFFECT = 2

  -- Option parameters extracted from the mark text.
  local options = {}
  options.verb = ArtilleryUnitHandler.VERB_FIRE_FORAIM -- can be "aim", "fire"
  options.target = nil -- the coordinates of the target
  options.shells = nil -- the number of shells to fire
  options.radius = nil -- the precision of the shelling

  -- Check for correct keywords.
  if text:lower():find("aim") then
    options.verb = ArtilleryUnitHandler.VERB_FIRE_FORAIM
  elseif text:lower():find("fire") then
    options.verb = ArtilleryUnitHandler.VERB_FIRE_FOREFFECT
  else
    return nil
  end

  -- keywords are split by ";"
  local keywords = veaf.split(text, ";")

  for _, keyphrase in pairs(keywords) do
    -- Split keyphrase by space. First one is the key and second, ... the parameter(s) until the next comma.
    local str = veaf.breakString(veaf.trim(keyphrase), " ")
    local key = str[1]
    local val = str[2] or ""

    if key:lower() == "target" then
      -- Set the target
      veaf.loggers.get(veafGroundAI.Id):trace("Keyword target = %s", veaf.p(val))
      if veaf.computeLLFromString(val) then -- check target string validity
        options.target = val
      end
    end

    if key:lower() == "shells" then
      -- Set the number of shells
      veaf.loggers.get(veafGroundAI.Id):trace("Keyword shells = %s", veaf.p(val))
      local nVal = veaf.getRandomizableNumeric(val)
      veaf.loggers.get(veafGroundAI.Id):trace("shells = %s", veaf.p(nVal))
      options.shells = nVal
    end

    if key:lower() == "radius" then
      -- Set the radius of the shelling
      veaf.loggers.get(veafGroundAI.Id):trace("Keyword radius = %s", veaf.p(val))
      local nVal = veaf.getRandomizableNumeric(val)
      veaf.loggers.get(veafGroundAI.Id):trace("radius = %s", veaf.p(nVal))
      options.radius = nVal
    end
  end

  if options.verb == ArtilleryUnitHandler.VERB_FIRE_FORAIM then
    self:fireForAim(options.target, options.shells, options.radius)
  elseif options.verb == ArtilleryUnitHandler.VERB_FIRE_FOREFFECT then
    self:fireForEffect(options.target, options.shells, options.radius)
  end

  return options
end

-- give the artillery unit a fire for effect order
function ArtilleryUnitHandler:fireForAim(coordinates, shells, radius)
  if not shells then
    shells = ArtilleryUnitHandler.FIREFORAIM_SHELLS
  end
  if not radius then
    radius = ArtilleryUnitHandler.FIREFORAIM_RADIUS
  end
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:fireForAim(%s, %s, %s)", veaf.p(self:getName()), veaf.p(coordinates), veaf.p(shells), veaf.p(radius))
  -- check the parameters
  if not coordinates then
    veaf.loggers.get(veafGroundAI.Id):warn(self.CLASS_NAME .. "[%s]:fireForAim() : no target coordinates", veaf.p(self:getName()))
    if not self.silent then
      local message = string.format("%s cannot aim, no target coordinates provided", veaf.p(self:getName()))
      trigger.action.outText(message, 10)
    end
    return
  end
  self:fireAtCoordinates(coordinates, shells, radius)
end

-- give the artillery unit a fire for effect order
function ArtilleryUnitHandler:fireForEffect(coordinates, shells, radius)
  if not shells then
    shells = ArtilleryUnitHandler.FIREFOREFFECT_SHELLS
  end
  if not radius then
    radius = ArtilleryUnitHandler.FIREFOREFFECT_RADIUS
  end
  if not coordinates then
    coordinates = self._lastTarget
  end
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:fireForEffect(%s, %s)", veaf.p(self:getName()), veaf.p(shells), veaf.p(radius))
  if not coordinates then
    veaf.loggers.get(veafGroundAI.Id):warn(self.CLASS_NAME .. "[%s]:fireForEffect() : no previous target - cannot fire for effect", veaf.p(self:getName()))
    if not self.silent then
      local message = string.format("%s cannot fire for effect, no target coordinates provided and no previous target exist", veaf.p(self:getName()))
      trigger.action.outText(message, 10)
    end
    return
  end
  self:fireAtCoordinates(coordinates, shells, radius)
end

-- give the artillery unit a fire order
function ArtilleryUnitHandler:fireAtCoordinates(coordinates, shells, radius)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:fireAtCoordinates(%d, %s, %s)", veaf.p(self:getName()), veaf.p(shells), veaf.p(coordinates), veaf.p(radius))
  -- check the parameters
  if not shells then
    veaf.loggers.get(veafGroundAI.Id):warn(self.CLASS_NAME .. "[%s]:fireAtCoordinates() : shells is nil", veaf.p(self:getName()))
    return
  end
  if not coordinates then
    veaf.loggers.get(veafGroundAI.Id):warn(self.CLASS_NAME .. "[%s]:fireAtCoordinates() : coordinates is nil", veaf.p(self:getName()))
    return
  end
  if not radius then
    radius = ArtilleryUnitHandler.DEFAULT_FIRE_RADIUS
  end
  -- check if these are coordinates
  local target = nil
  if type(coordinates) == "table" then
    target = coordinates
  elseif type(coordinates) == "string" then
    local _lat, _lon = veaf.computeLLFromString(coordinates)
    veaf.loggers.get(veafGroundAI.Id):trace(string.format("_lat=%s", veaf.p(_lat)))
    veaf.loggers.get(veafGroundAI.Id):trace(string.format("_lon=%s", veaf.p(_lon)))
    if _lat and _lon then
      target = coord.LLtoLO(_lat, _lon)
    else
      veaf.loggers.get(veafGroundAI.Id):warn(self.CLASS_NAME .. "[%s]:fireAtCoordinates() : coordinates are not valid: %s", veaf.p(self:getName()), veaf.p(coordinates))
    end
  end
  local order = { verb = ArtilleryUnitHandler.ORDER_FIRE, parameters = { shells = shells, target = target, radius = radius } }
  self:addOrder(order)
end

function ArtilleryUnitHandler:handleOrder(order)
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:handleOrder(%s)", veaf.p(self:getName()), veaf.p(order))
  if order.verb == ArtilleryUnitHandler.ORDER_FIRE then
    -- fire at the target
    local shells = order.parameters.shells
    local target = order.parameters.target
    local radius = order.parameters.radius
    if not target then
      veaf.loggers.get(veafGroundAI.Id):warn(self.CLASS_NAME .. "[%s]:handleOrder() : no target", veaf.p(self:getName()))
    else
      -- convert the target coordinates to UTM for the message
      local lat, lon, _ = coord.LOtoLL(target)
      local grid = coord.LLtoMGRS(lat, lon)
      local coordinates = grid.UTMZone .. ' ' .. grid.MGRSDigraph .. ' ' .. grid.Easting .. ' ' .. grid.Northing
      local message = string.format("%s is firing %d shells at %s with a %s m dispersion", veaf.p(self:getName()), veaf.p(shells), veaf.p(coordinates), veaf.p(radius))
      trigger.action.outText(message, 10)
      veaf.loggers.get(veafGroundAI.Id):trace("ArtilleryUnitHandler[%s]:handleOrder() : firing %d shells at %s with a %s m dispersion", veaf.p(self:getName()), veaf.p(shells), veaf.p(coordinates), veaf.p(radius))
      -- fire the shells
      local fireParams = {
        x = target.x,
        y = target.z,
        zoneRadius = radius,
        expendQty = shells,
        expendQtyEnabled = true,
        counterbattaryRadius = 500,
      }
      local fire = { id = 'FireAtPoint', params = fireParams }
      self:getDcsGroup():getController():pushTask(fire)
      self._lastTarget = target
    end
  end
  self:completeOrder()
end

function ArtilleryUnitHandler:stop()
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:stop()", veaf.p(self:getName()))
  -- clear the group's orders queue
  self:getDcsGroup():getController():resetTask()
  return GroundUnitHandler.stop(self)
end

function ArtilleryUnitHandler:clearOrders()
  veaf.loggers.get(veafGroundAI.Id):debug(self.CLASS_NAME .. "[%s]:clearOrders()", veaf.p(self:getName()))
  -- clear the group's orders queue
  self:getDcsGroup():getController():resetTask()
  return GroundUnitHandler.clearOrders(self)
end

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Event handler functions.
-------------------------------------------------------------------------------------------------------------------------------------------------------------

--- Function executed when a mark has changed. This happens when text is entered or changed.
function veafGroundAI.onEventMarkChange(eventPos, event)
  -- choose by default the coalition of the player who triggered the event
  local coa = coalition.side.BLUE
  if event.coalition == coalition.side.RED then
    coa = coalition.side.RED
  end

  veaf.loggers.get(veafGroundAI.Id):trace(string.format("event.idx  = %s", veaf.p(event.idx)))

  if veafGroundAI.executeCommand(eventPos, event.text, coa, event.idx) then
    -- Delete old mark.
    veaf.loggers.get(veafGroundAI.Id):trace(string.format("Removing mark # %d.", event.idx))
    trigger.action.removeMark(event.idx)
  end
end

function veafGroundAI.executeCommand(eventPos, eventText, eventCoalition, markId, bypassSecurity, spawnedGroups, route)
  veaf.loggers.get(veafGroundAI.Id):debug(string.format("veafGroundAI.executeCommand(eventText=[%s])", eventText))

  -- Check if marker has a text and contains an alias
  if eventText ~= nil then
    -- Analyse the mark point text and extract the keywords.
    local options = veafGroundAI.markTextAnalysis(eventPos, eventCoalition, eventText)
    veaf.loggers.get(veafGroundAI.Id):trace(string.format("options = %s", veaf.p(options)))

    if options then
      -- do the magic
      if options.verb == veafGroundAI.VERB_SET then
        veaf.loggers.get(veafGroundAI.Id):trace("options.verb == veafGroundAI.VERB_SET")
        local handlerName = options.name
        local group = options.group
        if group and handlerName then
          veaf.loggers.get(veafGroundAI.Id):trace("group = %s", veaf.p(group))
          local handler = veafGroundAI.get(handlerName)
          if not handler then
            handler = ArtilleryUnitHandler:new():setName(handlerName)
          end
          if handler then
            handler:setDcsGroup(group)
            handler:start()
            return true
          end
        end
      elseif options.verb == veafGroundAI.VERB_UNSET then
        veaf.loggers.get(veafGroundAI.Id):trace("options.verb == veafGroundAI.VERB_UNSET")
        local handlerName = options.name
        local handler = veafGroundAI.get(handlerName)
        if handler then
          handler:stop()
          veafGroundAI.remove(handler)
          return true
        end
      elseif options.verb == veafGroundAI.VERB_START then
        veaf.loggers.get(veafGroundAI.Id):trace("options.verb == veafGroundAI.VERB_START")
        local handlerName = options.name
        local handler = veafGroundAI.get(handlerName)
        if handler then
          handler:start()
          return true
        end
      elseif options.verb == veafGroundAI.VERB_STOP then
        veaf.loggers.get(veafGroundAI.Id):trace("options.verb == veafGroundAI.VERB_STOP")
        local handlerName = options.name
        local handler = veafGroundAI.get(handlerName)
        if handler then
          handler:stop()
          return true
        end
      elseif options.verb == veafGroundAI.VERB_CLEAR then
        veaf.loggers.get(veafGroundAI.Id):trace("options.verb == veafGroundAI.VERB_CLEAR")
        local handlerName = options.name
        local handler = veafGroundAI.get(handlerName)
        if handler then
          handler:stop()
          handler:clearOrders()
          return true
        end
      elseif options.verb == veafGroundAI.VERB_STATUS then
        veaf.loggers.get(veafGroundAI.Id):trace("options.verb == veafGroundAI.VERB_STATUS")
        local handlerName = options.name
        local handler = veafGroundAI.get(handlerName)
        if handler then
          trigger.action.outText(string.format("AI handler %s: %s", handlerName, handler:getDescription()), 10)
          return true
        end
      elseif options.verb == veafGroundAI.VERB_ORDER then
        veaf.loggers.get(veafGroundAI.Id):trace("options.verb == veafGroundAI.VERB_ORDER")
        local handlerName = options.name
        local handler = veafGroundAI.get(handlerName)
        if handler then
          if handler:orderTextAnalysis(options.order) then
            return true
          end
        end
      end
    end
  end

  -- None of the keywords matched.
  return false
end

--- Extract keywords from mark text.
function veafGroundAI.markTextAnalysis(eventPos, eventCoalition, text)
  veaf.loggers.get(veafGroundAI.Id):trace("veafGroundAI.markTextAnalysis(text=%s)", veaf.p(text))

  veafGroundAI.VERB_SET = 1
  veafGroundAI.VERB_UNSET = 2
  veafGroundAI.VERB_ORDER = 3
  veafGroundAI.VERB_START = 4
  veafGroundAI.VERB_STOP = 5
  veafGroundAI.VERB_CLEAR = 6
  veafGroundAI.VERB_STATUS = 7

  -- Option parameters extracted from the mark text.
  local options = {}
  options.verb = veafGroundAI.VERB_SET -- can be "set", "unset", "order", "start", "stop", "status"
  options.group = nil                  -- the DCS group that is concerned by "set" and "unset" verbs
  options.order = nil                  -- the order that is given by "order" verb
  options.name = nil                   -- the name of the handler that is concerned by all verbs

  -- Check for correct keywords.
  if text:lower():find(veafGroundAI.MarkerKeyphrase .. " set") then
    options.verb = veafGroundAI.VERB_SET
  elseif text:lower():find(veafGroundAI.MarkerKeyphrase .. " unset") then
    options.verb = veafGroundAI.VERB_UNSET
  elseif text:lower():find(veafGroundAI.MarkerKeyphrase .. " order") then
    options.verb = veafGroundAI.VERB_ORDER
  elseif text:lower():find(veafGroundAI.MarkerKeyphrase .. " start") then
    options.verb = veafGroundAI.VERB_START
  elseif text:lower():find(veafGroundAI.MarkerKeyphrase .. " stop") then
    options.verb = veafGroundAI.VERB_STOP
  elseif text:lower():find(veafGroundAI.MarkerKeyphrase .. " clear") then
    options.verb = veafGroundAI.VERB_CLEAR
  elseif text:lower():find(veafGroundAI.MarkerKeyphrase .. " status") then
    options.verb = veafGroundAI.VERB_STATUS
  else
    return nil
  end

  -- keywords are split by ","
  local keywords = veaf.split(text, ",")

  for _, keyphrase in pairs(keywords) do
    -- Split keyphrase by space. First one is the key and second, ... the parameter(s) until the next comma.
    local str = veaf.breakString(veaf.trim(keyphrase), " ")
    local key = str[1]
    local val = str[2] or ""

    if key:lower() == "groupname" then
      -- Set dcs group name.
      veaf.loggers.get(veafGroundAI.Id):trace("Keyword groupname = %s", veaf.p(val))
      -- search for the DCS group
      options.group = Group.getByName(val)
    end

    if key:lower() == "name" then
      -- Set AI handler name.
      veaf.loggers.get(veafGroundAI.Id):trace("Keyword name = %s", veaf.p(val))
      options.name = val
    end

    if key:lower() == "order" then
      -- Set order
      veaf.loggers.get(veafGroundAI.Id):trace("Keyword order = %s", veaf.p(val))
      options.order = val
    end
  end

  -- check mandatory parameter "name" for all commands
  if not (options.name) then return nil end

  -- check mandatory parameter "groupname" for commands "set" and "unset"
  if ((options.verb == veafGroundAI.VERB_SET or options.verb == veafGroundAI.VERB_UNSET) and not (options.group)) then
    -- search for the nearest allied group
    local minDist = 999999
    local closestUnit = nil
    for _, unit in pairs(veaf.getUnitsOfCoalition(false, eventCoalition)) do
      local pos = unit:getPosition().p
      if pos then
        local name = unit:getName()
        local distanceFromCenter = ((pos.x - eventPos.x) ^ 2 + (pos.z - eventPos.z) ^ 2) ^ 0.5
        veaf.loggers.get(veaf.Id):trace("name=%s; distanceFromCenter=%s", veaf.p(name), veaf.p(distanceFromCenter))
        if distanceFromCenter <= 250 then
          if distanceFromCenter < minDist then
            minDist = distanceFromCenter
            closestUnit = unit
          end
        end
      end
    end
    if closestUnit then
      options.group = closestUnit:getGroup()
    else
      return nil
    end
  end

  return options
end

-------------------------------------------------------------------------------------------------------------------------------------------------------------
--- Global functions for the module
-------------------------------------------------------------------------------------------------------------------------------------------------------------

function veafGroundAI.add(handler)
  veaf.loggers.get(veafGroundAI.Id):debug("veafGroundAI.add([%s])", veaf.p(handler:getName()))
  veafGroundAI.handlers[handler:getName():lower()] = handler
  return handler
end

function veafGroundAI.remove(handler)
  veaf.loggers.get(veafGroundAI.Id):debug("veafGroundAI.remove([%s])", veaf.p(handler:getName()))
  veafGroundAI.handlers[handler:getName():lower()] = nil
end

function veafGroundAI.get(handlerName)
  veaf.loggers.get(veafGroundAI.Id):debug("veafGroundAI.get([%s])", veaf.p(handlerName))
  local handler = veafGroundAI.handlers[handlerName:lower()]
  if handler then
    veaf.loggers.get(veafGroundAI.Id):trace("handler found: %s", veaf.p(handler))
  end
  return handler
end

function veafGroundAI.initialize()
  veaf.loggers.get(veafGroundAI.Id):info("Initializing module")
  veafMarkers.registerEventHandler(veafMarkers.MarkerChange, veafGroundAI.onEventMarkChange)
end
