-- Save editor app shell.  Boots the game's generated Data plus a save file
-- and draws the chrome the design spec fixes (SaveEditor.dc.html): a version
-- rail, a title bar, a tab rail and a status bar, with one panel filling the
-- space between.  Panels own their tab's content; this module owns everything
-- around it.
--
-- The editor is reachable two ways and behaves the same in both:
--   * `love . --editor`      standalone window, Close quits
--   * Edit on a launcher save row (main.lua, embedded = true), Close returns
--     to the launcher with the slot list refreshed
--
-- Vertical rhythm inside the platform safe area (scaled by Kit's height/768
-- factor, everything else flexes).  Background still fills the full window so
-- the notch / home-indicator bands match the field colour; interactive chrome
-- starts at SafeArea.rect() so Save stays reachable on a punch-hole phone
-- (#917).  Offsets below are relative to that safe origin:
--   0    6px   tri-colour version rail, identical to the launcher's
--   6    64px  title bar   identity, file chip, Save / Reload / Open / Close
--                          (104px when the bar reflows to two rows, #715)
--   70   66px  tab rail    6 tab tiles + right-aligned validation pill
--   136  flex  content     one panel per tab, 20px gutters
--   -38  38px  status bar  the last Ops message + the keyboard map

local Data = require("src.core.Data")
local SafeArea = require("src.core.SafeArea")
local TileRenderer = require("src.render.TileRenderer")
local SaveIO = require("SaveIO")
local Catalog = require("Catalog")
local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PadInput = require("PadInput")
local PAL = Theme.PAL

local Party = require("Party")
local Boxes = require("Boxes")
local Items = require("Items")
local Events = require("Events")
local MapBrowser = require("MapBrowser")
local Dex = require("Dex")
-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541)
local SpeciesPicker = require("SpeciesPicker")
local MovePicker = require("MovePicker")
local ItemPicker = require("ItemPicker")

local App = {}
local S
-- one loader per process: registries collide if a second load re-registers
-- vanilla records over an already-merged Data
local mods
local mouseClicked = false
-- Click position from the press event.  Kit samples the pointer in draw, so a
-- touch / mouse / pad-A click must use the event coords -- not love.mouse
-- (often stale on NX) and not the virtual cursor when a finger taps elsewhere.
local clickX, clickY
-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit
-- there like mouseClicked is: LOVE delivers events before love.draw, so a
-- notch is always spent by the frame that follows it (#595).
local wheelY = 0

-- Which game's cache Data was loaded from.  main.lua checks this before
-- opening the editor on a save from the other version, because the two
-- caches cannot both be mounted in one process (see CacheFs.mountVersion).
App.dataVersion = nil

local TABS = {
  { id = "party",  glyph = "PT", label = "PARTY" },
  { id = "boxes",  glyph = "BX", label = "BOXES" },
  { id = "items",  glyph = "IT", label = "ITEMS" },
  { id = "events", glyph = "EV", label = "EVENTS" },
  { id = "map",    glyph = "MP", label = "MAP" },
  { id = "dex",    glyph = "DX", label = "DEX" },
}

local PANELS = {
  party = Party, boxes = Boxes, items = Items,
  events = Events, map = MapBrowser, dex = Dex,
}

local function fileExists(path)
  local f = io.open(path, "rb")
  if f then f:close(); return true end
  return false
end

-- Apply a load attempt for `path` into the current State (S must exist).
local function applyLoaded(path, statusVerb)
  statusVerb = statusVerb or "Loaded"
  S.path = path
  local existed = fileExists(path)
  local save, err = SaveIO.load(path)
  if save then
    S.save = save
    S.status = statusVerb .. " " .. path
    S.loadError = false
    S.allowSave = true
  elseif existed then
    S.save = Gen.newGame(S.version)
    S.status = "Corrupt save at " .. path .. " (" .. tostring(err) ..
      "),  Save disabled, use Reload after fixing the file"
    S.loadError = true
    S.allowSave = false
  else
    S.save = Gen.newGame(S.version)
    S.status = "No save at " .. path .. " (" .. tostring(err) ..
      "),  editing new game stub"
    S.loadError = false
    S.allowSave = true
  end
  if Gen.of(S.save, S.version) == 2 then
    S.events = Catalog.gen2EventList(Gen.engineOf(S.save, S.version), S.modRoots)
  end
  local mapId = Gen.playerMap(S.save)
  S.mapId = mapId
  S.dirty = false
  S._quitArmed = false
  S._openArmed = false
  S.editingMon = nil
  Ops.disarm(S)
  Gen.ensureBoxes(S.save)
  Gen.hydrateSave(Data, S.save)
  local probe = require("src.mods.Merge").deepCopy(S.save)
  S.validation = Gen.validate(probe, Data)
  if not Gen.emptyReport(S.save, S.validation) then
    if Gen.of(S.save, S.version) == 2 then
      S.status = S.status .. string.format(
        ",  game would quarantine: %d script bytes, %d mail, %d events",
        #(S.validation.lostScriptMem or {}),
        #(S.validation.lostMail or {}),
        #(S.validation.lostEvents or {}))
    else
      S.status = S.status .. string.format(",  game would quarantine: %d mons, %d items, %d maps",
        #S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
    end
  end
end

-- pathOverride lets tests point App.load at a scratch file instead of the
-- real default save path (used to exercise the corrupt-save branch below).
-- opts carries what only the launcher knows: which game the save belongs to,
-- its slot id, and where Close should go back to.
function App.load(pathOverride, opts)
  opts = opts or {}
  S = State.new()
  S.data = Data
  S.version = opts.version
  S.slotId = opts.slotId
  S.embedded = opts.embedded or false
  S.onClose = opts.onClose
  if opts.version then
    require("src.core.GameVersion").set(opts.version)
  end
  -- the same mod set the game loads, merged into Data before the catalogs
  -- build, so modded species/items/moves are editable and MonOps stops
  -- asserting on them
  if not mods or App.dataVersion ~= opts.version then
    -- One loader per editor session.  A previous session leaves Data holding
    -- that session's merged registries (and possibly the other game's cache),
    -- and a second builtin registration over them collides -- "statuses
    -- already registered: FRZ".  _pristineKeys only exists once Data has been
    -- loaded at least once, so it doubles as the "needs evicting" marker.
    if Data._pristineKeys then Data:unloadGenerated() end
    Data:load()
    if Gen.of(nil, opts.version) == 2
        or require("src.core.GameVersion").generation() == 2 then
      Gen.bindGoldData(Data)
    end
    local ModLoader = require("src.mods.Loader")
    mods = ModLoader.new()
    mods:load(Data)
    App.dataVersion = opts.version
  end
  S.mods = mods
  S.cat = Catalog.build(Data)
  local modRoots = {}
  for _, mod in ipairs(S.mods:status().loaded) do
    modRoots[#modRoots + 1] = mod.path
  end
  S.modRoots = modRoots
  if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
    S.events = Catalog.gen2EventList(Gen.engineOf(nil, opts.version), modRoots)
  else
    S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
                                    nil, modRoots)
  end
  applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
end

-- Switch to another save file (Open button, drag-drop, or --save arg).
-- If there are unsaved edits, the first call arms a confirm; call again
-- (or pass force=true) to discard and open.
function App.openPath(path, force)
  if not path or path == "" then return false end
  if not S then return false end
  if S.dirty and not force and not S._openArmed then
    S._openArmed = true
    S.status = "Unsaved changes,  open again to discard and load " .. path
    return false
  end
  applyLoaded(path, "Opened")
  return true
end

function App.chooseAndOpen()
  local path = SaveIO.choosePath()
  if path then
    App.openPath(path)
  else
    local osName = love and love.system and love.system.getOS
      and love.system.getOS()
    if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then
      S.status = "File picker unavailable,  drop a save.lua onto the window"
    end
  end
end

function App.filedropped(file)
  if not (file and S) then return end
  local path = file.getFilename and file:getFilename() or nil
  if not path or path == "" then
    S.status = "Could not read dropped file path"
    return
  end
  App.openPath(path)
end

-- Test hook: App.load keeps its state in a module-local so headless tests
-- can drive App.load/App.draw against a scratch path and then inspect the
-- resulting flags/status without loving a real save file.
function App.getState()
  return S
end

-- Tear the editor down far enough that a later App.load rebuilds from
-- scratch.  main.lua calls this after Close so the next Edit -- possibly on
-- the other game's save -- re-runs Data:load against whatever cache is
-- mounted by then, instead of reusing this session's merged registries.
function App.unload()
  S = nil
  mods = nil
  App.dataVersion = nil
  -- Kit is never evicted from package.loaded, so a Close taken while a text
  -- field still owns focus would leak Kit.focus and a raised soft keyboard
  -- (against a rect that is gone) into the launcher and the next session
  -- (#529).  A Close taken on the frame the species picker went up would
  -- likewise leave its modal shield raised, and the next session would open
  -- deaf to every click (#541).
  Kit.blur()
  Kit.blockClicks = false
  PadInput.reset()
end

local function cycleTab(delta)
  if not S then return end
  local idx = 1
  for i, t in ipairs(TABS) do
    if t.id == S.tab then idx = i; break end
  end
  idx = ((idx - 1 + delta) % #TABS) + 1
  S.tab = TABS[idx].id
  Ops.say(S, "Tab: " .. TABS[idx].label)
end

-- Pad / Joy-Con actions from PadInput.gamepadpressed (A/B via GamepadMap so
-- NX physical A confirms and B closes).
local function handlePadAction(action)
  if not action or not S then return end
  if action == "a" then
    local mx, my = PadInput.pointer()
    App.mousepressed(mx, my, 1)
  elseif action == "b" then
    App.close()
  elseif action == "tab_prev" then
    cycleTab(-1)
  elseif action == "tab_next" then
    cycleTab(1)
  end
end

function App.save()
  if not S.allowSave then
    return Ops.say(S, "Save disabled,  corrupt save loaded; fix the file and Reload first")
  end
  local ok, err = SaveIO.save(S.path, S.save)
  if ok then
    S.dirty = false
    S._quitArmed = false
    Ops.disarm(S)
    S.status = "Saved " .. S.path
    return true
  end
  S.status = "Save failed: " .. tostring(err)
  return false
end

function App.reload()
  local save, err = SaveIO.load(S.path)
  if save then
    S.save = save
    S.dirty = false
    S.loadError = false
    S.allowSave = true
    S._quitArmed = false
    S._openArmed = false
    S.editingMon = nil
    S.status = "Reloaded " .. S.path
    require("src.pokemon.Boxes").ensure(S.save)
    return true
  end
  S.status = "Reload failed: " .. tostring(err)
  return false
end

-- Close: back to the launcher when hosted there, otherwise quit.  Unsaved
-- edits arm a confirm exactly like Open does, so leaving can't lose work.
--
-- The teardown itself is DEFERRED to the end of the frame (App.draw calls
-- finishClose below).  Close is dispatched from inside drawTitleBar, and the
-- host's onClose runs App.unload, which drops S -- doing that inline left the
-- rest of the frame drawing against a nil state.
function App.close()
  if not S then return false end
  if S.dirty and not S._quitArmed then
    S._quitArmed = true
    S.status = "Unsaved changes,  Save first or click Close again to discard"
    return false
  end
  S._closeRequested = true
  return true
end

local function finishClose()
  local embedded, onClose = S.embedded, S.onClose
  S._closeRequested = false
  if embedded and onClose then
    onClose()
  elseif love and love.event then
    love.event.quit()
  end
end

function App.update(dt)
  -- Immediate-mode UI: nothing to simulate per-frame; input is sampled
  -- directly in App.draw() via Kit.beginFrame. Tile animation (water,
  -- flowers) still needs ticking so the Map tab isn't static.
  TileRenderer.tick()
  PadInput.update(dt)
  local notches = PadInput.takeWheel()
  if notches ~= 0 then
    App.wheelmoved(0, notches)
  end

  -- Dev harness, the launcher's POKEPORT_LAUNCHER_SHOT for this window:
  -- POKEPORT_EDITOR_SHOT=/path.png with POKEPORT_WIN=WxH resizes, lets the
  -- view settle, captures one frame and quits, so a scripted run can see the
  -- real editor at any window shape.  POKEPORT_EDITOR_TAB picks the tab and
  -- POKEPORT_EDITOR_ITEMPICK=1 opens the add-item modal.
  local shot = os.getenv("POKEPORT_EDITOR_SHOT")
  if shot and not App._shotDone then
    if not App._shotSized then
      App._shotSized = true
      local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$")
      if w and love.window and love.window.setMode then
        pcall(love.window.setMode, tonumber(w), tonumber(h), { resizable = true })
      end
      local tab = os.getenv("POKEPORT_EDITOR_TAB")
      if tab and tab ~= "" and S then S.tab = tab end
      if os.getenv("POKEPORT_EDITOR_ITEMPICK") == "1" and S then
        Ops.openItemPicker(S, Kit, "bag")
      end
    end
    App._shotTimer = (App._shotTimer or 0) + dt
    if App._shotTimer > 1.0 then
      App._shotDone = true
      love.graphics.captureScreenshot(function(imagedata)
        local fd = imagedata:encode("png")
        local f = io.open(shot, "wb")
        if f then f:write(fd:getString()) f:close() end
        love.event.quit()
      end)
    end
  end
end

function App.mousepressed(x, y, button)
  if button == 1 then
    mouseClicked = true
    clickX, clickY = x, y
    -- A finger / mouse tap yields the virtual cursor so the click lands where
    -- the event said, not under the Joy-Con pointer (NX touch soft-miss).
    PadInput.yieldToPointer()
  end
end

function App.textinput(text)
  Kit.textinput(text)
end

function App.gamepadpressed(joystick, button)
  handlePadAction(PadInput.gamepadpressed(joystick, button))
end

function App.gamepadreleased(joystick, button)
  PadInput.gamepadreleased(joystick, button)
end

function App.gamepadaxis(joystick, axis, value)
  PadInput.gamepadaxis(joystick, axis, value)
end

function App.joystickpressed(joystick, button)
  handlePadAction(PadInput.joystickpressed(joystick, button))
end

function App.joystickreleased(joystick, button)
  PadInput.joystickreleased(joystick, button)
end

function App.joystickaxis(joystick, axis, value)
  PadInput.joystickaxis(joystick, axis, value)
end

function App.joystickhat(joystick, hat, direction)
  PadInput.joystickhat(joystick, hat, direction)
end

-- ------------------------------------------------------------------ chrome
-- The file chip: the single source of truth for "which file am I editing".
-- The path truncates from the LEFT so the filename is always readable, and
-- an amber dot plus the word UNSAVED calls out dirty state from any tab.
local function drawFileChip(x, y, w, h)
  local s = Kit.scale
  Theme.row(x, y, w, h, 10 * s, 0.6)
  local pad = 14 * s
  local dot = 8 * s
  local cx = x + pad
  if S.dirty then
    Theme.col(PAL.yellow, 1)
    if love.graphics.circle then
      love.graphics.circle("fill", cx + dot / 2, y + h / 2, dot / 2)
    else
      love.graphics.rectangle("fill", cx, y + h / 2 - dot / 2, dot, dot)
    end
    cx = cx + dot + 8 * s
  end
  local label = S.dirty and "UNSAVED" or "SAVED"
  local labelW = Kit.textWidth("tiny", label)
  Kit.textRight("tiny", label, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2,
    S.dirty and PAL.yellow or PAL.caption)
  local avail = (x + w - pad - labelW - 10 * s) - cx
  local shown = Theme.ellipsizeLeft(Kit.fonts.mono, S.path or "(no file)", avail)
  Kit.text("mono", shown, cx, y + (h - Kit.textHeight("mono")) / 2, PAL.detail)
end

-- Measure the title bar's right-aligned action cluster.  Shared by App.draw
-- (which must size the bar before drawing it) and drawTitleBar, so the
-- two-row decision and the layout can never disagree (#715).
local function titleButtons()
  local s = Kit.scale
  local gap = 8 * s
  local b = {
    gap = gap,
    closeW = 22 * s + Kit.textWidth("button", S._quitArmed and "Discard?" or "Close"),
    openW = 22 * s + Kit.textWidth("button", "Open..."),
    reloadW = 22 * s + Kit.textWidth("button", "Reload"),
  }
  b.saveLabel, b.saveKind, b.saveEnabled = "SAVED", "disabled", false
  if not S.allowSave then
    b.saveLabel = "SAVE LOCKED"
  elseif S.dirty then
    b.saveLabel, b.saveKind, b.saveEnabled = "SAVE", "primary", true
  end
  b.saveW = 30 * s + Kit.textWidth("button", b.saveLabel)
  b.total = b.saveW + b.reloadW + b.openW + b.closeW + 3 * gap
  return b
end

-- Whether the identity block plus the action cluster fit on one 64px row.
-- When they do not, the bar reflows to two rows (identity + file chip above,
-- buttons below) instead of shrinking or overlapping (#715).
local function titleNeedsTwoRows(w)
  local s = Kit.scale
  return titleButtons().total > w - 2 * (22 * s) - (34 * s) - 10 * s
end

local function drawTitleBar(x, y, w, h, twoRow)
  local s = Kit.scale
  local pad = 22 * s
  Theme.col(PAL.cardBorder, 0.22)
  love.graphics.rectangle("fill", x, y + h - 1, w, 1)

  -- the identity row is the whole bar in one-row mode, the top slice in two
  local rowH = twoRow and (h * 0.55) or h
  local cx = x + pad
  -- SE badge, the same rounded-square chip shape the launcher's tabs use
  local badge = 34 * s
  local by = y + (rowH - badge) / 2
  Theme.gradRounded(cx, by, badge, badge, 9 * s, PAL.chipTop, PAL.chipBot, 1, 1)
  Kit.textCenter("tab", "SE", cx, by + (badge - Kit.textHeight("tab")) / 2, badge,
    { 159, 180, 221 })
  cx = cx + badge + 10 * s

  -- The right-aligned action cluster is laid out from the right edge inward
  -- BEFORE anything on the left is drawn: the buttons are the one thing in
  -- this bar that must always be reachable, so on a phone the identity block,
  -- the version chip and the file chip are what yield.  Measuring them last
  -- is why they used to paint straight through the buttons (#497).
  local b = titleButtons()
  local btnH = 38 * s
  local btnY = twoRow and (y + rowH + (h - rowH - btnH) / 2) or (y + (h - btnH) / 2)
  local rightEdge = x + w - pad
  local gap = b.gap
  local saveLabel, saveKind, saveEnabled = b.saveLabel, b.saveKind, b.saveEnabled

  -- clamped at the left pad so a window narrower than the cluster overflows
  -- to the right (clipped) instead of stacking buttons on each other
  local saveX = math.max(x + pad, rightEdge - b.total)
  local reloadX = saveX + b.saveW + gap
  local openX = reloadX + b.reloadW + gap
  local closeX = openX + b.openW + gap
  -- the identity row yields to the buttons in one-row mode; in two-row mode
  -- the buttons are on their own row and the identity keeps the full width
  local identityLimit = twoRow and (rightEdge + 14 * s) or saveX

  local wordH = Kit.textHeight("wordmark")
  local brandH = Kit.textHeight("brand")
  local blockY = y + (rowH - (wordH + 2 * s + brandH)) / 2
  local wordW = math.max(
    Theme.spacedWidth(Kit.fonts.wordmark, "SAVE EDITOR", 2 * s),
    Theme.spacedWidth(Kit.fonts.brand, "GEN1RECOMP", 1 * s))
  if cx + wordW + 12 * s < identityLimit then
    love.graphics.setFont(Kit.fonts.wordmark)
    Theme.col(PAL.heading, 1)
    Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s)
    love.graphics.setFont(Kit.fonts.brand)
    Theme.col(PAL.caption, 1)
    Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx,
      blockY + wordH + 2 * s, 1 * s)
    cx = cx + wordW + 12 * s
  end

  -- version chip: which game this save belongs to (from the launcher slot,
  -- or the save's own header in a standalone run)
  if S.version then
    local name = S.version:upper()
    local c = (S.version == "blue") and PAL.blue or PAL.red
    local cw = Kit.textWidth("chip", name) + 16 * s
    local ch = 22 * s
    local cy = y + (rowH - ch) / 2
    if cx + cw + 12 * s < identityLimit then
      Theme.col(c, 0.1)
      love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s)
      Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1)
      Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2,
        cw, c)
      cx = cx + cw + 12 * s
    end
  end

  -- Save is the only green-filled control in the chrome; a corrupt load
  -- renders it steel with the reason parked in the status bar rather than
  -- hiding it (rule 3 of the design spec).
  if Kit.button(saveX, btnY, b.saveW, btnH, saveLabel,
      { kind = saveKind, enabled = saveEnabled or not S.allowSave,
        glow = S.dirty and S.allowSave and 0.6 or nil }) then
    App.save()
  end
  if Kit.button(reloadX, btnY, b.reloadW, btnH, "Reload") then App.reload() end
  if Kit.button(openX, btnY, b.openW, btnH, "Open...") then App.chooseAndOpen() end
  if Kit.button(closeX, btnY, b.closeW, btnH,
      S._quitArmed and "Discard?" or "Close",
      { kind = S._quitArmed and "danger" or "ghost" }) then
    App.close()
  end

  local chipW = (identityLimit - 14 * s) - cx
  if chipW > 80 * s then
    drawFileChip(cx, y + (rowH - 38 * s) / 2, chipW, 38 * s)
  end
end

-- Per-tab counters shown under each tile, so the rail doubles as a summary.
local function tabCount(id)
  if id == "party" then
    return ("%d/%d"):format(#S.save.party, require("src.pokemon.Party").MAX)
  elseif id == "boxes" then
    local n = 0
    for _, box in ipairs(Ops.boxes(S)) do n = n + #box end
    return tostring(n)
  elseif id == "items" then
    local Bag = require("src.inventory.Bag")
    return ("%d/%d"):format(Bag.slots(S.save, S.data), Bag.capacity(S.data))
  elseif id == "events" then
    return tostring(Gen.flagCount(S.save))
  elseif id == "map" then
    -- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label
    return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale)
  elseif id == "dex" then
    local _, owned, total = Ops.dexCounts(S)
    return ("%d/%d"):format(owned, total)
  end
  return ""
end

-- The validation pill mirrors SaveData.validate: green when the report is
-- empty, yellow with counts when the running game would quarantine
-- something.  Returns what to draw plus the tab that owns the first problem,
-- so the rail can reserve the pill's width before laying out the tiles.
local function validationPill()
  local report = S.validation
  if not report or Gen.emptyReport(S.save, report) then
    return "Save validates clean", PAL.green, nil, true
  end
  local parts = {}
  local target
  local function add(n, singular, plural, tab)
    n = n or 0
    if n <= 0 then return end
    parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
    target = target or tab
  end
  if Gen.of(S.save, S.version) == 2 then
    add(#(report.lostScriptMem or {}), "script byte", "script bytes", "events")
    add(#(report.lostMail or {}), "mail", "mail", "party")
    add(#(report.lostEvents or {}), "event", "events", "events")
    add(#(report.lostMapScenes or {}), "map scene", "map scenes", "map")
  else
    add(#(report.lostMons or {}), "mon", "mons", "party")
    add(#(report.lostItems or {}), "item", "items", "items")
    add(#(report.remappedMaps or {}), "map", "maps", "map")
  end
  return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false
end

-- Tab tiles degrade rather than collide: at full width each tile carries its
-- glyph, label and counter; when the validation pill would overlap, the
-- counters drop first and then the labels, leaving the 2-letter glyphs.  A
-- tile is always at least its own square, so every tab stays clickable.
local function railDetail(x, pillX)
  local s = Kit.scale
  local tile = 40 * s
  local widths = { full = 0, nocount = 0, glyph = 0 }
  for _, t in ipairs(TABS) do
    local labelW = Theme.spacedWidth(Kit.fonts.tab, t.label, 1.5 * s)
    local countW = Kit.textWidth("tiny", tabCount(t.id))
    widths.full = widths.full + tile + 9 * s + labelW + 8 * s + countW + 20 * s
    widths.nocount = widths.nocount + tile + 9 * s + labelW + 20 * s
    widths.glyph = widths.glyph + tile + 12 * s
  end
  local avail = pillX - 14 * s - (x + 22 * s)
  -- the widths come back too: the caller needs the glyph-mode figure to decide
  -- whether the pill still has room, and measuring the rail twice is waste
  if widths.full <= avail then return "full", widths end
  if widths.nocount <= avail then return "nocount", widths end
  return "glyph", widths
end

local function drawTabRail(x, y, w, h)
  local s = Kit.scale
  local pad = 22 * s
  Theme.col(PAL.cardBorder, 0.22)
  love.graphics.rectangle("fill", x, y + h - 1, w, 1)

  local label, pillColor, target, clean = validationPill()
  local ph = 26 * s
  local pw = Kit.textWidth("small", label) + 28 * s
  local px = x + w - pad - pw
  local detail, widths = railDetail(x, px)
  -- Last stop before the tiles and the pill collide: at phone widths even the
  -- 2-letter glyph tiles need the room the pill is sitting in, and the pill
  -- only repeats a line the status bar already prints on load, so the pill is
  -- what goes (#497).  With it gone the tiles get the full bar back.
  local showPill = widths.glyph <= px - 14 * s - (x + 22 * s)
  if not showPill then
    detail = railDetail(x, x + w - pad)
  end

  local tile = 40 * s
  local cx = x + pad
  local tileY = y + h - 12 * s - tile
  for _, t in ipairs(TABS) do
    local active = (S.tab == t.id)
    local count = (detail == "full") and tabCount(t.id) or ""
    local labelW = (detail == "glyph") and 0
      or Theme.spacedWidth(Kit.fonts.tab, t.label, 1.5 * s)
    local countW = Kit.textWidth("tiny", count)
    local cellW = (detail == "glyph") and (tile + 12 * s)
      or (tile + 9 * s + labelW + (count ~= "" and 8 * s + countW or 0) + 20 * s)

    Theme.gradRounded(cx, tileY, tile, tile, 11 * s, PAL.chipTop, PAL.chipBot, 1, 1)
    Kit.textCenter("tile", t.glyph, cx, tileY + (tile - Kit.textHeight("tile")) / 2,
      tile, PAL.chipInk)
    if not active then
      -- one tile style plus a scrim, the same trick the launcher uses
      Theme.col(PAL.bgBot, 0.55)
      love.graphics.rectangle("fill", cx, tileY, tile, tile, 11 * s, 11 * s)
    else
      Theme.stroke(cx, tileY, tile, tile, 11 * s, PAL.blue, 0.75, 1.5 * s)
    end

    if detail ~= "glyph" then
      local lx = cx + tile + 9 * s
      love.graphics.setFont(Kit.fonts.tab)
      Theme.col(active and PAL.heading or PAL.muted, 1)
      Theme.spaced(Kit.fonts.tab, t.label, lx,
        tileY + (tile - Kit.textHeight("tab")) / 2, 1.5 * s)
      if count ~= "" then
        Kit.text("tiny", count, lx + labelW + 8 * s,
          tileY + (tile - Kit.textHeight("tiny")) / 2, PAL.faint)
      end
    end

    if active then
      Theme.col(PAL.blue, 1)
      love.graphics.rectangle("fill", cx, y + h - 3 * s,
        math.max(tile, cellW - 20 * s), 3 * s)
    end
    if Kit.press(cx - 8 * s, y, cellW, h) then
      S.tab = t.id
      Kit.blur()
      Ops.disarm(S)
    end
    cx = cx + cellW
  end

  if showPill then
    local py = y + h - 14 * s - ph
    Theme.col(pillColor, clean and 0.08 or 0.1)
    love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2)
    Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1)
    Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2,
      pw, pillColor)
    -- paint and hit target are suppressed together, so a hidden pill cannot
    -- still eat a tap meant for the DEX tile
    if target and Kit.press(px, py, pw, ph) then
      S.tab = target
      Ops.say(S, "Jumped to the tab holding the first quarantine warning")
    end
  end
end

local function drawStatusBar(x, y, w, h)
  local s = Kit.scale
  local pad = 22 * s
  Theme.col(PAL.bgBot, 0.6)
  love.graphics.rectangle("fill", x, y, w, h)
  Theme.col(PAL.cardBorder, 0.22)
  love.graphics.rectangle("fill", x, y, w, 1)

  local ctrl = (love.system and love.system.getOS
    and love.system.getOS() == "OS X") and "Cmd" or "Ctrl"
  local hint = S.embedded
    and (ctrl .. "+S save . " .. ctrl ..
         "+R reload . Esc clear selection . Close returns to the launcher")
    or (ctrl .. "+S save . " .. ctrl ..
        "+R reload . Esc clear selection . arrows pan map . wheel scrolls lists")
  -- The status message is the load-bearing half of this bar (every Ops verb
  -- narrates through it); the keyboard map is decoration.  On a phone the
  -- two used to overlap because the hint was drawn unconditionally and the
  -- status ellipsized against a negative budget (#715), so now the hint only
  -- draws when the status still keeps a readable share of the bar.
  local hintW = Kit.textWidth("tiny", hint)
  local avail = w - 2 * pad - hintW - 14 * s
  if avail >= 120 * s then
    Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint)
  else
    avail = w - 2 * pad
  end
  Kit.text("mono", Kit.ellipsize("mono", S.status or "", avail), x + pad,
    y + (h - Kit.textHeight("mono")) / 2, PAL.detail)
end

function App.draw()
  -- Closing the editor unloads it, and the host may still deliver one more
  -- frame or a queued event before it re-routes; every entry point below
  -- tolerates that rather than indexing a torn-down state.
  if not S then return end
  local width, height = love.graphics.getDimensions()
  width = math.max(1, tonumber(width) or 1)
  height = math.max(1, tonumber(height) or 1)
  -- Usable chrome rect.  Background still fills the window so the notch /
  -- home-indicator bands stay the field colour; every button (Save first)
  -- lives inside the safe area, matching the launcher and Skin Studio (#917).
  local ox, oy, sw, sh = SafeArea.rect()
  ox = math.max(0, tonumber(ox) or 0)
  oy = math.max(0, tonumber(oy) or 0)
  sw = math.max(1, tonumber(sw) or width)
  sh = math.max(1, tonumber(sh) or height)
  Kit.layout(sw, sh)
  local s = Kit.scale

  local mx, my = love.mouse.getPosition()
  local padX, padY, padOn = PadInput.pointer()
  if mouseClicked and clickX ~= nil then
    mx, my = clickX, clickY
  elseif padOn then
    mx, my = padX, padY
  end
  Kit.beginFrame(mx, my, mouseClicked, wheelY)
  mouseClicked = false
  clickX, clickY = nil, nil
  wheelY = 0
  -- Modal shield.  Kit has no z-order, so the picker cannot simply be drawn
  -- last: the chrome and the panel underneath would take the same tap.  The
  -- shield goes up before anything dispatches and comes down only for the
  -- picker's own layer at the bottom of this function (#541).
  Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil)
    or (S.movePicker ~= nil)

  Theme.field(width, height)

  local railH = 6 * s
  -- The title bar reflows to two rows (identity above, buttons below) when
  -- the window is too narrow for both on one, instead of the buttons and the
  -- identity painting through each other (#715).  The taller bar simply
  -- costs the content column height, which scrolls.
  local titleTwoRow = titleNeedsTwoRows(sw)
  local titleH = (titleTwoRow and 104 or 64) * s
  local tabH = 66 * s
  local statusH = 38 * s

  Theme.versionRail(ox, oy, sw, railH)
  drawTitleBar(ox, oy + railH, sw, titleH, titleTwoRow)
  drawTabRail(ox, oy + railH + titleH, sw, tabH)

  local contentY = oy + railH + titleH + tabH
  local contentH = sh - railH - titleH - tabH - statusH
  local panel = PANELS[S.tab]
  if panel then
    panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s,
      sw - 44 * s, contentH - 38 * s)
  end

  drawStatusBar(ox, oy + sh - statusH, sw, statusH)
  Kit.blockClicks = false
  -- Scrim still covers the full window (including unsafe bands); the card
  -- itself is centred in the safe rect so search fields clear the notch.
  SpeciesPicker.draw(S, Kit, width, height)
  MovePicker.draw(S, Kit, width, height)
  ItemPicker.draw(S, Kit, width, height)
  Kit.endFrame()
  PadInput.draw()

  -- Only now, with the whole frame painted, is it safe to drop the editor.
  if S._closeRequested then finishClose() end
end

function App.keypressed(key)
  if not S then return end
  -- The picker takes Enter and Escape before the focused field does: Kit maps
  -- both to the same "\r" edit (a blur), which cannot tell "commit the top
  -- match" apart from "give up" (#541).
  if S.itemPicker then
    if key == "return" or key == "kpenter" then
      ItemPicker.commitFirst(S, Kit)
      return
    elseif key == "escape" then
      Ops.closeItemPicker(S, Kit)
      return
    end
  end
  if S.movePicker then
    if key == "return" or key == "kpenter" then
      MovePicker.commitFirst(S, Kit)
      return
    elseif key == "escape" then
      Ops.closeMovePicker(S, Kit)
      return
    end
  end
  if S.speciesPicker then
    if key == "return" or key == "kpenter" then
      SpeciesPicker.commitFirst(S, Kit)
      return
    elseif key == "escape" then
      Ops.closeSpeciesPicker(S, Kit)
      return
    end
  end
  -- The inspector's nickname field is a commit-on-Enter field, unlike the
  -- search fields, which are live view state.  Enter commits the draft through
  -- Ops and blurs; Escape discards it and blurs.  Both must run before
  -- Kit.keypressed, which maps return/escape to the same "\r" edit and cannot
  -- tell "commit" from "cancel".
  if Kit.focus == "mon-nickname" then
    if key == "return" or key == "kpenter" then
      if S.editingMon and Ops.setNickname(S, S.editingMon, S.nicknameDraft) then
        S.nicknameDraft = S.editingMon.nickname or ""
      end
      Kit.blur()
      return
    elseif key == "escape" then
      Kit.blur()
      if S.editingMon then S.nicknameDraft = S.editingMon.nickname or "" end
      return
    end
  end
  -- A focused text field eats the keys it cares about (typing "s" into the
  -- map filter must not trigger Save).
  if Kit.keypressed(key) then return end
  if Kit.focus then
    if key == "escape" then Kit.blur() end
    return
  end
  -- Save and Reload both touch the file on disk (Reload discards unsaved
  -- edits), so they need a modifier.  A bare letter is one stray keystroke
  -- away from a write, and the editor has no undo.
  local mod = love.keyboard and love.keyboard.isDown
    and (love.keyboard.isDown("lgui", "rgui")
      or love.keyboard.isDown("lctrl", "rctrl"))
  if key == "escape" then
    S.editingMon = nil
    Ops.disarm(S)
    Ops.say(S, "Selection cleared")
  elseif key == "s" and mod then
    App.save()
  elseif key == "r" and mod then
    App.reload()
  end
  if S.tab == "map" and MapBrowser.keypressed then
    MapBrowser.keypressed(S, key)
  end
end

function App.wheelmoved(x, y)
  if not S then return end
  -- The map tab spends the wheel on zoom; every other tab routes it through
  -- Kit so whichever list the pointer is over takes it next draw (#595).
  if S.tab == "map" and MapBrowser.wheelmoved then
    MapBrowser.wheelmoved(S, y)
    return
  end
  wheelY = wheelY + (y or 0)
end

function App.quit()
  if not S then return false end
  if S.dirty then
    -- simple: block quit once and set status; user saves or force-quits again
    if not S._quitArmed then
      S._quitArmed = true
      S.status = "Unsaved changes,  save or press quit again"
      return true
    end
  end
  return false
end

return App
