-- DopingControl scan/hit.lua
-- Hit reading for one unit: the +hit percentages an equipped set of items
-- grants, plus the weapon information the hit CAPS depend on.
--
-- There is no hit API on this client family (see data/hit.lua) -- every
-- number here is summed from item TOOLTIP text. The read path is
-- GameTooltip:SetInventoryItem(unit, slot) on the SHARED hidden tooltip
-- (scan/aura.lua's DC_Aura.GetScanTip -- never a second frame under the
-- same global name), NOT SetHyperlink: SetInventoryItem shows the item as
-- it is actually worn (temporary weapon enchants included) and sidesteps
-- item-link edge cases entirely.
--
-- Performance (this is the expensive part of a scan -- one tooltip build
-- per equipped item per player):
--   * slots without an item link are skipped without touching the tooltip;
--   * nothing beyond the equipment slots is ever scanned;
--   * the parsed result of an item is CACHED by item id + enchant id, so
--     the second player wearing the same epic costs one table lookup. The
--     cache lives in memory only (never saved to disk), and a cap-safety
--     wipe keeps it bounded -- it is a pure accelerator, so a wipe only
--     costs one re-scan;
--   * the caller can hand in the item links scan/gear.lua already fetched
--     for this unit, which removes a second GetInventoryItemLink pass.
--
-- WHAT IS COUNTED, and how:
--   * PER ITEM: lines that start with the equip trigger ("Equip: Improves
--     your chance to hit by 1%").
--   * PER SET: set bonuses, counted ONCE for the whole set and only when
--     the piece threshold is met (H.CollectSet / H.SumSets). The set block
--     is printed on the tooltip of EVERY piece, which is why a per-item
--     sum would multiply it -- but the block also names the set, the worn
--     piece count and each bonus's threshold, so grouping by set is all
--     that is needed. The worn count comes from OUR OWN tally of the
--     player's pieces, never from a cached tooltip's "(x/y)": that number
--     belongs to whichever unit the tooltip was drawn for.
--
-- Store shape produced BY THIS MODULE'S ReadUnit (players[i].hit, before
-- scan/engine.lua's BuildPlayer augments it with hit-granting auras --
-- see AuraHit below and BuildPlayer's own header comment):
--   { melee = <number>,   -- generic physical hit % from GEAR (melee AND
--                         -- ranged); BuildPlayer folds DC.HIT_AURAS on top
--                         -- and turns this into the melee-column TOTAL
--     spell = <number>,   -- general spell hit % from GEAR; BuildPlayer
--                         -- folds auras on top the same way
--     schools = { [school] = <number> },  -- school-only spell hit extras
--     sources = { { item = <name|nil>, kind = , value = }, ... },
--     mainSub = <itemSubType|nil>,   -- main hand
--     offSub  = <itemSubType|nil>,   -- off hand
--     rangedSub = <itemSubType|nil>, -- ranged slot
--     skillName = <weapon skill of the main hand|nil>,
--     skill = <number|nil>,      -- main-hand weapon skill: MEASURED for
--                                -- the own character, otherwise the
--                                -- estimate H.ApplyEstimate filled in
--     skillExact = <true|nil>,   -- true only when `skill` was MEASURED
--     book = <bool|nil>,         -- a skill book was ASSUMED (never on a
--                                -- measured skill)
--     rangedSkill = <number|nil>,      -- same for the ranged weapon
--     rangedSkillExact = <true|nil>,
--     hitRead = <bool> }
-- Readability rule (mirrors scan/gear.lua): ANY slot returned a link =>
-- hitRead = true; ALL slots nil => hitRead = false (a failed read, not "a
-- naked player"). Unlike the aura/gear channels the table rides into the
-- store EVEN WHEN hitRead is false, because the flag lives inside it --
-- every consumer must test hit.hitRead before believing the zeros.
--
-- Weapon skill: MEASURED for the own character (H.ReadSelfSkill ->
-- hit.skill + hit.skillExact), ESTIMATED for everyone else -- foreign
-- weapon skill is unreadable in principle (no inspect-skill channel
-- exists). The estimate is deliberately NOT baked into the store: it
-- depends on the user's per-player weapon-skill-book flags, and baking it
-- in would freeze those flags until the next scan. Consumers resolve it at
-- display time:
--     local skill = hit.skillExact and hit.skill
--         or DC_Hit.SkillFor(hit.mainSub, player.race,
--                            db.skillBooks and db.skillBooks[player.name])
-- (player.race is the race token the scan stored alongside the hit table.)
--
-- TALENTS are the third source (gear, auras, talents). GetTalentInfo has
-- no unit parameter, so ranks come from two places that must produce the
-- SAME shape:
--   * H.TalentHit is PURE (class token + learned ranks -> the four
--     buckets and the school extras, see data/talenthit.lua) and is
--     offline-tested like the rest of the pure half;
--   * H.ReadSelfTalents (WoW glue) reads the ranks for the own character;
--   * for every other player scan/talents.lua asks over the server's own
--     inspect protocol and hands the same name->rank table to H.TalentHit.
--     Until a reply arrives that player keeps hit.talentsRead = false and
--     zeroed talent fields, and every consumer must say so -- their number
--     EXCLUDES talents, which for a hybrid or a mage is easily 3-10%.
-- The talent fields the engine adds on top of this module's store shape:
--   talentMelee / talentRanged / talentSpell / talentOffhand (numbers),
--   talentSchools ({ [lowercase school] = number }), talentsRead (bool).
--
-- Pure Lua 5.0, dofile-loadable offline: ParseHitLine / SumGear / AddSum /
-- IsHitSourceLine / SkillFor / MeleeCap / RangedCap / DualWield /
-- TalentHit / SchoolHit are pure (offline tested); ReadUnit,
-- ReadSelfSkill and ReadSelfTalents touch the WoW API and run in-game
-- only, pcall-wrapped.
--
-- RESIST-TAB REUSE (added 2026-08-02, see scan/resist.lua's header for the
-- full rationale): the server never transmits UNIT_FIELD_RESISTANCES for a
-- foreign player at all, so scan/engine.lua's three direct read paths can
-- NEVER answer for anyone but the own character -- reconstruction from
-- equipped-item tooltips is the only path that can. Rather than run a
-- second per-item tooltip scan for that, itemHit() below hands the SAME
-- tooltip lines it already fetched for +hit to DC_Resist.SumLines (pure,
-- scan/resist.lua) and caches the result alongside the hit sum; H.ReadUnit
-- accumulates it into hit.resistGear/hit.resistGearRead the same way it
-- accumulates hit.melee/hit.spell. Zero extra SetInventoryItem calls.
-- hit.resistGearRead is the STRICT honesty flag scan/engine.lua's
-- gear-reconstruction fallback requires: true only when at least one
-- equipped item's tooltip was ACTUALLY read (H.SumItemResists), never
-- merely "a link existed" (hit.hitRead's looser bar) -- a player whose
-- tooltips are wholly unreadable must keep showing "?" on RESIST, not a
-- reconstructed 0. DC_Resist is existence-guarded throughout, so this
-- module works unchanged without it (resistGearRead simply stays false).

DopingControl = DopingControl or {}
local DC = DopingControl

DC_Hit = DC_Hit or {}
local H = DC_Hit

-- Equipment slots that can carry +hit, in inventory-slot order. This is
-- deliberately its OWN list and not DC_Gear.InvSlots(): the two lists
-- happen to agree today (both cover the waist, invSlot 6 -- see
-- data/enchants.lua's bug-history note), but this file must not assume
-- that stays true, so it keeps every hit-bearing slot spelled out here.
-- Shirt (4) and tabard (19) never carry stats and are left out.
H.SLOTS = { 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 }

H.SLOT_MAIN   = 16
H.SLOT_OFF    = 17
H.SLOT_RANGED = 18

-- Hard cap for the per-item parse cache (in memory only, see header).
H.CACHE_CAP = 300

-- Hard cap for the per-player breakdown list (tooltip detail only -- no
-- realistic set of gear reaches it; the cap exists so a broken tooltip
-- cannot grow the store without bound).
H.SOURCE_CAP = 16

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

-- round to one decimal (0.2 is not representable in binary floating point,
-- so 8 - 3*0.2 lands at 7.399999...; every cap leaves this function)
local function round1(v)
    return math.floor(v * 10 + 0.5) / 10
end

-- ParseHitLine(text) -> kind, value | nil
--   kind  : "melee" | "spell" | "both" | <school name> (see DC.HIT_PATTERNS)
--   value : the percentage as a number
-- Returns nil (a single value) when no pattern matches -- an unknown
-- phrasing is ignored, never guessed at.
function H.ParseHitLine(text)
    if type(text) ~= "string" then
        return nil
    end
    local pats = DC.HIT_PATTERNS
    if type(pats) ~= "table" then
        return nil
    end
    local n = table.getn(pats)
    for i = 1, n do
        local e = pats[i]
        local _, _, cap = string.find(text, e.pattern)
        if cap then
            local v = tonumber(cap)
            if v then
                return e.kind, v
            end
        end
    end
    return nil
end

-- IsHitSourceLine(text) -> bool
-- The gate that separates PER-ITEM hit from SET-BONUS hit: only a line
-- carrying the equip trigger is a per-item hit source. A set-bonus line
-- ("(2) Set: Improves your chance to hit by 2%") is printed on EVERY piece
-- of the set, so adding it per item would multiply it.
--
-- That is a reason to count set bonuses ONCE PER SET, not a reason to drop
-- them -- see H.CollectSet / H.SumSets below. The Devilsaur two-piece
-- bonus is +2% hit; leaving it out understates hit by more than most
-- enchants add.
function H.IsHitSourceLine(text)
    if type(text) ~= "string" then
        return false
    end
    if string.find(text, "^Equip:") then
        return true
    end
    return false
end

-- AddSum(dst, add) -> dst -- fold one hit sum into another (in place).
-- Only the three hit fields are touched, so it can be applied directly to
-- a store hit table that carries weapon/readability fields as well.
function H.AddSum(dst, add)
    if type(dst) ~= "table" or type(add) ~= "table" then
        return dst
    end
    dst.melee = (dst.melee or 0) + (add.melee or 0)
    dst.spell = (dst.spell or 0) + (add.spell or 0)
    if add.schools then
        dst.schools = dst.schools or {}
        for school, v in pairs(add.schools) do
            dst.schools[school] = (dst.schools[school] or 0) + v
        end
    end
    return dst
end

-- SumItemResists(items) -> resistGear, resistGearRead
--   items : array of the itemHit() cache entries collected while walking
--           H.SLOTS for ONE unit -- one entry per slot whose tooltip was
--           SUCCESSFULLY read (a slot with no item, or whose tooltip read
--           failed, contributes no entry at all -- see H.ReadUnit).
--   resistGear     : { [UnitResistance school index] = total } summed
--                     over every entry's .resist table (itself possibly
--                     {} for a plain item -- see DC_Resist.SumLines). A
--                     school with no contribution is simply absent, not
--                     zero -- callers default with `or 0`, same as every
--                     other resist table in this addon.
--   resistGearRead : true iff AT LEAST ONE entry carried a .resist table
--                     (i.e. its tooltip was actually read, whether or not
--                     that item happened to grant any resistance).
--                     FALSE means every equipped item's tooltip failed --
--                     the exact scan/hit.lua-documented failure mode
--                     (SetOwner missing on the scan tooltip, or the unit
--                     simply out of tooltip range) -- and the caller MUST
--                     show "?", never a reconstructed 0. This is the
--                     honesty gate scan/engine.lua's AssembleResists
--                     gear-fallback checks before trusting resistGear at
--                     all.
-- `.resist` being nil on every entry (DC_Resist not loaded, see the file
-- header) degrades the same way: nothing is ever marked read, so the
-- fallback simply never activates -- no module, no reconstruction, never a
-- guess.
function H.SumItemResists(items)
    local sum = {}
    local read = false
    items = items or {}
    local n = table.getn(items)
    for i = 1, n do
        local it = items[i]
        if it and it.resist then
            read = true
            for school, v in pairs(it.resist) do
                sum[school] = (sum[school] or 0) + v
            end
        end
    end
    return sum, read
end

-- SumGear(lines) -> sum, sources
--   lines : array of raw tooltip line strings (already gated by
--           IsHitSourceLine when they come from an item scan)
--   sum   : { melee = <number>, spell = <number>, schools = { ... } }
--   sources : array of { kind = , value = } in line order -- the
--           breakdown a tooltip shows; the caller attaches the item name.
-- Folding rules (see DC.HIT_PATTERNS): "both" counts into melee AND
-- spell; a school-specific line counts ONLY into schools[<school>], never
-- into the general spell number -- it buys hit against one school only.
-- Lines that parse to nothing are ignored.
function H.SumGear(lines)
    local sum = { melee = 0, spell = 0, schools = {} }
    local sources = {}
    lines = lines or {}
    local n = table.getn(lines)
    for i = 1, n do
        local kind, v = H.ParseHitLine(lines[i])
        if kind then
            if kind == "melee" then
                sum.melee = sum.melee + v
            elseif kind == "spell" then
                sum.spell = sum.spell + v
            elseif kind == "both" then
                sum.melee = sum.melee + v
                sum.spell = sum.spell + v
            else
                sum.schools[kind] = (sum.schools[kind] or 0) + v
            end
            table.insert(sources, { kind = kind, value = v })
        end
    end
    return sum, sources
end

-- ------------------------------------------------------------------
-- SET BONUSES
--
-- Shape of the set block in an item tooltip:
--
--   L10 gold  "Devilsaur Armor (0/2)"                      <- ITEM_SET_NAME
--   L11 grey  "  Devilsaur Leggings"                       <- piece list
--   L14 grey  "(2) Set: Improves your chance to hit by 2%." <- ITEM_SET_BONUS_GRAY
--
-- So the client states both halves itself: how many pieces are worn, and
-- what each bonus costs. The 1.12 GlobalStrings confirm the two forms --
-- ITEM_SET_BONUS_GRAY = "(%d) Set: %s" for a bonus that is NOT active and
-- ITEM_SET_BONUS = "Set: %s" for one that IS.
--
-- Counting rule: group by SET NAME, count each bonus at most ONCE, and
-- take it only when its piece threshold is met. The worn count is taken
-- from OUR OWN tally of the player's equipped pieces, never from the
-- "(x/y)" of a cached tooltip -- that number belongs to whichever unit was
-- scanned when the item entered the cache and would otherwise leak between
-- players.
-- ------------------------------------------------------------------

-- ParseSetHeader(text) -> name, worn, total | nil
-- The gold "<Set name> (x/y)" line that opens the set block.
function H.ParseSetHeader(text)
    if type(text) ~= "string" then
        return nil
    end
    local _, _, name, worn, total =
        string.find(text, "^(.+) %((%d+)/(%d+)%)%s*$")
    if not name then
        return nil
    end
    return name, tonumber(worn), tonumber(total)
end

-- ParseSetBonus(text) -> threshold|nil, body | nil
-- "(2) Set: <body>"  -> 2, <body>     (inactive form, ITEM_SET_BONUS_GRAY)
-- "Set: <body>"      -> nil, <body>   (ACTIVE form, ITEM_SET_BONUS)
-- Anything else      -> nil
-- A nil threshold with a body is NOT "no threshold" -- it means the client
-- already decided the bonus is active for the unit it drew the tooltip
-- for, which is why such an entry must not be cached across players.
function H.ParseSetBonus(text)
    if type(text) ~= "string" then
        return nil
    end
    local _, _, n, body = string.find(text, "^%((%d+)%) Set: (.+)$")
    if n then
        return tonumber(n), body
    end
    local _, _, body2 = string.find(text, "^Set: (.+)$")
    if body2 then
        return nil, body2
    end
    return nil
end

-- CollectSet(lines) -> { name, worn, total, bonuses = { {n=, text=} },
--                        contextual = <bool> } | nil
-- Reads the set block out of ONE item's tooltip lines. `contextual` marks
-- a block that carries at least one active-form line, i.e. one whose
-- meaning depends on the unit the tooltip was drawn for -- the caller must
-- not cache those.
function H.CollectSet(lines)
    if type(lines) ~= "table" then
        return nil
    end
    local set = nil
    for i = 1, table.getn(lines) do
        local text = lines[i]
        if not set then
            local name, worn, total = H.ParseSetHeader(text)
            if name then
                set = { name = name, worn = worn, total = total,
                        bonuses = {}, contextual = false }
            end
        else
            local n, body = H.ParseSetBonus(text)
            if body then
                if not n then
                    set.contextual = true
                end
                table.insert(set.bonuses, { n = n, text = body })
            end
        end
    end
    return set
end

-- SumSets(sets) -> sum, sources
--   sets : { [setName] = { pieces = <how many the player wears>,
--                          bonuses = { {n=, text=} } } }
-- Each bonus contributes AT MOST ONCE, and only when the player wears
-- enough pieces. A bonus with no threshold (active form) is taken as met:
-- the client only prints it that way once it applies.
function H.SumSets(sets)
    local sum = { melee = 0, spell = 0, schools = {} }
    local sources = {}
    if type(sets) ~= "table" then
        return sum, sources
    end
    -- deterministic order: sets are keyed by name, and two sets granting
    -- hit must not swap places in the tooltip between two scans
    local names = {}
    for name in pairs(sets) do
        table.insert(names, name)
    end
    table.sort(names)
    for i = 1, table.getn(names) do
        local name = names[i]
        local s = sets[name]
        local pieces = (s and s.pieces) or 0
        local bonuses = (s and s.bonuses) or {}
        local seen = {}
        for b = 1, table.getn(bonuses) do
            local bonus = bonuses[b]
            local met = (bonus.n == nil) or (pieces >= bonus.n)
            -- the same bonus text can arrive from several pieces of the
            -- set; key on it so it lands in the sum exactly once
            if met and not seen[bonus.text] then
                seen[bonus.text] = true
                local kind, v = H.ParseHitLine(bonus.text)
                if kind then
                    if kind == "melee" then
                        sum.melee = sum.melee + v
                    elseif kind == "spell" then
                        sum.spell = sum.spell + v
                    elseif kind == "both" then
                        sum.melee = sum.melee + v
                        sum.spell = sum.spell + v
                    else
                        sum.schools[kind] = (sum.schools[kind] or 0) + v
                    end
                    table.insert(sources, { kind = kind, value = v,
                        set = name, pieces = bonus.n or pieces })
                end
            end
        end
    end
    return sum, sources
end

-- SkillFor(subType, race, books) -> skill, skillName | nil
-- The ESTIMATE for a foreign player's weapon skill:
--   base (level-60 cap) + racial specialization + weapon skill book.
-- `books` is one player's opt-in flag table (db.skillBooks[playerName]),
-- keyed by weapon SKILL name; nil is fine. Returns nil when the subtype is
-- not a weapon at all (shield, totem, nothing equipped) -- the caller then
-- has no skill claim to make.
-- The own character never needs this: its skill is measured
-- (H.ReadSelfSkill) and a measured value already includes any book.
function H.SkillFor(subType, race, books)
    local map = DC.WEAPON_SKILL_OF_SUBTYPE
    local skillName = map and map[subType]
    if not skillName then
        return nil
    end
    local skill = DC.BASE_WEAPON_SKILL or 300
    local byRace = DC.RACE_WEAPON_SKILL
    local racial = race and byRace and byRace[race]
    if racial and racial[skillName] then
        skill = skill + racial[skillName]
    end
    if books and books[skillName] then
        skill = skill + (DC.SKILL_BOOK_BONUS or 0)
    end
    return skill, skillName
end

-- ApplyEstimate(hit, race, books) -> hit (in place)
-- Fills the weapon-skill fields a display layer needs for a player whose
-- skill could NOT be measured -- which is everyone except the own
-- character (foreign weapon skill is unreadable in principle):
--   hit.skill       main-hand weapon skill estimate (base + racial + book)
--   hit.book        true when a weapon skill book was ASSUMED for the
--                   main-hand skill (false whenever the skill is measured
--                   -- a measured value already contains the book)
--   hit.rangedSkill same estimate for the ranged weapon's own skill line
-- A MEASURED value (skillExact / rangedSkillExact) is never overwritten.
-- `books` is one player's flag table, db.skillBooks[playerName]; nil-safe.
--
-- Note for consumers: this is a SNAPSHOT taken when the unit was scanned.
-- Toggling a book flag afterwards does not travel back into the store, so
-- a display layer that wants the flag to take effect immediately should
-- recompute with H.SkillFor(hit.mainSub, player.race, books) whenever
-- hit.skillExact is not set.
function H.ApplyEstimate(hit, race, books)
    if type(hit) ~= "table" then
        return hit
    end
    if not hit.skillExact then
        local skill = H.SkillFor(hit.mainSub, race, books)
        if skill then
            hit.skill = skill
        end
        hit.book = (hit.skillName and books and books[hit.skillName] and true)
            or false
    else
        hit.book = false
    end
    if not hit.rangedSkillExact then
        local rskill = H.SkillFor(hit.rangedSub, race, books)
        if rskill then
            hit.rangedSkill = rskill
        end
    end
    return hit
end

-- AuraHit(auraNames) -> { melee = , ranged = , spell = }
-- The hit granted by AURAS (DC.HIT_AURAS, data/hit.lua) rather than gear.
-- auraNames : player.auras.names ({[name]=true,...}, nil-safe) -- the SAME
--   aura scan the class-buff tab already reads; no second scan needed.
-- Sums every DC.HIT_AURAS entry whose name is present in auraNames. A
-- hit-granting aura may bump any subset of the three columns (e.g. a
-- spell-only buff) -- absent fields on an entry default to 0, never nil,
-- so the caller can always add the result straight onto a gear sum.
function H.AuraHit(auraNames)
    local sum = { melee = 0, ranged = 0, spell = 0 }
    local auras = DC.HIT_AURAS
    if type(auraNames) ~= "table" or type(auras) ~= "table" then
        return sum
    end
    for name, entry in pairs(auras) do
        if auraNames[name] then
            sum.melee = sum.melee + (entry.melee or 0)
            sum.ranged = sum.ranged + (entry.ranged or 0)
            sum.spell = sum.spell + (entry.spell or 0)
        end
    end
    return sum
end

-- rankValue(arr, rank) -- the per-rank value of one talent bucket.
-- A rank ABOVE the listed count means the table is stale (a tree was
-- rebalanced and the talent gained a rank): it clamps to the highest
-- KNOWN value, which under-reports rather than extrapolating a number
-- nobody verified. Rank 0 / no such bucket contribute nothing.
local function rankValue(arr, rank)
    if type(arr) ~= "table" or not rank or rank < 1 then
        return 0
    end
    local n = table.getn(arr)
    if n < 1 then
        return 0
    end
    if rank > n then
        rank = n
    end
    return arr[rank] or 0
end

-- DualWield(mainSub, offSub) -> bool
-- Both hands hold something that maps to a WEAPON skill. A shield, totem,
-- idol, libram or held-in-off-hand item is not dual wielding -- that is
-- exactly what the gaps in DC.WEAPON_SKILL_OF_SUBTYPE encode.
function H.DualWield(mainSub, offSub)
    local map = DC.WEAPON_SKILL_OF_SUBTYPE or {}
    return (map[mainSub] ~= nil) and (map[offSub] ~= nil)
end

-- TalentHit(classToken, learnedRanks, dualWield)
--   -> { melee = , ranged = , spell = , offhand = , schools = { ... } }
--   classToken   : UnitClass file token ("MAGE", "WARRIOR", ...)
--   learnedRanks : { [talent name] = rank } -- what H.ReadSelfTalents
--                  returns; matching is BY NAME (see data/talenthit.lua)
--   dualWield    : true while two WEAPONS are equipped (H.DualWield) --
--                  gates the hunter's dual-wield-only extra
-- Every field is a number and `schools` is always a table, so the result
-- can be added onto a hit table without nil checks. An unknown class, a
-- nil rank table or a class with no hit talents all yield clean zeros --
-- which is also what a player whose talents have not arrived yet gets.
-- `offhand` is returned SEPARATELY and never folded into `melee`: it buys
-- hit for off-hand swings only.
function H.TalentHit(classToken, learnedRanks, dualWield)
    local sum = { melee = 0, ranged = 0, spell = 0, offhand = 0, schools = {} }
    local byClass = DC.TALENT_HIT
    if type(byClass) ~= "table" or type(learnedRanks) ~= "table" then
        return sum
    end
    local list = classToken and byClass[classToken]
    if type(list) ~= "table" then
        return sum
    end
    local n = table.getn(list)
    for i = 1, n do
        local t = list[i]
        local rank = nil
        if type(t) == "table" and t.name then
            rank = tonumber(learnedRanks[t.name])
        end
        if rank and rank > 0 then
            sum.melee = sum.melee + rankValue(t.melee, rank)
            sum.ranged = sum.ranged + rankValue(t.ranged, rank)
            sum.spell = sum.spell + rankValue(t.spell, rank)
            sum.offhand = sum.offhand + rankValue(t.offhand, rank)
            if dualWield then
                sum.melee = sum.melee + rankValue(t.dwExtra, rank)
            end
            if type(t.schools) == "table" then
                for school, arr in pairs(t.schools) do
                    sum.schools[school] = (sum.schools[school] or 0)
                        + rankValue(arr, rank)
                end
            end
        end
    end
    return sum
end

-- SchoolHit(hit, school) -> total, generic, specific
-- What ONE school column of the HIT tab is worth for this player:
--   generic  : spell hit that helps EVERY school (hit.spell -- gear, auras
--              and generic talents, already summed by the engine)
--   specific : what only THIS school gets -- the gear affix parsed from
--              tooltips plus the school-specific talents
--   total    : the number the column shows
-- `school` may be given in either spelling: the lowercase column id
-- ("fire", DC.SLOTS_HIT) or the capitalized tooltip word ("Fire",
-- DC.HIT_SCHOOLS). The two live side by side because they come from two
-- different sources; DC.HIT_SCHOOL_KEY bridges them (data/hit.lua).
-- Note the input: `hit` is a STORE hit table (after scan/engine.lua's
-- BuildPlayer folded auras and talents in). Handing in the raw table
-- scan/hit.lua's ReadUnit returns yields the gear-only picture, which is
-- correct but is not what a cell should display.
function H.SchoolHit(hit, school)
    if type(hit) ~= "table" then
        return 0, 0, 0
    end
    local generic = hit.spell or 0
    if type(school) ~= "string" then
        return generic, generic, 0
    end
    local lower = string.lower(school)
    local gearKey = (DC.HIT_SCHOOL_KEY and DC.HIT_SCHOOL_KEY[lower]) or school
    local specific = 0
    if type(hit.schools) == "table" then
        specific = specific + (hit.schools[gearKey] or 0)
    end
    if type(hit.talentSchools) == "table" then
        specific = specific + (hit.talentSchools[lower] or 0)
    end
    return generic + specific, generic, specific
end

-- capFor(base, skill) -- linear weapon-skill scaling: every point above
-- the base skill lowers the cap by DC.CAP_PER_SKILL, never below zero.
local function capFor(base, skill)
    local baseSkill = DC.BASE_WEAPON_SKILL or 300
    local extra = (skill or baseSkill) - baseSkill
    if extra < 0 then
        extra = 0
    end
    local v = (base or 0) - extra * (DC.CAP_PER_SKILL or 0)
    if v < 0 then
        v = 0
    end
    return round1(v)
end

-- MeleeCap(mainSub, offSub, skill, capTable)
--   -> { yellow = , white = , dualWield = , skillName = }
--   mainSub/offSub : GetItemInfo itemSubType of the two weapon slots
--   skill          : effective weapon skill (measured or estimated);
--                    nil falls back to the base skill
--   capTable       : DC.HIT_CAPS by default
-- yellow  = cap for special attacks.
-- white   = cap for auto attacks: the dual-wield cap while two WEAPONS are
--           held, otherwise identical to yellow (a single weapon's white
--           swings carry no dual-wield penalty).
-- dualWield = both hands hold something that maps to a weapon skill -- a
--           shield/totem/held-in-off-hand off hand is not dual wielding.
-- skillName = weapon skill of the MAIN hand, nil when it is unknown or
--           empty (bare fists are the caller's business, see ReadUnit).
function H.MeleeCap(mainSub, offSub, skill, capTable)
    capTable = capTable or DC.HIT_CAPS or {}
    local map = DC.WEAPON_SKILL_OF_SUBTYPE or {}
    local skillName = map[mainSub]
    local dualWield = H.DualWield(mainSub, offSub)
    local yellow = capFor(capTable.meleeYellow, skill)
    local white = yellow
    if dualWield then
        white = capFor(capTable.meleeWhiteDW, skill)
    end
    return { yellow = yellow, white = white, dualWield = dualWield,
        skillName = skillName }
end

-- RangedCap(rangedSub, skill, capTable) -> { cap = , skillName = } | nil
-- nil means "this player has no ranged hit question": nothing in the
-- ranged slot, or a relic/wand rather than a ranged ATTACK weapon (see
-- DC.RANGED_ATTACK_SUBTYPE -- wand mechanics are disputed, so wands never
-- drive this column).
-- `skill` is the skill of the RANGED weapon (bows/guns/crossbows/thrown
-- each have their own skill line), not the main hand's.
function H.RangedCap(rangedSub, skill, capTable)
    capTable = capTable or DC.HIT_CAPS or {}
    local ranged = DC.RANGED_ATTACK_SUBTYPE or {}
    if not rangedSub or not ranged[rangedSub] then
        return nil
    end
    local map = DC.WEAPON_SKILL_OF_SUBTYPE or {}
    return { cap = capFor(capTable.ranged, skill), skillName = map[rangedSub] }
end

-- ==================================================================
-- WoW part (runtime only; every call pcall-wrapped)
-- ==================================================================

-- The shared hidden tooltip's global name -- the line FontStrings are read
-- back as getglobal("<name>TextLeft<i>"), so the name must match the frame
-- created in scan/aura.lua.
local TIP_NAME = "DopingControlScanTip"

-- per-item parse cache, [itemId:enchantId] = { sum, sources, name }
local itemCache = {}
local itemCacheSize = 0

-- ClearCache() -- in-memory cache reset (cap safety, and a handy manual
-- reset when item tooltips changed under us).
function H.ClearCache()
    itemCache = {}
    itemCacheSize = 0
end

function H.CacheSize()
    return itemCacheSize
end

-- read all left-hand tooltip lines of one equipped item; nil on failure
local function scanItemLines(unit, slot)
    local tip = DC_Aura and DC_Aura.GetScanTip and DC_Aura.GetScanTip()
    if not tip then
        return nil
    end
    local ok = pcall(function()
        tip:ClearLines()
        tip:SetInventoryItem(unit, slot)
    end)
    if not ok then
        return nil
    end
    local n = 30
    if tip.NumLines then
        local ok2, v = pcall(tip.NumLines, tip)
        if ok2 and type(v) == "number" then
            n = v
        end
    end
    local lines = {}
    for i = 1, n do
        local obj = getglobal(TIP_NAME .. "TextLeft" .. i)
        if obj then
            local text = obj:GetText()
            if text then
                table.insert(lines, text)
            end
        end
    end
    return lines
end

-- parse ONE equipped item -> { sum, sources, name, set } (cached by item
-- id + enchant id). Random suffixes are not part of the key on purpose: no
-- 1.12 suffix grants hit, and a temporary weapon enchant cannot either --
-- the key stays cheap.
--
-- `set` is the item's set block (H.CollectSet). It is item data and caches
-- fine -- EXCEPT when it contains an active-form bonus line, which the
-- client only prints for a unit that already wears enough pieces. Such an
-- entry describes the unit, not the item, so it is returned but never
-- stored: the next player re-reads the tooltip and gets their own truth.
local function itemHit(unit, slot, itemId, enchantId)
    local key = tostring(itemId) .. ":" .. tostring(enchantId)
    local hitEntry = itemCache[key]
    if hitEntry then
        return hitEntry
    end
    local lines = scanItemLines(unit, slot)
    if not lines then
        return nil          -- unreadable tooltip: do NOT cache a zero
    end
    local keep = {}
    -- line 1 is the item name, never a stat line
    for i = 2, table.getn(lines) do
        if H.IsHitSourceLine(lines[i]) then
            table.insert(keep, lines[i])
        end
    end
    local sum, sources = H.SumGear(keep)
    local set = H.CollectSet(lines)
    -- RESIST tab reuse (see file header addendum): the FULL lines array
    -- this pass already fetched for +hit is handed to DC_Resist's pure
    -- parser for resistance stats -- one more in-memory table walk, no
    -- extra SetInventoryItem call. Resistance lines are plain stat text
    -- (no "Equip:" trigger, unlike +hit -- see data/resist.lua), so `lines`
    -- is passed unfiltered rather than `keep`. Existence-guarded: without
    -- scan/resist.lua loaded, `resist` stays nil and H.SumItemResists
    -- treats this item as "not scanned for resist" (never a guessed zero).
    local resist = nil
    if DC_Resist and DC_Resist.SumLines then
        resist = DC_Resist.SumLines(lines)
    end
    hitEntry = { sum = sum, sources = sources, name = lines[1], set = set,
        resist = resist }
    if set and set.contextual then
        return hitEntry     -- unit-specific: use it, never cache it
    end
    if itemCacheSize >= H.CACHE_CAP then
        -- cap-safety wipe: the cache is a pure accelerator, a reset only
        -- costs one re-scan (no correctness meaning, no eviction order)
        H.ClearCache()
    end
    itemCache[key] = hitEntry
    itemCacheSize = itemCacheSize + 1
    return hitEntry
end

-- ReadSelfSkill(skillName) -> effective, rank, modifier | nil
-- The own character's MEASURED weapon skill: rank (4th return of
-- GetSkillLineInfo) plus its modifier (6th return), which is how a
-- temporary or permanent skill bonus shows up.
-- Gotcha: a COLLAPSED skill category is not enumerated by
-- GetNumSkillLines/GetSkillLineInfo. We deliberately do NOT expand
-- categories (that would change the player's skill window); an
-- unenumerable skill simply comes back nil and the caller falls back to
-- the estimate.
function H.ReadSelfSkill(skillName)
    if not skillName or not GetNumSkillLines or not GetSkillLineInfo then
        return nil
    end
    local okN, n = pcall(GetNumSkillLines)
    if not okN or type(n) ~= "number" then
        return nil
    end
    for i = 1, n do
        local ok, name, isHeader, isExpanded, rank, numTemp, modifier =
            pcall(GetSkillLineInfo, i)
        if ok and not isHeader and name == skillName then
            local base = tonumber(rank) or 0
            local mod = tonumber(modifier) or 0
            return base + mod, base, mod
        end
    end
    return nil
end

-- ReadSelfTalents() -> { [talent name] = rank } | nil
-- The OWN character's learned talent ranks, walked as
-- GetNumTalentTabs -> GetNumTalents(tab) -> GetTalentInfo(tab, i).
-- GetTalentInfo takes no unit, so this function is only ever called for
-- the own character; other players' ranks arrive over the protocol in
-- scan/talents.lua and are handed to the same DC_Hit.TalentHit.
--
-- nil vs empty table is the load-bearing distinction:
--   nil   the API is absent or enumerated nothing at all (talent data not
--         available yet) -> the caller must report talents as UNREAD
--   {}    the enumeration worked and the character has spent no points ->
--         a MEASURED zero
-- Only ranks above 0 are stored, so the table stays small and a lookup
-- miss is indistinguishable from "not learned", which is what it means.
function H.ReadSelfTalents()
    if not GetNumTalentTabs or not GetNumTalents or not GetTalentInfo then
        return nil
    end
    local okT, tabs = pcall(GetNumTalentTabs)
    if not okT or type(tabs) ~= "number" then
        return nil
    end
    local ranks = {}
    local enumerated = false
    for tab = 1, tabs do
        local okN, n = pcall(GetNumTalents, tab)
        if okN and type(n) == "number" then
            for i = 1, n do
                -- 1.12: name, iconTexture, tier, column, rank, maxRank, ...
                local ok, name, _, _, _, rank = pcall(GetTalentInfo, tab, i)
                if ok and type(name) == "string" and name ~= "" then
                    enumerated = true
                    local r = tonumber(rank) or 0
                    if r > 0 then
                        ranks[name] = r
                    end
                end
            end
        end
    end
    if not enumerated then
        return nil
    end
    return ranks
end

-- ReadUnit(unit, rawLinks) -> hit, race
--   rawLinks : OPTIONAL { [invSlot] = link } that scan/gear.lua already
--              fetched for this unit. Slots it covers are taken from it
--              (no second GetInventoryItemLink pass); any slot H.SLOTS
--              needs that rawLinks did not cover is fetched here instead.
--   hit      : the store shape documented in the file header
--   race     : race token from UnitRace (2nd return, e.g. "NightElf");
--              needed for the racial weapon-skill estimate of foreign
--              players and stored next to the hit table by the engine.
function H.ReadUnit(unit, rawLinks)
    local hit = {
        melee = 0, spell = 0, schools = {}, sources = {},
        mainSub = nil, offSub = nil, rangedSub = nil,
        skillName = nil, hitRead = false,
        -- RESIST tab reuse (see file header addendum): resistGear starts
        -- empty and resistGearRead false so a caller that never reaches
        -- the accumulation below (e.g. an early return elsewhere) still
        -- sees a well-formed, honestly-unread pair rather than nil fields.
        resistGear = {}, resistGearRead = false,
    }

    local race = nil
    if UnitRace then
        -- 1.12 returns the display name first, the file token second
        local ok, display, token = pcall(UnitRace, unit)
        if ok then
            race = token or display
        end
    end

    -- which slots the caller's link table already covers
    local covered = {}
    if rawLinks and DC_Gear and DC_Gear.InvSlots then
        local gs = DC_Gear.InvSlots()
        for i = 1, table.getn(gs) do
            covered[gs[i]] = true
        end
    end

    local mainHasItem = false
    -- set pieces this unit wears, keyed by set name -- filled in the slot
    -- loop, folded into the sum ONCE after it (H.SumSets)
    local setsWorn = {}
    -- RESIST tab reuse: every itemHit() entry that came back non-nil this
    -- pass (tooltip actually read, whether cached or freshly scanned) --
    -- handed to H.SumItemResists after the loop, see that function's
    -- header for why "entry existed" is the correct bar here.
    local resistItems = {}
    local n = table.getn(H.SLOTS)
    for i = 1, n do
        local slot = H.SLOTS[i]
        local link = nil
        if rawLinks and covered[slot] then
            link = rawLinks[slot]
        elseif GetInventoryItemLink then
            local ok, l = pcall(GetInventoryItemLink, unit, slot)
            if ok then
                link = l
            end
        end
        if link then
            hit.hitRead = true      -- any link at all = the read worked
            local itemId, enchantId = nil, nil
            if DC_Gear and DC_Gear.ParseLink then
                itemId, enchantId = DC_Gear.ParseLink(link)
            end
            if slot == H.SLOT_MAIN then
                mainHasItem = true
            end
            if itemId then
                local entry = itemHit(unit, slot, itemId, enchantId or 0)
                if entry then
                    H.AddSum(hit, entry.sum)
                    table.insert(resistItems, entry)
                    local ns = table.getn(entry.sources)
                    for s = 1, ns do
                        if table.getn(hit.sources) < H.SOURCE_CAP then
                            table.insert(hit.sources, {
                                item = entry.name,
                                kind = entry.sources[s].kind,
                                value = entry.sources[s].value,
                            })
                        end
                    end
                    -- set membership: tally the pieces THIS unit wears per
                    -- set name. The bonus list comes from whichever piece
                    -- is seen first -- every piece of a set prints the same
                    -- list, which is exactly why it must be counted once.
                    if entry.set and entry.set.name then
                        local sname = entry.set.name
                        if not setsWorn[sname] then
                            setsWorn[sname] = { pieces = 0,
                                                bonuses = entry.set.bonuses }
                        end
                        setsWorn[sname].pieces = setsWorn[sname].pieces + 1
                    end
                end
                -- weapon subtypes drive the caps; only the three weapon
                -- slots need the extra item lookup
                if slot == H.SLOT_MAIN or slot == H.SLOT_OFF
                   or slot == H.SLOT_RANGED then
                    local sub = nil
                    if DC_Gear and DC_Gear.ItemInfo then
                        local _, _, s = DC_Gear.ItemInfo(itemId)
                        sub = s
                    end
                    if slot == H.SLOT_MAIN then
                        hit.mainSub = sub
                    elseif slot == H.SLOT_OFF then
                        hit.offSub = sub
                    else
                        hit.rangedSub = sub
                    end
                end
            end
        end
    end

    -- SET BONUSES, folded in ONCE per set (H.SumSets) now that the whole
    -- inventory has been walked and the piece count per set is known.
    -- Doing it here rather than per item is the entire fix: the bonus line
    -- is printed on every piece, so a per-item sum would multiply it by
    -- the number of pieces worn.
    local setSum, setSources = H.SumSets(setsWorn)
    H.AddSum(hit, setSum)
    -- kept SEPARATELY as well, so the tooltip can show the hit number as a
    -- plain addition (items + set bonuses + auras + talents) instead of one
    -- merged "from gear" figure. The totals above already contain it; these
    -- three fields only say how much of it came from sets.
    hit.setMelee = setSum.melee
    hit.setSpell = setSum.spell
    hit.setSchools = setSum.schools

    -- RESIST tab reuse (see file header addendum): fold every scanned
    -- item's resistance contribution into ONE sum, and record whether ANY
    -- item's tooltip was actually read (the honesty gate scan/engine.lua's
    -- gear-reconstruction fallback checks -- see H.SumItemResists).
    hit.resistGear, hit.resistGearRead = H.SumItemResists(resistItems)
    for s = 1, table.getn(setSources) do
        if table.getn(hit.sources) < H.SOURCE_CAP then
            local src = setSources[s]
            table.insert(hit.sources, {
                item = src.set .. " (" .. src.pieces .. " pieces)",
                kind = src.kind,
                value = src.value,
                set = src.set,
            })
        end
    end

    -- main-hand weapon skill name: bare fists run on Unarmed, but an item
    -- whose subtype could not be resolved stays UNKNOWN (nil) -- claiming
    -- "Unarmed" for a weapon we simply failed to identify would be a
    -- wrong assumption, not a missing one. A FAILED read claims nothing
    -- at all: "no main hand" is only meaningful once something was read.
    local map = DC.WEAPON_SKILL_OF_SUBTYPE or {}
    if hit.mainSub then
        hit.skillName = map[hit.mainSub]
    elseif hit.hitRead and not mainHasItem then
        hit.skillName = "Unarmed"
    end

    return hit, race
end
