-- VDS_BindingEngine
-- ============================================================================
-- Wires selectors to widget dispatchers. On boot, computes each bound
-- widget's reads-closure from its binding(s). On every Store notify, walks
-- bound widgets and pushes new selector values via each kind's dispatch.push.

VDS = VDS or {}
VDS.BindingEngine = VDS.BindingEngine or {}
-- VDS.Locale: optional i18n table; defaulted here so `locale:KEY` resolver
-- can strict-read it without cascade guards.
VDS.Locale = VDS.Locale or {}

local Engine = VDS.BindingEngine

-- `static:literal` + `locale:KEY` bypass fixed/looked-up text.
local function resolve(spec, state, ctx)
    if type(spec) ~= "string" then return spec end
    if spec:sub(1, 7) == "static:" then return spec:sub(8) end
    if spec:sub(1, 7) == "locale:" then
        local k = spec:sub(8)
        return VDS.Locale[k] or k
    end
    return VDS.Selectors:Call(spec, state, ctx)
end

-- Per-widget async-kick: after resolved values are computed, walk
-- widget._lsResolveAsync (declarative widget config). For each entry,
-- inspect each row in resolved.rows; if the row's valueField is nil and
-- the row's idField is set, call resolver:Request(id) to kick the async
-- load. The resolver handles its own pending-id dedup so duplicate calls
-- across paints are cheap.
--
-- Widget config shape (array-shaped from day one):
--   resolveAsync = {
--     { idField = "itemID", valueField = "name", resolver = X },
--     ...
--   }
local function processResolveAsync(widget, resolved)
    local spec = widget._lsResolveAsync
    if not (spec and resolved and resolved.rows) then return end  -- exception(optional): resolveAsync is declared-optional widget config
    for _, entry in ipairs(spec) do
        local idField, valueField, resolver = entry.idField, entry.valueField, entry.resolver
        if idField and valueField and resolver and resolver.Request then  -- exception(optional): entry fields are the declared contract; partial = skip
            for _, row in ipairs(resolved.rows) do
                local id = row[idField]
                if id and row[valueField] == nil then
                    resolver:Request(id)
                end
            end
        end
    end
end

-- Scalar binding: single-field dispatch wraps as { field = selector }.
-- Multi-field dispatch: selector must return a struct matching fields[]
-- -- marked _lsSpread so Apply passes the return through directly.
-- Table binding: pass-through.
local function normaliseBinding(binding, kind, kindDef)
    if type(binding) == "string" then
        local dispatch = kindDef and kindDef.dispatch or nil  -- exception(false-positive): normaliser validates the kind contract; error()s below
        local fields = dispatch and dispatch.fields  -- exception(false-positive): normaliser validates; error()s below
        if not (fields and fields[1]) then
            error(("binding: kind %q does not accept scalar binding %q"):format(
                tostring(kind), tostring(binding)), 2)
        end
        if #fields > 1 then
            return { _lsSpread = true, _lsSelector = binding } -- spread: Apply unpacks selector return into values
        end
        return { [fields[1]] = binding }
    end
    if type(binding) == "table" then return binding end
    return nil
end

function Engine:Build(rootFrame, config)
    if not (rootFrame and rootFrame.widgets and config and config.widgets) then return end  -- exception(false-positive): engine entrypoint; depth below is the binding walk, not a cliff
    for id, spec in pairs(config.widgets) do
        local widget = rootFrame.widgets[id]
        if widget then
            if spec.binding then
                local kindDef = VDS.WidgetTypes:TryGet(spec.kind)
                local dispatcher = kindDef and kindDef.dispatch and kindDef.dispatch.push  -- exception(false-positive): probe feeds the error() below
                if not dispatcher then
                    error(("binding: kind %q has no dispatch (widget %q)"):format(
                        tostring(spec.kind), tostring(id)), 2)
                end
                local binding = normaliseBinding(spec.binding, spec.kind, kindDef)
                widget._lsBinding    = binding
                widget._lsDispatcher = dispatcher

                local readsClosure = {}
                local function unionReads(selectorSpec)
                    if type(selectorSpec) == "string"
                       and selectorSpec:sub(1, 7) ~= "static:"
                       and selectorSpec:sub(1, 7) ~= "locale:" then
                        local sub = VDS.Selectors:GetReads(selectorSpec)
                        for p in pairs(sub) do readsClosure[p] = true end
                    end
                end
                if binding._lsSpread then
                    unionReads(binding._lsSelector)
                else
                    for _, selectorSpec in pairs(binding) do unionReads(selectorSpec) end
                end
                widget._lsReadsClosure = readsClosure
                widget._lsBound = true

                -- resolveAsync: declarative async-kick config (array-shaped). Each
                -- entry is { idField, valueField, resolver }. BindingEngine walks
                -- resolved.rows after each push and calls resolver:Request(id) for
                -- any row where row[valueField] is nil but row[idField] is set.
                -- Row factories no longer need the if-not-data.name-Request
                -- boilerplate. Per-id dedup is the resolver's responsibility.
                if spec.resolveAsync then
                    widget._lsResolveAsync = spec.resolveAsync
                end

                -- Phase 0 visibility hook: when widget transitions hidden -> visible
                -- (including parent-show cascade, e.g. tab switch revealing the
                -- panel), push the latest state into the widget so it has fresh
                -- data on first paint. Without this hook, widgets that were
                -- hidden at boot would never receive their first push because
                -- Engine:Apply skips hidden widgets (Phase 0 default-skip), and
                -- the action that shows them (e.g. SET_FILTER_MODE) usually
                -- doesn't invalidate the paths in their readsClosure.
                widget:HookScript("OnShow", function(self)
                    if not (self._lsBound and self._lsBinding and self._lsDispatcher) then return end
                    local state = VDS.Store:GetState()
                    local ctx = { actionType = "ON_SHOW" }
                    local resolved
                    if self._lsBinding._lsSpread then
                        resolved = resolve(self._lsBinding._lsSelector, state, ctx) or {}
                    else
                        resolved = {}
                        for field, selectorSpec in pairs(self._lsBinding) do
                            resolved[field] = resolve(selectorSpec, state, ctx)
                        end
                    end
                    processResolveAsync(self, resolved)
                    self._lsDispatcher(self, resolved, ctx)
                end)
            end
            -- visibleWhen reads are unioned into the closure so the widget
            -- re-evaluates when ANY dependency invalidates.
            if spec.visibleWhen then
                widget._lsVisibleWhen = spec.visibleWhen
                local sub = VDS.Selectors:GetReads(spec.visibleWhen)
                widget._lsReadsClosure = widget._lsReadsClosure or {}
                for p in pairs(sub) do widget._lsReadsClosure[p] = true end
            end
        end
    end
end

function Engine:Apply(rootFrame, state, ctx, invalidation)
    if not (rootFrame and rootFrame.widgets and state) then return end  -- exception(false-positive): engine entrypoint; depth below is the apply walk, not a cliff
    invalidation = invalidation or "*"
    -- DIAGNOSTIC
    Engine._applyCount = (Engine._applyCount or 0) + 1
    local dispatchCtx = { actionType = ctx and ctx.actionType }  -- exception(optional): ctx optional on boot-path Apply calls
    for _, widget in pairs(rootFrame.widgets) do
        if widget._lsBound or widget._lsVisibleWhen then
            local readsClosure = widget._lsReadsClosure or "*"
            if VDS.Paths.MatchesAny(readsClosure, invalidation) then
                -- visibleWhen runs BEFORE binding push so hidden widgets don't waste paint.
                if widget._lsVisibleWhen and widget.Show and widget.Hide then
                    local visible = VDS.Selectors:Call(widget._lsVisibleWhen, state, ctx)
                    if visible then
                        if not widget:IsShown() then widget:Show() end
                    else
                        if widget:IsShown() then widget:Hide() end
                    end
                end
                if widget._lsBound and widget._lsBinding and widget._lsDispatcher then
                    -- Default-skip-if-hidden (Phase 0 async-resolver migration). Hidden widgets
                    -- skip BOTH selector eval AND dispatch. Was previously gated on _lsVisibleWhen
                    -- (only widgets with explicit visibleWhen skipped) -- zero widgets in any
                    -- Lattice addon actually used visibleWhen, so every widget was pushed
                    -- regardless of tab visibility. At HDGR scale (103 binding-tick widgets,
                    -- 50 cold-item resolves per session) that was 5150 wasted selector evals.
                    -- Visibility transitions must route through Store dispatch (MainFrame
                    -- subscriber Show/Hide runs BEFORE Apply) so IsShown reflects intent.
                    local skip = widget.IsShown and not widget:IsShown()
                    if not skip then
                        local resolved
                        if widget._lsBinding._lsSpread then
                            resolved = resolve(widget._lsBinding._lsSelector, state, ctx) or {} -- selector return IS the values struct
                        else
                            resolved = {}
                            for field, selectorSpec in pairs(widget._lsBinding) do
                                resolved[field] = resolve(selectorSpec, state, ctx)
                            end
                        end
                        processResolveAsync(widget, resolved)
                        widget._lsDispatcher(widget, resolved, dispatchCtx)
                    end
                end
            end
        end
    end
end
