--!name Shadowdark Light Timer
--!icon clock
--!category Games
-- Real-time light for the Shadowdark RPG: torches, lanterns, the Light spell
-- and campfires burn down against the real clock (os.time). Each light's
-- burn-out time is saved, so closing the app or the X3 going to sleep
-- doesn't stop the clock; anything that went out meanwhile is reported when
-- the app opens. Redraws once a minute, on the fast waveform.
-- Third-party; not affiliated with The Arcane Library.

local KINDS = {
  { "Torch", 60, "A torch burns for 1 hour of real time." },
  { "Lantern", 60, "A lantern burns 1 hour on each flask of oil." },
  { "Light spell", 60, "The Light spell lasts 1 hour of real time." },
  { "Campfire", 480, "A campfire burns for up to 8 hours." },
  { "Other light", 60, "Use LEFT and RIGHT on the list to set its time." },
}
local MAXL = 8
local MAXS = 24 * 3600

-- A light is { k = kind, n = number among its kind, ends = os.time() it goes
-- out } while it burns, or { ..., left = seconds } while it's paused.
local lights = {}
local seen = 0
local screen, sel, lsel, psel = "main", 1, 1, 1
local cur, armed, msg

-- 3x5 digits for the big countdown, drawn as board cells.
local F = {
  ["0"] = "####.##.##.####", ["1"] = ".#.##..#..#.###", ["2"] = "###..#####..###",
  ["3"] = "###..####..####", ["4"] = "#.##.####..#..#", ["5"] = "####..###..####",
  ["6"] = "####..####.####", ["7"] = "###..#..#..#..#", ["8"] = "####.#####.####",
  ["9"] = "####.####..####",
}

local DARK = ("#################/"):rep(4) .. "#################"

local function big(s)
  local rows = {}
  for y = 1, 5 do
    local r = {}
    for i = 1, #s do
      local c = s:sub(i, i)
      r[i] = c == ":" and (".#.#."):sub(y, y) or F[c]:sub(y * 3 - 2, y * 3)
    end
    rows[y] = ".." .. table.concat(r, ".") .. ".."
  end
  return table.concat(rows, "/")
end

local function remaining(l, t) return l.ends and math.max(0, l.ends - t) or l.left end
local function mins(s) return (s + 59) // 60 end
local function hm(s) local m = mins(s); return m // 60 .. ":" .. string.format("%02d", m % 60) end
local function dur(s)
  local m = mins(s)
  if m < 60 then return m .. " min" end
  return m // 60 .. " h" .. (m % 60 > 0 and " " .. m % 60 .. " min" or "")
end
local function name(l) return KINDS[l.k][1] .. (l.n > 1 and " " .. l.n or "") end

local function save()
  local o = { "L1|" .. seen }
  for _, l in ipairs(lights) do
    o[#o + 1] = l.k .. "|" .. l.n .. "|" .. (l.ends and "e" .. l.ends or "p" .. l.left)
  end
  snail.save(table.concat(o, "\n"))
end

local function restore()
  local s = snail.load()
  if not s then return end
  seen = tonumber(s:match("^L1|(%d+)")) or 0
  for k, n, m, v in s:gmatch("\n(%d+)|(%d+)|([ep])(%d+)") do
    local l = { k = tonumber(k), n = tonumber(n) }
    if m == "e" then l.ends = tonumber(v) else l.left = tonumber(v) end
    if KINDS[l.k] and #lights < MAXL then lights[#lights + 1] = l end
  end
end

-- The light that goes out first: burning ones before paused ones.
local function first(t)
  local best
  for _, l in ipairs(lights) do
    if not best or (l.ends and not best.ends) or
        ((l.ends ~= nil) == (best.ends ~= nil) and remaining(l, t) < remaining(best, t)) then
      best = l
    end
  end
  return best
end

local function burning()
  for _, l in ipairs(lights) do if l.ends then return true end end
  return false
end

-- Puts out what has burned down. A clock that jumps (set by Wi-Fi for the
-- first time, or moved back) moves the burning lights with it.
local function update()
  local t = os.time()
  local d = t - seen
  local changed = false
  if seen > 0 and (d < 0 or d > 30 * 86400) then
    for _, l in ipairs(lights) do if l.ends then l.ends = l.ends + d end end
    changed = true
  end
  seen = t
  local out, late = {}, 0
  for i = #lights, 1, -1 do
    local l = lights[i]
    if remaining(l, t) <= 0 then
      table.insert(out, 1, name(l))
      if l.ends then late = math.max(late, t - l.ends) end
      table.remove(lights, i)
      if l == cur then screen, cur = "main", nil end
    end
  end
  if #out > 0 then
    msg = table.concat(out, ", ") .. " went out" .. (late >= 120 and " " .. late // 60 .. " min ago" or "") .. "."
    changed = true
  end
  if changed then save() end
end

-- Wakes when the minutes shown change, or when a light goes out.
local function arm()
  local t, best = os.time(), nil
  for _, l in ipairs(lights) do
    if l.ends then
      local r = l.ends - t
      local s = r % 60
      if s == 0 then s = 60 end
      if r < s then s = r end
      if not best or s < best then best = s end
    end
  end
  snail.tick(best and math.max(1, best) * 1000 or 0)
end

local function add(k)
  if #lights >= MAXL then
    msg = "That's as many lights as it keeps."
    return
  end
  local used = {}
  for _, l in ipairs(lights) do if l.k == k then used[l.n] = true end end
  local n = 1
  while used[n] do n = n + 1 end
  local l = { k = k, n = n, ends = os.time() + KINDS[k][2] * 60 }
  lights[#lights + 1] = l
  sel = #lights
  msg = name(l) .. " lit: " .. dur(KINDS[k][2] * 60) .. "."
  save()
end

local function adjust(l, s)
  local t = os.time()
  local r = math.min(MAXS, math.max(0, remaining(l, t) + s))
  if l.ends then l.ends = t + r else l.left = r end
  update()
  save()
end

local function pause(l, on)
  local t = os.time()
  if on and l.ends then l.left, l.ends = remaining(l, t), nil
  elseif not on and l.left then l.ends, l.left = t + l.left, nil end
end

local function putOut(l)
  for i, x in ipairs(lights) do
    if x == l then table.remove(lights, i) end
  end
  msg = name(l) .. " is out."
  screen, cur = "main", nil
  save()
end

-- Main list: the lights, then "light a torch", "light something else" and
-- pause / resume all.
local function mainRows()
  return #lights + 2 + (#lights > 0 and 1 or 0)
end

local function wrap(i, d, n) return (i - 1 + d) % n + 1 end

function start()
  restore()
  update()
  snail.ink("fast")
  arm()
end

function tick()
  update()
  arm()
end

function key(k)
  msg = nil
  update()
  local d = (k == "up" and -1) or (k == "down" and 1) or nil
  local lr = (k == "left" and -1) or (k == "right" and 1) or nil
  if screen == "main" then
    local n = mainRows()
    sel = math.min(sel, n)
    if d then sel = wrap(sel, d, n)
    elseif lights[sel] then
      if k == "ok" then screen, cur, lsel, armed = "light", lights[sel], 1, nil
      elseif lr then adjust(lights[sel], lr * 60) end
    elseif k == "ok" then
      local i = sel - #lights
      if i == 1 then add(1)
      elseif i == 2 then screen, psel = "pick", 1
      else
        local on = burning()
        for _, l in ipairs(lights) do pause(l, on) end
        msg = on and "All paused. Each light keeps its time." or "All burning again."
        save()
      end
    end
  elseif screen == "pick" then
    if d then psel = wrap(psel, d, #KINDS)
    elseif k == "top" then screen = "main"
    elseif k == "ok" then
      add(psel)
      screen = "main"
    end
  elseif screen == "light" then
    local l = cur
    if d then lsel, armed = wrap(lsel, d, 4), nil
    elseif k == "top" then screen, cur = "main", nil
    elseif k == "ok" then
      if lsel == 1 then
        pause(l, l.ends ~= nil)
        msg = l.ends and name(l) .. " is burning again." or name(l) .. " is paused."
        save()
      elseif lsel == 2 then adjust(l, l.k == 2 and 3600 or 600)
      elseif lsel == 3 then adjust(l, -600)
      elseif armed then putOut(l)
      else armed = true end
    end
  end
  arm()
end

local function countdown(l, t)
  snail.board(big(hm(remaining(l, t))))
end

function draw()
  local t = os.time()
  if screen == "pick" then
    snail.title("Light something")
    snail.status(msg or (#lights .. " of " .. MAXL .. " lights"))
    for i, kd in ipairs(KINDS) do
      snail.row(kd[1] .. "   " .. dur(kd[2] * 60), i == psel)
    end
    snail.small(KINDS[psel][3])
    snail.hint("BACK back  OK light it  UP/DN move")
    return
  end
  if screen == "light" then
    local l = cur
    snail.title(name(l))
    snail.status(msg or (l.ends and "Burning" or "Paused"))
    countdown(l, t)
    snail.center(true)
    snail.small(dur(remaining(l, t)) .. (l.ends and " left" or " left, paused"))
    snail.center(false)
    snail.row(l.ends and "Pause" or "Light it again", lsel == 1)
    snail.row(l.k == 2 and "Add a flask of oil (1 hour)" or "Add 10 minutes", lsel == 2)
    snail.row("Take off 10 minutes", lsel == 3)
    snail.row(armed and "Press OK again to put it out" or "Put it out", lsel == 4)
    snail.small(KINDS[l.k][3])
    snail.hint("BACK lights  OK choose  UP/DN move")
    return
  end
  snail.title("Light")
  local f = first(t)
  if not f then
    snail.status(msg or "No light")
    snail.board(DARK, nil, "Total darkness")
    snail.small("Disadvantage on most tasks, and the GM checks for an encounter every round.")
  else
    snail.status(msg or (burning() and #lights .. (#lights > 1 and " lights" or " light") .. " burning" or "All paused"))
    countdown(f, t)
    snail.center(true)
    snail.small(f.ends and name(f) .. " goes out first" or "Paused: " .. name(f) .. " has the least time")
    snail.center(false)
  end
  for i, l in ipairs(lights) do
    local r = remaining(l, t)
    snail.row(name(l) .. "   " .. (l.ends and dur(r) or "paused, " .. dur(r)) ..
      (l.ends and r <= 300 and "  - guttering" or ""), sel == i)
  end
  local n = #lights
  snail.row("+ Light a torch", sel == n + 1)
  snail.row("+ Light something else", sel == n + 2)
  if n > 0 then snail.row(burning() and "Pause all" or "Light all again", sel == n + 3) end
  snail.small("Lights burn on while the app is closed.")
  snail.hint(lights[sel] and "OK open  L/R -1/+1 min  UP/DN move" or "BACK leave  OK choose  UP/DN move")
end
