--[[ Core/Init.lua — addon table, hand-rolled event dispatcher (spec §5.1: no Ace),
     slash commands. Everything hangs off the single global `BlueprintBazaar`. ]]--

local ADDON, ns = ...
BlueprintBazaar = ns          -- the only global besides BlueprintBazaarDB (spec §8.9)
ns.name = ADDON
ns.version = C_AddOns and C_AddOns.GetAddOnMetadata(ADDON, "Version") or "dev"

-- The 12.1 partial-import wipe bug: Blizzard applied a PTR fix 2026-08-08
-- (Kaivax). Flip to true ONLY if the bug turns out to be alive on live -
-- DetailPane then shows the harsh red warning again instead of the soft one.
ns.BUG_PARTIAL_WIPE = false

-- Where players are pointed to submit their own builds. Kept in one place so
-- it can move without hunting through the UI. A Discord invite only goes here
-- once that server's owner has said yes to the traffic.
-- Housing of Argent Dawn's #blueprints channel. Linked with the server
-- owner's explicit permission (12 Aug). If it ever needs to move, this is the
-- only line to change.
ns.SUBMIT_URL = "discord.gg/rwEVcXXZXZ"

------------------------------------------------------------------------
-- Tiny event dispatcher (~40 lines). ns.events:Register(event, fn) -> handle
------------------------------------------------------------------------
local dispatcher = { frame = CreateFrame("Frame"), handlers = {} }
ns.events = dispatcher

function dispatcher:Register(event, fn)
  if not self.handlers[event] then
    self.handlers[event] = {}
    -- Some events won't exist on all client builds; never hard-error
    pcall(self.frame.RegisterEvent, self.frame, event)
  end
  table.insert(self.handlers[event], fn)
  return fn
end

function dispatcher:Unregister(event, fn)
  local list = self.handlers[event]
  if not list then return end
  for i = #list, 1, -1 do
    if list[i] == fn then table.remove(list, i) end
  end
end

dispatcher.frame:SetScript("OnEvent", function(_, event, ...)
  local list = dispatcher.handlers[event]
  if not list then return end
  for i = 1, #list do
    local ok, err = pcall(list[i], event, ...)
    if not ok then
      geterrorhandler()(("BlueprintBazaar handler error (%s): %s"):format(event, tostring(err)))
    end
  end
end)

-- Internal message bus (same shape, no Blizzard events)
local bus = { handlers = {} }
ns.bus = bus
function bus:On(msg, fn)
  self.handlers[msg] = self.handlers[msg] or {}
  table.insert(self.handlers[msg], fn)
end
function bus:Fire(msg, ...)
  local list = self.handlers[msg]
  if not list then return end
  for i = 1, #list do pcall(list[i], ...) end
end

------------------------------------------------------------------------
-- Slash commands
------------------------------------------------------------------------
SLASH_BLUEPRINTBAZAAR1 = "/bb"
SLASH_BLUEPRINTBAZAAR2 = "/bazaar"
SlashCmdList.BLUEPRINTBAZAAR = function(msg)
  msg = (msg or ""):gsub("^%s+", ""):gsub("%s+$", "")
  local cmd, arg = msg:match("^(%S*)%s*(.-)$")
  cmd = cmd:lower()
  if cmd == "" then
    if ns.UI and ns.UI.Toggle then ns.UI.Toggle() else
      print("|cffc9a227BlueprintBazaar|r UI not built yet (M2). Core is loaded.")
    end
  elseif cmd == "bake" and ns.dev then
    -- §6.1 curation: dump a code's manifest into SavedVariables for the JSON pipeline
    ns.API.Request(arg, function(ok, payload)
      BlueprintBazaarDB.bake = BlueprintBazaarDB.bake or {}
      BlueprintBazaarDB.bake[arg] = { ok = ok, payload = payload, at = date("%c") }
      print("|cffc9a227BlueprintBazaar|r baked " .. arg .. " -> SavedVariables (ok=" .. tostring(ok) .. "). /reload to flush.")
    end)
  elseif cmd == "bakeall" then
    -- Curation harvest: request every library code through the polite queue
    -- and store each server reply in SavedVariables. The replies carry the
    -- full manifest and the real budget costs - everything the JSON entries
    -- are missing. /reload afterwards to flush to disk.
    -- An optional argument narrows the harvest to matching ids, so a single
    -- new build can be baked without re-requesting the whole library.
    local all = (ns.Library and ns.Library.entries) or {}
    local entries = all
    if arg and arg ~= "" then
      local needle = arg:lower()
      entries = {}
      for _, e in ipairs(all) do
        if e.id and e.id:lower():find(needle, 1, true) then
          entries[#entries + 1] = e
        end
      end
      if #entries == 0 then
        print("|cffc9a227BlueprintBazaar|r no library entry matches '" .. arg .. "'.")
        return
      end
    end
    if #entries == 0 then
      print("|cffc9a227BlueprintBazaar|r library is empty, nothing to bake.")
      return
    end
    BlueprintBazaarDB.bake = BlueprintBazaarDB.bake or {}
    local done, total = 0, #entries
    print("|cffc9a227BlueprintBazaar|r baking " .. total .. " builds...")
    local function bakeNext(i)
      local e = entries[i]
      if not e then
        print("|cffc9a227BlueprintBazaar|r bake complete: " .. done .. "/" ..
          total .. " ok. /reload to flush to disk.")
        return
      end
      ns.API.Request(e.code, function(ok, payload)
        BlueprintBazaarDB.bake[e.id] = { ok = ok, code = e.code,
          payload = ok and payload or tostring(payload), at = date("%c") }
        if ok then done = done + 1 end
        print("  " .. e.id .. " " .. (ok and "|cff40ff40ok|r" or "|cffff5050failed|r")
          .. "  (" .. i .. "/" .. total .. ")")
        bakeNext(i + 1)
      end, true)
    end
    bakeNext(1)
  elseif cmd == "dev" then
    ns.dev = not ns.dev
    print("|cffc9a227BlueprintBazaar|r dev mode: " .. tostring(ns.dev))
  elseif cmd == "diag" and arg == "scan" then
    -- Ask the server for every entry's contents and WRITE THE FLAGS DOWN.
    -- The session cache lives in a local table that never reaches disk, and
    -- the old ["contentsCache"] key in SavedVariables is a leftover nothing
    -- writes any more - which is why verifying builds by hand produced no new
    -- data to read. Requests are staggered because the server answers
    -- TooManyRequests (104) if they all go at once.
    local all = (ns.Library and ns.Library.entries) or {}
    local scan = { at = date("%Y-%m-%d %H:%M:%S"), zone = GetZoneText(),
                   availability = ns.API.CanImport() and 0 or "blocked",
                   results = {} }
    BlueprintBazaarDB.diag = BlueprintBazaarDB.diag or {}
    BlueprintBazaarDB.diag.scan = scan
    local done, total = 0, #all
    print("|cffc9a227BlueprintBazaar|r scanning " .. total .. " builds from "
      .. scan.zone .. "...")
    for i, e in ipairs(all) do
      C_Timer.After(i * 0.35, function()
        ns.API.Request(e.code, function(ok, res)
          done = done + 1
          if ok and type(res) == "table" and res.flags then
            local r = { unmet = res.flags.unmet, blocking = res.flags.blocking }
            -- Cost AND ceiling, per build. The score answers "do you own the
            -- pieces"; it has never answered "will they fit", and every
            -- exterior in the library costs more than the ceiling measured on
            -- this plot. Recording both is how that stops being an inference.
            local bi = res.budgets
            if type(bi) == "table" then
              r.budgets = {}
              for side, list in pairs(bi) do
                if type(list) == "table" then
                  for _, b in pairs(list) do
                    if type(b) == "table" and b.budgetType then
                      r.budgets[side .. ":" .. tostring(b.budgetType)] =
                        tostring(b.cost or "?") .. "/" .. tostring(b.max or "?")
                    end
                  end
                end
              end
            end
            scan.results[e.id] = r
          else
            scan.results[e.id] = { error = tostring(res) }
          end
          if done == total then
            print("|cffc9a227BlueprintBazaar|r scan complete (" .. done
              .. "). Type /reload to write it to disk.")
          end
        end, true)   -- forceRefresh: a stale answer is the thing we are chasing
      end)
    end
  elseif cmd == "diag" then
    -- Writes to SavedVariables instead of printing, because the chat frame
    -- never reaches disk - /dump and /run answer into a window nobody can
    -- read afterwards. Run this, then /reload, and the answers are in
    -- WTF\...\SavedVariables\BlueprintBazaar.lua under ["diag"].
    -- Doubles as the thing to ask a player for when they report a failure.
    local d = {}
    BlueprintBazaarDB.diag = d
    d.at = date("%Y-%m-%d %H:%M:%S")
    d.version = C_AddOns and C_AddOns.GetAddOnMetadata
      and C_AddOns.GetAddOnMetadata("BlueprintBazaar", "Version") or "?"
    d.zone = GetZoneText()
    d.subzone = GetSubZoneText()
    -- Every housing enum the client exposes, flattened name -> number, so the
    -- flag enums can be decoded without guessing at bit meanings.
    d.enums = {}
    if type(Enum) == "table" then
      for k, v in pairs(Enum) do
        if type(k) == "string" and k:find("Housing") then
          if type(v) == "table" then
            local t = {}
            for name, val in pairs(v) do t[tostring(name)] = val end
            d.enums[k] = t
          else
            d.enums[k] = tostring(v)
          end
        end
      end
    end
    -- The decisive one: does the pre-flight check exist on live at all, and
    -- what does it say from where the player is standing right now?
    local BP = C_HousingBlueprint
    if not (BP and BP.GetImportAvailability) then
      d.importAvailability = "FUNCTION MISSING"
    else
      local ok, res = pcall(BP.GetImportAvailability)
      d.importAvailability = ok and res or ("ERROR: " .. tostring(res))
      d.importAvailabilityMeans = ns.Result.Explain(d.importAvailability)
        or "Success - import is possible here"
    end
    -- Is there a DIRECT house level on live, or only ceilings to infer one
    -- from? Call the candidates and write down exactly what comes back, so
    -- the level shown in the UI can be the game's own number and never a
    -- table lookup that Blizzard could silently invalidate.
    local function dump(v, depth)
      depth = depth or 0
      if type(v) ~= "table" then return tostring(v) end
      if depth > 2 then return "{...}" end
      local parts = {}
      for k, x in pairs(v) do
        parts[#parts + 1] = tostring(k) .. "=" .. dump(x, depth + 1)
      end
      table.sort(parts)
      return "{" .. table.concat(parts, ", ") .. "}"
    end
    d.house = {}
    if type(C_Housing) == "table" then
      local fns = {}
      for k, v in pairs(C_Housing) do
        if type(v) == "function" then fns[#fns + 1] = k end
      end
      table.sort(fns)
      d.house.functions = fns
      local function try(name, ...)
        local fn = C_Housing[name]
        if not fn then d.house[name] = "MISSING"; return end
        local ok, a, b, c = pcall(fn, ...)
        if not ok then d.house[name] = "ERROR: " .. tostring(a); return nil end
        d.house[name] = dump(a) .. (b ~= nil and (", " .. dump(b)) or "")
          .. (c ~= nil and (", " .. dump(c)) or "")
        return a
      end
      local info = try("GetCurrentHouseInfo")
      try("GetMaxHouseLevel")
      try("GetPlayerOwnedHouses")
      try("GetHousingAccessFlags")
      local guid = type(info) == "table" and info.houseGUID or nil
      if guid then
        try("GetCurrentHouseLevelFavor", guid)
        try("GetHouseLevelFavorForLevel", 1)
      else
        try("GetCurrentHouseLevelFavor")
      end
    else
      d.house = "C_Housing MISSING"
    end
    -- Which C_HousingBlueprint calls this client actually has.
    d.functions = {}
    if type(BP) == "table" then
      for k, v in pairs(BP) do
        if type(v) == "function" then d.functions[#d.functions + 1] = k end
      end
      table.sort(d.functions)
    end
    print("|cffc9a227BlueprintBazaar|r diagnostics written. Now type /reload, "
      .. "then the answers are in SavedVariables under \"diag\".")
  elseif cmd == "probe" and ns.dev then
    -- Why does the catalog say "unknown" for dye? Before adding background
    -- traffic to work around it, find out whether the count is simply sitting
    -- somewhere we never looked. Prints every field the catalog and the item
    -- API will give us for one record. Default: Black Housing Dye.
    local rid = tonumber(arg) or 274464
    print("|cffc9a227BlueprintBazaar|r probing record " .. rid
      .. " -> SavedVariables (/reload to flush, chat clips long lines)")
    local CC = C_HousingCatalog
    if not CC then print("  no C_HousingCatalog"); return end
    -- Chat truncates from the left and screenshots of it cannot be trusted,
    -- so the full answer goes to disk where it can be read exactly.
    BlueprintBazaarDB.probe = BlueprintBazaarDB.probe or {}
    local rec = { recordID = rid, at = date("%c"), entries = {} }
    BlueprintBazaarDB.probe[tostring(rid)] = rec
    for entryType = 0, 6 do
      local ok, info = pcall(CC.GetCatalogEntryInfoByRecordID, entryType, rid)
      if ok and type(info) == "table" then
        local copy = {}
        for k, v in pairs(info) do
          if type(v) ~= "table" then copy[tostring(k)] = tostring(v) end
        end
        rec.entries["type" .. entryType] = copy
      end
    end
    local okC, n = pcall(CC.GetDecorTotalOwnedCount, rid)
    rec.decorTotalOwnedCount = okC and tostring(n) or "error"
    for _, fname in ipairs({ "GetDecorStoredCount", "GetDecorPlacedCount",
                             "GetDecorMaxOwnedCount", "IsDecorOwned",
                             "GetCatalogEntryInfo" }) do
      if CC[fname] then
        local ok2, r2 = pcall(CC[fname], rid)
        rec[fname] = ok2 and tostring(r2) or "error"
      else
        rec[fname] = "missing"
      end
    end
    for entryType = 0, 6 do
      local ok, info = pcall(CC.GetCatalogEntryInfoByRecordID, entryType, rid)
      if ok and type(info) == "table" then
        local fields = {}
        for k, v in pairs(info) do
          if type(v) ~= "table" then
            fields[#fields + 1] = tostring(k) .. "=" .. tostring(v)
          end
        end
        table.sort(fields)
        -- Four per line: a single long line gets clipped off the left edge of
        -- the chat frame, and the clipped part is where the answer hides.
        print("  entryType " .. entryType .. ":")
        for i = 1, #fields, 4 do
          print("    " .. table.concat(fields, ", ", i, math.min(i + 3, #fields)))
        end
      end
    end
    local owned = CC.GetDecorTotalOwnedCount and select(2, pcall(CC.GetDecorTotalOwnedCount, rid))
    print("  GetDecorTotalOwnedCount -> " .. tostring(owned))
    local info = ns.Catalog.InfoByRecord(rid)
    local itemID = info and info.itemID
    print("  itemID -> " .. tostring(itemID))
    if itemID and C_Item then
      local c1 = C_Item.GetItemCount and select(2, pcall(C_Item.GetItemCount, itemID, true, false, true, true))
      print("  C_Item.GetItemCount(bank+warband) -> " .. tostring(c1))
    end
  elseif cmd == "dyescan" and ns.dev then
    -- Dye has no catalog entry, but it is still an item somewhere. If the
    -- dyes sit in bags or the warband bank as ordinary items, their itemIDs
    -- can be mapped once and counted locally forever after - and the last
    -- thing Verify is needed for disappears.
    BlueprintBazaarDB.dyescan = { at = date("%c"), found = {} }
    local found = BlueprintBazaarDB.dyescan.found
    for bag = 0, 13 do
      local slots = C_Container and C_Container.GetContainerNumSlots(bag) or 0
      for slot = 1, slots do
        local info = C_Container.GetContainerItemInfo(bag, slot)
        if info and info.itemName and info.itemName:find("Dye", 1, true) then
          found[info.itemName] = { itemID = tostring(info.itemID),
            stack = tostring(info.stackCount),
            total = tostring(C_Item.GetItemCount(info.itemID, true, false, true, true)) }
        end
      end
    end
    local n = 0
    for name, d in pairs(found) do
      n = n + 1
      print(("  %s  itemID=%s  total=%s"):format(name, d.itemID, d.total))
    end
    print("|cffc9a227BlueprintBazaar|r dye scan: " .. n
      .. " found -> SavedVariables (/reload to flush)")
  elseif cmd == "diff" and ns.dev then
    -- Where does our local score disagree with the server's? Prints only the
    -- lines that differ, so a wrong percentage points straight at the records
    -- the catalog is answering badly instead of at a theory.
    local entry
    for _, e in ipairs((ns.Library and ns.Library.entries) or {}) do
      if e.id == arg or (e.title and e.title:lower():find(arg:lower(), 1, true)) then
        entry = e; break
      end
    end
    if not entry then print("|cffc9a227BlueprintBazaar|r no build matches '" .. arg .. "'"); return end
    print("|cffc9a227BlueprintBazaar|r comparing " .. entry.id .. " " .. (entry.title or ""))
    ns.API.Request(entry.code, function(ok, live)
      if not ok then print("  server said: " .. tostring(live)); return end
      local serverMissing = {}
      for _, e in ipairs(live.entries or {}) do
        serverMissing[e.recordID] = { missing = e.missing or 0, qty = e.qty or 1, name = e.name }
      end
      local shown = 0
      for _, m in ipairs(entry.manifest or {}) do
        local rid = m.recordID or m.entryID
        local need = m.qty or 1
        local haveLocal = ns.Catalog.OwnedByRecord(rid)
        local srv = serverMissing[rid]
        local ownedServer = srv and (srv.qty - srv.missing) or nil
        local ownedLocal = haveLocal and math.min(haveLocal, need) or nil
        if ownedServer and ownedLocal ~= ownedServer then
          shown = shown + 1
          print(("  %s (%d): local %s, server owns %d of %d")
            :format(m.name or ("record " .. tostring(rid)), rid,
              haveLocal == nil and "|cffff5050UNKNOWN|r" or tostring(haveLocal),
              ownedServer, need))
        end
      end
      if shown == 0 then print("  no disagreements - local matches the server") end
    end, true)
  elseif cmd == "mock" and arg ~= "off" then
    -- M2 acceptance test: 500 generated entries, smooth scroll expected
    local n = tonumber(arg) or 500
    if ns._realLibrary == nil then ns._realLibrary = ns.Library end
    local types = { "house", "interior", "exterior", "room" }
    local entries = {}
    for i = 1, n do
      entries[i] = {
        id = "mock-" .. i,
        title = "Mock Blueprint #" .. i,
        author = { name = "Tester" .. (i % 25) },
        addedIn = ("2026-%02d-%02d"):format((i % 12) + 1, (i % 28) + 1),
        type = types[(i % 4) + 1],
        code = ("MOCKCODE%020d=="):format(i),
        tags = { "mock" },
        budget = { interior = (i * 7) % 400, exterior = 0 },
        description = "Generated perf-test entry.",
        mockPct = ((i - 1) % 101) / 100,
        manifest = {},
      }
    end
    ns.Library = { datasetVersion = 0, entries = entries }
    print("|cffc9a227BlueprintBazaar|r mock library: " .. n .. " entries. /bb mock off to restore.")
    if ns.UI and ns.UI.RefreshGrid then ns.UI.RefreshGrid() end
  elseif cmd == "unmock" or (cmd == "mock" and arg == "off") then
    if ns._realLibrary then ns.Library = ns._realLibrary end
    if ns.UI and ns.UI.RefreshGrid then ns.UI.RefreshGrid() end
    print("|cffc9a227BlueprintBazaar|r real library restored.")
  elseif cmd == "code" then
    if ns.UI and ns.UI.InspectCode then ns.UI.InspectCode(arg) end
  elseif cmd == "minimap" then
    if ns.UI and ns.UI.ToggleMinimapButton then ns.UI.ToggleMinimapButton() end
  else
    print("|cffc9a227BlueprintBazaar|r /bb (toggle) | /bb code <sharecode> | /bb minimap | /bb dev | /bb mock [n] | /bb unmock | /bb bakeall [id] | /bb bake <code> (dev)")
  end
end

------------------------------------------------------------------------
-- Lifecycle
------------------------------------------------------------------------
ns.events:Register("ADDON_LOADED", function(_, name)
  if name ~= ADDON then return end
  ns.DB.Init()
  if ns.UI and ns.UI.BuildMinimapButton then
    local ok, err = pcall(ns.UI.BuildMinimapButton)
    if not ok then
      -- a broken minimap button must never take the addon down with it
      geterrorhandler()("BlueprintBazaar minimap button: " .. tostring(err))
    end
  end
  -- API drift check runs before we say anything, so the login line is honest
  local warning = ns.Guard and ns.Guard.CheckAndDescribe()
  if warning then
    print("|cffc9a227BlueprintBazaar|r " .. warning .. " (/bb still opens the library)")
  else
    print("|cffc9a227BlueprintBazaar|r loaded - /bb")   -- spec §7: one login line max
  end
end)
