-- =====================================================================
-- Arcfall Party Status client - HP/MP HUD + minimap flags for party.
--
-- Listens on extended opcode PartyStatus.opcode (180). Server pushes a
-- party_update payload every 2 seconds; we redraw rows + refresh minimap
-- flags. On party_ended / no_party we clear everything.
--
-- Sandboxed module per partystatus.otmod.
-- =====================================================================

PartyStatus = {}
PartyStatus.opcode = 180

local partyWindow            = nil
local partyButton            = nil
local partyStatusPanel       = nil   -- the container we add member rows to
local emptyLabel             = nil   -- "Not in a party" placeholder

-- Last-known set of minimap flags we created, keyed by member id.
-- Each entry: { pos = {x,y,z}, name = "..." }. We track positions so we
-- can clean up when a member moves / leaves.
local minimapFlags           = {}

-- Minimap flag icon. UIMinimap:addFlag accepts numeric icons that map to
-- /images/game/minimap/flagN. 4 is the standard "blue flag" in the OTCv8
-- minimap image set; safe to swap if the user wants a different one later.
local PARTY_FLAG_ICON        = 4

-- =====================================================================
-- JSON (minimal, sandbox-local). We need both encode + decode.
-- =====================================================================

local json = {}

do
    local escape_map = {
        ['"']  = '\\"',  ['\\'] = '\\\\', ['/']  = '\\/',
        ['\b'] = '\\b',  ['\f'] = '\\f',  ['\n'] = '\\n',
        ['\r'] = '\\r',  ['\t'] = '\\t',
    }
    local function escape_str(s)
        return '"' .. s:gsub('[%z\1-\31\\"/]', function(c)
            return escape_map[c] or string.format('\\u%04x', c:byte())
        end) .. '"'
    end

    local function encode(v)
        local tv = type(v)
        if tv == 'nil' then return 'null'
        elseif tv == 'boolean' then return v and 'true' or 'false'
        elseif tv == 'number' then
            if v ~= v or v == math.huge or v == -math.huge then return 'null' end
            if v == math.floor(v) and math.abs(v) < 1e15 then
                return string.format('%d', v)
            end
            return string.format('%.14g', v)
        elseif tv == 'string' then
            return escape_str(v)
        elseif tv == 'table' then
            local parts = {}
            local n = 0
            for k, _ in pairs(v) do
                if type(k) ~= 'number' then n = -1; break end
                n = n + 1
            end
            if n >= 0 then
                for i = 1, n do parts[#parts+1] = encode(v[i]) end
                return '[' .. table.concat(parts, ',') .. ']'
            end
            for k, val in pairs(v) do
                if type(k) == 'string' then
                    parts[#parts+1] = escape_str(k) .. ':' .. encode(val)
                end
            end
            return '{' .. table.concat(parts, ',') .. '}'
        end
        return 'null'
    end
    json.encode = encode
end

-- Tiny recursive-descent decoder. Good enough for the small object/array
-- shapes the server sends us. Returns nil on parse error.
do
    local pos
    local src

    local function skipws()
        while pos <= #src do
            local c = src:sub(pos, pos)
            if c == ' ' or c == '\t' or c == '\n' or c == '\r' then
                pos = pos + 1
            else
                break
            end
        end
    end

    local parseValue
    local function parseString()
        if src:sub(pos, pos) ~= '"' then return nil end
        pos = pos + 1
        local buf = {}
        while pos <= #src do
            local c = src:sub(pos, pos)
            if c == '"' then
                pos = pos + 1
                return table.concat(buf)
            elseif c == '\\' then
                local n = src:sub(pos + 1, pos + 1)
                if n == 'n' then buf[#buf+1] = '\n'
                elseif n == 't' then buf[#buf+1] = '\t'
                elseif n == 'r' then buf[#buf+1] = '\r'
                elseif n == 'b' then buf[#buf+1] = '\b'
                elseif n == 'f' then buf[#buf+1] = '\f'
                elseif n == '"' then buf[#buf+1] = '"'
                elseif n == '\\' then buf[#buf+1] = '\\'
                elseif n == '/' then buf[#buf+1] = '/'
                elseif n == 'u' then
                    local hex = src:sub(pos + 2, pos + 5)
                    local cp = tonumber(hex, 16) or 0
                    if cp < 128 then
                        buf[#buf+1] = string.char(cp)
                    else
                        buf[#buf+1] = '?'
                    end
                    pos = pos + 4
                else
                    buf[#buf+1] = n
                end
                pos = pos + 2
            else
                buf[#buf+1] = c
                pos = pos + 1
            end
        end
        return nil
    end

    local function parseNumber()
        local s = pos
        if src:sub(pos, pos) == '-' then pos = pos + 1 end
        while pos <= #src do
            local c = src:sub(pos, pos)
            if c:match('[%d%.eE%+%-]') then pos = pos + 1 else break end
        end
        return tonumber(src:sub(s, pos - 1))
    end

    local function parseObject()
        pos = pos + 1  -- skip {
        local obj = {}
        skipws()
        if src:sub(pos, pos) == '}' then pos = pos + 1; return obj end
        while pos <= #src do
            skipws()
            local key = parseString()
            if key == nil then return nil end
            skipws()
            if src:sub(pos, pos) ~= ':' then return nil end
            pos = pos + 1
            skipws()
            local val = parseValue()
            obj[key] = val
            skipws()
            local c = src:sub(pos, pos)
            if c == ',' then pos = pos + 1
            elseif c == '}' then pos = pos + 1; return obj
            else return nil end
        end
        return nil
    end

    local function parseArray()
        pos = pos + 1  -- skip [
        local arr = {}
        skipws()
        if src:sub(pos, pos) == ']' then pos = pos + 1; return arr end
        while pos <= #src do
            skipws()
            arr[#arr+1] = parseValue()
            skipws()
            local c = src:sub(pos, pos)
            if c == ',' then pos = pos + 1
            elseif c == ']' then pos = pos + 1; return arr
            else return nil end
        end
        return nil
    end

    parseValue = function()
        skipws()
        if pos > #src then return nil end
        local c = src:sub(pos, pos)
        if c == '{' then return parseObject()
        elseif c == '[' then return parseArray()
        elseif c == '"' then return parseString()
        elseif c == 't' and src:sub(pos, pos + 3) == 'true' then
            pos = pos + 4; return true
        elseif c == 'f' and src:sub(pos, pos + 4) == 'false' then
            pos = pos + 5; return false
        elseif c == 'n' and src:sub(pos, pos + 3) == 'null' then
            pos = pos + 4; return nil
        else
            return parseNumber()
        end
    end

    function json.decode(s)
        if type(s) ~= 'string' then return nil end
        src = s
        pos = 1
        local ok, val = pcall(parseValue)
        if not ok then return nil end
        return val
    end
end

-- =====================================================================
-- Minimap flag tracking
--
-- ARCFALL: Use the UIMinimap widget (game_minimap.minimapWidget), NOT the
-- C++ singleton g_minimap. g_minimap only exposes data ops (loadOtmm,
-- clean, saveOtmm); add/remove flag are widget methods on UIMinimap. The
-- previous code addressed the wrong object and the pcall hid the failure.
-- =====================================================================

local function getMinimapWidget()
    local mod = modules and modules.game_minimap
    if not mod then return nil end
    return mod.minimapWidget
end

local function clearAllFlags()
    local mw = getMinimapWidget()
    for _, info in pairs(minimapFlags) do
        if mw and info and info.pos then
            mw:removeFlag(info.pos, PARTY_FLAG_ICON, info.name or "")
        end
    end
    minimapFlags = {}
end

local function refreshFlags(members)
    local mw = getMinimapWidget()
    if not mw then return end

    -- Build a set of currently-active member ids; remove flags for anyone
    -- not in the new list, and update flags for everyone who is.
    local seen = {}
    for _, m in ipairs(members or {}) do
        if m.pos then
            seen[m.id] = true
            local prev = minimapFlags[m.id]
            if prev and prev.pos then
                if prev.pos.x ~= m.pos.x or prev.pos.y ~= m.pos.y or prev.pos.z ~= m.pos.z then
                    mw:removeFlag(prev.pos, PARTY_FLAG_ICON, prev.name or "")
                    minimapFlags[m.id] = nil
                end
            end
            if not minimapFlags[m.id] then
                local pos = { x = m.pos.x, y = m.pos.y, z = m.pos.z }
                mw:addFlag(pos, PARTY_FLAG_ICON, m.name or "?", true)
                minimapFlags[m.id] = { pos = pos, name = m.name or "?" }
            end
        end
    end

    for id, info in pairs(minimapFlags) do
        if not seen[id] then
            if info and info.pos then
                mw:removeFlag(info.pos, PARTY_FLAG_ICON, info.name or "")
            end
            minimapFlags[id] = nil
        end
    end
end

-- =====================================================================
-- Member-row rendering
-- =====================================================================

local function clearRows()
    if not partyStatusPanel then return end
    -- Walk children backwards, destroy everything except emptyLabel.
    local children = partyStatusPanel:getChildren()
    for i = #children, 1, -1 do
        local c = children[i]
        if c and c:getId() ~= 'emptyLabel' then
            c:destroy()
        end
    end
end

local function showEmpty(visible)
    if not emptyLabel then return end
    emptyLabel:setVisible(visible)
end

local function clampPercent(cur, max)
    if not max or max <= 0 then return 0 end
    local p = (cur or 0) / max
    if p < 0 then p = 0 end
    if p > 1 then p = 1 end
    return math.floor(p * 100 + 0.5)
end

local function renderRow(parent, m)
    local row = g_ui.createWidget('PartyMemberPanel', parent)
    local nameLabel = row:getChildById('nameLabel')
    local hpBar     = row:getChildById('hpBar')
    local mpBar     = row:getChildById('mpBar')

    if nameLabel then
        local lvl = m.level and m.level > 0 and (' [' .. m.level .. ']') or ''
        nameLabel:setText((m.name or '?') .. lvl)
    end

    if hpBar then
        local p = clampPercent(m.hp, m.max_hp)
        hpBar:setPercent(p)
        hpBar:setText(string.format('%d / %d', m.hp or 0, m.max_hp or 0))
        -- Color HP bar by health %.
        if p > 60 then
            hpBar:setBackgroundColor('#00cc33ff')
        elseif p > 30 then
            hpBar:setBackgroundColor('#ddaa00ff')
        else
            hpBar:setBackgroundColor('#cc2222ff')
        end
    end

    if mpBar then
        local p = clampPercent(m.mp, m.max_mp)
        mpBar:setPercent(p)
        mpBar:setText(string.format('%d / %d', m.mp or 0, m.max_mp or 0))
        mpBar:setBackgroundColor('#3366ddff')
    end

    return row
end

-- =====================================================================
-- Opcode dispatcher
-- =====================================================================

local function applyPartyUpdate(payload)
    if not partyWindow then return end

    local members = payload.members or {}
    if #members == 0 then
        clearRows()
        clearAllFlags()
        showEmpty(true)
        return
    end

    showEmpty(false)
    clearRows()
    for _, m in ipairs(members) do
        renderRow(partyStatusPanel, m)
    end
    refreshFlags(members)
end

local function applyPartyEnded()
    clearRows()
    clearAllFlags()
    showEmpty(true)
end

local function parsePartyOpcode(protocol, opcode, buffer)
    if opcode ~= PartyStatus.opcode then return end
    local payload = json.decode(buffer)
    if type(payload) ~= 'table' or type(payload.action) ~= 'string' then
        return
    end

    local action = payload.action
    if action == 'party_update' then
        applyPartyUpdate(payload)
    elseif action == 'party_ended' or action == 'no_party' then
        applyPartyEnded()
    end
end

-- =====================================================================
-- Networking helper (for force-refresh / future use)
-- =====================================================================

function PartyStatus.requestUpdate()
    local proto = g_game.getProtocolGame()
    if not proto then return end
    proto:sendExtendedOpcode(PartyStatus.opcode, json.encode({action = 'request_update'}))
end

-- =====================================================================
-- Window lifecycle
-- =====================================================================

function onMiniWindowClose()
    if partyButton then partyButton:setOn(false) end
end

local function partyWindowToggle()
    if not partyWindow then return end
    if partyWindow:isVisible() then
        partyWindow:close()
        if partyButton then partyButton:setOn(false) end
    else
        partyWindow:open()
        if partyButton then partyButton:setOn(true) end
        PartyStatus.requestUpdate()
    end
end

local function onGameEnd()
    -- Connection lost / logged out: clear UI + flags.
    applyPartyEnded()
end

-- =====================================================================
-- init / terminate
-- =====================================================================

function init()
    g_ui.importStyle('partystatus.otui')

    partyWindow = g_ui.loadUI('partystatus', modules.game_interface.getRightPanel())
    partyStatusPanel = partyWindow:recursiveGetChildById('partyStatusPanel')
    emptyLabel       = partyWindow:recursiveGetChildById('emptyLabel')

    -- Persist window state across sessions (the &save:true on the OTUI
    -- handles the dock/closed/locked state automatically).
    partyWindow:setup()

    if modules.client_topmenu and modules.client_topmenu.addRightGameToggleButton then
        partyButton = modules.client_topmenu.addRightGameToggleButton(
            'partyStatusButton',
            tr('Party Status') .. ' (Ctrl+Shift+P)',
            '/images/topbuttons/battle',
            partyWindowToggle,
            false,
            10
        )
        if partyButton then
            partyButton:setOn(partyWindow:isVisible())
        end
    end

    g_keyboard.bindKeyDown('Ctrl+Shift+P', partyWindowToggle)

    ProtocolGame.registerExtendedOpcode(PartyStatus.opcode, parsePartyOpcode)
    connect(g_game, { onGameEnd = onGameEnd })

    showEmpty(true)
end

function terminate()
    ProtocolGame.unregisterExtendedOpcode(PartyStatus.opcode)
    disconnect(g_game, { onGameEnd = onGameEnd })

    g_keyboard.unbindKeyDown('Ctrl+Shift+P')

    clearAllFlags()

    if partyButton then partyButton:destroy(); partyButton = nil end
    if partyWindow then partyWindow:destroy(); partyWindow = nil end

    partyStatusPanel = nil
    emptyLabel       = nil
end
