unjustifiedPointsWindow = nil
unjustifiedPointsButton = nil
contentsPanel = nil

-- Absolute UNIX timestamp at which the current skull (white OR red) expires.
-- Sent by the server as extended opcode 89 alongside every UnjustifiedStats
-- packet (0xB7). The 0xB7 packet only carries day-precision; this lets us
-- show precise "Xh Ym" / "Xm Ys" / "Xs" countdowns. 0 = no skull.
local skullExpiresAt = 0
-- Handle for the 1-second self-refresh event so we can cancel on window
-- close / offline. Gated on window-open AND active skull so it stays idle
-- for players with no skull or a closed Frags window.
local refreshEvent = nil

openPvpSituationsLabel = nil
currentSkullWidget = nil
skullTimeLabel = nil

dayProgressBar = nil
weekProgressBar = nil
monthProgressBar = nil

dayProgressBarBackground = nil
weekProgressBarBackground = nil
monthProgressBarBackground = nil

daySkullWidget = nil
weekSkullWidget = nil
monthSkullWidget = nil

function init()
    connect(g_game, {
        onGameStart = online,
        onGameEnd = offline,
        onUnjustifiedPointsChange = onUnjustifiedPointsChange,
        onOpenPvpSituationsChange = onOpenPvpSituationsChange,
        onAttackingCreatureChange = onAttack
    })
    connect(LocalPlayer, {
        onSkullChange = onSkullChange
    })

    unjustifiedPointsWindow = g_ui.loadUI('unjustifiedpoints')
    unjustifiedPointsWindow:disableResize()
    unjustifiedPointsWindow:setup()

    -- Vexia: companion extended opcode for sub-day skull-expiry precision.
    -- Carries the absolute UNIX timestamp for whichever skull is currently
    -- active (white or red). Server sends 0 when no skull is active.
    ProtocolGame.registerExtendedOpcode(89, function(_, _, buffer)
        skullExpiresAt = tonumber(buffer) or 0
        local pts = g_game.getUnjustifiedPoints()
        if pts then
            onUnjustifiedPointsChange(pts)
        end
        -- Start/stop the 1s tick based on whether a skull is now active.
        -- Guards against running a tick for players with no skull.
        if isWindowOpen() then
            if skullExpiresAt > os.time() then
                startSkullTimeRefresh()
            else
                stopSkullTimeRefresh()
            end
        end
    end)

    contentsPanel = unjustifiedPointsWindow:getChildById('contentsPanel')

    -- Set the title and icon in the header elements
    local titleWidget = unjustifiedPointsWindow:getChildById('miniwindowTitle')
    if titleWidget then
        titleWidget:setText('Frags')
    else
        -- Fallback to old method if miniwindowTitle doesn't exist
        unjustifiedPointsWindow:setText('Frags')
    end

    local iconWidget = unjustifiedPointsWindow:getChildById('miniwindowIcon')
    if iconWidget then
        iconWidget:setImageSource('/images/icons/icon-unjustified-points-widget')
    end

    openPvpSituationsLabel = contentsPanel:getChildById('openPvpSituationsLabel')
    currentSkullWidget = contentsPanel:getChildById('currentSkullWidget')
    skullTimeLabel = contentsPanel:getChildById('skullTimeLabel')

    dayProgressBar = contentsPanel:getChildById('dayProgressBar')
    weekProgressBar = contentsPanel:getChildById('weekProgressBar')
    monthProgressBar = contentsPanel:getChildById('monthProgressBar')
    
    dayProgressBarBackground = contentsPanel:getChildById('dayProgressBarBackground')
    weekProgressBarBackground = contentsPanel:getChildById('weekProgressBarBackground')
    monthProgressBarBackground = contentsPanel:getChildById('monthProgressBarBackground')
    
    daySkullWidget = contentsPanel:getChildById('daySkullWidget')
    weekSkullWidget = contentsPanel:getChildById('weekSkullWidget')
    monthSkullWidget = contentsPanel:getChildById('monthSkullWidget')

    -- Hide buttons as requested
    local toggleFilterButton = unjustifiedPointsWindow:recursiveGetChildById('toggleFilterButton')
    if toggleFilterButton then
        toggleFilterButton:setVisible(false)
    end
    
    local contextMenuButton = unjustifiedPointsWindow:recursiveGetChildById('contextMenuButton')
    if contextMenuButton then
        contextMenuButton:setVisible(false)
    end
    
    local newWindowButton = unjustifiedPointsWindow:recursiveGetChildById('newWindowButton')
    if newWindowButton then
        newWindowButton:setVisible(false)
    end

    -- Position lockButton where toggleFilterButton was (to the left of minimize button)
    local lockButton = unjustifiedPointsWindow:recursiveGetChildById('lockButton')
    local minimizeButton = unjustifiedPointsWindow:recursiveGetChildById('minimizeButton')
    
    if lockButton and minimizeButton then
        lockButton:breakAnchors()
        lockButton:addAnchor(AnchorTop, minimizeButton:getId(), AnchorTop)
        lockButton:addAnchor(AnchorRight, minimizeButton:getId(), AnchorLeft)
        lockButton:setMarginRight(7)  -- Same margin as toggleFilterButton had
        lockButton:setMarginTop(0)
    end

    if unjustifiedPointsButton then
        unjustifiedPointsButton:setOn(true)
    end

    if g_game.isOnline() then
        online()
    end
end

function terminate()
    disconnect(g_game, {
        onGameStart = online,
        onGameEnd = offline,
        onUnjustifiedPointsChange = onUnjustifiedPointsChange,
        onOpenPvpSituationsChange = onOpenPvpSituationsChange,
        onAttackingCreatureChange = onOpenPvpSituationsChange
    })
    disconnect(LocalPlayer, {
        onSkullChange = onSkullChange
    })

    ProtocolGame.unregisterExtendedOpcode(89)
    stopSkullTimeRefresh()

    unjustifiedPointsWindow:destroy()
    if unjustifiedPointsButton then
        unjustifiedPointsButton:destroy()
        unjustifiedPointsButton = nil
    end
end

function onMiniWindowOpen()
    if unjustifiedPointsButton then
        unjustifiedPointsButton:setOn(true)
    end
    -- Kick an immediate recompute so the label isn't stale from a long
    -- closed period, then start the 1s countdown tick.
    local pts = g_game.getUnjustifiedPoints()
    if pts then
        onUnjustifiedPointsChange(pts)
    end
    startSkullTimeRefresh()
end

function onMiniWindowClose()
    if unjustifiedPointsButton then
        unjustifiedPointsButton:setOn(false)
    end
    -- Stop ticking while closed — no point updating an invisible label.
    stopSkullTimeRefresh()
end

function isWindowOpen()
    -- Ephemeral close() only detaches from parent without flipping visibility,
    -- so we treat "no parent" as closed regardless of the visibility flag.
    if unjustifiedPointsWindow and unjustifiedPointsWindow:getParent() then
        return unjustifiedPointsWindow:isVisible()
    end
    return false
end

function toggle()
    if unjustifiedPointsButton and unjustifiedPointsButton:isOn() then
        unjustifiedPointsWindow:close()
        unjustifiedPointsButton:setOn(false)
    else
        if not unjustifiedPointsWindow:getParent() then
            -- Pass savedParentId so the window restores into its previous panel
            -- (e.g. the gameLeftExtraPanel) instead of always going to the default
            -- right panel after a logout/login cycle.
            local panel = modules.game_interface.findContentPanelAvailable(
                unjustifiedPointsWindow,
                unjustifiedPointsWindow:getMinimumHeight(),
                unjustifiedPointsWindow.savedParentId)
            unjustifiedPointsWindow.savedParentId = nil
            if not panel then
                return
            end

            panel:addChild(unjustifiedPointsWindow)
        end
        unjustifiedPointsWindow:open()
        if unjustifiedPointsButton then
            unjustifiedPointsButton:setOn(true)
        end
    end
end

function online()
    -- Vexia: force-enable the feature here so the panel works regardless of
    -- whether features.lua's onClientVersionChange handler ran first.
    g_game.enableFeature(GameUnjustifiedPoints)

    if g_game.getFeature(GameUnjustifiedPoints) then
        unjustifiedPointsWindow:setupOnStart() -- load saved open/closed state

        if not unjustifiedPointsButton then
            local invMod = modules.game_inventory
            if invMod and invMod.inventoryController and invMod.inventoryController.ui then
                unjustifiedPointsButton = invMod.inventoryController.ui:recursiveGetChildById('buttonFrags')
            end
        end

        -- Ephemeral window: setupOnStart detached + hid us. Reopen if it was
        -- open on logout. We intentionally leave `savedParentId` on the widget
        -- so toggle() picks it up via findContentPanelAvailable and re-attaches
        -- to the same panel (extra panel, etc.) instead of the default right.
        if unjustifiedPointsWindow.wasOpenOnLogout then
            local savedIndex = unjustifiedPointsWindow.savedIndex or 999
            unjustifiedPointsWindow.wasOpenOnLogout = nil
            unjustifiedPointsWindow.savedIndex = nil
            local savedHeight = unjustifiedPointsWindow.savedHeight
            unjustifiedPointsWindow.savedHeight = nil
            local delay = savedIndex * 50 + 50
            scheduleEvent(function()
                if not unjustifiedPointsWindow:isExplicitlyVisible() then
                    if unjustifiedPointsButton then
                        unjustifiedPointsButton:setOn(false)
                    end
                    toggle()
                    if savedHeight then
                        unjustifiedPointsWindow:setHeight(savedHeight)
                    end
                end
            end, delay)
        elseif unjustifiedPointsButton then
            unjustifiedPointsButton:setOn(false)
        end
    end

    refresh()
    -- Tick lifecycle is owned by onMiniWindowOpen / onMiniWindowClose. If the
    -- window is restored from the logout-state above, onMiniWindowOpen will
    -- fire shortly after via the scheduled toggle() and start the tick there.
end

local function tickSkullTime()
    -- Self-terminate if the skull has expired — avoids a live tick on a
    -- player with no skull (e.g. canvas farewell just completed).
    if skullExpiresAt <= os.time() then
        refreshEvent = nil
        return
    end
    local pts = g_game.getUnjustifiedPoints()
    if pts then
        onUnjustifiedPointsChange(pts)
    end
    refreshEvent = scheduleEvent(tickSkullTime, 1000)
end

function startSkullTimeRefresh()
    if refreshEvent then
        return
    end
    -- No tick for players without an active skull — keeps the module idle
    -- when it has nothing to count down.
    if skullExpiresAt <= os.time() then
        return
    end
    refreshEvent = scheduleEvent(tickSkullTime, 1000)
end

function stopSkullTimeRefresh()
    if refreshEvent then
        removeEvent(refreshEvent)
        refreshEvent = nil
    end
end

function offline()
    stopSkullTimeRefresh()
    if g_game.getFeature(GameUnjustifiedPoints) then
        unjustifiedPointsWindow:setParent(nil, true)
    end
end

function refresh()
    local localPlayer = g_game.getLocalPlayer()

    local unjustifiedPoints = g_game.getUnjustifiedPoints()
    onUnjustifiedPointsChange(unjustifiedPoints)

    onSkullChange(localPlayer, localPlayer:getSkull())
    onOpenPvpSituationsChange(g_game.getOpenPvpSituations())
end

function onSkullChange(localPlayer, skull)
    if not localPlayer:isLocalPlayer() then
        return
    end

    if skull == SkullWhite or skull == SkullRed or skull == SkullBlack then
        currentSkullWidget:setIcon(getSkullImagePath(skull))
        currentSkullWidget:setTooltip('Remaining skull time')
    else
        currentSkullWidget:setIcon('')
        currentSkullWidget:setTooltip('You have no skull')
    end

    daySkullWidget:setIcon(getSkullImagePath(getNextSkullId(skull)))
    weekSkullWidget:setIcon(getSkullImagePath(getNextSkullId(skull)))
    monthSkullWidget:setIcon(getSkullImagePath(getNextSkullId(skull)))

    -- Skull transition changes what the countdown label should show — kick
    -- an immediate recompute instead of waiting up to 1s for the next tick.
    local pts = g_game.getUnjustifiedPoints()
    if pts then
        onUnjustifiedPointsChange(pts)
    end
end

function onOpenPvpSituationsChange(amount)
    -- This shows the actual open PvP situations count
    local validAmount = tonumber(amount) or 0
    openPvpSituationsLabel:setText('Open: ' .. validAmount)
end

local function getImageByKills(kills, maxKills, period)
    if period == 'day' then
        -- Day: 1/3 or 2/6 of max = green, 2/3 or 4/6 of max = orange, 3/3 or 6/6 of max = red
        if kills <= maxKills / 3 then
            return '/images/ui/unjustified-points-bar-texture-green'
        elseif kills <= maxKills * 2 / 3 then
            return '/images/ui/unjustified-points-bar-texture-yellow'
        else
            return '/images/ui/unjustified-points-bar-texture-red'
        end
    elseif period == 'week' then
        -- Week: up to 2/5 or 4/10 of max = green, 3/5 or 6/10 of max = orange, 4-5/5 or 8-10/10 of max = red
        if kills <= math.floor(maxKills * 2 / 5) then
            return '/images/ui/unjustified-points-bar-texture-green'
        elseif kills <= math.floor(maxKills * 3 / 5) then
            return '/images/ui/unjustified-points-bar-texture-yellow'
        else
            return '/images/ui/unjustified-points-bar-texture-red'
        end
    elseif period == 'month' then
        -- Month: up to 4/10 or 8/20 of max = green, 5-8/10 or 10-16/20 = orange, 9-10/10 or 17-20/20 = red
        if kills <= math.floor(maxKills * 4 / 10) then
            return '/images/ui/unjustified-points-bar-texture-green'
        elseif kills <= math.floor(maxKills * 8 / 10) then
            return '/images/ui/unjustified-points-bar-texture-yellow'
        else
            return '/images/ui/unjustified-points-bar-texture-red'
        end
    end
    
    -- Fallback to green
    return '/images/ui/unjustified-points-bar-texture-green'
end

local function setProgressBarImage(progressBar, progressBarBackground, currentKills, maxKills, tooltip, period)
    -- Set tooltip on both progress bar and background so it's always visible
    progressBar:setTooltip(tooltip)
    if progressBarBackground then
        progressBarBackground:setTooltip(tooltip)
    end
    
    -- If no kills, don't show any colored fill
    if currentKills == 0 then
        progressBar:setImageSource('')
        progressBar:setVisible(false)
        return
    end
    
    progressBar:setVisible(true)
    
    -- Calculate the percentage and set the progress bar width accordingly
    local percentage = currentKills / maxKills
    local backgroundWidth = progressBarBackground:getWidth()
    local foregroundWidth = math.floor(backgroundWidth * percentage)
    
    -- Break current anchors and set fixed width
    progressBar:breakAnchors()
    progressBar:addAnchor(AnchorTop, progressBarBackground:getId(), AnchorTop)
    progressBar:addAnchor(AnchorLeft, progressBarBackground:getId(), AnchorLeft)
    progressBar:setWidth(foregroundWidth)
    progressBar:setHeight(progressBarBackground:getHeight())
    
    -- Get the appropriate colored image for the fill based on period and maxKills
    local imagePath = getImageByKills(currentKills, maxKills, period)
    
    -- Set the foreground (fill) image
    progressBar:setImageSource(imagePath)
    progressBar:setImageBorder(1)
    progressBar:setImageBorderTop(1)
    progressBar:setImageBorderBottom(1)
end

function onUnjustifiedPointsChange(unjustifiedPoints)
    if unjustifiedPoints.skullTime == 0 then
        skullTimeLabel:setText('')
        skullTimeLabel:setTooltip('')
        skullTimeLabel:setVisible(false)
    else
        skullTimeLabel:setVisible(true)
        -- Compact two-unit format, picked by magnitude. Pairs with the 1s
        -- tick so the seconds digit is live for short-lived skulls (60s PZ
        -- lock) while longer ones stay minute-precise.
        local remainingSeconds = (skullExpiresAt or 0) - os.time()
        local label
        if remainingSeconds >= 86400 then
            local days = math.floor(remainingSeconds / 86400)
            local minutes = math.floor((remainingSeconds % 3600) / 60)
            label = string.format('%dd %dm', days, minutes)
        elseif remainingSeconds >= 3600 then
            local hours = math.floor(remainingSeconds / 3600)
            local minutes = math.floor((remainingSeconds % 3600) / 60)
            label = string.format('%dh %dm', hours, minutes)
        elseif remainingSeconds >= 60 then
            local minutes = math.floor(remainingSeconds / 60)
            local seconds = remainingSeconds % 60
            label = string.format('%dm %ds', minutes, seconds)
        elseif remainingSeconds > 0 then
            label = string.format('%ds', remainingSeconds)
        else
            label = unjustifiedPoints.skullTime .. (unjustifiedPoints.skullTime == 1 and ' day' or ' days')
        end
        skullTimeLabel:setText(label)
        skullTimeLabel:setTooltip('Remaining skull time')
    end

    -- Check if player has red skull to determine max kill thresholds
    local localPlayer = g_game.getLocalPlayer()
    local hasRedBlackSkull = localPlayer and localPlayer:getSkull() == SkullRed or localPlayer:getSkull() == SkullBlack

    -- Set base thresholds: 3 daily, 5 weekly, 10 monthly for red skull
    -- Double these amounts (6, 10, 20) for black skull when player already has red skull
    local maxDayKills = hasRedBlackSkull and 6 or 3
    local maxWeekKills = hasRedBlackSkull and 10 or 5
    local maxMonthKills = hasRedBlackSkull and 20 or 10

    -- Calculate actual kills based on remaining kills vs max kills.
    -- If the server hasn't sent the UnjustifiedStats packet yet, all fields are
    -- zero (struct default). In that state we can't distinguish "fresh, no
    -- kills" from "max kills hit" using `remaining` alone, so we treat
    -- "killsDay% == 0 AND remaining == 0" as no data and default to 0 kills.
    local function computeKills(percent, remaining, maxKills)
        local pct = percent or 0
        local rem = remaining or 0
        if pct == 0 and rem == 0 then
            return 0  -- no server data yet
        end
        return math.max(0, maxKills - rem)
    end
    local actualDayKills   = computeKills(unjustifiedPoints.killsDay,   unjustifiedPoints.killsDayRemaining,   maxDayKills)
    local actualWeekKills  = computeKills(unjustifiedPoints.killsWeek,  unjustifiedPoints.killsWeekRemaining,  maxWeekKills)
    local actualMonthKills = computeKills(unjustifiedPoints.killsMonth, unjustifiedPoints.killsMonthRemaining, maxMonthKills)

    -- The next milestone after a frag is the red skull until the player has
    -- one; once red-skulled the next milestone is the auto-ban.
    local nextMilestone = hasRedBlackSkull and 'banned' or 'red skull'

    local function formatTooltip(window, count, max)
        local left = math.max(0, max - count)
        return string.format('Unjustified kills in the last %s: %d / %d.\n%d kill%s before %s.',
                             window, count, max, left, (left == 1 and '' or 's'), nextMilestone)
    end

    setProgressBarImage(dayProgressBar,   dayProgressBarBackground,   actualDayKills,   maxDayKills,   formatTooltip('24 hours', actualDayKills,   maxDayKills),   'day')
    setProgressBarImage(weekProgressBar,  weekProgressBarBackground,  actualWeekKills,  maxWeekKills,  formatTooltip('7 days',   actualWeekKills,  maxWeekKills),  'week')
    setProgressBarImage(monthProgressBar, monthProgressBarBackground, actualMonthKills, maxMonthKills, formatTooltip('30 days',  actualMonthKills, maxMonthKills), 'month')
end
