-- Preferred monsters modal - methods added to TaskBoardController
-- Uses openModal/closeModal to open a secondary HTML window without
-- affecting self.ui which points to taskboard.html
local cachedRemoveCost = 0
local cachedAllRaceIds = {}
local cachedRawSlots = {}

local MONSTER_ROW_HEIGHT_PX = 44
local MONSTER_OVERSCAN_ROWS = 2
local MONSTER_BIND_RETRY_DELAY_MS = 25
local MONSTER_BIND_MAX_RETRIES = 20
local MONSTER_SPACER_SEGMENT_PX = 20000
local MONSTER_ROW_HEIGHT_MIN_PX = 36
local MONSTER_ROW_HEIGHT_MAX_PX = 72

-- Split a large pixel value across three spacer segments (max 20000px each).
-- Needed because OTClient widget heights are capped; three divs simulate tall spacers.
local function splitSpacerPx(totalPx)
    local px = math.max(0, tonumber(totalPx) or 0)
    local a = math.min(px, MONSTER_SPACER_SEGMENT_PX)
    px = px - a
    local b = math.min(px, MONSTER_SPACER_SEGMENT_PX)
    local c = px - b
    return a, b, c
end

local function clampRowHeightPx(value)
    local px = math.floor((tonumber(value) or MONSTER_ROW_HEIGHT_PX) + 0.5)
    return math.max(MONSTER_ROW_HEIGHT_MIN_PX, math.min(MONSTER_ROW_HEIGHT_MAX_PX, px))
end

local function getPreferredUnlockPrice(slotNum, serverPrice)
    local slot = tonumber(slotNum) or 0
    local price = tonumber(serverPrice) or 0
    if price > 0 then
        return price
    end
    return tonumber(PREFERRED_UNLOCK_COST and PREFERRED_UNLOCK_COST[slot]) or 0
end

local function getPreferredActionCost()
    local assignCost = tonumber(PREFERRED_SLOT_ASSIGN_COST) or 0
    if assignCost > 0 then
        return assignCost
    end
    return tonumber(cachedRemoveCost) or 0
end

local function updateCachedSlot(slotNum, key, value)
    local slot = tonumber(slotNum) or 0
    if slot <= 0 then
        return
    end

    for index, slotData in ipairs(cachedRawSlots) do
        if (tonumber(slotData.slot) or index) == slot then
            slotData[key] = value
            break
        end
    end
end

-- Modal open / close
function TaskBoardController:showPreferred()
    if self.preferredModal then
        return
    end

    self.preferredModal = self:openModal('template/html/modal_preferred.html', {
        mode = 'html'
    })
    addEvent(function()
        if not self.preferredModal or not self.preferredModal.ui then
            return
        end
        self:ensurePreferredMonsterScrollBound()
        self:resetPreferredMonsterScroll()
    end)
    g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_REQUEST, 0, 0)
end

function TaskBoardController:hidePreferred()
    if self._preferredBindRetryEvent then
        removeEvent(self._preferredBindRetryEvent)
        self._preferredBindRetryEvent = nil
    end

    local scrollWidget = self:getPreferredMonsterScrollWidget()
    if scrollWidget and scrollWidget.verticalScrollBar then
        local sb = scrollWidget.verticalScrollBar
        if sb._preferredValueHook then
            disconnect(sb, 'onValueChange', sb._preferredValueHook)
            sb._preferredValueHook = nil
        end
    end

    if self.preferredModal then
        self:closeModal(self.preferredModal)
        self.preferredModal = nil
    end

    self.allAvailableMonsters = {}
    self.visibleMonsters = {}
    self.availableMonsters = {}
    self.monsterTopSpacerPxA = 0
    self.monsterTopSpacerPxB = 0
    self.monsterTopSpacerPxC = 0
    self.monsterBottomSpacerPxA = 0
    self.monsterBottomSpacerPxB = 0
    self.monsterBottomSpacerPxC = 0
    self.preferredSlots = {}
    self.selectedRaceId = 0
    self.searchText = ""

    self._preferredViewportStart = 0
    self._preferredViewportEnd = 0
    self._preferredViewportRowHeight = 0
    self._preferredApplyingScrollSnap = false
    self._preferredViewportRefreshing = false
    self._preferredRowHeightPx = MONSTER_ROW_HEIGHT_PX
    self._preferredRowHeightMeasured = false

    cachedRemoveCost = 0
    cachedAllRaceIds = {}
    cachedRawSlots = {}
end

-- Server data
function TaskBoardController:onPreferredServerData(slots, removeCost, availableRaceIds)
    cachedRawSlots = slots or {}
    cachedRemoveCost = tonumber(removeCost) or 0
    cachedAllRaceIds = availableRaceIds or {}

    self:rebuildPreferredSlots()
    self:rebuildAvailableMonsters()
end

-- Slots
function TaskBoardController:rebuildPreferredSlots()
    local result = self.preferredSlots or {}
    table.clear(result)
    local actionCost = getPreferredActionCost()
    local selectedRaceId = tonumber(self.selectedRaceId) or 0
    local player = g_game.getLocalPlayer()
    local balance = player and player:getResourceBalance(ResourceTypes.BOUNTY_POINTS) or 0

    for index, slotData in ipairs(cachedRawSlots) do
        local slotNum = tonumber(slotData.slot) or index
        local unlocked = index == 1 or tonumber(slotData.locked) ~= 1
        local preferredId = tonumber(slotData.preferred) or 0
        local unwantedId = tonumber(slotData.unwanted) or 0
        local lockPrice = getPreferredUnlockPrice(slotNum, slotData.price)

        local prefName, prefOutfit, unwName, unwOutfit
        if preferredId > 0 then
            local rd = g_things.getRaceData(preferredId)
            prefName = rd and rd.name and rd.name:capitalize() or 'Unknown'
            prefOutfit = rd and rd.outfit or nil
        else
            prefName = 'Empty'
        end
        if unwantedId > 0 then
            local rd = g_things.getRaceData(unwantedId)
            unwName = rd and rd.name and rd.name:capitalize() or 'Unknown'
            unwOutfit = rd and rd.outfit or nil
        else
            unwName = 'Empty'
        end

        local hasPreferred = preferredId > 0
        local hasUnwanted = unwantedId > 0
        local canAssign = selectedRaceId ~= 0

        table.insert(result, {
            slotNum = slotNum,
            slotTitle = slotNum == 1 and 'Main Slot' or 'Additional Slots',
            unlocked = unlocked and 1 or 0,
            locked = unlocked and 0 or 1,
            lockPrice = lockPrice,
            canUnlock = lockPrice > 0 and balance >= lockPrice,
            preferredId = preferredId,
            preferredName = prefName,
            preferredOutfit = prefOutfit,
            showPreferredCreature = hasPreferred and 1 or 0,
            showPreferredPlaceholder = hasPreferred and 0 or 1,
            showPreferredAssign = hasPreferred and 0 or 1,
            showPreferredClear = hasPreferred and 1 or 0,
            unwantedId = unwantedId,
            unwantedName = unwName,
            unwantedOutfit = unwOutfit,
            showUnwantedCreature = hasUnwanted and 1 or 0,
            showUnwantedPlaceholder = hasUnwanted and 0 or 1,
            showUnwantedAssign = hasUnwanted and 0 or 1,
            showUnwantedClear = hasUnwanted and 1 or 0,
            actionCost = actionCost,
            preferredButtonText = hasPreferred and 'Clear' or 'Assign',
            unwantedButtonText = hasUnwanted and 'Clear' or 'Assign',
            preferredAction = hasPreferred and 'clearPreferred' or 'assignPreferred',
            unwantedAction = hasUnwanted and 'clearUnwanted' or 'assignUnwanted',
            canPreferredAct = hasPreferred or canAssign,
            canUnwantedAct = hasUnwanted or canAssign,
            clearCost = actionCost,
            canClear = balance >= actionCost
        })
    end
    self.preferredSlots = result
end

-- Monster list
function TaskBoardController:getPreferredMonsterScrollWidget()
    if not self.preferredModal or not self.preferredModal.ui then
        return nil
    end
    return self.preferredModal.ui:querySelector("#monsterListScroll")
end

function TaskBoardController:onPreferredMonsterScrollChange(widget, virtualOffset)
    if self._preferredApplyingScrollSnap then
        return
    end
    self:refreshMonsterViewport((virtualOffset and virtualOffset.y) or 0)
end

function TaskBoardController:bindPreferredMonsterScroll()
    local scrollWidget = self:getPreferredMonsterScrollWidget()
    if not scrollWidget then
        return
    end
    if scrollWidget._preferredVirtualized then
        return
    end

    scrollWidget._preferredVirtualized = true
    scrollWidget._skipScrollLayoutRecalc = true
    scrollWidget._preferredOriginalUpdateScrollBars = scrollWidget.updateScrollBars
    scrollWidget.updateScrollBars = function()
    end

    -- Without AutoFocusNone, OTClient calls focusPreviousChild() automatically when a
    -- focused row widget is destroyed during virtual-scroll re-renders (uiwidget.cpp
    -- removeChild), shifting focus to an adjacent row without any user interaction.
    scrollWidget:setAutoFocusPolicy(AutoFocusNone)

    scrollWidget.onScrollChange = function(widget, virtualOffset)
        self:onPreferredMonsterScrollChange(widget, virtualOffset)
    end

    if scrollWidget.verticalScrollBar then
        local sb = scrollWidget.verticalScrollBar
        sb:setStep(self:getPreferredMonsterRowHeight(scrollWidget))
    end
end

function TaskBoardController:ensurePreferredMonsterScrollBound(retryCount)
    local scrollWidget = self:getPreferredMonsterScrollWidget()
    if scrollWidget and scrollWidget.verticalScrollBar then
        self:bindPreferredMonsterScroll()
        self:refreshMonsterViewport(tonumber(scrollWidget.verticalScrollBar:getValue()) or 0)
        return true
    end

    retryCount = tonumber(retryCount) or 0
    if retryCount >= MONSTER_BIND_MAX_RETRIES then
        g_logger.warning('[taskboard/preferred] failed to bind monster scroll: vertical scrollbar not ready')
        return false
    end

    if self._preferredBindRetryEvent then
        removeEvent(self._preferredBindRetryEvent)
        self._preferredBindRetryEvent = nil
    end

    self._preferredBindRetryEvent = scheduleEvent(function()
        self._preferredBindRetryEvent = nil
        if self.preferredModal then
            self:ensurePreferredMonsterScrollBound(retryCount + 1)
        end
    end, MONSTER_BIND_RETRY_DELAY_MS)

    return false
end

function TaskBoardController:updatePreferredMonsterScrollRange(totalRows, viewportHeight, rowHeight)
    local scrollWidget = self:getPreferredMonsterScrollWidget()
    rowHeight = math.max(1, math.floor((tonumber(rowHeight) or self:getPreferredMonsterRowHeight(scrollWidget)) + 0.5))
    local visibleRows = math.max(1, math.floor(viewportHeight / rowHeight))
    local maxScroll = math.max((math.max(1, totalRows - visibleRows + 1) - 1) * rowHeight, 0)

    if scrollWidget and scrollWidget.verticalScrollBar then
        local sb = scrollWidget.verticalScrollBar
        sb:setMinimum(0)
        sb:setMaximum(maxScroll)
        sb:setStep(rowHeight)

        if (tonumber(sb:getValue()) or 0) > maxScroll then
            self._preferredApplyingScrollSnap = true
            sb:setValue(maxScroll)
            self._preferredApplyingScrollSnap = false
        end
    end

    return maxScroll, visibleRows
end

function TaskBoardController:getPreferredMonsterRowHeight(scrollWidget)
    local measured = clampRowHeightPx(self._preferredRowHeightPx)
    if self._preferredRowHeightMeasured then
        return measured
    end

    scrollWidget = scrollWidget or self:getPreferredMonsterScrollWidget()
    if not scrollWidget or not scrollWidget.querySelectorAll then
        return measured
    end

    local rows = scrollWidget:querySelectorAll(".monsterRow")
    local h = 0
    if rows and #rows >= 2 then
        local delta = math.abs((tonumber(rows[#rows]:getY()) or 0) - (tonumber(rows[1]:getY()) or 0))
        local pitch = delta / (#rows - 1)
        if pitch > 0 then
            h = pitch
        end
    elseif rows and #rows == 1 then
        h = tonumber(rows[1]:getHeight()) or 0
        if rows[1].getMarginTop and rows[1].getMarginBottom then
            h = h + (tonumber(rows[1]:getMarginTop()) or 0) + (tonumber(rows[1]:getMarginBottom()) or 0)
        end
    end

    if h > 0 then
        local rounded = math.floor(h + 0.5)
        if rounded >= MONSTER_ROW_HEIGHT_MIN_PX and rounded <= MONSTER_ROW_HEIGHT_MAX_PX then
            self._preferredRowHeightPx = rounded
            self._preferredRowHeightMeasured = true
            return rounded
        end
    end

    return measured
end

function TaskBoardController:resetPreferredMonsterScroll()
    local scrollWidget = self:getPreferredMonsterScrollWidget()
    if not scrollWidget or not scrollWidget.verticalScrollBar then
        self:ensurePreferredMonsterScrollBound()
        scrollWidget = self:getPreferredMonsterScrollWidget()
    end

    if scrollWidget and scrollWidget.verticalScrollBar then
        self._preferredApplyingScrollSnap = true
        scrollWidget.verticalScrollBar:setValue(0)
        self._preferredApplyingScrollSnap = false
    elseif scrollWidget then
        scrollWidget:setVirtualOffset({
            x = 0,
            y = 0
        })
    end

    self:refreshMonsterViewport(0)
end

function TaskBoardController:refreshMonsterViewport(scrollValue)
    if self._preferredViewportRefreshing then
        return
    end
    self._preferredViewportRefreshing = true

    local list = self.allAvailableMonsters or {}
    local total = #list
    local viewportHeight = 400

    local scrollWidget = self:getPreferredMonsterScrollWidget()
    local rowHeight = self:getPreferredMonsterRowHeight(scrollWidget)
    if scrollWidget and scrollWidget.getPaddingRect then
        local paddingRect = scrollWidget:getPaddingRect()
        if paddingRect and paddingRect.height and paddingRect.height > 0 then
            viewportHeight = paddingRect.height
        end
    end

    if total == 0 then
        local visible = self.visibleMonsters or {}
        table.clear(visible)
        self.visibleMonsters = visible
        self.availableMonsters = self.visibleMonsters
        self.monsterTopSpacerPxA, self.monsterTopSpacerPxB, self.monsterTopSpacerPxC = 0, 0, 0
        self.monsterBottomSpacerPxA, self.monsterBottomSpacerPxB, self.monsterBottomSpacerPxC = 0, 0, 0
        self._preferredViewportStart = 0
        self._preferredViewportEnd = 0
        self._preferredViewportRowHeight = rowHeight
        self:updatePreferredMonsterScrollRange(0, viewportHeight, rowHeight)
        self._preferredViewportRefreshing = false
        return
    end

    local y = tonumber(scrollValue) or 0
    local maxScroll, visibleRows = self:updatePreferredMonsterScrollRange(total, viewportHeight, rowHeight)
    y = math.max(0, math.min(y, maxScroll))

    local snappedY = math.min(math.floor(y / rowHeight) * rowHeight, maxScroll)

    if scrollWidget and scrollWidget.verticalScrollBar and not self._preferredApplyingScrollSnap then
        local currentY = scrollWidget.verticalScrollBar:getValue()
        if currentY ~= snappedY then
            self._preferredApplyingScrollSnap = true
            scrollWidget.verticalScrollBar:setValue(snappedY)
            self._preferredApplyingScrollSnap = false
            y = snappedY
        else
            y = currentY
        end
    end

    local firstVisibleIndex = math.min(math.floor(y / rowHeight) + 1, math.max(1, total - visibleRows + 1))
    local startIndex = math.max(1, firstVisibleIndex - MONSTER_OVERSCAN_ROWS)
    local endIndex = math.min(total, firstVisibleIndex + visibleRows + MONSTER_OVERSCAN_ROWS - 1)

    if self._preferredViewportStart == startIndex and self._preferredViewportEnd == endIndex and
        self._preferredViewportRowHeight == rowHeight then
        self._preferredViewportRefreshing = false
        return
    end

    local visible = self.visibleMonsters or {}
    table.clear(visible)
    for i = startIndex, endIndex do
        visible[#visible + 1] = list[i]
    end

    self.visibleMonsters = visible
    self.availableMonsters = visible
    self.monsterTopSpacerPxA, self.monsterTopSpacerPxB, self.monsterTopSpacerPxC =
        splitSpacerPx((startIndex - 1) * rowHeight)
    self.monsterBottomSpacerPxA, self.monsterBottomSpacerPxB, self.monsterBottomSpacerPxC = splitSpacerPx((total -
                                                                                                              endIndex) *
                                                                                                              rowHeight)
    self._preferredViewportStart = startIndex
    self._preferredViewportEnd = endIndex
    self._preferredViewportRowHeight = rowHeight
    self._preferredViewportRefreshing = false
end

function TaskBoardController:rebuildAvailableMonsters()
    local usedIds = {}
    for _, slotData in ipairs(cachedRawSlots) do
        local prefId = tonumber(slotData.preferred) or 0
        local unwId = tonumber(slotData.unwanted) or 0
        if prefId > 0 then
            usedIds[prefId] = true
        end
        if unwId > 0 then
            usedIds[unwId] = true
        end
    end

    local filter = (self.searchText or ""):lower()

    local sorted = {}
    for _, raceId in ipairs(cachedAllRaceIds) do
        if not usedIds[raceId] then
            local rd = g_things.getRaceData(raceId)
            local name = rd and rd.name or 'Unknown'
            name = name:capitalize()
            if filter == '' or name:lower():find(filter, 1, true) then
                table.insert(sorted, {
                    raceId = raceId,
                    name = name,
                    outfit = rd and rd.outfit or nil
                })
            end
        end
    end

    table.sort(sorted, function(a, b)
        return a.name < b.name
    end)

    self.allAvailableMonsters = sorted
    self._preferredViewportStart = 0
    self._preferredViewportEnd = 0
    self._preferredViewportRowHeight = 0
    self._preferredRowHeightMeasured = false
    self._preferredRowHeightPx = MONSTER_ROW_HEIGHT_PX
    self:ensurePreferredMonsterScrollBound()
    self:resetPreferredMonsterScroll()
end

-- HTML event handlers
function TaskBoardController:filterMonsters(text)
    self.searchText = text or ""
    self:rebuildAvailableMonsters()
end

function TaskBoardController:selectMonster(raceId)
    self.selectedRaceId = tonumber(raceId) or 0
    self:rebuildPreferredSlots()
end

function TaskBoardController:isRaceAssigned(raceId, targetSlotNum, targetField)
    local targetRaceId = tonumber(raceId) or 0
    local targetSlot = tonumber(targetSlotNum) or 0
    if targetRaceId == 0 then
        return false
    end

    for index, slotData in ipairs(cachedRawSlots) do
        local slotNum = tonumber(slotData.slot) or index
        local preferredId = tonumber(slotData.preferred) or 0
        local unwantedId = tonumber(slotData.unwanted) or 0

        if preferredId == targetRaceId and not (slotNum == targetSlot and targetField == 'preferred') then
            return true
        end

        if unwantedId == targetRaceId and not (slotNum == targetSlot and targetField == 'unwanted') then
            return true
        end
    end

    return false
end

function TaskBoardController:onPreferredSlotAction(slotNum, action, cost)
    if action == 'assignPreferred' then
        self:assignPreferred(slotNum)
    elseif action == 'assignUnwanted' then
        self:assignUnwanted(slotNum)
    elseif action == 'clearPreferred' then
        self:clearPreferred(slotNum, cost)
    elseif action == 'clearUnwanted' then
        self:clearUnwanted(slotNum, cost)
    end
end

function TaskBoardController:assignPreferred(slotNum)
    if self.selectedRaceId == 0 then
        return
    end
    local parsedSlot = tonumber(slotNum) or 0
    if parsedSlot == 0 then
        return
    end
    if self:isRaceAssigned(self.selectedRaceId, parsedSlot, 'preferred') then
        return
    end

    g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_SET_PREFERRED, parsedSlot, self.selectedRaceId)
    updateCachedSlot(parsedSlot, 'preferred', self.selectedRaceId)
    self.selectedRaceId = 0
    self:rebuildPreferredSlots()
    self:rebuildAvailableMonsters()
end

function TaskBoardController:assignUnwanted(slotNum)
    if self.selectedRaceId == 0 then
        return
    end
    local parsedSlot = tonumber(slotNum) or 0
    if parsedSlot == 0 then
        return
    end
    if self:isRaceAssigned(self.selectedRaceId, parsedSlot, 'unwanted') then
        return
    end

    g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_SET_UNWANTED, parsedSlot, self.selectedRaceId)
    updateCachedSlot(parsedSlot, 'unwanted', self.selectedRaceId)
    self.selectedRaceId = 0
    self:rebuildPreferredSlots()
    self:rebuildAvailableMonsters()
end

function TaskBoardController:clearPreferred(slotNum, cost)
    local parsedSlot = tonumber(slotNum) or 0
    if parsedSlot == 0 then
        return
    end
    local parsedCost = tonumber(cost) or getPreferredActionCost()

    local msgBox
    local function yes()
        if msgBox then
            msgBox:destroy();
            msgBox = nil
        end
        g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_REMOVE_PREFERRED, parsedSlot, 0)
        updateCachedSlot(parsedSlot, 'preferred', 0)
        self:rebuildPreferredSlots()
        self:rebuildAvailableMonsters()
    end
    local function cancel()
        if msgBox then
            msgBox:destroy();
            msgBox = nil
        end
    end
    msgBox = displayGeneralBox(tr('Clear Preferred'),
        tr('Remove preferred monster? This costs %d Bounty Task Points.', parsedCost), {{
            text = tr('Yes'),
            callback = yes
        }, {
            text = tr('Cancel'),
            callback = cancel
        }}, yes, cancel)
end

function TaskBoardController:clearUnwanted(slotNum, cost)
    local parsedSlot = tonumber(slotNum) or 0
    if parsedSlot == 0 then
        return
    end
    local parsedCost = tonumber(cost) or getPreferredActionCost()

    local msgBox
    local function yes()
        if msgBox then
            msgBox:destroy();
            msgBox = nil
        end
        g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_REMOVE_UNWANTED, parsedSlot, 0)
        updateCachedSlot(parsedSlot, 'unwanted', 0)
        self:rebuildPreferredSlots()
        self:rebuildAvailableMonsters()
    end
    local function cancel()
        if msgBox then
            msgBox:destroy();
            msgBox = nil
        end
    end
    msgBox = displayGeneralBox(tr('Clear Unwanted'),
        tr('Remove unwanted monster? This costs %d Bounty Task Points.', parsedCost), {{
            text = tr('Yes'),
            callback = yes
        }, {
            text = tr('Cancel'),
            callback = cancel
        }}, yes, cancel)
end

function TaskBoardController:buyPreferredSlot(slotNum, price)
    local parsedSlot = tonumber(slotNum) or 0
    if parsedSlot == 0 then
        return
    end
    local parsedPrice = tonumber(price) or getPreferredUnlockPrice(parsedSlot, 0)

    local msgBox
    local function yes()
        if msgBox then
            msgBox:destroy();
            msgBox = nil
        end
        g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_BUY_SLOT, parsedSlot, 0)
    end
    local function cancel()
        if msgBox then
            msgBox:destroy();
            msgBox = nil
        end
    end
    msgBox = displayGeneralBox(tr('Unlock Slot'),
        tr('Unlock this preferred slot for %d Bounty Task Points?', parsedPrice), {{
            text = tr('Yes'),
            callback = yes
        }, {
            text = tr('Cancel'),
            callback = cancel
        }}, yes, cancel)
end
