-- Action handlers: the RPC protocol between the browser and the
-- plugin.
--
-- Each handler takes (id, requestJsonString) and returns
-- (jsonResponseString, error). The IPC module writes the response
-- to resp-<id>.json where the helper picks it up and returns the
-- body to the browser's fetch.
--
-- Phase 2 scope:
--   getState     - current source info + current pair (if any)
--   advance      - pick a new pair, push old to history
--   back         - pop history, restore prior pair
--   swap         - exchange L/R in the current pair (no re-sample)
--
-- Phases 3+ add reshuffleSide, save, refreshSnapshot, newSession,
-- listCollections, etc.

local LrApplication = import 'LrApplication'
local LrFunctionContext = import 'LrFunctionContext'
local LrPathUtils = import 'LrPathUtils'
local LrTasks = import 'LrTasks'

local Log = require 'Log'
local IPC = require 'IPC'
local Sampler = require 'Sampler'
local Prefs = require 'Prefs'
local Catalog = require 'Catalog'
local Platform = require 'Platform'
local Save = require 'Save'
local Stats = require 'Stats'
local Thumbs = require 'Thumbs'
local Version = require 'Version'

local Handlers = {}

-- Displayed image size is a user setting (Prefs.imageSize),
-- resolved per-call here so a Settings change takes effect
-- on the next advance without a plugin reload.  Default
-- 1024 is conservative against LR's "Standard Preview Size"
-- catalog setting; see Prefs.lua for rationale and
-- Settings.lua for the dropdown.
--
-- Naming note: internally we still cache these as
-- "thumbnails" on disk (`Thumbs.lua`, `/thumbs/`) because
-- that's what they are from the cache's perspective —
-- derived scaled representations of the originals.  The
-- user-facing dimension is called "image size" because at
-- 1024-2880px these are display images, not the UI sense
-- of "thumbnail."
local function currentImageSize()
    return Prefs.config().imageSize or 1024
end

-- Module-level session state. Tracks the live pair as LrPhoto
-- references (which LR invalidates across restarts, so prefs carry
-- the persistent uuids and we resolve back to LrPhoto on startup).
local State = {
    currentPair = nil,       -- { left = LrPhoto, right = LrPhoto }

    -- Back/forward history. `history` is what Back pops. `forward`
    -- is what Advance redoes into when non-empty. Matches the
    -- browser back/forward semantics: Back pushes to forward, and
    -- a fresh Advance from the middle of history clears forward
    -- (it would otherwise be unreachable anyway). Non-linear
    -- actions (reshuffleSide, refreshSnapshot) also clear forward
    -- since they fork the path.
    history = {},            -- list of { leftUuid, rightUuid }
    forward = {},            -- list of { leftUuid, rightUuid }

    pools = nil,             -- cached bucket structure from Sampler
    poolsKey = nil,
    snapshotPhotos = nil,    -- resolved LrPhoto list for 'current' mode
    snapshotMeta = nil,

    -- Prefetch queue: a FIFO of candidate "next pairs" with their
    -- thumbnails already on disk. Each Advance pops from the head
    -- and a background task tops the queue back up to target size.
    -- Response includes uuids of upcoming queue entries so the
    -- browser can pre-fetch those JPEGs into its HTTP cache,
    -- making subsequent <img src=...> swaps truly instant.
    nextQueue = {},
    prefetchRunning = false,
}

-- Prefetch re-enabled (build 71) with a queue depth of 5.
-- Background: the on-demand preview-build flakiness that
-- forced the earlier `= 0` parking (see bugs.md "Thumb
-- generation takes 30-50s...") is mitigated by users
-- pre-building standard previews per the install-docs
-- warmup step; remaining flakiness is acceptable while
-- we test the perceived-latency win of a deeper queue.
-- 5 is deeper than the comment further down suggests
-- (single-slot was the originally-simplest depth) — try
-- the bigger queue first; if pool-coherence churn or
-- thumb-concurrency issues surface we drop back to 1.
local PREFETCH_TARGET = 5

-- ---- utilities ----

local function catalog()
    return LrApplication.activeCatalog()
end

local function uuidOf(photo)
    return photo and photo:getRawMetadata('uuid') or nil
end

local function filenameOf(photo)
    return photo and photo:getFormattedMetadata('fileName') or ''
end

-- Map Sampler's internal orientation vocab (landscape /
-- portrait / square / unknown — used by Settings filters and
-- internal pool bucketing) to the frontend's data-orientation
-- vocabulary (horizontal / vertical / square / "").  The
-- browser uses data-orientation on .slot-image to pick which
-- axis the <img> element fills (see web-src/main.css).
local function dataOrientation(photo)
    local o = Sampler.orientationOf(photo)
    if o == 'landscape' then return 'horizontal'
    elseif o == 'portrait' then return 'vertical'
    elseif o == 'square' then return 'square'
    else return '' end
end

local function jsonPair(pair)
    if not pair then return 'null' end
    -- Include pixel dimensions per side so the frontend can set
    -- HTML width/height attributes on the <img>.  HTML width/
    -- height attrs are the canonical way to give the browser
    -- the photo's aspect ratio BEFORE the image loads — layout
    -- is stable from the first paint, no flash, no async wait
    -- on naturalWidth/Height.  width/height = 0 means LR didn't
    -- expose croppedDimensions; frontend omits the attributes
    -- and CSS falls back to the orientation-only rule.
    local leftW, leftH = Sampler.dimensionsOf(pair.left)
    local rightW, rightH = Sampler.dimensionsOf(pair.right)
    return string.format(
        '{"left":{"uuid":"%s","filename":"%s","orientation":"%s","width":%d,"height":%d},'
        .. '"right":{"uuid":"%s","filename":"%s","orientation":"%s","width":%d,"height":%d}}',
        uuidOf(pair.left), IPC.jsonEscape(filenameOf(pair.left)),
        dataOrientation(pair.left), leftW or 0, leftH or 0,
        uuidOf(pair.right), IPC.jsonEscape(filenameOf(pair.right)),
        dataOrientation(pair.right), rightW or 0, rightH or 0)
end

local function historyEntryFromPair(pair)
    return {
        leftUuid = uuidOf(pair.left),
        rightUuid = uuidOf(pair.right),
    }
end

-- Build the "don't reuse these photos in the next picked pair"
-- list from the current pair, if any.  Used by handleAdvance and
-- the prefetch loop so a fresh Advance can't produce (A, B) →
-- (A, C) or (A, B) → (B, A) — the user reads either as "Advance
-- didn't do anything," even though the new pair is technically
-- different.  Returns an empty list when there's no current
-- pair, so callers can pass the result unconditionally.
local function currentPairExcludes()
    local out = {}
    local cur = State.currentPair
    if cur then
        if cur.left then out[#out + 1] = cur.left end
        if cur.right then out[#out + 1] = cur.right end
    end
    return out
end

-- ---- pool management ----

-- Forward declaration: ensurePools (below) calls takeSnapshot,
-- which is defined further down. Without this, the closure
-- captures takeSnapshot as a global lookup and blows up at
-- runtime with "attempt to call global 'takeSnapshot' (a nil
-- value)."
local takeSnapshot
-- Forward declaration so Handlers.onPoolSettingsChanged (line
-- ~365) can invoke startPrefetchIfIdle, which is defined later
-- in the file (~line 495).  Required because Lua closures bind
-- a name at definition time — without the forward `local`,
-- onPoolSettingsChanged's reference would fall through to the
-- global namespace and hit nil at runtime.  See CLAUDE.md on
-- forward references between local functions.
local startPrefetchIfIdle

-- Hard cap on the pool size, applied uniformly across all
-- three source modes (current, single, split).  The cost the
-- cap protects against is pool-build time + memory: each
-- photo's `getRawMetadata('uuid')` yields, and buildBuckets
-- iterates every one, so cost grows with pool size.  Hit →
-- the pool truncates and `capped = true` flows out through
-- Handlers.sourcePhotoCount; Dashboard's Photo source row
-- shows "(limit reached, some excluded)" in yellow.
--
-- Dropped from original 20000 to 10000 during alpha to keep
-- worst-case pool-build cost bounded on slow catalogs;
-- easily bumped once we see how testers hit it.
--
-- Declared HERE (before ensurePools / sourcePhotoCount) so
-- those functions capture the local, not a nil global.  Lua
-- closures bind names at definition time: a `local` declared
-- further down would be invisible to any function defined
-- earlier in the same file.
local POOL_CAP = 10000

-- Truncate photos to POOL_CAP and report whether we had to.
-- Returns (possibly-truncated list, capped bool).
local function capPhotos(photos)
    if #photos <= POOL_CAP then return photos, false end
    local out = {}
    for i = 1, POOL_CAP do out[i] = photos[i] end
    return out, true
end

local function configPoolsKey(config)
    local parts = {
        config.sourceMode,
        tostring(config.minRating or 0),
        tostring(config.requiredFlag or 'either'),
        tostring(config.requiredColorLabel or 'any'),
        tostring(config.poolOrientation or 'any'),
    }
    if config.sourceMode == 'current' then
        parts[#parts + 1] = 'ts=' .. tostring(State.snapshotMeta and State.snapshotMeta.timestamp or 0)
    elseif config.sourceMode == 'split' then
        parts[#parts + 1] = 'L=' .. tostring(config.sourceCollectionLeftId)
        parts[#parts + 1] = 'R=' .. tostring(config.sourceCollectionRightId)
    else
        parts[#parts + 1] = 'C=' .. tostring(config.sourceCollectionId)
    end
    return table.concat(parts, '|')
end

local function ensurePools(config)
    local key = configPoolsKey(config)
    if State.pools and State.poolsKey == key then return State.pools end
    local cat = catalog()
    if config.sourceMode == 'current' then
        -- If the user *just* switched into 'current' mode via
        -- Settings, no snapshot exists yet for this session —
        -- initOnce() only snapshots when 'current' is the mode
        -- at plugin load. Without this, the first advance after
        -- a mode switch hits an empty pool and errors. Take a
        -- snapshot now; the key recomputes because the snapshot
        -- timestamp is part of it.
        if not State.snapshotPhotos or #State.snapshotPhotos == 0 then
            takeSnapshot()
            key = configPoolsKey(config)
        end
        Log.line('handlers', 'building pools', 'key=' .. key)
        State.pools = {
            kind = 'single',
            buckets = Sampler.buildBuckets(State.snapshotPhotos or {}, config),
        }
    elseif config.sourceMode == 'split' then
        Log.line('handlers', 'building pools', 'key=' .. key)
        local leftCol = Catalog.findCollection(cat, config.sourceCollectionLeftId)
        local rightCol = Catalog.findCollection(cat, config.sourceCollectionRightId)
        -- Cap each side independently.  Alternative would be
        -- capping their sum, but that systematically penalizes
        -- the second side — unfair if both are large.  Per-side
        -- cap matches the user's mental model (each collection
        -- contributes up to POOL_CAP photos to its slot).
        local leftPhotos = leftCol and leftCol:getPhotos() or {}
        local rightPhotos = rightCol and rightCol:getPhotos() or {}
        leftPhotos = capPhotos(leftPhotos)
        rightPhotos = capPhotos(rightPhotos)
        State.pools = {
            kind = 'split',
            left = Sampler.buildBuckets(leftPhotos, config),
            right = Sampler.buildBuckets(rightPhotos, config),
        }
    else
        Log.line('handlers', 'building pools', 'key=' .. key)
        local col = Catalog.findCollection(cat, config.sourceCollectionId)
        local photos = col and col:getPhotos() or {}
        photos = capPhotos(photos)
        State.pools = {
            kind = 'single',
            buckets = Sampler.buildBuckets(photos, config),
        }
    end
    State.poolsKey = key
    return State.pools
end

local function invalidatePools()
    State.pools = nil
    State.poolsKey = nil
    -- Prefetched candidates were drawn from the old pool; they
    -- might not even still match the new filters. Drop the queue
    -- and let the prefetch loop produce fresh candidates.
    State.nextQueue = {}
end

-- ---- snapshot for 'current' source mode ----

takeSnapshot = function()
    local cat = catalog()
    local targetPhotos = cat:getMultipleSelectedOrAllPhotos() or {}
    local uuids = {}
    local photos = {}
    local capped = false
    for i, p in ipairs(targetPhotos) do
        if i > POOL_CAP then capped = true; break end
        local uuid = uuidOf(p)
        if uuid then
            uuids[#uuids + 1] = uuid
            photos[#photos + 1] = p
        end
    end
    local snap = {
        uuids = uuids,
        timestamp = os.time(),
        capped = capped,
    }
    local catalogId = cat.localIdentifier or 'default'
    Prefs.setSnapshot(catalogId, snap)
    State.snapshotPhotos = photos
    State.snapshotMeta = snap
    invalidatePools()
    Log.line('handlers', 'snapshot taken',
        'photos=' .. #photos .. ' capped=' .. tostring(capped))
end

local function resolveSnapshot()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'
    local snap = Prefs.snapshot(catalogId)
    if not snap or not snap.uuids then return end
    local photos = {}
    for _, uuid in ipairs(snap.uuids) do
        local p = cat:findPhotoByUuid(uuid)
        if p then photos[#photos + 1] = p end
    end
    State.snapshotPhotos = photos
    State.snapshotMeta = snap
end

-- ---- init called on first handler invocation ----

-- Is the given photo reachable from the current configured
-- source?  Used on plugin load to validate the restored pair —
-- a pair saved under one source config shouldn't survive into a
-- session configured for a different source.
local function photoInCurrentSource(photo, config, cat)
    if not photo then return false end
    local targetId = photo.localIdentifier
    if config.sourceMode == 'current' then
        -- Snapshot is the source of truth in current mode.
        if not State.snapshotPhotos then return false end
        for _, p in ipairs(State.snapshotPhotos) do
            if p.localIdentifier == targetId then return true end
        end
        return false
    elseif config.sourceMode == 'single' then
        local col = Catalog.findCollection(cat, config.sourceCollectionId)
        if not col then return false end
        for _, p in ipairs(col:getPhotos()) do
            if p.localIdentifier == targetId then return true end
        end
        return false
    elseif config.sourceMode == 'split' then
        local l = Catalog.findCollection(cat, config.sourceCollectionLeftId)
        local r = Catalog.findCollection(cat, config.sourceCollectionRightId)
        for _, col in ipairs({ l, r }) do
            if col then
                for _, p in ipairs(col:getPhotos()) do
                    if p.localIdentifier == targetId then return true end
                end
            end
        end
        return false
    end
    return false
end

local initialized = false
local function initOnce()
    if initialized then return end
    initialized = true
    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'

    -- For 'current' mode we always take a FRESH snapshot on
    -- plugin load rather than reusing the persisted one from
    -- prefs.  Reason: "current Library source" means "what's in
    -- Library view right now" — reusing a stale snapshot from a
    -- previous session produces pairs from photos the user
    -- doesn't consider their source anymore.  Losing the
    -- across-session snapshot continuity is the right trade.
    if config.sourceMode == 'current' then
        takeSnapshot()
    end

    -- Restore prior pair if possible (fast path via uuid).  But
    -- only if both photos are actually reachable from the
    -- currently-configured source — otherwise we'd show the user
    -- pairs from a past session's source config, which looks
    -- like a bug ("these aren't from my source").
    local prior = Prefs.currentPair(catalogId, config.targetSetId)
    if prior and prior.leftUuid and prior.rightUuid then
        local l = cat:findPhotoByUuid(prior.leftUuid)
        local r = cat:findPhotoByUuid(prior.rightUuid)
        if l and r then
            if photoInCurrentSource(l, config, cat)
                and photoInCurrentSource(r, config, cat) then
                State.currentPair = { left = l, right = r }
                Log.line('handlers', 'restored prior pair via uuid')
            else
                Log.line('handlers',
                    'discarded stale prior pair — photos not in current source')
                Prefs.setCurrentPair(catalogId, config.targetSetId, nil)
            end
        end
    end

    State.history = Prefs.history(catalogId, config.targetSetId) or {}
    Handlers.publishDisplaySettings()

    -- Thumb cache hygiene: cap to 500 newest on startup so the
    -- /tmp/lr-random-pair-generator/thumbs/ dir doesn't grow unbounded. Each
    -- JPEG is ~500KB-1MB; 500 files is a generous window without
    -- becoming a disk-usage problem. Runs on first handler
    -- invocation per plugin load.
    LrTasks.startAsyncTask(function()
        Thumbs.cleanup(500)
    end)
end

-- ---- prefetch ----
--
-- While the user is looking at the current pair, produce a
-- candidate "next pair" in the background: pick it from the pool
-- and generate its thumbnails on disk. On the subsequent advance
-- click the plugin can return instantly with no sampling or
-- thumbnail work. Subsequent Back / reshuffle also benefit
-- indirectly because LR's own preview cache is warmed by the
-- prefetch work.
--
-- A single-slot prefetch (one pair ahead) is the simplest form
-- that still eliminates most perceived latency. Deeper prefetch
-- (2+ pairs ahead) could win more but is harder to keep coherent
-- with pool changes.
-- Publish display-related prefs and a settings generation counter
-- through the heartbeat. The browser polls /status every ~2s:
-- display prefs get re-applied directly; a bumped generation
-- triggers a full getState refetch so filter changes (source,
-- min rating, etc.) invalidate any in-browser caches of
-- upcoming pairs and refresh the current pair from the server's
-- post-save state.
-- Fields on the config table that affect which photos can appear
-- or how they pair up. Display-only settings (color scheme,
-- metadata visibility) are deliberately excluded — a user who
-- opens Settings just to flip the theme shouldn't lose their
-- current pair to an unwanted auto-advance.
local POOL_AFFECTING_FIELDS = {
    'sourceMode',
    'sourceCollectionId',
    'sourceCollectionLeftId',
    'sourceCollectionRightId',
    'minRating',
    'requiredFlag',
    'requiredColorLabel',
    'poolOrientation',
    'allowMixedOrientation',
}

function Handlers.poolAffectingFieldsChanged(before, after)
    for _, field in ipairs(POOL_AFFECTING_FIELDS) do
        if before[field] ~= after[field] then return true end
    end
    return false
end

-- Called by the Dashboard after the Settings modal closes *and*
-- a pool-affecting field changed. Invalidates state drawn from
-- the old pool, snapshots fresh if the user switched to 'current'
-- mode, and immediately produces a new pair so the browser sees
-- fresh content on its next /status poll without the user having
-- to click Advance.
function Handlers.onPoolSettingsChanged()
    initOnce()
    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'

    Log.line('handlers', 'pool-affecting settings changed — auto-advancing')

    -- State drawn from the old pool is all invalid: prefetch
    -- queue (photos may not pass new filters), forward stack
    -- (from old pool, unreachable linearly), pools cache.
    invalidatePools()
    State.forward = {}

    -- If the user switched *into* 'current' mode, take a snapshot
    -- now. ensurePools also falls back to this if snapshotPhotos
    -- is empty, but doing it here is more explicit about the
    -- "mode changed" semantics.
    if config.sourceMode == 'current' then
        takeSnapshot()
    end

    local pools = ensurePools(config)
    local pair = Sampler.pickPair(pools, config)
    if not pair then
        Log.line('handlers', 'auto-advance: new pool yielded no pair')
        -- Clear currentPair so the browser doesn't keep showing
        -- a pair that's no longer consistent with the config.
        State.currentPair = nil
        Prefs.setCurrentPair(catalogId, config.targetSetId, nil)
        return
    end

    -- Push old current onto history, same as advance does.
    if State.currentPair then
        table.insert(State.history, historyEntryFromPair(State.currentPair))
        while #State.history > (config.historyDepth or 50) do
            table.remove(State.history, 1)
        end
        Prefs.setHistory(catalogId, config.targetSetId, State.history)
    end
    State.currentPair = pair
    Prefs.setCurrentPair(catalogId, config.targetSetId, {
        leftUuid = uuidOf(pair.left), rightUuid = uuidOf(pair.right),
        leftId = pair.left.localIdentifier,
        rightId = pair.right.localIdentifier,
    })
    Stats.incrementViewed(catalogId)

    -- Warm the prefetch queue immediately.  Without this, the
    -- queue stays empty until the user's first Advance action,
    -- which means that first Advance has to sample + generate
    -- thumbs synchronously (30-60s cold on Windows) and the
    -- helper times out before we finish.  With early warming,
    -- by the time the user clicks Advance the queue has 1+
    -- pre-generated pairs and the handler serves in ~100ms.
    startPrefetchIfIdle()
end

-- Rebuild the snapshot for 'current' mode and auto-advance to
-- a fresh pair drawn from the new snapshot.  Called on every
-- dashboard open (so the user's "current Library view"
-- always reflects their actual LR view) and from the explicit
-- "Rebuild snapshot" button.
--
-- We *always* pick a new pair rather than keeping the existing
-- one even when still valid: the user explicitly asked for
-- rebuild, which implies "show me something new from the new
-- view" — keeping the old pair makes the action look like a
-- no-op.  Matches onPoolSettingsChanged's behaviour on source
-- changes in Settings, so the UX is consistent.
--
-- No-op for non-current modes.
function Handlers.rebuildCurrentSnapshotIfNeeded()
    initOnce()
    local config = Prefs.config()
    if config.sourceMode ~= 'current' then return end
    Log.line('handlers', 'rebuild snapshot requested')
    Handlers.onPoolSettingsChanged()
    -- Bump generation so the browser refetches state and shows
    -- the freshly-advanced pair. Without this, the browser keeps
    -- displaying whatever it had before — plugin state changed
    -- but browser has no signal to refresh.
    Prefs.bumpGeneration()
    Handlers.publishDisplaySettings()
end

-- Number of photos in the currently-configured source. Used by
-- the Dashboard to render the photo source checklist row with a
-- three-state indicator (red: 0 photos, yellow: <20, green: 20+).
-- Returns count + a short human-readable description of the
-- source, for display.
-- Returns (count, description, capped, problem).
--
-- POOL_CAP is applied uniformly across all three source
-- modes.  Dashboard surfaces `capped` on the Photo source
-- row so users know their pool is truncated — otherwise
-- it's silent data loss (pool just feels smaller than
-- expected).  Count is reported POST-cap so "N in (mode)
-- (limit reached, some excluded)" makes sense — N is what
-- the plugin will actually sample from.
--
-- `problem` is a string key when the configuration is in a
-- state that needs user action, otherwise nil.  Known
-- values:
--   'no-collection'   — source mode is 'single' or 'split'
--                       but the referenced collection
--                       doesn't exist / was never picked.
-- Kept as a string key (not a formatted message) so the
-- Dashboard can render its own copy with the appropriate
-- recovery button — otherwise callers would have to
-- pattern-match the description string.
function Handlers.sourcePhotoCount()
    initOnce()
    local config = Prefs.config()
    local cat = catalog()
    if config.sourceMode == 'current' then
        local n = State.snapshotPhotos and #State.snapshotPhotos or 0
        local capped = State.snapshotMeta
            and State.snapshotMeta.capped == true
        return n, 'Current Library view', capped, nil
    elseif config.sourceMode == 'single' then
        local col = Catalog.findCollection(cat, config.sourceCollectionId)
        if not col then
            return 0, 'Collection', false, 'no-collection'
        end
        local raw = #col:getPhotos()
        local n = math.min(raw, POOL_CAP)
        return n, col:getName(), raw > POOL_CAP, nil
    elseif config.sourceMode == 'split' then
        local l = Catalog.findCollection(cat, config.sourceCollectionLeftId)
        local r = Catalog.findCollection(cat, config.sourceCollectionRightId)
        if not l or not r then
            return 0, 'Split', false, 'no-collection'
        end
        local lnRaw = #l:getPhotos()
        local rnRaw = #r:getPhotos()
        local ln = math.min(lnRaw, POOL_CAP)
        local rn = math.min(rnRaw, POOL_CAP)
        return ln + rn,
            'Split: ' .. ln .. ' left / ' .. rn .. ' right',
            lnRaw > POOL_CAP or rnRaw > POOL_CAP,
            nil
    end
    return 0, 'Unknown source mode', false, nil
end

function Handlers.publishDisplaySettings()
    local config = Prefs.config()
    IPC.setHeartbeatField('color_scheme', config.colorScheme or 'grey')
    local showMeta = config.showMetadata
    if showMeta == nil then showMeta = true end
    IPC.setHeartbeatField('show_metadata', showMeta and true or false)
    IPC.setHeartbeatField('settings_generation', Prefs.settingsGeneration())
end

local function publishPrefetchState()
    -- Surface the queue depth as part of prefetch_state so the
    -- browser's indicator shows "3/5" etc. and we can see at a
    -- glance whether topping up is keeping up with clicks.
    local depth = #State.nextQueue
    local state
    if State.prefetchRunning then
        state = string.format('running (%d/%d)', depth, PREFETCH_TARGET)
    elseif depth > 0 then
        state = string.format('ready (%d/%d)', depth, PREFETCH_TARGET)
    else
        state = 'idle'
    end
    IPC.setHeartbeatField('prefetch_state', state)
end

-- Assigns into the forward-declared local above.  Must NOT be
-- `local function` — that would shadow the outer forward
-- declaration with a fresh local and break the forward
-- reference from onPoolSettingsChanged.
function startPrefetchIfIdle()
    if State.prefetchRunning then return end
    if #State.nextQueue >= PREFETCH_TARGET then return end
    State.prefetchRunning = true
    publishPrefetchState()
    Log.line('prefetch', 'topping up',
        'have=' .. #State.nextQueue .. ' target=' .. PREFETCH_TARGET)

    LrTasks.startAsyncTask(function()
        LrFunctionContext.callWithContext('pairs-prefetch', function(context)
            local producedPair = false

            context:addCleanupHandler(function()
                State.prefetchRunning = false
                publishPrefetchState()
                -- Only re-trigger if we actually made progress.
                -- An empty pool would otherwise spin forever,
                -- logging "no pair available" on every retry.
                if producedPair and #State.nextQueue < PREFETCH_TARGET then
                    LrTasks.startAsyncTask(function()
                        LrTasks.sleep(0.05)
                        startPrefetchIfIdle()
                    end)
                end
            end)

            -- Generate one pair per outer invocation. Cleanup
            -- handler re-triggers on success until we reach the
            -- target. Stops cleanly when the pool is empty.
            -- Exclude current + already-queued pairs so the
            -- prefetch buffer doesn't reuse photos the user is
            -- about to see.  Fallback to no-exclude if pool is
            -- too small to satisfy strict exclusion.
            local config = Prefs.config()
            local pools = ensurePools(config)
            local excludes = currentPairExcludes()
            for _, queued in ipairs(State.nextQueue) do
                if queued.left then excludes[#excludes + 1] = queued.left end
                if queued.right then excludes[#excludes + 1] = queued.right end
            end
            local pair = Sampler.pickPair(pools, config, excludes)
            if not pair and #excludes > 0 then
                pair = Sampler.pickPair(pools, config)
            end
            if not pair then
                Log.line('prefetch', 'no pair available from pool')
                return
            end
            Thumbs.generatePair(
                uuidOf(pair.left), uuidOf(pair.right), currentImageSize())
            table.insert(State.nextQueue, pair)
            producedPair = true
            publishPrefetchState()
            Log.line('prefetch', 'appended',
                'depth=' .. #State.nextQueue ..
                ' left=' .. (uuidOf(pair.left) or '?') ..
                ' right=' .. (uuidOf(pair.right) or '?'))
        end)
    end)
end

-- Serialize upcoming queue for browser-side pre-caching. Browser
-- uses these uuids to warm its HTTP cache for thumbnails it'll
-- likely need on the next few Advance clicks.
local function serializePrefetchPeek()
    -- Each peek entry includes UUIDs AND dimensions so the
    -- browser can do an optimistic advance: render the next
    -- pair from peek[0] immediately on Space, then call the
    -- plugin async to keep state in sync.  Dimensions match
    -- the shape jsonPair() emits for the current pair so the
    -- browser's buildImg() path can set aspect-ratio without
    -- waiting for image decode (avoids layout pop on optimistic
    -- display).
    local parts = {}
    for _, pair in ipairs(State.nextQueue) do
        local lW, lH = Sampler.dimensionsOf(pair.left)
        local rW, rH = Sampler.dimensionsOf(pair.right)
        parts[#parts + 1] = string.format(
            '{"left":{"uuid":"%s","orientation":"%s","width":%d,"height":%d},'
            .. '"right":{"uuid":"%s","orientation":"%s","width":%d,"height":%d}}',
            uuidOf(pair.left),  dataOrientation(pair.left),  lW or 0, lH or 0,
            uuidOf(pair.right), dataOrientation(pair.right), rW or 0, rH or 0)
    end
    return '[' .. table.concat(parts, ',') .. ']'
end

-- ---- response builders ----
--
-- We build JSON responses as strings directly rather than through
-- a generic table-to-JSON helper. Too easy to mix up "plain string
-- that needs quoting" with "pre-serialized JSON literal" — the
-- earlier generic version did this wrong and corrupted every pair
-- response with double-quoted nested JSON.

-- Returns a JSON-fragment string (e.g. ',"preview_warning":true')
-- if any of the supplied thumb errors was a timeout, indicating
-- LR is generating previews on demand from RAW (cold preview
-- cache).  Empty string otherwise.  Browser surfaces this as an
-- actionable banner: "Library → Previews → Build Standard-Sized
-- Previews" — the one-time fix that makes future requests fast.
local function previewWarningFragment(...)
    for i = 1, select('#', ...) do
        local err = select(i, ...)
        if type(err) == 'string' and err:match('^timeout') then
            return ',"preview_warning":true'
        end
    end
    return ''
end

local function respondPair(id, pair, extraHistoryDepth)
    -- Generate thumbnails for both sides before responding.  If
    -- thumb generation fails for either side, return an error
    -- response rather than a success response with empty thumb
    -- names — otherwise the browser gets
    -- `thumb_left=""` and sets `img.src = ""`, which silently
    -- shows a blank slot with no toast to the user (classic
    -- "action disappeared into the void" UX failure).  See
    -- bugs.md for the original repro.
    local thumbNames = Thumbs.generatePair(
        uuidOf(pair.left), uuidOf(pair.right), currentImageSize())

    if not thumbNames.left or not thumbNames.right then
        -- Either side missing its name means Thumbs.generatePair
        -- hit a timeout or LR SDK error.  Report both sides'
        -- errors so diagnostic logs / toasts contain the real
        -- cause.  Pattern deliberately mirrors respondError so
        -- the browser's ok=false path fires the toast.
        local msg = 'thumb generation failed'
        if thumbNames.leftError then
            msg = msg .. ' (left: ' .. thumbNames.leftError .. ')'
        end
        if thumbNames.rightError then
            msg = msg .. ' (right: ' .. thumbNames.rightError .. ')'
        end
        Log.line('advance', 'thumb gen failed',
            'left_err=' .. tostring(thumbNames.leftError) ..
            ' right_err=' .. tostring(thumbNames.rightError))
        local pw = previewWarningFragment(
            thumbNames.leftError, thumbNames.rightError)
        return string.format(
            '{"id":"%s","ok":false,"error":"%s"%s}',
            id, IPC.jsonEscape(msg), pw)
    end

    local extra = extraHistoryDepth
        and string.format(',"history_depth":%d', extraHistoryDepth)
        or ''
    -- Peek of the current prefetch queue. Browser uses these uuids
    -- to pre-cache the JPEGs for upcoming advances so the <img
    -- src=...> swap is instant.
    local peekJson = serializePrefetchPeek()
    return string.format(
        '{"id":"%s","ok":true,"pair":%s,"image_size":%d,"image_left":"%s","image_right":"%s","prefetch_peek":%s%s}',
        id,
        jsonPair(pair),
        currentImageSize(),
        IPC.jsonEscape(thumbNames.left),
        IPC.jsonEscape(thumbNames.right),
        peekJson,
        extra)
end

local function respondError(id, msg)
    return string.format(
        '{"id":"%s","ok":false,"error":"%s"}',
        id, IPC.jsonEscape(msg))
end

-- ---- handlers ----

local function handleGetState(id)
    initOnce()
    local config = Prefs.config()

    -- If we have a restored prior pair, re-generate its thumbnails
    -- on disk in case the /tmp dir was swept between sessions.
    -- The JPEGs themselves persist there, so this is a no-op when
    -- they're already present; otherwise it regenerates before we
    -- hand the filename to the browser.
    local thumbLeft, thumbRight = '', ''
    local previewWarning = ''
    if State.currentPair then
        local names = Thumbs.generatePair(
            uuidOf(State.currentPair.left),
            uuidOf(State.currentPair.right),
            currentImageSize())
        thumbLeft = names.left or ''
        thumbRight = names.right or ''
        previewWarning = previewWarningFragment(
            names.leftError, names.rightError)
    end

    local pairJson = State.currentPair and jsonPair(State.currentPair) or 'null'
    -- Include user-visible preferences so the browser can reflect
    -- them (show/hide caption, color scheme, etc.) without its
    -- own prefs store.
    local showMeta = config.showMetadata
    if showMeta == nil then showMeta = true end
    local response = string.format(
        '{"id":"%s","ok":true,"source_mode":"%s","pair":%s,"history_depth":%d,"image_size":%d,"image_left":"%s","image_right":"%s","show_metadata":%s,"color_scheme":"%s","version":"%s"%s}',
        id,
        IPC.jsonEscape(config.sourceMode or 'current'),
        pairJson,
        #State.history,
        currentImageSize(),
        IPC.jsonEscape(thumbLeft),
        IPC.jsonEscape(thumbRight),
        tostring(showMeta and true or false),
        IPC.jsonEscape(config.colorScheme or 'grey'),
        IPC.jsonEscape(Version.string()),
        previewWarning)

    -- Start prefetch so the user's first Advance serves from a
    -- warm queue instead of having to synchronously sample and
    -- generate thumbs.  getState runs on every browser open
    -- (the initial request from the web UI), so this is the
    -- natural "connection is live" hook for queue warming.
    -- startPrefetchIfIdle is idempotent: no-op if queue is
    -- already at target or prefetch task is already running.
    startPrefetchIfIdle()

    return response
end

-- Try to resolve a stored history entry { leftUuid, rightUuid }
-- back to a live { left = LrPhoto, right = LrPhoto }. Returns nil
-- if either photo no longer resolves (deleted, catalog switched,
-- etc.).
local function resolveHistoryEntry(entry)
    if not entry or not entry.leftUuid or not entry.rightUuid then
        return nil
    end
    local cat = catalog()
    local l = cat:findPhotoByUuid(entry.leftUuid)
    local r = cat:findPhotoByUuid(entry.rightUuid)
    if not l or not r then return nil end
    return { left = l, right = r }
end

local function handleAdvance(id)
    initOnce()
    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'

    local pair
    local source  -- for the log line

    -- If there's a forward stack (user hit Back and is now going
    -- forward again), redo into it before drawing anything new.
    -- Matches browser back/forward semantics.
    while #State.forward > 0 and not pair do
        local entry = table.remove(State.forward)
        pair = resolveHistoryEntry(entry)
        if pair then source = 'forward' end
    end

    -- No forward history — serve the next prefetched candidate.
    if not pair then
        pair = table.remove(State.nextQueue, 1)
        if pair then
            publishPrefetchState()
            source = 'prefetch'
        end
    end

    -- Prefetch queue empty — sample fresh.
    -- Exclude the current pair's photos so a fresh Advance never
    -- shows (A, B) → (A, C) or (A, B) → (B, A).  Strict
    -- exclusion can return nil on a 2-photo pool where both
    -- photos are excluded; fall back to a no-exclude pick so the
    -- user still gets *something* rather than an error message
    -- in tiny pools.
    if not pair then
        local pools = ensurePools(config)
        local excludes = currentPairExcludes()
        pair = Sampler.pickPair(pools, config, excludes)
        if not pair and #excludes > 0 then
            pair = Sampler.pickPair(pools, config)
        end
        if not pair then
            return respondError(id, 'No pair available - check source and filters')
        end
        source = 'fresh'
    end

    -- Log accuracy note: this is "picked", not "served".  Served
    -- means "response is out the door" — that only happens after
    -- respondPair (thumb generation) below and the IPC
    -- writeResponseFile after this handler returns.  Previously
    -- this was mislabelled "served" and masked a first-advance
    -- timeout bug: the log showed "served" at t+0 while the
    -- helper's 30s timer was still running against the slow
    -- thumb generation that followed.
    Log.line('advance', 'picked',
        'source=' .. source ..
        ' queue=' .. #State.nextQueue ..
        ' fwd=' .. #State.forward ..
        ' hist=' .. #State.history)

    -- Push old current onto history, trim to configured depth.
    if State.currentPair then
        table.insert(State.history, historyEntryFromPair(State.currentPair))
        while #State.history > (config.historyDepth or 50) do
            table.remove(State.history, 1)
        end
        Prefs.setHistory(catalogId, config.targetSetId, State.history)
    end
    State.currentPair = pair
    Prefs.setCurrentPair(catalogId, config.targetSetId, {
        leftUuid = uuidOf(pair.left),
        rightUuid = uuidOf(pair.right),
        leftId = pair.left.localIdentifier,
        rightId = pair.right.localIdentifier,
    })

    Stats.incrementViewed(catalogId)
    local t0 = os.time()
    local response = respondPair(id, pair, #State.history)
    local elapsed = os.time() - t0
    Log.line('advance', 'responded',
        'source=' .. source .. ' thumb_gen_s=' .. elapsed)
    startPrefetchIfIdle()
    return response
end

local function handleBack(id)
    initOnce()
    if #State.history == 0 then
        return respondError(id, 'No history')
    end
    local prev = table.remove(State.history)
    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'
    Prefs.setHistory(catalogId, config.targetSetId, State.history)

    local l = cat:findPhotoByUuid(prev.leftUuid or '')
    local r = cat:findPhotoByUuid(prev.rightUuid or '')
    if not l or not r then
        return respondError(id, 'Could not resolve previous pair')
    end
    -- Push the pair we're leaving onto the forward stack so a
    -- subsequent Advance redoes into it rather than drawing a
    -- fresh fork.
    if State.currentPair then
        table.insert(State.forward, historyEntryFromPair(State.currentPair))
    end
    State.currentPair = { left = l, right = r }
    Prefs.setCurrentPair(catalogId, config.targetSetId, {
        leftUuid = prev.leftUuid, rightUuid = prev.rightUuid,
        leftId = l.localIdentifier, rightId = r.localIdentifier,
    })
    Stats.incrementBack()
    return respondPair(id, State.currentPair, #State.history)
end

local function handleSwap(id)
    initOnce()
    if not State.currentPair then
        return respondError(id, 'No current pair')
    end
    local p = State.currentPair
    State.currentPair = { left = p.right, right = p.left }
    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'
    Prefs.setCurrentPair(catalogId, config.targetSetId, {
        leftUuid = uuidOf(State.currentPair.left),
        rightUuid = uuidOf(State.currentPair.right),
        leftId = State.currentPair.left.localIdentifier,
        rightId = State.currentPair.right.localIdentifier,
    })
    Stats.incrementSwap()
    return respondPair(id, State.currentPair, #State.history)
end

local function handleReshuffleSide(id, reqJson)
    initOnce()
    local side = IPC.extractField(reqJson, 'side') or 'left'
    if side ~= 'left' and side ~= 'right' then
        return respondError(id, 'side must be left or right')
    end
    if not State.currentPair then
        -- Fall through to a fresh draw so clicking the per-side
        -- button on an empty window still does something useful.
        return handleAdvance(id)
    end

    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'

    local pools = ensurePools(config)
    local pair = Sampler.reshuffleSide(pools, config, State.currentPair, side)
    if not pair then
        return respondError(id, 'No candidate available for ' .. side)
    end

    table.insert(State.history, historyEntryFromPair(State.currentPair))
    while #State.history > (config.historyDepth or 50) do
        table.remove(State.history, 1)
    end
    Prefs.setHistory(catalogId, config.targetSetId, State.history)
    State.currentPair = pair
    -- Reshuffle is a path fork — any forward redo we had can no
    -- longer be reached linearly, so clear it.
    State.forward = {}
    Prefs.setCurrentPair(catalogId, config.targetSetId, {
        leftUuid = uuidOf(pair.left), rightUuid = uuidOf(pair.right),
        leftId = pair.left.localIdentifier,
        rightId = pair.right.localIdentifier,
    })
    Stats.incrementViewed(catalogId)
    return respondPair(id, pair, #State.history)
end

-- Compute the next pair number by scanning the target set for
-- existing "Saved Random Pair NNNN" collection names and taking
-- max+1.  This keeps saves aligned with what's actually in the
-- catalog, not with an LrPrefs counter that can drift if
-- collections are deleted or if the plugin is uninstalled /
-- reinstalled.  4-digit minimum, overflows to 5+ digits naturally.
--
-- Build 102 renamed the saved-pair collections from the old
-- "Pair NNNN ..." (with optional "L:<x> R:<y>" suffix) format to
-- "Saved Random Pair NNNN".  The regex used to be `^Pair (%d+)`,
-- which silently stopped matching the new names — every save
-- computed max=0 and tried to create "Saved Random Pair 0001"
-- forever, except createCollection's "return existing" mode
-- collapsed them all into the same collection.  That's how a
-- single "Saved Random Pair NNNN" can end up with 6+ photos.
-- Old-format collections are NOT migrated; testers who care
-- can delete or rename pre-build-102 collections by hand.
local function nextPairNumberForSet(targetSet)
    local max = 0
    for _, child in ipairs(targetSet:getChildCollections()) do
        local n = tonumber(child:getName():match('^Saved Random Pair (%d+)'))
        if n and n > max then max = n end
    end
    return max + 1
end

local function handleSave(id)
    initOnce()
    if not State.currentPair then
        return respondError(id, 'No pair to save')
    end
    local config = Prefs.config()
    local cat = catalog()
    local targetSet = Catalog.findCollectionSet(cat, config.targetSetId)
    if not targetSet then
        return respondError(id, 'Target for saved pairs not configured, select in settings')
    end
    local n = nextPairNumberForSet(targetSet)
    -- No pcall: Save.savePair enters withWriteAccessDo which yields
    -- internally. Any genuine error propagates as a plugin-side
    -- task error; the browser sees a gateway timeout.
    Save.savePair(cat, targetSet, State.currentPair, n)
    Stats.incrementSaved(cat.localIdentifier or 'default')
    return string.format(
        '{"id":"%s","ok":true,"pair_number":%d,"target_set_name":"%s"}',
        id, n, IPC.jsonEscape(targetSet:getName()))
end

local function handleNewSession(id)
    initOnce()
    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'

    Prefs.clearSession(catalogId, config.targetSetId)
    State.history = {}
    State.currentPair = nil
    -- forward/nextQueue reference photos that belonged to the
    -- prior session. If we leave them in place, the very next
    -- Advance redoes into the old pair (forward stack) or serves
    -- a pre-picked "next" (nextQueue), which defeats the point of
    -- a fresh session.
    State.forward = {}
    State.nextQueue = {}

    -- Immediately draw a fresh pair so the window isn't empty.
    local pools = ensurePools(config)
    local pair = Sampler.pickPair(pools, config)
    if not pair then
        return string.format(
            '{"id":"%s","ok":true,"pair":null,"history_depth":0,"message":"session cleared"}',
            id)
    end
    State.currentPair = pair
    Prefs.setCurrentPair(catalogId, config.targetSetId, {
        leftUuid = uuidOf(pair.left), rightUuid = uuidOf(pair.right),
        leftId = pair.left.localIdentifier,
        rightId = pair.right.localIdentifier,
    })
    return respondPair(id, pair, 0)
end

local function handleRefreshSnapshot(id)
    initOnce()
    takeSnapshot()
    local config = Prefs.config()
    local cat = catalog()
    local catalogId = cat.localIdentifier or 'default'
    local pools = ensurePools(config)
    local pair = Sampler.pickPair(pools, config)
    if not pair then
        return respondError(id, 'Snapshot is empty')
    end
    if State.currentPair then
        table.insert(State.history, historyEntryFromPair(State.currentPair))
        while #State.history > (config.historyDepth or 50) do
            table.remove(State.history, 1)
        end
        Prefs.setHistory(catalogId, config.targetSetId, State.history)
    end
    State.currentPair = pair
    -- Pool changed; forward redo entries point at a prior pool and
    -- aren't meaningfully reachable. Clear.
    State.forward = {}
    Prefs.setCurrentPair(catalogId, config.targetSetId, {
        leftUuid = uuidOf(pair.left), rightUuid = uuidOf(pair.right),
        leftId = pair.left.localIdentifier,
        rightId = pair.right.localIdentifier,
    })
    Stats.incrementViewed(catalogId)
    return respondPair(id, pair, #State.history)
end

local function handleReveal(id, reqJson)
    initOnce()
    local side = IPC.extractField(reqJson, 'side') or 'left'
    if not State.currentPair then
        return respondError(id, 'No current pair')
    end
    local photo = side == 'left' and State.currentPair.left or State.currentPair.right
    local cat = catalog()
    local config = Prefs.config()

    -- setSelectedPhotos only highlights photos that are in the
    -- active source. If a source collection is configured, switch
    -- to it so the photo is actually visible in Library.
    local sources
    if config.sourceMode == 'single' then
        local col = Catalog.findCollection(cat, config.sourceCollectionId)
        if col then sources = { col } end
    elseif config.sourceMode == 'split' then
        sources = {}
        local leftCol = Catalog.findCollection(cat, config.sourceCollectionLeftId)
        local rightCol = Catalog.findCollection(cat, config.sourceCollectionRightId)
        if leftCol then sources[#sources + 1] = leftCol end
        if rightCol then sources[#sources + 1] = rightCol end
        if #sources == 0 then sources = nil end
    end
    if sources then cat:setActiveSources(sources) end
    cat:setSelectedPhotos(photo, { photo })

    -- Bring Lightroom to the OS-level front so the user
    -- actually sees the photo we just selected.  Without
    -- this, the browser stays on top and the user has to
    -- alt-tab to verify the reveal worked — defeats the
    -- point of a Reveal action.  Fire-and-forget; we
    -- don't await or check rc.
    LrTasks.execute(Platform.focusLightroomCmd())

    return string.format(
        '{"id":"%s","ok":true,"filename":"%s","side":"%s"}',
        id, IPC.jsonEscape(filenameOf(photo)), side)
end

-- Manual "focus Lightroom" — bound to the `L` key and the
-- Focus Lightroom button in the web view.  Useful as a
-- fallback when the auto-focus paths (Reveal, web-window-
-- close beacon) miss; user explicitly says "bring me back
-- to LR" without needing to do a Reveal first.
--
-- Windows wrinkle: bare `Platform.focusLightroomCmd()`
-- (Win32 SetForegroundWindow via P/Invoke from PowerShell)
-- is silently blocked by focus-stealing prevention when LR
-- has no recent UI activity — Windows flashes the taskbar
-- instead of activating.  In Reveal this works because
-- `setSelectedPhotos` first wakes LR and gives it
-- foreground privilege the shell-out can inherit.
--
-- Here we use a less-invasive nudge: switch to the
-- currently-active LR module (a no-op switch — same
-- module — but still registers as UI activity from LR's
-- side, which should satisfy Windows' focus-claim rules
-- without touching the user's photo selection.  macOS
-- doesn't need this priming but the call is cheap.
local function handleFocusLr(id, _reqJson)
    local LrApplicationView = import 'LrApplicationView'
    local ok, currentModule = pcall(
        LrApplicationView.getCurrentModuleName)
    if ok and currentModule then
        pcall(LrApplicationView.switchToModule, currentModule)
    end
    LrTasks.execute(Platform.focusLightroomCmd())
    return string.format('{"id":"%s","ok":true}', id)
end

-- ---- register ----

function Handlers.register()
    IPC.register('getState',        handleGetState)
    IPC.register('advance',         handleAdvance)
    IPC.register('back',            handleBack)
    IPC.register('swap',            handleSwap)
    IPC.register('reshuffleSide',   handleReshuffleSide)
    IPC.register('save',            handleSave)
    IPC.register('newSession',      handleNewSession)
    IPC.register('refreshSnapshot', handleRefreshSnapshot)
    IPC.register('reveal',          handleReveal)
    IPC.register('focusLr',         handleFocusLr)
    Log.line('handlers', 'registered Phase 2 + 3 actions')
end

return Handlers
