-- LrView dashboard. Control panel that lives in Lightroom alongside
-- the external browser UI.
--
-- Responsibilities:
--   - Always open on menu click, regardless of whether the helper
--     or browser launch succeeded. Users always have visible,
--     actionable state in LR.
--   - Surface errors the web UI can't (e.g. helper binary missing,
--     helper won't start, Chrome not installed).
--   - Provide recovery actions: restart helper, reopen browser,
--     open settings (LrView modal until Phase 4 ports to web),
--     view log, stop everything.

local LrApplication = import 'LrApplication'
local LrBinding = import 'LrBinding'
local LrDialogs = import 'LrDialogs'
local LrFunctionContext = import 'LrFunctionContext'
local LrFileUtils = import 'LrFileUtils'
local LrHttp = import 'LrHttp'
local LrPathUtils = import 'LrPathUtils'
local LrTasks = import 'LrTasks'
local LrView = import 'LrView'

local Log = require 'Log'
local Catalog = require 'Catalog'
local Handlers = require 'Handlers'
local Platform = require 'Platform'
local Prefs = require 'Prefs'
local Settings = require 'Settings'
local Thumbs = require 'Thumbs'
local Troubleshooting = require 'Troubleshooting'
local Version = require 'Version'

-- Dashboard has its OWN epoch coordination, separate from IPC's.
-- Reasoning: IPC's epoch is bumped on every IPC.start AND IPC.stop
-- (so IPC's own poll loop retires on Stop pairing).  If the
-- dashboard piggybacked on IPC's epoch, Stop pairing would also
-- retire the dashboard's poll loop — but the dashboard NEEDS to
-- keep polling through a Stop so it can transition the helper
-- row from "Stopping..." to "Stopped — ready to restart".
-- A separate dashboard-epoch file is bumped only on
-- Dashboard.show, retiring previous dashboards' poll loops while
-- letting Stop/Start cycles run uninterrupted.  See AGENTS.md
-- "Module reloads leave zombie LrTasks".
local DASHBOARD_EPOCH_FILE = LrPathUtils.child(
    Platform.scratchRoot, 'dashboard-epoch')

local function readDashboardEpoch()
    local f = io.open(DASHBOARD_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 function writeDashboardEpoch(n)
    local f = io.open(DASHBOARD_EPOCH_FILE, 'w')
    if not f then return end
    f:write(tostring(n))
    f:close()
end

local Dashboard = {}

local active = nil
local pendingHelperRunning = false

local GO_INSTALL_URL = 'https://go.dev/doc/install'

local function helperLogPath()
    return LrPathUtils.child(Platform.scratchRoot, 'helper.log')
end

local function helperFailureSummary()
    local f = io.open(helperLogPath(), 'r')
    if not f then return nil end
    local body = f:read('*a') or ''
    f:close()
    if body == '' then return nil end

    local last
    for line in body:gmatch('[^\r\n]+') do
        local trimmed = line:gsub('^%s+', ''):gsub('%s+$', '')
        if trimmed ~= '' then
            last = trimmed
        end
    end
    if not last then return nil end

    -- Ignore the successful startup banner; this helper row is
    -- only interested in why the process is down.
    if last:match('^%[helper%]%s+listening on ') then
        return nil
    end

    last = last:gsub('^%[helper%]%s+error:%s*', '')
    if #last > 110 then
        last = last:sub(1, 109) .. '…'
    end
    return last
end

-- "Is the helper alive?" probe.  Two implementations, branched
-- per platform, because each one has a platform-specific failure
-- mode and the other doesn't:
--
--   curl probe (macOS):
--     LrTasks.execute curl -> /status, parse HTTP code.  Same
--     mechanism Launcher.helperAlive uses.  Works reliably on
--     mac, including on slow filesystems like network drives
--     (bypasses the filesystem entirely — it's HTTP to
--     localhost).  On Windows, LrTasks.execute in a 1s-tick
--     loop has historically been flaky — the reason we moved
--     off this approach for Windows in the first place.
--
--   file probe (Windows):
--     Read helper-ready.json, written every 1s by the Go helper's
--     startReadyHeartbeat goroutine.  Reliable on Windows (no
--     per-tick shell-out).  On macOS network drives, Go-write /
--     Lua-read cache coherence is unreliable and the Dashboard
--     sees stale "missing" reads while the Launcher's direct
--     HTTP probe succeeds (see bugs.md).
--
-- Branching means each platform runs the probe that's proven
-- to work for it.  Both paths return the same bool.
-- Debug flag for helperAlive.  Dashboard poll ticks every 1s,
-- so these logs can grow fast; only turn on when investigating
-- "dashboard disagrees with helper state" bugs (see bugs.md).
local HELPER_ALIVE_DEBUG = false

local function helperAlive(helperPort)
    if Platform.isWindows then
        local readyPath = LrPathUtils.child(
            Platform.scratchRoot, 'helper-ready.json')
        local f = io.open(readyPath, 'r')
        if not f then
            if HELPER_ALIVE_DEBUG then
                Log.line('dashboard', 'helperAlive win no ready file')
            end
            return false
        end
        local body = f:read('*a') or ''
        f:close()
        local ts = tonumber(body:match('"ts_ms"%s*:%s*(%d+)'))
        if not ts then
            if HELPER_ALIVE_DEBUG then
                Log.line('dashboard', 'helperAlive win unparseable',
                    body:sub(1, 80))
            end
            return false
        end
        local ageMs = (os.time() * 1000) - ts
        local ok = ageMs >= 0 and ageMs < 5000
        if HELPER_ALIVE_DEBUG then
            Log.line('dashboard',
                'helperAlive win ts=' .. tostring(ts) ..
                ' age_ms=' .. tostring(ageMs) .. ' ok=' .. tostring(ok))
        end
        return ok
    end
    -- macOS: curl HTTP probe.
    local out = LrPathUtils.child(Platform.scratchRoot, 'dash-probe.out')
    LrFileUtils.createAllDirectories(Platform.scratchRoot)
    local cmd = Platform.curlStatusProbeCmd(
        string.format('http://localhost:%d/status', helperPort),
        out)
    local rc = LrTasks.execute(cmd)
    local f = io.open(out, 'r')
    local body
    if f then
        body = f:read('*a') or ''
        f:close()
    end
    local ok = body and body:match('^%s*200%s*$') ~= nil
    if HELPER_ALIVE_DEBUG then
        Log.line('dashboard',
            'helperAlive mac rc=' .. tostring(rc) ..
            ' body=' .. tostring(body and body:sub(1, 20)) ..
            ' ok=' .. tostring(ok))
    end
    return ok
end

-- deps: {
--     helperPort,
--     openBrowser = fn, stopAll = fn, restartHelper = fn,
--     findGo      = fn() -> path|nil,
--     findBrowser = fn() -> { kind = 'chromium'|'default', name? },
--     spawnResult = 'spawned' | 'already-running' | 'no-go' | 'no-src',
-- }
--
-- Layout: a five-row status checklist at the top — Go runtime,
-- Helper, Browser, Photo source, Save set — with emoji indicators
-- so the user can scan prerequisites at a glance. Green means
-- ready; yellow means functional-but-suboptimal; red means the
-- feature this row backs won't work. Recovery buttons inline on
-- rows that need one.
--
-- Row order is deliberate: runtime (Go) and the process that
-- uses it (Helper) cluster first, then the UI surface (Browser),
-- then user-configured sources (Photo source, Save set).
function Dashboard.show(deps)
    -- Bump the dashboard epoch SYNCHRONOUSLY before spawning the
    -- async task.  This guarantees the new dashboard's poll loop
    -- (when it eventually captures myEpoch) sees the post-bump
    -- value AND any previously-running zombie poll loop sees the
    -- mismatch on its next tick.  Bumping inside the async task
    -- would race: a zombie poller could observe the still-old
    -- epoch one more time and believe it's still current.
    -- LrFileUtils-free; uses stdlib io which doesn't yield.
    LrFileUtils.createAllDirectories(Platform.scratchRoot)
    local myDashboardEpoch = readDashboardEpoch() + 1
    writeDashboardEpoch(myDashboardEpoch)
    Log.line('dashboard', 'epoch bumped',
        'epoch=' .. tostring(myDashboardEpoch))

    LrTasks.startAsyncTask(function()
        LrFunctionContext.callWithContext('pairs-web-dashboard', function(context)
            local props = LrBinding.makePropertyTable(context)
            local closeHandled = false

            -- Status glyphs per platform.  macOS gets color
            -- emoji (🟢🟡🔴) which are the clearest scan; Windows
            -- gets monochrome ASCII (✓ ! ✗) because the LR text
            -- widget on Windows can't render the color glyphs.
            local G = Platform.statusGlyphs()

            -- Spawn-mode info from Launcher.  Optional: deps.spawnInfo
            -- only present in builds with the binary-mode plumbing.
            -- useBinary suppresses the Go-runtime row entirely (Go is
            -- irrelevant when the helper spawns from a bundled binary).
            -- debugMode (dev-config debug=true) gates the debug
            -- indicator section appended at the bottom of the dashboard.
            local debugInfo = deps.spawnInfo and deps.spawnInfo() or nil
            local useBinary = debugInfo
                and debugInfo.resolvedSource == 'binary'
            local debugMode = debugInfo and debugInfo.debug == true

            -- ---- Rescan helpers ----
            -- Each row's state refreshes via one of these. The
            -- Rescan button in Troubleshooting calls all of them
            -- so a user who just installed a missing prerequisite
            -- (Go, browser) gets an up-to-date dashboard.

            local function refreshGo()
                local goPath = deps.findGo and deps.findGo() or nil
                props.goEmoji = goPath and G.ok or G.err
                props.goDetail = goPath
                    and ('installed: ' .. goPath)
                    or 'Not installed'
                props.goShowInstall = (goPath == nil)
            end

            local function refreshBrowser()
                local browser = deps.findBrowser
                    and deps.findBrowser()
                    or { kind = 'default' }
                Log.line('dashboard', 'refreshBrowser',
                    'kind=' .. tostring(browser.kind)
                    .. ' name=' .. tostring(browser.name or 'nil'))
                if browser.kind == 'chromium' then
                    -- Chromium-family browser: --app minimal
                    -- window.  Green.  Suffix mirrors the
                    -- Settings dropdown so the user has the
                    -- same vocabulary across both surfaces.
                    props.browserEmoji = G.ok
                    props.browserDetail =
                        browser.name .. ' (minimal window)'
                elseif browser.kind == 'tab' then
                    -- User explicitly picked a non-Chromium
                    -- browser (Firefox / Safari) in Settings.
                    -- Will open as a regular tab.  Green
                    -- because user-chosen, not a fallback.
                    props.browserEmoji = G.ok
                    props.browserDetail =
                        browser.name .. ' (regular tab)'
                else
                    -- No Chromium browser detected, no user-
                    -- picked alternative — falling back to
                    -- system default.  Amber: works, just
                    -- not the cleanest experience.
                    props.browserEmoji = G.warn
                    props.browserDetail =
                        'No Chrome/Edge/Brave detected: '
                        .. 'will launch default browser'
                end
            end

            local function refreshHelper()
                local ok = helperAlive(deps.helperPort)
                props.helperEmoji = ok and G.ok or G.err
                if ok then
                    props.helperDetail = 'Running'
                else
                    local err = helperFailureSummary()
                    props.helperDetail = err
                        and ('Start failed: ' .. err)
                        or 'Not running'
                end
                -- Surface the Start button only when starting
                -- could actually succeed: helper is down AND Go
                -- is present AND the plugin source is on disk.
                -- Without the prerequisites, "Start" is a dead
                -- button — clicking it would just fail silently.
                -- Binary mode skips the Go check (Go irrelevant
                -- when spawning from a bundled binary).
                props.helperShowStart = (not ok)
                    and (useBinary or props.goEmoji == G.ok)
                    and (deps.spawnResult ~= 'no-src')
            end

            local function refreshSource()
                -- No pcall here: sourcePhotoCount() calls catalog
                -- APIs (col:getPhotos, findPhotoByUuid) that yield,
                -- and Lua 5.1 forbids yielding across pcall. This
                -- function is only called from async-task contexts
                -- where yielding is fine; if something genuinely
                -- breaks, the task errors out and LR logs it.
                local n, desc, capped, problem =
                    Handlers.sourcePhotoCount()
                local cfg = Prefs.config()
                local isCurrent = (cfg.sourceMode == 'current')

                -- Special case: single/split mode but no
                -- collection picked.  Previously produced the
                -- awkward "No photos in No collection chosen"
                -- message.  Now we show a purpose-built string
                -- and offer an Open Settings inline button to
                -- take the user straight to the fix.  Rebuild
                -- snapshot doesn't apply here, so we force it
                -- off regardless of mode.
                if problem == 'no-collection' then
                    props.sourceEmoji = G.err
                    props.sourceDetail =
                        'No collection selected — open Settings'
                    props.sourceShowRebuild = false
                    props.sourceShowSettings = true
                    return
                end

                -- For 'current' mode, we append "(snapshot)" so
                -- the user can see the data is frozen — otherwise
                -- they wonder why a photo they just added to the
                -- library doesn't show up.
                if isCurrent then
                    -- "Library snapshot" tells the user this
                    -- pool is frozen — explains why a just-added
                    -- photo doesn't appear until Rebuild snapshot.
                    -- Dropped the "current" qualifier (was
                    -- "current Library snapshot") to free width
                    -- for the clearer "X photos in Y" format
                    -- below; "current" is implicit in the
                    -- snapshot concept.
                    desc = 'Library snapshot'
                elseif #desc > 18 then
                    -- Tightened from 22 → 18 to leave room for
                    -- the explicit "photos" unit + possible
                    -- status suffix without overflowing
                    -- DETAIL_WIDTH.  Earlier feedback: the count
                    -- ran together with the collection name
                    -- ("4235 in Shuffled 2025-11-25 2…") and the
                    -- bare count was ambiguous (4235 what?).
                    desc = desc:sub(1, 17) .. '…'
                end
                -- Format: `<count> photos in <desc>` for healthy,
                -- with a parenthetical status suffix for warnings.
                -- The explicit "photos" unit + unambiguous "in"
                -- preposition disambiguates what the number is.
                if n == 0 then
                    props.sourceEmoji = G.err
                    props.sourceDetail = 'No photos in ' .. desc
                elseif capped then
                    -- Snapshot hit the CAP during iteration —
                    -- pool is a prefix of the user's actual
                    -- selection.  Warn them so they know why
                    -- photos at the tail of their selection
                    -- aren't appearing.  Takes precedence over
                    -- the "low count" branch since the count
                    -- itself is meaningful here even when it's
                    -- the cap number.
                    props.sourceEmoji = G.warn
                    props.sourceDetail =
                        n .. ' photos in ' .. desc ..
                        ' (cap reached)'
                elseif n < 20 then
                    -- Yellow: works, but with only a handful of
                    -- photos the variety is limited. 20 is a soft
                    -- threshold; tune based on tester feedback.
                    props.sourceEmoji = G.warn
                    props.sourceDetail =
                        n .. ' photos in ' .. desc .. ' (low pool)'
                else
                    props.sourceEmoji = G.ok
                    props.sourceDetail = n .. ' photos in ' .. desc
                end
                -- Rebuild button shows only for 'current' mode —
                -- the other modes don't use a snapshot concept.
                -- Open Settings button is only for the
                -- no-collection-chosen case handled above.
                props.sourceShowRebuild = isCurrent
                props.sourceShowSettings = false
            end

            local function refreshSaveSet()
                local cfg = Prefs.config()
                local ts = nil
                if cfg.targetSetId and cfg.targetSetId ~= 0 then
                    local catalog = LrApplication.activeCatalog()
                    ts = Catalog.findCollectionSet(catalog, cfg.targetSetId)
                end
                Log.line('dashboard', 'refreshSaveSet',
                    'targetSetId=' .. tostring(cfg.targetSetId)
                    .. ' resolved=' .. tostring(ts and ts:getName() or 'nil'))
                if ts then
                    props.saveSetEmoji = G.ok
                    -- "(collection set)" suffix disambiguates the
                    -- LR type — user's Settings label uses the
                    -- same phrasing.  If the set name is long
                    -- the detail may clip; the truncation isn't
                    -- critical since Settings shows the full
                    -- name in the dropdown.
                    props.saveSetDetail = ts:getName()
                        .. ' (collection set)'
                    props.saveSetShowSettings = false
                else
                    -- Yellow not red: browsing and advancing
                    -- still work without a save set, only Save
                    -- is blocked. Exploratory users are fine.
                    props.saveSetEmoji = G.warn
                    props.saveSetDetail =
                        'Not configured (collection set)'
                    props.saveSetShowSettings = true
                end
            end

            -- Initial helper state depends on spawn result, not
            -- /status (which hasn't had time to respond yet).
            -- refreshHelper() takes over once the poll loop runs.
            local function seedHelperFromSpawn()
                if deps.spawnResult == 'no-go' then
                    props.helperEmoji = G.err
                    props.helperDetail =
                        'Cannot start — Go runtime required'
                    props.helperShowStart = false  -- no point, Go missing
                elseif deps.spawnResult == 'no-src' then
                    props.helperEmoji = G.err
                    props.helperDetail =
                        'Source missing from plugin bundle'
                    props.helperShowStart = false  -- no point, source missing
                elseif deps.spawnResult == 'already-running' then
                    props.helperEmoji = G.ok
                    props.helperDetail =
                        'Running (attached to existing process)'
                    props.helperShowStart = false
                else
                    props.helperEmoji = G.warn
                    props.helperDetail = 'Starting...'
                    props.helperShowStart = false
                end
            end

            refreshGo()
            refreshBrowser()
            seedHelperFromSpawn()
            refreshSource()
            refreshSaveSet()

            -- Derived: "any status row isn't green?"  Drives
            -- the "Recheck status" button's visible binding —
            -- button appears only when at least one row needs
            -- the user's attention.  Observers below keep the
            -- prop in sync with every emoji change, so neither
            -- rescanAll nor the poll-loop branches need to
            -- remember to refresh it manually.
            local function refreshDerivedStatus()
                -- Any row not green → show the "Recheck status"
                -- button (it would be useless if everything is
                -- green).
                props.anyRowNotGreen =
                    props.goEmoji ~= G.ok or
                    props.helperEmoji ~= G.ok or
                    props.browserEmoji ~= G.ok or
                    props.sourceEmoji ~= G.ok or
                    props.saveSetEmoji ~= G.ok
                -- Any row red (err) → disable Open window, since
                -- a red row means at least one component is
                -- non-functional.  Yellow rows don't disable
                -- (e.g. "low photo count" or "no save set
                -- configured" — plugin still works, user can
                -- still browse).  Helper must specifically be
                -- green because Open window points the browser
                -- at localhost:9417.
                props.anyRowRed =
                    props.goEmoji == G.err or
                    props.helperEmoji == G.err or
                    props.browserEmoji == G.err or
                    props.sourceEmoji == G.err or
                    props.saveSetEmoji == G.err
                props.openWindowEnabled =
                    (props.helperEmoji == G.ok) and
                    not props.anyRowRed
                -- Stop pairing is only meaningful when the helper
                -- is actually running.  Disabled during
                -- "Stopping..." (helper still alive but already
                -- being torn down — second click would race the
                -- in-flight kill) AND during "Stopped"
                -- (nothing left to stop).  Yellow states like
                -- "Compiling..." are also disabled — the helper
                -- isn't fully up yet.  Tied directly to
                -- helperEmoji because the click handler flips
                -- helperEmoji to err immediately on click,
                -- triggering this observer and disabling the
                -- button before the user can double-click.
                props.stopPairingEnabled =
                    (props.helperEmoji == G.ok)
            end
            for _, key in ipairs({
                'goEmoji', 'helperEmoji', 'browserEmoji',
                'sourceEmoji', 'saveSetEmoji',
            }) do
                props:addObserver(key, refreshDerivedStatus)
            end
            refreshDerivedStatus()

            -- One-shot full rescan.
            local function rescanAll()
                refreshGo()
                refreshBrowser()
                refreshHelper()
                refreshSource()
                refreshSaveSet()
            end

            -- Forward-declared so button actions that restart
            -- the plugin runtime (Start pairing after Stop,
            -- etc.) can
            -- reset the first-launch timer / latch.  Actual
            -- initial values assigned at the top of the poll
            -- loop.  Without these `local` declarations up
            -- here, button closures would capture _G globals —
            -- nil in the poll loop's context.
            local started
            local helperReady

            -- `helperIntentionallyStopped`: tri-state tracker
            -- for what the Start pairing button should do when
            -- clicked.  Not a visibility binding (we learned
            -- the hard way that LR's visible-binding on f:column
            -- doesn't reliably hide children) — just a flag the
            -- button action reads to decide reopen-browser vs
            -- full-restart.  Set true by Stop pairing, cleared
            -- by Start.
            local helperIntentionallyStopped = false

            -- Button actions queue work via flags on `active`; the
            -- main dashboard task (poll loop below) picks them up
            -- and runs the code IN ITS OWN CONTEXT.  Reason: LR's
            -- binding layer only reliably propagates prop updates
            -- to the UI when the update happens inside the
            -- `LrFunctionContext.callWithContext` where the props
            -- were created.  Spawning a separate async task from
            -- a button callback and updating props from there
            -- silently fails to repaint — symptom was "Save pairs
            -- to row stays yellow even though refreshSaveSet ran
            -- and resolved the set correctly."
            active = {
                props = props,
                deps = deps,
                rescanRequested = false,
                settingsRequested = false,
                restartAllRequested = false,
                rebuildSnapshotRequested = false,
                clearThumbsRequested = false,
            }
            local thisActive = active

            if pendingHelperRunning then
                props.helperEmoji = G.ok
                props.helperDetail = 'Running'
                props.helperShowStart = false
                pendingHelperRunning = false
            end

            -- Auto-rebuild the current-mode snapshot every time
            -- the dashboard opens.  User's mental model for
            -- "current Library source" is "what's in LR right
            -- now", not a persisted snapshot from last session.
            -- This queues the rebuild for the main task so the
            -- prop-refresh happens in the right context.
            active.rebuildSnapshotRequested = true

            local f = LrView.osFactory()

            -- Row helper. Detail column has a fixed width
            -- (needed — static_text doesn't relayout on bound
            -- updates, see CLAUDE.md) but narrower than before so
            -- recovery buttons on the same row sit close to the
            -- end of the detail text rather than far right.
            -- Trade-off: the longest detail strings may clip, so
            -- keep them short (under ~35 chars).
            -- DETAIL_WIDTH history: 38 (initial, too narrow —
            -- "4235 in Shuffled..." was unclear), 48 (build 112,
            -- accommodated "X photos in Y (cap reached)" but
            -- pushed dashboard to ~1000px wide, intimidating
            -- on first-run / screen-recording demos), 40 (build
            -- 128), and now 30.  The narrower width makes a
            -- ~700px dashboard feasible once browser/save-set
            -- wording and the footer are also tightened.  Long
            -- detail strings may truncate; acceptable cost for
            -- a friendlier first-run footprint.
            local DETAIL_WIDTH = 25
            -- Fixed width for the recovery-action column.  The
            -- previous layout had no explicit fourth-column
            -- width at all — button cells were just appended
            -- opportunistically at their natural width, which
            -- made the right side feel ghostly and inconsistent.
            -- Giving the action cell a real width makes the
            -- table structure more intentional and helps the
            -- content columns compress first.
            local ACTION_WIDTH = 80
            -- statusRow — builds one row of the Component
            -- status cluster.  `buttons` is a list of
            -- { title, visibleBind, action } tables; typically
            -- 0 or 1 visible at a time but the row can host
            -- several (e.g. Photo source has Rebuild-snapshot
            -- AND Open-Settings wired to mutually exclusive
            -- visibility bindings).
            --
            -- Buttons use `font = '<system/small>'` to reduce
            -- their chrome height.  Prior attempts with full-
            -- size buttons + spacers / margins couldn't fully
            -- unify row heights across button-hosting and
            -- plain-text rows; smaller buttons narrow the
            -- delta.  If even this still leaves rows uneven
            -- we'll move recovery actions out of status rows
            -- entirely.
            -- statusRow — emoji + label + detail + optional
            -- inline recovery buttons.  `buttons` is a list of
            -- { title, visibleBind, action } tables; each
            -- renders when its visible-bind resolves true.
            --
            -- Row-height variance: LR's push_button is a
            -- platform-native widget we don't have enough
            -- styling control over.  On macOS, button-hosting
            -- rows render slightly taller than text-only rows
            -- despite `<system/small>` font and margin_bottom
            -- padding.  We accept that cosmetic cost rather
            -- than remove the inline affordances — "Rebuild
            -- snapshot" in particular is normal workflow, not
            -- recovery, and it belongs next to the Photo
            -- source status it acts on.  Windows does not
            -- exhibit the clipping.
            local function statusRow(emojiBind, labelText, detailBind,
                    buttons)
                local items = {
                    spacing = f:control_spacing(),
                    f:static_text {
                        title = LrView.bind(emojiBind),
                        -- text_color transform: derive a colored
                        -- text_color from the current glyph value.
                        -- macOS returns nil (let the native
                        -- color emoji render with its built-in
                        -- color); Windows returns a vivid red /
                        -- yellow / green LrColor so the ASCII
                        -- "OK" / "!!" / "X" labels read as
                        -- distinct status indicators rather than
                        -- three same-looking monochrome strings.
                        text_color = LrView.bind {
                            key = emojiBind,
                            transform = function(v)
                                return Platform.statusColor(v)
                            end,
                        },
                        font = '<system/bold>',
                        width_in_chars = 1,
                    },
                    f:static_text {
                        title = labelText,
                        font = '<system/bold>',
                        width_in_chars = 8,
                    },
                    f:static_text {
                        title = LrView.bind(detailBind),
                        text_color = LrView.kColorGrey,
                        width_in_chars = DETAIL_WIDTH,
                    },
                }
                local actionItems = {
                    spacing = f:control_spacing(),
                }
                for _, b in ipairs(buttons or {}) do
                    actionItems[#actionItems + 1] = f:view {
                        visible = LrView.bind(b.visibleBind),
                        f:push_button {
                            title = b.title,
                            action = b.action,
                            font = '<system/small>',
                        },
                    }
                end
                items[#items + 1] = f:view {
                    width = ACTION_WIDTH,
                    f:row(actionItems),
                }
                -- Row-level padding so button descenders have
                -- room to draw even when LR measures the row
                -- tight to button chrome.
                items.margin_bottom = 8
                return f:row(items)
            end

            -- Primary actions. Open browser disabled if Go is
            -- missing (helper can't run, so the browser has
            -- nothing to talk to). Re-evaluated on rescan so the
            -- button becomes enabled after the user installs Go
            -- and clicks Rescan.
            -- Recheck-status lives on its own row below the
            -- status list (above the separator, so it reads as
            -- "act on the status above").  Visible-binding
            -- collapses the row when all rows are green.  Kept
            -- out of the action row so the primary buttons
            -- don't shift left/right as Recheck appears and
            -- disappears.
            --
            -- The height-anchor f:spacer is the same pattern
            -- used in statusRow (see comment there): without
            -- it the push_button gets clipped at the bottom
            -- because LR sizes the row tight to the button and
            -- the NEXT row's top margin eats the descender
            -- pixels.  Explicit 24px on the row ensures the
            -- button renders complete.
            local recheckRow = f:view {
                visible = LrView.bind('anyRowNotGreen'),
                f:row {
                    spacing = f:control_spacing(),
                    margin_bottom = 8,  -- clear button descender
                    f:push_button {
                        title = 'Recheck status',
                        action = function()
                            Log.line('dashboard', 'recheck status requested')
                            if active then active.rescanRequested = true end
                        end,
                    },
                },
            }

            -- Action row (stable layout, always 4 buttons in
            -- the same order).  Build 131 temporarily split
            -- this into two rows to test whether the button
            -- strip was the dominant width floor.  Later
            -- tightening in the status-table columns/action
            -- slot got the window down far enough that the
            -- cleaner one-row control strip is worth restoring.
            local actionRow = f:row {
                spacing = f:control_spacing(),
                margin_bottom = 8,  -- clear button descender
                f:push_button {
                    title = 'Start pairing',
                    -- Enabled requires (a) helper specifically
                    -- green — browser points at localhost:9417
                    -- so a dead helper means ERR_CONNECTION_REFUSED
                    -- — and (b) no other row red.  Yellow rows
                    -- (low pool, no save set) do NOT disable:
                    -- user can still browse.  refreshDerivedStatus
                    -- computes openWindowEnabled after every
                    -- emoji change, so this binding stays
                    -- current without per-site bookkeeping.
                    --
                    -- This is the "reopen browser" path — helper
                    -- is already running; user just closed the
                    -- Chrome window and wants it back.  Post-
                    -- Stop pairing the whole action row is
                    -- hidden and a separate Start pairing button
                    -- in the stopped-state view handles the full
                    -- restart (IPC + helper + browser).
                    enabled = LrView.bind('openWindowEnabled'),
                    action = function()
                        Log.line('dashboard', 'start pairing (reopen)')
                        -- IMPORTANT: openBrowser internally calls
                        -- LrTasks.execute (and findBrowser may also
                        -- yield via a probe).  LrView button-action
                        -- callbacks run on the UI thread and do NOT
                        -- provide an LrTask context — yielding
                        -- silently kills the call.  Wrap in
                        -- startAsyncTask so the yields are legal.
                        --
                        -- Symptom prior to this fix (build ≤112):
                        -- after the user X'd out of the pair window
                        -- (or hard-quit Chrome) leaving the helper
                        -- alive, clicking Start pairing logged the
                        -- click but never produced the matching
                        -- "[launcher] opening in app mode" line —
                        -- openBrowser errored mid-flight on the
                        -- yield.  Stop pairing → Start pairing
                        -- worked only because Launcher.stop wraps
                        -- itself in startAsyncTask, providing the
                        -- task context the next Launcher.start
                        -- inherited.
                        if deps.openBrowser then
                            LrTasks.startAsyncTask(function()
                                deps.openBrowser()
                            end)
                        end
                    end,
                },
                f:push_button {
                    title = 'Settings',
                    action = function()
                        Log.line('dashboard', 'settings clicked')
                        if active then active.settingsRequested = true end
                    end,
                },
                f:push_button {
                    title = 'Stop pairing',
                    enabled = LrView.bind('stopPairingEnabled'),
                    action = function()
                        Log.line('dashboard', 'stop pairing')
                        if deps.stopAll then deps.stopAll() end
                        -- Leave `active` alive so the poll loop
                        -- keeps probing the now-dead helper and
                        -- flips Helper red on its next tick.
                        -- BUT force an immediate red + detail
                        -- right now, so the user sees Stop =
                        -- red with zero perceptible lag — the
                        -- 1s poll tick is too long to wait for
                        -- visible cause-and-effect.  See
                        -- bug-helper-lifecycle.md.
                        --
                        -- Setting helperEmoji=err triggers the
                        -- refreshDerivedStatus observer which
                        -- immediately disables this button (via
                        -- stopPairingEnabled = helperEmoji==ok),
                        -- preventing a second click during the
                        -- 2.5s grace period that would race
                        -- Launcher.stop's in-flight kill.
                        helperIntentionallyStopped = true
                        props.helperEmoji = G.err
                        props.helperDetail = 'Stopping...'
                        -- helperShowStart stays false during
                        -- "Stopping..." (helper still alive,
                        -- ok=true).  Poll loop sets it to true
                        -- once ok=false (helper actually dead),
                        -- gating the Start button behind a true
                        -- "Stopped" state to prevent the same
                        -- race as above.
                        props.helperShowStart = false
                    end,
                },
                f:push_button {
                    title = 'Troubleshooting…',
                    action = function()
                        Log.line('dashboard', 'troubleshooting window opened')
                        Troubleshooting.show({ active = active })
                    end,
                },
            }

            local contents = f:column {
                bind_to_object = props,
                -- DEBUG: explicit small width to test whether
                -- LrView's `width` prop is even being honored.
                -- If the dialog shrinks to ~400pt (~800px on
                -- retina Mac), we know the prop works and we
                -- can tune to taste.  If the dialog stays
                -- ~700pt regardless, LR is determining width
                -- from somewhere else (cached save_frame
                -- prefs, NSWindow min-width, or a hidden
                -- child) and we need a different approach.
                width = 350,
                -- 6 px between rows (default-ish).  Button
                -- descender clipping is handled by
                -- `margin_bottom = 8` on each row, not by
                -- inter-row column spacing — because spacer
                -- children + bumped column spacing (tried up
                -- to 16 in build 46) still left buttons
                -- clipping on macOS.  margin_bottom on the row
                -- itself sits OUTSIDE the row's content
                -- measurement, so button shadows have guaranteed
                -- draw room regardless of how LR measures the
                -- row's natural height.
                spacing = 6,
                margin_horizontal = 14,
                margin_vertical = 12,

                -- Heading for the status-row cluster.  Replaces
                -- the previous "Random Pair Generator" bold
                -- title — that was redundant with the window
                -- titlebar.  "Component status" framing gives
                -- the five rows below a clear purpose so first-
                -- time users know what they're looking at.
                f:static_text {
                    title = 'Component status',
                    font = '<system/bold>',
                },
                -- Breathing room between the bold header and
                -- the first status row.  Without it the header
                -- sits flush against "Go runtime" and reads as
                -- part of the same row rather than as a
                -- section title.
                f:spacer { height = 4 },

                -- Runtime cluster: Go first (required, source
                -- mode only), then Helper (the process that uses
                -- it).  Recovery buttons ride the same row as the
                -- detail text, left-aligned right after it — made
                -- possible by the tighter DETAIL_WIDTH above.
                --
                -- Binary mode (dev-config helperMode=force-binary
                -- or auto+binary-present) skips the Go row
                -- entirely — Go is irrelevant when the helper
                -- spawns from a bundled binary.  Use a zero-height
                -- spacer in that case to keep the column-children
                -- list shape stable rather than rebuilding it
                -- conditionally.
                (useBinary
                    and f:spacer { height = 0 }
                    or statusRow('goEmoji', 'Go runtime', 'goDetail', {
                        {
                            title = 'Open go.dev and download installer',
                            visibleBind = 'goShowInstall',
                            action = function()
                                Log.line('dashboard', 'opening go.dev')
                                LrHttp.openUrlInBrowser(GO_INSTALL_URL)
                            end,
                        },
                    })),

                statusRow('helperEmoji', 'Helper', 'helperDetail', {
                    {
                        title = 'Start',
                        visibleBind = 'helperShowStart',
                        action = function()
                            Log.line('dashboard', 'start from helper row')
                            if active then
                                active.restartAllRequested = true
                            end
                        end,
                    },
                }),

                statusRow('browserEmoji', 'Browser', 'browserDetail'),

                -- Configuration cluster: what content the plugin
                -- operates on.  Photo source hosts TWO mutually-
                -- exclusive inline buttons: "Rebuild snapshot"
                -- shows only in current-mode with a healthy
                -- snapshot; "Open Settings" shows only when
                -- source mode is single/split but no collection
                -- has been chosen yet (so the user can't
                -- usefully Rebuild anything — they need to pick
                -- a collection first).  The refreshSource
                -- function keeps the two visibility props
                -- mutually exclusive.
                statusRow('sourceEmoji', 'Photo source', 'sourceDetail', {
                    {
                        title = 'Rebuild snapshot',
                        visibleBind = 'sourceShowRebuild',
                        action = function()
                            Log.line('dashboard',
                                'rebuild snapshot requested')
                            if active then
                                active.rebuildSnapshotRequested = true
                            end
                        end,
                    },
                    {
                        title = 'Open Settings',
                        visibleBind = 'sourceShowSettings',
                        action = function()
                            Log.line('dashboard',
                                'opening settings from photo source row')
                            if active then
                                active.settingsRequested = true
                            end
                        end,
                    },
                }),

                statusRow('saveSetEmoji', 'Save pairs to',
                    'saveSetDetail', {
                    {
                        title = 'Open Settings',
                        visibleBind = 'saveSetShowSettings',
                        action = function()
                            Log.line('dashboard',
                                'opening settings from save-pairs-to row')
                            if active then
                                active.settingsRequested = true
                            end
                        end,
                    },
                }),

                -- Recheck status button.  Sits under the status
                -- list so it reads as "act on the status above";
                -- collapses when all rows are green.  Isolated
                -- from the action row below to keep the primary
                -- buttons stable as Recheck appears/disappears.
                recheckRow,

                f:separator { fill_horizontal = 1 },

                -- Primary action row.
                actionRow,

                -- Version stamp at the bottom of the dashboard
                -- so testers can see what build they're on
                -- without opening a log.  Alpha bug reports
                -- benefit heavily; we can stop having to ask
                -- "which build was running?"
                f:static_text {
                    title = 'Version ' .. Version.string(),
                    text_color = LrView.kColorGrey,
                },
                -- Contact block.  Visible on every launch so
                -- users who got the plugin outside the intended
                -- distribution channel (forum post + hand-picked
                -- DMs) still know how to reach the author and
                -- find the home page.  Mirrors the
                -- LrPluginInfoProvider section in
                -- InfoProvider.lua — keep the two in sync.
                f:static_text {
                    title = 'This is a pre-release: please '
                        .. 'send problem reports and feature '
                        .. 'requests.',
                    text_color = LrView.kColorGrey,
                },
                f:row {
                    spacing = f:control_spacing(),
                    f:static_text {
                        title = 'Made by Ali Bosworth',
                        text_color = LrView.kColorGrey,
                    },
                    f:push_button {
                        title = 'alibosworth.com',
                        action = function()
                            LrHttp.openUrlInBrowser(
                                'https://alibosworth.com')
                        end,
                    },
                    f:push_button {
                        title = 'ali@alibosworth.com',
                        action = function()
                            LrHttp.openUrlInBrowser(
                                'mailto:ali@alibosworth.com')
                        end,
                    },
                },

                -- ---- Debug indicator (dev-config debug=true) ----
                -- Hidden entirely in normal/shipped builds.  Shows
                -- a snapshot of the spawn decision: helperMode,
                -- which path was picked, the binary path (if
                -- applicable), and the macOS quarantine xattr
                -- finding.  Snapshot is taken at dashboard render
                -- time and not live-bound — to refresh, restart
                -- the plugin (which is what dev-config edits
                -- require anyway).
                (debugMode and f:column {
                    spacing = 2,
                    margin_top = 8,
                    f:separator { fill_horizontal = 1 },
                    f:static_text {
                        title = 'Debug (dev-config.lua)',
                        font = '<system/bold/small>',
                        text_color = LrView.kColorGrey,
                    },
                    f:static_text {
                        title = 'helperMode = '
                            .. tostring(debugInfo.helperMode),
                        font = '<system/small>',
                        text_color = LrView.kColorGrey,
                    },
                    f:static_text {
                        title = 'resolved = '
                            .. tostring(debugInfo.resolvedSource)
                            .. (debugInfo.binaryExists
                                and ' (binary present)'
                                or ' (no binary)'),
                        font = '<system/small>',
                        text_color = LrView.kColorGrey,
                    },
                    f:static_text {
                        title = (debugInfo.lastSpawn
                            and ('last spawn = '
                                .. tostring(debugInfo.lastSpawn.source)
                                .. ' rc='
                                .. tostring(debugInfo.lastSpawn.rc))
                            or 'last spawn = (none yet)'),
                        font = '<system/small>',
                        text_color = LrView.kColorGrey,
                        width_in_chars = 55,
                    },
                    f:static_text {
                        title = (function()
                            if not Platform.isMac then
                                return 'xattr quarantine = (mac only)'
                            end
                            local s = debugInfo.lastSpawn
                            if not s then
                                return 'xattr quarantine = (probe pending)'
                            end
                            if s.xattrFound and s.xattrStripped then
                                return 'xattr quarantine = found, stripped'
                            elseif s.xattrFound and s.xattrStripped == false then
                                return 'xattr quarantine = found, STRIP FAILED'
                            elseif s.xattrFound == false then
                                return 'xattr quarantine = absent'
                            end
                            return 'xattr quarantine = (probe pending)'
                        end)(),
                        font = '<system/small>',
                        text_color = LrView.kColorGrey,
                        width_in_chars = 55,
                    },
                } or f:spacer { height = 0 }),
            }

            -- Present the dialog in a sibling async task rather
            -- than inline before the main loop below. In
            -- practice the dialog can be slow to materialize on
            -- Windows, and if we wait on it here the helper /
            -- status loop also waits, which makes the whole
            -- dashboard feel like it "isn't opening" for
            -- minutes. Running it separately lets the status
            -- logic start immediately while Lightroom gets
            -- around to creating the actual window.
            --
            -- Build 125 EXPERIMENT: Windows uses floating dialog
            -- WITHOUT save_frame.  Hypothesis: save_frame's
            -- per-platform implementation may have been the
            -- trigger for the always-on-top behavior we
            -- observed on Windows when previously trying
            -- floating mode.  If this works, Windows users
            -- get the same non-blocking dashboard Mac users
            -- have, at the cost of position/size not being
            -- remembered between sessions.  Adobe's SDK Guide
            -- explicitly documents floating dialogs as designed
            -- for "user interacting with main window AND the
            -- dialog itself" — so always-on-top contradicts
            -- the documented intent.  Either save_frame is the
            -- trigger (this test) or it's an LR 15.3 Windows
            -- regression we have to route around differently.
            -- windowWillClose callback gives us a reliable
            -- "user closed the dashboard" signal that the Mac
            -- path lacks (presentFloatingDialog returns
            -- immediately, we couldn't trust the post-call
            -- code on Mac).
            LrTasks.startAsyncTask(function()
                LrDialogs.presentFloatingDialog(_PLUGIN, {
                    title = 'Random Pair Generator',
                    contents = contents,
                    -- save_frame deliberately omitted on both
                    -- platforms.  Mac users lose position-memory
                    -- across sessions but that's acceptable.
                    -- blockTask = true keeps THIS async task
                    -- alive for the observer callbacks below to
                    -- fire over the dialog's lifetime.  Without
                    -- it the task exits immediately and the
                    -- observers stop firing.  Per kevinkiklee/
                    -- adobe-plugin-skills lrc-sdk.md.
                    blockTask = true,
                    -- onShow gives us programmatic handles to
                    -- the dialog window: toFront brings it to
                    -- the visual front of LR's window stack,
                    -- close dismisses it.  Stash them on _G so
                    -- a subsequent menu-click of "Start Random
                    -- Pair Generator" can detect "dashboard
                    -- already up" and bring it forward instead
                    -- of opening a duplicate floating window.
                    -- Cleared in windowWillClose so the next
                    -- menu-click correctly opens fresh after
                    -- the user explicitly closed.  See
                    -- Launcher.start for the consumer side.
                    onShow = function(funcs)
                        _G.__lrrpg_dashboardToFront = funcs.toFront
                        _G.__lrrpg_dashboardClose = funcs.close
                        Log.line('dashboard',
                            'onShow — toFront/close handles stashed')
                    end,
                    -- sourceChangeObserver / selectionChangeObserver
                    -- fire when the user changes LR's active
                    -- source or selection while the dashboard
                    -- is up.  We do NOT auto-rebuild the
                    -- snapshot on source change — testers
                    -- legitimately click around (reviewing
                    -- saved-pair sub-collections, just
                    -- browsing) without intending to switch
                    -- their pool.  Auto-rebuild would replace
                    -- the active pool with whatever the user
                    -- last clicked, which is hostile.  The
                    -- existing flow stands: user navigates to
                    -- the desired view in LR, then explicitly
                    -- clicks Rebuild snapshot on the dashboard.
                    sourceChangeObserver = function()
                        Log.line('dashboard', 'sourceChangeObserver fired')
                    end,
                    selectionChangeObserver = function()
                        Log.line('dashboard',
                            'selectionChangeObserver fired')
                    end,
                    windowWillClose = function()
                        Log.line('dashboard',
                            'windowWillClose — user closed dashboard')
                        -- Drop the stashed dialog handles so
                        -- the NEXT Launcher.start opens fresh
                        -- rather than calling toFront on a
                        -- dialog that's already gone.
                        _G.__lrrpg_dashboardToFront = nil
                        _G.__lrrpg_dashboardClose = nil
                        if closeHandled then return end
                        closeHandled = true
                        if active == thisActive then
                            active = nil
                        end
                        if deps.stopAll then deps.stopAll() end
                    end,
                })
                -- With blockTask = true, this point is reached
                -- only after the dialog closes — observers
                -- have stopped firing by now and windowWillClose
                -- has already run cleanup.
            end)

            -- Main dashboard loop.  Always runs so that button
            -- action flags (Rescan, Settings, Restart) get
            -- drained even when the helper itself can't start
            -- (no Go, or missing source).  Progressive
            -- compile-status messaging is skipped in those cases
            -- to avoid overwriting the clearer "Cannot start"
            -- helper text set at dashboard init.
            local skipHelperPolling =
                (props.goEmoji == G.err) or
                (deps.spawnResult == 'no-src')

            -- Main dashboard loop. Runs as long as the dashboard
            -- is active. Two responsibilities:
            --   1. Progressive compile/start messaging for Helper
            --      during the 30-second first-launch window.
            --   2. Drain the action-request flags set by button
            --      callbacks.  Buttons can't run yielding code
            --      and can't spawn new tasks (prop updates from a
            --      foreign task don't repaint the UI), so they
            --      signal here and we do the actual work in this
            --      context.
            started = os.time()
            helperReady = skipHelperPolling
            -- Capture the dashboard epoch at loop entry so this
            -- task self-retires when a later Dashboard.show
            -- (plugin reload, new Lua state) bumps it.  Reading
            -- the file each tick is the only coordination
            -- mechanism robust to Lua-state recreation (`_G`
            -- isn't shared across states, module locals reset).
            -- See AGENTS.md "Module reloads leave zombie
            -- LrTasks" + the rationale comment near
            -- DASHBOARD_EPOCH_FILE above.
            --
            -- Captured from the bump done synchronously at the
            -- top of Dashboard.show before this async task was
            -- spawned, so the value here is guaranteed to match
            -- the file at this point.
            local myEpoch = myDashboardEpoch
            -- Diagnostic: confirm the poll loop is actually
            -- running.  The "dashboard helper row stuck on
            -- Starting" bug in bugs.md has no helperAlive log
            -- lines in repros — we need to know if the loop
            -- itself is running before we dig into why the probe
            -- inside the loop isn't firing.
            Log.line('dashboard', 'poll loop entering',
                'skip=' .. tostring(skipHelperPolling) ..
                ' helperReady=' .. tostring(helperReady) ..
                ' epoch=' .. tostring(myEpoch))
            -- Tick counter so we can distinguish "loop is running
            -- but probe always returns ok" from "loop stopped
            -- running after Stop pairing."  See
            -- bug-helper-lifecycle.md for the investigation plan.
            local tickN = 0
            while active ~= nil do
                LrTasks.sleep(1.0)
                if active == nil then break end
                -- Self-retire on dashboard-epoch mismatch.
                -- Cheap io.open per tick (1 Hz; doesn't yield).
                local currentEpoch = readDashboardEpoch()
                if currentEpoch ~= myEpoch then
                    Log.line('dashboard',
                        'poll loop retiring (epoch mismatch)',
                        'myEpoch=' .. tostring(myEpoch) ..
                        ' currentEpoch=' .. tostring(currentEpoch) ..
                        ' tickN=' .. tostring(tickN))
                    return
                end
                tickN = tickN + 1

                -- Each branch below may YIELD (Settings.show is
                -- modal/blocking; rebuildCurrentSnapshotIfNeeded,
                -- rescanAll, Thumbs.clear all touch the catalog
                -- or filesystem and yield).  During a yield the
                -- dashboard's close-handler can fire and nil out
                -- `active` — when the yielding call returns,
                -- subsequent `active.X` accesses crash with
                -- "attempt to index upvalue 'active' (a nil
                -- value)".  Defensive break-on-nil after each
                -- branch keeps the loop graceful when the user
                -- closes the dashboard mid-action.

                -- Settings request has to go first because it
                -- blocks (modal dialog); everything else should
                -- wait until the user is done with Settings.
                if active.settingsRequested then
                    active.settingsRequested = false
                    local before = Prefs.config()
                    Settings.show()
                    local after = Prefs.config()
                    if Handlers.poolAffectingFieldsChanged(before, after) then
                        Handlers.onPoolSettingsChanged()
                    end
                    Handlers.publishDisplaySettings()
                    rescanAll()
                end
                if not active then break end

                if active.rebuildSnapshotRequested then
                    active.rebuildSnapshotRequested = false
                    Handlers.rebuildCurrentSnapshotIfNeeded()
                    -- Pool size likely changed; refresh the
                    -- Photo source row (and everything else
                    -- while we're at it — cheap).
                    rescanAll()
                end
                if not active then break end

                if active.rescanRequested then
                    active.rescanRequested = false
                    rescanAll()
                end
                if not active then break end

                if active.restartAllRequested then
                    active.restartAllRequested = false
                    helperIntentionallyStopped = false
                    props.helperEmoji = G.warn
                    props.helperDetail = 'Restarting...'
                    props.helperShowStart = false
                    if deps.restartAll then
                        deps.restartAll()
                    elseif deps.restartHelper then
                        deps.restartHelper()
                    end
                    -- Reset the first-launch window so the
                    -- progressive messaging covers the helper's
                    -- second compile (if any).
                    started = os.time()
                    helperReady = false
                end
                if not active then break end

                if active.clearThumbsRequested then
                    active.clearThumbsRequested = false
                    -- Thumbs.clear iterates and deletes via
                    -- LrFileUtils, which yields — safe to call
                    -- here since we're inside the poll loop's
                    -- async task context.  The call logs its own
                    -- "cache cleared" line so the user can
                    -- confirm via View log.
                    Thumbs.clear()
                end
                if not active then break end

                -- Helper progressive status.  Always probe —
                -- do NOT skip once helperReady is true.
                -- Previous "if not helperReady then ...; helperReady
                -- = true on first success" latch meant the row
                -- stayed green forever even if the helper later
                -- died (Stop pairing, external kill, crash), so
                -- the dashboard lied about helper state.
                --
                -- State machine branches on (ok, helperReady,
                -- elapsed):
                --   ok → green "Running", mark helperReady.
                --   !ok && helperReady → red "Not running"
                --     (was alive, no longer is — helper died).
                --   !ok && !helperReady → progressive starting/
                --     compiling messages (we've never seen it
                --     alive, so we're still in the initial
                --     spin-up window).
                local ok = helperAlive(deps.helperPort)
                local elapsed = os.time() - started
                -- Per-tick diagnostic.  Keep minimal — one line
                -- per second for the life of the dashboard adds
                -- up, but "helper never goes red" debugging
                -- needs visibility into every probe result.
                -- Remove once the helper-lifecycle bug is
                -- resolved.
                Log.line('dashboard',
                    'tick=' .. tostring(tickN) ..
                    ' epoch=' .. tostring(myEpoch) ..
                    ' ok=' .. tostring(ok) ..
                    ' helperReady=' .. tostring(helperReady) ..
                    ' intentionallyStopped=' ..
                        tostring(helperIntentionallyStopped) ..
                    ' elapsed=' .. tostring(elapsed))
                if helperIntentionallyStopped then
                    -- User explicitly clicked Stop pairing. Even
                    -- though the helper may still be alive for a
                    -- short grace period while the browser sees
                    -- close-requested, keep the row red so the UI
                    -- doesn't flicker red -> green -> red during
                    -- shutdown. Once the helper is actually down,
                    -- switch the detail text from "Stopping..." to
                    -- "Stopped".
                    props.helperEmoji = G.err
                        props.helperDetail = ok
                            and 'Stopping...'
                            or 'Stopped — ready to restart'
                    -- Start button is gated behind `not ok` —
                    -- only show it once the helper is actually
                    -- down ("Stopped"), not during the
                    -- "Stopping..." grace period.  Clicking
                    -- Start during the grace would queue a
                    -- restart that races Launcher.stop's
                    -- in-flight kill, leading to a "I clicked
                    -- Start but the helper just died" surprise.
                    props.helperShowStart =
                        (not ok)
                        and (useBinary or props.goEmoji == G.ok)
                        and (deps.spawnResult ~= 'no-src')
                    if not ok then
                        helperReady = false
                    end
                elseif ok then
                    props.helperEmoji = G.ok
                    props.helperDetail = 'Running'
                    props.helperShowStart = false
                    helperReady = true
                elseif helperReady then
                    -- Was running, isn't now.  Don't revert to
                    -- the "Starting/Compiling" progressive
                    -- copy — we're past first-launch.  Show
                    -- a clean "Not running" and surface Start
                    -- if Go is present.
                    props.helperEmoji = G.err
                    props.helperDetail = 'Not running'
                    props.helperShowStart =
                        (useBinary or props.goEmoji == G.ok)
                        and (deps.spawnResult ~= 'no-src')
                elseif elapsed < 3 then
                    props.helperEmoji = G.warn
                    props.helperDetail = 'Starting...'
                    props.helperShowStart = false
                elseif elapsed < 45 then
                    props.helperEmoji = G.warn
                    -- Compile messaging only applies to source
                    -- mode (`go run` does a first-launch compile
                    -- ~30s).  Binary mode has no compile step;
                    -- "Starting..." stays accurate longer.
                    props.helperDetail = useBinary
                        and 'Starting...'
                        or 'Compiling helper (first launch)...'
                    props.helperShowStart = false
                elseif elapsed < 300 then
                    props.helperEmoji = G.warn
                    props.helperDetail = useBinary
                        and string.format(
                            'Helper not responding (%ds) — '
                            .. 'check View log',
                            elapsed)
                        or string.format(
                            'Still compiling (%ds) — '
                            .. 'first run can take a few minutes',
                            elapsed)
                    props.helperShowStart = false
                else
                    props.helperEmoji = G.err
                    local err = helperFailureSummary()
                    props.helperDetail = err
                        and ('Start failed: ' .. err)
                        or 'Did not start after 5 min — check View log'
                    props.helperShowStart =
                        (useBinary or props.goEmoji == G.ok)
                        and (deps.spawnResult ~= 'no-src')
                end
            end
        end)
    end)
end

function Dashboard.updateStatus(text, hint)
    if active and active.props then
        active.props.status = text
        if hint ~= nil then active.props.hint = hint end
    end
end

function Dashboard.markHelperRunning()
    if active and active.props then
        local props = active.props
        local G = Platform.statusGlyphs()
        props.helperEmoji = G.ok
        props.helperDetail = 'Running'
        props.helperShowStart = false
    else
        pendingHelperRunning = true
    end
end

return Dashboard
