-- Launcher: coordinates the three pieces that make Pairs work.
--
--   1. Plugin-side IPC poller (file watcher + handler dispatch).
--   2. External Go helper process (HTTP server).
--   3. Browser window (Chrome --app preferred).
--   4. LrView dashboard — always opens as a control panel so the
--      user has visible state in Lightroom even if 2 or 3 fail.
--
-- The dashboard is deliberately the *entry point* the user sees.
-- If the helper binary is missing, the dashboard explains how to
-- build it. If Chrome isn't installed, it falls back to the default
-- browser. If the helper crashes, the dashboard can restart it.

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

local Log = require 'Log'
local IPC = require 'IPC'
local Dashboard = require 'Dashboard'
local Platform = require 'Platform'
local Stats = require 'Stats'
local Version = require 'Version'

local Launcher = {}
local stopEpoch = 0

-- Last-spawn breadcrumb populated by spawnHelperFrom{Binary,Source}.
-- Read by Dashboard's debug indicator (debug-mode only).  Not
-- load-bearing for normal operation; nil before first spawn,
-- overwritten on each spawn attempt.
--   source        'binary' | 'source'
--   path          full path to the binary or source dir
--   rc            shell return code from the spawn dispatch
--   xattrFound    true | false | nil  (Mac only; nil on Windows)
--   xattrStripped true | false | nil  (Mac only; true if probe
--                 found quarantine AND we stripped it before
--                 spawn — the path that gets us past Gatekeeper
--                 on a fresh download)
local lastSpawnInfo = nil

-- 9417 reads as "PAIR" in leetspeak (9~P mirror, 4~A, 1~I,
-- 7~R) — memorable, in the 16-bit port range, and not on
-- any well-known service list.  Was 8791 in earlier builds;
-- arbitrary then, themed now.
local HELPER_PORT = 9417
local HELPER_URL = 'http://localhost:' .. HELPER_PORT .. '/'

-- Exposed on the module table so other files (Shutdown.lua at
-- least) can use the same value without redeclaring the magic
-- number.  Don't add a setter — port is fixed at module load
-- time; see IDEAS.md for the user-configurable-port follow-up.
Launcher.HELPER_PORT = HELPER_PORT

local function cancelPendingStop()
    stopEpoch = stopEpoch + 1
    local closeMarker = LrPathUtils.child(
        Platform.scratchRoot, 'close-requested')
    if LrFileUtils.exists(closeMarker) then
        LrFileUtils.delete(closeMarker)
    end
end

-- Alpha distribution: we ship the Go helper as source and spawn
-- it via `go run`. That means every launch needs a usable `go`
-- binary somewhere on the filesystem. Beta will switch back to
-- bundling a signed/notarized compiled binary.  Per-platform
-- install-location probes live in Platform.goProbePaths.
-- LrTasks.execute runs commands in a non-interactive shell that
-- often doesn't inherit the user's $PATH additions, so a bare
-- `go` probe is unreliable. We check known install locations
-- first, then fall back to asking the shell (which will work if
-- the user has set things up for non-interactive use).
function Launcher.findGo()
    for _, path in ipairs(Platform.goProbePaths()) do
        if LrFileUtils.exists(path) then
            return path
        end
    end
    local out = LrPathUtils.child(Platform.scratchRoot, 'go-probe.out')
    LrFileUtils.createAllDirectories(Platform.scratchRoot)
    LrTasks.execute(Platform.whichGoCmd(out))
    local f = io.open(out, 'r')
    if not f then return nil end
    local body = f:read('*a') or ''
    f:close()
    -- Take only the first line.  Windows `where go` commonly
    -- returns multiple matches (e.g. Program Files install +
    -- a PATH symlink) separated by CRLF.  Unix `command -v go`
    -- is single-line, but the match pattern is tolerant of
    -- either.  If we take the raw trimmed body we end up with
    -- a multi-line string and LrFileUtils.exists returns false
    -- even though Go is installed.
    local firstLine = body:match('([^\r\n]+)')
    if not firstLine then return nil end
    firstLine = firstLine:gsub('^%s+', ''):gsub('%s+$', '')
    if firstLine ~= '' and LrFileUtils.exists(firstLine) then
        return firstLine
    end
    return nil
end

local function helperSourcePath()
    -- Use nested LrPathUtils.child so the separator is native
    -- on both platforms (backslash on Windows).  A single
    -- 'helper-src/main.go' arg to .child works on some SDK
    -- versions but double-.child is safer.
    return LrPathUtils.child(
        LrPathUtils.child(_PLUGIN.path, 'helper-src'),
        'main.go')
end

local function helperSourceDir()
    return LrPathUtils.child(_PLUGIN.path, 'helper-src')
end

local function webRootPath()
    return LrPathUtils.child(_PLUGIN.path, 'web')
end

local function helperReadyPath()
    return LrPathUtils.child(Platform.scratchRoot, 'helper-ready.json')
end

local function clearHelperReadyMarker()
    local readyPath = helperReadyPath()
    if LrFileUtils.exists(readyPath) then
        LrFileUtils.delete(readyPath)
    end
end

local function writeWindowsHelperLaunchScript(path, go, srcDir, stdoutPath, stderrPath)
    local webRoot = webRootPath()
    local f, err = io.open(path, 'w')
    if not f then
        return nil, err
    end
    -- Use a tiny batch wrapper on Windows instead of passing a
    -- long quoted argument list through PowerShell Start-Process.
    -- Paths like "Shared Files\\...\\web" were being split at the
    -- space on some machines despite several quoting strategies.
    -- A .cmd file lets cmd.exe own quoting and working-directory
    -- setup directly, which is much more predictable.
    f:write('@echo off\r\n')
    f:write('cd /d "' .. srcDir .. '"\r\n')
    f:write('"' .. go .. '" run . -port ' .. tostring(HELPER_PORT)
        .. ' -web "' .. webRoot .. '"'
        .. ' 1>"' .. stdoutPath .. '"'
        .. ' 2>"' .. stderrPath .. '"\r\n')
    f:close()
    return true
end

-- Resolve the platform-specific bundled helper binary path.
-- Returns the path regardless of whether the file exists; caller
-- checks LrFileUtils.exists.  Built by
-- scripts/build-helper-binaries.sh (universal Mac via lipo;
-- Windows amd64).
local function helperBinaryPath()
    if Platform.isWindows then
        return LrPathUtils.child(_PLUGIN.path, 'helper-windows-amd64.exe')
    end
    return LrPathUtils.child(_PLUGIN.path, 'helper-darwin')
end

-- Dev-config: read once on first call, cached.  Missing file or
-- load error → defaults.  See dev-config.example.lua for the shape
-- and AGENTS.md for the rationale.
local devConfigCache = nil
local function devConfig()
    if devConfigCache ~= nil then return devConfigCache end
    local path = LrPathUtils.child(_PLUGIN.path, 'dev-config.lua')
    local defaults = { helperMode = 'auto', debug = false }
    if not LrFileUtils.exists(path) then
        devConfigCache = defaults
        return devConfigCache
    end
    local ok, loaded = pcall(dofile, path)
    if not ok or type(loaded) ~= 'table' then
        Log.line('launcher', 'dev-config.lua present but failed to load',
            tostring(loaded))
        devConfigCache = defaults
        return devConfigCache
    end
    devConfigCache = {
        helperMode = loaded.helperMode or defaults.helperMode,
        debug = loaded.debug == true,
    }
    Log.line('launcher', 'dev-config loaded',
        string.format('helperMode=%s debug=%s',
            tostring(devConfigCache.helperMode),
            tostring(devConfigCache.debug)))
    return devConfigCache
end

-- Verbose logger gated on dev-config debug flag.  In production
-- (no dev-config.lua, or debug=false), no-ops.
local function dlog(scope, msg, detail)
    if devConfig().debug then
        Log.line(scope, '[debug] ' .. msg, detail)
    end
end

-- Resolve which spawn path will (or did) be used: 'binary' or
-- 'source'.  Same decision tree as spawnHelper, exposed so
-- Dashboard can hide the Go-runtime row when binary mode is
-- in effect (Go is irrelevant in that path).
local function resolveSpawnSource()
    local mode = devConfig().helperMode or 'auto'
    if mode == 'force-binary' then return 'binary' end
    if mode == 'force-source' then return 'source' end
    -- auto
    if LrFileUtils.exists(helperBinaryPath()) then
        return 'binary'
    end
    return 'source'
end

-- Public accessor for Dashboard's debug indicator.  Returns the
-- combined dev-config + last-spawn breadcrumb.  Safe to call
-- before any spawn has happened (lastSpawnInfo will be nil).
function Launcher.spawnInfo()
    local cfg = devConfig()
    return {
        helperMode    = cfg.helperMode,
        debug         = cfg.debug,
        resolvedSource = resolveSpawnSource(),
        binaryPath    = helperBinaryPath(),
        binaryExists  = LrFileUtils.exists(helperBinaryPath()) ~= false,
        lastSpawn     = lastSpawnInfo,  -- nil until first spawn
    }
end

-- Detect an app-mode browser.  Returns { kind = 'chromium',
-- name, bin } if one is installed, or { kind = 'default' } if
-- we'll have to fall back to the system default browser.  The
-- Dashboard uses the kind to render a green/yellow checklist
-- indicator.
--
-- Rationale for invoking the binary directly (rather than `open
-- -a Chrome` or `start chrome.exe`): when the browser is already
-- running, those higher-level launchers tend to focus the
-- existing instance without creating a new --app window.
-- Invoking the binary path directly passes --app as a process
-- arg regardless of whether there's already a browser running.
-- Resolution order:
--   1. Read the user's `browserPref` from Prefs.config().
--   2. If pref is a specific browser name AND that browser
--      is installed → use it (cold-start cost is the user's
--      explicit choice).
--   3. If pref is 'auto' (default):
--      a. From the chromiumBrowsers list, find any that are
--         currently RUNNING — pick the first match in
--         preference order.  This avoids cold-start lag
--         when the user already has a Chromium browser
--         primed.
--      b. If none running, fall back to the first INSTALLED
--         Chromium browser.
--   4. If neither (no Chromium installed at all) → 'default'
--      kind (regular browser tab via `open` / `start`).
--
-- See ideas/focus-cohesion.md "Smarter browser choice" for
-- design rationale and trade-offs.
function Launcher.findBrowser()
    local Prefs = require 'Prefs'
    local pref = Prefs.config().browserPref or 'auto'
    local chromium = Platform.chromiumBrowsers()
    local regular = Platform.regularBrowsers()

    -- Helpers: lookup an installed browser by canonical
    -- name in either list.
    local function findInChromium(name)
        for _, b in ipairs(chromium) do
            if b.name == name and LrFileUtils.exists(b.bin) then
                return b
            end
        end
        return nil
    end
    local function findInRegular(name)
        for _, b in ipairs(regular) do
            if b.name == name and LrFileUtils.exists(b.bin) then
                return b
            end
        end
        return nil
    end

    -- Pref → canonical name mapping.  Prefs are stored as
    -- short identifiers (stable across UI label changes).
    -- Chromium-family prefs return `kind='chromium'` (with
    -- --app=URL minimal-window mode); regular prefs return
    -- `kind='tab'` (opens as a normal browser tab).
    local CHROMIUM_PREFS = {
        chrome   = 'Google Chrome',
        edge     = 'Microsoft Edge',
        brave    = 'Brave Browser',
        chromium = 'Chromium',
    }
    -- Arc is here, not CHROMIUM_PREFS, because Arc's --app
    -- flag is broken (opens blank window + "only one
    -- instance" complaint).  Treated as regular tab via
    -- `open -a Arc.app URL`.
    local REGULAR_PREFS = {
        firefox = 'Firefox',
        safari  = 'Safari',
        arc     = 'Arc',
    }

    if pref ~= 'auto' then
        local chromiumTarget = CHROMIUM_PREFS[pref]
        if chromiumTarget then
            local hit = findInChromium(chromiumTarget)
            if hit then
                return { kind = 'chromium',
                    name = hit.name, bin = hit.bin }
            end
            Log.line('launcher',
                'browserPref=' .. pref .. ' but '
                .. chromiumTarget .. ' not installed — '
                .. 'falling back to auto')
        end
        local regularTarget = REGULAR_PREFS[pref]
        if regularTarget then
            local hit = findInRegular(regularTarget)
            if hit then
                return { kind = 'tab',
                    name = hit.name, appPath = hit.appPath }
            end
            Log.line('launcher',
                'browserPref=' .. pref .. ' but '
                .. regularTarget .. ' not installed — '
                .. 'falling back to auto')
        end
    end

    -- Auto: prefer a running Chromium browser.
    local runningNames = Platform.runningBrowsers()
    local runningSet = {}
    for _, n in ipairs(runningNames) do runningSet[n] = true end
    for _, b in ipairs(chromium) do
        if runningSet[b.name] and LrFileUtils.exists(b.bin) then
            Log.line('launcher',
                'auto-picked running browser', b.name)
            return { kind = 'chromium', name = b.name, bin = b.bin }
        end
    end

    -- Nothing running — fall back to first-installed
    -- Chromium browser.
    for _, b in ipairs(chromium) do
        if LrFileUtils.exists(b.bin) then
            Log.line('launcher',
                'no running Chromium browser, falling back '
                .. 'to first-installed', b.name)
            return { kind = 'chromium', name = b.name, bin = b.bin }
        end
    end

    return { kind = 'default' }
end

-- Smart pair-window geometry was attempted in build 77 and
-- reverted — see IDEAS.md "Smart pair-window geometry on
-- first launch" + Platform.lua note.  openBrowser uses
-- Chrome's default geometry; user reshapes once, Chrome
-- remembers per-URL thereafter.

local function openBrowser()
    local b = Launcher.findBrowser()
    if b.kind == 'chromium' then
        local cmd = Platform.openAppModeCmd(b.bin, HELPER_URL)
        Log.line('launcher', 'opening in app mode', b.name)
        LrTasks.execute(cmd)
        return
    end
    if b.kind == 'tab' then
        -- User-picked Firefox / Safari (etc.) — open as a
        -- regular tab in that specific browser, even if
        -- it's not the system default.
        local cmd = Platform.openTabCmd(b.appPath, HELPER_URL)
        Log.line('launcher',
            'opening as regular tab', b.name)
        LrTasks.execute(cmd)
        return
    end
    Log.line('launcher', 'no app-mode browser found — opening default')
    LrTasks.execute(Platform.openDefaultBrowserCmd(HELPER_URL))
end

-- Helper-stdout/stderr paths are shared between binary and
-- source spawn paths.  Helper writes diagnostic lines to stderr;
-- shell redirect captures into helper.log.  Despite the name,
-- helper.log is the helper's full journal, not errors-only —
-- was originally called helper.stderr which was misleading.
local function helperLogPaths()
    return
        LrPathUtils.child(Platform.scratchRoot, 'helper.stdout'),
        LrPathUtils.child(Platform.scratchRoot, 'helper.log')
end

local function clearHelperLogs(stdoutPath, stderrPath)
    -- Clear prior launch logs so the dashboard's helper failure
    -- summary reflects this spawn attempt, not a stale error
    -- from yesterday's failed startup.
    if LrFileUtils.exists(stdoutPath) then
        LrFileUtils.delete(stdoutPath)
    end
    if LrFileUtils.exists(stderrPath) then
        LrFileUtils.delete(stderrPath)
    end
end

-- Spawn the helper from a bundled binary (helper-darwin universal
-- on Mac, helper-windows-amd64.exe on Windows).  Returns true if
-- the spawn was dispatched.
local function spawnHelperFromBinary(binaryPath)
    -- Seed the breadcrumb synchronously so Dashboard's debug
    -- indicator reflects this spawn even before the async task
    -- fills in rc and xattr fields.
    lastSpawnInfo = {
        source = 'binary', path = binaryPath, rc = nil,
        xattrFound = nil, xattrStripped = nil,
    }
    LrTasks.startAsyncTask(function()
        local stdoutPath, stderrPath = helperLogPaths()
        clearHelperLogs(stdoutPath, stderrPath)

        if Platform.isMac then
            -- Pre-spawn quarantine handling.  On macOS 15+,
            -- Gatekeeper blocks direct-spawn of unsigned binaries
            -- carrying com.apple.quarantine — confirmed
            -- empirically in build 13 testing where macOS 15.7.2
            -- showed the "Apple could not verify" dialog and
            -- killed the helper before main() ran.
            --
            -- Path: probe → if found, strip → spawn.  The user
            -- already trusted the plugin via Plug-in Manager
            -- (explicit consent action); stripping a transport
            -- flag from a sub-component of an installed plugin
            -- is not subverting security.  Until beta switches
            -- to Developer ID signing + notarization, this is
            -- the alpha-era workaround.
            local xattrOut = LrPathUtils.child(
                Platform.scratchRoot, 'xattr-probe.out')
            LrTasks.execute(string.format(
                'xattr -p com.apple.quarantine "%s" >"%s" 2>&1',
                binaryPath, xattrOut))
            local f = io.open(xattrOut, 'r')
            local found = false
            if f then
                local body = f:read('*a') or ''
                f:close()
                if body and not body:match('No such xattr') and body ~= '' then
                    found = true
                end
            end
            lastSpawnInfo.xattrFound = found

            if found then
                Log.line('launcher',
                    'binary has com.apple.quarantine xattr — stripping before spawn',
                    binaryPath)
                local rc = LrTasks.execute(string.format(
                    'xattr -d com.apple.quarantine "%s" 2>"%s"',
                    binaryPath, xattrOut))
                if rc == 0 then
                    lastSpawnInfo.xattrStripped = true
                    Log.line('launcher', 'quarantine xattr stripped',
                        'spawn should bypass Gatekeeper')
                else
                    lastSpawnInfo.xattrStripped = false
                    Log.line('launcher',
                        'xattr -d failed rc=' .. tostring(rc),
                        'spawn may still be blocked by Gatekeeper')
                end
            else
                lastSpawnInfo.xattrStripped = false  -- nothing to strip
                dlog('launcher', 'no com.apple.quarantine xattr',
                    'binary is not quarantined')
            end
        end

        local cmd = Platform.spawnBackground(
            binaryPath,
            { '-port', tostring(HELPER_PORT),
              '-web', webRootPath(),
              '-version', Version.string() },
            stdoutPath, stderrPath)
        Log.line('launcher', 'launching helper from binary', binaryPath)
        dlog('launcher', 'spawn cmd', cmd)
        local rc = LrTasks.execute(cmd)
        lastSpawnInfo.rc = rc
        Log.line('launcher', 'helper rc=' .. tostring(rc))
    end)
    return true
end

-- Spawn the helper via `go run`. Returns true if dispatched, or
-- a string error code if go/source missing.
local function spawnHelperFromSource()
    local go = Launcher.findGo()
    if not go then
        Log.line('launcher', 'go not found — cannot spawn helper',
            'see dashboard for go.dev link')
        return 'no-go'
    end
    local src = helperSourcePath()
    local srcDir = helperSourceDir()
    lastSpawnInfo = {
        source = 'source', path = srcDir, rc = nil, xattrFound = nil,
    }
    local goMod = LrPathUtils.child(srcDir, 'go.mod')
    if not LrFileUtils.exists(src) then
        Log.line('launcher', 'helper source missing',
            'expected at ' .. src)
        return 'no-src'
    end
    if not LrFileUtils.exists(goMod) then
        Log.line('launcher', 'helper go.mod missing',
            'expected at ' .. goMod)
        return 'no-src'
    end
    LrTasks.startAsyncTask(function()
        -- `go run` compiles on first launch (~30s) and caches
        -- the compiled binary in the user's Go build cache.
        -- Subsequent launches reuse the cache and start in a
        -- fraction of a second.  We pass -web explicitly
        -- because under `go run`, os.Executable inside the
        -- helper points at Go's temp build dir, not at the
        -- plugin directory.
        local stdoutPath, stderrPath = helperLogPaths()
        clearHelperLogs(stdoutPath, stderrPath)
        local cmd
        if Platform.isWindows then
            local scriptPath = LrPathUtils.child(
                Platform.scratchRoot, 'helper-launch.cmd')
            if LrFileUtils.exists(scriptPath) then
                LrFileUtils.delete(scriptPath)
            end
            local ok, writeErr = writeWindowsHelperLaunchScript(
                scriptPath, go, srcDir, stdoutPath, stderrPath)
            if not ok then
                Log.line('launcher', 'failed writing helper launch script',
                    tostring(writeErr))
                return
            end
            cmd = string.format('start "" /b "%s"', scriptPath)
        else
            cmd = Platform.spawnBackground(
                go,
                { '-C', srcDir,
                  'run', '.',
                  '-port', tostring(HELPER_PORT),
                  '-web', webRootPath(),
                  '-version', Version.string() },
                stdoutPath, stderrPath)
        end
        Log.line('launcher', 'launching helper via go run', cmd)
        dlog('launcher', 'spawn cmd', cmd)
        local rc = LrTasks.execute(cmd)
        if lastSpawnInfo then lastSpawnInfo.rc = rc end
        Log.line('launcher', 'helper rc=' .. tostring(rc))
    end)
    return true
end

-- Dispatch helper spawn based on dev-config helperMode.  Returns:
--   'spawned'    — launch command dispatched (binary or source)
--   'no-go'      — auto/force-source mode, go not installed
--   'no-src'     — auto/force-source mode, helper source missing
--   'no-binary'  — force-binary mode, binary not present
local function spawnHelper()
    local cfg = devConfig()
    local mode = cfg.helperMode or 'auto'
    local binPath = helperBinaryPath()
    local binExists = LrFileUtils.exists(binPath)

    dlog('launcher', 'spawnHelper resolving mode',
        string.format('mode=%s binary=%s exists=%s',
            mode, binPath, tostring(binExists)))

    if mode == 'force-binary' then
        if not binExists then
            Log.line('launcher',
                'force-binary mode but binary missing',
                'expected at ' .. binPath)
            return 'no-binary'
        end
        Log.line('launcher', 'mode=force-binary — using bundled binary')
        spawnHelperFromBinary(binPath)
        return 'spawned'
    end

    if mode == 'force-source' then
        Log.line('launcher', 'mode=force-source — bypassing bundled binary even if present')
        local rv = spawnHelperFromSource()
        if rv == true then return 'spawned' end
        return rv  -- 'no-go' or 'no-src'
    end

    -- mode == 'auto' (default): prefer binary, fall back to source.
    if binExists then
        dlog('launcher', 'auto mode resolved to binary', binPath)
        spawnHelperFromBinary(binPath)
        return 'spawned'
    end
    dlog('launcher', 'auto mode falling back to source',
        'no binary at ' .. binPath)
    local rv = spawnHelperFromSource()
    if rv == true then return 'spawned' end
    return rv
end

-- Kill whatever process is holding the helper port.  Port-
-- based kill is robust under `go run`, which spawns a temp-
-- built child whose process name isn't predictable — killing
-- by pattern (as the bundled-binary version did) would miss
-- the child.  Platform wraps the platform-specific shell.
local function killHelper()
    LrTasks.startAsyncTask(function()
        LrTasks.execute(Platform.killByPortCmd(HELPER_PORT))
        Log.line('launcher', 'helper killed (by port)',
            tostring(HELPER_PORT))
    end)
end

local function restartHelper()
    killHelper()
    LrTasks.startAsyncTask(function()
        LrTasks.sleep(0.3)
        spawnHelper()
    end)
end

-- Cheap probe for "is the helper already listening?". Uses curl
-- through LrTasks.execute with output redirected to a side file
-- since LrTasks.execute can't capture stdout directly.
local function helperAlive()
    local f = io.open(helperReadyPath(), 'r')
    if not f then 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 return false end
    local ageMs = (os.time() * 1000) - ts
    return ageMs >= 0 and ageMs < 5000
end

function Launcher.start()
    -- Wrap everything in an async task because the menu-handler
    -- context that calls Launcher.start can't yield, and
    -- Log.reset + IPC.start + helperAlive all call
    -- LrTasks.execute which DOES yield (error: "We can only
    -- wait from within a task").  Nested startAsyncTask (IPC.start
    -- also spawns its own task internally) is fine.
    LrTasks.startAsyncTask(function()
        -- Early-return: if a dashboard from a previous menu
        -- click is still up, just bring it to the front and
        -- (re)open the pair browser — don't spawn a duplicate
        -- floating dialog or cycle the helper.  Without this,
        -- multiple menu clicks while the plugin is already
        -- running stack up dashboard windows visually.  The
        -- toFront handle is captured on dashboard onShow and
        -- cleared in windowWillClose, so it's non-nil only
        -- when a dashboard is actually still on screen.  Don't
        -- Log.reset here — preserves the existing session's
        -- log continuity across re-clicks.
        if _G.__lrrpg_dashboardToFront then
            Log.line('launcher',
                'menu re-click — dashboard already up, bringing to front')
            local ok, err = pcall(_G.__lrrpg_dashboardToFront)
            if not ok then
                -- toFront failed (e.g. dialog destroyed under
                -- us) — clear the stale handle and fall through
                -- to a normal start so the user gets a working
                -- dashboard rather than a silent no-op.
                Log.line('launcher',
                    'toFront failed, clearing stale handle and starting fresh',
                    tostring(err))
                _G.__lrrpg_dashboardToFront = nil
                _G.__lrrpg_dashboardClose = nil
            else
                -- Reopen the pair browser too in case the user
                -- closed it.  openBrowser is idempotent on
                -- Chrome --app: existing window comes to front
                -- if still open, new window if not.
                openBrowser()
                return
            end
        end

        Log.reset()
        -- First real lines after reset are the version stamps:
        -- our plugin version AND the host LR Classic version.
        -- Bug reports without LR-version context are hard to
        -- correlate against platform-specific SDK quirks
        -- (e.g. floating-dialog modality, focus behavior on
        -- different LR builds).  Tester-pasted log excerpts
        -- self-identify both axes via these two lines.
        Log.line('version', Version.string())
        local v = LrApplication.versionTable()
        local lrPlatform = LrApplication.versionTable().platform
            or (Platform.isWindows and 'win' or 'mac')
        Log.line('lr-version',
            string.format('%d.%d.%d build=%s platform=%s',
                v.major or 0, v.minor or 0, v.revision or 0,
                tostring(v.build or '?'),
                tostring(lrPlatform)))
        Log.line('launcher', 'start')
        -- Bump session counter AFTER the dashboard re-click
        -- early-return above, so menu re-clicks against an
        -- already-open dashboard don't inflate the count.
        Stats.incrementSessionStarted()
        cancelPendingStop()

        -- IPC poller is idempotent (epoch-based; start()
        -- increments the epoch so any old tasks retire cleanly
        -- and one new task takes over).
        IPC.start()

        -- Kill-and-respawn on startup, unconditionally.
        --
        -- We previously trusted the `helperAlive()` probe to
        -- detect an already-running helper from a prior session
        -- and reuse it.  The probe uses curl HTTP `GET /status`
        -- and returns true on any 200 response.  That turned
        -- out to be too permissive: a helper that's gotten into
        -- a wedged state (seen after a dirty LR shutdown) can
        -- still respond 200 to `/status` but fail to serve real
        -- browser traffic, producing a dashboard that thinks
        -- things are fine and a Chrome window that just shows
        -- ERR_CONNECTION_REFUSED.  See bugs.md "Wedged zombie
        -- helper passes Launcher's startup probe but can't
        -- serve".
        --
        -- Simpler + more reliable: on every plugin launch, kill
        -- whatever's on the helper port and spawn a fresh
        -- helper.  Cost
        -- is ~0.5-1s (Go's build cache makes respawn fast after
        -- the first compile).  Benefit is a guaranteed clean
        -- state for the new session — zombies never persist
        -- across launches, no matter how dirty the prior
        -- shutdown was.
        --
        -- Log the prior alive state for diagnostic purposes so
        -- we can still see in plugin.log whether a zombie was
        -- found on launch or whether the port was clean.
        local priorAlive = helperAlive()
        Log.line('launcher', 'startup probe',
            'helper_alive=' .. tostring(priorAlive)
            .. ' (killing+respawning regardless)')
        clearHelperReadyMarker()

        -- Kill whatever's there.  `Platform.killByPortCmd` is
        -- a no-op when nothing is listening on the port.  Run
        -- synchronously so the port is free before spawn.
        LrTasks.execute(Platform.killByPortCmd(HELPER_PORT))
        if priorAlive then
            Log.line('launcher', 'killed stale helper',
                'port=' .. tostring(HELPER_PORT))
            -- Brief sleep gives the OS a moment to release the
            -- socket before the new helper binds.  Most platforms
            -- handle this cleanly without a wait, but SO_REUSEADDR
            -- timing on Windows has been flaky.
            LrTasks.sleep(0.3)
        end

        local spawnResult = spawnHelper()

        -- Show the Dashboard IMMEDIATELY.  Its progressive poll
        -- loop displays "Starting..." / "Compiling..." so the
        -- user has visible feedback while the helper is
        -- spinning up.  Dashboard.show is non-blocking (it
        -- spawns its own async task).  Blocking here — e.g. to
        -- wait for helperAlive() before the dashboard appears —
        -- leaves the user staring at a 30s blank screen on a
        -- cold first `go run`.
        Dashboard.show({
            helperPort    = HELPER_PORT,
            openBrowser   = openBrowser,
            restartHelper = restartHelper,
            stopAll       = function() Launcher.stop() end,
            restartAll    = function() Launcher.restart() end,
            findGo        = Launcher.findGo,
            findBrowser   = Launcher.findBrowser,
            spawnResult   = spawnResult,
            spawnInfo     = Launcher.spawnInfo,
        })

        -- Now wait for the helper's port to come up before
        -- pointing the browser at it.  Under `go run`, first
        -- launch compiles (~30s, or longer with a cold Go
        -- module cache) — a fixed 1s sleep here would land the
        -- user on ERR_CONNECTION_REFUSED.  A warm cache
        -- responds in ~200ms, so the poll finishes fast in the
        -- common case.  Capped at 60s; past that we open the
        -- browser anyway and let the user refresh once the
        -- Dashboard shows the helper alive.
        -- Error paths: helper can't start at all, no point
        -- opening a browser at a dead port.  Dashboard will
        -- surface the reason.
        if spawnResult == 'no-go' or spawnResult == 'no-src' then
            return
        end

        -- Common path for both 'spawned' and 'already-running':
        -- wait for helperAlive() to confirm HTTP responsiveness
        -- before opening Chrome.  Truly-alive already-running
        -- case returns on the first probe tick (~no wait).
        -- Freshly-spawned case polls until the Go compile +
        -- listen completes.  Either way the browser only opens
        -- once there's something to talk to.
        --
        -- Previously the 'already-running' path skipped this
        -- wait entirely, which opened Chrome onto a dead port
        -- when the startup probe was a false positive (stale
        -- helper-ready marker, or something transiently bound
        -- the port then died).  Reported on Windows AND macOS.
        -- See bugs.md "Launcher opens browser immediately on
        -- already-running without re-probing."
        --
        -- 5 min cap matches Dashboard's progressive-status
        -- ceiling.  Observed Windows cold first-run compiles
        -- hit ~3m46s.  Warm cache runs finish under a second.
        local waited = 0
        local HELPER_READY_TIMEOUT = 300
        while not helperAlive() and waited < HELPER_READY_TIMEOUT do
            LrTasks.sleep(0.5)
            waited = waited + 0.5
        end
        if helperAlive() then
            Log.line('launcher',
                'helper ready, opening browser',
                'waited=' .. waited .. 's spawnResult=' .. spawnResult)
            Dashboard.markHelperRunning()
        else
            Log.line('launcher',
                'helper did not respond within '
                .. HELPER_READY_TIMEOUT
                .. 's — opening browser anyway',
                'spawnResult=' .. spawnResult)
        end
        openBrowser()
    end)
end

-- Re-arm after Stop pairing: bring IPC back up, spawn a fresh
-- helper, and open the browser once the helper is listening.
-- Called from Dashboard's Start pairing button when the user
-- stopped via Stop pairing and wants to resume without closing /
-- reopening the dashboard via the menu.
--
-- Distinct from Launcher.start: does NOT reset the log, does NOT
-- open a new Dashboard window (one is already visible, and
-- reopening would leave a zombie).
function Launcher.restart()
    LrTasks.startAsyncTask(function()
        Log.line('launcher', 'restart (from dashboard Start pairing)')
        cancelPendingStop()
        clearHelperReadyMarker()
        IPC.start()
        -- Kill anything still bound to the port (belt-and-braces —
        -- Launcher.stop already killed by port, but a user who
        -- re-clicked quickly might hit the window where a stray
        -- process is bound).
        LrTasks.execute(Platform.killByPortCmd(HELPER_PORT))
        LrTasks.sleep(0.3)
        local spawnResult = spawnHelper()
        if spawnResult == 'no-go' or spawnResult == 'no-src' then
            return
        end
        local waited = 0
        local HELPER_READY_TIMEOUT = 300
        while not helperAlive() and waited < HELPER_READY_TIMEOUT do
            LrTasks.sleep(0.5)
            waited = waited + 0.5
        end
        if helperAlive() then
            Dashboard.markHelperRunning()
        end
        openBrowser()
    end)
end

function Launcher.stop()
    Log.line('launcher', 'stop')
    stopEpoch = stopEpoch + 1
    local myStopEpoch = stopEpoch

    -- Write a close-request signal first so the browser's next
    -- /status poll sees it and closes itself. Give it ~1s to
    -- notice before we tear down the helper that's serving
    -- /status.
    LrTasks.startAsyncTask(function()
        -- Portable "touch": create the dir via LR API, open
        -- the marker file for write and close it.  Avoids
        -- shelling out to `touch` (Unix) / `type nul >>` (Win).
        LrFileUtils.createAllDirectories(Platform.scratchRoot)
        local closeMarker = LrPathUtils.child(
            Platform.scratchRoot, 'close-requested')
        local readyMarker = LrPathUtils.child(
            Platform.scratchRoot, 'helper-ready.json')
        local f = io.open(closeMarker, 'w')
        if f then f:close() end
        if LrFileUtils.exists(readyMarker) then
            LrFileUtils.delete(readyMarker)
        end

        -- Browser polls /status about every 2s.  Give it longer
        -- than one poll interval to observe close-requested
        -- before we kill the helper that's serving /status;
        -- otherwise Stop pairing races and the browser window
        -- stays open showing a stale page.
        LrTasks.sleep(2.5)
        if myStopEpoch ~= stopEpoch then
            Log.line('launcher', 'stop cancelled before cleanup')
            return
        end
        IPC.stop()
        killHelper()

        -- Portable cleanup of scratch files that aren't part
        -- of the epoch coordination (heartbeat, req/resp
        -- already handled by IPC.stop).  LrFileUtils.delete
        -- is a no-op on missing files.
        for _, name in ipairs({
            'helper.stdout', 'helper.log', 'close-requested',
        }) do
            LrFileUtils.delete(LrPathUtils.child(
                Platform.scratchRoot, name))
        end
        Log.line('launcher', 'close complete')
    end)
end

return Launcher
