-- ---------------------------------------------------------------------------
--  Authoritative player registry.
--
--  While a player is online their state lives in this table and nowhere else.
--  Balance changes are applied in memory *synchronously* (no yield between
--  read and write, so two concurrent handlers cannot both spend the same
--  money) and then mirrored to MariaDB asynchronously alongside a row in
--  `transactions`, which is the audit trail.
-- ---------------------------------------------------------------------------

Core = Core or {}

local players = {}          -- [source] = player table
local byChar  = {}          -- [character id] = source

-- ---------------------------------------------------------------------------
--  Lookup
-- ---------------------------------------------------------------------------

function Core.Get(src)              return players[src] end
function Core.GetByChar(charId)     return players[byChar[charId] or -1] end
function Core.All()                 return players end

local function requireChar(src)
  local p = players[src]
  if not p or not p.char then return nil end
  return p
end

-- ---------------------------------------------------------------------------
--  Lifecycle
-- ---------------------------------------------------------------------------

--- Called by rp_session once credentials have been accepted.
function Core.AttachAccount(src, account)
  players[src] = {
    source   = src,
    license  = account.license,
    account  = { id = account.id, username = account.username, role = account.role },
    char     = nil,
    spawned  = false,
    joinedAt = os.time(),
  }
  return players[src]
end

--- Called once a character has been chosen. `row` is the DB row.
function Core.AttachCharacter(src, row)
  local p = players[src]
  if not p then return nil end

  p.char = {
    id         = row.id,
    firstName  = row.first_name,
    lastName   = row.last_name,
    dob        = row.dob,
    gender     = row.gender,
    backstory  = row.backstory,
    cash       = math.floor(tonumber(row.cash) or 0),
    bank       = math.floor(tonumber(row.bank) or 0),
    health     = tonumber(row.health) or 200,
    armour     = tonumber(row.armour) or 0,
    job        = row.job or 'unemployed',
    jobGrade   = tonumber(row.job_grade) or 0,
    playtime   = tonumber(row.playtime) or 0,
    appearance = row.appearance and json.decode(row.appearance) or nil,
    position   = row.position and json.decode(row.position) or nil,
    needs      = row.needs and json.decode(row.needs) or { hunger = 100, thirst = 100, stress = 0 },
  }
  p.sessionStart = os.time()
  byChar[row.id] = src

  Core.PushCharacter(src)
  Core.PublishState(src)
  return p.char
end

function Core.Detach(src)
  local p = players[src]
  if not p then return end
  if p.char then byChar[p.char.id] = nil end
  players[src] = nil
end

-- ---------------------------------------------------------------------------
--  Synchronisation
-- ---------------------------------------------------------------------------

--- Private state: only the owning client receives this.
function Core.PushCharacter(src)
  local p = requireChar(src)
  if not p then return end
  TriggerClientEvent('rp:core:character', src, p.char)
end

--- Public state: what every other client is allowed to know about this player.
function Core.PublishState(src)
  local p = requireChar(src)
  if not p then return end
  local st = Player(src).state
  st:set('rp:name', p.char.firstName .. ' ' .. p.char.lastName, true)
  st:set('rp:job', p.char.job, true)
  st:set('rp:charId', p.char.id, true)
end

-- ---------------------------------------------------------------------------
--  Money
-- ---------------------------------------------------------------------------

local VALID_ACCOUNTS = { cash = true, bank = true }

local function journal(charId, kind, delta, balanceAfter, reason)
  DB.TransactionAsync({
    {
      query  = ('UPDATE characters SET %s = ? WHERE id = ?'):format(kind),
      values = { balanceAfter, charId },
    },
    {
      query  = 'INSERT INTO transactions (character_id, account_kind, delta, balance_after, reason) VALUES (?, ?, ?, ?, ?)',
      values = { charId, kind, delta, balanceAfter, reason },
    },
  }, function(ok, err)
    if not ok then
      print(('^1[rp_core]^7 failed to journal %s %+d for character %d: %s')
        :format(kind, delta, charId, tostring(err)))
    end
  end)
end

--- Returns true on success. Amounts are always positive integers.
function Core.AddMoney(src, kind, amount, reason)
  local p = requireChar(src)
  if not p or not VALID_ACCOUNTS[kind] then return false end
  amount = math.floor(tonumber(amount) or 0)
  if amount <= 0 then return false end

  p.char[kind] = p.char[kind] + amount        -- no yield: cannot interleave
  local after = p.char[kind]

  Core.PushCharacter(src)
  journal(p.char.id, kind, amount, after, reason or 'unspecified')
  return true
end

--- Returns false (and changes nothing) when the player cannot afford it.
function Core.RemoveMoney(src, kind, amount, reason)
  local p = requireChar(src)
  if not p or not VALID_ACCOUNTS[kind] then return false end
  amount = math.floor(tonumber(amount) or 0)
  if amount <= 0 then return false end
  if p.char[kind] < amount then return false end

  p.char[kind] = p.char[kind] - amount
  local after = p.char[kind]

  Core.PushCharacter(src)
  journal(p.char.id, kind, -amount, after, reason or 'unspecified')
  return true
end

function Core.GetMoney(src, kind)
  local p = requireChar(src)
  if not p or not VALID_ACCOUNTS[kind] then return 0 end
  return p.char[kind]
end

-- ---------------------------------------------------------------------------
--  Persistence
-- ---------------------------------------------------------------------------

--- Reads position and health from the server's own copy of the entity rather
--- than asking the client, so a modified client cannot lie about either.
local function snapshot(p)
  local ped = GetPlayerPed(p.source)
  if ped and ped ~= 0 and DoesEntityExist(ped) then
    local c = GetEntityCoords(ped)
    local h = GetEntityHeading(ped)
    if c and c.x and not (c.x == 0.0 and c.y == 0.0) then
      p.char.position = { x = c.x, y = c.y, z = c.z, h = h }
    end
    local hp = GetEntityHealth(ped)
    if hp and hp > 0 then p.char.health = hp end
  end
  if p.sessionStart then
    local now = os.time()
    p.char.playtime = p.char.playtime + (now - p.sessionStart)
    p.sessionStart = now
  end
end

function Core.Save(src, reason)
  local p = requireChar(src)
  if not p or not p.spawned then return end
  snapshot(p)
  local c = p.char

  DB.UpdateAsync([[
    UPDATE characters
       SET cash = ?, bank = ?, health = ?, armour = ?, job = ?, job_grade = ?,
           position = ?, needs = ?, appearance = ?, playtime = ?, last_played_at = NOW()
     WHERE id = ?
  ]], {
    c.cash, c.bank, c.health, c.armour, c.job, c.jobGrade,
    json.encode(c.position or {}), json.encode(c.needs or {}),
    c.appearance and json.encode(c.appearance) or DB.NULL,
    c.playtime, c.id,
  }, function(_, err)
    if err then
      print(('^1[rp_core]^7 save failed for character %d (%s): %s'):format(c.id, tostring(reason), err))
    end
  end)
end

function Core.SaveAll(reason)
  local n = 0
  for src in pairs(players) do
    if players[src].char and players[src].spawned then
      Core.Save(src, reason)
      n = n + 1
    end
  end
  return n
end

CreateThread(function()
  local interval = (Config.Session.autosaveSeconds or 300) * 1000
  while true do
    Wait(interval)
    local n = Core.SaveAll('autosave')
    if n > 0 then print(('^5[rp_core]^7 autosaved %d character(s)'):format(n)) end
  end
end)

AddEventHandler('playerDropped', function(reason)
  local src = source
  Core.Save(src, 'disconnect: ' .. tostring(reason))
  Core.Detach(src)
end)

AddEventHandler('onResourceStop', function(res)
  if res ~= GetCurrentResourceName() then return end
  local n = Core.SaveAll('resource stop')
  print(('^5[rp_core]^7 saving %d character(s) on shutdown'):format(n))
end)

-- ---------------------------------------------------------------------------
--  Exports for the other resources. None of these yield.
-- ---------------------------------------------------------------------------

exports('getPlayer',      function(src) return players[src] end)
exports('getCharacter',   function(src) local p = players[src]; return p and p.char or nil end)
exports('attachAccount',  Core.AttachAccount)
exports('attachCharacter',Core.AttachCharacter)
exports('detach',         Core.Detach)
exports('addMoney',       Core.AddMoney)
exports('removeMoney',    Core.RemoveMoney)
exports('getMoney',       Core.GetMoney)
exports('save',           Core.Save)
exports('pushCharacter',  Core.PushCharacter)
exports('publishState',   Core.PublishState)
exports('setSpawned',     function(src, v)
  local p = players[src]
  if p then p.spawned = v and true or false end
end)
