-- TOG Tools — I'd Hit That (engine + HUD)
-- Shows your chance to hit the current target, always-visible, at a glance.
--
-- The whole design turns on one fact: hit chance is a PURE function of two
-- independent halves that change at completely different rates.
--
--   * The player half (gear hit%, weapon skill, talents, buffs) is expensive
--     to compute but changes rarely — gear swaps, level ups, talent changes.
--   * The target half (level -> defense -> level offset) is ~5 arithmetic
--     ops and changes constantly, every time you tab targets.
--
-- So they are cached separately. Target switching — the frequent operation —
-- never touches the expensive path. There is NO OnUpdate anywhere in this
-- file; every recompute is event-driven, and the noisy events (UNIT_AURA,
-- UNIT_INVENTORY_CHANGED) only set a dirty flag that is consumed lazily on
-- the next display refresh. Idle cost is zero.
--
-- Version coverage (this addon ships 6 TOCs, so every branch matters):
--   Vanilla / TBC / Wrath : weapon skill vs defense model. Weapon skill is
--                           real and UnitAttackBothHands exists (verified in
--                           Blizzard's Vanilla PaperDollFrame.lua).
--   Cata / Mists          : weapon skill was removed in 4.0. Uses Blizzard's
--                           own BaseMissChance tables + combat ratings.
--   Retail                : hit was removed as a stat entirely. The module
--                           reports "always hits" and does no math.

local _, addon = ...
local gv = addon.gameVersion

local IdHitThat = {}
addon.IdHitThat = IdHitThat

-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
-- Vanilla/TBC/Wrath weapon-skill model. A unit's defense skill is level * 5;
-- it is NOT exposed by any API for NPCs, so it must be derived from level.
local SKILL_PER_LEVEL = 5

-- Dual-wielding adds a flat penalty to WHITE swings only. Specials (yellow)
-- are unaffected. Blizzard's own constant on clients that define it; the
-- literal is the long-standing Vanilla value for clients that don't.
local DUAL_WIELD_WHITE_PENALTY = DUAL_WIELD_HIT_PENALTY or 19

-- Base spell miss % by (targetLevel - playerLevel), clamped to 0..3.
-- Taken from Blizzard's own Cata PaperDollFrameUtil BaseMissChanceSpell,
-- whose values {4, 5, 6, 17} are identical to the Vanilla spell miss table.
local SPELL_MISS_BY_OFFSET = { [0] = 4, [1] = 5, [2] = 6, [3] = 17 }

-- Blizzard's Cata/Mists physical tables, used verbatim on those flavors
-- instead of the weapon-skill model (weapon skill does not exist there).
local PHYSICAL_MISS_BY_OFFSET = gv.isMists
    and { [0] = 3.0, [1] = 4.5, [2] = 6.0, [3] = 7.5 }
    or  { [0] = 5.0, [1] = 5.5, [2] = 6.0, [3] = 8.0 }

-- A "??" (skull) target returns UnitLevel == -1. Raid bosses are the player's
-- level + 3, which is also the worst realistic case, so it is the safe
-- assumption for an unknown-level elite.
local UNKNOWN_LEVEL_OFFSET = 3

-- Feature-detects. GetHitModifier is NOT called by Blizzard's Vanilla
-- PaperDollFrame, so its presence on the 1.15 Era client is not something we
-- can assume — hence the guard and the tooltip fallback below.
local _GetHitModifier      = _G.GetHitModifier
local _GetSpellHitModifier = _G.GetSpellHitModifier
local _GetCombatRatingBonus = _G.GetCombatRatingBonus
local _UnitAttackBothHands = _G.UnitAttackBothHands
local _UnitRangedAttack    = _G.UnitRangedAttack
local _UnitHasRelicSlot    = _G.UnitHasRelicSlot

-- Inventory slot 18 is the ranged/relic slot.
local RANGED_SLOT = 18

-- ---------------------------------------------------------------------------
-- Cached state
-- ---------------------------------------------------------------------------
-- The expensive half. Rebuilt only when _playerDirty is set.
local _player = {
    baseHit     = 0,     -- gear/talent/buff hit shared by melee and ranged
    hit         = 0,     -- melee +hit %
    rangedHit   = 0,     -- ranged +hit % (melee hit + scope, see rebuildPlayer)
    spellHit    = 0,     -- +spell hit %
    mhSkill     = 0,     -- main-hand weapon skill
    ohSkill     = 0,     -- off-hand weapon skill (0 when not dual wielding)
    rangedSkill = 0,     -- bow/gun/crossbow/thrown/wand skill
    dualWield   = false,
    hasRanged   = false, -- a real ranged WEAPON, not a relic/libram/totem
    level       = 1,
}
local _playerDirty = true

-- The cheap half, recomputed inline on every target change.
local _result = {
    valid      = false,
    whiteMain  = 0,  -- % chance for a main-hand white swing to land
    whiteOff   = 0,
    special    = 0,  -- % chance for a yellow (special) attack to land
    ranged     = 0,  -- % chance for a ranged shot to land (0 = no ranged weapon)
    spell      = 0,
    -- % of landed white swings that glance (reduced damage). FALSE means "not known
    -- on this flavour" and is distinct from 0, which is a real answer -- Retail has
    -- no glancing at all since 6.0.2, and on the weapon-skill flavours the formula
    -- legitimately reaches 0 against a target at or below your own skill. Consumers
    -- must test it before comparing (audit finding rev-K).
    glancing   = 0,
    capShort   = 0,  -- +hit % still needed to cap specials (0 = capped)
    rangedCapShort = 0,
    targetName = nil,
}

-- ---------------------------------------------------------------------------
-- Player half
-- ---------------------------------------------------------------------------
-- Scan equipped items for "+N% hit" when GetHitModifier is unavailable.
-- Vanilla stores hit as an equip AURA rather than an item mod, so
-- GetItemStats does not report it and the tooltip text is the only
-- in-client source. Cached — this runs on gear change, never in combat.
local _scanner
local function scanSlotHit(slot)
    if not GetInventoryItemLink("player", slot) then return 0 end

    if not _scanner then
        _scanner = CreateFrame("GameTooltip", "TOGToolsIdHitThatScanner", nil, "GameTooltipTemplate")
        _scanner:SetOwner(UIParent, "ANCHOR_NONE")
    end

    _scanner:ClearLines()
    if not pcall(_scanner.SetInventoryItem, _scanner, "player", slot) then return 0 end

    local total = 0
    for line = 1, _scanner:NumLines() do
        local fs = _G["TOGToolsIdHitThatScannerTextLeft" .. line]
        local text = fs and fs:GetText()
        -- Vanilla wording is "Improves your chance to hit by N%".
        -- Match a percentage on any line mentioning "hit" so the TBC+
        -- "+N% Hit" phrasings are picked up by the same pass.
        if text and text:lower():find("hit", 1, true) then
            local pct = text:match("(%d+)%%")
            if pct then total = total + tonumber(pct) end
        end
    end
    return total
end

-- Slots 1..17 only. The ranged slot is deliberately excluded and handled
-- separately as ranged-only hit — see rebuildPlayer.
local function tooltipHitScan()
    local total = 0
    for slot = 1, RANGED_SLOT - 1 do
        total = total + scanSlotHit(slot)
    end
    return total
end

local function rebuildPlayer()
    _playerDirty = false

    -- Retail has no hit stat, so Recalculate answers 100 from the table below
    -- without reading a single _player field — the whole expensive half is
    -- dead work there. It is also actively harmful: GetCombatRatingBonus
    -- returns a SECRET NUMBER once execution is tainted by an addon, and
    -- arithmetic on a secret value raises rather than returning a wrong
    -- answer. That is the "attempt to perform arithmetic on field 'hit'
    -- (a secret number value)" error, 30x per target swap. Returning here
    -- kills it at the source instead of guarding each read.
    if gv.isRetail then return end

    _player.level = UnitLevel("player") or 1

    -- Gear/talent/buff hit shared by melee and ranged. GetHitModifier is the
    -- authoritative source where it exists; the tooltip scan is the
    -- self-contained fallback. Kept as a local because melee and ranged each
    -- add their OWN combat rating on top — folding CR_HIT_MELEE into the
    -- shared value would leak melee rating into the ranged number on
    -- Cata/Mists, where the two ratings are genuinely separate.
    local baseHit
    if _GetHitModifier then
        baseHit = _GetHitModifier() or 0
    else
        baseHit = tooltipHitScan()
    end
    _player.baseHit = baseHit

    -- Cata/Mists express hit as a rating; fold the converted bonus in.
    _player.hit = baseHit
    if _GetCombatRatingBonus and CR_HIT_MELEE then
        _player.hit = _player.hit + (_GetCombatRatingBonus(CR_HIT_MELEE) or 0)
    end

    if _GetSpellHitModifier then
        _player.spellHit = _GetSpellHitModifier() or 0
    else
        _player.spellHit = 0
    end
    if _GetCombatRatingBonus and CR_HIT_SPELL then
        _player.spellHit = _player.spellHit + (_GetCombatRatingBonus(CR_HIT_SPELL) or 0)
    end

    -- Weapon skill. Absent on Cata+ (removed in 4.0), where the miss tables
    -- are used instead, so a nil return is expected and not an error.
    if _UnitAttackBothHands then
        local mhBase, mhMod, ohBase, ohMod = _UnitAttackBothHands("player")
        _player.mhSkill = (mhBase or 0) + (mhMod or 0)
        _player.ohSkill = (ohBase or 0) + (ohMod or 0)
    else
        _player.mhSkill, _player.ohSkill = 0, 0
    end

    _player.dualWield = (OffhandHasWeapon and OffhandHasWeapon()) and true or false

    -- Ranged. Slot 18 holds either a ranged weapon OR a relic (Paladin libram,
    -- Shaman totem, Druid idol) — Blizzard's own PaperDollFrame gates its
    -- ranged display on UnitHasRelicSlot for exactly this reason, and without
    -- the check those classes would get a meaningless ranged readout.
    local hasRelic = _UnitHasRelicSlot and _UnitHasRelicSlot("player") or false
    _player.hasRanged = (not hasRelic) and GetInventoryItemLink("player", RANGED_SLOT) ~= nil

    if _player.hasRanged then
        if _UnitRangedAttack then
            local base, mod = _UnitRangedAttack("player")
            _player.rangedSkill = (base or 0) + (mod or 0)
        else
            _player.rangedSkill = 0
        end

        -- Scope hit is RANGED-ONLY and GetHitModifier does not include it, so
        -- it is always read off the ranged weapon separately and added only to
        -- the ranged total. tooltipHitScan skips slot 18 for the same reason,
        -- which is what keeps this from being double-counted on the fallback
        -- path where the scan (not GetHitModifier) produced _player.hit.
        _player.rangedHit = _player.baseHit + scanSlotHit(RANGED_SLOT)

        if _GetCombatRatingBonus and CR_HIT_RANGED then
            _player.rangedHit = _player.rangedHit + (_GetCombatRatingBonus(CR_HIT_RANGED) or 0)
        end
    else
        _player.rangedSkill, _player.rangedHit = 0, 0
    end
end

-- ---------------------------------------------------------------------------
-- Target half
-- ---------------------------------------------------------------------------
-- Vanilla/TBC/Wrath base miss for a given (defense - weaponSkill) delta.
-- The 10-point break is what makes the last few skill points worth double.
local function skillMissChance(delta)
    if delta > 10 then
        return 5 + delta * 0.2
    end
    return 5 + delta * 0.1
end

-- Fraction of landed white swings that glance. Glancing blows only exist
-- against higher-level targets and only on white damage.
local function glancingChance(targetDefense, playerBaseSkill)
    local chance = 10 + (targetDefense - playerBaseSkill) * 2
    if chance < 0 then return 0 end
    if chance > 99 then return 99 end
    return chance
end

-- Resolve the target's effective level. -1 means "??" — the client refuses to
-- report levels far above yours, so fall back to the classification.
local function targetLevel()
    local lvl = UnitLevel("target")
    if not lvl or lvl < 0 then
        return (UnitLevel("player") or 1) + UNKNOWN_LEVEL_OFFSET, true
    end
    return lvl, false
end

local function clampHit(missPct)
    local hit = 100 - missPct
    if hit < 0 then return 0 end
    if hit > 100 then return 100 end
    return hit
end

-- Recompute the target-dependent half. Deliberately allocation-free and
-- branch-light: this is the path that runs on every single target change.
function IdHitThat:Recalculate()
    if _playerDirty then rebuildPlayer() end

    if not UnitExists("target") or not UnitCanAttack("player", "target") then
        _result.valid = false
        return _result
    end

    _result.valid = true
    _result.targetName = UnitName("target")

    -- Retail has no hit stat — everything lands.
    if gv.isRetail then
        _result.whiteMain, _result.whiteOff = 100, 100
        _result.special, _result.spell, _result.ranged = 100, 100, 100
        _result.glancing, _result.capShort, _result.rangedCapShort = 0, 0, 0
        return _result
    end

    local tLevel = targetLevel()
    local offset = tLevel - _player.level
    if offset < 0 then offset = 0 end
    local spellOffset = offset > 3 and 3 or offset

    -- Spell hit is level-table driven on every Classic flavor.
    local spellMiss = (SPELL_MISS_BY_OFFSET[spellOffset] or 4) - _player.spellHit
    if spellMiss < 0 then spellMiss = 0 end
    _result.spell = clampHit(spellMiss)

    -- THIS TESTS THE DATA, NOT THE FLAVOUR, AND THAT IS DELIBERATE -- audit finding
    -- tog-AK. The comment here used to read "Cata / Mists", which describes a flavour
    -- branch and is not what the condition does. Everywhere else in this file the
    -- flavour is asked directly (`gv.isMists` at the miss table, `gv.isRetail` twice),
    -- so the obvious tidy-up is to make this one match them:
    --
    --     if not (gv.isCata or gv.isMists) then <weapon-skill model> end
    --
    -- DO NOT DO THAT. The two agree on every live client, and where they disagree the
    -- flag version is catastrophically worse. If a weapon-skill flavour ever reports
    -- mhSkill as 0 -- an API that moves, a spec that omits the stub, an unarmed edge
    -- case -- the proxy falls through to the miss table, while the flag version feeds
    -- skillMissChance a delta equal to the target's whole defense. MEASURED, by making
    -- the swap and running the suite: a level-60 player against a level-63 target goes
    -- from 92 to hit to 32. Both are numbers, neither raises, and nothing else in the
    -- addon would notice. The proxy degrades gracefully; the flag does not.
    -- `Tests/idhitthat_spec.lua` pins that degenerate case so this cannot be "fixed"
    -- quietly. The ranged branch below reads the same way for the same reason.
    local baseMiss, glance
    if _player.mhSkill > 0 then
        -- Weapon skill is real and reported: Vanilla / TBC / Wrath.
        local defense = tLevel * SKILL_PER_LEVEL
        baseMiss = skillMissChance(defense - _player.mhSkill)
        glance = glancingChance(defense, _player.level * SKILL_PER_LEVEL)
    else
        -- No weapon skill reported. Blizzard's own table, which is what Cata and
        -- Mists use for real (skill was removed in 4.0) and what any other
        -- skill-less state degrades to.
        --
        -- glance becomes FALSE, meaning "not known", which is NOT the same as zero --
        -- audit finding rev-K. It used to be 0, justified by the parenthesis above:
        -- true about WEAPON SKILL, and doing duty for a claim about GLANCING BLOWS,
        -- which are a different mechanic removed two expansions later. Glancing
        -- survived until 6.0.2 (Warlords), so it was live throughout both Cata and
        -- Mists, and a player on those flavours was reading "0% glancing" for
        -- something costing them a chunk of their white damage.
        --
        -- Not a number, because nobody has sourced the Cata/MoP figure yet:
        -- glancingChance() is a weapon-skill formula and there is no weapon skill
        -- here, so it cannot just be called from this branch. A missing number sends
        -- someone looking; a confident wrong one does not. WoWSims' MoP
        -- implementation is the next place to look, since a sim that models the boss
        -- hit table has to commit to a value.
        --
        -- FALSE RATHER THAN NIL, and that is not a style choice. `Tests/idhitthat_spec.lua`
        -- guards the rule that every _result field is ASSIGNED on every flavour, by
        -- poisoning the table and asserting nothing survives. Assigning nil REMOVES the
        -- key, so `pairs` never sees it and that spec would pass without ever checking
        -- this field -- silently retiring the guarantee that stops Retail handing the
        -- HUD a nil. False keeps the key present, and reads the same at every consumer
        -- (`r.glancing and r.glancing > 0`).
        local physOffset = offset > 3 and 3 or offset
        baseMiss = PHYSICAL_MISS_BY_OFFSET[physOffset] or 5
        glance = false
    end

    -- Specials ignore the dual-wield penalty; white swings do not.
    local specialMiss = baseMiss - _player.hit
    if specialMiss < 0 then specialMiss = 0 end
    _result.special = clampHit(specialMiss)
    _result.capShort = (baseMiss - _player.hit > 0) and (baseMiss - _player.hit) or 0

    local whiteMainMiss = specialMiss
    local whiteOffMiss  = specialMiss
    if _player.dualWield then
        whiteMainMiss = whiteMainMiss + DUAL_WIELD_WHITE_PENALTY
        whiteOffMiss  = whiteOffMiss + DUAL_WIELD_WHITE_PENALTY
        if _player.ohSkill > 0 and _player.mhSkill > 0 then
            -- Off-hand uses its own skill, which is often lower.
            local defense = tLevel * SKILL_PER_LEVEL
            local ohBase = skillMissChance(defense - _player.ohSkill) - _player.hit
            if ohBase < 0 then ohBase = 0 end
            whiteOffMiss = ohBase + DUAL_WIELD_WHITE_PENALTY
        end
    end

    _result.whiteMain = clampHit(whiteMainMiss)
    _result.whiteOff  = _player.dualWield and clampHit(whiteOffMiss) or 0
    _result.glancing  = glance

    -- Ranged. Deliberately NOT derived from the melee numbers: it uses its own
    -- weapon skill and its own hit total, and unlike melee it has no dual-wield
    -- penalty and no glancing blows — every shot that isn't a miss lands for
    -- full damage. For a hunter this is the number that actually matters.
    if _player.hasRanged then
        local rangedMiss
        if _player.rangedSkill > 0 then
            rangedMiss = skillMissChance(tLevel * SKILL_PER_LEVEL - _player.rangedSkill)
        else
            rangedMiss = PHYSICAL_MISS_BY_OFFSET[offset > 3 and 3 or offset] or 5
        end
        rangedMiss = rangedMiss - _player.rangedHit
        if rangedMiss < 0 then rangedMiss = 0 end
        _result.ranged = clampHit(rangedMiss)
        _result.rangedCapShort = rangedMiss
    else
        _result.ranged, _result.rangedCapShort = 0, 0
    end

    return _result
end

-- Read-only accessor for the tab / HUD.
function IdHitThat:GetResult()
    return _result
end

-- ---------------------------------------------------------------------------
-- Events
-- ---------------------------------------------------------------------------
-- Everything that invalidates the EXPENSIVE half only sets a flag. The
-- rebuild happens once, lazily, inside the next Recalculate — so a full gear
-- swap firing 19 UNIT_INVENTORY_CHANGED events costs exactly one rebuild.
local function markPlayerDirty()
    _playerDirty = true
end

local _frame

function IdHitThat:OnEnable()
    if _frame then return end
    _frame = CreateFrame("Frame")

    _frame:RegisterEvent("PLAYER_TARGET_CHANGED")
    _frame:RegisterEvent("UNIT_INVENTORY_CHANGED")
    _frame:RegisterEvent("PLAYER_LEVEL_UP")
    _frame:RegisterEvent("CHARACTER_POINTS_CHANGED")
    if not gv.isRetail then
        _frame:RegisterEvent("SKILL_LINES_CHANGED")
    end
    -- UNIT_AURA is the only high-frequency event here. Registering it unit-
    -- filtered (where supported) means we are not even woken for other units'
    -- aura churn in a 40-man raid.
    if _frame.RegisterUnitEvent then
        _frame:RegisterUnitEvent("UNIT_AURA", "player")
    else
        _frame:RegisterEvent("UNIT_AURA")
    end

    _frame:SetScript("OnEvent", function(_, event, unit)
        if event == "UNIT_INVENTORY_CHANGED" or event == "UNIT_AURA" then
            if unit and unit ~= "player" then return end
            markPlayerDirty()
        elseif event ~= "PLAYER_TARGET_CHANGED" then
            markPlayerDirty()
        end
        IdHitThat:Refresh()
    end)

    markPlayerDirty()
    self:Refresh()
end

-- ---------------------------------------------------------------------------
-- Registration (tab + HUD live in GUI/IdHitThatTab.lua and GUI/IdHitThatHUD.lua)
-- ---------------------------------------------------------------------------
function IdHitThat:Refresh()
    -- The enabled flag used to be read ONLY by UpdateHUD, so switching the
    -- module off in settings hid the bubbles and left the engine recomputing
    -- on every target change, aura and gear event. Gate the expensive half
    -- here instead. UpdateHUD is still called — it is what hides a HUD that
    -- was on screen at the moment the toggle flipped — and it re-checks the
    -- same flag itself, so it draws nothing.
    -- Read through addon:IsModuleEnabled, not `c.enabled` directly: the inline
    -- spelling defaults a MISSING flag to OFF while the shared predicate defaults
    -- it to ON, so on a partly-migrated DB the two disagreed about the same
    -- module (audit round 4 finding G).
    if not addon:IsModuleEnabled("idhitthat") then
        if self.UpdateHUD then self:UpdateHUD() end
        return
    end

    self:Recalculate()
    if self.ResetHUDRetries then self:ResetHUDRetries() end
    if self.UpdateHUD then self:UpdateHUD() end
end

addon.modules["idhitthat"] = IdHitThat
