-- VDS_Controller_Live
-- ============================================================================
-- OnClick handlers for liveEdit + gallery widgets. State intent dispatched
-- first; C_HousingCustomizeMode write API called imperatively (not in
-- reducer). boundary

VDS = VDS or {}
VDS.Controller_Live = VDS.Controller_Live or {}

local C = VDS.Controller_Live
local A = VDS.Constants.ACTIONS

function C:OnSlotRowClick(widget)
    if not (widget and widget._slotID) then return end  -- exception(boundary): click can race slot reconfigure
    -- Focus only for now; DyeSelectionPopout:SetDyeSlotInfo (Blizzard_HouseEditor LoD) wires in Phase 6f.
    VDS.Store:Dispatch({
        type    = A.LIVE_SLOT_FOCUS_SET,
        payload = { slotID = widget._slotID },
    })
end

function C:OnGalleryTileClick(row)
    if not (row and row._loadout) then return end  -- exception(boundary): click can race pool reconfigure
    local loadout = row._loadout
    local slots = VDS.Store:GetState().session.ui.live.slots  -- top-level only: gallery apply walks live slots
    if not slots then return end
    -- Map slot.channel to dyes (NOT slot loop index): some decors expose {0,2}
    -- skipping ch1; iterating slots[i] would mis-map dyes. (VDS_Canvas.lua:1309)
    for _, slot in ipairs(slots) do
        local ch = slot.channel
        local dyeID = ch ~= nil and loadout.dyes and loadout.dyes[ch] or nil
        if dyeID then
            VDS.Store:Dispatch({
                type    = A.LIVE_SET_SLOT_DYE_INTENT,
                payload = { slotID = slot.ID, dyeID = dyeID },
            })
            VDS.LiveCustomizeObserver:ApplyDye(slot.ID, dyeID)
        end
    end
end

function C:OnGalleryTileDelete(decorID, idx)
    if not decorID or not idx then return end
    VDS.Store:Dispatch({
        type    = A.CANVAS_DELETE_LOADOUT,
        payload = { decorID = decorID, idx = idx },
    })
end

local function dispatchOwnedOnly(self)
    local value = self:GetChecked() and true or false
    VDS.Store:Dispatch({ type = A.SET_OWNED_ONLY, payload = { value = value } })
end

-- Revert (nee Cancel) restores every slot's preview to whatever it was when
-- the user entered Live mode (snapshotted into session.ui.live.originalDyes
-- on LIVE_MODE_ENTER; re-baselined on Apply via LIVE_COMMIT_SNAPSHOT), and
-- STAYS in customize mode. Previously called CancelActiveEditing() which
-- killed customize mode entirely + closed VDS -- jarring when you just wanted
-- to back out of one experiment. Donor's Cancel was even more aggressive
-- (ClearDyes + CancelActiveEditing); neither matches the "revert pending"
-- semantic, hence the rename (Blizzard Edit Mode precedent: Apply/Revert pair).
--
-- Blizzard's API treats dyeColorID == 0 as "no dye" (sentinel value, NOT nil).
-- The snapshot stores dyeID = slot.dyeColorID verbatim, so an originally-empty
-- slot snapshots as { dyeID = 0 }. Cancel must route that through ClearSlot,
-- not ApplyDye(slotID, 0) -- the latter is a verified no-op on 12.0.5 retail.
local function dispatchRevert()
    local live = VDS.Store:GetState().session.ui.live  -- top-level only: Revert reads originals
    for _, orig in ipairs(live.originalDyes) do
        if orig.dyeID and orig.dyeID ~= 0 then
            VDS.LiveCustomizeObserver:ApplyDye(orig.slotID, orig.dyeID)
        else
            -- Slot was empty originally (dyeID is nil OR 0 = Blizzard's
            -- no-dye sentinel); re-clear in case the user previewed a dye.
            VDS.LiveCustomizeObserver:ClearSlot(orig.slotID)
        end
    end
end

-- Clear-all button (top-right of liveEdit panel) wipes every slot's preview
-- back to "no dye". Apply commits the wipe.
local function dispatchClearAll()
    VDS.LiveCustomizeObserver:ClearAllSlots()
end

-- Build a channel-keyed dyes map from current live slots. Returns nil if
-- no decor selected or no colored slots. Shared by Apply (auto-save branch)
-- and Snapshot (always saves).
--
-- Empty-slot guard: Blizzard reads empty dyeSlots back with dyeColorID = 0
-- (MCP gotcha on C_HousingCustomizeMode.ApplyDyeToSelectedDecor: "empty
-- slots from GetSelectedDecorInfo's dyeSlots array READ as dyeColorID = 0").
-- Lua treats 0 as truthy, so a bare `if slot.dyeColorID` lets empty slots
-- through, causing Apply-after-Clear-All to autosave an all-empty loadout.
-- Guard with explicit `> 0` so anyColor only flips true on a real dye.
local function collectCurrentDyes(live)
    if not (live and live.active and live.decorID and live.slots) then return nil end  -- exception(false-positive): live-mode gate, not a guard; nil = not editing
    local dyes, anyColor = { [0] = nil, [1] = nil, [2] = nil }, false
    for _, slot in ipairs(live.slots) do
        if slot.channel ~= nil and slot.dyeColorID and slot.dyeColorID > 0 then
            dyes[slot.channel] = slot.dyeColorID
            anyColor = true
        end
    end
    if not anyColor then return nil end
    return dyes
end

local function dispatchSaveLoadout(live, namePrefix)
    local dyes = collectCurrentDyes(live)
    if not dyes then return end
    VDS.Store:Dispatch({
        type    = A.CANVAS_SAVE_LOADOUT,
        payload = {
            decorID = live.decorID,
            dyes    = dyes,
            name    = namePrefix .. " " .. _G.date("%H:%M"),
        },
    })
end

local function dispatchApply()
    local state = VDS.Store:GetState()  -- top-level only: Apply reads autosave + slots
    local live  = state.session.ui.live

    -- Auto-save: capture current combo to canvas loadouts before committing; reducer dedupes + caps at 20.
    if state.account.live.autoSave then
        dispatchSaveLoadout(live, "Auto-saved")
    end

    VDS.LiveCustomizeObserver:CommitDyes()

    -- Re-snapshot originalDyes from current slots so the Apply gate's
    -- slot-vs-original comparison treats THIS commit as the new baseline.
    -- Without this, originalDyes stays at the LIVE_MODE_ENTER snapshot and
    -- post-commit clears (e.g. apply purple, then clear) compare 0-vs-0
    -- instead of 0-vs-purple -- the gate misses the pending clear and
    -- Apply stays disabled.
    --
    -- Pass current live.slots as the payload; the reducer transforms it via
    -- the same buildOriginalDyes helper LIVE_MODE_ENTER uses, so both code
    -- paths produce identical originalDyes shape (single source of truth).
    VDS.Store:Dispatch({
        type    = A.LIVE_COMMIT_SNAPSHOT,
        payload = { dyeSlots = live.slots },
    })
end

local function dispatchSnapshot()
    -- User-triggered save of the currently-displayed dye scheme.
    -- Always fires (no autoSave gate). Does NOT commit — pure save action.
    local live = VDS.Store:GetState().session.ui.live  -- top-level only
    dispatchSaveLoadout(live, "Snapshot")
end

-- Fill EVERY non-focused slot with a COMPLETE harmony scheme (VDS.Harmony.autoFill
-- pads the strategy's own picks with grounding companions so no channel is left
-- empty -- a true auto dresses every channel).
-- Main slot (focusedSlot) is always left unchanged. ApplyDye triggers
-- HOUSING_DECOR_CUSTOMIZATION_CHANGED -> OnCustomizationChanged ->
-- LIVE_SLOTS_UPDATE, so the slot rows + preview repaint automatically
-- with no extra dispatch needed.
local function dispatchHarmonise(strategyKey)
    local st   = VDS.Store:GetState()  -- top-level only: harmonise reads live + catalog
    local live = st.session.ui.live
    if not live.active then return end  -- not in live mode; button is a no-op
    if not live.slots then return end   -- exception(nullable): slots absent until LIVE_MODE_ENTER populates them
    if not live.focusedSlot then return end  -- exception(nullable): no slot focused yet

    -- Locate the focused slot and its dye.
    local focusedDyeID
    local otherSlots = {}
    for _, slot in ipairs(live.slots) do
        if slot.ID == live.focusedSlot then
            focusedDyeID = (slot.dyeColorID and slot.dyeColorID ~= 0) and slot.dyeColorID or nil
        else
            otherSlots[#otherSlots + 1] = slot
        end
    end
    if not focusedDyeID then return end  -- exception(nullable): focused slot has no dye; nothing to harmonise around

    local res = VDS.Harmony.forDye(focusedDyeID,
        st.session.dyes.catalog.infos,
        st.session.dyes.ownership.perDye)
    if not res then return end  -- exception(nullable): pre-catalog; forDye returns nil for unknown anchor

    local picks = VDS.Harmony.autoFill(res, strategyKey, #otherSlots)

    -- Fill every non-focused slot in order (picks is sized to #otherSlots).
    for i, slot in ipairs(otherSlots) do
        local pick = picks[i]
        if pick then
            VDS.Store:Dispatch({
                type    = A.LIVE_SET_SLOT_DYE_INTENT,
                payload = { slotID = slot.ID, dyeID = pick.dyeID },
            })
            VDS.LiveCustomizeObserver:ApplyDye(slot.ID, pick.dyeID)
        end
        -- A pick can still be absent only if the palette genuinely can't fill the
        -- channel (tiny family, all duplicates); leave that slot unchanged.
    end
end

-- All 10 strategy buttons (7 chromatic + 3 neutral). Wire stamps _harmonyKey on each.
-- Visibility is layout-gated via live.harmonyShowChromatic / live.harmonyShowNeutral;
-- the Wire loop runs unconditionally so absent widgets are tolerated by the existing
-- false-positive guard on the widget lookup.
local HARMONY_STRATEGIES = {
    "Neutral", "Monochromatic", "Analogous", "Complement", "Split", "Triad", "Clash",
    "Tones", "Contrast", "Accent",
}

local CLICK_HANDLERS = {
    ["liveEdit.ownedCheck"]    = dispatchOwnedOnly,
    ["liveEdit.snapshotBtn"]   = dispatchSnapshot,
    ["liveEdit.revertBtn"]     = dispatchRevert,
    ["liveEdit.applyBtn"]      = dispatchApply,
    ["liveEdit.clearAllBtn"]   = dispatchClearAll,
}

function C:Wire(rootFrame)
    if not (rootFrame and rootFrame.widgets) then return end  -- exception(false-positive): Wire-on-empty-rootFrame is a supported no-op (wire smoke contract)

    for id, handler in pairs(CLICK_HANDLERS) do
        local widget = rootFrame.widgets[id]
        if widget then
                -- labeled-checkbox: outer widget is a Frame; inner CheckButton at ._check.
            local target = widget._check or widget
            target:SetScript("OnClick", handler)
        end
    end

    -- Harmony strategy buttons: stamp _harmonyKey + wire OnClick.
    for _, key in ipairs(HARMONY_STRATEGIES) do
        local widget = rootFrame.widgets["liveEdit.harmonyBtn." .. key]
        if widget then  -- exception(false-positive): Wire tolerates absent widgets (empty-root contract)
            widget._harmonyKey = key
            local capturedKey = key
            widget:SetScript("OnClick", function() dispatchHarmonise(capturedKey) end)
        end
    end

    for i = 1, 3 do
        local widget = rootFrame.widgets["liveEdit.slot" .. i]
        if widget and widget.SetScript then  -- exception(false-positive): Wire tolerates absent widgets (empty-root contract)
            -- slotID not stored on widget at build time; read from state at click time.
            widget:SetScript("OnClick", function(self, button)
                -- Slot is only clickable when live.hasSlotN gates it true, which
                -- means live.slots[i] structurally exists -- strict-read it.
                local slots = VDS.Store:GetState().session.ui.live.slots  -- top-level only: slot click
                self._slotID = slots[i].ID
                -- Right-click clears the slot in-place (donor parity, VDS_LivePreview.lua:178-184).
                -- Apply still required to commit the cleared state to the actual decor.
                if button == "RightButton" then
                    VDS.LiveCustomizeObserver:ClearSlot(self._slotID)
                    return
                end
                C:OnSlotRowClick(self)
            end)
            -- Dynamic per-hover tooltip via the engine (donor parity for the
            -- "Right-click: clear the dye" affordance hint).
            VDS.TooltipEngine:Attach(widget, VDS.TooltipRecipes.LiveSlot)
        end
    end

    -- Recent-dye strip: each pooled swatch re-applies its dye to the focused
    -- slot (donor parity VDS_LivePreview.lua:547-554) AND re-pushes it to the
    -- front of the strip, so the strip tracks most-recently-used -- matching the
    -- dye-list path. (Users expect the last dye they used to sit first.)
    local strip = rootFrame.widgets["liveEdit.recentStrip"]
    if strip then
        for _, btn in ipairs(strip._swatches) do
            btn:SetScript("OnClick", function(self)
                -- Capture the dye BEFORE dispatching: the LIVE_RECENT_DYE_ADD
                -- below re-renders the strip, repainting this pooled swatch, so
                -- self._dyeID would be stale for the reads after it.
                local dyeID = self._dyeID
                -- Strip only clickable while liveEdit is shown (live mode active),
                -- which means LIVE_MODE_ENTER seeded focusedSlot -- strict-read it.
                local live  = VDS.Store:GetState().session.ui.live  -- top-level only: recent-swatch click
                VDS.Store:Dispatch({
                    type    = A.LIVE_SET_SLOT_DYE_INTENT,
                    payload = { slotID = live.focusedSlot, dyeID = dyeID },
                })
                VDS.LiveCustomizeObserver:ApplyDye(live.focusedSlot, dyeID)
                -- Promote to front last (it re-renders the strip; keep it after
                -- the reads above).
                VDS.Store:Dispatch({
                    type    = A.LIVE_RECENT_DYE_ADD,
                    payload = { dyeID = dyeID },
                })
            end)
            VDS.TooltipEngine:Attach(btn, VDS.TooltipRecipes.RecentDye)
        end
        VDS.TooltipEngine:Attach(strip._marker, VDS.TooltipRecipes.RecentStrip)
    end

    -- Apply button gate: enabled when there are pending dye changes AND
    -- the player owns enough of each new dye to cover all staged uses.
    --
    -- Previously gated on live.cost.spend/remove via Blizzard's
    -- GetNumDyesToSpendOnSelectedDecor / GetNumDyesToRemoveOnSelectedDecor.
    -- That signal is unreliable: returns 0 in the "previously-empty slots
    -- now filled with owned dyes" case (verified washbasin 2026-05-27),
    -- which falsely disables Apply when the user has legitimate changes.
    --
    -- New gate combines two state-derived checks:
    --   1. CHANGE check: any current slot.dyeColorID differs from the
    --      LIVE_MODE_ENTER snapshot (live.originalDyes). Both sides
    --      normalize nil/0 to 0 (Blizzard's no-dye sentinel for reads).
    --   2. OWNERSHIP check: aggregate the staged dyeIDs (counting how
    --      many slots each dye is destined for) and require the player's
    --      bag count >= staged uses for each dye. Skipped for clears
    --      (cur == 0, no inventory needed).
    --
    -- The ownership check prevents the silent-revert footgun: Blizzard
    -- previews dyes the player doesn't own (slot widget shows the color)
    -- but CommitDyesForSelectedDecor silently reverts on commit when
    -- inventory is insufficient. By gating on actual ownership we keep
    -- Apply disabled in that case so the user isn't surprised by a revert.
    --
    -- Subscriber refreshes on slot/mode changes AND OWNERSHIP_TICK
    -- (BAG_UPDATE-driven inventory re-scan) so if the player picks up
    -- a missing dye while the panel is open, Apply lights up.
    --
    -- Revert shares the CHANGE check (Blizzard Edit Mode parity: the
    -- RevertChangesButton enables only on HasActiveChanges) but skips the
    -- ownership check -- reverting needs no inventory, so it stays enabled
    -- when Apply is blocked by an unowned staged dye.
    local applyBtn = rootFrame.widgets["liveEdit.applyBtn"]
    if applyBtn then
        C._applyBtn = applyBtn  -- VDS_ApplyDyesKeybind clicks this when visible + enabled
        local revertBtn = rootFrame.widgets["liveEdit.revertBtn"]
        C._revertBtn = revertBtn  -- VDS_RevertDyesKeybind clicks this when visible + enabled

        -- Warn paint: unowned staged dye(s) turn the Apply text semantic.error
        -- (same red as the slot row's not-in-bags warn) and the tooltip names
        -- the blockers. The IDs are stamped on the widget for the R.Apply
        -- recipe to resolve per-hover; the color routes through the Button
        -- skinner's warn state so theme switches repaint it correctly.
        local function setApplyWarn(missingIDs)
            applyBtn._unownedStagedDyeIDs = missingIDs
            VDS.Theme:SetState(applyBtn, { warn = missingIDs ~= nil })
            VDS.Theme:Apply(applyBtn, "Button")
        end

        local function refreshActionsEnabled()
            local state = VDS.Store:GetState()  -- top-level only: Apply/Revert gate subscriber
            local live  = state.session.ui.live
            if not (live and live.active and live.slots and live.originalDyes) then  -- exception(false-positive): live-mode gate; buttons disabled outside live edit
                applyBtn:SetEnabled(false)
                revertBtn:SetEnabled(false)
                setApplyWarn(nil)
                return
            end
            -- slotID -> original dyeID (0 = no-dye sentinel for empty slots)
            local origBySlot = {}
            for _, orig in ipairs(live.originalDyes) do
                origBySlot[orig.slotID] = orig.dyeID or 0  -- exception(boundary): Blizzard's no-dye read is nil/0; normalize to sentinel 0
            end
            -- Walk current slots; collect change + staged-dye usage counts.
            local hasChange = false
            local stagedUses = {}  -- dyeID -> # of slots staged with that dye
            for _, slot in ipairs(live.slots) do
                local cur  = slot.dyeColorID or 0  -- exception(boundary): Blizzard's no-dye read is nil/0; normalize to sentinel 0
                local orig = origBySlot[slot.ID] or 0  -- exception(nullable): slot absent from snapshot = originally empty
                if cur ~= orig then
                    hasChange = true
                    if cur > 0 then
                        stagedUses[cur] = (stagedUses[cur] or 0) + 1
                    end
                end
            end
            revertBtn:SetEnabled(hasChange)
            if not hasChange then
                applyBtn:SetEnabled(false)
                setApplyWarn(nil)
                return
            end
            -- Ownership check: every staged dye needs enough inventory.
            -- Collect ALL shortfalls (not first-fail) so the warn tooltip
            -- names everything blocking the commit.
            local perDye = state.session.dyes.ownership.perDye
            local missing
            for dyeID, needed in pairs(stagedUses) do
                if (perDye[dyeID] or 0) < needed then
                    missing = missing or {}
                    missing[#missing + 1] = dyeID
                end
            end
            applyBtn:SetEnabled(missing == nil)  -- before warn paint: SetEnabled's alpha fade runs first
            setApplyWarn(missing)
        end
        refreshActionsEnabled()
        if C._actionsEnabledToken then VDS.Store:Unsubscribe(C._actionsEnabledToken) end
        C._actionsEnabledToken = VDS.Store:Subscribe(function(actionType)
            if actionType == A.LIVE_MODE_ENTER
                or actionType == A.LIVE_MODE_EXIT
                or actionType == A.LIVE_SLOTS_UPDATE
                or actionType == A.LIVE_COMMIT_SNAPSHOT
                or actionType == A.OWNERSHIP_TICK then
                refreshActionsEnabled()
            end
        end)
    end
end
