local Sampler = {}

-- Public so Handlers.jsonPair can include per-photo orientation
-- in pair responses.  The browser uses it to size the <img> to
-- fill exactly one axis of the slot (landscape → width,
-- portrait/square → height), keeping the img element snug to
-- the visible photo so border-radius and right-click context
-- don't leak into letterbox space.  See web-src/main.css
-- ".slot-image[data-orientation=...]" for the consumer side.
function Sampler.orientationOf(photo)
    local dims = photo:getRawMetadata('croppedDimensions')
    if not dims or not dims.width or not dims.height then return 'unknown' end
    if dims.width > dims.height then return 'landscape'
    elseif dims.width < dims.height then return 'portrait'
    else return 'square' end
end
local orientationOf = Sampler.orientationOf

-- Public so Handlers.jsonPair can include the photo's exact
-- pixel dimensions in pair responses.  Frontend sets these as
-- HTML width/height attributes on the <img> via
-- `new Image(width, height)`.  HTML width/height attrs are the
-- canonical aspect-ratio signal — set BEFORE image load,
-- honored by browsers reliably alongside any CSS constraint
-- (where implicit aspect-ratio from naturalWidth/Height alone
-- can be unreliable).  Layout is stable from the first paint;
-- no flash of wrong-aspect.
-- Returns nil when croppedDimensions isn't available; caller
-- should omit the attributes and fall back to the orientation-
-- only CSS rule.
function Sampler.dimensionsOf(photo)
    local dims = photo:getRawMetadata('croppedDimensions')
    if not dims or not dims.width or not dims.height then return nil end
    return dims.width, dims.height
end

local FLAG_VALUES = { pick = 1, unflagged = 0, reject = -1 }

local COLOR_LABEL_MATCH = {
    red = 'Red', yellow = 'Yellow', green = 'Green',
    blue = 'Blue', purple = 'Purple', none = '',
}

local function passesFilter(photo, config)
    if config.minRating and config.minRating > 0 then
        local rating = photo:getRawMetadata('rating') or 0
        if rating < config.minRating then return false end
    end

    if config.requiredFlag and config.requiredFlag ~= 'either' then
        local flag = photo:getRawMetadata('pickStatus') or 0
        local target = FLAG_VALUES[config.requiredFlag]
        if target and flag ~= target then return false end
    end

    if config.requiredColorLabel and config.requiredColorLabel ~= 'any' then
        local label = photo:getRawMetadata('colorNameForLabel') or ''
        local target = COLOR_LABEL_MATCH[config.requiredColorLabel]
        if target ~= nil and label ~= target then return false end
    end

    if config.poolOrientation and config.poolOrientation ~= 'any' then
        if orientationOf(photo) ~= config.poolOrientation then return false end
    end

    return true
end

-- Build a bucketed pool from a list of photos. One filter pass, one orientation
-- read per photo. Result is cached by the caller; per-click sampling is then a
-- handful of array lookups instead of a 20k-photo scan.
--
-- Returns: {
--     all         = [photos],
--     landscape   = [photos],
--     portrait    = [photos],
--     square      = [photos],
--     orientations = { [localIdentifier] = 'landscape'|'portrait'|'square'|'unknown' },
-- }
function Sampler.buildBuckets(photos, config)
    local buckets = {
        all = {},
        landscape = {},
        portrait = {},
        square = {},
        unknown = {},
        orientations = {},
    }
    if not photos then return buckets end
    for _, photo in ipairs(photos) do
        if passesFilter(photo, config) then
            local o = orientationOf(photo)
            buckets.all[#buckets.all + 1] = photo
            local bucket = buckets[o] or buckets.unknown
            bucket[#bucket + 1] = photo
            buckets.orientations[photo.localIdentifier] = o
        end
    end
    return buckets
end

local function orientationFor(buckets, photo)
    return buckets.orientations[photo.localIdentifier] or orientationOf(photo)
end

-- Resample-and-fallback: pick uniformly from pool, excluding any
-- photos in the variadic `excludes` list (nils permitted; treated
-- as "no constraint").  Strategy: 10 random tries, then a full
-- filter scan as a guaranteed-fair fallback if the pool is too
-- excluded for random tries to land.  Returns nil only when every
-- photo in pool is excluded.
local function pickOne(pool, ...)
    if #pool == 0 then return nil end
    local excludes = { ... }
    local excludeIds = {}
    for _, e in ipairs(excludes) do
        if e and e.localIdentifier then
            excludeIds[e.localIdentifier] = true
        end
    end
    local hasExcludes = next(excludeIds) ~= nil
    if not hasExcludes then
        return pool[math.random(#pool)]
    end
    for _ = 1, 10 do
        local candidate = pool[math.random(#pool)]
        if not excludeIds[candidate.localIdentifier] then
            return candidate
        end
    end
    local filtered = {}
    for _, p in ipairs(pool) do
        if not excludeIds[p.localIdentifier] then
            filtered[#filtered + 1] = p
        end
    end
    if #filtered == 0 then return nil end
    return filtered[math.random(#filtered)]
end

-- `excludes` is an optional list of photos to keep out of BOTH
-- positions in the new pair.  Used to avoid producing a fresh
-- pair that reuses either of the photos the user just saw —
-- (A, B) → (A, C) feels like Advance didn't do anything; we
-- want (C, D).  Caller is expected to fall back to an
-- exclude-less call if strict-exclude returns nil (small pools
-- where every photo is in the exclude list).
local function pickPairFromBuckets(buckets, config, excludes)
    if #buckets.all < 2 then return nil end
    excludes = excludes or {}
    if config.allowMixedOrientation then
        local left = pickOne(buckets.all, unpack(excludes))
        if not left then return nil end
        local right = pickOne(buckets.all, left, unpack(excludes))
        if not right then return nil end
        return { left = left, right = right }
    end
    -- Same-orientation: pick left (excluding the prior pair if
    -- given), then sample right from left's bucket (also excluding
    -- the prior pair AND left itself).  If left lands in a bucket
    -- too small for a pair after exclusion, resample a few times
    -- before giving up — avoids bias toward the larger buckets.
    for _ = 1, 10 do
        local left = pickOne(buckets.all, unpack(excludes))
        if left then
            local o = orientationFor(buckets, left)
            local bucket = buckets[o]
            if bucket and #bucket >= 2 then
                local right = pickOne(bucket, left, unpack(excludes))
                if right then return { left = left, right = right } end
            end
        end
    end
    return nil
end

local function pickPairFromSplitBuckets(leftBuckets, rightBuckets, config, excludes)
    if #leftBuckets.all == 0 or #rightBuckets.all == 0 then return nil end
    excludes = excludes or {}
    if config.allowMixedOrientation then
        local left = pickOne(leftBuckets.all, unpack(excludes))
        if not left then return nil end
        local right = pickOne(rightBuckets.all, left, unpack(excludes))
        if not right then return nil end
        return { left = left, right = right }
    end
    for _ = 1, 10 do
        local left = pickOne(leftBuckets.all, unpack(excludes))
        if left then
            local o = orientationFor(leftBuckets, left)
            local bucket = rightBuckets[o]
            if bucket and #bucket > 0 then
                local right = pickOne(bucket, left, unpack(excludes))
                if right then return { left = left, right = right } end
            end
        end
    end
    return nil
end

-- pools: either { kind = 'single', buckets = ... }
--     or { kind = 'split',  left = ..., right = ... }
-- excludes: optional list of photos to keep out of the result
-- pair.  Typically the current pair's left+right when the caller
-- wants Advance to produce a freshly different pair; nil to
-- accept any sample.  Caller should fall back to a no-exclude
-- call if a strict-exclude pass returns nil (small pools may
-- have nothing left after exclusion).
function Sampler.pickPair(pools, config, excludes)
    if not pools then return nil end
    if pools.kind == 'split' then
        return pickPairFromSplitBuckets(
            pools.left, pools.right, config, excludes)
    end
    return pickPairFromBuckets(pools.buckets, config, excludes)
end

function Sampler.reshuffleSide(pools, config, currentPair, side)
    if not pools or not currentPair then return nil end
    local kept = currentPair[side == 'left' and 'right' or 'left']
    local replaced = currentPair[side]
    local buckets
    if pools.kind == 'split' then
        buckets = side == 'left' and pools.left or pools.right
    else
        buckets = pools.buckets
    end
    if not buckets or #buckets.all == 0 then return nil end

    local pool
    if config.allowMixedOrientation then
        pool = buckets.all
    else
        local o = orientationFor(buckets, kept)
        pool = buckets[o] or {}
    end
    -- Exclude both the kept (other side) and replaced (current side) photos so
    -- we can't end up with a duplicate pair or a "reshuffle" that returns the
    -- same photo we're trying to replace.
    local newPhoto = pickOne(pool, kept, replaced)
    if not newPhoto then return nil end
    if side == 'left' then
        return { left = newPhoto, right = kept }
    else
        return { left = kept, right = newPhoto }
    end
end

-- Count the number of distinct ordered pairs the current settings can
-- produce from the given pools. Cheap — just sums bucket sizes.
function Sampler.possiblePairs(pools, config)
    if not pools then return 0 end
    local function prSingle(buckets)
        if not buckets then return 0 end
        if config.allowMixedOrientation then
            local n = #buckets.all
            return n * (n - 1)
        end
        local total = 0
        for _, o in ipairs({'landscape', 'portrait', 'square'}) do
            local n = buckets[o] and #buckets[o] or 0
            total = total + n * (n - 1)
        end
        return total
    end
    if pools.kind == 'split' then
        if config.allowMixedOrientation then
            return #pools.left.all * #pools.right.all
        end
        local total = 0
        for _, o in ipairs({'landscape', 'portrait', 'square'}) do
            local l = pools.left[o] and #pools.left[o] or 0
            local r = pools.right[o] and #pools.right[o] or 0
            total = total + l * r
        end
        return total
    end
    return prSingle(pools.buckets)
end

return Sampler
