local CURRENT_STATION_CODE = 'Ticket-Machine'
local API_BASE = 'http://ticket.fse-media.group/api'
local VERSION = 'v1.5.7'

-- ###########################
-- Core HTTP & JSON Utilities
-- ###########################
local function jsonDecode(str)
  if not str then return nil end
  local ok, res = pcall(textutils.unserializeJSON, str)
  if ok then return res else return nil end
end

local serverConnected = nil
local serverLastChangeTs = 0

local function setServerConnected(ok)
  if serverConnected == ok then return end
  serverConnected = ok
  serverLastChangeTs = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
  os.queueEvent('config_updated')
end

local function fetchHTTP(url)
  if not http then
    print('Error: HTTP API is disabled in ComputerCraft config!')
    setServerConnected(false)
    return nil
  end

  if http.get then
    local ok, res, err = pcall(http.get, url)
    if not ok or not res then
      if err then print('HTTP GET failed: ' .. tostring(err) .. ' url=' .. tostring(url)) end
      setServerConnected(false)
      return nil
    end
    local data = res.readAll()
    res.close()
    setServerConnected(true)
    return data
  end

  if not http.request then
    print('Error: HTTP API is disabled in ComputerCraft config!')
    return nil
  end

  local okReq, reqOkOrErr = pcall(http.request, { url = url, method = 'GET' })
  if not okReq or not reqOkOrErr then
    print('HTTP request failed: ' .. tostring(reqOkOrErr) .. ' url=' .. tostring(url))
    setServerConnected(false)
    return nil
  end

  while true do
    local ev, u, p1, p2 = os.pullEvent()
    if ev == 'http_success' and u == url then
      local res = p1
      local data = res.readAll()
      res.close()
      setServerConnected(true)
      return data
    elseif ev == 'http_failure' and u == url then
      print('HTTP GET failed: ' .. tostring(p1 or p2 or 'http_failure') .. ' url=' .. tostring(url))
      setServerConnected(false)
      return nil
    else
      os.queueEvent(ev, u, p1, p2)
      sleep(0)
    end
  end
end

local function fetchJSON(url)
  local txt = fetchHTTP(url)
  if not txt then return nil end
  return jsonDecode(txt)
end

local function ensureDir(path)
  local dir = path:match('^(.+)/[^/]+$')
  if dir and not fs.exists(dir) then pcall(fs.makeDir, dir) end
end

local function postJSONResult(url, dataTable)
  if not http then
    setServerConnected(false)
    return false, nil, nil, 'HTTP API disabled'
  end
  local okBody, body = pcall(textutils.serializeJSON, dataTable)
  if not okBody or type(body) ~= 'string' then return false, nil, nil, 'serializeJSON failed' end

  local headers = { ['Content-Type'] = 'application/json' }

  if http.post then
    local ok, res, err = pcall(http.post, url, body, headers)
    if not ok or not res then
      setServerConnected(false)
      return false, nil, nil, tostring(err or res or 'http.post error')
    end
    local code = res.getResponseCode and res.getResponseCode() or nil
    local respBody = res.readAll and res.readAll() or nil
    res.close()
    setServerConnected(true)
    return true, code, respBody, nil
  end

  if not http.request then
    setServerConnected(false)
    return false, nil, nil, 'HTTP API disabled'
  end

  local okReq, reqOkOrErr = pcall(http.request, { url = url, method = 'POST', body = body, headers = headers })
  if not okReq or not reqOkOrErr then
    setServerConnected(false)
    return false, nil, nil, tostring(reqOkOrErr or 'http.request failed')
  end

  while true do
    local ev, u, p1, p2 = os.pullEvent()
    if ev == 'http_success' and u == url then
      local res = p1
      local code = res.getResponseCode and res.getResponseCode() or nil
      local respBody = res.readAll and res.readAll() or nil
      res.close()
      setServerConnected(true)
      return true, code, respBody, nil
    elseif ev == 'http_failure' and u == url then
      setServerConnected(false)
      return false, nil, nil, tostring(p1 or p2 or 'http_failure')
    else
      os.queueEvent(ev, u, p1, p2)
      sleep(0)
    end
  end
end

local function postJSON(url, dataTable)
  local ok, code, body, err = postJSONResult(url, dataTable)
  if not ok or not code then
    return false, code, nil, tostring(err or 'request error')
  end
  local parsed = jsonDecode(body)
  if code < 200 or code >= 300 then
    local msg = nil
    if type(parsed) == 'table' then
      msg = parsed.error or parsed.message or parsed.reason
    end
    if not msg then msg = tostring(body or ('HTTP ' .. tostring(code))) end
    return false, code, parsed, msg
  end
  return true, code, parsed, nil
end

local function unicodeEscape(str)
  if type(str) ~= 'string' then return str end
  local out = {}
  local okUtf8 = type(utf8) == 'table' and type(utf8.codes) == 'function'
  if okUtf8 then
    for _, cp in utf8.codes(str) do
      if cp <= 0x7F then
        out[#out+1] = string.char(cp)
      elseif cp <= 0xFFFF then
        out[#out+1] = string.format("\\u%04x", cp)
      else
        local v = cp - 0x10000
        local hi = 0xD800 + math.floor(v / 0x400)
        local lo = 0xDC00 + (v % 0x400)
        out[#out+1] = string.format("\\u%04x\\u%04x", hi, lo)
      end
    end
    return table.concat(out)
  end
  local i = 1
  while i <= #str do
    local b1 = string.byte(str, i)
    if not b1 then break end
    if b1 <= 0x7F then
      out[#out+1] = string.char(b1)
      i = i + 1
    elseif b1 >= 0xC0 and b1 <= 0xDF then
      local b2 = string.byte(str, i + 1) or 0
      local cp = (b1 % 0x20) * 0x40 + (b2 % 0x40)
      out[#out+1] = string.format("\\u%04x", cp)
      i = i + 2
    elseif b1 >= 0xE0 and b1 <= 0xEF then
      local b2 = string.byte(str, i + 1) or 0
      local b3 = string.byte(str, i + 2) or 0
      local cp = (b1 % 0x10) * 0x1000 + (b2 % 0x40) * 0x40 + (b3 % 0x40)
      out[#out+1] = string.format("\\u%04x", cp)
      i = i + 3
    else
      local b2 = string.byte(str, i + 1) or 0
      local b3 = string.byte(str, i + 2) or 0
      local b4 = string.byte(str, i + 3) or 0
      local cp = (b1 % 0x08) * 0x40000 + (b2 % 0x40) * 0x1000 + (b3 % 0x40) * 0x40 + (b4 % 0x40)
      local v = cp - 0x10000
      local hi = 0xD800 + math.floor(v / 0x400)
      local lo = 0xDC00 + (v % 0x400)
      out[#out+1] = string.format("\\u%04x\\u%04x", hi, lo)
      i = i + 4
    end
  end
  return table.concat(out)
end

local function firstString(...)
  for i = 1, select('#', ...) do
    local v = select(i, ...)
    if v ~= nil then
      local s = tostring(v):gsub('^%s+', ''):gsub('%s+$', '')
      if #s > 0 then return s end
    end
  end
  return ''
end

local function firstNumber(...)
  for i = 1, select('#', ...) do
    local v = select(i, ...)
    if v ~= nil then
      local n = tonumber(v)
      if n ~= nil then return n end
    end
  end
  return nil
end

local function currentDeviceId()
  local dev = nil
  if os.getComputerLabel then dev = os.getComputerLabel() end
  if not dev and os.getComputerID then dev = tostring(os.getComputerID()) end
  return tostring(dev or 'unknown')
end

local function normalizeTrainTypeLabel(v)
  local s = tostring(v or ''):lower()
  if s == 'express' or s == 'limited_express' or s == 'limited express' then
    return 'Express'
  end
  return 'Local'
end

local PENDING_UPLOAD_PATH = 'logs/pending_ticket_upload.jsonl'
local pendingUploads = {}
local pendingUploadsLoaded = false

local function loadPendingUploadsOnce()
  if pendingUploadsLoaded then return end
  pendingUploadsLoaded = true
  if not fs.exists(PENDING_UPLOAD_PATH) then return end
  for line in io.lines(PENDING_UPLOAD_PATH) do
    local obj = jsonDecode(line)
    if type(obj) == 'table' then table.insert(pendingUploads, obj) end
  end
end

local function savePendingUploads()
  ensureDir(PENDING_UPLOAD_PATH)
  local f = fs.open(PENDING_UPLOAD_PATH, 'w')
  if not f then
    print('Save pending upload failed: cannot open ' .. tostring(PENDING_UPLOAD_PATH))
    return false
  end
  for _, obj in ipairs(pendingUploads) do
    local okSer, s = pcall(textutils.serializeJSON, obj)
    if okSer and type(s) == 'string' then
      f.write(s .. "\n")
    end
  end
  f.close()
  return true
end

local function uploadTicketRecord(ticketData)
  local url = API_BASE .. '/tickets/sale'
  local payload = {
    ticket_id = tostring((type(ticketData) == 'table' and (ticketData.ticket_id or ticketData.id)) or ''),
    start = tostring((type(ticketData) == 'table' and (ticketData.start or ticketData.start_station_id or ticketData.start_station)) or ''),
    terminal = tostring((type(ticketData) == 'table' and (ticketData.terminal or ticketData.terminal_station_id or ticketData.end_station)) or ''),
    train_type = tostring((type(ticketData) == 'table' and (ticketData.train_type or ticketData.trainType or ticketData.type)) or ''),
    cost = (type(ticketData) == 'table' and tonumber(ticketData.cost)) or 0,
    station_code = tostring((type(ticketData) == 'table' and (ticketData.station_code or ticketData.stationCode)) or CURRENT_STATION_CODE or ''),
    device = tostring((type(ticketData) == 'table' and (ticketData.device or ticketData.device_id or ticketData.deviceId)) or 'unknown'),
    trips_total = (type(ticketData) == 'table' and (ticketData.trips_total or ticketData.rides_total or ticketData.rides)) or nil,
    trips_remaining = (type(ticketData) == 'table' and (ticketData.trips_remaining or ticketData.rides_remaining or ticketData.rides)) or nil
  }
  local ok, code, body, err = postJSONResult(url, payload)
  if not ok or not code then
    print('Upload ticket failed: ' .. tostring(err or 'request error') .. ' url=' .. tostring(url))
    return false
  end
  if code < 200 or code >= 300 then
    print('Upload ticket failed: HTTP ' .. tostring(code) .. ' ' .. tostring(body or '') .. ' url=' .. tostring(url))
    return false
  end
  return true
end

local function enqueueTicketUpload(ticketData)
  loadPendingUploadsOnce()
  if uploadTicketRecord(ticketData) then return true end
  table.insert(pendingUploads, ticketData)
  local okSave = savePendingUploads()
  if okSave then
    print('Ticket upload queued. pending=' .. tostring(#pendingUploads))
  else
    print('Ticket upload queued in memory only. pending=' .. tostring(#pendingUploads))
  end
  return false
end

-- ###########################
-- Peripheral discovery
-- ###########################
local monitor = peripheral.find('monitor')
local ticketVendingMachine = peripheral.find('ticket_vending_machine')
local speaker = peripheral.find('speaker')
local MOD_DEBUG = true

pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time())

local function safe(term)
  if monitor then return peripheral.wrap(peripheral.getName(monitor)) end
  return term
end

local termDev = safe(term)
if monitor then pcall(monitor.setTextScale, 0.5) end
local w, h = termDev.getSize()

local function saveCardIssueSnapshot(cardData)
  pcall(function()
    ensureDir('logs/last_card_issue.json')
    local okSer, s = pcall(textutils.serializeJSON, cardData)
    if okSer and type(s) == 'string' then
      local f = fs.open('logs/last_card_issue.json', 'w')
      if f then f.write(s); f.close() end
    end
  end)
end

local function extractPeripheralId(...)
  for i = 1, select('#', ...) do
    local v = select(i, ...)
    if type(v) == 'string' or type(v) == 'number' then
      local s = tostring(v):gsub('^%s+', ''):gsub('%s+$', '')
      if #s > 0 then return s end
    end
  end
  return ''
end

local function peripheralCallSucceeded(r1)
  return r1 ~= nil and r1 ~= false
end

local function callPeripheralMethods(dev, methodNames, variants)
  if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end
  for _, methodName in ipairs(methodNames) do
    local fn = dev[methodName]
    if type(fn) == 'function' then
      for _, args in ipairs(variants) do
        local okCall, r1, r2, r3 = pcall(fn, table.unpack(args))
        if okCall and peripheralCallSucceeded(r1) then
          return true, methodName, r1, r2, r3
        end
      end
    end
  end
  return false, 'unsupported_method'
end

local function issueBlankICCard(holderName, initialBalance)
  local dev = ticketVendingMachine
  if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end
  local safeHolderName = firstString(holderName, 'CARD USER')
  local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0))
  for _, methodName in ipairs({ 'issueICCard', 'issueCard' }) do
    local fn = dev[methodName]
    if type(fn) == 'function' then
      local okCall, r1, r2, r3 = pcall(fn, safeHolderName, safeInitialBalance)
      if not (okCall and peripheralCallSucceeded(r1)) then
        okCall, r1, r2, r3 = pcall(fn, safeHolderName)
      end
      if not (okCall and peripheralCallSucceeded(r1)) then
        okCall, r1, r2, r3 = pcall(fn)
      end
      if okCall and peripheralCallSucceeded(r1) then
        return true, extractPeripheralId(r1, r2, r3), methodName
      end
    end
  end
  return false, '', 'unsupported_method'
end

local function writeICCard(cardData)
  local dev = ticketVendingMachine
  local payload = {}
  for k, v in pairs(cardData or {}) do payload[k] = v end
  payload.media = payload.media or 'ic_card'
  payload.product_type = payload.product_type or 'stored_value'
  payload.device = payload.device or currentDeviceId()
  payload.initial_balance = payload.initial_balance or payload.topup or payload.balance or 0
  payload.owner = payload.owner or payload.holder_name or ''
  payload.card_holder = payload.card_holder or payload.holder_name or ''
  _G.TICKET_MACHINE_LAST_TICKET = payload
  saveCardIssueSnapshot(payload)

  local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev,
    { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
    {
      { payload },
      { tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 },
      { tostring(payload.holder_name or '') },
      { tostring(payload.card_id or ''), tostring(payload.holder_name or ''), tonumber(payload.balance) or 0, tonumber(payload.deposit) or 0, tostring(payload.station_code or ''), tostring(payload.product_type or 'stored_value') },
      { tostring(payload.holder_name or ''), tonumber(payload.balance) or 0, tonumber(payload.deposit) or 0 },
      {}
    })
  if okWrite then
    local issuedId = extractPeripheralId(r1, r2, r3, payload.card_id)
    if #issuedId > 0 then payload.card_id = issuedId end
    return true, payload, methodName
  end
  return false, payload, methodName
end

local function submitCardOpen(payload)
  return postJSON(API_BASE .. '/cards/open', payload)
end

-- ###########################
-- Audio Utilities & Playback
-- ###########################
local function stopAudio() end

local function waitAudioComplete() end

local function playNote(instrument, pitch, volume, dur)
  if not speaker then return end
  instrument = tostring(instrument or 'pling')
  local p = tonumber(pitch)
  if p == nil then p = 12 end
  p = math.max(0, math.min(24, math.floor(p)))
  local v = tonumber(volume)
  if v == nil then v = 1 end
  if v < 0 then v = 0 end
  local ok = pcall(function() speaker.playNote(instrument, v, p) end)
  if ok and dur and dur > 0 then sleep(dur) end
end

local function playMelody(instrument, notes, volume, noteDur, gap)
  if not speaker then return end
  instrument = tostring(instrument or 'harp')
  local v = tonumber(volume); if v == nil then v = 1 end
  local nd = tonumber(noteDur); if nd == nil then nd = 0.04 end
  local g = tonumber(gap); if g == nil then g = 0.02 end
  if type(notes) ~= 'table' then return end
  for i = 1, #notes do
    playNote(instrument, notes[i], v, nd)
    if g > 0 then sleep(g) end
  end
end


local function playConfirmTicketMelody()
  playMelody('bell', { 19, 22, 24, 22, 19 }, 1, 0.04, 0.02)
end

local function playAudioFile(path)
  local p = tostring(path or ''):lower()
  if p:find('welcome') then
    playMelody('chime', { 12, 16, 19, 24 }, 1, 0.05, 0.02)
    return
  end
end

local function clickSound() end


-- ###########################
-- Background Tasks
-- ###########################
local function backgroundSyncTask()
  while true do
    sleep(5)
    pcall(refreshConfigOnce)
  end
end

local function backgroundTicketUploadTask()
  loadPendingUploadsOnce()
  local backoff = 2
  while true do
    if #pendingUploads > 0 then
      if uploadTicketRecord(pendingUploads[1]) then
        table.remove(pendingUploads, 1)
        savePendingUploads()
        backoff = 2
      else
        backoff = math.min(60, math.floor(backoff * 1.5))
      end
    end
    sleep(backoff)
  end
end



-- ###########################
-- Data logic & Config
-- ###########################
local CFG = { stations = {}, lines = {}, fares = {}, transfers = {} }
local stationByCode = {}
local adjacency_regular, adjacency_express = {}, {}
local transferGroupByCode = {}

local function normalizeCode(s)
  s = tostring(s or '')
  s = s:gsub('[\239\187\191]', ''):gsub('%s+', '')
  return s
end

local function rebuildMaps()
  stationByCode = {}
  for _, s in ipairs(CFG.stations or {}) do
    if type(s) == 'table' then
      local code = normalizeCode(s.code or s.id)
      if #code > 0 then
        s.code = code
        if s.en_name == nil then s.en_name = s.en or s.enName or s.name_en or s.english_name end
        stationByCode[code] = s
      end
    end
  end
  adjacency_regular, adjacency_express = {}, {}
  local transferAdj = {}
  local function addTransfer(a, b)
    a = normalizeCode(a)
    b = normalizeCode(b)
    if #a == 0 or #b == 0 or a == b then return end
    adjacency_regular[a] = adjacency_regular[a] or {}; adjacency_regular[a][b] = 0
    adjacency_regular[b] = adjacency_regular[b] or {}; adjacency_regular[b][a] = 0
    adjacency_express[a] = adjacency_express[a] or {}; adjacency_express[a][b] = 0
    adjacency_express[b] = adjacency_express[b] or {}; adjacency_express[b][a] = 0
    transferAdj[a] = transferAdj[a] or {}; transferAdj[a][b] = true
    transferAdj[b] = transferAdj[b] or {}; transferAdj[b][a] = true
  end
  for _, e in ipairs(CFG.fares or {}) do
    local cr = e.cost_regular or e.cost or 0
    local ce = e.cost_express or e.cost or 0
    adjacency_regular[e.from] = adjacency_regular[e.from] or {}
    adjacency_regular[e.from][e.to] = cr
    adjacency_regular[e.to] = adjacency_regular[e.to] or {}
    adjacency_regular[e.to][e.from] = cr
    adjacency_express[e.from] = adjacency_express[e.from] or {}
    adjacency_express[e.from][e.to] = ce
    adjacency_express[e.to] = adjacency_express[e.to] or {}
    adjacency_express[e.to][e.from] = ce
  end
  for _, p in ipairs(CFG.transfers or {}) do
    addTransfer(p[1], p[2])
  end
  for _, s in ipairs(CFG.stations or {}) do
    if type(s) == 'table' and s.transfer_enabled and type(s.transfer_to) == 'table' then
      local from = normalizeCode(s.code or s.id)
      for _, t in ipairs(s.transfer_to) do
        local to = t
        if type(t) == 'table' then to = t.code or t.station or t.id or t[1] end
        addTransfer(from, to)
      end
    end
  end
  local groups = {}
  for _, s in ipairs(CFG.stations or {}) do
    if type(s) == 'table' then
      local cn = tostring(s.name or s.cn_name or ''):gsub('%s+', '')
      local en = tostring(s.en_name or s.en or s.enName or ''):lower():gsub('%s+', '')
      local code = normalizeCode(s.code or s.id)
      if #cn > 0 and #en > 0 and #code > 0 then
        local k = cn .. '|' .. en
        groups[k] = groups[k] or {}
        table.insert(groups[k], code)
      end
    end
  end
  for _, arr in pairs(groups) do
    if #arr >= 2 then
      for i = 1, #arr do
        for j = i + 1, #arr do
          local a, b = tostring(arr[i] or ''), tostring(arr[j] or '')
          if #a > 0 and #b > 0 and a ~= b then
            addTransfer(a, b)
          end
        end
      end
    end
  end
  transferGroupByCode = {}
  local visited = {}
  local groupId = 0
  local function flood(start)
    groupId = groupId + 1
    local q = { start }
    visited[start] = true
    while #q > 0 do
      local u = table.remove(q, 1)
      transferGroupByCode[u] = groupId
      for v, _ in pairs(transferAdj[u] or {}) do
        if not visited[v] then
          visited[v] = true
          table.insert(q, v)
        end
      end
    end
  end
  for code, _ in pairs(stationByCode) do
    if not visited[code] then flood(code) end
  end
  for code, _ in pairs(transferAdj) do
    if not visited[code] then flood(code) end
  end
end

local function sameLogicalStation(a, b)
  a = normalizeCode(a)
  b = normalizeCode(b)
  if a == b then return true end
  local ga = transferGroupByCode[a]
  local gb = transferGroupByCode[b]
  return ga ~= nil and ga == gb
end

local function fetchConfig()
  local txt = fetchHTTP(API_BASE .. '/config')
  return txt and jsonDecode(txt) or nil
end

local function refreshConfigOnce()
  local cfg = fetchConfig()
  if not cfg then return false end
  local f = fs.open('config.json', 'w')
  if f then f.write(textutils.serializeJSON(cfg)); f.close() end
  CFG = cfg
  rebuildMaps()
  os.queueEvent('config_updated')
  return true
end

local function loadConfig()
  local cfg = fetchConfig()
  if cfg then
    local f = fs.open('config.json', 'w')
    if f then f.write(textutils.serializeJSON(cfg)); f.close() end
    return cfg
  end
  if fs.exists('config.json') then
    local f = fs.open('config.json', 'r')
    local c = f.readAll(); f.close()
    return jsonDecode(c)
  end
  return nil
end

CFG = loadConfig() or CFG
rebuildMaps()


-- ###########################
-- UI Helpers
-- ###########################
local CC_PALETTE = {
  {name='white', val=colors.white, rgb={0xF2,0xF2,0xF2}},
  {name='orange', val=colors.orange, rgb={0xF2,0xB2,0x33}},
  {name='magenta', val=colors.magenta, rgb={0xE5,0x7F,0xD8}},
  {name='lightBlue', val=colors.lightBlue, rgb={0x99,0xB2,0xF2}},
  {name='yellow', val=colors.yellow, rgb={0xDE,0xDE,0x6C}},
  {name='lime', val=colors.lime, rgb={0x7F,0xCC,0x19}},
  {name='pink', val=colors.pink, rgb={0xF2,0xB2,0xCC}},
  {name='gray', val=colors.gray, rgb={0x4C,0x4C,0x4C}},
  {name='lightGray', val=colors.lightGray, rgb={0x99,0x99,0x99}},
  {name='cyan', val=colors.cyan, rgb={0x4C,0x99,0xB2}},
  {name='purple', val=colors.purple, rgb={0xB2,0x66,0xE5}},
  {name='blue', val=colors.blue, rgb={0x33,0x66,0xCC}},
  {name='brown', val=colors.brown, rgb={0x7F,0x66,0x4C}},
  {name='green', val=colors.green, rgb={0x57,0xA6,0x4E}},
  {name='red', val=colors.red, rgb={0xCC,0x4C,0x4C}},
  {name='black', val=colors.black, rgb={0x11,0x11,0x11}},
}

local function nearestCCColor(val)
  if type(val) ~= 'string' then return colors.gray end
  if val:sub(1,1) == '#' then
    local hex = val:sub(2)
    local r, g, b = tonumber(hex:sub(1,2), 16), tonumber(hex:sub(3,4), 16), tonumber(hex:sub(5,6), 16)
    if not r then return colors.gray end
    local bestD, bestV = math.huge, colors.gray
    for _,c in ipairs(CC_PALETTE) do
      local d = (r-c.rgb[1])^2 + (g-c.rgb[2])^2 + (b-c.rgb[3])^2
      if d < bestD then bestD, bestV = d, c.val end
    end
    return bestV
  end
  for _,c in ipairs(CC_PALETTE) do if c.name == val then return c.val end end
  return colors.gray
end

local function clear()
  termDev.setBackgroundColor(colors.black)
  termDev.clear()
  termDev.setCursorPos(1,1)
end

local function centerText(y, text, color)
  termDev.setTextColor(color or colors.white)
  local x = math.max(1, math.floor((w - #text) / 2))
  termDev.setCursorPos(x, y)
  termDev.write(text)
end

local function drawRainbowLabelRow(y, text, fg)
  local palette = { colors.red, colors.orange, colors.yellow, colors.lime, colors.green, colors.cyan, colors.blue, colors.purple, colors.magenta }
  local x = math.max(1, math.floor((w - #text) / 2))
  termDev.setTextColor(fg or colors.white)
  for i = 1, #text do
    termDev.setBackgroundColor(palette[((i - 1) % #palette) + 1])
    termDev.setCursorPos(x + i - 1, y)
    termDev.write(text:sub(i, i))
  end
  termDev.setBackgroundColor(colors.black)
end

local Buttons = {}
local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick)
  table.insert(Buttons, { x=x, y=y, w=wBtn, h=hBtn, onClick=onClick })
  termDev.setBackgroundColor(colorsPair[1]); termDev.setTextColor(colorsPair[2])
  for i=0,hBtn-1 do termDev.setCursorPos(x, y+i); termDev.write(string.rep(' ', wBtn)) end
  termDev.setCursorPos(x + math.floor((wBtn - #label)/2), y + math.floor(hBtn/2))
  termDev.write(label)
end

local function inRect(btn, px, py)
  return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
end

local function waitButtons()
  while true do
    local ev, p1, p2, p3 = os.pullEvent()
    if ev == 'mouse_click' or ev == 'monitor_touch' then
      -- For mouse_click: p1=button, p2=x, p3=y
      -- For monitor_touch: p1=side, p2=x, p3=y
      for _, b in ipairs(Buttons) do
        if inRect(b, p2, p3) then 
          clickSound(); if b.onClick then b.onClick() end; return 
        end
      end
    elseif ev == 'config_updated' then
      -- If config was updated in background, return to force UI re-render
      return
    end
  end
end

-- ###########################
-- Pages & Navigation
-- ###########################
local state = {
  page = 'home',
  stationName = (CFG.current_station and (CFG.current_station.en_name or CFG.current_station.name)) or 'Station',
  stationCode = (CFG.current_station and CFG.current_station.code) or CURRENT_STATION_CODE,
  departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false,
  productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local',
  holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0,
  card_id = nil, card_server_data = nil, pendingBlankCardId = nil
}

local function getCardConfig()
  local cfg = CFG.card or CFG.ic_card or {}
  local minTopup = math.max(1, math.floor(firstNumber(cfg.first_topup_min, cfg.min_first_topup, cfg.min_topup, 10) or 10))
  local quickSrc = type(cfg.quick_amounts) == 'table' and cfg.quick_amounts or { 10, 20, 50, 100 }
  local quick = {}
  for _, v in ipairs(quickSrc) do
    local n = tonumber(v)
    if n and n > 0 then quick[#quick + 1] = math.floor(n) end
  end
  if #quick == 0 then quick = { 10, 20, 50, 100 } end
  return {
    deposit = 0,
    min_topup = minTopup,
    quick_amounts = quick
  }
end

local function resetTicketFlow()
  state.productMode = 'ticket'
  state.cardMode = nil
  state.cardPaymentMode = 'local'
  state.departure, state.terminal, state.trainType = nil, nil, nil
  state.trips, state.cost, state.paid, state.doneAudioPlayed = 1, 0, 0, false
  state.voucher_code = nil
  state.holderName = ''
  state.cardDeposit = 0
  state.cardTopup = 0
  state.cardBalance = 0
  state.cardOrderValue = 0
  state.card_id = nil
  state.card_server_data = nil
  state.pendingBlankCardId = nil
end

local function resetCardFlow(mode)
  local cardCfg = getCardConfig()
  state.productMode = 'card'
  state.cardMode = mode or 'open'
  state.cardPaymentMode = 'local'
  state.departure, state.terminal, state.trainType = nil, nil, nil
  state.trips, state.paid, state.doneAudioPlayed = 1, 0, 0, false
  state.voucher_code = nil
  state.holderName = ''
  state.cardDeposit = cardCfg.deposit
  state.cardTopup = cardCfg.min_topup
  state.cardBalance = cardCfg.min_topup
  state.cardOrderValue = state.cardTopup
  state.cost = state.cardOrderValue
  state.card_id = nil
  state.card_server_data = nil
  state.pendingBlankCardId = nil
end

local function isCardOrderLike(data)
  if type(data) ~= 'table' then return false end
  local kind = firstString(
    data.order_type, data.type, data.kind, data.media, data.media_type, data.product,
    data.product_type, data.ticket_type, data.service_type
  ):lower()
  if kind:find('card', 1, true) or kind:find('stored', 1, true) or kind:find('wallet', 1, true) then
    return true
  end
  if kind:find('ic', 1, true) then return true end
  if type(data.card) == 'table' or type(data.ic_card) == 'table' then return true end
  return firstString(data.holder_name, data.holderName, data.passenger_name, data.passengerName) ~= ''
end

local function buildCardOrderState(data, voucherCode)
  if not isCardOrderLike(data) then return nil end
  local cardCfg = getCardConfig()
  local totalValue = math.max(0, math.floor(firstNumber(
    data.order_value, data.total_value, data.price, data.cost, data.amount,
    type(data.card) == 'table' and data.card.order_value or nil,
    type(data.ic_card) == 'table' and data.ic_card.order_value or nil,
    cardCfg.min_topup
  ) or cardCfg.min_topup))
  local topup = math.max(0, math.floor(firstNumber(
    data.topup, data.first_topup, data.recharge, data.initial_balance,
    type(data.card) == 'table' and data.card.topup or nil,
    type(data.ic_card) == 'table' and data.ic_card.topup or nil,
    totalValue
  ) or totalValue))
  local balance = math.max(0, math.floor(firstNumber(
    data.balance, data.stored_value, data.wallet_balance,
    type(data.card) == 'table' and data.card.balance or nil,
    type(data.ic_card) == 'table' and data.ic_card.balance or nil,
    topup
  ) or topup))
  return {
    holderName = firstString(
      data.holder_name, data.holderName, data.passenger_name, data.passengerName,
      type(data.card) == 'table' and data.card.holder_name or nil,
      type(data.ic_card) == 'table' and data.ic_card.holder_name or nil,
      'CARD USER'
    ),
    deposit = 0,
    topup = topup,
    balance = balance,
    orderValue = totalValue,
    voucher = voucherCode,
    raw = data
  }
end

local function stationDisplay(code)
  code = normalizeCode(code)
  if #code == 0 then return '' end
  local st = stationByCode[code]
  if st then return tostring(st.en_name or st.name or code) .. ' ' .. code end
  return code
end

local function drawVersionIndicator()
  if w < 1 then return end
  termDev.setBackgroundColor(colors.black)
  termDev.setTextColor(colors.gray)
  termDev.setCursorPos(1, 1)
  termDev.write(tostring(VERSION))
  termDev.setTextColor(colors.white)
end

local function drawServerStatusIndicator()
  if w < 2 then return end
  local col = colors.yellow
  if serverConnected == true then col = colors.lime
  elseif serverConnected == false then col = colors.red end
  termDev.setBackgroundColor(colors.black)
  termDev.setTextColor(col)
  termDev.setCursorPos(w-1, 1)
  termDev.write('S')
  termDev.setCursorPos(w, 1)
  termDev.write('*')
  termDev.setTextColor(colors.white)
end

local function drawHeader(title, sub, hideStationLabel)
  clear()
  drawVersionIndicator()
  drawServerStatusIndicator()
  centerText(2, title, colors.white)
  if sub and #tostring(sub) > 0 then centerText(3, tostring(sub), colors.lightBlue) end
end

local ui_cancel_request, ui_cancel_confirmed = false, false
local function renderConfirmCancel()
  local boxW, boxH = math.max(24, math.min(32, w-4)), 6
  local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
  termDev.setBackgroundColor(colors.gray)
  for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
  local msg = 'Cancel this purchase?'
  if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
  termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
  Buttons = {}
  local bw = math.floor((boxW - 6) / 2)
  addButton(bx+2, by+4, 'YES', bw, 1, {colors.red, colors.white}, function() ui_cancel_confirmed = true; ui_cancel_request = false end)
  addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
end

local function showAlert(msg)
  local boxW, boxH = math.max(24, math.min(32, w-4)), 6
  local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
  termDev.setBackgroundColor(colors.gray)
  for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
  msg = tostring(msg or '')
  if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
  termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
  Buttons = {}
  addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
  waitButtons()
end

local function addCancelButton()
  addButton(2, h, 'CANCEL', 8, 1, {colors.red, colors.white}, function() ui_cancel_request = true end)
end

local function showHome()
  state.stationName = (CFG.current_station and (CFG.current_station.en_name or CFG.current_station.name)) or 'Station'
  state.stationCode = (CFG.current_station and CFG.current_station.code) or CURRENT_STATION_CODE
  drawHeader('FSE Ticket Machine', 'Select Mode', true)
  Buttons = {}
  local btnW, btnH = 12, 3
  local y = math.floor(h/2) - 2
  local x1, x2, x3 = math.floor(w/2) - btnW - 2, math.floor(w/2) + 3, math.floor(w/2) - math.floor(btnW / 2)
  if x1 < 2 or (x2 + btnW) > w-1 then
    local cx = math.floor((w - btnW) / 2) + 1
    addButton(cx, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
      resetTicketFlow()
      stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
      state.page = 'departure'
    end)
    addButton(cx, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
      resetCardFlow('open')
      stopAudio(); state.page = 'card_home'
    end)
    addButton(cx, y+6, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
      resetTicketFlow()
      stopAudio(); state.page = 'online'
    end)
  else
    addButton(x1, y, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
      resetTicketFlow()
      stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
      state.page = 'departure'
    end)
    addButton(x2, y, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
      resetTicketFlow()
      stopAudio(); state.page = 'online'
    end)
    addButton(x3, y+4, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
      resetCardFlow('open')
      stopAudio(); state.page = 'card_home'
    end)
  end
  waitButtons()
end

local function showCardHome()
  while state.page == 'card_home' do
    drawHeader('IC Card Service', 'Open card or redeem online order')
    Buttons = {}
    local btnW, btnH = 16, 3
    local cx = math.floor((w - btnW) / 2) + 1
    local y = math.floor(h / 2) - 3
    addButton(cx, y, 'OPEN CARD', btnW, btnH, {colors.orange, colors.white}, function()
      resetCardFlow('open')
      state.page = 'card_name'
    end)
    addButton(cx, y + 4, 'ONLINE REDEEM', btnW, btnH, {colors.cyan, colors.white}, function()
      resetCardFlow('redeem')
      state.cardPaymentMode = 'online'
      state.page = 'card_online'
    end)
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
    waitButtons()
  end
end

local function showCardNameInput()
  local name = firstString(state.holderName)
  local msg, msgCol = '', colors.red
  local maxNameLen = 24
  local keyboardMode = 'alpha'
  local alphaRows = {
    {'Q','W','E','R','T','Y','U','I','O','P'},
    {'A','S','D','F','G','H','J','K','L'},
    {'Z','X','C','V','B','N','M'}
  }
  local symbolRows = {
    {'-','_','.','\'',','},
    {'(',')','&','@','/'},
    {'+'}
  }

  local function isAllowedHolderChar(ch)
    if type(ch) ~= 'string' or #ch ~= 1 then return false end
    if ch:match('[A-Za-z ]') then return true end
    return ch == '.' or ch == ',' or ch == '\'' or ch == '(' or ch == ')' or ch == '&' or ch == '@' or ch == '/' or ch == '_' or ch == '-' or ch == '+'
  end

  local function appendHolderChar(ch)
    if not isAllowedHolderChar(ch) or #name >= maxNameLen then return end
    if ch:match('[A-Za-z]') then ch = ch:upper() end
    if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end
    name = name .. ch
  end

  while state.page == 'card_name' do
    local placeholder = name
    if #placeholder == 0 then
      placeholder = 'ENTER NAME'
    elseif #placeholder > (w - 8) then
      placeholder = '...' .. placeholder:sub(-(w - 11))
    end
    local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows
    drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space')
    centerText(4, '[' .. placeholder .. ']', colors.yellow)
    if msg and #msg > 0 then centerText(5, msg, msgCol) end
    Buttons = {}
    local keyW, keyH = (w < 44 and 2 or 3), 2
    local sY = 7
    for rIdx, row in ipairs(rows) do
      local y = sY + (rIdx-1) * (keyH + 1)
      local rowW = math.max(0, #row * (keyW + 1) - 1)
      local x = math.max(1, math.floor((w - rowW) / 2) + 1)
      for _, ch in ipairs(row) do
        addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
          appendHolderChar(ch)
        end)
        x = x + keyW + 1
      end
    end
    addButton(math.max(1, math.floor((w - 12) / 2) + 1), sY + 10, 'SPACE', 12, 2, {colors.gray, colors.white}, function()
      appendHolderChar(' ')
    end)
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
    addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function()
      keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol'
    end)
    addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() name = name:sub(1, -2) end)
    addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() name = '' end)
    addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
      local clean = firstString(name)
      if #clean < 2 then
        msg, msgCol = 'Name too short', colors.red
        return
      end
      state.holderName = clean
      state.page = 'card_topup'
    end)
    local ev, p1, p2, p3 = os.pullEvent()
    if ev == 'mouse_click' or ev == 'monitor_touch' then
      for _, b in ipairs(Buttons) do
        if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
      end
    elseif ev == 'char' then
      appendHolderChar(tostring(p1 or ''))
    elseif ev == 'key' and p1 == keys.backspace then
      name = name:sub(1, -2)
    elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
      local clean = firstString(name)
      if #clean >= 2 then
        state.holderName = clean
        state.page = 'card_topup'
      else
        msg, msgCol = 'Name too short', colors.red
      end
    end
  end
end

local function showCardTopup()
  while state.page == 'card_topup' do
    local cardCfg = getCardConfig()
    state.cardTopup = math.max(cardCfg.min_topup, tonumber(state.cardTopup) or cardCfg.min_topup)
    state.cardBalance = state.cardTopup
    state.cardDeposit = 0
    state.cardOrderValue = state.cardTopup
    state.cost = state.cardOrderValue
    drawHeader('First Recharge', 'Holder: ' .. firstString(state.holderName, 'CARD USER'))
    Buttons = {}
    centerText(6, 'First Recharge: ' .. tostring(state.cardTopup), colors.yellow)
    centerText(8, 'Need Pay: ' .. tostring(state.cardOrderValue), colors.red)
    local colCount = 2
    local btnW, btnH = 12, 3
    local gap = 2
    local startX = math.max(2, math.floor((w - (colCount * btnW + (colCount - 1) * gap)) / 2) + 1)
    local startY = 11
    for idx, amount in ipairs(cardCfg.quick_amounts) do
      local row = math.floor((idx - 1) / colCount)
      local col = (idx - 1) % colCount
      addButton(startX + col * (btnW + gap), startY + row * (btnH + 1), tostring(amount), btnW, btnH,
        {state.cardTopup == amount and colors.green or colors.gray, colors.white},
        function() state.cardTopup = amount end)
    end
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_name' end)
    addButton(math.max(12, math.floor(w / 2) - 7), h-3, '-10', 6, 3, {colors.black, colors.red}, function()
      state.cardTopup = math.max(cardCfg.min_topup, state.cardTopup - 10)
    end)
    addButton(math.max(20, math.floor(w / 2)), h-3, '+10', 6, 3, {colors.black, colors.green}, function()
      state.cardTopup = math.min(999, state.cardTopup + 10)
    end)
    addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
      state.cardPaymentMode = 'local'
      state.page = 'order'
    end)
    waitButtons()
  end
end

local function showCardOnlineRedeem()
  local code = ''
  local msg, msgCol = '', colors.red
  local rows = {
    {'1','2','3','4','5','6','7','8','9','0'},
    {'Q','W','E','R','T','Y','U','I','O','P'},
    {'A','S','D','F','G','H','J','K','L'},
    {'Z','X','C','V','B','N','M'}
  }

  local function submitCode()
    if #code ~= 5 then
      msg, msgCol = 'Need 5 chars', colors.red
      return
    end
    local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code)
    if not (res and res.ok) then
      res = fetchJSON(API_BASE .. '/public/orders/' .. code)
    end
    if not (res and res.ok) then
      msg, msgCol = 'Voucher Invalid', colors.red
      return
    end
    local d = res.data or res
    local cardStatus = tostring(d.status or ''):lower()
    if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then
      msg, msgCol = 'Already Used!', colors.red
      return
    end
    local cardOrder = buildCardOrderState(d, code)
    if not cardOrder then
      msg, msgCol = 'Not a card order', colors.red
      return
    end
    resetCardFlow('redeem')
    state.holderName = cardOrder.holderName
    state.cardDeposit = cardOrder.deposit
    state.cardTopup = cardOrder.topup
    state.cardBalance = cardOrder.balance
    state.cardOrderValue = cardOrder.orderValue
    state.cost = state.cardOrderValue
    state.paid = 0
    state.voucher_code = code
    state.cardPaymentMode = 'local'
    state.card_server_data = cardOrder.raw
    state.page = 'order'
  end

  while state.page == 'card_online' do
    local placeholder = code .. string.rep('_', math.max(0, 5-#code))
    drawHeader('Redeem IC Card', 'Type 5 chars then OK')
    centerText(4, '[' .. placeholder .. ']', colors.yellow)
    if msg and #msg > 0 then centerText(5, msg, msgCol) end
    Buttons = {}
    local keyW, keyH = (w < 44 and 2 or 3), 2
    local kbW = 10 * (keyW + 1)
    local sX, sY = math.max(1, math.floor((w - kbW) / 2) + 1), 7
    for rIdx, row in ipairs(rows) do
      local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
      for _, ch in ipairs(row) do
        addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
          if #code < 5 then code = code .. ch end
        end)
        x = x + keyW + 1
      end
    end
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
    addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
    addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() code = '' end)
    addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
    local ev, p1, p2, p3 = os.pullEvent()
    if ev == 'mouse_click' or ev == 'monitor_touch' then
      for _, b in ipairs(Buttons) do
        if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
      end
    elseif ev == 'char' and #code < 5 then
      code = code .. tostring(p1 or ''):upper()
    elseif ev == 'key' and p1 == keys.backspace then
      code = code:sub(1, -2)
    elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
      submitCode()
    end
  end
end

local ui_selected_code, ui_scroll_offset = nil, 0
local function renderLinesSelection(title, selectedCode, backPage, nextPage, infoLine)
  drawHeader(title, infoLine or 'Tap a station to select')
  Buttons = {}; local startY, endY = 5, h - 4
  local sbX = w - 3
  
  local function computeHeight()
    local y = startY
    for _, l in ipairs(CFG.lines) do
      y = y + 2; local x = 2
      for _, sc in ipairs((type(l) == 'table' and type(l.stations) == 'table' and l.stations) or {}) do
        local c = normalizeCode(sc)
        local st = stationByCode[c]
        local label = (st and (st.en_name or st.name)) or c
        local bw = #label + 1
        if x + bw > sbX - 1 then y = y + 4; x = 2 end
        x = x + bw + 2
      end
      y = y + 4
    end
    return y - startY
  end

  local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1))
  ui_scroll_offset = math.min(ui_scroll_offset, maxO)
  
  addButton(sbX, startY-1, '^', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end)
  addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end)
  
  local y = startY
  for _, l in ipairs(CFG.lines) do
    local py = y - ui_scroll_offset
    if py >= startY and py <= endY then
      termDev.setTextColor(nearestCCColor(type(l) == 'table' and l.color or nil)); termDev.setCursorPos(2, py); termDev.write(tostring(type(l) == 'table' and l.en_name or ''))
    end
    y = y + 1
    local by = y - ui_scroll_offset
    if by >= startY and by <= endY then
      local cc = nearestCCColor(type(l) == 'table' and l.color or nil); termDev.setBackgroundColor(cc); termDev.setCursorPos(2, by); termDev.write(string.rep(' ', sbX-4))
      termDev.setBackgroundColor(colors.black)
    end
    y = y + 1; local x = 2
    for _, sc in ipairs((type(l) == 'table' and type(l.stations) == 'table' and l.stations) or {}) do
      local c = normalizeCode(sc)
      local st = stationByCode[c]
      local label = (st and (st.en_name or st.name)) or c
      local bw = #label + 1
      if x + bw > sbX - 1 then y = y + 4; x = 2 end
      local ry = y - ui_scroll_offset
      if ry >= startY and ry+2 <= endY then
        addButton(x, ry, label, bw, 3, {selectedCode == c and colors.green or colors.gray, colors.white}, function() ui_selected_code = c end)
      end
      x = x + bw + 2
    end
    y = y + 4
  end
  
  addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = backPage end)
  if selectedCode then
    addButton(w-15, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function() state.page = nextPage end)
  end
  return selectedCode
end

local function showDeparture()
  local sel; stopAudio(); playAudioFile('Audio/xzqd.wav')
  while state.page == 'departure' do
    sel = renderLinesSelection('Select Departure', sel, 'home', 'terminal', 'From: ' .. stationDisplay(sel)); addCancelButton()
    waitButtons()
    if ui_selected_code then
      sel = ui_selected_code
      ui_selected_code = nil
      sel = normalizeCode(sel)
      state.departure = sel
    end
    if ui_cancel_request then
      renderConfirmCancel()
      waitButtons()
      if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
      ui_cancel_request = false
    end
  end
end

local function showTerminal()
  local sel; stopAudio(); playAudioFile('Audio/xzzd.wav')
  local invalidUntil = 0
  while state.page == 'terminal' do
    local info = 'From: ' .. stationDisplay(state.departure) .. '  To: ' .. stationDisplay(sel)
    if invalidUntil > os.epoch('utc') then
      info = info .. '  (Invalid: same station)'
    end
    sel = renderLinesSelection('Select Terminal', sel, 'departure', 'type', info); addCancelButton()
    waitButtons()
    if ui_selected_code then
      sel = ui_selected_code
      ui_selected_code = nil
      sel = normalizeCode(sel)
      if sameLogicalStation(sel, state.departure) then
        invalidUntil = os.epoch('utc') + 1500
        sel = nil
        state.terminal = nil
        showAlert('Same station')
      else
        state.terminal = sel
      end
    end
    if ui_cancel_request then
      renderConfirmCancel()
      waitButtons()
      if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
      ui_cancel_request = false
    end
  end
end

local function showType()
  stopAudio(); playAudioFile('Audio/xzlc.wav')
  while state.page == 'type' do
    drawHeader('Select Train Type', 'From: ' .. stationDisplay(state.departure) .. '  To: ' .. stationDisplay(state.terminal))
    Buttons = {}
    local btnW, btnH = 12, 3
    local y = math.floor(h/2) - 2
    local x1, x2 = math.floor(w/2) - btnW - 2, math.floor(w/2) + 3
    if x1 < 2 or (x2 + btnW) > w-1 then
      local cx = math.floor((w - btnW) / 2) + 1
      addButton(cx, y-2, 'Local', btnW, btnH, {state.trainType == 'Local' and colors.green or colors.gray, colors.white}, function() state.trainType = 'Local' end)
      addButton(cx, y+2, 'Express', btnW, btnH, {state.trainType == 'Express' and colors.blue or colors.gray, colors.white}, function() state.trainType = 'Express' end)
    else
      addButton(x1, y, 'Local', btnW, btnH, {state.trainType == 'Local' and colors.green or colors.gray, colors.white}, function() state.trainType = 'Local' end)
      addButton(x2, y, 'Express', btnW, btnH, {state.trainType == 'Express' and colors.blue or colors.gray, colors.white}, function() state.trainType = 'Express' end)
    end
    addCancelButton()
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'terminal' end)
    if state.trainType then addButton(w-9, h-3, 'NEXT->', 8, 3, {colors.black, colors.green}, function() state.page = 'trips' end) end
    waitButtons()
    if ui_cancel_request then
      renderConfirmCancel()
      waitButtons()
      if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
      ui_cancel_request = false
    end
  end
end

local function showTrips()
  while state.page == 'trips' do
    drawHeader('Select Trips', 'From: ' .. stationDisplay(state.departure) .. '  To: ' .. stationDisplay(state.terminal))
    Buttons = {}
    local y = math.floor(h/2) - 2
    local midW = 14
    local xMid = math.floor((w - midW) / 2) + 1
    addButton(xMid, y, tostring(state.trips) .. (state.trips == 1 and ' TRIP' or ' TRIPS'), midW, 3, {colors.gray, colors.white}, function() end)
    addButton(xMid-5, y, '+', 4, 3, {colors.black, colors.green}, function() state.trips = math.min(99, (tonumber(state.trips) or 1) + 1) end)
    addButton(xMid+midW+1, y, '-', 4, 3, {colors.black, colors.red}, function() state.trips = math.max(1, (tonumber(state.trips) or 1) - 1) end)
    addCancelButton()
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'type' end)
    addButton(w-9, h-3, 'NEXT->', 8, 3, {colors.black, colors.green}, function() state.page = 'order' end)
    waitButtons()
    if ui_cancel_request then
      renderConfirmCancel()
      waitButtons()
      if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
      ui_cancel_request = false
    end
  end
end

local function computeCost(src, dst, trainType)
  src = normalizeCode(src)
  dst = normalizeCode(dst)
  if sameLogicalStation(src, dst) then return 0 end
  local adj = trainType == 'Express' and adjacency_express or adjacency_regular

  local heapN, heapD = {}, {}
  local function heapPush(n, d)
    local i = #heapN + 1
    heapN[i] = n; heapD[i] = d
    while i > 1 do
      local p = math.floor(i / 2)
      if heapD[p] <= heapD[i] then break end
      heapN[p], heapN[i] = heapN[i], heapN[p]
      heapD[p], heapD[i] = heapD[i], heapD[p]
      i = p
    end
  end
  local function heapPop()
    if #heapN == 0 then return nil end
    local n, d = heapN[1], heapD[1]
    local ln, ld = heapN[#heapN], heapD[#heapD]
    heapN[#heapN], heapD[#heapD] = nil, nil
    if #heapN > 0 then
      heapN[1], heapD[1] = ln, ld
      local i = 1
      while true do
        local l = i * 2
        local r = l + 1
        if l > #heapN then break end
        local m = l
        if r <= #heapN and heapD[r] < heapD[l] then m = r end
        if heapD[i] <= heapD[m] then break end
        heapN[i], heapN[m] = heapN[m], heapN[i]
        heapD[i], heapD[m] = heapD[m], heapD[i]
        i = m
      end
    end
    return n, d
  end

  local dist = { [src] = 0 }
  heapPush(src, 0)
  while true do
    local u, du = heapPop()
    if not u then break end
    if du == dist[u] then
      if u == dst then return du end
      for v, w in pairs(adj[u] or {}) do
        local nd = du + (tonumber(w) or 0)
        if nd < (dist[v] or math.huge) then
          dist[v] = nd
          heapPush(v, nd)
        end
      end
    end
  end
  return nil
end

local function drawOrder()
  if state.productMode == 'card' then
    local cardSub = (state.cardMode == 'redeem') and 'Redeem IC card order' or 'Open new stored-value card'
    drawHeader('Confirm Card', cardSub)
  else
    local depLine = stationDisplay(state.departure)
    local terLine = stationDisplay(state.terminal)
    drawHeader('Confirm Order', 'From: ' .. depLine .. '  To: ' .. terLine)
  end
  local y = 5
  local function L(label, value, col)
    termDev.setTextColor(colors.white)
    termDev.setCursorPos(2, y); termDev.write(label .. ': ')
    termDev.setTextColor(col or colors.lightBlue)
    termDev.write(tostring(value or 'N/A'))
    y = y + 2
  end

  local cost = tonumber(state.cost)
  local paid = tonumber(state.paid) or 0
  local total = tonumber(state.cost)
  if state.productMode == 'card' then
    L('Action', (state.cardMode == 'redeem') and 'Online Redeem' or 'Open Card', colors.lightBlue)
    L('Name', firstString(state.holderName, 'CARD USER'), colors.yellow)
    L('Top-up', tonumber(state.cardTopup) or 0, colors.lightBlue)
    L('Balance', tonumber(state.cardBalance) or 0, colors.cyan)
    L('Order', tonumber(state.cardOrderValue) or 0, colors.red)
    if state.cardPaymentMode == 'online' then
      L('Payment', 'Paid Online', colors.green)
    else
      L('Pay Now', total or 0, colors.red)
    end
    if state.voucher_code then
      L('Voucher', tostring(state.voucher_code), colors.cyan)
    end
  else
    L('Type', state.trainType or 'N/A', colors.lightBlue)
    local rides = tonumber(state.trips) or 1
    if cost == nil then
      local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
      cost = unit and (unit * rides) or nil
    end
    L('Trips', rides)
    L('Cost', cost, colors.red)
    if state.voucher_code then
      L('Voucher', tostring(state.voucher_code), colors.cyan)
    end
    local discount = (CFG.promotion and CFG.promotion.discount) or 1
    if discount > 0 and discount < 1 then
      local promoName = (CFG.promotion and CFG.promotion.name) or 'Promo'
      local perc = math.floor(discount * 100)
      local promoText = ('[PROMO] %s • Discount: %d%%'):format(promoName, perc)
      drawRainbowLabelRow(y, promoText, colors.black)
      y = y + 2
    end
  end
  if total == nil then total = cost or 0 end
  local remain = math.max(0, total - paid)
  local statusY = math.max(1, h - 6)
  local barW = math.max(10, w - 10)
  local ratio = (total <= 0) and 1 or math.min(1, paid / math.max(1, total))
  local fill = math.floor(barW * ratio)
  local barX = math.max(1, math.floor((w - barW) / 2) + 1)
  termDev.setCursorPos(barX, statusY)
  termDev.setTextColor(colors.green)
  termDev.write(string.rep('=', fill))
  termDev.setTextColor(colors.gray)
  termDev.write(string.rep('-', math.max(0, barW - fill)))
  statusY = statusY + 1
  if remain <= 0 then
    termDev.setTextColor(colors.green)
    centerText(statusY, ('Paid: %d / %d  (OK)'):format(paid, total), colors.green)
  else
    termDev.setTextColor(colors.red)
    centerText(statusY, ('Paid: %d / %d  Remaining: %d'):format(paid, total, remain), colors.red)
  end
  if total <= 0 then
    centerText(statusY + 1, 'Ready to confirm', colors.lightGray)
  else
    centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray)
  end
end

local function showOrderAndAudio()
  state.paid = 0; state.doneAudioPlayed = false; state.order_datetime = os.date('%Y/%m/%d %H:%M:%S')
  
  -- Recalculate cost immediately to avoid stale data
  if state.productMode == 'card' then
    state.cardDeposit = 0
    state.cardOrderValue = tonumber(state.cardOrderValue) or (tonumber(state.cardTopup) or 0)
    state.cardBalance = tonumber(state.cardBalance) or tonumber(state.cardTopup) or 0
    if state.cardPaymentMode ~= 'online' then
      state.cost = state.cardOrderValue
    elseif state.cost == nil then
      state.cost = 0
    end
  elseif not state.voucher_code then
    local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
    if not unit then
      showAlert('No route')
      state.page = 'terminal'
      return
    end
    state.cost = unit * (state.trips or 1)
  else
    if state.cost == nil then state.cost = 0 end
  end

  if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
    showAlert('Same station')
    state.page = state.voucher_code and 'online' or 'terminal'
    return
  end
  
  local confirmed = false
  local processing = false
  local statusMsg, statusCol = '', colors.red
  local render = nil
  
  local function confirmAction()
    if processing or confirmed then return end
    if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
      showAlert('Same station')
      if render then render() end
      return
    end
    processing = true
    statusMsg, statusCol = 'Processing...', colors.yellow
    if render then render() end
    if state.productMode == 'card' then
      local payload = {
        holder_name = firstString(state.holderName, 'CARD USER'),
        deposit = tonumber(state.cardDeposit) or 0,
        topup = tonumber(state.cardTopup) or 0,
        balance = tonumber(state.cardBalance) or 0,
        order_value = tonumber(state.cardOrderValue) or 0,
        voucher_code = state.voucher_code,
        station_code = state.stationCode or CURRENT_STATION_CODE,
        device = currentDeviceId(),
        card_mode = state.cardMode or 'open',
        payment_mode = state.cardPaymentMode or 'local',
        amount_paid = tonumber(state.paid) or 0
      }
      local reuseBlankCardId = firstString(state.pendingBlankCardId)
      if #reuseBlankCardId > 0 then
        payload.card_id = reuseBlankCardId
      end
      local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending'
      if #reuseBlankCardId == 0 then
        okIssueBlank, blankCardId, issueMethod = issueBlankICCard(payload.holder_name, payload.balance)
      else
        okIssueBlank, blankCardId = true, reuseBlankCardId
      end
      if okIssueBlank and #blankCardId > 0 then
        payload.card_id = blankCardId
        state.pendingBlankCardId = blankCardId
        local okReq, code, parsed, err = submitCardOpen(payload)
        if okReq then
          local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
          state.card_id = firstString(respData.card_id, respData.id, payload.card_id)
          state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance
          state.card_server_data = respData
          state.pendingBlankCardId = nil
          confirmed = true
          statusMsg, statusCol = 'Card ready', colors.green
          if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
        else
          local errorMsg = 'Card API Err'
          if code == 409 then
            errorMsg = 'Already Used!'
          elseif err and #tostring(err) > 0 then
            errorMsg = tostring(err)
          elseif code and type(code) == 'number' then
            errorMsg = 'HTTP ' .. tostring(code)
          end
          statusMsg, statusCol = 'Issued, retry sync: ' .. errorMsg, colors.red
        end
      elseif okIssueBlank then
        statusMsg, statusCol = 'Card issued without ID', colors.red
      else
        local okReq, code, parsed, err = submitCardOpen(payload)
        if okReq then
          local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
          local finalCard = {
            card_id = firstString(respData.card_id, respData.id, payload.card_id),
            holder_name = payload.holder_name,
            balance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance,
            deposit = firstNumber(respData.deposit, payload.deposit) or payload.deposit,
            topup = firstNumber(respData.topup, respData.first_topup, payload.topup) or payload.topup,
            station_code = payload.station_code,
            device = payload.device,
            voucher_code = payload.voucher_code,
            media = 'ic_card',
            product_type = 'stored_value',
            order_value = payload.order_value,
            initial_balance = payload.topup
          }
          local okWrite, writtenCard, writeMethod = writeICCard(finalCard)
          if okWrite then
            state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
            state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
            state.card_server_data = respData
            state.pendingBlankCardId = nil
            confirmed = true
            statusMsg, statusCol = 'Card ready', colors.green
            if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
          else
            statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red
          end
        else
          local errorMsg = 'Card API Err'
          if code == 409 then
            errorMsg = 'Already Used!'
          elseif err and #tostring(err) > 0 then
            errorMsg = tostring(err)
          elseif code and type(code) == 'number' then
            errorMsg = 'HTTP ' .. tostring(code)
          end
          statusMsg, statusCol = errorMsg, colors.red
        end
      end
    elseif state.voucher_code then
      local url = API_BASE .. '/public/orders/' .. state.voucher_code .. '/consume'
      local okReq, code = postJSONResult(url, {})
      if okReq and code and code >= 200 and code < 300 then
        confirmed = true
        if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
      else
        local errorMsg = 'NetErr'
        if code == 409 then
          errorMsg = 'Already Used!'
        elseif code and type(code) == 'number' then
          errorMsg = 'HTTP ' .. tostring(code)
        end
        statusMsg, statusCol = errorMsg, colors.red
      end
    else 
      confirmed = true 
      local totalCost = tonumber(state.cost) or 0
      if totalCost <= 0 and not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
    end
    processing = false
    if render and not confirmed then render() end
  end

  render = function()
    Buttons = {}
    drawOrder()
    addCancelButton()
    local same = (state.productMode ~= 'card') and sameLogicalStation(state.departure, state.terminal)
    local costV = tonumber(state.cost)
    local paidV = tonumber(state.paid) or 0
    local canConfirm = (not same) and ((costV or 0) <= 0 or (costV ~= nil and paidV >= costV))
    local msgY = h - 2
    if statusMsg and #statusMsg > 0 then centerText(msgY, statusMsg, statusCol) end
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function()
      stopAudio()
      if state.productMode == 'card' then
        state.page = state.voucher_code and 'card_online' or 'card_topup'
      else
        state.page = state.voucher_code and 'online' or 'trips'
      end
    end)
    if canConfirm then
      if processing then
        addButton(w-12, h-3, 'WAIT', 10, 3, {colors.gray, colors.white}, function() end)
      else
        addButton(w-12, h-3, 'CONFIRM', 10, 3, {colors.green, colors.black}, confirmAction)
      end
    end
  end
  render()
  
  -- If cost is zero, auto-confirm immediately after a brief delay for audio
  if ((state.productMode == 'card') or (not sameLogicalStation(state.departure, state.terminal))) and ((tonumber(state.cost) or 0) <= 0) then
    sleep(0.5)
    confirmAction()
  end
  
  local prev = redstone.getInput('right')
  while state.page == 'order' do
    local ev, p1, p2, p3 = os.pullEvent()
    if ev == 'redstone' then
      local now = redstone.getInput('right')
      if now and not prev then
        playNote('hat', 20, 1, 0.01)
        state.paid = (state.paid or 0) + 1; render()
        if state.paid >= (state.cost or 0) then
          if not state.doneAudioPlayed then
            playConfirmTicketMelody(); state.doneAudioPlayed = true
          end
          -- Auto-confirm when paid enough
          sleep(0.5) -- Wait for UI/Audio slightly
          confirmAction()
        end
      end; prev = now
    elseif ev == 'mouse_click' or ev == 'monitor_touch' then
      -- For mouse_click: p1=button, p2=x, p3=y
      -- For monitor_touch: p1=side, p2=x, p3=y
      for _, b in ipairs(Buttons) do 
        if inRect(b, p2, p3) then 
          clickSound()
          if b.onClick then b.onClick() end; 
          render() 
          break
        end 
      end
      if ui_cancel_request then
        renderConfirmCancel()
        waitButtons()
        if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false; break end
        ui_cancel_request = false
        render()
      end
    elseif ev == 'config_updated' then
      render()
    end
    if confirmed then state.page = 'done'; break end
  end
end

local function showPrePrintCheck()
  while state.page == 'preprint' do
    state.page = 'done'
  end
end

local function generateTicketId()
  local function randLetter()
    return string.char(string.byte('A') + math.random(0, 25))
  end
  local prefix = randLetter() .. randLetter()
  local num = string.format('%08d', math.random(0, 99999999))
  return prefix .. '-' .. num
end

local function ensureTicketIdFormat(id)
  if id == nil then return generateTicketId() end
  local s = tostring(id)
  s = s:gsub('%s+', '')
  local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$')
  if prefix and num then
    prefix = prefix:upper()
    if #num < 8 then
      num = string.rep('0', 8 - #num) .. num
    elseif #num > 8 then
      num = num:sub(-8)
    end
    return prefix .. '-' .. num
  end
  return s:lower()
end



local function showDone()
  if state.productMode == 'card' then
    drawHeader('Card Ready', 'Please take your IC card')
    local y = 5
    local function line(label, value, col)
      termDev.setTextColor(colors.white)
      termDev.setCursorPos(2, y); termDev.write(label .. ': ')
      termDev.setTextColor(col or colors.lightBlue)
      termDev.write(tostring(value or ''))
      y = y + 2
    end
    line('Name', firstString(state.holderName, 'CARD USER'), colors.yellow)
    line('Deposit', tonumber(state.cardDeposit) or 0, colors.lightBlue)
    line('Top-up', tonumber(state.cardTopup) or 0, colors.lightBlue)
    line('Balance', tonumber(state.cardBalance) or 0, colors.green)
    line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan)
    if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end
    saveCardIssueSnapshot({
      card_id = state.card_id,
      holder_name = state.holderName,
      deposit = state.cardDeposit,
      topup = state.cardTopup,
      balance = state.cardBalance,
      voucher_code = state.voucher_code,
      station_code = state.stationCode or CURRENT_STATION_CODE,
      ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
    })
    for i = 5, 1, -1 do
      centerText(h-4, 'Returning to Home: ' .. i .. 's', colors.red)
      sleep(1)
    end
    resetTicketFlow()
    state.page = 'home'
    return
  end
  local rides = tonumber(state.trips) or 1
  local orderDatetime = state.order_datetime or os.date('%Y/%m/%d %H:%M:%S')
  local rawCost = tonumber(state.cost)
  if (not state.voucher_code) and rawCost == nil then
    local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
    rawCost = unit and (unit * rides) or 0
    state.cost = rawCost
  end
  local cost = rawCost or 0
  local issueSource = 'local'
  local startCode = normalizeCode(state.departure)
  local terminalCode = normalizeCode(state.terminal)
  local startObj = stationByCode[startCode]
  local terminalObj = stationByCode[terminalCode]
  local fromNameEn = (startObj and (startObj.en_name or startObj.name)) or state.departure
  local toNameEn = (terminalObj and (terminalObj.en_name or terminalObj.name)) or state.terminal
  local fromNameCnU = (startObj and unicodeEscape(startObj.name)) or ''
  local toNameCnU = (terminalObj and unicodeEscape(terminalObj.name)) or ''
  local startStationArg = tostring(startCode or '')
  local terminalStationArg = tostring(terminalCode or '')
  local fromNameEnArg = tostring(fromNameEn or '???')
  local toNameEnArg = tostring(toNameEn or '???')
  local fromNameCnUArg = tostring(fromNameCnU or '')
  local toNameCnUArg = tostring(toNameCnU or '')
  local issueArgs = {
    start_name_en = fromNameEnArg,
    terminal_name_en = toNameEnArg,
    type = (state.trainType == 'Express') and 'limited_express' or 'local',
    rides = rides,
    cost = cost,
    start_station = startStationArg,
    terminal_station = terminalStationArg,
    fromNameCnU = fromNameCnUArg,
    toNameCnU = toNameCnUArg,
  }
  _G.TICKET_MACHINE_LAST_TICKET = {
    start_station_id = startCode,
    terminal_station_id = terminalCode,
    start_station = startCode,
    terminal_station = terminalCode,
    start_name = startObj and unicodeEscape(startObj.name) or nil,
    terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil,
    start_name_en = fromNameEn,
    terminal_name_en = toNameEn
  }
  if ticketVendingMachine and ticketVendingMachine.issueTicket then
    local apiType = (state.trainType == 'Express') and 'limited_express' or 'local'
    local okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
    if not (okCall and okIssue and ticketId) then
      okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
    end
    if okCall and okIssue and ticketId then
      state.ticket_id = ensureTicketIdFormat(ticketId)
      issueSource = 'ticket_vending_machine'
    else
      state.ticket_id = generateTicketId()
    end
  else
    state.ticket_id = generateTicketId()
  end

  pcall(function()
    ensureDir('logs/last_ticket_issue.json')
    issueArgs.ticket_id = state.ticket_id
    issueArgs.issue_source = issueSource
    issueArgs.ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
    local okSer, s = pcall(textutils.serializeJSON, issueArgs)
    if okSer and type(s) == 'string' then
      local f = fs.open('logs/last_ticket_issue.json', 'w')
      if f then f.write(s); f.close() end
    end
  end)

  local typeStr = (state.trainType == 'Express') and 'Limited Express' or 'Ordinary'
  local ticketData = {
    start_station = startCode,
    terminal_station = terminalCode,
    end_station = terminalCode,
    type = typeStr,
    entered = false,
    exited = false,
    id = state.ticket_id,
    ticket_id = state.ticket_id,
    trips_total = rides,
    trips_remaining = rides,
    cost = cost,
    order_datetime = orderDatetime,
    timestamp = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
  }

  local ticketDataMod = {}
  for k, v in pairs(ticketData) do ticketDataMod[k] = v end
  ticketDataMod.start_station_id = startCode
  ticketDataMod.terminal_station_id = terminalCode
  ticketDataMod.start_station = startCode
  ticketDataMod.terminal_station = terminalCode
  ticketDataMod.start_name = startObj and unicodeEscape(startObj.name) or nil
  ticketDataMod.terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil
  ticketDataMod.start_name_en = fromNameEn
  ticketDataMod.terminal_name_en = toNameEn
  ticketDataMod.issue_source = issueSource
  ticketDataMod.ticket_id = state.ticket_id
  ticketDataMod.train_type = state.trainType or 'Local'
  ticketDataMod.station_code = state.stationCode or CURRENT_STATION_CODE
  ticketDataMod.device = currentDeviceId()
  _G.TICKET_MACHINE_LAST_TICKET = ticketDataMod

  if MOD_DEBUG then
    local t = _G.TICKET_MACHINE_LAST_TICKET
    print("DEBUG start_name_en=" .. tostring(t.start_name_en))
    print("DEBUG terminal_name_en=" .. tostring(t.terminal_name_en))
    print("DEBUG cost=" .. tostring(t.cost))
  end

  local okUpload, uploadRes = pcall(enqueueTicketUpload, ticketDataMod)
  if not okUpload then
    print('enqueueTicketUpload error: ' .. tostring(uploadRes))
  end
  drawHeader('Purchase Complete', 'Please take your ticket')
  local y = 5
  local function line(label, value, col)
    termDev.setTextColor(colors.white)
    termDev.setCursorPos(2, y); termDev.write(label .. ': ')
    termDev.setTextColor(col or colors.lightBlue)
    termDev.write(tostring(value or ''))
    y = y + 2
  end
  line('From', fromNameEn, colors.yellow)
  line('To', toNameEn, colors.yellow)
  line('Type', typeStr, colors.lightBlue)
  line('Trips', rides, colors.lightBlue)
  line('Cost', cost, colors.red)
  line('ID', state.ticket_id or '', colors.cyan)

  
  
  for i = 5, 1, -1 do
    centerText(h-4, 'Returning to Home: ' .. i .. 's', colors.red)
    sleep(1)
  end
  resetTicketFlow()
  state.page = 'home'
end

local function showOnlineVoucher()
  local code = ''
  local msg, msgCol = '', colors.red
  local rows = {
    {'1','2','3','4','5','6','7','8','9','0'},
    {'Q','W','E','R','T','Y','U','I','O','P'},
    {'A','S','D','F','G','H','J','K','L'},
    {'Z','X','C','V','B','N','M'}
  }
  
  local function submitCode()
    if #code == 5 then
      local res = fetchJSON(API_BASE .. '/public/orders/' .. code)
      if res and res.ok then
        local d = res.data or res
        
        -- Check if already consumed
        if d.consumed then
          msg, msgCol = 'Already Used!', colors.red
          return
        end
        
        state.departure = d.start or d.from or d.departure
        state.terminal = d.terminal or d.to or d.destination
        state.trainType = normalizeTrainTypeLabel(d.train_type or d.type or 'Local')
        state.trips = tonumber(d.trips or d.count) or 1
        state.cost = tonumber(d.price) or tonumber(d.cost) or 0
        state.voucher_code = code
        msg = ''
        state.page = 'order'
      else
        msg, msgCol = 'Voucher Invalid', colors.red
      end
    end
  end

  while state.page == 'online' do
    local placeholder = code .. string.rep('_', math.max(0, 5-#code))
    drawHeader('Enter Voucher', 'Type 5 chars then OK')
    centerText(4, '[' .. placeholder .. ']', colors.yellow)
    if msg and #msg > 0 then centerText(5, msg, msgCol) end
    Buttons = {}
    
    local keyW, keyH = (w < 44 and 2 or 3), 2
    local kbW = 10 * (keyW + 1)
    local sX, sY = math.max(1, math.floor((w - kbW) / 2) + 1), 7
    
    for rIdx, row in ipairs(rows) do
      local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
      for _, ch in ipairs(row) do
        addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() if #code < 5 then code = code .. ch end end)
        x = x + keyW + 1
      end
    end
    
    local actY = h - 6
    local bw, cw, ow = 10, 8, 8
    local gap = 2
    local total = bw + gap + cw + gap + ow
    local ax = math.floor((w - total) / 2) + 1
    if ax < 1 then
      bw, cw, ow = 6, 6, 6
      total = bw + 1 + cw + 1 + ow
      ax = math.floor((w - total) / 2) + 1
      addButton(math.max(1, ax), actY, 'BKSP', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
      addButton(math.max(1, ax) + bw + 1, actY, 'CLEAR', cw, 3, {colors.black, colors.red}, function() code = '' end)
      addButton(math.max(1, ax) + bw + 1 + cw + 1, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
    else
      addButton(ax, actY, 'Backspace', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
      addButton(ax + bw + gap, actY, 'Clear', cw, 3, {colors.black, colors.red}, function() code = '' end)
      addButton(ax + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
    end
    addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
    
    local ev, p1, p2, p3 = os.pullEvent()
    if ev == 'mouse_click' or ev == 'monitor_touch' then
      -- For mouse_click: p1=button, p2=x, p3=y
      -- For monitor_touch: p1=side, p2=x, p3=y
      for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end
    elseif ev == 'char' and #code < 5 then code = code .. p1:upper()
    elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2)
    elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
    elseif ev == 'config_updated' then
      -- Config updated in background, continue to redraw
    end
  end
end

-- ###########################
-- Main entry point
-- ###########################
local function mainPageLoop()
  while true do
    if state.page == 'home' then showHome()
    elseif state.page == 'card_home' then showCardHome()
    elseif state.page == 'card_name' then showCardNameInput()
    elseif state.page == 'card_topup' then showCardTopup()
    elseif state.page == 'card_online' then showCardOnlineRedeem()
    elseif state.page == 'departure' then showDeparture()
    elseif state.page == 'terminal' then showTerminal()
    elseif state.page == 'type' then showType()
    elseif state.page == 'trips' then showTrips()
    elseif state.page == 'order' then showOrderAndAudio()
    elseif state.page == 'preprint' then showPrePrintCheck()
    elseif state.page == 'done' then showDone()
    elseif state.page == 'online' then showOnlineVoucher()
    else state.page = 'home'; sleep(0.5) end
  end
end

parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask)
