-- Rendering pipelines: the engine side of the render_pipelines registry.
--
-- A pipeline is a display mode a mod owns.  It may replace the overworld's
-- world pass with geometry of its own (drawWorld) and/or post-process the
-- finished composite (present).  Everything else about being a display mode
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
-- free-roam gate, and never letting a mod's error take the frame down -- is
-- engine plumbing and lives here, so a renderer mod writes the two draw
-- functions and declares the rest.
--
-- The two halves compose independently and in priority order: the highest
-- priority eligible drawWorld renders the world, then every eligible
-- present folds over whatever came out (the world pipeline's canvas, or the
-- vanilla flat/tilt composite when none ran).  A present that is switched
-- off returns its input, so a full ladder of them costs nothing at level 0.
--
-- Nothing here reaches collision, movement, triggers or scripts: like
-- survey zoom and tilt, a pipeline is purely presentational, which is why
-- its level rides in save.options rather than the save proper.
--
-- Spec: docs/modding.md (rendering pipelines)

local Data = require("src.core.Data")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Zoom = require("src.render.Zoom")

local Pipelines = {}

-- id -> level.  Levels live here rather than on the records because the
-- records are merged content: frozen after load, and shared with whatever
-- else reads Data.
local levels = {}

-- ids whose callbacks have already thrown, so a pipeline that fails every
-- frame reports once instead of filling the log at 60Hz
local broken = {}

Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }

-- The merged dataset the records live in.  The boot singleton is the
-- default -- Game.data is that very table -- and install() lets a headless
-- caller (the SDK harness, a tool) point this at a dataset of its own.
local source = Data

function Pipelines.install(data)
  source = data or Data
  Pipelines.reset()
end

-- ------- catalog

-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
-- (descending, ties by id) so selection, the options rows and the present
-- fold all walk the same sequence.
--
-- Memoized on the namespace table's identity.  Content freezes at the merge
-- boundary, so the answer cannot change for a given table -- and this is
-- read several times per frame by update(), worldPipeline() and the
-- endFrame present check, which is no place to allocate and sort.  An empty
-- list is cached too, so a mod-free boot pays one table for the process.
local listCache, listSource = nil, nil

function Pipelines.list()
  local defs = source and source.render_pipelines
  if defs == listSource and listCache then return listCache end
  local out = {}
  if type(defs) == "table" then
    for id, def in pairs(defs) do
      -- the merge writes provenance under _owners; skip the bookkeeping
      -- keys rather than treating them as pipelines
      if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
        out[#out + 1] = { id = id, def = def }
      end
    end
    table.sort(out, function(a, b)
      local pa, pb = a.def.priority or 0, b.def.priority or 0
      if pa ~= pb then return pa > pb end
      return a.id < b.id
    end)
  end
  listCache, listSource = out, defs
  return out
end

function Pipelines.get(id)
  local defs = source and source.render_pipelines
  local def = type(defs) == "table" and defs[id] or nil
  return type(def) == "table" and def or nil
end

-- the mod that registered a pipeline, so a runtime failure lands in the
-- feed the mod manager shows instead of only in the console
local function ownerOf(id)
  local defs = source and source.render_pipelines
  local owners = type(defs) == "table" and defs._owners or nil
  return owners and owners[id] or nil
end

-- Run one of a pipeline's callbacks under pcall.  A mod that throws mid-
-- frame must not take the frame with it: the pipeline is marked broken,
-- attributed once, and treated as absent from then on -- which degrades to
-- the vanilla 2D path rather than a black screen.
local function guard(id, fn, ...)
  if broken[id] then return nil end
  local ok, result = pcall(fn, ...)
  if ok then return result end
  broken[id] = true
  Logger.error("render pipeline %s failed: %s -- disabled for this session",
               id, tostring(result))
  Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
  return nil
end

-- Whether a callback's return is a real Canvas we can composite.  A mod that
-- forgets a return, or hands back a shade string / flag / number, must be
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
-- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v)
  if type(v) == "userdata" then
    return type(v.getWidth) == "function" and type(v.getHeight) == "function"
  end
  if type(v) == "table" then
    return type(v.getWidth) == "function" and type(v.getHeight) == "function"
  end
  return false
end

-- Dispatch a mod render callback with its GPU state fenced off: push("all")
-- before and pop() after, so a callback that returns cleanly but leaves a
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
-- the engine composite that follows.  guard() catches a callback that throws;
-- this catches one that dirties state.  A pipeline already retired skips the
-- push/pop entirely, so the stack stays balanced.
local function guardRender(id, fn, ...)
  if broken[id] then return nil end
  love.graphics.push("all")
  local out = guard(id, fn, ...)
  love.graphics.pop()
  return out
end

-- ------- levels

function Pipelines.levelLabels(id)
  local def = Pipelines.get(id)
  local labels = def and def.levels
  if type(labels) ~= "table" or labels[1] == nil then
    return Pipelines.DEFAULT_LEVELS
  end
  return labels
end

-- highest selectable level: one less than the label count, so a two-label
-- ladder is a plain OFF/ON toggle
function Pipelines.maxLevel(id)
  return #Pipelines.levelLabels(id) - 1
end

function Pipelines.level(id)
  return levels[id] or 0
end

function Pipelines.levelLabel(id, level)
  local labels = Pipelines.levelLabels(id)
  return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
end

-- A world pipeline and the engine's own tilt mode are two answers to the
-- same question, so switching one on switches the other off -- the rule
-- tilt and survey zoom already follow between themselves.  Present-only
-- pipelines (post-processes) compose with tilt and are left alone.
local function excludeTilt(id, level)
  local def = Pipelines.get(id)
  if not (def and def.drawWorld) or level <= 0 then return end
  local Tilt = require("src.render.Tilt")
  if Tilt.level > 0 then Tilt.setLevel(0) end
  -- one world pipeline at a time, for the same reason
  for _, entry in ipairs(Pipelines.list()) do
    if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
      levels[entry.id] = 0
    end
  end
end

function Pipelines.setLevel(id, level)
  if not Pipelines.get(id) then return 0 end
  level = math.floor(tonumber(level) or 0)
  if level < 0 then level = 0 end
  local max = Pipelines.maxLevel(id)
  if level > max then level = max end
  levels[id] = level
  excludeTilt(id, level)
  return level
end

-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
function Pipelines.cycle(id, dir)
  local max = Pipelines.maxLevel(id)
  if max < 1 then return 0 end
  local span = max + 1
  local target = (Pipelines.level(id) + (dir or 1)) % span
  if target < 0 then target = target + span end
  return Pipelines.setLevel(id, target)
end

-- Turning a world pipeline on must switch tilt off in the save too, not
-- just in the live module, or the next boot restores both.  Call sites hand
-- over the options table so this stays the one place that rule lives.
function Pipelines.syncOptions(opts)
  if type(opts) ~= "table" then return end
  local bucket = opts.pipelines
  if type(bucket) ~= "table" then
    bucket = {}
    opts.pipelines = bucket
  end
  for _, entry in ipairs(Pipelines.list()) do
    bucket[entry.id] = Pipelines.level(entry.id)
    if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
      opts.tilt = 0
    end
  end
end

-- Restore levels from a loaded options table.  A pipeline whose mod is gone
-- keeps its stored level untouched in the bucket (so re-enabling the mod
-- restores the mode) but contributes nothing while absent.
function Pipelines.applyOptions(opts)
  local bucket = type(opts) == "table" and opts.pipelines or nil
  levels = {}
  broken = {}
  local world = nil
  for _, entry in ipairs(Pipelines.list()) do
    local stored = type(bucket) == "table" and bucket[entry.id] or 0
    local level = math.floor(tonumber(stored) or 0)
    if level < 0 then level = 0 end
    local max = Pipelines.maxLevel(entry.id)
    if level > max then level = max end
    -- list() is priority order, so the first world pipeline with a stored
    -- level is the one that wins; the rest restore to OFF rather than
    -- sitting on a level that can never render
    if entry.def.drawWorld and level > 0 then
      if world then level = 0 else world = entry.id end
    end
    levels[entry.id] = level
  end
  -- a restored world pipeline and tilt are two answers to the same
  -- question; the pipeline wins, as it does at every place that sets one
  if world then require("src.render.Tilt").setLevel(0) end
end

function Pipelines.reset()
  levels = {}
  broken = {}
end

-- ------- per-frame

-- Presentational tweens run on real frame time, like Tilt's.  Every
-- pipeline ticks, not just the active ones: a mode easing back OUT still
-- has an angle to retire.
function Pipelines.update(dt)
  for _, entry in ipairs(Pipelines.list()) do
    if entry.def.update then
      guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
    end
  end
end

-- A pipeline may run this frame when it is switched on, has not thrown, and
-- its hardware gate says yes.  `available` is consulted every frame rather
-- than cached: a driver that loses its depth canvas on a resize has to be
-- able to change its mind.
--
-- Deliberately NOT gated on the state stack.  `gate` governs whether the
-- player may CHANGE the mode, never whether it draws -- a display mode that
-- stopped rendering during a warp, a scripted cutscene or an open menu
-- would flash the flat 2D world for those frames every time the player
-- walked through a door.  Once a mode is on it renders until it is off.
function Pipelines.eligible(id)
  local def = Pipelines.get(id)
  if not def or broken[id] then return false end
  if Pipelines.level(id) <= 0 then return false end
  if def.available and guard(id, def.available) ~= true then return false end
  return true
end

-- Whether the player may cycle this mode right now: the free-roam gate,
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
-- Input only -- see eligible() for why the draw path does not consult it.
function Pipelines.canToggle(id, top, overworld)
  local def = Pipelines.get(id)
  if not def then return false end
  local gate = def.gate or Zoom.gateOK
  return guard(id, gate, top, overworld) == true
end

-- The pipeline that owns the world pass right now, or nil for the vanilla
-- flat/tilt draw.  Highest priority wins; the exclusion rules above mean
-- there is normally only one candidate anyway.
function Pipelines.worldPipeline()
  for _, entry in ipairs(Pipelines.list()) do
    if entry.def.drawWorld and Pipelines.eligible(entry.id) then
      return entry.id, entry.def
    end
  end
  return nil
end

-- Render the world through `id`.  Returns the canvas to composite, or nil
-- when the pipeline declined this frame (nothing to draw, a transient
-- failure), which the caller treats as "fall back to the 2D path".
function Pipelines.drawWorld(id, ctx)
  local def = Pipelines.get(id)
  if not (def and def.drawWorld) then return nil end
  return guardRender(id, def.drawWorld, ctx)
end

-- Fold every eligible world post-process over a pipeline's world image,
-- before the UI composites on top.  This is where a depth-of-field or a
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
-- `present` below is the whole-frame counterpart.  Only reachable once some
-- pipeline rendered the world, so it is gated on the overworld state the
-- same way drawWorld is.
function Pipelines.worldPresent(canvas, ctx)
  if canvas == nil then return nil end
  for _, entry in ipairs(Pipelines.list()) do
    if entry.def.worldPresent and Pipelines.eligible(entry.id) then
      local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
      -- accept only a real Canvas: a pass that returns a non-canvas (a
      -- forgotten return, a shade string) is ignored, not folded in
      if isCanvas(out) then canvas = out end
    end
  end
  return canvas
end

-- Fold every eligible post-process over the finished frame.  Present
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
-- grade applies to menus and battles too -- so eligibility here is just
-- "switched on and available".  A pass that returns a non-canvas is
-- ignored rather than trusted, so a mod cannot blank the screen by
-- forgetting a return.
function Pipelines.present(canvas, ctx)
  if canvas == nil then return nil end
  for _, entry in ipairs(Pipelines.list()) do
    if entry.def.present and Pipelines.eligible(entry.id) then
      local out = guardRender(entry.id, entry.def.present, canvas, ctx)
      -- accept only a real Canvas: a pass that returns a non-canvas is
      -- ignored (docstring above), so a mod cannot blank or crash the frame
      -- by forgetting a return or handing back a truthy non-canvas
      if isCanvas(out) then canvas = out end
    end
  end
  return canvas
end

-- true when any present-only pass wants to run, so the composite path can
-- skip allocating a target it would not use
function Pipelines.wantsPresent()
  for _, entry in ipairs(Pipelines.list()) do
    if entry.def.present and Pipelines.eligible(entry.id) then return true end
  end
  return false
end

-- ------- input and UI

-- Cycle whichever pipeline claims `key`.  Returns the id when one did, so
-- the caller knows the key was consumed.  Checked after the engine's own
-- display hotkeys, so a mod can never shadow one.
function Pipelines.hotkey(key, top, overworld)
  for _, entry in ipairs(Pipelines.list()) do
    if entry.def.hotkey == key then
      -- the gate belongs here and nowhere else: it stops the player
      -- flipping modes mid-warp or mid-cutscene, and has no say over
      -- whether an already-on mode draws
      if Pipelines.canToggle(entry.id, top, overworld) then
        Pipelines.cycle(entry.id)
        return entry.id
      end
      return nil
    end
  end
  return nil
end

-- Options rows for every registered pipeline, in the same priority order,
-- in the descriptor shape src/ui/OptionRows.lua renders.
function Pipelines.rows(game)
  local rows = {}
  for _, entry in ipairs(Pipelines.list()) do
    local id = entry.id
    rows[#rows + 1] = {
      id = "pipeline:" .. id,
      label = entry.def.label or id:upper(),
      value = function() return Pipelines.levelLabel(id) end,
      step = function(g, dir)
        Pipelines.cycle(id, dir)
        local opts = g and g.save and g.save.options
        if opts then
          Pipelines.syncOptions(opts)
          -- the exclusion above may have switched tilt off; keep the live
          -- module in step with the option it just wrote
          require("src.render.Tilt").setLevel(opts.tilt or 0)
        end
        return true
      end,
    }
  end
  return rows
end

-- Drop every pipeline's GPU objects (window resize, hot reload).
function Pipelines.invalidate()
  for _, entry in ipairs(Pipelines.list()) do
    if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
  end
end

return Pipelines
