--[[
===============================================================================
  SOF_Insert.lua  •  Version 2.9.5
  Purpose: Marker-driven SF insert/extract with on-demand Recce, SRS, persistence
           + Smart F10 Menu (only show deployed LZs, smart Abort submenu)

  v2.9.3 — F10 menu made dynamic and filtered
    • "SF Teams" shows only deployed LZs (or "No SF Teams deployed").
    • "Abort Deployment" shows only active insertions (or "No active insertions").
    • Menu rebuilds automatically when state changes (insert, extract, abort, kill).
    • Kept your original builder as buildSFMenu_Legacy() for instant revert.

  v2.9.2 — Bearing/Distance made consistent everywhere
    • Bearings from COORDINATE:HeadingTo()  (0°=N, CW).
    • Distances use COORDINATE:Get2DDistance().
    • Fixed Recce list distance formatting.

  v2.9.1 — JTAC as hidden infantry, passive; bug fix
    • JTAC spawns hidden/passive/immortal. 200 m flare fix.
	
	### Do not put anything before the intial SF in the .lua file name it breaks the persistance load ###
	### SF_Teams_v2.9.5_Static_Menu.lua works 
	### This will cause it to fail 	14_SF_Teams_v2.9.5_Static_Menu.lua
===============================================================================
]]

_SETTINGS:SetPlayerMenuOff()

-- ===== Script Identity / Banners ============================================
local SCRIPT_NAME, SCRIPT_VERSION = "SOF_Insert.lua", "2.9.3"
local function banner(msg,t) trigger.action.outText(msg, t or 10) end
local function log(msg) if env and env.info then env.info(("[%s v%s] %s"):format(SCRIPT_NAME,SCRIPT_VERSION,msg)) end end
local SHOW_LOAD_BANNER = false
local function printHeader() if SHOW_LOAD_BANNER then banner("Loaded • "..os.date("%Y-%m-%d %H:%M:%S"),12) end end

-- Debug / visual cue options
local DEBUG_FX = false
local ILLUM_FALLBACK_ENABLED = false

-- ===== Config ===============================================================

-- Insert helos (ME Late Activated)
local LZ_TEMPLATES = { LZ1="SF_HELO_LZ1", LZ2="SF_HELO_LZ2", LZ3="SF_HELO_LZ3", LZ4="SF_HELO_LZ4", LZ5="SF_HELO_LZ5" }
-- Extraction helos (ME Late Activated)
local REC_TEMPLATES= { LZ1="SF_REC_LZ1",  LZ2="SF_REC_LZ2",  LZ3="SF_REC_LZ3",  LZ4="SF_REC_LZ4",  LZ5="SF_REC_LZ5" }

local AIRBASE_NAME    = "Batumi"
local SOF_GROUPNAME   = "SF_TEAM"
local SOF_TYPE        = "Soldier M4"
local SOF_COUNT       = 8
local ENEMY_SIDE      = coalition.side.RED
local REPORT_RADIUS_M = 5000
local LZ_HOLD_S       = 25
local CRUISE_KTS      = 90
local RTB_KTS         = 110
local MAX_LZ_INDEX    = 5

-- Insert landing + spawn-ahead
local SPAWN_DELAY_AFTER_LAND_S = 15
local INS_APPROACH_SLOW_M   = 1200
local INS_FINAL_KTS         = 40
local INS_LAND_PUSH_DELAY_S = 8
local INS_LZ_NEAR_M         = 300
local INS_SPAWN_OFFSET_M    = 25

-- Extraction flow
local REC_APPROACH_SLOW_M   = 1200
local REC_FINAL_KTS         = 40
local REC_LZ_NEAR_M         = 300
local REC_LAND_PUSH_DELAY_S = 8
local REC_BOARD_DELAY_SEC   = 15
local REC_GROUND_WAIT_SEC   = 10
local REC_COLD_START_DELAY  = 10
local REC_LZ_OFFSET_M       = 15
local REC_LZ_OFFSET_BRG     = 90

-- Extraction cues
local EXTRACT_SMOKE_TRIGGER_M = 1000
local EXTRACT_SMOKE_OFFSET_M  = 150
local EXTRACT_SMOKE_OFFSET_BRG= 0
local EXTRACT_SMOKE_COLOR     = (trigger and trigger.smokeColor and trigger.smokeColor.Blue) or 4
local FLARE_ALT_AGL_M         = 300
local FLARE_COLOR_RED         = (trigger and trigger.flareColor and trigger.flareColor.Red) or 1

-- Auto-despawn on base land
local DESPAWN_AFTER_LAND_SEC = 300
local RTB_TOUCH_NEAR_M       = 600

-- Recce / monitors
local RECCE_MONITOR_PERIOD_S    = 10
local AUTO_RECCE_AFTER_DEPLOY_S = 120

-- Threat proximity alerts
local THREAT_WARN_RADIUS_M      = 750
local THREAT_URGENT_RADIUS_M    = 500
local THREAT_WARN_REPEAT_SEC    = 600  -- 10 min

-- SF Map Markers (enabled)
local SF_MARKERS_ENABLED        = true
local SF_MARKER_OFFSET_M        = 999
local SF_MARKER_RANDOM_BEARING  = false
local SF_MARKER_OFFSET_BRG      = 270
local SF_MARKERS_FORCE_ALL      = true

-- Lasing
local LASER_ENABLED             = true
local LASER_CODE                = 1688
local LASER_RANGE_M             = 3000

-- ===== Require MOOSE ========================================================
if not (SPAWN and AIRBASE and COORDINATE and TIMER and SCHEDULER) then
  banner("ERROR: MOOSE not detected. Load MOOSE.lua BEFORE this script.", 20)
  return
end

-- ===== State / Helpers ======================================================
local active    = { LZ1=false, LZ2=false, LZ3=false, LZ4=false, LZ5=false }  -- insertion mid-flight flag
local activeRec = { LZ1=false, LZ2=false, LZ3=false, LZ4=false, LZ5=false }  -- extraction mid-flight flag
local spawners = {}; for code,tpl in pairs(LZ_TEMPLATES) do spawners[code]=SPAWN:New(tpl) end
local lastSOFByLZ = { LZ1=nil, LZ2=nil, LZ3=nil, LZ4=nil, LZ5=nil }
local lzCounters = {}; local function nextSuffix(code) lzCounters[code]=(lzCounters[code] or 0)+1; return lzCounters[code] end
local function vec2OfCoord(c) local v=c:GetVec2(); return {x=v.x, y=v.y} end
local function km(m) return (m or 0)/1000 end
local function isGroupAliveByName(n) local g=Group.getByName(n); if not g then return false end; for _,u in ipairs(g:getUnits() or {}) do if u and u.isExist and u:isExist() and u.getLife and u:getLife()>0 then return true end end; return false end
local function codeNum(code) local n=tostring(code):match("LZ(%d)"); return n or "?" end

-- Track spawned groups
local _insertHelosByLZ = { LZ1=nil, LZ2=nil, LZ3=nil, LZ4=nil, LZ5=nil }
local _recHelosByLZ    = { LZ1=nil, LZ2=nil, LZ3=nil, LZ4=nil, LZ5=nil }
local _insertAbortFlag = { LZ1=false, LZ2=false, LZ3=false, LZ4=false, LZ5=false }

-- Lasing state
local _jtacByLZ        = { LZ1=nil, LZ2=nil, LZ3=nil, LZ4=nil, LZ5=nil }
local _laseSchedByLZ   = { LZ1=nil, LZ2=nil, LZ3=nil, LZ4=nil, LZ5=nil }
local _laseTargetGID   = { LZ1=nil, LZ2=nil, LZ3=nil, LZ4=nil, LZ5=nil }

-- On-ground & slow
local function _isOnGroundAndSlow(mu)
  if not (mu and mu.IsAlive and mu:IsAlive()) then return false end
  local d=mu.GetDCSObject and mu:GetDCSObject() or nil; if not d then return false end
  local on=false
  if d.inAir then local ok,air=pcall(function() return d:inAir() end); on=ok and (air==false) end
  if not on then return false end
  local v=d.getVelocity and d:getVelocity() or nil; if not v then return on end
  local s=math.sqrt((v.x or 0)^2+(v.y or 0)^2+(v.z or 0)^2)
  return s < 10
end

---------------------------------------------------------------------
-- Bearing & distance helpers (via MOOSE COORDINATE)
---------------------------------------------------------------------
local function _bearingDeg(ax, az, bx, bz)
  local A = COORDINATE:New(ax or 0, 0, az or 0)
  local B = COORDINATE:New(bx or 0, 0, bz or 0)
  local hdg = A:HeadingTo(B) or 0           -- 0..360, 0 = North, clockwise
  return math.floor((hdg + 0.5) % 360)
end

local function _dist2D(ax, az, bx, bz)
  local A = COORDINATE:New(ax or 0, 0, az or 0)
  local B = COORDINATE:New(bx or 0, 0, bz or 0)
  return A:Get2DDistance(B) or 0            -- meters
end

-- Format helpers
local function _fmtKm(dMeters)
  local kmv = (dMeters or 0) / 1000
  return string.format("%.1f Km", kmv)
end
local function _pad3(n) return string.format("%03d", (n or 0) % 360) end

-- ===== SRS ================================================================
local SRS_ENABLED = true
local SRS_LABEL   = "SF RECCE"
local SRS_LABEL_RESTORE_DELAY_S = 0.5
local _srsSavedLabel, _srsRefCount = nil, 0
local SRS_WORD_HELO = "Heelo"
local function _getCsarSRS() if not mycsar then return nil end; return mycsar.MSRS or mycsar.msrs or mycsar._MSRS end
local function _srsSay(u, text)
  if not (SRS_ENABLED and u and mycsar and mycsar._DisplayMessageToSAR) then return end
  local srs = _getCsarSRS()
  if srs and srs.SetLabel then
    if _srsRefCount == 0 then
      _srsSavedLabel = "CSAR"
      if srs.GetLabel then pcall(function() _srsSavedLabel = srs:GetLabel() end) end
      pcall(function() srs:SetLabel(SRS_LABEL) end)
    end
    _srsRefCount = _srsRefCount + 1
  end
  mycsar:_DisplayMessageToSAR(u, text, 20, false, true, true)
  if srs and srs.SetLabel then
    TIMER:New(function()
      _srsRefCount = math.max(0, _srsRefCount - 1)
      if _srsRefCount == 0 and _srsSavedLabel then
        pcall(function() srs:SetLabel(_srsSavedLabel) end)
        _srsSavedLabel = nil
      end
    end):Start(SRS_LABEL_RESTORE_DELAY_S)
  end
end
local function _sayableCode(code) local n=tostring(code):match("LZ(%d)"); if not n then return code end; return "L Z "..n end

-- Digitized SRS fragments (for SITREP & Threat only)
local function _digits(n) n=tostring(n or ""):gsub("%s+",""); local out={} for c in n:gmatch(".") do table.insert(out,c) end; return table.concat(out,"-") end
local function _digits3(brg) return _digits(string.format("%03d", (tonumber(brg) or 0)%360)) end
local function _srsDistKm(meters)
  local kmv = (meters or 0)/1000
  local s = string.format("%.1f", kmv)
  s = s:gsub("^0","zero"):gsub("^1","one")
  s = s:gsub("%.", " point ")
  s = s:gsub("2","two"):gsub("3","three"):gsub("4","four"):gsub("5","five"):gsub("6","six"):gsub("7","seven"):gsub("8","eight"):gsub("9","nine")
  return s.." kilometers"
end

-- ===== SF MAP MARKERS (MOOSE MARKER-based) =================================
local _sfMarkers = {}           -- sofName -> { marker=MARKER, brg=deg }
local function _sfMarkerText(sofName) local code=tostring(sofName):match("_(LZ[1-5])_") or "LZ?"; return ("SF %s"):format(code) end

local function _makeMarkerFor(sofName, coord)
  if not SF_MARKERS_ENABLED then return end
  if not coord then return end
  local rec = _sfMarkers[sofName] or {}
  if SF_MARKER_RANDOM_BEARING and not rec.brg then rec.brg = math.random(0, 359) end
  local brg = rec.brg or SF_MARKER_OFFSET_BRG
  local mcoord = coord:Translate(SF_MARKER_OFFSET_M, brg)
  if rec.marker and rec.marker.Remove then pcall(function() rec.marker:Remove() end) end
  local mk = MARKER:New(mcoord, _sfMarkerText(sofName))
  if SF_MARKERS_FORCE_ALL then mk:ToAll() else mk:ToCoalition(coalition.side.BLUE) end
  rec.marker = mk
  _sfMarkers[sofName] = rec
end

local function _ensureSFMarker(sofName)
  if not SF_MARKERS_ENABLED then return end
  local g = Group.getByName(sofName); if not g then return end
  local u = g:getUnit(1); if not (u and u:isExist()) then return end
  local p = u:getPoint(); if not p then return end
  local baseC = COORDINATE:New(p.x, p.y or 0, p.z)
  _makeMarkerFor(sofName, baseC)
end

local function _removeSFMarkerFor(sofName)
  local rec=_sfMarkers[sofName]
  if rec and rec.marker and rec.marker.Remove then pcall(function() rec.marker:Remove() end) end
  _sfMarkers[sofName]=nil
end

local function _ensureSFMarkerWithRetries(name)
  if not SF_MARKERS_ENABLED then return end
  _ensureSFMarker(name)
  for i=1,4 do TIMER:New(function() _ensureSFMarker(name) end):Start(i*1.5) end
end

local function _startSFMarkerUpdater()
  if not SF_MARKERS_ENABLED then return end
  SCHEDULER:New(nil,function()
    for sofName,_ in pairs(_sfMarkers) do
      if isGroupAliveByName(sofName) then _ensureSFMarker(sofName) else _removeSFMarkerFor(sofName) end
    end
  end,{},2,5)
end

local function _reseedAllMarkers()
  if not SF_MARKERS_ENABLED then return end
  for _, name in pairs(lastSOFByLZ) do
    if name and isGroupAliveByName(name) then _ensureSFMarkerWithRetries(name) end
  end
end

-- ===== Persistence ==========================================================
-- Portable path: writes into the current user's Saved Games/DCS... folder.
local lfs = _G.lfs or nil  -- DCS usually exposes lfs globally; this keeps it explicit
local SAVE_PATH = ((lfs and lfs.writedir) and lfs.writedir() or "./")
                  .. "Missions/Saves/SOF_state.lua"

local _saveEnabled = false  -- set true after init; see _initOnce()

local function _serialize(v,indent)
  indent=indent or ""; local t=type(v)
  if t=="number" or t=="boolean" then return tostring(v)
  elseif t=="string" then return string.format("%q",v)
  elseif t=="table" then
    local ni=indent.."  "; local out={"{\n"}
    for k,val in pairs(v) do
      local key=(type(k)=="string" and k:match("^%a[%w_]*$")) and k or "[".._serialize(k,ni).."]"
      out[#out+1]=ni..key.." = ".._serialize(val,ni)..",\n"
    end
    out[#out+1]=indent.."}"; return table.concat(out)
  end
  return "nil"
end

local function _ensureDir(path)
  local dir=path:match("^(.*)[/\\][^/\\]+$"); if not dir then return end
  local seg=""
  for part in (dir.."/"):gmatch("([^/\\]+)/") do
    seg=(seg=="" and part) or (seg.."/"..part)
    if lfs and lfs.mkdir then pcall(lfs.mkdir, seg) end
  end
end

local function _collectTeamsState()
  local teams = {}
  for code,_ in pairs(lastSOFByLZ) do
    local name = lastSOFByLZ[code]
    if name and isGroupAliveByName(name) then
      local tg=Group.getByName(name)
      if tg then
        local u=tg:getUnit(1)
        if u and u:isExist() then
          local p=u:getPoint()
          if p then teams[#teams+1]={code=code,name=name,x=p.x,z=p.z} end
        end
      end
    end
  end
  return teams
end

local function _saveState()
  if not _saveEnabled then return end
  _ensureDir(SAVE_PATH)
  local f, err = io.open(SAVE_PATH, "w")
  if not f then
    log("SF persistence: FAILED to open save file: "..tostring(SAVE_PATH).." | "..tostring(err))
    banner("SF persistence: failed to write save (check folder/permissions).", 10)
    return
  end
  local data={ lzCounters=lzCounters, teams=_collectTeamsState() }
  f:write("return ", _serialize(data), "\n")
  f:close()
  log("SF state saved: "..tostring(#data.teams).." team(s) -> "..SAVE_PATH)
end

local function _spawnSOFAt(name,x,z)
  local data={ category=Group.Category.GROUND, country=country.id.USA, name=name, task={}, units={} }
  for i=1, SOF_COUNT do
    data.units[#data.units+1]={ name=name.."_"..i, type=SOF_TYPE, skill="Excellent", x=x+math.random(-10,10), y=z+math.random(-10,10), heading=0 }
  end
  coalition.addGroup(country.id.USA, Group.Category.GROUND, data)
end

local function _loadState()
  local f = io.open(SAVE_PATH,"r")
  if not f then
    log("SF persistence: no save file at "..tostring(SAVE_PATH))
    return
  end
  local chunk=f:read("*a"); f:close()
  if not (chunk and #chunk>0) then log("SF persistence: empty/corrupt file"); return end
  local okFn,fn = pcall(loadstring,chunk)
  if not (okFn and type(fn)=="function") then log("SF persistence: loadstring failed"); return end
  local okTbl,tbl = pcall(fn)
  if not (okTbl and type(tbl)=="table") then log("SF persistence: eval did not return table"); return end
  local data = tbl

  if type(data.lzCounters)=="table" then for k,v in pairs(data.lzCounters) do lzCounters[k]=v end end
  for k,_ in pairs(lastSOFByLZ) do lastSOFByLZ[k]=nil end

  local restored=0
  for _,t in ipairs(data.teams or {}) do
    if t.name and t.x and t.z and t.code then
      _spawnSOFAt(t.name,t.x,t.z)
      lastSOFByLZ[t.code]=t.name
      _ensureSFMarkerWithRetries(t.name)
      restored=restored+1
    end
  end
  if restored>0 and SHOW_LOAD_BANNER then
    banner(string.format("SF persistence restored %d team(s).", restored),8)
  end
  log("SF persistence: restored "..restored.." team(s) from "..SAVE_PATH)
end


-- ===== SF Team spawn + Recce ===============================================
local function spawnSOF(lz, name)
  local data={ category=Group.Category.GROUND, country=country.id.USA, name=name, task={}, units={} }
  for i=1, SOF_COUNT do
    data.units[#data.units+1] = {
      name=name.."_"..i, type=SOF_TYPE, skill="Excellent",
      x=lz.x + math.random(-10,10), y=lz.z + math.random(-10,10), heading=0,
    }
  end
  coalition.addGroup(country.id.USA, Group.Category.GROUND, data)
  trigger.action.outText("SF Team on the ground", 8)

  local code = tostring(name):match("_(LZ[1-5])_")
  if code then
    lastSOFByLZ[code] = name
    _saveState()
    _ensureSFMarkerWithRetries(name)
  end
end

local function _countTargetsInRange(sofName)
  local g=Group.getByName(sofName); if not g then return 0 end
  local u=g:getUnit(1); if not (u and u:isExist()) then return 0 end
  local up=u:getPoint(); if not up then return 0 end
  local count=0
  local A = COORDINATE:New(up.x, up.y or 0, up.z)
  for _,eg in ipairs(coalition.getGroups(ENEMY_SIDE, Group.Category.GROUND) or {}) do
    for _,eu in ipairs(eg:getUnits() or {}) do
      if eu and eu:isExist() then
        local ep=eu:getPoint()
        if ep then
          local d = A:Get2DDistance(COORDINATE:New(ep.x, ep.y or 0, ep.z))
          if d<=REPORT_RADIUS_M then count=count+1 end
        end
      end
    end
  end
  return count
end

-- nearest enemy (<=capMeters) and its group
local function _nearestEnemyUnit(sofName, capMeters)
  local cap = capMeters or REPORT_RADIUS_M
  local g=Group.getByName(sofName); if not g then return nil,nil,nil end
  local u=g:getUnit(1); if not (u and u:isExist()) then return nil,nil,nil end
  local up=u:getPoint(); if not up or not up.x or not up.z then return nil,nil,nil end
  local A = COORDINATE:New(up.x, up.y or 0, up.z)

  local bestD, bestU, bestG=nil,nil,nil
  for _,eg in ipairs(coalition.getGroups(ENEMY_SIDE, Group.Category.GROUND) or {}) do
    for _,eu in ipairs(eg:getUnits() or {}) do
      if eu and eu:isExist() and eu.getPoint then
        local ep=eu:getPoint()
        if ep and ep.x and ep.z then
          local d = A:Get2DDistance(COORDINATE:New(ep.x, ep.y or 0, ep.z))
          if d<=cap and (not bestD or d<bestD) then
            bestD, bestU, bestG = d, eu, eg
          end
        end
      end
    end
  end
  return bestU, bestG, bestD
end

-- Cleaned Recce output
local function runSOFRecceOnce(sofName)
  local g=Group.getByName(sofName); if not g then banner(("Recce: group not found: %s"):format(sofName),8); return end
  local u=g:getUnit(1); if not (u and u:isExist()) then banner(("Recce: team not present: %s"):format(sofName),8); return end
  local up=u:getPoint(); if not up then banner(("Recce: team not present: %s"):format(sofName),8); return end

  local A = COORDINATE:New(up.x, up.y or 0, up.z)
  local items={}
  for _,eg in ipairs(coalition.getGroups(ENEMY_SIDE, Group.Category.GROUND) or {}) do
    local gn=eg.getName and eg:getName() or "?"
    for _,eu in ipairs(eg:getUnits() or {}) do
      if eu and eu:isExist() and eu.getTypeName then
        local ep=eu:getPoint()
        if ep and ep.x and ep.z then
          local d = A:Get2DDistance(COORDINATE:New(ep.x, ep.y or 0, ep.z))
          if d<=REPORT_RADIUS_M then
            items[#items+1]=string.format("%s (%s) — %.1f Km", eu:getTypeName(), gn, km(d))
          end
        end
      end
    end
  end
  if #items>0 then
    trigger.action.outText(("SF Recce:\n%s"):format(table.concat(items,"\n")),12)
  else
    trigger.action.outText("SF Recce: Nothing in range.",8)
  end
end

local function recceForLZ(code)
  local n=lastSOFByLZ[code]; if not (n and isGroupAliveByName(n)) then banner(code..": No SF team recorded yet.",8); return end
  runSOFRecceOnce(n)
end

-- Recce monitors per LZ (state-change pings)
local _recceMon={}
local function stopRecceMonitor(code) local m=_recceMon[code]; if m and m.sched then m.sched:Stop() end; _recceMon[code]=nil end
local function startRecceMonitor(code)
  stopRecceMonitor(code)
  local lastHas=nil
  local sched=SCHEDULER:New(nil,function()
    local name=lastSOFByLZ[code]
    if not (name and isGroupAliveByName(name)) then stopRecceMonitor(code); return end
    local has=_countTargetsInRange(name)>0
    if lastHas==nil then lastHas=has; return end
    if has~=lastHas then
      if has then trigger.action.outText(("SF Recce %s New targets detected"):format(codeNum(code)),6)
      else trigger.action.outText("SF Recce No Targets",6) end
      lastHas=has
    end
  end,{},RECCE_MONITOR_PERIOD_S,RECCE_MONITOR_PERIOD_S)
  _recceMon[code]={sched=sched,lastHas=false}
end

-- ===== Threat proximity monitors (750m / 500m) =============================
local _threatMon={}
local function stopThreatMonitor(code) local m=_threatMon[code]; if m and m.sched then m.sched:Stop() end; _threatMon[code]=nil end
local function _threatSRS(u, meters, brg)
  local msg = string.format("Hostiles %s. Bearing %s. From my position.", _srsDistKm(meters), _digits3(brg))
  _srsSay(u, ("S F %s %s"):format(_sayableCode(code), msg))
end
local function startThreatMonitor(code)
  stopThreatMonitor(code)
  local state = { inside750=false, inside500=false, srs750Sent=false, srs500Sent=false, last750OnscreenAt=0 }
  local sched=SCHEDULER:New(nil,function()
    local sofName=lastSOFByLZ[code]
    if not (sofName and isGroupAliveByName(sofName)) then stopThreatMonitor(code); return end

    local uSpeak = nil
    if GROUP and GROUP.FindByName then
      local G = GROUP:FindByName(sofName)
      if G and G.IsAlive and G:IsAlive() then
        local U = G:GetUnit(1)
        if U and U.IsAlive and U:IsAlive() then uSpeak = U end
      end
    end

    local g=Group.getByName(sofName)
    local u=g and g:getUnit(1) or nil
    local up=u and u:getPoint() or nil
    local tgtU, tgtG, d = _nearestEnemyUnit(sofName, THREAT_WARN_RADIUS_M)
    local brg = 0
    if tgtU and up then
      local tp=tgtU:getPoint()
      if tp then brg=_bearingDeg(up.x, up.z, tp.x, tp.z) end
    end

    local now = timer.getTime and timer.getTime() or 0

    local inside750 = (d and d <= THREAT_WARN_RADIUS_M) or false
    if inside750 and not state.inside750 then
      local msg = ("SF Team %s: Hostiles at 750 meters of my location"):format(code)
      trigger.action.outText(msg.."\n"..("Dist %s, Brg %03d° From my position"):format(_fmtKm(d), brg), 8)
      if not state.srs750Sent then _threatSRS(uSpeak, d, brg); state.srs750Sent=true end
      state.last750OnscreenAt = now
    elseif inside750 and state.inside750 then
      if (now - (state.last750OnscreenAt or 0)) >= THREAT_WARN_REPEAT_SEC then
        local msg = ("SF Team %s: Hostiles at 750 meters of my location"):format(code)
        trigger.action.outText(msg.."\n"..("Dist %s, Brg %03d° From my position"):format(_fmtKm(d), brg), 8)
        state.last750OnscreenAt = now
      end
    elseif (not inside750) and state.inside750 then
      state.srs750Sent=false
      state.last750OnscreenAt=0
    end
    state.inside750 = inside750

    local inside500 = (d and d <= THREAT_URGENT_RADIUS_M) or false
    if inside500 and not state.inside500 then
      local msg2 = ("SF Team %s: Request Immediate Extraction!"):format(code)
      trigger.action.outText(msg2.."\n"..("Dist %s, Brg %03d° From my position"):format(_fmtKm(d), brg), 10)
      if not state.srs500Sent then _threatSRS(uSpeak, d, brg); state.srs500Sent=true end
    elseif (not inside500) and state.inside500 then
      state.srs500Sent=false
    end
    state.inside500 = inside500

  end,{},RECCE_MONITOR_PERIOD_S,RECCE_MONITOR_PERIOD_S)
  _threatMon[code]={sched=sched}
end

-- ===== RTB completion + auto-despawn ======================================
local function watchForMissionEnd(code, heloGroup, baseObj)
  local baseC=baseObj and baseObj.GetCoordinate and baseObj:GetCoordinate() or nil
  local t0=timer.getTime and timer.getTime() or 0
  timer.scheduleFunction(function()
    if not heloGroup or not heloGroup.IsAlive or not heloGroup:IsAlive() then active[code]=false; return end
    if baseC then
      local here=heloGroup:GetCoordinate()
      if here and here.Get2DDistance and here:Get2DDistance(baseC) < 400 and ((timer.getTime() or 0)-t0) > 60 then
        active[code]=false; return
      end
    end
    return (timer.getTime() or 0)+10
  end,{},(timer.getTime() or 0)+10)
end

local function _autoDespawnAfterLanding(heloGroup, baseC, delaySec)
  if not (heloGroup and baseC) then return end
  local armed=false
  local sch; sch=SCHEDULER:New(nil,function()
    if not heloGroup or not heloGroup.IsAlive or not heloGroup:IsAlive() then sch:Stop(); return end
    local here=heloGroup:GetCoordinate()
    if here and here.Get2DDistance and here:Get2DDistance(baseC) <= RTB_TOUCH_NEAR_M then
      local u=heloGroup:GetUnit(1)
      if u and _isOnGroundAndSlow(u) then
        if not armed then
          armed=true
          TIMER:New(function() if heloGroup:IsAlive() then pcall(function() heloGroup:Destroy() end) end end):Start(delaySec or DESPAWN_AFTER_LAND_SEC)
          sch:Stop()
        end
      end
    end
  end,{},2,2)
end

-- ===== INSERTION (land → spawn ahead → RTB) ================================
local function launchSOF(lz, code)
  do
    local existing = lastSOFByLZ[code]
    if existing and isGroupAliveByName(existing) then
      trigger.action.outText(code.." already Deployed", 8)
      return
    end
  end
  if active[code] then banner(code.." already active; ignoring new marker.",6); return end
  local sp=spawners[code]; if not sp then banner("No spawner for "..code.." (template missing?)",15); return end

  local g=sp:Spawn(); if not g then banner(("Spawn failed for %s (template '%s')"):format(code, tostring(LZ_TEMPLATES[code])),15); return end
  active[code]=true
  _insertHelosByLZ[code]=g

  local sofName=("%s_%s_%02d"):format(SOF_GROUPNAME, code, nextSuffix(code))
  lastSOFByLZ[code]=sofName

  trigger.action.outText(("SF Helo Online from %s"):format(AIRBASE_NAME), 8)
  local uSpeak = g:GetUnit(1)
  _srsSay(uSpeak, ("S F "..SRS_WORD_HELO.." Online for Insertion Task from %s."):format(AIRBASE_NAME))
  _srsSay(uSpeak, ("S F "..SRS_WORD_HELO.." %s lifting %s."):format(_sayableCode(code), AIRBASE_NAME))

  local lzCoord = COORDINATE:New(lz.x, 0, lz.z)
  local lzLocked, spawned = false, false

  local watch; watch = SCHEDULER:New(nil, function()
    if not g or not g.IsAlive or not g:IsAlive() then active[code]=false; _insertHelosByLZ[code]=nil; watch:Stop(); return end

    if _insertAbortFlag[code] then
      _insertAbortFlag[code] = false
      local base = AIRBASE:FindByName(AIRBASE_NAME) or AIRBASE:FindClosestAirbase(g:GetCoordinate())
      if base then
        local bc = base:GetCoordinate()
        g:RouteToVec2(vec2OfCoord(bc), RTB_KTS, "Vee")
        watchForMissionEnd(code, g, base)
        _autoDespawnAfterLanding(g, bc, DESPAWN_AFTER_LAND_SEC)
      end
      trigger.action.outText(("SF Helo %s: Insertion aborted, returning to base."):format(code), 10)
      _srsSay(uSpeak, ("S F "..SRS_WORD_HELO.." %s insertion aborted, returning to base."):format(_sayableCode(code)))
      active[code]=false
      _insertHelosByLZ[code]=nil
      watch:Stop()
      return
    end

    local here = g:GetCoordinate()
    local dist = here and here.Get2DDistance and here:Get2DDistance(lzCoord) or 1e9

    if not lzLocked then
      g:RouteToVec2(lzCoord:GetVec2(), CRUISE_KTS, "Vee")
      if dist <= INS_APPROACH_SLOW_M then
        lzLocked = true
        trigger.action.outText(("SF Helo Landing at %s"):format(code), 8)
        pcall(function() g:SetAltitude(lzCoord:GetLandHeight() + 15, true) end)
        g:RouteToVec2(lzCoord:GetVec2(), INS_FINAL_KTS, "Vee")
        TIMER:New(function()
          if not g or not g.IsAlive or not g:IsAlive() then return end
          local DG=g:GetDCSObject()
          if DG and DG.getController then
            local ctrl=DG:getController()
            if ctrl then
              local v2 = lzCoord:GetVec2()
              pcall(function() ctrl:pushTask({ id='Land', params={ point={ x=v2.x, y=v2.y }, duration=LZ_HOLD_S } }) end)
            end
          end
        end):Start(INS_LAND_PUSH_DELAY_S)
      end
      return
    end

    local u1 = g:GetUnit(1)
    local near = (dist <= INS_LZ_NEAR_M)

    if (not spawned) and u1 and _isOnGroundAndSlow(u1) and near then
      spawned = true
      TIMER:New(function()
        local hdg = 0
        pcall(function() hdg = (u1.GetHeading and u1:GetHeading()) or (g.GetHeading and g:GetHeading()) or 0 end)
        local spawnC = lzCoord:Translate(INS_SPAWN_OFFSET_M, hdg)
        local v2s = spawnC:GetVec2()
        spawnSOF({ x=v2s.x, z=v2s.y }, sofName)

        _srsSay(u1, ("S F Recce %s inserted."):format(_sayableCode(code)))

        TIMER:New(function() runSOFRecceOnce(sofName) end):Start(AUTO_RECCE_AFTER_DEPLOY_S)
        startRecceMonitor(code)
        startThreatMonitor(code)

        trigger.action.outText(("SF Helo RTB %s"):format(AIRBASE_NAME), 8)
        local base = AIRBASE:FindByName(AIRBASE_NAME) or AIRBASE:FindClosestAirbase(g:GetCoordinate())
        if base then
          local bc = base:GetCoordinate()
          g:RouteToVec2(vec2OfCoord(bc), RTB_KTS, "Vee")
          watchForMissionEnd(code, g, base)
          _autoDespawnAfterLanding(g, bc, DESPAWN_AFTER_LAND_SEC)
        else
          banner(code.." RTB airbase not found.", 10); active[code]=false
        end
        _insertHelosByLZ[code]=nil
        watch:Stop()
      end):Start(SPAWN_DELAY_AFTER_LAND_S)
    end
  end, {}, 1, 1)
end

-- ===== Extraction (smoke + robust flares) ==================================
local function _fireFlareAt(coord, color, alt_agl)
  local pG = coord:GetVec3()
  pG.y = (coord:GetLandHeight() or 0) + 1
  local col = color or FLARE_COLOR_RED
  pcall(function() trigger.action.signalFlare(pG, col, 0) end)
  if ILLUM_FALLBACK_ENABLED then
    local pA = coord:GetVec3()
    pA.y = (coord:GetLandHeight() or 0) + (alt_agl or FLARE_ALT_AGL_M)
    pcall(function() trigger.action.illuminationBomb(pA, 300) end)
  end
end

local function startRecovery(code)
  if activeRec[code] then banner(code.." recovery already in progress.",8); return end
  local sofName=lastSOFByLZ[code]; if not (sofName and isGroupAliveByName(sofName)) then banner(code..": No SF team recorded yet.",8); return end
  local tpl=REC_TEMPLATES[code]; if not tpl then banner("No recovery template for "..code,12); return end

  local spn=SPAWN:New(tpl):InitUnControlled(true); local g=spn:Spawn()
  if not g then banner(("Recovery spawn failed for %s (template '%s')"):format(code,tpl),12); return end

  _recHelosByLZ[code] = g
  activeRec[code]=true
  trigger.action.outText(("SF Recovery Helo Online from %s"):format(AIRBASE_NAME),8)
  _srsSay(g:GetUnit(1), ("S F "..SRS_WORD_HELO.." Online for Extraction from %s."):format(AIRBASE_NAME))

  TIMER:New(function()
    if not g or not g.IsAlive or not g:IsAlive() then activeRec[code]=false; _recHelosByLZ[code]=nil; return end
    g:StartUncontrolled()

    local lzCoord=nil
    local boarded=false
    local fivekmWarned=false
    local smokeDone=false
    local flare1kmDone=false
    local flare200mDone=false

    local watch; watch=SCHEDULER:New(nil,function()
      if not g or not g.IsAlive or not g:IsAlive() then activeRec[code]=false; _recHelosByLZ[code]=nil; watch:Stop(); return end

      local tg=Group.getByName(sofName); if not tg or tg:getSize()==0 then activeRec[code]=false; _recHelosByLZ[code]=nil; watch:Stop(); return end
      local tu=tg:getUnit(1); if not (tu and tu:isExist()) then activeRec[code]=false; _recHelosByLZ[code]=nil; watch:Stop(); return end
      local tp=tu:getPoint(); local teamC=COORDINATE:New(tp.x, tp.y or 0, tp.z)

      local here=g:GetCoordinate(); local dist=here:Get2DDistance(teamC)

      if (not fivekmWarned) and dist<=5000 then fivekmWarned=true; trigger.action.outText("SF Recovery helo 5 km inbound for pickup",5) end

      if (not smokeDone) and dist <= EXTRACT_SMOKE_TRIGGER_M then
        smokeDone = true
        if DEBUG_FX then trigger.action.outText("SF Extraction: BLUE smoke @1km", 4) end
        local smokeC = teamC:Translate(EXTRACT_SMOKE_OFFSET_M, EXTRACT_SMOKE_OFFSET_BRG)
        local p = smokeC:GetVec3(); p.y = (smokeC:GetLandHeight() or p.y or 0) + 1
        pcall(function() trigger.action.smoke(p, EXTRACT_SMOKE_COLOR) end)
      end
      if (not flare1kmDone) and dist <= 1000 then
        flare1kmDone = true
        if DEBUG_FX then trigger.action.outText("SF Extraction: RED flare @1km", 4) end
        _fireFlareAt(teamC, FLARE_COLOR_RED, FLARE_ALT_AGL_M)
      end
      if (not flare200mDone) and dist <= 200 then
        flare200mDone = true
        if DEBUG_FX then trigger.action.outText("SF Extraction: RED flare @200m", 4) end
        _fireFlareAt(teamC, FLARE_COLOR_RED, FLARE_ALT_AGL_M)
      end

      if not lzCoord then
        g:RouteToVec2(teamC:GetVec2(), CRUISE_KTS, "Vee")
        if dist<=REC_APPROACH_SLOW_M then
          lzCoord = teamC:Translate(REC_LZ_OFFSET_M, REC_LZ_OFFSET_BRG)
          pcall(function() g:SetAltitude(lzCoord:GetLandHeight()+15, true) end)
          g:RouteToVec2(lzCoord:GetVec2(), REC_FINAL_KTS, "Vee")
          TIMER:New(function()
            if not g or not g.IsAlive or not g:IsAlive() then return end
            local DG=g:GetDCSObject(); if DG and DG.getController then
              local ctrl=DG:getController(); if ctrl then
                local v2=lzCoord:GetVec2()
                pcall(function() ctrl:pushTask({ id='Land', params={ point={x=v2.x,y=v2.y}, duration=REC_BOARD_DELAY_SEC } }) end)
              end
            end
          end):Start(REC_LAND_PUSH_DELAY_S)
        end
        return
      end

      local u1=g:GetUnit(1); local near=lzCoord and (here:Get2DDistance(lzCoord)<=REC_LZ_NEAR_M) or false
      if (not boarded) and u1 and _isOnGroundAndSlow(u1) and near then
        boarded=true
        TIMER:New(function()
          local gobj=Group.getByName(sofName); if gobj then pcall(function() gobj:destroy() end) end
          _removeSFMarkerFor(sofName)
          lastSOFByLZ[code]=nil; trigger.action.outText("SF Team recovered",8)
          stopRecceMonitor(code); stopThreatMonitor(code)
          _saveState()
          TIMER:New(function()
            if not g or not g.IsAlive or not g:IsAlive() then activeRec[code]=false; _recHelosByLZ[code]=nil; watch:Stop(); return end
            local base=AIRBASE:FindByName(AIRBASE_NAME) or AIRBASE:FindClosestAirbase(g:GetCoordinate())
            if base then
              local bc=base:GetCoordinate()
              g:RouteToVec2(vec2OfCoord(bc), RTB_KTS, "Vee")
              _autoDespawnAfterLanding(g, bc, DESPAWN_AFTER_LAND_SEC)
            end
            activeRec[code]=false; _recHelosByLZ[code]=nil; watch:Stop()
          end):Start(REC_GROUND_WAIT_SEC)
        end):Start(REC_BOARD_DELAY_SEC)
        return
      end
    end,{},1,1)

  end):Start(REC_COLD_START_DELAY)
end

-- ===== Lasing (hidden JTAC spawner + retarget monitor) =====================
local function _spawnHiddenJTACAt(sofName, x, z)
  local gname = sofName.."_JTAC"
  local data = {
    category    = Group.Category.GROUND,
    country     = country.id.USA,
    hidden      = true,
    hiddenOnMap = true,
    name        = gname,
    task        = {},
    units       = {
      {
        name            = gname.."_1",
        type            = SOF_TYPE,
        skill           = "Average",
        x               = x, y = z,
        heading         = 0,
        playerCanDrive  = false,
      }
    }
  }
  coalition.addGroup(country.id.USA, Group.Category.GROUND, data)

  local jg = Group.getByName(gname)
  if jg then
    local ctrl = jg:getController()
    if ctrl and AI and AI.Option and AI.Option.Ground then
      pcall(ctrl.setOption, ctrl, AI.Option.Ground.id.ROE,         AI.Option.Ground.val.ROE.WEAPON_HOLD)
      pcall(ctrl.setOption, ctrl, AI.Option.Ground.id.ALARM_STATE, AI.Option.Ground.val.ALARM_STATE.GREEN)
      if AI.Option.Ground.id.DISPERSION_ON_OFF then
        pcall(ctrl.setOption, ctrl, AI.Option.Ground.id.DISPERSION_ON_OFF, false)
      end
    end
    local u = jg:getUnit(1)
    if u then
      if u.setImmortal then pcall(function() u:setImmortal(true) end) end
      if ctrl then pcall(ctrl.setCommand, ctrl, { id = 'SetImmortal', params = { value = true } }) end
    end
  end

  return gname
end

local function _pushFACTask(jtacGroup, targetGroup, code)
  if not (jtacGroup and targetGroup) then return end
  local ctrl = jtacGroup:getController(); if not ctrl then return end
  local task = {
    id = 'FAC_EngageGroup',
    params = {
      groupId    = targetGroup:getID(),
      priority   = 1,
      designation= 'Laser',
      attackType = 'Laser',
      datalink   = true,
      laserCode  = code or LASER_CODE,
    }
  }
  pcall(function() ctrl:pushTask(task) end)
end

local function _stopJTAC(code)
  local jname = _jtacByLZ[code]
  if jname then
    local jg = Group.getByName(jname)
    if jg then pcall(function() jg:destroy() end) end
  end
  _jtacByLZ[code] = nil
  _laseTargetGID[code] = nil
  local sch = _laseSchedByLZ[code]
  if sch and sch.Stop then pcall(function() sch:Stop() end) end
  _laseSchedByLZ[code] = nil
end

local function laseStop(code)
  _stopJTAC(code)
  trigger.action.outText(("SF %s: Laser off."):format(code), 6)
  local sofName=lastSOFByLZ[code]
  if sofName and GROUP and GROUP.FindByName then
    local G=GROUP:FindByName(sofName); if G and G:IsAlive() then local U=G:GetUnit(1); if U and U:IsAlive() then _srsSay(U, ("S F %s laser off."):format(_sayableCode(code))) end end
  end
end

local function laseClosest(code)
  if not LASER_ENABLED then banner("Lasing disabled in config.",6); return end
  local sofName=lastSOFByLZ[code]; if not (sofName and isGroupAliveByName(sofName)) then banner(code..": No SF team recorded yet.",8); return end
  local g=Group.getByName(sofName); if not g then banner(code..": Team group not found.",8); return end
  local u=g:getUnit(1); if not (u and u:isExist()) then banner(code..": Team not present.",8); return end
  local up=u:getPoint(); if not up or not up.x or not up.z then banner(code..": Team not present.",8); return end

  local tgtU, tgtG, d = _nearestEnemyUnit(sofName, LASER_RANGE_M)
  if not (tgtU and tgtG and d) then
    trigger.action.outText(("SF %s: No valid targets within %.1f Km."):format(code, km(LASER_RANGE_M)), 8)
    return
  end

  local jtacName = _jtacByLZ[code]
  if not jtacName or not isGroupAliveByName(jtacName) then
    jtacName = _spawnHiddenJTACAt(sofName, up.x, up.z)
    _jtacByLZ[code] = jtacName
  end

  _laseTargetGID[code] = tgtG:getID()

  local jg = Group.getByName(jtacName)
  if not jg then banner(code..": JTAC spawn failed.",8); return end

  _pushFACTask(jg, tgtG, LASER_CODE)

  local tp = tgtU:getPoint()
  local brg = tp and _bearingDeg(up.x, up.z, tp.x, tp.z) or 0
  local msg1 = ("SF %s: Lasing %s (Code %d)"):format(code, tgtU:getTypeName() or "Target", LASER_CODE)
  local msg2 = ("Dist %s, Brg %03d° From my position"):format(_fmtKm(d), brg)
  trigger.action.outText(msg1.."\n"..msg2, 10)

  local old = _laseSchedByLZ[code]; if old and old.Stop then pcall(function() old:Stop() end) end
  local sched = SCHEDULER:New(nil,function()
    if not isGroupAliveByName(sofName) then laseStop(code); return end
    if not isGroupAliveByName(jtacName) then _jtacByLZ[code]=nil; laseStop(code); return end

    local wantGid = _laseTargetGID[code]
    local currTargetGroup=nil
    for _,eg in ipairs(coalition.getGroups(ENEMY_SIDE, Group.Category.GROUND) or {}) do
      if eg:getID()==wantGid then currTargetGroup=eg break end
    end

    local currAlive=false
    if currTargetGroup then
      for _,eu in ipairs(currTargetGroup:getUnits() or {}) do
        if eu and eu:isExist() and eu:getLife()>0 then currAlive=true break end
      end
    end

    if currAlive then return end

    local nu,ng,nd = _nearestEnemyUnit(sofName, LASER_RANGE_M)
    if nu and ng and nd then
      _laseTargetGID[code] = ng:getID()
      local jg2 = Group.getByName(jtacName)
      if jg2 then _pushFACTask(jg2, ng, LASER_CODE) end

      local up2=u:getPoint(); local tp2=nu:getPoint()
      local brg2 = (up2 and tp2) and _bearingDeg(up2.x, up2.z, tp2.x, tp2.z) or 0
      local m1 = ("SF %s: Target down. Shifting laser to %s (Code %d)"):format(code, nu:getTypeName() or "Target", LASER_CODE)
      local m2 = ("Dist %s, Brg %03d° From my position"):format(_fmtKm(nd), brg2)
      trigger.action.outText(m1.."\n"..m2, 10)
      return
    end

    laseStop(code)
    trigger.action.outText(("SF %s: Laser off—no further targets in range."):format(code),8)
  end,{},3,3)
  _laseSchedByLZ[code] = sched
end

-- ===== Marker Handler (Insert), guarded EH =================================
local processed={}
local function handleMarker(idx,pos,text)
  local txtU=(text or ""):upper():gsub("%s+","")
  local lzNum=txtU:match("^LZ([1-5])$")
  if not lzNum or processed[idx] then return end
  processed[idx]=true
  local code="LZ"..lzNum

  local existing = lastSOFByLZ[code]
  if existing and isGroupAliveByName(existing) then
    trigger.action.outText(code.." already Deployed", 8)
    return
  end

  banner(("Marker %s accepted at (%.0f, %.0f)"):format(code, pos.x, pos.z), 6)
  launchSOF({x=pos.x, z=pos.z}, code)
end

local function registerEventHandlerGuarded()
  if _G.__SF_EH then pcall(world.removeEventHandler, _G.__SF_EH) end
  local EH={}
  function EH:onEvent(e)
    if not e or not e.id then return end
    if e.id==world.event.S_EVENT_MARK_ADDED or e.id==world.event.S_EVENT_MARK_CHANGE then
      if e.pos and e.idx then handleMarker(e.idx, e.pos, e.text or "") end
    end
  end
  world.addEventHandler(EH)
  _G.__SF_EH = EH
end

-- ===== Extract All / Kill All / Abort Deployment ===========================
local function extractAllTeams()
  for code, name in pairs(lastSOFByLZ) do
    if name and isGroupAliveByName(name) then
      startRecovery(code)
    end
  end
end

local function killAllRecoveries()
  for code,g in pairs(_recHelosByLZ) do
    if g and g.IsAlive and g:IsAlive() then pcall(function() g:Destroy() end) end
    _recHelosByLZ[code]=nil
    activeRec[code]=false
  end
  banner("All recovery helos killed. Extraction state reset.", 8)
end

local function abortDeployment(code)
  if not active[code] then banner(code.." no active insertion.",6); return end
  local g = _insertHelosByLZ[code]
  if not (g and g.IsAlive and g:IsAlive()) then
    active[code]=false; _insertHelosByLZ[code]=nil
    banner(code.." insertion aborted (helo not present).",6)
    return
  end
  _insertAbortFlag[code] = true
  trigger.action.outText(("SF Helo %s: Insertion aborted, returning to base."):format(code), 10)
  local uSpeak=g:GetUnit(1)
  _srsSay(uSpeak, ("S F "..SRS_WORD_HELO.." %s insertion aborted, returning to base."):format(_sayableCode(code)))
end

local function abortAllDeployments()
  for i=1,MAX_LZ_INDEX do abortDeployment("LZ"..i) end
end

-- ===== Team Status (SITREP) ================================================
local function _nearestThreat(sofName, capMeters)
  local u, g, d = _nearestEnemyUnit(sofName, capMeters or REPORT_RADIUS_M)
  return u, g, d
end

local function _statusLineFor(code)
  local sofName = lastSOFByLZ[code]
  if not (sofName and isGroupAliveByName(sofName)) then
    return ("LZ%s – Not Deployed"):format(codeNum(code)), nil
  end

  -- target count + optional threat info
  local count = _countTargetsInRange(sofName)
  local threatU,_,threatD = _nearestThreat(sofName, REPORT_RADIUS_M)
  local head = ("LZ%s – Deployed | Active"):format(codeNum(code))

  if threatU and threatD then
    local g=Group.getByName(sofName); local u=g and g:getUnit(1) or nil; local up=u and u:getPoint() or nil
    local tp=threatU:getPoint(); local brg=0; if up and tp then brg=_bearingDeg(up.x,up.z,tp.x,tp.z) end
    head = ("%s | Tgts: %d | Dist %s | Brg %03d°"):format(head, count, _fmtKm(threatD), brg)
  elseif count > 0 then
    head = ("%s | Tgts: %d"):format(head, count)
  end

  return head, nil
end

local function showTeamStatus()
  local lines = {"--- SF TEAM STATUS ---"}

  for i=1,MAX_LZ_INDEX do
    local code="LZ"..i
    local head, detail = _statusLineFor(code)
    table.insert(lines, head)
    if detail then table.insert(lines, detail) end
  end

  local recList = {}
  for code,g in pairs(_recHelosByLZ) do if g and g.IsAlive and g:IsAlive() then table.insert(recList, code) end end
  if #recList>0 then
    table.insert(lines, ("Recovery Helos – %d active (%s)"):format(#recList, table.concat(recList, ", ")))
  else
    table.insert(lines, "Recovery Helos – none active")
  end

  -- footer always added
  table.insert(lines, "## Dist/Brg Relatve to SF Teams ##")

  trigger.action.outText(table.concat(lines, "\n"), 12)

  local anySOF = nil
  for _,name in pairs(lastSOFByLZ) do if name and isGroupAliveByName(name) then anySOF=name break end end
  if anySOF and GROUP and GROUP.FindByName then
    local G=GROUP:FindByName(anySOF); if G and G:IsAlive() then
      local U=G:GetUnit(1)
      if U and U:IsAlive() then
        local parts = {"S F Status Report."}
        for i=1,MAX_LZ_INDEX do
          local code="LZ"..i
          local sofName=lastSOFByLZ[code]
          if sofName and isGroupAliveByName(sofName) then
            local _, detail = _statusLineFor(code)
            table.insert(parts, ("L Z %d active."):format(i))
            if detail then
              local brg = tonumber(detail:match("Brg%s+(%d+)")) or 0
              local distM = tonumber((detail:match("Dist%s+([%d%.]+)") or "0")) * 1000
              table.insert(parts, ("Distance %s. Bearing %s."):format(_srsDistKm(distM), _digits3(brg)))
            end
          else
            table.insert(parts, ("L Z %d not deployed."):format(i))
          end
        end
        if #recList>0 then
          table.insert(parts, ("Recovery helos: %d active."):format(#recList))
        else
          table.insert(parts, "Recovery helos: none active.")
        end
        _srsSay(U, table.concat(parts, " "))
      end
    end
  end
end

-- ===== F10 Menus ===========================================================

-- Helpers for immediate despawn actions (HARD despawn)
local function _escape_pat(s)
  return (tostring(s or ""):gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])","%%%1"))
end

local function _hardDestroyMooseGroup(mg)
  local killed = 0
  if mg and mg.IsAlive and mg:IsAlive() then
    pcall(function() mg:Destroy() end)
    killed = 1
  end
  local n = nil
  if mg and mg.GetName then pcall(function() n = mg:GetName() end) end
  if n then
    local dg = Group.getByName(n)
    if dg then pcall(function() dg:destroy() end) end
  end
  return killed
end

local function _sweepDestroyByPrefix(prefix)
  local patt = "^".._escape_pat(prefix)
  local function sweepSide(side)
    local total = 0
    for _,cat in ipairs({Group.Category.HELICOPTER, Group.Category.AIRPLANE}) do
      for _,dg in ipairs(coalition.getGroups(side, cat) or {}) do
        local name = dg.getName and dg:getName() or ""
        if name:match(patt) then
          pcall(function() dg:destroy() end)
          total = total + 1
        end
      end
    end
    return total
  end
  return sweepSide(coalition.side.BLUE)
       + sweepSide(coalition.side.RED)
       + sweepSide(coalition.side.NEUTRAL)
end

local function _forceDespawn(prefix, mooseGroup)
  local killed = 0
  killed = killed + _hardDestroyMooseGroup(mooseGroup)
  killed = killed + _sweepDestroyByPrefix(prefix)

  -- second pass a moment later (DCS sometimes re-ticks controllers)
  TIMER:New(function() _sweepDestroyByPrefix(prefix) end):Start(0.3)

  return killed
end

local function killRecoveryByCode(codeOrIdx)
  local idx = tostring(codeOrIdx or ""):match("(%d+)")
  if not idx then return end
  local key     = "LZ"..idx
  local mooseG  = _recHelosByLZ and _recHelosByLZ[key]
  local prefix  = (REC_TEMPLATES and REC_TEMPLATES[key]) or ("SF_REC_"..key)

  local killed = _forceDespawn(prefix, mooseG)

  _recHelosByLZ[key] = nil
  activeRec[key]     = false
  trigger.action.outText(string.format("FORCE Despawned Recovery %s (%d grp)", key, killed), 6)
end

local function killInsertByLZ(code)
  local mooseG = _insertHelosByLZ and _insertHelosByLZ[code]
  local prefix = (LZ_TEMPLATES and LZ_TEMPLATES[code]) or ("SF_HELO_"..code)

  local killed = _forceDespawn(prefix, mooseG)

  _insertHelosByLZ[code] = nil
  active[code]           = false
  trigger.action.outText(string.format("FORCE Despawned Insertion %s (%d grp)", code, killed), 6)
end







-- Small helpers for Commander Status threat bar
local function _threatLabelFromCount(n)
  if n >= 5 then return "HIGH"
  elseif n >= 3 then return "MEDIUM"
  elseif n >= 1 then return "LOW"
  else return "NONE" end
end

local function _threatBar8(n)
  local filled = math.max(0, math.min(8, n))
  local blocks = string.rep("■", filled)..string.rep("□", 8-filled)
  return "["..blocks.."]"
end

-- --- Static builder (manual Refresh only; trimmed LZ titles) ----------------
local function _safeRemoveMenu(h)
  if h then pcall(missionCommands.removeItemForCoalition, coalition.side.BLUE, h) end
end

-- Helper (used by Commander Status)
local function _isSOFDeployed(code)
  local name = lastSOFByLZ[code]
  return name and isGroupAliveByName(name)
end

local function _addLZSubmenu_Static(parent, code, idx)
  -- Default title based on deployment state
  local sofName = lastSOFByLZ[code]
  local titleText

  if sofName and isGroupAliveByName(sofName) then
    local tgtCount = _countTargetsInRange(sofName) or 0
    titleText = code:gsub("^LZ(%d+)", "SF%1") .. " | Active | Tgts:" .. tgtCount
  else
    titleText = code:gsub("^LZ(%d+)", "SF%1") .. " | Not Deployed"
  end

  local lzMenu = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, titleText, parent)

  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Threats",             lzMenu, function() recceForLZ(code) end)
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Lase Closest Target", lzMenu, function() laseClosest(code) end)
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Stop Lasing",         lzMenu, function() laseStop(code) end)
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Extract",             lzMenu, function() startRecovery(code) end)
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Abort Insertion",     lzMenu, function() abortDeployment(code) end)
end


function buildSFMenu_Static()
  local M = _G.__SF_MENU or {}
  _safeRemoveMenu(M.root)

  local root = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, "SF Missions")

  -- SF Teams (STATIC: always show LZ1..LZ5) + Refresh (no top-level Refresh)
  local teamsRoot = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, "SF Teams", root)
  for i=1,MAX_LZ_INDEX do
    _addLZSubmenu_Static(teamsRoot, "LZ"..i)
  end
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Refresh", teamsRoot, function() buildSFMenu_Static() end)

  -- Team Status
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Team Status", root, showTeamStatus)

  -- Commander Status (static, shows all LZs with threat bar)
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Commander Status", root, function()
    local lines = {"--- COMMANDER STATUS ---"}

    local activeTeams = 0
    for i=1,MAX_LZ_INDEX do if _isSOFDeployed("LZ"..i) then activeTeams = activeTeams + 1 end end
    table.insert(lines, ("SF Teams Active: %d"):format(activeTeams))

    for i=1,MAX_LZ_INDEX do
      local code="LZ"..i
      local sofName = lastSOFByLZ[code]
      if sofName and isGroupAliveByName(sofName) then
        local tgtCount = _countTargetsInRange(sofName)
        local bar = _threatBar8(tgtCount)
        local lvl = _threatLabelFromCount(tgtCount)
        table.insert(lines, (" - %s Active | Tgts %d | Threat: %s %s"):format(code, tgtCount, bar, lvl))
      else
        table.insert(lines, (" - %s Not Deployed"):format(code))
      end
    end

    local deployCount = 0
    for _,flag in pairs(active) do if flag then deployCount = deployCount + 1 end end
    table.insert(lines, ("Deployment Helos Active: %d"):format(deployCount))

    local recCount = 0
    for _,g in pairs(_recHelosByLZ) do if g and g.IsAlive and g:IsAlive() then recCount = recCount + 1 end end
    table.insert(lines, ("Extraction Helos Active: %d"):format(recCount))

    trigger.action.outText(table.concat(lines, "\n"), 15)
  end)

  -- Mission submenu (help text + Refresh here too)
  local missionRoot = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, "Mission", root)
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Deployment Info", missionRoot, function()
    banner("To deploy SF: place an F10 map mark named 'LZ1'..'LZ5' at the desired LZ.\nA helo will launch, land, insert, then RTB.", 12)
  end)
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Refresh", missionRoot, function() buildSFMenu_Static() end)

  -- Abort Deployment (STATIC: always show all)
  local abortSub = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, "Abort Deployment", missionRoot)
  for i=1,MAX_LZ_INDEX do
    local code = "LZ"..i
    missionCommands.addCommandForCoalition(coalition.side.BLUE, code, abortSub, function() abortDeployment(code) end)
  end
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Abort All", abortSub, abortAllDeployments)

  -- Extract All
  missionCommands.addCommandForCoalition(coalition.side.BLUE, "Extract All", missionRoot, extractAllTeams)

  -- Despawn menus (STATIC)
  local despawnRoot = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, "Despawn", missionRoot)

  local despawnDeploy = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, "Deploy", despawnRoot)
  for i=1,MAX_LZ_INDEX do
    local code = "LZ"..i
    missionCommands.addCommandForCoalition(
      coalition.side.BLUE, "Despawn "..code, despawnDeploy,
      function() killInsertByLZ(code) end
    )
  end
  missionCommands.addCommandForCoalition(
    coalition.side.BLUE, "Despawn All LZ", despawnDeploy,
    function() for i=1,MAX_LZ_INDEX do killInsertByLZ("LZ"..i) end end
  )

  local despawnRecovery = missionCommands.addSubMenuForCoalition(coalition.side.BLUE, "Recovery", despawnRoot)
  for i=1,MAX_LZ_INDEX do
    missionCommands.addCommandForCoalition(
      coalition.side.BLUE, "Despawn RCY"..i, despawnRecovery,
      function() killRecoveryByCode(i) end
    )
  end
  missionCommands.addCommandForCoalition(
    coalition.side.BLUE, "Despawn All RCY", despawnRecovery,
    function() for i=1,MAX_LZ_INDEX do killRecoveryByCode(i) end end
  )

  _G.__SF_MENU = { root = root }
end

-- ===== Bootstrap: detect existing SF teams already on map ===================
local function _bootstrapExistingSOFTeams()
  local list = coalition.getGroups(coalition.side.BLUE, Group.Category.GROUND) or {}
  for _, dg in ipairs(list) do
    local name = dg.getName and dg:getName() or nil
    if name then
      local lzNum = name:match("^SF_TEAM_LZ([1-5])_")
      if lzNum and isGroupAliveByName(name) then
        local code = "LZ" .. lzNum
        if not lastSOFByLZ[code] then
          lastSOFByLZ[code] = name
          _ensureSFMarkerWithRetries(name)
          startRecceMonitor(code)
          startThreatMonitor(code)
        end
      end
    end
  end
end

-- ===== Init / Persistence / Schedulers =====================================
local function _initOnce()
  _loadState()
  printHeader()

  -- USE STATIC MENU (refresh is manual via menu)
  buildSFMenu_Static()
  -- (No periodic rebuild)

  registerEventHandlerGuarded()
  _reseedAllMarkers()

  for code, name in pairs(lastSOFByLZ) do
    if name and isGroupAliveByName(name) then
      startRecceMonitor(code)
      startThreatMonitor(code)
      _ensureSFMarkerWithRetries(name)
    end
  end

  TIMER:New(_bootstrapExistingSOFTeams):Start(1)
  _startSFMarkerUpdater()
  TIMER:New(function() _saveEnabled = true; _saveState() end):Start(5)
end

_initOnce()

--[[ End of SOF_Insert.lua v2.9.5 ]]
