-- Platform.lua
--
-- Encapsulates the macOS / Windows differences in one place so
-- the rest of the plugin doesn't have to branch inline.  The
-- LR SDK globals WIN_ENV and MAC_ENV make the branch cheap.
--
-- Rules of thumb used here:
--   - For file system operations LrFileUtils / LrPathUtils are
--     already portable, so we use them directly in the callers
--     (createAllDirectories, delete, files, exists, move).  No
--     wrapper needed.
--   - For shell commands run via LrTasks.execute, the target
--     shell differs: /bin/sh on macOS vs. cmd.exe on Windows.
--     Those need explicit branching and the helpers below wrap
--     each case.

local LrPathUtils = import 'LrPathUtils'
local LrColor = import 'LrColor'
local LrFileUtils = import 'LrFileUtils'
local LrTasks = import 'LrTasks'

local Platform = {}

Platform.isWindows = (WIN_ENV == true)
Platform.isMac     = (MAC_ENV == true)

-- Root dir for all scratch state (heartbeat, req/resp, thumbs,
-- probe outputs, helper stdout/stderr, plugin log, epoch file).
-- On macOS: /tmp/lr-random-pair-generator.
-- On Windows: <user>\AppData\Local\Temp\lr-random-pair-generator.
-- Using LrPathUtils.getStandardFilePath means both platforms
-- land in the OS's conventional temp location, no hardcoded
-- separators.
local function scratchRoot()
    local tmp = LrPathUtils.getStandardFilePath('temp')
    return LrPathUtils.child(tmp, 'lr-random-pair-generator')
end

Platform.scratchRoot = scratchRoot()

-- Shell command that creates scratchRoot() if missing.  Used at
-- points where the LrFileUtils.createAllDirectories Lua API
-- isn't convenient (e.g. inline inside another shell command).
function Platform.mkdirScratchCmd()
    if Platform.isWindows then
        -- `md` errors if the dir already exists; `2>NUL` swallows
        -- that.  `if not exist` would be more correct but shell
        -- escaping gets messy.
        return string.format(
            'md "%s" 2>NUL', Platform.scratchRoot)
    end
    return string.format(
        "mkdir -p '%s'", Platform.scratchRoot)
end

-- Shell command that kills whatever process holds `port`.
-- Port-based is robust across go-run (temp-built child) and
-- compiled-binary distributions — we don't need to know the
-- process name.
function Platform.killByPortCmd(port)
    if Platform.isWindows then
        -- for-loop over netstat output, taskkill each listening
        -- PID.  The doubled %% is required because LrTasks.execute
        -- runs the string through cmd.exe's for-loop grammar.
        -- findstr LISTENING already restricts this to the
        -- server-side socket, mirroring the -sTCP:LISTEN filter
        -- in the macOS branch below.
        return string.format(
            'for /f "tokens=5" %%a in (\'netstat -ano ^| findstr :%d ^| findstr LISTENING\') do @taskkill /F /PID %%a >NUL 2>NUL',
            port)
    end
    -- LISTEN-state only.  The old `lsof -ti :port` form returns
    -- every process with ANY socket on the port, including
    -- ESTABLISHED client connections — with a pair window open,
    -- that list includes the Chrome renderer polling /status, and
    -- kill -9 crashed the user's window to an "Aw, Snap!" tab on
    -- every Stop/restart.  See the "lsof -ti returns client
    -- sockets too" rule in AGENTS.md and
    -- dev/code-review-2026-07-03.md §2.2.
    return string.format(
        "lsof -iTCP:%d -sTCP:LISTEN -t | xargs kill -9 2>/dev/null",
        port)
end

-- Shell command that spawns `binary args...` as a detached
-- background process with stdout/stderr captured to the given
-- paths. Optional `workingDir` sets the child process cwd.
--
-- Signature takes binary + args separately (rather than a
-- pre-formatted command string) because the two platforms
-- have wildly different quoting rules and building a
-- correctly-quoted child command line is much easier from
-- structured input.
--
-- macOS: shell `&` to background; redirects attach to the
-- foreground shell's output of the child, which is what we
-- want.
--
-- Windows: PowerShell's Start-Process with -RedirectStandard*.
-- Using PowerShell (instead of `start /b "" cmd >o 2>e`) is
-- because `start` attaches the outer redirects to itself, not
-- to the detached child — that left stderr empty on Windows
-- and made debugging impossible.  Start-Process reliably
-- redirects the CHILD's output.
function Platform.spawnBackground(binary, args, stdoutPath, stderrPath, workingDir)
    if Platform.isWindows then
        -- Write a .cmd batch wrapper and detach via `start "" /b`.
        -- This was previously implemented with PowerShell
        -- `Start-Process -ArgumentList`, but that was empirically
        -- unreliable for any path containing a space (e.g.
        -- `C:\Users\<user>\Desktop\Shared Files\...\web`): the
        -- arg list got split mid-path despite multiple quoting
        -- strategies, and the helper started with a truncated
        -- `-web` value (and silently dropped flags after it).
        -- cmd.exe handles its own quoting reliably.
        --
        -- Same .cmd pattern is used in
        -- Launcher.writeWindowsHelperLaunchScript for the
        -- source-spawn path.
        local scriptPath = LrPathUtils.child(
            Platform.scratchRoot, 'spawn-background.cmd')
        local LrFileUtils = import 'LrFileUtils'
        LrFileUtils.createAllDirectories(Platform.scratchRoot)
        if LrFileUtils.exists(scriptPath) then
            LrFileUtils.delete(scriptPath)
        end
        local f, err = io.open(scriptPath, 'w')
        if not f then
            error('spawnBackground: cannot write script: ' .. tostring(err))
        end
        f:write('@echo off\r\n')
        if workingDir and workingDir ~= '' then
            f:write('cd /d "' .. workingDir .. '"\r\n')
        end
        f:write('"' .. binary .. '"')
        for _, a in ipairs(args) do
            f:write(' "' .. tostring(a) .. '"')
        end
        f:write(' 1>"' .. stdoutPath .. '"')
        f:write(' 2>"' .. stderrPath .. '"\r\n')
        f:close()
        return string.format('start "" /b "%s"', scriptPath)
    end
    -- POSIX.
    local parts = {}
    if workingDir and workingDir ~= '' then
        parts[#parts + 1] = string.format('cd "%s" &&', workingDir)
    end
    parts[#parts + 1] = string.format('"%s"', binary)
    for _, a in ipairs(args) do
        parts[#parts + 1] = string.format('"%s"', a)
    end
    return string.format(
        '%s >"%s" 2>"%s" &',
        table.concat(parts, ' '), stdoutPath, stderrPath)
end

-- Shell command that opens `path` in the OS's default text
-- viewer.  macOS uses Console.app for .log-looking files
-- specifically (tail-friendly).  Windows uses notepad which
-- doesn't tail but is universally available.
function Platform.openTextFileCmd(path)
    if Platform.isWindows then
        return string.format('start "" notepad "%s"', path)
    end
    -- Try Console.app first (has log-tail niceties); fall
    -- back to the user's default .txt handler.
    return string.format(
        'open -a Console.app "%s" 2>/dev/null || open -t "%s"',
        path, path)
end

-- Shell command that reveals `path` in the OS file manager:
-- Finder on macOS, Explorer on Windows.  Used by the
-- Troubleshooting "Open temp folder" button so testers can
-- get to the log files without typing /tmp/...  paths into
-- a terminal.
function Platform.openFolderCmd(path)
    if Platform.isWindows then
        return string.format('explorer "%s"', path)
    end
    return string.format('open "%s"', path)
end

-- Shell command that brings Lightroom Classic to the OS-
-- level front.  Used after actions triggered from the web
-- view (Reveal in Library, etc.) where the user's expected
-- next moment is "now I'm in Lightroom looking at the
-- result," not "Chrome is still on top."
--
-- macOS: AppleScript via osascript.  Note: the first time
-- this runs after a fresh helper-source compile, macOS may
-- prompt for Automation permission ("Lightroom would like
-- to control Lightroom Classic").  Once granted it
-- persists for that LR install.
--
-- Windows: P/Invoke to Win32 `SetForegroundWindow` via
-- PowerShell.  Earlier attempt used `WScript.Shell.AppActivate`
-- which empirically didn't work — Windows' focus-stealing
-- prevention silently refuses the foreground change from
-- AppActivate's COM-level path; it returns success but the
-- window flashes the taskbar instead of activating.
--
-- The SetForegroundWindow path looks up the LR process by
-- executable name (Lightroom.exe), grabs its
-- MainWindowHandle, and sets it as the foreground window
-- via P/Invoke.  More reliable because it's the same Win32
-- call Adobe's own activation paths use; the focus-
-- stealing-prevention rules are also more permissive when
-- the calling process is itself a child of a foreground
-- application (LR shells out → cmd → powershell, with LR
-- foreground at start).
function Platform.focusLightroomCmd()
    if Platform.isWindows then
        return [[powershell -NoProfile -Command "$p = Get-Process Lightroom -ErrorAction SilentlyContinue | Select-Object -First 1; if ($p) { Add-Type -Name W -Namespace P -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr h);'; [P.W]::SetForegroundWindow($p.MainWindowHandle) }"]]
    end
    return [[osascript -e 'tell application "Adobe Lightroom Classic" to activate']]
end

-- Smart pair-window geometry was attempted in build 77 — see
-- IDEAS.md "Smart pair-window geometry on first launch" for
-- the design — but the macOS detection path required
-- `osascript -e 'tell application "Finder" to ...'` which
-- triggers a TCC Automation permission prompt with alarming
-- phrasing ("Lightroom Classic wants access to control
-- Finder.app").  Bad first impression and not worth the
-- modest UX win.  Detection removed entirely; future revival
-- would use one of the alternatives noted in IDEAS.md
-- (system_profiler / undocumented AgScreen / Windows-only).

-- Shell command that opens `url` in the default browser (NOT
-- app mode — this is the fallback when Chrome/Edge/Brave
-- aren't available).
function Platform.openDefaultBrowserCmd(url)
    if Platform.isWindows then
        -- Empty "" first arg is the window title; start needs it
        -- before a quoted URL or it'll interpret the URL as the
        -- title.
        return string.format('start "" "%s"', url)
    end
    return string.format('open "%s"', url)
end

-- Shell command that curl-probes `url` and writes the HTTP
-- status code to `outPath`.  Redirect to the platform's
-- null device so curl's output doesn't leak.
function Platform.curlStatusProbeCmd(url, outPath)
    local nullDev = Platform.isWindows and 'NUL' or '/dev/null'
    if Platform.isWindows then
        -- cmd.exe expands %VAR% before launching curl. If we pass
        -- curl's normal -w "%{http_code}" form through cmd
        -- unchanged, cmd eats %{http_code} as an environment-
        -- variable lookup and curl receives an empty format
        -- string, so probe.out never contains 200 even when the
        -- helper is healthy. Double the percent sign for cmd so
        -- curl sees a literal %{http_code}.
        return string.format(
            'curl -s -o %s -w "%%%%{http_code}" "%s" > "%s" 2>%s',
            nullDev, url, outPath, nullDev)
    end
    return string.format(
        'curl -s -o %s -w "%%{http_code}" "%s" > "%s" 2>%s',
        nullDev, url, outPath, nullDev)
end

-- Single "is the helper alive?" probe, shared by Launcher and
-- the Dashboard.  There used to be two implementations (a
-- ready-file read in Launcher, this platform branch in the
-- Dashboard) and they disagreed exactly the way the "two
-- probes for the same thing" rule in AGENTS.md predicts: on
-- macOS network installs the Dashboard showed green over HTTP
-- while Launcher's stale file reads stalled browser-open for
-- minutes.  See dev/code-review-2026-07-03.md §2.4.
--
-- The branch keeps the mechanism each platform has proven
-- reliable:
--
--   Windows: read helper-ready.json, written every 1s by the
--   Go helper's startReadyHeartbeat goroutine.  Per-tick
--   LrTasks.execute (which curl would need) has historically
--   been flaky on Windows.
--
--   macOS: curl HTTP probe of /status.  Bypasses the
--   filesystem entirely — on network-drive installs,
--   Go-write / Lua-read cache coherence is unreliable and
--   file reads see stale "missing" while HTTP succeeds.
--
-- `probeOutName` names the scratch file the curl branch
-- writes the HTTP code to.  Callers that can probe
-- concurrently (Launcher's ready-wait loop, the Dashboard's
-- 1s poll) must pass distinct names or they race on the file
-- and manufacture transient false negatives.
--
-- Yields on the macOS branch (LrTasks.execute) — call from a
-- task context only.
function Platform.helperAlive(port, probeOutName)
    if Platform.isWindows then
        local readyPath = LrPathUtils.child(
            Platform.scratchRoot, 'helper-ready.json')
        local readyFile = io.open(readyPath, 'r')
        if not readyFile then return false end
        local body = readyFile:read('*a') or ''
        readyFile: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
    local out = LrPathUtils.child(
        Platform.scratchRoot, probeOutName or 'helper-probe.out')
    LrFileUtils.createAllDirectories(Platform.scratchRoot)
    LrTasks.execute(Platform.curlStatusProbeCmd(
        string.format('http://localhost:%d/status', port), out))
    local outFile = io.open(out, 'r')
    if not outFile then return false end
    local body = outFile:read('*a') or ''
    outFile:close()
    return body:match('^%s*200%s*$') ~= nil
end

-- Chrome / Edge / Brave app-mode browser probes.  Each entry
-- {name, bin}; first `bin` that exists wins.  Per-platform
-- lists because the install locations differ wildly.
--
-- Windows entries include both Program Files variants plus
-- the per-user install locations under %LOCALAPPDATA%.  The
-- Lua formatting fills in the real LOCALAPPDATA at runtime.
--
-- LR's sandboxed Lua does NOT expose `os.getenv` (fails with
-- "attempt to call field 'getenv' (a nil value)"), so we
-- derive LOCALAPPDATA from the SDK-provided home folder.  On
-- every supported Windows install, LOCALAPPDATA is
-- %USERPROFILE%\AppData\Local (OneDrive can redirect
-- Documents but not AppData\Local, so this is stable).
local function windowsLocalAppData()
    local home = LrPathUtils.getStandardFilePath('home')
    return LrPathUtils.child(
        LrPathUtils.child(home, 'AppData'), 'Local')
end

local function windowsUserProfile()
    return LrPathUtils.getStandardFilePath('home')
end

local function expandWindowsLocalAppData(path)
    return (path:gsub('%%LOCALAPPDATA%%', windowsLocalAppData()))
end

function Platform.chromiumBrowsers()
    if Platform.isWindows then
        local paths = {
            { name = 'Google Chrome',
              bin = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
            { name = 'Google Chrome',
              bin = 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe' },
            { name = 'Google Chrome',
              bin = '%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe' },
            { name = 'Microsoft Edge',
              bin = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' },
            { name = 'Microsoft Edge',
              bin = 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe' },
            { name = 'Brave Browser',
              bin = 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' },
            { name = 'Brave Browser',
              bin = 'C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' },
            { name = 'Brave Browser',
              bin = '%LOCALAPPDATA%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' },
        }
        for _, entry in ipairs(paths) do
            entry.bin = expandWindowsLocalAppData(entry.bin)
        end
        return paths
    end
    return {
        -- Most-installed-first ordering.  See IDEAS.md
        -- "Browser-preference selection" for the longer-
        -- term plan to prefer the running browser instead
        -- of the first-installed (avoids cold-start lag
        -- when the user's daily-driver isn't Chrome).
        { name = 'Google Chrome',
          bin = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' },
        { name = 'Microsoft Edge',
          bin = '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' },
        { name = 'Brave Browser',
          bin = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' },
        -- Arc was tried in chromiumBrowsers (build 94) but
        -- doesn't honor --app=URL — opens a blank window and
        -- complains "Only one instance can be open."  Moved
        -- to regularBrowsers in build 95.
        { name = 'Chromium',
          bin = '/Applications/Chromium.app/Contents/MacOS/Chromium' },
    }
end

-- Non-Chromium browsers (Firefox, Safari).  These open the
-- pair window as a regular tab in their normal browser
-- chrome — they don't support `--app=URL` mode, so the
-- minimal-window experience isn't available.  Listed in
-- Settings as a "(regular tab)" option for users who
-- prefer to launch the pair window in their existing
-- non-Chromium browser instead of falling back to system
-- default.
--
-- Each entry has a `bin` path used for existence checking
-- and an `appPath` (macOS) or `bin` (Windows) used by
-- `Platform.openTabCmd` to launch.  Safari is macOS-only.
function Platform.regularBrowsers()
    if Platform.isWindows then
        local entries = {
            { name = 'Firefox',
              bin = 'C:\\Program Files\\Mozilla Firefox\\firefox.exe',
              appPath = 'C:\\Program Files\\Mozilla Firefox\\firefox.exe' },
            { name = 'Firefox',
              bin = 'C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe',
              appPath = 'C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe' },
            -- Arc on Windows: same singleton-with-no-app-mode
            -- behavior as macOS.  Opens as a regular tab in
            -- the running Arc instance.
            { name = 'Arc',
              bin = '%LOCALAPPDATA%\\Programs\\TheBrowserCompany\\Arc\\Arc.exe',
              appPath = '%LOCALAPPDATA%\\Programs\\TheBrowserCompany\\Arc\\Arc.exe' },
        }
        -- Expand %LOCALAPPDATA% in both `bin` and `appPath`
        -- so LrFileUtils.exists resolves correctly.
        -- chromiumBrowsers uses the same pattern.
        for _, e in ipairs(entries) do
            e.bin = expandWindowsLocalAppData(e.bin)
            e.appPath = expandWindowsLocalAppData(e.appPath)
        end
        return entries
    end
    return {
        { name = 'Firefox',
          bin = '/Applications/Firefox.app/Contents/MacOS/firefox',
          appPath = '/Applications/Firefox.app' },
        { name = 'Safari',
          bin = '/Applications/Safari.app/Contents/MacOS/Safari',
          appPath = '/Applications/Safari.app' },
        -- Arc is Chromium-derived but its --app flag is
        -- broken (opens blank window + singleton complaint).
        -- Treat as a regular browser: open -a Arc.app URL
        -- adds a tab to the running Arc instance.
        { name = 'Arc',
          bin = '/Applications/Arc.app/Contents/MacOS/Arc',
          appPath = '/Applications/Arc.app' },
    }
end

-- Shell command that opens `url` as a regular browser tab
-- in the given non-Chromium browser.  No --app mode (those
-- browsers don't support it).  Used for Firefox / Safari
-- picks from the Browser preference.
function Platform.openTabCmd(appPath, url)
    local null = Platform.isWindows and 'NUL' or '/dev/null'
    if Platform.isWindows then
        -- Windows: launch the binary directly with the URL
        -- as an arg.  Firefox accepts the URL as
        -- positional.
        return string.format(
            'start /b "" "%s" "%s" >%s 2>%s',
            appPath, url, null, null)
    end
    -- macOS: `open -a <bundle> URL` opens the URL in that
    -- specific app even if it's not the default browser.
    return string.format(
        'open -a "%s" "%s" >%s 2>&1 &',
        appPath, url, null)
end

-- Returns a list of Chromium-family browser names currently
-- running on the system.  Used by `Launcher.findBrowser` for
-- the "auto" preference: pick a running browser to avoid
-- cold-start lag.  TCC-safe — uses `pgrep` (sysctl-based,
-- no Apple Events) on macOS and `tasklist` on Windows; no
-- permission prompts.
--
-- Returns names matching what `chromiumBrowsers()` returns,
-- so the caller can intersect installed × running.
function Platform.runningBrowsers()
    local LrFileUtils = import 'LrFileUtils'
    local LrPathUtils = import 'LrPathUtils'
    local LrTasks = import 'LrTasks'
    LrFileUtils.createAllDirectories(Platform.scratchRoot)
    local out = LrPathUtils.child(
        Platform.scratchRoot, 'browser-probe.out')

    -- Only Chromium-family --app-supporting browsers here —
    -- runningBrowsers feeds the "auto" preference's
    -- prefer-already-running-Chromium logic.  Arc was
    -- removed in build 95 when --app turned out broken;
    -- Arc's now a regularBrowsers entry, picked explicitly
    -- via Settings if at all.
    local probes
    if Platform.isWindows then
        probes = {
            { name = 'Google Chrome', proc = 'chrome.exe' },
            { name = 'Microsoft Edge', proc = 'msedge.exe' },
            { name = 'Brave Browser', proc = 'brave.exe' },
            { name = 'Chromium',      proc = 'chromium.exe' },
        }
    else
        probes = {
            { name = 'Google Chrome', proc = 'Google Chrome' },
            { name = 'Microsoft Edge', proc = 'Microsoft Edge' },
            { name = 'Brave Browser', proc = 'Brave Browser' },
            { name = 'Chromium',     proc = 'Chromium' },
        }
    end

    local running = {}
    for _, p in ipairs(probes) do
        local cmd
        if Platform.isWindows then
            cmd = 'tasklist /FI "IMAGENAME eq '
                .. p.proc .. '" /NH > "' .. out .. '" 2>NUL'
        else
            cmd = 'pgrep -ax "' .. p.proc
                .. '" > "' .. out .. '" 2>/dev/null'
        end
        LrTasks.execute(cmd)
        local f = io.open(out, 'r')
        if f then
            local body = f:read('*a') or ''
            f:close()
            local hit = false
            if Platform.isWindows then
                -- tasklist with /NH outputs nothing for no
                -- match, "INFO: ..." for filter-no-match,
                -- or a process line containing the .exe
                -- name when matched.
                hit = body:find(p.proc, 1, true) ~= nil
            else
                -- pgrep prints "<pid> <command line>" per
                -- match, empty for no match.  Numeric PID
                -- at start of line is the easiest test.
                hit = body:find('%d') ~= nil
            end
            if hit then running[#running + 1] = p.name end
        end
    end
    return running
end

-- Shell command that opens `url` in the given Chromium-family
-- browser binary with --app= so there's no browser chrome
-- around the plugin window.  Detached background; browser's
-- own stdout/stderr discarded (its logs aren't interesting
-- from our side — the browser's DevTools is the right place
-- to look at them).
function Platform.openAppModeCmd(browserBin, url)
    local null = Platform.isWindows and 'NUL' or '/dev/null'
    if Platform.isWindows then
        return string.format(
            'start /b "" "%s" --app=%s >%s 2>%s',
            browserBin, url, null, null)
    end
    return string.format(
        '"%s" --app=%s >%s 2>&1 &',
        browserBin, url, null)
end

-- Go binary probe paths.  macOS has the go.dev installer
-- location plus Homebrew (Apple Silicon and Intel).  Windows
-- has the MSI installer location plus the Scoop location and
-- WinGet per-user install.
function Platform.goProbePaths()
    if Platform.isWindows then
        local localAppData = windowsLocalAppData()
        local userProfile  = windowsUserProfile()
        return {
            'C:\\Program Files\\Go\\bin\\go.exe',
            localAppData .. '\\Programs\\Go\\bin\\go.exe',
            userProfile .. '\\scoop\\apps\\go\\current\\bin\\go.exe',
            userProfile .. '\\go\\bin\\go.exe',
        }
    end
    return {
        '/usr/local/go/bin/go',     -- go.dev installer default
        '/opt/homebrew/bin/go',     -- Apple Silicon Homebrew
        '/usr/local/bin/go',        -- Intel Homebrew
    }
end

-- Shell command that writes `command -v go` (Unix) or
-- `where go` (Windows) output to `outPath`.  Used as a PATH
-- fallback when the known install-location probes miss.
function Platform.whichGoCmd(outPath)
    if Platform.isWindows then
        return string.format('where go > "%s" 2>NUL', outPath)
    end
    return string.format("command -v go > '%s' 2>/dev/null", outPath)
end

-- Shell command that trims the thumbnail cache in `dir` to the
-- `keepNewest` most-recently-modified .jpg files.  macOS uses
-- `ls -t` + `tail` + `xargs rm`.  Windows uses PowerShell
-- (Get-ChildItem / Sort-Object / Select-Object -Skip /
-- Remove-Item) because cmd.exe lacks a clean way to sort by
-- mtime.  PowerShell is present on every supported Windows 10+.
function Platform.thumbCleanupCmd(dir, keepNewest)
    if Platform.isWindows then
        -- Single long line.  -Skip N leaves the N newest in
        -- place and removes the rest.  -Force skips the
        -- "really delete?" prompt for read-only items.
        return string.format(
            'powershell -NoProfile -Command "Get-ChildItem -Path \\"%s\\" -Filter *.jpg | Sort-Object LastWriteTime -Descending | Select-Object -Skip %d | Remove-Item -Force"',
            dir, keepNewest)
    end
    return string.format(
        "cd '%s' && ls -t *.jpg 2>/dev/null | tail -n +%d | xargs -I {} rm -f '{}'",
        dir, keepNewest + 1)
end

-- Three-state status glyphs for the Dashboard checklist.
-- macOS LR renders color emoji cleanly (🟢🟡🔴 → green, yellow,
-- red dots), which is the best visual scan.  Windows LR's text
-- widgets fall back to a font without color glyphs, so emoji
-- render as empty-grey placeholders — useless as status
-- indicators.
--
-- Windows path: short ASCII labels that any Windows font
-- renders, paired with `Platform.statusColor` below to apply
-- red/yellow/green via LrView text_color binding.  Earlier
-- attempt used `✓ ! ✗` — technically renderable but visually
-- too thin and similar to be scannable as different states.
-- "OK" / "!!" / "X" gives more weight per cell, and colored
-- they read as distinct status indicators from across the
-- dashboard.
function Platform.statusGlyphs()
    if Platform.isWindows then
        return { ok = 'OK', warn = '!!', err = 'X' }
    end
    return { ok = '🟢', warn = '🟡', err = '🔴' }
end

-- Map a glyph string back to a color for `text_color`.  Used
-- via a transform binding so the color reactively updates
-- whenever the row's emoji prop changes.  Returns nil on macOS
-- — color emoji already carry their own color, layering an
-- LR text_color on top would either be redundant or fight
-- the native rendering.  Windows returns a vivid LrColor that
-- reads as a status indicator at small sizes.
function Platform.statusColor(glyph)
    if not Platform.isWindows then return nil end
    local g = Platform.statusGlyphs()
    if glyph == g.ok then
        -- LR-friendly green — slightly darker than pure 0,1,0
        -- so it doesn't look fluorescent against the LR panel
        -- background.
        return LrColor(0.15, 0.65, 0.20)
    elseif glyph == g.warn then
        -- Amber rather than pure yellow; pure yellow is hard
        -- to read against the panel highlight.
        return LrColor(0.90, 0.65, 0.05)
    elseif glyph == g.err then
        return LrColor(0.85, 0.15, 0.15)
    end
    return nil
end

return Platform
