-- ---------------------------------------------------------------------------
--  rp_session - everything between "player opened the server" and "player is
--  standing in the world": ban gate, accounts, characters, spawn.
--
--  The client drives the UI, but every decision is made here. The client may
--  only ever ask; it can never assert that it is logged in, that a character
--  belongs to it, or where it spawned.
-- ---------------------------------------------------------------------------

local sessions = {}     -- [src] = { license, ip, accountId, username, role, stage }

-- This build of FXServer ships no `hardcap` resource (it only advertises the
-- name in /info.json), and the bundled `chat` and `monitor` resources are not
-- started, so capacity has to be enforced here - see the deferral below.
-- Kept as a guard in case a future artefact does ship one.
CreateThread(function()
  if GetResourceState('hardcap') == 'started' then
    StopResource('hardcap')
    print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource')
  end
end)

local STAGE_AUTH  = 'auth'
local STAGE_CHARS = 'characters'
local STAGE_SPAWN = 'spawn'
local STAGE_LIVE  = 'live'

-- ---------------------------------------------------------------------------
--  helpers
-- ---------------------------------------------------------------------------

local function licenseOf(src)
  for i = 0, GetNumPlayerIdentifiers(src) - 1 do
    local id = GetPlayerIdentifier(src, i)
    if id and id:sub(1, 8) == 'license:' then return id:sub(9) end
  end
  return nil
end

local function hashPassword(pw)
  local p = promise.new()
  exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end)
  local r = Citizen.Await(p)
  return r.hash, r.err
end

local function verifyPassword(pw, stored)
  local p = promise.new()
  exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
  local r = Citizen.Await(p)
  if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end
  return r.ok == true
end

local function recordAttempt(identity, ip, success)
  DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)',
    { identity, ip or DB.NULL, success and 1 or 0 })
end

--- True when this identity has burned through its allowance recently.
local function throttled(identity)
  local n = DB.Scalar(
    'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)',
    { identity, Config.Session.attemptWindowS })
  return (tonumber(n) or 0) >= Config.Session.maxAttempts
end

local function characterRows(accountId)
  return DB.Query([[
    SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
           cash, bank, job, job_grade, playtime, last_played_at
      FROM characters
     WHERE account_id = ? AND deleted = 0
     ORDER BY slot ASC, id ASC
  ]], { accountId }) or {}
end

--- Trim a DB row down to what the character-select screen is allowed to see.
local function characterCard(row)
  return {
    id        = row.id,
    firstName = row.first_name,
    lastName  = row.last_name,
    dob       = tostring(row.dob):sub(1, 10),
    gender    = row.gender,
    backstory = row.backstory,
    cash      = math.floor(tonumber(row.cash) or 0),
    bank      = math.floor(tonumber(row.bank) or 0),
    job       = row.job,
    playtime  = math.floor(tonumber(row.playtime) or 0),
    lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil,
    appearance= row.appearance and json.decode(row.appearance) or nil,
  }
end

local function sessionOf(src, requiredStage)
  local s = sessions[src]
  if not s then return nil, 'no session' end
  if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end
  return s
end

-- ---------------------------------------------------------------------------
--  connection gate
-- ---------------------------------------------------------------------------

AddEventHandler('playerConnecting', function(_, _, deferrals)
  local src = source
  deferrals.defer()
  Wait(0)

  local license = licenseOf(src)
  if not license then
    return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.')
  end

  -- Capacity. This replaces the stock `hardcap` resource, which is stopped in
  -- server.cfg so that everything running here is our own code.
  local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48
  local online = #GetPlayers()          -- the joining player is not counted yet
  if online >= maxClients then
    return deferrals.done(
      ('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients))
  end

  deferrals.update('Checking your record...')

  if not DB.WaitReady(15000) then
    return deferrals.done('The server database is not available right now. Try again in a minute.')
  end

  local ban = DB.Single([[
    SELECT id, ban_reason, ban_until
      FROM accounts
     WHERE license = ? AND banned = 1
       AND (ban_until IS NULL OR ban_until > NOW())
     LIMIT 1
  ]], { license })

  if ban then
    local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently'
    return deferrals.done(('You are banned%s.\n\nReason: %s')
      :format(until_, ban.ban_reason or 'no reason recorded'))
  end

  -- clear bans that have run out so the account is usable again
  DB.UpdateAsync([[
    UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL
     WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW()
  ]], { license })

  sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH }
  deferrals.done()
end)

AddEventHandler('playerDropped', function()
  sessions[source] = nil
end)

-- ---------------------------------------------------------------------------
--  callbacks: authentication
-- ---------------------------------------------------------------------------

Core.RegisterCallback('session:state', function(src)
  local s = sessions[src]
  if not s then return nil, 'no session' end
  return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars }
end)

Core.RegisterCallback('auth:register', function(src, data)
  local s, e = sessionOf(src, STAGE_AUTH)
  if not s then return nil, e end
  if type(data) ~= 'table' then return nil, 'malformed request' end

  local username = Util.trim(data.username or '')
  local password = data.password or ''

  local ok, why = Rules.checkUsername(username)
  if not ok then return nil, why end
  ok, why = Rules.checkPassword(password)
  if not ok then return nil, why end

  if throttled(s.license) then
    return nil, 'Too many attempts. Wait a few minutes and try again.'
  end

  local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username })
  if taken then
    recordAttempt(s.license, s.ip, false)
    return nil, 'That username is already taken'
  end

  local hash, herr = hashPassword(password)
  if not hash then
    print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr)))
    return nil, 'Could not create the account, try again'
  end

  local id, ierr = DB.Insert(
    'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)',
    { username, hash, s.license, s.ip or DB.NULL })

  if not id then
    -- unique key race: someone registered the same name a moment ago
    print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr)))
    return nil, 'That username is already taken'
  end

  recordAttempt(s.license, s.ip, true)

  s.accountId = id
  s.username  = username
  s.role      = 'player'
  s.stage     = STAGE_CHARS

  exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license })
  print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src)))

  return { username = username, characters = {}, maxChars = Config.MaxChars }
end)

Core.RegisterCallback('auth:login', function(src, data)
  local s, e = sessionOf(src, STAGE_AUTH)
  if not s then return nil, e end
  if type(data) ~= 'table' then return nil, 'malformed request' end

  local username = Util.trim(data.username or '')
  local password = data.password or ''
  if username == '' or password == '' then return nil, 'Enter your username and password' end

  if throttled(s.license) then
    return nil, 'Too many failed attempts. Wait a few minutes and try again.'
  end

  local acc = DB.Single(
    'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1',
    { username })

  -- Always run a verification so a missing username and a wrong password take
  -- the same amount of time and cannot be told apart from outside.
  local stored = acc and acc.password_hash
    or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
  local good = verifyPassword(password, stored)

  if not acc or not good then
    recordAttempt(s.license, s.ip, false)
    return nil, 'Incorrect username or password'
  end

  if acc.banned == 1 then
    -- re-checked against NOW() so an expired ban does not keep locking them out
    local active = DB.Scalar(
      'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())',
      { acc.id })
    if active == 1 then
      return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded')
    end
  end

  recordAttempt(s.license, s.ip, true)

  DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?',
    { s.ip or DB.NULL, s.license, acc.id })

  s.accountId = acc.id
  s.username  = acc.username
  s.role      = acc.role
  s.stage     = STAGE_CHARS

  exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license })

  local cards = {}
  for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end

  print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username))
  return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars }
end)

-- ---------------------------------------------------------------------------
--  callbacks: characters
-- ---------------------------------------------------------------------------

Core.RegisterCallback('char:list', function(src)
  local s, e = sessionOf(src, STAGE_CHARS)
  if not s then return nil, e end
  local cards = {}
  for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end
  return { characters = cards, maxChars = Config.MaxChars }
end)

Core.RegisterCallback('char:create', function(src, data)
  local s, e = sessionOf(src, STAGE_CHARS)
  if not s then return nil, e end
  if type(data) ~= 'table' then return nil, 'malformed request' end

  local first = Util.properName(data.firstName or '')
  local last  = Util.properName(data.lastName or '')

  local ok, why = Rules.checkName(first)
  if not ok then return nil, 'First name: ' .. why end
  ok, why = Rules.checkName(last)
  if not ok then return nil, 'Last name: ' .. why end
  ok, why = Rules.checkDob(data.dob or '')
  if not ok then return nil, 'Date of birth: ' .. why end

  local gender = (data.gender == 'f') and 'f' or 'm'
  local backstory = Util.trim(data.backstory or '')
  if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end

  local existing = tonumber(DB.Scalar(
    'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0
  if existing >= Config.MaxChars then
    return nil, ('You already have %d characters'):format(Config.MaxChars)
  end

  local clash = DB.Scalar(
    'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last })
  if clash then return nil, 'Someone on this server already has that name' end

  local appearance = Appearance.Sanitise(data.appearance)

  local id, ierr = DB.Insert([[
    INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory,
                            appearance, cash, bank)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  ]], {
    s.accountId, existing, first, last, data.dob, gender,
    backstory ~= '' and backstory or DB.NULL,
    json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank,
  })

  if not id then
    print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr)))
    return nil, 'Could not create that character'
  end

  print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id))

  local row = DB.Single([[
    SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
           cash, bank, job, job_grade, playtime, last_played_at
      FROM characters WHERE id = ?
  ]], { id })

  return { character = characterCard(row) }
end)

Core.RegisterCallback('char:delete', function(src, data)
  local s, e = sessionOf(src, STAGE_CHARS)
  if not s then return nil, e end
  local id = tonumber(type(data) == 'table' and data.id or nil)
  if not id then return nil, 'malformed request' end

  -- ownership is checked in the WHERE clause, so a forged id changes nothing
  local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0',
    { id, s.accountId })
  if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end

  print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id))
  return { deleted = id }
end)

Core.RegisterCallback('char:select', function(src, data)
  local s, e = sessionOf(src, STAGE_CHARS)
  if not s then return nil, e end
  local id = tonumber(type(data) == 'table' and data.id or nil)
  if not id then return nil, 'malformed request' end

  local row = DB.Single([[
    SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0
  ]], { id, s.accountId })
  if not row then return nil, 'That character is not yours' end

  exports.rp_core:attachCharacter(src, row)
  s.stage = STAGE_SPAWN
  s.charId = id

  local last = row.position and json.decode(row.position) or nil
  local spawns = {}
  for _, sp in ipairs(Config.SpawnPoints) do
    spawns[#spawns + 1] = {
      id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map,
      coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w },
      cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z },
              look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } },
    }
  end

  return {
    character = characterCard(row),
    appearance = row.appearance and json.decode(row.appearance) or nil,
    spawns = spawns,
    lastPosition = last,
  }
end)

-- ---------------------------------------------------------------------------
--  callbacks: spawn
-- ---------------------------------------------------------------------------

Core.RegisterCallback('spawn:confirm', function(src, data)
  local s, e = sessionOf(src, STAGE_SPAWN)
  if not s then return nil, e end

  local choice = type(data) == 'table' and data.id or nil
  local coords

  if choice == 'last' then
    local p = exports.rp_core:getCharacter(src)
    local last = p and p.position
    if last and last.x then
      coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 }
    end
  end

  if not coords then
    for _, sp in ipairs(Config.SpawnPoints) do
      if sp.id == choice then
        coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
        break
      end
    end
  end

  if not coords then
    local sp = Config.SpawnPoints[1]
    coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
  end

  s.stage = STAGE_LIVE
  exports.rp_core:setSpawned(src, true)

  local char = exports.rp_core:getCharacter(src)
  print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName))

  TriggerClientEvent('rp:session:spawned', src, coords)
  return { coords = coords, character = char }
end)
