-- This mod's settings, in categories, on menus of their own.
--
-- ------- why the flat list had to end
--
-- Every setting used to be spliced straight into the engine's OPTIONS list,
-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
-- was four screens of scrolling inside a list that already carried twenty
-- engine rows -- and a player looking for SHADOWS had to know it was in there
-- somewhere, past the wireframe and the horizon bend.
--
-- The engine has no grouping to borrow: a row descriptor is
-- { id, label, value, step, activate } and nothing else. No headers, no
-- sections, no pages. What it DOES have is `activate`, and a state stack that
-- any state may push onto -- which is how the engine's own MODS and CONTROLS
-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
--
-- ------- how the split was chosen
--
-- Not invented here: the mod already sorted its own settings, in the `full`
-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
-- take away, and the reasoning written next to each one is always the same
-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
-- the diorama FULL is a preset for.
--
-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
-- case to disappear under FULL: every child filters itself out and the
-- category goes with them (see rows). PERFORMANCE is the three rows marked
-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
-- are the two features that are not about the look at all.
--
-- ------- what did NOT change
--
-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
-- row id, so options.lua is byte-identical for a player who upgrades and
-- changes nothing -- see lib/ModSetting.lua for why the key is the only
-- identity a setting has. The hotkeys are untouched too, which is what makes
-- the nesting affordable: a buried row is still one keypress away.

-- the mod namespace (see main.lua)
local V = ...

local OptionRows = require("src.ui.OptionRows")
local PaletteFX = require("src.render.PaletteFX")

local SettingsMenu = {}
SettingsMenu.__index = SettingsMenu

-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
-- covered, so there is no reason to pay for drawing it.
SettingsMenu.isOpaque = true

SettingsMenu.ROOT = "root"
SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."

-- Row ids live in a namespace of their own -- "menu." rather than a setting
-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
-- settings rows have carried since the beginning.
function SettingsMenu.id(catId)
  return "DRAMATIC_SHAPE:menu." .. catId
end

-- ------- the categories, in menu order
--
-- `summary` is the second line of the category's own row, the way MODS reads
-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
-- 3D-BTL for the battles, VR for the headset -- it says that setting's
-- current rung, which is the thing a player actually wants to know without
-- opening it. Where no single row speaks for the rest, it counts them, which
-- is honest rather than arbitrary.
SettingsMenu.CATEGORIES = {
  { id = "world", label = "3D WORLD.." },
  { id = "battles", label = "BATTLES..",
    summary = function() return V.require("OverworldBattle").setting:valueLabel() end },
  { id = "perf", label = "PERFORMANCE.." },
  { id = "vr", label = "VR..",
    summary = function() return V.require("VR").setting:valueLabel() end },
}

-- ------- what the menus are built from
--
-- SETTINGS lives in main.lua, next to the help text that goes with each row
-- and the comments explaining every `when` and `full`. It is handed here
-- rather than moved, so this file stays about PRESENTATION and that one stays
-- the single place the mod's settings are declared.
local settings = {}
local pipelineRows = {}

function SettingsMenu.define(list)
  settings = list or {}
end

-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
-- .rows), captured by the options hook on its way past and shown here instead
-- of at the top level. Reused verbatim, tables and all: they persist in
-- save.options.pipelines through their own step functions, and rebuilding
-- them here would be a second implementation of a thing the engine already
-- got right.
function SettingsMenu.setPipelineRows(rows)
  pipelineRows = rows or {}
end

-- ------- a step here has the same consequences as a step anywhere
--
-- Two of these settings PIN something else when they change: 3D-BTL holds
-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
-- DAYTIME at SYNC while it owns that row. Both used to happen because every
-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
-- pinning on its way past.
--
-- Nothing reruns that hook from in here, so the pin is asked for directly.
-- main.lua supplies it, because WHICH values follow which is a question about
-- the mod's settings and not about the menu they are on.
local onChanged = nil

function SettingsMenu.setOnChanged(fn)
  onChanged = fn
end

local function isFull()
  local Pipelines = require("src.render.Pipelines")
  return V.require("VoxelState").isFull(Pipelines.level("voxel"))
end

-- The one rule that decides whether a setting is on a menu, lifted unchanged
-- from the options hook it used to live in.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. And a row whose own switch is off the table this frame (BACK
-- SPRITES, which needs a staged fight to be about) is left off with it. The
-- mod manager's page carries every one of them either way.
local function offered(entry, full)
  return (entry.full or not full) and (not entry.when or entry.when())
end

-- The rows of one category, or of the root menu. PURE -- no state, no stack,
-- no side effects -- so a caller that only wants to know what is on a menu
-- (a test, or the root menu asking whether a category has anything in it)
-- does not have to push a screen to find out.
function SettingsMenu.rows(catId, game)
  local full = isFull()
  local out = {}
  if catId == SettingsMenu.ROOT then
    for _, row in ipairs(pipelineRows) do
      -- FULL owns the blur exactly as it owns the wireframe and the horizon
      -- bend, so T-SHIFT comes off with them
      if not (full and row.id == "pipeline:tiltshift") then
        out[#out + 1] = row
      end
    end
    for _, cat in ipairs(SettingsMenu.CATEGORIES) do
      local kids = SettingsMenu.rows(cat.id, game)
      -- An EMPTY category is not offered. This is the whole of what makes
      -- 3D WORLD disappear under FULL and VR disappear off Windows: no
      -- special case, just nothing left inside to open.
      if kids[1] then
        out[#out + 1] = {
          id = SettingsMenu.id(cat.id),
          label = cat.label,
          value = cat.summary
            or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
          activate = function(g)
            g.stack:push(SettingsMenu.new(g, cat.id))
          end,
        }
      end
    end
    -- ------- and the ROM import, last, on the top-level menu
    --
    -- An ACTION and not a setting: there is no rung to store, nothing for the
    -- mod manager's page to persist and nothing to restore on the next boot,
    -- so it is appended rather than living in SETTINGS.
    --
    -- On the ROOT menu rather than under the battles whose STADIUM rungs it
    -- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge
    -- and wait while it builds -- and a player who has been told to import a
    -- ROM should find the row where the mod begins, not two levels down a
    -- category they have no reason to open until it has worked. Last, because
    -- the categories are what the menu is FOR.
    local ok, importRow = pcall(function()
      return V.require("StadiumRomPick").row()
    end)
    if ok and importRow then out[#out + 1] = importRow end
    return out
  end
  for _, entry in ipairs(settings) do
    if entry.cat == catId and offered(entry, full) then
      out[#out + 1] = entry[1]:row()
    end
  end
  return out
end

-- ------- red ink for the mod's row on the OPTIONS menu
--
-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
-- nobody spends an afternoon on it. Twice over:
--
--   1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
--      and Font.drawCode is a plain love.graphics.draw, which LOVE tints
--      MULTIPLICATIVELY. black x red is black.
--   2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
--      RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
--      red pixel lands in c0, the LIGHTEST slot: white text on white paper.
--
-- What actually happens on this screen is that setColor picks a SHADE and the
-- zone palette picks the COLOR. Black text is c3 and the white box fill is
-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
-- The engine does the same thing for the party menu's HP bars
-- (src/ui/PartyMenu.lua), which is the pattern this follows.
SettingsMenu.INK = { 255, 0, 0 }

-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
-- Blue and Yellow ship different MEWMON tables, and this way the paper under
-- the row is the same white as the row above it in all three.
function SettingsMenu.redPalette(data)
  local base = PaletteFX.pal(data, "MEWMON")
  if not base then return nil end
  local out = { base[1], base[2], base[3], base[4] }
  -- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
  -- it the ink is the first slot and the paper the last. Put the red where it
  -- will land on the INK either way: without this the row draws as a solid
  -- red block with white letters cut out of it.
  --
  -- The other modes need nothing. OG, OG INV and CLASSIC discard the table
  -- outright and substitute their own, so the row simply draws monochrome --
  -- which is correct: the player asked for a screen with no colors in it.
  out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
  return out
end

-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
-- 19 are the box's own borders and tile 1 is the cursor, and all three are
-- black glyphs that would turn red along with the text if the band spanned
-- the whole row.
function SettingsMenu.rowZone(data, slot)
  local pal = SettingsMenu.redPalette(data)
  if not pal then return nil end
  local top = (slot - 1) * 4 + 1
  return PaletteFX.zone(pal, 2, top, 18, top + 1)
end

-- ------- the screen
--
-- Deliberately NOT an OptionsMenu instance, though the update loop below is
-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
-- level or the battle rows change -- which would replace a submenu's rows
-- with the whole top-level OPTIONS list under the player's cursor. A state of
-- our own cannot be caught by it.
--
-- It still renders through OptionRows, so it is the same four boxes, the same
-- cursor and the same bottom line as every other menu in the game.
function SettingsMenu.new(game, catId)
  local self = setmetatable({
    game = game,
    cat = catId or SettingsMenu.ROOT,
    index = 1,
    scroll = 0,
  }, SettingsMenu)
  self.rows = SettingsMenu.rows(self.cat, game)
  self.sig = SettingsMenu.signature(self.rows)
  return self
end

function SettingsMenu.signature(rows)
  local ids = {}
  for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
  return table.concat(ids, "\1")
end

-- OptionRows has no room for a title: the four boxes fill the screen down to
-- the bottom line. So the bottom line carries the name as well as the way
-- out, which is the one place left to say where the player is. "BACK" alone
-- at the root, where "BACK: DRAMATIC SHAPE" would run past the 18 characters
-- the line has.
function SettingsMenu:backLabel()
  if self.cat == SettingsMenu.ROOT then return "BACK" end
  for _, cat in ipairs(SettingsMenu.CATEGORIES) do
    if cat.id == self.cat then
      return "BACK: " .. (cat.label:gsub("%.%.$", ""))
    end
  end
  return "BACK"
end

-- A category's contents can change while the player is looking at them: 3D-BTL
-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
-- actually differs, so the common case -- every other rung of every other row
-- -- costs one string compare.
function SettingsMenu:refresh()
  local rows = SettingsMenu.rows(self.cat, self.game)
  local sig = SettingsMenu.signature(rows)
  if sig == self.sig then return end
  -- Follow the row the cursor was ON rather than the slot it was in: a row
  -- can appear ABOVE the one just used, which would otherwise slide the
  -- cursor onto its neighbour. The bottom line follows itself.
  local wasBack = self.index > #self.rows
  local wasOn = self.rows[self.index] and self.rows[self.index].id
  self.rows, self.sig = rows, sig
  self.index, self.scroll = 1, 0
  if wasBack then
    self.index = #rows + 1
  else
    for i, row in ipairs(rows) do
      if wasOn and row.id == wasOn then self.index = i break end
    end
  end
end

local function pop(self)
  local stack = self.game and self.game.stack
  if self.game and self.game.data then
    require("src.core.Sound").play(self.game.data, "Press_AB")
  end
  if stack and stack:top() == self then stack:pop() end
end

-- The engine's own options loop (src/ui/OptionsMenu.update), including its
-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
-- alone, and the bottom line is a synthetic index past the end of the list
-- rather than a row, so nothing a category contains can orphan the way out.
function SettingsMenu:update()
  local input = self.game and self.game.input
  if not input then return end
  local rows = self.rows
  local back = #rows + 1
  local changed = false
  if input:wasPressed("up") then
    self.index = self.index - 1
    if self.index < 1 then self.index = back end
  elseif input:wasPressed("down") then
    self.index = self.index + 1
    if self.index > back then self.index = 1 end
  elseif input:wasPressed("left") or input:wasPressed("right")
      or input:wasPressed("a") then
    local dir = input:wasPressed("left") and -1 or 1
    local row = rows[self.index]
    if row and row.activate then
      if input:wasPressed("a") then row.activate(self.game) end
    elseif row and row.step then
      changed = row.step(self.game, dir) and true or false
    elseif input:wasPressed("a") then
      pop(self)
      return
    end
  elseif input:wasPressed("b") or input:wasPressed("start") then
    -- B and START both, like every other menu -- and one level only: this
    -- pops US, leaving the OPTIONS menu underneath exactly as the player
    -- left it, with its own onCancel still to fire when they leave THAT.
    pop(self)
    return
  end
  if changed then
    -- before the rebuild, not after: pinning can itself change which rows are
    -- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
    -- it), and refresh has to see the settled answer
    if onChanged then pcall(onChanged, self.game) end
    if self.game.writeOptions then
      pcall(self.game.writeOptions, self.game)
    end
  end
  self:refresh()
  self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
                                       #self.rows + 1)
end

function SettingsMenu:draw()
  OptionRows.draw(self.game, self.rows, self.index, self.scroll,
                  self:backLabel(), #self.rows + 1)
end

-- REQUIRED, even though nothing here is red.
--
-- Game:draw walks the stack from the top and stops at the first state that
-- HAS this method, not the first that answers something. Without one of our
-- own the walk would fall through to the OPTIONS menu underneath -- whose
-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
-- zone is addressed by SLOT, so it would land on whatever this menu happens
-- to be showing in the same box.
--
-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
function SettingsMenu:sgbPalettes(game)
  return PaletteFX.wholeNamed(game.data, "MEWMON")
end

return SettingsMenu
