Codmunity Meta Overlay

@itzchillv1.3.1

Overlays the weekly CoD meta, loadouts, and weapon stats pulled from CODMunity.

Files

main.luau

--[[
  rebind: min_sdk=3.9.0
  rebind: name=CODMunity Meta Overlay
  rebind: version=1.3.0
  rebind: permission=net:codmunity.gg
  rebind: permission=clipboard:write
  rebind: permission_reason="net: Downloads public weekly CoD meta, loadout, and weapon database pages from CODMunity"
  rebind: memory_limit_mb=64
  rebind: tick_rate=60
]]

-- CODMunity publishes the weekly top 10 and each weapon's FAQ as JSON-LD.
-- This script reads those public pages, keeps a local fallback cache, and
-- never writes anything to the site.

local SOURCE = "CODMunity"
local CACHE_VERSION = 2
local USER_FILE = "user_state.json"
local INDEX_FILE = "weapon_index.json"
local GAME_ORDER = { "warzone", "mw4", "wz_ranked", "bo7" }
local GAMES = {
  warzone = {
    key = "warzone",
    label = "Warzone",
    title = "WARZONE META",
    path = "warzone",
    index = "https://codmunity.gg/weekly-meta/warzone",
    cache = "meta_cache.json",
    faq_name = "Warzone",
    loadout_meta = "wz",
    prefixes = { "bo7", "mw4", "mw3", "mw2", "mw2019" },
  },
  mw4 = {
    key = "mw4",
    label = "MW4",
    title = "MW4 META",
    path = "modern-warfare-4",
    index = "https://codmunity.gg/weekly-meta/modern-warfare-4",
    cache = "meta_cache_mw4.json",
    faq_name = "Modern Warfare 4",
    loadout_meta = "mw4-mp",
    prefixes = { "mw4" },
  },
  wz_ranked = {
    key = "wz_ranked",
    label = "Warzone Ranked",
    title = "WZ RANKED META",
    path = "warzone-ranked",
    index = "https://codmunity.gg/weekly-meta/warzone-ranked",
    cache = "meta_cache_wz_ranked.json",
    faq_name = nil,
    loadout_meta = "Warzone Ranked",
    prefixes = { "bo7", "mw4" },
  },
  bo7 = {
    key = "bo7",
    label = "BO7",
    title = "BO7 META",
    path = "black-ops-7",
    index = "https://codmunity.gg/weekly-meta/black-ops-7",
    cache = "meta_cache_bo7.json",
    faq_name = nil,
    loadout_meta = "wz",
    prefixes = { "bo7" },
  },
}
local MIN_REFRESH_SECONDS = 60
local HTTP_TIMEOUT_MS = 25000
local USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 RebindMetaOverlay/1.0"

local THEME = {
  surface = "#0d1210f2",
  border = "#a5e63c55",
  text = "#f2f7ee",
  dim = "#929c96",
  faint = "#5f6b65",
  accent = "#a5e63c",
  positive = "#75c017",
  warning = "#ffb020",
  danger = "#eb5757",
}

local THEME_PALETTES = {
  default = {
    surface = "#0d1210f2",
    border = "#a5e63c55",
    text = "#f2f7ee",
    dim = "#929c96",
    faint = "#5f6b65",
    accent = "#a5e63c",
    positive = "#75c017",
    warning = "#ffb020",
    danger = "#eb5757",
  },
  contrast = {
    surface = "#000000f2",
    border = "#ffffffff",
    text = "#ffffff",
    dim = "#dddddd",
    faint = "#a8a8a8",
    accent = "#ffff00",
    positive = "#00ff66",
    warning = "#ffb000",
    danger = "#ff5555",
  },
  mono = {
    surface = "#111111f2",
    border = "#666666ff",
    text = "#f4f4f4",
    dim = "#c0c0c0",
    faint = "#8a8a8a",
    accent = "#ffffff",
    positive = "#d8d8d8",
    warning = "#b0b0b0",
    danger = "#e8e8e8",
  },
}

local cfg = rb.ui.schema({
  game = rb.ui.select("warzone", GAME_ORDER, { label = "Game" }),
  view = rb.ui.select("meta", { "meta", "favorites", "recent" }, { label = "Menu view" }),
  category = rb.ui.select("all", { "all", "ar", "smg", "sniper", "lmg", "shotgun", "pistol" }, { label = "Category filter" }),
  sort = rb.ui.select("rank", { "rank", "pick", "name" }, { label = "Sort by" }),
  theme = rb.ui.select("default", { "default", "contrast", "mono" }, { label = "Theme" }),
  hud_mode = rb.ui.select("full", { "full", "compact", "codes", "off" }, { label = "HUD style" }),
  hud_position = rb.ui.select("top-left", { "top-left", "top-right", "bottom-left", "bottom-right" }, { label = "HUD position" }),
  hud_scale = rb.ui.select("normal", { "small", "normal", "large" }, { label = "HUD size" }),
  search_scope = rb.ui.select("all", { "all", "game" }, { label = "Search scope" }),
  open_key = rb.ui.keybind("F8", { label = "Open meta overlay" }),
  help_key = rb.ui.keybind("F1", { label = "Help / hotkeys" }),
  search_key = rb.ui.keybind("F2", { label = "Search weapons" }),
  hud_key = rb.ui.keybind("F3", { label = "Toggle top-3 HUD" }),
  compare_key = rb.ui.keybind("F4", { label = "Compare with another weapon" }),
  favorite_key = rb.ui.keybind("F5", { label = "Toggle favorite" }),
  build_key = rb.ui.keybind("F6", { label = "Choose another build" }),
  refresh_key = rb.ui.keybind("F7", { label = "Refresh meta data" }),
  copy_key = rb.ui.keybind("F9", { label = "Copy last build code" }),
  full_copy_key = rb.ui.keybind("F10", { label = "Copy full loadout" }),
  extras_key = rb.ui.keybind("F11", { label = "Extras menu" }),
  copy_on_select = rb.ui.toggle(true, { label = "Copy build code on select" }),
})

local function apply_theme()
  local palette = THEME_PALETTES[tostring(cfg.theme or "default")] or THEME_PALETTES.default
  for key, value in pairs(palette) do
    THEME[key] = value
  end
end

local state = {
  game_key = nil,
  game = GAMES.warzone,
  items = {},
  week = "",
  report_url = "",
  updated_at = 0,
  previous_week = "",
  previous_items = {},
  history = {},
  refreshing = false,
  menu = nil,
  menu_map = nil,
  detail = nil,
  compare_surface = nil,
  compare_handler = nil,
  compare_a = nil,
  compare_b = nil,
  compare_a_build = nil,
  compare_b_build = nil,
  changes_surface = nil,
  hud_surface = nil,
  help_surface = nil,
  help_handler = nil,
  status = nil,
  last = nil,
  last_build = nil,
  last_copied_code = nil,
  user = {
    favorites = {},
    recent = {},
    hud = {},
    pins = {},
    favorite_builds = {},
    tags = {},
    plan = {},
    clipboard_history = {},
    search_recent = {},
  },
  dynamic = {},
  index = {},
  index_updated_at = 0,
  index_refreshing = false,
  search = nil,
}

local sync_game
local notify
local close_search
local build_issues
local rank_history_text
local compare_summary_text

local function selected_game()
  local key = tostring(cfg.game or "warzone")
  return GAMES[key] or GAMES.warzone
end

local function trim(value)
  return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end

local function fit(value, max_chars)
  local text = tostring(value or "")
  if #text <= max_chars then
    return text
  end
  if max_chars <= 2 then
    return text:sub(1, max_chars)
  end
  return text:sub(1, max_chars - 2) .. ".."
end

local function parse_jsonld(html)
  local blocks = {}
  for raw in tostring(html or ""):gmatch('<script[^>]*application/ld%+json[^>]*>(.-)</script>') do
    local ok, value = pcall(rb.json.parse, raw)
    if ok and type(value) == "table" then
      blocks[#blocks + 1] = value
    end
  end
  return blocks
end

local function best_guns_list(blocks)
  for _, block in ipairs(blocks) do
    if block["@type"] == "ItemList" and type(block.itemListElement) == "table" then
      local name = tostring(block.name or "")
      if #block.itemListElement >= 10 and name:find("Best", 1, true) then
        return block
      end
    end
  end

  for _, block in ipairs(blocks) do
    if block["@type"] == "ItemList" and type(block.itemListElement) == "table" then
      local first = block.itemListElement[1]
      if type(first) == "table" and type(first.url) == "string" and first.url:find("/weapon/", 1, true) then
        return block
      end
    end
  end
  return nil
end

local function latest_report_url(blocks)
  for _, block in ipairs(blocks) do
    if block["@type"] == "ItemList" and type(block.itemListElement) == "table" then
      local name = tostring(block.name or "")
      if name:find("Weekly Meta Report", 1, true) then
        local first = block.itemListElement[1]
        if type(first) == "table" and type(first.url) == "string" then
          return first.url
        end
      end
    end
  end
  return nil
end

local function meta_description(html)
  local desc = tostring(html or ""):match('<meta name="description" content="([^"]*)"')
  if not desc then
    desc = tostring(html or ""):match('<meta property="og:description" content="([^"]*)"')
  end
  return desc or ""
end

local ATTACHMENT_SUFFIXES = {
  "Rear Grip",
  "Fire Mods",
  "Underbarrel",
  "Magazine",
  "Barrel",
  "Muzzle",
  "Optic",
  "Stock",
  "Laser",
  "Bolt",
  "Comb",
  "Apex",
  "Grip",
}

local DOUBLE_STRIP_SUFFIXES = {
  ["Rear Grip"] = true,
  ["Fire Mods"] = true,
  ["Underbarrel"] = true,
  ["Magazine"] = true,
  ["Barrel"] = true,
  ["Muzzle"] = true,
  ["Optic"] = true,
  ["Stock"] = true,
  ["Laser"] = true,
  ["Grip"] = true,
}

local function strip_attachment_suffix(text)
  for _, suffix in ipairs(ATTACHMENT_SUFFIXES) do
    local tail = " " .. suffix
    if #text - #tail >= 6 and text:sub(-#tail) == tail then
      return text:sub(1, #text - #tail), suffix
    end
  end
  return text, nil
end

local function clean_attachment(value)
  local text = trim(value):gsub("%.$", "")
  local suffix
  text, suffix = strip_attachment_suffix(text)
  if suffix and DOUBLE_STRIP_SUFFIXES[suffix] then
    text = strip_attachment_suffix(text)
  end
  return trim(text)
end

local function split_attachments(raw)
  local attachments = {}
  local text = trim(raw)
  if text == "" then
    return attachments
  end
  for part in (text .. ","):gmatch("(.-),%s*") do
    local cleaned = clean_attachment(part)
    if cleaned ~= "" then
      attachments[#attachments + 1] = cleaned
    end
  end
  return attachments
end

local BUILD_SLOTS = {
  "Optic",
  "Muzzle",
  "Barrel",
  "Underbarrel",
  "Magazine",
  "Stock",
  "Laser",
  "Rear Grip",
  "Fire Mods",
  "Conversion Kit",
  "Apex",
  "Bolt",
  "Comb",
  "Guard",
  "Sling",
  "Rail",
  "Stock Pad",
  "Trigger Action",
  "Ammunition",
  "Lever",
  "Shard",
  "Cylinder",
}

local SLOT_ABBR = {
  Optic = "OPT",
  Muzzle = "MZL",
  Barrel = "BRL",
  Underbarrel = "UND",
  Magazine = "MAG",
  Stock = "STK",
  Laser = "LSR",
  ["Rear Grip"] = "GRP",
  ["Fire Mods"] = "MOD",
  ["Conversion Kit"] = "KIT",
  Apex = "APX",
  Bolt = "BLT",
  Comb = "CMB",
  Guard = "GRD",
  Sling = "SLG",
  Rail = "RAI",
  ["Stock Pad"] = "PAD",
  ["Trigger Action"] = "TRG",
  Ammunition = "AMM",
  Lever = "LEV",
  Shard = "SHD",
  Cylinder = "CYL",
}

local function infer_slot(name)
  local text = tostring(name or ""):lower()
  if text:find("rear grip", 1, true) then
    return "Rear Grip"
  end
  if text:find("foregrip", 1, true) or text:find("underbarrel", 1, true) then
    return "Underbarrel"
  end
  if text:find("suppressor", 1, true) or text:find("muzzle", 1, true) then
    return "Muzzle"
  end
  if text:find("barrel", 1, true) then
    return "Barrel"
  end
  if text:find("optic", 1, true) or text:find("scope", 1, true)
    or text:find("red dot", 1, true) or text:find("holo", 1, true) then
    return "Optic"
  end
  if text:find("magazine", 1, true) or text:find(" mag", 1, true) or text:find("drum", 1, true) then
    return "Magazine"
  end
  if text:find("stock", 1, true) or text:find("control pad", 1, true) then
    return "Stock"
  end
  if text:find("laser", 1, true) then
    return "Laser"
  end
  if text:find("fire mod", 1, true) or text:find("burst mod", 1, true) then
    return "Fire Mods"
  end
  if text:find("conversion", 1, true) then
    return "Conversion Kit"
  end
  if text:find("apex", 1, true) then
    return "Apex"
  end
  if text:find("bolt", 1, true) then
    return "Bolt"
  end
  if text:find("comb", 1, true) then
    return "Comb"
  end
  if text:find("guard", 1, true) then
    return "Guard"
  end
  if text:find("sling", 1, true) then
    return "Sling"
  end
  if text:find("rail", 1, true) then
    return "Rail"
  end
  if text:find("lever", 1, true) then
    return "Lever"
  end
  if text:find("shard", 1, true) then
    return "Shard"
  end
  if text:find("cylinder", 1, true) then
    return "Cylinder"
  end
  if text:find("ammunition", 1, true) or text:find("ammo", 1, true) then
    return "Ammunition"
  end
  if text:find("grip", 1, true) then
    return "Rear Grip"
  end
  if text:find("pad", 1, true) then
    return "Stock"
  end
  return nil
end

local function attachment_slot(attachment)
  if type(attachment) ~= "table" then
    return "Attachment"
  end
  local slot = attachment.slot
  if not slot or slot == "Attachment" then
    slot = infer_slot(attachment.name)
  end
  return slot or "Attachment"
end

local function attachment_abbr(attachment)
  return SLOT_ABBR[attachment_slot(attachment)] or "ATT"
end

local function read_js_string(segment, quote_at)
  local out = {}
  local index = quote_at + 1
  while index <= #segment do
    local char = segment:sub(index, index)
    if char == '"' then
      return table.concat(out)
    end
    if char == "\\" then
      local next_char = segment:sub(index + 1, index + 1)
      if next_char == "n" then
        out[#out + 1] = "\n"
      elseif next_char == "t" then
        out[#out + 1] = "\t"
      else
        out[#out + 1] = next_char
      end
      index = index + 2
    else
      out[#out + 1] = char
      index = index + 1
    end
  end
  return nil
end

local function object_field(segment, name)
  local patterns = {
    '"' .. name .. '":"',
    name .. ':"',
  }
  for _, pattern in ipairs(patterns) do
    local found = segment:find(pattern, 1, true)
    if found then
      local value = read_js_string(segment, found + #pattern - 1)
      if value ~= nil then
        return value
      end
    end
  end
  return nil
end

local function number_field(segment, name)
  return tonumber(segment:match(name .. ":([%d%.]+)"))
end

local function extract_builds(html, game)
  local loadouts_at = tostring(html or ""):find("loadouts:", 1, true)
  if not loadouts_at then
    return {}
  end

  local section = tostring(html):sub(loadouts_at)
  local starts = {}
  local cursor = 1
  while true do
    local found = section:find("{Ammunition:", cursor, true)
    if not found then
      break
    end
    starts[#starts + 1] = found
    cursor = found + 1
  end

  local builds = {}
  for index, start in ipairs(starts) do
    local finish = starts[index + 1] and (starts[index + 1] - 1) or math.min(#section, start + 14000)
    local segment = section:sub(start, finish)
    local code = segment:match('LoadoutCode:"([%w%-]+)"')
    local meta = object_field(segment, "meta")
    if code and meta == game.loadout_meta then
      local attachments = {}
      for _, slot in ipairs(BUILD_SLOTS) do
        local name = object_field(segment, slot)
        if name and name ~= "" then
          attachments[#attachments + 1] = {
            slot = slot,
            name = name,
          }
        end
      end
      builds[#builds + 1] = {
        playstyle = object_field(segment, "Playstyle") or "Build",
        code = code,
        category = object_field(segment, "Category") or "",
        rating = number_field(segment, "Rating") or 0,
        attachments = attachments,
      }
    end
  end
  return builds
end

local function attachment_names(build)
  local names = {}
  for _, attachment in ipairs(build and build.attachments or {}) do
    names[#names + 1] = attachment.name
  end
  return names
end

local function split_simple_list(raw)
  local out = {}
  local text = trim(raw):gsub("%.$", "")
  if text == "" then
    return out
  end
  for part in (text .. ","):gmatch("(.-),%s*") do
    part = trim(part)
    if part ~= "" then
      out[#out + 1] = part
    end
  end
  return out
end

local function extract_weapon(entry, html, game)
  local blocks = parse_jsonld(html)
  local faq = nil
  for _, block in ipairs(blocks) do
    if block["@type"] == "FAQPage" and type(block.mainEntity) == "table" then
      faq = block
      break
    end
  end
  if not faq then
    error("no FAQ data")
  end

  local tier = ""
  local pick_rate = ""
  local faq_build = ""
  local faq_ttk = ""
  local faq_attachments = {}
  local alternatives = {}

  for _, question in ipairs(faq.mainEntity) do
    local name = tostring(question.name or "")
    local answer = ""
    if type(question.acceptedAnswer) == "table" then
      answer = tostring(question.acceptedAnswer.text or "")
    end

    if game.faq_name and name:find("meta in " .. game.faq_name, 1, true) then
      tier = answer:match("ranked as (.-) in " .. game.faq_name .. "%.") or tier
      pick_rate = answer:match("pick rate is ([%d%.]+%%)") or pick_rate
    elseif game.faq_name and name:find("best", 1, true) and name:find("Loadout", 1, true) then
      faq_build = answer:match("Loadout is the (.-) build%.") or faq_build
      local raw_attachments = answer:match("For the attachment you want to use: (.*)") or ""
      faq_attachments = split_attachments(raw_attachments)
    elseif game.faq_name and (name:find("TTK", 1, true) or name:find("Time To Kill", 1, true)) then
      local parts = {}
      for value in answer:gmatch("(%d+ms)") do
        parts[#parts + 1] = value
      end
      faq_ttk = table.concat(parts, " / ")
    elseif game.faq_name and name:find("alternatives", 1, true) then
      local raw_alternatives = answer:match("best alternatives to .- are (.+)%.")
      if raw_alternatives then
        alternatives = split_simple_list(raw_alternatives)
      end
    end
  end

  local description = meta_description(html)
  local faq_code = description:match("Build code ([%w%-]+)")
  local builds = extract_builds(html, game)
  local primary = nil
  if faq_code then
    for _, candidate in ipairs(builds) do
      if candidate.code == faq_code then
        primary = candidate
        break
      end
    end
  end
  if not primary and #builds > 0 then
    primary = builds[1]
  end

  if not primary then
    if not game.faq_name then
      error("no " .. game.label .. " loadout object")
    end
    primary = {
      playstyle = faq_build ~= "" and faq_build or "Build",
      code = faq_code or "",
      category = "",
      rating = 0,
      attachments = {},
    }
    for _, name in ipairs(faq_attachments) do
      primary.attachments[#primary.attachments + 1] = {
        slot = infer_slot(name) or "Attachment",
        name = name,
      }
    end
    builds = { primary }
  elseif #primary.attachments == 0 and #faq_attachments > 0 then
    for _, name in ipairs(faq_attachments) do
      primary.attachments[#primary.attachments + 1] = {
        slot = infer_slot(name) or "Attachment",
        name = name,
      }
    end
  end

  return {
    game = game.key,
    game_label = game.label,
    rank = tonumber(entry.position) or 0,
    name = tostring(entry.name or "Unknown"),
    url = tostring(entry.url or ""),
    tier = trim(tier),
    pick_rate = trim(pick_rate),
    build = trim(primary.playstyle),
    attachments = attachment_names(primary),
    code = tostring(primary.code or ""),
    ttk = trim(faq_ttk),
    category = trim(primary.category),
    rating = tonumber(primary.rating) or 0,
    alternatives = alternatives,
    builds = builds,
  }
end

local function http_get(url)
  if type(url) ~= "string" or url == "" then
    error("empty URL")
  end
  local response = rb.net.get(url, {
    headers = {
      ["User-Agent"] = USER_AGENT,
      ["Accept"] = "text/html,application/xhtml+xml",
      ["Accept-Language"] = "en-GB,en;q=0.9",
    },
    timeout = HTTP_TIMEOUT_MS,
  })
  if type(response) ~= "table" or tonumber(response.status) ~= 200 then
    error("HTTP " .. tostring(response and response.status or "?") .. " for " .. url)
  end
  return tostring(response.body or "")
end

local function load_cache(game)
  if not rb.file.exists(game.cache) then
    return false
  end
  local ok, data = pcall(rb.file.read_json, game.cache)
  if not ok or type(data) ~= "table" or type(data.items) ~= "table" then
    return false
  end

  local items = {}
  for _, item in ipairs(data.items) do
    if type(item) == "table" and type(item.name) == "string" and type(item.code) == "string" then
      if type(item.attachments) ~= "table" then
        item.attachments = {}
      end
      if type(item.alternatives) ~= "table" then
        item.alternatives = {}
      end
      if type(item.builds) ~= "table" or #item.builds == 0 then
        local fallback = {
          playstyle = tostring(item.build or "Build"),
          code = tostring(item.code or ""),
          category = tostring(item.category or ""),
          rating = tonumber(item.rating) or 0,
          attachments = {},
        }
        for _, name in ipairs(item.attachments) do
          fallback.attachments[#fallback.attachments + 1] = {
            slot = infer_slot(name) or "Attachment",
            name = tostring(name),
          }
        end
        item.builds = { fallback }
      end
      items[#items + 1] = item
    end
  end
  if #items == 0 then
    return false
  end

  table.sort(items, function(a, b)
    return (tonumber(a.rank) or 99) < (tonumber(b.rank) or 99)
  end)
  state.items = items
  state.week = tostring(data.week or "")
  state.report_url = tostring(data.report_url or "")
  state.updated_at = tonumber(data.updated_at) or 0
  state.previous_week = tostring(data.previous_week or "")
  state.previous_items = type(data.previous_items) == "table" and data.previous_items or {}
  state.history = type(data.history) == "table" and data.history or {}
  state.last_build = nil
  return true
end

local function compact_snapshot(items)
  local out = {}
  for _, weapon in ipairs(items or {}) do
    out[#out + 1] = {
      name = weapon.name,
      url = weapon.url,
      rank = weapon.rank,
      tier = weapon.tier,
      pick_rate = weapon.pick_rate,
      build = weapon.build,
      code = weapon.code,
    }
  end
  return out
end

local function save_cache(game, items, week, report_url)
  local previous_week = ""
  local previous_items = {}
  local history = {}

  if rb.file.exists(game.cache) then
    local ok, old = pcall(rb.file.read_json, game.cache)
    if ok and type(old) == "table" and type(old.items) == "table" then
      previous_week = tostring(old.week or "")
      previous_items = compact_snapshot(old.items)
      if type(old.history) == "table" then
        history = old.history
      end
      if previous_week == week then
        previous_week = tostring(old.previous_week or "")
        previous_items = type(old.previous_items) == "table" and old.previous_items or {}
      elseif previous_week ~= "" then
        local already = false
        for _, snapshot in ipairs(history) do
          if snapshot.week == previous_week then
            already = true
            break
          end
        end
        if not already then
          table.insert(history, 1, {
            week = previous_week,
            items = compact_snapshot(old.items),
          })
        end
        while #history > 8 do
          table.remove(history)
        end
      end
    end
  end

  local payload = {
    version = CACHE_VERSION,
    game = game.key,
    game_label = game.label,
    source = SOURCE,
    report_url = report_url,
    week = week,
    updated_at = os.time(),
    previous_week = previous_week,
    previous_items = previous_items,
    history = history,
    items = items,
  }
  local ok, err = pcall(rb.file.write_json, game.cache, payload)
  if not ok then
    rb.log.warn("CODMunity meta cache write failed: " .. tostring(err))
  end
  return previous_week, previous_items, history
end

local function load_user_state()
  if not rb.file.exists(USER_FILE) then
    return
  end
  local ok, data = pcall(rb.file.read_json, USER_FILE)
  if not ok or type(data) ~= "table" then
    return
  end
  local fields = {
    "favorites",
    "recent",
    "hud",
    "pins",
    "favorite_builds",
    "tags",
    "plan",
    "clipboard_history",
    "search_recent",
  }
  for _, key in ipairs(fields) do
    if type(data[key]) == "table" then
      state.user[key] = data[key]
    end
  end
end

local function save_user_state()
  local ok, err = pcall(rb.file.write_json, USER_FILE, state.user)
  if not ok then
    rb.log.warn("CODMunity user state write failed: " .. tostring(err))
  end
end

local function user_list(container, game_key)
  local list = container[game_key]
  if type(list) ~= "table" then
    list = {}
    container[game_key] = list
  end
  return list
end

local function favorite_list(game_key)
  return user_list(state.user.favorites, game_key)
end

local function recent_list(game_key)
  return user_list(state.user.recent, game_key)
end

local function find_weapon_index(list, url)
  for index, weapon in ipairs(list) do
    if weapon.url == url then
      return index
    end
  end
  return nil
end

local function is_favorite(weapon)
  if not weapon then
    return false
  end
  return find_weapon_index(favorite_list(state.game_key), weapon.url) ~= nil
end

local function toggle_favorite(weapon)
  if not weapon then
    notify("Select a weapon first", { title = "Favorite", duration = 2500 })
    return
  end
  local list = favorite_list(state.game_key)
  local index = find_weapon_index(list, weapon.url)
  if index then
    table.remove(list, index)
    notify("Removed from favorites", { title = weapon.name, duration = 2500 })
  else
    table.insert(list, 1, weapon)
    notify("Added to favorites", { title = weapon.name, duration = 2500 })
  end
  save_user_state()
end

local function add_recent(weapon)
  if not weapon then
    return
  end
  local list = recent_list(state.game_key)
  local index = find_weapon_index(list, weapon.url)
  if index then
    table.remove(list, index)
  end
  table.insert(list, 1, weapon)
  while #list > 8 do
    table.remove(list)
  end
  save_user_state()
end

local function hud_enabled(game_key)
  return state.user.hud[game_key] == true
end

local function set_hud_enabled(game_key, enabled)
  state.user.hud[game_key] = enabled and true or false
  save_user_state()
end

notify = function(text, options)
  options = options or {}
  options.anchor = options.anchor or "bottom-right"
  options.offset_x = options.offset_x or 24
  options.offset_y = options.offset_y or 24
  rb.overlay.toast(text, options)
end

local function set_status(text)
  if state.status then
    state.status:set(text)
  else
    state.status = rb.overlay.status(text, {
      anchor = "bottom-left",
      offset_x = 24,
      offset_y = 24,
    })
  end
end

local function clear_status()
  if state.status then
    pcall(function()
      state.status:remove()
    end)
    state.status = nil
  end
end

local function pin_map(game_key)
  local map = state.user.pins[game_key]
  if type(map) ~= "table" then
    map = {}
    state.user.pins[game_key] = map
  end
  return map
end

local function pinned_build(weapon)
  if not weapon then
    return nil
  end
  local code = pin_map(state.game_key)[weapon.url]
  if not code or code == "" then
    return nil
  end
  for _, build in ipairs(weapon.builds or {}) do
    if build.code == code then
      return build
    end
  end
  return nil
end

local function set_pinned_build(weapon, build)
  if not weapon or not build or not build.code or build.code == "" then
    return
  end
  pin_map(state.game_key)[weapon.url] = build.code
  save_user_state()
end

local function clear_pinned_build(weapon)
  if not weapon then
    return
  end
  pin_map(state.game_key)[weapon.url] = nil
  save_user_state()
end

local function favorite_builds_list(game_key)
  local list = state.user.favorite_builds[game_key]
  if type(list) ~= "table" then
    list = {}
    state.user.favorite_builds[game_key] = list
  end
  return list
end

local function find_favorite_build(weapon, build)
  if not weapon or not build then
    return nil
  end
  local list = favorite_builds_list(state.game_key)
  for index, entry in ipairs(list) do
    if entry.weapon and entry.build
      and entry.weapon.url == weapon.url
      and entry.build.code == build.code then
      return index
    end
  end
  return nil
end

local function toggle_favorite_build(weapon, build)
  if not weapon or not build then
    return false
  end
  local list = favorite_builds_list(state.game_key)
  local index = find_favorite_build(weapon, build)
  if index then
    table.remove(list, index)
    save_user_state()
    return false
  end
  table.insert(list, 1, { weapon = weapon, build = build })
  save_user_state()
  return true
end

local function tag_map(game_key)
  local map = state.user.tags[game_key]
  if type(map) ~= "table" then
    map = {}
    state.user.tags[game_key] = map
  end
  return map
end

local function tag_list(weapon)
  if not weapon then
    return {}
  end
  local list = tag_map(state.game_key)[weapon.url]
  if type(list) ~= "table" then
    list = {}
    tag_map(state.game_key)[weapon.url] = list
  end
  return list
end

local function has_tag(weapon, tag)
  for _, value in ipairs(tag_list(weapon)) do
    if value == tag then
      return true
    end
  end
  return false
end

local function toggle_tag(weapon, tag)
  if not weapon then
    return false
  end
  local list = tag_list(weapon)
  for index, value in ipairs(list) do
    if value == tag then
      table.remove(list, index)
      save_user_state()
      return false
    end
  end
  list[#list + 1] = tag
  save_user_state()
  return true
end

local function plan_table(game_key)
  local plan = state.user.plan[game_key]
  if type(plan) ~= "table" then
    plan = {}
    state.user.plan[game_key] = plan
  end
  return plan
end

local function set_plan_slot(slot, weapon, build)
  if not weapon or not build then
    return
  end
  plan_table(state.game_key)[slot] = {
    weapon = weapon,
    build = build,
  }
  save_user_state()
end

local function clear_plan()
  state.user.plan[state.game_key] = {}
  save_user_state()
end

local function record_clipboard(text, label)
  if not text or text == "" then
    return
  end
  local list = state.user.clipboard_history
  for index = #list, 1, -1 do
    if list[index].text == text then
      table.remove(list, index)
    end
  end
  table.insert(list, 1, {
    text = text,
    label = label or "Copied",
    at = os.time(),
  })
  while #list > 6 do
    table.remove(list)
  end
  save_user_state()
end

local function search_recent_list()
  local list = state.user.search_recent
  if type(list) ~= "table" then
    list = {}
    state.user.search_recent = list
  end
  return list
end

local function add_search_recent(query)
  query = trim(query)
  if query == "" then
    return
  end
  local list = search_recent_list()
  for index = #list, 1, -1 do
    if list[index] == query then
      table.remove(list, index)
    end
  end
  table.insert(list, 1, query)
  while #list > 6 do
    table.remove(list)
  end
  save_user_state()
end

local CATEGORY_KEYS = {
  ar = { "Assault Rifle" },
  smg = { "Submachine Gun", "SMG" },
  sniper = { "Sniper Rifle", "Sniper" },
  lmg = { "Light Machine Gun", "LMG" },
  shotgun = { "Shotgun" },
  pistol = { "Pistol" },
}

local function primary_build(weapon)
  if not weapon then
    return nil
  end
  local builds = weapon.builds or {}
  for _, build in ipairs(builds) do
    if build.code == weapon.code then
      return build
    end
  end
  return builds[1]
end

local function current_build(weapon)
  if not weapon then
    return nil
  end
  if state.last_build then
    for _, build in ipairs(weapon.builds or {}) do
      if build.code == state.last_build.code then
        return build
      end
    end
  end
  local pinned = pinned_build(weapon)
  if pinned then
    return pinned
  end
  return primary_build(weapon)
end

local function pick_value(weapon)
  local text = tostring(weapon and weapon.pick_rate or ""):gsub("%%", "")
  return tonumber(text) or 0
end

local function category_matches(weapon)
  local filter = tostring(cfg.category or "all")
  if filter == "all" then
    return true
  end
  local category = tostring(weapon and weapon.category or ""):lower()
  for _, name in ipairs(CATEGORY_KEYS[filter] or {}) do
    if category:find(name:lower(), 1, true) then
      return true
    end
  end
  return false
end

local function get_view_items()
  local view = tostring(cfg.view or "meta")
  local base
  if view == "favorites" then
    base = favorite_list(state.game_key)
  elseif view == "recent" then
    base = recent_list(state.game_key)
  else
    base = state.items
  end

  local items = {}
  for _, weapon in ipairs(base) do
    if type(weapon) == "table" and category_matches(weapon) then
      items[#items + 1] = weapon
    end
  end

  local sort = tostring(cfg.sort or "rank")
  if sort == "pick" then
    table.sort(items, function(a, b)
      return pick_value(a) > pick_value(b)
    end)
  elseif sort == "name" then
    table.sort(items, function(a, b)
      return tostring(a.name or "") < tostring(b.name or "")
    end)
  else
    table.sort(items, function(a, b)
      return (tonumber(a.rank) or 99) < (tonumber(b.rank) or 99)
    end)
  end
  return items
end

local function updated_label()
  if state.updated_at <= 0 then
    return "not updated"
  end
  return os.date("%d %b %H:%M", state.updated_at)
end

local function weapon_hint(weapon)
  local parts = {}
  if weapon.tier and weapon.tier ~= "" then
    parts[#parts + 1] = weapon.tier
  end
  if weapon.pick_rate and weapon.pick_rate ~= "" then
    parts[#parts + 1] = weapon.pick_rate
  end
  if #parts == 0 then
    local build = primary_build(weapon)
    if build then
      parts[#parts + 1] = tostring(build.playstyle or "Build")
    end
    if tonumber(weapon.rating or 0) > 0 then
      parts[#parts + 1] = string.format("%.2f", weapon.rating)
    end
  end
  return table.concat(parts, "  ")
end

local function remove_surface(handle)
  if handle then
    pcall(function()
      handle:remove()
    end)
  end
end

local function copy_code(weapon, build)
  build = build or current_build(weapon)
  local code = build and build.code or (weapon and weapon.code)
  if not weapon or not code or code == "" then
    notify("No build code on this page", {
      title = weapon and weapon.name or "CODMunity",
      duration = 3500,
    })
    return false
  end

  local ok, err = pcall(rb.clipboard.set, code)
  if not ok then
    rb.log.warn("CODMunity code copy failed: " .. tostring(err))
    notify("Clipboard write failed", { title = "Copy failed", duration = 4000 })
    return false
  end

  state.last = weapon
  state.last_build = build
  state.last_copied_code = code
  add_recent(weapon)
  record_clipboard(code, "Build code")
  notify(code, {
    title = "Copied " .. weapon.name,
    duration = 2500,
  })
  return true
end

local function show_detail(weapon)
  apply_theme()
  remove_surface(state.detail)
  state.detail = nil
  if state.compare_handler then
    pcall(function()
      state.compare_handler:stop()
    end)
    state.compare_handler = nil
  end
  remove_surface(state.compare_surface)
  state.compare_surface = nil
  remove_surface(state.changes_surface)
  state.changes_surface = nil
  if not weapon then
    return
  end

  local build = current_build(weapon) or primary_build(weapon)
  local width = 470
  local pad = 16
  local y = 14
  local ops = {
    { kind = "rect", x = 0, y = 0, w = width, h = 400, fill = THEME.surface,
      border = THEME.border, border_width = 1, radius = 10, shadow = 0.45 },
  }

  local function add_text(size, text, color, bold)
    ops[#ops + 1] = {
      kind = "text",
      x = pad,
      y = y,
      size = size,
      text = tostring(text or ""),
      color = color,
      bold = bold or false,
    }
    y = y + math.max(size + 5, 18)
  end

  local favorite_mark = is_favorite(weapon) and " *" or ""
  local rank = tonumber(weapon.rank) or 0
  local rank_text = rank > 0 and ("#" .. rank) or "#--"
  add_text(20, string.format("%s  %s%s", rank_text, fit(weapon.name, 30), favorite_mark), THEME.accent, true)

  local summary = {}
  if weapon.tier and weapon.tier ~= "" then
    summary[#summary + 1] = weapon.tier
  end
  if weapon.pick_rate and weapon.pick_rate ~= "" then
    summary[#summary + 1] = weapon.pick_rate
  end
  if weapon.category and weapon.category ~= "" then
    summary[#summary + 1] = weapon.category
  end
  add_text(12, table.concat(summary, "  |  "), THEME.dim, false)

  local build_label = tostring((build and build.playstyle) or weapon.build or "Build") .. " build"
  if tonumber((build and build.rating) or weapon.rating or 0) > 0 then
    build_label = build_label .. string.format("  |  %.2f", tonumber((build and build.rating) or weapon.rating))
  end
  add_text(14, build_label, THEME.text, true)
  local pinned = pinned_build(weapon)
  if pinned and build and pinned.code == build.code then
    add_text(10, "PINNED BUILD", THEME.positive, true)
  end
  local tags = tag_list(weapon)
  if #tags > 0 then
    add_text(10, "Tags: " .. table.concat(tags, ", "), THEME.dim, false)
  end

  ops[#ops + 1] = { kind = "rect", x = pad, y = y, w = width - pad * 2, h = 1, fill = THEME.border }
  y = y + 12

  local attachments = build and build.attachments or nil
  if type(attachments) ~= "table" or #attachments == 0 then
    attachments = {}
    for _, name in ipairs(weapon.attachments or {}) do
      attachments[#attachments + 1] = { slot = infer_slot(name) or "Attachment", name = tostring(name) }
    end
  end
  for _, attachment in ipairs(attachments) do
    add_text(13, string.format("[%s] %s", attachment_abbr(attachment), fit(attachment.name, 44)), THEME.text, false)
  end
  local issues = build_issues(build)
  if #issues > 0 then
    add_text(10, "Check: " .. fit(table.concat(issues, ", "), 54), THEME.warning, false)
  end

  y = y + 6
  local code = build and build.code or weapon.code or ""
  add_text(10, "BUILD CODE", THEME.faint, false)
  add_text(17, code ~= "" and code or "not listed", THEME.accent, true)

  if state.last_copied_code and state.last_copied_code == code then
    add_text(11, "Copied to clipboard", THEME.positive, false)
  end
  if weapon.ttk and weapon.ttk ~= "" then
    add_text(11, "TTK  " .. weapon.ttk, THEME.dim, false)
  end
  local history = rank_history_text(weapon)
  if history ~= "" then
    add_text(11, "Rank: " .. history, THEME.dim, false)
  end
  if type(weapon.alternatives) == "table" and #weapon.alternatives > 0 then
    add_text(11, "Alt: " .. fit(table.concat(weapon.alternatives, ", "), 58), THEME.dim, false)
  end

  local game_label = weapon.game_label or state.game.label
  add_text(10, "F6 builds  |  F4 compare  |  F5 favorite  |  F11 extras", THEME.faint, false)
  add_text(10, "F9 copy  |  F10 full  |  F8 close  |  " .. game_label .. " / " .. SOURCE .. " / " .. updated_label(), THEME.faint, false)

  local height = y + pad
  ops[1].h = height

  local ok, err = pcall(function()
    state.detail = rb.overlay.surface({
      anchor = "top-right",
      width = width,
      height = height,
      offset_x = 24,
      offset_y = 24,
      opacity = 0.98,
      ops = ops,
    })
  end)
  if not ok then
    rb.log.warn("CODMunity detail surface failed: " .. tostring(err))
  end
end

local function select_weapon(weapon, build)
  if not weapon then
    return
  end
  state.last = weapon
  state.last_build = build or current_build(weapon) or primary_build(weapon)
  if cfg.copy_on_select then
    copy_code(weapon, state.last_build)
  else
    add_recent(weapon)
  end
  show_detail(weapon)
end

local function open_build_menu()
  if sync_game then
    sync_game(false)
  end
  if state.search then
    close_search()
  end
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end

  local weapon = state.last or state.items[1]
  if not weapon then
    notify("Open the meta menu first", { title = "Builds", duration = 3000 })
    return
  end
  local builds = weapon.builds or {}
  if #builds <= 1 then
    notify("This weapon has one build in the current cache", { title = weapon.name, duration = 3000 })
    return
  end

  local items = {}
  local mapping = {}
  local pin = pinned_build(weapon)
  for _, build in ipairs(builds) do
    local hint_parts = {}
    if build.code and build.code ~= "" then
      hint_parts[#hint_parts + 1] = build.code
    end
    if tonumber(build.rating or 0) > 0 then
      hint_parts[#hint_parts + 1] = string.format("%.2f", build.rating)
    end
    local marks = {}
    if pin and pin.code == build.code then
      marks[#marks + 1] = "PIN"
    end
    if find_favorite_build(weapon, build) then
      marks[#marks + 1] = "FAV"
    end
    if #build_issues(build) > 0 then
      marks[#marks + 1] = "!"
    end
    local prefix = #marks > 0 and ("[" .. table.concat(marks, "/") .. "] ") or ""
    items[#items + 1] = {
      label = prefix .. fit(build.playstyle or "Build", 20),
      hint = table.concat(hint_parts, "  "),
    }
    mapping[#mapping + 1] = build
  end

  local menu, why = rb.overlay.menu(items, {
    title = weapon.name .. " builds",
    subtitle = "Enter copies code and shows attachments",
    width = 620,
    theme = "tokyo-night",
    on_select = function(index)
      local build = mapping[index]
      if build then
        state.last = weapon
        state.last_build = build
        if cfg.copy_on_select then
          copy_code(weapon, build)
        else
          add_recent(weapon)
        end
        show_detail(weapon)
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    rb.log.warn("CODMunity build menu: " .. tostring(why))
    notify("Could not open builds: " .. tostring(why), { title = "Builds", duration = 3500 })
  end
end

local function full_loadout_text(weapon, build_override)
  local build = build_override or current_build(weapon) or primary_build(weapon)
  local lines = {}
  lines[#lines + 1] = string.format("%s - %s #%d", weapon.name or "Weapon", state.game.label, tonumber(weapon.rank) or 0)
  local summary = {}
  if weapon.tier and weapon.tier ~= "" then
    summary[#summary + 1] = weapon.tier
  end
  if weapon.pick_rate and weapon.pick_rate ~= "" then
    summary[#summary + 1] = weapon.pick_rate
  end
  if weapon.category and weapon.category ~= "" then
    summary[#summary + 1] = weapon.category
  end
  if #summary > 0 then
    lines[#lines + 1] = table.concat(summary, " | ")
  end
  lines[#lines + 1] = tostring((build and build.playstyle) or weapon.build or "Build") .. " build"
  local attachments = build and build.attachments or {}
  if #attachments == 0 then
    for _, name in ipairs(weapon.attachments or {}) do
      attachments[#attachments + 1] = { slot = infer_slot(name) or "Attachment", name = tostring(name) }
    end
  end
  for _, attachment in ipairs(attachments) do
    lines[#lines + 1] = string.format("%s: %s", attachment_slot(attachment), attachment.name or "")
  end
  local code = build and build.code or weapon.code or ""
  lines[#lines + 1] = "Code: " .. (code ~= "" and code or "not listed")
  lines[#lines + 1] = "Week of " .. (state.week ~= "" and state.week or "current")
  return table.concat(lines, "\n")
end

local function copy_text_async(text, title, message)
  rb.run(function()
    local ok, err = pcall(rb.clipboard.set, text)
    if not ok then
      rb.sleep(120)
      ok, err = pcall(rb.clipboard.set, text)
    end
    if not ok then
      rb.log.warn("CODMunity clipboard copy failed: " .. tostring(err))
      notify("Clipboard write failed", { title = "Copy failed", duration = 4000 })
      return
    end
    record_clipboard(text, title or "Copied")
    notify(message or "Copied", { title = title or SOURCE, duration = 2500 })
  end)
end

local function copy_full_loadout()
  if sync_game then
    sync_game(false)
  end
  if state.search then
    close_search()
  end
  local weapon = state.last or state.items[1]
  if not weapon then
    notify("Open the meta menu first", { title = "Full loadout", duration = 3000 })
    return
  end
  local text = full_loadout_text(weapon)
  add_recent(weapon)
  copy_text_async(text, weapon.name, "Full loadout copied")
end

local function parse_ttk(weapon)
  local values = {}
  for value in tostring(weapon and weapon.ttk or ""):gmatch("(%d+)ms") do
    values[#values + 1] = tonumber(value)
  end
  return values
end

local function remove_compare()
  if state.compare_handler then
    pcall(function()
      state.compare_handler:stop()
    end)
    state.compare_handler = nil
  end
  remove_surface(state.compare_surface)
  state.compare_surface = nil
  state.compare_a = nil
  state.compare_b = nil
  state.compare_a_build = nil
  state.compare_b_build = nil
end

local function install_compare_handler()
  if not state.compare_surface or state.compare_handler then
    return
  end
  state.compare_handler = rb.on("down", function(key)
    if key == "Escape" then
      rb.after(0, remove_compare)
      return false
    end
    return true
  end)
end

local function show_compare(a, b, build_a, build_b)
  apply_theme()
  remove_compare()
  if not a or not b then
    notify("Need two weapons to compare", { title = "Compare", duration = 3000 })
    return
  end
  build_a = build_a or current_build(a) or primary_build(a)
  build_b = build_b or current_build(b) or primary_build(b)
  state.compare_a = a
  state.compare_b = b
  state.compare_a_build = build_a
  state.compare_b_build = build_b

  local width = 780
  local pad = 18
  local colw = 356
  local gap = 24
  local ops = {
    { kind = "rect", x = 0, y = 0, w = width, h = 470, fill = THEME.surface,
      border = THEME.border, border_width = 1, radius = 10, shadow = 0.45 },
  }
  local y = 14

  local function add_text(x, yy, size, text, color, bold)
    ops[#ops + 1] = {
      kind = "text",
      x = x,
      y = yy,
      size = size,
      text = tostring(text or ""),
      color = color,
      bold = bold or false,
    }
  end

  add_text(pad, y, 18, "COMPARE", THEME.accent, true)
  y = y + 24
  add_text(pad, y, 10, state.game.label .. "  |  Week of " .. (state.week ~= "" and state.week or "current") .. "  |  " .. updated_label(), THEME.faint, false)
  y = y + 24

  local function draw_column(x, weapon, build, marker)
    local yy = y
    local rank = tonumber(weapon.rank) or 0
    local rank_text = rank > 0 and ("#" .. rank) or "#--"
    add_text(x, yy, 17, marker .. "  " .. rank_text .. "  " .. fit(weapon.name, 24), THEME.accent, true)
    yy = yy + 24
    local summary = {}
    if weapon.tier and weapon.tier ~= "" then
      summary[#summary + 1] = weapon.tier
    end
    if weapon.pick_rate and weapon.pick_rate ~= "" then
      summary[#summary + 1] = weapon.pick_rate
    end
    if #summary == 0 and tonumber(weapon.rating or 0) > 0 then
      summary[#summary + 1] = string.format("Rating %.2f", weapon.rating)
    end
    add_text(x, yy, 11, table.concat(summary, "  |  "), THEME.dim, false)
    yy = yy + 18
    add_text(x, yy, 12, tostring((build and build.playstyle) or weapon.build or "Build") .. " build", THEME.text, true)
    yy = yy + 18
    local code = build and build.code or weapon.code or ""
    add_text(x, yy, 10, "Code  " .. (code ~= "" and code or "not listed"), THEME.accent, false)
    yy = yy + 18
    local attachments = build and build.attachments or {}
    for index = 1, math.min(6, #attachments) do
      local attachment = attachments[index]
      add_text(x, yy, 10, string.format("%s  %s", attachment_abbr(attachment), fit(attachment.name, 38)), THEME.dim, false)
      yy = yy + 14
    end
    return yy
  end

  local left_bottom = draw_column(pad, a, build_a, "A")
  local right_bottom = draw_column(pad + colw + gap, b, build_b, "B")
  y = math.max(left_bottom, right_bottom) + 8
  ops[#ops + 1] = { kind = "rect", x = pad, y = y, w = width - pad * 2, h = 1, fill = THEME.border }
  y = y + 14

  add_text(pad, y, 12, "TTK by range", THEME.text, true)
  y = y + 20
  local av = parse_ttk(a)
  local bv = parse_ttk(b)
  local labels = { "10m", "40m", "80m", "120m" }
  local max_value = 0
  for index = 1, 4 do
    max_value = math.max(max_value, av[index] or 0, bv[index] or 0)
  end

  if max_value == 0 then
    add_text(pad, y, 11, "No TTK data for this mode", THEME.dim, false)
    y = y + 22
  else
    local bar_x = pad + 48
    local bar_max = width - pad * 2 - 180
    for index = 1, 4 do
      add_text(pad, y - 2, 10, labels[index], THEME.faint, false)
      local aw = av[index] and math.max(2, av[index] / max_value * bar_max) or 0
      local bw = bv[index] and math.max(2, bv[index] / max_value * bar_max) or 0
      ops[#ops + 1] = { kind = "rect", x = bar_x, y = y, w = aw, h = 8, fill = THEME.accent, radius = 3 }
      add_text(bar_x + bar_max + 8, y - 3, 10, av[index] and (av[index] .. "ms") or "-", THEME.dim, false)
      y = y + 12
      ops[#ops + 1] = { kind = "rect", x = bar_x, y = y, w = bw, h = 8, fill = THEME.positive, radius = 3 }
      add_text(bar_x + bar_max + 8, y - 3, 10, bv[index] and (bv[index] .. "ms") or "-", THEME.dim, false)
      y = y + 18
    end
  end

  add_text(pad, y + 2, 10, "A " .. a.name .. "   |   B " .. b.name, THEME.faint, false)
  add_text(pad, y + 16, 10, "F9 copy A   |   F10 copy B   |   F6 actions / switch builds", THEME.faint, false)
  add_text(pad, y + 30, 10, "F4 change opponent   |   Esc close   |   F8 meta menu", THEME.faint, false)
  y = y + 48
  ops[1].h = y + pad

  local ok, err = pcall(function()
    state.compare_surface = rb.overlay.surface({
      anchor = "center",
      width = width,
      height = ops[1].h,
      opacity = 0.98,
      ops = ops,
    })
  end)
  if not ok then
    rb.log.warn("CODMunity compare surface failed: " .. tostring(err))
  else
    install_compare_handler()
  end
end

local function compare_candidates(anchor)
  local seen = {}
  local candidates = {}
  local function add(weapon)
    if not weapon or type(weapon) ~= "table" then
      return
    end
    local key = weapon.url or weapon.name
    if not key or (anchor and key == (anchor.url or anchor.name)) or seen[key] then
      return
    end
    seen[key] = true
    candidates[#candidates + 1] = weapon
  end

  for _, weapon in ipairs(get_view_items()) do
    add(weapon)
  end
  for _, weapon in ipairs(state.items) do
    add(weapon)
  end
  for _, weapon in pairs(state.dynamic) do
    add(weapon)
  end
  for _, weapon in ipairs(recent_list(state.game_key)) do
    add(weapon)
  end
  for _, weapon in ipairs(favorite_list(state.game_key)) do
    add(weapon)
  end
  while #candidates > 24 do
    table.remove(candidates)
  end
  return candidates
end

local function toggle_compare()
  if state.compare_surface then
    remove_compare()
  end
  if sync_game then
    sync_game(false)
  end
  if state.search then
    close_search()
  end
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end

  local anchor = state.last or state.items[1]
  if not anchor then
    notify("Open the meta menu first", { title = "Compare", duration = 3000 })
    return
  end
  local candidates = compare_candidates(anchor)
  if #candidates == 0 then
    notify("No other weapons loaded. Search for one first.", { title = "Compare", duration = 3500 })
    return
  end

  remove_surface(state.detail)
  state.detail = nil
  local items = {}
  local mapping = {}
  for _, weapon in ipairs(candidates) do
    local rank = tonumber(weapon.rank) or 0
    local rank_text = rank > 0 and string.format("%2d", rank) or " -"
    items[#items + 1] = {
      label = string.format("%s  %s", rank_text, fit(weapon.name, 26)),
      hint = weapon_hint(weapon),
    }
    mapping[#mapping + 1] = weapon
  end

  local menu, why = rb.overlay.menu(items, {
    title = "Compare " .. anchor.name,
    subtitle = "Choose the opponent  |  Enter compares  |  Esc cancels",
    width = 680,
    theme = "tokyo-night",
    on_select = function(index)
      local opponent = mapping[index]
      if opponent then
        show_compare(anchor, opponent)
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    rb.log.warn("CODMunity compare menu: " .. tostring(why))
    notify("Could not open compare picker: " .. tostring(why), { title = "Compare", duration = 3500 })
  end
end

local function compare_side(side)
  if side == "A" then
    return state.compare_a, state.compare_a_build
  end
  return state.compare_b, state.compare_b_build
end

local function copy_compare_side(side, full)
  local weapon, build = compare_side(side)
  if not weapon then
    notify("Comparison is not open", { title = "Copy " .. side, duration = 2500 })
    return
  end
  build = build or primary_build(weapon)
  if full then
    copy_text_async(full_loadout_text(weapon, build), weapon.name, "Full loadout copied")
    return
  end
  local code = build and build.code or weapon.code or ""
  if code == "" then
    notify("No build code on this side", { title = "Copy " .. side, duration = 3000 })
    return
  end
  copy_text_async(code, weapon.name, "Build code copied")
end

local function open_compare_build_menu(side)
  local weapon, active_build = compare_side(side)
  if not weapon then
    notify("Comparison is not open", { title = "Compare builds", duration = 2500 })
    return
  end
  local builds = weapon.builds or {}
  if #builds <= 1 then
    notify(side .. " has only one build", { title = weapon.name, duration = 3000 })
    return
  end
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end
  if state.compare_handler then
    pcall(function()
      state.compare_handler:stop()
    end)
    state.compare_handler = nil
  end

  local items = {}
  local mapping = {}
  for _, build in ipairs(builds) do
    local marker = active_build and build.code == active_build.code and "TOP  " or ""
    items[#items + 1] = {
      label = marker .. fit(build.playstyle or "Build", 24),
      hint = string.format("%s  %.2f", build.code or "", tonumber(build.rating or 0) or 0),
    }
    mapping[#mapping + 1] = build
  end

  local menu, why = rb.overlay.menu(items, {
    title = side .. " build  |  " .. weapon.name,
    subtitle = "Enter switches this side of the comparison",
    width = 680,
    theme = "tokyo-night",
    on_select = function(index)
      local build = mapping[index]
      if not build then
        return
      end
      if side == "A" then
        state.compare_a_build = build
        if state.last and state.last.url == weapon.url then
          state.last_build = build
        end
      else
        state.compare_b_build = build
      end
      show_compare(state.compare_a, state.compare_b, state.compare_a_build, state.compare_b_build)
    end,
    on_close = function()
      state.menu = nil
      install_compare_handler()
    end,
  })
  if menu then
    state.menu = menu
  else
    rb.log.warn("CODMunity compare build menu: " .. tostring(why))
    notify("Could not open builds: " .. tostring(why), { title = "Compare builds", duration = 3500 })
    install_compare_handler()
  end
end

local function open_compare_actions()
  if not state.compare_surface or not state.compare_a or not state.compare_b then
    notify("Comparison is not open", { title = "Compare actions", duration = 2500 })
    return
  end
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end
  if state.compare_handler then
    pcall(function()
      state.compare_handler:stop()
    end)
    state.compare_handler = nil
  end

  local a, b = state.compare_a, state.compare_b
  local build_a, build_b = state.compare_a_build, state.compare_b_build
  local items = {
    { label = "Copy A full loadout", hint = a.name },
    { label = "Copy B full loadout", hint = b.name },
    { label = "Copy A build code", hint = build_a and build_a.playstyle or "" },
    { label = "Copy B build code", hint = build_b and build_b.playstyle or "" },
    { label = "Switch A build", hint = build_a and build_a.playstyle or "choose" },
    { label = "Switch B build", hint = build_b and build_b.playstyle or "choose" },
    { label = "Copy A+B summary", hint = "short comparison text" },
    { label = "Change opponent", hint = "back to weapon picker" },
    { label = "Close comparison", hint = "Esc" },
  }
  local actions = {
    "copy_a_full",
    "copy_b_full",
    "copy_a_code",
    "copy_b_code",
    "switch_a",
    "switch_b",
    "copy_summary",
    "change_opponent",
    "close",
  }

  local menu, why = rb.overlay.menu(items, {
    title = "Compare actions",
    subtitle = "Copy either side, switch builds, or go back",
    width = 640,
    theme = "tokyo-night",
    on_select = function(index)
      local action = actions[index]
      if action == "copy_a_full" then
        copy_compare_side("A", true)
      elseif action == "copy_b_full" then
        copy_compare_side("B", true)
      elseif action == "copy_a_code" then
        copy_compare_side("A", false)
      elseif action == "copy_b_code" then
        copy_compare_side("B", false)
      elseif action == "switch_a" then
        rb.after(0, function()
          open_compare_build_menu("A")
        end)
      elseif action == "switch_b" then
        rb.after(0, function()
          open_compare_build_menu("B")
        end)
      elseif action == "copy_summary" then
        local summary = compare_summary_text()
        if summary then
          copy_text_async(summary, "A vs B", "Comparison summary copied")
        end
      elseif action == "change_opponent" then
        rb.after(0, toggle_compare)
      elseif action == "close" then
        rb.after(0, remove_compare)
      end
    end,
    on_close = function()
      state.menu = nil
      install_compare_handler()
    end,
  })
  if menu then
    state.menu = menu
  else
    rb.log.warn("CODMunity compare actions: " .. tostring(why))
    notify("Could not open compare actions: " .. tostring(why), { title = "Compare actions", duration = 3500 })
    install_compare_handler()
  end
end

local function active_weapon()
  return state.last or state.items[1]
end

local function active_build()
  local weapon = active_weapon()
  if not weapon then
    return nil, nil
  end
  return current_build(weapon) or primary_build(weapon), weapon
end

build_issues = function(build)
  local issues = {}
  if not build then
    return { "no build" }
  end
  local attachments = build.attachments or {}
  if #attachments < 5 then
    issues[#issues + 1] = #attachments .. " attachments"
  end
  local seen = {}
  for _, attachment in ipairs(attachments) do
    local slot = attachment_slot(attachment)
    if seen[slot] then
      issues[#issues + 1] = "duplicate " .. (SLOT_ABBR[slot] or slot)
    else
      seen[slot] = true
    end
  end
  if not build.code or build.code == "" then
    issues[#issues + 1] = "no code"
  end
  return issues
end

local function rank_history(weapon)
  local ranks = {}
  if not weapon then
    return ranks
  end
  for index = #state.history, 1, -1 do
    local snapshot = state.history[index]
    for _, item in ipairs(snapshot.items or {}) do
      if item.url == weapon.url and tonumber(item.rank) then
        ranks[#ranks + 1] = tonumber(item.rank)
        break
      end
    end
  end
  local current = tonumber(weapon.rank) or 0
  if current > 0 then
    ranks[#ranks + 1] = current
  end
  return ranks
end

rank_history_text = function(weapon)
  local ranks = rank_history(weapon)
  if #ranks < 2 then
    return ""
  end
  local parts = {}
  for _, rank in ipairs(ranks) do
    parts[#parts + 1] = "#" .. rank
  end
  return table.concat(parts, " > ")
end

local function csv_escape(value)
  local text = tostring(value or "")
  if text:find('[,"\n]') then
    return '"' .. text:gsub('"', '""') .. '"'
  end
  return text
end

local function short_format(weapon, build)
  return string.format("%s - %s - %s",
    weapon.name or "Weapon",
    (build and build.playstyle) or weapon.build or "Build",
    (build and build.code) or weapon.code or "no code")
end

local function discord_format(weapon, build)
  local lines = {
    string.format("**%s** - %s", weapon.name or "Weapon", (build and build.playstyle) or weapon.build or "Build"),
  }
  for _, attachment in ipairs(build and build.attachments or {}) do
    lines[#lines + 1] = string.format("- [%s] %s", attachment_abbr(attachment), attachment.name or "")
  end
  lines[#lines + 1] = "`" .. ((build and build.code) or weapon.code or "no code") .. "`"
  return table.concat(lines, "\n")
end

local function csv_format(weapon, build)
  local names = {}
  for _, attachment in ipairs(build and build.attachments or {}) do
    names[#names + 1] = attachment.name or ""
  end
  return table.concat({
    csv_escape(weapon.name),
    csv_escape((build and build.playstyle) or weapon.build),
    csv_escape((build and build.code) or weapon.code),
    csv_escape(table.concat(names, " | ")),
  }, ",")
end

compare_summary_text = function()
  if not state.compare_a or not state.compare_b then
    return nil
  end
  local a_build = state.compare_a_build or primary_build(state.compare_a)
  local b_build = state.compare_b_build or primary_build(state.compare_b)
  return table.concat({
    "A: " .. short_format(state.compare_a, a_build),
    "B: " .. short_format(state.compare_b, b_build),
  }, "\n")
end

local function show_copy_formats()
  local build, weapon = active_build()
  if not weapon then
    notify("Open the meta menu first", { title = "Copy formats", duration = 2500 })
    return
  end
  local items = {
    { label = "Code only", hint = build and build.code or "no code" },
    { label = "Short line", hint = "Weapon - build - code" },
    { label = "Discord format", hint = "bold name + attachments + code" },
    { label = "CSV row", hint = "spreadsheet-friendly" },
  }
  local menu, why = rb.overlay.menu(items, {
    title = "Copy formats  |  " .. weapon.name,
    subtitle = "Choose the clipboard format",
    width = 620,
    theme = "tokyo-night",
    on_select = function(index)
      if index == 1 then
        copy_text_async(build and build.code or weapon.code or "", weapon.name, "Code copied")
      elseif index == 2 then
        copy_text_async(short_format(weapon, build), weapon.name, "Short line copied")
      elseif index == 3 then
        copy_text_async(discord_format(weapon, build), weapon.name, "Discord format copied")
      elseif index == 4 then
        copy_text_async(csv_format(weapon, build), weapon.name, "CSV row copied")
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open copy formats: " .. tostring(why), { title = "Copy formats", duration = 3000 })
  end
end

local function top_names(items, count)
  local parts = {}
  for index = 1, math.min(count, #items) do
    local item = items[index]
    parts[#parts + 1] = "#" .. (tonumber(item.rank) or index) .. " " .. tostring(item.name or "?")
  end
  return table.concat(parts, "  |  ")
end

local function show_history()
  local items = {}
  if #state.items > 0 then
    items[#items + 1] = {
      label = "Current  |  " .. (state.week ~= "" and state.week or "current"),
      hint = top_names(state.items, 3),
    }
  end
  for _, snapshot in ipairs(state.history) do
    items[#items + 1] = {
      label = tostring(snapshot.week or "previous week"),
      hint = top_names(snapshot.items or {}, 3),
    }
  end
  if #items == 0 then
    notify("No history yet", { title = "Meta history", duration = 2500 })
    return
  end
  local menu, why = rb.overlay.menu(items, {
    title = "Meta history",
    subtitle = "Newest first  |  history grows after each new weekly snapshot",
    width = 720,
    theme = "tokyo-night",
    on_select = function()
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open history: " .. tostring(why), { title = "Meta history", duration = 3000 })
  end
end

local function show_clipboard_history()
  if #state.user.clipboard_history == 0 then
    notify("Nothing copied yet", { title = "Clipboard history", duration = 2500 })
    return
  end
  local items = {}
  local mapping = {}
  for _, entry in ipairs(state.user.clipboard_history) do
    local preview = tostring(entry.text or ""):gsub("\n", " / ")
    items[#items + 1] = {
      label = tostring(entry.label or "Copied"),
      hint = fit(preview, 58),
    }
    mapping[#mapping + 1] = entry
  end
  local menu, why = rb.overlay.menu(items, {
    title = "Clipboard history",
    subtitle = "Enter copies the selected entry again",
    width = 720,
    theme = "tokyo-night",
    on_select = function(index)
      local entry = mapping[index]
      if entry then
        copy_text_async(entry.text, entry.label or "Clipboard history", "Copied again")
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open clipboard history: " .. tostring(why), { title = "Clipboard", duration = 3000 })
  end
end

local function show_favorite_builds()
  local list = favorite_builds_list(state.game_key)
  if #list == 0 then
    notify("No favorite builds yet", { title = "Favorite builds", duration = 2500 })
    return
  end
  local items = {}
  local mapping = {}
  for _, entry in ipairs(list) do
    if entry.weapon and entry.build then
      items[#items + 1] = {
        label = tostring(entry.weapon.name or "Weapon") .. "  |  " .. tostring(entry.build.playstyle or "Build"),
        hint = tostring(entry.build.code or ""),
      }
      mapping[#mapping + 1] = entry
    end
  end
  local menu, why = rb.overlay.menu(items, {
    title = "Favorite builds",
    subtitle = "Enter loads this weapon and build",
    width = 720,
    theme = "tokyo-night",
    on_select = function(index)
      local entry = mapping[index]
      if entry then
        state.last = entry.weapon
        state.last_build = entry.build
        add_recent(entry.weapon)
        show_detail(entry.weapon)
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open favorite builds: " .. tostring(why), { title = "Favorite builds", duration = 3000 })
  end
end

local function show_tags_menu()
  local weapon = active_weapon()
  if not weapon then
    notify("Open the meta menu first", { title = "Tags", duration = 2500 })
    return
  end
  local available = { "Meta", "Tested", "Ranked", "Fun", "Laser", "Off-meta" }
  local items = {}
  for _, tag in ipairs(available) do
    items[#items + 1] = {
      label = (has_tag(weapon, tag) and "[x] " or "[ ] ") .. tag,
      hint = has_tag(weapon, tag) and "on" or "off",
    }
  end
  local menu, why = rb.overlay.menu(items, {
    title = "Tags  |  " .. weapon.name,
    subtitle = "Enter toggles a tag for this weapon",
    width = 520,
    theme = "tokyo-night",
    on_select = function(index)
      local tag = available[index]
      if tag then
        local on = toggle_tag(weapon, tag)
        notify(tag .. (on and " added" or " removed"), { title = weapon.name, duration = 2000 })
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open tags: " .. tostring(why), { title = "Tags", duration = 3000 })
  end
end

local function plan_hint(plan, slot)
  local entry = plan[slot]
  if entry and entry.weapon and entry.build then
    return string.format("%s  |  %s", entry.weapon.name, entry.build.playstyle or "Build")
  end
  return "not set"
end

local function copy_plan_codes()
  local plan = plan_table(state.game_key)
  local lines = {}
  for _, slot in ipairs({ "primary", "secondary", "backup" }) do
    local entry = plan[slot]
    if entry and entry.weapon and entry.build then
      lines[#lines + 1] = string.format("%s: %s - %s - %s",
        string.upper(slot),
        entry.weapon.name or "Weapon",
        entry.build.playstyle or "Build",
        entry.build.code or "no code")
    end
  end
  if #lines == 0 then
    notify("Loadout plan is empty", { title = "Plan", duration = 2500 })
    return
  end
  copy_text_async(table.concat(lines, "\n"), "Loadout plan", "Plan codes copied")
end

local function show_plan_menu()
  local build, weapon = active_build()
  local plan = plan_table(state.game_key)
  local items = {
    { label = "Set primary", hint = plan_hint(plan, "primary") },
    { label = "Set secondary", hint = plan_hint(plan, "secondary") },
    { label = "Set backup", hint = plan_hint(plan, "backup") },
    { label = "Copy plan codes", hint = "primary + secondary + backup" },
    { label = "Clear plan", hint = "remove all three" },
  }
  local menu, why = rb.overlay.menu(items, {
    title = "Loadout plan",
    subtitle = weapon and ("Current: " .. weapon.name .. " | " .. tostring(build and build.playstyle or "Build")) or "No current weapon",
    width = 660,
    theme = "tokyo-night",
    on_select = function(index)
      if index <= 3 and weapon and build then
        local slot = ({ "primary", "secondary", "backup" })[index]
        set_plan_slot(slot, weapon, build)
        notify(string.upper(slot) .. " set", { title = weapon.name, duration = 2200 })
      elseif index == 4 then
        copy_plan_codes()
      elseif index == 5 then
        clear_plan()
        notify("Plan cleared", { title = "Loadout plan", duration = 2200 })
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open loadout plan: " .. tostring(why), { title = "Plan", duration = 3000 })
  end
end

local function show_build_duel()
  local weapon = active_weapon()
  if not weapon then
    notify("Open the meta menu first", { title = "Build duel", duration = 2500 })
    return
  end
  local builds = weapon.builds or {}
  if #builds < 2 then
    notify("This weapon has only one build", { title = weapon.name, duration = 3000 })
    return
  end
  local first = current_build(weapon) or builds[1]
  local second = nil
  for _, build in ipairs(builds) do
    if build.code ~= first.code then
      second = build
      break
    end
  end
  if not second then
    notify("No second build to compare", { title = weapon.name, duration = 3000 })
    return
  end
  state.last = weapon
  state.last_build = first
  show_compare(weapon, weapon, first, second)
end

local function show_top3_compare()
  if #state.items < 2 then
    notify("Need at least two weapons", { title = "Top 3 compare", duration = 2500 })
    return
  end
  local markers = { "A", "B", "C" }
  local items = {}
  for index = 1, math.min(3, #state.items) do
    local weapon = state.items[index]
    local build = current_build(weapon) or primary_build(weapon)
    items[#items + 1] = {
      label = string.format("%s  #%d  %s", markers[index], tonumber(weapon.rank) or index, fit(weapon.name, 26)),
      hint = string.format("%s  |  %s  |  %s",
        (build and build.playstyle) or weapon.build or "Build",
        (build and build.code) or weapon.code or "no code",
        weapon.ttk ~= "" and weapon.ttk or "no TTK data"),
    }
  end
  local menu, why = rb.overlay.menu(items, {
    title = "Top 3 compare",
    subtitle = "Compact codes and TTK  |  use F4 for a full two-way comparison",
    width = 820,
    theme = "tokyo-night",
    on_select = function()
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open top 3: " .. tostring(why), { title = "Top 3 compare", duration = 3000 })
  end
end

local function show_why()
  local build, weapon = active_build()
  if not weapon or not build then
    notify("Open the meta menu first", { title = "Why this build", duration = 2500 })
    return
  end
  local max_rating = 0
  for _, candidate in ipairs(weapon.builds or {}) do
    max_rating = math.max(max_rating, tonumber(candidate.rating or 0) or 0)
  end
  local rating = tonumber(build.rating or 0) or 0
  local rating_hint = string.format("%.2f", rating)
  if rating > 0 and rating >= max_rating then
    rating_hint = rating_hint .. "  |  highest-rated build"
  elseif max_rating > 0 then
    rating_hint = rating_hint .. string.format("  |  %.2f below top", max_rating - rating)
  end
  local issues = build_issues(build)
  local history = rank_history_text(weapon)
  local items = {
    { label = "Selected build", hint = (build.playstyle or "Build") .. "  |  " .. rating_hint },
    { label = "Attachment check", hint = #issues == 0 and "no obvious issues" or table.concat(issues, ", ") },
    { label = "Rank history", hint = history ~= "" and history or "starts after the next weekly snapshot" },
    { label = "Alternatives", hint = type(weapon.alternatives) == "table" and #weapon.alternatives > 0 and table.concat(weapon.alternatives, ", ") or "none listed" },
    { label = "Source note", hint = "derived from CODMunity page metadata, not editorial reasoning" },
  }
  local menu, why = rb.overlay.menu(items, {
    title = "Why this build?  |  " .. weapon.name,
    subtitle = "Metadata-derived summary",
    width = 720,
    theme = "tokyo-night",
    on_select = function()
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open summary: " .. tostring(why), { title = "Why this build", duration = 3000 })
  end
end

local function random_meta_pick()
  if #state.items == 0 then
    notify("No meta data yet", { title = "Random pick", duration = 2500 })
    return
  end
  local weapon = state.items[math.random(1, #state.items)]
  select_weapon(weapon)
  notify("Random pick: " .. weapon.name, { title = state.game.label, duration = 3000 })
end

local function export_setup()
  local payload = {
    version = 1,
    exported_at = os.time(),
    user = state.user,
  }
  local ok, err = pcall(rb.file.write_json, "meta_overlay_setup.json", payload)
  if not ok then
    notify("Export failed: " .. tostring(err), { title = "Setup export", duration = 4000 })
    return
  end
  notify("Saved meta_overlay_setup.json", { title = "Setup exported", duration = 3500 })
end

local function import_setup()
  if not rb.file.exists("meta_overlay_setup.json") then
    notify("meta_overlay_setup.json not found", { title = "Setup import", duration = 3500 })
    return
  end
  local ok, data = pcall(rb.file.read_json, "meta_overlay_setup.json")
  if not ok or type(data) ~= "table" then
    notify("Could not read setup file", { title = "Setup import", duration = 3500 })
    return
  end
  local imported = data.user or data
  if type(imported) ~= "table" then
    notify("Setup file has no user data", { title = "Setup import", duration = 3500 })
    return
  end
  local fields = {
    "favorites", "recent", "hud", "pins", "favorite_builds",
    "tags", "plan", "clipboard_history", "search_recent",
  }
  local count = 0
  for _, key in ipairs(fields) do
    if type(imported[key]) == "table" then
      state.user[key] = imported[key]
      count = count + 1
    end
  end
  save_user_state()
  notify("Imported " .. count .. " setup sections", { title = "Setup imported", duration = 3500 })
end

local function close_help()
  if state.help_handler then
    pcall(function()
      state.help_handler:stop()
    end)
    state.help_handler = nil
  end
  remove_surface(state.help_surface)
  state.help_surface = nil
end

local function show_help()
  apply_theme()
  if state.help_surface then
    close_help()
    return
  end
  remove_compare()
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end
  if state.search then
    close_search()
  end

  local width = 780
  local pad = 18
  local ops = {
    { kind = "rect", x = 0, y = 0, w = width, h = 560, fill = THEME.surface,
      border = THEME.border, border_width = 1, radius = 10, shadow = 0.45 },
  }
  local y = 16
  local function add_text(size, text, color, bold)
    ops[#ops + 1] = {
      kind = "text",
      x = pad,
      y = y,
      size = size,
      text = tostring(text or ""),
      color = color,
      bold = bold or false,
    }
    y = y + math.max(size + 6, 20)
  end

  add_text(20, "HELP / HOTKEYS", THEME.accent, true)
  add_text(10, "Keyboard-only overlay. F1 closes this page.", THEME.faint, false)
  add_text(12, "META MENU", THEME.text, true)
  add_text(11, "F8 open/close   |   Up/Down move   |   Enter or 1-9 select   |   Esc close", THEME.dim, false)
  add_text(11, "F7 refresh   |   F5 favorite weapon   |   F11 extras", THEME.dim, false)
  add_text(12, "SEARCH", THEME.text, true)
  add_text(11, "F2 open   |   type to filter   |   Backspace edit   |   Up/Down select   |   Enter load", THEME.dim, false)
  add_text(12, "COMPARE", THEME.text, true)
  add_text(11, "F4 pick/change opponent   |   F6 actions and switch builds", THEME.dim, false)
  add_text(11, "F9 copy A full loadout   |   F10 copy B full loadout   |   Esc close   |   F8 menu", THEME.dim, false)
  add_text(12, "BUILDS / SAVED", THEME.text, true)
  add_text(11, "F6 build picker   |   Extras: pin build, favorite build, tags, plan", THEME.dim, false)
  add_text(11, "Extras: history, build duel, clipboard history, copy formats, export/import", THEME.dim, false)
  add_text(12, "HUD / STYLE", THEME.text, true)
  add_text(11, "F3 toggle HUD   |   Options: theme, HUD style, position, size, search scope", THEME.dim, false)
  add_text(10, "CODMunity data is attribution in the README; copied loadouts stay clean.", THEME.faint, false)

  ops[1].h = y + 16
  local ok, err = pcall(function()
    state.help_surface = rb.overlay.surface({
      anchor = "center",
      width = width,
      height = ops[1].h,
      opacity = 0.99,
      ops = ops,
    })
  end)
  if not ok then
    rb.log.warn("CODMunity help surface failed: " .. tostring(err))
    return
  end
  state.help_handler = rb.on("down", function(key)
    if key == "Escape" or key == cfg.help_key then
      rb.after(0, close_help)
      return false
    end
    return true
  end)
end

local function open_extras()
  apply_theme()
  if sync_game then
    sync_game(false)
  end
  if state.search then
    close_search()
  end
  close_help()
  remove_compare()
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end

  local build, weapon = active_build()
  local tags = weapon and tag_list(weapon) or {}
  local pin = weapon and pinned_build(weapon) or nil
  local items = {
    { label = "Help / hotkeys", hint = "F1" },
    { label = "Meta history", hint = #state.history .. " previous week(s)" },
    { label = "Build duel", hint = weapon and weapon.name or "select a weapon" },
    { label = "Loadout plan", hint = "primary / secondary / backup" },
    { label = "Clipboard history", hint = #state.user.clipboard_history .. " item(s)" },
    { label = "Favorite builds", hint = #favorite_builds_list(state.game_key) .. " saved" },
    { label = "Pin current build", hint = build and (build.playstyle or "Build") or "no build" },
    { label = "Clear pin", hint = pin and (pin.playstyle or "set") or "none" },
    { label = "Favorite current build", hint = build and (build.code or "") or "no build" },
    { label = "Tags", hint = #tags > 0 and table.concat(tags, ", ") or "none" },
    { label = "Why this build?", hint = weapon and weapon.name or "select a weapon" },
    { label = "Random meta pick", hint = "roll the top 10" },
    { label = "Copy formats", hint = "code / short / Discord / CSV" },
    { label = "Export setup", hint = "meta_overlay_setup.json" },
    { label = "Import setup", hint = "replace saved setup" },
    { label = "Top 3 compare", hint = "compact A/B/C codes and TTK" },
  }
  local menu, why = rb.overlay.menu(items, {
    title = "Extras",
    subtitle = weapon and ("Current: " .. weapon.name .. " | " .. tostring(build and build.playstyle or "Build")) or "No current weapon",
    width = 700,
    theme = "tokyo-night",
    on_select = function(index)
      if index == 1 then
        rb.after(0, show_help)
      elseif index == 2 then
        rb.after(0, show_history)
      elseif index == 3 then
        rb.after(0, show_build_duel)
      elseif index == 4 then
        rb.after(0, show_plan_menu)
      elseif index == 5 then
        rb.after(0, show_clipboard_history)
      elseif index == 6 then
        rb.after(0, show_favorite_builds)
      elseif index == 7 then
        if weapon and build then
          set_pinned_build(weapon, build)
          notify("Pinned " .. (build.playstyle or "build"), { title = weapon.name, duration = 2500 })
        else
          notify("Select a weapon first", { title = "Pin build", duration = 2500 })
        end
      elseif index == 8 then
        if weapon then
          clear_pinned_build(weapon)
          notify("Pin cleared", { title = weapon.name, duration = 2500 })
        end
      elseif index == 9 then
        if weapon and build then
          local added = toggle_favorite_build(weapon, build)
          notify(added and "Build favorited" or "Build unfavorited", { title = weapon.name, duration = 2500 })
        else
          notify("Select a weapon first", { title = "Favorite build", duration = 2500 })
        end
      elseif index == 10 then
        rb.after(0, show_tags_menu)
      elseif index == 11 then
        rb.after(0, show_why)
      elseif index == 12 then
        rb.after(0, random_meta_pick)
      elseif index == 13 then
        rb.after(0, show_copy_formats)
      elseif index == 14 then
        export_setup()
      elseif index == 15 then
        import_setup()
      elseif index == 16 then
        rb.after(0, show_top3_compare)
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    notify("Could not open extras: " .. tostring(why), { title = "Extras", duration = 3000 })
  end
end

local function render_hud()
  apply_theme()
  remove_surface(state.hud_surface)
  state.hud_surface = nil
  local mode = tostring(cfg.hud_mode or "full")
  if not hud_enabled(state.game_key) or #state.items == 0 or mode == "off" then
    return
  end

  local scale = 1
  if cfg.hud_scale == "small" then
    scale = 0.9
  elseif cfg.hud_scale == "large" then
    scale = 1.15
  end
  local row_count = math.min(3, #state.items)
  local width = mode == "compact" and 560 or (mode == "codes" and 340 or 390)
  local height = mode == "compact" and 48 or (34 + row_count * math.floor(20 * scale))
  local ops = {
    { kind = "rect", x = 0, y = 0, w = width, h = height, fill = THEME.surface,
      border = THEME.border, border_width = 1, radius = 8, shadow = 0.35 },
  }
  local function add_text(x, y, size, text, color, bold)
    ops[#ops + 1] = {
      kind = "text",
      x = x,
      y = y,
      size = math.max(8, math.floor(size * scale)),
      text = tostring(text or ""),
      color = color,
      bold = bold or false,
    }
  end

  add_text(12, 8, 10, state.game.title .. "  |  " .. (state.week ~= "" and state.week or ""), THEME.faint, false)
  if mode == "compact" then
    local names = {}
    for index = 1, row_count do
      names[#names + 1] = "#" .. (tonumber(state.items[index].rank) or index) .. " " .. fit(state.items[index].name, 18)
    end
    add_text(12, 26, 12, table.concat(names, "   "), THEME.accent, true)
  elseif mode == "codes" then
    local y = 26
    for index = 1, row_count do
      local weapon = state.items[index]
      local build = current_build(weapon) or primary_build(weapon)
      local code = build and build.code or weapon.code or "-"
      add_text(12, y, 11, "#" .. (tonumber(weapon.rank) or index) .. "  " .. fit(code, 24), index == 1 and THEME.accent or THEME.dim, index == 1)
      y = y + math.floor(18 * scale)
    end
  else
    local y = 26
    for index = 1, row_count do
      local weapon = state.items[index]
      local hint = weapon_hint(weapon)
      add_text(12, y, 12, string.format("%d  %s", tonumber(weapon.rank) or index, fit(weapon.name, 22)), index == 1 and THEME.accent or THEME.text, index == 1)
      add_text(width - 114, y, 10, fit(hint, 16), THEME.dim, false)
      y = y + math.floor(20 * scale)
    end
  end

  local anchor = tostring(cfg.hud_position or "top-left")
  local ok, err = pcall(function()
    state.hud_surface = rb.overlay.surface({
      anchor = anchor,
      width = width,
      height = height,
      offset_x = 24,
      offset_y = 24,
      opacity = 0.92,
      ops = ops,
    })
  end)
  if not ok then
    rb.log.warn("CODMunity HUD surface failed: " .. tostring(err))
  end
end

local function toggle_hud()
  if sync_game then
    sync_game(false)
  end
  if state.search then
    close_search()
  end
  local enabled = not hud_enabled(state.game_key)
  set_hud_enabled(state.game_key, enabled)
  render_hud()
  if cfg.hud_mode == "off" then
    notify("HUD style is off in Options", { title = state.game.label, duration = 3000 })
  else
    notify(enabled and "Top-3 HUD on" or "Top-3 HUD off", { title = state.game.label, duration = 2500 })
  end
end

local function compute_changes(current, previous)
  local changes = {}
  if type(previous) ~= "table" or #previous == 0 then
    return changes
  end

  local old_by_key = {}
  for _, weapon in ipairs(previous) do
    old_by_key[weapon.url or weapon.name] = weapon
  end
  local new_by_key = {}
  for _, weapon in ipairs(current) do
    new_by_key[weapon.url or weapon.name] = weapon
  end

  for _, weapon in ipairs(current) do
    local key = weapon.url or weapon.name
    local old = old_by_key[key]
    if not old then
      changes[#changes + 1] = { kind = "new", text = "NEW   " .. weapon.name .. "   #" .. (weapon.rank or "?") }
    else
      local old_rank = tonumber(old.rank)
      local new_rank = tonumber(weapon.rank)
      if old_rank and new_rank and old_rank ~= new_rank then
        changes[#changes + 1] = {
          kind = new_rank < old_rank and "up" or "down",
          text = string.format("%s   %s   #%d -> #%d", new_rank < old_rank and "UP  " or "DOWN", weapon.name, old_rank, new_rank),
        }
      end
      if old.tier and weapon.tier and old.tier ~= "" and weapon.tier ~= "" and old.tier ~= weapon.tier then
        changes[#changes + 1] = {
          kind = "tier",
          text = "TIER  " .. weapon.name .. "   " .. old.tier .. " -> " .. weapon.tier,
        }
      end
      local old_pick = pick_value(old)
      local new_pick = pick_value(weapon)
      if old.pick_rate and weapon.pick_rate and math.abs(new_pick - old_pick) >= 0.5 then
        changes[#changes + 1] = {
          kind = "pick",
          text = string.format("PICK  %s   %s -> %s", weapon.name, old.pick_rate, weapon.pick_rate),
        }
      end
    end
  end

  for _, old in ipairs(previous) do
    if not new_by_key[old.url or old.name] then
      changes[#changes + 1] = { kind = "out", text = "OUT   " .. old.name }
    end
  end
  return changes
end

local function watchlist_alerts(items, previous_items, game_key)
  local alerts = {}
  local previous = {}
  for _, weapon in ipairs(previous_items or {}) do
    previous[weapon.url or weapon.name] = weapon
  end
  local current = {}
  for _, weapon in ipairs(items or {}) do
    current[weapon.url or weapon.name] = weapon
  end
  local favorites = state.user.favorites[game_key] or {}
  for _, favorite in ipairs(favorites) do
    local key = favorite.url or favorite.name
    local old = previous[key]
    local new = current[key]
    if old and not new then
      alerts[#alerts + 1] = "Dropped: " .. tostring(favorite.name)
    elseif old and new then
      if old.tier and new.tier and old.tier ~= "" and new.tier ~= "" and old.tier ~= new.tier then
        alerts[#alerts + 1] = string.format("%s: %s -> %s", new.name, old.tier, new.tier)
      elseif tonumber(old.rank) and tonumber(new.rank) and math.abs(old.rank - new.rank) >= 2 then
        alerts[#alerts + 1] = string.format("%s: #%d -> #%d", new.name, old.rank, new.rank)
      end
    end
    if #alerts >= 3 then
      break
    end
  end
  return alerts
end

local function show_changes()
  apply_theme()
  remove_surface(state.changes_surface)
  state.changes_surface = nil
  if state.previous_week == "" or #state.previous_items == 0 then
    notify("No previous weekly snapshot yet", { title = "What changed", duration = 3000 })
    return
  end

  local changes = compute_changes(state.items, state.previous_items)
  local width = 620
  local lines = math.max(1, math.min(#changes, 16))
  local height = 92 + lines * 20
  local ops = {
    { kind = "rect", x = 0, y = 0, w = width, h = height, fill = THEME.surface,
      border = THEME.border, border_width = 1, radius = 10, shadow = 0.45 },
  }
  local y = 14
  local function add_text(size, text, color, bold)
    ops[#ops + 1] = {
      kind = "text",
      x = 16,
      y = y,
      size = size,
      text = tostring(text or ""),
      color = color,
      bold = bold or false,
    }
    y = y + 20
  end

  add_text(18, "WEEKLY CHANGES", THEME.accent, true)
  add_text(10, state.previous_week .. "  ->  " .. (state.week ~= "" and state.week or "current"), THEME.faint, false)
  if #changes == 0 then
    add_text(12, "No top-10 changes in this snapshot", THEME.dim, false)
  else
    for index = 1, math.min(#changes, 16) do
      local change = changes[index]
      local color = THEME.dim
      if change.kind == "new" or change.kind == "up" then
        color = THEME.positive
      elseif change.kind == "out" or change.kind == "down" then
        color = THEME.danger
      elseif change.kind == "tier" then
        color = THEME.accent
      end
      add_text(11, change.text, color, false)
    end
  end
  add_text(10, "F8 close  |  " .. SOURCE, THEME.faint, false)
  ops[1].h = y + 12

  local ok, err = pcall(function()
    state.changes_surface = rb.overlay.surface({
      anchor = "center",
      width = width,
      height = ops[1].h,
      opacity = 0.98,
      ops = ops,
    })
  end)
  if not ok then
    rb.log.warn("CODMunity changes surface failed: " .. tostring(err))
  end
end

local function weapon_database_list(blocks)
  for _, block in ipairs(blocks) do
    if block["@type"] == "ItemList" and type(block.itemListElement) == "table" and #block.itemListElement > 100 then
      return block
    end
  end
  return nil
end

local function load_index()
  if not rb.file.exists(INDEX_FILE) then
    return
  end
  local ok, data = pcall(rb.file.read_json, INDEX_FILE)
  if not ok or type(data) ~= "table" or type(data.items) ~= "table" then
    return
  end
  state.index = data.items
  state.index_updated_at = tonumber(data.updated_at) or 0
end

local function refresh_index()
  if state.index_refreshing then
    return
  end
  state.index_refreshing = true
  rb.run(function()
    local ok, err = pcall(function()
      local html = http_get("https://codmunity.gg/database/weapons")
      local list = weapon_database_list(parse_jsonld(html))
      if not list then
        error("weapon database item list missing")
      end
      local items = {}
      for _, entry in ipairs(list.itemListElement) do
        if type(entry) == "table" and entry.name and entry.url then
          local prefix = tostring(entry.url):match("/weapon/([^/]+)/") or "cod"
          items[#items + 1] = {
            name = tostring(entry.name),
            url = tostring(entry.url),
            prefix = prefix,
          }
        end
      end
      state.index = items
      state.index_updated_at = os.time()
      local payload = {
        version = 1,
        updated_at = state.index_updated_at,
        items = items,
      }
      local write_ok, write_err = pcall(rb.file.write_json, INDEX_FILE, payload)
      if not write_ok then
        rb.log.warn("CODMunity weapon index write failed: " .. tostring(write_err))
      end
      rb.log.info("CODMunity weapon index updated: " .. #items .. " entries")
    end)
    if not ok then
      rb.log.warn("CODMunity weapon index failed: " .. tostring(err))
    end
    state.index_refreshing = false
  end)
end

local function ensure_index()
  if #state.index == 0 or os.time() - state.index_updated_at > 86400 then
    refresh_index()
  end
end

local function preferred_prefix_rank(prefix)
  for index, candidate in ipairs(state.game.prefixes or {}) do
    if candidate == prefix then
      return index
    end
  end
  return 99
end

local function top_weapon_by_url(url)
  for _, weapon in ipairs(state.items) do
    if weapon.url == url then
      return weapon
    end
  end
  return nil
end

local function fuzzy_match_score(text, query)
  local cursor = 1
  local gaps = 0
  for index = 1, #query do
    local found = text:find(query:sub(index, index), cursor, true)
    if not found then
      return nil
    end
    gaps = gaps + math.max(0, found - cursor)
    cursor = found + 1
  end
  return 40 - math.min(30, gaps)
end

local function search_matches()
  if state.search == nil then
    return {}
  end
  local query = trim(state.search.query):lower()
  if query == "" then
    local results = {}
    for _, weapon in ipairs(state.items) do
      results[#results + 1] = {
        name = weapon.name,
        url = weapon.url,
        prefix = tostring(weapon.url):match("/weapon/([^/]+)/") or "cod",
        in_top = true,
      }
    end
    return results
  end

  local results = {}
  for _, entry in ipairs(state.index) do
    local name = tostring(entry.name or "")
    local name_lower = name:lower()
    local prefix = tostring(entry.prefix or "")
    local prefix_rank = preferred_prefix_rank(prefix)
    local allowed = cfg.search_scope ~= "game" or prefix_rank < 99
    if allowed then
      local score = nil
      local substring_at = name_lower:find(query, 1, true)
      if substring_at then
        score = 0
        if name_lower == query then
          score = score + 100
        elseif name_lower:sub(1, #query) == query then
          score = score + 50
        else
          score = score + 25
        end
      else
        score = fuzzy_match_score(name_lower, query)
      end
      if score then
        local in_top = top_weapon_by_url(entry.url) ~= nil
        if in_top then
          score = score + 20
        end
        score = score + math.max(0, 10 - prefix_rank)
        results[#results + 1] = {
          name = name,
          url = entry.url,
          prefix = prefix ~= "" and prefix or "cod",
          in_top = in_top,
          score = score,
        }
      end
    end
  end
  table.sort(results, function(a, b)
    if a.score == b.score then
      return a.name < b.name
    end
    return a.score > b.score
  end)
  return results
end

local function render_search()
  if state.search == nil then
    return
  end
  remove_surface(state.search.surface)
  state.search.surface = nil

  local results = search_matches()
  state.search.results = results
  if #results == 0 then
    state.search.selected = 1
  elseif state.search.selected > #results then
    state.search.selected = #results
  end

  local width = 640
  local visible = math.min(8, #results)
  local height = 118 + math.max(1, visible) * 22
  local ops = {
    { kind = "rect", x = 0, y = 0, w = width, h = height, fill = THEME.surface,
      border = THEME.border, border_width = 1, radius = 10, shadow = 0.45 },
  }
  local function add_text(x, y, size, text, color, bold)
    ops[#ops + 1] = {
      kind = "text",
      x = x,
      y = y,
      size = size,
      text = tostring(text or ""),
      color = color,
      bold = bold or false,
    }
  end

  add_text(16, 12, 18, "SEARCH WEAPONS", THEME.accent, true)
  add_text(16, 38, 10, "Index " .. #state.index .. "  |  " .. state.game.label .. " preferred  |  Esc close", THEME.faint, false)
  local query_text = state.search.query ~= "" and state.search.query or "type a weapon name"
  add_text(16, 58, 15, "> " .. query_text, state.search.query ~= "" and THEME.text or THEME.faint, true)

  local list_y = 88
  if #results == 0 then
    add_text(16, list_y, 12, #state.index == 0 and "Weapon index loading..." or "No matches", THEME.dim, false)
  else
    for index = 1, visible do
      local entry = results[index]
      if index == state.search.selected then
        ops[#ops + 1] = { kind = "rect", x = 10, y = list_y - 3, w = width - 20, h = 20,
          fill = "#a5e63c22", border = "#a5e63c55", border_width = 1, radius = 4 }
      end
      local marker = entry.in_top and "*" or " "
      local prefix = string.upper(tostring(entry.prefix or "COD"))
      add_text(16, list_y, 12, marker .. " " .. fit(entry.name, 36) .. "  [" .. prefix .. "]", index == state.search.selected and THEME.accent or THEME.text, false)
      list_y = list_y + 22
    end
  end

  add_text(16, height - 24, 10, "Enter open  |  Up/Down select  |  Backspace edit  |  F2/Esc close", THEME.faint, false)
  ops[1].h = height

  local ok, err = pcall(function()
    state.search.surface = rb.overlay.surface({
      anchor = "center",
      width = width,
      height = height,
      opacity = 0.99,
      ops = ops,
    })
  end)
  if not ok then
    rb.log.warn("CODMunity search surface failed: " .. tostring(err))
  end
end

close_search = function()
  if state.search == nil then
    return
  end
  remove_surface(state.search.surface)
  if state.search.handler then
    pcall(function()
      state.search.handler:stop()
    end)
  end
  state.search = nil
end

local function open_indexed_weapon(entry)
  local weapon = top_weapon_by_url(entry.url) or state.dynamic[entry.url]
  if weapon then
    select_weapon(weapon)
    return
  end

  local game = state.game
  set_status({ { game.label .. " ", "dim" }, { "loading " .. fit(entry.name, 22), "accent" } })
  rb.run(function()
    local ok, result = pcall(function()
      local html = http_get(entry.url)
      local parsed = extract_weapon({
        position = 0,
        name = entry.name,
        url = entry.url,
      }, html, game)
      parsed.rank = 0
      return parsed
    end)
    clear_status()
    if ok and result then
      state.dynamic[entry.url] = result
      if state.game_key == game.key then
        select_weapon(result)
      end
    else
      rb.log.warn("CODMunity indexed weapon failed: " .. tostring(entry.url) .. " - " .. tostring(result))
      notify("No " .. game.label .. " build on that page", {
        title = entry.name,
        duration = 3500,
      })
    end
  end)
end

local function choose_search_result()
  if state.search == nil or #state.search.results == 0 then
    return
  end
  local entry = state.search.results[state.search.selected]
  if state.search.query ~= "" then
    add_search_recent(state.search.query)
  end
  close_search()
  if entry then
    open_indexed_weapon(entry)
  end
end

local function search_on_down(key)
  if state.search == nil then
    return true
  end
  local modifiers = rb.input.modifiers()
  if modifiers.ctrl or modifiers.alt or modifiers.win then
    return true
  end
  if key == "Escape" or key == cfg.search_key then
    close_search()
    return false
  end
  if key == "Backspace" then
    if #state.search.query > 0 then
      state.search.query = state.search.query:sub(1, -2)
      state.search.selected = 1
      render_search()
    end
    return false
  end
  if key == "Enter" then
    choose_search_result()
    return false
  end
  if key == "Up" or key == "Down" then
    local count = #state.search.results
    if count > 0 then
      if key == "Up" then
        state.search.selected = state.search.selected - 1
      else
        state.search.selected = state.search.selected + 1
      end
      if state.search.selected < 1 then
        state.search.selected = count
      elseif state.search.selected > count then
        state.search.selected = 1
      end
      render_search()
    end
    return false
  end

  local character = nil
  if #key == 1 then
    local lower = key:lower()
    if lower:match("%a") or lower:match("%d") then
      character = lower
    end
  elseif key == "Space" then
    character = " "
  elseif key == "Minus" then
    character = "-"
  elseif key == "Period" then
    character = "."
  elseif key == "Apostrophe" then
    character = "'"
  end
  if character then
    if #state.search.query < 32 then
      state.search.query = state.search.query .. character
      state.search.selected = 1
      render_search()
    end
    return false
  end
  return true
end

local function open_search()
  apply_theme()
  if sync_game then
    sync_game(false)
  end
  close_help()
  if state.search then
    close_search()
    return
  end
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end
  remove_surface(state.detail)
  state.detail = nil
  remove_compare()
  remove_surface(state.changes_surface)
  state.changes_surface = nil

  ensure_index()
  state.search = {
    query = "",
    selected = 1,
    results = {},
    surface = nil,
    handler = nil,
  }
  render_search()
  state.search.handler = rb.on("down", search_on_down)
  if #state.index == 0 then
    notify("Weapon index loading; showing top 10", { title = "Search", duration = 3000 })
  end
end

local function copy_last()
  if sync_game then
    sync_game(false)
  end
  if state.search then
    close_search()
  end
  local weapon = state.last
  if not weapon and #state.items > 0 then
    weapon = state.items[1]
  end
  if not weapon then
    notify("Open the meta menu first", { title = "CODMunity", duration = 3000 })
    return
  end
  if copy_code(weapon) and state.detail then
    show_detail(weapon)
  end
end

local function open_menu()
  apply_theme()
  if sync_game then
    sync_game(false)
  end
  close_help()
  if state.search then
    close_search()
  end
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
    return
  end

  if #state.items == 0 then
    notify(state.refreshing and "Downloading meta data..." or "No meta data yet. Press F7 to retry.", {
      title = SOURCE,
      duration = 4000,
    })
    return
  end

  remove_surface(state.detail)
  state.detail = nil
  remove_compare()
  remove_surface(state.changes_surface)
  state.changes_surface = nil

  if not rb.overlay.visible() then
    notify("Exclusive fullscreen hides overlays. Use borderless.", {
      title = "Overlay hidden",
      duration = 5000,
    })
    return
  end

  local listed = get_view_items()
  if #listed == 0 then
    local view = tostring(cfg.view or "meta")
    if view == "favorites" then
      notify("No favorites for " .. state.game.label, { title = "Favorites", duration = 3500 })
    elseif view == "recent" then
      notify("No recent weapons for " .. state.game.label, { title = "Recent", duration = 3500 })
    else
      notify("No weapons match the current filter", { title = "Filter", duration = 3500 })
    end
    return
  end

  local items = {}
  local mapping = {}
  if state.previous_week ~= "" and #state.previous_items > 0 then
    items[#items + 1] = {
      label = "What changed",
      hint = state.previous_week .. " -> " .. (state.week ~= "" and state.week or "current"),
    }
    mapping[#mapping + 1] = { kind = "changes" }
  end

  for _, weapon in ipairs(listed) do
    local mark = is_favorite(weapon) and " *" or ""
    local rank = tonumber(weapon.rank) or 0
    local rank_text = rank > 0 and string.format("%2d", rank) or " -"
    items[#items + 1] = {
      label = string.format("%s  %s%s", rank_text, fit(weapon.name, 24), mark),
      hint = weapon_hint(weapon),
    }
    mapping[#mapping + 1] = { kind = "weapon", weapon = weapon }
  end

  local view = tostring(cfg.view or "meta")
  local subtitle = string.upper(view) .. "  |  Week of " .. (state.week ~= "" and state.week or "current")
    .. "  |  updated " .. updated_label()
  local category = tostring(cfg.category or "all")
  local sort = tostring(cfg.sort or "rank")
  if category ~= "all" then
    subtitle = subtitle .. "  |  " .. string.upper(category)
  end
  if sort ~= "rank" then
    subtitle = subtitle .. "  |  " .. sort
  end
  if state.refreshing then
    subtitle = subtitle .. "  |  updating"
  end

  local menu, why = rb.overlay.menu(items, {
    title = state.game.title,
    subtitle = subtitle,
    width = 680,
    theme = "tokyo-night",
    on_select = function(index)
      local entry = mapping[index]
      if not entry then
        return
      end
      if entry.kind == "changes" then
        show_changes()
      elseif entry.weapon then
        select_weapon(entry.weapon)
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })

  if menu then
    state.menu = menu
  else
    rb.log.warn("CODMunity meta menu: " .. tostring(why))
    notify("Could not open menu: " .. tostring(why), { title = "Meta", duration = 4000 })
  end
end

local function refresh(game, force)
  if state.refreshing then
    notify("Meta update already running", { title = SOURCE, duration = 2000 })
    return
  end

  if not force and state.game_key == game.key and state.updated_at > 0
    and os.time() - state.updated_at < MIN_REFRESH_SECONDS then
    rb.log.info("CODMunity " .. game.label .. " cache is fresh; skipping refresh")
    return
  end

  state.refreshing = true
  set_status({ { game.label .. " ", "dim" }, { "checking " .. SOURCE, "accent" } })

  rb.run(function()
    local ok, err = pcall(function()
      local index_html = http_get(game.index)
      local report_url = latest_report_url(parse_jsonld(index_html))
        or index_html:match("https://codmunity%.gg/weekly%-meta/" .. game.path .. "/%d%d%d%d%-%d%d%-%d%d")
      if not report_url then
        error("could not find the latest " .. game.label .. " meta report")
      end

      set_status({ { game.label .. " ", "dim" }, { "reading report", "accent" } })
      local report_html = http_get(report_url)
      local list = best_guns_list(parse_jsonld(report_html))
      if not list then
        error("could not read the top 10 from the " .. game.label .. " report")
      end

      local week = tostring(list.name or ""):match("week of (.+)$")
        or tostring(report_url):match("(%d%d%d%d%-%d%d%-%d%d)$")
        or "current"
      local total = #list.itemListElement
      local items = {}
      local failures = 0

      for index, entry in ipairs(list.itemListElement) do
        set_status({
          { game.label .. " ", "dim" },
          { string.format("%d/%d", index, total), "accent" },
        })
        local page_ok, page = pcall(function()
          return extract_weapon(entry, http_get(entry.url), game)
        end)
        if page_ok and page then
          items[#items + 1] = page
        else
          failures = failures + 1
          rb.log.warn("CODMunity weapon page failed: " .. tostring(entry.url) .. " - " .. tostring(page))
        end
        rb.sleep(300)
      end

      if failures > 0 then
        error(string.format("%d weapon page(s) failed", failures))
      end
      if #items ~= total then
        error("incomplete weapon list")
      end

      table.sort(items, function(a, b)
        return (tonumber(a.rank) or 99) < (tonumber(b.rank) or 99)
      end)

      local previous_week, previous_items, history = save_cache(game, items, week, report_url)
      if state.game_key == game.key then
        local last_url = state.last and state.last.url
        state.items = items
        state.week = week
        state.report_url = report_url
        state.updated_at = os.time()
        state.previous_week = previous_week or ""
        state.previous_items = previous_items or {}
        state.history = history or {}
        state.last = nil
        state.last_build = nil
        if last_url then
          for _, weapon in ipairs(items) do
            if weapon.url == last_url then
              state.last = weapon
              break
            end
          end
        end
        render_hud()
      end

      local change_count = #compute_changes(items, previous_items or {})
      clear_status()
      notify("Week of " .. week .. "  |  " .. #items .. " weapons"
        .. (change_count > 0 and ("  |  " .. change_count .. " changes") or ""), {
        title = game.label .. " updated",
        duration = 4500,
      })
      local alerts = watchlist_alerts(items, previous_items or {}, game.key)
      if #alerts > 0 then
        notify(table.concat(alerts, "  |  "), {
          title = "Watchlist",
          duration = 6500,
        })
      end
      rb.log.info("CODMunity " .. game.label .. " meta updated: " .. week .. " (" .. #items .. " weapons)")
    end)

    if not ok then
      clear_status()
      rb.log.warn("CODMunity " .. game.label .. " meta update failed: " .. tostring(err))
      notify(#state.items > 0 and "Using saved meta data" or "No meta data; check network", {
        title = game.label .. " update failed",
        duration = 5000,
      })
    end
    state.refreshing = false
  end)
end

sync_game = function(force)
  apply_theme()
  close_help()
  local game = selected_game()
  if state.game_key ~= game.key then
    if state.menu then
      pcall(function()
        state.menu:close()
      end)
      state.menu = nil
    end
    remove_surface(state.detail)
    state.detail = nil
    remove_compare()
    remove_surface(state.changes_surface)
    state.changes_surface = nil
    remove_surface(state.hud_surface)
    state.hud_surface = nil

    state.game_key = game.key
    state.game = game
    state.items = {}
    state.week = ""
    state.report_url = ""
    state.updated_at = 0
    state.previous_week = ""
    state.previous_items = {}
    state.history = {}
    state.last = nil
    state.last_build = nil
    state.last_copied_code = nil

    local loaded = load_cache(game)
    if loaded then
      rb.log.info("CODMunity " .. game.label .. " cache loaded: " .. tostring(state.week))
    end
    render_hud()
  end

  local stale = state.updated_at == 0 or (os.time() - state.updated_at >= MIN_REFRESH_SECONDS)
  if force or stale then
    refresh(game, force)
  end
end

rb.bind(cfg.open_key, open_menu)
rb.bind(cfg.search_key, function()
  if open_search then
    open_search()
  end
end)
rb.bind(cfg.hud_key, function()
  toggle_hud()
end)
rb.bind(cfg.compare_key, function()
  toggle_compare()
end)
rb.bind(cfg.favorite_key, function()
  if state.search then
    close_search()
  end
  if sync_game then
    sync_game(false)
  end
  toggle_favorite(state.last or state.items[1])
end)
rb.bind(cfg.build_key, function()
  if state.compare_surface then
    open_compare_actions()
  else
    open_build_menu()
  end
end)
rb.bind(cfg.refresh_key, function()
  if sync_game then
    sync_game(true)
  end
end)
rb.bind(cfg.copy_key, function()
  if state.compare_surface then
    copy_compare_side("A", true)
  else
    copy_last()
  end
end)
rb.bind(cfg.full_copy_key, function()
  if state.compare_surface then
    copy_compare_side("B", true)
  else
    copy_full_loadout()
  end
end)
rb.bind(cfg.help_key, show_help)
rb.bind(cfg.extras_key, open_extras)

rb.on("start", function()
  math.randomseed(os.time())
  load_user_state()
  load_index()
  if sync_game then
    sync_game(false)
  end
  ensure_index()
end)

rb.on("stop", function()
  state.menu = nil
  close_search()
  close_help()
  remove_surface(state.detail)
  state.detail = nil
  remove_compare()
  remove_surface(state.changes_surface)
  state.changes_surface = nil
  remove_surface(state.hud_surface)
  state.hud_surface = nil
  clear_status()
end)

2 files without text previews