-- DopingControl scan/engine.lua
-- Scan orchestration: roster snapshot, per-unit reads (reach/aura/gear/
-- weapon), OnUpdate batching (5 units per frame), store
-- assembly and the DC.OnStoreUpdated() completion hook
-- (wired by core/init.lua).
--
-- Triggers:
--   * manual: DC_Scan.Start(force)
--   * READY_CHECK: registered DEFENSIVELY via pcall (unverified as a
--     native client event on 1.12; if the
--     registration throws, the feature silently does not exist). Honors
--     db.readyCheckScan and never fires in test mode (sim store).
--   * roster change (RAID_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED): raid
--     indices are VOLATILE after these events,
--     so a running scan is restarted with a fresh roster snapshot; a
--     finished store is flagged stale via DC.storeInvalid.
-- No UNIT_AURA registration at all: UNIT_AURA is unreliable for raidN
-- tokens on 1.12 -- we scan on demand instead.
--
-- Diagnostics filled here:
--   DC.probeStats  = { scans, unitsPathA, unitsPathB, unitsBoth,
--                      idDisagreements, lastScan = { unitsA, unitsB, both,
--                      disagreements } }
--                    -- cumulative A/B aura-path coverage + ID-set
--                    disagreement counts (scan/aura.lua merge)
--   DC.unknownSeen = { [nameOrSpellKey] = count } -- auras no table knows
--                    (no silent discard, dumped by /dc unknown)
--
-- Last-known cache: DC.lastKnown -- see the
-- "Last-known cache" block below. THE IRON RULE: cached data is DISPLAY-ONLY;
-- it never counts as HAS/MISSING in gaps, ready pills, sums, footer or
-- report. A flask seen 20 minutes ago is not verified preparation.
--
-- RESIST tab data: resistances are read via THREE DIRECT fallback paths
-- (unit token, GUID, GetUnitField descriptor -- see readResists/
-- AssembleResists below), independent of the aura/gear reads, plus a
-- FOURTH gear-reconstruction path for foreign players (added 2026-08-02):
-- the server never transmits UNIT_FIELD_RESISTANCES for anyone but the
-- owning client, so the three direct paths can only ever answer for the
-- own character -- every foreign player's resistances are rebuilt from
-- equipped-item tooltips (scan/hit.lua's per-item cache, scan/resist.lua's
-- pure parser) plus the racial bonus (data/resist.lua DC.RACE_RESIST). All
-- four ride into the store as p.resists/p.resistsRead/p.resistsSource
-- ("token"|"guid"|"field"|"gear"|nil for self).
--
-- HIT tab data: scan/hit.lua sums the +hit of the equipped items from
-- their tooltips -- there is no hit API on this client -- and rides into
-- the store as p.hit (its own hitRead flag lives INSIDE that table) plus
-- p.race, which the display layer needs for the racial weapon-skill
-- estimate of foreign players. The hit read reuses the item links the gear
-- read already fetched, so it costs one extra link call (the waist, which
-- has no enchant column) plus one tooltip build per item that is not in
-- scan/hit.lua's per-item cache. This SAME read now also produces
-- hit.resistGear/resistGearRead (see above), which is why scanOne runs the
-- hit read BEFORE the resist read despite RESIST sitting before HIT on the
-- tab bar.
--
-- WEAPON SKILL is readable for the OWN CHARACTER ONLY, and is stated as
-- such rather than guessed for everyone else: MEASURED via
-- GetSkillLineInfo and stored as hit.skill/hit.skillExact, while foreign
-- weapon skill has no channel and stays an estimate made at display time.
--
-- TALENT HIT (data/talenthit.lua) has TWO sources that meet in the same
-- fields: GetTalentInfo for the own character, and this server's inspect
-- protocol (scan/talents.lua) for everyone else. Both fill
-- hit.talentMelee/talentRanged/talentSpell/talentOffhand/talentSchools
-- with hit.talentsRead = true. A player whose reply has not arrived yet
-- keeps talentsRead = false and zeroed talent fields, and their displayed
-- number EXCLUDES talents until it does.
--
-- Pure Lua 5.0, dofile-loadable offline: NormalizeWeapon /
-- NormalizeWeaponName / AssembleResists / BuildPlayer / NewStore /
-- UpdateLastKnown / SweepLastKnown are pure (offline-tested); everything
-- that touches frames or the WoW API sits behind `if CreateFrame then`.

DopingControl = DopingControl or {}
local DC = DopingControl

DC_Scan = DC_Scan or {}
local E = DC_Scan

E.BATCH = 5  -- units processed per OnUpdate frame

DC.probeStats = DC.probeStats or {
    scans = 0, unitsPathA = 0, unitsPathB = 0, unitsBoth = 0,
    idDisagreements = 0,
    lastScan = { unitsA = 0, unitsB = 0, both = 0, disagreements = 0 },
}
DC.unknownSeen = DC.unknownSeen or {}

-- ==================================================================
-- Pure part
-- ==================================================================

-- NormalizeWeapon(isSelf, v, readable) -> weaponMain (true|false|nil, store shape)
--   self   : v = 1st return of GetWeaponEnchantInfo() (hasMainEnchant);
--            the own unit is always readable => never nil
--   foreign: v = 1st return of GetWeaponEnchantInfo(unit) (SuperWoW): a name
--            string => true. `readable` says whether the unit's main hand
--            could be read at all and is what makes nil decidable.
--
-- Measured 2026-08-02 across a 26-man raid, every unit with a readable main
-- hand: nil came back 14 times, a name 11 times, the empty string NEVER. So
-- the old ""-means-no-imbue contract (documented here as "unverified") is
-- dead code, and nil carries BOTH real cases at once: "carries no imbue" and
-- "we could not read this unit". Treating nil as unknown -- as this function
-- did -- meant the WPN column could never report a gap: all 14 players
-- without a weapon buff showed a grey "?" and were counted as unreadable, so
-- they never reached the gap tally, the footer or a whisper. That is the one
-- thing a raid buff checker exists to say.
--
-- `readable` separates the two: a unit whose main-hand item link resolves is
-- readable, so nil from the enchant call means there is genuinely no imbue
-- (=> false, a real gap). Without the hint the old behaviour stands (nil),
-- which keeps every existing caller and the self path untouched.
function E.NormalizeWeapon(isSelf, v, readable)
    if isSelf then
        if v then
            return true
        end
        return false
    end
    -- Stock 1.12 GetWeaponEnchantInfo takes NO arguments: on a client without
    -- SuperWoW the extra unit is ignored and the first return is the PLAYER's
    -- hasMainHandEnchant (1|nil). Only the SuperWoW name channel answers about
    -- the foreign unit, and it is always string-or-nil (measured: 11 names,
    -- 14 nils, "" never). A non-string therefore says nothing about this unit
    -- => not readable, never "has an imbue".
    if v ~= nil and type(v) ~= "string" then
        return nil
    end
    if v == nil then
        if readable == true then
            return false
        end
        return nil
    end
    if v == "" then
        return false
    end
    return true
end

-- NormalizeWeaponName(v) -> weaponMainName (string|nil, store shape).
-- v = 1st return of GetWeaponEnchantInfo(unit)
-- (SuperWoW name path): non-empty string => the temp-enchant name;
-- anything else => nil (measured in-game: nil at cross-continent
-- distance -- acceptable, the weapon cell stays UNKNOWN per the existing
-- weaponMain rules).
function E.NormalizeWeaponName(v)
    if type(v) == "string" and v ~= "" then
        return v
    end
    return nil
end

-- BuildPlayer(base, online, distance, auraRes, gearRes, weaponMain,
--             weaponMainName, resRes)
--   base    : { name, class (english token), guid|nil, subgroup|nil }
--   online  : bool (roster-first, reach fallback -- merged by the caller)
--   auraRes : nil | { read = bool, auras = { names={}, ids={} },
--             debuffs = nil | { names={}, ids={}, textures={} } }
--             (readability rides on
--             read/aurasRead -- empty debuff sets are a legitimate clean
--             state, never a failure)
--   gearRes : nil | { read = bool, gear = table|nil }
--   weaponMainName : string|nil -- main-hand temp-enchant NAME
--             (self: tooltip scan via
--             DC_Gear.ReadSelfWeaponEnchantName, foreign: SuperWoW
--             GetWeaponEnchantInfo(unit) name); rides into the store as
--             p.weaponMainName -- DC_Model uses it as the weapon HAS
--             detail when set
--   resRes  : nil | { read = bool, resists = table|nil, source = string|nil }
--             (E.AssembleResists' three returns, same read/wrapper
--             convention as auraRes/gearRes) -- rides into the store as
--             p.resists/p.resistsRead/p.resistsSource; independent of
--             aurasRead/gearRead (a resistance read is neither an aura
--             nor a gear read)
--   hitRes  : nil | { hit = table|nil, race = string|nil } (DC_Hit.ReadUnit's
--             two returns). DELIBERATELY SHAPED DIFFERENTLY from the
--             wrappers above: there is no `read` key, because the hit
--             table carries its OWN hitRead flag and rides into the store
--             EVEN WHEN the read failed (p.hit.hitRead == false). Every
--             consumer must therefore test p.hit and p.hit.hitRead --
--             the zeros in a failed read are not a measurement. `race` is
--             the race token, stored as p.race: the display layer needs it
--             to estimate a FOREIGN player's weapon skill (racial
--             specialization), which no API can read.
--
--             Hit-granting AURAS (DC.HIT_AURAS, data/hit.lua) are folded
--             in RIGHT HERE, not inside scan/hit.lua's ReadUnit -- that
--             module only ever sees item tooltips, never the aura scan.
--             No extra client call: p.auras was already assembled above
--             by the SAME scan. The split is kept so the cell tooltip can
--             show every source apart: hit.gearMelee/hit.gearSpell are the
--             tooltip-parsed sums (unchanged from scan/hit.lua),
--             hit.auraMelee/hit.auraRanged/hit.auraSpell are the aura
--             sums, hit.talentMelee/talentRanged/talentSpell/
--             talentOffhand/talentSchools are the TALENT sums the scan
--             filled in for the own character (normalized to zeros here
--             for everyone else, with hit.talentsRead saying which it
--             was), and hit.melee/hit.spell become the TOTAL -- the field
--             every existing display-layer caller already reads.
--             hit.aurasRead mirrors p.aurasRead at the moment this ran, so
--             a failed aura channel shows as "aura sources unknown"
--             (ui/matrix.lua) instead of a silent zero; hit.talentsRead
--             does the same job for talents.
--             Deliberately independent of any expectation/role table --
--             BuildPlayer never sees one -- so a hit-granting aura counts
--             here even when its class-buff column (e.g. EMB) is not
--             expected/active for this player at all.
-- -> one store player entry. Readability rule
-- (binding): online==false => "offline"; else aurasRead==false
-- (aura paths yielded ZERO auras) => "read failed"; else nil.
function E.BuildPlayer(base, online, distance, auraRes, gearRes, weaponMain,
                       weaponMainName, resRes, hitRes)
    local p = {
        name = base.name,
        class = base.class,
        online = (online and true) or false,
        guid = base.guid,
        subgroup = base.subgroup,
        distance = distance,
        aurasRead = false,
        auras = nil,
        weaponMain = weaponMain,
        weaponMainName = weaponMainName,
        gearRead = false,
        gear = nil,
        resistsRead = false,
        resists = nil,
        resistsSource = nil,
        hit = nil,
        race = nil,
        unreadableReason = nil,
    }
    if auraRes and auraRes.read then
        p.aurasRead = true
        p.auras = auraRes.auras
        p.debuffs = auraRes.debuffs
    end
    if gearRes and gearRes.read then
        p.gearRead = true
        p.gear = gearRes.gear
    end
    if resRes and resRes.read then
        p.resistsRead = true
        p.resists = resRes.resists
        p.resistsSource = resRes.source
    end
    -- hit: attached WHATEVER the outcome (see the hitRes doc above) --
    -- readability lives in p.hit.hitRead, not in a wrapper flag
    if hitRes then
        p.hit = hitRes.hit
        p.race = hitRes.race
    end
    -- hit-granting auras (DC.HIT_AURAS, data/hit.lua) folded on top of the
    -- gear sum -- see the hitRes doc above for the field split and why
    -- this runs here rather than in scan/hit.lua
    if p.hit then
        -- SET bonuses are part of the scanned totals but are reported
        -- separately, so the tooltip can print the number as an addition
        -- (items + set bonuses + auras + talents) rather than one merged
        -- figure. "gear" therefore means ITEMS ONLY from here on: the set
        -- part is subtracted out, never counted twice.
        p.hit.setMelee = p.hit.setMelee or 0
        p.hit.setSpell = p.hit.setSpell or 0
        p.hit.setSchools = p.hit.setSchools or {}
        p.hit.gearMelee = (p.hit.melee or 0) - p.hit.setMelee
        p.hit.gearSpell = (p.hit.spell or 0) - p.hit.setSpell
        p.hit.aurasRead = p.aurasRead
        -- TALENTS (data/talenthit.lua): filled from GetTalentInfo for the
        -- own character and from this server's inspect protocol
        -- (scan/talents.lua) for everyone else. Whoever has not answered
        -- YET keeps talentsRead = false and clean zeros. They are
        -- normalized HERE so no consumer has to nil-check them and so a
        -- hand-built or older store (sim, tests) folds identically.
        if p.hit.talentsRead == nil then
            p.hit.talentsRead = false
        end
        p.hit.talentMelee = p.hit.talentMelee or 0
        p.hit.talentRanged = p.hit.talentRanged or 0
        p.hit.talentSpell = p.hit.talentSpell or 0
        p.hit.talentOffhand = p.hit.talentOffhand or 0
        p.hit.talentSchools = p.hit.talentSchools or {}
        -- the aura half only exists when the aura channel worked: a FAILED
        -- read must not leave a zero that reads like a measurement, which
        -- is why auraMelee/auraRanged/auraSpell stay nil there (the cell
        -- tooltip tells "no aura present" from "aura sources unknown" by
        -- exactly this)
        local auraMelee, auraSpell = 0, 0
        if p.aurasRead and DC_Hit and DC_Hit.AuraHit then
            local a = DC_Hit.AuraHit(p.auras and p.auras.names)
            p.hit.auraMelee = a.melee
            p.hit.auraRanged = a.ranged
            p.hit.auraSpell = a.spell
            auraMelee = a.melee
            auraSpell = a.spell
        end
        -- the TOTALS every display-layer caller reads. The ranged column
        -- has no total of its own on purpose: it shares the melee GEAR
        -- number (this client has no separate ranged-hit item stat) but
        -- has its own aura and talent halves, so it is composed as
        -- gearMelee + auraRanged + talentRanged where it is shown.
        p.hit.melee = p.hit.gearMelee + auraMelee + p.hit.talentMelee
        p.hit.spell = p.hit.gearSpell + auraSpell + p.hit.talentSpell
    end
    if not p.online then
        p.unreadableReason = "offline"
    elseif not p.aurasRead then
        p.unreadableReason = "read failed"
    end
    return p
end

-- AssembleResists(isSelf, tokenRows, guidRows, fieldRows, gearFallback)
--   -> resists (table|nil), resistsRead (bool), source (string|nil)
--
-- Three DIRECT read paths, tried in priority order (token > guid > field);
-- each row array may be empty (the WoW-only caller skips a path once an
-- earlier one already delivered usable data -- see scanOne below):
--   tokenRows/guidRows : { school = 2..6, ok = bool, total = number|nil }
--                         (raw pcall(UnitResistance, unitOrGuid, school)
--                         results -- `total` is UnitResistance's 2nd
--                         return; base/bonus/minus are not used here)
--   fieldRows          : { school = 2..6, val = number|nil } (raw
--                         GetUnitField(guid, "resistances") readout,
--                         already split into rows by the caller)
--
-- A path counts as USABLE when at least one of its rows has ok==true (or,
-- for fieldRows, a numeric val) -- the first usable path wins outright,
-- its rows become `resists` (missing schools simply absent from the
-- table; classify() defaults an absent school to 0). Field-path values
-- are trusted ONLY when positive (a lone unverified foreign zero is not
-- accepted from that path -- see the honesty rule below); token/guid
-- values are trusted at face value, including legitimate zeros.
--
-- Honesty rule (binding): resistsRead = true only when
-- isSelf, OR the assembled `resists` contains at least one total > 0. A
-- foreign UnitResistance call can silently return 0 for an out-of-sight/
-- out-of-range unit -- a LONE zero from a foreign read is not trusted (it
-- could be a real 0 resistance or a failed read that happens to default
-- to 0); a MIX with at least one real positive value is. `source` names
-- the path that won ("token"|"guid"|"field") for foreign reads, and is
-- always nil for the self read (self is unconditionally trusted, so the
-- specific path is not interesting -- it is always "token" in practice,
-- UnitResistance("player", school)).
--
-- FOURTH path -- gear reconstruction (added 2026-08-02, see
-- scan/resist.lua and data/resist.lua's headers for the full rationale):
-- the server never transmits UNIT_FIELD_RESISTANCES for a foreign player
-- at all (Player::InitVisibleBits() only sets the bit for the owning
-- client), so paths 1-3 above are not merely "the usual case" for a
-- foreign unit -- they can NEVER succeed for one. This is the only path
-- that can ever answer for a foreign player, tried only once none of the
-- three DIRECT paths produced anything trustworthy.
--   gearFallback : nil, or { resistGear = { [school]=total },
--                  resistGearRead = bool, race = string|nil } -- exactly
--                  DC_Hit.ReadUnit's hit.resistGear/hit.resistGearRead
--                  plus its race return, so the WoW-only caller can just
--                  hand its `hit`/`race` locals through unpacked.
-- Gated on resistGearRead, NOT on "did any school come back positive": a
-- tooltip-based read has no silent-zero ambiguity the way UnitResistance
-- does for an out-of-range foreign unit (a GameTooltip:SetInventoryItem
-- call either returns real lines or it visibly fails, see
-- scan/hit.lua's header) -- once resistGearRead is true, an all-zero
-- gear+racial total is a GENUINE verified zero, not a guess.
-- NEVER taken for the own character (`not isSelf`): the own character's
-- token path is always usable (see the honesty rule above) and must keep
-- priority over any reconstruction, per the addon's binding rule that a
-- MEASURED value always outranks a rebuilt one.
-- Racial bonus (DC_Resist.RaceBonus, data/resist.lua's DC.RACE_RESIST) is
-- added on top here rather than inside scan/hit.lua: it is not a gear
-- fact, and scan/hit.lua's job stops at "what the equipped items say".
-- Existence-guarded like every other DC_Resist call -- without that
-- module this path simply never activates.
function E.AssembleResists(isSelf, tokenRows, guidRows, fieldRows, gearFallback)
    -- `any` decides which path WINS, so it has to mean "this path carried
    -- evidence". For a foreign unit an all-zero row set is not evidence
    -- (see readResists): it would otherwise beat a descriptor path that
    -- actually has the numbers. Zeros are still kept in `out` -- a mixed
    -- read (0 fire, 24 nature) is legitimate and complete.
    local function usableRows(rows)
        local out = {}
        local any = false
        rows = rows or {}
        for i = 1, table.getn(rows) do
            local r = rows[i]
            if r.ok and type(r.total) == "number" then
                out[r.school] = r.total
                if isSelf or r.total > 0 then
                    any = true
                end
            end
        end
        return out, any
    end

    local resists, source
    local tok, tokAny = usableRows(tokenRows)
    if tokAny then
        resists, source = tok, "token"
    else
        local gid, gidAny = usableRows(guidRows)
        if gidAny then
            resists, source = gid, "guid"
        else
            local fld = {}
            local fldAny = false
            local rows = fieldRows or {}
            for i = 1, table.getn(rows) do
                local r = rows[i]
                -- field path: positive values only (see header) --
                -- a zero/nil field entry carries no evidence
                if type(r.val) == "number" and r.val > 0 then
                    fld[r.school] = r.val
                    fldAny = true
                end
            end
            if fldAny then
                resists, source = fld, "field"
            end
        end
    end

    if resists ~= nil then
        local anyPositive = false
        for _, v in pairs(resists) do
            if v > 0 then
                anyPositive = true
            end
        end
        if isSelf or anyPositive then
            if isSelf then
                source = nil
            end
            return resists, true, source
        end
        -- all-zero foreign read from a direct path: falls through to the
        -- gear fallback below exactly like "no direct path answered at
        -- all" -- an untrusted all-zero carries no more evidence than no
        -- read at all (see the honesty rule above)
    end

    -- FOURTH path: gear reconstruction (see the header addendum above) --
    -- never for the own character, and only once resistGearRead proves at
    -- least one item tooltip was actually read this scan.
    if not isSelf and gearFallback and gearFallback.resistGearRead then
        local g = {}
        for school, v in pairs(gearFallback.resistGear or {}) do
            g[school] = v
        end
        if DC_Resist and DC_Resist.RaceBonus then
            local racial = DC_Resist.RaceBonus(gearFallback.race)
            for school, v in pairs(racial) do
                g[school] = (g[school] or 0) + v
            end
        end
        return g, true, "gear"
    end

    return nil, false, nil
end

-- NewStore(players, scanAt) -> store (source = "scan")
function E.NewStore(players, scanAt)
    return {
        scanAt = scanAt,
        source = "scan",
        players = players or {},
    }
end

-- ==================================================================
-- Last-known cache
-- ==================================================================
-- DC.lastKnown[name] = { auras=<auras table>, aurasAt=<GetTime()>,
--   debuffs=, debuffsAt=, gear=<gear table>, gearAt=, weaponMain=,
--   weaponMainName=, weaponAt= }
-- Runtime-only: lives on DC, NEVER in DopingControlDB/SavedVariables.
-- Holds REFERENCES to the store tables (every scan builds fresh tables,
-- so a cache entry never aliases a live player's current data).

DC.lastKnown = DC.lastKnown or {}

-- Hard entry cap for the cap-safety sweep.
E.CACHE_CAP = 80

-- UpdateLastKnown(cache, p, now) -- both halves of the cache rule:
--   * successful channels overwrite the cache channel + its At stamp:
--     aurasRead=true (debuffs ride on the aura read -- they share its
--     success and its stamp), gearRead=true, weaponMain non-nil (false
--     included: a verified "no temp enchant" IS a successful read, and it
--     clears any stale cached name);
--   * failed channels attach the cached references onto the store player:
--     p.lastAuras/p.lastAurasAt, p.lastGear/p.lastGearAt,
--     p.lastWeaponMain/p.lastWeaponMainName/p.lastWeaponAt -- nil when no
--     cache. (No lastDebuffs: the DEBUFFS tab has no last-known channel
--     -- live afflictions only.)
-- Failure never erases an older cache entry, and a failed read never
-- CREATES an entry.
function E.UpdateLastKnown(cache, p, now)
    if not p.name then
        return
    end
    local entry = cache[p.name]
    if p.aurasRead or p.gearRead or p.weaponMain ~= nil then
        if not entry then
            entry = {}
            cache[p.name] = entry
        end
    end
    if p.aurasRead then
        entry.auras = p.auras
        entry.aurasAt = now
        entry.debuffs = p.debuffs
        entry.debuffsAt = now
    end
    if p.gearRead then
        entry.gear = p.gear
        entry.gearAt = now
    end
    if p.weaponMain ~= nil then
        entry.weaponMain = p.weaponMain
        entry.weaponMainName = p.weaponMainName
        entry.weaponAt = now
    end
    if not entry then
        return
    end
    if not p.aurasRead then
        p.lastAuras = entry.auras
        p.lastAurasAt = entry.aurasAt
    end
    if not p.gearRead then
        p.lastGear = entry.gear
        p.lastGearAt = entry.gearAt
    end
    if p.weaponMain == nil then
        p.lastWeaponMain = entry.weaponMain
        p.lastWeaponMainName = entry.weaponMainName
        p.lastWeaponAt = entry.weaponAt
    end
end

-- SweepLastKnown(cache, rosterSet, cap) -- cap-safety eviction (item 1).
-- rosterSet = { [name]=true } of the CURRENT roster. Roster members are
-- never evicted (their rows may need the data right now); once the cache
-- holds more than cap entries, roster-absent entries drop oldest-first
-- (oldest = smallest NEWEST At across the entry's channels; name is the
-- deterministic tiebreaker) until the size is back at cap.
function E.SweepLastKnown(cache, rosterSet, cap)
    cap = cap or E.CACHE_CAP
    rosterSet = rosterSet or {}
    local size = 0
    for _ in pairs(cache) do
        size = size + 1
    end
    if size <= cap then
        return
    end
    local cand = {}
    for name, entry in pairs(cache) do
        if not rosterSet[name] then
            local newest = 0
            if entry.aurasAt and entry.aurasAt > newest then
                newest = entry.aurasAt
            end
            if entry.debuffsAt and entry.debuffsAt > newest then
                newest = entry.debuffsAt
            end
            if entry.gearAt and entry.gearAt > newest then
                newest = entry.gearAt
            end
            if entry.weaponAt and entry.weaponAt > newest then
                newest = entry.weaponAt
            end
            table.insert(cand, { name = name, at = newest })
        end
    end
    table.sort(cand, function(a, b)
        if a.at ~= b.at then
            return a.at < b.at
        end
        return a.name < b.name
    end)
    local i = 1
    local n = table.getn(cand)
    while size > cap and i <= n do
        cache[cand[i].name] = nil
        size = size - 1
        i = i + 1
    end
end

-- ==================================================================
-- WoW part
-- ==================================================================
if CreateFrame then

    local queue = {}          -- pending roster entries
    local results = {}        -- store player entries, roster order
    local running = false
    local restartPending = false

    -- BuildRoster() -> array of { unit, name, class, online, guid,
    -- subgroup }. online is true|false for raid entries (roster flag,
    -- GetRaidRosterInfo return 8 = SERVER TRUTH) and nil for party
    -- entries (no roster flag exists there -- scanOne falls back to the
    -- reach result); "player" is trivially true.
    -- Snapshot semantics: raid indices are volatile, so this
    -- is (re)taken at scan (re)start, never cached across roster events.
    -- Public: core/probe.lua reuses it for the probe battery.
    function E.BuildRoster()
        local list = {}
        local nraid = 0
        if GetNumRaidMembers then
            nraid = GetNumRaidMembers() or 0
        end
        if nraid > 0 then
            for i = 1, nraid do
                -- 11 returns on 1.12; fileName (english class token) is
                -- position 6, online position 8
                local ok, name, rank, subgroup, level, class, fileName,
                    zone, online = pcall(GetRaidRosterInfo, i)
                if ok and name then
                    local unit = "raid" .. i
                    local guid = nil
                    if UnitExists then
                        -- SuperWoW: 2nd return of UnitExists = GUID
                        local ok2, ex, g = pcall(UnitExists, unit)
                        if ok2 and ex and g then
                            guid = g
                        end
                    end
                    table.insert(list, {
                        unit = unit, name = name, class = fileName,
                        online = (online and true) or false,
                        guid = guid, subgroup = subgroup,
                    })
                end
            end
        else
            local units = { "player", "party1", "party2", "party3", "party4" }
            for i = 1, 5 do
                local unit = units[i]
                local exists = false
                local guid = nil
                if UnitExists then
                    local ok, ex, g = pcall(UnitExists, unit)
                    if ok and ex then
                        exists = true
                        if g then
                            guid = g
                        end
                    end
                end
                if exists then
                    local name = UnitName(unit)
                    local classToken = nil
                    if UnitClass then
                        local ok, loc, token = pcall(UnitClass, unit)
                        if ok then
                            classToken = token
                        end
                    end
                    -- party path has NO roster online flag: online stays
                    -- nil so scanOne falls back to the reach result;
                    -- the own unit is trivially online
                    local online = nil
                    if unit == "player" then
                        online = true
                    end
                    if name then
                        table.insert(list, {
                            unit = unit, name = name, class = classToken,
                            online = online, guid = guid, subgroup = nil,
                        })
                    end
                end
            end
        end
        return list
    end

    -- readWeapon(unit) -> weaponMain, weaponMainName
    -- (the NAME rides along -- self via the hidden-tooltip scan, foreign
    -- via the SuperWoW GetWeaponEnchantInfo(unit) name return)
    local function readWeapon(unit)
        if not GetWeaponEnchantInfo then
            return nil, nil
        end
        local isSelf = false
        if UnitIsUnit then
            local ok, v = pcall(UnitIsUnit, unit, "player")
            if ok and v then
                isSelf = true
            end
        end
        if isSelf then
            local ok, hasMain = pcall(GetWeaponEnchantInfo)
            if not ok then
                return nil, nil
            end
            local main = E.NormalizeWeapon(true, hasMain)
            local name = nil
            if main and DC_Gear and DC_Gear.ReadSelfWeaponEnchantName then
                name = DC_Gear.ReadSelfWeaponEnchantName()
            end
            return main, name
        end
        local ok, mainName = pcall(GetWeaponEnchantInfo, unit)
        if not ok then
            return nil, nil
        end
        -- A nil name is ambiguous on its own (see E.NormalizeWeapon): it means
        -- either "no imbue" or "this unit is not readable". The main-hand item
        -- link decides -- if the slot resolves, the unit IS readable and the
        -- missing imbue is a real gap. Measured: all 14 nil cases in a raid had
        -- a resolvable main hand, i.e. they were genuinely unbuffed.
        local readable = nil
        if mainName == nil and GetInventoryItemLink then
            local lok, link = pcall(GetInventoryItemLink, unit, 16)
            readable = (lok and link ~= nil) or false
        end
        return E.NormalizeWeapon(false, mainName, readable),
            E.NormalizeWeaponName(mainName)
    end

    local function bumpUnknown(unknown)
        local n = table.getn(unknown)
        for i = 1, n do
            local key = unknown[i]
            DC.unknownSeen[key] = (DC.unknownSeen[key] or 0) + 1
        end
    end

    -- readResists(unit, guid, isSelf, hit, race) -> resists, resistsRead,
    -- source (E.AssembleResists' three returns). WoW-only gather for the
    -- RESIST tab: three DIRECT read paths, each ATTEMPTED only once the
    -- earlier one has already proven unusable (skips the extra client
    -- calls on the common case -- token alone almost always answers for a
    -- same-zone unit) -- plus the gear-reconstruction fallback (see
    -- E.AssembleResists' header), which is why the caller now hands in the
    -- `hit`/`race` results of this SAME scan's hit read (scanOne runs the
    -- hit read before this call precisely so hit.resistGear/
    -- resistGearRead are available here; no extra client call of its own).
    -- School range 2..6 (fire/nature/frost/shadow/arcane); school 0
    -- (physical) and 1 (holy, always 0) are never queried.
    local function readResists(unit, guid, isSelf, hit, race)
        local tokenRows = {}
        if UnitResistance then
            for school = 2, 6 do
                local ok, base, total = pcall(UnitResistance, unit, school)
                table.insert(tokenRows, { school = school, ok = ok,
                    total = total })
            end
        end
        local guidRows = {}
        local fieldRows = {}
        -- A path counts as usable only if it produced EVIDENCE. For the
        -- own character any number is evidence (a real 0 resistance is a
        -- measurement). For a FOREIGN unit it is not: UnitResistance
        -- returns ok=true with total=0 for every school of a player the
        -- client cannot resolve, which is indistinguishable from a
        -- genuinely unresisted character. Treating that as "usable"
        -- claimed the token path and left the GUID and descriptor paths
        -- below unreachable -- so every foreign player read as UNKNOWN
        -- even though GetUnitField(guid, "resistances") had the numbers.
        local tokenUsable = false
        for i = 1, table.getn(tokenRows) do
            local r = tokenRows[i]
            if r.ok and type(r.total) == "number"
               and (isSelf or r.total > 0) then
                tokenUsable = true
            end
        end
        if not tokenUsable and guid and UnitResistance then
            -- SuperWoW accepts a GUID directly as the first argument on
            -- several UnitXXX calls (source-confirmed for mob armor;
            -- unverified for a player's elemental schools -- pcall-guarded
            -- and gated by the same honesty rule as every other path)
            for school = 2, 6 do
                local ok, base, total = pcall(UnitResistance, guid, school)
                table.insert(guidRows, { school = school, ok = ok,
                    total = total })
            end
        end
        local guidUsable = false
        for i = 1, table.getn(guidRows) do
            local r = guidRows[i]
            if r.ok and type(r.total) == "number"
               and (isSelf or r.total > 0) then
                guidUsable = true
            end
        end
        if not tokenUsable and not guidUsable and guid and GetUnitField then
            local ok, t = pcall(GetUnitField, guid, "resistances")
            if ok and type(t) == "table" then
                -- 7-entry table, index convention verified defensively:
                -- tried as 1-based for school 0..6 (t[school+1]); a
                -- non-number entry is simply skipped (AssembleResists
                -- also drops non-positive field values -- see its header)
                for school = 2, 6 do
                    table.insert(fieldRows, { school = school,
                        val = t[school + 1] })
                end
            end
        end
        local gearFallback = nil
        if hit then
            gearFallback = { resistGear = hit.resistGear,
                resistGearRead = hit.resistGearRead, race = race }
        end
        return E.AssembleResists(isSelf, tokenRows, guidRows, fieldRows,
            gearFallback)
    end

    -- scan one roster entry into a store player entry.
    -- Online source (measured in-game): the roster
    -- online flag (GetRaidRosterInfo return 8) is SERVER TRUTH and WINS
    -- over UnitIsConnected, which can read nil mid-zoning while the
    -- player is actually online (false-offline bug). ONLY entries
    -- without a roster flag (entry.online == nil, party path) fall back
    -- to the reach result. Distance stays from reach (diagnostic only).
    local function scanOne(entry)
        local reachOnline, distance = DC_Reach.ReadUnit(entry.unit)
        local online = entry.online
        if online == nil then
            online = reachOnline  -- no roster flag: reach fallback
        end

        local auraRes = nil
        local gearRes = nil
        local weaponMain = nil
        local weaponMainName = nil
        local resRes = nil
        local hitRes = nil

        if online then
            local rawA, rawB, rawDA, rawDB, idNames =
                DC_Aura.ReadUnit(entry.unit, entry.guid)
            local auras, aurasRead, disagreements, unknown =
                DC_Aura.Assemble(rawA, rawB, idNames)
            -- debuffs: assembled alongside, readability
            -- rides on aurasRead; debuff names NEVER feed DC.unknownSeen
            auraRes = { read = aurasRead, auras = auras,
                debuffs = DC_Aura.AssembleDebuffs(rawDA, rawDB) }
            bumpUnknown(unknown)

            -- diagnostic path coverage (plan: disagreements between path A
            -- and path B are counted into DC.probeStats)
            local ps = DC.probeStats
            local hadA = table.getn(rawA) > 0
            local hadB = table.getn(rawB) > 0
            if hadA then
                ps.unitsPathA = ps.unitsPathA + 1
                ps.lastScan.unitsA = ps.lastScan.unitsA + 1
            end
            if hadB then
                ps.unitsPathB = ps.unitsPathB + 1
                ps.lastScan.unitsB = ps.lastScan.unitsB + 1
            end
            if hadA and hadB then
                ps.unitsBoth = ps.unitsBoth + 1
                ps.lastScan.both = ps.lastScan.both + 1
            end
            ps.idDisagreements = ps.idDisagreements + disagreements
            ps.lastScan.disagreements = ps.lastScan.disagreements + disagreements

            local rawLinks = DC_Gear.ReadUnit(entry.unit)
            local gear, gearRead = DC_Gear.Assemble(rawLinks, DC_Gear.InvSlots(),
                DC_Gear.ItemInfo)
            gearRes = { read = gearRead, gear = gear }

            weaponMain, weaponMainName = readWeapon(entry.unit)

            local isSelf = false
            if UnitIsUnit then
                local ok, v = pcall(UnitIsUnit, entry.unit, "player")
                isSelf = (ok and v) and true or false
            end

            -- hit: the item links from the gear read above are handed over
            -- so the tooltip pass does not fetch them a second time.
            -- Declared OUTSIDE the if-block (unlike before this scope held
            -- only hitRes) because readResists below now needs `hit`/
            -- `race` too, for the gear-reconstruction fallback -- see
            -- E.AssembleResists' header for why the hit read must run
            -- BEFORE the resist read.
            local hit, race = nil, nil
            if DC_Hit and DC_Hit.ReadUnit then
                hit, race = DC_Hit.ReadUnit(entry.unit, rawLinks)
                if isSelf and hit and DC_Hit.ReadSelfSkill then
                    -- the own character is the ONLY one whose weapon skill
                    -- can be MEASURED (rank + modifier); for everyone else
                    -- it is unreadable in principle and stays an estimate
                    if hit.skillName then
                        local exact = DC_Hit.ReadSelfSkill(hit.skillName)
                        if exact then
                            hit.skill = exact
                            hit.skillExact = true
                        end
                    end
                    local rangedSkillName = DC.WEAPON_SKILL_OF_SUBTYPE
                        and DC.WEAPON_SKILL_OF_SUBTYPE[hit.rangedSub]
                    if rangedSkillName then
                        local rexact = DC_Hit.ReadSelfSkill(rangedSkillName)
                        if rexact then
                            hit.rangedSkill = rexact
                            hit.rangedSkillExact = true
                        end
                    end
                end
                -- estimate whatever was not measured: base skill + racial
                -- specialization + the user's per-player weapon-skill-book
                -- flag. Snapshot semantics -- a flag toggled after the scan
                -- reaches the display layer, not this table.
                if DC_Hit.ApplyEstimate then
                    local db = DopingControlDB
                    local books = nil
                    if db and db.skillBooks then
                        books = db.skillBooks[entry.name]
                    end
                    DC_Hit.ApplyEstimate(hit, race, books)
                end
                -- TALENT hit (data/talenthit.lua). TWO sources, ONE
                -- mathematics:
                --   * own character -- GetTalentInfo, which has no unit
                --     parameter and never will (the server transmits no
                --     foreign talents at all: no SMSG_INSPECT_TALENT in
                --     this fork, no talent field in InitVisibleBits);
                --   * every other player -- this server's own inspect
                --     protocol (scan/talents.lua): we ask by name, their
                --     client answers with its whole tree.
                -- Both hand the SAME name->rank table to the same
                -- DC_Hit.TalentHit, so a foreign hit number cannot drift
                -- from an own one. talentsRead stays false only while no
                -- plausible reply has arrived yet -- that is a "not yet",
                -- not "impossible". The dual-wield gate uses the weapon
                -- subtypes the item pass just resolved.
                if hit and DC_Hit.TalentHit then
                    local ranks = nil
                    if isSelf then
                        if DC_Hit.ReadSelfTalents then
                            ranks = DC_Hit.ReadSelfTalents()
                        end
                    elseif DC_Talents and DC_Talents.RanksFor then
                        ranks = DC_Talents.RanksFor(entry.name)
                    end
                    if ranks then
                        local dw = DC_Hit.DualWield(hit.mainSub, hit.offSub)
                        local t = DC_Hit.TalentHit(entry.class, ranks, dw)
                        hit.talentMelee = t.melee
                        hit.talentRanged = t.ranged
                        hit.talentSpell = t.spell
                        hit.talentOffhand = t.offhand
                        hit.talentSchools = t.schools
                        hit.talentsRead = true
                    end
                end
                hitRes = { hit = hit, race = race }
            end

            local resists, resistsRead, source =
                readResists(entry.unit, entry.guid, isSelf, hit, race)
            resRes = { read = resistsRead, resists = resists, source = source }
        end

        local p = E.BuildPlayer(entry, online, distance, auraRes, gearRes,
            weaponMain, weaponMainName, resRes, hitRes)
        -- last-known cache: successful channels refresh
        -- DC.lastKnown, failed channels get the cached references attached
        -- as p.last* -- display-only for the model (IRON RULE)
        local now = 0
        if GetTime then
            now = GetTime()
        end
        E.UpdateLastKnown(DC.lastKnown, p, now)
        table.insert(results, p)
    end

    -- one reused batch frame (allocating a throwaway frame per
    -- batch would leak; we use ONE named frame).
    -- CAUTION: hidden frames receive no OnUpdate on 1.12, so the frame is
    -- Show()n for the duration of a scan (it has no regions -- draws
    -- nothing) and Hide()n again in finish().
    local scanFrame = CreateFrame("Frame", "DopingControlScanFrame")
    scanFrame:Hide()

    local function beginQueue()
        queue = E.BuildRoster()
        results = {}
        DC.probeStats.lastScan =
            { unitsA = 0, unitsB = 0, both = 0, disagreements = 0 }
    end

    local function finish()
        running = false
        scanFrame:SetScript("OnUpdate", nil)
        scanFrame:Hide()
        -- cap-safety sweep on the last-known cache: runs on
        -- every finished pass, roster members are never evicted
        local rosterSet = {}
        for i = 1, table.getn(results) do
            rosterSet[results[i].name] = true
        end
        E.SweepLastKnown(DC.lastKnown, rosterSet, E.CACHE_CAP)
        local db = DopingControlDB
        if DC.SimOwnsStore(db) then
            -- Test mode OR demo mode was on (or switched on while this scan
            -- was in flight): the simulator fully replaces the data source
            -- ("real data never beats the simulation") -- drop the result.
            -- This is the load-bearing gate: it is the only thing between a
            -- finished scan and DC.store = E.NewStore(...) below.
            results = {}
            return
        end
        local now = 0
        if GetTime then
            now = GetTime()
        end
        DC.probeStats.scans = DC.probeStats.scans + 1
        -- Line up talent requests for everyone this scan actually saw
        -- (scan/talents.lua paces and de-duplicates them; a player whose
        -- talents we already hold is skipped there, not here). Only
        -- players with a KNOWN CLASS are worth asking: DC_Hit.TalentHit
        -- keys its table by class, so a reply we cannot attribute would be
        -- traffic for nothing. Own character excluded -- GetTalentInfo is
        -- authoritative and free for that one.
        if DC_Talents and DC_Talents.Request then
            local me = UnitName and UnitName("player") or nil
            local names = {}
            for i = 1, table.getn(results) do
                local p = results[i]
                if p.name and p.name ~= me and p.class and p.class ~= "" then
                    table.insert(names, p.name)
                end
            end
            DC_Talents.Request(names)
        end
        DC.store = E.NewStore(results, now)
        DC.storeInvalid = nil
        results = {}
        if type(DC.OnStoreUpdated) == "function" then
            DC.OnStoreUpdated()
        end
    end

    local function tick()
        if restartPending then
            -- roster changed mid-scan: indices volatile => full restart
            restartPending = false
            beginQueue()
        end
        local count = 0
        while table.getn(queue) > 0 and count < E.BATCH do
            local entry = table.remove(queue, 1)
            scanOne(entry)
            count = count + 1
        end
        if table.getn(queue) == 0 and not restartPending then
            finish()
        end
    end

    -- Start(force) -> bool (scan started/restarted).
    -- Already running: no-op unless force, which restarts with a fresh
    -- roster snapshot. DC.SimOwnsStore is NOT checked here -- an explicit
    -- Start is user intent, and the callers that are NOT user intent
    -- (READY_CHECK, the roster-change and window-open auto-scans) check it
    -- themselves. The scan may therefore run in test/demo mode; what it may
    -- never do is publish, so finish() drops the result instead.
    function E.Start(force)
        if running then
            if not force then
                return false
            end
            restartPending = true
            return true
        end
        running = true
        restartPending = false
        beginQueue()
        scanFrame:Show()  -- hidden frames get no OnUpdate on 1.12
        scanFrame:SetScript("OnUpdate", tick)
        tick()  -- first batch immediately (solo/party scans finish at once)
        return true
    end

    function E.IsRunning()
        return running
    end

    -- event wiring
    local eventFrame = CreateFrame("Frame", "DopingControlScanEventFrame")
    eventFrame:RegisterEvent("RAID_ROSTER_UPDATE")
    eventFrame:RegisterEvent("PARTY_MEMBERS_CHANGED")
    -- READY_CHECK: unverified as a native 1.12 event -- register defensively
    pcall(eventFrame.RegisterEvent, eventFrame, "READY_CHECK")
    eventFrame:SetScript("OnEvent", function()
        if event == "READY_CHECK" then
            local db = DopingControlDB
            if db and db.readyCheckScan and not DC.SimOwnsStore(db) then
                E.Start()
            end
            return
        end
        -- RAID_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED
        if running then
            restartPending = true
        elseif DC.store and DC.store.source == "scan" then
            DC.storeInvalid = true  -- finished store no longer matches roster
        end
    end)

end
