local _, ns = ...

ns.Fishing = {}
local Fishing = ns.Fishing

BINDING_HEADER_AZEROTHANGLER = "Azeroth Angler"
BINDING_NAME_AZEROTHANGLER_CASTINTERACT = "Cast / Interact"
BINDING_NAME_AZEROTHANGLER_RECAST = "Recast (while fishing)"
BINDING_NAME_AZEROTHANGLER_VOIDFINDER = "Void Finder (Midnight)"

local OVERSIZED_BOBBER_TOY = 118427
local OVERSIZED_BOBBER_AURA = 397827

local interactCVarTable = {
    "SoftTargetInteract",
    "SoftTargetInteractArc",
    "SoftTargetInteractRange",
    "SoftTargetIconGameObject",
    "SoftTargetIconInteract",
}
local interactCVars = {}
for _, cvar in ipairs(interactCVarTable) do interactCVars[string.lower(cvar)] = GetCVar(cvar) end

local baitEnchantIDTable = {
    [4] = true,
    [5] = true,
    [22] = true,
    [26] = true,
    [846] = true,
}

-- Hybrid compatibility list based on Angleur's classic/MoP rod tables.
local legacyFishingPoleIDs = {
    [6256] = true,
    [6365] = true,
    [6366] = true,
    [6367] = true,
    [12225] = true,
    [19022] = true,
    [19970] = true,
    [25978] = true,
    [44050] = true,
    [45120] = true,
    [45858] = true,
    [45991] = true,
    [45992] = true,
    [46337] = true,
    [52678] = true,
    [84660] = true,
    [84661] = true,
}

local baitDefinitions = {
    { itemID = 6529, name = "Shiny Bauble", bonus = 10, duration = 600, source = "Vendor", vendor = "Fishing vendors", price = "50c" },
    { itemID = 6530, name = "Nightcrawlers", bonus = 10, duration = 600, source = "Vendor", vendor = "Fishing vendors", price = "1s" },
    { itemID = 6532, name = "Bright Baubles", bonus = 25, duration = 600, source = "Vendor", vendor = "Fishing vendors", price = "2s 50c" },
    { itemID = 6533, name = "Aquadynamic Fish Lens", bonus = 50, duration = 600, source = "Vendor", vendor = "Fishing vendors", price = "" },
    { itemID = 6811, name = "Aquadynamic Fish Attractor", bonus = 50, duration = 600, source = "Quest/Vendor", vendor = "Alliance quest + vendors", price = "" },
    { itemID = 7307, name = "Flesh Eating Worm", bonus = 25, duration = 600, source = "Loot", vendor = "", price = "" },
    { itemID = 62673, name = "Feathered Lure", bonus = 50, duration = 600, source = "Crafted", vendor = "", price = "" },
    { itemID = 68049, name = "Heat-Treated Spinning Lure", bonus = 75, duration = 900, source = "Crafted", vendor = "", price = "" },
}

local baitByID = {}
for _, bait in ipairs(baitDefinitions) do
    baitByID[tonumber(bait.itemID)] = bait
end

local function getBagItemID(bag, slot)
    if C_Container and C_Container.GetContainerItemID then
        return C_Container.GetContainerItemID(bag, slot)
    end
    if GetContainerItemID then
        return GetContainerItemID(bag, slot)
    end
    return nil
end

local function getBagItemLink(bag, slot)
    if C_Container and C_Container.GetContainerItemLink then
        return C_Container.GetContainerItemLink(bag, slot)
    end
    if GetContainerItemLink then
        return GetContainerItemLink(bag, slot)
    end
    return nil
end

local function getBagItemCount(bag, slot)
    if C_Container and C_Container.GetContainerItemInfo then
        local info = C_Container.GetContainerItemInfo(bag, slot)
        if info and info.stackCount then
            return tonumber(info.stackCount) or 1
        end
    end
    if GetContainerItemInfo then
        local _, count = GetContainerItemInfo(bag, slot)
        return tonumber(count) or 1
    end
    return 1
end

local function resolveLureEntry(itemID)
    return itemID and baitByID[tonumber(itemID)] or nil
end

local function getBagNumSlots(bag)
    if C_Container and C_Container.GetContainerNumSlots then
        return C_Container.GetContainerNumSlots(bag)
    end
    if GetContainerNumSlots then
        return GetContainerNumSlots(bag)
    end
    return 0
end

local function buildBagList()
    local bags = {}
    local maxBag = NUM_BAG_SLOTS or 4
    for bag = 0, maxBag do
        bags[#bags + 1] = bag
    end
    local reagentBag = Enum and Enum.BagIndex and Enum.BagIndex.Reagentbag or nil
    if reagentBag and reagentBag > maxBag then
        bags[#bags + 1] = reagentBag
    end
    return bags
end

local function getEquipmentSetIDs()
    if C_EquipmentSet and C_EquipmentSet.GetEquipmentSetIDs then
        return C_EquipmentSet.GetEquipmentSetIDs() or {}
    end
    return {}
end

local function getEquipmentSetInfo(setID)
    if C_EquipmentSet and C_EquipmentSet.GetEquipmentSetInfo then
        local a, b, _, d = C_EquipmentSet.GetEquipmentSetInfo(setID)
        if type(a) == "table" then
            return a.name, a.iconFileID, a.isEquipped
        end
        return a, b, d
    end
    if GetEquipmentSetInfo then
        local name, iconFileID, _, isEquipped = GetEquipmentSetInfo(setID)
        return name, iconFileID, isEquipped
    end
    return nil, nil, false
end

local function useEquipmentSet(setRef)
    if C_EquipmentSet and C_EquipmentSet.UseEquipmentSet then
        return pcall(C_EquipmentSet.UseEquipmentSet, setRef)
    end
    if UseEquipmentSet then
        return pcall(UseEquipmentSet, setRef)
    end
    return false
end

local function getEquipmentSetRefByName(name)
    for _, info in ipairs(Fishing:GetEquipmentSets()) do
        if info.name == name then
            return info.id or info.name
        end
    end
    return name
end

local function isTaintable()
    return InCombatLockdown() or UnitAffectingCombat("player") or UnitAffectingCombat("pet")
end

local function getPreferredLureSlots()
    local slots = {}
    local names
    local mainhandItemID = GetInventoryItemID and GetInventoryItemID("player", INVSLOT_MAINHAND) or nil
    local mainhandSubclassID = mainhandItemID and C_Item and C_Item.GetItemInfoInstant and select(7, C_Item.GetItemInfoInstant(mainhandItemID)) or nil
    local mainhandIsLegacyPole = legacyFishingPoleIDs[tonumber(mainhandItemID) or -1] == true
    local fishingSubclass = Enum and Enum.ItemWeaponSubclass and Enum.ItemWeaponSubclass.Fishingpole or 20
    local mainhandIsPoleSubclass = mainhandSubclassID and mainhandSubclassID == fishingSubclass or false

    if mainhandIsLegacyPole or mainhandIsPoleSubclass then
        -- Legacy behavior (Angleur-like): apply to main hand first when a rod is equipped there.
        names = {
            "MAINHANDSLOT",
            "FISHINGTOOLSLOT",
            "PROF0TOOLSLOT",
            "PROF1TOOLSLOT",
        }
    else
        -- Modern behavior: profession/fishing tool slots first, main hand as fallback.
        names = {
            "FISHINGTOOLSLOT",
            "PROF0TOOLSLOT",
            "PROF1TOOLSLOT",
            "MAINHANDSLOT",
        }
    end

    local seen = {}
    for _, name in ipairs(names) do
        local slotID = GetInventorySlotInfo and select(1, GetInventorySlotInfo(name)) or nil
        slotID = tonumber(slotID)
        if slotID and slotID > 0 and not seen[slotID] then
            seen[slotID] = true
            slots[#slots + 1] = slotID
        end
    end
    return slots
end

local function getEquippedMainhandInfo()
    local itemID = GetInventoryItemID and GetInventoryItemID("player", INVSLOT_MAINHAND) or nil
    if not itemID then
        return nil, nil, nil
    end
    local subclassID = C_Item and C_Item.GetItemInfoInstant and select(7, C_Item.GetItemInfoInstant(itemID)) or nil
    local link = GetInventoryItemLink and GetInventoryItemLink("player", INVSLOT_MAINHAND) or nil
    return tonumber(itemID), tonumber(subclassID), link
end

local function isFishingPoleSubclass(subclassID)
    local fishingSubclass = Enum and Enum.ItemWeaponSubclass and Enum.ItemWeaponSubclass.Fishingpole or 20
    return subclassID and subclassID == fishingSubclass or false
end

local function isLegacyFishingPole(itemID)
    return legacyFishingPoleIDs[tonumber(itemID) or -1] == true
end

local function spellKnown(spellID)
    if IsSpellKnown then return IsSpellKnown(spellID) end
    if C_SpellBook and C_SpellBook.IsSpellKnown then return C_SpellBook.IsSpellKnown(spellID) end
    return false
end

function Fishing:GetFishingSpellID()
    for fishingID in pairs(ns.constants.FISHING_IDS) do
        if spellKnown(fishingID) then return fishingID end
    end
    return ns.constants.FISHING_PRIMARY_ID
end

function Fishing:GetFishingSpellName()
    return ns.Utils:GetSpellNameByID(self:GetFishingSpellID()) or ns.constants.FISHING_SPELL_NAME or "Fishing"
end

function Fishing:GetFishingToolState()
    local itemID, subclassID, link = getEquippedMainhandInfo()
    local hasLegacyPoleEquipped = isLegacyFishingPole(itemID)
    local hasPoleSubclassEquipped = isFishingPoleSubclass(subclassID)
    local hasMainhandPoleEquipped = hasLegacyPoleEquipped or hasPoleSubclassEquipped

    local hasModernFishingTool = false
    local modernToolName = nil
    local fishingToolSlotID = nil
    local fishingToolSlotName = nil
    for _, slotName in ipairs({ "FISHINGTOOLSLOT", "PROF0TOOLSLOT", "PROF1TOOLSLOT" }) do
        local slotID = GetInventorySlotInfo and select(1, GetInventorySlotInfo(slotName)) or nil
        slotID = tonumber(slotID)
        if slotID and slotID > 0 then
            local slotItemID = GetInventoryItemID and GetInventoryItemID("player", slotID) or nil
            if slotItemID then
                hasModernFishingTool = true
                fishingToolSlotID = slotID
                fishingToolSlotName = slotName
                modernToolName = GetInventoryItemLink and GetInventoryItemLink("player", slotID) or nil
                break
            end
        end
    end

    local canCastFishing = spellKnown(self:GetFishingSpellID())
    
    -- Read enchant info from the equipped fishing tool.
    -- For legacy poles in the mainhand, GetWeaponEnchantInfo() works directly.
    -- For profession fishing tools in dedicated slots, GetWeaponEnchantInfo() does NOT
    -- cover them — we fall back to scanning active player auras for known lure spell IDs.
    -- Known fishing lure buff spell IDs (applied when a lure consumable is used):
    --   6529 Shiny Bauble buff, 6530 Nightcrawlers, 6532 Bright Baubles, etc.
    -- These all share enchant IDs 4,5,22,26,846 — or surface as short-duration weapon buffs.
    local hasEnchant, expirationMS, charges, enchantID
    if hasMainhandPoleEquipped then
        -- Legacy / subclass pole in mainhand: GetWeaponEnchantInfo reads mainhand slot.
        hasEnchant, expirationMS, charges, enchantID = GetWeaponEnchantInfo and GetWeaponEnchantInfo() or nil
    elseif hasModernFishingTool and fishingToolSlotID > 0 then
        -- Profession fishing tool slot: GetWeaponEnchantInfo only covers mainhand (slot 16)
        -- and offhand (slot 17). For profession tool slots there is no public retail API
        -- equivalent to GetWeaponEnchantInfo. We detect lure activity via player auras —
        -- all fishing lure consumables apply a buff with a known set of spell aura IDs.
        -- Lure aura spell IDs (Shiny Bauble=567, Nightcrawlers=566, Bright Baubles=564,
        --   Aquadynamic Fish Attractor=838, Aquadynamic Fish Lens=6811, Heat-Treated=46522).
        local lureAuraIDs = { 567, 566, 564, 838, 6811, 46522 }
        for _, auraSpellID in ipairs(lureAuraIDs) do
            local aura = C_UnitAuras and C_UnitAuras.GetPlayerAuraBySpellID and
                         C_UnitAuras.GetPlayerAuraBySpellID(auraSpellID)
            if aura then
                hasEnchant = true
                -- expirationTime is in absolute seconds (server time); convert to ms
                -- to match GetWeaponEnchantInfo's millisecond-remaining format.
                local now = GetTime and GetTime() or 0
                local serverNow = GetServerTime and GetServerTime() or time()
                local expireAt = aura.expirationTime or 0
                local remaining = math.max(0, expireAt - (GetTime and GetTime() or 0))
                expirationMS = remaining * 1000
                charges = aura.applications or 0
                enchantID = 4  -- treat all lure auras as baitEnchantIDTable-compatible
                break
            end
        end
    end
    
    local enchantRemaining = hasEnchant and math.max(0, math.floor((tonumber(expirationMS) or 0) / 1000)) or 0
    local enchantCharges = tonumber(charges) or 0
    local hasTimedEnchant = (tonumber(expirationMS) or 0) > 0
    local hasChargeEnchant = enchantCharges > 0
    local knownLureEnchant = baitEnchantIDTable[tonumber(enchantID) or -1] == true
    local hasActiveLure = hasEnchant and (hasTimedEnchant or hasChargeEnchant or knownLureEnchant) or false

    local toolLabel
    if hasMainhandPoleEquipped then
        if link and link ~= "" then
            toolLabel = ns.Utils:CleanDisplayText(link)
        else
            toolLabel = "Fishing Pole"
        end
    elseif hasModernFishingTool then
        if modernToolName and modernToolName ~= "" then
            toolLabel = ns.Utils:CleanDisplayText(modernToolName)
        else
            toolLabel = "Profession Fishing Tool"
        end
    elseif canCastFishing then
        toolLabel = "Spell-based fishing (no rod equipped)"
    else
        toolLabel = "Fishing unavailable"
    end

    return {
        hasLegacyPoleEquipped = hasLegacyPoleEquipped,
        hasPoleSubclassEquipped = hasPoleSubclassEquipped,
        hasMainhandPoleEquipped = hasMainhandPoleEquipped,
        hasModernFishingTool = hasModernFishingTool,
        hasLureTarget = hasMainhandPoleEquipped or hasModernFishingTool,
        fishingToolSlotID = fishingToolSlotID or 0,
        fishingToolSlotName = fishingToolSlotName or "",
        canCastFishing = canCastFishing,
        hasActiveLure = hasActiveLure,
        hasMainHandEnchant = hasEnchant and true or false,  -- BUG FIX: was referencing undefined variable; now uses declared hasEnchant
        enchantID = tonumber(enchantID) or 0,
        enchantRemaining = enchantRemaining,
        enchantCharges = enchantCharges,
        toolLabel = toolLabel,
    }
end

function Fishing:IsFishingSpell(spellName, spellID)
    if spellID and ns.constants.FISHING_IDS[spellID] then return true end
    if not spellName or spellName == "" then return false end
    return spellName == self:GetFishingSpellName() or spellName == "Fishing"
end

function Fishing:IsFishing()
    local spellID = select(8, UnitChannelInfo("player"))
    return spellID and ns.constants.FISHING_IDS[spellID] or false
end

function Fishing:IsLunkerActive()
    local spellID = select(8, UnitChannelInfo("player"))
    return spellID == 392270
end

function Fishing:IsFishingpoleEquipped()
    local state = self:GetFishingToolState()
    return state.hasMainhandPoleEquipped
end

function Fishing:HasLureTarget()
    local state = self:GetFishingToolState()
    return state.hasLureTarget and true or false
end

function Fishing:CanUseLures()
    local state = self:GetFishingToolState()
    return state.hasLureTarget or state.canCastFishing
end

function Fishing:GetPreferredLureItemID()
    if not (ns.DB and ns.DB.profile and ns.DB.profile.alerts) then return nil end
    local itemID = tonumber(ns.DB.profile.alerts.preferredLureItemID)
    return itemID and baitByID[itemID] and itemID or nil
end

function Fishing:SetPreferredLure(itemID)
    if not (ns.DB and ns.DB.profile and ns.DB.profile.alerts) then return end
    local numeric = tonumber(itemID)
    if not numeric or not baitByID[numeric] then return end
    ns.DB.profile.alerts.preferredLureItemID = numeric
    if ns.UI and ns.UI.RefreshAll then ns.UI:RefreshAll() end
end

function Fishing:CyclePreferredLure(direction)
    local rows = self:GetAvailableLures()
    if #rows == 0 then
        return nil
    end

    local preferred = self:GetPreferredLureItemID()
    local index = 0
    for i, row in ipairs(rows) do
        if tonumber(row.itemID) == tonumber(preferred) then
            index = i
            break
        end
    end

    local step = tonumber(direction) or 1
    if index == 0 then
        index = step >= 0 and 1 or #rows
    else
        index = index + step
        if index > #rows then index = 1 end
        if index < 1 then index = #rows end
    end

    self:SetPreferredLure(rows[index].itemID)
    return rows[index]
end

function Fishing:GetSyntheticLureState()
    local synthetic = ns.state.syntheticLure
    if not synthetic then return nil end
    local now = GetTime and GetTime() or 0
    if (synthetic.expiresAt or 0) <= now then
        ns.state.syntheticLure = nil
        return nil
    end
    return {
        active = true,
        remaining = math.max(0, math.floor((synthetic.expiresAt or now) - now)),
        charges = 0,
        label = synthetic.label or "Active",
        synthetic = true,
        name = synthetic.name or "Lure",
    }
end

function Fishing:GetPendingLureState()
    local pending = ns.state.pendingLure
    if not pending then return nil end
    local now = GetTime and GetTime() or 0
    if (pending.expiresAt or 0) <= now then
        ns.state.pendingLure = nil
        return nil
    end
    return {
        active = true,
        remaining = math.max(0, math.floor((pending.expiresAt or now) - now)),
        charges = 0,
        label = "Applying " .. tostring(pending.name or "lure") .. "...",
        synthetic = true,
        pending = true,
        name = pending.name or "Lure",
    }
end

function Fishing:SetPendingLure(lure, seconds)
    if not lure then return end
    local now = GetTime and GetTime() or 0
    ns.state.pendingLure = {
        name = lure.name,
        expiresAt = now + (tonumber(seconds) or 2),
    }
end

function Fishing:SetSyntheticLure(lure)
    if not lure or (tonumber(lure.duration) or 0) <= 0 then return end
    local now = GetTime and GetTime() or 0
    ns.state.syntheticLure = {
        name = lure.name,
        expiresAt = now + (tonumber(lure.duration) or 0),
        label = "Active • " .. ns.Utils:FormatDuration(tonumber(lure.duration) or 0),
    }
end

function Fishing:IsFlyingSafe()
    if C_UnitAuras and not (C_Secrets and C_Secrets.ShouldSpellAuraBeSecret and C_Secrets.ShouldSpellAuraBeSecret(125883)) then
        return C_UnitAuras.GetPlayerAuraBySpellID and C_UnitAuras.GetPlayerAuraBySpellID(125883)
    end
    return IsFlying and IsFlying()
end

function Fishing:IsSleeping()
    return ns.DB and ns.DB.profile and ns.DB.profile.interaction and ns.DB.profile.interaction.sleepingMode == true
end

function Fishing:SetSleeping(enabled, source)
    if not (ns.DB and ns.DB.profile and ns.DB.profile.interaction) then return false end
    local desired = enabled and true or false
    if self:IsSleeping() == desired then
        return desired
    end

    ns.DB.profile.interaction.sleepingMode = desired

    if desired then
        self:ClearBindings()
        ns.Utils:Print("Sleep mode enabled. Auto cast helpers are paused.")
        if source ~= "command" and source ~= "minimap" then
            -- no-op, keep source parameter available for future behavior routing
        end
    else
        ns.Utils:Print("Sleep mode disabled. Fishing helpers are active.")
    end

    if ns.Minimap and ns.Minimap.RefreshButton then ns.Minimap:RefreshButton() end
    if ns.UI and ns.UI.RefreshAll then ns.UI:RefreshAll() end
    return desired
end

function Fishing:ToggleSleeping(source)
    return self:SetSleeping(not self:IsSleeping(), source)
end

function Fishing:AllowFishing()
    if self:IsSleeping() then
        return false
    end
    -- Dismount with key: dismount instead of blocking
    if IsMounted() then
        if ns.DB.profile.interaction.dismountWithKey then
            if not isTaintable() and Dismount then Dismount() end
        end
        return false
    end
    if not spellKnown(self:GetFishingSpellID())
        or IsPlayerMoving()
        or self:IsFlyingSafe()
        or IsFalling()
        or IsStealthed()
        or IsSubmerged()
        or UnitHasVehicleUI("player")
        or not HasFullControl() then
        return false
    end
    -- Raft support: if surface-swimming, try to deploy raft first
    if IsSwimming() then
        if ns.DB.profile.interaction.raftEnabled then
            self:TryUseRaftToy()
        end
        return false
    end
    if not ns.DB.profile.interaction.overrideLunker and self:IsLunkerActive() then return false end
    if self:IsFishing() then
        return (ns.DB.profile.interaction.recastOnDoubleClick and not IsModifierKeyDown()) or (not ns.DB.profile.interaction.recastOnDoubleClick and IsModifierKeyDown())
    end

    if self:HasLureTarget() then
        local lureState = self:GetLureState()
        if not lureState.active then
            ns.Alerts:NotifyMissingLure()
        end
    end

    return true
end

function Fishing:GetPoleName()
    local state = self:GetFishingToolState()
    return state.toolLabel
end

function Fishing:GetLureState()
    local synthetic = self:GetSyntheticLureState()
    local pending = self:GetPendingLureState()
    local state = self:GetFishingToolState()
    local numericCharges = state.enchantCharges or 0
    local remaining = state.enchantRemaining or 0
    local active = state.hasActiveLure and true or false
    if not active and synthetic then
        return synthetic
    end
    if not active and pending then
        return pending
    end
    local canLure = self:HasLureTarget()
    local label = "Missing"
    if active then
        if remaining > 0 then
            label = "Active • " .. ns.Utils:FormatDuration(remaining)
        else
            label = "Active"
        end
    elseif not canLure then
        label = "No Fishing Tool"
    end
    return {
        active = active,
        remaining = remaining,
        charges = numericCharges,
        enchantID = state.enchantID or 0,
        label = label,
    }
end

function Fishing:GetAvailableLures(limit, options)
    options = options or {}
    local rowsByKey = {}
    for _, bag in ipairs(buildBagList()) do
        local slots = getBagNumSlots(bag)
        for slot = 1, slots do
            local itemID = getBagItemID(bag, slot)
            local info = resolveLureEntry(itemID)
            if info then
                local key = tostring(itemID)
                local row = rowsByKey[key]
                local stackCount = getBagItemCount(bag, slot)
                if not row then
                    row = {
                        itemID = tonumber(itemID),
                        name = info.name,
                        count = 0,
                        bonus = tonumber(info.bonus) or 0,
                        duration = tonumber(info.duration) or 0,
                        source = info.source or "Unknown",
                        vendor = info.vendor or "",
                        price = info.price or "",
                        bag = bag,
                        slot = slot,
                    }
                    rowsByKey[key] = row
                end
                row.count = row.count + math.max(1, tonumber(stackCount) or 1)
            end
        end
    end

    local rows = {}
    for _, row in pairs(rowsByKey) do
        rows[#rows + 1] = row
    end
    local preferred = self:GetPreferredLureItemID()
    table.sort(rows, function(a, b)
        if tonumber(a.itemID) == tonumber(preferred) then return true end
        if tonumber(b.itemID) == tonumber(preferred) then return false end
        if a.bonus == b.bonus then
            return tostring(a.name) < tostring(b.name)
        end
        return a.bonus > b.bonus
    end)

    if limit and #rows > limit then
        while #rows > limit do
            table.remove(rows)
        end
    end

    return rows
end

function Fishing:GetBestAvailableLure()
    local preferred = self:GetPreferredLureItemID()
    if preferred then
        for _, row in ipairs(self:GetAvailableLures()) do
            if tonumber(row.itemID) == tonumber(preferred) then
                return row
            end
        end
    end
    return self:GetAvailableLures(1)[1]
end

function Fishing:ApplyBestLure(reason)
    if not self:HasLureTarget() then
        return false
    end
    if InCombatLockdown and InCombatLockdown() then
        return false
    end

    local preferredID = self:GetPreferredLureItemID()
    local lure = nil
    if preferredID then
        for _, row in ipairs(self:GetAvailableLures()) do
            if tonumber(row.itemID) == tonumber(preferredID) then
                lure = row
                break
            end
        end
    end
    if not lure then
        lure = self:GetAvailableLures(1)[1]
        if reason == "manual" and preferredID and baitByID[preferredID] then
            ns.Utils:Print(string.format("Selected lure not found in bags: %s. Using best available.", baitByID[preferredID].name))
        end
    end
    if not lure then
        return false
    end

    local beforeCount = getBagItemCount(lure.bag, lure.slot)

    -- UseContainerItem and C_Container.UseContainerItem are both PROTECTED in
    -- WoW retail and CANNOT be called by addons (ADDON_ACTION_BLOCKED).
    -- The correct addon-safe approach for applying a fishing lure is:
    --   1. UseItemByName(name)  → picks up the lure item onto the cursor
    --   2. UseInventoryItem(slotID) → applies cursor item to the rod slot
    local attempted = false
    if UseItemByName and lure.name and lure.name ~= "" then
        UseItemByName(lure.name)
        attempted = true
    end

    if CursorHasItem and CursorHasItem() then
        -- Build slot list: prefer slots from getPreferredLureSlots(), but
        -- also include the directly-resolved fishingToolSlotID so profession
        -- fishing tool slots are never missed even if slot name lookup fails.
        local lureSlots = getPreferredLureSlots()
        local toolState = Fishing:GetFishingToolState()
        local toolSlotID = toolState and toolState.fishingToolSlotID and toolState.fishingToolSlotID > 0 and toolState.fishingToolSlotID or nil
        if toolSlotID then
            local alreadyIncluded = false
            for _, sid in ipairs(lureSlots) do
                if sid == toolSlotID then alreadyIncluded = true break end
            end
            if not alreadyIncluded then
                table.insert(lureSlots, 1, toolSlotID)
            end
        end
        for _, slotID in ipairs(lureSlots) do
            if UseInventoryItem then
                UseInventoryItem(slotID)
            end
            if not CursorHasItem() then
                break
            end
            if PickupInventoryItem then
                PickupInventoryItem(slotID)
            end
            if not CursorHasItem() then
                break
            end
        end
        if ClearCursor and CursorHasItem() then
            ClearCursor()
        end
    end

    if attempted then
        local pendingToken = (ns.state.pendingLureToken or 0) + 1
        ns.state.pendingLureToken = pendingToken
        self:SetPendingLure(lure, 4)
        ns.Utils:Print(string.format("Attempting bait: %s (+%d)", ns.Utils:AbbrevText(lure.name, 32), lure.bonus or 0))
        local function finalizeLureConfirmation(success)
            if ns.state.pendingLureToken ~= pendingToken then return end
            ns.state.pendingLure = nil
            if success then
                Fishing:SetSyntheticLure(lure)
                ns.Utils:Print(string.format("Bait active: %s", ns.Utils:AbbrevText(lure.name, 32)))
            else
                ns.Utils:Print(string.format("Bait not confirmed: %s", ns.Utils:AbbrevText(lure.name, 32)))
            end
            if ns.UI and ns.UI.RefreshAll then ns.UI:RefreshAll() end
        end

        local function checkLureConfirmation(attemptIndex)
            if ns.state.pendingLureToken ~= pendingToken then return end
            local liveState = Fishing:GetLureState()
            local afterCount = getBagItemCount(lure.bag, lure.slot)
            local consumed = (afterCount < beforeCount) or (getBagItemID(lure.bag, lure.slot) ~= lure.itemID)
            if liveState.active or consumed then
                finalizeLureConfirmation(true)
                return
            end

            if attemptIndex >= 3 then
                finalizeLureConfirmation(false)
                return
            end

            ns:ScheduleTimer(function()
                checkLureConfirmation(attemptIndex + 1)
            end, 0.8)
        end

        ns:ScheduleTimer(function()
            checkLureConfirmation(1)
        end, 0.8)
        return true
    end

    return false
end

function Fishing:TryAutoAttachLure(reason)
    if not (ns.DB and ns.DB.profile.alerts.autoAttachLure) then return false end
    local why = reason or "auto"
    if self:ApplyBestLure(why) then
        return true
    end

    -- If a lure was depleted, rotate preference once and retry.
    if why == "depletion" and self.CyclePreferredLure then
        local previousID = self:GetPreferredLureItemID()
        local nextLure = self:CyclePreferredLure(1)
        if nextLure and tonumber(nextLure.itemID) ~= tonumber(previousID) then
            ns.Utils:Print("Lure depleted. Cycling to: " .. tostring(nextLure.name or "next lure"))
            return self:ApplyBestLure("depletion-cycle")
        end
    end

    return false
end

function Fishing:TryReapplyOversizedBobber()
    if not (ns.DB and ns.DB.profile.alerts.autoReapplyBobber) then return end
    if not (PlayerHasToy and PlayerHasToy(OVERSIZED_BOBBER_TOY)) then return end
    if C_UnitAuras and C_UnitAuras.GetPlayerAuraBySpellID and C_UnitAuras.GetPlayerAuraBySpellID(OVERSIZED_BOBBER_AURA) then return end
    if C_ToyBox and C_ToyBox.IsToyUsable and not C_ToyBox.IsToyUsable(OVERSIZED_BOBBER_TOY) then return end
    if isTaintable() then return end
    if C_ToyBox and C_ToyBox.UseToy then pcall(C_ToyBox.UseToy, OVERSIZED_BOBBER_TOY) end
end

function Fishing:TryUseRaftToy()
    if not (ns.DB and ns.DB.profile.interaction.raftEnabled) then return false end
    local toyName = ns.DB.profile.interaction.raftToyName or ""
    if toyName == "" then return false end
    if isTaintable() then return false end
    pcall(UseItemByName, toyName)
    return true
end

function Fishing:GetVoidFinderButton()
    if self.voidFinderButton then return self.voidFinderButton end
    local btn = CreateFrame("Button", ns.addonName .. "VoidFinderButton", UIParent, "SecureActionButtonTemplate")
    btn:RegisterForClicks("AnyDown", "AnyUp")
    btn:SetAttribute("type", "macro")
    btn:SetAttribute("macrotext",
        "/cleartarget\n" ..
        "/target Hyper-Compressed\n" ..
        "/run if UnitExists('target') and SetRaidTarget then SetRaidTarget('target', 8) end"
    )
    btn:Hide()
    self.voidFinderButton = btn
    return btn
end

function Fishing:ArmVoidFinderBinding()
    if isTaintable() then return end
    local key = GetBindingKey("AZEROTHANGLER_VOIDFINDER")
    if not key then return end
    local btn = self:GetVoidFinderButton()
    SetOverrideBindingClick(btn, true, key, ns.addonName .. "VoidFinderButton")
end

function Fishing:ClearVoidFinderBinding()
    local btn = self.voidFinderButton
    if btn then ClearOverrideBindings(btn) end
end

function Fishing:CheckSleepForMissingTool()
    if not (ns.DB and ns.DB.profile.interaction.sleepWhenNoTool) then return end
    local state = self:GetFishingToolState()
    local hasTool = state.hasMainhandPoleEquipped or state.hasModernFishingTool
    if not hasTool then
        if not self:IsSleeping() then
            ns.state.autoSleptForTool = true
            self:SetSleeping(true, "autosleep")
        end
    else
        if self:IsSleeping() and ns.state.autoSleptForTool then
            ns.state.autoSleptForTool = false
            self:SetSleeping(false, "autosleep")
        end
    end
end

function Fishing:GetEquipmentSets()
    local rows = {}
    for _, setID in ipairs(getEquipmentSetIDs()) do
        local name, iconFileID, isEquipped = getEquipmentSetInfo(setID)
        if name and name ~= "" then
            table.insert(rows, {
                id = setID,
                name = name,
                icon = iconFileID,
                isEquipped = isEquipped and true or false,
            })
        end
    end
    table.sort(rows, function(a, b) return a.name < b.name end)
    return rows
end

function Fishing:GetEquippedSetName()
    for _, info in ipairs(self:GetEquipmentSets()) do
        if info.isEquipped then return info.name end
    end
    return ""
end

function Fishing:SetPreferredSet(name)
    if not (ns.DB and ns.DB.profile and ns.DB.profile.gear) then return end
    ns.DB.profile.gear.preferredSet = name or ""
    if ns.UI then ns.UI:RefreshAll() end
end

function Fishing:CyclePreferredSet(direction)
    local sets = self:GetEquipmentSets()
    if #sets == 0 then
        self:SetPreferredSet("")
        return ""
    end

    local current = ns.DB.profile.gear.preferredSet or ""
    local index = 0
    for i, info in ipairs(sets) do
        if info.name == current then
            index = i
            break
        end
    end

    local step = direction or 1
    if index == 0 then
        index = step >= 0 and 1 or #sets
    else
        index = index + step
        if index > #sets then index = 1 end
        if index < 1 then index = #sets end
    end

    self:SetPreferredSet(sets[index].name)
    return sets[index].name
end

function Fishing:SaveCurrentSetAsPreferred()
    local equippedName = self:GetEquippedSetName()
    if equippedName == "" then
        ns.Utils:Print("Equip an Equipment Manager set first, then save it as your fishing set.")
        return false
    end
    self:SetPreferredSet(equippedName)
    ns.Utils:Print(string.format("Preferred fishing set saved: %s", equippedName))
    return true
end

function Fishing:EquipPreferredSet()
    if InCombatLockdown and InCombatLockdown() then
        ns.Utils:Print("Cannot swap equipment sets during combat.")
        return false
    end

    local preferred = ns.DB.profile.gear.preferredSet or ""
    if preferred == "" then
        ns.Utils:Print("No preferred fishing set selected.")
        return false
    end

    local equipped = self:GetEquippedSetName()
    if equipped ~= "" and equipped ~= preferred then
        ns.DB.profile.gear.returnSet = equipped
    end

    local ok = useEquipmentSet(getEquipmentSetRefByName(preferred))
    if ok then
        ns.Utils:Print(string.format("Equipping fishing set: %s", preferred))
        if ns.UI then ns.UI:RefreshAll() end
        return true
    end
    ns.Utils:Print("Unable to equip the preferred fishing set.")
    return false
end

function Fishing:RestorePreviousSet()
    if InCombatLockdown and InCombatLockdown() then
        ns.Utils:Print("Cannot restore equipment sets during combat.")
        return false
    end

    local returnSet = ns.DB.profile.gear.returnSet or ""
    if returnSet == "" then
        ns.Utils:Print("No previous equipment set has been stored yet.")
        return false
    end

    local ok = useEquipmentSet(getEquipmentSetRefByName(returnSet))
    if ok then
        ns.Utils:Print(string.format("Restoring equipment set: %s", returnSet))
        if ns.UI then ns.UI:RefreshAll() end
        return true
    end
    ns.Utils:Print("Unable to restore the previous equipment set.")
    return false
end

function Fishing:GetSecureButton()
    if self.secureButton then return self.secureButton end
    local button = CreateFrame("Button", ns.addonName .. "SecureButton", UIParent, "SecureActionButtonTemplate")
    button:RegisterForClicks("AnyDown", "AnyUp")
    button:SetAttribute("type", "spell")
    button:SetAttribute("spell", self:GetFishingSpellName())
    button:SetScript("PostClick", function(_, _, down)
        if down then return end
        -- Clear bindings after the click fires so the cast isn't missed
        -- by a GLOBAL_MOUSE_UP race when double-click armed the binding.
        Fishing:ClearBindings()
        ns.state.doubleClickCastArmed = false
        if IsMouselooking and IsMouselooking() and MouselookStop then
            MouselookStop()
        end
    end)
    self.secureButton = button
    return button
end

function Fishing:SetInteractCVars(enabled)
    ns.state.cvarsChanged = true
    if enabled then
        ns.Alerts:EnhanceSounds(true)
        local highlightEnabled = ns.DB and ns.DB.profile and ns.DB.profile.alerts and ns.DB.profile.alerts.highlightBobber
        local iconEnabled = highlightEnabled and not ns.DB.profile.interaction.objectIconDisabled
        SetCVar("SoftTargetInteract", 3)
        SetCVar("SoftTargetInteractArc", 2)
        SetCVar("SoftTargetInteractRange", 60)
        SetCVar("SoftTargetIconGameObject", iconEnabled and 1 or 0)
        SetCVar("SoftTargetIconInteract", iconEnabled and 1 or 0)
    else
        ns.Alerts:EnhanceSounds(false)
        for cvar, value in pairs(interactCVars) do SetCVar(cvar, value) end
    end
    ns:ScheduleTimer(function() ns.state.cvarsChanged = false end, 0.2)
end

function Fishing:ClearBindings()
    if not isTaintable() then
        self:SetInteractCVars(false)
        ClearOverrideBindings(self:GetSecureButton())
        self:ClearVoidFinderBinding()
        ns.state.bindingArmed = false
    else
        ns.state.pendingClear = true
    end
end

function Fishing:ArmBinding()
    if isTaintable() or self:IsFlyingSafe() or GetNumLootItems() ~= 0 then return end
    local key1, key2 = GetBindingKey("AZEROTHANGLER_CASTINTERACT")
    local localizedName = self:GetFishingSpellName()
    local secureButton = self:GetSecureButton()
    if key1 then SetOverrideBindingSpell(secureButton, true, key1, localizedName) end
    if key2 then SetOverrideBindingSpell(secureButton, true, key2, localizedName) end
    -- Recast key: always bound to fishing spell for explicit recast
    if ns.DB.profile.interaction.recastKeyEnabled then
        local rkey = GetBindingKey("AZEROTHANGLER_RECAST")
        if rkey then SetOverrideBindingSpell(secureButton, true, rkey, localizedName) end
    end
    self:SetInteractCVars(true)
    ns.state.bindingArmed = true
end

function Fishing:MarkFishingWindow()
    local now = GetTime and GetTime() or 0
    ns.state.awaitingFishingLootUntil = now + 40
    ns.state.fishingLootRecorded = false
end

function Fishing:IsAwaitingFishingLoot()
    local now = GetTime and GetTime() or 0
    return (ns.state.awaitingFishingLootUntil or 0) > now
end

function Fishing:ConsumeFishingWindow(delay)
    local now = GetTime and GetTime() or 0
    ns.state.awaitingFishingLootUntil = now + (delay or 1.5)
end

function Fishing:HasRecordedCatchThisWindow()
    return ns.state.fishingLootRecorded == true
end

function Fishing:MarkCatchRecorded(delay)
    ns.state.fishingLootRecorded = true
    self:ConsumeFishingWindow(delay or 0.25)
end

function Fishing:HandleSpellcast(unit, castGUID, spellID)
    if unit ~= "player" then return end
    local spellName = ns.Utils:GetSpellNameByID(spellID)
    if not self:IsFishingSpell(spellName, spellID) then return end

    ns.state.lastFishingCastGUID = ns.state.lastFishingCastGUID or ""
    ns.state.lastFishingCastAt = ns.state.lastFishingCastAt or 0

    local now = GetTime and GetTime() or 0
    if castGUID and castGUID ~= "" and castGUID == ns.state.lastFishingCastGUID then
        return
    end
    if now - ns.state.lastFishingCastAt < 0.35 then
        return
    end

    ns.state.lastFishingCastGUID = castGUID or ""
    ns.state.lastFishingCastAt = now
    -- Detect pool vs open water
    local softTarget = GetSoftInteractionTarget and GetSoftInteractionTarget() or nil
    ns.state.lastCastIsPool = softTarget and softTarget ~= "" and true or false
    -- Track last pool cast time for respawn countdown
    if ns.state.lastCastIsPool then
        ns.state.lastPoolCastAt = now
        ns.state.lastPoolCastZone = ns.Utils:GetZoneName()
    end
    self:MarkFishingWindow()
    ns.Tracking:RecordCast()
    self:ArmBinding()
    self:ArmVoidFinderBinding()
    self:TryReapplyOversizedBobber()
    local lureState = self:GetLureState()
    if self:HasLureTarget() and not lureState.active then
        ns.Alerts:NotifyMissingLure()
    end
end

function Fishing:HandleGlobalMouseDown(button)
    if self:IsSleeping() then return end
    if not ns.DB.profile.interaction.doubleClickEnabled then return end
    if button ~= "RightButton" or IsMouseButtonDown("LeftButton") or isTaintable() then return end
    if GetNumLootItems() ~= 0 then return end

    ns.state.doubleClickHeldDown = true
    ns.state.doubleClickHoldToken = (ns.state.doubleClickHoldToken or 0) + 1
    local holdToken = ns.state.doubleClickHoldToken
    ns:ScheduleTimer(function()
        if ns.state.doubleClickHoldToken ~= holdToken then return end
        if ns.state.doubleClickHeldDown and IsMouseButtonDown("RightButton") then
            ns.state.ignoreNextMouseUp = true
            ns.state.previousClickTime = 0
        end
    end, 0.2)

    if not WorldFrame:IsMouseMotionFocus() and GetMouseFoci and GetMouseFoci()[1] ~= nil then
        return
    end

    local now = GetTime()
    local delta = now - (ns.state.previousClickTime or 0)
    if not ns.state.ignoreNextMouseUp and delta >= 0.04 and delta <= (ns.DB.profile.interaction.doubleClickSpeed or 0.2) then
        if self:AllowFishing() then
            SetOverrideBindingClick(self:GetSecureButton(), true, "BUTTON2", ns.addonName .. "SecureButton")
            ns.state.bindingArmed = true
            ns.state.doubleClickCastArmed = true
        elseif self:IsFishing() then
            SetOverrideBinding(self:GetSecureButton(), true, "BUTTON2", "INTERACTTARGET")
            ns.state.bindingArmed = true
            ns.state.doubleClickCastArmed = false
        end
        ns.state.previousClickTime = 0
        return
    end
    ns.state.previousClickTime = now
end

function Fishing:HandleGlobalMouseUp(button)
    if button ~= "RightButton" then return end
    ns.state.doubleClickHeldDown = false
    if ns.state.ignoreNextMouseUp then
        ns.state.ignoreNextMouseUp = false
        if IsMouselooking and IsMouselooking() and MouselookStop then MouselookStop() end
        return
    end
    -- When a double-click cast was just armed, let PostClick clear the binding
    -- after the spell fires. Clearing here races with the SecureButton click.
    if ns.state.doubleClickCastArmed then return end
    if not isTaintable() and ns.state.bindingArmed then self:ClearBindings() end
end

function AzerothAngler_RunBinding()
    if ns and ns.Fishing then
        if ns.Fishing:AllowFishing() then
            ns.Fishing:ArmBinding()
        end
    end
end
