-- The boot shell: the heart of the self-updater.  A fused build, before it
-- runs the game bundled inside it, looks in its save directory for a newer
-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and
-- runnable, mounts it over the bundled source and chainloads it -- so the
-- binary shipped once can keep updating the Lua it runs without a reinstall.
--
-- Only a fused build self-updates.  A dev / source checkout IS the game, so
-- Boot.run is a no-op there.
--
-- Three pieces, deliberately layered so the risky part is small and the
-- decision part is testable:
--   * Boot.select     -- pure: given probed candidates + the bundled version,
--                        decide what to run and what to delete.  No love.*.
--   * Boot.probePayload-- read one archive's advertised version, isolated.
--   * Boot.run        -- orchestrates: crash-guard, enumerate, select, and
--                        (if a payload wins) mount + chainload with full
--                        rollback on any failure so the bundled game always
--                        boots.
--
-- Known limitation: the bundled love.run keeps driving the frame loop after a
-- handoff (it has already returned its stepper to LÖVE; redefining the global
-- love.run does nothing to the running one).  A payload that must change
-- love.run itself therefore requires a minShell bump so an older shell refuses
-- to chainload it.

local Semver = require("src.update.Semver")

local Boot = {}

-- Save-directory layout (identity "pokemon-love2d"), per the shared contract.
local PAYLOAD_DIR = "updates"
local PENDING = "updates/pending.txt"

-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its
-- copy never collides with the running source's copy at "/".
local PROBE_MOUNT = "__pokeport_probe"

-- Downloaded payloads are named gen1recomp-<X.Y.Z>.love.
local function isPayloadName(name)
  return name:match("^gen1recomp%-.+%.love$") ~= nil
end

-- The love callbacks the payload's main.lua chunk may redefine when it runs.
-- We snapshot these before a handoff and restore them if the handoff fails, so
-- the exact bundled closures (with their intact upvalues) drive the game
-- again.  love.run is included: harmless to restore, and it is one of the
-- globals a payload main.lua reassigns.
local CALLBACK_NAMES = {
  "load", "update", "draw", "quit", "run",
  "keypressed", "keyreleased", "textinput",
  "mousepressed", "mousereleased", "mousemoved", "wheelmoved",
  "touchpressed", "touchmoved", "touchreleased",
  "gamepadpressed", "gamepadreleased", "gamepadaxis",
  "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
  "joystickremoved",
  "focus", "visible", "resize", "filedropped", "directorydropped",
  "errorhandler", "threaderror", "lowmemory",
}

local function snapshotCallbacks()
  local snap = {}
  for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end
  return snap
end

local function restoreCallbacks(snap)
  for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end
end

-- Drop every bundled Lua module the payload must be allowed to re-resolve: all
-- src.* modules plus the main/conf chunks.  conf.lua cached the bundled
-- Version into package.loaded["src.core.Version"]; without this purge the next
-- require would hand back the bundled copy instead of the payload's.  Setting
-- existing fields to nil during a pairs traversal is explicitly permitted.
local function purgeBundledModules()
  for key in pairs(package.loaded) do
    if key:match("^src%.") or key == "main" or key == "conf" then
      package.loaded[key] = nil
    end
  end
end

-- Boot.probePayload(rel)
--   -> { engine = string, minShell = number, payloadHost = string } | nil, err
--
-- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with
-- loadstring (NEVER require -- we must not cache or run it as a module), then
-- unmount.  Version.lua is zero-require, so running its chunk is safe.
function Boot.probePayload(rel)
  if not love.filesystem.mount(rel, PROBE_MOUNT) then
    return nil, "could not mount " .. tostring(rel)
  end
  local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua"
  local ok, result = pcall(function()
    local src = love.filesystem.read(chunkPath)
    if not src then error("Version.lua missing", 0) end
    local chunk = loadstring(src, "@" .. chunkPath)
    if not chunk then error("Version.lua would not compile", 0) end
    return chunk()
  end)
  love.filesystem.unmount(rel)
  if not ok then return nil, tostring(result) end
  local v = result
  if type(v) ~= "table" or type(v.engine) ~= "string" then
    return nil, "payload has no usable Version table"
  end
  return {
    engine = v.engine,
    minShell = tonumber(v.minShell) or 1,
    payloadHost = type(v.payloadHost) == "string" and v.payloadHost or "love",
  }
end

-- Pure host gate shared by boot selection and the download worker. Missing
-- payloadHost fields mean "love" so payloads made before this contract remain
-- compatible with ordinary LOVE packages.
function Boot.canHost(info, bundledShell, bundledPayloadHost)
  if type(info) ~= "table" then return false end
  local payloadHost = type(info.payloadHost) == "string"
    and info.payloadHost or "love"
  local host = type(bundledPayloadHost) == "string"
    and bundledPayloadHost or "love"
  return payloadHost == host and (tonumber(info.minShell) or 1)
    <= (tonumber(bundledShell) or 1)
end

local function samePayloadHost(info, bundledPayloadHost)
  local payloadHost = type(info.payloadHost) == "string"
    and info.payloadHost or "love"
  local host = type(bundledPayloadHost) == "string"
    and bundledPayloadHost or "love"
  return payloadHost == host
end

-- Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
--   -> chosen | nil, toDelete
--
-- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = , payloadHost = }.
--   * chosen: the highest engine that is STRICTLY newer than bundledEngine and
--     whose payloadHost matches and minShell <= bundledShell.
--   * toDelete: stale payloads -- engine <= bundled (old or the same as what we
--     already ship), or superseded by the chosen one (not newer than chosen).
--     A newer incompatible payload is kept: a matching host or future shell
--     may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
  local chosen
  for _, c in ipairs(candidates) do
    local newer = Semver.compare(c.engine, bundledEngine) > 0
    local runnable = Boot.canHost(c, bundledShell, bundledPayloadHost)
    if newer and runnable then
      if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
        chosen = c
      end
    end
  end

  local toDelete = {}
  for _, c in ipairs(candidates) do
    if not (chosen and c.name == chosen.name) then
      local stale = false
      -- Never clean up another host family's payloads. A shared save directory
      -- may be opened by multiple native packages, and only the matching host
      -- can decide whether one of its own archives is stale.
      if samePayloadHost(c, bundledPayloadHost) then
        stale = Semver.compare(c.engine, bundledEngine) <= 0
        if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
          stale = true
        end
      end
      if stale then toDelete[#toDelete + 1] = c.name end
    end
  end

  return chosen and chosen.name or nil, toDelete
end

-- Mount the chosen payload and hand control to it.  Returns true when the
-- payload is live and has completed its own love.load; false (with full
-- rollback) on any failure, so the caller runs the bundled game instead.
local function chainload(name, args)
  local rel = PAYLOAD_DIR .. "/" .. name

  -- Crash marker: if we die between here and clearing it, the next boot's
  -- crash guard distrusts this payload and deletes it.
  love.filesystem.write(PENDING, name)

  -- Prepend-mount the payload at "/" (appendToPath = false) so its files win
  -- over the fused source for every subsequent require / love.filesystem read.
  if not love.filesystem.mount(rel, "/", false) then
    love.filesystem.remove(PENDING)
    return false
  end

  local snapshot = snapshotCallbacks()
  purgeBundledModules()
  _G.POKEPORT_PAYLOAD_MOUNTED = true

  -- Chainload: run the payload's main.lua (redefines the love callbacks from
  -- the NEW code), then call its love.load.  The new love.load calls Boot.run
  -- again, which no-ops via the flag set above.
  local ok, err = pcall(function()
    local chunk = assert(love.filesystem.load("main.lua"))
    chunk()
    love.load(args)
  end)

  if not ok then
    -- Handoff failed after mounting.  Unwind everything so the bundled game
    -- boots cleanly: clear the flag, unmount the payload, purge any payload
    -- modules it cached (so bundled requires reload from source), restore the
    -- bundled love callbacks with their intact upvalues, and drop the marker.
    -- Delete the payload too: it failed deterministically once, so leaving it
    -- would re-select and re-fail it on every boot forever.
    print("update: payload handoff failed, reverting to bundled: " .. tostring(err))
    _G.POKEPORT_PAYLOAD_MOUNTED = nil
    pcall(love.filesystem.unmount, rel)
    purgeBundledModules()
    restoreCallbacks(snapshot)
    love.filesystem.remove(rel)
    love.filesystem.remove(PENDING)
    return false
  end

  -- Success: the payload owns the game now.  Drop the marker and tell the
  -- caller to stop so the bundled love.load does not run on top of it.
  love.filesystem.remove(PENDING)
  return true
end

-- Everything after the fused / flag guards, wrapped so an unexpected error in
-- enumeration or selection can never crash the boot.
local function runInner(args)
  -- Crash guard first: a pending.txt naming a payload means a previous boot
  -- crashed mid-handoff.  Distrust that payload -- delete it and the marker --
  -- then continue (we may still pick an older valid payload, or fall through
  -- to the bundled game).
  local pending = love.filesystem.read(PENDING)
  if pending then
    pending = pending:gsub("%s+$", "")
    if pending ~= "" then
      love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending)
    end
    love.filesystem.remove(PENDING)
  end

  -- Enumerate and probe every payload in updates/.
  local candidates = {}
  if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then
    for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do
      if isPayloadName(entry) then
        local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry)
        if info then
          candidates[#candidates + 1] = {
            name = entry,
            engine = info.engine,
            minShell = info.minShell,
            payloadHost = info.payloadHost,
          }
        end
      end
    end
  end

  local Version = require("src.core.Version")
  local chosen, toDelete = Boot.select(candidates, Version.engine,
    Version.shell, Version.payloadHost)

  for _, victim in ipairs(toDelete) do
    love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
  end

  if not chosen then return false end
  return chainload(chosen, args)
end

-- Boot.run(args) -> boolean
--
-- The first line of love.load.  True means a payload was mounted and
-- chainloaded and the caller must return immediately; false means boot the
-- bundled game as normal.
function Boot.run(args)
  -- Dev / source checkouts never self-update.
  if not (love.filesystem.isFused and love.filesystem.isFused()) then
    return false
  end
  -- Switch (and any host without validated network): never probe payloads.
  local okp, Platform = pcall(require, "src.core.Platform")
  if okp and Platform and Platform.networkValidated
      and not Platform.networkValidated() then
    return false
  end
  -- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
  if _G.POKEPORT_PAYLOAD_MOUNTED then return false end

  local ok, result = pcall(runInner, args)
  if not ok then
    -- An error escaped before any handoff mount (chainload cleans up after
    -- itself), so state is still clean.  Never crash the boot.
    return false
  end
  return result
end

return Boot
