-- VDS.Layout -- layout engine.
--
-- Pure Compute over NormalizedConfig produced by VDS.Layout:Normalize. See
-- VDS_LayoutNormalize.lua for the canonical input shape; ADR-025 in
-- HDGR_ARCHITECTURE.md for the rationale.
--
-- Containment: window > panel > slot (header|body|footer) > section > widget.
-- Routing: spec declares `in = "<parentId>"` + optional `slot` (panels only).
--
-- Public API:
--   Layout:Compute(normalized)        -> placements, frameW, frameH  (pure)
--   Layout:Apply(rootFrame, placements)
--   Layout:BuildAll(rootFrame, rawConfig)
--   Layout:Validate(rawConfig)        -> { errors }
--   Layout:AssertValid(rawConfig)     -> raises if any
--
-- Compute reads only its input. BuildAll + Validate consume the RAW
-- (author-facing) config -- they handle frame creation and closed-schema
-- enforcement, not geometry. DescribeView lives in VDS_LayoutDebug.lua.

VDS = VDS or {}
VDS.Layout = VDS.Layout or {}

local Layout = VDS.Layout

-- Chrome kinds. Value is the Blizzard template OR `false` for "plain Frame, no
-- template" (used by `inset` so we paint our own sunken look without fighting
-- InsetFrameTemplate's NineSlice). Membership: `CHROME[name] ~= nil`. The
-- `false` value gets `or nil`-coerced before CreateFrame.
local CHROME = {
    card    = "TooltipBorderedFrameTemplate",
    tooltip = "TooltipBorderedFrameTemplate",
    inset   = false,
}
local function hasChrome(name) return name ~= nil and CHROME[name] ~= nil end
Layout.CHROME = CHROME

-- Over-spec warning dedupe -- persists across Computes because resetting
-- per-Compute caused a feedback loop (Log:Warn -> LOG_PUSH -> Refresh ->
-- Compute -> same over-spec -> ...). Capped so long sessions with dynamic
-- dims don't grow forever.
local OVER_SPEC_WARN_CAP = 256
Layout._overSpecWarned = Layout._overSpecWarned or {}
Layout._overSpecWarnedCount = Layout._overSpecWarnedCount or 0  -- exception(false-positive): idempotent module-field init, not a value default

local function warnOverSpec(key, text)
    if Layout._overSpecWarned[key] then return end
    if Layout._overSpecWarnedCount >= OVER_SPEC_WARN_CAP then
        Layout._overSpecWarned = {}
        Layout._overSpecWarnedCount = 0
    end
    Layout._overSpecWarned[key] = true
    Layout._overSpecWarnedCount = Layout._overSpecWarnedCount + 1
    VDS.Log:Warn("layout", text)
end

-- ============================================================================
-- Geometry primitives
-- ============================================================================

local function clamp(value)
    if not value or value < 0 then return 0 end
    return value
end

local function rect(x, y, w, h)
    return { x = x or 0, y = y or 0, width = clamp(w), height = clamp(h) }  -- exception(optional): x/y optional rect params, default origin
end

local function applyPadding(r, pad)
    return rect(r.x + pad.left, r.y + pad.top,
                r.width - pad.left - pad.right,
                r.height - pad.top - pad.bottom)
end

-- Cross-axis (relative to a stack's primary axis).
local function getCross(spec, axis)
    if axis == "vertical" then return spec.width end
    return spec.height
end

-- ============================================================================
-- Window grid
-- ============================================================================
-- Resolve a track template against available length. liveMask collapses
-- unused rows/cols; "fill" entries split the remaining space.

local function resolveTracks(template, total, gap, liveMask)
    local fixedSum, flex, liveCount = 0, 0, 0
    for i, t in ipairs(template) do
        local live = not liveMask or liveMask[i]
        if live then
            liveCount = liveCount + 1
            if t == "fill" then flex = flex + 1
            else fixedSum = fixedSum + t end  -- templates are normalized pre-solve: number | "fill" only
        end
    end
    local gapTotal = math.max(0, liveCount - 1) * gap
    local flexSize = flex > 0 and math.max(0, (total - fixedSum - gapTotal) / flex) or 0
    local sizes, offsets = {}, {}
    local cursor = 0
    for i, t in ipairs(template) do
        local live = not liveMask or liveMask[i]
        if live then
            sizes[i] = (t == "fill") and flexSize or (tonumber(t) or 0)
            offsets[i] = cursor
            cursor = cursor + sizes[i] + gap
        else
            sizes[i] = 0
            offsets[i] = cursor
        end
    end
    return sizes, offsets
end

local function computeWindowCells(n)
    local w   = n.window
    local pad = w.padding
    local gap = w.gap
    local contentW = clamp(w.frameW - pad * 2)
    local contentH = clamp(w.frameH - pad * 2)

    -- Mark rows/cols touched by at least one visible panel. Spanned tracks
    -- must also be marked live; a colSpan'd panel collapses otherwise.
    local liveRows, liveCols = {}, {}
    for _, panel in pairs(n.panels) do
        if panel.visible and panel.cell then
            local c = w.cells[panel.cell]
            if c then
                for r = c.row, c.row + c.rowSpan - 1 do liveRows[r] = true end
                for k = c.col, c.col + c.colSpan - 1 do liveCols[k] = true end
            end
        end
    end

    local colSizes, colOffsets = resolveTracks(w.cols.sizes, contentW, gap, liveCols)
    local rowSizes, rowOffsets = resolveTracks(w.rows.sizes, contentH, gap, liveRows)

    -- Sum only non-collapsed tracks; avoids overshoot when a spanned panel's
    -- neighbour was track-collapsed.
    local cells = {}
    for name, c in pairs(w.cells) do
        local x = pad + (colOffsets[c.col] or 0)
        local y = pad + (rowOffsets[c.row] or 0)
        local cw, hasW = 0, false
        for i = c.col, c.col + c.colSpan - 1 do
            local s = colSizes[i] or 0  -- exception(nullable): masked-out track has no size; contributes 0
            if s > 0 then
                if hasW then cw = cw + gap end
                cw = cw + s; hasW = true
            end
        end
        local ch, hasH = 0, false
        for i = c.row, c.row + c.rowSpan - 1 do
            local s = rowSizes[i] or 0  -- exception(nullable): masked-out track has no size; contributes 0
            if s > 0 then
                if hasH then ch = ch + gap end
                ch = ch + s; hasH = true
            end
        end
        cells[name] = rect(x, y, cw, ch)
    end
    return cells
end

local function computeSlotRects(panelRect, slots)
    local headerH = slots.header and clamp(slots.header.height) or 0
    local footerH = slots.footer and clamp(slots.footer.height) or 0
    local bodyH = clamp(panelRect.height - headerH - footerH)

    local out = {}
    if slots.header then out.header = rect(0, 0, panelRect.width, headerH) end
    out.body = rect(0, headerH, panelRect.width, bodyH)
    if slots.footer then
        out.footer = rect(0, headerH + bodyH, panelRect.width, footerH)
    end
    return out
end

-- ============================================================================
-- Stack layout (panel slots + section bodies)
-- ============================================================================

local layoutContainer  -- forward

local function layoutStack(specOrSlot, parentRect, children, placements, n, containerId)
    local inner = applyPadding(parentRect, specOrSlot.padding)
    local layout = specOrSlot.layout
    local axis = (layout == "horizontal") and "horizontal"
              or (layout == "fill" and "fill" or "vertical")

    if axis == "fill" then
        for _, childId in ipairs(children) do
            layoutContainer(childId, inner, placements, n, false)
        end
        return
    end

    local along = (axis == "vertical") and inner.height or inner.width
    local gap = specOrSlot.gap
    local fixedSum, flexCount, contentSum = 0, 0, 0
    local sizes = {}

    for _, childId in ipairs(children) do
        local c = n.sections[childId] or n.widgets[childId]
        local s
        if c then
            if axis == "vertical" then s = c.height else s = c.width end
        end
        if s == nil or s == "fill" then
            flexCount = flexCount + 1
            sizes[#sizes + 1] = "fill"
        else
            sizes[#sizes + 1] = s
            fixedSum   = fixedSum   + s
            contentSum = contentSum + s
        end
    end
    local gapTotal = math.max(0, #children - 1) * gap
    local flexAvail = math.max(0, along - fixedSum - gapTotal)

    if (fixedSum + gapTotal) > along + 1 then
        local key = (containerId or "?") .. "|" .. axis .. "|" .. tostring(along)
        warnOverSpec(key, string.format(
            "over-spec in %q (%s): %dpx available but children need %dpx (fixed=%d, gap=%d, %d flex). Drop a fixed %s or split the container.",
            containerId or "(unknown)", axis, math.floor(along + 0.5),
            math.floor(fixedSum + gapTotal + 0.5), math.floor(fixedSum + 0.5),
            math.floor(gapTotal + 0.5), flexCount,
            axis == "vertical" and "height" or "width"))
    end
    local flexSize = flexCount > 0 and (flexAvail / flexCount) or 0

    local cursor = (axis == "vertical") and inner.y or inner.x
    -- align="right" only meaningful with no flex children (flex absorbs slack).
    if specOrSlot.align == "right" and axis == "horizontal" and flexCount == 0 then
        cursor = inner.x + inner.width - (contentSum + gapTotal)
    end
    for i, childId in ipairs(children) do
        local size = sizes[i] == "fill" and flexSize or sizes[i]
        local cspec = n.sections[childId] or n.widgets[childId]
        local crossSize = cspec and getCross(cspec, axis)

        local childRect
        if axis == "vertical" then
            local cw = inner.width
            local x = inner.x
            if type(crossSize) == "number" then
                cw = crossSize
                local ca = cspec and cspec.align  -- exception(optional): cell align is an optional spec field
                if ca == "left" then       x = inner.x
                elseif ca == "right" then  x = inner.x + inner.width - cw
                else                       x = inner.x + (inner.width - cw) / 2 end
            end
            childRect = rect(x, cursor, cw, size)
        else
            local ch = inner.height
            local y = inner.y
            if type(crossSize) == "number" then
                ch = crossSize
                local ca = cspec and cspec.align  -- exception(optional): cell align is an optional spec field
                if ca == "top" then        y = inner.y
                elseif ca == "bottom" then y = inner.y + inner.height - ch
                else                       y = inner.y + (inner.height - ch) / 2 end
            end
            childRect = rect(cursor, y, size, ch)
        end

        layoutContainer(childId, childRect, placements, n, false)
        cursor = cursor + size + gap
    end
end

layoutContainer = function(id, parentRect, placements, n, isPanel)
    placements[id] = parentRect

    if isPanel then
        local panel = n.panels[id]
        if not panel then return end
        local slotRects = computeSlotRects(parentRect, panel.slots)
        local panelChildren = n.children[id] or {}

        for slotName, slotSpec in pairs(panel.slots) do
            local slotRect = slotRects[slotName]
            if slotRect then
                placements[id .. "." .. slotName] = slotRect
                layoutStack(slotSpec, slotRect, panelChildren[slotName] or {},
                            placements, n, id .. "." .. slotName)
            end
        end
        return
    end

    -- Non-panel section. Chromed sections reset child coords to (0,0,w,h)
    -- because children are section-parented (SetPoint section-relative).
    local section = n.sections[id]
    if not section then return end
    local children = (n.children[id] or {}).body or {}
    if hasChrome(section.chrome) then
        local localRect = rect(0, 0, parentRect.width, parentRect.height)
        layoutStack(section, localRect, children, placements, n, id)
    else
        layoutStack(section, parentRect, children, placements, n, id)
    end
end

-- ============================================================================
-- Public API
-- ============================================================================

-- Pure function. `n` MUST be a NormalizedConfig from Layout:Normalize.
function Layout:Compute(n)
    if not n or not n.window or not n.window.frameW then
        error("Layout:Compute: expected NormalizedConfig (call Layout:Normalize first)", 2)
    end

    local placements = {}
    local cells = computeWindowCells(n)

    for panelId, panel in pairs(n.panels) do
        if panel.visible and panel.cell then
            local cellRect = cells[panel.cell]
            if cellRect then
                layoutContainer(panelId, cellRect, placements, n, true)
            end
        end
    end

    return placements, n.window.frameW, n.window.frameH
end

-- ============================================================================
-- Apply
-- ============================================================================

local function rectsEqual(a, b)
    if not a or not b then return false end
    return a.x == b.x and a.y == b.y and a.width == b.width and a.height == b.height
end

local function ApplyOne(widget, region)
    if not widget or not region then return end
    if rectsEqual(widget._lastRect, region) then return end

    if widget.ApplyLayout then  -- exception(optional): ApplyLayout is an opt-in widget-kind hook
        widget:ApplyLayout(region)
    else
        widget:ClearAllPoints()
        widget:SetPoint("TOPLEFT", region.x, -region.y)
        widget:SetSize(region.width, region.height)
    end

    -- Cache AFTER apply so a failed path doesn't poison the next diff.
    widget._lastRect = { x = region.x, y = region.y, width = region.width, height = region.height }

    if widget.OnLayoutChanged then  -- exception(optional): OnLayoutChanged is an opt-in widget-kind hook
        local ok, err = pcall(widget.OnLayoutChanged, widget, region)
        if not ok then
            VDS.Log:Error("layout", "OnLayoutChanged error: " .. tostring(err))
        end
    end
end

local function SetVisible(widget, visible)
    if not widget then return end
    if visible then
        widget:Show()
    else
        widget:Hide()
        widget._lastRect = nil  -- re-show with same rect must re-SetPoint
    end
end

function Layout:Apply(rootFrame, placements)
    if not rootFrame or not placements then return end

    local consumed = {}
    local function placeBucket(bucket, onApply)
        for id, frame in pairs(bucket or {}) do
            local r = placements[id]
            if r then
                ApplyOne(frame, r); SetVisible(frame, true); consumed[id] = true
                if onApply then onApply(frame) end
            else
                SetVisible(frame, false)
            end
        end
    end

    placeBucket(rootFrame.panels)
    placeBucket(rootFrame.sections)
    placeBucket(rootFrame.slotChromes, function(chrome)
        -- One-shot pending-skinner registration: first real rect.
        if chrome._pendingSkin then
            VDS.Theme:Register(chrome, chrome._pendingSkin)
            chrome._pendingSkin = nil
        end
    end)
    placeBucket(rootFrame.widgets)

    -- Slot placements (panelId..".slot") aren't in rootFrame.*; skip orphan check.
    for key in pairs(placements) do
        if not consumed[key] and not key:find("%.") then
            VDS.Log:Warn("layout", ("Apply: placement %q has no matching frame in rootFrame"):format(key))
        end
    end
end

-- ============================================================================
-- Build phase (raw config -- frame creation is one-time scaffolding)
-- ============================================================================

-- Walk `cursor` up the `in` chain. visit(cursor) returns (val[, info]) to stop;
-- nil to keep walking. Errors loud on cycle (malformed config).
local function walkInChain(cursor, originId, config, visit)
    local visited = {}
    while cursor do
        if visited[cursor] then
            error(("Layout: cyclic `in` chain detected at %q (started from %q)"):format(cursor, originId), 2)
        end
        visited[cursor] = true
        local val, info = visit(cursor)
        if val ~= nil then return val, info end
        local spec = (config.panels   and config.panels[cursor])
                  or (config.sections and config.sections[cursor])
                  or (config.widgets  and config.widgets[cursor])
        cursor = spec and spec["in"] or nil
    end
end

local function findEnclosingPanel(id, config)
    return walkInChain(id, id, config, function(cur)
        if config.panels and config.panels[cur] then return cur end
    end)
end

-- First chromed section OR panel above a widget. Chromed sections bear a real
-- Frame and become widget parents; virtual sections pass through.
local function findEnclosingContainer(id, config)
    local widgetSpec = config.widgets and config.widgets[id]
    local start = widgetSpec and widgetSpec["in"]
    if not start then return nil, nil end
    return walkInChain(start, id, config, function(cur)
        if config.panels and config.panels[cur] then return cur, "panel" end
        local s = config.sections and config.sections[cur]
        if s and hasChrome(s.chrome) then return cur, "section" end
    end)
end

function Layout:BuildAll(rootFrame, config, env)
    if not rootFrame then return end  -- exception(false-positive): engine tree-walk; depth mirrors the config tree, not a cliff
    config = config or VDS.LayoutConfig or {}
    rootFrame.panels = rootFrame.panels or {}
    rootFrame.widgets = rootFrame.widgets or {}
    rootFrame.sections = rootFrame.sections or {}

    -- env carries scheme for sub-tree previews / cross-addon themes without
    -- touching Theme.currentScheme. Default = current Environment scheme.
    env = env or VDS.Environment._current

    local function buildKind(kind, parent, spec)
        local kindDef = VDS.WidgetTypes:Get(kind)
        local widget = kindDef.build(parent, spec)
        if not widget then return nil end
        widget._hdgrKind = kind
        if kindDef.skin then
            local state = kindDef.initialState and kindDef.initialState(spec) or nil
            VDS.Theme:Register(widget, kindDef.skin, state, env)
        end
        return widget
    end

    for panelId, panelSpec in pairs(config.panels or {}) do
        if panelSpec.defaultEnabled ~= false and not rootFrame.panels[panelId] then
            local panel = buildKind(panelSpec.kind or "panel", rootFrame, panelSpec)
            if panel then
                panel.id = panelId
                rootFrame.panels[panelId] = panel
            end
        end
    end

    -- Chromed section frames. Tint via theme tokens so sections match palette
    -- regardless of which Blizzard template they inherit.
    local CHROME_BG_TOKEN = {
        card    = "surface.panel_soft",
        tooltip = "surface.panel_soft",
        inset   = "surface.sunken",
    }
    if CreateFrame then
        for sectionId, sectionSpec in pairs(config.sections or {}) do
            local chrome = sectionSpec.chrome
            if hasChrome(chrome) and not rootFrame.sections[sectionId] then
                local inheritsFrom = CHROME[chrome] or nil  -- `false` -> nil
                local panelId = findEnclosingPanel(sectionId, config)
                local panelFrame = panelId and rootFrame.panels[panelId] or rootFrame
                local sectionFrame = CreateFrame("Frame", nil, panelFrame, inheritsFrom)
                if sectionFrame then
                    sectionFrame.id = sectionId
                    rootFrame.sections[sectionId] = sectionFrame
                    local bgToken = CHROME_BG_TOKEN[chrome]
                    if bgToken then
                        local tint = sectionFrame:CreateTexture(nil, "BACKGROUND", nil, 1)
                        tint:SetAllPoints()
                        VDS.Theme:Register(tint, "SectionBgTint", { token = bgToken })
                        sectionFrame._hdgrBgTint = tint
                    end
                end
            end
        end
    end

    -- Panel-slot chrome frames. Pure backdrop -- slot widgets parent to the
    -- panel; chrome sits at a lower frame level.
    rootFrame.slotChromes = rootFrame.slotChromes or {}
    if CreateFrame then
        for panelId, panelSpec in pairs(config.panels or {}) do
            local panelFrame = rootFrame.panels[panelId]
            if panelFrame and panelSpec.slots then
                for slotName, slotSpec in pairs(panelSpec.slots) do
                    local skinName = slotSpec.chrome
                    local key = panelId .. "." .. slotName
                    if skinName and not rootFrame.slotChromes[key] then
                        local chrome = CreateFrame("Frame", nil, panelFrame, "BackdropTemplate")
                        if chrome then
                            if chrome.SetFrameLevel and panelFrame.GetFrameLevel then
                                chrome:SetFrameLevel(panelFrame:GetFrameLevel())
                            end
                            -- Defer skinner until first Apply sizes the chrome.
                            -- CreateTexture skinners SetAllPoints into a 0x0
                            -- rect at zero size and never paint.
                            chrome._pendingSkin = skinName
                            rootFrame.slotChromes[key] = chrome
                        end
                    end
                end
            end
        end
    end

    for widgetId, widgetSpec in pairs(config.widgets or {}) do
        if widgetSpec.defaultEnabled ~= false and not rootFrame.widgets[widgetId] then
            local containerId, containerKind = findEnclosingContainer(widgetId, config)
            local parent
            if containerKind == "section" then
                parent = rootFrame.sections[containerId] or rootFrame
            else
                parent = (containerId and rootFrame.panels[containerId]) or rootFrame
            end
            local widget = buildKind(widgetSpec.kind, parent, widgetSpec)
            if widget then
                widget.id = widgetId
                rootFrame.widgets[widgetId] = widget
            end
        end
    end
end

-- ============================================================================
-- Validation -- catches typos in `in`, `slot`, `kind`, `cell` on raw config.
-- ============================================================================

local UNIVERSAL_WIDGET_FIELDS = {
    kind = true, ["in"] = true, slot = true, order = true,
    binding = true, visible = true, defaultEnabled = true,
    width = true, height = true, align = true,
    -- Tooltip declaration: every widget MUST set tooltip = { recipe = "Name" }
    -- or tooltip = false. Per docs/VDS_TOOLTIP_MIGRATION.md, all tooltip
    -- content lives in Core/VDS_TooltipRecipes.lua. The Validate pass below
    -- enforces shape + recipe-exists.
    tooltip = true,
    -- Phase 1 async-resolver middleware: declarative async-name kick for
    -- list-bound widgets. Array of { idField, valueField, resolver }. See
    -- VDS_BindingEngine processResolveAsync.
    resolveAsync = true,
}
local PANEL_SPEC_FIELDS = {
    kind = true, cell = true, slots = true, frameName = true,
    visible = true, defaultEnabled = true, visibleInViews = true,
    bodyLayout = true, chrome = true, padding = true, gap = true,
}
local SECTION_SPEC_FIELDS = {
    ["in"] = true, slot = true, order = true,
    layout = true, padding = true, gap = true, chrome = true,
    visible = true, align = true, width = true, height = true,
}

function Layout:Validate(config)
    config = config or VDS.LayoutConfig or {}  -- exception(false-positive): closed-schema validator; per-field check ladder is the grammar
    local errors = {}
    local function err(msg) errors[#errors + 1] = msg end
    local function hasKind(kind) return VDS.WidgetTypes:TryGet(kind) ~= nil end
    -- Shared with Normalize so we don't maintain two copies of the
    -- flat-or-named track decoder.
    local normalizeTracks = Layout._normalizeTracks

    -- Window shape.
    local windowCfg = config.window
    if not windowCfg then
        err("config.window is missing")
    else
        if not windowCfg.views or type(windowCfg.views) ~= "table" then
            err("config.window.views must be a table")
        else
            if not windowCfg.defaultView then
                err("config.window.defaultView is required")
            elseif not windowCfg.views[windowCfg.defaultView] then
                err(("config.window.defaultView = %q does not exist in window.views"):format(tostring(windowCfg.defaultView)))
            end
            local function validDim(v) return type(v) == "number" or v == "auto" end
            for viewName, viewSpec in pairs(windowCfg.views) do
                if not validDim(viewSpec.width) then
                    err(("config.window.views.%s.width must be a number or \"auto\""):format(viewName))
                end
                if not validDim(viewSpec.height) then
                    err(("config.window.views.%s.height must be a number or \"auto\""):format(viewName))
                end
                if not viewSpec.columns then
                    err(("config.window.views.%s.columns is required"):format(viewName))
                end
                if not viewSpec.rows then
                    err(("config.window.views.%s.rows is required"):format(viewName))
                end
                if viewSpec.dynamicColumns ~= nil and type(viewSpec.dynamicColumns) ~= "string" then
                    err(("config.window.views.%s.dynamicColumns must be a selector name string"):format(viewName))
                end
                if viewSpec.dynamicRows ~= nil and type(viewSpec.dynamicRows) ~= "string" then
                    err(("config.window.views.%s.dynamicRows must be a selector name string"):format(viewName))
                end
                if viewSpec.dynamicHeight ~= nil and type(viewSpec.dynamicHeight) ~= "string" then
                    err(("config.window.views.%s.dynamicHeight must be a selector name string"):format(viewName))
                end
            end
        end
    end

    local containerIds = {}
    for id in pairs(config.panels   or {}) do containerIds[id] = "panel"   end
    for id in pairs(config.sections or {}) do containerIds[id] = "section" end

    local panelSlots = {}
    for id, spec in pairs(config.panels or {}) do
        panelSlots[id] = { body = true }
        for slotName in pairs(spec.slots or {}) do panelSlots[id][slotName] = true end
    end

    -- Stacked containers require explicit `order`; missing order defaults to
    -- 0 and sorts ABOVE ordered siblings (silent regression class).
    local function isStackedContainer(parentId, slot)
        if not parentId then return false end
        local panelSpec = config.panels and config.panels[parentId]
        if panelSpec then
            local slots = panelSpec.slots or {}
            local slotSpec = slot and slots[slot]
            if slotSpec then
                local layout = slotSpec.layout
                    or ((slot == "header" or slot == "footer") and "horizontal")
                    or "vertical"
                return layout == "vertical" or layout == "horizontal"
            end
            local bodyLayout = panelSpec.bodyLayout or "vertical"
            return bodyLayout == "vertical" or bodyLayout == "horizontal"
        end
        local sectionSpec = config.sections and config.sections[parentId]
        if sectionSpec then
            local l = sectionSpec.layout or "vertical"
            return l == "vertical" or l == "horizontal"
        end
        return false
    end

    -- Sub-table mis-keying audit. A widget spec wrongly placed in config.panels
    -- is structurally invisible -- no build, no error, no crash.
    for id, spec in pairs(config.panels or {}) do
        if spec["in"] then
            err(("panel %q has `in` = %q -- panels are placed via `cell`, not `in`."
                .. " Did you mean to put this in config.widgets or config.sections?")
                :format(id, tostring(spec["in"])))
        end
        if spec.kind then
            local kindDef = VDS.WidgetTypes:TryGet(spec.kind)
            if kindDef and kindDef.dispatch then  -- exception(optional): dispatch is optional on static kinds (spacer/label)
                err(("panel %q has kind = %q -- that's a leaf widget kind "
                    .. "(has dispatch). Did you mean config.widgets?"):format(id, spec.kind))
            end
        end
    end
    for id, spec in pairs(config.sections or {}) do
        if spec.kind then
            err(("section %q has `kind` = %q -- sections don't have a kind."
                .. " Did you mean config.widgets?"):format(id, tostring(spec.kind)))
        end
    end
    for id, spec in pairs(config.widgets or {}) do
        if spec.cell then
            err(("widget %q has `cell` = %s -- cell is panel placement, not widget."
                .. " Did you mean config.panels?"):format(id, tostring(spec.cell)))
        end
    end

    -- Validate panels.
    for id, spec in pairs(config.panels or {}) do
        for fieldName in pairs(spec) do
            if not PANEL_SPEC_FIELDS[fieldName] then
                err(("panel %q: unknown field %q -- not in PANEL_SPEC_FIELDS."
                    .. " Typo? Wrong sub-table (widgets/sections)?"):format(id, fieldName))
            end
        end
        if spec.kind and not hasKind(spec.kind) then
            err(("panel %q: kind %q is not a registered WidgetType"):format(id, spec.kind))
        end
        for view, cellName in pairs(spec.cell or {}) do
            local viewSpec = (config.window and config.window.views or {})[view]  -- exception(false-positive): validator probes possibly-malformed config
            if not viewSpec then
                err(("panel %q: cell view %q does not exist in window.views"):format(id, view))
            else
                local hasExplicit = viewSpec.cells and viewSpec.cells[cellName]
                if not hasExplicit then
                    local _, rowNames = normalizeTracks(viewSpec.rows,    "rows")
                    local _, colNames = normalizeTracks(viewSpec.columns, "columns")
                    local autoDerivable =
                        (#(viewSpec.columns or {}) <= 1 and rowNames[cellName]) or
                        (#(viewSpec.rows    or {}) <= 1 and colNames[cellName])
                    if not autoDerivable then
                        err(("panel %q: cell %q does not exist in window.views.%s.cells (no explicit entry, and no auto-derive from named tracks)"):format(
                            id, cellName, view))
                    end
                end
            end
        end
    end

    -- Shared parent-chain + slot + order check used by sections AND widgets.
    local function checkParentChain(kindLabel, id, spec)
        local parent = spec["in"]
        if not parent then
            err(("%s %q: missing `in` field"):format(kindLabel, id)); return
        end
        if not containerIds[parent] then
            err(("%s %q: `in` = %q does not resolve to a panel or section"):format(kindLabel, id, parent))
            return
        end
        if spec.slot then
            if containerIds[parent] == "panel" and not panelSlots[parent][spec.slot] then
                err(("%s %q: slot %q not declared on panel %q"):format(kindLabel, id, spec.slot, parent))
            elseif containerIds[parent] == "section" then
                err(("%s %q: slot %q is ignored because parent %q is a section, not a panel"):format(kindLabel, id, spec.slot, parent))
            end
        end
        if isStackedContainer(parent, spec.slot) and spec.order == nil then
            err(("%s %q: `in` = %q is a stacked container; `order` is required"):format(kindLabel, id, tostring(parent)))
        end
    end

    local function checkClosedSchema(spec, allowed, prefix, sourceLabel, hint)
        for fieldName in pairs(spec) do
            if not allowed[fieldName] then
                err(("%s: unknown field %q -- not in %s. %s"):format(
                    prefix, fieldName, sourceLabel, hint))
            end
        end
    end

    for id, spec in pairs(config.sections or {}) do
        checkClosedSchema(spec, SECTION_SPEC_FIELDS, ("section %q"):format(id),
            "SECTION_SPEC_FIELDS", "Typo? Wrong sub-table (widgets/panels)?")
        checkParentChain("section", id, spec)
    end

    -- Font requirement opt-in via WidgetType.requiresFont(spec). Replaces the
    -- old hardcoded text-bearing list that didn't track new kinds.
    local function specRequiresFont(spec)
        local kindDef = spec.kind and VDS.WidgetTypes:TryGet(spec.kind)
        if not kindDef or type(kindDef.requiresFont) ~= "function" then return false end
        return kindDef.requiresFont(spec) == true
    end

    for id, spec in pairs(config.widgets or {}) do
        if not spec.kind then
            err(("widget %q: missing `kind` field"):format(id))
        elseif not hasKind(spec.kind) then
            err(("widget %q: kind %q is not a registered WidgetType"):format(id, spec.kind))
        end
        checkParentChain("widget", id, spec)
        if spec.kind and specRequiresFont(spec) and not spec.font then
            err(("widget %q: kind %q is text-bearing and requires a `font` role"):format(id, spec.kind))
        end

        -- Tooltip declaration: every widget MUST declare tooltip status.
        -- Valid: false (explicit no-tooltip) OR { recipe = "Name" }.
        -- Inline strings/tables/functions in spec are forbidden -- all tooltip
        -- content lives in Core/VDS_TooltipRecipes.lua for centralization +
        -- localization seam + style consistency. See docs/VDS_TOOLTIP_MIGRATION.md.
        local t = spec.tooltip
        if t == nil then
            -- tostring: spec.kind may be nil here (already err'd above); %q on nil throws.
            err(("widget %q (kind %q): missing `tooltip` -- declare { recipe = name } or false"):format(id, tostring(spec.kind)))
        elseif t == false then
            -- ok: explicit opt-out
        elseif type(t) == "table" then
            if type(t.recipe) ~= "string" then
                err(("widget %q tooltip: table form requires `recipe` field (got %s)"):format(id, type(t.recipe)))
            elseif VDS.TooltipRecipes[t.recipe] == nil then
                err(("widget %q tooltip: recipe %q not registered in VDS.TooltipRecipes"):format(id, t.recipe))
            end
        else
            err(("widget %q tooltip: must be { recipe = name } or false (got %s)"):format(id, type(t)))
        end

        if spec.kind == "scrollbox" then
            if spec.rowKind then
                if not VDS.RowRegistry:Get(spec.rowKind) then
                    err(("widget %q: scrollbox rowKind %q not registered in VDS.RowRegistry"):format(id, spec.rowKind))
                end
            elseif not spec.rowHeight then
                err(("widget %q: scrollbox without rowKind requires a top-level rowHeight"):format(id))
            end
        end

        if spec.kind and hasKind(spec.kind) then
            local kindDef = VDS.WidgetTypes:TryGet(spec.kind)
            if kindDef.specFields then
                local allowed = {}
                for k in pairs(UNIVERSAL_WIDGET_FIELDS) do allowed[k] = true end
                for _, fieldName in ipairs(kindDef.specFields) do allowed[fieldName] = true end
                checkClosedSchema(spec, allowed, ("widget %q (kind %q)"):format(id, spec.kind),
                    "UNIVERSAL_WIDGET_FIELDS or WidgetType.specFields", "Typo? Retracted pattern?")
            end
        end
    end

    return errors
end

function Layout:AssertValid(config)
    local errors = self:Validate(config)
    if errors and #errors > 0 then
        error("Layout:AssertValid: LayoutConfig has " .. #errors .. " validation error(s):\n  - "
            .. table.concat(errors, "\n  - "), 2)
    end
end
