-- VDS_Harmony  (pure engine core)
-- ============================================================================
-- Anchor dye -> per-relationship dye picks. PURE: takes the catalog `infos` map
-- and an `ownership` map, returns picks. No Store/Selectors/WoW dependency, so
-- it is fully unit-testable (the selector in UI/ is a thin wrapper). Lua 5.1.
--
-- Selection per target family (spec 3.3): value-gap (anti-mud) + chroma profile
-- (vivid for pops, washed for grounds -- the Pinewood guard) + ownership bias.
-- Relationship -> target FAMILY comes from HarmonyWheel; the family label is the
-- curated ColorMapDB group (never a re-derived hue).

VDS = VDS or {}
VDS.Harmony = VDS.Harmony or {}
local H  = VDS.Harmony
local CM = VDS.ColorMath
local HW = VDS.HarmonyWheel

local VALUE_GAP    = 0.15   -- |dL| floor: below this two large areas mud
local CHROMA_MIN   = 0.08   -- hue relationships reject washed dyes (vivid pops only)
local MID_CHROMA   = 0.12   -- Analogous aims for a moderate companion
local WARMTH_FLOOR = 0.008  -- below this |warmth| a neutral anchor has no usable undertone (achromatic)
local ACCENT_CHROMA = 0.10  -- a neutral's chromatic accent must be genuinely vivid
local MONO_STEP     = 0.05  -- min lightness difference for a mono shade to read as a
                            -- distinct tonal step (same-hue gradients don't mud)

-- The anti-mud value-gap (Itten light-dark contrast) is needed in INVERSE
-- proportion to hue distance: same-hue (Mono) and hue-undefined (Neutral/Tones/
-- Contrast/Accent) keep the full VALUE_GAP. Every DISTINCT-hue relationship
-- (Analogous/Complement/Triad/Split/Clash) needs NONE -- the hue change carries
-- the boundary, so it just takes the best-chroma partner. Analogous was 0.08,
-- which rejected close-value neighbours and left some anchors (e.g. purples) with
-- NO analogous at all; a 40-degree hue shift reads distinct without a value gap.
-- docs/VDS_ColorTheory.md s8.
local HUE_GAP = { Complement = 0, Triad = 0, Split = 0, Clash = 0, Analogous = 0 }

local HUE_RELS = { "Complement", "Analogous", "Triad", "Split", "Clash" }
local PREF     = { Complement = "high", Clash = "high", Triad = "high",
                   Split = "high", Analogous = "mid" }

local KEY = {  -- smaller sorts first
    high = function(c) return -c.chroma end,
    mid  = function(c) return math.abs(c.chroma - MID_CHROMA) end,
    low  = function(c) return c.chroma end,
    gap  = function(c) return -c.dL end,        -- most-contrasting shade first (mono)
}

local function isOwned(ownership, id)
    local c = ownership and ownership[id]
    return c ~= nil and c > 0
end

-- value-gap-passing candidates matching matchFn; optional chroma floor.
local function gather(anchorID, rgb, infos, ownership, matchFn, floor, minGap)
    minGap = minGap or VALUE_GAP
    local out = {}
    for id, info in pairs(infos) do
        if id ~= anchorID and info.swatchRGB and matchFn(info) then
            local dL = CM.valueGap(rgb, info.swatchRGB)
            if dL >= minGap then
                local c = CM.chroma(info.swatchRGB.r, info.swatchRGB.g, info.swatchRGB.b)
                if not floor or c >= floor then
                    out[#out + 1] = { dyeID = id, swatchRGB = info.swatchRGB,
                        owned = isOwned(ownership, id), dL = dL, chroma = c }
                end
            end
        end
    end
    return out
end

-- rank (owned first, then key, then stable by id) and take n.
local function take(cands, keyFn, n)
    table.sort(cands, function(a, b)
        if a.owned ~= b.owned then return a.owned end
        local ka, kb = keyFn(a), keyFn(b)
        if ka ~= kb then return ka < kb end
        return a.dyeID < b.dyeID
    end)
    local out = {}
    for i = 1, math.min(n, #cands) do out[i] = cands[i] end
    return out
end

-- anchorID: dyeColorID; infos: { [id] = { colorGroup, swatchRGB={r,g,b} } };
-- ownership: { [id] = count }. Returns nil pre-catalog / bad id.
-- Monochromatic: a tonal pair from the SAME family. Prefer one darker + one
-- lighter (a true range); if the anchor sits at the family's value extreme, fall
-- back to the two most-extreme shades on whichever side has them.
local function pickMono(anchorID, rgb, infos, ownership, grp)
    local aL = CM.L(rgb.r, rgb.g, rgb.b)
    local darkers, lighters = {}, {}
    for id, info in pairs(infos) do
        if id ~= anchorID and info.colorGroup == grp and info.swatchRGB then
            local s = info.swatchRGB
            local d = CM.L(s.r, s.g, s.b) - aL
            if math.abs(d) >= MONO_STEP then
                local cand = { dyeID = id, swatchRGB = s, owned = isOwned(ownership, id), dL = math.abs(d) }
                local side = (d < 0) and darkers or lighters
                side[#side + 1] = cand
            end
        end
    end
    local function byExtreme(a, b)
        if a.dL ~= b.dL then return a.dL > b.dL end
        return a.dyeID < b.dyeID
    end
    table.sort(darkers, byExtreme)
    table.sort(lighters, byExtreme)
    if #darkers > 0 and #lighters > 0 then return { darkers[1], lighters[1] } end
    local side = (#darkers >= #lighters) and darkers or lighters
    return { side[1], side[2] }  -- side[2] nil when the family has only one distinct shade
end

function H.forDye(anchorID, infos, ownership)
    local anchor = infos and infos[anchorID]
    if not (anchor and anchor.swatchRGB) then return nil end  -- exception(nullable): pre-catalog / bad id
    local rgb, grp = anchor.swatchRGB, anchor.colorGroup
    local result = { anchor = anchorID, byRelationship = {} }

    if HW.ANGLE[grp] then
        -- HUE LENS -- the anchor has a wheel position.
        for _, rel in ipairs(HUE_RELS) do
            local picks = {}
            for _, g in ipairs(HW.targets(grp, rel)) do
                local got = take(gather(anchorID, rgb, infos, ownership,
                    function(i) return i.colorGroup == g end, CHROMA_MIN, HUE_GAP[rel]), KEY[PREF[rel]], 1)
                if got[1] then picks[#picks + 1] = got[1] end
            end
            result.byRelationship[rel] = picks
        end
        -- Neutral grounding (no chroma floor -- washed is the point) + same-family shades.
        result.neutral = take(gather(anchorID, rgb, infos, ownership,
            function(i) return HW.NEUTRALS[i.colorGroup] end, nil), KEY.low, 2)
        result.mono = pickMono(anchorID, rgb, infos, ownership, grp)
        -- Tone-matched neutral for padding a SHORT mono ramp (tiny hue families):
        -- a neutral sharing the anchor's temperature (warm dye -> brown/tan, cool
        -- -> grey) so the fill reads as part of the scheme, not a random grey.
        -- Value-distant first, so it extends the tonal range.
        local aw = CM.warmth(rgb.r, rgb.g, rgb.b)
        result.toneNeutral = take(gather(anchorID, rgb, infos, ownership,
            function(i)
                if not HW.NEUTRALS[i.colorGroup] then return false end
                return CM.warmth(i.swatchRGB.r, i.swatchRGB.g, i.swatchRGB.b) * aw >= 0
            end, nil), KEY.gap, 2)
    else
        -- VALUE/TEMPERATURE LENS -- a neutral anchor has no wheel position, so it
        -- relates on value + temperature, not hue (docs/VDS_ColorTheory.md section 8).
        local aw = CM.warmth(rgb.r, rgb.g, rgb.b)
        local function neutral(i) return HW.NEUTRALS[i.colorGroup] end
        if math.abs(aw) >= WARMTH_FLOOR then
            local function sameTemp(i) return neutral(i) and CM.warmth(i.swatchRGB.r, i.swatchRGB.g, i.swatchRGB.b) * aw > 0 end
            local function oppTemp(i)  return neutral(i) and CM.warmth(i.swatchRGB.r, i.swatchRGB.g, i.swatchRGB.b) * aw < 0 end
            result.tones    = take(gather(anchorID, rgb, infos, ownership, sameTemp, nil), KEY.gap, 2)
            result.contrast = take(gather(anchorID, rgb, infos, ownership, oppTemp, nil), KEY.gap, 1)
        else
            -- achromatic anchor: no undertone -> pure value (Munsell N-axis).
            result.tones    = take(gather(anchorID, rgb, infos, ownership, neutral, nil), KEY.gap, 2)
            result.contrast = {}
        end
        -- Accent: a vivid chromatic pop (saturation contrast) laid on the neutral.
        result.accent = take(gather(anchorID, rgb, infos, ownership,
            function(i) return HW.ANGLE[i.colorGroup] ~= nil end, ACCENT_CHROMA), KEY.high, 2)
    end

    return result
end

-- Strategy key -> the field on a forDye result. Shared by the apply controller
-- and the strategy-button tooltip so the preview equals what gets applied.
local STRATEGY_FIELD = {
    Neutral = "neutral", Monochromatic = "mono",
    Tones = "tones", Contrast = "contrast", Accent = "accent",
}
function H.picksFor(res, key)
    local f = STRATEGY_FIELD[key]
    if f then return res[f] or {} end
    return res.byRelationship[key] or {}
end

-- Auto-fill N channels with a COMPLETE scheme: the strategy's own picks first,
-- then grounding companions so no channel is left empty (a true auto dresses
-- every channel). Chromatic anchor pads with neutral grounds then mono shades;
-- neutral anchor pads with the other value/temperature picks. Deduped, capped at N.
function H.autoFill(res, key, n)
    local out, seen = {}, {}
    local function add(list)
        if not list then return end
        for _, p in ipairs(list) do
            if #out >= n then return end
            if not seen[p.dyeID] then seen[p.dyeID] = true; out[#out + 1] = p end
        end
    end
    add(H.picksFor(res, key))
    if res.mono then
        -- chromatic anchor. Mono pads with a TONE-MATCHED neutral first so a short
        -- ramp stays coordinated (not a random grey); other strategies ground in
        -- neutrals, then mono shades, then analogous as a last-resort fill.
        if key == "Monochromatic" then add(res.toneNeutral) end
        add(res.neutral); add(res.mono); add(res.byRelationship.Analogous)
    else
        add(res.tones); add(res.accent); add(res.contrast)     -- neutral anchor
    end
    return out
end
