-- Mod manager v2 (18-mod-manager-ux.md): one stack state routing a fixed
-- set of screens -- list (MODS/PROF/ERRS tabs), detail, options,
-- permissions, errors, apply -- over mapped input, so gamepad and touch
-- drive it like any other menu.  Toggles resolve their dependency closure
-- before they land, edits stage until one apply/restart, and safe mode is
-- read from Runtime.safeMode (19 owns the detection).
local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local ModTargets = require("src.mods.ModTargets")
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = require("src.mods.Sandbox")
local SaveData = require("src.core.SaveData")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
local ModProfile = require("src.mods.ModProfile")

local ManagerState = {}
ManagerState.__index = ManagerState
ManagerState.isOpaque = true
-- stamped here as well as by Screens.push so the F10 toggle in
-- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState"

-- Same as OptionsMenu: without this, title LOGO zones leak through when
-- MODS is opened from the title-screen options path.
function ManagerState:sgbPalettes(game)
  return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end

-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
-- `skipped` is not a fault: the mod is enabled and intact, this game is just
-- not the generation it declared (Loader:_gateGeneration)
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?",
                skipped = "-" }

local TABS = { "MODS", "PROFILES", "ERRORS" }
local TAB_LINE = { "[MODS] PROF ERRS", "MODS [PROF] ERRS", "MODS PROF [ERRS]" }

local LIST_TOP = 3   -- first content row (tile y)
local LIST_ROWS = 11 -- single-line rows in the scroll region

-- what the mod declared it does, shown before the player enables it
local PERMISSION_ROWS = {
  engine_internals = { glyph = "!", text = "PATCHES ENGINE CODE" },
  network = { glyph = "!", text = "USES THE NETWORK" },
  filesystem = { glyph = "!", text = "READS/WRITES FILES" },
}

local OPTION_TYPES = { toggle = true, choice = true, number = true, text = true }

local function wrap(text, width)
  local lines = {}
  for paragraph in tostring(text or ""):gmatch("[^\n]+") do
    local line = ""
    for word in paragraph:gmatch("%S+") do
      while #word > width do
        if line ~= "" then
          lines[#lines + 1] = line
          line = ""
        end
        lines[#lines + 1] = word:sub(1, width)
        word = word:sub(width + 1)
      end
      if word ~= "" then
        local candidate = line == "" and word or line .. " " .. word
        if #candidate > width and line ~= "" then
          lines[#lines + 1] = line
          line = word
        else
          line = candidate
        end
      end
    end
    if line ~= "" then lines[#lines + 1] = line end
  end
  if #lines == 0 then lines[1] = "" end
  return lines
end

local function clampIndex(i, n)
  if i < 1 then return n end
  if i > n then return 1 end
  return i
end

-- enabled at boot is the loader's verdict, not the user's flag: setEnabled
-- flips manifest.enabled but never state, so the difference is exactly the
-- staged-since-boot set and survives closing the manager
local function bootEnabled(m)
  return m.state ~= "disabled"
end

-- ------- pure toggle resolution
-- The closure a requested flip drags along (18 "enable/disable flow").
-- mods is id -> status manifest; enabledSet is the current desired set.
-- Module-level so tests table-drive it without a game.

function ManagerState.resolveToggle(mods, id, want, enabledSet)
  local r = { apply = {}, alsoEnable = {}, alsoDisable = {},
              conflicts = {}, missing = {}, badVersion = {} }
  local function enabledAfter(mid)
    if r.apply[mid] ~= nil then return r.apply[mid] end
    return enabledSet[mid] and true or false
  end
  local function enableWalk(mid, root)
    if r.apply[mid] then return end
    r.apply[mid] = true
    if not root then r.alsoEnable[#r.alsoEnable + 1] = mid end
    local m = mods[mid]
    if not m then return end
    if m.game_version and not Semver.satisfies(Version.engine, m.game_version) then
      r.badVersion[#r.badVersion + 1] =
        { id = mid, need = m.game_version, got = Version.engine, engine = true }
    end
    for _, spec in ipairs(m.dependencySpecs or {}) do
      local dep = mods[spec.id]
      if not dep then
        r.missing[#r.missing + 1] = spec.id
      elseif spec.range and not Semver.satisfies(dep.version, spec.range) then
        r.badVersion[#r.badVersion + 1] =
          { id = spec.id, need = spec.range, got = dep.version }
      elseif not enabledAfter(spec.id) then
        enableWalk(spec.id, false)
      end
    end
  end
  local function disableWalk(mid, root)
    if r.apply[mid] == false then return end
    r.apply[mid] = false
    if not root then r.alsoDisable[#r.alsoDisable + 1] = mid end
    -- reverse hard deps: whoever needs mid has to switch off with it
    for otherId, other in pairs(mods) do
      if enabledAfter(otherId) then
        for _, spec in ipairs(other.dependencySpecs or {}) do
          if spec.id == mid then
            disableWalk(otherId, false)
            break
          end
        end
      end
    end
  end
  if want then enableWalk(id, true) else disableWalk(id, true) end
  if want then
    -- a conflict blocks whichever side declared it
    for mid in pairs(r.apply) do
      local m = mods[mid]
      for _, spec in ipairs((m and m.conflictSpecs) or {}) do
        local other = mods[spec.id]
        if other and spec.id ~= mid and enabledAfter(spec.id)
            and (not spec.range or Semver.satisfies(other.version, spec.range)) then
          r.conflicts[#r.conflicts + 1] = spec.id
        end
      end
      for otherId, other in pairs(mods) do
        if otherId ~= mid and enabledAfter(otherId) then
          for _, spec in ipairs(other.conflictSpecs or {}) do
            if spec.id == mid and (not spec.range
                or not m or Semver.satisfies(m.version, spec.range)) then
              r.conflicts[#r.conflicts + 1] = otherId
            end
          end
        end
      end
    end
  end
  return r
end

-- ------- lifecycle

function ManagerState.new(game)
  return setmetatable({
    game = game,
    screen = "list",
    tab = 1,
    cursor = 1,
    scroll = 1,
    backStack = {},
    descScroll = 1,
    restartPending = false,
  }, ManagerState)
end

function ManagerState:enter()
  self:refresh()
  -- #593: the setup that existed before profiles shipped becomes PROFILE 1
  -- the first time the manager opens, so switching away from it is always a
  -- round trip.  Seeded here rather than at boot because profiles are only
  -- ever read on this screen.
  local seeded = ModProfile.ensureFirst(self:optionsTable(),
    self.status.available, self:modOptionsTable())
  if seeded then
    self:persistOptions()
    self:refresh()
  end
  if Runtime.safeMode then
    self.banner = "SAFE MODE - ALL MODS OFF"
  end
  self:snapCursor()
end

function ManagerState:refresh()
  local loader = self.game.mods
  self.status = (loader and loader.status and loader:status())
    or self.game.modStatus or { available = {}, errors = {} }
  self.byId = {}
  for _, m in ipairs(self.status.available or {}) do
    self.byId[m.id] = m
  end
  if self.currentMod then
    self.currentMod = self.byId[self.currentMod.id]
  end
  -- gen2Pending is not in stagedList: the Gen 2 override is not an enable flag
  -- and there is nothing in `available` to diff it against
  self.restartPending = #self:stagedList() > 0 or self.gen2Pending == true
  -- a live set that drifted off the named profile reverts to ad-hoc
  local opts = self:optionsTable()
  if opts.activeProfile then
    local p = self:findProfile(opts.activeProfile)
    if not p or not self:matchesProfile(p) then
      opts.activeProfile = nil
    end
  end
end

function ManagerState:optionsTable()
  local save = self.game.save
  return (save and save.options) or {}
end

-- Per-mod option values as persisted (options.modOptions; setOption below is
-- the only writer).  A profile captures this alongside the enable set.
function ManagerState:modOptionsTable()
  return self:optionsTable().modOptions or {}
end

function ManagerState:manifestMap()
  return self.byId or {}
end

function ManagerState:enabledSet()
  local set = {}
  for _, m in ipairs(self.status.available or {}) do
    if m.enabled then set[m.id] = true end
  end
  return set
end

function ManagerState:isStaged(m)
  return (m.enabled and true or false) ~= bootEnabled(m)
end

function ManagerState:stagedList()
  local out = {}
  for _, m in ipairs(self.status.available or {}) do
    if self:isStaged(m) then out[#out + 1] = m end
  end
  table.sort(out, function(a, b) return a.id < b.id end)
  return out
end

-- The game this manager judges targets against: the running version, unless a
-- harness injected a loader generation that disagrees with it (Loader.new
-- opts.generation), where only the generation can be trusted.
function ManagerState:targetGame()
  local loader = self.game and self.game.mods
  local gen = loader and loader.generation
  local version = GameVersion.get()
  if gen and GameVersion.generation(version) ~= gen then return nil, gen end
  return version, GameVersion.generation(version)
end

-- will this mod run on this game at all (src/mods/ModTargets.lua, the same
-- derivation the launcher panel shows)
function ManagerState:runsHere(m)
  local version, gen = self:targetGame()
  return ModTargets.runsHere(m, version, gen, m.gen2Forced)
end

function ManagerState:glyphFor(m)
  if self:isStaged(m) then return GLYPH.staged end
  if not m.enabled then return GLYPH.disabled end
  if m.state == "wrong_generation" then return GLYPH.skipped end
  if not self:runsHere(m) then return GLYPH.skipped end
  if m.state == "blocked_dependency" then return GLYPH.blocked end
  if m.error then return GLYPH.errored end
  return " "
end

-- ------- row models
-- Every screen is one flat row list the shared cursor walks; headers are
-- skipped by the cursor and drawn dim.

function ManagerState:modRows()
  local rows = {}
  local byCategory, categories = {}, {}
  for _, m in ipairs(self.status.available or {}) do
    local cat = m.category or "OTHER"
    if not byCategory[cat] then
      byCategory[cat] = {}
      categories[#categories + 1] = cat
    end
    table.insert(byCategory[cat], m)
  end
  table.sort(categories)
  for _, cat in ipairs(categories) do
    rows[#rows + 1] = { header = true, label = cat }
    for _, m in ipairs(byCategory[cat]) do
      rows[#rows + 1] = { mod = m, label = m.name or m.id,
                          glyph = self:glyphFor(m) }
    end
  end
  if #rows == 0 then
    rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
  end
  return rows
end

function ManagerState:profileRows()
  local rows = {}
  local opts = self:optionsTable()
  for _, p in ipairs(opts.modProfiles or {}) do
    rows[#rows + 1] = { profile = p, label = p.name,
      glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
  end
  rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
  -- #593 sharing: EXPORT writes the focused profile to profiles/*.g1rmodlist
  -- in the save dir, IMPORT reads back every file dropped in that folder.
  rows[#rows + 1] = { exportProfile = true, label = Strings("EXPORT..") }
  rows[#rows + 1] = { importProfile = true, label = Strings("IMPORT..") }
  rows[#rows + 1] = { adhoc = true,
    label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
  return rows
end

function ManagerState:errorLines(mod)
  local lines = {}
  if mod and mod.error then
    for _, line in ipairs(wrap("FAILED: " .. mod.error, 16)) do
      lines[#lines + 1] = line
    end
  end
  for _, err in ipairs(self.status.errors or {}) do
    for _, line in ipairs(wrap(err, 16)) do
      lines[#lines + 1] = line
    end
  end
  if #lines == 0 then lines[1] = "NO ERRORS" end
  return lines
end

function ManagerState:errorRows(mod)
  local rows = {}
  for _, line in ipairs(self:errorLines(mod)) do
    rows[#rows + 1] = { label = line, inert = true }
  end
  return rows
end

function ManagerState:detailRows(m)
  local rows = {}
  if Runtime.safeMode then
    rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
  else
    rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
      action = function() self:beginToggle(m) end }
  end
  if not Runtime.safeMode and self:schemaFor(m) then
    rows[#rows + 1] = { label = Strings("OPTIONS.."),
      action = function() self:openOptions(m) end }
  end
  if m.permissions and #m.permissions > 0 then
    rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
      action = function() self:goTo("permissions") end }
  end
  -- The manifest's games list is the AUTHOR's claim, and a mod written before
  -- the field existed can never carry one, so the player gets the override
  -- here rather than being told to edit a manifest they do not own.  Exactly
  -- the answer the loader gates on (Loader:_gateGeneration reads the same
  -- ModTargets.supports), so the row appears only where a restart can change
  -- what this mod does.
  local loader = self.game.mods
  local version, gen = self:targetGame()
  if loader and loader.setGen2Forced and not Runtime.safeMode
      and not ModTargets.supports(m, version, gen) then
    rows[#rows + 1] = {
      label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
      action = function() self:toggleGen2Force(m) end }
  end
  -- which games the mod says it is for, in the one place the player is
  -- already looking when they wonder why it did not run
  rows[#rows + 1] = { inert = true, label = "FOR " .. ModTargets.chip(m) }
  if m.github then
    rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
  end
  if m.experimental then
    rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
  end
  if m.error then
    rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
      action = function() self:goTo("errors") end }
  end
  rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
  return rows
end

function ManagerState:applyRows()
  local rows = {}
  rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
    self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
  end }
  rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
    self:discardChanges()
  end }
  rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
  return rows
end

function ManagerState:permissionRows(m)
  local rows = {}
  for _, name in ipairs(m.permissions or {}) do
    local info = PERMISSION_ROWS[name]
    rows[#rows + 1] = { inert = true,
      glyph = info and info.glyph or "?",
      label = info and info.text or name }
  end
  if #rows == 0 then
    rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
  end
  return rows
end

function ManagerState:rowsForScreen()
  if self.screen == "list" then
    if self.tab == 1 then return self:modRows() end
    if self.tab == 2 then return self:profileRows() end
    return self:errorRows(nil)
  elseif self.screen == "detail" then
    return self.currentMod and self:detailRows(self.currentMod) or {}
  elseif self.screen == "errors" then
    return self:errorRows(self.currentMod)
  elseif self.screen == "permissions" then
    return self.currentMod and self:permissionRows(self.currentMod) or {}
  elseif self.screen == "apply" then
    return self:applyRows()
  end
  return {}
end

-- ------- navigation

function ManagerState:goTo(screen)
  self.backStack[#self.backStack + 1] =
    { screen = self.screen, cursor = self.cursor, scroll = self.scroll }
  self.screen = screen
  self.cursor = 1
  self.scroll = screen == "options" and 0 or 1
  self.descScroll = 1
  self:snapCursor()
end

function ManagerState:goBack()
  local prev = table.remove(self.backStack)
  if prev then
    self.screen = prev.screen
    self.cursor = prev.cursor
    self.scroll = prev.scroll
    self:refresh()
  else
    self.game.stack:pop()
  end
end

function ManagerState:snapCursor()
  local rows = self:rowsForScreen()
  local row = rows[self.cursor]
  if row and not row.header then return end
  for i, candidate in ipairs(rows) do
    if not candidate.header then
      self.cursor = i
      return
    end
  end
  self.cursor = 1
end

function ManagerState:moveCursor(dir)
  local rows = self:rowsForScreen()
  local n = #rows
  if n == 0 then return end
  local i = self.cursor
  for _ = 1, n do
    i = clampIndex(i + dir, n)
    if not rows[i].header then
      self.cursor = i
      break
    end
  end
  -- keep the cursor inside the scroll window
  if self.cursor < self.scroll then
    self.scroll = self.cursor
  elseif self.cursor > self.scroll + LIST_ROWS - 1 then
    self.scroll = self.cursor - LIST_ROWS + 1
  end
end

function ManagerState:adjustOrTab(dir)
  if self.screen == "list" then
    self.tab = clampIndex(self.tab + dir, #TABS)
    self.cursor, self.scroll = 1, 1
    self:snapCursor()
  elseif self.screen == "detail" then
    self.descScroll = math.max(1, self.descScroll + dir)
  else
    for _ = 1, LIST_ROWS do self:moveCursor(dir) end
  end
end

function ManagerState:focusedRow()
  return self:rowsForScreen()[self.cursor]
end

function ManagerState:confirmSound()
  if self.game.data then
    require("src.core.Sound").play(self.game.data, "Press_AB")
  end
end

function ManagerState:notify(text)
  self.notice = text
  self.noticeTimer = 90
end

function ManagerState:activate()
  local row = self:focusedRow()
  if not row or row.header or row.inert then return end
  self:confirmSound()
  if row.action then
    row.action()
  elseif row.mod then
    self.currentMod = row.mod
    self:goTo("detail")
  elseif row.profile then
    self:applyProfile(row.profile)
  elseif row.saveAs then
    self:saveCurrentAs()
  elseif row.exportProfile then
    self:exportActiveProfile()
  elseif row.importProfile then
    self:importProfiles()
  elseif row.adhoc then
    self:optionsTable().activeProfile = nil
    self:notify("AD-HOC SET ACTIVE")
  end
end

function ManagerState:pressStart()
  if self.screen == "list" and self.tab == 2 then
    local row = self:focusedRow()
    if row and row.profile then
      self:openConfirm({ "DELETE " .. row.profile.name .. "?" }, function()
        self:deleteProfile(row.profile)
      end)
      return
    end
  end
  if self.screen == "apply" then return end
  if self.restartPending or Runtime.safeMode then
    self:goTo("apply")
  else
    self:notify("NO CHANGES")
  end
end

function ManagerState:quickToggle()
  if self.screen == "list" and self.tab == 1 then
    local row = self:focusedRow()
    if row and row.mod then self:beginToggle(row.mod) end
  elseif self.screen == "list" and self.tab == 2 then
    local row = self:focusedRow()
    if row and row.profile then self:renameProfile(row.profile) end
  elseif self.screen == "detail" and self.currentMod then
    self:beginToggle(self.currentMod)
  end
end

function ManagerState:update()
  if self.notice then
    self.noticeTimer = (self.noticeTimer or 0) - 1
    if self.noticeTimer <= 0 then self.notice = nil end
  end
  local input = self.game.input
  if self.overlay then return self:updateOverlay(input) end
  if self.screen == "options" then return self:updateOptions(input) end
  if input:wasPressed("up") then self:moveCursor(-1)
  elseif input:wasPressed("down") then self:moveCursor(1)
  elseif input:wasPressed("left") then self:adjustOrTab(-1)
  elseif input:wasPressed("right") then self:adjustOrTab(1)
  elseif input:wasPressed("a") then self:activate()
  elseif input:wasPressed("b") then self:goBack()
  elseif input:wasPressed("start") then self:pressStart()
  elseif input:wasPressed("select") then self:quickToggle()
  end
end

-- ------- overlays

function ManagerState:openBlocked(r)
  local lines = {}
  for _, depId in ipairs(r.missing) do
    lines[#lines + 1] = "NEEDS " .. depId
    lines[#lines + 1] = "NOT INSTALLED"
  end
  for _, otherId in ipairs(r.conflicts) do
    local other = self.byId[otherId]
    lines[#lines + 1] = "CONFLICTS WITH"
    lines[#lines + 1] = (other and other.name or otherId)
    lines[#lines + 1] = "DISABLE IT FIRST"
  end
  for _, bad in ipairs(r.badVersion) do
    if bad.engine then
      lines[#lines + 1] = "NEEDS ENGINE " .. bad.need
      lines[#lines + 1] = "HAVE " .. bad.got
    else
      lines[#lines + 1] = "NEEDS " .. bad.id .. " " .. bad.need
    end
  end
  self.overlay = { kind = "ok", lines = lines }
end

function ManagerState:openCascade(r, m, want)
  local lines = {}
  if want then
    local names = {}
    for _, depId in ipairs(r.alsoEnable) do
      local dep = self.byId[depId]
      names[#names + 1] = dep and dep.name or depId
    end
    lines[#lines + 1] = "ALSO ENABLE"
    lines[#lines + 1] = table.concat(names, ", ") .. "?"
  else
    local dep = self.byId[r.alsoDisable[1]]
    lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
    lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
  end
  self.overlay = { kind = "confirm", lines = lines, index = 1,
    onYes = function() self:commitToggle(r.apply) end }
end

function ManagerState:openConfirm(lines, onYes)
  self.overlay = { kind = "confirm", lines = lines, index = 1, onYes = onYes }
end

function ManagerState:updateOverlay(input)
  local overlay = self.overlay
  if overlay.kind == "ok" then
    if input:wasPressed("a") or input:wasPressed("b") then
      self.overlay = nil
    end
    return
  end
  if input:wasPressed("up") or input:wasPressed("down") then
    overlay.index = overlay.index == 1 and 2 or 1
  elseif input:wasPressed("a") then
    self:confirmSound()
    self.overlay = nil
    if overlay.index == 1 and overlay.onYes then overlay.onYes() end
  elseif input:wasPressed("b") then
    self.overlay = nil
  end
end

-- ------- the enable/disable flow

function ManagerState:beginToggle(m)
  if Runtime.safeMode then
    self:notify("SAFE MODE ACTIVE")
    return
  end
  if not m then return end
  local want = not m.enabled
  local loader = self.game.mods
  local r
  if loader and loader.resolveToggle then
    r = loader:resolveToggle(m.id, want, self:enabledSet())
  else
    r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
                                   self:enabledSet())
  end
  local function proceed()
    if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
      self:openBlocked(r)
    elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
      self:openCascade(r, m, want)
    else
      self:commitToggle(r.apply)
    end
  end
  -- Experimental mods ask once on enable; disable is silent.
  if want and m.experimental then
    self:openConfirm({
      "EXPERIMENTAL MOD",
      "THIS MOD IS MARKED",
      "EXPERIMENTAL.",
      "ENABLE ANYWAY?",
    }, proceed)
    return
  end
  proceed()
end

-- The gate runs once, before any entry chunk, so this can only take effect on
-- the next boot: it stages a restart the way an enable toggle does.  The
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
-- memory only, which the notice says rather than promising a restart.
function ManagerState:toggleGen2Force(m)
  if Runtime.safeMode then
    self:notify("SAFE MODE ACTIVE")
    return
  end
  local loader = self.game.mods
  if not (loader and loader.setGen2Forced) then return end
  local want = not m.gen2Forced
  local function apply()
    local _, persisted = loader:setGen2Forced(m.id, want)
    self.gen2Pending = persisted ~= false
    if loader.status then self.game.modStatus = loader:status() end
    self:refresh()
    if persisted == false then
      -- the gate already ran, so an unsaved override changes no boot at all
      self:notify("COULD NOT SAVE")
    else
      self:notify(want and "WILL TRY ON RESTART" or "WILL BE SKIPPED")
    end
  end
  if not want then
    apply()
    return
  end
  self:openConfirm({
    "NOT MADE FOR",
    "THIS GAME.",
    "TRY IT ANYWAY?",
  }, apply)
end

-- Where the loader persists an enable flag: this running game's slot.
function ManagerState:enableScope()
  return SaveData.modScope((self:targetGame()))
end

function ManagerState:commitToggle(apply)
  if Runtime.safeMode then
    self:notify("SAFE MODE ACTIVE")
    return
  end
  local loader = self.game.mods
  local opts = self:optionsTable()
  local scope = self:enableScope()
  for id, en in pairs(apply) do
    if loader and loader.setEnabled then loader:setEnabled(id, en) end
    -- mirror into the live options so a later writeOptions cannot revert
    -- what setEnabled just persisted
    SaveData.setModEnabled(opts, id, en, scope)
  end
  if loader and loader.status then self.game.modStatus = loader:status() end
  self:refresh()
end

function ManagerState:discardChanges()
  if Runtime.safeMode then
    self:notify("SAFE MODE ACTIVE")
    return
  end
  local loader = self.game.mods
  local opts = self:optionsTable()
  local scope = self:enableScope()
  for _, m in ipairs(self:stagedList()) do
    local en = bootEnabled(m)
    if loader and loader.setEnabled then loader:setEnabled(m.id, en) end
    SaveData.setModEnabled(opts, m.id, en, scope)
  end
  if loader and loader.status then self.game.modStatus = loader:status() end
  self:refresh()
  self:notify("CHANGES DISCARDED")
end

function ManagerState:restartGame()
  if self.game.restartWithMods then
    self.game:restartWithMods()
  elseif love.event and love.event.quit then
    love.event.quit("restart")
  end
end

-- ------- profiles (named enable-sets, not the manifest profile field)

function ManagerState:findProfile(name)
  for _, p in ipairs(self:optionsTable().modProfiles or {}) do
    if p.name == name then return p end
  end
  return nil
end

function ManagerState:matchesProfile(p)
  for _, m in ipairs(self.status.available or {}) do
    local want = p.enabled[m.id] ~= false
    if (m.enabled and true or false) ~= want then return false end
  end
  -- the per-game answers count too, or a profile that only differs on Gold
  -- would read as still active after the player changed it
  return ModProfile.matchesVersions(p, self:optionsTable())
end

function ManagerState:persistOptions()
  if self.game.writeOptions then self.game:writeOptions() end
end

function ManagerState:applyProfile(p)
  if Runtime.safeMode then
    self:notify("SAFE MODE ACTIVE")
    return
  end
  local mods = self:manifestMap()
  local set = self:enabledSet()
  local combined = {}
  for _, m in ipairs(self.status.available or {}) do
    local want = p.enabled[m.id] ~= false
    local cur = set[m.id] and true or false
    if cur ~= want then
      local r = ManagerState.resolveToggle(mods, m.id, want, set)
      if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
        self:openBlocked(r)
        return
      end
      for id, en in pairs(r.apply) do
        combined[id] = en
        set[id] = en or nil
      end
    end
  end
  self:commitToggle(combined)
  -- #593: a profile is the whole setup, not just the enable set.  Options go
  -- through setOption so loader.modOptions and mod.options_changed stay in
  -- step; save slots move only to slots that version already registered
  -- (SaveData.setActiveSlot would otherwise conjure one).
  for modId, bucket in pairs(p.options or {}) do
    if self.byId[modId] then
      for key, value in pairs(bucket) do self:setOption(modId, key, value) end
    end
  end
  for _, move in ipairs(ModProfile.slotMoves(p)) do
    require("src.core.SaveData").setActiveSlot(move[1], move[2])
  end
  -- the per-game half of the setup, restored beside the shared enable set
  ModProfile.restoreVersions(p, self:optionsTable())
  self:optionsTable().activeProfile = p.name
  self:persistOptions()
  local missing = ModProfile.missingIds(p, self.byId)
  if #missing > 0 then
    self:notify(#missing .. " MODS MISSING")
  else
    self:notify("PROFILE STAGED")
  end
end

function ManagerState:saveCurrentAs()
  local NamingScreen = require("src.ui.NamingScreen")
  self.game.stack:push(NamingScreen.new(self.game, {
    title = Strings("PROFILE NAME?"),
    maxLen = 10,
    onDone = function(name)
      local opts = self:optionsTable()
      opts.modProfiles = opts.modProfiles or {}
      local snap = ModProfile.capture(self.status.available,
        self:modOptionsTable(), opts.modsByVersion)
      local existing = self:findProfile(name)
      if existing then
        existing.enabled, existing.options, existing.slots =
          snap.enabled, snap.options, snap.slots
        existing.enabledByVersion = snap.enabledByVersion
      else
        snap.name = name
        opts.modProfiles[#opts.modProfiles + 1] = snap
      end
      opts.activeProfile = name
      self:persistOptions()
      self:refresh()
    end,
  }))
end

-- #593 sharing.  Export drops the active profile (or the focused one) in the
-- save directory's profiles/ folder as <NAME>.g1rmodlist; import reads every
-- file already there.  Deliberately folder-based rather than a native file
-- picker: the same shape SaveFileIO uses for exports/, and it works on the
-- mobile builds where no picker exists.
function ManagerState:exportActiveProfile()
  local row = self:focusedRow()
  local p = (row and row.profile)
    or self:findProfile(self:optionsTable().activeProfile)
  if not p then return self:notify("NO PROFILE") end
  local ok = ModProfile.export(p)
  self:notify(ok and ("SAVED " .. p.name) or "EXPORT FAILED")
end

function ManagerState:importProfiles()
  local opts = self:optionsTable()
  opts.modProfiles = opts.modProfiles or {}
  local n = 0
  for _, path in ipairs(ModProfile.files()) do
    if ModProfile.import(path, opts.modProfiles) then n = n + 1 end
  end
  if n == 0 then return self:notify("NO MODLISTS") end
  self:persistOptions()
  self:snapCursor()
  self:notify(n .. " IMPORTED")
end

function ManagerState:renameProfile(p)
  local NamingScreen = require("src.ui.NamingScreen")
  self.game.stack:push(NamingScreen.new(self.game, {
    title = Strings("RENAME?"),
    maxLen = 10,
    default = p.name,
    onDone = function(name)
      local opts = self:optionsTable()
      if opts.activeProfile == p.name then opts.activeProfile = name end
      p.name = name
      self:persistOptions()
    end,
  }))
end

function ManagerState:deleteProfile(p)
  local opts = self:optionsTable()
  local profiles = opts.modProfiles or {}
  for i, candidate in ipairs(profiles) do
    if candidate == p then
      table.remove(profiles, i)
      break
    end
  end
  if opts.activeProfile == p.name then opts.activeProfile = nil end
  self:persistOptions()
  self:snapCursor()
end

-- ------- per-mod options (auto-UI from options_schema)

-- the loader captured schemas when mod.options:define ran; a mod that only
-- shipped the manifest options_schema file gets it loaded here on demand
function ManagerState:schemaFor(m)
  local loader = self.game.mods
  if not loader then return nil end
  local schema = loader.optionSchemas and loader.optionSchemas[m.id]
  if schema == nil and m.options_schema and m.path
      and loader.fs and loader.fs.load then
    -- mod-authored code, so it runs in the same sandbox the entry chunk does
    local ok, rows = pcall(function()
      local path = SafePath.join(m.path, m.options_schema, "options_schema")
      local mod = loader.mods and loader.mods[m.id]
      local env = mod and loader._modEnv and loader:_modEnv(mod)
        or Sandbox.envFor()
      local chunk = Sandbox.loadFile(loader.fs, path, env)
      return chunk and chunk()
    end)
    if ok and type(rows) == "table" then
      schema = rows
      if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
    end
  end
  return schema
end

function ManagerState:optionValue(modId, row)
  local loader = self.game.mods
  local stored = loader and loader.modOptions and loader.modOptions[modId]
  local v = stored and stored[row.key]
  if v == nil then v = row.default end
  return v
end

function ManagerState:setOption(modId, key, value)
  if Runtime.safeMode then
    self:notify("SAFE MODE ACTIVE")
    return false
  end
  local save = self.game.save
  if save and save.options then
    save.options.modOptions = save.options.modOptions or {}
    local t = save.options.modOptions
    t[modId] = t[modId] or {}
    t[modId][key] = value
  end
  local loader = self.game.mods
  if loader then
    loader.modOptions = loader.modOptions or {}
    loader.modOptions[modId] = loader.modOptions[modId] or {}
    loader.modOptions[modId][key] = value
  end
  self:persistOptions()
  if loader and loader.events then
    loader.events:emit("mod.options_changed",
      { mod = modId, key = key, value = value })
  end
end

function ManagerState:buildOptionRows(m, schema)
  local rows = {}
  local modId = m.id
  local byKey, visibilityKeys = {}, {}
  for _, row in ipairs(schema) do
    if type(row) == "table" and type(row.key) == "string" then
      byKey[row.key] = row
      local condition = row.visible_if
      if type(condition) == "table" and type(condition.key) == "string" then
        visibilityKeys[condition.key] = true
      end
    end
  end
  local function visible(row)
    local condition = row.visible_if
    if condition == nil then return true end
    if type(condition) ~= "table" or type(condition.key) ~= "string" then
      return false
    end
    local dependency = byKey[condition.key] or { key = condition.key }
    local value = self:optionValue(modId, dependency)
    if condition.equals ~= nil then return value == condition.equals end
    if condition.not_equals ~= nil then return value ~= condition.not_equals end
    return false
  end
  local function refresh(key)
    if not visibilityKeys[key] then return end
    local preferred = rows[self.cursor] and rows[self.cursor].id
    self.optionRows = self:buildOptionRows(m, schema)
    for index, candidate in ipairs(self.optionRows) do
      if candidate.id == preferred then self.cursor = index break end
    end
    self.cursor = clampIndex(self.cursor, #self.optionRows)
  end
  for _, row in ipairs(schema) do
    if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
        or not OPTION_TYPES[row.type] then
      -- malformed rows are skipped, reported where the errors screen reads
      Runtime.reportError(modId, "options row skipped: "
        .. tostring(type(row) == "table" and (row.key or row.type) or row))
    elseif not visible(row) then
      -- Keep the row in the schema and stored options, only hide its menu row.
    elseif row.type == "toggle" then
      rows[#rows + 1] = { id = row.key, label = row.label or row.key,
        value = function()
          return self:optionValue(modId, row) and "ON" or "OFF"
        end,
        step = function()
          self:setOption(modId, row.key, not self:optionValue(modId, row))
          refresh(row.key)
          return true
        end }
    elseif row.type == "choice" then
      rows[#rows + 1] = { id = row.key, label = row.label or row.key,
        value = function()
          local cur = self:optionValue(modId, row)
          for _, choice in ipairs(row.choices or {}) do
            if choice[2] == cur then return choice[1] end
          end
          local first = (row.choices or {})[1]
          return first and first[1] or "----"
        end,
        step = function(_, dir)
          local choices = row.choices or {}
          if #choices == 0 then return false end
          local cur = self:optionValue(modId, row)
          local index = 1
          for i, choice in ipairs(choices) do
            if choice[2] == cur then index = i break end
          end
          index = clampIndex(index + dir, #choices)
          self:setOption(modId, row.key, choices[index][2])
          refresh(row.key)
          return true
        end }
    elseif row.type == "number" then
      local function clamp(v)
        if row.min then v = math.max(row.min, v) end
        if row.max then v = math.min(row.max, v) end
        return v
      end
      rows[#rows + 1] = { id = row.key, label = row.label or row.key,
        value = function()
          return tostring(self:optionValue(modId, row) or 0)
        end,
        step = function(_, dir)
          local cur = tonumber(self:optionValue(modId, row)) or 0
          self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
          refresh(row.key)
          return true
        end,
        activate = function()
          local QuantityBox = require("src.ui.QuantityBox")
          self.game.stack:push(QuantityBox.new(self.game, {
            max = row.max or 99,
            start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
            onDone = function(qty)
              if qty then
                self:setOption(modId, row.key, clamp(qty))
                refresh(row.key)
              end
            end,
          }))
        end }
    elseif row.type == "text" then
      rows[#rows + 1] = { id = row.key, label = row.label or row.key,
        value = function()
          return tostring(self:optionValue(modId, row) or "")
        end,
        activate = function()
          local NamingScreen = require("src.ui.NamingScreen")
          self.game.stack:push(NamingScreen.new(self.game, {
            title = (row.label or row.key) .. "?",
            maxLen = row.maxLen or 7,
            default = self:optionValue(modId, row),
            onDone = function(name)
              self:setOption(modId, row.key, name)
              refresh(row.key)
            end,
          }))
        end }
    end
  end
  rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
    value = function() return "" end,
    activate = function()
      for _, row in ipairs(schema) do
        if type(row) == "table" and type(row.key) == "string"
            and OPTION_TYPES[row.type] then
          self:setOption(modId, row.key, row.default)
        end
      end
      self.optionRows = self:buildOptionRows(m, schema)
      self.cursor = clampIndex(self.cursor, #self.optionRows)
      self:notify("DEFAULTS RESTORED")
    end }
  return rows
end

function ManagerState:openOptions(m)
  if Runtime.safeMode then
    self:notify("SAFE MODE ACTIVE")
    return
  end
  local schema = self:schemaFor(m)
  if not schema then
    self:notify("NO OPTIONS")
    return
  end
  self.optionRows = self:buildOptionRows(m, schema)
  self:goTo("options")
end

function ManagerState:updateOptions(input)
  local rows = self.optionRows or {}
  local n = #rows
  if input:wasPressed("b") then
    self:goBack()
    return
  end
  if n == 0 then return end
  if input:wasPressed("up") then
    self.cursor = clampIndex(self.cursor - 1, n)
  elseif input:wasPressed("down") then
    self.cursor = clampIndex(self.cursor + 1, n)
  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.cursor]
    if row.activate and input:wasPressed("a") then
      self:confirmSound()
      row.activate()
    elseif row.step then
      row.step(self.game, dir)
    end
  end
  self.scroll = OptionRows.clampScroll(self.cursor, self.scroll or 0, n, nil)
end

-- ------- drawing

local function drawTruncated(text, x, y, cols)
  text = tostring(text or "")
  if #text > cols then text = text:sub(1, cols) end
  Font.draw(text, x, y)
end

function ManagerState:drawRows(rows)
  local last = math.min(#rows, self.scroll + LIST_ROWS - 1)
  local y = LIST_TOP
  for i = self.scroll, last do
    local row = rows[i]
    if row.header then
      drawTruncated(row.label, 16, y * 8, 17)
    else
      if row.glyph and row.glyph ~= " " then
        Font.draw(row.glyph, 16, y * 8)
      end
      drawTruncated(row.label, 32, y * 8, 15)
      if i == self.cursor then
        Font.drawCode(Theme.cursor, 8, y * 8)
      end
    end
    y = y + 1
  end
  if #rows > last then
    Font.drawCode(Theme.moreArrow, 18 * 8, (LIST_TOP + LIST_ROWS) * 8)
  end
end

function ManagerState:drawFooter(line1, line2)
  if self.notice then
    Font.draw(self.notice, 16, 15 * 8)
    return
  end
  if line1 then Font.draw(line1, 16, 15 * 8) end
  if line2 then Font.draw(line2, 16, 16 * 8) end
end

function ManagerState:drawList()
  Font.draw(TAB_LINE[self.tab], 16, 2 * 8)
  self:drawRows(self:rowsForScreen())
  if self.tab == 1 then
    self:drawFooter("A:OPEN SEL:TOGGLE", "START:APPLY B:EXIT")
  elseif self.tab == 2 then
    self:drawFooter("A:APPLY SEL:RENAME", "START:DELETE")
  else
    self:drawFooter("UP/DOWN:SCROLL")
  end
end

function ManagerState:drawDetail()
  local m = self.currentMod
  if not m then return end
  local title = wrap(m.name or m.id, 14)
  drawTruncated(title[1] .. " " .. (m.version or ""), 16, 2 * 8, 17)
  local statusLine = m.enabled and "ENABLED" or "DISABLED"
  if m.state == "wrong_generation" or not self:runsHere(m) then
    -- enabled and fine, just not for this game; the detail body says why
    statusLine = statusLine .. " (NOT THIS GAME)"
  elseif m.state == "blocked_dependency" then
    statusLine = statusLine .. " ?"
  elseif m.error then
    statusLine = statusLine .. " !"
  end
  if self:isStaged(m) then statusLine = statusLine .. " (STAGED)" end
  drawTruncated(statusLine, 16, 3 * 8, 17)
  drawTruncated((m.category or "OTHER") .. " / " .. (m.profile or "content"),
                16, 4 * 8, 17)
  local lines = wrap(m.error and ("FAILED: " .. m.error)
    or (m.note and ("SKIPPED: " .. m.note)) or m.description, 16)
  local visible = 5
  for i = 1, visible do
    local line = lines[self.descScroll + i - 1]
    if not line then break end
    Font.draw(line, 16, (5 + i) * 8)
  end
  if self.descScroll + visible <= #lines then
    Font.drawCode(Theme.moreArrow, 17 * 8, 10 * 8)
  end
  local rows = self:rowsForScreen()
  local y = 11
  for i, row in ipairs(rows) do
    drawTruncated(row.label, 32, y * 8, 15)
    if i == self.cursor then Font.drawCode(Theme.cursor, 24, y * 8) end
    y = y + 1
  end
  self:drawFooter("A:CHOOSE B:BACK")
end

function ManagerState:drawPermissions()
  drawTruncated("PERMISSIONS", 16, 2 * 8, 17)
  self:drawRows(self:rowsForScreen())
  self:drawFooter("DECLARED BY AUTHOR,", "NOT ENFORCED")
end

function ManagerState:drawErrors()
  drawTruncated("ERRORS", 16, 2 * 8, 17)
  self:drawRows(self:rowsForScreen())
  self:drawFooter("UP/DOWN:SCROLL B:BACK")
end

function ManagerState:drawApply()
  drawTruncated("PENDING CHANGES", 16, 2 * 8, 17)
  local staged = self:stagedList()
  local y = LIST_TOP
  local shown = math.min(#staged, 7)
  for i = 1, shown do
    local m = staged[i]
    local verb = m.enabled and "ON " or "OFF "
    drawTruncated(verb .. (m.name or m.id), 16, y * 8, 17)
    y = y + 1
  end
  if #staged == 0 then
    Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
    y = y + 1
  end
  local rows = self:rowsForScreen()
  local base = 12
  for i, row in ipairs(rows) do
    drawTruncated(row.label, 32, (base + i - 1) * 8, 15)
    if i == self.cursor then Font.drawCode(Theme.cursor, 24, (base + i - 1) * 8) end
  end
  self:drawFooter("A:CHOOSE B:BACK")
end

function ManagerState:drawOverlay()
  local overlay = self.overlay
  local lines = {}
  for _, raw in ipairs(overlay.lines) do
    for _, line in ipairs(wrap(raw, 14)) do lines[#lines + 1] = line end
  end
  local th = math.max(6, #lines + (overlay.kind == "confirm" and 5 or 3))
  local ty = math.max(1, math.floor((18 - th) / 2))
  love.graphics.setColor(0, 0, 0, 1)
  love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
  love.graphics.setColor(1, 1, 1, 1)
  Font.drawBox(2, ty, 16, th)
  love.graphics.setColor(0, 0, 0, 1)
  for i, line in ipairs(lines) do
    drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
  end
  if overlay.kind == "confirm" then
    local yesY = ty + #lines + 1
    Font.draw(Strings("YES"), 5 * 8, yesY * 8)
    Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
    Font.drawCode(Theme.cursor, 4 * 8,
                  (overlay.index == 1 and yesY or yesY + 1) * 8)
  else
    Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
  end
end

function ManagerState:draw()
  if self.screen == "options" then
    OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
                    self.scroll or 0)
    love.graphics.setColor(0, 0, 0, 1)
    Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
    love.graphics.setColor(1, 1, 1, 1)
    if self.overlay then self:drawOverlay() end
    return
  end
  love.graphics.setColor(0, 0, 0, 1)
  love.graphics.rectangle("fill", 0, 0, 160, 144)
  love.graphics.setColor(1, 1, 1, 1)
  Font.drawBox(0, 0, 20, 18)
  love.graphics.setColor(0, 0, 0, 1)
  Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
  if self.screen == "list" then
    self:drawList()
  elseif self.screen == "detail" then
    self:drawDetail()
  elseif self.screen == "permissions" then
    self:drawPermissions()
  elseif self.screen == "errors" then
    self:drawErrors()
  elseif self.screen == "apply" then
    self:drawApply()
  end
  if self.overlay then self:drawOverlay() end
end

return ManagerState
