--[[ Exact, per-character progress for named rare hunter beasts. ]]

local _, ns = ...

local RareState = ns.NewModule("RareState")
ns.Collection = ns.Collection or {}
ns.Collection.RareState = RareState

local TAME_BEAST_SPELL_ID = 1515
-- Modern tames can finish their pet-GUID/stable update noticeably after the
-- channel ends. Keep the target candidate long enough to verify that handoff.
local TAME_CONFIRM_WINDOW = 35
local PET_CONFIRM_DELAYS = { 0, 0.25, 0.75, 1.5, 3, 6, 10, 15 }
local pendingTame

-- This client can hide a unit's GUID from addons, and `strsplit` on a hidden
-- one raises "attempt to perform string conversion on a secret string value".
--
-- The guard belongs here rather than in the callers. It used to live in them,
-- and when the defeat detection was rewritten to read the target's GUID
-- directly the guard was left behind -- so every target change on a protected
-- unit threw an error. One choke point cannot be forgotten by the next caller.
local function CreatureIDFromGUID(guid)
    if not guid or (issecretvalue and issecretvalue(guid)) then
        return nil
    end

    local unitType, _, _, _, _, creatureID = strsplit("-", guid)
    return (unitType == "Creature" or unitType == "Pet")
        and tonumber(creatureID) or nil
end

local function ClearPendingTame(candidate)
    if not candidate or pendingTame == candidate then
        pendingTame = nil
    end
end

local function RememberTameTarget()
    local npcID = CreatureIDFromGUID(UnitGUID("target"))
    if not npcID or not ns.Data.RareCatalog:Get(npcID) then
        -- START and CHANNEL_START are both emitted for Tame Beast on some
        -- clients. The second notification can arrive after the target has
        -- already been converted into a pet, so retain the fresh candidate
        -- captured by the first event instead of throwing it away.
        if pendingTame and GetTime() - pendingTame.startedAt <= 3 then
            return
        end
        pendingTame = nil
        return
    end

    -- Tame Beast requires the hunter to have no active pet. Recording that
    -- starting state gives us independent, locale-safe proof of a successful
    -- tame even on clients where the new pet GUID no longer contains the wild
    -- creature ID and the pet's name is localized.
    local candidate = {
        npcID = npcID,
        startedAt = GetTime(),
        hadPetAtStart = UnitExists("pet") and true or false,
    }
    pendingTame = candidate
    C_Timer.After(TAME_CONFIRM_WINDOW, function()
        ClearPendingTame(candidate)
    end)
end

function RareState:IsCaptured(npcID)
    local character = ns.Database:GetCharacterData()
    return character and character.capturedRares[npcID] == true or false
end

function RareState:IsKilled(npcID)
    local character = ns.Database:GetCharacterData()
    return character and character.killedRares
        and character.killedRares[npcID] == true or false
end

function RareState:IsCompleted(npcID)
    return self:IsCaptured(npcID) or self:IsKilled(npcID)
end

function RareState:GetOutcome(npcID)
    local captured = self:IsCaptured(npcID)
    local killed = self:IsKilled(npcID)
    if captured and killed then return "both" end
    if captured then return "captured" end
    if killed then return "killed" end
    return nil
end

function RareState:SetCaptured(npcID, captured)
    local character = ns.Database:GetCharacterData()
    if not character or not ns.Data.RareCatalog:Get(npcID) then return false end
    local value = captured == true
    if (character.capturedRares[npcID] == true) == value then return false end
    character.capturedRares[npcID] = value or nil
    ns.Events:Send("RARE_COLLECTION_CHANGED", npcID, value, "captured")
    return true
end

function RareState:SetKilled(npcID, killed)
    local character = ns.Database:GetCharacterData()
    if not character or not ns.Data.RareCatalog:Get(npcID) then return false end
    character.killedRares = character.killedRares or {}
    local value = killed == true
    if (character.killedRares[npcID] == true) == value then return false end
    character.killedRares[npcID] = value or nil
    ns.Events:Send("RARE_COLLECTION_CHANGED", npcID, value, "killed")
    return true
end

function RareState:MarkSeen(npcID)
    local character = ns.Database:GetCharacterData()
    if not character or not ns.Data.RareCatalog:Get(npcID) then return false end
    local mapID = C_Map and C_Map.GetBestMapForUnit
        and C_Map.GetBestMapForUnit("player") or nil
    local position = mapID and C_Map.GetPlayerMapPosition
        and C_Map.GetPlayerMapPosition(mapID, "player") or nil
    local previous = character.seenRares[npcID]
    character.seenRares[npcID] = {
        seenAt = time(), mapID = mapID,
        x = position and position.x or nil,
        y = position and position.y or nil,
    }
    if not previous or time() - (previous.seenAt or 0) > 30 then
        ns.Events:Send("RARE_SIGHTING_CHANGED", npcID)
    end
    return previous == nil
end

function RareState:GetSightings()
    local character = ns.Database:GetCharacterData()
    return character and character.seenRares or {}
end

function RareState:GetProgress(npcIDs)
    local completed, total = 0, 0
    if npcIDs then
        for npcID in pairs(npcIDs) do
            total = total + 1
            if self:IsCompleted(npcID) then completed = completed + 1 end
        end
    else
        for _, record in ipairs(ns.Data.RareCatalog:GetAll()) do
            total = total + 1
            if self:IsCompleted(record.npcID) then completed = completed + 1 end
        end
    end
    return completed, total
end

-- Stable data only proves a named rare when no other record shares its skin.
function RareState:ReconcileStable(pets)
    local character = ns.Database:GetCharacterData()
    if not character then return 0 end
    local marked = 0
    for _, pet in ipairs(pets or {}) do
        local rare = pet.displayID
            and ns.Data.RareCatalog:GetUniqueByDisplayID(pet.displayID)
        if rare and not character.capturedRares[rare.npcID] then
            character.capturedRares[rare.npcID] = true
            marked = marked + 1
        end
    end
    if marked > 0 then ns.Events:Send("RARE_COLLECTION_CHANGED") end
    return marked
end

function RareState:OnEnable()
    local function RecordDefeat(npcID, source)
        if not npcID or not ns.Data.RareCatalog:Get(npcID) then return false end
        self:MarkSeen(npcID)
        if self:SetKilled(npcID, true) then
            ns.Debug:Log("KILL", "recorded defeated rare %d from %s", npcID, source)
            return true
        end
        return false
    end

    ns.Events:RegisterEvent("PARTY_KILL", self,
        function(_, targetGUID)
            if (issecretvalue and issecretvalue(targetGUID))
                or not targetGUID then
                return
            end
            local npcID = CreatureIDFromGUID(targetGUID)
            if not npcID or not ns.Data.RareCatalog:Get(npcID) then return end
            RecordDefeat(npcID, "party kill")
        end)

    -- The combat log used to be the fallback here, because some modern pet
    -- kills emit the death before group credit reaches addons. This client
    -- refuses COMBAT_LOG_EVENT_UNFILTERED outright: registering it raises
    -- ADDON_ACTION_FORBIDDEN and shows the player the dialog offering to
    -- disable the addon. It is not available to us, on any terms.
    --
    -- What is left is the target itself, which is enough: a hunter fighting a
    -- named rare has it targeted, and the moment it dies the unit still
    -- carries its real Creature GUID.
    local function CheckTargetDefeat()
        local isDead = UnitIsDeadOrGhost and UnitIsDeadOrGhost("target")
        if issecretvalue and issecretvalue(isDead) then return end
        if isDead then
            RecordDefeat(CreatureIDFromGUID(UnitGUID("target")), "dead target")
            return
        end

        local health = UnitHealth and UnitHealth("target")
        -- Midnight can mark unit health as a secret number. Never compare or
        -- format it; wait for other evidence instead.
        if (issecretvalue and issecretvalue(health)) or health ~= 0 then
            return
        end
        RecordDefeat(CreatureIDFromGUID(UnitGUID("target")), "target health")
    end

    ns.Events:RegisterEvent("PLAYER_TARGET_CHANGED", self, CheckTargetDefeat)

    -- Filtered to the target by the client. Unfiltered, these fire for every
    -- nameplate and group member on screen.
    ns.Events:RegisterUnitEvent("UNIT_HEALTH", { "target" }, self,
        CheckTargetDefeat)

    -- Death changes a unit's flags even when the client keeps its numerical
    -- health private, so this is deliberately separate from UNIT_HEALTH: it
    -- uses the safe UnitIsDeadOrGhost answer at the instant of death rather
    -- than finding the corpse later.
    ns.Events:RegisterUnitEvent("UNIT_FLAGS", { "target" }, self,
        CheckTargetDefeat)

    local function OnTameStarted(_, unit, _, spellID)
        if unit == "player" and spellID == TAME_BEAST_SPELL_ID then
            RememberTameTarget()
        end
    end
    ns.Events:RegisterEvent("UNIT_SPELLCAST_START", self, OnTameStarted)
    ns.Events:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START", self, OnTameStarted)

    local function ResolveActiveRare()
        local npcID = CreatureIDFromGUID(UnitGUID("pet"))
        local rare = npcID and ns.Data.RareCatalog:Get(npcID)
        if rare then return rare, "npcID" end

        -- Some modern tames replace the wild NPC id in the Pet GUID. The
        -- freshly tamed pet keeps the rare's proper name, so a globally unique
        -- catalogue name plus a matching localized family is safe fallback
        -- evidence. Shared names are deliberately rejected by RareCatalog.
        local petName = UnitName("pet")
        rare = ns.Data.RareCatalog:GetUniqueByName(petName)
        if not rare then return nil end
        local family = rare.family and ns.Data.API:GetFamily(rare.family)
        local expectedFamily = family and ns.L[family.nameKey]
        local activeFamily = UnitCreatureFamily("pet")
        if activeFamily and expectedFamily and activeFamily ~= expectedFamily then
            return nil
        end

        return rare, "unique name"
    end

    local function CaptureActiveRare(expectedNPCID)
        local rare, evidence = ResolveActiveRare()
        if not rare or (expectedNPCID and rare.npcID ~= expectedNPCID) then
            return false
        end

        local candidate = pendingTame
        if candidate and candidate.npcID == rare.npcID then
            ClearPendingTame(candidate)
        end
        if self:SetCaptured(rare.npcID, true) then
            ns.Debug:Log("TAME", "captured rare %d from active pet (%s)",
                rare.npcID, evidence)
        end
        return true
    end

    local function CaptureTameTransition(candidate)
        if not candidate or candidate.hadPetAtStart or not UnitExists("pet") then
            return false
        end

        -- The player began Tame Beast with no pet and now owns one. This is
        -- deliberately only used while the original target candidate is alive;
        -- it cannot turn an ordinary summoned pet into rare progress.
        ClearPendingTame(candidate)
        if self:SetCaptured(candidate.npcID, true) then
            ns.Debug:Log("TAME", "captured rare %d from tame transition",
                candidate.npcID)
        end
        return true
    end

    local function ConfirmTame()
        local candidate = pendingTame
        if not candidate or GetTime() - candidate.startedAt > TAME_CONFIRM_WINDOW then
            ClearPendingTame(candidate)
            return false
        end

        if CaptureActiveRare(candidate.npcID) then
            return true
        end

        return CaptureTameTransition(candidate)
    end

    local function SchedulePetChecks(requirePending)
        for _, delay in ipairs(PET_CONFIRM_DELAYS) do
            C_Timer.After(delay, requirePending and ConfirmTame or CaptureActiveRare)
        end
    end

    ns.Events:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED", self,
        function(_, unit, _, spellID)
            if unit ~= "player" or spellID ~= TAME_BEAST_SPELL_ID then return end
            SchedulePetChecks(true)
        end)

    ns.Events:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP", self,
        function(_, unit, _, spellID)
            if unit == "player" and spellID == TAME_BEAST_SPELL_ID then
                SchedulePetChecks(true)
            end
        end)

    ns.Events:RegisterEvent("UNIT_PET", self, function(_, unit)
        if unit == "player" then SchedulePetChecks(false) end
    end)

    -- UNIT_PET may precede the final Pet GUID and a reload has no pending tame
    -- at all. A known rare NPC id on the active pet is sufficient proof that
    -- this character owns it, so reconcile it independently of the cast.
    ns.Events:RegisterEvent("PLAYER_ENTERING_WORLD", self, function()
        SchedulePetChecks(false)
    end)

    local function OnTameFailed(_, unit, _, spellID)
        if unit == "player" and spellID == TAME_BEAST_SPELL_ID then
            pendingTame = nil
        end
    end
    ns.Events:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED", self, OnTameFailed)
    ns.Events:RegisterEvent("UNIT_SPELLCAST_FAILED", self, OnTameFailed)
    ns.Events:RegisterEvent("UNIT_SPELLCAST_FAILED_QUIET", self, OnTameFailed)
end
