-- DopingControl core/probe.lua
-- Feasibility probe: dumps RAW API returns to dc_probe.txt (in the WoW
-- client's Imports folder) via SuperWoW ExportFile so raw facts can be read
-- from disk instead of retyped from chat lines.
--
-- Entry points (core/init.lua wires the slash command):
--   DC_Probe.HandleSlash(rest) -- rest = text after "probe":
--       ""             -> Battery()          (part 2, near-range questions)
--       "range <name>" -> RangeStart(name) / RangeStop() toggle (part 1)
--   DC_Probe.Battery()
--   DC_Probe.RangeStart(playerName) / DC_Probe.RangeStop()
--   DC_Probe.IsRangeRunning()
--
-- Part 1 (range run): a raid member with known buffs walks away; a 1 Hz
-- poller writes one line per tick:
--   dist | UnitExists | UnitIsVisible | nA=count(UnitBuff) | 4th-return |
--   nB=count(GetUnitField) | UnitPosition
-- The unit token is RE-RESOLVED from the name every tick (raid indices are
-- volatile after roster changes).
--
-- Part 2 (battery) answers five open client-behavior questions with raw data:
--   Q1 does UnitBuff(unit, i) return a 4th value ~= nil (spell id)?
--   Q2 does GetUnitField(guid,"aura") match the tooltip names; is the
--      slot split really 1-32 buffs / 33-48 debuffs?
--   Q3 does GetWeaponEnchantInfo(unit) name a foreign temp enchant?
--   Q4 does GetInventoryItemLink(unit, slot) serve out-of-sight units?
--   Q5 is the ENCHANT field in foreign item links filled (~= 0)?
--
-- File mechanics: ExportFile appends ".txt" ITSELF -- the name is passed
-- WITHOUT extension ("dc_probe", never "dc_probe.txt"). ExportFile is a
-- whole-file overwrite, so the probe keeps an in-memory ring of lines and
-- rewrites the complete file on every flush.
--
-- Pure Lua 5.0, dofile-loadable offline (all WoW usage inside functions /
-- existence-checked; the poller frame is created lazily in-game).

DopingControl = DopingControl or {}
local DC = DopingControl

DC_Probe = DC_Probe or {}
local P = DC_Probe

P.RING_CAP = 2000          -- max lines kept; oldest dropped first
P.EXPORT_NAME = "dc_probe" -- NO extension (ExportFile appends .txt)
P.BATTERY_UNIT_CAP = 8     -- full detail for at most N foreign units

local ring = {}

-- ------------------------------------------------------------------
-- ring + flush
-- ------------------------------------------------------------------
function P.Clear()
    ring = {}
end

function P.Push(line)
    table.insert(ring, line)
    while table.getn(ring) > P.RING_CAP do
        table.remove(ring, 1)
    end
end

function P.Text()
    return table.concat(ring, "\n")
end

local function say(msg)
    if DEFAULT_CHAT_FRAME then
        DEFAULT_CHAT_FRAME:AddMessage("|cffe6c87eDopingControl|r probe: " .. msg)
    end
end

function P.Flush()
    if ExportFile then
        -- name WITHOUT extension -- ExportFile appends .txt itself.
        -- pcall: optional-DLL call -- a DLL-side I/O error
        -- must not escape into the slash handler or the 1 Hz range ticker.
        local ok = pcall(ExportFile, P.EXPORT_NAME, P.Text())
        if not ok then
            say("ExportFile failed - dump not written")
            return false
        end
        return true
    end
    say("ExportFile missing (SuperWoW required) - dump not written")
    return false
end

-- ------------------------------------------------------------------
-- formatting helpers
-- ------------------------------------------------------------------
local function fmt(v)
    if v == nil then
        return "nil"
    end
    if v == true then
        return "true"
    end
    if v == false then
        return "false"
    end
    if type(v) == "number" then
        return tostring(v)
    end
    if type(v) == "string" then
        return v
    end
    return type(v)
end

local function gtype(name)
    local v = getglobal and getglobal(name)
    if v == nil then
        return "nil"
    end
    return type(v)
end

local function nowStamp()
    if date then
        return date("%Y-%m-%d %H:%M:%S")
    end
    return "?"
end

local function header(kind)
    P.Push("=== DopingControl probe " .. kind .. " " .. nowStamp() .. " ===")
    P.Push("client: TURTLE_WOW_VERSION=" .. fmt(TURTLE_WOW_VERSION)
        .. " SUPERWOW_VERSION=" .. fmt(SUPERWOW_VERSION))
    P.Push("dll-surface: UnitXP=" .. gtype("UnitXP")
        .. " GetUnitField=" .. gtype("GetUnitField")
        .. " ExportFile=" .. gtype("ExportFile")
        .. " UnitPosition=" .. gtype("UnitPosition"))
end

-- resolve a roster unit token by player name; nil if not in group.
-- Re-run every use: raid indices are volatile.
local function resolveUnit(name)
    if not UnitName then
        return nil
    end
    local lower = string.lower(name)
    local nraid = (GetNumRaidMembers and GetNumRaidMembers()) or 0
    if nraid > 0 then
        for i = 1, nraid do
            local ok, nm = pcall(UnitName, "raid" .. i)
            if ok and nm and string.lower(nm) == lower then
                return "raid" .. i
            end
        end
        return nil
    end
    for i = 1, 4 do
        local ok, nm = pcall(UnitName, "party" .. i)
        if ok and nm and string.lower(nm) == lower then
            return "party" .. i
        end
    end
    return nil
end

local function unitGuid(unit)
    if not UnitExists then
        return nil
    end
    local ok, ex, g = pcall(UnitExists, unit)
    if ok and ex and g then
        return g
    end
    return nil
end

-- count UnitBuff entries + first non-nil 4th return
local function pathACounts(unit)
    local n = 0
    local firstId = nil
    local idCount = 0
    if UnitBuff then
        for i = 1, 32 do
            local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, unit, i)
            if not ok or not tex then
                break
            end
            n = n + 1
            if type(id4) == "number" and id4 > 0 then
                idCount = idCount + 1
                if not firstId then
                    firstId = id4
                end
            end
        end
    end
    return n, firstId, idCount
end

-- GetUnitField(guid,"aura") -> comma list for a slot range (sparse table)
local function pathBList(guid, from, to)
    if not GetUnitField or not guid then
        return nil, 0
    end
    local ok, t = pcall(GetUnitField, guid, "aura")
    if not ok then
        return "ERR", 0
    end
    if type(t) ~= "table" then
        return "type=" .. type(t), 0
    end
    local parts = {}
    local n = 0
    for i = from, to do
        local v = t[i]
        if type(v) == "number" and v > 0 then
            n = n + 1
            table.insert(parts, i .. ":" .. v)
        end
    end
    if n == 0 then
        return "-", 0
    end
    return table.concat(parts, ","), n
end

-- ------------------------------------------------------------------
-- Part 2: the battery (/dc probe)
-- ------------------------------------------------------------------
function P.Battery()
    if not UnitName then
        say("no client APIs (offline?) - battery skipped")
        return false
    end
    P.Clear()
    header("battery")

    local roster = (DC_Scan and DC_Scan.BuildRoster and DC_Scan.BuildRoster()) or {}
    local total = table.getn(roster)
    P.Push("roster: " .. total .. " units; full detail for the first "
        .. P.BATTERY_UNIT_CAP .. " foreign units")
    P.Push("")

    -- self weapon enchant baseline (vanilla 6-return form)
    if GetWeaponEnchantInfo then
        local ok, r1, r2, r3, r4, r5, r6 = pcall(GetWeaponEnchantInfo)
        P.Push("self GetWeaponEnchantInfo(): ok=" .. fmt(ok)
            .. " hasMain=" .. fmt(r1) .. " mainExp=" .. fmt(r2)
            .. " mainCharges=" .. fmt(r3) .. " hasOff=" .. fmt(r4)
            .. " offExp=" .. fmt(r5) .. " offCharges=" .. fmt(r6))
    else
        P.Push("self GetWeaponEnchantInfo(): API missing")
    end
    P.Push("")

    local detailed = 0
    for r = 1, total do
        local e = roster[r]
        local isSelf = false
        if UnitIsUnit then
            local ok, v = pcall(UnitIsUnit, e.unit, "player")
            isSelf = (ok and v) and true or false
        end
        if not isSelf and detailed < P.BATTERY_UNIT_CAP then
            detailed = detailed + 1
            local guid = unitGuid(e.unit)
            local online, dist = DC_Reach.ReadUnit(e.unit)
            P.Push("--- " .. e.unit .. " name=" .. fmt(e.name)
                .. " class=" .. fmt(e.class) .. " online=" .. fmt(online)
                .. " dist=" .. fmt(dist) .. " guid=" .. fmt(guid))

            -- Q1: UnitBuff rows with 4th return + tooltip name
            if UnitBuff then
                local rows = 0
                for i = 1, 32 do
                    local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, e.unit, i)
                    if not ok or not tex then
                        break
                    end
                    rows = rows + 1
                    local tipName = nil
                    -- same hidden tooltip as scan/aura.lua (shared accessor,
                    -- never a second frame under the same global name)
                    local tip = DC_Aura and DC_Aura.GetScanTip
                        and DC_Aura.GetScanTip()
                    if tip then
                        local ok2 = pcall(function()
                            tip:ClearLines()
                            tip:SetUnitBuff(e.unit, i)
                        end)
                        if ok2 then
                            local obj = getglobal("DopingControlScanTipTextLeft1")
                            if obj then
                                tipName = obj:GetText()
                            end
                        end
                    end
                    P.Push("Q1 UnitBuff i=" .. i .. " tex=" .. fmt(tex)
                        .. " stacks=" .. fmt(stacks) .. " dtype=" .. fmt(dtype)
                        .. " id4=" .. fmt(id4) .. " tipName=" .. fmt(tipName))
                end
                local nA, firstId, idCount = pathACounts(e.unit)
                P.Push("Q1 summary: buffs=" .. nA .. " id4nonNil=" .. idCount
                    .. " (id4 ~= nil answers Q1)")
            else
                P.Push("Q1 UnitBuff: API missing")
            end

            -- Q2: descriptor list, buff and debuff halves separately
            local buffList = pathBList(guid, 1, 32)
            local debuffList = pathBList(guid, 33, 48)
            P.Push("Q2 GetUnitField(guid,\"aura\") slots1-32=" .. fmt(buffList))
            P.Push("Q2 GetUnitField(guid,\"aura\") slots33-48=" .. fmt(debuffList)
                .. " (compare vs UnitBuff names above -> slot-split check)")

            -- Q3: foreign weapon enchant name
            if GetWeaponEnchantInfo then
                local ok, w1, w2 = pcall(GetWeaponEnchantInfo, e.unit)
                P.Push("Q3 GetWeaponEnchantInfo(unit): ok=" .. fmt(ok)
                    .. " r1=" .. fmt(w1) .. " r2=" .. fmt(w2))
            end

            -- Q4/Q5: inventory links, raw + parsed enchant field.
            -- subType (GetItemInfo return 6, de-shifted by DC_Gear.ItemInfo)
            -- is dumped per slot to verify the DC.ENCH_EXEMPT [ASSUMPTION]
            -- strings against reality (data/enchants.lua).
            local slots = DC_Gear.InvSlots()
            local linkCount = 0
            for s = 1, table.getn(slots) do
                local inv = slots[s]
                local ok, link = pcall(GetInventoryItemLink, e.unit, inv)
                if ok and link then
                    linkCount = linkCount + 1
                    local id, ench = DC_Gear.ParseLink(link)
                    local subType = nil
                    if id then
                        local _, _, st = DC_Gear.ItemInfo(id)
                        subType = st
                    end
                    P.Push("Q4/Q5 slot" .. inv .. " link=" .. fmt(link)
                        .. " -> id=" .. fmt(id) .. " ench=" .. fmt(ench)
                        .. " subType=" .. fmt(subType))
                end
            end
            P.Push("Q4 summary: " .. linkCount .. "/" .. table.getn(slots)
                .. " slots returned a link (0 for an out-of-sight unit"
                .. " answers Q4 negatively)")
            P.Push("Q5b: subType above checks the ENCH_EXEMPT assumption"
                .. " strings - expected \"Wands\"/\"Thrown\" (slot 18) and"
                .. " \"Miscellaneous\" (slot 17), data/enchants.lua")

            -- Q6: resistance read paths (RESIST tab, scan/engine.lua
            -- readResists) -- school 2 (Fire) as a representative probe,
            -- all three paths dumped raw: does UnitResistance answer for a
            -- foreign unit token, does it also accept the GUID directly,
            -- and does GetUnitField(guid,"resistances") carry a usable
            -- table at all -- or does a foreign read silently return 0?
            if UnitResistance then
                local ok1, base1, total1 = pcall(UnitResistance, e.unit, 2)
                P.Push("Q6 UnitResistance(unit,2): ok=" .. fmt(ok1)
                    .. " base=" .. fmt(base1) .. " total=" .. fmt(total1))
                if guid then
                    local ok2, base2, total2 = pcall(UnitResistance, guid, 2)
                    P.Push("Q6 UnitResistance(guid,2): ok=" .. fmt(ok2)
                        .. " base=" .. fmt(base2) .. " total=" .. fmt(total2))
                end
            else
                P.Push("Q6 UnitResistance: API missing")
            end
            if guid and GetUnitField then
                local ok3, t = pcall(GetUnitField, guid, "resistances")
                if ok3 and type(t) == "table" then
                    P.Push("Q6 GetUnitField(guid,\"resistances\"): [1]="
                        .. fmt(t[1]) .. " [2]=" .. fmt(t[2]) .. " [3]="
                        .. fmt(t[3]) .. " [4]=" .. fmt(t[4]) .. " [5]="
                        .. fmt(t[5]) .. " [6]=" .. fmt(t[6]) .. " [7]="
                        .. fmt(t[7]))
                else
                    P.Push("Q6 GetUnitField(guid,\"resistances\"): ok="
                        .. fmt(ok3) .. " type=" .. type(t))
                end
            end
            P.Push("")
        end
    end
    if detailed == 0 then
        P.Push("no foreign units in group - battery needs a party/raid")
    end

    local written = P.Flush()
    if written then
        say("battery done (" .. detailed .. " foreign units) -> "
            .. P.EXPORT_NAME .. ".txt")
    end
    return written
end

-- ------------------------------------------------------------------
-- Part 1: the 1 Hz range run (/dc probe range <name>)
-- ------------------------------------------------------------------
local rangeFrame = nil
local rangeName = nil
local rangeRunning = false
local rangeAcc = 0
local rangeT0 = 0

local function rangeTick()
    local t = (GetTime and GetTime()) or 0
    local rel = math.floor(t - rangeT0 + 0.5)
    local unit = resolveUnit(rangeName)
    if not unit then
        P.Push("t=" .. rel .. "s target '" .. rangeName .. "' not in roster")
        P.Flush()
        return
    end
    local guid = unitGuid(unit)

    local dist = nil
    if UnitXP then
        local ok, d = pcall(UnitXP, "distanceBetween", "player", unit)
        if ok and type(d) == "number" then
            dist = d
        end
    end
    local exists = nil
    if UnitExists then
        local ok, ex = pcall(UnitExists, unit)
        if ok then
            exists = ex
        end
    end
    local visible = nil
    if UnitIsVisible then
        local ok, v = pcall(UnitIsVisible, unit)
        if ok then
            visible = v
        end
    end
    local nA, firstId = pathACounts(unit)
    local _, nB = pathBList(guid, 1, 32)
    local pos = "nil"
    if UnitPosition and guid then
        local ok, x, y = pcall(UnitPosition, guid)
        if ok and type(x) == "number" then
            pos = "ok(" .. string.format("%.1f", x) .. ","
                .. string.format("%.1f", y or 0) .. ")"
        end
    end

    -- part-1 column order (matches the header line written by RangeStart)
    P.Push("t=" .. rel .. "s unit=" .. unit
        .. " dist=" .. fmt(dist)
        .. " exists=" .. fmt(exists)
        .. " visible=" .. fmt(visible)
        .. " nA=" .. nA
        .. " id4first=" .. fmt(firstId)
        .. " nB=" .. nB
        .. " pos=" .. pos)
    P.Flush()
end

function P.IsRangeRunning()
    return rangeRunning
end

function P.RangeStart(name)
    if not CreateFrame then
        say("no client (offline) - range run unavailable")
        return false
    end
    if rangeRunning then
        say("range run already active for " .. fmt(rangeName)
            .. " - stop it first (/dc probe range)")
        return false
    end
    rangeName = name
    rangeRunning = true
    rangeAcc = 0
    rangeT0 = (GetTime and GetTime()) or 0
    P.Clear()
    header("range-run target=" .. name)
    P.Push("columns: t | unit | dist | exists | visible | nA=count(UnitBuff) |"
        .. " id4first | nB=count(GetUnitField slots 1-32) | pos=UnitPosition")
    if not rangeFrame then
        rangeFrame = CreateFrame("Frame", "DopingControlProbeFrame")
    end
    rangeFrame:SetScript("OnUpdate", function()
        rangeAcc = rangeAcc + (arg1 or 0)
        if rangeAcc >= 1.0 then
            rangeAcc = 0
            rangeTick()
        end
    end)
    say("range run started, target " .. name .. ", 1 Hz -> "
        .. P.EXPORT_NAME .. ".txt (stop: /dc probe range " .. name .. ")")
    return true
end

function P.RangeStop()
    if rangeFrame then
        rangeFrame:SetScript("OnUpdate", nil)
    end
    if rangeRunning then
        rangeRunning = false
        P.Push("=== range run stopped " .. nowStamp() .. " ===")
        P.Flush()
        say("range run stopped, " .. table.getn(ring) .. " lines in "
            .. P.EXPORT_NAME .. ".txt")
    end
    rangeName = nil
end

-- ------------------------------------------------------------------
-- slash glue: rest of the line after "probe"
-- ------------------------------------------------------------------
function P.HandleSlash(rest)
    rest = rest or ""
    rest = string.gsub(rest, "^%s+", "")
    rest = string.gsub(rest, "%s+$", "")
    if rest == "" then
        return P.Battery()
    end
    local _, _, word = string.find(rest, "^(%S+)")
    if word and string.lower(word) == "range" then
        if rangeRunning then
            P.RangeStop()
            return true
        end
        local _, _, name = string.find(rest, "^%S+%s+(.+)$")
        if name then
            return P.RangeStart(name)
        end
        say("usage: /dc probe range <playername>")
        return false
    end
    say("usage: /dc probe  |  /dc probe range <playername>")
    return false
end
