-- Thumbnail generation. Wraps LrPhoto:requestJpegThumbnail (async
-- with callback) into a task-friendly synchronous-feeling call and
-- writes the bytes to /tmp/lr-random-pair-generator/thumbs/<uuid>-<size>.jpg so the
-- helper's http.FileServer can serve them as static images.

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

local Log = require 'Log'
local IPC = require 'IPC'
local Platform = require 'Platform'

local Thumbs = {}

local function thumbsDir()
    return LrPathUtils.child(IPC.dir(), 'thumbs')
end

local function ensureDir()
    -- LrFileUtils.createAllDirectories is portable and a no-op
    -- when the dir already exists.
    LrFileUtils.createAllDirectories(thumbsDir())
end

-- 30s is generous; a big TIF at 2400px can take several seconds the
-- first time. Subsequent requests at the same size for the same photo
-- hit LR's preview cache.
local FETCH_TIMEOUT_SEC = 30

-- One outstanding requestJpegThumbnail across the WHOLE plugin.
-- Thumbs.generatePair keeps its two calls sequential, but that
-- convention only serializes within one caller — the prefetch
-- task and a user action (advance, reshuffle) could still each
-- run their own generatePair concurrently, which is exactly the
-- two-callbacks-in-flight failure mode documented above
-- Thumbs.generatePair.  This slot enforces the invariant by
-- construction for every caller.  Lives on _G so a module
-- reload can't strand a fresh false alongside an old task that
-- still holds the slot (same reasoning as
-- _G.__lrrpg_dispatch_in_flight in IPC.lua).
-- See dev/code-review-2026-07-03.md §1.2.
local function acquireFetchSlot()
    while _G.__lrrpg_thumb_fetch_in_flight do
        LrTasks.sleep(0.02)
    end
    _G.__lrrpg_thumb_fetch_in_flight = true
end

-- Monotonic suffix so two tasks generating the same uuid+size
-- never write to the same temporary file.  With a shared name,
-- interleaved writes could publish a corrupt JPEG that the
-- exists() cache check then serves forever.
local tmpNameCounter = 0

local function fetchBytes(photo, width, height)
    local bytes, errStatus
    local done = false
    -- requestJpegThumbnail returns a request object that MUST stay
    -- referenced until the callback fires: if the handle becomes
    -- garbage, the SDK cancels the request and the callback never
    -- runs.  We used to discard the return value, so a GC pass
    -- during the wait loop (likely under big-catalog metadata
    -- churn) could silently cancel the fetch and we burned the
    -- full FETCH_TIMEOUT_SEC.  Suspected root cause of the
    -- intermittent stall described above Thumbs.generatePair —
    -- see dev/code-review-2026-07-03.md §1.1.  The local keeps
    -- the handle alive for the whole scope; nothing reads it.
    local thumbnailRequest = photo:requestJpegThumbnail(width, height,
        function(b, status)
            bytes = b
            errStatus = status
            done = true
        end)
    local waited = 0
    while not done and waited < FETCH_TIMEOUT_SEC do
        LrTasks.sleep(0.02)
        waited = waited + 0.02
    end
    thumbnailRequest = nil
    if not done then
        return nil, 'timeout after ' .. FETCH_TIMEOUT_SEC .. 's'
    end
    return bytes, errStatus
end

-- Generate a thumbnail for uuid + size, write to disk, return the
-- filename relative to thumbs/ (e.g. "abc123-1600.jpg") so the
-- browser can load /thumbs/<filename>.
function Thumbs.generate(uuid, size)
    ensureDir()
    local catalog = LrApplication.activeCatalog()
    local photo = catalog:findPhotoByUuid(uuid)
    if not photo then
        return nil, 'photo not found'
    end
    local name = uuid .. '-' .. tostring(size) .. '.jpg'
    local path = LrPathUtils.child(thumbsDir(), name)
    if LrFileUtils.exists(path) then
        return name, nil  -- already on disk
    end
    local bytes, err
    -- Pattern B (callWithContext): the cleanup handler releases
    -- the fetch slot on both the normal and the error path, so a
    -- raise inside the SDK call can't wedge every future thumb.
    LrFunctionContext.callWithContext('lrrpg-thumb-fetch',
        function(context)
            acquireFetchSlot()
            context:addCleanupHandler(function()
                _G.__lrrpg_thumb_fetch_in_flight = false
            end)
            -- Another task may have produced this exact thumb
            -- while we waited for the slot; skip the fetch and
            -- let the exists() check after the context pick it up.
            if LrFileUtils.exists(path) then return end
            bytes, err = fetchBytes(photo, size, size)
        end)
    if LrFileUtils.exists(path) then
        return name, nil  -- produced by whoever held the slot
    end
    if not bytes then
        -- Log the UUID on every generation failure so we can
        -- check whether the same photo(s) recur.  If the same
        -- UUIDs show up across multiple stall repros it's
        -- catalog/photo-specific; if each stall is a different
        -- UUID it's a general SDK concurrency problem.
        Log.line('thumbs', 'generate failed',
            'uuid=' .. tostring(uuid) ..
            ' size=' .. tostring(size) ..
            ' err=' .. tostring(err))
        return nil, err or 'thumbnail request failed'
    end
    -- Atomic publish: write to .tmp first, then rename to the
    -- final path.  Without this, an interrupted write (LR
    -- quit, force-kill, disk full mid-write) leaves a corrupt
    -- JPEG at the final path, and future runs see
    -- LrFileUtils.exists() = true and serve the bad bytes as
    -- a "cache hit".  Rename is atomic on same filesystem.
    tmpNameCounter = tmpNameCounter + 1
    local tmpPath = path .. '.' .. tmpNameCounter .. '.tmp'
    local f, openErr = io.open(tmpPath, 'wb')
    if not f then
        return nil, 'write failed: ' .. tostring(openErr)
    end
    f:write(bytes)
    f:close()
    local ok, moveErr = LrFileUtils.move(tmpPath, path)
    if not ok then
        LrFileUtils.delete(tmpPath)
        return nil, 'rename failed: ' .. tostring(moveErr)
    end
    return name, nil
end

-- Generate both slots of a pair, strictly sequentially.
--
-- LR's `requestJpegThumbnail` has a concurrency pathology
-- where a second outstanding callback intermittently never
-- fires, stalling the full 30s `FETCH_TIMEOUT_SEC` ceiling
-- before our poll loop gives up.  We first saw this on
-- Windows (build-16 repros: single-side reshuffle fine,
-- full-pair advance ~10% failure, size irrelevant) and
-- assumed it was Windows-only.  Build 26 on macOS hit the
-- same failure pattern (both sides timing out together
-- after a filter change), so the pathology exists on both
-- platforms — just at a lower rate on Mac because its
-- bigger preview cache means fewer slow rebuilds.
--
-- So: sequential on both platforms.  Trades a small mean
-- latency increase (~min(left, right) of extra wait on
-- the happy path) for never exposing ourselves to the
-- two-callbacks-in-flight failure mode.
--
-- Build 20: the one-at-a-time invariant is additionally
-- enforced for ALL callers by the fetch slot inside
-- Thumbs.generate (sequential-per-pair alone couldn't stop
-- prefetch and a user action overlapping), and the retained
-- request handle in fetchBytes likely removes the underlying
-- cause.  The sequential structure here stays because it's
-- the simplest shape.
function Thumbs.generatePair(leftUuid, rightUuid, size)
    -- One outstanding requestJpegThumbnail at a time.
    -- Cache-hit short-circuits inside Thumbs.generate make
    -- this effectively free when either side is already on
    -- disk (e.g. the unchanged slot during reshuffleSide).
    local leftName, leftErr = Thumbs.generate(leftUuid, size)
    local rightName, rightErr = Thumbs.generate(rightUuid, size)
    return {
        left = leftName, leftError = leftErr,
        right = rightName, rightError = rightErr,
    }
end

-- Former parallel path kept below, commented out, so the
-- diff of the switch reads cleanly and the parallel
-- implementation is easy to restore if future SDK behavior
-- eliminates the concurrency pathology.  Not reachable.
--[[
local function generatePairConcurrent(leftUuid, rightUuid, size)
    ensureDir()
    local catalog = LrApplication.activeCatalog()

    local slots = {
        { uuid = leftUuid, key = 'left' },
        { uuid = rightUuid, key = 'right' },
    }

    -- Phase 1: fire requests (or mark already-cached / error).
    -- Slot fields used downstream:
    --   name    — final filename on disk, nil on error
    --   path    — absolute path (used by write phase)
    --   cached  — true if the JPEG was already on disk
    --   bytes   — filled by the requestJpegThumbnail callback
    --   status  — error status from the callback
    --   done    — true once either the cache-hit check succeeded
    --             or the callback fired (or we short-circuited)
    --   err     — human-readable error if anything went wrong
    for _, slot in ipairs(slots) do
        if not slot.uuid then
            slot.err = 'missing uuid'
            slot.done = true
        else
            slot.name = slot.uuid .. '-' .. tostring(size) .. '.jpg'
            slot.path = LrPathUtils.child(thumbsDir(), slot.name)
            if LrFileUtils.exists(slot.path) then
                slot.cached = true
                slot.done = true
            else
                local photo = catalog:findPhotoByUuid(slot.uuid)
                if not photo then
                    slot.err = 'photo not found'
                    slot.name = nil
                    slot.done = true
                else
                    -- Capture slot by closure — each callback
                    -- writes back to its own slot table.  The
                    -- SDK guarantees the callback runs on the
                    -- main task once the thumb is ready.
                    photo:requestJpegThumbnail(size, size,
                        function(b, status)
                            slot.bytes = b
                            slot.status = status
                            slot.done = true
                        end)
                end
            end
        end
    end

    -- Phase 2: wait for both to finish (or fetch timeout).  A
    -- single loop polls both flags — max(L, R) wall time.
    local waited = 0
    while waited < FETCH_TIMEOUT_SEC do
        if slots[1].done and slots[2].done then break end
        LrTasks.sleep(0.02)
        waited = waited + 0.02
    end

    -- Phase 3: finalize each slot — write bytes, record error,
    -- or mark timeout.  File I/O here is microseconds next to
    -- the SDK calls above, so sequential writes are fine.
    for _, slot in ipairs(slots) do
        if not slot.done then
            slot.err = 'timeout after ' .. FETCH_TIMEOUT_SEC .. 's'
            slot.name = nil
        elseif slot.bytes and not slot.cached then
            local tmpPath = slot.path .. '.tmp'
            local f, openErr = io.open(tmpPath, 'wb')
            if not f then
                slot.err = 'write failed: ' .. tostring(openErr)
                slot.name = nil
            else
                f:write(slot.bytes)
                f:close()
                local ok, moveErr = LrFileUtils.move(tmpPath, slot.path)
                if not ok then
                    LrFileUtils.delete(tmpPath)
                    slot.err = 'rename failed: ' .. tostring(moveErr)
                    slot.name = nil
                end
            end
        elseif not slot.cached and not slot.bytes and not slot.err then
            -- Callback fired but bytes were nil — request failed
            -- for a reason the SDK signaled via the status arg.
            slot.err = tostring(slot.status or 'thumbnail request failed')
            slot.name = nil
        end
    end

    return {
        left = slots[1].name, leftError = slots[1].err,
        right = slots[2].name, rightError = slots[2].err,
    }
end
--]]

function Thumbs.clear()
    -- Portable iteration + delete instead of a shell glob —
    -- `rm -f 'dir'/*.jpg` on Unix vs. `del /q *.jpg` on Windows.
    local dir = thumbsDir()
    if not LrFileUtils.exists(dir) then return end
    for path in LrFileUtils.files(dir) do
        -- .tmp catches temporaries orphaned by a crash mid-write
        -- (they carry a counter suffix, so they never get
        -- overwritten by a later attempt the way a fixed name was).
        if path:match('%.jpg$') or path:match('%.tmp$') then
            LrFileUtils.delete(path)
        end
    end
    Log.line('thumbs', 'cache cleared')
end

-- Cap the thumbnail cache to N newest files. Rationale: each
-- 1600px JPEG is roughly 500KB-1MB, and accumulation across
-- months of use would bloat the thumbs dir arbitrarily.  500
-- files ≈ 500MB — generous headroom for recent pair history
-- while bounding disk use.
--
-- Sort-by-mtime lives in a shell command because LrFileUtils
-- doesn't expose file modification times; Platform.thumbCleanupCmd
-- picks the right shell per platform (POSIX ls+tail on Mac,
-- PowerShell Get-ChildItem on Windows).
function Thumbs.cleanup(maxFiles)
    maxFiles = maxFiles or 500
    local dir = thumbsDir()
    if not LrFileUtils.exists(dir) then return end
    LrTasks.execute(Platform.thumbCleanupCmd(dir, maxFiles))
    Log.line('thumbs', 'cleanup run',
        'dir=' .. dir .. ' keep_newest=' .. maxFiles)
end

return Thumbs
