-- VDS.Layout:Normalize -- ADR-025 seam.
--
-- Reads (rawConfig, ctx) -> NormalizedConfig. The author writes shorthand
-- (padding tokens, "auto"/"content" widths, visible selectors, per-view cell
-- maps, dynamic-track selectors); Normalize decodes them all upfront so
-- VDS.Layout:Compute can be a pure function of its input -- zero reads of
-- Store, Selectors, or Theme inside the layout walk.
--
-- Field names preserved (padding, width, height, visible, cell, columns,
-- rows); only values change shape. See ADR-025 in HDGR_ARCHITECTURE.md for
-- the full canonical schema.

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

-- ============================================================================
-- Module-local constants + tiny helpers
-- ============================================================================

local ZERO_PAD = { top = 0, right = 0, bottom = 0, left = 0 }

local function resolveSpacing(value)
    if value == nil then return 0 end
    if type(value) == "number" then return value end
    if type(value) == "string" then
        local n = VDS.Theme:GetMetric("spacing." .. value)
        if not n then
            error(("Normalize: unknown spacing token %q (expected xs/sm/md/lg/xl/xxl/huge)"):format(value), 2)
        end
        return n
    end
    error(("Normalize: invalid spacing value %s"):format(tostring(value)), 2)
end

local function normalizePadding(p)
    if p == nil then return ZERO_PAD end
    if type(p) == "number" or type(p) == "string" then
        local n = resolveSpacing(p)
        return { top = n, right = n, bottom = n, left = n }
    end
    if type(p) == "table" then
        return {
            top    = resolveSpacing(p.top),
            right  = resolveSpacing(p.right),
            bottom = resolveSpacing(p.bottom),
            left   = resolveSpacing(p.left),
        }
    end
    error(("Normalize: invalid padding value %s"):format(tostring(p)), 2)
end

-- Flat array of numbers/"fill" OR array-of-{name,size}. Returns (sizes, names).
local function normalizeTracks(template, axis)
    if type(template) ~= "table" or #template == 0 then
        return template or {}, {}
    end
    if type(template[1]) ~= "table" then
        return template, {}  -- flat form passthrough
    end
    local key = (axis == "rows") and "height" or "width"
    local sizes, names = {}, {}
    for i, entry in ipairs(template) do
        sizes[i] = entry[key] or entry.size
        if entry.name then names[entry.name] = i end
    end
    return sizes, names
end

local function resolveSpan(cellSpec, axis, totalTracks)
    local v = cellSpec[axis .. "Span"] or 1  -- exception(optional): span optional in cell specs; default 1
    if type(v) == "string" then
        if v == "fill" then return totalTracks end
        error(("Normalize: unknown %sSpan token %q (expected number or \"fill\")"):format(axis, v), 2)
    end
    return v
end

-- nil/missing -> true (default visible). Selector + function forms return bool.
local function resolveVisible(spec, view, state, ctx)
    local vv = spec.visibleInViews
    if vv ~= nil then
        local found = false
        for _, m in ipairs(vv) do if m == view then found = true; break end end
        if not found then return false end
    end
    local v = spec.visible
    if v == nil then return true end
    if type(v) == "boolean" then return v end
    if type(v) == "string" then
        return not not VDS.Selectors:Call(v, state, ctx)
    end
    if type(v) == "function" then
        return not not v(state, ctx)
    end
    return true   -- unknown form: default visible (validator should flag)
end

-- dynamicColumns/Rows selector -> tracks table. Strict; nil if not declared.
local function resolveDynamicTracks(viewSpec, key, state, ctx)
    local selName = viewSpec[key]
    if not selName then return nil end
    local result = VDS.Selectors:Call(selName, state, ctx)
    if type(result) ~= "table" then
        error(("Normalize: dynamic%s selector %q returned %s; expected a table"):format(
            key:gsub("^dynamic", ""), selName, type(result)), 2)
    end
    return result
end

-- Number passthrough or name lookup against the track's names map.
local function resolveCellAxis(value, names, dimLabel, cellName)
    if value == nil then return 1 end
    if type(value) == "number" then return value end
    if type(value) == "string" then
        local idx = names and names[value]
        if not idx then
            error(("Normalize: cell %q references %s = %q but no track has that name"):format(
                tostring(cellName), dimLabel, value), 2)
        end
        return idx
    end
    error(("Normalize: cell %q has invalid %s %q (expected number or name)"):format(
        tostring(cellName), dimLabel, tostring(value)), 2)
end

-- ============================================================================
-- Cell auto-derive (single-axis views) + collision check
-- ============================================================================

local function buildEffectiveCells(viewSpec, cols, rows, colNames, rowNames)
    local out, fromRow = {}, {}
    if #cols == 1 and rowNames then
        for name, idx in pairs(rowNames) do
            out[name] = { col = 1, row = idx, colSpan = 1, rowSpan = 1 }
            fromRow[name] = true
        end
    end
    if #rows == 1 and colNames then
        for name, idx in pairs(colNames) do
            if fromRow[name] then
                error(("Normalize: cell name %q exists in BOTH a named row AND a named "
                    .. "column of the same view (auto-derive ambiguous). Rename one or "
                    .. "declare an explicit `cells` entry."):format(name), 2)
            end
            out[name] = { col = idx, row = 1, colSpan = 1, rowSpan = 1 }
        end
    end
    for name, spec in pairs(viewSpec.cells or {}) do
        out[name] = {
            col     = resolveCellAxis(spec.col, colNames, "col", name),
            row     = resolveCellAxis(spec.row, rowNames, "row", name),
            colSpan = resolveSpan(spec, "col", #cols),
            rowSpan = resolveSpan(spec, "row", #rows),
        }
    end
    return out
end

-- ============================================================================
-- Window resolution
-- ============================================================================

local function resolveWindow(rawWindow, view, state, ctx)
    local viewSpec = rawWindow.views[view]
    if not viewSpec then
        error(("Normalize: unknown view %q"):format(tostring(view)), 2)
    end

    local pad = resolveSpacing(rawWindow.padding)
    local gap = resolveSpacing(rawWindow.gap)

    local rawCols = resolveDynamicTracks(viewSpec, "dynamicColumns", state, ctx) or viewSpec.columns
    local rawRows = resolveDynamicTracks(viewSpec, "dynamicRows",    state, ctx) or viewSpec.rows
    local colSizes, colNames = normalizeTracks(rawCols, "columns")
    local rowSizes, rowNames = normalizeTracks(rawRows, "rows")

    -- "auto" sums numeric tracks + padding + gaps. "fill" tracks are
    -- incompatible with "auto" -- fail loud (silent off-by-N-px otherwise).
    local function resolveDim(value, sizes, dimName)
        if value == "auto" then
            local sum = 0
            for i, t in ipairs(sizes) do
                local n = tonumber(t)
                if not n then
                    error(("Normalize: view %q has non-numeric %s[%d] = %q; \"fill\" is incompatible with \"auto\""):format(
                        view, dimName, i, tostring(t)), 2)
                end
                sum = sum + n
            end
            return pad * 2 + sum + math.max(0, #sizes - 1) * gap
        elseif type(value) == "number" then
            return value
        else
            error(("Normalize: view %q has invalid %s %q (expected number or \"auto\")"):format(
                view, dimName, tostring(value)), 2)
        end
    end

    local frameW = resolveDim(viewSpec.width,  colSizes, "width/columns")
    local frameH = resolveDim(viewSpec.height, rowSizes, "height/rows")

    -- dynamicHeight: optional selector override (live mode grows window).
    if viewSpec.dynamicHeight then
        local v = VDS.Selectors:Call(viewSpec.dynamicHeight, state, ctx)
        if type(v) == "number" then frameH = v end
    end

    return {
        padding = pad,
        gap     = gap,
        frameW  = frameW,
        frameH  = frameH,
        cols    = { sizes = colSizes, names = colNames },
        rows    = { sizes = rowSizes, names = rowNames },
        cells   = buildEffectiveCells(viewSpec, colSizes, rowSizes, colNames, rowNames),
    }
end

-- ============================================================================
-- Per-entity resolution
-- ============================================================================

-- "auto" -> intrinsic from ctx.intrinsics, else "fill". "fill"/number/nil
-- passthrough. "content" handled later in resolveContentSizes.
-- axisField is "width" or "height" -- selects the intrinsic axis.
local function resolveAxisSize(id, value, axisField, intrinsics)
    if value == "auto" then
        local entry = id and intrinsics[id]
        if entry then
            local v = (axisField == "height") and entry.height or entry.width
            if type(v) == "number" then return v end
        end
        return "fill"
    end
    return value
end

local function resolvePanel(id, rawSpec, view, state, ctx, _intrinsics)
    -- Slot defaults: header = 34, footer = 28. Framework defaults, not magic.
    local DEFAULT_HEADER_HEIGHT, DEFAULT_FOOTER_HEIGHT = 34, 28
    local bodyLayout = rawSpec.bodyLayout or "vertical"

    local slots = {}
    if rawSpec.slots then
        for name, slotSpec in pairs(rawSpec.slots) do
            local layout = slotSpec.layout
                or ((name == "header" or name == "footer") and "horizontal")
                or "vertical"
            slots[name] = {
                layout  = layout,
                padding = normalizePadding(slotSpec.padding),
                gap     = resolveSpacing(slotSpec.gap),
                chrome  = slotSpec.chrome,
                height  = slotSpec.height
                          or (name == "header" and DEFAULT_HEADER_HEIGHT)
                          or (name == "footer" and DEFAULT_FOOTER_HEIGHT)
                          or nil,
                align   = slotSpec.align,
            }
        end
    end
    if not slots.body then
        slots.body = { layout = bodyLayout, padding = ZERO_PAD, gap = 0 }
    end

    return {
        id       = id,
        kind     = rawSpec.kind,
        visible  = resolveVisible(rawSpec, view, state, ctx),
        cell     = rawSpec.cell and rawSpec.cell[view] or nil,
        chrome   = rawSpec.chrome,
        padding  = normalizePadding(rawSpec.padding),
        gap      = resolveSpacing(rawSpec.gap),
        slots    = slots,
        defaultEnabled = rawSpec.defaultEnabled,
    }
end

local function resolveSection(id, rawSpec, view, state, ctx, intrinsics)
    return {
        id      = id,
        ["in"]  = rawSpec["in"],
        slot    = rawSpec.slot or "body",
        order   = rawSpec.order or 0,  -- exception(optional): order optional in section specs; default 0
        visible = resolveVisible(rawSpec, view, state, ctx),
        layout  = rawSpec.layout or "vertical",
        padding = normalizePadding(rawSpec.padding),
        gap     = resolveSpacing(rawSpec.gap),
        chrome  = rawSpec.chrome,
        align   = rawSpec.align,
        width   = resolveAxisSize(id, rawSpec.width,  "width",  intrinsics),
        height  = resolveAxisSize(id, rawSpec.height, "height", intrinsics),
    }
end

local function resolveWidget(id, rawSpec, view, state, ctx, intrinsics)
    local out = {
        id      = id,
        kind    = rawSpec.kind,
        ["in"]  = rawSpec["in"],
        slot    = rawSpec.slot or "body",
        order   = rawSpec.order or 0,  -- exception(optional): order optional in widget specs; default 0
        visible = resolveVisible(rawSpec, view, state, ctx),
        align   = rawSpec.align,
        width   = resolveAxisSize(id, rawSpec.width,  "width",  intrinsics),
        height  = resolveAxisSize(id, rawSpec.height, "height", intrinsics),
        defaultEnabled = rawSpec.defaultEnabled,
    }
    -- Kind-specific fields (text, font, binding, action, variant, rowKind...)
    -- aren't decoded here -- they're consumed by the widget kind's
    -- build/dispatch/skin. Preserve every other field verbatim.
    for k, v in pairs(rawSpec) do
        if out[k] == nil and k ~= "visible" and k ~= "visibleInViews" then
            out[k] = v
        end
    end
    return out
end

-- ============================================================================
-- Content-size resolution (post-order over the tree)
-- ============================================================================
-- width/height = "content" -> sum of visible descendants on that axis.
-- Walks bottom-up so children are sized before parents try to sum them.
-- Returns "fill" if any descendant is indeterminate.

local function resolveContentSizes(panels, sections, widgets, children)
    local function specOf(id)
        return panels[id] or sections[id] or widgets[id]
    end

    local function getAxis(node, axis)
        if axis == "vertical" then return node.height end
        return node.width
    end

    local memo = {}
    local function measure(id, axis)
        local key = id .. "|" .. axis
        if memo[key] ~= nil then
            local v = memo[key]
            if v == false then return nil end  -- false = memoized "indeterminate"
            return v
        end
        local node = specOf(id)
        if not node then memo[key] = false; return nil end

        local own = getAxis(node, axis)
        if type(own) == "number" then memo[key] = own; return own end

        local bodyChildren = (children[id] or {}).body or {}
        if #bodyChildren == 0 then memo[key] = 0; return 0 end

        -- Children's axis = parent's layout direction.
        local localAxis = node.layout == "horizontal" and "horizontal"
                       or node.layout == "vertical"   and "vertical"
                       or axis

        local total = 0
        for _, childId in ipairs(bodyChildren) do
            local cnode = specOf(childId)
            if not cnode then memo[key] = false; return nil end
            local s = getAxis(cnode, localAxis)
            if s == "content" then s = measure(childId, localAxis) end
            if type(s) ~= "number" then memo[key] = false; return nil end
            total = total + s
        end
        total = total + math.max(0, #bodyChildren - 1) * (node.gap or 0)
        if localAxis == axis then
            local pad = node.padding or ZERO_PAD
            if axis == "vertical"   then total = total + pad.top  + pad.bottom end
            if axis == "horizontal" then total = total + pad.left + pad.right  end
        end
        memo[key] = total
        return total
    end

    local function resolveOne(node)
        if node.width == "content" then
            node.width = measure(node.id, "horizontal") or "fill"
        end
        if node.height == "content" then
            node.height = measure(node.id, "vertical") or "fill"
        end
    end
    for _, bucket in ipairs({ sections, widgets }) do
        for _, node in pairs(bucket) do resolveOne(node) end
    end
end

-- ============================================================================
-- Visible-filtered, order-sorted child index
-- ============================================================================

local function buildChildren(sections, widgets)
    local index = {}
    local function add(parentId, childId, order, slot)
        if not parentId then return end
        slot = slot or "body"
        index[parentId] = index[parentId] or {}
        index[parentId][slot] = index[parentId][slot] or {}
        local bucket = index[parentId][slot]
        bucket[#bucket + 1] = { id = childId, order = order or 0 }  -- exception(optional): order optional; default 0
    end

    local function indexEntries(entries)
        for id, e in pairs(entries) do
            if e.visible then add(e["in"], id, e.order, e.slot) end
        end
    end
    indexEntries(sections)
    indexEntries(widgets)

    for _, slots in pairs(index) do
        for slotName, list in pairs(slots) do
            table.sort(list, function(a, b) return (a.order or 0) < (b.order or 0) end)
            local ordered = {}
            for _, e in ipairs(list) do ordered[#ordered + 1] = e.id end
            slots[slotName] = ordered
        end
    end
    return index
end

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

function VDS.Layout:Normalize(rawConfig, ctx)
    if not rawConfig then error("Layout:Normalize: rawConfig is nil", 2) end
    ctx = ctx or {}
    local view  = ctx.view or (rawConfig.window and rawConfig.window.defaultView)  -- exception(false-positive): normalizer probes raw pre-validation config
    local state = ctx.state or VDS.Store:GetState()
    local intrinsics = ctx.intrinsics or {}

    local window = resolveWindow(rawConfig.window, view, state, ctx)

    local panels, sections, widgets = {}, {}, {}
    for id, raw in pairs(rawConfig.panels   or {}) do panels[id]   = resolvePanel(id,   raw, view, state, ctx, intrinsics) end
    for id, raw in pairs(rawConfig.sections or {}) do sections[id] = resolveSection(id, raw, view, state, ctx, intrinsics) end
    for id, raw in pairs(rawConfig.widgets  or {}) do widgets[id]  = resolveWidget(id,  raw, view, state, ctx, intrinsics) end

    -- Build children FIRST so resolveContentSizes can walk it bottom-up.
    local children = buildChildren(sections, widgets)
    resolveContentSizes(panels, sections, widgets, children)

    return {
        view     = view,
        window   = window,
        panels   = panels,
        sections = sections,
        widgets  = widgets,
        children = children,
    }
end

-- Shared with Layout:Validate (which checks cell auto-derive against named
-- tracks before Normalize runs on the active view).
VDS.Layout._normalizeTracks = normalizeTracks
