-- Plugin-side file IPC poller.
--
-- Watches IPC_DIR/req-*.json for incoming requests from the Go
-- helper, dispatches to action handlers, writes responses to
-- IPC_DIR/resp-<id>.json. Also maintains a heartbeat file the
-- helper reads to decide whether Lightroom is still alive.
--
-- Phase 1: only handles "ping" (used by the browser's status
-- indicator to confirm full round-trip works). Real actions land
-- in Phase 2+.

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

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

local IPC = {}

-- IPC_DIR resolves per platform via Platform.scratchRoot.  On
-- macOS: /tmp/lr-random-pair-generator.  On Windows:
-- <user>\AppData\Local\Temp\lr-random-pair-generator.
local IPC_DIR = Platform.scratchRoot
local EPOCH_FILE = LrPathUtils.child(IPC_DIR, 'epoch')
local POLL_SEC = 0.05

-- Epoch tracking so module reloads don't leave zombie poller
-- tasks.  The current epoch lives in a file, not in a Lua value:
-- LR can (under certain conditions — plugin disable+enable,
-- auto-reload quirks) create new Lua states for the plugin, and
-- `_G` isn't shared across states.  A file IS shared, and is
-- the only coordination mechanism that works in every case.
--
-- Old tasks read the file each tick and self-retire when the
-- stored epoch no longer matches the one they captured at
-- startup.  IPC.start increments the file; IPC.stop increments
-- it too (to kill any in-flight tasks).
-- Public: also used by Dashboard's poll loop so it self-retires
-- on plugin reload using the same coordination IPC uses.  Without
-- this, dashboard's `while active ~= nil` keeps zombie poll loops
-- ticking from prior module instances after a reload — visible
-- as helper-row green/red flicker (two loops racing on the same
-- props) and tick-counter interleaving in plugin.log.
function IPC.readEpoch()
    -- io.open (stdlib) returns nil if the file doesn't exist.
    -- LrFileUtils.readFile throws instead, which would bubble a
    -- nasty "Couldn't open file … for reading" error on the
    -- very first start when the epoch file hasn't been created
    -- yet.
    local f = io.open(EPOCH_FILE, 'r')
    if not f then return 0 end
    local content = f:read('*a') or ''
    f:close()
    return tonumber(content:match('%-?%d+')) or 0
end
local readEpoch = IPC.readEpoch

local function writeEpoch(n)
    local f = io.open(EPOCH_FILE, 'w')
    if not f then return end
    f:write(tostring(n))
    f:close()
    -- Also bump the _G-based counter so any zombie tasks from
    -- earlier versions of this module (which checked _G rather
    -- than the file) see a mismatch and retire themselves.
    -- Belt-and-suspenders against the live zombies we're trying
    -- to evict.
    _G.__lrrpg_ipc_epoch = n
end

local requestsProcessed = 0

-- Registered action handlers: name -> function(id, requestJson).
-- Handlers return a string (JSON body) or nil + error string.
local handlers = {}

-- Serialize handler execution. Each handler runs in its own
-- async task (so yielding + error-catching work via
-- LrFunctionContext.postAsyncTaskWithContext), but those tasks
-- share mutable State in Handlers.lua (currentPair, history,
-- pools, nextQueue). Without this flag, two clicks arriving
-- close together produce two tasks racing on State — symptoms
-- include intermittent "No pair available" (stale pool read)
-- and "handler error" (nil-deref on half-modified state).
-- Dispatch waits for the in-flight handler to clear before
-- starting the next. Flag lives on _G so module reloads don't
-- leave it stuck on true.
_G.__lrrpg_dispatch_in_flight = false

function IPC.register(name, fn)
    handlers[name] = fn
end

-- Minimal JSON field extractors. Handlers that need complex parsing
-- should be selective; for now we only need id and action at the
-- router level.
function IPC.extractField(json, field)
    return json:match('"' .. field .. '"%s*:%s*"([^"]*)"')
end

function IPC.extractNumber(json, field)
    return tonumber(json:match('"' .. field .. '"%s*:%s*(%-?%d+)'))
end

function IPC.jsonEscape(s)
    return (tostring(s):gsub('\\', '\\\\'):gsub('"', '\\"'))
end

-- Atomic publish: write to resp-<id>.json.tmp, then rename into
-- place. The helper polls resp-<id>.json and would otherwise race
-- our in-progress write and read a truncated/empty body. Rename on
-- the same filesystem is atomic, so the helper either sees the
-- final file or nothing.
local function writeResponseFile(id, body)
    local respPath = LrPathUtils.child(IPC_DIR, 'resp-' .. id .. '.json')
    local tmpPath = respPath .. '.tmp'
    local f, err = io.open(tmpPath, 'w')
    if not f then
        Log.line('ipc', 'write resp failed', tostring(err))
        return false
    end
    f:write(body)
    f:close()
    local ok, moveErr = LrFileUtils.move(tmpPath, respPath)
    if not ok then
        Log.line('ipc', 'rename resp failed', tostring(moveErr))
        LrFileUtils.delete(tmpPath)
        return false
    end
    return true
end

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

local function dispatchRequest(reqPath)
    local content = LrFileUtils.readFile(reqPath)
    if not content or content == '' then return end
    local id = IPC.extractField(content, 'id')
    local action = IPC.extractField(content, 'action')
    if not id then
        LrFileUtils.delete(reqPath)
        return
    end
    local handler = handlers[action or '']
    if not handler then
        writeResponseFile(id, errorResponse(id,
            'unknown action: ' .. tostring(action)))
        LrFileUtils.delete(reqPath)
        return
    end
    -- Timing: measure mutex wait and handler elapsed separately
    -- so the log tells us WHICH layer ate the time when a slow
    -- action shows up.  Browser-visible /action elapsed ==
    -- mutex_wait_s + handler_s + small IPC overhead.  See
    -- bugs.md "Thumb generation takes 30-50s..." hypothesis 2.
    local dispatchStart = os.time()

    -- Wait for any prior handler to finish before starting this
    -- one. See the _G.__lrrpg_dispatch_in_flight comment for
    -- why serialization is necessary. Yields in the outer scan
    -- task are fine; scan is supposed to advance at polling
    -- cadence, not faster.
    while _G.__lrrpg_dispatch_in_flight do
        LrTasks.sleep(0.02)
    end

    local handlerStart = os.time()
    local mutexWaitSec = handlerStart - dispatchStart

    -- Delete the request file now, before dispatching. The
    -- handler runs in its own async task below, and we don't want
    -- the next scan tick to pick up the same request while it's
    -- in flight.
    LrFileUtils.delete(reqPath)
    requestsProcessed = requestsProcessed + 1
    _G.__lrrpg_dispatch_in_flight = true

    -- Run the handler in its own cleanup-context async task. This
    -- is the LR-idiomatic way to run code that (a) yields freely
    -- (catalog access, withWriteAccessDo, thumbnail generation —
    -- all of which yield, so stdlib pcall is out under Lua 5.1
    -- rules) and (b) catches errors without taking down the outer
    -- scan loop. On error the cleanup handler fires; we detect
    -- that via a local flag rather than assuming a SDK-specific
    -- error-arg convention we might misread.
    LrFunctionContext.postAsyncTaskWithContext(
        'lrrpg-handler-' .. tostring(action),
        function(context)
            local completed = false
            context:addCleanupHandler(function(...)
                -- Always release the serialization flag, whether
                -- we completed normally or crashed. Otherwise a
                -- handler error wedges the dispatcher forever.
                _G.__lrrpg_dispatch_in_flight = false

                -- LR SDK passes whatever error info it has to
                -- cleanup handlers as args. Log everything so we
                -- can diagnose which handler raised and why,
                -- without assuming a specific SDK signature.
                if not completed then
                    local args = { ... }
                    local detail = (#args > 0)
                        and tostring(args[1])
                        or '(no detail from context)'
                    Log.line('ipc', 'handler raised',
                        'action=' .. tostring(action)
                        .. ' detail=' .. detail)
                    writeResponseFile(id, errorResponse(id,
                        'handler error: ' .. detail))
                end

                -- Always log timings — even fast actions, so we
                -- have a baseline when comparing against slow
                -- ones.  os.time resolution is 1s; good enough
                -- for the 30-60s cases we're trying to isolate.
                Log.line('ipc', 'dispatch done',
                    'action=' .. tostring(action) ..
                    ' mutex_wait_s=' .. mutexWaitSec ..
                    ' handler_s=' .. (os.time() - handlerStart) ..
                    ' ok=' .. tostring(completed))
            end)
            local body, err = handler(id, content)
            if body then
                writeResponseFile(id, body)
            else
                writeResponseFile(id, errorResponse(id,
                    err or 'handler returned nil'))
            end
            completed = true
        end)
end

-- Forward declaration: focusLightroomCmd resolves Platform
-- lazily on first use to avoid a require-cycle (Platform
-- requires nothing of IPC, but loading order during
-- Init.lua / module reload edges has bitten us before).
local Platform

local function handleFocusMarker(path)
    -- Marker dropped by the helper's /focus-lr endpoint
    -- (which the browser hits via sendBeacon on pagehide).
    -- Bring Lightroom to the OS front so the user lands in
    -- LR after closing the pair window.
    --
    -- Delete the marker BEFORE shelling out so we don't
    -- re-trigger on the next 50ms scan if the focus command
    -- takes any time.  Execution is fire-and-forget; we
    -- don't await rc.
    LrFileUtils.delete(path)
    if not Platform then Platform = require 'Platform' end
    Log.line('ipc', 'focus-lr marker observed, activating LR')
    LrTasks.execute(Platform.focusLightroomCmd())
end

local function scan()
    if not LrFileUtils.exists(IPC_DIR) then return end
    for path in LrFileUtils.files(IPC_DIR) do
        local name = LrPathUtils.leafName(path)
        if name:match('^req%-.+%.json$') then
            dispatchRequest(path)
        elseif name == 'focus-lr-requested' then
            handleFocusMarker(path)
        end
    end
end

-- Module-level extras that handlers can stash into the heartbeat.
-- Kept here so IPC owns the heartbeat schema; handlers just set
-- individual fields. Surfaced through the helper's /status, which
-- lets the browser observe plugin-internal state like the prefetch
-- slot without a separate polling endpoint.
local heartbeatExtras = {}

function IPC.setHeartbeatField(name, value)
    heartbeatExtras[name] = value
end

local function writeHeartbeat()
    local hbPath = LrPathUtils.child(IPC_DIR, 'heartbeat.json')
    local extras = {}
    for k, v in pairs(heartbeatExtras) do
        if type(v) == 'string' then
            extras[#extras + 1] = string.format(',"%s":"%s"', k, v)
        elseif type(v) == 'number' or type(v) == 'boolean' then
            extras[#extras + 1] = string.format(',"%s":%s', k, tostring(v))
        end
    end
    -- %.0f, not %d: Lua 5.1 on Windows truncates %d through a
    -- 32-bit long, which wraps the 13-digit millisecond
    -- timestamp to garbage (observed: ts_ms=0 in heartbeat.json,
    -- so the helper reports plugin_alive=false forever).
    local body = string.format(
        '{"ts_ms":%.0f,"requests_processed":%d%s}',
        os.time() * 1000, requestsProcessed,
        table.concat(extras))
    local f = io.open(hbPath, 'w')
    if not f then return end
    f:write(body)
    f:close()
end

function IPC.start()
    -- Most of IPC.start runs inside an LrTasks context (yields
    -- via LrFileUtils, etc.).  But the epoch bump itself needs
    -- to happen SYNCHRONOUSLY before this function returns,
    -- because the caller (Launcher.start) immediately calls
    -- Dashboard.show afterward, and Dashboard's poll loop reads
    -- the epoch on entry to decide which session it belongs to.
    -- If the bump were inside the async task, Dashboard's task
    -- could read the OLD epoch before the bump runs, causing
    -- both the old zombie poller AND the new poller to capture
    -- the same epoch — neither retires.  io.open / io.write
    -- on the local epoch file don't yield, so the synchronous
    -- bump is safe outside a task context.
    Log.line('ipc', 'IPC.start invoked')

    -- mkdir BEFORE writeEpoch so first-ever-run doesn't fail
    -- silently (writeEpoch's io.open returns nil if the parent
    -- dir doesn't exist, which would leave the epoch file
    -- missing and IPC's poll loop's `while readEpoch() == myEpoch`
    -- check would fail-fast on the first iteration).
    -- LrFileUtils.createAllDirectories is a thin SDK wrapper
    -- that does not yield — safe to call synchronously here.
    LrFileUtils.createAllDirectories(IPC_DIR)

    local myEpoch = readEpoch() + 1
    writeEpoch(myEpoch)
    Log.line('ipc', 'epoch bumped synchronously', 'epoch=' .. myEpoch)

    LrTasks.startAsyncTask(function()
        Log.line('ipc', 'starting poll loop', 'epoch=' .. myEpoch)

        -- Heartbeat runs in a separate task so long-running
        -- handler work in scan doesn't starve heartbeat
        -- writes.  Spawned from INSIDE this task so it sees
        -- myEpoch via closure without having to read the
        -- epoch file at its own startup (which would race).
        LrTasks.startAsyncTask(function()
            while readEpoch() == myEpoch do
                pcall(writeHeartbeat)
                LrTasks.sleep(0.5)
            end
        end)

        -- Start-of-run sweep: clear transient state but leave
        -- anything else (thumbs, the epoch file, etc.) alone.
        -- close-requested is written by a prior Stop; if LR
        -- crashed before the cleanup step ran, the file survives
        -- and would make the next browser window close itself on
        -- first /status poll.
        --
        -- No pcall: the LrFileUtils.files iterator yields, and
        -- stdlib pcall around a yield is the forbidden
        -- C-boundary pattern (AGENTS.md cardinal rule).  The
        -- old pcall wrap trapped that raise silently, so the
        -- sweep no-op'd and a stale close-requested survived —
        -- the exact bug the sweep exists to prevent.  We're
        -- inside the poll task, so yielding is fine; a genuine
        -- error terminates this task loudly in LR's log
        -- (Pattern C) instead of being swallowed.
        for path in LrFileUtils.files(IPC_DIR) do
            local name = LrPathUtils.leafName(path)
            if name:match('^req%-') or name:match('^resp%-')
                or name == 'heartbeat.json'
                or name == 'close-requested'
                or name:match('%.tmp$') then
                LrFileUtils.delete(path)
            end
        end

        local tickCount = 0
        while readEpoch() == myEpoch do
            scan()
            tickCount = tickCount + 1
            if tickCount % 600 == 0 then
                Log.line('ipc', 'alive',
                    'epoch=' .. myEpoch ..
                    ' processed=' .. requestsProcessed)
            end
            LrTasks.sleep(POLL_SEC)
        end
        Log.line('ipc', 'poll loop exiting',
            'epoch=' .. myEpoch)
    end)
end

-- Caller must provide an LrTasks context: the sweep below
-- iterates via LrFileUtils.files, which yields.  The only
-- caller today is Launcher.stop's async task.
function IPC.stop()
    Log.line('ipc', 'stop requested — retiring all epochs')
    writeEpoch(readEpoch() + 1)
    -- Delete heartbeat and transient files immediately so the
    -- helper flips to paused state without waiting for staleness.
    -- No pcall around the iterator — same cardinal-rule reasoning
    -- as the start-of-run sweep in IPC.start: the old wrap
    -- silently swallowed the yield-across-C-boundary raise and
    -- left heartbeat.json behind, so the helper kept reporting
    -- plugin_alive=true until staleness.
    if LrFileUtils.exists(IPC_DIR) then
        for path in LrFileUtils.files(IPC_DIR) do
            local name = LrPathUtils.leafName(path)
            if name == 'heartbeat.json'
                or name:match('^req%-')
                or name:match('^resp%-') then
                LrFileUtils.delete(path)
            end
        end
    end
end

function IPC.dir()
    return IPC_DIR
end

return IPC
