-- Order-book acquisition (protocol/link-v1.md "Scanning", strip-v1 kind 3).
-- Read-only: ReplicateItems does not change the player's Browse list. A missing API or a
-- throttled/no-answer request sends no market data. Browse events are only used passively
-- for catalogue names. Forever replicate behavior remains UNVERIFIED; see the API ledger.
local _, ns = ...

local Auction, Strip, Links = ns.Auction, ns.Strip, ns.Links
local try, plain, Print = ns.try, ns.plain, ns.Print
local issecret = issecretvalue

local AH = C_AuctionHouse
-- GetReplicateItemTimeLeft is optional (a missing one sends "time left unknown").
local REPLICATE = {
  "ReplicateItems",
  "GetNumReplicateItems",
  "GetReplicateItemInfo",
  "GetReplicateItemBattlePetInfo",
  "GetReplicateItemLink",
}
local HOOKS = { "SendBrowseQuery", "SearchForFavorites", "SearchForItemKeys" }
local R = Strip.RESULT
local PHASE = Strip.PHASE

local open, shownAt, showId = false, nil, 0 -- the auction house window
local running -- the scan in flight: { kind = "replicate", phase = "wait" | "read" | "pack", faction, book, n, i, at }
local last -- the last scan this session: { kind, items, auctions, at, complete, why, failed, sending, replaced }
local lastBook -- the replicate's last outcome that sent nothing: { throttled | empty | cancelled = true, at }
local result = R.none -- the strip's `lastResult`
local ownAt, ownBookAt -- server time of this session's last own scan / order book
local lastAuto -- server time of this session's last automatic scan
local lastReplicate -- server time of this session's last ReplicateItems call
local replicateEvent = true -- REPLICATE_ITEM_LIST_UPDATE registered
local lastForeign -- GetTime() of the last browse we didn't send
local lastSearch -- GetTime() of the player's own last search (not the game's favourites search)
local backoffUntil -- GetTime() before which no automatic scan starts
local backoffStep = 0 -- back-offs in a row (reset by a complete scan)
local lastLinks -- the last book's link counters (Auction.newLinkStats), this session only
local liveOf = 0 -- the window opening (showId) whose 30 s checks are running, 0 = none

local function Clock()
  return GetTime and GetTime() or 0
end

local function Now()
  local t = plain(try("GetServerTime", GetServerTime))
  return type(t) == "number" and t > 0 and t or nil
end

local function After(secs, fn)
  if C_Timer and C_Timer.After then
    C_Timer.After(secs, fn)
    return true
  end
  ns.missing["C_Timer.After"] = true
  return false
end

-- "Alliance" or "Horde", or nil.
local function Faction()
  local f = plain(try("UnitFactionGroup", UnitFactionGroup, "player"))
  return Auction.FACTIONS[f] and f or nil
end

local function Settings()
  return Auction.settings(ns.appLoaded, ns.appSettings)
end

-- The first API a scan needs that this client lacks, or nil.
local function Missing()
  if type(AH) ~= "table" then
    return "C_AuctionHouse"
  end
  if not GetServerTime then
    return "GetServerTime"
  end
  return nil
end

do
  local miss = Missing()
  if miss then
    ns.missing[miss] = true
  end
end

-- The first replicate API this client lacks, or nil.
local function MissingReplicate()
  if Missing() then
    return Missing()
  end
  for _, name in ipairs(REPLICATE) do
    if type(AH[name]) ~= "function" then
      return "C_AuctionHouse." .. name
    end
  end
  if not replicateEvent then
    return "REPLICATE_ITEM_LIST_UPDATE"
  end
  return nil
end

local function CanReplicate()
  local miss = MissingReplicate()
  if miss and Settings().book then
    ns.missing[miss] = true
  end
  return miss == nil
end

-- Seconds left of the back-off after a sign of the game's throttle (nil: none).
local function BackoffLeft()
  local left = backoffUntil and backoffUntil - Clock()
  return left and left > 0 and left or nil
end

-- Seconds since the player's own last search (nil: none this session).
local function Quiet()
  return lastSearch and Clock() - lastSearch or nil
end

-- Whether the game's throttle lets a query of ours go now: IsThrottledMessageSystemReady() isn't
-- false (a client without it, or a call that fails or reads secret: as before, yes).
local function Ready()
  local fn = type(AH) == "table" and AH.IsThrottledMessageSystemReady
  if type(fn) ~= "function" then
    return true
  end
  local ok, ready = pcall(fn)
  return not ok or plain(ready) ~= false
end

-- A sign of the game's throttle: back automatic scans off (stale x 2^n; a sign during a back-off
-- doesn't add to it).
local function Throttled()
  if not BackoffLeft() then
    backoffStep = backoffStep + 1
    backoffUntil = Clock() + Auction.backoff(backoffStep, Settings().stale)
  end
end

-- What Auction.shouldScan and shouldRescan decide on.
local function State()
  local m = ns.PriceMarket and ns.PriceMarket()
  return {
    settings = Settings(),
    standingDown = ns.standingDown,
    backoff = BackoffLeft(),
    quiet = Quiet(),
    throttling = not Ready(),
    faction = Faction(),
    scanAt = m and m.scanAt,
    bookAt = m and m.bookAt,
    ownAt = ownAt,
    ownBookAt = ownBookAt,
    lastAuto = lastAuto,
    lastReplicate = lastReplicate,
    canReplicate = CanReplicate(),
    now = Now() or 0,
    combat = InCombatLockdown and InCombatLockdown() or false,
    running = running ~= nil,
  }
end

local function Decide()
  if Missing() then
    return false, "missing"
  end
  return Auction.shouldScan(State())
end

-- A copy of the browse results so far, plain values only (a secret one reads nil and is skipped).
local function Copy(r)
  local list = plain(try("C_AuctionHouse.GetBrowseResults", AH.GetBrowseResults))
  if type(list) ~= "table" then
    return
  end
  local out = {}
  for i = 1, #list do
    local x = plain(list[i])
    local key = type(x) == "table" and plain(x.itemKey)
    if type(key) == "table" then
      local pet = key.battlePetSpeciesID
      if not issecret or issecret(pet) then
        pet = -1 -- can't tell: left out
      end
      out[#out + 1] = {
        id = plain(key.itemID),
        suffix = plain(key.itemSuffix), -- nil (missing or secret): no suffix
        price = plain(x.minPrice),
        qty = plain(x.totalQuantity),
        pet = pet,
        level = plain(key.itemLevel),
        itemKey = key, -- for GetItemKeyInfo (the catalogue)
      }
    end
  end
  r.results, r.raw = out, #list
end

-- Send `bytes` on the bulk strip for `owner` (a `last` record, which then shows the progress). It
-- replaces a transfer still being sent (strip-v1 encoder rule 3); that one's owner says it was cut
-- short. Returns whether it went out.
local function SendBulk(owner, bytes)
  local bulk = ns.bulk
  if not bulk then
    return false
  end
  local prev = bulk:active() and last ~= owner and last or nil
  if bulk:active() and ns.CatalogueCutShort then
    ns.CatalogueCutShort(bulk:progress().transfer)
  end
  if not bulk:start(math.random(1, 65535), bytes) then
    return false
  end
  if prev then
    prev.replaced = true
  end
  owner.sending = true
  return true
end

-- The book is packed: send it and update the prices.
local function Send(r, bytes, entries, auctions)
  running = nil
  local at, book = r.at, r.book
  if not bytes then
    lastBook, result = { empty = true, at = at }, R.empty
    return
  end
  last = {
    kind = "book",
    auctions = auctions,
    entries = entries,
    items = book:itemCount(),
    at = at,
    complete = r.complete,
    why = r.why,
  }
  result = r.complete and R.complete or R[r.why] or R.error
  SendBulk(last, bytes)
  ownAt, ownBookAt = at, at
  if r.complete then
    backoffStep, backoffUntil = 0, nil
  end
  if ns.PriceApplyScan then
    ns.PriceApplyScan(book:prices(), at, r.complete, r.faction)
  end
end

-- Pack PACK_BATCH items per frame (a big book would stall one frame).
local function Pack(r)
  if running ~= r then
    return
  end
  local done, bytes, entries, auctions = r.step(Auction.PACK_BATCH)
  r.packed = (r.packed or 0) + Auction.PACK_BATCH
  if done then
    Send(r, bytes, entries, auctions)
  elseif not After(0, function()
    Pack(r)
  end) then
    Pack(r)
  end
end

-- The read is over (or cut short): pack the book, then send it.
local function FinishBook(r, complete, why)
  if running ~= r or r.phase ~= "read" then
    return
  end
  r.phase, r.complete, r.why = "pack", complete, why
  r.at = r.at or Now() or 0
  r.step = r.book:encoder(r.at, Auction.FACTIONS[r.faction], complete)
  Pack(r)
end

-- GetReplicateItemInfo's count, buyoutPrice and itemID (returns 3, 10 and 17); the owner and bidder
-- names are dropped unread.
local function pick(_, _, count, _, _, _, _, _, _, buyout, _, _, _, _, _, _, id)
  return count, buyout, id
end

-- The suffix id of auction `i`'s link (nil: none, or no link), counted in `st`.
local function SuffixOf(i, st)
  local l = plain(AH.GetReplicateItemLink(i))
  local _, s, from, bonus = Links.parse(l)
  Auction.countLink(st, l, from, bonus)
  return s ~= 0 and s or nil
end

-- Read up to READ_BATCH indices (0-based) into the book.
local function ReadSome(r)
  local info, left, pet = AH.GetReplicateItemInfo, AH.GetReplicateItemTimeLeft, AH.GetReplicateItemBattlePetInfo
  local stop = math.min(r.i + Auction.READ_BATCH, r.n) - 1
  local book = r.book
  for i = r.i, stop do
    local creature = pet(i)
    -- nil or 0 means an item; a pet is keyed by the species in its link (none: left out); a secret
    -- one can't be told and is left out too.
    local secret = not issecret or issecret(creature)
    local isPet = not secret and creature ~= nil and creature ~= 0
    if not secret then
      local count, buyout, id = pick(info(i))
      local species, suffix = nil, nil
      if isPet then
        species = Links.petSpecies(plain(AH.GetReplicateItemLink(i))) or true
      else
        suffix = SuffixOf(i, r.links)
      end
      book:add(plain(id), plain(count), plain(buyout), left and plain(left(i)), species, suffix)
    end
    r.i = i + 1
  end
end

local function ReadBatch(r)
  if running ~= r or r.phase ~= "read" then
    return
  end
  local ok, err = pcall(ReadSome, r)
  if not ok then
    ns.errors["C_AuctionHouse.GetReplicateItemInfo"] = ns.errors["C_AuctionHouse.GetReplicateItemInfo"] or tostring(err)
    FinishBook(r, false, "error")
  elseif r.i >= r.n then
    FinishBook(r, true)
  elseif not After(0, function()
    ReadBatch(r)
  end) then
    ReadBatch(r)
  end
end

-- No answer: wait for the next permitted book attempt. Never export a browse summary.
local function NoBook()
  lastBook, result = { throttled = true, at = Now() or 0 }, R.replicateThrottled
  running = nil
end

local function Replicate(r)
  r.kind, r.phase = "replicate", "wait"
  running = r
  lastReplicate = Now()
  local ok, err = pcall(AH.ReplicateItems)
  if not ok then
    ns.errors["C_AuctionHouse.ReplicateItems"] = ns.errors["C_AuctionHouse.ReplicateItems"] or tostring(err)
    NoBook()
    return running ~= nil
  end
  if
    not After(Auction.REPLICATE_WAIT, function()
      if running == r and r.phase == "wait" then
        NoBook()
      end
    end)
  then
    NoBook() -- without timers there's no waiting for the answer
  end
  return true
end

-- Start a book acquisition. Returns whether it went out.
local function Start(auto, mode)
  local miss = MissingReplicate()
  if miss then
    ns.missing[miss] = true
    return false
  end
  local faction = Faction()
  if not faction then
    return false
  end
  if not Ready() then
    return false -- the game's throttle: skipped, not delayed
  end
  if auto then
    lastAuto = Now()
  end
  local r = { faction = faction }
  if mode == "replicate" and not MissingReplicate() then
    return Replicate(r)
  end
  return false
end

-- REPLICATE_ITEM_LIST_UPDATE: the answer, when it has auctions. Read them from the next frame on.
local function OnReplicate()
  local r = running
  if not r or r.kind ~= "replicate" or r.phase ~= "wait" then
    return
  end
  local n = plain(try("C_AuctionHouse.GetNumReplicateItems", AH.GetNumReplicateItems))
  if type(n) ~= "number" or n < 1 then
    return -- not yet (a throttled call never answers; the timer stops waiting)
  end
  r.phase, r.n, r.i, r.at, r.book = "read", math.floor(n), 0, Now(), Strip.newBook()
  r.links = Auction.newLinkStats()
  lastLinks = r.links
  if not After(0, function()
    ReadBatch(r)
  end) then
    ReadBatch(r)
  end
end

-- Catalogue names from the player's own browse only. This never starts or uploads a browse.
local function OnResults()
  if not open or not ns.CatalogueCollect or type(AH.GetBrowseResults) ~= "function" then
    return
  end
  local r = {}
  Copy(r)
  ns.CatalogueCollect(r.results, Faction())
end

-- A browse we didn't send: the player searched (or the game's window did). The player wins.
local function Foreign()
  lastForeign = Clock()
  if not (open and shownAt and lastForeign <= shownAt + Auction.GRACE) then
    lastSearch = lastForeign -- the player's own (not the game's favourites search on show)
  end
end

if type(AH) == "table" and hooksecurefunc then
  for _, name in ipairs(HOOKS) do
    if type(AH[name]) == "function" then
      local ok, err = pcall(hooksecurefunc, AH, name, Foreign)
      if not ok then
        ns.errors["hooksecurefunc(C_AuctionHouse." .. name .. ")"] = tostring(err)
      end
    else
      ns.missing["C_AuctionHouse." .. name] = true
    end
  end
elseif not hooksecurefunc then
  ns.missing.hooksecurefunc = true
end

---------------------------------------------------------------------------------------------------
-- Events

local events = CreateFrame("Frame")
local handlers = {}

local function Sending()
  return ns.bulk ~= nil and ns.bulk:active()
end

-- One check while the window stays open, then the next LIVE_EVERY later; the checks end when the
-- window closes (or opens again: a new opening runs its own).
local function LiveCheck(id)
  if not open or id ~= showId then
    if liveOf == id then
      liveOf = 0
    end
    return
  end
  if not After(Auction.LIVE_EVERY, function()
    LiveCheck(id)
  end) then
    liveOf = 0
    return
  end
  if Missing() then
    return
  end
  local o = State()
  o.sending = Sending()
  local ok, mode = Auction.shouldRescan(o)
  if ok then
    Start(true, mode)
  end
end

local function StartLive()
  local s = Settings()
  if not (s.live and s.scan) or liveOf == showId then
    return
  end
  local id = showId
  liveOf = id
  if not After(Auction.LIVE_EVERY, function()
    LiveCheck(id)
  end) then
    liveOf = 0
  end
end

function handlers.AUCTION_HOUSE_SHOW()
  open, shownAt, showId = true, Clock(), showId + 1
  StartLive()
  if not Decide() then
    return
  end
  local id = showId
  local function go()
    if not open or id ~= showId or running then
      return
    end
    if lastForeign and lastForeign > shownAt + Auction.GRACE then
      return -- the player searched in the first seconds: theirs wins, no scan this time
    end
    local ok, mode = Decide()
    if ok then
      Start(true, mode)
    end
  end
  if not After(Auction.DELAY, go) then
    go()
  end
end

function handlers.AUCTION_HOUSE_CLOSED()
  open = false
  local r = running
  if not r then
    return
  elseif r.phase == "read" then
    FinishBook(r, false, "closed") -- what was read so far, not complete
  elseif r.phase == "wait" then
    running = nil -- no answer yet: nothing to read
    lastBook = { cancelled = true, at = Now() or 0 }
  end
end

handlers.REPLICATE_ITEM_LIST_UPDATE = OnReplicate
handlers.AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = OnResults
handlers.AUCTION_HOUSE_BROWSE_RESULTS_ADDED = OnResults

-- A dropped message backs off; a waiting replicate can safely stop without sending data.
local function ThrottleSign(dropped)
  if not dropped then
    return
  end
  Throttled()
  if running and running.phase == "wait" then
    NoBook()
  end
end

function handlers.AUCTION_HOUSE_THROTTLED_MESSAGE_QUEUED()
  ThrottleSign(false)
end

function handlers.AUCTION_HOUSE_THROTTLED_MESSAGE_DROPPED()
  ThrottleSign(true)
end

events:SetScript("OnEvent", function(_, event, ...)
  local fn = handlers[event]
  if fn then
    fn(...)
  end
end)
for _, event in ipairs({
  "AUCTION_HOUSE_SHOW",
  "AUCTION_HOUSE_CLOSED",
  "AUCTION_HOUSE_BROWSE_RESULTS_UPDATED",
  "AUCTION_HOUSE_BROWSE_RESULTS_ADDED",
  "AUCTION_HOUSE_THROTTLED_MESSAGE_QUEUED",
  "AUCTION_HOUSE_THROTTLED_MESSAGE_DROPPED",
}) do
  if not pcall(events.RegisterEvent, events, event) then
    ns.failedEvents[#ns.failedEvents + 1] = event
  end
end
-- The replicate's answer; an unknown event leaves acquisition off.
if not pcall(events.RegisterEvent, events, "REPLICATE_ITEM_LIST_UPDATE") then
  ns.failedEvents[#ns.failedEvents + 1] = "REPLICATE_ITEM_LIST_UPDATE"
  replicateEvent = false
end

---------------------------------------------------------------------------------------------------
-- The strip and /ahf

local F = Strip.FLAGS

--- The strip's scan fields (Main.lua, every update); returns the flags this module owns.
function ns.FillAuctionState(w)
  local s = Settings()
  local r = running
  local phase, progress, total = PHASE.idle, 0, 0
  if r and r.kind == "replicate" then
    if r.phase == "read" then
      phase, progress, total = PHASE.read, r.i or 0, r.n or 0
    elseif r.phase == "pack" then
      phase, progress, total = PHASE.pack, r.packed or 0, r.book and r.book:itemCount() or 0
    else
      phase = PHASE.wait
    end
  end
  w.phase, w.progress, w.total = phase, progress, total
  w.lastResult = result
  w.lastScanAt = ownAt or 0
  w.lastBookAt = ownBookAt or 0
  w.lastItems = last and last.items or 0
  w.backoff = BackoffLeft() or 0
  w.stale = math.floor(s.stale / 60)
  local flags = (open and F.ahOpen or 0) + (s.scan and F.scanOn or 0) + (s.book and F.bookOn or 0)
  flags = flags
    + (s.live and F.liveOn or 0)
    + (Ready() and 0 or F.throttled)
    + (MissingReplicate() and F.missingApi or 0)
  flags = flags + (ns.appLoaded and F.appLoaded or 0)
  return flags
end

local function Lines()
  local m = ns.PriceMarket and ns.PriceMarket()
  local now = Now() or 0
  local l = last
  if l and l.sending and ns.bulk then
    l.progress = ns.bulk:progress()
  end
  local r = running
  local b = lastBook
  if r and r.kind == "replicate" then
    if r.phase == "read" then
      b = { reading = r.i, of = r.n }
    elseif r.phase == "pack" then
      b = { packing = r.book.auctions }
    else
      b = { waiting = true }
    end
  end
  return Auction.describe({
    settings = Settings(),
    standingDown = ns.standingDown,
    label = m and m.label,
    scanAt = m and m.scanAt,
    bookAt = m and m.bookAt,
    ownAt = ownAt,
    ownBookAt = ownBookAt,
    now = now,
    throttle = BackoffLeft(),
    throttling = not Ready(),
    links = lastLinks,
    last = l,
    book = b,
    nextReplicate = lastReplicate and Auction.REPLICATE_EVERY - (now - lastReplicate) or nil,
    missing = MissingReplicate(),
  })
end
ns.AuctionLines = Lines

--- /ahf scan: scan now with the window open, whatever the market's age (the whole order book when
--- a replicate is allowed now; otherwise wait). Skips the back-off and the 10 s, not the ready check.
function ns.AuctionScanNow()
  if ns.standingDown then
    Print("the Wowzers addon is loaded and scans the auction house; this addon stands down")
  elseif not open then
    Print("open an auctioneer's window first")
  elseif running then
    Print("a scan is already running")
  else
    local replicate, why = Auction.canReplicate({
      settings = Settings(),
      ownBookAt = ownBookAt,
      lastReplicate = lastReplicate,
      canReplicate = CanReplicate(),
      now = Now() or 0,
      forced = true,
    })
    if not replicate then
      Print("waiting for an order book (" .. (MissingReplicate() or why or "unavailable") .. ")")
    elseif Start(false, "replicate") then
      Print("taking the whole order book...")
    elseif not Missing() and not Ready() then
      Print("the game is throttling auction searches; relog clears it")
    else
      Print("can't scan (" .. (Missing() or "faction unknown") .. ")")
    end
  end
end
