-- ============================================================
--  POCKETOS BANK BACKEND  |  CC: Tweaked + PTR Utilities + Numismatics
--
--  Runs on a NORMAL computer at the bank. The pocket's Bank app
--  talks to this computer over the ender modem (wireless modems
--  also work if in range).
--
--  Required peripherals (direct or via wired modems):
--    - PTR Utilities Card Reader  (ptr_utilities:card_reader)
--        -> players tap cards here to create banking sessions
--    - Numismatics Bank Terminal  (Numismatics_BankTerminal)
--        -> account queries + transfers
--    - PTR Utilities Server Monitor (ptr_utilities:server_monitor)
--        -> player name resolution (optional but recommended)
--    - a modem (ender modem recommended so pockets anywhere can connect)
--
--  Protocol (channel from config below, reply channel = same):
--    request:  { t="pkt.bank.req/1", id=<n>, src=<computer id>, op="<OP>", args={...} }
--    response: { t="pkt.bank.res/1", id=<n>, ok=<bool>, data=<table>, err=<string> }
--
--  Ops:
--    PING                 -> { version, terminal }
--    ACCOUNTS             -> { { id, label, isPlayer, balance } ... }
--    BALANCE  <account>   -> { balance }
--    ACCOUNT_INFO <acc>   -> { id, label, isPlayer, balance }
--    PLAYER_NAME <uuid>   -> { name }
--    PLAYER_UUID <name>   -> { uuid }
--    AUTHORIZED_ACCOUNTS <player uuid> -> { { id, label, isPlayer, balance } ... }
--    SESSIONS             -> { { sessionID, accountID, type, scannedByName,
--                                balance, authorizationID?, expiresAt } ... }
--    TRANSFER <session id> <to account> <amount spurs> -> receipt or error
--    LINK_STATUS  <pocket id>  -> { linked, accountID, label, balance, maxAvailable }
--    LINK_ENROLL <pocket id>   -> enrollment via active authorized-card session
--    LINK_UNENROLL <pocket id> -> forget the pocket's stored authorization
--    LINK_PAY <pocket id> <to account> <amount spurs> -> receipt or error
--    LINK_AVAILABLE <pocket id> -> { maxAvailable }
--
--  Security model (unchanged from the ATM, plus enrollment):
--    - one-off ATM transfers: card sessions from the physical Card Reader
--      (60s expiry, consumed on use, one transfer per scan)
--    - recurring/remote spending ("Amazon"): a player creates a
--      sub-account with a SPEND LIMIT in their Bank Terminal, makes an
--      AUTHORIZED card bound to it, and taps that card at the reader
--      once to enroll a pocket. The backend stores the
--      (accountID, authorizationID) pair keyed by pocket computer id.
--      Numismatics itself enforces the sub-account's spend limit --
--      the backend (and any leaked pair) cannot overspend it.
--
--  Transfer security model (same as the ATM script):
--    - card sessions come from the physical Card Reader (60s expiry,
--      consumed on use, one transfer per scan)
--    - the backend forwards to the Card Reader peripheral's
--      transfer(), which enforces card type + session validity
-- ============================================================

os.pullEvent = os.pullEventRaw

-- ============================================================
--  CONFIG
-- ============================================================

local CFG = {
    channel     = 54123,          -- must match BankChannel in PocketOS Config
    footerText  = "PocketOS Bank Backend",
}

-- allow overriding via a config file next to the script
local CONFIG_FILE = "bank_backend_config.json"
if fs.exists(CONFIG_FILE) then
    local f = fs.open(CONFIG_FILE, "r")
    if f then
        local ok, parsed = pcall(textutils.unserialiseJSON, f.readAll())
        f.close()
        if ok and type(parsed) == "table" then
            for k, v in pairs(parsed) do CFG[k] = v end
        end
    end
end

-- ============================================================
--  PERIPHERAL DISCOVERY
-- ============================================================

print("PocketOS Bank Backend starting...")
print("Scanning for peripherals...")

local function findPeripheral(ptype)
    for _, name in ipairs(peripheral.getNames()) do
        if peripheral.getType(name) == ptype then return name end
    end
    return nil
end

local readerName = findPeripheral("ptr_utilities:card_reader")
local termName   = findPeripheral("Numismatics_BankTerminal")
local monName    = findPeripheral("ptr_utilities:server_monitor")
local modemName  = findPeripheral("modem")

if not readerName then error("No PTR Utilities Card Reader found!", 0) end
if not termName   then error("No Numismatics Bank Terminal found!", 0) end
if not modemName  then error("No modem found! Attach a (ender) modem.", 0) end

local reader = peripheral.wrap(readerName)
local bank   = peripheral.wrap(termName)
local mon    = monName and peripheral.wrap(monName) or nil

local modem = peripheral.wrap(modemName)
modem.open(CFG.channel)

print("  [OK] Card Reader   -> " .. readerName)
print("  [OK] Bank Terminal -> " .. termName)
if mon then
    print("  [OK] Server Monitor -> " .. monName)
else
    print("  [--] Server Monitor not found (name resolution limited)")
end
print("  [OK] Modem         -> " .. modemName .. " (ch " .. CFG.channel .. ")")

-- ============================================================
--  HELPERS
-- ============================================================

local nameCache = {}

local function nameFromUUID(uuidStr)
    if not uuidStr then return nil end
    if nameCache[uuidStr] then return nameCache[uuidStr] end

    if mon then
        local ok, name = pcall(mon.uuidToName, uuidStr)
        if ok and name and name ~= "" then
            nameCache[uuidStr] = name
            return name
        end
    end

    local okLabel, label = pcall(bank.getAccountLabel, uuidStr)
    if okLabel and label and label ~= "block.numismatics.bank_terminal" and label ~= "Bank Terminal" then
        nameCache[uuidStr] = label
        return label
    end

    return nil
end

-- resolve an account to a display label (player name, banker label, or owner's banker)
local function resolveAccountLabel(accID)
    local okLabel, label = pcall(bank.getAccountLabel, accID)
    if okLabel and label
       and label ~= "block.numismatics.bank_terminal"
       and label ~= "Bank Terminal" then
        return label, false
    end

    local okPlayer, isPlayer = pcall(bank.isPlayerOwned, accID)
    if okPlayer and isPlayer then
        local name = nameFromUUID(accID)
        if name then return name, true end
    end

    -- unlabeled blaze banker -> trust list owner
    if reader then
        local okTrust, trust = pcall(reader.getTrustList, accID)
        if okTrust and trust and #trust > 0 then
            local ownerName = nameFromUUID(trust[1])
            if ownerName then return ownerName .. "'s Banker", false end
        end
    end

    return "Banker " .. string.sub(tostring(accID), 1, 8), false
end

-- account entry for the pocket
local function accountEntry(accID)
    local label, isPlayer = resolveAccountLabel(accID)
    local okBal, bal = pcall(bank.getBalance, accID)
    return {
        id = accID,
        label = label,
        isPlayer = isPlayer,
        balance = okBal and bal or 0,
    }
end

-- get the card reader's current card data (session source of truth)
local function currentSessionData()
    local ok, data = pcall(reader.getCardData)
    if not ok or type(data) ~= "table" then return nil end
    if not data.accountID and not data.playerID then return nil end
    return data
end

local function isSessionExpired(data)
    if not data or not data.sessionExpiresAt then return true end
    local now = os.epoch("utc") / 1000
    return now >= data.sessionExpiresAt
end

local function sessionEntryFromData(data)
    local entry = {
        sessionID = data.sessionID,
        accountID = data.accountID or data.playerID,
        type = data.type,
        scannedByName = data.scannedByName,
        authorizationID = data.authorizationID,
        terminalID = data.terminalID,
        expiresAt = data.sessionExpiresAt,
    }

    local okBal, bal = pcall(bank.getBalance, entry.accountID)
    if okBal then entry.balance = bal end

    return entry
end

-- ============================================================
--  LINKED POCKETS (persistent enrollment store)
--
--  links.json: { [pocket_computer_id] = {
--      accountID = parent account uuid,
--      authorizationID = sub-account authorization uuid,
--      label = display name, enrolledBy = who tapped, at = unix time } }
-- ============================================================

local LINKS_FILE = "bank_backend_links.json"

local function load_links()
    if not fs.exists(LINKS_FILE) then return {} end
    local f = fs.open(LINKS_FILE, "r")
    if not f then return {} end
    local ok, parsed = pcall(textutils.unserialiseJSON, f.readAll())
    f.close()
    if ok and type(parsed) == "table" then return parsed end
    return {}
end

local function save_links(links)
    local f = fs.open(LINKS_FILE, "w")
    if not f then return end
    f.write(textutils.serialiseJSON(links))
    f.close()
end

-- ============================================================
--  OP HANDLERS
-- ============================================================

local OPS = {}

OPS.PING = function ()
    return { version = "1.0.0", terminal = reader.getTerminalID and reader.getTerminalID() or "?" }
end

OPS.ACCOUNTS = function ()
    local ok, accounts = pcall(bank.getAccounts)
    if not ok or type(accounts) ~= "table" then return nil, "failed to list accounts" end

    local out = {}
    for _, accID in ipairs(accounts) do
        table.insert(out, accountEntry(accID))
    end
    return out
end

OPS.BALANCE = function (account_id)
    if type(account_id) ~= "string" then return nil, "account id required" end
    local ok, bal = pcall(bank.getBalance, account_id)
    if not ok then return nil, tostring(bal) end
    return { balance = bal }
end

OPS.ACCOUNT_INFO = function (account_id)
    if type(account_id) ~= "string" then return nil, "account id required" end
    return accountEntry(account_id)
end

OPS.PLAYER_NAME = function (uuid)
    local name = nameFromUUID(uuid)
    if not name then return nil, "name not found" end
    return { name = name }
end

OPS.PLAYER_UUID = function (name)
    if not mon then return nil, "server monitor not attached" end
    local ok, uuid = pcall(mon.nameToUuid, name)
    if not ok or not uuid then return nil, "player not found" end
    return { uuid = uuid }
end

OPS.AUTHORIZED_ACCOUNTS = function (player_uuid)
    if not reader then return nil, "card reader not attached" end
    local ok, accs = pcall(reader.getAuthorizedAccounts, player_uuid)
    if not ok then return nil, "query failed" end

    local out = {}
    for _, accID in ipairs(accs) do
        table.insert(out, accountEntry(accID))
    end
    return out
end

OPS.SESSIONS = function ()
    local data = currentSessionData()
    if not data then return {} end
    if isSessionExpired(data) then return {} end
    if data.sessionConsumed then return {} end
    return { sessionEntryFromData(data) }
end

OPS.TRANSFER = function (session_id, to_account, amount)
    if type(session_id) ~= "string" then return nil, "session id required" end
    if type(to_account) ~= "string" then return nil, "destination account required" end

    amount = math.floor(tonumber(amount) or 0)
    if amount <= 0 then return nil, "amount must be > 0" end

    local data = currentSessionData()
    if not data then return nil, "no active card session - tap card at the bank" end
    if data.sessionConsumed then return nil, "session already used - re-tap card" end
    if isSessionExpired(data) then return nil, "session expired - re-tap card" end
    if data.sessionID ~= session_id then return nil, "session mismatch" end

    -- execute through the card reader (validates card type + session)
    local ok, receipt = pcall(reader.transfer, to_account, amount)
    if not ok then
        return nil, tostring(receipt):gsub("^[^:]+:%s*", "")
    end

    return receipt
end

--#region LINKED POCKET OPS

local function get_link(pocket_id)
    local links = load_links()
    local link = links[tostring(pocket_id)]
    if type(link) ~= "table" or not link.accountID or not link.authorizationID then
        return nil, "pocket not linked - enroll with an authorized card"
    end
    return link
end

OPS.LINK_STATUS = function (pocket_id)
    local link, err = get_link(pocket_id)
    if not link then return nil, err end

    local out = {
        linked = true,
        accountID = link.accountID,
        label = link.label,
    }

    local okBal, bal = pcall(bank.getBalance, link.accountID)
    if okBal then out.balance = bal end

    local okMax, maxavail = pcall(bank.getMaxAvailableWithdrawal, link.accountID, link.authorizationID)
    if okMax then out.maxAvailable = maxavail end

    return out
end

OPS.LINK_ENROLL = function (pocket_id)
    if type(pocket_id) ~= "number" then return nil, "pocket id required" end

    local data = currentSessionData()
    if not data then return nil, "no active card session - tap card at the bank" end
    if data.sessionConsumed then return nil, "session already used - re-tap card" end
    if isSessionExpired(data) then return nil, "session expired - re-tap card" end

    -- enrollment REQUIRES an authorized card (sub-account)
    if data.type ~= "authorized" or not data.authorizationID then
        return nil, "enrollment requires an authorized card (sub-account)"
    end

    local links = load_links()

    links[tostring(pocket_id)] = {
        accountID = data.accountID,
        authorizationID = data.authorizationID,
        label = (data.scannedByName or "?") .. " sub-account",
        enrolledBy = data.scannedByName,
        at = os.epoch("utc") / 1000,
    }

    save_links(links)

    -- consume the session so the same scan can't enroll two pockets
    if reader.clear then pcall(reader.clear) end

    local label, _ = resolveAccountLabel(data.accountID)
    return { linked = true, accountID = data.accountID, label = label }
end

OPS.LINK_UNENROLL = function (pocket_id)
    local links = load_links()
    if links[tostring(pocket_id)] == nil then
        return nil, "pocket not linked"
    end
    links[tostring(pocket_id)] = nil
    save_links(links)
    return { unlinked = true }
end

OPS.LINK_PAY = function (pocket_id, to_account, amount)
    if type(to_account) ~= "string" then return nil, "destination account required" end

    amount = math.floor(tonumber(amount) or 0)
    if amount <= 0 then return nil, "amount must be > 0" end

    local link, err = get_link(pocket_id)
    if not link then return nil, err end

    -- spend through the sub-account; Numismatics enforces the limit
    local ok, receipt = pcall(bank.transfer,
        link.accountID, link.authorizationID, to_account, amount)

    if not ok then
        return nil, tostring(receipt):gsub("^[^:]+:%s*", "")
    end

    return receipt
end

OPS.LINK_AVAILABLE = function (pocket_id)
    local link, err = get_link(pocket_id)
    if not link then return nil, err end

    local ok, maxavail = pcall(bank.getMaxAvailableWithdrawal, link.accountID, link.authorizationID)
    if not ok then return nil, tostring(maxavail) end
    return { maxAvailable = maxavail }
end

--#endregion

-- ============================================================
--  REQUEST PUMP
-- ============================================================

local function handleRequest(msg, reply_channel)
    if type(msg) ~= "table" or msg.t ~= "pkt.bank.req/1" then return end

    local handler = OPS[msg.op]
    if not handler then
        modem.transmit(reply_channel, CFG.channel, {
            t = "pkt.bank.res/1", id = msg.id, ok = false, err = "unknown op " .. tostring(msg.op)
        })
        return
    end

    local ok, data_or_err = pcall(handler, table.unpack(msg.args or {}))
    if ok and data_or_err ~= nil then
        modem.transmit(reply_channel, CFG.channel, {
            t = "pkt.bank.res/1", id = msg.id, ok = true, data = data_or_err
        })
    elseif ok then
        -- handler returned nothing: treat as empty success
        modem.transmit(reply_channel, CFG.channel, {
            t = "pkt.bank.res/1", id = msg.id, ok = true, data = {}
        })
    else
        modem.transmit(reply_channel, CFG.channel, {
            t = "pkt.bank.res/1", id = msg.id, ok = false, err = tostring(data_or_err)
        })
    end
end

print("\nBank backend ready. Pockets on channel " .. CFG.channel .. " can now connect.")

while true do
    local event, p1, p2, p3, p4, p5 = os.pullEvent()

    if event == "modem_message" then
        -- side/frequency, reply frequency, long distance, payload
        local side, freq, reply_freq, payload = p1, p2, p3, p4
        local ok, msg = pcall(textutils.unserialiseJSON, payload)
        if ok and type(msg) == "table" then
            handleRequest(msg, reply_freq)
        end
    elseif event == "card_scanned" then
        local data = p2
        if type(data) == "table" then
            local who = data.scannedByName or "?"
            print("[session] " .. who .. " tapped a " .. (data.type or "?") .. " card")
            if data.sessionExpiresAt then
                local ttl = math.floor(data.sessionExpiresAt - os.epoch("utc") / 1000)
                print("           session valid " .. ttl .. "s")
            end
        end
    end
end
