local _, ns = ...

local MedaUI = LibStub("MedaUI-2.0")
local Pixel = MedaUI.Pixel

local format = format
local GetTime = GetTime
local pairs = pairs
local ipairs = ipairs
local wipe = wipe
local pcall = pcall
local unpack = unpack
local CreateFrame = CreateFrame
local math_sqrt = math.sqrt
local math_floor = math.floor
local math_max = math.max
local math_min = math.min
local math_abs = math.abs
local math_sin = math.sin
local math_cos = math.cos
local math_rad = math.rad
local C_Timer = C_Timer

-- ============================================================================
-- Constants
-- ============================================================================

local MODULE_NAME      = "GoneFishin"
local MODULE_VERSION   = "1.4"
local MODULE_STABILITY = "stable"   -- "experimental" | "beta" | "stable"
local FISHING_SPELL_NAMES = {
    ["Fishing"] = true,
    ["Void Hole Fishing"] = true,
}
local MAX_RECENT = 6
local ICON_ZOOM = 0.22
local ICON_TEXCOORD = { 0.11, 0.89, 0.11, 0.89 }
local OUTLINE_MAP = { none = "", outline = "OUTLINE", thick = "THICKOUTLINE" }
local QUALITY_COLORS = ITEM_QUALITY_COLORS or {}
local DISTANCE_INTERVAL = 2
local TIMER_INTERVAL = 1
local CHECKLIST_MAX_VISIBLE = 6
local CHECKLIST_ROW_HEIGHT = 20
local RARITY_SORT = { common = 1, uncommon = 2, rare = 3 }
local FALLBACK_DIM = { 0.6, 0.6, 0.6 }
local FALLBACK_GOLD = { 0.9, 0.7, 0.15 }
local FALLBACK_BRIGHT = { 1, 1, 1 }

-- ============================================================================
-- State
-- ============================================================================

local db
local isEnabled = false
local isFishing = false
local fishingStartTime = 0
local sessionStartTime = 0
local sessionCaught = 0
local sessionCasts = 0
local sessionJunk = 0
local currentStreak = 0
local hideTimer = nil
local hideRequestId = 0

local SESSION_TIMEOUT = 300
local sessionTimer = nil
local sessionActive = false

local poolNameSet = {}
local itemInfoCache = {}

local eventFrame
local hudFrame, hudFadeCtrl
local leftPanel, rightPanel, bottomPanel
local statsPanel, tabBar, minimapButton
local scanTip

local arcDirty = true
local hudVisible = false
local distanceElapsed = 0
local timerElapsed = 0

local tabFrames = {}
local tabRendered = {}
local collectionList, zonesList
local collectionSearchBox
local mapFavoritesList, mapFavoritesSummary, mapFavoritesEmpty
local selectedFavoriteId = nil
local activeWaypointFavoriteId = nil
local RefreshMapFavoritesList
local ShowHUD, HideHUD
local EndSession, CancelSessionTimeout, ShowExportWindow

local checklistScroll, checklistContent
local checklistRows = {}
local expandRows = {}
local junkExpanded = false
local missingExpanded = false
local junkHeaderBtn, missingHeaderBtn

-- Font cache
local fontCache = {}
local function GetFontObj(fontValue, size, outline)
    local path = MedaUI:GetFontPath(fontValue)
    local flags = OUTLINE_MAP[outline] or outline or ""
    local key = (path or "default") .. "_" .. size .. "_" .. flags
    if fontCache[key] then return fontCache[key] end
    local fo = CreateFont("MedaAurasGF_" .. key:gsub("[^%w]", "_"))
    if path then
        fo:SetFont(path, size, flags)
    else
        fo:CopyFontObject(GameFontNormal)
        local p = fo:GetFont()
        fo:SetFont(p, size, flags)
    end
    fontCache[key] = fo
    return fo
end

-- ============================================================================
-- Data Migration
-- ============================================================================

local DB_VERSION = 3

local MIGRATIONS = {
    [1] = function(mdb)
        mdb.fishLog       = mdb.fishLog or {}
        mdb.zoneStats     = mdb.zoneStats or {}
        mdb.poolStats     = mdb.poolStats or {}
        mdb.favorites     = mdb.favorites or {}
        mdb.recentCatches = mdb.recentCatches or {}
        mdb.totalCaught      = mdb.totalCaught or 0
        mdb.totalCasts       = mdb.totalCasts or 0
        mdb.totalFishingTime = mdb.totalFishingTime or 0
        mdb.sessionCount     = mdb.sessionCount or 0
        mdb.longestStreak    = mdb.longestStreak or 0
    end,
    [2] = function(mdb)
        mdb.discoveredZones = mdb.discoveredZones or {}
        mdb.discoveredPools = mdb.discoveredPools or {}
        mdb.collectionSort  = mdb.collectionSort or "missing"
        for _, entry in pairs(mdb.fishLog) do
            entry.classID     = entry.classID or nil
            entry.subClassID  = entry.subClassID or nil
            entry.expansionID = entry.expansionID or nil
            entry.category    = entry.category or nil
        end
    end,
    [3] = function(mdb)
        if mdb.auraShowRecent ~= nil and mdb.auraShowChecklist == nil then
            mdb.auraShowChecklist = mdb.auraShowRecent
        end
        mdb.poolObjectNames = mdb.poolObjectNames or {}
    end,
}

local function RunMigrations(mdb)
    local currentVersion = mdb.dbVersion or 0
    if currentVersion >= DB_VERSION then return end

    for version = currentVersion + 1, DB_VERSION do
        local migrateFn = MIGRATIONS[version]
        if migrateFn then
            local ok, err = pcall(migrateFn, mdb)
            if ok then
                mdb.dbVersion = version
                MedaAuras.Log(format("[GoneFishin] Migration v%d applied", version))
            else
                MedaAuras.LogError(format("[GoneFishin] Migration v%d FAILED: %s", version, err))
                return
            end
        else
            mdb.dbVersion = version
        end
    end
end

-- ============================================================================
-- Helpers
-- ============================================================================

local function GetQualityColor(quality)
    local c = QUALITY_COLORS[quality]
    if c then return c.r, c.g, c.b end
    return 1, 1, 1
end

local function FormatTime(seconds)
    seconds = math_floor(seconds)
    if seconds >= 3600 then
        return format("%dh %02dm", math_floor(seconds / 3600), math_floor((seconds % 3600) / 60))
    elseif seconds >= 60 then
        return format("%dm %02ds", math_floor(seconds / 60), seconds % 60)
    end
    return format("%ds", seconds)
end

local function GetCurrentArea()
    local sub = GetSubZoneText()
    if sub and sub ~= "" then return sub end
    return GetMinimapZoneText() or ""
end

local function GetCurrentZone()
    return GetRealZoneText() or ""
end

local function GetFishingSkill()
    local _, _, _, fishIdx = GetProfessions()
    if not fishIdx then return 0, 0 end
    local _, _, cur, max = GetProfessionInfo(fishIdx)
    return cur or 0, max or 0
end

local function GetPlayerMapPos()
    local mapID = C_Map.GetBestMapForUnit("player")
    if not mapID then return nil, 0, 0 end
    local pos = C_Map.GetPlayerMapPosition(mapID, "player")
    if not pos then return mapID, 0, 0 end
    return mapID, pos:GetXY()
end

local function CacheItemInfo(itemID)
    if itemInfoCache[itemID] then return itemInfoCache[itemID] end
    local gfd = ns.GoneFishinData
    local staticData = gfd and gfd.midnightItems and gfd.midnightItems[itemID]
    if staticData then
        itemInfoCache[itemID] = {
            name = staticData.name, icon = staticData.icon or "",
            quality = staticData.quality or 1, category = staticData.category,
            isMidnight = true,
        }
        return itemInfoCache[itemID]
    end
    local name, _, quality, _, _, _, _, _, _, icon, _, classID, subClassID, _, expansionID = C_Item.GetItemInfo(itemID)
    if name then
        itemInfoCache[itemID] = {
            name = name, icon = icon, quality = quality,
            classID = classID, subClassID = subClassID, expansionID = expansionID,
        }
        return itemInfoCache[itemID]
    end
    return nil
end

-- Runtime item classification for items not in the static dataset
local CLASSID_CATEGORIES = {
    [0]  = "consumable",
    [2]  = "gear",
    [4]  = "gear",
    [7]  = "reagent",
    [9]  = "recipe",
    [12] = "quest",
    [15] = "other",
}
local MISC_SUBCLASS = {
    [0] = "junk", [2] = "pet", [4] = "mount", [5] = "toy",
}

local function ClassifyItem(itemID, info)
    local gfd = ns.GoneFishinData
    if gfd and gfd.midnightItems and gfd.midnightItems[itemID] then
        return gfd.midnightItems[itemID].category, true
    end

    info = info or CacheItemInfo(itemID)
    if not info then return "other", false end

    local quality = info.quality or 1
    if quality == 0 then return "junk", false end

    local classID = info.classID
    local subClassID = info.subClassID

    if classID == 7 and subClassID == 8 then
        return "fish", false
    end

    if classID == 15 then
        local sub = MISC_SUBCLASS[subClassID]
        if sub then return sub, false end
    end

    return CLASSID_CATEGORIES[classID] or "other", false
end

local function BuildPoolNameSet()
    wipe(poolNameSet)
    local gfd = ns.GoneFishinData
    if gfd and gfd.pools then
        for name in pairs(gfd.pools) do
            poolNameSet[name] = true
        end
    end
end

local function ComputeBestSpot()
    if not db then return end
    local best, bestCount = nil, 0
    for subzone, stats in pairs(db.poolStats) do
        if (stats.poolCatches or 0) > bestCount then
            bestCount = stats.poolCatches
            best = subzone
        end
    end
    db.bestSpot = best
end

local function GetCurrentFavId()
    local mapID = C_Map.GetBestMapForUnit("player")
    local subzone = GetCurrentArea()
    if mapID and subzone ~= "" then
        return mapID .. ":" .. subzone
    end
    return nil
end

local function IsCurrentSpotFaved()
    local favId = GetCurrentFavId()
    return favId and db.favorites[favId] ~= nil
end

local function GetFavoriteLabel(fav)
    if not fav then return "Favorite Spot" end
    return fav.label or fav.subzone or fav.zone or "Favorite Spot"
end

local function GetFavoriteDistance(fav)
    if not fav or not fav.mapID or not fav.x or not fav.y then
        return nil
    end

    local mapID, px, py = GetPlayerMapPos()
    if not mapID or mapID ~= fav.mapID then
        return nil
    end

    local dx, dy = px - fav.x, py - fav.y
    return math_sqrt(dx * dx + dy * dy)
end

local function ClearFavoriteWaypoint(silent)
    local prevFavId = activeWaypointFavoriteId
    activeWaypointFavoriteId = nil

    if C_Map and C_Map.ClearUserWaypoint then
        pcall(C_Map.ClearUserWaypoint)
    end
    if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
        pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, false)
    end

    arcDirty = true
    if RefreshMapFavoritesList then
        RefreshMapFavoritesList()
    end

    if not silent and prevFavId and db and db.favorites and db.favorites[prevFavId] then
        print(format("|cff00ccffGone Fishin':|r Cleared waypoint: |cffffd100%s|r", GetFavoriteLabel(db.favorites[prevFavId])))
    end

    if hudVisible and not isFishing then
        HideHUD()
    end
end

local function HasTrackedFavoriteWaypoint()
    if not activeWaypointFavoriteId or not db or not db.favorites then
        return false
    end

    if not db.favorites[activeWaypointFavoriteId] then
        activeWaypointFavoriteId = nil
        if RefreshMapFavoritesList then
            RefreshMapFavoritesList()
        end
        return false
    end

    if C_Map and C_Map.HasUserWaypoint and not C_Map.HasUserWaypoint() then
        activeWaypointFavoriteId = nil
        if RefreshMapFavoritesList then
            RefreshMapFavoritesList()
        end
        return false
    end

    return true
end

local function EnsureWorldMapLoaded()
    if WorldMapFrame then
        return true
    end

    if WorldMapFrame_LoadUI then
        pcall(WorldMapFrame_LoadUI)
    elseif UIParentLoadAddOn then
        pcall(UIParentLoadAddOn, "Blizzard_WorldMap")
    end

    return WorldMapFrame ~= nil
end

local function OpenFavoriteOnMap(favId)
    if not db or not db.favorites then
        return false
    end

    local fav = db.favorites[favId]
    if not fav or not fav.mapID then
        return false
    end

    selectedFavoriteId = favId

    if not EnsureWorldMapLoaded() then
        print("|cff00ccffGone Fishin':|r World map is not available yet.")
        if RefreshMapFavoritesList then
            RefreshMapFavoritesList()
        end
        return false
    end

    if ShowUIPanel then
        pcall(ShowUIPanel, WorldMapFrame)
    elseif WorldMapFrame.Show then
        pcall(WorldMapFrame.Show, WorldMapFrame)
    end

    if WorldMapFrame and WorldMapFrame.SetMapID then
        pcall(WorldMapFrame.SetMapID, WorldMapFrame, fav.mapID)
    end

    if RefreshMapFavoritesList then
        RefreshMapFavoritesList()
    end

    return true
end

local function SetFavoriteWaypoint(favId)
    if not db or not db.favorites then
        return false
    end

    local fav = db.favorites[favId]
    if not fav or not fav.mapID or not fav.x or not fav.y then
        print("|cff00ccffGone Fishin':|r Favorite is missing map coordinates.")
        return false
    end

    if activeWaypointFavoriteId == favId then
        ClearFavoriteWaypoint()
        return true
    end

    if not (C_Map and C_Map.SetUserWaypoint and UiMapPoint and UiMapPoint.CreateFromCoordinates) then
        print("|cff00ccffGone Fishin':|r Waypoints are not available in this client.")
        return false
    end

    local point = UiMapPoint.CreateFromCoordinates(fav.mapID, fav.x, fav.y)
    if not point then
        print("|cff00ccffGone Fishin':|r Could not create waypoint for that favorite.")
        return false
    end

    if C_Map.ClearUserWaypoint then
        pcall(C_Map.ClearUserWaypoint)
    end

    local ok, result = pcall(C_Map.SetUserWaypoint, point)
    if not ok or result == false then
        print("|cff00ccffGone Fishin':|r Failed to set waypoint.")
        return false
    end

    if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
        pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, true)
    end

    activeWaypointFavoriteId = favId
    selectedFavoriteId = favId
    ShowHUD()
    arcDirty = true

    if RefreshMapFavoritesList then
        RefreshMapFavoritesList()
    end

    print(format("|cff00ccffGone Fishin':|r Waypoint set: |cffffd100%s|r", GetFavoriteLabel(fav)))
    return true
end

-- ============================================================================
-- Pool Detection (Tooltip Scan)
-- ============================================================================

local function CreateScanTooltip()
    if scanTip then return end
    scanTip = CreateFrame("GameTooltip", "GoneFishinScanTip", nil, "GameTooltipTemplate")
    scanTip:SetOwner(UIParent, "ANCHOR_NONE")
end

local lastSeenPoolName = nil
local poolTooltipHooked = false

local POOL_KEYWORDS = {
    "School", "Pool", "Swarm", "Surge", "Ripple", "Bloom",
    "Torrent", "Vortex", "Cargo", "Treasures", "Salmon",
}

local function LooksLikePool(text)
    if not text then return false end
    if poolNameSet[text] then return true end
    for _, kw in ipairs(POOL_KEYWORDS) do
        if text:find(kw) then return true end
    end
    return false
end

local function HookPoolTooltip()
    if poolTooltipHooked then return end
    poolTooltipHooked = true
    GameTooltip:HookScript("OnShow", function(self)
        local ok, name = pcall(function()
            local line = _G[self:GetName() .. "TextLeft1"]
            if not line then return nil end
            local text = line:GetText()
            if LooksLikePool(text) then return text end
            return nil
        end)
        if ok and name then
            lastSeenPoolName = name
        end
    end)
end

local function LearnPoolObjectName(objectID, realName)
    if not db or not objectID or not realName then return end
    if not db.poolObjectNames then db.poolObjectNames = {} end
    if db.poolObjectNames[objectID] then return end
    db.poolObjectNames[objectID] = realName
    MedaAuras.Log(format("[GoneFishin] Learned pool: #%s = %s", objectID, realName))
end

local function RepairPoolNames()
    if not db or not db.poolObjectNames then return end
    local repaired = 0

    for objectID, realName in pairs(db.poolObjectNames) do
        local badKey = "Pool #" .. objectID

        for _, ps in pairs(db.poolStats) do
            if ps.pools and ps.pools[badKey] then
                ps.pools[realName] = (ps.pools[realName] or 0) + ps.pools[badKey]
                ps.pools[badKey] = nil
                repaired = repaired + 1
            end
        end

        if db.discoveredPools then
            for itemID, pools in pairs(db.discoveredPools) do
                if pools[badKey] then
                    pools[realName] = (pools[realName] or 0) + pools[badKey]
                    pools[badKey] = nil
                    repaired = repaired + 1
                end
            end
        end
    end

    if repaired > 0 then
        MedaAuras.Log(format("[GoneFishin] Repaired %d pool name entries", repaired))
    end
    return repaired
end

local function PurgeOpenWaterPoolData()
    if not db then return 0 end
    RepairPoolNames()
    local purged = 0
    local known = db.poolObjectNames or {}

    for _, ps in pairs(db.poolStats) do
        if ps.pools then
            for poolName, count in pairs(ps.pools) do
                local objID = poolName:match("^Pool #(%d+)$")
                if objID and not known[objID] then
                    ps.poolCatches = math_max(0, (ps.poolCatches or 0) - count)
                    ps.pools[poolName] = nil
                    purged = purged + 1
                end
            end
        end
    end

    if db.discoveredPools then
        for _, pools in pairs(db.discoveredPools) do
            for poolName in pairs(pools) do
                local objID = poolName:match("^Pool #(%d+)$")
                if objID and not known[objID] then
                    pools[poolName] = nil
                    purged = purged + 1
                end
            end
        end
    end

    if purged > 0 then
        MedaAuras.Log(format("[GoneFishin] Purged %d false pool entries from open water catches", purged))
    end
    return purged
end

local function DetectPool()
    for i = 1, GetNumLootItems() do
        local sources = { GetLootSourceInfo(i) }
        if sources[1] then
            local guid = sources[1]
            if type(guid) == "string" and guid:find("^GameObject") then
                local _, _, _, _, _, objectID = strsplit("-", guid)

                local resolvedName = lastSeenPoolName
                if not resolvedName and GameTooltip:IsShown() then
                    local line = _G[GameTooltip:GetName() .. "TextLeft1"]
                    if line then
                        local text = line:GetText()
                        if text and text ~= "" and LooksLikePool(text) then
                            resolvedName = text
                        end
                    end
                end

                lastSeenPoolName = nil

                if resolvedName and objectID then
                    LearnPoolObjectName(objectID, resolvedName)
                    return resolvedName
                end

                if objectID and db and db.poolObjectNames and db.poolObjectNames[objectID] then
                    return db.poolObjectNames[objectID]
                end

                return nil
            end
        end
    end
    lastSeenPoolName = nil
    return nil
end

-- ============================================================================
-- Data Recording
-- ============================================================================

local function RecordCatch(itemID, name, icon, quality)
    if not db then return end

    local now = time()
    db.totalCaught = db.totalCaught + 1
    sessionCaught = sessionCaught + 1

    local info = CacheItemInfo(itemID)
    local category, isMidnight = ClassifyItem(itemID, info)

    if category == "junk" then
        currentStreak = 0
        sessionJunk = sessionJunk + 1
    else
        currentStreak = currentStreak + 1
        if currentStreak > db.longestStreak then
            db.longestStreak = currentStreak
        end
    end

    local entry = db.fishLog[itemID]
    if not entry then
        entry = {
            name = name, icon = icon, quality = quality,
            count = 0, firstCaught = now, lastCaught = now,
            category = category,
        }
        if info then
            entry.classID     = info.classID
            entry.subClassID  = info.subClassID
            entry.expansionID = info.expansionID
        end
        db.fishLog[itemID] = entry
    end
    entry.count = entry.count + 1
    entry.lastCaught = now
    if not entry.name or entry.name == "" then entry.name = name end
    if not entry.icon or entry.icon == "" then entry.icon = icon end
    if not entry.category then entry.category = category end

    local recent = db.recentCatches
    table.insert(recent, 1, { itemID = itemID, name = name, icon = icon, quality = quality, time = now })
    while #recent > MAX_RECENT do
        table.remove(recent)
    end

    local zone = GetCurrentZone()
    local subzone = GetCurrentArea()
    if zone ~= "" then
        if not db.zoneStats[zone] then
            db.zoneStats[zone] = { total = 0, subZones = {} }
        end
        local zs = db.zoneStats[zone]
        zs.total = zs.total + 1
        if subzone ~= "" then
            if not zs.subZones[subzone] then
                zs.subZones[subzone] = { total = 0, items = {} }
            end
            local ss = zs.subZones[subzone]
            ss.total = ss.total + 1
            ss.items[itemID] = (ss.items[itemID] or 0) + 1
        end
    end

    local poolName = DetectPool()
    if subzone ~= "" then
        if not db.poolStats[subzone] then
            db.poolStats[subzone] = { poolCatches = 0, totalCatches = 0, pools = {} }
        end
        local ps = db.poolStats[subzone]
        ps.totalCatches = ps.totalCatches + 1
        if poolName then
            ps.poolCatches = ps.poolCatches + 1
            ps.pools[poolName] = (ps.pools[poolName] or 0) + 1
        end
    end

    -- Dynamic discovery tracking
    if zone ~= "" then
        if not db.discoveredZones[itemID] then
            db.discoveredZones[itemID] = {}
        end
        db.discoveredZones[itemID][zone] = (db.discoveredZones[itemID][zone] or 0) + 1
    end
    if poolName then
        if not db.discoveredPools[itemID] then
            db.discoveredPools[itemID] = {}
        end
        db.discoveredPools[itemID][poolName] = (db.discoveredPools[itemID][poolName] or 0) + 1
    end

    ComputeBestSpot()
end

-- ============================================================================
-- Favorites
-- ============================================================================

local function SaveFavorite()
    if not db then return end
    local mapID, x, y = GetPlayerMapPos()
    local zone = GetCurrentZone()
    local subzone = GetCurrentArea()
    if not mapID or subzone == "" then
        print("|cff00ccffGone Fishin':|r Cannot save favorite here.")
        return
    end

    local favId = mapID .. ":" .. subzone
    db.favorites[favId] = {
        label = subzone,
        zone = zone,
        subzone = subzone,
        mapID = mapID,
        x = x,
        y = y,
        notes = "",
        poolCatches = db.poolStats[subzone] and db.poolStats[subzone].poolCatches or 0,
    }

    local MapPins = ns.Services.MapPinProvider
    if MapPins then
        MapPins:SetPin("GoneFishin_Favorites", favId, db.favorites[favId])
    end

    selectedFavoriteId = favId
    if RefreshMapFavoritesList then
        RefreshMapFavoritesList()
    end

    print(format("|cff00ccffGone Fishin':|r Saved favorite: |cffffd100%s|r", subzone))
end

local function RemoveFavorite(favId)
    if not db or not favId then return end
    local fav = db.favorites[favId]
    db.favorites[favId] = nil

    if activeWaypointFavoriteId == favId then
        ClearFavoriteWaypoint(true)
    end
    if selectedFavoriteId == favId then
        selectedFavoriteId = nil
    end

    local MapPins = ns.Services.MapPinProvider
    if MapPins then
        MapPins:RemovePin("GoneFishin_Favorites", favId)
    end

    if RefreshMapFavoritesList then
        RefreshMapFavoritesList()
    end

    if fav then
        print(format("|cff00ccffGone Fishin':|r Removed favorite: |cffffd100%s|r", fav.subzone or favId))
    end
end

local function ToggleFavorite()
    local favId = GetCurrentFavId()
    if not favId then return end
    if db.favorites[favId] then
        RemoveFavorite(favId)
    else
        SaveFavorite()
    end
end

local function SyncMapPins()
    local MapPins = ns.Services.MapPinProvider
    if not MapPins then return end

    MapPins:RegisterPinGroup("GoneFishin_Favorites", {
        icon = "Interface\\AddOns\\MedaAuras\\Media\\Textures\\gonefishin-pin",
        iconSize = 42,
        tooltipFunc = function(pin)
            GameTooltip:AddLine(pin.data.label or "Fishing Spot", 0.9, 0.7, 0.15)
            GameTooltip:AddLine(format("Pool catches: %d", pin.data.poolCatches or 0), 1, 1, 1)
            if pin.data.notes and pin.data.notes ~= "" then
                GameTooltip:AddLine(pin.data.notes, 0.7, 0.7, 0.7, true)
            end
        end,
    })

    for favId, fav in pairs(db.favorites) do
        MapPins:SetPin("GoneFishin_Favorites", favId, fav)
    end

    MapPins:SetGroupVisible("GoneFishin_Favorites", db.showMapPins ~= false)

    if RefreshMapFavoritesList then
        RefreshMapFavoritesList()
    end
end

-- ============================================================================
-- Arc HUD
-- ============================================================================

local leftTexts = {}
local centerTexts = {}
local faveButton
local endSessionBtn

local LINE_SPACING = 18

local function AttachPanelPosition(panel, dbKey)
    panel.OnMove = function(_, state)
        if db and state then
            db[dbKey] = { x = state.x or 0, y = state.y or 0 }
        end
    end
end

local function SetPanelsLocked(locked)
    if leftPanel then leftPanel:SetLocked(locked) end
    if rightPanel then rightPanel:SetLocked(locked) end
    if bottomPanel then bottomPanel:SetLocked(locked) end
end

local function RestorePanelPos(panel, dbKey, defX, defY)
    panel:RestoreRelativePosition(db[dbKey], { x = defX, y = defY })
end

local function CreateRow(parent)
    local iconSz = db.auraIconSize or 20
    local textFont = GetFontObj(db.auraFont or "default", db.auraTextSize or 13, db.auraTextOutline or "outline")

    local row = MedaUI:CreateHUDRow(parent, {
        width = 280,
        iconSize = iconSz,
        showState = false,
        showTimer = false,
        showDelta = false,
        interactive = false,
    })
    row.text:SetFontObject(textFont)
    row.text:SetJustifyH("LEFT")
    row.icon:SetTexCoord(unpack(ICON_TEXCOORD))

    return row
end

local function GetChecklistRow(index)
    if checklistRows[index] then return checklistRows[index] end
    local row = CreateRow(checklistContent)
    checklistRows[index] = row
    return row
end

local function GetExpandRow(index)
    if expandRows[index] then return expandRows[index] end
    local row = CreateRow(rightPanel)
    expandRows[index] = row
    return row
end

local function CreateHUD()
    if hudFrame then return end

    hudFrame = MedaUI:CreateHUDGroup("MedaAuras_GoneFishinHUD", {
        strata = "BACKGROUND",
        frameLevel = 1,
        fadeInDuration = db.auraFadeIn or 0.4,
        fadeOutDuration = db.auraFadeOut or 0.6,
        opacity = db.auraOpacity or 0.92,
        scale = db.auraScale or 1.0,
    })

    local textFont = GetFontObj(db.auraFont or "default", db.auraTextSize or 13, db.auraTextOutline or "outline")
    local headerFont = GetFontObj(db.auraFont or "default", (db.auraTextSize or 13) + 2, "thick")
    local smallFont = GetFontObj(db.auraFont or "default", math_max((db.auraTextSize or 13) - 3, 8), db.auraTextOutline or "outline")

    -- ---- Left Panel (zone, session stats) ----
    leftPanel = MedaUI:CreateHUDSection(hudFrame, {
        width = 300,
        height = 8 * LINE_SPACING,
        locked = db.auraLockPanels ~= false,
        positionMode = "relative",
    })
    AttachPanelPosition(leftPanel, "leftPanelPos")

    local leftBlock = MedaUI:CreateHUDTextBlock(leftPanel, {
        width = 300,
        lineCount = 8,
        lineHeight = LINE_SPACING,
        fontObject = textFont,
        justifyH = "RIGHT",
    })
    leftBlock:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", 0, 0)

    for i = 1, 8 do
        leftTexts[i] = leftBlock:GetLine(i)
        leftTexts[i]:SetFontObject(i <= 2 and headerFont or textFont)
    end

    -- ---- Right Panel (zone fish checklist) ----
    rightPanel = MedaUI:CreateHUDSection(hudFrame, {
        width = 280,
        height = CHECKLIST_MAX_VISIBLE * CHECKLIST_ROW_HEIGHT,
        locked = db.auraLockPanels ~= false,
        positionMode = "relative",
    })
    AttachPanelPosition(rightPanel, "rightPanelPos")

    local scrollHeight = CHECKLIST_MAX_VISIBLE * CHECKLIST_ROW_HEIGHT
    checklistScroll = MedaUI:CreateScrollFrame(rightPanel, nil, 280, scrollHeight)
    Pixel.SetPoint(checklistScroll, "TOPLEFT", rightPanel, "TOPLEFT", 0, 0)
    checklistScroll:SetScrollStep(CHECKLIST_ROW_HEIGHT * 2)

    checklistContent = checklistScroll.scrollContent
    Pixel.SetHeight(checklistContent, 1)

    -- Collapsible section headers (outside scroll area, in rightPanel)
    junkHeaderBtn = MedaUI:CreateCollapsibleSectionHeader(rightPanel, {
        text = "Junk",
        width = 280,
        height = CHECKLIST_ROW_HEIGHT,
        fontObject = smallFont,
        tone = "textDim",
        showLine = false,
        expanded = junkExpanded,
    })
    junkHeaderBtn:SetOnToggle(function(expanded)
        junkExpanded = expanded
        arcDirty = true
    end)

    missingHeaderBtn = MedaUI:CreateCollapsibleSectionHeader(rightPanel, {
        text = "Missing",
        width = 280,
        height = CHECKLIST_ROW_HEIGHT,
        fontObject = smallFont,
        tone = "textDim",
        showLine = false,
        expanded = missingExpanded,
    })
    missingHeaderBtn:SetOnToggle(function(expanded)
        missingExpanded = expanded
        arcDirty = true
    end)

    -- ---- Bottom Panel (fave, best spot, lure hint) ----
    bottomPanel = MedaUI:CreateHUDSection(hudFrame, {
        width = 300,
        height = 70,
        locked = db.auraLockPanels ~= false,
        positionMode = "relative",
    })
    AttachPanelPosition(bottomPanel, "bottomPanelPos")

    for i = 1, 3 do
        local fs = MedaUI:CreateLabel(bottomPanel, "", {
            fontObject = textFont,
            justifyH = "CENTER",
            tone = "text",
            shadow = true,
            wrap = false,
        })
        centerTexts[i] = fs
    end
    centerTexts[1]:SetPoint("TOP", bottomPanel, "TOP", 10, 0)
    centerTexts[2]:SetPoint("TOP", bottomPanel, "TOP", 0, -16)
    centerTexts[3]:SetPoint("TOP", bottomPanel, "TOP", 0, -32)

    faveButton = MedaUI:CreateIconButton(bottomPanel, {
        size = 16,
        atlas = "Waypoint-MapPin-Untracked",
        flat = true,
        tooltip = "Click to save as favorite",
        tooltipActive = "Click to remove favorite",
    })
    faveButton.OnClick = function()
        ToggleFavorite()
        arcDirty = true
    end
    faveButton:SetPoint("RIGHT", centerTexts[1], "LEFT", -2, 0)
    faveButton.tex = faveButton.icon

    endSessionBtn = MedaUI:CreateHUDTextButton(bottomPanel, "End Session", {
        width = 80,
        height = 16,
        fontObject = textFont,
        tone = "dim",
        hoverTone = "bright",
        tooltip = "Click to end fishing session",
    })
    endSessionBtn:SetScript("OnClick", function()
        if sessionActive then
            CancelSessionTimeout()
            EndSession()
            HideHUD()
            print("|cff00ccffGone Fishin':|r Session ended.")
        end
    end)
    endSessionBtn:SetPoint("TOP", bottomPanel, "TOP", 0, -52)
    endSessionBtn.text = endSessionBtn.label
    hudFadeCtrl = hudFrame.fadeEffect
end

local function RecalcPositions()
    if not hudFrame then return end

    local scale = db.auraScale or 1.0
    hudFrame:SetHUDScale(scale)
    hudFrame:SetHUDOpacity(db.auraOpacity or 0.92)
    hudFrame:SetFadeDurations(db.auraFadeIn or 0.4, db.auraFadeOut or 0.6)

    local xOff = db.auraHOffset or 200
    local vOff = db.auraVerticalOffset or -20

    local leftDefX = -xOff - 150
    local leftDefY = vOff + 60 + LINE_SPACING / 2 - 4 * LINE_SPACING
    RestorePanelPos(leftPanel, "leftPanelPos", leftDefX, leftDefY)

    local rightDefX = xOff + 140
    local rightDefY = leftDefY
    RestorePanelPos(rightPanel, "rightPanelPos", rightDefX, rightDefY)

    local bottomDefY = vOff + 60 - 8 * LINE_SPACING - 8 - 35
    RestorePanelPos(bottomPanel, "bottomPanelPos", 0, bottomDefY)

    local iconSz = db.auraIconSize or 20
    for _, row in ipairs(checklistRows) do
        if row.icon then row.icon:SetSize(iconSz, iconSz) end
    end
    for _, row in ipairs(expandRows) do
        if row.icon then row.icon:SetSize(iconSz, iconSz) end
    end

    local textFont = GetFontObj(db.auraFont or "default", db.auraTextSize or 13, db.auraTextOutline or "outline")
    local headerFont = GetFontObj(db.auraFont or "default", (db.auraTextSize or 13) + 2, "thick")
    local smallFont = GetFontObj(db.auraFont or "default", math_max((db.auraTextSize or 13) - 3, 8), db.auraTextOutline or "outline")

    for i = 1, 8 do
        if leftTexts[i] then
            leftTexts[i]:SetFontObject(i <= 2 and headerFont or textFont)
        end
    end
    for i = 1, 3 do
        if centerTexts[i] then centerTexts[i]:SetFontObject(textFont) end
    end
    for _, row in ipairs(checklistRows) do
        if row.text then row.text:SetFontObject(textFont) end
    end
    for _, row in ipairs(expandRows) do
        if row.text then row.text:SetFontObject(textFont) end
    end
    if junkHeaderBtn and junkHeaderBtn.header then junkHeaderBtn.header:SetFontObject(smallFont) end
    if junkHeaderBtn and junkHeaderBtn.badge then junkHeaderBtn.badge:SetFontObject(smallFont) end
    if missingHeaderBtn and missingHeaderBtn.header then missingHeaderBtn.header:SetFontObject(smallFont) end
    if missingHeaderBtn and missingHeaderBtn.badge then missingHeaderBtn.badge:SetFontObject(smallFont) end
    if endSessionBtn and endSessionBtn.text then endSessionBtn.text:SetFontObject(textFont) end
end

-- Lure hint state for rotation
local lureHintPool = {}
local lureHintIndex = 0
local lureHintLastRotate = 0
local LURE_HINT_ROTATE_INTERVAL = 10

local function GetLureHint(zone, area)
    local gfd = ns.GoneFishinData
    if not gfd or not gfd.midnightItems or not gfd.lureLookup or not gfd.zoneAliasMap then
        return nil
    end

    local canonical = gfd.zoneAliasMap[zone] or gfd.zoneAliasMap[area]

    -- Voidstorm special tip
    if canonical == "Voidstorm" then
        local now = GetTime()
        if now - lureHintLastRotate > LURE_HINT_ROTATE_INTERVAL then
            lureHintLastRotate = now
            lureHintIndex = lureHintIndex + 1
        end
        if lureHintIndex % 2 == 0 then
            return "|cff8888ccTip: Fish from Oceanic Vortex bubbles (no open water here)|r"
        end
    end

    if not canonical then return nil end

    -- Build tips for this zone
    local now = GetTime()
    if now - lureHintLastRotate > LURE_HINT_ROTATE_INTERVAL or #lureHintPool == 0 then
        lureHintLastRotate = now
        wipe(lureHintPool)

        for itemID, info in pairs(gfd.midnightItems) do
            if info.category == "fish" and info.openWaterZones then
                local inZone = false
                for _, z in ipairs(info.openWaterZones) do
                    if z == canonical then inZone = true; break end
                end
                if inZone then
                    local logEntry = db and db.fishLog[itemID]
                    local count = logEntry and logEntry.count or 0
                    if count == 0 then
                        local lure = gfd.lureLookup[info.name]
                        if lure then
                            lureHintPool[#lureHintPool + 1] = format(
                                "|cff88cc88Tip: Use %s for %s|r", lure.lureName, info.name
                            )
                        else
                            lureHintPool[#lureHintPool + 1] = format(
                                "|cffaaaaaa%s not yet caught here|r", info.name
                            )
                        end
                    end
                end
            end
        end

        if #lureHintPool > 0 then
            lureHintIndex = lureHintIndex + 1
        end
    end

    if #lureHintPool == 0 then return nil end
    local idx = ((lureHintIndex - 1) % #lureHintPool) + 1
    return lureHintPool[idx]
end

-- ============================================================================
-- Zone Fish Checklist Data
-- ============================================================================

local function GetZoneFishLists(zone, area)
    local gfd = ns.GoneFishinData
    if not gfd or not gfd.midnightItems or not gfd.zoneAliasMap then
        return {}, {}, {}
    end

    local canonical = gfd.zoneAliasMap[zone] or gfd.zoneAliasMap[area]
    if not canonical then return {}, {}, {} end

    local zoneFishSet = {}
    for itemID, info in pairs(gfd.midnightItems) do
        if info.category == "fish" and info.openWaterZones then
            for _, z in ipairs(info.openWaterZones) do
                if z == canonical then
                    zoneFishSet[itemID] = true
                    break
                end
            end
        end
    end
    if gfd.pools then
        for _, poolInfo in pairs(gfd.pools) do
            local inZone = false
            if poolInfo.zones then
                for _, z in ipairs(poolInfo.zones) do
                    if z == canonical then inZone = true; break end
                end
            end
            if inZone and poolInfo.fish then
                for _, fishID in ipairs(poolInfo.fish) do
                    zoneFishSet[fishID] = true
                end
            end
        end
    end

    local caught, missing = {}, {}
    for itemID in pairs(zoneFishSet) do
        local info = gfd.midnightItems[itemID]
        if info then
            local logEntry = db and db.fishLog[itemID]
            local count = logEntry and logEntry.count or 0
            local entry = {
                itemID = itemID,
                name = info.name,
                icon = info.icon,
                quality = info.quality or 1,
                rarity = info.rarity or "common",
                count = count,
            }
            if count > 0 then
                caught[#caught + 1] = entry
            else
                missing[#missing + 1] = entry
            end
        end
    end

    local junk = {}
    local junkSeen = {}
    if db then
        local zs = db.zoneStats[zone]
        if zs and zs.subZones then
            for _, subInfo in pairs(zs.subZones) do
                if subInfo.items then
                    for itemID, cnt in pairs(subInfo.items) do
                        if not junkSeen[itemID] then
                            local cat = ClassifyItem(itemID)
                            if cat == "junk" then
                                junkSeen[itemID] = true
                                local logEntry = db.fishLog[itemID]
                                junk[#junk + 1] = {
                                    itemID = itemID,
                                    name = logEntry and logEntry.name or ("Item " .. itemID),
                                    icon = logEntry and logEntry.icon or "inv_misc_questionmark",
                                    quality = logEntry and logEntry.quality or 0,
                                    count = logEntry and logEntry.count or cnt,
                                }
                            end
                        end
                    end
                end
            end
        end
    end

    table.sort(caught, function(a, b)
        if a.quality ~= b.quality then return a.quality > b.quality end
        return a.name < b.name
    end)
    table.sort(missing, function(a, b)
        local ra = RARITY_SORT[a.rarity] or 0
        local rb = RARITY_SORT[b.rarity] or 0
        if ra ~= rb then return ra < rb end
        return a.name < b.name
    end)
    table.sort(junk, function(a, b) return a.name < b.name end)

    return caught, missing, junk
end

local function UpdateHUDContent()
    if not hudFrame or not db then return end

    local Theme = MedaUI.Theme
    local gold = Theme and Theme.gold or FALLBACK_GOLD
    local dim = Theme and Theme.textDim or FALLBACK_DIM
    local bright = Theme and Theme.text or FALLBACK_BRIGHT

    local area = GetCurrentArea()
    local zone = GetCurrentZone()
    local skillCur, skillMax = GetFishingSkill()
    local notMaxed = skillMax > 0 and skillCur < skillMax

    leftTexts[1]:SetText(area)
    leftTexts[1]:SetTextColor(gold[1], gold[2], gold[3])
    leftTexts[2]:SetText(area ~= zone and zone or "")
    leftTexts[2]:SetTextColor(dim[1], dim[2], dim[3])
    leftTexts[3]:SetText("")
    if db.auraShowSessionJunk ~= false then
        leftTexts[4]:SetText(format("Session: %d fish | %d junk", sessionCaught, sessionJunk))
    else
        leftTexts[4]:SetText(format("Session: %d fish", sessionCaught))
    end
    leftTexts[4]:SetTextColor(bright[1], bright[2], bright[3])

    local rate = sessionCasts > 0 and math_floor(sessionCaught / sessionCasts * 100) or 0
    leftTexts[5]:SetText(format("Casts: %d | Rate: %d%%", sessionCasts, rate))
    leftTexts[5]:SetTextColor(dim[1], dim[2], dim[3])

    local elapsed = sessionActive and sessionStartTime > 0 and (GetTime() - sessionStartTime) or 0
    leftTexts[6]:SetText(format("Time: %s", FormatTime(elapsed)))
    leftTexts[6]:SetTextColor(dim[1], dim[2], dim[3])

    local streakColor = currentStreak >= db.longestStreak and currentStreak > 0 and gold or dim
    leftTexts[7]:SetText(format("Streak: %d", currentStreak))
    leftTexts[7]:SetTextColor(streakColor[1], streakColor[2], streakColor[3])

    if notMaxed then
        leftTexts[8]:SetText(format("Fishing: %d/%d", skillCur, skillMax))
        leftTexts[8]:SetTextColor(dim[1], dim[2], dim[3])
    else
        leftTexts[8]:SetText("")
    end

    local showChecklist = db.auraShowChecklist ~= false
    if showChecklist and checklistContent then
        rightPanel:Show()
        local caught, missingList, junkList = GetZoneFishLists(zone, area)

        local function PopulateRow(row, entry, desaturate)
            local iconPath = entry.icon
            if type(iconPath) == "string" and not iconPath:find("\\") then
                iconPath = "Interface\\Icons\\" .. iconPath
            end
            row:SetIcon(iconPath)
            row:SetIconDesaturated(desaturate or false)

            local r, g, b = GetQualityColor(entry.quality or 1)
            if desaturate then
                row.text:SetText(format("|cff666666%s|r", entry.name or "?"))
            elseif entry.count and entry.count > 0 then
                row.text:SetText(format("|cff%02x%02x%02x%s|r |cff999999(x%d)|r", r * 255, g * 255, b * 255, entry.name or "?", entry.count))
            else
                row.text:SetText(format("|cff%02x%02x%02x%s|r", r * 255, g * 255, b * 255, entry.name or "?"))
            end
        end

        -- Caught fish inside the scroll area
        for i, entry in ipairs(caught) do
            local row = GetChecklistRow(i)
            row:Show()
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", checklistContent, "TOPLEFT", 0, -(i - 1) * CHECKLIST_ROW_HEIGHT)
            PopulateRow(row, entry, false)
        end
        for i = #caught + 1, #checklistRows do
            checklistRows[i]:Hide()
        end

        local caughtHeight = #caught * CHECKLIST_ROW_HEIGHT
        checklistContent:SetHeight(math_max(caughtHeight, 1))

        local scrollVisibleH = math_min(CHECKLIST_MAX_VISIBLE, #caught) * CHECKLIST_ROW_HEIGHT
        Pixel.SetHeight(checklistScroll, math_max(scrollVisibleH, 1))

        -- Expand sections below the scroll area
        local belowY = -scrollVisibleH
        local expIdx = 0

        if #junkList > 0 then
            junkHeaderBtn:Show()
            junkHeaderBtn:ClearAllPoints()
            junkHeaderBtn:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 0, belowY)
            junkHeaderBtn:SetText("Junk")
            junkHeaderBtn:SetCount(#junkList)
            junkHeaderBtn:SetExpanded(junkExpanded)
            belowY = belowY - CHECKLIST_ROW_HEIGHT
            if junkExpanded then
                for _, entry in ipairs(junkList) do
                    expIdx = expIdx + 1
                    local row = GetExpandRow(expIdx)
                    row:Show()
                    row:ClearAllPoints()
                    row:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 0, belowY)
                    PopulateRow(row, entry, false)
                    belowY = belowY - CHECKLIST_ROW_HEIGHT
                end
            end
        else
            junkHeaderBtn:Hide()
        end

        if #missingList > 0 then
            missingHeaderBtn:Show()
            missingHeaderBtn:ClearAllPoints()
            missingHeaderBtn:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 0, belowY)
            missingHeaderBtn:SetText("Missing")
            missingHeaderBtn:SetCount(#missingList)
            missingHeaderBtn:SetExpanded(missingExpanded)
            belowY = belowY - CHECKLIST_ROW_HEIGHT
            if missingExpanded then
                for _, entry in ipairs(missingList) do
                    expIdx = expIdx + 1
                    local row = GetExpandRow(expIdx)
                    row:Show()
                    row:ClearAllPoints()
                    row:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 0, belowY)
                    PopulateRow(row, entry, true)
                    belowY = belowY - CHECKLIST_ROW_HEIGHT
                end
            end
        else
            missingHeaderBtn:Hide()
        end

        for i = expIdx + 1, #expandRows do
            expandRows[i]:Hide()
        end

        rightPanel:SetHeight(math_max(-belowY, CHECKLIST_ROW_HEIGHT))
    elseif rightPanel then
        rightPanel:Hide()
    end

    if db.auraShowFaves ~= false then
        faveButton:Show()
        if IsCurrentSpotFaved() then
            faveButton:SetActive(true)
            faveButton.tex:SetAtlas("Waypoint-MapPin-Tracked")
            faveButton.tex:SetDesaturated(false)
            local subzone = GetCurrentArea()
            centerTexts[1]:SetText(format("|cffffd100Fave: %s|r", subzone))
        elseif HasTrackedFavoriteWaypoint() then
            local trackedFav = db.favorites[activeWaypointFavoriteId]
            faveButton:SetActive(false)
            faveButton.tex:SetAtlas("Waypoint-MapPin-Untracked")
            faveButton.tex:SetDesaturated(true)
            centerTexts[1]:SetText(format("|cff7fd1ffRoute: %s|r", GetFavoriteLabel(trackedFav)))
        else
            faveButton:SetActive(false)
            faveButton.tex:SetAtlas("Waypoint-MapPin-Untracked")
            faveButton.tex:SetDesaturated(true)
            centerTexts[1]:SetText("|cff666666No favorite set|r")
        end
    else
        faveButton:Hide()
        faveButton:SetActive(false)
        centerTexts[1]:SetText("")
    end

    if db.auraShowBestSpot ~= false then
        local bestSpot = db.bestSpot
        if bestSpot then
            local ps = db.poolStats[bestSpot]
            local poolCount = ps and ps.poolCatches or 0
            centerTexts[2]:SetText(format("Best Spot: |cffffffff%s|r |cff999999(%d pool catches)|r", bestSpot, poolCount))
        else
            centerTexts[2]:SetText("|cff666666No pool data yet|r")
        end
    else
        centerTexts[2]:SetText("")
    end

    if db.auraShowTips ~= false then
        local lureHint = GetLureHint(zone, area)
        centerTexts[3]:SetText(lureHint or "")
    else
        centerTexts[3]:SetText("")
    end
end

local function UpdateDistanceText()
    if not hudFrame or not db then return end

    local showFaves = db.auraShowFaves ~= false
    local showTips = db.auraShowTips ~= false

    if not showFaves and not showTips then
        centerTexts[3]:SetText("")
        return
    end

    local mapID, px, py = GetPlayerMapPos()

    if showFaves and HasTrackedFavoriteWaypoint() then
        local trackedFav = db.favorites[activeWaypointFavoriteId]
        local trackedDist = GetFavoriteDistance(trackedFav)
        if trackedDist then
            local yards = trackedDist * 1000
            centerTexts[3]:SetText(format("|cff7fd1ffWP: %s ~%d yd|r", GetFavoriteLabel(trackedFav), math_floor(yards)))
        else
            centerTexts[3]:SetText(format("|cff7fd1ffWaypoint: %s|r", GetFavoriteLabel(trackedFav)))
        end
        return
    end

    if showFaves and mapID then
        local nearestDist = math.huge
        for _, fav in pairs(db.favorites) do
            if fav.mapID == mapID and fav.x and fav.y then
                local dx, dy = px - fav.x, py - fav.y
                local dist = math_sqrt(dx * dx + dy * dy)
                if dist < nearestDist then
                    nearestDist = dist
                end
            end
        end
        if nearestDist < math.huge then
            local yards = nearestDist * 1000
            centerTexts[3]:SetText(format("|cff999999~%d yd to fave|r", math_floor(yards)))
            return
        end
    end

    if showTips then
        local zone = GetCurrentZone()
        local area = GetCurrentArea()
        centerTexts[3]:SetText(GetLureHint(zone, area) or "")
    else
        centerTexts[3]:SetText("")
    end
end

local FRAME_THROTTLE = 0.033 -- ~30 Hz cap
local frameElapsed = 0

local function HUD_OnUpdate(self, elapsed)
    frameElapsed = frameElapsed + elapsed
    timerElapsed = timerElapsed + elapsed
    distanceElapsed = distanceElapsed + elapsed

    if frameElapsed < FRAME_THROTTLE then return end
    frameElapsed = 0

    if arcDirty then
        RecalcPositions()
        UpdateHUDContent()
        arcDirty = false
    end

    if timerElapsed >= TIMER_INTERVAL then
        timerElapsed = 0
        if sessionActive and sessionStartTime > 0 then
            local sessionTime = GetTime() - sessionStartTime
            local dim = MedaUI.Theme and MedaUI.Theme.textDim or FALLBACK_DIM
            leftTexts[6]:SetText(format("Time: %s", FormatTime(sessionTime)))
            leftTexts[6]:SetTextColor(dim[1], dim[2], dim[3])
        end
    end

    if distanceElapsed >= DISTANCE_INTERVAL then
        distanceElapsed = 0
        UpdateDistanceText()
    end
end

local function EnableHUDUpdates()
    if hudFrame then
        hudFrame:SetScript("OnUpdate", HUD_OnUpdate)
    end
end

local function DisableHUDUpdates()
    if hudFrame then
        hudFrame:SetScript("OnUpdate", nil)
    end
end

ShowHUD = function()
    if not db or not db.auraEnabled then return end
    if not hudFrame then CreateHUD() end

    hideRequestId = hideRequestId + 1

    if hideTimer then
        hideTimer:Cancel()
        hideTimer = nil
    end

    arcDirty = true
    timerElapsed = 0
    distanceElapsed = 0
    EnableHUDUpdates()

    if not hudVisible then
        hudFadeCtrl:FadeIn()
        hudVisible = true
    end
end

HideHUD = function()
    if not hudFrame or not hudVisible then return end
    if HasTrackedFavoriteWaypoint() then
        return
    end

    local delay = db and db.auraHideDelay or 8
    local requestId = hideRequestId + 1
    hideRequestId = requestId
    if hideTimer then hideTimer:Cancel() end
    hideTimer = C_Timer.NewTimer(delay, function()
        if requestId ~= hideRequestId then
            return
        end
        hideTimer = nil
        if hudFadeCtrl then
            hudFadeCtrl:FadeOut()
        end
        C_Timer.After((db and db.auraFadeOut or 0.6) + 0.1, function()
            if requestId ~= hideRequestId then
                return
            end
            DisableHUDUpdates()
            hudVisible = false
        end)
    end)
end

local function SaveSessionState()
    if not db then return end
    if sessionActive then
        local elapsed = sessionStartTime > 0 and (GetTime() - sessionStartTime) or 0
        db.currentSession = {
            elapsed = elapsed,
            caught = sessionCaught,
            casts = sessionCasts,
            junk = sessionJunk,
            streak = currentStreak,
            savedAt = GetServerTime(),
        }
    else
        db.currentSession = nil
    end
end

local function RestoreSession()
    local s = db and db.currentSession
    if not s then return end
    local gap = GetServerTime() - (s.savedAt or 0)
    if gap > SESSION_TIMEOUT then
        db.currentSession = nil
        return
    end
    sessionActive = true
    sessionStartTime = GetTime() - (s.elapsed or 0) - gap
    sessionCaught = s.caught or 0
    sessionCasts = s.casts or 0
    sessionJunk = s.junk or 0
    currentStreak = s.streak or 0
end

EndSession = function()
    if not sessionActive then return end
    sessionActive = false
    if fishingStartTime > 0 then
        db.totalFishingTime = db.totalFishingTime + (GetTime() - fishingStartTime)
        fishingStartTime = 0
    end
    isFishing = false
    if db then db.currentSession = nil end
end

CancelSessionTimeout = function()
    if sessionTimer then
        sessionTimer:Cancel()
        sessionTimer = nil
    end
end

local function StartSessionTimeout()
    CancelSessionTimeout()
    sessionTimer = C_Timer.NewTimer(SESSION_TIMEOUT, function()
        sessionTimer = nil
        EndSession()
    end)
end

-- ============================================================================
-- Stats Window
-- ============================================================================

local function CreateStatsPanel()
    if statsPanel then return end

    statsPanel = MedaUI:CreatePanel("MedaAurasGoneFishinStats", 520, 500, "Gone Fishin' Stats")
    local content = statsPanel:GetContent()

    tabBar = MedaUI:CreateTabBar(content, {
        { id = "overview",   label = "Overview" },
        { id = "collection", label = "Collection" },
        { id = "zones",      label = "Zones" },
    })
    tabBar:SetPoint("TOPLEFT", 0, 0)
    tabBar:SetPoint("TOPRIGHT", 0, 0)

    for _, tabId in ipairs({ "overview", "collection", "zones" }) do
        local frame = CreateFrame("Frame", nil, content)
        frame:SetPoint("TOPLEFT", 0, -32)
        frame:SetPoint("BOTTOMRIGHT", 0, 0)
        frame:Hide()
        tabFrames[tabId] = frame
    end

    tabBar.OnTabChanged = function(_, tabId)
        for id, frame in pairs(tabFrames) do
            if id == tabId then
                frame:Show()
            else
                frame:Hide()
            end
        end

        if not tabRendered[tabId] then
            if tabId == "overview" then
                BuildOverviewTab(tabFrames.overview)
            elseif tabId == "collection" then
                BuildCollectionTab(tabFrames.collection)
            elseif tabId == "zones" then
                BuildZonesTab(tabFrames.zones)
            end
            tabRendered[tabId] = true
        else
            if tabId == "overview" then
                RefreshOverviewTab()
            elseif tabId == "collection" then
                RefreshCollectionTab()
            elseif tabId == "zones" then
                RefreshZonesTab()
            end
        end
    end

    statsPanel.OnMove = function(self, state)
        if db then db.statsPosition = state.position end
    end

    if db.statsPosition then
        statsPanel:RestoreState({ position = db.statsPosition })
    end

    -- Force-build the default tab since CreateTabBar may auto-select
    -- the first tab before OnTabChanged is assigned
    tabFrames.overview:Show()
    BuildOverviewTab(tabFrames.overview)
    tabRendered.overview = true
end

-- ============================================================================
-- Overview Tab
-- ============================================================================

local overviewTexts = {}

function BuildOverviewTab(parent)
    local yOff = -8
    local Theme = MedaUI.Theme
    local gold = Theme and Theme.gold or FALLBACK_GOLD
    local bright = Theme and Theme.text or FALLBACK_BRIGHT
    local dim = Theme and Theme.textDim or FALLBACK_DIM

    local function AddHeader(text)
        local hdr = MedaUI:CreateSectionHeader(parent, text)
        hdr:SetPoint("TOPLEFT", 0, yOff)
        yOff = yOff - 35
    end

    local function AddStat(label, key, col)
        local lbl = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        lbl:SetPoint("TOPLEFT", col == 2 and 250 or 8, yOff)
        lbl:SetTextColor(dim[1], dim[2], dim[3])
        lbl:SetText(label)

        local val = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        val:SetPoint("LEFT", lbl, "RIGHT", 6, 0)
        val:SetTextColor(bright[1], bright[2], bright[3])
        overviewTexts[key] = val
    end

    AddHeader("Lifetime Stats")
    AddStat("Total Fish Caught:", "totalCaught", 1)
    AddStat("Total Casts:", "totalCasts", 2)
    yOff = yOff - 22
    AddStat("Catch Rate:", "catchRate", 1)
    AddStat("Time Fishing:", "totalTime", 2)
    yOff = yOff - 22
    AddStat("Fish Per Hour:", "fishPerHour", 1)
    AddStat("Unique Species:", "uniqueSpecies", 2)
    yOff = yOff - 30

    AddHeader("Records")
    AddStat("Most Caught:", "mostCaught", 1)
    AddStat("Rarest Catch:", "rarestCatch", 2)
    yOff = yOff - 22
    AddStat("Longest Streak:", "longestStreak", 1)
    AddStat("Sessions:", "sessionCount", 2)
    yOff = yOff - 22
    AddStat("Avg Fish/Session:", "avgPerSession", 1)
    AddStat("Favorite Zone:", "favZone", 2)
    yOff = yOff - 30

    AddHeader("Spots")
    AddStat("Best Spot:", "bestSpot", 1)
    AddStat("Pool Catches:", "poolCatches", 2)
    yOff = yOff - 22
    AddStat("Open Water:", "openWater", 1)
    AddStat("Favorites Saved:", "favCount", 2)
    yOff = yOff - 22

    RefreshOverviewTab()
end

function RefreshOverviewTab()
    if not db or not overviewTexts.totalCaught then return end

    overviewTexts.totalCaught:SetText(tostring(db.totalCaught))
    overviewTexts.totalCasts:SetText(tostring(db.totalCasts))

    local rate = db.totalCasts > 0 and math_floor(db.totalCaught / db.totalCasts * 100) or 0
    overviewTexts.catchRate:SetText(rate .. "%")
    overviewTexts.totalTime:SetText(FormatTime(db.totalFishingTime))

    local hours = db.totalFishingTime / 3600
    local fph = hours > 0 and math_floor(db.totalCaught / hours) or 0
    overviewTexts.fishPerHour:SetText(tostring(fph))

    local unique = 0
    local mostName, mostCount = "None", 0
    local rarestName, rarestQ, rarestCount = "None", -1, math.huge
    for _, entry in pairs(db.fishLog) do
        unique = unique + 1
        if entry.count > mostCount then
            mostCount = entry.count
            mostName = entry.name or "?"
        end
        local q = entry.quality or 1
        if q > rarestQ or (q == rarestQ and (entry.count or 0) < rarestCount) then
            rarestQ = q
            rarestCount = entry.count or 0
            rarestName = entry.name or "?"
        end
    end
    overviewTexts.uniqueSpecies:SetText(tostring(unique))
    overviewTexts.mostCaught:SetText(format("%s (x%d)", mostName, mostCount))

    local rr, rg, rb = GetQualityColor(rarestQ)
    overviewTexts.rarestCatch:SetText(format("|cff%02x%02x%02x%s|r", rr * 255, rg * 255, rb * 255, rarestName))

    overviewTexts.longestStreak:SetText(tostring(db.longestStreak))
    overviewTexts.sessionCount:SetText(tostring(db.sessionCount))
    local avgPS = db.sessionCount > 0 and format("%.1f", db.totalCaught / db.sessionCount) or "0"
    overviewTexts.avgPerSession:SetText(avgPS)

    local favZone, favZoneCount = "None", 0
    for zone, zs in pairs(db.zoneStats) do
        if zs.total > favZoneCount then
            favZoneCount = zs.total
            favZone = zone
        end
    end
    overviewTexts.favZone:SetText(favZone)

    overviewTexts.bestSpot:SetText(db.bestSpot or "None")

    local totalPool, totalOpen = 0, 0
    for _, ps in pairs(db.poolStats) do
        totalPool = totalPool + (ps.poolCatches or 0)
        totalOpen = totalOpen + ((ps.totalCatches or 0) - (ps.poolCatches or 0))
    end
    overviewTexts.poolCatches:SetText(tostring(totalPool))
    overviewTexts.openWater:SetText(tostring(totalOpen))

    local favCount = 0
    for _ in pairs(db.favorites) do favCount = favCount + 1 end
    overviewTexts.favCount:SetText(tostring(favCount))
end

-- ============================================================================
-- Collection Tab
-- ============================================================================

local collectionHeader, collectionProgressBar, collectionSortDD
local SORT_OPTIONS = {
    { value = "missing",  label = "Missing First" },
    { value = "name",     label = "Name" },
    { value = "quality",  label = "Quality" },
    { value = "count",    label = "Count" },
    { value = "recent",   label = "Recently Caught" },
}

local MIDNIGHT_CATEGORY_ORDER = { "fish", "recipe", "line", "rod", "treasure" }
local MIDNIGHT_CATEGORY_LABELS = {
    fish     = "Fish",
    recipe   = "Lures & Recipes",
    line     = "Fishing Line",
    rod      = "Rods",
    treasure = "Treasures & Special",
}

local function SortCollectionItems(items, sortMode)
    local mode = sortMode or "missing"
    table.sort(items, function(a, b)
        if mode == "missing" then
            if a.caught ~= b.caught then return not a.caught end
            if a.caught then return (a.count or 0) > (b.count or 0) end
            return (a.name or "") < (b.name or "")
        elseif mode == "name" then
            return (a.name or "") < (b.name or "")
        elseif mode == "quality" then
            if (a.quality or 0) ~= (b.quality or 0) then return (a.quality or 0) > (b.quality or 0) end
            return (a.name or "") < (b.name or "")
        elseif mode == "count" then
            if (a.count or 0) ~= (b.count or 0) then return (a.count or 0) > (b.count or 0) end
            return (a.name or "") < (b.name or "")
        elseif mode == "recent" then
            local aTime = a.lastCaught or 0
            local bTime = b.lastCaught or 0
            if aTime ~= bTime then return aTime > bTime end
            return (a.name or "") < (b.name or "")
        end
        return (a.name or "") < (b.name or "")
    end)
end

local function ShowCollectionTooltip(row, item)
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    local gfd = ns.GoneFishinData

    local r, g, b = GetQualityColor(item.quality or 1)
    GameTooltip:AddLine(item.name or "?", r, g, b)

    local staticInfo = gfd and gfd.midnightItems and gfd.midnightItems[item.itemID]

    if staticInfo then
        local cat = staticInfo.category
        if cat == "fish" then
            if staticInfo.rarity then
                GameTooltip:AddLine(format("Rarity: %s", staticInfo.rarity:sub(1,1):upper() .. staticInfo.rarity:sub(2)), 0.7, 0.7, 0.7)
            end
            if staticInfo.openWaterZones and #staticInfo.openWaterZones > 0 then
                GameTooltip:AddLine("Open water: " .. table.concat(staticInfo.openWaterZones, ", "), 0.6, 0.8, 1.0)
            end
            if staticInfo.pools and #staticInfo.pools > 0 then
                GameTooltip:AddLine("Pools: " .. table.concat(staticInfo.pools, ", "), 0.6, 0.8, 1.0)
            end
            local lureLookup = gfd.lureLookup
            if lureLookup and lureLookup[staticInfo.name] then
                local lureInfo = lureLookup[staticInfo.name]
                GameTooltip:AddLine("Lure: " .. (lureInfo.lureName or "?"), 0.4, 1.0, 0.4)
            end
            if staticInfo.notes and staticInfo.notes ~= "" then
                GameTooltip:AddLine(staticInfo.notes, 1, 0.82, 0, true)
            end
            if gfd.zones then
                for _, zoneName in ipairs(staticInfo.openWaterZones or {}) do
                    local zoneInfo = gfd.zones[zoneName]
                    if zoneInfo then
                        GameTooltip:AddLine(format("  %s: Skill %d (%s)", zoneName, zoneInfo.skill, zoneInfo.range or "?"), 0.5, 0.5, 0.5)
                    end
                end
            end

        elseif cat == "recipe" then
            if staticInfo.craftedName then
                GameTooltip:AddLine("Crafts: " .. staticInfo.craftedName, 0.6, 0.8, 1.0)
            end
            if staticInfo.targetFishName then
                GameTooltip:AddLine("Increases chance for: " .. staticInfo.targetFishName, 0.4, 1.0, 0.4)
            end
            if staticInfo.reagents then
                for _, reagent in ipairs(staticInfo.reagents) do
                    local reagentInfo = CacheItemInfo(reagent[1])
                    local rName = reagentInfo and reagentInfo.name or tostring(reagent[1])
                    GameTooltip:AddLine(format("  %dx %s", reagent[2], rName), 0.7, 0.7, 0.7)
                end
            end
            if staticInfo.source and staticInfo.source ~= "" then
                GameTooltip:AddLine("Drops from: " .. staticInfo.source, 0.6, 0.6, 0.6)
            end

        elseif cat == "line" then
            if staticInfo.chain and staticInfo.tier then
                local chainLabel = staticInfo.chain == "bloom" and "Bloomline" or
                                   staticInfo.chain == "glimmer" and "Glimmerline" or "Grand Line"
                GameTooltip:AddLine(format("Chain: %s (Tier %d)", chainLabel, staticInfo.tier), 0.6, 0.8, 1.0)
            end

        elseif cat == "rod" then
            -- nothing beyond notes

        elseif cat == "treasure" then
            if staticInfo.source and staticInfo.source ~= "" then
                GameTooltip:AddLine("Source: " .. staticInfo.source, 0.6, 0.8, 1.0)
            end
        end

        if staticInfo.notes and staticInfo.notes ~= "" and cat ~= "fish" then
            GameTooltip:AddLine(staticInfo.notes, 1, 0.82, 0, true)
        end
    end

    -- Dynamic discovery data
    if db and item.itemID then
        local discZones = db.discoveredZones[item.itemID]
        if discZones then
            local parts = {}
            for zName, cnt in pairs(discZones) do
                parts[#parts + 1] = format("%s (x%d)", zName, cnt)
            end
            if #parts > 0 then
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine("Discovered in: " .. table.concat(parts, ", "), 0.5, 1.0, 0.5, true)
            end
        end
        local discPools = db.discoveredPools[item.itemID]
        if discPools then
            local parts = {}
            for pName, cnt in pairs(discPools) do
                parts[#parts + 1] = format("%s (x%d)", pName, cnt)
            end
            if #parts > 0 then
                GameTooltip:AddLine("Pools discovered: " .. table.concat(parts, ", "), 0.5, 1.0, 0.5, true)
            end
        end
    end

    -- Catch stats
    if item.caught and db then
        GameTooltip:AddLine(" ")
        local logEntry = db.fishLog[item.itemID]
        if logEntry then
            GameTooltip:AddLine(format("Total caught: x%d", logEntry.count or 0), 1, 1, 1)
            if logEntry.firstCaught then
                GameTooltip:AddLine("First caught: " .. date("%b %d, %Y", logEntry.firstCaught), 0.7, 0.7, 0.7)
            end
        end
    end

    GameTooltip:Show()
end

function BuildCollectionTab(parent)
    collectionHeader = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    collectionHeader:SetPoint("TOPLEFT", 8, -8)

    collectionProgressBar = CreateFrame("StatusBar", nil, parent)
    collectionProgressBar:SetSize(200, 12)
    collectionProgressBar:SetPoint("LEFT", collectionHeader, "RIGHT", 12, 0)
    collectionProgressBar:SetStatusBarTexture("Interface\\Buttons\\WHITE8x8")
    collectionProgressBar:SetStatusBarColor(0.9, 0.7, 0.15, 0.8)
    collectionProgressBar:SetMinMaxValues(0, 1)
    local bg = collectionProgressBar:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.15, 0.15, 0.15, 0.8)

    collectionSortDD = MedaUI:CreateDropdown(parent, 140, SORT_OPTIONS)
    collectionSortDD:SetPoint("TOPRIGHT", -8, -4)
    collectionSortDD:SetSelected(db and db.collectionSort or "missing")
    collectionSortDD.OnValueChanged = function(_, v)
        if db then db.collectionSort = v end
        RefreshCollectionTab()
    end

    collectionSearchBox = MedaUI:CreateSearchBox(parent, 200)
    collectionSearchBox:SetPoint("TOPLEFT", 8, -32)
    collectionSearchBox:SetPlaceholder("Search items...")

    local listHeight = parent:GetHeight() and parent:GetHeight() - 70 or 380
    collectionList = MedaUI:CreateScrollList(parent, parent:GetWidth() or 490, listHeight, {
        rowHeight = 32,
        renderRow = function(row, item, index)
            if not row.icon then
                row.icon = row:CreateTexture(nil, "ARTWORK")
                row.icon:SetSize(24, 24)
                row.icon:SetPoint("LEFT", 6, 0)
                row.icon:SetTexCoord(unpack(ICON_TEXCOORD))
            end
            if not row.nameText then
                row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                row.nameText:SetPoint("LEFT", row.icon, "RIGHT", 6, 0)
                row.nameText:SetJustifyH("LEFT")
                row.nameText:SetWidth(260)
                row.nameText:SetWordWrap(false)
            end
            if not row.countText then
                row.countText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                row.countText:SetPoint("RIGHT", -8, 0)
                row.countText:SetJustifyH("RIGHT")
            end
            if not row.zoneText then
                row.zoneText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                row.zoneText:SetPoint("RIGHT", row.countText, "LEFT", -8, 0)
                row.zoneText:SetJustifyH("RIGHT")
                row.zoneText:SetWidth(120)
                row.zoneText:SetWordWrap(false)
            end
            if not row.progressBar then
                row.progressBar = CreateFrame("StatusBar", nil, row)
                row.progressBar:SetSize(row:GetWidth() - 16, 4)
                row.progressBar:SetPoint("BOTTOMLEFT", 8, 2)
                row.progressBar:SetPoint("BOTTOMRIGHT", -8, 2)
                row.progressBar:SetStatusBarTexture("Interface\\Buttons\\WHITE8x8")
                row.progressBar:SetMinMaxValues(0, 1)
                local pbg = row.progressBar:CreateTexture(nil, "BACKGROUND")
                pbg:SetAllPoints()
                pbg:SetColorTexture(0.1, 0.1, 0.1, 0.5)
                row.progressBarText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                row.progressBarText:SetPoint("RIGHT", row.progressBar, "RIGHT", 0, 9)
            end

            -- Hover tooltip
            row:EnableMouse(true)
            row:SetScript("OnEnter", function(self)
                if item.isHeader then return end
                ShowCollectionTooltip(self, item)
            end)
            row:SetScript("OnLeave", function() GameTooltip:Hide() end)

            if item.isHeader then
                row.icon:Hide()
                row.zoneText:SetText("")
                row.nameText:SetPoint("LEFT", 6, 4)
                row.nameText:SetWidth(300)
                local Theme = MedaUI.Theme
                local gold = Theme and Theme.gold or FALLBACK_GOLD

                if item.isSectionHeader then
                    row.nameText:SetText(format("|cff%02x%02x%02x=== %s ===|r", gold[1]*255, gold[2]*255, gold[3]*255, item.label or ""))
                    row.countText:SetText("")
                    row.progressBar:Hide()
                    row.progressBarText:SetText("")
                else
                    row.nameText:SetText(format("|cff%02x%02x%02x%s|r", gold[1]*255, gold[2]*255, gold[3]*255, item.label or ""))
                    if item.total and item.total > 0 then
                        row.countText:SetText(format("|cffffffff%d/%d|r", item.caught or 0, item.total))
                        row.progressBar:Show()
                        row.progressBar:SetValue((item.total > 0) and ((item.caught or 0) / item.total) or 0)
                        row.progressBar:SetStatusBarColor(gold[1], gold[2], gold[3], 0.7)
                        row.progressBarText:SetText("")
                    else
                        row.countText:SetText(format("|cffffffff%d found|r", item.caught or 0))
                        row.progressBar:Hide()
                        row.progressBarText:SetText("")
                    end
                end
                return
            end

            row.nameText:SetPoint("LEFT", row.icon, "RIGHT", 6, 0)
            row.progressBar:Hide()
            row.progressBarText:SetText("")

            local iconPath = item.icon
            if type(iconPath) == "string" and iconPath ~= "" and not iconPath:find("\\") then
                iconPath = "Interface\\Icons\\" .. iconPath
            end
            if not iconPath or iconPath == "" then
                iconPath = "Interface\\Icons\\INV_Misc_QuestionMark"
            end

            row.icon:Show()
            row.icon:SetTexture(iconPath)

            local zoneHint = ""
            local gfd = ns.GoneFishinData
            local staticInfo = gfd and gfd.midnightItems and gfd.midnightItems[item.itemID]
            if staticInfo and staticInfo.openWaterZones and #staticInfo.openWaterZones > 0 then
                zoneHint = table.concat(staticInfo.openWaterZones, ", ")
            end

            if item.caught then
                row.icon:SetDesaturated(false)
                local r, g, b = GetQualityColor(item.quality or 1)
                row.nameText:SetText(format("|cff%02x%02x%02x%s|r", r * 255, g * 255, b * 255, item.name or "?"))
                row.countText:SetText(format("x%d", item.count or 0))
                row.countText:SetTextColor(1, 1, 1)
                row.zoneText:SetText(zoneHint)
                row.zoneText:SetTextColor(0.5, 0.5, 0.5)
            else
                row.icon:SetDesaturated(true)
                row.nameText:SetText(format("|cff666666%s|r", item.name or "?"))
                row.countText:SetText("|cff555555---|r")
                row.zoneText:SetText("|cff555555" .. zoneHint .. "|r")
            end
        end,
    })
    collectionList:SetPoint("TOPLEFT", 0, -58)

    collectionSearchBox.OnSearch = function(_, text)
        if not text or text == "" then
            collectionList:ClearFilter()
        else
            local lower = text:lower()
            collectionList:SetFilter(function(item)
                if item.isHeader then return true end
                return item.name and item.name:lower():find(lower, 1, true)
            end)
        end
    end

    RefreshCollectionTab()
end

function RefreshCollectionTab()
    if not collectionList or not db then return end

    local gfd = ns.GoneFishinData
    local midnightItems = gfd and gfd.midnightItems or {}
    local sortMode = db.collectionSort or "missing"

    local data = {}
    local totalCaught, totalCount = 0, 0

    -- Build Midnight categories
    local categoryItems = {}
    for _, cat in ipairs(MIDNIGHT_CATEGORY_ORDER) do
        categoryItems[cat] = {}
    end

    for itemID, itemInfo in pairs(midnightItems) do
        local cat = itemInfo.category or "other"
        if not categoryItems[cat] then
            categoryItems[cat] = {}
        end
        totalCount = totalCount + 1
        local logEntry = db.fishLog[itemID]
        local isCaught = logEntry and logEntry.count and logEntry.count > 0
        if isCaught then totalCaught = totalCaught + 1 end

        categoryItems[cat][#categoryItems[cat] + 1] = {
            itemID = itemID,
            name = itemInfo.name,
            icon = itemInfo.icon or "",
            quality = itemInfo.quality or 1,
            count = isCaught and logEntry.count or 0,
            caught = isCaught or false,
            lastCaught = logEntry and logEntry.lastCaught or 0,
        }
    end

    -- MIDNIGHT section header
    data[#data + 1] = { isHeader = true, isSectionHeader = true, label = "MIDNIGHT" }

    for _, cat in ipairs(MIDNIGHT_CATEGORY_ORDER) do
        local items = categoryItems[cat]
        if items and #items > 0 then
            SortCollectionItems(items, sortMode)
            local catCaught = 0
            for _, item in ipairs(items) do
                if item.caught then catCaught = catCaught + 1 end
            end
            data[#data + 1] = {
                isHeader = true, isSectionHeader = false,
                label = MIDNIGHT_CATEGORY_LABELS[cat] or cat,
                caught = catCaught, total = #items,
            }
            for _, item in ipairs(items) do
                data[#data + 1] = item
            end
        end
    end

    -- OTHER CATCHES section: items in fishLog not in midnightItems
    local otherCategories = {}
    for itemID, logEntry in pairs(db.fishLog) do
        if not midnightItems[itemID] then
            local cat = logEntry.category or ClassifyItem(itemID) or "other"
            if not otherCategories[cat] then
                otherCategories[cat] = {}
            end
            otherCategories[cat][#otherCategories[cat] + 1] = {
                itemID = itemID,
                name = logEntry.name or "Unknown",
                icon = logEntry.icon or "",
                quality = logEntry.quality or 0,
                count = logEntry.count or 0,
                caught = true,
                lastCaught = logEntry.lastCaught or 0,
            }
        end
    end

    local hasOther = false
    for _ in pairs(otherCategories) do hasOther = true; break end

    if hasOther then
        data[#data + 1] = { isHeader = true, isSectionHeader = true, label = "OTHER CATCHES" }

        local otherCatOrder = { "fish", "junk", "reagent", "gear", "recipe", "consumable", "pet", "mount", "toy", "quest", "other" }
        local otherCatLabels = {
            fish = "Fish", junk = "Junk", reagent = "Reagents", gear = "Gear",
            recipe = "Recipes", consumable = "Consumables", pet = "Pets",
            mount = "Mounts", toy = "Toys", quest = "Quest Items", other = "Other",
        }

        for _, cat in ipairs(otherCatOrder) do
            local items = otherCategories[cat]
            if items and #items > 0 then
                SortCollectionItems(items, sortMode)
                data[#data + 1] = {
                    isHeader = true, isSectionHeader = false,
                    label = otherCatLabels[cat] or cat,
                    caught = #items, total = 0,
                }
                for _, item in ipairs(items) do
                    data[#data + 1] = item
                end
            end
        end
    end

    collectionList:SetData(data)

    if collectionHeader then
        local Theme = MedaUI.Theme
        local gold = Theme and Theme.gold or FALLBACK_GOLD
        collectionHeader:SetText(format("Midnight Pokedex: %d / %d", totalCaught, totalCount))
        collectionHeader:SetTextColor(gold[1], gold[2], gold[3])
    end
    if collectionProgressBar then
        collectionProgressBar:SetValue(totalCount > 0 and totalCaught / totalCount or 0)
    end
end

-- ============================================================================
-- Zones Tab
-- ============================================================================

function BuildZonesTab(parent)
    zonesList = MedaUI:CreateScrollList(parent, parent:GetWidth() or 490, parent:GetHeight() or 420, {
        rowHeight = 28,
        renderRow = function(row, item, index)
            if not row.nameText then
                row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                row.nameText:SetPoint("LEFT", 8, 0)
                row.nameText:SetJustifyH("LEFT")
                row.nameText:SetWidth(280)
                row.nameText:SetWordWrap(false)
            end
            if not row.countText then
                row.countText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                row.countText:SetPoint("RIGHT", -8, 0)
                row.countText:SetJustifyH("RIGHT")
            end
            if not row.starBtn then
                row.starBtn = CreateFrame("Button", nil, row)
                row.starBtn:SetSize(16, 16)
                row.starBtn:SetPoint("RIGHT", row.countText, "LEFT", -8, 0)
                row.starBtn.tex = row.starBtn:CreateTexture(nil, "ARTWORK")
                row.starBtn.tex:SetAllPoints()
            end

            if item.isZone then
                local Theme = MedaUI.Theme
                local gold = Theme and Theme.gold or FALLBACK_GOLD
                row.nameText:SetText(item.name)
                row.nameText:SetTextColor(gold[1], gold[2], gold[3])
                row.countText:SetText(format("%d fish", item.total or 0))
                row.countText:SetTextColor(1, 1, 1)
                row.starBtn:Hide()
            else
                row.nameText:SetText("    " .. (item.name or ""))
                row.nameText:SetTextColor(0.8, 0.8, 0.8)
                local poolCount = item.poolCatches or 0
                row.countText:SetText(format("%d caught (%d pool)", item.total or 0, poolCount))
                row.countText:SetTextColor(0.6, 0.6, 0.6)

                row.starBtn:Show()
                if item.isFaved then
                    row.starBtn.tex:SetAtlas("Waypoint-MapPin-Tracked")
                    row.starBtn.tex:SetDesaturated(false)
                else
                    row.starBtn.tex:SetAtlas("Waypoint-MapPin-Untracked")
                    row.starBtn.tex:SetDesaturated(true)
                end
                row.starBtn:SetScript("OnClick", function()
                    if item.favId and db.favorites[item.favId] then
                        RemoveFavorite(item.favId)
                    else
                        local mapID = C_Map.GetBestMapForUnit("player")
                        if mapID and item.name then
                            local favId = mapID .. ":" .. item.name
                            db.favorites[favId] = {
                                label = item.name,
                                zone = item.zoneName or "",
                                subzone = item.name,
                                mapID = mapID,
                                x = 0.5, y = 0.5,
                                notes = "",
                                poolCatches = item.poolCatches or 0,
                            }
                            local MapPins = ns.Services.MapPinProvider
                            if MapPins then
                                MapPins:SetPin("GoneFishin_Favorites", favId, db.favorites[favId])
                            end
                            selectedFavoriteId = favId
                            if RefreshMapFavoritesList then
                                RefreshMapFavoritesList()
                            end
                        end
                    end
                    RefreshZonesTab()
                end)
            end
        end,
    })
    zonesList:SetPoint("TOPLEFT", 0, -4)

    RefreshZonesTab()
end

function RefreshZonesTab()
    if not zonesList or not db then return end

    local data = {}

    local sortedZones = {}
    for zone, zs in pairs(db.zoneStats) do
        sortedZones[#sortedZones + 1] = { name = zone, total = zs.total, subZones = zs.subZones }
    end
    table.sort(sortedZones, function(a, b) return (a.total or 0) > (b.total or 0) end)

    for _, zone in ipairs(sortedZones) do
        data[#data + 1] = { isZone = true, name = zone.name, total = zone.total }

        if zone.subZones then
            local sortedSubs = {}
            for sub, ss in pairs(zone.subZones) do
                sortedSubs[#sortedSubs + 1] = { name = sub, total = ss.total }
            end
            table.sort(sortedSubs, function(a, b) return (a.total or 0) > (b.total or 0) end)

            for _, sub in ipairs(sortedSubs) do
                local ps = db.poolStats[sub.name]
                local poolCatches = ps and ps.poolCatches or 0
                local mapID = C_Map.GetBestMapForUnit("player")
                local favId = mapID and (mapID .. ":" .. sub.name) or nil
                data[#data + 1] = {
                    isZone = false,
                    name = sub.name,
                    total = sub.total,
                    poolCatches = poolCatches,
                    zoneName = zone.name,
                    favId = favId,
                    isFaved = favId and db.favorites[favId] ~= nil,
                }
            end
        end
    end

    zonesList:SetData(data)
end

-- ============================================================================
-- Minimap Button
-- ============================================================================

local function CreateMinimapBtn()
    if minimapButton then return end
    minimapButton = MedaUI:CreateMinimapButton(
        "MedaAurasGoneFishin",
        "Interface\\Icons\\Trade_Fishing",
        function()
            if statsPanel then
                if statsPanel:IsShown() then
                    statsPanel:Hide()
                else
                    CreateStatsPanel()
                    statsPanel:Show()
                    tabBar:SetActiveTab("overview")
                end
            else
                CreateStatsPanel()
                statsPanel:Show()
                tabBar:SetActiveTab("overview")
            end
        end,
        function()
            if MedaAuras.ToggleSettings then
                MedaAuras:ToggleSettings()
            end
        end
    )

    if minimapButton and db.showMinimapButton == false then
        minimapButton.HideButton()
    end
end

-- ============================================================================
-- Event Handling
-- ============================================================================

local function OnEvent(self, event, ...)
    if not isEnabled then return end

    if event == "UNIT_SPELLCAST_CHANNEL_START" then
        local unit, _, spellID = ...
        if unit ~= "player" then return end
        local spellName = C_Spell.GetSpellName(spellID)
        if FISHING_SPELL_NAMES[spellName] then
            CancelSessionTimeout()
            if not sessionActive then
                db.sessionCount = db.sessionCount + 1
                sessionStartTime = GetTime()
                sessionCaught = 0
                sessionCasts = 0
                sessionJunk = 0
                currentStreak = 0
                sessionActive = true
            end
            isFishing = true
            fishingStartTime = GetTime()
            db.totalCasts = db.totalCasts + 1
            sessionCasts = sessionCasts + 1
            ShowHUD()
            arcDirty = true
        end

    elseif event == "UNIT_SPELLCAST_CHANNEL_STOP" then
        local unit = ...
        if unit ~= "player" then return end
        if isFishing then
            if fishingStartTime > 0 then
                db.totalFishingTime = db.totalFishingTime + (GetTime() - fishingStartTime)
            end
            isFishing = false
            fishingStartTime = 0
            HideHUD()
            StartSessionTimeout()
        end

    elseif event == "LOOT_OPENED" then
        if not sessionActive then return end
        for i = 1, GetNumLootItems() do
            local lootIcon, lootName, lootQuantity, currencyID, lootQuality = GetLootSlotInfo(i)
            local link = GetLootSlotLink(i)
            if link then
                local itemID = tonumber(link:match("item:(%d+)"))
                if itemID then
                    RecordCatch(itemID, lootName, lootIcon, lootQuality)
                end
            end
        end
        arcDirty = true

        if statsPanel and statsPanel:IsShown() then
            local activeTab = tabBar and tabBar:GetActiveTab()
            if activeTab == "overview" then RefreshOverviewTab() end
            if activeTab == "collection" then RefreshCollectionTab() end
            if activeTab == "zones" then RefreshZonesTab() end
        end

    elseif event == "LOOT_CLOSED" then
        if sessionActive and fishingStartTime > 0 then
            db.totalFishingTime = db.totalFishingTime + (GetTime() - fishingStartTime)
            fishingStartTime = GetTime()
        end

    elseif event == "PLAYER_STARTED_MOVING" then
        if isFishing then
            if fishingStartTime > 0 then
                db.totalFishingTime = db.totalFishingTime + (GetTime() - fishingStartTime)
            end
            isFishing = false
            fishingStartTime = 0
            HideHUD()
            StartSessionTimeout()
        end

    elseif event == "PLAYER_LOGOUT" then
        CancelSessionTimeout()
        if isFishing and fishingStartTime > 0 then
            db.totalFishingTime = db.totalFishingTime + (GetTime() - fishingStartTime)
            fishingStartTime = 0
            isFishing = false
        end
        SaveSessionState()

    elseif event == "ZONE_CHANGED_NEW_AREA" or event == "ZONE_CHANGED" or event == "ZONE_CHANGED_INDOORS" then
        if hudVisible then
            arcDirty = true
        end
    end
end

local function RegisterEvents()
    if not eventFrame then
        eventFrame = CreateFrame("Frame")
        eventFrame:SetScript("OnEvent", OnEvent)
    end
    eventFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START")
    eventFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
    eventFrame:RegisterEvent("LOOT_OPENED")
    eventFrame:RegisterEvent("LOOT_CLOSED")
    eventFrame:RegisterEvent("PLAYER_STARTED_MOVING")
    eventFrame:RegisterEvent("PLAYER_LOGOUT")
    eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    eventFrame:RegisterEvent("ZONE_CHANGED")
    eventFrame:RegisterEvent("ZONE_CHANGED_INDOORS")
end

local function UnregisterEvents()
    if eventFrame then
        eventFrame:UnregisterAllEvents()
        eventFrame:SetScript("OnEvent", nil)
    end
end

-- ============================================================================
-- Settings Panel (BuildConfig)
-- ============================================================================

local function BuildSettingsPage(parent, moduleDB)
    local LEFT_X, RIGHT_X = 0, 238
    db = moduleDB

    local UpdatePreview

    local function MarkDirty()
        arcDirty = true
        wipe(fontCache)
        if hudFrame and hudVisible then
            RecalcPositions()
            UpdateHUDContent()
        end
        if UpdatePreview then UpdatePreview() end
    end

    local tabBar, tabs = MedaAuras:CreateConfigTabs(parent, {
        { id = "hud",  label = "HUD" },
        { id = "map",  label = "Map" },
        { id = "data", label = "Data" },
    })
    local baseTabChanged = tabBar.OnTabChanged
    tabBar.OnTabChanged = function(self, tabId, previousTabId)
        if baseTabChanged then
            baseTabChanged(self, tabId, previousTabId)
        end

        if tabId == "map" then
            C_Timer.After(0, function()
                if RefreshMapFavoritesList then
                    RefreshMapFavoritesList()
                end
                if mapFavoritesList then
                    mapFavoritesList:Refresh()
                end
            end)
        end
    end

    -- ===== HUD Tab =====
    do
        local p = tabs["hud"]
        local yOff = 0

        local hdr = MedaUI:CreateSectionHeader(p, "General")
        hdr:SetPoint("TOPLEFT", LEFT_X, yOff)
        yOff = yOff - 45

        local enableCB = MedaUI:CreateCheckbox(p, "Enable Module")
        enableCB:SetPoint("TOPLEFT", LEFT_X, yOff)
        enableCB:SetChecked(moduleDB.enabled)
        enableCB.OnValueChanged = function(_, checked)
            if checked then MedaAuras:EnableModule(MODULE_NAME) else MedaAuras:DisableModule(MODULE_NAME) end
            MedaAuras:RefreshSidebarDot(MODULE_NAME)
        end
        yOff = yOff - 40

        local hdr2 = MedaUI:CreateSectionHeader(p, "Fishing Aura HUD")
        hdr2:SetPoint("TOPLEFT", LEFT_X, yOff)
        yOff = yOff - 45

        local auraCB = MedaUI:CreateCheckbox(p, "Enable Aura HUD")
        auraCB:SetPoint("TOPLEFT", LEFT_X, yOff)
        auraCB:SetChecked(moduleDB.auraEnabled ~= false)
        auraCB.OnValueChanged = function(_, checked)
            moduleDB.auraEnabled = checked
            if not checked and hudVisible then
                DisableHUDUpdates()
                if hudFrame then hudFrame:Hide() end
                hudVisible = false
            end
        end
        local lockCB = MedaUI:CreateCheckbox(p, "Lock HUD Panels")
        lockCB:SetPoint("TOPLEFT", RIGHT_X, yOff)
        lockCB:SetChecked(moduleDB.auraLockPanels ~= false)
        lockCB.OnValueChanged = function(_, checked)
            moduleDB.auraLockPanels = checked
            SetPanelsLocked(checked)
        end
        yOff = yOff - 30

        local checklistCB = MedaUI:CreateCheckbox(p, "Show Zone Checklist")
        checklistCB:SetPoint("TOPLEFT", LEFT_X, yOff)
        checklistCB:SetChecked(moduleDB.auraShowChecklist ~= false)
        checklistCB.OnValueChanged = function(_, checked) moduleDB.auraShowChecklist = checked; MarkDirty() end
        local faveCB = MedaUI:CreateCheckbox(p, "Show Favorite Spot")
        faveCB:SetPoint("TOPLEFT", RIGHT_X, yOff)
        faveCB:SetChecked(moduleDB.auraShowFaves ~= false)
        faveCB.OnValueChanged = function(_, checked) moduleDB.auraShowFaves = checked; MarkDirty() end
        yOff = yOff - 30

        local bestCB = MedaUI:CreateCheckbox(p, "Show Best Spot")
        bestCB:SetPoint("TOPLEFT", LEFT_X, yOff)
        bestCB:SetChecked(moduleDB.auraShowBestSpot ~= false)
        bestCB.OnValueChanged = function(_, checked) moduleDB.auraShowBestSpot = checked; MarkDirty() end
        local tipsCB = MedaUI:CreateCheckbox(p, "Show Lure Tips")
        tipsCB:SetPoint("TOPLEFT", RIGHT_X, yOff)
        tipsCB:SetChecked(moduleDB.auraShowTips ~= false)
        tipsCB.OnValueChanged = function(_, checked) moduleDB.auraShowTips = checked; MarkDirty() end
        yOff = yOff - 30

        local junkCB = MedaUI:CreateCheckbox(p, "Show Session Junk")
        junkCB:SetPoint("TOPLEFT", LEFT_X, yOff)
        junkCB:SetChecked(moduleDB.auraShowSessionJunk ~= false)
        junkCB.OnValueChanged = function(_, checked) moduleDB.auraShowSessionJunk = checked; MarkDirty() end
        yOff = yOff - 40

        local scaleSlider = MedaUI:CreateLabeledSlider(p, "Scale (%)", 200, 50, 200, 5)
        scaleSlider:SetPoint("TOPLEFT", LEFT_X, yOff)
        scaleSlider:SetValue((moduleDB.auraScale or 1) * 100)
        scaleSlider.OnValueChanged = function(_, v) moduleDB.auraScale = v / 100; MarkDirty() end
        local textSzSlider = MedaUI:CreateLabeledSlider(p, "Text Size", 200, 8, 24, 1)
        textSzSlider:SetPoint("TOPLEFT", RIGHT_X, yOff)
        textSzSlider:SetValue(moduleDB.auraTextSize or 13)
        textSzSlider.OnValueChanged = function(_, v) moduleDB.auraTextSize = v; MarkDirty() end
        yOff = yOff - 55

        local iconSzSlider = MedaUI:CreateLabeledSlider(p, "Icon Size", 200, 12, 32, 1)
        iconSzSlider:SetPoint("TOPLEFT", LEFT_X, yOff)
        iconSzSlider:SetValue(moduleDB.auraIconSize or 20)
        iconSzSlider.OnValueChanged = function(_, v) moduleDB.auraIconSize = v; MarkDirty() end
        local opacitySlider = MedaUI:CreateLabeledSlider(p, "Opacity (%)", 200, 0, 100, 5)
        opacitySlider:SetPoint("TOPLEFT", RIGHT_X, yOff)
        opacitySlider:SetValue((moduleDB.auraOpacity or 0.92) * 100)
        opacitySlider.OnValueChanged = function(_, v) moduleDB.auraOpacity = v / 100; MarkDirty() end
        yOff = yOff - 55

        local fontDD = MedaUI:CreateLabeledDropdown(p, "Font", 200, MedaUI:GetFontList(), "font")
        fontDD:SetPoint("TOPLEFT", LEFT_X, yOff)
        fontDD:SetSelected(moduleDB.auraFont or "default")
        fontDD.OnValueChanged = function(_, v) moduleDB.auraFont = v; MarkDirty() end
        local outlineDD = MedaUI:CreateLabeledDropdown(p, "Text Outline", 200, {
            { value = "none", label = "None" },
            { value = "outline", label = "Outline" },
            { value = "thick", label = "Thick Outline" },
        })
        outlineDD:SetPoint("TOPLEFT", RIGHT_X, yOff)
        outlineDD:SetSelected(moduleDB.auraTextOutline or "outline")
        outlineDD.OnValueChanged = function(_, v) moduleDB.auraTextOutline = v; MarkDirty() end
        yOff = yOff - 55

        local fadeInSlider = MedaUI:CreateLabeledSlider(p, "Fade In (sec)", 200, 0, 2, 0.1)
        fadeInSlider:SetPoint("TOPLEFT", LEFT_X, yOff)
        fadeInSlider:SetValue(moduleDB.auraFadeIn or 0.4)
        fadeInSlider.OnValueChanged = function(_, v)
            moduleDB.auraFadeIn = v
            if hudFadeCtrl then hudFadeCtrl:SetDurations(v, moduleDB.auraFadeOut or 0.6) end
        end
        local fadeOutSlider = MedaUI:CreateLabeledSlider(p, "Fade Out (sec)", 200, 0, 2, 0.1)
        fadeOutSlider:SetPoint("TOPLEFT", RIGHT_X, yOff)
        fadeOutSlider:SetValue(moduleDB.auraFadeOut or 0.6)
        fadeOutSlider.OnValueChanged = function(_, v)
            moduleDB.auraFadeOut = v
            if hudFadeCtrl then hudFadeCtrl:SetDurations(moduleDB.auraFadeIn or 0.4, v) end
        end
        yOff = yOff - 55

        local delaySlider = MedaUI:CreateLabeledSlider(p, "Hide Delay (sec)", 200, 1, 30, 1)
        delaySlider:SetPoint("TOPLEFT", LEFT_X, yOff)
        delaySlider:SetValue(moduleDB.auraHideDelay or 8)
        delaySlider.OnValueChanged = function(_, v) moduleDB.auraHideDelay = v end
        local resetPosBtn = MedaUI:CreateButton(p, "Reset Panel Positions")
        resetPosBtn:SetPoint("TOPLEFT", RIGHT_X, yOff)
        resetPosBtn:SetScript("OnClick", function()
            moduleDB.leftPanelPos = nil
            moduleDB.rightPanelPos = nil
            moduleDB.bottomPanelPos = nil
            MarkDirty()
        end)
    end

    -- ===== Map Tab =====
    do
        local p = tabs["map"]
        local yOff = 0

        local hdr = MedaUI:CreateSectionHeader(p, "Map & Favorites")
        hdr:SetPoint("TOPLEFT", LEFT_X, yOff)
        yOff = yOff - 45

        local mapPinCB = MedaUI:CreateCheckbox(p, "Show Map Pins")
        mapPinCB:SetPoint("TOPLEFT", LEFT_X, yOff)
        mapPinCB:SetChecked(moduleDB.showMapPins ~= false)
        mapPinCB.OnValueChanged = function(_, checked)
            moduleDB.showMapPins = checked
            local MapPins = ns.Services.MapPinProvider
            if MapPins then MapPins:SetGroupVisible("GoneFishin_Favorites", checked) end
        end
        local mmCB = MedaUI:CreateCheckbox(p, "Show Minimap Button")
        mmCB:SetPoint("TOPLEFT", RIGHT_X, yOff)
        mmCB:SetChecked(moduleDB.showMinimapButton ~= false)
        mmCB.OnValueChanged = function(_, checked)
            moduleDB.showMinimapButton = checked
            if minimapButton then
                if checked then minimapButton.ShowButton() else minimapButton.HideButton() end
            end
        end
        yOff = yOff - 40

        local clearFavBtn = MedaUI:CreateButton(p, "Clear All Favorites")
        clearFavBtn:SetPoint("TOPLEFT", LEFT_X, yOff)
        clearFavBtn:SetScript("OnClick", function()
            StaticPopup_Show("GONEFISHIN_CLEAR_FAVORITES")
        end)
        local resetStatsBtn = MedaUI:CreateButton(p, "Reset Stats Position")
        resetStatsBtn:SetPoint("TOPLEFT", RIGHT_X, yOff)
        resetStatsBtn:SetScript("OnClick", function()
            moduleDB.statsPosition = nil
            if statsPanel then
                statsPanel:ClearAllPoints()
                statsPanel:SetPoint("CENTER")
            end
        end)

        yOff = yOff - 46

        local favHdr = MedaUI:CreateSectionHeader(p, "Favorite Spots", 470)
        favHdr:SetPoint("TOPLEFT", LEFT_X, yOff)
        yOff = yOff - 34

        mapFavoritesSummary = p:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        mapFavoritesSummary:SetPoint("TOPLEFT", LEFT_X, yOff)
        mapFavoritesSummary:SetJustifyH("LEFT")
        mapFavoritesSummary:SetWidth(460)

        yOff = yOff - 22

        mapFavoritesList = MedaUI:CreateScrollList(p, 470, 420, {
            rowHeight = 42,
            renderRow = function(row, item)
                if not row.nameText then
                    row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                    row.nameText:SetPoint("TOPLEFT", 8, -6)
                    row.nameText:SetJustifyH("LEFT")
                    row.nameText:SetWidth(220)
                    row.nameText:SetWordWrap(false)
                end
                if not row.detailText then
                    row.detailText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                    row.detailText:SetPoint("BOTTOMLEFT", 8, 6)
                    row.detailText:SetJustifyH("LEFT")
                    row.detailText:SetWidth(250)
                    row.detailText:SetWordWrap(false)
                end
                if not row.coordsText then
                    row.coordsText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                    row.coordsText:SetPoint("RIGHT", -86, 0)
                    row.coordsText:SetJustifyH("RIGHT")
                    row.coordsText:SetWidth(90)
                    row.coordsText:SetWordWrap(false)
                end
                if not row.trackBtn then
                    row.trackBtn = MedaUI:CreateButton(row, "Track", 72, 24)
                    row.trackBtn:SetPoint("RIGHT", -8, 0)
                end

                row:EnableMouse(true)
                row:SetScript("OnMouseUp", function(_, button)
                    if button ~= "LeftButton" then return end
                    if row.trackBtn and MouseIsOver and MouseIsOver(row.trackBtn) then
                        return
                    end
                    selectedFavoriteId = item.favId
                    OpenFavoriteOnMap(item.favId)
                end)
                row:SetScript("OnEnter", function(self)
                    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                    GameTooltip:AddLine(GetFavoriteLabel(item.fav), 0.9, 0.7, 0.15)
                    if item.fav.zone and item.fav.zone ~= "" then
                        GameTooltip:AddLine(item.fav.zone, 0.8, 0.8, 0.8)
                    end
                    GameTooltip:AddLine(format("Coords: %.1f, %.1f", (item.fav.x or 0) * 100, (item.fav.y or 0) * 100), 1, 1, 1)
                    GameTooltip:AddLine(format("Pool catches: %d", item.fav.poolCatches or 0), 0.7, 0.7, 0.7)
                    if item.fav.notes and item.fav.notes ~= "" then
                        GameTooltip:AddLine(item.fav.notes, 0.7, 0.7, 0.7, true)
                    end
                    GameTooltip:AddLine("Click row to open on map", 0.5, 0.85, 1)
                    GameTooltip:Show()
                end)
                row:SetScript("OnLeave", function()
                    GameTooltip:Hide()
                end)

                local Theme = MedaUI.Theme
                local gold = Theme and Theme.gold or FALLBACK_GOLD
                local bright = Theme and Theme.text or FALLBACK_BRIGHT
                local dim = Theme and Theme.textDim or FALLBACK_DIM
                local isSelected = item.favId == selectedFavoriteId
                local isActive = item.favId == activeWaypointFavoriteId

                row:SetBackdropBorderColor(unpack(isSelected and gold or (Theme and Theme.border or FALLBACK_DIM)))
                row.nameText:SetText(GetFavoriteLabel(item.fav))
                row.nameText:SetTextColor(bright[1], bright[2], bright[3])
                row.detailText:SetText(item.detailLine)
                row.detailText:SetTextColor(dim[1], dim[2], dim[3])
                row.coordsText:SetText(item.coordLine)
                if isActive then
                    row.coordsText:SetTextColor(0.5, 0.82, 1)
                    row.trackBtn:SetText("Clear")
                else
                    row.coordsText:SetTextColor(0.8, 0.8, 0.8)
                    row.trackBtn:SetText("Track")
                end

                row.trackBtn.OnClick = function()
                    selectedFavoriteId = item.favId
                    SetFavoriteWaypoint(item.favId)
                end
            end,
        })
        mapFavoritesList:SetPoint("TOPLEFT", LEFT_X, yOff)

        mapFavoritesEmpty = p:CreateFontString(nil, "OVERLAY", "GameFontDisable")
        mapFavoritesEmpty:SetPoint("TOP", mapFavoritesList, "TOP", 0, -26)
        mapFavoritesEmpty:SetText("No favorite fishing spots saved yet.")
        mapFavoritesEmpty:Hide()

        RefreshMapFavoritesList = function()
            if not mapFavoritesList then return end

            local entries = {}
            for favId, fav in pairs(moduleDB.favorites or {}) do
                entries[#entries + 1] = {
                    favId = favId,
                    fav = fav,
                    detailLine = ((fav.zone and fav.zone ~= "") and fav.zone or "Unknown Zone")
                        .. format("  |  %d pool catches", fav.poolCatches or 0),
                    coordLine = format("%.1f, %.1f", (fav.x or 0) * 100, (fav.y or 0) * 100),
                }
            end

            table.sort(entries, function(a, b)
                local zoneA = (a.fav.zone or ""):lower()
                local zoneB = (b.fav.zone or ""):lower()
                if zoneA ~= zoneB then
                    return zoneA < zoneB
                end
                return GetFavoriteLabel(a.fav):lower() < GetFavoriteLabel(b.fav):lower()
            end)

            if selectedFavoriteId and not moduleDB.favorites[selectedFavoriteId] then
                selectedFavoriteId = nil
            end
            if activeWaypointFavoriteId and not moduleDB.favorites[activeWaypointFavoriteId] then
                activeWaypointFavoriteId = nil
            end
            if not selectedFavoriteId and entries[1] then
                selectedFavoriteId = entries[1].favId
            end

            mapFavoritesList:SetData(entries)
            mapFavoritesList:Refresh()

            local activeFav = activeWaypointFavoriteId and moduleDB.favorites[activeWaypointFavoriteId] or nil
            local count = #entries
            if mapFavoritesSummary then
                local summary = format("%d favorite%s saved", count, count == 1 and "" or "s")
                if activeFav then
                    summary = summary .. format("  |  Waypoint: %s", GetFavoriteLabel(activeFav))
                end
                mapFavoritesSummary:SetText(summary)
            end

            if mapFavoritesEmpty then
                if count == 0 then
                    mapFavoritesEmpty:Show()
                else
                    mapFavoritesEmpty:Hide()
                end
            end
        end

        RefreshMapFavoritesList()
    end

    -- ===== Data Tab =====
    do
        local p = tabs["data"]
        local yOff = 0
        local Theme = MedaUI.Theme
        local dim = Theme and Theme.textDim or FALLBACK_DIM
        local bright = Theme and Theme.text or FALLBACK_BRIGHT

        local function DataLabel(label, x, y)
            local lbl = p:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            lbl:SetPoint("TOPLEFT", x, y)
            lbl:SetTextColor(dim[1], dim[2], dim[3])
            lbl:SetText(label)
            return lbl
        end

        local function DataValue(anchor, text)
            local val = p:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            val:SetPoint("LEFT", anchor, "RIGHT", 6, 0)
            val:SetTextColor(bright[1], bright[2], bright[3])
            val:SetText(text)
            return val
        end

        local hdrSummary = MedaUI:CreateSectionHeader(p, "Data Summary")
        hdrSummary:SetPoint("TOPLEFT", LEFT_X, yOff)
        yOff = yOff - 35

        local uniqueItems = 0
        for _ in pairs(db.fishLog or {}) do uniqueItems = uniqueItems + 1 end
        local zoneCount = 0
        for _ in pairs(db.zoneStats or {}) do zoneCount = zoneCount + 1 end
        local poolNames = {}
        for _, ps in pairs(db.poolStats or {}) do
            if ps.pools then
                for name in pairs(ps.pools) do poolNames[name] = true end
            end
        end
        local poolCount = 0
        for _ in pairs(poolNames) do poolCount = poolCount + 1 end
        local faveCount = 0
        for _ in pairs(db.favorites or {}) do faveCount = faveCount + 1 end
        local rate = db.totalCasts > 0 and math_floor(db.totalCaught / db.totalCasts * 100) or 0

        DataValue(DataLabel("Total Caught:", 8, yOff), tostring(db.totalCaught))
        DataValue(DataLabel("Total Casts:", RIGHT_X, yOff), tostring(db.totalCasts))
        yOff = yOff - 20
        DataValue(DataLabel("Catch Rate:", 8, yOff), rate .. "%")
        DataValue(DataLabel("Time Fishing:", RIGHT_X, yOff), FormatTime(db.totalFishingTime))
        yOff = yOff - 20
        DataValue(DataLabel("Sessions:", 8, yOff), tostring(db.sessionCount))
        DataValue(DataLabel("Longest Streak:", RIGHT_X, yOff), tostring(db.longestStreak))
        yOff = yOff - 20
        DataValue(DataLabel("Unique Items:", 8, yOff), tostring(uniqueItems))
        DataValue(DataLabel("Zones Fished:", RIGHT_X, yOff), tostring(zoneCount))
        yOff = yOff - 20
        DataValue(DataLabel("Pools Found:", 8, yOff), tostring(poolCount))
        DataValue(DataLabel("Favorite Spots:", RIGHT_X, yOff), tostring(faveCount))
        yOff = yOff - 35

        local hdr = MedaUI:CreateSectionHeader(p, "Data Management")
        hdr:SetPoint("TOPLEFT", LEFT_X, yOff)
        yOff = yOff - 45

        local exportBtn = MedaUI:CreateButton(p, "Export Data")
        exportBtn:SetPoint("TOPLEFT", LEFT_X, yOff)
        exportBtn:SetScript("OnClick", function() ShowExportWindow() end)
        local resetDataBtn = MedaUI:CreateButton(p, "Reset All Fishing Data")
        resetDataBtn:SetPoint("TOPLEFT", RIGHT_X, yOff)
        resetDataBtn:SetScript("OnClick", function()
            StaticPopup_Show("GONEFISHIN_RESET_DATA")
        end)
        yOff = yOff - 35

        local resetStreakBtn = MedaUI:CreateButton(p, "Reset Streak Record")
        resetStreakBtn:SetPoint("TOPLEFT", LEFT_X, yOff)
        resetStreakBtn:SetScript("OnClick", function()
            StaticPopup_Show("GONEFISHIN_RESET_STREAK")
        end)
        local repairPoolBtn = MedaUI:CreateButton(p, "Repair Pool Data")
        repairPoolBtn:SetPoint("TOPLEFT", RIGHT_X, yOff)
        repairPoolBtn:SetScript("OnClick", function()
            StaticPopup_Show("GONEFISHIN_REPAIR_POOLS")
        end)
    end

    MedaAuras:SetContentHeight(550)

    -- ================================================================
    -- Floating Side Preview (mock HUD panels)
    -- ================================================================
    do
        local anchor = MedaAurasSettingsPanel or _G["MedaAurasSettingsPanel"]
        if not anchor then return end

        local dim = { 0.55, 0.55, 0.55, 1 }
        local gold = { 1, 0.82, 0, 1 }
        local green = { 0.53, 0.80, 0.53, 1 }
        local blue = { 0.4, 0.78, 1, 1 }

        local pvContainer = MedaUI:CreateHUDGroup(nil, {
            point = {
                point = "TOPLEFT",
                relativeTo = anchor,
                relativePoint = "TOPRIGHT",
                x = 6,
                y = 0,
            },
            strata = "HIGH",
            opacity = moduleDB.auraOpacity or 0.92,
            scale = moduleDB.auraScale or 1,
        })
        MedaAuras:RegisterConfigCleanup(pvContainer)
        pvContainer:Show()

        local pvLeft = MedaUI:CreateHUDSection(pvContainer, {
            width = 280,
            height = 8 * LINE_SPACING,
            locked = true,
        })
        pvLeft:SetPoint("TOPLEFT", pvContainer, "TOPLEFT", 0, 0)

        local pvLeftBlock = MedaUI:CreateHUDTextBlock(pvLeft, {
            width = 280,
            lineCount = 8,
            lineHeight = LINE_SPACING,
            justifyH = "RIGHT",
        })
        pvLeftBlock:SetPoint("TOPLEFT", pvLeft, "TOPLEFT", 0, 0)
        local pvLeftLines = {}
        for i = 1, 8 do
            pvLeftLines[i] = pvLeftBlock:GetLine(i)
        end

        local pvRight = MedaUI:CreateHUDSection(pvContainer, {
            width = 280,
            height = 170,
            locked = true,
        })
        pvRight:SetPoint("TOPLEFT", pvLeft, "BOTTOMLEFT", 0, -14)

        local pvChecklistHeader = MedaUI:CreateLabel(pvRight, "Zone Fish", {
            fontObject = "GameFontNormal",
            tone = "gold",
            shadow = true,
            wrap = false,
        })
        pvChecklistHeader:SetPoint("TOPLEFT", pvRight, "TOPLEFT", 0, 0)

        local pvChecklistRows = {}
        local previewFish = {
            { name = "Lunker Salmon",       q = 3, count = 7 },
            { name = "Moonpearl Trout",     q = 2, count = 12 },
            { name = "Midnight Anglerfish", q = 4, count = 2 },
            { name = "Duskwater Eel",       q = 1, count = 5 },
        }
        for index, fish in ipairs(previewFish) do
            local row = MedaUI:CreateHUDRow(pvRight, {
                width = 280,
                showState = false,
                showTimer = false,
                showDelta = false,
                interactive = false,
                iconSize = moduleDB.auraIconSize or 20,
            })
            row:SetPoint("TOPLEFT", pvRight, "TOPLEFT", 0, -(18 + ((index - 1) * CHECKLIST_ROW_HEIGHT)))
            local r, g, b = GetQualityColor(fish.q)
            row:SetIconColor(r, g, b)
            row:SetText(format("|cff%02x%02x%02x%s|r |cff999999(x%d)|r", r * 255, g * 255, b * 255, fish.name, fish.count))
            pvChecklistRows[index] = row
        end

        local pvJunkHeader = MedaUI:CreateCollapsibleSectionHeader(pvRight, {
            text = "Junk",
            count = 3,
            width = 280,
            height = CHECKLIST_ROW_HEIGHT,
            tone = "textDim",
            showLine = false,
            expanded = false,
        })
        pvJunkHeader:SetPoint("TOPLEFT", pvRight, "TOPLEFT", 0, -(18 + (#previewFish * CHECKLIST_ROW_HEIGHT)))

        local pvMissingHeader = MedaUI:CreateCollapsibleSectionHeader(pvRight, {
            text = "Missing",
            count = 2,
            width = 280,
            height = CHECKLIST_ROW_HEIGHT,
            tone = "textDim",
            showLine = false,
            expanded = false,
        })
        pvMissingHeader:SetPoint("TOPLEFT", pvJunkHeader, "BOTTOMLEFT", 0, 0)

        local pvBottom = MedaUI:CreateHUDSection(pvContainer, {
            width = 280,
            height = 70,
            locked = true,
        })
        pvBottom:SetPoint("TOPLEFT", pvRight, "BOTTOMLEFT", 0, -14)

        local pvCenterTexts = {}
        for i = 1, 3 do
            pvCenterTexts[i] = MedaUI:CreateLabel(pvBottom, "", {
                justifyH = "CENTER",
                tone = "text",
                shadow = true,
                wrap = false,
            })
        end
        pvCenterTexts[1]:SetPoint("TOP", pvBottom, "TOP", 10, 0)
        pvCenterTexts[2]:SetPoint("TOP", pvBottom, "TOP", 0, -16)
        pvCenterTexts[3]:SetPoint("TOP", pvBottom, "TOP", 0, -32)

        local pvFaveButton = MedaUI:CreateIconButton(pvBottom, {
            size = 16,
            atlas = "Waypoint-MapPin-Tracked",
            flat = true,
        })
        pvFaveButton:SetPoint("RIGHT", pvCenterTexts[1], "LEFT", -2, 0)
        pvFaveButton:Disable()

        local pvEndSessionBtn = MedaUI:CreateHUDTextButton(pvBottom, "End Session", {
            width = 80,
            height = 16,
            tone = "dim",
            hoverTone = "dim",
        })
        pvEndSessionBtn:SetPoint("TOP", pvBottom, "TOP", 0, -52)
        pvEndSessionBtn:Disable()

        UpdatePreview = function()
            local textFont = GetFontObj(moduleDB.auraFont or "default", moduleDB.auraTextSize or 13, moduleDB.auraTextOutline or "outline")
            local headerFont = GetFontObj(moduleDB.auraFont or "default", (moduleDB.auraTextSize or 13) + 2, "thick")
            local smallFont = GetFontObj(moduleDB.auraFont or "default", math_max((moduleDB.auraTextSize or 13) - 3, 8), moduleDB.auraTextOutline or "outline")

            pvContainer:SetHUDScale(moduleDB.auraScale or 1)
            pvContainer:SetHUDOpacity(moduleDB.auraOpacity or 0.92)

            for i = 1, 8 do
                pvLeftLines[i]:SetFontObject(i <= 2 and headerFont or textFont)
            end
            pvChecklistHeader:SetFontObject(headerFont)
            for _, row in ipairs(pvChecklistRows) do
                row.text:SetFontObject(textFont)
                if row.icon then row.icon:SetSize(moduleDB.auraIconSize or 20, moduleDB.auraIconSize or 20) end
            end
            pvJunkHeader.header:SetFontObject(smallFont)
            pvJunkHeader.badge:SetFontObject(smallFont)
            pvMissingHeader.header:SetFontObject(smallFont)
            pvMissingHeader.badge:SetFontObject(smallFont)
            for _, fs in ipairs(pvCenterTexts) do
                fs:SetFontObject(textFont)
            end
            pvEndSessionBtn:SetFontObject(textFont)

            local showJunk = moduleDB.auraShowSessionJunk ~= false
            local showChecklist = moduleDB.auraShowChecklist ~= false
            local showFave = moduleDB.auraShowFaves ~= false
            local showBest = moduleDB.auraShowBestSpot ~= false
            local showTips = moduleDB.auraShowTips ~= false

            pvLeftLines[1]:SetText("Silvermoon Harbor")
            pvLeftLines[1]:SetColorOverride(gold[1], gold[2], gold[3], gold[4])
            pvLeftLines[2]:SetText("The Moonwell")
            pvLeftLines[2]:SetColorOverride(dim[1], dim[2], dim[3], dim[4])
            pvLeftLines[3]:SetText("")
            pvLeftLines[4]:SetText(showJunk and "Session: 24 fish | 3 junk" or "Session: 24 fish")
            pvLeftLines[4]:SetColorOverride(1, 1, 1, 1)
            pvLeftLines[5]:SetText("Casts: 31 | Rate: 77%")
            pvLeftLines[5]:SetColorOverride(dim[1], dim[2], dim[3], dim[4])
            pvLeftLines[6]:SetText("Time: 12m 34s")
            pvLeftLines[6]:SetColorOverride(dim[1], dim[2], dim[3], dim[4])
            pvLeftLines[7]:SetText("Streak: 8")
            pvLeftLines[7]:SetColorOverride(green[1], green[2], green[3], green[4])
            pvLeftLines[8]:SetText("Fishing: 179/225")
            pvLeftLines[8]:SetColorOverride(dim[1], dim[2], dim[3], dim[4])

            if showChecklist then pvRight:Show() else pvRight:Hide() end

            if showFave then
                pvFaveButton:Show()
                pvCenterTexts[1]:Show()
                pvCenterTexts[1]:SetText("Favorite: The Moonwell")
                pvCenterTexts[1]:SetColorOverride(blue[1], blue[2], blue[3], blue[4])
            else
                pvFaveButton:Hide()
                pvCenterTexts[1]:Hide()
            end

            if showBest then
                pvCenterTexts[2]:Show()
                pvCenterTexts[2]:SetText("Best Spot: Sunsail Anchorage (41)")
                pvCenterTexts[2]:SetColorOverride(dim[1], dim[2], dim[3], dim[4])
            else
                pvCenterTexts[2]:Hide()
            end

            if showTips then
                pvCenterTexts[3]:Show()
                pvCenterTexts[3]:SetText("Tip: Use Moonpearl Lure for rare fish")
                pvCenterTexts[3]:SetColorOverride(dim[1], dim[2], dim[3], dim[4])
            else
                pvCenterTexts[3]:Hide()
            end
        end
        UpdatePreview()
    end
end

-- ============================================================================
-- Static Popups
-- ============================================================================

-- ============================================================================
-- Export Feature
-- ============================================================================

local exportFrame

local function SerializeTable(t, indent)
    indent = indent or ""
    local parts = {}
    local nextIndent = indent .. "    "

    local isArray = true
    local maxN = 0
    for k in pairs(t) do
        if type(k) ~= "number" or k < 1 or k ~= math_floor(k) then
            isArray = false
            break
        end
        if k > maxN then maxN = k end
    end
    if isArray and maxN ~= #t then isArray = false end

    if isArray then
        for i = 1, #t do
            local v = t[i]
            if type(v) == "table" then
                parts[#parts + 1] = nextIndent .. SerializeTable(v, nextIndent) .. ","
            elseif type(v) == "string" then
                parts[#parts + 1] = nextIndent .. format("%q", v) .. ","
            else
                parts[#parts + 1] = nextIndent .. tostring(v) .. ","
            end
        end
    else
        local keys = {}
        for k in pairs(t) do keys[#keys + 1] = k end
        table.sort(keys, function(a, b)
            if type(a) == type(b) then return tostring(a) < tostring(b) end
            return type(a) < type(b)
        end)
        for _, k in ipairs(keys) do
            local v = t[k]
            local keyStr
            if type(k) == "number" then
                keyStr = format("[%d]", k)
            else
                keyStr = format("[%q]", tostring(k))
            end
            if type(v) == "table" then
                parts[#parts + 1] = nextIndent .. keyStr .. " = " .. SerializeTable(v, nextIndent) .. ","
            elseif type(v) == "string" then
                parts[#parts + 1] = nextIndent .. keyStr .. " = " .. format("%q", v) .. ","
            elseif type(v) == "boolean" then
                parts[#parts + 1] = nextIndent .. keyStr .. " = " .. tostring(v) .. ","
            elseif v ~= nil then
                parts[#parts + 1] = nextIndent .. keyStr .. " = " .. tostring(v) .. ","
            end
        end
    end

    return "{\n" .. table.concat(parts, "\n") .. "\n" .. indent .. "}"
end

local function BuildExportString()
    if not db then return "-- No data" end

    local exportData = {
        version = DB_VERSION,
        exportDate = date("%Y-%m-%d"),
        character = UnitName("player") .. "-" .. GetRealmName(),
        discoveredZones = db.discoveredZones or {},
        discoveredPools = db.discoveredPools or {},
        poolZones = {},
        fishLog = {},
    }

    for itemID, entry in pairs(db.fishLog) do
        exportData.fishLog[itemID] = {
            count = entry.count,
            firstCaught = entry.firstCaught,
            category = entry.category,
        }
    end

    for subzone, ps in pairs(db.poolStats) do
        if ps.pools then
            for poolName in pairs(ps.pools) do
                if not exportData.poolZones[poolName] then
                    exportData.poolZones[poolName] = {}
                end
                exportData.poolZones[poolName][subzone] = true
            end
        end
    end

    return "GoneFishinExport = " .. SerializeTable(exportData)
end

ShowExportWindow = function()
    if not exportFrame then
        exportFrame = MedaUI:CreateImportExportDialog({
            width = 600,
            height = 400,
            title = "|cff00ccffGone Fishin'|r Export Data",
            mode = "export",
            hintText = "Press Ctrl+A to select all, then Ctrl+C to copy.",
        })
    end

    exportFrame:ShowExport("|cff00ccffGone Fishin'|r Export Data", BuildExportString())
end

-- ============================================================================
-- Static Popups
-- ============================================================================

StaticPopupDialogs["GONEFISHIN_CLEAR_FAVORITES"] = {
    text = "Remove all favorite fishing spots?",
    button1 = "Yes",
    button2 = "No",
    OnAccept = function()
        if not db then return end
        ClearFavoriteWaypoint(true)
        selectedFavoriteId = nil
        local MapPins = ns.Services.MapPinProvider
        if MapPins then MapPins:ClearGroup("GoneFishin_Favorites") end
        wipe(db.favorites)
        if RefreshMapFavoritesList then
            RefreshMapFavoritesList()
        end
        print("|cff00ccffGone Fishin':|r All favorites cleared.")
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
}

StaticPopupDialogs["GONEFISHIN_RESET_DATA"] = {
    text = "Reset ALL fishing data? This cannot be undone!",
    button1 = "Yes, Reset",
    button2 = "Cancel",
    OnAccept = function()
        if not db then return end
        ClearFavoriteWaypoint(true)
        selectedFavoriteId = nil
        wipe(db.fishLog)
        wipe(db.zoneStats)
        wipe(db.poolStats)
        wipe(db.recentCatches)
        wipe(db.discoveredZones)
        wipe(db.discoveredPools)
        local MapPins = ns.Services.MapPinProvider
        if MapPins then MapPins:ClearGroup("GoneFishin_Favorites") end
        wipe(db.favorites)
        db.totalCaught = 0
        db.totalCasts = 0
        db.totalFishingTime = 0
        db.sessionCount = 0
        db.longestStreak = 0
        db.bestSpot = nil
        sessionCaught = 0
        sessionCasts = 0
        sessionJunk = 0
        currentStreak = 0
        if RefreshMapFavoritesList then
            RefreshMapFavoritesList()
        end
        print("|cff00ccffGone Fishin':|r All fishing data has been reset.")
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
}

StaticPopupDialogs["GONEFISHIN_RESET_STREAK"] = {
    text = "Reset your longest streak record? This cannot be undone!",
    button1 = "Yes, Reset",
    button2 = "Cancel",
    OnAccept = function()
        if not db then return end
        db.longestStreak = 0
        currentStreak = 0
        print("|cff00ccffGone Fishin':|r Streak record has been reset.")
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
}

StaticPopupDialogs["GONEFISHIN_REPAIR_POOLS"] = {
    text = "Remove false pool entries caused by open water fishing? This corrects pool catch counts but cannot be undone.",
    button1 = "Yes, Repair",
    button2 = "Cancel",
    OnAccept = function()
        local count = PurgeOpenWaterPoolData()
        if count > 0 then
            print(format("|cff00ccffGone Fishin':|r Purged %d false pool entries from open water catches.", count))
        else
            print("|cff00ccffGone Fishin':|r No false pool entries found.")
        end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
}

-- ============================================================================
-- Lifecycle
-- ============================================================================

local function StartModule()
    isEnabled = true

    BuildPoolNameSet()
    CreateScanTooltip()
    HookPoolTooltip()
    CreateHUD()
    CreateMinimapBtn()

    RegisterEvents()
    SyncMapPins()
    RepairPoolNames()

    MedaAuras.Log("[GoneFishin] Module enabled")
end

local function StopModule()
    isEnabled = false
    isFishing = false

    CancelSessionTimeout()
    sessionActive = false

    UnregisterEvents()
    DisableHUDUpdates()

    if hideTimer then hideTimer:Cancel(); hideTimer = nil end
    if hudFrame then hudFrame:Hide() end
    hudVisible = false

    if statsPanel then statsPanel:Hide() end

    local MapPins = ns.Services.MapPinProvider
    if MapPins then MapPins:UnregisterPinGroup("GoneFishin_Favorites") end

    MedaAuras.Log("[GoneFishin] Module disabled")
end

local function OnInitialize(moduleDB)
    db = moduleDB
    RunMigrations(db)
    RestoreSession()
    if sessionActive then
        StartSessionTimeout()
    end
    StartModule()
end

local function OnEnable(moduleDB)
    db = moduleDB
    StartModule()
end

local function OnDisable(moduleDB)
    db = moduleDB
    StopModule()
end

-- ============================================================================
-- Slash Commands
-- ============================================================================

local slashCommands = {
    ["toggle"] = function(moduleDB)
        db = moduleDB
        if hudFrame and hudVisible then
            DisableHUDUpdates()
            hudFrame:Hide()
            hudVisible = false
        else
            ShowHUD()
        end
    end,
    ["stats"] = function(moduleDB)
        db = moduleDB
        if statsPanel and statsPanel:IsShown() then
            statsPanel:Hide()
        else
            CreateStatsPanel()
            statsPanel:Show()
            tabBar:SetActiveTab("overview")
        end
    end,
    ["fave"] = function(moduleDB)
        db = moduleDB
        ToggleFavorite()
    end,
    ["reset"] = function(moduleDB)
        db = moduleDB
        StaticPopup_Show("GONEFISHIN_RESET_DATA")
    end,
    ["show"] = function(moduleDB)
        db = moduleDB
        ShowHUD()
    end,
    ["hide"] = function(moduleDB)
        db = moduleDB
        if hudFrame and hudVisible then
            DisableHUDUpdates()
            hudFrame:Hide()
            hudVisible = false
        end
    end,
    ["aura"] = function(moduleDB)
        db = moduleDB
        moduleDB.auraEnabled = not moduleDB.auraEnabled
        if not moduleDB.auraEnabled and hudVisible then
            DisableHUDUpdates()
            hudFrame:Hide()
            hudVisible = false
        end
        print(format("|cff00ccffGone Fishin':|r Aura HUD %s.", moduleDB.auraEnabled and "enabled" or "disabled"))
    end,
    ["export"] = function(moduleDB)
        db = moduleDB
        ShowExportWindow()
    end,
    ["endsession"] = function(moduleDB)
        db = moduleDB
        if not sessionActive then
            print("|cff00ccffGone Fishin':|r No active session.")
            return
        end
        CancelSessionTimeout()
        EndSession()
        if hudVisible then
            HideHUD()
        end
        print("|cff00ccffGone Fishin':|r Session ended.")
    end,
    ["repairpools"] = function(moduleDB)
        db = moduleDB
        local count = RepairPoolNames()
        if count > 0 then
            print(format("|cff00ccffGone Fishin':|r Repaired %d pool name entries.", count))
        else
            print("|cff00ccffGone Fishin':|r No pool entries to repair.")
        end
    end,
    ["repairdata"] = function(moduleDB)
        db = moduleDB
        local count = PurgeOpenWaterPoolData()
        if count > 0 then
            print(format("|cff00ccffGone Fishin':|r Purged %d false pool entries from open water catches.", count))
        else
            print("|cff00ccffGone Fishin':|r No false pool entries found.")
        end
    end,
}

-- ============================================================================
-- Module Defaults
-- ============================================================================

local MODULE_DEFAULTS = {
    enabled = false,
    dbVersion = 0,

    fishLog = {},
    zoneStats = {},
    totalCaught = 0,
    totalCasts = 0,
    totalFishingTime = 0,
    sessionCount = 0,
    longestStreak = 0,
    recentCatches = {},

    poolStats = {},
    bestSpot = nil,

    favorites = {},
    showMapPins = true,

    discoveredZones = {},
    discoveredPools = {},
    poolObjectNames = {},
    collectionSort = "missing",

    auraEnabled = true,
    auraLockPanels = false,
    auraShowChecklist = true,
    auraShowRecent = true,
    auraShowTips = true,
    auraShowFaves = true,
    auraShowBestSpot = true,
    auraShowSessionJunk = true,
    auraHOffset = 200,
    auraVerticalOffset = -20,
    auraScale = 1.0,
    auraTextSize = 13,
    auraIconSize = 20,
    auraOpacity = 0.92,
    auraFont = "default",
    auraTextOutline = "outline",
    auraFadeIn = 0.4,
    auraFadeOut = 0.6,
    auraHideDelay = 8,

    leftPanelPos = nil,
    rightPanelPos = nil,
    bottomPanelPos = nil,

    statsPosition = nil,
    statsSize = nil,
    showMinimapButton = true,
}

-- ============================================================================
-- Register Module
-- ============================================================================

MedaAuras:RegisterModule({
    name          = MODULE_NAME,
    title         = "Gone Fishin'",
    version       = MODULE_VERSION,
    stability     = MODULE_STABILITY,
    author        = "Medalink",
    description   = "Tracks every fish and item caught while fishing. "
                 .. "Displays a three-panel HUD with zone stats, a zone fish checklist "
                 .. "(with collapsible junk and missing sections), and favorites/tips. "
                 .. "Each panel can be dragged independently. "
                 .. "Includes a stats window with Midnight fish collection checklist, "
                 .. "zone breakdowns, and custom map pins for favorite fishing spots.",
    sidebarDesc   = "Tracks every fish and item caught while fishing with a three-panel HUD.",
    defaults      = MODULE_DEFAULTS,
    OnInitialize  = OnInitialize,
    OnEnable      = OnEnable,
    OnDisable     = OnDisable,
    pages         = {
        { id = "settings", label = "Settings" },
    },
    buildPage     = function(_, parent)
        BuildSettingsPage(parent, MedaAuras:GetModuleDB(MODULE_NAME))
        return 980
    end,
    slashCommands = slashCommands,
})
