local json = require("JsonParser")
local socket = require("socket")
local UEHelpers = require("UEHelpers")

---Convert player state to JSON serializable table
---@param playerState AMotorTownPlayerState
local function PlayerStateToTable(playerState)
  local data = {}

  if playerState:IsValid() then
    data.UniqueID = GetUniqueNetIdAsString(playerState)

    data.PlayerName = playerState:GetPlayerName():ToString()
    data.GridIndex = playerState.GridIndex
    data.bIsHost = playerState.bIsHost
    data.bIsAdmin = playerState.bIsAdmin
    data.CharacterGuid = GuidToString(playerState.CharacterGuid)
    data.BestLapTime = playerState.BestLapTime

    data.Levels = {}
    playerState.Layers:ForEach(function(index, element)
      table.insert(data.Levels, element:get())
    end)

    data.OwnCompanyGuid = GuidToString(playerState.OwnCompanyGuid)
    data.JoinedCompanyGuid = GuidToString(playerState.JoinedCompanyGuid)
    data.CustomDestinationAbsoluteLocation = VectorToTable(playerState.CustomDestinationAbsoluteLocation)

    data.OwnEventGuids = {}
    playerState.OwnEventGuids:ForEach(function(index, element)
      table.insert(data.OwnEventGuids, GuidToString(element:get()))
    end)

    data.JoinedEventGuids = {}
    playerState.JoinedEventGuids:ForEach(function(index, element)
      table.insert(data.JoinedEventGuids, GuidToString(element:get()))
    end)

    data.Location = VectorToTable(playerState.Location)
    local pawn = playerState:GetPawn()
    if pawn:IsValid() then
      data.Rotation = RotatorToTable(pawn:K2_GetActorRotation())
    end
    data.VehicleKey = playerState.VehicleKey:ToString()
    data.bAFK = playerState.Net_bAFK
  end

  return data
end

-- Cache for GetPlayerStates() to reduce game-thread load under high GET concurrency.
local _playerStatesCache = nil
local _playerStatesCacheTs = 0
local _playerStatesCacheTtlMs = 1000

---Get all or selected player state(s)
---@param uniqueId string? Filter by player state unique net ID
---@return table[]
local function GetPlayerStates(uniqueId)
  local gameState = GetMotorTownGameState()
  local arr = {}

  if not gameState:IsValid() then return arr end

  -- Use cached full list when available and not filtering by uniqueId.
  local now = socket.gettime() * 1000
  if not uniqueId and _playerStatesCache and (now - _playerStatesCacheTs) < _playerStatesCacheTtlMs then
    return _playerStatesCache
  end

  local playerStates = gameState.PlayerArray

  if uniqueId then
    for i = 1, #playerStates, 1 do
      local playerState = playerStates[i]
      if playerState:IsValid() then
        if GetUniqueNetIdAsString(playerState) == uniqueId then
          table.insert(arr, PlayerStateToTable(playerState))
          break
        end
      end
    end
  else
    for i = 1, #playerStates, 1 do
      local playerState = playerStates[i]
      if playerState:IsValid() then
        table.insert(arr, PlayerStateToTable(playerState))
      end
    end
    _playerStatesCache = arr
    _playerStatesCacheTs = now
  end

  return arr
end


---TransferMoneyToPlayer
---@param uniqueId string Filter by player state unique net ID
---@param amount integer The amount to transfer (potentially negative)
---@param message string The message that comes with the transfer
---@return boolean
local function TransferMoneyToPlayer(uniqueId, amount, message)
  local PC = GetPlayerControllerFromUniqueId(uniqueId)
  if PC == nil or not PC:IsValid() then return false end
  LogOutput("INFO", "TransferMoneyToPlayer")
  if PC:IsValid() then
    ---@diagnostic disable-next-line: param-type-mismatch
    PC:ClientAddMoney(amount, 'Context', FText(message), true, 'Context', 'Context')
  end
  return true
end

local function TransferMoneyToCharacter(characterGuid, amount, message)
  local PC = GetPlayerControllerFromGuid(characterGuid)
  if PC == nil or not PC:IsValid() then return false end
  LogOutput("INFO", "TransferMoneyToPlayer")
  if PC:IsValid() then
    ---@diagnostic disable-next-line: param-type-mismatch
    PC:ClientAddMoney(amount, '', FText(message), true, '', '')
  end
  return true
end


local function PlayerSendChat(uniqueId, message)
  local PC = GetPlayerControllerFromUniqueId(uniqueId)
  LogOutput("INFO", "PlayerSendChat")
  if PC:IsValid() then
    PC:ServerSendChat(message, 0)
  end
  return true
end


---Get my current pawn transform
---@return FVector? location
---@return FRotator? rotation
local function GetMyCurrentTransform()
  local PC = GetMyPlayerController()
  if PC:IsValid() then
    local pawn = PC:K2_GetPawn()
    if pawn:IsValid() then
      local location = pawn:K2_GetActorLocation()
      local rotation = pawn:K2_GetActorRotation()
      return location, rotation
    end
  end
  return nil, nil
end

-- Console commands

RegisterConsoleCommandHandler("getplayers", function(Cmd, CommandParts, Ar)
  local playerStates = json.stringify(GetPlayerStates(CommandParts[1]))
  LogOutput("INFO", playerStates)
  return true
end)

RegisterConsoleCommandHandler("getplayertransform", function(Cmd, CommandParts, Ar)
  local location, rotation = GetMyCurrentTransform()
  LogOutput("INFO", "Actor transform: %s", json.stringify({ Location = location, Rotation = rotation }))
  return true
end)

-- HTTP request handlers

---Handle request for player states
---@type RequestPathHandler
local function HandleGetPlayerStates(session)
  local playerId = session.pathComponents[2]
  local res = {}
  local ok = true
  res = GetPlayerStates(playerId)
  if not ok then return { error = "Game thread timeout" }, nil, 503 end
  if playerId and #res == 0 then
    return { message = string.format("Player with unique ID %s not found", playerId) }, nil, 404
  end

  return { data = res }, nil, 200
end

---Handle request to teleport player
---@type RequestPathHandler
local function HandleTransferMoneyToPlayer(session)
  local playerId = session.pathComponents[2]
  if not playerId then
    return { error = string.format("Invalid player ID %s", playerId) }, nil, 400
  end

  local data = json.parse(session.content)
  if data and data.Amount and data.Message and data.CharacterGuid then
    TransferMoneyToPlayer(data.CharacterGuid, data.Amount, data.Message)
    return nil, nil, 200
  end
  if data and data.Amount and data.Message then
    TransferMoneyToPlayer(playerId, data.Amount, data.Message)
    return nil, nil, 200
  end
  return { error = "Invalid payload" }, nil, 400
end

local function HandleSetPlayerName(session)
  local characterGuid = session.pathComponents[2]
  if not characterGuid then
    return { error = string.format("Invalid character guid %s", characterGuid) }, nil, 400
  end

  local data = json.parse(session.content)
  if data and data.name then
    local PC = GetPlayerControllerFromGuid(characterGuid)
    if not PC:IsValid() or not PC.PlayerState:IsValid() then
      return { error = string.format("Invalid player controller %s", characterGuid) }, nil, 400
    end
    if PC:IsValid() and PC.PlayerState:IsValid() then
      PC.PlayerState.PlayerNamePrivate = data.name
      PC:SetName(data.name)
    end

    return nil, nil, 200
  end
  return { error = "Invalid payload" }, nil, 400
end

local function HandlePlayerSendChat(session)
  local playerId = session.pathComponents[2]
  if not playerId then
    return { error = string.format("Invalid player ID %s", playerId) }, nil, 400
  end

  local data = json.parse(session.content)
  if data and data.Message then
    PlayerSendChat(playerId, data.Message)
    return nil, nil, 204
  end
  return { error = "Invalid payload" }, nil, 400
end

local mutedPlayers = {}

local MUTE_CATEGORY_SOFT = 7
local MUTE_CATEGORY_HARD = 2

local function GetMuteInfo(uniqueId)
  local entry = mutedPlayers[uniqueId]
  if entry == nil then return nil end
  local muteUntil = entry.expiry
  if muteUntil == true or muteUntil == "permanent" then return entry end
  if type(muteUntil) == "number" then
    if muteUntil <= 0 then return entry end
    if os.time() >= muteUntil then
      mutedPlayers[uniqueId] = nil
      return nil
    end
    return entry
  end
  return nil
end

local function IsPlayerMuted(uniqueId)
  return GetMuteInfo(uniqueId) ~= nil
end

local function MutePlayer(uniqueId, muteUntil, hard)
  mutedPlayers[uniqueId] = { expiry = muteUntil, hard = hard }
  LogOutput("INFO", "Player %s muted until %s (hard=%s)", uniqueId, tostring(muteUntil), tostring(hard))
end

local function UnmutePlayer(uniqueId)
  mutedPlayers[uniqueId] = nil
  LogOutput("INFO", "Player %s unmuted", uniqueId)
end

local function GetMutedPlayers()
  local result = {}
  local now = os.time()
  for uniqueId, entry in pairs(mutedPlayers) do
    local muteUntil = entry.expiry
    local expired = false
    if type(muteUntil) == "number" and muteUntil > 0 and now >= muteUntil then
      mutedPlayers[uniqueId] = nil
      expired = true
    end
    if not expired then
      table.insert(result, {
        UniqueID = uniqueId,
        MuteUntil = muteUntil,
        Hard = entry.hard,
      })
    end
  end
  return result
end

RegisterHook("/Script/MotorTown.MotorTownPlayerController:ServerSendChat", function(PC, Message, Category)
  LogOutput("INFO", "ServerSendChat hook CALLBACK INVOKED")
  LogOutput("INFO", "ServerSendChat hook CALLBACK INVOKED PC type=%s", type(PC))

  local playerController = PC:get()
  if not playerController:IsValid() then
    LogOutput("WARN", "ServerSendChat: playerController invalid")
    return
  end
  local playerState = playerController.PlayerState
  if not playerState:IsValid() then
    LogOutput("WARN", "ServerSendChat: playerState invalid")
    return
  end

  local uniqueId = GetUniqueNetIdAsString(playerState)
  if not uniqueId then
    LogOutput("WARN", "ServerSendChat: uniqueId nil")
    return
  end

  local characterGuid = GuidToString(playerState.CharacterGuid)
  local messageStr = Message:get():ToString()
  local categoryVal = Category:get()

  LogOutput("INFO", "ServerSendChat: player=%s msg=%s cat=%d guid=%s", uniqueId, messageStr, categoryVal, characterGuid)

  local muteInfo = GetMuteInfo(uniqueId)
  LogOutput("INFO", "GetMuteInfo(%s) = %s", uniqueId, tostring(muteInfo ~= nil))

  local finalCategory = categoryVal

  if muteInfo and categoryVal == 0 then
    finalCategory = muteInfo.hard and MUTE_CATEGORY_HARD or MUTE_CATEGORY_SOFT
    LogOutput("INFO", "Muted player %s: redirected chat from Normal to %s", uniqueId, muteInfo.hard and "Company" or "SmallArea")
  end

  -- Slash command filtering: redirect non-whitelisted commands to company chat
  if categoryVal == 0 and messageStr:sub(1, 1) == "/" then
    local command = messageStr:match("^/([^%s]+)")
    if command then
      local whitelist = { bot = true, song_request = true }
      if not whitelist[command] then
        finalCategory = MUTE_CATEGORY_HARD
        LogOutput("INFO", "Slash command /%s redirected to company chat", command)
      end
    end
  end

  if finalCategory ~= categoryVal then
    Category:set(finalCategory)
  end

  -- Send webhook for slash commands and muted players; normal chat is handled by logs
  if messageStr:sub(1, 1) == "/" or muteInfo ~= nil then
    local ok = EnqueueWebhookEvent("ServerSendChat", {
      Message = messageStr,
      Category = finalCategory,
      CharacterGuid = characterGuid,
      UniqueID = uniqueId,
    })
    LogOutput("INFO", "EnqueueWebhookEvent result: %s", tostring(ok))
  end
end)

RegisterHook("/Script/MotorTown.MotorTownPlayerController:ServerSetCustomizationParts", function(PC, CustomizationParts)
  local playerController = PC:get()
  if not playerController:IsValid() then return end
  local playerState = playerController.PlayerState
  if not playerState:IsValid() then return end

  local uniqueId = GetUniqueNetIdAsString(playerState)
  local characterGuid = GuidToString(playerState.CharacterGuid)

  -- Build a lookup of the character's CURRENT slots so we can diff.
  -- The hook fires *before* the server applies the RPC, so reading the
  -- character's Net_CustomizationParts here yields the old state.
  local currentSlots = {}   -- [slotEnum] = partKeyString
  local costumeBodyKey, costumeItemKey
  pcall(function()
    local character = playerController.Net_MyDrivingCharacter
    if not character or not character:IsValid() then
      character = playerController:K2_GetPawn()
    end
    if character and character:IsValid() then
      character.Net_CustomizationParts.Slots:ForEach(function(_, element)
        local entry = element:get()
        local ok_pk, val_pk = pcall(function() return entry.PartKey:ToString() end)
        currentSlots[entry.Slot] = ok_pk and val_pk or ""
      end)
      local custom = character.Net_Customization
      if custom then
        local ok_cbk, val_cbk = pcall(function() return custom.CostumeBodyKey:ToString() end)
        costumeBodyKey = ok_cbk and val_cbk or nil
        local ok_cik, val_cik = pcall(function() return custom.CostumeItemKey:ToString() end)
        costumeItemKey = ok_cik and val_cik or nil
      end
    end
  end)

  -- Collect the incoming slots into a lookup for diffing
  local newSlotsBySlot = {}   -- [slotEnum] = partKeyString
  pcall(function()
    local parts = CustomizationParts:get()
    if parts then
      parts.Slots:ForEach(function(_, element)
        local entry = element:get()
        local ok_pk, val_pk = pcall(function() return entry.PartKey:ToString() end)
        newSlotsBySlot[entry.Slot] = ok_pk and val_pk or ""
      end)
    end
  end)

  -- Diff: added = in new but not in current (or different PartKey);
  --       removed = in current but not in new (or different PartKey).
  local added, removed = {}, {}
  for slot, newKey in pairs(newSlotsBySlot) do
    local oldKey = currentSlots[slot]
    if oldKey ~= newKey then
      if newKey ~= "" then
        table.insert(added, { Slot = slot, PartKey = newKey })
      end
      if oldKey and oldKey ~= "" then
        table.insert(removed, { Slot = slot, PartKey = oldKey })
      end
    end
  end
  for slot, oldKey in pairs(currentSlots) do
    if newSlotsBySlot[slot] == nil and oldKey ~= "" then
      table.insert(removed, { Slot = slot, PartKey = oldKey })
    end
  end

  -- Skip emitting if nothing actually changed (RPC may fire for no-op applies)
  if #added == 0 and #removed == 0 then return end

  LogOutput("INFO",
    "ServerSetCustomizationParts: player=%s guid=%s added=%d removed=%d",
    uniqueId, characterGuid, #added, #removed)

  EnqueueWebhookEvent("ServerSetCustomizationParts", {
    UniqueID = uniqueId,
    CharacterGuid = characterGuid,
    Added = added,             -- parts put on
    Removed = removed,         -- parts taken off
    CostumeBodyKey = costumeBodyKey,
    CostumeItemKey = costumeItemKey,
  })
end)

RegisterHook("/Script/MotorTown.MTCharacter:ServerHideCostume", function(Character, bHideCostume)
  local character = Character:get()
  if not character:IsValid() then return end

  local hide = bHideCostume:get()
  local playerState = character.Net_MTPlayerState
  if not playerState:IsValid() then return end

  local uniqueId = GetUniqueNetIdAsString(playerState)
  local characterGuid = GuidToString(playerState.CharacterGuid)

  LogOutput("INFO", "ServerHideCostume: player=%s guid=%s hide=%s", uniqueId, characterGuid, tostring(hide))

  EnqueueWebhookEvent("ServerHideCostume", {
    UniqueID = uniqueId,
    CharacterGuid = characterGuid,
    HideCostume = hide,
  })
end)

-- ServerSetEquipmentInventory fires when a player equips/unequips a hat, glasses,
-- beard, or costume. We iterate the incoming inventory explicitly (per AGENTS.md:
-- avoid whole-struct conversion — safe since we only read field primitives).
-- The hook fires BEFORE the server applies the change, so reading the character's
-- current Net_EquipmentInventory gives us the OLD state for a clean per-slot diff.
RegisterHook("/Script/MotorTown.MotorTownPlayerController:ServerSetEquipmentInventory", function(PC, EquipmentInventory)
  local playerController = PC:get()
  if not playerController:IsValid() then return end
  local playerState = playerController.PlayerState
  if not playerState:IsValid() then return end

  local uniqueId = GetUniqueNetIdAsString(playerState)
  local characterGuid = GuidToString(playerState.CharacterGuid)

  -- Current equipment on the character (pre-apply)
  local currentBySlot = {}   -- [slotEnum] = itemKey string ("" if unset)
  pcall(function()
    local character = playerController.Net_MyDrivingCharacter
    if not character or not character:IsValid() then
      character = playerController:K2_GetPawn()
    end
    if character and character:IsValid() then
      character.Net_EquipmentInventory.EquipmentSlots:ForEach(function(_, element)
        local entry = element:get()
        local ok_ik, val_ik = pcall(function() return entry.ItemKey:ToString() end)
        local key = ok_ik and val_ik or ""
        if key == "None" then key = "" end
        currentBySlot[entry.Slot] = key
      end)
    end
  end)

  -- Incoming equipment (what the player is REQUESTING to wear)
  local newBySlot = {}
  pcall(function()
    local inv = EquipmentInventory:get()
    if inv then
      inv.EquipmentSlots:ForEach(function(_, element)
        local entry = element:get()
        local ok_ik, val_ik = pcall(function() return entry.ItemKey:ToString() end)
        local key = ok_ik and val_ik or ""
        if key == "None" then key = "" end
        newBySlot[entry.Slot] = key
      end)
    end
  end)

  -- Diff: Equipped = slots where a new item appears or replaces a different one;
  --       Unequipped = slots cleared or replaced by a different item.
  local equipped, unequipped = {}, {}
  for slot, newKey in pairs(newBySlot) do
    local oldKey = currentBySlot[slot] or ""
    if oldKey ~= newKey then
      if newKey ~= "" then
        table.insert(equipped, { Slot = slot, ItemKey = newKey })
      end
      if oldKey ~= "" then
        table.insert(unequipped, { Slot = slot, ItemKey = oldKey })
      end
    end
  end
  for slot, oldKey in pairs(currentBySlot) do
    if newBySlot[slot] == nil and oldKey ~= "" then
      table.insert(unequipped, { Slot = slot, ItemKey = oldKey })
    end
  end

  -- Skip no-op applies
  if #equipped == 0 and #unequipped == 0 then return end

  LogOutput("INFO",
    "ServerSetEquipmentInventory: player=%s guid=%s equipped=%d unequipped=%d",
    uniqueId, characterGuid, #equipped, #unequipped)

  EnqueueWebhookEvent("ServerSetEquipmentInventory", {
    UniqueID = uniqueId,
    CharacterGuid = characterGuid,
    Equipped = equipped,       -- items put on
    Unequipped = unequipped,   -- items taken off
  })
end)

local function HandleMutePlayer(session)
  local playerId = session.pathComponents[2]
  if not playerId then
    return { error = string.format("Invalid player ID %s", playerId) }, nil, 400
  end

  local data = json.parse(session.content)
  if data then
    local muteFor = data.MuteFor
    if muteFor == nil or muteFor == false then
      UnmutePlayer(playerId)
      return { status = "unmuted" }, nil, 200
    end
    local muteUntil = muteFor
    if type(muteUntil) == "number" and muteUntil > 0 and muteUntil < 1000000000 then
      muteUntil = os.time() + muteUntil
    end
    local hard = data.Hard == true
    MutePlayer(playerId, muteUntil, hard)
    return { status = "muted", MuteUntil = muteUntil, Hard = hard }, nil, 200
  end
  return { error = "Invalid payload" }, nil, 400
end

local function HandleUnmutePlayer(session)
  local playerId = session.pathComponents[2]
  if not playerId then
    return { error = string.format("Invalid player ID %s", playerId) }, nil, 400
  end

  UnmutePlayer(playerId)
  return { status = "unmuted" }, nil, 200
end

local function HandleGetMutedPlayers(session)
  local result = GetMutedPlayers()
  return { data = result }, nil, 200
end


---Handle request to teleport player
---@type RequestPathHandler
local function HandleTeleportPlayer(session)
  local playerId = session.pathComponents[2]
  local data = json.parse(session.content)

  if data and data.Location then
    ---@type FVector
    local location = { X = data.Location.X, Y = data.Location.Y, Z = data.Location.Z }
    ---@type FRotator
    local rotation = {
      Roll = data.Rotation and data.Rotation.Roll or 0.0,
      Pitch = data.Rotation and data.Rotation.Pitch or 0.0,
      Yaw = data.Rotation and data.Rotation.Yaw or 0.0
    }

    if playerId then
      local PC = GetPlayerControllerFromUniqueId(playerId)
      ---@cast PC AMotorTownPlayerController

      if PC:IsValid() then
        local pawn = PC:K2_GetPawn()
        if pawn:IsValid() then
          LogOutput("DEBUG", "pawn: %s", pawn:GetFullName())
          local charClass = StaticFindObject("/Script/MotorTown.MTCharacter")
          ---@cast charClass UClass
          local vehicleClass = StaticFindObject("/Script/MotorTown.MTVehicle")
          ---@cast vehicleClass UClass

          if not pawn:IsValid() then
            return
          end
          if pawn:IsA(charClass) then
            if data.Force then
              -- Bypass Motor Town's native suspect check which blocks
              -- ServerTeleportCharacter for suspects with a popup.
              -- K2_SetActorLocation moves the pawn server-side, then
              -- ClientTeleportedCharacter notifies the owning client
              -- (camera reset, loading screen, etc.) — mimicking what
              -- ServerTeleportCharacter does internally sans suspect gate.
              pawn:K2_SetActorLocation(location, false, {}, true)
              PC:ClientTeleportedCharacter(location)
            else
              PC:ServerTeleportCharacter(location, false, false)
            end
          elseif pawn:IsA(vehicleClass) and data.NoVehicles then
            return { error = string.format("Failed to teleport player %s: Player is inside a vehicle", playerId) }, nil, 400
          elseif pawn:IsA(vehicleClass) then
            ---@cast pawn AMTVehicle
            local bResetTrailers = true
            if data.bResetTrailers ~= nil then
              bResetTrailers = data.bResetTrailers
            end
            local bResetCarriedVehicles = true
            if data.bResetCarriedVehicles ~= nil then
              bResetCarriedVehicles = data.bResetCarriedVehicles
            end
            PC:ClientResetVehicleAt(pawn, location, rotation, bResetTrailers, bResetCarriedVehicles)
          else
            error("Failed to teleport player")
          end

          local msg = string.format("Teleported player %s to %s", playerId, json.stringify(location))
          return { status = msg }
        end
      end
      return { error = string.format("Failed to teleport player %s", playerId) }, nil, 400
    end
    return { error = string.format("Invalid player ID %s", playerId) }, nil, 400
  end
  return { error = "Invalid payload" }, nil, 400
end

---Get all current parties
---@return table[]
local function GetParties()
  local gameState = GetMotorTownGameState()
  local arr = {}
  if not gameState:IsValid() then return arr end

  for i = 1, #gameState.Net_Parties do
    local party = gameState.Net_Parties[i]
    local players = {}
    for j = 1, #party.Players do
      local ps = party.Players[j]
      ---@cast ps AMotorTownPlayerState
      if ps:IsValid() then
        table.insert(players, GuidToString(ps.CharacterGuid))
      end
    end
    if #players > 0 then
      table.insert(arr, {
        PartyId = party.PartyId,
        Players = players,
      })
    end
  end
  return arr
end

---Handle request for parties
---@type RequestPathHandler
local function HandleGetParties(session)
  local data = {}
  local ok = true
  data = GetParties()
  if not ok then return { error = "Game thread timeout" }, nil, 503 end
  return { data = data }, nil, 200
end

---Handle request to make a player a suspect
---Applies UGE_PoliceSuspect_C gameplay effect and appends suspect tags
---@type RequestPathHandler
local function HandleMakePlayerSuspect(session)
  local characterGuid = session.pathComponents[2]
  if not characterGuid then
    return { error = "Missing character GUID" }, nil, 400
  end

  local PC = GetPlayerControllerFromGuid(characterGuid)
  if not PC:IsValid() then
    return { error = string.format("Player %s not found", characterGuid) }, nil, 404
  end

  local result = {}
  local ok, err = pcall(function()
    if not PC:IsValid() then result.status = "pc_invalid"; return end

    local character = PC.Net_MyDrivingCharacter
    if not character or not character:IsValid() then
      character = PC:K2_GetPawn()
    end
    if not character or not character:IsValid() then result.status = "char_not_found"; return end

    local gameInstance = UEHelpers.GetGameInstance()
    if not gameInstance or not gameInstance:IsValid() then
      result.status = "game_instance_invalid"
      return
    end

    local gameResource = gameInstance.GameResource
    if not gameResource or not gameResource:IsValid() then
      result.status = "game_resource_invalid"
      return
    end

    local asc = character.AbilityComponent
    result.asc_valid = asc and asc:IsValid() or false

    local geClass = gameResource.PoliceSuspectGE
    result.ge_class_valid = geClass and geClass:IsValid() or false

    if asc and asc:IsValid() and geClass and geClass:IsValid() then
      asc:BP_ApplyGameplayEffectToSelf(geClass, 1.0, {})
      result.ge_applied = true
    else
      result.ge_applied = false
    end

    result.status = "ok"
  end)
  if not ok then
    result.status = "error"
    result.error = tostring(err)
  end

  return result, nil, 200
end

---Diagnostic: peek into Net_Suspects, character tags, and police state
---@type RequestHandler
local function HandleGetPoliceState()
  local result = { suspects = {}, police_valid = false }

  local ok, err = pcall(function()
    local gameState = GetMotorTownGameState()
    if not gameState:IsValid() then return end

    result.police_valid = gameState.Net_Police:IsValid()
    if not result.police_valid then return end

    local gameInstance = UEHelpers.GetGameInstance()
    result.game_instance_valid = gameInstance and gameInstance:IsValid() or false
    if gameInstance and gameInstance:IsValid() then
      local gameResource = gameInstance.GameResource
      result.game_resource_valid = gameResource and gameResource:IsValid() or false
      if gameResource and gameResource:IsValid() then
        result.ge_class_valid = gameResource.PoliceSuspectGE and gameResource.PoliceSuspectGE:IsValid() or false
        result.suspect_tags = {}
        local st = gameResource.SuspectGameplayTags
        if st and st:IsValid() then
          pcall(function()
            st.GameplayTags:ForEach(function(i, element)
              local tag = element:get()
              result.suspect_tags[i] = tag.TagName:ToString()
            end)
          end)
        end
      end
    end

    local police = gameState.Net_Police
    police.Net_Suspects:ForEach(function(index, suspect)
      local entry = {
        index = index,
        bOutOfSight = suspect.bOutOfSight,
      }
      if suspect.Character:IsValid() then
        entry.character_class = suspect.Character:GetClass():GetFName():ToString()
        local loc = suspect.LastSeenLocation
        entry.location = { x = loc.X, y = loc.Y, z = loc.Z }
        entry.character_tags = {}
        local charTags = suspect.Character.GameplayTagContainer
        if charTags and charTags:IsValid() then
          pcall(function()
            charTags.GameplayTags:ForEach(function(i, element)
              local tag = element:get()
              entry.character_tags[i] = tag.TagName:ToString()
            end)
          end)
        end
      else
        entry.character = "invalid"
      end
      entry.tags = {}
      pcall(function()
        suspect.ViolationTags.GameplayTags:ForEach(function(i, element)
          local tag = element:get()
          entry.tags[i] = tag.TagName:ToString()
        end)
      end)
      result.suspects[#result.suspects + 1] = entry
    end)
  end)
  if not ok then
    result.error = tostring(err)
  end

  return result, nil, 200
end

---Experimental endpoint to hide actor and disable collision
---@type RequestPathHandler
local function HandleExperimentalHideActor(session)
  local characterGuid = session.pathComponents[2]
  if not characterGuid then
    return { error = "Missing character GUID" }, nil, 400
  end

  local data = json.parse(session.content)
  local hide = true
  if data and data.hidden ~= nil then
    hide = data.hidden
  end

  local resultMsg = "not_executed"
  local PC = GetPlayerControllerFromGuid(characterGuid)
  if PC:IsValid() then
    local pawn = PC:K2_GetPawn()
    if pawn:IsValid() then
      pawn:SetActorHiddenInGame(hide)
      pawn:SetActorEnableCollision(not hide)
      resultMsg = "success: hidden=" .. tostring(hide)
    else
      resultMsg = "pawn_invalid"
    end
  else
    resultMsg = "pc_invalid"
  end

  return { status = resultMsg }, nil, 200
end

---Experimental endpoint to hide costume using MotorTown API
---@type RequestPathHandler
local function HandleExperimentalHideCostume(session)
  local characterGuid = session.pathComponents[2]
  if not characterGuid then
    return { error = "Missing character GUID" }, nil, 400
  end

  local data = json.parse(session.content)
  local hide = true
  if data and data.hide_costume ~= nil then
    hide = data.hide_costume
  end

  local resultMsg = "not_executed"
  local PC = GetPlayerControllerFromGuid(characterGuid)
  if PC:IsValid() then
    local character = PC:K2_GetPawn()
    if character:IsValid() and character:IsA(StaticFindObject("/Script/MotorTown.MTCharacter")) then
      ---@cast character AMTCharacter
      character:ServerHideCostume(hide)
      resultMsg = "success: hide_costume=" .. tostring(hide)
    else
      resultMsg = "char_invalid"
    end
  else
    resultMsg = "pc_invalid"
  end

  return { status = resultMsg }, nil, 200
end

---Experimental endpoint to manipulate Ghost flag
---@type RequestPathHandler
local function HandleExperimentalGhostFlag(session)
  local characterGuid = session.pathComponents[2]
  if not characterGuid then
    return { error = "Missing character GUID" }, nil, 400
  end

  local data = json.parse(session.content)
  local ghost = true
  if data and data.ghost ~= nil then
    ghost = data.ghost
  end

  local resultMsg = "not_executed"
  local PC = GetPlayerControllerFromGuid(characterGuid)
  if PC:IsValid() then
    local character = PC:K2_GetPawn()
    if character:IsValid() and character:IsA(StaticFindObject("/Script/MotorTown.MTCharacter")) then
      ---@cast character AMTCharacter
      local flags = character.Net_CharacterFlags
      -- 4 is EMTCharacterFlags.Ghost
      if ghost then
        flags = flags | 4
      else
        flags = flags & ~4
      end
      character.Net_CharacterFlags = flags
      resultMsg = "success: ghost=" .. tostring(ghost)
    else
      resultMsg = "char_invalid"
    end
  else
    resultMsg = "pc_invalid"
  end

  return { status = resultMsg }, nil, 200
end

---Experimental endpoint to spectate target player
---@type RequestPathHandler
local function HandleExperimentalSpectate(session)
  local callerGuid = session.pathComponents[2]
  if not callerGuid then
    return { error = "Missing caller character GUID" }, nil, 400
  end

  local data = json.parse(session.content)
  if not data or not data.target_guid then
     return { error = "Missing target_guid in payload" }, nil, 400
  end
  local targetGuid = data.target_guid

  local resultMsg = "not_executed"
  local PC = GetPlayerControllerFromGuid(callerGuid)
  local targetPC = GetPlayerControllerFromGuid(targetGuid)
  
  if PC:IsValid() and targetPC:IsValid() then
    local targetPS = targetPC.PlayerState
    if targetPS:IsValid() and targetPS:IsA(StaticFindObject("/Script/MotorTown.MotorTownPlayerState")) then
      ---@cast PC AMotorTownPlayerController
      ---@cast targetPS AMotorTownPlayerState
      PC:ServerStartSpectatingPlayer(targetPS)
      resultMsg = "success: spectating " .. tostring(targetGuid)
    else
      resultMsg = "target_ps_invalid"
    end
  else
    resultMsg = "pc_invalid"
  end

  return { status = resultMsg }, nil, 200
end

---Collect current costume/customization snapshot for a player's character.
---
---Data model (what actually works in UE4SS):
---  - Net_CustomizationParts.Slots: per-slot hair/body customization
---    (EMTCharacterCustomizationSlot: Hair=1, Body=2).
---  - Net_EquipmentInventory.EquipmentSlots: equipped items with FName ItemKey
---    (EMTEquipmentSlot: Hat=1, Glasses=2, Beard=3, Costume=4).
---  - Mesh.GetSkeletalMeshAsset(): the actively rendered body mesh. When a costume
---    is worn it replaces the main character mesh (e.g. SK_Chr_Scarecrow_01), so
---    this is the ground truth for what's currently being rendered.
---
---Note: the FMTCharacterCustomization struct (BodyKey/CostumeBodyKey/CostumeItemKey)
---is not reliably readable via UE4SS — direct struct-field FName access returns null
---UObject proxies. We rely on EquipmentInventory + rendered mesh instead.
---@param character AMTCharacter
---@return table
local function CharacterCustomizationToTable(character)
  local data = {
    Slots = {},
    Equipment = {},
    Costume = nil,
    MeshAsset = nil,
    bHideCostume = nil,
  }
  if not character or not character:IsValid() then return data end

  -- Per-slot customization (Hair / Body)
  pcall(function()
    character.Net_CustomizationParts.Slots:ForEach(function(_, element)
      local entry = element:get()
      local ok_pk, val_pk = pcall(function() return entry.PartKey:ToString() end)
      table.insert(data.Slots, {
        Slot = entry.Slot,
        PartKey = ok_pk and val_pk and val_pk ~= "None" and val_pk or nil,
      })
    end)
  end)

  -- Equipment slots (Hat=1, Glasses=2, Beard=3, Costume=4).
  -- EMTEquipmentSlot.Costume = 4 — this is the currently-worn costume key.
  pcall(function()
    character.Net_EquipmentInventory.EquipmentSlots:ForEach(function(_, element)
      local entry = element:get()
      local ok_ik, val_ik = pcall(function() return entry.ItemKey:ToString() end)
      local itemKey = ok_ik and val_ik and val_ik ~= "None" and val_ik or nil
      table.insert(data.Equipment, { Slot = entry.Slot, ItemKey = itemKey })
      if entry.Slot == 4 then
        data.Costume = itemKey
      end
    end)
  end)

  -- Rendered body mesh (ground truth for what's visually worn; costume replaces body).
  pcall(function()
    local mesh = character.Mesh
    if mesh and mesh:IsValid() then
      local ok_a, asset = pcall(function() return mesh:GetSkeletalMeshAsset() end)
      if ok_a and asset and asset:IsValid() then
        local ok_n, name = pcall(function() return asset:GetFullName() end)
        if ok_n then data.MeshAsset = name end
      end
    end
  end)

  -- Hide-costume flag (EMTCharacterFlags.HideCostume = 16)
  pcall(function()
    local flags = character.Net_CharacterFlags
    if flags ~= nil then
      data.bHideCostume = (flags & 16) ~= 0
    end
  end)

  return data
end

---Handle GET /players/{character_guid}/customization
---Returns the current costume/customization snapshot for the player's character.
---player_id is the character GUID (matches /players/*/suspect, /players/*/destination,
---/players/*/experimental/hide_costume conventions).
---@type RequestPathHandler
local function HandleGetPlayerCustomization(session)
  local characterGuid = session.pathComponents[2]
  if not characterGuid then
    return { error = "Missing character GUID" }, nil, 400
  end

  local PC = GetPlayerControllerFromGuid(characterGuid)
  if not PC:IsValid() then
    return { error = string.format("Player %s not found", characterGuid) }, nil, 404
  end

  local character = PC:K2_GetPawn()
  local charClass = StaticFindObject("/Script/MotorTown.MTCharacter")
  if not character:IsValid() or not character:IsA(charClass) then
    character = PC.Net_MyDrivingCharacter
  end
  if not character or not character:IsValid() then
    return { error = "Character not available" }, nil, 404
  end
  ---@cast character AMTCharacter

  local ps = PC.PlayerState
  local data = CharacterCustomizationToTable(character)
  data.UniqueID = ps:IsValid() and GetUniqueNetIdAsString(ps) or nil
  data.CharacterGuid = ps:IsValid() and GuidToString(ps.CharacterGuid) or nil

  return { data = data }, nil, 200
end

local function HandleSetCustomDestination(session)
  local characterGuid = session.pathComponents[2]
  if not characterGuid then
    return { error = "Missing character GUID" }, nil, 400
  end

  local data = json.parse(session.content)
  if not data or not data.Location then
    return { error = "Invalid payload: Location required" }, nil, 400
  end

  local PC = GetPlayerControllerFromGuid(characterGuid)
  if not PC:IsValid() then
    return { error = string.format("Player %s not found", characterGuid) }, nil, 404
  end

  local location = { X = data.Location.X, Y = data.Location.Y, Z = data.Location.Z }
  PC:ServerSetCustomDestination(location)
  return nil, nil, 204
end

return {
  HandleGetPlayerStates = HandleGetPlayerStates,
  GetMyCurrentTransform = GetMyCurrentTransform,
  PlayerStateToTable = PlayerStateToTable,
  HandleTeleportPlayer = HandleTeleportPlayer,
  HandleTransferMoneyToPlayer = HandleTransferMoneyToPlayer,
  HandleSetPlayerName = HandleSetPlayerName,
  HandlePlayerSendChat = HandlePlayerSendChat,
  HandleMutePlayer = HandleMutePlayer,
  HandleUnmutePlayer = HandleUnmutePlayer,
  HandleGetMutedPlayers = HandleGetMutedPlayers,
  HandleGetParties = HandleGetParties,
  HandleMakePlayerSuspect = HandleMakePlayerSuspect,
  HandleGetPoliceState = HandleGetPoliceState,
  HandleExperimentalHideActor = HandleExperimentalHideActor,
  HandleExperimentalHideCostume = HandleExperimentalHideCostume,
  HandleExperimentalGhostFlag = HandleExperimentalGhostFlag,
  HandleExperimentalSpectate = HandleExperimentalSpectate,
  HandleSetCustomDestination = HandleSetCustomDestination,
  HandleGetPlayerCustomization = HandleGetPlayerCustomization,
}
