-- ============================================================
-- RealNibba.Pack v2.0  --  Core.lua
-- Ace3 backbone: AceAddon · AceDB · AceSerializer · AceComm
--                AceEvent · AceConsole · AceHook
-- ============================================================

-- ── Create addon object, embed all Ace3 mixins
local RNPack = LibStub("AceAddon-3.0"):NewAddon("RealNibba.Pack",
    "AceConsole-3.0",
    "AceEvent-3.0",
    "AceHook-3.0",
    "AceSerializer-3.0",
    "AceComm-3.0"
)

-- Export as global so every file does: local P = RNPack
_G.RNPack = RNPack

-- Carbonite-safe wrapper: SetMapToCurrentZone corrupts Carbonite's map state
local _rnp_carbonite = IsAddOnLoaded("Carbonite") or IsAddOnLoaded("Carbonite_Core") or (Nx ~= nil)
local function SafeSetMapToCurrentZone()
    if _rnp_carbonite then return end
    SetMapToCurrentZone()
end

-- ============================================================
-- DB DEFAULTS  (AceDB profile structure)
-- ============================================================
local DEFAULTS = {
    profile = {
        routes           = {},
        enabled          = {Herbs=true, Mines=true, Mixed=true},
        priceByQuality   = {[0]="Vendor",[1]="Vendor",[2]="Atr",
                            [3]="Atr",  [4]="Atr",  [5]="Atr"},
        trackQualities   = {[0]=true,[1]=true,[2]=true,
                            [3]=true,[4]=true,[5]=true},
        customPrices     = {},
        blacklist        = {},
        sessions         = {},
        total            = {moneyCash=0,moneyItems=0,moneyQuests=0},
        farmMinimized    = false,
        addonScale       = 1.0,
        pendingSession   = nil,
        autoTrack = {
            enabled  = false,
            interval = 3.0,
        },
        mapTrail = {
            enabled        = false,
            minimapEnabled = false,
            dotCount       = 100,
            fadeTime       = 90,
            dotSpacing     = 2.0,
            dotSize        = 8,
            opacity        = 1.0,
            color          = {r=1, g=0, b=0, a=1},
        },
        minimapFlash = {
            enabled     = false,
            flashSpeed  = 0.4,
            sessionOnly = false,
        },
    }
}

-- ============================================================
-- CONSTANTS
-- ============================================================
local ADDON_VER    = "2.0"

local RIBBON_LEVELS = {0.003, 0.0045, 0.006}
local RIBBON        = 0.006
local MAX16         = 65535
local SAMPLE_RATE   = 0.5
local MIN_DIST_MAP  = 0.008
local WP_RATE       = 0.05
local ARRIVE_DIST   = 40
local twopi         = math.pi * 2

RNPack.RIBBON_LEVELS = RIBBON_LEVELS

local TYPE_COLOR = {
    Herbs = {r=0.1, g=0.9, b=0.1},
    Mines = {r=0.3, g=0.5, b=1.0},
    Mixed = {r=0.9, g=0.7, b=0.1},
}
RNPack.TYPE_COLOR = TYPE_COLOR

local QUALITY_COLORS = {
    [0]={0.62,0.62,0.62},{1,1,1},{0.12,1,0},{0,0.44,0.87},{0.64,0.21,0.93},{1,0.5,0},
}
RNPack.QUALITY_COLORS = QUALITY_COLORS

-- Layout constants (used by all window files)
local HEADER_H  = 24
local CONTENT_Y = -30
local BPAD      = 14
local GAP       = 6
local GAP_S     = 3
local BTN_H     = 22
local LBL_H     = 14
local WIN_W     = 320
local WIN_H     = 440

RNPack.HEADER_H  = HEADER_H
RNPack.CONTENT_Y = CONTENT_Y
RNPack.BPAD      = BPAD
RNPack.GAP       = GAP
RNPack.GAP_S     = GAP_S
RNPack.BTN_H     = BTN_H
RNPack.LBL_H     = LBL_H
RNPack.WIN_W     = WIN_W
RNPack.WIN_H     = WIN_H

-- ============================================================
-- UTILITY FUNCTIONS
-- ============================================================
local function prt(msg)
    DEFAULT_CHAT_FRAME:AddMessage("|cffCC2222RealNibba|r.Pack: "..tostring(msg))
end
RNPack.prt = prt

local function FormatMoney(c)
    c = math.floor(c or 0)
    local g=math.floor(c/10000); local s=math.floor((c%10000)/100); local cp=c%100
    if g>0 then return string.format("|cffFFD700%dg|r |cffC0C0C0%ds|r |cffCC7A3D%dc|r",g,s,cp)
    elseif s>0 then return string.format("|cffC0C0C0%ds|r |cffCC7A3D%dc|r",s,cp)
    else return string.format("|cffCC7A3D%dc|r",cp) end
end
RNPack.FormatMoney = FormatMoney

local function FormatMoneyShort(c)
    c = math.floor(c or 0)
    local g=c/10000
    if g>=1 then return string.format("|cffFFD700%.1fg|r",g)
    else
        local s=math.floor((c%10000)/100)
        if s>=1 then return string.format("|cffC0C0C0%ds|r",s)
        else return string.format("|cffCC7A3D%dc|r",c%100) end
    end
end
RNPack.FormatMoneyShort = FormatMoneyShort

local function FormatTime(secs)
    secs = math.floor(secs or 0)
    local h=math.floor(secs/3600); local m=math.floor((secs%3600)/60); local s=secs%60
    if h>0 then return string.format("%dh %02dm %02ds",h,m,s)
    else return string.format("%dm %02ds",m,s) end
end
RNPack.FormatTime = FormatTime

-- ============================================================
-- PRICE SOURCE  (original working version from transcript)
-- Uses Atr_GetAuctionPrice for 3.3.5a ClassicFix/GepyFix Auctionator
-- ============================================================
local GetItemPrice
do
    local atrInit = false
    local AtrGetPrice

    local function InitAuctionator()
        if atrInit then return end
        atrInit = true
        -- Auctionator ClassicFix / GepyFix for 3.3.5a
        if Atr_GetAuctionPrice then
            AtrGetPrice = function(name) return Atr_GetAuctionPrice(name) end
        end
    end

    function GetItemPrice(itemLink)
        InitAuctionator()
        if not itemLink then return 0, nil, nil end
        local name, _, rarity, _, _, _, _, _, _, _, vendorPrice = GetItemInfo(itemLink)
        if not name then return 0, nil, nil end
        local DB = RNPack.DB
        -- Custom price overrides everything
        if DB and DB.customPrices and DB.customPrices[itemLink] then
            return DB.customPrices[itemLink], rarity, name
        end
        local src = "Vendor"
        if DB and DB.priceByQuality then
            src = DB.priceByQuality[rarity or 0] or "Vendor"
        end
        local price = 0
        if src == "Atr" and AtrGetPrice then
            price = AtrGetPrice(name) or 0
        elseif src == "TSM" and TSM_API and TSM_API.GetCustomPriceValue then
            local itemID = tonumber(string.match(itemLink, "item:(%d+):"))
            price = itemID and (TSM_API.GetCustomPriceValue("dbmarket","i:"..itemID) or 0) or 0
        elseif src == "Vendor" then
            price = vendorPrice or 0
        end
        if price <= 0 then price = vendorPrice or 0 end
        return price, rarity, name
    end
end
RNPack.GetItemPrice = GetItemPrice

-- ============================================================
-- SESSION  (references RNPack.DB set in OnInitialize)
-- ============================================================
local DEFDATA = {
    startTime=nil, duration=0, zoneName="",
    moneyCash=0, moneyItems=0, moneyQuests=0,
    countItems=0, countMobs=0, countHerbs=0, countMines=0,
    lootedItems={}, killedMobs={},
}

local session    = {}
local sessionActive = false
local sessionPaused = false
RNPack.session       = session
RNPack.sessionActive = sessionActive
RNPack.sessionPaused = sessionPaused

local function CopyDefaults(dst, src)
    for k,v in pairs(src) do
        if type(v)=="table" then dst[k]={}; CopyDefaults(dst[k],v)
        else dst[k]=v end
    end
end

local function ResetSession()
    CopyDefaults(session, DEFDATA)
    RNPack.session = session
end

local function GetSessionDuration()
    if not session.startTime then return session.duration or 0 end
    return (session.duration or 0) + (time() - session.startTime)
end

local function GetTotalMoney()
    return (session.moneyCash or 0)+(session.moneyItems or 0)+(session.moneyQuests or 0)
end

local function GetGoldPerHour()
    local dur = GetSessionDuration()
    if dur < 1 then return 0 end
    return math.floor(GetTotalMoney() / dur * 3600)
end

RNPack.ResetSession      = ResetSession
RNPack.GetSessionDuration= GetSessionDuration
RNPack.GetTotalMoney     = GetTotalMoney
RNPack.GetGoldPerHour    = GetGoldPerHour

local function SessionStart()
    if sessionActive and not sessionPaused then return end
    if not sessionPaused then
        ResetSession()
        session.zoneName  = RNPack.curZone or ""
        session.startTime = time()
    else
        session.startTime = time()
    end
    sessionActive = true; sessionPaused = false
    RNPack.sessionActive = true; RNPack.sessionPaused = false
    if RNPack.RegisterLootEvents then RNPack.RegisterLootEvents() end
    if RNPack.RefreshFarmStats   then RNPack.RefreshFarmStats() end
end

local function SessionPause()
    if not sessionActive or sessionPaused then return end
    session.duration = GetSessionDuration()
    session.startTime= nil
    sessionPaused=true; RNPack.sessionPaused=true
    if RNPack.UnregisterLootEvents then RNPack.UnregisterLootEvents() end
    if RNPack.RefreshFarmStats     then RNPack.RefreshFarmStats() end
end

local function SessionStop()
    if not sessionActive then return end
    session.duration = GetSessionDuration()
    session.startTime= nil
    sessionActive=false; sessionPaused=false
    RNPack.sessionActive=false; RNPack.sessionPaused=false
    if RNPack.DB then RNPack.DB.pendingSession = nil end  -- user ended session cleanly
    if RNPack.UnregisterLootEvents then RNPack.UnregisterLootEvents() end
    -- Save to history (max 15)
    local DB = RNPack.DB
    if DB then
        local top30={}
        for link,data in pairs(session.lootedItems or {}) do
            top30[#top30+1]={link=link,value=data[1],count=data[2]}
        end
        table.sort(top30,function(a,b)return a.value>b.value end)
        while #top30>30 do table.remove(top30) end
        table.insert(DB.sessions,1,{
            zone      = session.zoneName,
            duration  = session.duration,
            cash      = session.moneyCash,
            items     = session.moneyItems,
            quests    = session.moneyQuests,
            herbs     = session.countHerbs,
            mines     = session.countMines,
            itemCount = session.countItems,
            timestamp = time(),
            lootedItems = top30,
        })
        while #DB.sessions > 15 do table.remove(DB.sessions) end
    end
    if RNPack.RefreshFarmStats then RNPack.RefreshFarmStats() end
end

RNPack.SessionStart  = SessionStart
RNPack.SessionPause  = SessionPause
RNPack.SessionStop   = SessionStop

-- Save active session to DB so it survives logout/crash
local function SnapshotSession()
    local DB = RNPack.DB; if not DB then return end
    if not sessionActive then DB.pendingSession = nil; return end
    -- Pause first so duration is accurate
    if not sessionPaused then SessionPause() end
    local snap = {}
    for k, v in pairs(session) do
        if type(v) == "table" then
            snap[k] = {}
            for k2, v2 in pairs(v) do snap[k][k2] = v2 end
        else
            snap[k] = v
        end
    end
    snap.wasActive = true
    DB.pendingSession = snap
end

-- Restore a snapshotted session on login (shows as paused)
local function RestoreSession()
    local DB = RNPack.DB; if not DB or not DB.pendingSession then return end
    local snap = DB.pendingSession
    DB.pendingSession = nil
    CopyDefaults(session, DEFDATA)
    for k, v in pairs(snap) do
        if k ~= "wasActive" then
            if type(v) == "table" then
                session[k] = {}
                for k2, v2 in pairs(v) do session[k][k2] = v2 end
            else
                session[k] = v
            end
        end
    end
    session.startTime = nil  -- ensure paused state
    RNPack.session = session
    sessionActive = true;  RNPack.sessionActive = true
    sessionPaused = true;  RNPack.sessionPaused = true
end
RNPack.SnapshotSession = SnapshotSession
RNPack.RestoreSession  = RestoreSession

-- ============================================================
-- LOOT DETECTION
-- ============================================================
local function OnChatLoot(msg)
    local DB = RNPack.DB; if not DB then return end
    if not msg then return end
    local lc = string.lower(msg)
    if string.find(lc,"you create") or string.find(lc,"you have created") then return end
    if not string.find(lc,"you receive") then return end
    local link = string.match(msg, "|%x+|Hitem:[^|]+|h%[([^%]]+)%]|h|r")
    if not link then return end
    -- Find the actual hyperlink
    local fullLink = string.match(msg, "|c%x+|Hitem:[^|]+|h%[[^%]]+%]|h|r")
    if not fullLink then return end
    if DB.blacklist and DB.blacklist[fullLink] then return end
    local count = tonumber(string.match(msg,"x(%d+)") or "1") or 1
    local price,rarity = GetItemPrice(fullLink)
    if not rarity then return end
    if DB.trackQualities and DB.trackQualities[rarity]==false then return end
    local _,_,_,_,_,_,subType = GetItemInfo(fullLink)
    if subType then
        local stl = string.lower(subType)
        if string.find(stl,"herb") then
            session.countHerbs=(session.countHerbs or 0)+1
        elseif string.find(stl,"metal") or string.find(stl,"stone") then
            session.countMines=(session.countMines or 0)+1
        end
    end
    local val = price * count
    session.moneyItems=(session.moneyItems or 0)+val
    session.countItems=(session.countItems or 0)+count
    if not session.lootedItems then session.lootedItems={} end
    local existing = session.lootedItems[fullLink]
    if existing then
        session.lootedItems[fullLink]={existing[1]+val, existing[2]+count}
    else
        session.lootedItems[fullLink]={val,count}
    end
end

local function OnChatMoney(msg)
    if not msg then return end
    local gold=tonumber(string.match(msg,"(%d+) Gold")) or 0
    local silver=tonumber(string.match(msg,"(%d+) Silver")) or 0
    local copper=tonumber(string.match(msg,"(%d+) Copper")) or 0
    local total=(gold*10000)+(silver*100)+copper
    if total>0 then session.moneyCash=(session.moneyCash or 0)+total end
end

local function OnQuestTurnedIn(money)
    if money and money>0 then
        session.moneyQuests=(session.moneyQuests or 0)+money
    end
end

-- ============================================================
-- WAYPOINT ARROW  (verbatim from original working version)
-- Textures: Interface\AddOns\RealNibba.Pack\Images\Arrow.blp
--           Interface\AddOns\RealNibba.Pack\Images\Arrow-UP.blp
-- NOTE: Requires FULL game restart (not /reload) to load BLP files
-- Arrow.blp  : 108 frames, 9x12 grid, 56x42px each, 512x512
-- Arrow-UP.blp: 55 frames, 9x7 grid,  53x70px each, 512x512
-- ============================================================
local WP = {}
_G["RNPack_WP"] = WP

local ZONE_WIDTHS  = { [492]=6270.833, [495]=8493.75 }
local ZONE_HEIGHTS = { [492]=4181.25,  [495]=5662.5  }
local wpFrame, wpArrow, wpDistText, wpTitleText, wpTtaText
local wpTimer=0; local flashTimer=0; local flashOn=false
local lastDist=0; local ttaTimer=0; local showDown=false; local arrowCount=0

-- Confirmed path: Interface\AddOns\RealNibba.Pack\Images-- WoW accepts forward slashes as path separators
local ARROW_PATH = [[Interface\AddOns\RealNibba.Pack\Images\]]

local function GetArrowTexCoord(angle)
    -- Arrow.blp: 108 frames, 9 cols x 12 rows, 56x42px in 512x512
    local cell = math.floor(angle / twopi * 108 + 0.5) % 108
    local col  = cell % 9
    local row  = math.floor(cell / 9)
    return (col*56)/512, ((col+1)*56)/512, (row*42)/512, ((row+1)*42)/512
end

local function GetArrowUPTexCoord(cell)
    -- Arrow-UP.blp: 55 frames, 9 cols x 7 rows, 53x70px in 512x512
    local col = cell % 9
    local row = math.floor(cell / 9)
    return (col*53)/512, ((col+1)*53)/512, (row*70)/512, ((row+1)*70)/512
end

local function CreateWPArrow()
    wpFrame = CreateFrame("Frame","RNPackWPArrow",UIParent)
    wpFrame:SetSize(56,42)
    wpFrame:SetPoint("CENTER",UIParent,"CENTER",0,150)
    wpFrame:SetMovable(true); wpFrame:EnableMouse(true)
    wpFrame:RegisterForDrag("LeftButton")
    wpFrame:SetScript("OnDragStart",function() wpFrame:StartMoving() end)
    wpFrame:SetScript("OnDragStop", function() wpFrame:StopMovingOrSizing() end)
    wpFrame:SetClampedToScreen(true)
    wpFrame:SetFrameStrata("HIGH"); wpFrame:Hide()

    -- Arrow texture from RealNibba.Pack\Images\Arrow.blp
    wpArrow = wpFrame:CreateTexture(nil,"ARTWORK")
    wpArrow:SetTexture(ARROW_PATH.."Arrow.blp")  -- explicit extension
    wpArrow:SetSize(56,42)
    wpArrow:SetPoint("CENTER",wpFrame,"CENTER",0,0)
    wpArrow:SetTexCoord(0, 56/512, 0, 42/512)  -- first frame
    wpArrow:SetVertexColor(0.2,1.0,0.2,1)

    -- Text labels in a child frame below the arrow
    local tf = CreateFrame("Frame",nil,wpFrame)
    tf:SetPoint("TOP",wpFrame,"BOTTOM",0,0)
    tf:SetSize(120,52)

    wpTitleText = tf:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
    wpTitleText:SetPoint("TOP",tf,"TOP",0,0)
    wpTitleText:SetText("Start Point")

    wpDistText = tf:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
    wpDistText:SetPoint("TOP",wpTitleText,"BOTTOM",0,-1)
    wpDistText:SetTextColor(1,0.82,0,1)
    wpDistText:SetText("-- yds")

    wpTtaText = tf:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
    wpTtaText:SetPoint("TOP",wpDistText,"BOTTOM",0,-1)
    wpTtaText:SetTextColor(0.6,0.6,0.6,1)
    wpTtaText:SetText("***")
end

function WP.Show(mapID,startX,startY,routeName)
    if not wpFrame then CreateWPArrow() end
    WP.active=true  -- set so ticker knows arrow is live
    WP.mapID=mapID; WP.startX=startX; WP.startY=startY
    showDown=false; lastDist=0; ttaTimer=0; flashTimer=0; flashOn=false
    if wpTitleText then wpTitleText:SetText(routeName or "Start Point") end
    -- Set Arrow.blp as the active texture (explicit extension)
    wpArrow:SetTexture(ARROW_PATH.."Arrow.blp")
    wpArrow:SetSize(56,42); wpFrame:SetSize(56,42)
    wpArrow:SetVertexColor(0.2,1.0,0.2,1)
    wpFrame:Show()
end

function WP.Hide()
    WP.active=false
    if wpFrame then wpFrame:Hide() end
    WP.mapID = nil
end

function WP.Update(elapsed)
    if not wpFrame or not wpFrame:IsShown() then return end
    wpTimer=wpTimer+elapsed
    if wpTimer < WP_RATE then return end; wpTimer=0

    local px,py = GetPlayerMapPosition("player")
    if not px or (px==0 and py==0) then
        SafeSetMapToCurrentZone(); px,py=GetPlayerMapPosition("player")
        if not px or (px==0 and py==0) then return end
    end
    local mapID=GetCurrentMapAreaID()-1
    if mapID ~= WP.mapID then
        wpDistText:SetText("Wrong zone"); return
    end

    local zw=ZONE_WIDTHS[mapID] or 6000
    local zh=ZONE_HEIGHTS[mapID] or 4000
    local dx=(WP.startX-px)*zw
    local dy=(WP.startY-py)*zh
    local dist=math.sqrt(dx*dx+dy*dy)
    wpDistText:SetText(string.format("%d yds",math.floor(dist)))

    -- TTA
    ttaTimer=ttaTimer+WP_RATE
    if ttaTimer>=1.0 then
        local spd=(lastDist-dist)/ttaTimer
        if spd>0 then
            local eta=dist/spd
            wpTtaText:SetFormattedText("%01d:%02d",math.floor(eta/60),math.floor(eta%60))
        else
            wpTtaText:SetText("***")
        end
        lastDist=dist; ttaTimer=0
    end

    if dist <= ARRIVE_DIST then
        -- Arrival: switch to Arrow-UP.blp and animate
        if not showDown then
            wpArrow:SetTexture(ARROW_PATH.."Arrow-UP.blp")  -- explicit extension
            wpArrow:SetSize(53,70); wpFrame:SetSize(53,70)
            showDown=true
        end
        arrowCount=(arrowCount+1)%55
        wpArrow:SetTexCoord(GetArrowUPTexCoord(arrowCount))
        wpArrow:SetVertexColor(0.2,1.0,0.2,1)
        wpDistText:SetText("|cffff0000HERE!|r")
    else
        -- Direction: switch back to Arrow.blp if needed
        if showDown then
            wpArrow:SetTexture(ARROW_PATH.."Arrow")
            wpArrow:SetSize(56,42); wpFrame:SetSize(56,42)
            showDown=false
        end
        -- Astrolabe/TomTom exact angle formula
        local dir=math.atan2(dx,-dy)
        if dir>0 then dir=twopi-dir else dir=-dir end
        local angle=(dir-(GetPlayerFacing() or 0)+twopi)%twopi
        -- Color gradient: red=pointing away, green=pointing toward
        local perc=math.abs((math.pi-math.abs(angle-math.pi))/math.pi)
        wpArrow:SetVertexColor(1-perc*0.8, perc, 0, 1)
        wpArrow:SetTexCoord(GetArrowTexCoord(angle))
    end
end

-- ============================================================
-- BASE64 ROUTE ENCODER  (original 100% working version)
-- Format: RNR~name~rtype~mapID~base64data
-- ============================================================
local B64  = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local B64D = {}
for i=1,#B64 do B64D[B64:sub(i,i)] = i-1 end

local function ToBase64(s)
    local out={}; local bytes={string.byte(s,1,-1)}
    for i=1,#bytes,3 do
        local b1=bytes[i] or 0; local b2=bytes[i+1] or 0; local b3=bytes[i+2] or 0
        local n = b1*65536 + b2*256 + b3
        out[#out+1]=B64:sub(math.floor(n/262144)%64+1, math.floor(n/262144)%64+1)
        out[#out+1]=B64:sub(math.floor(n/4096)%64+1,   math.floor(n/4096)%64+1)
        out[#out+1]=(i+1<=#bytes) and B64:sub(math.floor(n/64)%64+1,math.floor(n/64)%64+1) or "="
        out[#out+1]=(i+2<=#bytes) and B64:sub(n%64+1,n%64+1) or "="
    end
    return table.concat(out)
end

local function FromBase64(s)
    local out={}
    for i=1,#s,4 do
        local c1=B64D[s:sub(i,i)] or 0; local c2=B64D[s:sub(i+1,i+1)] or 0
        local c3=B64D[s:sub(i+2,i+2)]; local c4=B64D[s:sub(i+3,i+3)]
        local n=c1*262144+c2*4096+(c3 or 0)*64+(c4 or 0)
        out[#out+1]=string.char(math.floor(n/65536)%256)
        if s:sub(i+2,i+2)~="=" then out[#out+1]=string.char(math.floor(n/256)%256) end
        if s:sub(i+3,i+3)~="=" then out[#out+1]=string.char(n%256) end
    end
    return table.concat(out)
end

local function WaypointsToBytes(pts)
    local out={}
    for _,p in ipairs(pts) do
        local xi=math.floor(math.max(0,math.min(1,p[1]))*MAX16+0.5)
        local yi=math.floor(math.max(0,math.min(1,p[2]))*MAX16+0.5)
        out[#out+1]=string.char(math.floor(xi/256),xi%256,math.floor(yi/256),yi%256)
    end
    return table.concat(out)
end

local function BytesToWaypoints(bytes)
    local pts={}
    for i=1,#bytes-3,4 do
        local b={string.byte(bytes,i,i+3)}
        pts[#pts+1]={(b[1]*256+b[2])/MAX16,(b[3]*256+b[4])/MAX16}
    end
    return pts
end

local RIBBON_LEVELS = {0.003, 0.0045, 0.006}
local RIBBON        = 0.006
RNPack.RIBBON_LEVELS = RIBBON_LEVELS

local function EncodeRoute(pts)
    local N=#pts; if N<2 then return nil,0 end
    local MITER=4.0
    local function pdist(p,a,b)
        local dx,dy=b[1]-a[1],b[2]-a[2]
        if dx==0 and dy==0 then return math.sqrt((p[1]-a[1])^2+(p[2]-a[2])^2) end
        return math.abs(dy*p[1]-dx*p[2]+b[1]*a[2]-b[2]*a[1])/math.sqrt(dx*dx+dy*dy)
    end
    local function rdp(t,eps)
        if #t<3 then return t end
        local dmax,idx=0,0
        for i=2,#t-1 do local d=pdist(t[i],t[1],t[#t]); if d>dmax then dmax,idx=d,i end end
        if dmax>eps then
            local l=rdp({unpack(t,1,idx)},eps); local r=rdp({unpack(t,idx,#t)},eps)
            local o={}; for _,v in ipairs(l) do o[#o+1]=v end
            for i=2,#r do o[#o+1]=r[i] end; return o
        end
        return {t[1],t[#t]}
    end
    local simp=rdp(pts,0.001); N=#simp; if N<2 then return nil,0 end
    local normals,dirs={},{}
    for i=1,N-1 do
        local dx=simp[i+1][1]-simp[i][1]; local dy=simp[i+1][2]-simp[i][2]
        local l=math.sqrt(dx*dx+dy*dy); if l<1e-9 then l=1e-9 end
        normals[i]={-dy/l*RIBBON, dx/l*RIBBON}; dirs[i]={dx/l,dy/l}
    end
    local function isect(p1,d1,p2,d2)
        local cross=d1[1]*d2[2]-d1[2]*d2[1]
        if math.abs(cross)<1e-10 then return nil end
        local dx,dy=p2[1]-p1[1],p2[2]-p1[2]; local t=(dx*d2[2]-dy*d2[1])/cross
        return {p1[1]+t*d1[1],p1[2]+t*d1[2]}
    end
    local lp,rp={},{}
    lp[1]={simp[1][1]+normals[1][1],simp[1][2]+normals[1][2]}
    rp[1]={simp[1][1]-normals[1][1],simp[1][2]-normals[1][2]}
    lp[N]={simp[N][1]+normals[N-1][1],simp[N][2]+normals[N-1][2]}
    rp[N]={simp[N][1]-normals[N-1][1],simp[N][2]-normals[N-1][2]}
    for i=2,N-1 do
        local p=simp[i]; local n1,n2=normals[i-1],normals[i]; local d1,d2=dirs[i-1],dirs[i]
        local p1l={p[1]+n1[1],p[2]+n1[2]}; local p2l={p[1]+n2[1],p[2]+n2[2]}
        local ml=isect(p1l,d1,p2l,d2)
        if ml and math.sqrt((ml[1]-p[1])^2+(ml[2]-p[2])^2)>RIBBON*MITER then ml={(p1l[1]+p2l[1])/2,(p1l[2]+p2l[2])/2} end
        if not ml then ml=p1l end
        local p1r={p[1]-n1[1],p[2]-n1[2]}; local p2r={p[1]-n2[1],p[2]-n2[2]}
        local mr=isect(p1r,{-d1[1],-d1[2]},p2r,{-d2[1],-d2[2]})
        if mr and math.sqrt((mr[1]-p[1])^2+(mr[2]-p[2])^2)>RIBBON*MITER then mr={(p1r[1]+p2r[1])/2,(p1r[2]+p2r[2])/2} end
        if not mr then mr=p1r end
        lp[i]=ml; rp[i]=mr
    end
    local function enc16(v)
        local n=math.floor(math.max(0,math.min(1,v))*MAX16+0.5)
        return string.char(math.floor(n/256),n%256)
    end
    local out={}
    for i=1,N-1 do
        local tl,bl,tr,br=lp[i],rp[i],lp[i+1],rp[i+1]
        out[#out+1]=enc16(tl[1])..enc16(tl[2])..enc16(bl[1])..enc16(bl[2])..enc16(tr[1])..enc16(tr[2])
        out[#out+1]=enc16(bl[1])..enc16(bl[2])..enc16(br[1])..enc16(br[2])..enc16(tr[1])..enc16(tr[2])
    end
    return table.concat(out),(N-1)*2
end

local function ExportString(route)
    if not route or not route.pts or #route.pts==0 then return nil end
    return "RNR~"..route.name.."~"..route.rtype.."~"..tostring(route.mapID).."~"..ToBase64(WaypointsToBytes(route.pts))
end

local function ImportString(str)
    str = string.gsub(str or "","^%s+",""); str=string.gsub(str,"%s+$","")
    if not string.find(str,"^RNR~") then return nil,"Not a RNR route string" end
    local _,_,name,rtype,mapID,b64=string.find(str,"^RNR~([^~]+)~([^~]+)~(%d+)~(.+)$")
    if not name then return nil,"Malformed string" end
    local wpBytes=FromBase64(b64)
    if #wpBytes==0 or #wpBytes%4~=0 then return nil,"Corrupted data" end
    local pts=BytesToWaypoints(wpBytes)
    if #pts<2 then return nil,"Too few points" end
    local data,tris=EncodeRoute(pts)
    if not data then return nil,"Encoding failed" end
    return {name=name,rtype=rtype,mapID=tonumber(mapID),data=data,tris=tris,pts=pts}
end

RNPack.ExportString  = ExportString
RNPack.ImportString  = ImportString
RNPack.EncodeRoute   = EncodeRoute

-- AceSerializer wrappers (kept for AceComm sharing - but ExportString is primary)
function RNPack:ExportRoute(route)
    return ExportString(route)
end
function RNPack:ImportRoute(str)
    return ImportString(str)
end

-- ============================================================
-- SYNC TO OVERLAY  (original working version)
-- ============================================================
local function SyncToOverlay()
    local DB=RNPack.DB; if not DB then return false end
    local Ov=_RealNibbaOverlay; if not Ov then return false end
    -- Clear our routes (NPCIDs >= 90000)
    for mapID,mapData in pairs(Ov.PathData or {}) do
        for npcID in pairs(mapData) do
            if npcID>=90000 then mapData[npcID]=nil end
        end
    end
    -- Clear old route name lookups
    Ov.RouteNames = Ov.RouteNames or {}
    for k in pairs(Ov.RouteNames) do Ov.RouteNames[k]=nil end
    Ov.NPCColors = Ov.NPCColors or {}
    Ov.Colors    = Ov.Colors    or {}
    local id=90000
    for _,route in pairs(DB.routes) do
        if DB.enabled[route.rtype] and route.data then
            Ov.PathData[route.mapID] = Ov.PathData[route.mapID] or {}
            Ov.PathData[route.mapID][id] = route.data
            Ov.RouteNames[id] = route.name
            local col=TYPE_COLOR[route.rtype] or {r=1,g=1,b=0}
            Ov.Colors[#Ov.Colors+1] = col
            Ov.NPCColors[id]        = #Ov.Colors
            if Ov.NPCAdd then Ov.NPCAdd(id) end
            id=id+1
        end
    end
    if Ov.Modules and Ov.Modules.UpdateMap then Ov.Modules.UpdateMap() end
    return true
end
RNPack.SyncToOverlay=SyncToOverlay

-- ============================================================
-- ROUTE RECORDER
-- ============================================================
local rec = {
    active=false, name="", rtype="Herbs", mapID=0,
    pts={}, lastX=nil, lastY=nil, timer=0, wpShown=false,
}
RNPack.rec = rec

local SAMPLE_RATE   = 0.5
local MIN_DIST_MAP  = 0.008

local function StartRecording(name, rtype)
    rec.active  = true
    rec.name    = name
    rec.rtype   = rtype or "Herbs"
    rec.pts     = {}
    rec.lastX   = nil
    rec.lastY   = nil
    rec.timer   = 0
    rec.wpShown = false
    SafeSetMapToCurrentZone()
    rec.mapID = GetCurrentMapAreaID() - 1
    -- FIX: show arrow immediately at current position so WP.Update can run.
    -- wpFrame:IsShown() must be true before the ticker's guard passes.
    if not wpFrame then CreateWPArrow() end
    local px, py = GetPlayerMapPosition("player")
    if not px or (px == 0 and py == 0) then
        SafeSetMapToCurrentZone(); px, py = GetPlayerMapPosition("player")
    end
    if px and not (px == 0 and py == 0) then
        WP.Show(rec.mapID, px, py, name)  -- show arrow NOW, not after first sample
        rec.wpShown = true
        rec.lastX, rec.lastY = px, py
        rec.pts[1] = {px, py}
    end
    prt("Recording: |cffffff00"..name.."|r  ("..rec.rtype..")")
end

local function StopRecording(thick)
    if not rec.active then return nil, "not recording" end
    rec.active = false
    WP.Hide()
    if #rec.pts < 2 then return nil, "need at least 2 points" end

    -- Auto-close loop if end is near start
    local function dist(a, b)
        local dx = (a[1]-b[1])*6000
        local dy = (a[2]-b[2])*4000
        return math.sqrt(dx*dx + dy*dy)
    end
    local start = rec.pts[1]
    local closeDist = dist(rec.pts[#rec.pts], start)
    if closeDist <= 75 then
        rec.pts[#rec.pts] = start
    end

    RIBBON = RIBBON_LEVELS[thick or 3]
    local data, tris = EncodeRoute(rec.pts)
    if not data then return nil, "encoding failed" end
    return {
        name      = rec.name,
        rtype     = rec.rtype,
        mapID     = rec.mapID,
        zone      = GetZoneText() or "",
        thick     = thick or 3,
        pts       = rec.pts,
        data      = data,
        tris      = tris,
    }
end

RNPack.StartRecording = StartRecording
RNPack.StopRecording  = StopRecording
RNPack.rec            = rec



-- ============================================================
-- UI HELPER FUNCTIONS  (shared by all window files)
-- ============================================================
local BACKDROP_DIALOG = {
    bgFile   = "Interface/DialogFrame/UI-DialogBox-Background",
    edgeFile = "Interface/DialogFrame/UI-DialogBox-Border",
    tile=true, tileSize=32, edgeSize=32,
    insets={left=11,right=12,top=12,bottom=11}
}
local BACKDROP_DARK = {
    bgFile="Interface/ChatFrame/ChatFrameBackground",
    edgeFile="Interface/Tooltips/UI-Tooltip-Border",
    tile=true, tileSize=16, edgeSize=16,
    insets={left=3,right=3,top=3,bottom=3}
}
RNPack.BACKDROP_DIALOG = BACKDROP_DIALOG
RNPack.BACKDROP_DARK   = BACKDROP_DARK

local RNPACK_BG = "Interface\\AddOns\\RealNibba.Pack\\Images\\Backdrop"
local function ApplyBG(f, flip)
    local raw = f.frame or f  -- accept both AceGUI widgets and raw frames
    raw:SetBackdropColor(0, 0, 0, 0)
    local t = raw:CreateTexture(nil, "BACKGROUND")
    t:SetTexture(RNPACK_BG)
    t:SetBlendMode("DISABLE")
    t:SetAllPoints(raw)
    if flip then t:SetTexCoord(0,1,1,0) end
end
RNPack.ApplyBG = ApplyBG

local function MakeWindow(name,title,w,h)
    local f=CreateFrame("Frame",name,UIParent)
    f:SetSize(w,h); f:SetPoint("CENTER")
    f:SetMovable(true); f:EnableMouse(true); f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart",function() f:StartMoving() end)
    f:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)
    f:SetClampedToScreen(true)
    -- No backdrop fill — BLP texture is the sole background
    f:SetBackdrop({
        edgeFile="Interface/Tooltips/UI-Tooltip-Border",
        edgeSize=16,
        insets={left=3,right=3,top=3,bottom=3}
    })
    f:SetBackdropBorderColor(0.4,0.35,0.25,0.9)
    local _bg = f:CreateTexture(nil, "BACKGROUND")
    _bg:SetTexture("Interface\\AddOns\\RealNibba.Pack\\Images\\Backdrop")
    _bg:SetBlendMode("DISABLE")
    _bg:SetAllPoints(f)
    f:Hide()
    -- Title inside window, no divider line
    local lbl=f:CreateFontString(nil,"OVERLAY","GameFontNormal")
    lbl:SetPoint("TOP",f,"TOP",0,-10); lbl:SetText(title)
    local x=CreateFrame("Button",nil,f,"UIPanelCloseButton")
    x.isCloseBtn=true
    x:SetPoint("TOPRIGHT",f,"TOPRIGHT",-4,-4)
    x:SetScript("OnClick",function() f:Hide() end)
    return f,lbl
end

local function MakeBtn(label,parent,w,h)
    local b=CreateFrame("Button",nil,parent,"UIPanelButtonTemplate")
    b:SetSize(w,h); b:SetText(label); return b
end

local function MakeLbl(text,parent,size,r,g,b)
    local t=parent:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
    if size and size~=10 then t:SetFont(t:GetFont(),size) end
    t:SetText(text or "")
    if r then t:SetTextColor(r,g,b,1) end
    return t
end

local function MakeDivider(parent,y,lx,rx)
    local t=parent:CreateTexture(nil,"ARTWORK"); t:SetHeight(1)
    t:SetPoint("TOPLEFT", parent,"TOPLEFT",  lx or 10, y)
    t:SetPoint("TOPRIGHT",parent,"TOPRIGHT",-(rx or 10),y)
    t:SetTexture(0.4,0.35,0.25,0.7); return t
end

local function MakeInput(name,parent,w,h)
    local b=CreateFrame("EditBox",name,parent,"InputBoxTemplate")
    b:SetSize(w,h or 20); b:SetAutoFocus(false); b:SetMaxLetters(0)
    b:SetScript("OnEscapePressed",function() b:ClearFocus() end); return b
end

RNPack.MakeWindow   = MakeWindow
RNPack.MakeBtn      = MakeBtn
RNPack.MakeLbl      = MakeLbl
RNPack.MakeDivider  = MakeDivider
RNPack.MakeInput    = MakeInput

-- ── Scale registry: any window calls P.RegisterScalableFrame(f) at creation
local scalableFrames = {}
local function RegisterScalableFrame(f)
    for _, sf in ipairs(scalableFrames) do if sf == f then return end end
    scalableFrames[#scalableFrames+1] = f
end
local function ApplyAddonScale(v)
    v = math.max(0.5, math.min(2.0, v))
    if RNPack.DB then RNPack.DB.addonScale = v end
    for _, f in ipairs(scalableFrames) do f:SetScale(v) end
    local ACD = LibStub and LibStub("AceConfigDialog-3.0", true)
    if ACD and ACD.OpenFrames and ACD.OpenFrames["RealNibba.Pack"] then
        local cf = ACD.OpenFrames["RealNibba.Pack"]
        if cf and cf.frame then cf.frame:SetScale(v) end
    end
end
RNPack.RegisterScalableFrame = RegisterScalableFrame
RNPack.ApplyAddonScale       = ApplyAddonScale

-- ============================================================
-- ACECOMM: Route sharing between players
-- ============================================================
local COMM_PREFIX = "RNPackRoute"

function RNPack:ShareRouteToGroup(route)
    local str = ExportString(route)
    if not str then self:Print("Error serializing route."); return end
    local dist = IsInRaid() and "RAID" or IsInGroup() and "PARTY" or nil
    if not dist then self:Print("You need to be in a group to share routes!"); return end
    self:SendCommMessage(COMM_PREFIX, str, dist)
    self:Print("|cffFFD700Shared:|r "..route.name.." → "..dist)
end

function RNPack:OnRouteReceived(prefix, message, distribution, sender)
    if prefix ~= COMM_PREFIX then return end
    local route, err = self:ImportRoute(message)
    if not route then return end
    if not self.DB then return end
    self.DB.routes[route.name] = route
    self:SyncToOverlay()
    if self.UI_Routes then self.UI_Routes.RefreshList() end
    self:Print(string.format("|cffFFD700%s|r shared route: |cff00FF44%s|r — imported!",
        sender, route.name))
end

-- ============================================================
-- ACECONFIG: Options table (Config window via AceConfigDialog)
-- ============================================================
local QUAL_NAMES  = {[0]="Grey",[1]="White",[2]="Green",[3]="Blue",[4]="Purple",[5]="Orange"}
local QUAL_HEX    = {[0]="ff999999",[1]="ffffffff",[2]="ff1eff00",
                     [3]="ff0070dd",[4]="ffa335ee",[5]="ffff8000"}
local PRICE_VALS  = {Vendor="Vendor Price", Atr="Auctionator", TSM="TSM Market"}

function RNPack:GetOptions()
    local function qname(i)
        return string.format("|c%s%s|r", QUAL_HEX[i], QUAL_NAMES[i])
    end

    -- Price source selects
    local priceArgs = {}
    for i=0,5 do
        local qi=i
        priceArgs["q"..i] = {
            type  = "select",
            name  = qname(i),
            order = i+1,
            values= PRICE_VALS,
            get   = function() return (self.DB and self.DB.priceByQuality[qi]) or "Vendor" end,
            set   = function(_,v)
                if self.DB then self.DB.priceByQuality[qi]=v end
            end,
        }
    end

    -- Track quality toggles
    local qualArgs = {}
    for i=0,5 do
        local qi=i
        qualArgs["q"..i] = {
            type  = "toggle",
            name  = qname(i),
            order = i+1,
            get   = function() return (self.DB and self.DB.trackQualities[qi]~=false) end,
            set   = function(_,v)
                if self.DB then self.DB.trackQualities[qi]=v end
            end,
        }
    end

    local profilesOpts = LibStub("AceDBOptions-3.0"):GetOptionsTable(self.db)
    profilesOpts.order = 3

    return {
        type        = "group",
        name        = "|cffCC2222RealNibba|r.Pack",
        childGroups = "tab",
        args = {
            -- ── Tab 1: Loot Settings ──────────────────────────────────────
            loot = {
                type  = "group",
                name  = "Loot Settings",
                order = 1,
                args  = {
                    priceSources = {
                        type   = "group",
                        name   = "Price Sources by Quality",
                        inline = true,
                        order  = 1,
                        args   = priceArgs,
                    },
                    spacer1 = {type="description", name="\n", order=2},
                    trackQualities = {
                        type   = "group",
                        name   = "Track Qualities",
                        inline = true,
                        order  = 3,
                        args   = qualArgs,
                    },
                },
            },
            -- ── Tab 2: Map Features ───────────────────────────────────────
            map = {
                type  = "group",
                name  = "Map Features",
                order = 2,
                args  = {
                    carboniteWarning = {
                        type  = "description",
                        name  = _rnp_carbonite
                            and "|cffff4444Carbonite detected — Player Trail and Node Flash are disabled.\nThese features use SetMapToCurrentZone which conflicts with Carbonite's world map.\nDisable Carbonite to use these features.|r\n"
                            or "",
                        order = 0,
                        fontSize = "medium",
                        hidden = function() return not _rnp_carbonite end,
                    },
                    -- ── Player Trail ──────────────────────────────────
                    trailGroup = {
                        type   = "group",
                        name   = _rnp_carbonite and "|cff888888Player Trail (Disabled)|r" or "|cffCC2222Player|r Trail",
                        inline = true,
                        order  = 1,
                        disabled = function() return _rnp_carbonite end,
                        args   = {
                            minimapEnabled = {
                                type  = "toggle",
                                name  = "Enable on Minimap",
                                desc  = "Draw the trail on the minimap as well.",
                                order = 1,
                                get   = function() return self.DB and self.DB.mapTrail.minimapEnabled end,
                                set   = function(_,v)
                                    if self.DB then
                                        self.DB.mapTrail.minimapEnabled = v
                                        if RNPack.MapTrail_Toggle then RNPack.MapTrail_Toggle(v) end
                                    end
                                end,
                            },
                            color = {
                                type     = "color",
                                name     = "Trail Color",
                                desc     = "Color of the trail dots.",
                                order    = 3,
                                hasAlpha = false,
                                get   = function()
                                    local c = self.DB and self.DB.mapTrail.color
                                    if c then return c.r, c.g, c.b end
                                    return 1, 0, 0
                                end,
                                set   = function(_,r,g,b)
                                    if self.DB then
                                        self.DB.mapTrail.color.r = r
                                        self.DB.mapTrail.color.g = g
                                        self.DB.mapTrail.color.b = b
                                        if RNPack.MapTrail_RefreshColors then RNPack.MapTrail_RefreshColors() end
                                    end
                                end,
                            },
                            dotCount = {
                                type  = "range",
                                name  = "Trail Length",
                                desc  = "Maximum number of trail dots kept in history.",
                                order = 4,
                                min   = 10, max = 500, step = 10,
                                get   = function() return (self.DB and self.DB.mapTrail.dotCount) or 100 end,
                                set   = function(_,v)
                                    if self.DB then self.DB.mapTrail.dotCount = v end
                                end,
                            },
                            fadeTime = {
                                type  = "range",
                                name  = "Fade Time (sec)",
                                desc  = "Time in seconds before a dot fully disappears.",
                                order = 5,
                                min   = 10, max = 300, step = 5,
                                get   = function() return (self.DB and self.DB.mapTrail.fadeTime) or 90 end,
                                set   = function(_,v)
                                    if self.DB then self.DB.mapTrail.fadeTime = v end
                                end,
                            },
                            dotSpacing = {
                                type  = "range",
                                name  = "Dot Spacing",
                                desc  = "Minimum distance moved before a new dot is recorded.",
                                order = 6,
                                min   = 0.5, max = 20.0, step = 0.5,
                                get   = function() return (self.DB and self.DB.mapTrail.dotSpacing) or 2.0 end,
                                set   = function(_,v)
                                    if self.DB then self.DB.mapTrail.dotSpacing = v end
                                end,
                            },
                            dotSize = {
                                type  = "range",
                                name  = "Dot Size",
                                desc  = "Visual size of each trail dot in pixels.",
                                order = 7,
                                min   = 4, max = 24, step = 1,
                                get   = function() return (self.DB and self.DB.mapTrail.dotSize) or 8 end,
                                set   = function(_,v)
                                    if self.DB then
                                        self.DB.mapTrail.dotSize = v
                                        if RNPack.MapTrail_RefreshSize then RNPack.MapTrail_RefreshSize() end
                                    end
                                end,
                            },
                            opacity = {
                                type  = "range",
                                name  = "Opacity",
                                desc  = "Maximum alpha of the trail dots.",
                                order = 8,
                                min   = 0.05, max = 1.0, step = 0.05,
                                get   = function() return (self.DB and self.DB.mapTrail.opacity) or 1.0 end,
                                set   = function(_,v)
                                    if self.DB then self.DB.mapTrail.opacity = v end
                                end,
                            },
                        },
                    },
                    spacer1 = {type="description", name="\n", order=2},
                    -- ── Minimap Node Flash ─────────────────────────────
                    flashGroup = {
                        type   = "group",
                        name   = _rnp_carbonite and "|cff888888Minimap Node Flash (Disabled)|r" or "|cffCC2222Minimap|r Node Flash",
                        inline = true,
                        order  = 3,
                        disabled = function() return _rnp_carbonite end,
                        args   = {
                            enabled = {
                                type  = "toggle",
                                name  = "Enable Flash",
                                desc  = "Alternate minimap node icons between normal and glowing.",
                                order = 1,
                                width = "full",
                                get   = function() return self.DB and self.DB.minimapFlash.enabled end,
                                set   = function(_,v)
                                    if self.DB then
                                        self.DB.minimapFlash.enabled = v
                                        if RNPack.MinimapFlash_Toggle then RNPack.MinimapFlash_Toggle(v) end
                                    end
                                end,
                            },
                            sessionOnly = {
                                type  = "toggle",
                                name  = "Session Only",
                                desc  = "Only flash while a farming session is active.",
                                order = 2,
                                get   = function() return self.DB and self.DB.minimapFlash.sessionOnly end,
                                set   = function(_,v)
                                    if self.DB then self.DB.minimapFlash.sessionOnly = v end
                                end,
                            },
                            flashSpeed = {
                                type      = "range",
                                name      = "Flash Speed (sec)",
                                desc      = "Interval between normal and glow icon swap.",
                                order     = 3,
                                min       = 0.1, max = 2.0, step = 0.05,
                                isPercent = false,
                                get   = function() return (self.DB and self.DB.minimapFlash.flashSpeed) or 0.4 end,
                                set   = function(_,v)
                                    if self.DB then
                                        self.DB.minimapFlash.flashSpeed = v
                                        if RNPack.MinimapFlash_Restart then RNPack.MinimapFlash_Restart() end
                                    end
                                end,
                            },
                        },
                    },
                    -- ── Auto-Tracking ─────────────────────────────────
                    autoTrackGroup = {
                        type   = "group",
                        name   = "Auto-Tracking",
                        inline = true,
                        order  = 3,
                        args   = {
                            enabled = {
                                type  = "toggle",
                                name  = "Enable Auto-Tracking Swap",
                                desc  = "Automatically alternate minimap tracking between Herbs and Minerals.",
                                order = 1,
                                width = "full",
                                get   = function() return self.DB and self.DB.autoTrack.enabled end,
                                set   = function(_,v)
                                    if self.DB then
                                        self.DB.autoTrack.enabled = v
                                        if RNPack.SetAutoTrack then RNPack.SetAutoTrack(v) end
                                    end
                                end,
                            },
                            interval = {
                                type      = "range",
                                name      = "Swap Interval (sec)",
                                desc      = "How often to alternate between Herb and Mine tracking.",
                                order     = 2,
                                min       = 0.5, max = 10.0, step = 0.5,
                                isPercent = false,
                                get   = function() return (self.DB and self.DB.autoTrack.interval) or 3.0 end,
                                set   = function(_,v)
                                    if self.DB then self.DB.autoTrack.interval = v end
                                end,
                            },
                        },
                    },
                },
            },
            -- ── Tab 3: Interface ──────────────────────────────────────────
            interface = {
                type  = "group",
                name  = "Interface",
                order = 3,
                args  = {
                    scaleGroup = {
                        type   = "group",
                        name   = "Window Scale",
                        inline = true,
                        order  = 1,
                        args   = {
                            scale = {
                                type      = "range",
                                name      = "UI Scale",
                                desc      = "Resize all RealNibba.Pack windows. 100% = default size.",
                                order     = 1,
                                width     = "full",
                                min       = 50, max = 200, step = 5,
                                isPercent = false,
                                get   = function()
                                    return math.floor(((self.DB and self.DB.addonScale) or 1.0) * 100 + 0.5)
                                end,
                                set   = function(_,v)
                                    -- Save only; ApplyAddonScale fires on OnMouseUp
                                    if RNPack.DB then RNPack.DB.addonScale = v / 100 end
                                end,
                            },
                        },
                    },
                },
            },
            -- ── Tab 4: Profiles ───────────────────────────────────────────
            profiles = profilesOpts,
        }
    }
end

-- ============================================================
-- AH / MAIL SCAN DATA
-- ============================================================
RNPack.ahPosted   = 0
RNPack.ahIncoming = 0
RNPack.mailGold   = 0

-- ============================================================
-- LOOT FRAME  (created at load time, events registered on session start)
-- ============================================================
local lootFrame = CreateFrame("Frame","RealNibbaPack_LootFrame",UIParent)
RNPack.lootFrame = lootFrame

local function RegisterLootEvents()
    lootFrame:RegisterEvent("CHAT_MSG_LOOT")
    lootFrame:RegisterEvent("CHAT_MSG_MONEY")
    lootFrame:RegisterEvent("QUEST_TURNED_IN")
end
local function UnregisterLootEvents()
    lootFrame:UnregisterEvent("CHAT_MSG_LOOT")
    lootFrame:UnregisterEvent("CHAT_MSG_MONEY")
    lootFrame:UnregisterEvent("QUEST_TURNED_IN")
end
RNPack.RegisterLootEvents   = RegisterLootEvents
RNPack.UnregisterLootEvents = UnregisterLootEvents

lootFrame:SetScript("OnEvent",function(self,event,...)
    if event=="CHAT_MSG_LOOT" then
        OnChatLoot(...)
        if RNPack.RefreshItemList then RNPack.RefreshItemList() end
    elseif event=="CHAT_MSG_MONEY" then
        OnChatMoney(...)
    elseif event=="QUEST_TURNED_IN" then
        local _,_,money=...; OnQuestTurnedIn(money)
    end
    if RNPack.RefreshFarmStats then RNPack.RefreshFarmStats() end
end)

-- ============================================================
-- TICKER  (WP arrow + session refresh)
-- ============================================================
local tickerFrame = CreateFrame("Frame")
local updateT = 0
tickerFrame:SetScript("OnUpdate",function(self,elapsed)
    WP.Update(elapsed)
    updateT=updateT+elapsed
    if updateT>=1.0 then
        updateT=0
        if RNPack.farmFrame and RNPack.farmFrame:IsShown() then
            if RNPack.RefreshFarmStats then RNPack.RefreshFarmStats() end
        end
    end
    if rec.active then
        rec.timer=rec.timer+elapsed
        if rec.timer>=SAMPLE_RATE then
            rec.timer=0
            local px,py=GetPlayerMapPosition("player")
            if not px or (px==0 and py==0) then
                SafeSetMapToCurrentZone(); px,py=GetPlayerMapPosition("player")
            end
            if px and not(px==0 and py==0) then
                local curMap=GetCurrentMapAreaID()-1
                if curMap==rec.mapID then
                    if not rec.lastX then
                        rec.lastX,rec.lastY=px,py; rec.pts[1]={px,py}
                    else
                        local dx=px-rec.lastX; local dy=py-rec.lastY
                        if math.sqrt(dx*dx+dy*dy)>=MIN_DIST_MAP then
                            rec.pts[#rec.pts+1]={px,py}
                            rec.lastX,rec.lastY=px,py
                        end
                    end
                    if RNPack.recStatusLbl then
                        RNPack.recStatusLbl:SetText(
                            "|cff00FF44Recording...|r  "..#rec.pts.." pts")
                    end
                end
            end
        end
    end
end)

-- ============================================================
-- OnInitialize  (AceAddon: called after ADDON_LOADED, SVs available)
-- ============================================================
function RNPack:OnInitialize()
    -- AceDB: creates/loads SavedVariables with profile support
    self.db = LibStub("AceDB-3.0"):New("RealNibbaPack_DB", DEFAULTS, true)
    self.DB = self.db.profile      -- alias for backward compat

    -- Profile change callbacks
    self.db.RegisterCallback(self,"OnProfileChanged","SyncDB")
    self.db.RegisterCallback(self,"OnProfileCopied", "SyncDB")
    self.db.RegisterCallback(self,"OnProfileReset",  "SyncDB")

    -- Init session
    ResetSession()
    self.session = session

    -- AceConfig: register options table
    local AC  = LibStub("AceConfig-3.0")
    local ACD = LibStub("AceConfigDialog-3.0")
    self.GetOptions = RNPack.GetOptions  -- ensure method is on self
    AC:RegisterOptionsTable("RealNibba.Pack", function() return self:GetOptions() end)
    ACD:SetDefaultSize("RealNibba.Pack", 460, 560)

    -- AceComm: route sharing
    self:RegisterComm(COMM_PREFIX, "OnRouteReceived")

    -- Slash commands (AceConsole)
    self:RegisterChatCommand("rnpack",         "ToggleMainWindow")
    self:RegisterChatCommand("realnibbapack",  "ToggleMainWindow")
    self:RegisterChatCommand("rnconfig",       "OpenConfig")
    self:RegisterChatCommand("rnpackconfig",   "OpenConfig")
end

function RNPack:SyncDB()
    self.DB = self.db.profile
    if self.RefreshFarmStats then self:RefreshFarmStats() end
    if self.RefreshItemList  then self:RefreshItemList() end
    if self.UI_Routes        then self.UI_Routes.RefreshList() end
end

-- ============================================================
-- OnEnable  (AceAddon: called on PLAYER_LOGIN)
-- ============================================================
function RNPack:OnEnable()
    -- AceEvent: clean event registration
    self:RegisterEvent("PLAYER_ENTERING_WORLD",   "OnEnteringWorld")
    self:RegisterEvent("ZONE_CHANGED_NEW_AREA",   "OnZoneChanged")
    self:RegisterEvent("ZONE_CHANGED",            "OnZoneChanged")
    self:RegisterEvent("PLAYER_STARTED_MOVING",   "OnPlayerMoving")
    self:RegisterEvent("PLAYER_LOGOUT",           "OnLogout")
    self:RegisterEvent("PLAYER_LEAVING_WORLD",    "OnLogout")

    -- Overlay init
    if _RealNibbaOverlay then
        _RealNibbaOverlay.Init()
        if self.DB and next(self.DB.routes) then
            -- Back-fill zone name for routes recorded before the zone field existed
            local Ov = _RealNibbaOverlay
            for _, route in pairs(self.DB.routes) do
                if (not route.zone or route.zone == "") and route.mapID and Ov.GetZoneName then
                    route.zone = Ov.GetZoneName(route.mapID) or ""
                end
            end
            SyncToOverlay()
        end
    end

    -- Build arrow frame at login time
    CreateWPArrow()

    -- Zone
    SafeSetMapToCurrentZone()
    local z=GetZoneText() or ""
    self.curZone = z

    -- Minimap button
    if self.CreateMinimapButton then self.CreateMinimapButton() end

    -- Map Features (Trail + Minimap Flash)
    if self.MapFeatures_Init then self:MapFeatures_Init() end

    -- Start at maximum zoom-out (zoom 0 = largest visible radius)
    Minimap:SetZoom(0)

    -- Auto-Tracking: restore enabled state from DB
    if self.DB and self.DB.autoTrack and self.DB.autoTrack.enabled then
        if RNPack.SetAutoTrack then RNPack.SetAutoTrack(true) end
    end

    -- Scale: apply saved scale to all registered frames
    if self.DB and self.DB.addonScale and self.DB.addonScale ~= 1.0 then
        ApplyAddonScale(self.DB.addonScale)
    end

    -- Session recovery: restore a session that was interrupted by logout/crash
    if self.DB and self.DB.pendingSession then
        RestoreSession()
        if self.RefreshFarmStats then self.RefreshFarmStats() end
        self:Print("|cffFFD700Session recovered!|r Your previous session has been restored as |cffFF8800PAUSED|r — press Start to continue.")
    end

    -- Retry zone after 2s (GetZoneText sometimes empty on login)
    local zw=0
    local zr=CreateFrame("Frame")
    zr:SetScript("OnUpdate",function(s,e)
        zw=zw+e
        if zw>=2 then
            s:SetScript("OnUpdate",nil)
            local zz=GetZoneText() or ""
            if zz~="" then self.curZone=zz end
            if self.UpdateZoneLabel  then self.UpdateZoneLabel() end
            if self.RefreshFarmStats then self.RefreshFarmStats() end
        end
    end)

    if self.UpdateZoneLabel  then self.UpdateZoneLabel() end
    if self.RefreshFarmStats then self.RefreshFarmStats() end
    self:Print("|cffFFD700v"..ADDON_VER.."|r loaded — /rnpack to open  |cff888888/rnconfig for settings|r")
end

-- ============================================================
-- AceEvent Handlers
-- ============================================================
function RNPack:OnEnteringWorld()
    SafeSetMapToCurrentZone()
    local z=GetZoneText() or ""
    if z~="" then self.curZone=z end
    local _,instType=IsInInstance()
    if self.instanceLog and (instType=="raid" or instType=="party") then
        local name=GetInstanceInfo() or self.curZone
        self.instanceLog[name]=(self.instanceLog[name] or 0)+1
    end
    if self.RefreshFarmStats then self.RefreshFarmStats() end
end

function RNPack:OnZoneChanged()
    SafeSetMapToCurrentZone()
    local z=GetZoneText() or ""
    if z~="" then self.curZone=z end
    -- Update zone label even when no session is active
    if self.UpdateZoneLabel then self.UpdateZoneLabel() end
    if self.RefreshFarmStats then self.RefreshFarmStats() end
    if self.UI_Routes then self.UI_Routes.RefreshList() end
end

function RNPack:OnLogout()
    SnapshotSession()
end

function RNPack:OnPlayerMoving()
    for _,name in ipairs({"RNPackRouteNameInput","RNPackImportBox",
                           "RNPackExportBox"}) do
        local box=_G[name]; if box and box.ClearFocus then box:ClearFocus() end
    end
    if self.itemDropdown then self.itemDropdown:Hide() end
end

-- ============================================================
-- Slash command handlers
-- ============================================================
function RNPack:ToggleMainWindow()
    local f=self.farmFrame
    if f then
        if f:IsShown() then f:Hide()
        else
            f:Show()
            if self.RefreshFarmStats then self.RefreshFarmStats() end
            if self.RefreshItemList  then self.RefreshItemList() end
        end
    end
end

function RNPack:OpenConfig()
    local ACD = LibStub("AceConfigDialog-3.0")
    ACD:Open("RealNibba.Pack")
    local w = ACD.OpenFrames["RealNibba.Pack"]
    if not w then return end

    -- Lock dimensions in the status table so ApplyStatus always restores 460×560
    local status = w.status or w.localstatus
    status.width  = 460
    status.height = 560

    -- Apply via AceGUI widget methods (this also resizes the content frame)
    w:SetWidth(460)
    w:SetHeight(560)

    -- Prevent WoW-level resizing and hide the three sizer grips
    w.frame:SetResizable(false)
    w:EnableResize(false)
end
