-- EdiTedMusic.lua
-- Browser-based music finder for DaVinci Resolve
-- Finds and places licensed music tracks on the timeline via AI scene analysis
-- Version 0.13
--
-- Usage: Run from Workspace > Scripts > EdiTedMusic
-- (or drag into Workspace > Console on first run to self-install)
-- Opens browser UI with music search functionality

---@diagnostic disable: undefined-global

-- ============================================================
-- 0. Self-installer header
-- ============================================================
-- First run (file outside Scripts/Utility/): copies itself into the
-- correct Scripts/Utility/ folder, backs up any existing copy, then
-- stops so the user can launch via Workspace > Scripts > EdiTedMusic.
-- Subsequent runs (file already in Scripts/Utility/): falls straight
-- through to the main script body below.
--
-- Hardened vs. the Subaba precedent:
--   - canonical path comparison (no substring containment match)
--   - tolerates missing `app`, missing `bmd`, nil `arg[0]`
--   - tolerates Edit page (no Fusion comp) — falls back to console
--   - reads VERSION from existing installed file; backs it up before
--     overwrite; logs old → new transition
--   - binary-mode copy with post-copy size verification
--   - auto-creates Scripts/Utility/ if missing
--   - clear manual-fallback message on permission failure
--   - UNICODE-SAFE on Windows: source, dest, backup and existing-version
--     reads all go through the FFI bootstrap (winFileExists/winReadFileBytes/
--     winCopyFile) so a non-ANSI profile path like C:\Users\<non-ANSI name>\
--     Downloads\ works - the narrow io.open silently fails on those. io.open
--     is kept only on macOS/Linux (UTF-8 native) and as a degraded fallback
--     if FFI didn't load.
--   - strips wrapping quotes / whitespace from arg[0] & debug source path
--   - friendly "already installed" skip when re-dragging the installed file
--     (exact source==dest), distinct from the in-Utility/ silent skip
--   - rejects empty / truncated source (0 bytes, or missing version marker)
--   - re-verifies Scripts/Utility/ actually exists after the mkdir attempt
--   - verifies the copy by exact byte size AND version-marker presence
--   - advisory notes for long (>260) / UNC / OneDrive-synced source paths

local SELF_INSTALL_VERSION = "0.29"  -- kept in sync with VERSION below

-- ============================================================
-- SHARED FFI BOOTSTRAP (Windows zero-flash launch + Unicode-safe file I/O)
-- ============================================================
-- Defined BEFORE the Section 0 installer so the installer can use these too.
-- ALL Windows ffi.cdef declarations live here ONCE — nothing below re-declares
-- them (a second ffi.cdef of the same struct throws "attempt to redefine").
-- On macOS/Linux every win* helper stays nil; callers branch on isWindows.
--
-- Exposed (Windows only; nil elsewhere):
--   winCreateProcessHidden(cmdLine, waitMs) -> exitCode|0|nil
--       Hidden CreateProcessA (CREATE_NO_WINDOW). waitMs nil = fire-and-forget
--       (returns 0); 0xFFFFFFFF = wait INFINITE and return exit code.
--   winShellExecHidden(op, file, params, dir, show) -> HANDLE  (SW_HIDE default)
--   winFileExists(path) -> bool                 (GetFileAttributesW)
--   winReadFileBytes(path) -> string|nil        (CreateFileW + ReadFile loop)
--   winCopyFile(src, dst) -> bool               (CopyFileW, overwrites)
-- Path args are byte strings: UTF-8 is tried first, ANSI (CP_ACP) as fallback,
-- so non-ANSI paths like C:\Users\<hebrew>\Downloads\EdiTedMusic.lua work.
local winCreateProcessHidden = nil
local winShellExecHidden     = nil
local winFileExists          = nil
local winReadFileBytes       = nil
local winCopyFile            = nil
local winCreateDir           = nil
local _ffiBootstrapError     = nil

do
    local isWin = (package.config:sub(1, 1) == "\\")
    if isWin then
        local ok, err = pcall(function()
            local ffi = require("ffi")
            pcall(function()
                ffi.cdef[[
                    typedef void* HANDLE;
                    typedef unsigned long DWORD;
                    typedef int BOOL;
                    typedef unsigned int UINT;
                    typedef unsigned short WORD;
                    typedef unsigned char BYTE;
                    typedef wchar_t WCHAR;

                    void* ShellExecuteA(void*, const char*, const char*, const char*, const char*, int);

                    typedef struct {
                        DWORD  cb;
                        char*  lpReserved;
                        char*  lpDesktop;
                        char*  lpTitle;
                        DWORD  dwX;
                        DWORD  dwY;
                        DWORD  dwXSize;
                        DWORD  dwYSize;
                        DWORD  dwXCountChars;
                        DWORD  dwYCountChars;
                        DWORD  dwFillAttribute;
                        DWORD  dwFlags;
                        WORD   wShowWindow;
                        WORD   cbReserved2;
                        BYTE*  lpReserved2;
                        HANDLE hStdInput;
                        HANDLE hStdOutput;
                        HANDLE hStdError;
                    } STARTUPINFOA;

                    typedef struct {
                        HANDLE hProcess;
                        HANDLE hThread;
                        DWORD  dwProcessId;
                        DWORD  dwThreadId;
                    } PROCESS_INFORMATION;

                    BOOL  CreateProcessA(const char*, char*, void*, void*, BOOL, DWORD, void*, const char*, STARTUPINFOA*, PROCESS_INFORMATION*);
                    DWORD WaitForSingleObject(HANDLE, DWORD);
                    BOOL  GetExitCodeProcess(HANDLE, DWORD*);
                    BOOL  CloseHandle(HANDLE);

                    int    MultiByteToWideChar(UINT, DWORD, const char*, int, WCHAR*, int);
                    DWORD  GetFileAttributesW(const WCHAR*);
                    BOOL   CopyFileW(const WCHAR*, const WCHAR*, BOOL);
                    HANDLE CreateFileW(const WCHAR*, DWORD, DWORD, void*, DWORD, DWORD, HANDLE);
                    BOOL   ReadFile(HANDLE, void*, DWORD, DWORD*, void*);
                    BOOL   GetFileSizeEx(HANDLE, int64_t*);
                    BOOL   CreateDirectoryW(const WCHAR*, void*);
                ]]
            end)

            local kernel32 = ffi.load("kernel32")
            local shell32  = ffi.load("shell32")

            local CREATE_NO_WINDOW       = 0x08000000
            local STARTF_USESHOWWINDOW   = 0x00000001
            local SW_HIDE                = 0
            local INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
            local INVALID_HANDLE_VALUE   = ffi.cast("HANDLE", -1)
            local GENERIC_READ           = 0x80000000
            local FILE_SHARE_READ        = 0x00000001
            local OPEN_EXISTING          = 3
            local FILE_ATTRIBUTE_NORMAL  = 0x00000080
            local CP_UTF8                = 65001
            local CP_ACP                 = 0

            -- Convert a byte path to a UTF-16 (wchar_t) buffer. Tries CP_UTF8
            -- first, then CP_ACP. MB_ERR_INVALID_CHARS (0x8) is set ONLY on the
            -- CP_UTF8 attempt so non-UTF-8 bytes (e.g. an ANSI/CP-1255 path that
            -- Resolve might hand us) make MultiByteToWideChar return 0 instead of
            -- silently substituting U+FFFD -- which would otherwise mangle the
            -- path and skip the ACP fallback. ASCII / valid-UTF-8 paths are
            -- unaffected (ASCII is valid UTF-8).
            local MB_ERR_INVALID_CHARS = 0x00000008
            local function toWide(path)
                if not path then return nil end
                local codepages = { CP_UTF8, CP_ACP }
                for _, cp in ipairs(codepages) do
                    local flags = (cp == CP_UTF8) and MB_ERR_INVALID_CHARS or 0
                    local n = kernel32.MultiByteToWideChar(cp, flags, path, -1, nil, 0)
                    if n > 0 then
                        local buf = ffi.new("WCHAR[?]", n)
                        if kernel32.MultiByteToWideChar(cp, flags, path, -1, buf, n) > 0 then
                            return buf
                        end
                    end
                end
                return nil
            end

            winCreateProcessHidden = function(cmdLine, waitMs)
                local si = ffi.new("STARTUPINFOA")
                si.cb = ffi.sizeof("STARTUPINFOA")
                si.dwFlags = STARTF_USESHOWWINDOW
                si.wShowWindow = SW_HIDE
                local pi = ffi.new("PROCESS_INFORMATION")
                local cmdBuf = ffi.new("char[?]", #cmdLine + 1)
                ffi.copy(cmdBuf, cmdLine)
                local started = kernel32.CreateProcessA(nil, cmdBuf, nil, nil, 0, CREATE_NO_WINDOW, nil, nil, si, pi)
                if started == 0 then return nil end
                local exit = 0
                if waitMs then
                    kernel32.WaitForSingleObject(pi.hProcess, waitMs)
                    local ec = ffi.new("DWORD[1]")
                    if kernel32.GetExitCodeProcess(pi.hProcess, ec) ~= 0 then
                        exit = tonumber(ec[0])
                    end
                end
                kernel32.CloseHandle(pi.hProcess)
                kernel32.CloseHandle(pi.hThread)
                return exit
            end

            winShellExecHidden = function(op, file, params, dir, show)
                return shell32.ShellExecuteA(nil, op, file, params, dir, show or SW_HIDE)
            end

            winFileExists = function(path)
                local w = toWide(path)
                if not w then return false end
                local attr = kernel32.GetFileAttributesW(w)
                return tonumber(attr) ~= INVALID_FILE_ATTRIBUTES
            end

            winCopyFile = function(src, dst)
                local ws, wd = toWide(src), toWide(dst)
                if not ws or not wd then return false end
                return kernel32.CopyFileW(ws, wd, 0) ~= 0  -- bFailIfExists=FALSE => overwrite
            end

            -- Unicode-safe directory creation (CreateDirectoryW). Creates any
            -- missing ancestor segments so a first-time install under a non-ANSI
            -- profile (e.g. C:\Users\<non-ANSI>\...\Scripts\Utility\) succeeds
            -- where a narrow `cmd /c mkdir` would fail. No console window is ever
            -- spawned. Returns true if the directory exists afterward.
            winCreateDir = function(path)
                local w = toWide(path)
                if not w then return false end
                kernel32.CreateDirectoryW(w, nil)  -- fast path; may already exist
                if winFileExists(path) then return true end
                -- Walk ancestors for the rare case a parent is also missing.
                local norm = tostring(path):gsub("/", "\\"):gsub("\\+$", "")
                local accum = nil
                for seg in (norm .. "\\"):gmatch("([^\\]*)\\") do
                    if accum == nil then
                        accum = seg                 -- drive ("C:") or "" (UNC lead)
                    elseif seg ~= "" then
                        accum = accum .. "\\" .. seg
                        if not accum:match("^%a:$") then
                            local wseg = toWide(accum)
                            if wseg then kernel32.CreateDirectoryW(wseg, nil) end
                        end
                    end
                end
                return winFileExists(path)
            end

            winReadFileBytes = function(path)
                local w = toWide(path)
                if not w then return nil end
                local h = kernel32.CreateFileW(w, GENERIC_READ, FILE_SHARE_READ, nil, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nil)
                if h == INVALID_HANDLE_VALUE then return nil end
                local szBuf = ffi.new("int64_t[1]")
                if kernel32.GetFileSizeEx(h, szBuf) == 0 then kernel32.CloseHandle(h); return nil end
                local total = tonumber(szBuf[0])
                if total < 0 then kernel32.CloseHandle(h); return nil end
                if total == 0 then kernel32.CloseHandle(h); return "" end
                local buf = ffi.new("char[?]", total)
                local got = ffi.new("DWORD[1]")
                local off = 0
                -- ReadFile may return fewer bytes per call; loop until EOF.
                while off < total do
                    local want = total - off
                    local rok = kernel32.ReadFile(h, buf + off, want, got, nil)
                    local n = tonumber(got[0])
                    if rok == 0 then kernel32.CloseHandle(h); return nil end
                    if n == 0 then break end  -- EOF
                    off = off + n
                end
                kernel32.CloseHandle(h)
                return ffi.string(buf, off)
            end
        end)
        if not ok then
            _ffiBootstrapError = tostring(err)
        end
        if winCreateProcessHidden and winShellExecHidden then
            print("[EdiTedMusic] FFI shell32+kernel32 (hidden exec + Unicode file I/O) loaded — zero-flash launch enabled")
        else
            print("[EdiTedMusic] WARNING: shared FFI bootstrap NOT loaded (" ..
                  (_ffiBootstrapError or "no error captured") ..
                  ") — Windows shell calls may flash a console window")
        end
    end
end

local _installer = (function()
    local installer = {}
    installer.skipped = false

    local sep_ = package.config:sub(1, 1)
    local isWindows_ = (sep_ == "\\")

    -- Source path: where this file is currently running from.
    -- Resolve's Console doesn't always populate arg[0] on drag-and-drop, so
    -- fall back to debug.getinfo, which Lua sets whenever a file is loaded
    -- via dofile/loadfile regardless of how the host dispatches it.
    --
    -- Whatever the host hands us may arrive wrapped in quotes (a dragged
    -- "C:\path\EdiTedMusic.lua") and/or padded with whitespace or a trailing
    -- newline. An unstripped quote makes every subsequent open fail with a
    -- misleading "cannot access" error, so trim both before any io/win* call.
    local function trimPath(p)
        if not p then return nil end
        p = tostring(p)
        p = p:gsub("^%s+", ""):gsub("%s+$", "")     -- surrounding whitespace / newlines
        p = p:gsub('^"(.*)"$', "%1")                 -- one layer of wrapping double quotes
        p = p:gsub("^'(.*)'$", "%1")                 -- ... or single quotes
        p = p:gsub("^%s+", ""):gsub("%s+$", "")      -- whitespace that was inside the quotes
        return p
    end

    local sourcePath = nil
    if arg and arg[0] then sourcePath = trimPath(arg[0]) end
    if not sourcePath or sourcePath == "" then
        local info = debug.getinfo(1, "S")
        local src = info and info.source or ""
        if src:sub(1, 1) == "@" then sourcePath = trimPath(src:sub(2)) end
    end
    if not sourcePath or sourcePath == "" then
        print("[EdiTedMusic Installer] Cannot determine current file location.")
        print("[EdiTedMusic Installer] Skipping installer; assuming caller knows what it's doing.")
        installer.skipped = true
        return installer
    end

    -- Resolve Scripts: directory via app:MapPath, with OS fallback
    local scriptsRoot = nil
    do
        local ok, result = pcall(function()
            if app and app.MapPath then return app:MapPath("Scripts:") end
            return nil
        end)
        if ok and result and result ~= "" then
            scriptsRoot = result
        else
            if isWindows_ then
                local appdata = os.getenv("APPDATA")
                if appdata then
                    scriptsRoot = appdata
                        .. "\\Blackmagic Design\\DaVinci Resolve\\Support\\Fusion\\Scripts\\"
                end
            else
                local home = os.getenv("HOME")
                if home then
                    if home:find("/Users/") == 1 then
                        scriptsRoot = home
                            .. "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/"
                    else
                        scriptsRoot = home
                            .. "/.local/share/DaVinciResolve/Fusion/Scripts/"
                    end
                end
            end
        end
    end
    if not scriptsRoot then
        print("[EdiTedMusic Installer] Could not resolve Scripts: path. Aborting installer.")
        installer.skipped = true
        return installer
    end
    if scriptsRoot:sub(-1) ~= sep_ and scriptsRoot:sub(-1) ~= "/" and scriptsRoot:sub(-1) ~= "\\" then
        scriptsRoot = scriptsRoot .. sep_
    end

    local utilityDir = scriptsRoot .. "Utility" .. sep_
    local installPath = utilityDir .. "EdiTedMusic.lua"

    -- Canonical-path comparison: lowercase on Windows, unified separators,
    -- trailing-slash stripped. Substring containment (Subaba's approach) is
    -- fragile when arg[0] uses '/' and scriptsRoot uses '\\' or vice versa.
    local function canon(p)
        p = tostring(p or "")
        p = p:gsub("\\", "/")
        p = p:gsub("/+$", "")
        if isWindows_ then p = p:lower() end
        return p
    end

    local canonSource = canon(sourcePath)
    local canonInstall = canon(installPath)
    local canonUtility = canon(utilityDir)

    -- Comp is OPTIONAL - used only for AskUser modals. Edit-page users
    -- without a Fusion comp still get a clear console banner. Declared BEFORE
    -- the already-installed check so that path can use notify() too.
    local fu_ = fu or (Fusion and Fusion())
    local comp_ = comp or (fu_ and fu_.CurrentComp)
    local function notify(title, msg)
        local line = "[EdiTedMusic Installer] " .. title .. (msg and (": " .. msg) or "")
        print(line)
        if comp_ and comp_.AskUser then
            pcall(function()
                local win = {
                    {"Msg", "Text", Name = "Message:", ReadOnly = true,
                     Lines = 6, Wrap = true, Default = msg or title}
                }
                comp_:AskUser(title, win)
            end)
        end
    end

    -- Unicode-safe file primitives. On Windows the narrow CRT fopen (io.open)
    -- CANNOT open a path containing non-ANSI characters - e.g. a Hebrew/CJK
    -- username (think C:\Users\<non-ANSI name>\Downloads\EdiTedMusic.lua) - and
    -- silently returns nil, which is the real-world bug this installer was
    -- failing on. So on Windows we route every source/dest/backup op through
    -- the shared FFI bootstrap (winFileExists/winReadFileBytes/winCopyFile, all
    -- UTF-8 + ANSI aware). On macOS/Linux io.open is already UTF-8 native, so we
    -- keep it. If the FFI bootstrap failed to load (winFileExists is nil), we
    -- fall back to io.open even on Windows - degraded (ASCII-only) not broken.
    local haveWinIO = isWindows_ and winFileExists and winReadFileBytes and winCopyFile
    local function fileExists(p)
        if haveWinIO then return winFileExists(p) end
        local f = io.open(p, "rb")
        if f then f:close(); return true end
        return false
    end
    -- Read whole file as bytes; returns string or nil. Unicode-safe on Windows.
    local function readBytes(p)
        if haveWinIO then return winReadFileBytes(p) end
        local f = io.open(p, "rb")
        if not f then return nil end
        local data = f:read("*a")
        f:close()
        return data or ""
    end
    -- Copy src -> dst (overwrites). Unicode-safe on Windows via CopyFileW.
    -- Note: CopyFileW does NOT create missing parent dirs - Utility/ must
    -- already exist (we ensure that just below before calling this).
    local function copyBytes(srcP, dstP)
        if haveWinIO then return winCopyFile(srcP, dstP) end
        local data = readBytes(srcP)
        if not data then return false end
        local f = io.open(dstP, "wb")
        if not f then return false end
        f:write(data)
        f:flush()
        f:close()
        return true
    end

    -- "Already installed" = running from inside Utility/ AND filename matches.
    -- We accept any filename in Utility/ because Resolve sometimes hands us
    -- a path-with-symlinks; the directory match is the load-bearing check.
    local function startsWith(s, prefix)
        return s:sub(1, #prefix) == prefix
    end
    -- Exact source==dest (user re-drags the file that's ALREADY installed):
    -- skip cleanly with a friendly message rather than the silent return the
    -- broader Utility/ prefix check gives, so a re-drag isn't a confusing no-op.
    if canonSource == canonInstall then
        notify("Already installed",
            "This file is already installed at:\n" .. installPath ..
            "\n\nJust run it from Workspace > Scripts > EdiTedMusic." ..
            "\n(If it isn't listed, re-open the Workspace menu or restart Resolve.)")
        installer.skipped = true
        return installer
    end
    if startsWith(canonSource, canonUtility) then
        installer.skipped = true
        return installer
    end

    print("[EdiTedMusic Installer] Source:      " .. tostring(sourcePath))
    print("[EdiTedMusic Installer] Destination: " .. tostring(installPath))

    -- Advisory warnings for awkward (but not fatal) source locations. We still
    -- attempt the install - winCopyFile handles all of these - but a heads-up
    -- helps the user understand a slow copy or a later failure.
    do
        local sp = sourcePath
        if isWindows_ and #sp > 259 then
            print("[EdiTedMusic Installer] NOTE: source path exceeds 260 chars; if the copy" ..
                  " fails, move EdiTedMusic.lua to a shorter path (e.g. C:\\Temp) and re-run.")
        end
        if isWindows_ and (sp:sub(1, 2) == "\\\\" or sp:sub(1, 2) == "//") then
            print("[EdiTedMusic Installer] NOTE: source is on a UNC/network share; copying" ..
                  " from a local Downloads folder is more reliable.")
        end
        if sp:lower():find("onedrive") then
            print("[EdiTedMusic Installer] NOTE: source is inside a OneDrive-synced folder;" ..
                  " if it's online-only it may need to download first (the copy still works).")
        end
    end

    -- Verify source readable (Unicode-safe). bmd.fileexists, when present, is a
    -- useful extra signal but is itself narrow on some builds, so fileExists()
    -- (win* on Windows) is the authority.
    if not fileExists(sourcePath) then
        notify("ERROR", "Cannot access source file:\n" .. sourcePath ..
            "\n\nMake sure the file still exists and isn't open in another program.")
        installer.skipped = true
        return installer
    end

    -- Ensure Utility/ exists. winCopyFile won't create it, so this must succeed
    -- before the copy below. The actual mkdir spawn is owned by the spawn-
    -- silencing pass (it converts os.execute -> hidden launcher); we keep the
    -- structure and re-verify existence afterward with a clear error if it
    -- couldn't be created (almost always a permissions problem).
    if not fileExists(utilityDir) and not fileExists(utilityDir .. ".") then
        local created = false
        if bmd and bmd.createdir then
            local ok = pcall(function() bmd.createdir(utilityDir) end)
            created = ok
        end
        if not created then
            if isWindows_ then
                -- Best: Unicode-safe AND zero-flash. winCreateDir uses
                -- CreateDirectoryW (from the shared FFI bootstrap above this
                -- closure), which creates a non-ANSI path like
                -- C:\Users\<non-ANSI>\...\Utility\ with no console window. A
                -- narrow `cmd /c mkdir` would both flash a console and fail on
                -- such paths. Fallbacks (only if FFI didn't load): a HIDDEN cmd
                -- mkdir (no flash but narrow-path), then a visible os.execute.
                -- The fileExists re-check just below confirms success regardless.
                local winDir = utilityDir:gsub("/", "\\")
                if winCreateDir then
                    winCreateDir(utilityDir)
                elseif winCreateProcessHidden then
                    winCreateProcessHidden('cmd.exe /c mkdir "' .. winDir .. '" >nul 2>&1', 5000)
                else
                    print("[EdiTedMusic Installer] WARNING: FFI launcher unavailable - mkdir via visible os.execute (a console may flash)")
                    os.execute('mkdir "' .. winDir .. '" >nul 2>&1')
                end
            else
                os.execute("mkdir -p '" .. utilityDir:gsub("'", "'\\''") .. "' >/dev/null 2>&1")
            end
        end
        if not fileExists(utilityDir) and not fileExists(utilityDir .. ".") then
            local hint = isWindows_
                and ("Create this folder manually, then copy EdiTedMusic.lua into it:\n  " ..
                     utilityDir:gsub("/", "\\"))
                or  ("Create this folder manually, then copy EdiTedMusic.lua into it:\n  " ..
                     utilityDir)
            notify("Cannot create Scripts/Utility folder",
                "Failed to create:\n" .. utilityDir .. "\n\n" .. hint)
            installer.skipped = true
            return installer
        end
    end

    -- Read existing installed VERSION (if any) for backup-and-log. installPath
    -- lives under the SAME user profile as the source, so it is just as likely
    -- to be non-ANSI (C:\Users\<non-ANSI name>\...) - read it Unicode-safe too.
    local existingVersion = nil
    if fileExists(installPath) then
        local head = readBytes(installPath)
        if head then
            head = head:sub(1, 8192)
            existingVersion = head:match('SELF_INSTALL_VERSION%s*=%s*"([^"]+)"')
                or head:match('local%s+VERSION%s*=%s*"([^"]+)"')
        end
    end

    -- Read source bytes (Unicode-safe; binary - preserves any CRLFs).
    local payload = readBytes(sourcePath)
    if not payload then
        notify("ERROR", "Cannot open source for read:\n" .. sourcePath ..
            "\n\nThe file may be locked by another program, or on an unreadable drive.")
        installer.skipped = true
        return installer
    end
    if #payload == 0 then
        notify("ERROR", "Source file is empty (0 bytes):\n" .. sourcePath ..
            "\n\nThe download may be incomplete - re-download EdiTedMusic.lua and try again.")
        installer.skipped = true
        return installer
    end
    -- Cheap sanity check: a real EdiTedMusic.lua always carries its version
    -- marker. A partially-written / truncated download often won't, so we'd
    -- rather refuse than install a corrupt copy.
    if not payload:find('SELF_INSTALL_VERSION', 1, true) then
        notify("ERROR", "Source doesn't look like a complete EdiTedMusic.lua" ..
            " (missing version marker):\n" .. sourcePath ..
            "\n\nThe download may be truncated - re-download and try again.")
        installer.skipped = true
        return installer
    end

    -- Backup any existing install before overwrite. backupPath is also under
    -- the (possibly non-ANSI) user profile, so use the Unicode-safe copy.
    if existingVersion then
        local stamp = os.date("%Y%m%d_%H%M%S")
        local backupPath = installPath
            .. ".bak_v" .. existingVersion:gsub("[^%w%.%-]", "_")
            .. "_" .. stamp
        if copyBytes(installPath, backupPath) then
            print("[EdiTedMusic Installer] Backed up existing v" .. existingVersion ..
                  " -> " .. backupPath)
        else
            print("[EdiTedMusic Installer] WARNING: could not write backup at " .. backupPath ..
                  " (proceeding anyway)")
        end
    end

    -- Write destination via an atomic, Unicode-safe OS copy (CopyFileW on
    -- Windows). This is one call that reads source + writes dest, so it can't
    -- leave a half-written file the way a separate read/write pair could, and
    -- it works regardless of non-ANSI characters on EITHER end.
    local copied = copyBytes(sourcePath, installPath)
    if not copied then
        local manualHint = isWindows_
            and ("Open File Explorer at:\n  " .. utilityDir:gsub("/", "\\") ..
                 "\nand copy EdiTedMusic.lua into it manually.")
            or  ("Open Finder at:\n  " .. utilityDir ..
                 "\nand copy EdiTedMusic.lua into it manually.")
        notify("Write permission denied",
            "Cannot write to:\n" .. installPath ..
            "\n\nThe existing file may be read-only or locked by Resolve.\n\n" .. manualHint)
        installer.skipped = true
        return installer
    end

    -- Verify by exact byte size + the version marker, reading the destination
    -- back Unicode-safe. A size match alone could pass on a same-length but
    -- corrupt write; requiring the marker too is a cheap content sanity check.
    local got = readBytes(installPath)
    local verifyOK = got ~= nil
        and (#got == #payload)
        and got:find('SELF_INSTALL_VERSION', 1, true) ~= nil
    if not verifyOK then
        notify("ERROR", "Copy verification failed (size or content mismatch)." ..
            " Try again or copy manually to:\n" .. installPath)
        installer.skipped = true
        return installer
    end

    local transitionMsg
    if existingVersion and existingVersion ~= SELF_INSTALL_VERSION then
        transitionMsg = "Updated v" .. existingVersion .. " -> v" .. SELF_INSTALL_VERSION
    elseif existingVersion then
        transitionMsg = "Reinstalled v" .. SELF_INSTALL_VERSION .. " (same version)"
    else
        transitionMsg = "Installed v" .. SELF_INSTALL_VERSION
    end

    print("")
    print("  ███████╗██████╗ ██╗████████╗███████╗██████╗ ")
    print("  ██╔════╝██╔══██╗██║╚══██╔══╝██╔════╝██╔══██╗")
    print("  █████╗  ██║  ██║██║   ██║   █████╗  ██║  ██║")
    print("  ██╔══╝  ██║  ██║██║   ██║   ██╔══╝  ██║  ██║")
    print("  ███████╗██████╔╝██║   ██║   ███████╗██████╔╝")
    print("  ╚══════╝╚═════╝ ╚═╝   ╚═╝   ╚══════╝╚═════╝ ")
    print("                  EdiTedMusic " .. SELF_INSTALL_VERSION)
    print("")
    print("  " .. transitionMsg)
    print("  Location: " .. installPath)
    print("")
    print("  Open: Workspace -> Scripts -> EdiTedMusic")
    print("  (Re-open the Workspace menu if EdiTedMusic isn't listed yet —")
    print("   a full Resolve restart is rarely required.)")
    print("")

    notify("EdiTedMusic " .. SELF_INSTALL_VERSION .. " installed",
        transitionMsg ..
        "\n\nLocation:\n" .. installPath ..
        "\n\nOpen: Workspace > Scripts > EdiTedMusic" ..
        "\n\nIf EdiTedMusic isn't listed yet, re-open the Workspace menu" ..
        " or restart Resolve.")

    installer.installed = true
    return installer
end)()

if _installer and _installer.installed then
    _installer = nil
    collectgarbage("collect")
    return  -- stop here so the user opens via Workspace > Scripts > EdiTedMusic
end
_installer = nil

-- ============================================================
-- 1. Config & Constants
-- ============================================================
local VERSION = "0.29"
local PORT = 48201
local CLOUD_SERVER = "https://editedmusic.onrender.com"

-- Signal file paths (written to temp directory)
local LIVE_FILE = "edited_music_live.json"
local COMMAND_FILE = "edited_music_command.json"
local RESULT_FILE = "edited_music_result.json"
local READY_FILE = "edited_music_ready"
local STOP_FILE = "edited_music_stop"
local HTML_FILE = "edited_music.html"
local LAUNCHER_FILE = "edited_music_launch.html"
local PORT_FILE = "edited_music_server_port"
-- ============================================================
-- 2. Platform Detection
-- ============================================================
local sep = package.config:sub(1, 1)
local isWindows = (sep == "\\")
local isMac = (sep == "/") and (os.getenv("HOME") and os.getenv("HOME"):find("/Users/") == 1)
local isLinux = (sep == "/") and not isMac

local SERVER_SCRIPT = isWindows and "edited_music_server.ps1" or "edited_music_server.pl"

-- Set temp directory based on platform
local tempDir
if isWindows then
    tempDir = os.getenv("TEMP") or os.getenv("TMP") or "."
elseif isMac then
    tempDir = os.getenv("TMPDIR") or "/tmp"
else
    tempDir = "/tmp"
end

-- Build full paths
local htmlPath = tempDir .. sep .. HTML_FILE
local liveDataPath = tempDir .. sep .. LIVE_FILE
local commandFilePath = tempDir .. sep .. COMMAND_FILE
local resultFilePath = tempDir .. sep .. RESULT_FILE
local stopFilePath = tempDir .. sep .. STOP_FILE
local readyFilePath = tempDir .. sep .. READY_FILE
local launcherPath = tempDir .. sep .. LAUNCHER_FILE
local serverScriptPath = tempDir .. sep .. SERVER_SCRIPT
local portFilePath = tempDir .. sep .. PORT_FILE

-- ============================================================
-- Audio cache directory (for downloaded ES tracks)
-- ============================================================
-- DaVinci imports audio by reference — if the source file disappears the
-- clip turns red (offline) in the Media Pool. Windows aggressively cleans
-- %TEMP% (Storage Sense, Disk Cleanup), so we prefer durable locations.
-- Resolution order:
--   1. <project render targetDir>/EdiTedMusic_audio   (next to project output)
--   2. ~/Documents/EdiTedMusic/cache                  (Storage-Sense-safe)
--   3. <TEMP>                                         (last resort + warning)
-- Falls back automatically if a higher-priority dir is unwritable.
local _audioCacheDir = nil
local _audioCacheKind = nil
local _audioCacheWarned = false

local function _probeWritable(dir)
    if not dir or dir == "" then return false end
    local probe = dir .. sep .. ".editedmusic_probe"
    local f = io.open(probe, "wb")
    if f then f:close(); os.remove(probe); return true end
    return false
end

local function _ensureDir(dir)
    if _probeWritable(dir) then return true end
    if isWindows then
        -- Hidden so no console flashes; cmd.exe /c handles the >nul 2>&1
        -- redirection, wait so the dir exists before we re-probe.
        -- NOTE: must reference winCreateProcessHidden (in scope since the FFI
        -- bootstrap at the top of the file) — the createProcessHidden alias is
        -- declared as a local AFTER this function, so naming it here compiles
        -- as a nil global lookup and every mkdir fell into the visible branch.
        local winDir = dir:gsub("/", "\\")
        if winCreateProcessHidden then
            winCreateProcessHidden('cmd.exe /c mkdir "' .. winDir .. '" >nul 2>&1', 5000)
        else
            print("[EdiTedMusic] WARNING: FFI launcher unavailable — mkdir via visible os.execute (a console may flash)")
            os.execute('mkdir "' .. winDir .. '" >nul 2>&1')
        end
    else
        os.execute("mkdir -p '" .. dir:gsub("'", "'\\''") .. "' >/dev/null 2>&1")
    end
    return _probeWritable(dir)
end

-- Resolve's scripting API expects UTF-8 paths, but Lua's io/os layer on
-- Windows speaks the ANSI codepage. A pure-ASCII path is byte-identical in
-- both encodings; anything else (Hebrew/Cyrillic/CJK user profiles, e.g.
-- C:\Users\אליה\Documents\...) round-trips fine through io.open yet reaches
-- AddItemListToMediaPool/ImportMedia as mojibake — both return 0 items and
-- the import fails. So on Windows, reject any cache candidate containing
-- non-ASCII bytes and land on the fixed-ASCII ProgramData dir instead (the
-- same root the transcode stage already uses for exactly this reason).
local function _isImportSafePath(p)
    if not isWindows then return true end
    return not p:find("[\128-\255]")
end

function resolveAudioCacheDir()
    if _audioCacheDir then return _audioCacheDir, _audioCacheKind end

    -- 1. Project render target dir
    local okP, projDir = pcall(function()
        if not project then return nil end
        local td = project:GetSetting("targetDir")
        if td and td ~= "" then return td end
        return nil
    end)
    if okP and projDir and projDir ~= "" then
        local d = projDir .. sep .. "EdiTedMusic_audio"
        if not _isImportSafePath(d) then
            print("[EdiTedMusic] Audio cache: project folder path has non-ASCII characters (" .. d .. ") — Resolve import would fail, trying Documents")
        elseif _ensureDir(d) then
            _audioCacheDir, _audioCacheKind = d, "project"
            print("[EdiTedMusic] Audio cache: project folder -> " .. d)
            return d, "project"
        else
            print("[EdiTedMusic] Audio cache: project folder unwritable (" .. d .. "), trying Documents")
        end
    end

    -- 2. ~/Documents/EdiTedMusic/cache
    local home = isWindows and os.getenv("USERPROFILE") or os.getenv("HOME")
    if home and home ~= "" then
        local d = home .. sep .. "Documents" .. sep .. "EdiTedMusic" .. sep .. "cache"
        if not _isImportSafePath(d) then
            print("[EdiTedMusic] Audio cache: Documents path has non-ASCII characters (" .. d .. ") — Resolve import would fail, using shared folder")
        elseif _ensureDir(d) then
            _audioCacheDir, _audioCacheKind = d, "documents"
            print("[EdiTedMusic] Audio cache: documents folder -> " .. d)
            return d, "documents"
        else
            print("[EdiTedMusic] Audio cache: Documents folder unwritable (" .. d .. "), falling back to TEMP")
        end
    end

    -- 2.5 (Windows): C:\ProgramData\editedmusic\cache — fixed ASCII path that
    -- every encoding agrees on, durable (not subject to TEMP cleanup). This is
    -- where non-ASCII user profiles (Hebrew/Cyrillic/CJK) land.
    if isWindows then
        local d = "C:\\ProgramData\\editedmusic\\cache"
        if _ensureDir(d) then
            _audioCacheDir, _audioCacheKind = d, "shared"
            print("[EdiTedMusic] Audio cache: shared folder -> " .. d)
            return d, "shared"
        else
            print("[EdiTedMusic] Audio cache: shared folder unwritable (" .. d .. "), falling back to TEMP")
        end
    end

    -- 3. TEMP (warn loudly — files may vanish, clips will go offline)
    _audioCacheDir, _audioCacheKind = tempDir, "temp"
    if not _audioCacheWarned then
        _audioCacheWarned = true
        print("==========================================================")
        print("[EdiTedMusic] WARNING: Audio cache falling back to TEMP:")
        print("[EdiTedMusic]   " .. tempDir)
        print("[EdiTedMusic] Windows may delete these files automatically")
        print("[EdiTedMusic] (Storage Sense, Disk Cleanup, reboot), causing")
        print("[EdiTedMusic] imported songs to appear OFFLINE (red icons) in")
        print("[EdiTedMusic] the DaVinci Resolve Media Pool.")
        print("[EdiTedMusic] Restore write access to either:")
        print("[EdiTedMusic]   - your project's render target folder, or")
        print("[EdiTedMusic]   - " .. ((isWindows and os.getenv("USERPROFILE")) or os.getenv("HOME") or "$HOME") .. sep .. "Documents" .. sep .. "EdiTedMusic")
        print("==========================================================")
    end
    return tempDir, "temp"
end

-- ============================================================
-- 3. FFI Win32 launchers (Windows zero-flash) — aliases to shared bootstrap
-- ============================================================
-- The real implementation now lives in the SHARED FFI BOOTSTRAP block near the
-- top of this file (above the Section 0 installer) so the installer can use it
-- too. There is ONE ffi.cdef in the whole file — re-declaring the structs here
-- would throw "attempt to redefine". These are backward-compatible aliases for
-- the existing call sites that reference createProcessHidden / shellExecA.
--   shellExecA          == winShellExecHidden  (async open via ShellExecuteA, SW_HIDE)
--   createProcessHidden == winCreateProcessHidden (hidden CreateProcessA; waitMs
--                          nil = fire-and-forget, 0xFFFFFFFF = wait forever)
local shellExecA          = winShellExecHidden
local createProcessHidden = winCreateProcessHidden
local ffiLoadError        = _ffiBootstrapError

-- ============================================================
-- 4. Resolve Connection
-- ============================================================
function getResolve()
    if resolve then return resolve end
    if Resolve then
        local r = Resolve()
        if r then return r end
    end
    if app then
        return app:GetResolve()
    end
    return nil
end

-- Global references (set in main)
local resolve
local project
local timeline
local mediaPool

-- ============================================================
-- 5. JSON Encoder
-- ============================================================
function escapeString(str)
    -- Backslash MUST be escaped first, otherwise the \\ we insert below gets re-escaped.
    return str
        :gsub('\\', '\\\\')
        :gsub('"', '\\"')
        :gsub('\n', '\\n')
        :gsub('\r', '\\r')
        :gsub('\t', '\\t')
end

function encodeValue(val)
    if type(val) == "string" then
        return '"' .. escapeString(val) .. '"'
    elseif type(val) == "number" then
        return tostring(val)
    elseif type(val) == "boolean" then
        return tostring(val)
    elseif type(val) == "table" then
        return encodeTable(val)
    else
        return 'null'
    end
end

function encodeTable(tbl)
    -- Detect array vs object by inspecting keys, not by `#tbl`. The previous
    -- check (`#tbl > 0`) was wrong for sparse or string-keyed tables and
    -- silently produced bad JSON: e.g. {[2]="a"} → "[]" instead of {"2":"a"},
    -- breaking the browser's state parse and freezing the UI.
    local count = 0
    local maxIdx = 0
    local allIntKeys = true
    for k, _ in pairs(tbl) do
        count = count + 1
        if type(k) == "number" and k == math.floor(k) and k >= 1 then
            if k > maxIdx then maxIdx = k end
        else
            allIntKeys = false
        end
    end
    local isArray = (count > 0) and allIntKeys and (maxIdx == count)

    local result = {}
    if isArray then
        for i = 1, count do
            table.insert(result, encodeValue(tbl[i]))
        end
        return "[" .. table.concat(result, ",") .. "]"
    else
        for k, v in pairs(tbl) do
            table.insert(result, '"' .. tostring(k) .. '":' .. encodeValue(v))
        end
        return "{" .. table.concat(result, ",") .. "}"
    end
end

function generateJSON(data)
    return encodeTable(data)
end

-- ============================================================
-- 6. JSON Decoder
-- ============================================================
local JSON = {}
function JSON.decode(str)
    local pos = 1
    local function skip_ws()
        pos = str:find("[^ \t\r\n]", pos) or (#str + 1)
    end
    local function peek() skip_ws(); return str:sub(pos, pos) end
    local function next_char() skip_ws(); local c = str:sub(pos, pos); pos = pos + 1; return c end

    local parse_value -- forward declaration

    local function parse_string()
        pos = pos + 1 -- skip opening quote
        local start = pos
        local parts = {}
        while pos <= #str do
            local c = str:sub(pos, pos)
            if c == '"' then pos = pos + 1; return table.concat(parts) .. str:sub(start, pos - 2) end
            if c == '\\' then
                parts[#parts + 1] = str:sub(start, pos - 1)
                pos = pos + 1
                local esc = str:sub(pos, pos)
                if esc == 'n' then parts[#parts + 1] = '\n'
                elseif esc == 't' then parts[#parts + 1] = '\t'
                elseif esc == '"' then parts[#parts + 1] = '"'
                elseif esc == '\\' then parts[#parts + 1] = '\\'
                elseif esc == '/' then parts[#parts + 1] = '/'
                else parts[#parts + 1] = esc end
                pos = pos + 1; start = pos
            else pos = pos + 1 end
        end
        error("Unterminated string")
    end

    local function parse_number()
        local start = pos
        while pos <= #str and str:sub(pos, pos):find("[%d%.eE%+%-]") do pos = pos + 1 end
        return tonumber(str:sub(start, pos - 1))
    end

    local function parse_array()
        pos = pos + 1 -- skip [
        local arr = {}
        if peek() == ']' then next_char(); return arr end
        while true do
            arr[#arr + 1] = parse_value()
            local c = next_char()
            if c == ']' then return arr end
            if c ~= ',' then error("Expected ',' in array at " .. pos) end
        end
    end

    local function parse_object()
        pos = pos + 1 -- skip {
        local obj = {}
        if peek() == '}' then next_char(); return obj end
        while true do
            skip_ws()
            local key = parse_string()
            skip_ws()
            if next_char() ~= ':' then error("Expected ':' at " .. pos) end
            obj[key] = parse_value()
            local c = next_char()
            if c == '}' then return obj end
            if c ~= ',' then error("Expected ',' in object at " .. pos) end
        end
    end

    parse_value = function()
        skip_ws()
        local c = str:sub(pos, pos)
        if c == '"' then return parse_string()
        elseif c == '{' then return parse_object()
        elseif c == '[' then return parse_array()
        elseif c == 't' then pos = pos + 4; return true
        elseif c == 'f' then pos = pos + 5; return false
        elseif c == 'n' then pos = pos + 4; return nil
        elseif c:find("[%d%-]") then return parse_number()
        else error("Unexpected char '" .. c .. "' at " .. pos) end
    end

    return parse_value()
end

-- ============================================================
-- 7. File I/O + busyWait
-- ============================================================
function readAll(path)
    local f = io.open(path, "rb")
    if not f then return nil end
    local v = f:read("*a")
    f:close()
    return v
end

function writeAll(path, content)
    local f = io.open(path, "wb")
    if not f then return false end
    f:write(content or "")
    f:close()
    return true
end

function fileExists(path)
    local f = io.open(path, "rb")
    if f then f:close(); return true end
    return false
end

-- True only if the file exists AND has nonzero size. Used to guard test-WAV
-- caching: a previous interrupted ffmpeg run can leave a 0-byte stub that
-- otherwise looks "present" and breaks downstream MediaPool import.
function fileHasContent(path)
    local f = io.open(path, "rb")
    if not f then return false end
    local size = f:seek("end") or 0
    f:close()
    return size > 0
end

function copyFile(src, dst)
    local content = readAll(src)
    if not content then return false end
    return writeAll(dst, content)
end

function deleteFile(path)
    local ok = os.remove(path)
    return ok ~= nil
end

function busyWait(seconds)
    local t0 = os.clock()
    while os.clock() - t0 < seconds do end
end

local function shQuote(s)
    s = tostring(s or "")
    return "'" .. s:gsub("'", "'\\''") .. "'"
end

-- ============================================================
-- 8. Base64
-- ============================================================
local b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
local b64idx = {}
for i = 0, 63 do b64idx[i] = b64chars:sub(i + 1, i + 1) end
-- byte-indexed reverse map: char-byte → 6-bit value. Used by base64Decode.
local b64decmap = {}
for i = 1, #b64chars do b64decmap[b64chars:byte(i)] = i - 1 end

-- 3-bytes-in / 4-chars-out using bulk string.byte + table.concat.
-- The previous per-byte gsub + 2^i math + O(n^2) concat made a 25 MB encode
-- take many minutes; this runs in seconds.
function base64Encode(data)
    local out = {}
    local outIdx = 0
    local len = #data
    local n3 = len - (len % 3)
    local floor = math.floor
    local byte = string.byte
    local idx = b64idx
    local i = 1
    while i <= n3 do
        local b1, b2, b3 = byte(data, i, i + 2)
        outIdx = outIdx + 1
        out[outIdx] = idx[floor(b1 / 4)]
            .. idx[(b1 % 4) * 16 + floor(b2 / 16)]
            .. idx[(b2 % 16) * 4 + floor(b3 / 64)]
            .. idx[b3 % 64]
        i = i + 3
    end
    local rem = len - n3
    if rem == 1 then
        local b1 = byte(data, i)
        outIdx = outIdx + 1
        out[outIdx] = idx[floor(b1 / 4)] .. idx[(b1 % 4) * 16] .. '=='
    elseif rem == 2 then
        local b1, b2 = byte(data, i, i + 1)
        outIdx = outIdx + 1
        out[outIdx] = idx[floor(b1 / 4)]
            .. idx[(b1 % 4) * 16 + floor(b2 / 16)]
            .. idx[(b2 % 16) * 4]
            .. '='
    end
    return table.concat(out)
end

-- 4-chars-in / 3-bytes-out using bulk string.byte + table.concat, mirroring
-- base64Encode. The previous gsub+binary-string version did O(n) string
-- concatenations per char and rebuilt the lookup table per call, making a
-- ~500 KB decode take 60+ seconds; this version handles several MB in well
-- under a second.
function base64Decode(str)
    str = str:gsub('[^A-Za-z0-9+/=]', '')
    local len = #str
    -- Count trailing '=' padding so we don't try to decode it as data.
    local pad = 0
    if len > 0 and str:byte(len) == 61 then pad = pad + 1 end
    if len > 1 and str:byte(len - 1) == 61 then pad = pad + 1 end
    local usable = len - pad
    local n4 = usable - (usable % 4)

    local out = {}
    local outIdx = 0
    local floor = math.floor
    local byte = string.byte
    local char = string.char
    local map = b64decmap
    local i = 1
    while i <= n4 do
        local c1, c2, c3, c4 = byte(str, i, i + 3)
        local a, b, c, d = map[c1], map[c2], map[c3], map[c4]
        outIdx = outIdx + 1
        out[outIdx] = char(
            a * 4 + floor(b / 16),
            (b % 16) * 16 + floor(c / 4),
            (c % 4) * 64 + d
        )
        i = i + 4
    end
    -- Tail: 2 or 3 remaining base64 chars (after stripping padding).
    local rem = usable - n4
    if rem == 2 then
        local a = map[byte(str, i)]
        local b = map[byte(str, i + 1)]
        outIdx = outIdx + 1
        out[outIdx] = char(a * 4 + floor(b / 16))
    elseif rem == 3 then
        local a = map[byte(str, i)]
        local b = map[byte(str, i + 1)]
        local c = map[byte(str, i + 2)]
        outIdx = outIdx + 1
        out[outIdx] = char(
            a * 4 + floor(b / 16),
            (b % 16) * 16 + floor(c / 4)
        )
    end
    return table.concat(out)
end

-- ============================================================
-- 9. Time Conversions
-- ============================================================
function secToFrames(seconds, fps)
    return math.floor(seconds * fps)
end

function framesToSeconds(frames, fps, startFrame)
    return (frames - (startFrame or 0)) / fps
end

function framesToTimecode(totalFrames, fps)
    local f = totalFrames % fps
    local totalSec = math.floor(totalFrames / fps)
    local s = totalSec % 60
    local totalMin = math.floor(totalSec / 60)
    local m = totalMin % 60
    local h = math.floor(totalMin / 60)
    return string.format("%02d:%02d:%02d:%02d", h, m, s, f)
end

function timecodeToFrames(tc, fps)
    local h, m, s, f = tc:match("(%d+):(%d+):(%d+):(%d+)")
    if not h then return 0 end
    return tonumber(h)*3600*fps + tonumber(m)*60*fps + tonumber(s)*fps + tonumber(f)
end

function timecodeToSeconds(tc, fps)
    return timecodeToFrames(tc, fps) / fps
end

-- ============================================================
-- 10. HTTP Bridge Communication (Lua ↔ local server)
-- ============================================================
-- The bridge uses HTTP polling instead of signal files.  The server exposes:
--   GET  /lua-poll   → next pending command JSON, or HTTP 204 / empty body
--   POST /lua-result → Lua pushes result JSON (include id field)
--   POST /lua-live   → Lua pushes live state JSON
-- Browser-facing endpoints (/command, /result?id=, /live) are unchanged.
--
-- Scratch dir: C:\ProgramData\editedmusic\ — fixed ASCII path, never %TEMP%
-- (that 8.3/non-ASCII path is the exact bug v0.15 fixed in the transcode path).
-- On macOS the scratch dir is irrelevant; io.popen is safe (no console flash).

-- ASCII scratch directory for curl I/O on Windows.
local HTTP_SCRATCH_DIR = "C:\\ProgramData\\editedmusic"

-- Ensure the scratch dir exists once (hidden mkdir, no window).
local _httpScratchReady = false
local function _ensureHttpScratch()
    if _httpScratchReady then return end
    if isWindows then
        -- Use cmd /c mkdir; ignore error if already exists.  Hidden via
        -- createProcessHidden (CREATE_NO_WINDOW) so no console flash.
        -- C:\ProgramData\editedmusic has no spaces so no extra quoting needed.
        local mkCmd = 'cmd.exe /c mkdir ' .. HTTP_SCRATCH_DIR .. ' >nul 2>&1'
        if createProcessHidden then
            createProcessHidden(mkCmd, 0xFFFFFFFF)
        else
            os.execute(mkCmd)
        end
    end
    _httpScratchReady = true
end

-- Per-purpose scratch tag derived from the request path.  CRITICAL: each call
-- purpose (/lua-poll, /lua-live, /lua-result) MUST use its OWN scratch files.
-- With a single shared http_req/http_resp pair, the rapid writeLive -> readCommand
-- -> writeResult loop made one call's request/response file collide with the
-- next: writeLive would read an empty/contended response (false "failed" log) and
-- its late-landing {"ok":true} would be picked up by the following readCommand
-- (the "Invalid command JSON content={"ok":true}" spam).  Isolating the files by
-- path removes the contention entirely.  "/lua-poll" -> "lua_poll", etc.
local function _scratchTag(path)
    local t = tostring(path or ""):gsub("[^%w]", "_"):gsub("^_+", ""):sub(1, 32)
    if t == "" then t = "req" end
    return t
end

-- httpRequest(method, path, body) -> responseStringOrNil, curlExitCodeOrNil
--
-- Windows: writes body to a per-path ASCII scratch file, invokes curl.exe via
-- createProcessHidden (CREATE_NO_WINDOW — no console flash), reads the response
-- from a per-path scratch file.  Returns the body (or nil) AND curl's exit code
-- so fire-and-forget callers (writeLive) can tell "reached the server, empty/204
-- body" (exit 0) apart from a real curl/connection error (exit != 0).
--
-- macOS: uses io.popen (safe — no console flash on mac).
--
-- Always uses 127.0.0.1 (not "localhost") to avoid DNS/IPv6 surprises.
-- pcall-wrapped: never throws; on failure returns nil and the caller degrades
-- gracefully (poll loop keeps running).
function httpRequest(method, path, bodyOrNil)
    local ok, result, exitCode = pcall(function()
        local url = "http://127.0.0.1:" .. PORT .. path
        local tag = _scratchTag(path)

        if isWindows then
            _ensureHttpScratch()
            local reqFile  = HTTP_SCRATCH_DIR .. "\\http_" .. tag .. "_" .. PORT .. "_req.tmp"
            local respFile = HTTP_SCRATCH_DIR .. "\\http_" .. tag .. "_" .. PORT .. "_resp.tmp"

            -- Write body to scratch file if provided.
            if bodyOrNil and bodyOrNil ~= "" then
                writeAll(reqFile, bodyOrNil)
            else
                -- Remove any stale request file so curl doesn't accidentally
                -- send it when there's no body.
                os.remove(reqFile)
            end

            -- Build curl command.  -s = silent, -m 10 = 10s timeout.
            local cmd
            if bodyOrNil and bodyOrNil ~= "" then
                cmd = 'curl.exe -s -m 10 -X ' .. method
                    .. ' "' .. url .. '"'
                    .. ' -H "Content-Type: application/json"'
                    .. ' --data-binary "@' .. reqFile .. '"'
                    .. ' -o "' .. respFile .. '"'
            else
                cmd = 'curl.exe -s -m 10 -X ' .. method
                    .. ' "' .. url .. '"'
                    .. ' -o "' .. respFile .. '"'
            end

            -- Run hidden (CREATE_NO_WINDOW).  waitMs=15000 — synchronous.
            if not createProcessHidden then
                return nil, nil  -- FFI unavailable; degrade gracefully
            end
            -- Clear the (per-path) response file first so "no/failed response"
            -- is reliably nil even when curl writes nothing (e.g. HTTP 204).
            os.remove(respFile)
            local ec = createProcessHidden(cmd, 15000)

            local resp = readAll(respFile)
            -- AV / real-time scanners on some machines briefly hold the freshly
            -- written file open, so the immediate read can come back empty even
            -- though curl exited 0 and the bytes are on disk. Re-read once or
            -- twice with a short pause before giving up.
            if (not resp or resp == "") and ec == 0 then
                busyWait(0.05)
                resp = readAll(respFile)
                if not resp or resp == "" then
                    busyWait(0.1)
                    resp = readAll(respFile)
                end
            end
            if not resp or resp == "" then return nil, ec end
            return resp, ec
        else
            -- macOS: io.popen is fine (no console flash on mac).  Per-path req
            -- file so concurrent purposes never share a scratch file.
            local reqFile = "/tmp/editedmusic_http_" .. tag .. "_" .. tostring(PORT) .. ".tmp"
            local cmd
            if bodyOrNil and bodyOrNil ~= "" then
                writeAll(reqFile, bodyOrNil)
                cmd = 'curl -s -m 10 -X ' .. method
                    .. " '" .. url .. "'"
                    .. " -H 'Content-Type: application/json'"
                    .. " --data-binary '@" .. reqFile .. "'"
            else
                cmd = 'curl -s -m 10 -X ' .. method
                    .. " '" .. url .. "'"
            end
            local h = io.popen(cmd, "r")
            if not h then return nil, nil end
            local resp = h:read("*a")
            h:close()
            if not resp or resp == "" then return nil, 0 end
            return resp, 0
        end
    end)
    if ok then return result, exitCode else return nil, nil end
end

-- Write live state for browser to poll (HTTP POST to /lua-live).
-- Fire-and-forget: does not block the poll loop on failure.
function writeLive(stateTable)
    local json = generateJSON(stateTable)
    local resp, exitCode = httpRequest("POST", "/lua-live", json)
    -- Fire-and-forget: the live push reaching the server is what matters, not
    -- reading its {"ok":true} body.  Only warn when curl actually errored
    -- (exit != 0 — e.g. server down / connection refused).  exit 0 with an
    -- empty body means it was delivered; stay silent to avoid log spam.
    if not resp and exitCode ~= 0 then
        print("[EdiTedMusic] writeLive: POST /lua-live failed (curl exit "
            .. tostring(exitCode) .. " — server not ready or connection error)")
    end
end

-- Logged-once flag for "in/out detected" transitions, so the 200ms poll
-- loop doesn't flood the console.
local lastLoggedHasInOut = nil

-- Build a complete liveState table from the current project/timeline.
-- Shared by the initial write (so pre-existing in/out marks render on the
-- very first /live response) and the polling loop.
function buildLiveState(curProject, curTimeline)
    local state = {
        status = "ready",
        version = VERSION,
        project = curProject and curProject:GetName() or "No project",
        timeline = curTimeline and curTimeline:GetName() or "No timeline"
    }

    if not curTimeline then
        state.hasInOut = false
        state.hasIn = false
        state.hasOut = false
        state.inSeconds = 0
        state.outSeconds = 0
        state.startSeconds = 0
        state.startFrame = 0
        state.inFrame = 0
        state.outFrame = 0
        state.fps = 0
        state.width = 0
        state.height = 0
        state.duration = 0
        state.audioTrackCount = 0
        return state
    end

    local fps = tonumber(curTimeline:GetSetting("timelineFrameRate")) or 24
    local width = tonumber(curTimeline:GetSetting("timelineResolutionWidth")) or 0
    local height = tonumber(curTimeline:GetSetting("timelineResolutionHeight")) or 0
    local startFrame = curTimeline:GetStartFrame() or 0
    local endFrame = curTimeline:GetEndFrame() or 0
    local durationSec = (endFrame - startFrame) / fps

    local hasIn, hasOut = false, false
    local inSeconds, outSeconds = 0, 0
    local inFrame, outFrame = 0, 0
    pcall(function()
        local markInOut = curTimeline:GetMarkInOut()
        if markInOut then
            local markIn, markOut = parseMarkInOut(markInOut)
            -- Resolve omits the "in" key when the in point sits at the very
            -- first frame of the timeline (frame 0). If we got a valid out
            -- but no in, fill it as 0; mirror the workaround for the other
            -- side so a lone in marker still anchors a range to the end.
            if markOut and not markIn then markIn = 0 end
            if markIn and not markOut then markOut = endFrame - startFrame end
            if markIn then hasIn = true; inFrame = markIn; inSeconds = markIn / fps end
            if markOut then hasOut = true; outFrame = markOut; outSeconds = markOut / fps end
        end
    end)
    local hasInOut = hasIn and hasOut

    if hasInOut ~= lastLoggedHasInOut then
        if hasInOut then
            print(string.format("[EdiTedMusic] In/Out detected: in=%.2fs out=%.2fs (fps=%g)",
                inSeconds, outSeconds, fps))
        else
            print("[EdiTedMusic] In/Out cleared")
        end
        lastLoggedHasInOut = hasInOut
    end

    state.hasInOut = hasInOut
    state.hasIn = hasIn
    state.hasOut = hasOut
    state.inSeconds = inSeconds
    state.outSeconds = outSeconds
    state.startSeconds = startFrame / fps
    -- Raw frame counts: needed for accurate timecode display on NTSC framerates
    -- (23.976/29.97/59.94), where startFrame/fps gives wallclock seconds rather
    -- than timecode-seconds, drifting ~3.6s per hour of timeline-start offset.
    state.startFrame = startFrame
    state.inFrame = inFrame
    state.outFrame = outFrame
    local currentTimecode = "00:00:00:00"
    pcall(function()
        currentTimecode = curTimeline:GetCurrentTimecode() or "00:00:00:00"
    end)
    local currentFrame = timecodeToFrames(currentTimecode, fps)
    state.currentTimecode = currentTimecode
    state.currentSeconds = math.max(0, (currentFrame - startFrame) / fps)
    state.fps = fps
    state.width = width
    state.height = height
    state.duration = durationSec
    state.audioTrackCount = curTimeline:GetTrackCount("audio") or 0
    return state
end

-- Sanitize id for use in filename (alphanumeric + _ - only)
local function sanitizeId(id)
    return tostring(id or ""):gsub("[^%w_%-]", "_"):sub(1, 64)
end

-- Read next pending command from server (returns nil when none).
-- Uses GET /lua-poll.  Server returns the command JSON or HTTP 204 / empty
-- body / empty JSON object when the queue is empty.
-- Same return contract as before: a command table or nil.
function readCommand()
    local content = httpRequest("GET", "/lua-poll", nil)
    if not content or content == "" then return nil end

    -- HTTP 204 arrives as empty body; also treat bare "{}" as "no command".
    local trimmed = content:match("^%s*(.-)%s*$")
    if trimmed == "" or trimmed == "{}" then return nil end

    local ok, cmd = pcall(JSON.decode, content)
    if ok and cmd and cmd.cmd then
        print(string.format("[EdiTedMusic] readCommand: id=%s cmd=%s len=%d",
            tostring(cmd.id or "?"), tostring(cmd.cmd or "?"), #content))
        return cmd
    elseif ok and cmd and next(cmd) == nil then
        -- Empty table from "{}" — no command
        return nil
    else
        print("[EdiTedMusic] ERROR: Invalid command JSON: " .. tostring(cmd)
            .. " content=" .. tostring(content):sub(1, 200))
        -- The server already DEQUEUED this command, so it can't be re-fetched.
        -- Recover its id from the raw bytes and post an error result, else the
        -- browser polls a non-existent result for the full timeout (a 2nd, AV-
        -- race hang path that retain-on-read can't help — there's no result).
        local rawId = content:match('"id"%s*:%s*"([^"]+)"')
                   or content:match('"id"%s*:%s*(%d+)')
        if rawId then
            writeResult(tostring(rawId), {error = "Command parse failed (garbled response) — please retry"})
            print("[EdiTedMusic] Posted error result for garbled command id=" .. tostring(rawId))
        end
        return nil
    end
end

-- Write result for browser to fetch (POST /lua-result).
-- Retries up to 3 times on failure; logs outcome.
function writeResult(id, resultTable)
    resultTable.id = id
    local json = generateJSON(resultTable)
    local ok = false
    for attempt = 1, 5 do
        local resp = httpRequest("POST", "/lua-result", json)
        -- Require the server's {"ok":true} ack. A non-nil body could be an HTTP
        -- 400 rejection ("Missing id" / "Invalid JSON"), which MUST be retried —
        -- treating it as delivered silently dropped the result -> 5-minute hang.
        if resp and resp:find('"ok"', 1, true) and resp:find('true', 1, true) then
            ok = true
            break
        end
        if resp then
            print(string.format("[EdiTedMusic] writeResult attempt %d rejected by server: %s",
                attempt, tostring(resp):sub(1, 120)))
        end
        if attempt < 5 then busyWait(0.1) end
    end
    if not ok then
        print(string.format("[EdiTedMusic] writeResult FAILED after 5 attempts: id=%s", tostring(id)))
    else
        print(string.format("[EdiTedMusic] writeResult: id=%s len=%d ok=true", tostring(id), #json))
    end
    return ok
end

-- Wipe stale per-id result files (called from cleanup paths only)
function deletePerIdFiles()
    if isWindows then
        local wipeCmd = 'cmd.exe /c del /Q "' .. tempDir .. '\\edited_music_result_*.json" 2>nul'
        if createProcessHidden then
            createProcessHidden(wipeCmd, nil)  -- fire-and-forget, no window
        else
            print("[EdiTedMusic] WARNING: FFI launcher unavailable — result-file cleanup via visible os.execute (a console may flash)")
            os.execute('del /Q "' .. tempDir .. '\\edited_music_result_*.json" 2>nul')
        end
    else
        os.execute('rm -f "' .. tempDir .. '"/edited_music_result_*.json 2>/dev/null')
    end
end

-- ============================================================
-- 11. Resolve API Functions -- MUSIC SPECIFIC
-- ============================================================

-- ------------------------------------------------------------
-- 11a. WAV -> MP3 transcode (inline PowerShell / Perl script)
-- ------------------------------------------------------------
-- Resolve renders "Audio Only" as 24-bit PCM WAV (~17 MB/min) — too big
-- for the upload. We shrink it ~60x by transcoding to MP3 before upload.
--
-- Output is 32 kbps mono 16 kHz — speech-only spec. Server unconditionally
-- re-encodes to 16 kbps mono 16 kHz for Gemini (es_pipeline.py:_compress_
-- audio_for_gemini and audio_slicer.py:downsample_for_gemini), so anything
-- richer is thrown away. Whisper (multi-song path) resamples to 16 kHz mono
-- internally and stays accurate down to ~24 kbps; 32 kbps keeps a safety
-- margin without bloating the upload.
--
-- All the logic lives in a single inline script written to TEMP on first
-- call: it tries `where`/`which` ffmpeg first, falls back to a cached
-- copy in TEMP, and downloads ffmpeg into TEMP if neither exists.
--
-- Any failure (no network, AV block, ffmpeg crash) returns nil and the
-- caller falls back to uploading the WAV unchanged.

-- Windows: PowerShell script that finds (or downloads to %TEMP%\editedmusic_ffmpeg)
-- ffmpeg and transcodes WAV -> MP3 32k/16k mono. Single self-contained file.
local _WIN_TRANSCODE_PS1 = [=[
param(
    [Parameter(Mandatory=$true)][string]$Wav,
    [Parameter(Mandatory=$true)][string]$Mp3
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# P/Invoke wrapper for kernel32 GetLongPathNameW — the only reliable way to
# expand an 8.3 short-name segment (e.g. C:\Users\8A6B~1\...) to its true
# Unicode long form. Works even when PowerShell's own provider resolver
# treats the ~ as a wildcard/home-dir token and throws.
Add-Type -Namespace Win32 -Name LP -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("kernel32.dll",
    CharSet=System.Runtime.InteropServices.CharSet.Unicode,
    SetLastError=true)]
public static extern uint GetLongPathNameW(string s,
    System.Text.StringBuilder b, uint c);
'@
function Resolve-LongPath([string]$p) {
    if (-not $p) { return $p }
    if ($p -notmatch '~') { return $p }          # fast-path: no 8.3 alias present
    $sb = New-Object System.Text.StringBuilder 4096
    $n  = [Win32.LP]::GetLongPathNameW($p, $sb, [uint32]$sb.Capacity)
    if ($n -gt 0 -and $n -lt $sb.Capacity) { return $sb.ToString() }
    return $p   # expansion failed (path doesn't exist or 8dot3 disabled) — leave as-is
}

# Expand 8.3 short names in $Wav (it exists, so GetLongPathNameW succeeds).
$Wav = Resolve-LongPath $Wav

# $Mp3 does NOT exist yet — GetLongPathNameW on the full path returns 0.
# Resolve its parent dir (which exists) and rejoin the leaf name.
# Use [IO.Path] methods instead of Split-Path -LiteralPath (PS 6+ only).
$mp3Dir  = Resolve-LongPath ([System.IO.Path]::GetDirectoryName($Mp3))
$Mp3     = [System.IO.Path]::Combine($mp3Dir, [System.IO.Path]::GetFileName($Mp3))

# Expand 8.3 in $env:TEMP before deriving cache paths from it.
$env:TEMP = Resolve-LongPath $env:TEMP

# Secondary safety net: if resolution still left a ~ (dead alias / 8dot3
# disabled on this volume) or Get-Item itself fails, fall back to
# C:\ProgramData which is always ASCII and always exists.
$resolvedTemp = $null
try {
    $resolvedTemp = (Get-Item -LiteralPath $env:TEMP -ErrorAction Stop).FullName
} catch { $resolvedTemp = $null }
if (-not $resolvedTemp -or $resolvedTemp -match '~') {
    $resolvedTemp = 'C:\ProgramData'
}

function Test-Ffmpeg($exe) {
    if (-not $exe) { return $false }
    try {
        $out = & $exe -version 2>&1 | Out-String
        return ($out -match 'ffmpeg version')
    } catch { return $false }
}

function Resolve-Ffmpeg {
    $sys = Get-Command ffmpeg -ErrorAction SilentlyContinue
    if ($sys -and (Test-Ffmpeg $sys.Source)) { return $sys.Source }

    $cacheDir = Join-Path $resolvedTemp 'editedmusic_ffmpeg'
    $cacheBin = Join-Path $cacheDir 'ffmpeg.exe'
    if ((Test-Path -LiteralPath $cacheBin) -and (Test-Ffmpeg $cacheBin)) { return $cacheBin }

    Write-Host '[transcode] Downloading ffmpeg (~80MB one-time) from gyan.dev...'
    if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null }
    $zip  = Join-Path $resolvedTemp 'editedmusic_ffmpeg.zip'
    $xdir = Join-Path $resolvedTemp 'editedmusic_ffmpeg_x'
    if (Test-Path -LiteralPath $zip)  { Remove-Item -LiteralPath $zip  -Force }
    if (Test-Path -LiteralPath $xdir) { Remove-Item -LiteralPath $xdir -Recurse -Force }
    Invoke-WebRequest -Uri 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' -OutFile $zip -UseBasicParsing -TimeoutSec 900
    if ((Get-Item -LiteralPath $zip).Length -lt 1000000) { throw 'download size suspicious' }
    Expand-Archive -LiteralPath $zip -DestinationPath $xdir -Force
    $found = Get-ChildItem -LiteralPath $xdir -Recurse -Filter 'ffmpeg.exe' | Select-Object -First 1
    if (-not $found) { throw 'ffmpeg.exe not found in archive' }
    Copy-Item -LiteralPath $found.FullName -Destination $cacheBin -Force
    Remove-Item -LiteralPath $xdir -Recurse -Force -ErrorAction SilentlyContinue
    Remove-Item -LiteralPath $zip  -Force         -ErrorAction SilentlyContinue
    return $cacheBin
}

try {
    if (-not (Test-Path -LiteralPath $Wav)) { throw "input not found: $Wav" }
    $ff = Resolve-Ffmpeg
    Write-Host ('[transcode] Using ffmpeg: ' + $ff)
    if (Test-Path -LiteralPath $Mp3) { Remove-Item -LiteralPath $Mp3 -Force }
    & $ff -y -hide_banner -loglevel error -i $Wav -c:a libmp3lame -b:a 32k -ar 16000 -ac 1 $Mp3 2>&1 | ForEach-Object { Write-Host $_ }
    if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $Mp3) -or (Get-Item -LiteralPath $Mp3).Length -eq 0) {
        throw ('ffmpeg failed (exit=' + $LASTEXITCODE + ')')
    }
    Write-Host ('OK ' + $Mp3)
    exit 0
} catch {
    Write-Host ('ERROR ' + $_.Exception.Message)
    exit 1
}
]=]

-- macOS: Perl script doing the same. Downloads from evermeet.cx (universal
-- ffmpeg static build) if no system ffmpeg is found.
local _MAC_TRANSCODE_PL = [=[
#!/usr/bin/perl
use strict;
use warnings;

my ($wav, $mp3) = @ARGV;
die "usage: $0 <wav> <mp3>\n" unless $wav and $mp3;
die "input not found: $wav\n" unless -e $wav;

sub sh { my $s = shift; $s =~ s/'/'\\''/g; return "'$s'"; }

sub test_ffmpeg {
    my $exe = shift;
    return 0 unless $exe and -e $exe;
    my $out = `${\ sh($exe)} -version 2>&1`;
    return $out =~ /ffmpeg version/ ? 1 : 0;
}

sub resolve_ffmpeg {
    my $sys = `command -v ffmpeg 2>/dev/null`;
    chomp $sys;
    return $sys if $sys and test_ffmpeg($sys);

    my $tmp = $ENV{TMPDIR} || '/tmp';
    $tmp =~ s{/+$}{};
    my $cache_dir = "$tmp/editedmusic_ffmpeg";
    my $cache_bin = "$cache_dir/ffmpeg";
    return $cache_bin if -e $cache_bin and test_ffmpeg($cache_bin);

    print "[transcode] Downloading ffmpeg (one-time) from evermeet.cx...\n";
    mkdir $cache_dir unless -d $cache_dir;
    my $zip = "$tmp/editedmusic_ffmpeg.zip";
    unlink $zip if -e $zip;
    my $rc = system('curl', '-sSL', '--max-time', '900',
        '-o', $zip, 'https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip');
    die "curl failed (rc=$rc)\n" if $rc != 0;
    die "download empty or tiny\n" unless -s $zip and -s $zip > 1_000_000;
    $rc = system('unzip', '-o', '-d', $cache_dir, $zip);
    die "unzip failed (rc=$rc)\n" if $rc != 0;
    unlink $zip;
    chmod 0755, $cache_bin if -e $cache_bin;
    die "ffmpeg not present after extract\n" unless -e $cache_bin;
    return $cache_bin;
}

my $ff = eval { resolve_ffmpeg() };
if ($@) { print "ERROR $@"; exit 1; }
print "[transcode] Using ffmpeg: $ff\n";
unlink $mp3 if -e $mp3;
my $rc = system($ff, '-y', '-hide_banner', '-loglevel', 'error',
    '-i', $wav, '-c:a', 'libmp3lame', '-b:a', '32k',
    '-ar', '16000', '-ac', '1', $mp3);
if ($rc != 0 or !-e $mp3 or -s $mp3 == 0) {
    print "ERROR ffmpeg failed (rc=$rc)\n";
    exit 1;
}
print "OK $mp3\n";
exit 0;
]=]

-- Transcode WAV -> MP3 192k/48k stereo. Returns the MP3 path on success,
-- nil on any failure (caller falls back to uploading the WAV unchanged).
-- The inline PowerShell/Perl script handles ffmpeg discovery and one-time
-- download into TEMP itself — nothing else to set up on the Lua side.
--
-- Windows defense-in-depth: before invoking the PS1, the WAV is staged into
-- C:\ProgramData\editedmusic_transcode\ under a plain ASCII filename so that
-- the PS1 never sees a non-ASCII or 8.3 short-name path for its $Wav/$Mp3
-- args, even if the original wavPath lives under a non-ASCII username.
local _transcodeCounter = 0
local function _transcodeWavToMp3(wavPath)
    local mp3Path = wavPath:gsub("%.wav$", "") .. ".mp3"
    if mp3Path == wavPath then mp3Path = wavPath .. ".mp3" end
    deleteFile(mp3Path)

    local function dq(s) return '"' .. tostring(s or "") .. '"' end
    local function shq(s) return "'" .. tostring(s or ""):gsub("'", "'\\''") .. "'" end

    local logPath = tempDir .. sep .. "editedmusic_transcode.log"
    deleteFile(logPath)

    -- On Windows, stage WAV/MP3 through a guaranteed-ASCII directory so the
    -- PowerShell script never receives a non-ASCII or 8.3-short path for its
    -- $Wav and $Mp3 arguments. The PS1 also does its own GetLongPathNameW
    -- expansion as a secondary net, but this Lua-side staging is the primary
    -- guarantee (zero reliance on 8dot3 alias existence or P/Invoke success).
    local stagedWav, stagedMp3, stageDir
    if isWindows then
        _transcodeCounter = _transcodeCounter + 1
        stageDir  = "C:\\ProgramData\\editedmusic_transcode"
        stagedWav = stageDir .. "\\" .. "in_" .. _transcodeCounter .. ".wav"
        stagedMp3 = stageDir .. "\\" .. "out_" .. _transcodeCounter .. ".mp3"
        -- Ensure the staging dir exists. This runs on EVERY WAV->MP3 transcode
        -- (i.e. every find-music request), so it MUST be windowless — a bare
        -- os.execute('cmd /c mkdir ...') flashes a console window each time
        -- (the old code's "exits immediately, no window" claim was wrong).
        -- winCreateDir is CreateDirectoryW: no process spawned at all, zero
        -- flash, Unicode-safe. Fall back to a hidden cmd, then visible mkdir.
        if winCreateDir then
            winCreateDir(stageDir)
        elseif createProcessHidden then
            createProcessHidden('cmd.exe /c mkdir "' .. stageDir .. '" 2>nul', 5000)
        else
            print("[EdiTedMusic] WARNING: FFI launcher unavailable — staging mkdir via visible os.execute (a console may flash)")
            os.execute('cmd.exe /c mkdir "' .. stageDir .. '" 2>nul')
        end
        -- Copy the source WAV to the ASCII-named staging path.
        local wavData = readAll(wavPath)
        if not wavData or #wavData == 0 then
            print("[EdiTedMusic] Failed to read source WAV for staging")
            return nil
        end
        if not writeAll(stagedWav, wavData) then
            print("[EdiTedMusic] Failed to write staged WAV")
            return nil
        end
        print("[EdiTedMusic] staged WAV -> " .. stagedWav)
        deleteFile(stagedMp3)
    end

    local cmd
    if isWindows then
        local scriptPath = tempDir .. sep .. "editedmusic_transcode.ps1"
        if not writeAll(scriptPath, _WIN_TRANSCODE_PS1) then
            print("[EdiTedMusic] Failed to write transcode script")
            deleteFile(stagedWav)
            return nil
        end
        -- Use staged ASCII paths for $Wav and $Mp3 arguments.
        -- -WindowStyle Hidden + -NoProfile are defense-in-depth; the true
        -- no-flash guarantee comes from CREATE_NO_WINDOW in createProcessHidden.
        cmd = 'powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' ..
            scriptPath:gsub("/", "\\") .. '" -Wav ' .. dq(stagedWav) ..
            ' -Mp3 ' .. dq(stagedMp3) .. ' > ' .. dq(logPath) .. ' 2>&1'
    else
        local scriptPath = tempDir .. sep .. "editedmusic_transcode.pl"
        if not writeAll(scriptPath, _MAC_TRANSCODE_PL) then
            print("[EdiTedMusic] Failed to write transcode script")
            return nil
        end
        cmd = '/usr/bin/perl ' .. shq(scriptPath) .. ' ' .. shq(wavPath) ..
            ' ' .. shq(mp3Path) .. ' > ' .. shq(logPath) .. ' 2>&1'
    end

    -- Run hidden on Windows so NO console flashes on every WAV->MP3. The shell
    -- redirection (> log 2>&1) requires a shell, so wrap in cmd.exe /c (escape
    -- inner double-quotes as "") and wait INFINITE so the log is fully written
    -- before we read it below. Visible os.execute only if FFI is unavailable.
    if isWindows and createProcessHidden then
        local wrapped = 'cmd.exe /c "' .. cmd:gsub('"', '""') .. '"'
        createProcessHidden(wrapped, 0xFFFFFFFF)
    else
        if isWindows then
            print("[EdiTedMusic] WARNING: FFI launcher unavailable — transcode via visible os.execute (a console may flash)")
        end
        os.execute(cmd)
    end

    local log = readAll(logPath) or ""
    local trimmed = log:gsub("^%s+", ""):gsub("%s+$", "")

    -- On Windows the output MP3 is the staged path; clean up the staged WAV
    -- now (the MP3 is cleaned up by the caller after upload).
    local resultPath = isWindows and stagedMp3 or mp3Path
    if isWindows then deleteFile(stagedWav) end

    if fileHasContent(resultPath) and log:find("OK ", 1, true) then
        -- Print the first non-empty line for context (e.g. which ffmpeg was used).
        for line in trimmed:gmatch("[^\r\n]+") do
            print("[EdiTedMusic] " .. line)
        end
        return resultPath
    end

    if trimmed ~= "" then
        print("[EdiTedMusic] transcode failed: " .. trimmed:sub(1, 800))
    end
    deleteFile(resultPath)
    return nil
end

-- Robust in/out parser (handles different Resolve versions)
function parseMarkInOut(markData)
    if not markData then return nil, nil end
    local markIn, markOut = nil, nil

    -- Try flat keys first (In/Out)
    if markData["In"] then markIn = tonumber(markData["In"]) end
    if markData["Out"] then markOut = tonumber(markData["Out"]) end

    -- Try nested video
    if not markIn and markData["video"] and markData["video"]["in"] then
        markIn = tonumber(markData["video"]["in"])
    end
    if not markOut and markData["video"] and markData["video"]["out"] then
        markOut = tonumber(markData["video"]["out"])
    end

    -- Try nested audio
    if not markIn and markData["audio"] and markData["audio"]["in"] then
        markIn = tonumber(markData["audio"]["in"])
    end
    if not markOut and markData["audio"] and markData["audio"]["out"] then
        markOut = tonumber(markData["audio"]["out"])
    end

    -- Try lowercase
    if not markIn and markData["in"] then markIn = tonumber(markData["in"]) end
    if not markOut and markData["out"] then markOut = tonumber(markData["out"]) end

    return markIn, markOut
end

-- Get timeline info
function getTimelineInfo()
    print("[EdiTedMusic] getTimelineInfo called")

    if not timeline then
        return {error = "No timeline active"}
    end

    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local width = tonumber(timeline:GetSetting("timelineResolutionWidth")) or 0
    local height = tonumber(timeline:GetSetting("timelineResolutionHeight")) or 0
    local startFrame = timeline:GetStartFrame() or 0
    local endFrame = timeline:GetEndFrame() or 0
    local durationFrames = endFrame - startFrame
    local durationSec = durationFrames / fps

    local videoTrackCount = timeline:GetTrackCount("video") or 0
    local audioTrackCount = timeline:GetTrackCount("audio") or 0

    -- Get in/out points
    local hasInOut = false
    local inSeconds = 0
    local outSeconds = 0

    local ok, markInOut = pcall(function() return timeline:GetMarkInOut() end)
    if ok and markInOut then
        local markIn, markOut = parseMarkInOut(markInOut)
        -- Resolve omits the "in" key when the in point is at frame 0.
        if markOut and not markIn then markIn = 0 end
        if markIn and not markOut then markOut = endFrame - startFrame end
        if markIn and markOut then
            hasInOut = true
            inSeconds = framesToSeconds(markIn, fps, 0)
            outSeconds = framesToSeconds(markOut, fps, 0)
        end
    end

    -- Get current timecode
    local currentTimecode = "00:00:00:00"
    pcall(function()
        currentTimecode = timeline:GetCurrentTimecode() or "00:00:00:00"
    end)

    local result = {
        name = timeline:GetName() or "Untitled",
        fps = fps,
        width = width,
        height = height,
        duration = durationSec,
        audioTrackCount = audioTrackCount,
        videoTrackCount = videoTrackCount,
        hasInOut = hasInOut,
        inSeconds = inSeconds,
        outSeconds = outSeconds,
        startFrame = startFrame,
        startTimecode = timeline:GetStartTimecode() or "00:00:00:00",
        currentTimecode = currentTimecode,
        currentSeconds = math.max(0, (timecodeToFrames(currentTimecode, fps) - startFrame) / fps)
    }

    print(string.format("[EdiTedMusic] Timeline: %s, %dx%d, %.1ffps, %.2fs, %dV/%dA tracks",
        result.name, width, height, fps, durationSec, videoTrackCount, audioTrackCount))

    return result
end

-- Export audio from timeline
-- Diagnostic: dump the render formats / codecs / presets this Resolve build
-- exposes, so audio export uses REAL tokens instead of guessed ones. Switches
-- to the Deliver page (where these APIs are reliable) and restores the page.
function getRenderInfo(args)
    if not project then return {error = "No project active"} end
    local info = { presets = {}, formats = {}, current = nil, page = nil }
    local orig = nil
    pcall(function() orig = resolve:GetCurrentPage() end)
    pcall(function() resolve:OpenPage("deliver") end)
    busyWait(0.8)
    pcall(function() info.page = resolve:GetCurrentPage() end)
    pcall(function()
        local pl = project:GetRenderPresetList()
        if type(pl) == "table" then
            for _, p in pairs(pl) do table.insert(info.presets, tostring(p)) end
        end
    end)
    pcall(function()
        local fmts = project:GetRenderFormats()
        if type(fmts) == "table" then
            for name, id in pairs(fmts) do
                local entry = { name = tostring(name), id = tostring(id), codecs = {} }
                pcall(function()
                    local cs = project:GetRenderCodecs(id)
                    if type(cs) == "table" then
                        for cn, ci in pairs(cs) do
                            table.insert(entry.codecs, tostring(cn) .. "=" .. tostring(ci))
                        end
                    end
                end)
                table.insert(info.formats, entry)
            end
        end
    end)
    pcall(function()
        local cur = project:GetCurrentRenderFormatAndCodec()
        if type(cur) == "table" then
            info.current = tostring(cur["format"]) .. "/" .. tostring(cur["codec"])
        end
    end)
    pcall(function() resolve:OpenPage(orig or "edit") end)
    return info
end

function exportAudio(args)
    print("[EdiTedMusic] exportAudio called")

    if not timeline or not project then
        return {error = "No timeline/project active"}
    end

    if project:IsRenderingInProgress() then
        return {error = "Render already in progress"}
    end

    local useInOut = args and args.useInOut or false
    local fallbackStartSec = args and tonumber(args.fallbackStartSec) or nil

    -- Save original page
    local originalPage = resolve:GetCurrentPage()

    -- Switch to deliver page and WAIT until it is actually active — the render
    -- format/codec APIs only behave reliably once the Deliver page engine is up
    -- (a wrong/early call left current=unknown/ and AddRenderJob returned nil).
    resolve:OpenPage("deliver")
    do
        local waited = 0
        while waited < 3.0 do
            local cp = nil
            pcall(function() cp = resolve:GetCurrentPage() end)
            if cp == "deliver" then break end
            busyWait(0.25); waited = waited + 0.25
        end
    end

    -- Configure an AUDIO-ONLY render. Three strategies, most-specific first;
    -- whichever lands a valid audio format/codec wins. We do NOT trust a single
    -- hardcoded token (the wrong guess "wav"/"LinearPCM" failed on this build).
    local presetOk = false
    local fmtOk = false
    local fmtTried = {}

    -- (1) Any render preset whose name looks audio-only.
    pcall(function()
        local pl = project:GetRenderPresetList()
        if type(pl) == "table" then
            for _, p in pairs(pl) do
                local ps = tostring(p)
                if ps:lower():find("audio") then
                    if project:LoadRenderPreset(ps) then presetOk = true; break end
                end
            end
        end
    end)
    if not presetOk then
        pcall(function() presetOk = (project:LoadRenderPreset("Audio Only") and true) or false end)
    end

    -- (2) Explicit common audio format/codec token pairs (audio containers first).
    local candidates = {
        {"wav", "LinearPCM"}, {"wav", "lpcm"}, {"aiff", "LinearPCM"},
        {"mp3", "mp3"}, {"m4a", "aac"}, {"mp4", "aac"},
    }
    for _, c in ipairs(candidates) do
        if fmtOk then break end
        table.insert(fmtTried, c[1] .. "/" .. c[2])
        pcall(function()
            if project:SetCurrentRenderFormatAndCodec(c[1], c[2]) then fmtOk = true end
        end)
    end

    -- (3) Discover an audio format from THIS install and try each of its codecs.
    if not fmtOk then
        pcall(function()
            local formats = project:GetRenderFormats() or {}
            for name, id in pairs(formats) do
                local n = tostring(name):lower()
                local i = tostring(id):lower()
                if i == "wav" or i == "aiff" or i == "mp3" or i == "m4a" or i == "aac"
                   or n:find("wave") or n:find("audio") or n:find("aiff") or n:find("mp3") then
                    local codecs = project:GetRenderCodecs(id) or {}
                    for _, ci in pairs(codecs) do
                        table.insert(fmtTried, tostring(id) .. "/" .. tostring(ci))
                        if project:SetCurrentRenderFormatAndCodec(id, ci) then fmtOk = true; break end
                    end
                end
                if fmtOk then break end
            end
        end)
    end

    local curNow = "?"
    pcall(function()
        local f = project:GetCurrentRenderFormatAndCodec()
        if f then curNow = tostring(f["format"]) .. "/" .. tostring(f["codec"]) end
    end)
    print(string.format("[EdiTedMusic] exportAudio render config: presetOk=%s fmtOk=%s current=%s tried=[%s]",
        tostring(presetOk), tostring(fmtOk), curNow, table.concat(fmtTried, ", ")))

    -- Calculate frames
    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local startFrame = timeline:GetStartFrame() or 0
    local endFrame = timeline:GetEndFrame() or 0

    -- Set render settings
    -- Render to the fixed ASCII scratch dir on Windows (never %TEMP%, which
    -- can be a non-ASCII/8.3 path on Hebrew-username installs and break the
    -- renderer). The dir already exists — the HTTP bridge uses it constantly.
    local renderDir = (isWindows and "C:\\ProgramData\\editedmusic") or tempDir
    local renderSettings = {
        TargetDir = renderDir,
        CustomName = "editedmusic_audio_" .. os.time(),
        RenderMode = "Single clip",
        IsExportVideo = false,
        IsExportAudio = true,
        AudioBitDepth = 24,
        AudioSampleRate = 48000
    }

    -- If useInOut and marks exist, set MarkIn/MarkOut. If Resolve returns no
    -- marks, use the UI-captured current-timecode fallback so placement/export
    -- stay aligned instead of silently starting at timeline zero.
    if useInOut then
        local hasRenderRange = false
        local ok, markInOut = pcall(function() return timeline:GetMarkInOut() end)
        if ok and markInOut then
            local markIn, markOut = parseMarkInOut(markInOut)
            -- Resolve omits the "in" key when the in point is at frame 0;
            -- default the missing side so an in-at-start still renders correctly.
            if markOut and not markIn then markIn = 0 end
            if markIn and not markOut then markOut = endFrame - startFrame end
            if markIn and markOut then
                renderSettings.MarkIn = markIn + startFrame
                renderSettings.MarkOut = markOut + startFrame
                hasRenderRange = true
                print(string.format("[EdiTedMusic] Rendering in/out range: frames %d-%d",
                    renderSettings.MarkIn, renderSettings.MarkOut))
            end
        end
        if not hasRenderRange and fallbackStartSec and fallbackStartSec > 0 then
            local fallbackFrame = startFrame + secToFrames(fallbackStartSec, fps)
            if fallbackFrame < endFrame then
                renderSettings.MarkIn = fallbackFrame
                renderSettings.MarkOut = endFrame
                hasRenderRange = true
                print(string.format("[EdiTedMusic] Rendering fallback range from current timecode: frames %d-%d",
                    renderSettings.MarkIn, renderSettings.MarkOut))
            end
        end
        if not hasRenderRange then
            print("[EdiTedMusic] WARN: useInOut requested but no valid marks or fallback found, rendering full timeline")
        end
    end

    project:SetRenderSettings(renderSettings)

    -- Add render job
    local pid = project:AddRenderJob()
    if not pid then
        resolve:OpenPage(originalPage or "edit")
        local curFmt = "?"
        pcall(function()
            local f = project:GetCurrentRenderFormatAndCodec()
            if f then curFmt = tostring(f["format"]) .. "/" .. tostring(f["codec"]) end
        end)
        return {error = string.format(
            "Failed to add render job (presetOk=%s, audioFormatSet=%s, current=%s, tried=[%s]). " ..
            "Resolve refused an audio-only job — open the Deliver page and check the render format.",
            tostring(presetOk), tostring(fmtOk), curFmt, table.concat(fmtTried, ", "))}
    end

    -- Start rendering and wait
    project:StartRendering(pid)
    print("[EdiTedMusic] Rendering audio...")

    -- 10-minute ceiling on the render wait. Without this, a hung internal
    -- render (codec issue, missing asset, etc.) leaves the whole script
    -- wedged with no way out short of force-quitting Resolve.
    local renderTimeoutSec = 600
    local renderStart = os.clock()
    while project:IsRenderingInProgress() do
        if os.clock() - renderStart > renderTimeoutSec then
            pcall(function() project:DeleteRenderJob(pid) end)
            resolve:OpenPage(originalPage or "edit")
            return {error = "Audio render timed out after " .. renderTimeoutSec .. "s. Try a shorter selection."}
        end
        busyWait(0.3)
    end

    -- Detect cancellation/failure via job status. IsRenderingInProgress() just
    -- tells us the render stopped, not whether it succeeded — if the user hit
    -- "Stop Render" in Resolve, the output file will be missing and the generic
    -- "Failed to read exported audio file" message hides the real cause.
    local jobStatus = nil
    pcall(function()
        local status = project:GetRenderJobStatus(pid)
        if status then jobStatus = status["JobStatus"] end
    end)
    if jobStatus == "Cancelled" then
        resolve:OpenPage(originalPage or "edit")
        print("[EdiTedMusic] Audio export cancelled by user")
        return {error = "Audio export was cancelled in Resolve. Press Match Music again to retry."}
    elseif jobStatus == "Failed" then
        resolve:OpenPage(originalPage or "edit")
        print("[EdiTedMusic] Audio export failed (JobStatus=Failed)")
        return {error = "Resolve failed to render the audio. Check the Deliver page for details."}
    end

    -- Get output path
    local jobs = project:GetRenderJobList()
    local last = jobs and jobs[#jobs]
    if not last then
        resolve:OpenPage(originalPage or "edit")
        return {error = "No render job found after rendering"}
    end

    local audioPath = last["TargetDir"] .. sep .. last["OutputFilename"]

    -- Restore original page
    resolve:OpenPage(originalPage or "edit")

    -- Read file and convert to base64
    local audioData = readAll(audioPath)
    if not audioData then
        return {error = "Failed to read exported audio file (status=" .. tostring(jobStatus or "unknown") .. ")"}
    end

    print(string.format("[EdiTedMusic] Audio exported: %d bytes (WAV)", #audioData))

    -- Shrink the upload by ~10x via WAV -> MP3 transcode. The inline
    -- script discovers (or downloads on first use) ffmpeg itself; any
    -- failure falls through to uploading the WAV unchanged so the rest
    -- of the pipeline still works.
    local mp3Path = _transcodeWavToMp3(audioPath)
    if mp3Path then
        local mp3Data = readAll(mp3Path)
        if mp3Data and #mp3Data > 0 then
            print(string.format("[EdiTedMusic] Transcoded WAV (%d B) -> MP3 (%d B), ratio %.1fx",
                #audioData, #mp3Data, #audioData / math.max(#mp3Data, 1)))
            audioData = mp3Data
        end
        deleteFile(mp3Path)
    else
        print("[EdiTedMusic] Transcode failed; uploading WAV unchanged")
    end

    local audioBase64 = base64Encode(audioData)
    deleteFile(audioPath)

    return {audioBase64 = audioBase64}
end

-- Take screenshots at positions across a range
function takeScreenshots(args)
    print("[EdiTedMusic] takeScreenshots called")

    if not timeline or not project then
        return {error = "No timeline/project active"}
    end

    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local startTC = timeline:GetStartTimecode() or "00:00:00:00"
    local startOffset = timecodeToFrames(startTC, fps)
    local startFrame = timeline:GetStartFrame() or 0
    local endFrame = timeline:GetEndFrame() or 0
    local durationSec = (endFrame - startFrame) / fps

    -- Determine range
    local startSec, endSec

    if args and args.startSec and args.endSec then
        startSec = tonumber(args.startSec) or 0
        endSec = tonumber(args.endSec) or durationSec
    else
        -- Try to use in/out marks
        local ok, markInOut = pcall(function() return timeline:GetMarkInOut() end)
        if ok and markInOut then
            local markIn, markOut = parseMarkInOut(markInOut)
            -- Resolve omits the "in" key when the in point is at frame 0.
            if markOut and not markIn then markIn = 0 end
            if markIn and not markOut then markOut = endFrame - startFrame end
            if markIn and markOut then
                startSec = framesToSeconds(markIn, fps, 0)
                endSec = framesToSeconds(markOut, fps, 0)
            end
        end
        -- Fallback to full timeline
        if not startSec or not endSec then
            startSec = 0
            endSec = durationSec
        end
    end

    print(string.format("[EdiTedMusic] Screenshot range: %.2fs - %.2fs", startSec, endSec))

    local rangeDuration = endSec - startSec
    if rangeDuration <= 0 then
        -- The caller passed an empty range (e.g. the UI sends startSec=endSec=0
        -- when no in/out marks are set). Fall back to the full timeline instead
        -- of aborting, matching exportAudio's no-marks behavior.
        print("[EdiTedMusic] Screenshot range empty — falling back to full timeline")
        startSec = 0
        endSec = durationSec
        rangeDuration = endSec - startSec
    end
    if rangeDuration <= 0 then
        return {error = "Invalid screenshot range (timeline duration <= 0)"}
    end

    -- Calculate 5 positions: 10%, 30%, 50%, 70%, 90%
    local positions = {0.10, 0.30, 0.50, 0.70, 0.90}
    local screenshots = {}
    local ssCount = 0

    -- Try ExportCurrentFrameAsStill first (Resolve 18.5+)
    local hasExportStill = (project.ExportCurrentFrameAsStill ~= nil)

    if hasExportStill then
        for i, pct in ipairs(positions) do
            local targetSec = startSec + (rangeDuration * pct)
            local absFrame = startOffset + secToFrames(targetSec, fps)
            local tc = framesToTimecode(absFrame, fps)
            local outPath = tempDir .. sep .. string.format("editedmusic_ss_%02dpct.jpg", math.floor(pct * 100))

            print(string.format("[EdiTedMusic] Screenshot %d: %.2fs -> %s", i, targetSec, tc))

            timeline:SetCurrentTimecode(tc)
            busyWait(0.3)

            local ok, result = pcall(function()
                return project:ExportCurrentFrameAsStill(outPath)
            end)

            if ok and fileExists(outPath) then
                local imgData = readAll(outPath)
                if imgData and #imgData > 0 then
                    screenshots[i] = base64Encode(imgData)
                    ssCount = ssCount + 1
                    print(string.format("[EdiTedMusic] Screenshot %d: %d bytes", i, #imgData))
                    deleteFile(outPath)
                end
            else
                print(string.format("[EdiTedMusic] WARN: ExportCurrentFrameAsStill failed for position %d", i))
            end
        end
    end

    -- Fallback: GrabStill via Color page gallery
    if ssCount == 0 then
        print("[EdiTedMusic] Falling back to GrabStill method...")
        local originalPage = resolve:GetCurrentPage()
        resolve:OpenPage("color")
        busyWait(1.5)

        local gallery = project:GetGallery()
        local album = gallery and gallery:GetCurrentStillAlbum()

        if album then
            for i, pct in ipairs(positions) do
                local targetSec = startSec + (rangeDuration * pct)
                local absFrame = startOffset + secToFrames(targetSec, fps)
                local tc = framesToTimecode(absFrame, fps)

                print(string.format("[EdiTedMusic] Screenshot %d (GrabStill): %.2fs -> %s", i, targetSec, tc))

                timeline:SetCurrentTimecode(tc)
                busyWait(0.4)

                local okGrab, still = pcall(function() return timeline:GrabStill() end)
                if okGrab and still then
                    local prefix = string.format("editedmusic_grab_%02dpct", math.floor(pct * 100))
                    local okExp = pcall(function()
                        return album:ExportStills({still}, tempDir, prefix, "jpg")
                    end)
                    if okExp then
                        local expPath = tempDir .. sep .. prefix .. "_001.jpg"
                        if fileExists(expPath) then
                            local imgData = readAll(expPath)
                            if imgData and #imgData > 0 then
                                screenshots[i] = base64Encode(imgData)
                                ssCount = ssCount + 1
                                print(string.format("[EdiTedMusic] Screenshot %d: %d bytes (GrabStill)", i, #imgData))
                                deleteFile(expPath)
                            end
                        end
                    end
                else
                    print(string.format("[EdiTedMusic] WARN: GrabStill failed for position %d", i))
                end
            end
        else
            print("[EdiTedMusic] WARN: Could not access gallery/album for GrabStill fallback")
        end

        -- Restore original page
        if originalPage and originalPage ~= "color" then
            resolve:OpenPage(originalPage)
            busyWait(0.5)
        end
    end

    print(string.format("[EdiTedMusic] Captured %d screenshots", ssCount))

    return {
        screenshots = screenshots,
        count = ssCount
    }
end

-- Find an empty audio track or create a new one
function findOrCreateAudioTrack()
    print("[EdiTedMusic] findOrCreateAudioTrack called")

    if not timeline then
        return {error = "No timeline active"}
    end

    local audioTrackCount = timeline:GetTrackCount("audio") or 0

    -- Scan existing tracks for an empty one
    for i = 1, audioTrackCount do
        local items = timeline:GetItemListInTrack("audio", i)
        if not items or #items == 0 then
            print(string.format("[EdiTedMusic] Found empty audio track: %d", i))
            return {trackIndex = i, created = false}
        end
    end

    -- No empty track found, create a new one
    local addOk = timeline:AddTrack("audio")
    local newCount = timeline:GetTrackCount("audio") or 0

    if newCount > audioTrackCount then
        print(string.format("[EdiTedMusic] Created new audio track: %d", newCount))
        return {trackIndex = newCount, created = true}
    else
        return {error = "Failed to create new audio track"}
    end
end

-- Create or find a bin in the media pool
function createOrFindBin(binName)
    print(string.format("[EdiTedMusic] createOrFindBin: %s", binName))

    if not mediaPool then
        return {error = "Media pool not available"}
    end

    local rootFolder = mediaPool:GetRootFolder()
    local foundBin = nil

    local function findBin(folder)
        local folderName = folder:GetName()
        if folderName == binName then
            foundBin = folder
            return true
        end

        local subfolders = folder:GetSubFolderList()
        if subfolders then
            for _, subfolder in ipairs(subfolders) do
                if findBin(subfolder) then
                    return true
                end
            end
        end
        return false
    end

    findBin(rootFolder)

    -- If not found, create at root
    if not foundBin then
        foundBin = mediaPool:AddSubFolder(rootFolder, binName)
        if foundBin then
            print(string.format("[EdiTedMusic] Created bin: %s", binName))
        else
            return {error = "Failed to create bin"}
        end
    else
        print(string.format("[EdiTedMusic] Found existing bin: %s", binName))
    end

    return {
        success = foundBin ~= nil,
        bin = foundBin
    }
end

-- Import an audio file to the timeline
function importAudioToTimeline(args)
    print("[EdiTedMusic] importAudioToTimeline called")

    if not args then
        return {error = "No arguments provided"}
    end

    if not timeline or not mediaPool then
        return {error = "No timeline or media pool active"}
    end

    -- Batch shape: {trackIndex=N, clips={{...}, {...}, ...}} -> dispatch per-clip
    if type(args.clips) == "table" then
        local sharedTrackIndex = args.trackIndex or 1
        local results = {}
        local successCount = 0
        for i, clip in ipairs(args.clips) do
            local cStart = clip.startSec or 0
            local cEnd = clip.endSec or 0
            local singleArgs = {
                audioBase64 = clip.audioBase64,
                filePath = clip.filePath,
                filename = clip.filename,
                trackIndex = sharedTrackIndex,
                startSec = cStart,
                endSec = cEnd,
                trimInSec = clip.trimInSec or 0,
                trimOutSec = clip.trimOutSec or (cEnd - cStart),
            }
            local r = importAudioToTimeline(singleArgs)
            if r and r.success then
                successCount = successCount + 1
                table.insert(results, {
                    ok = true,
                    clipName = r.clipName,
                    clipUniqueId = r.clipUniqueId,
                    startSec = singleArgs.startSec,
                    index = i,
                })
            else
                table.insert(results, {
                    ok = false,
                    error = (r and r.error) or "Unknown error",
                    index = i,
                })
            end
        end
        return {ok = successCount > 0, results = results}
    end

    local audioBase64 = args.audioBase64
    local sourcePath = args.filePath
    local rawFilename = args.filename or ("editedmusic_track_" .. os.time())
    local trackIndex = args.trackIndex or 1
    local startSec = args.startSec or 0
    local endSec = args.endSec or 0
    local trimInSec = args.trimInSec or 0
    local trimOutSec = args.trimOutSec or (endSec - startSec)

    if not audioBase64 and not sourcePath then
        return {error = "No audioBase64 or filePath provided"}
    end

    local filePath
    if sourcePath then
        if not fileExists(sourcePath) then
            return {error = "filePath does not exist: " .. sourcePath}
        end
        filePath = sourcePath
        print(string.format("[EdiTedMusic] Importing existing file: %s", filePath))
    else
        -- Decode base64 to binary and write to temp file
        local okDecode, binaryData = pcall(base64Decode, audioBase64)
        if not okDecode or not binaryData or #binaryData == 0 then
            return {error = "Failed to decode base64 audio data"}
        end

        -- Sanitize filename: remove spaces and special chars, force .mp3 extension
        local safeName = rawFilename:gsub("[^%w_%-]", "_"):gsub("_+", "_"):gsub("^_", ""):gsub("_$", "")
        if safeName == "" then safeName = "editedmusic_track_" .. os.time() end
        -- Remove any existing extension and force .mp3 (backend returns MP3 data)
        safeName = safeName:gsub("%.[^%.]+$", "")
        local filename = safeName .. ".mp3"
        local cacheDir, cacheKind = resolveAudioCacheDir()
        filePath = cacheDir .. sep .. filename
        if not writeAll(filePath, binaryData) then
            -- Cache dir became unwritable since resolution — try one more fallback to TEMP
            if cacheKind ~= "temp" then
                print(string.format("[EdiTedMusic] Write failed in %s cache (%s), falling back to TEMP", cacheKind, filePath))
                filePath = tempDir .. sep .. filename
                if not writeAll(filePath, binaryData) then
                    return {error = "Failed to write audio file to disk (both cache and TEMP)"}
                end
            else
                return {error = "Failed to write audio file to disk"}
            end
        end

        print(string.format("[EdiTedMusic] Audio file written: %s (%d bytes)", filePath, #binaryData))
    end

    -- Switch to Edit page only if not already there. An unconditional page
    -- transition blocks the scripting thread ~0.2-0.8s on some Resolve builds
    -- and needlessly yanks the user off whatever page they were on.
    local cpOk, cp = pcall(function() return resolve:GetCurrentPage() end)
    if not cpOk or cp ~= "edit" then
        resolve:OpenPage("edit")
        busyWait(0.3)
    end

    -- Import file to media pool (import to root first — same as EdiTedStockFootage)
    local importedClips = nil
    print(string.format("[EdiTedMusic] Importing from: %s", filePath))

    -- Try MediaStorage first (proven pattern from EdiTedStockFootage).
    -- pcall-wrapped: a Resolve modal dialog or hard error here returns an
    -- immediate {error} instead of crashing out of the handler.
    local mediaStorage = resolve:GetMediaStorage()
    if mediaStorage then
        local aimOk, aimResult = pcall(function() return mediaStorage:AddItemListToMediaPool(filePath) end)
        if aimOk then importedClips = aimResult
        else print("[EdiTedMusic] AddItemListToMediaPool threw: " .. tostring(aimResult)) end
        print(string.format("[EdiTedMusic] MediaStorage import result: %s", tostring(importedClips and #importedClips or "nil")))
    end

    -- Fallback to ImportMedia
    if not importedClips or #importedClips == 0 then
        local imOk, imResult = pcall(function() return mediaPool:ImportMedia({filePath}) end)
        if imOk then importedClips = imResult
        else return {error = "ImportMedia threw: " .. tostring(imResult)} end
        print(string.format("[EdiTedMusic] ImportMedia result: %s", tostring(importedClips and #importedClips or "nil")))
    end

    if not importedClips or #importedClips == 0 then
        return {error = "Failed to import audio to media pool"}
    end

    local clip = importedClips[1]
    if not clip then
        return {error = "Media pool import returned an empty item"}
    end
    -- rawFilename (not the undefined 'filename', which is nil in the filePath
    -- branch) is the safe fallback; metadata reads are pcall'd in case the
    -- returned item userdata is invalid.
    local clipName = rawFilename
    pcall(function()
        clipName = clip:GetName() or rawFilename
        local clipProps = clip:GetClipProperty() or {}
        print(string.format("[EdiTedMusic] Imported: '%s' Type=%s AudioCh=%s",
            clipName, tostring(clipProps["Type"]), tostring(clipProps["Audio Ch"])))
    end)

    -- Ensure target audio track exists. Verify the count ACTUALLY increased —
    -- AddTrack can fail (read-only/locked timeline, internal track limit); the
    -- old loop incremented blindly, then AppendToTimeline silently targeted a
    -- track index that didn't exist and failed with a generic message.
    local audioTrackCount = timeline:GetTrackCount("audio") or 0
    local addAttempts = 0
    while audioTrackCount < trackIndex do
        local addOk = timeline:AddTrack("audio")
        addAttempts = addAttempts + 1
        local newCount = timeline:GetTrackCount("audio") or 0
        if newCount <= audioTrackCount then
            return {error = string.format(
                "Cannot create audio track %d (AddTrack returned %s; count stuck at %d). Is the timeline read-only?",
                trackIndex, tostring(addOk), newCount)}
        end
        audioTrackCount = newCount
        print(string.format("[EdiTedMusic] Added audio track %d", audioTrackCount))
        if addAttempts > 20 then return {error = "AddTrack safety limit reached"} end
    end

    -- Calculate frame positions (mirrors EdiTedStockFootage.lua importClip)
    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local startFrame = timeline:GetStartFrame() or 0
    local endFrame = timeline:GetEndFrame() or 0

    local recordFrame = startFrame + secToFrames(startSec, fps)
    local sourceStartFrame = secToFrames(trimInSec, fps)
    local sourceEndFrame = secToFrames(trimOutSec, fps)

    print(string.format("[EdiTedMusic] Timeline position: %.2fs - %.2fs", startSec, endSec))
    print(string.format("[EdiTedMusic] Source trim: %.2fs - %.2fs", trimInSec, trimOutSec))
    print(string.format("[EdiTedMusic] Record frame: %d, Source: %d-%d", recordFrame, sourceStartFrame, sourceEndFrame))

    -- Prepare clipInfo (mirrors EdiTedStockFootage.lua importClip)
    local clipInfo = {
        mediaPoolItem = clip,
        mediaType = 2,  -- Audio only
        trackIndex = trackIndex,
        recordFrame = recordFrame,
        startFrame = sourceStartFrame,
        endFrame = sourceEndFrame
    }

    -- Append to timeline (pcall-wrapped so a Resolve error here returns an
    -- immediate {error} rather than aborting the handler with no result).
    local appendOk, success = pcall(function() return mediaPool:AppendToTimeline({clipInfo}) end)
    if not appendOk then
        return {error = "AppendToTimeline threw: " .. tostring(success)}
    end
    if not success then
        return {error = "Failed to append clip to timeline (track index may be invalid or the timeline is locked)"}
    end

    print("[EdiTedMusic] Clip appended to timeline")

    -- CRITICAL: Re-fetch items after AppendToTimeline (mirrors EdiTedStockFootage.lua)
    busyWait(0.1)

    local items = timeline:GetItemListInTrack("audio", trackIndex)
    if not items or #items == 0 then
        return {error = "Failed to get timeline items after append"}
    end

    -- Find our newly added item (should be last). All metadata reads are
    -- pcall'd — an invalid item userdata (Resolve GC race) must not crash the
    -- handler AFTER a successful append (the clip is already placed).
    local placedItem = items[#items]
    local placedClipName = clipName
    local placedClipUniqueId = nil
    pcall(function()
        placedClipName = placedItem:GetName() or clipName
        placedClipUniqueId = placedItem:GetUniqueId()
        print(string.format("[EdiTedMusic] Re-fetched item: '%s' (id=%s) at track %d, start=%s, end=%s",
            placedClipName, tostring(placedClipUniqueId), trackIndex,
            tostring(placedItem:GetStart()), tostring(placedItem:GetEnd())))
    end)

    return {
        success = true,
        clipName = placedClipName,
        clipUniqueId = placedClipUniqueId,
        trackIndex = trackIndex
    }
end

-- Replace an existing audio clip on the timeline
function replaceAudioOnTimeline(args)
    print("[EdiTedMusic] replaceAudioOnTimeline called")

    if not args then
        return {error = "No arguments provided"}
    end

    if not timeline then
        return {error = "No timeline active"}
    end

    local trackIndex = args.trackIndex or 1
    local clipName = args.clipName
    local clipUniqueId = args.clipUniqueId
    local startSec = args.startSec

    -- Find existing clip across all audio tracks: ID first, then name fallback.
    -- Preferred track is searched first; if missed, we scan the rest so user-moved
    -- clips are still found (and the new insert lands on the moved track).
    local foundClip = nil
    local foundBy = nil
    local savedStartSec = startSec
    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local trackCount = timeline:GetTrackCount("audio") or 0

    local searchOrder = {trackIndex}
    for i = 1, trackCount do
        if i ~= trackIndex then table.insert(searchOrder, i) end
    end

    local function scanById(idx)
        if not clipUniqueId then return nil end
        local items = timeline:GetItemListInTrack("audio", idx)
        if not items then return nil end
        for _, item in ipairs(items) do
            local ok, id = pcall(function() return item:GetUniqueId() end)
            if ok and id and tostring(id) == tostring(clipUniqueId) then return item end
        end
        return nil
    end

    local function scanByName(idx)
        if not clipName then return nil end
        local items = timeline:GetItemListInTrack("audio", idx)
        if not items then return nil end
        for _, item in ipairs(items) do
            if item:GetName() == clipName then return item end
        end
        return nil
    end

    for _, idx in ipairs(searchOrder) do
        local item = scanById(idx)
        if item then
            foundClip = item
            foundBy = (idx == trackIndex) and "id" or ("id (moved to track " .. idx .. ")")
            trackIndex = idx
            break
        end
    end

    if not foundClip then
        for _, idx in ipairs(searchOrder) do
            local item = scanByName(idx)
            if item then
                foundClip = item
                foundBy = (idx == trackIndex) and "name" or ("name (moved to track " .. idx .. ")")
                trackIndex = idx
                break
            end
        end
    end

    local savedEndSec = nil
    if foundClip then
        local clipStart = foundClip:GetStart()
        local clipEnd = foundClip:GetEnd()
        local tlStart = timeline:GetStartFrame() or 0
        if not startSec then
            savedStartSec = framesToSeconds(clipStart, fps, tlStart)
        end
        savedEndSec = framesToSeconds(clipEnd, fps, tlStart)
    end

    -- Delete the found clip
    if foundClip then
        print(string.format("[EdiTedMusic] Found existing clip by %s on track %d", foundBy, trackIndex))
        local deleteOk = timeline:DeleteClips({foundClip})
        busyWait(0.2)
        print(string.format("[EdiTedMusic] Deleted existing clip: %s", tostring(deleteOk)))
    else
        print("[EdiTedMusic] WARN: Existing clip not found, importing at provided position")
    end

    -- Import the new audio at the saved position, preserving the original duration
    local importStartSec = savedStartSec or 0
    local importEndSec = args.endSec or savedEndSec or importStartSec
    local importArgs = {
        audioBase64 = args.audioBase64,
        filePath = args.filePath,
        filename = args.filename,
        trackIndex = trackIndex,
        startSec = importStartSec,
        endSec = importEndSec
    }

    return importAudioToTimeline(importArgs)
end

-- Delete only EdiTed-imported audio clips from a single audio track.
-- "Ours" = clip name starts with one of these prefixes (case-sensitive, matching
-- how importAudioToTimeline writes files to disk):
--   "EdiTed_"        — real production filename built in EdiTedMusicUI.html
--   "edited_music_"  — test fixtures (testInsertAudio / testBatchImport / etc.)
--   "editedmusic_"   — fallback used by importAudioToTimeline when no filename arg
function cleanupMusicTrack(args)
    print("[EdiTedMusic] cleanupMusicTrack called")

    if not args then
        return {ok = false, error = "No arguments provided"}
    end

    if not timeline then
        return {ok = false, error = "no timeline"}
    end

    local trackIndex = tonumber(args.trackIndex)
    if not trackIndex or trackIndex < 1 then
        return {ok = false, error = "Invalid trackIndex: " .. tostring(args.trackIndex)}
    end

    local trackCount = timeline:GetTrackCount("audio") or 0
    if trackIndex > trackCount then
        return {ok = false, error = string.format(
            "trackIndex %d out of range (audio track count = %d)", trackIndex, trackCount)}
    end

    local function isOurs(name)
        if not name then return false end
        return name:sub(1, 7) == "EdiTed_"
            or name:sub(1, 13) == "edited_music_"
            or name:sub(1, 12) == "editedmusic_"
    end

    local items = timeline:GetItemListInTrack("audio", trackIndex) or {}
    local toDelete = {}
    local deletedNames = {}
    for _, item in ipairs(items) do
        local okName, name = pcall(function() return item:GetName() end)
        if okName and isOurs(name) then
            table.insert(toDelete, item)
            table.insert(deletedNames, name)
        end
    end

    print(string.format("[EdiTedMusic] cleanupMusicTrack: track %d has %d items, %d match EdiTed prefix",
        trackIndex, #items, #toDelete))

    if #toDelete > 0 then
        local okDel, delResult = pcall(function() return timeline:DeleteClips(toDelete) end)
        busyWait(0.2)
        print(string.format("[EdiTedMusic] cleanupMusicTrack: DeleteClips ok=%s result=%s",
            tostring(okDel), tostring(delResult)))
        if not okDel then
            return {ok = false, trackIndex = trackIndex, error = "DeleteClips threw: " .. tostring(delResult)}
        end
    end

    return {
        ok = true,
        trackIndex = trackIndex,
        deletedCount = #toDelete,
        deletedNames = deletedNames
    }
end

-- Add a marker to the timeline
function addMusicMarker(args)
    print("[EdiTedMusic] addMusicMarker called")

    if not timeline then
        return {error = "No timeline active"}
    end

    if not args then
        return {error = "No arguments provided"}
    end

    local timeSec = tonumber(args.timeSec) or 0
    local name = args.name or "Music"
    local note = args.note or ""

    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local frameId = secToFrames(timeSec, fps)

    local addOk = timeline:AddMarker(frameId, "Green", name, note, 1, "editedmusic")

    print(string.format("[EdiTedMusic] Marker added at %.2fs (frame %d): %s - %s",
        timeSec, frameId, name, tostring(addOk)))

    return {success = addOk ~= false}
end

function getClipCounts()
    print("[EdiTedMusic] getClipCounts called")
    if not timeline then
        return {error = "No timeline active"}
    end
    local audioCount = timeline:GetTrackCount("audio") or 0
    local videoCount = timeline:GetTrackCount("video") or 0
    local clipsByAudioTrack = {}
    local totalAudioClips = 0
    for i = 1, audioCount do
        local items = timeline:GetItemListInTrack("audio", i) or {}
        local n = #items
        totalAudioClips = totalAudioClips + n
        table.insert(clipsByAudioTrack, {trackIndex = i, clipCount = n})
    end
    local clipsByVideoTrack = {}
    local totalVideoClips = 0
    for i = 1, videoCount do
        local items = timeline:GetItemListInTrack("video", i) or {}
        local n = #items
        totalVideoClips = totalVideoClips + n
        table.insert(clipsByVideoTrack, {trackIndex = i, clipCount = n})
    end
    return {
        audioTracks = clipsByAudioTrack,
        videoTracks = clipsByVideoTrack,
        totalAudioClips = totalAudioClips,
        totalVideoClips = totalVideoClips
    }
end

function checkAudioTracks()
    print("[EdiTedMusic] checkAudioTracks called")
    if not timeline then
        return {error = "No timeline active"}
    end
    local audioCount = timeline:GetTrackCount("audio") or 0
    local tracks = {}
    for i = 1, audioCount do
        local items = timeline:GetItemListInTrack("audio", i) or {}
        local trackName = ""
        pcall(function()
            trackName = timeline:GetTrackName("audio", i) or ""
        end)
        table.insert(tracks, {
            trackIndex = i,
            trackName = trackName,
            clipCount = #items,
            hasClips = #items > 0
        })
    end
    return {trackCount = audioCount, tracks = tracks}
end

function checkForEmptyTracks()
    print("[EdiTedMusic] checkForEmptyTracks called")
    if not timeline then
        return {error = "No timeline active"}
    end
    local audioCount = timeline:GetTrackCount("audio") or 0
    local empty = {}
    local nonEmpty = {}
    for i = 1, audioCount do
        local items = timeline:GetItemListInTrack("audio", i) or {}
        if #items == 0 then
            table.insert(empty, i)
        else
            table.insert(nonEmpty, i)
        end
    end
    return {
        totalAudioTracks = audioCount,
        emptyTracks = empty,
        emptyCount = #empty,
        nonEmptyTracks = nonEmpty,
        nonEmptyCount = #nonEmpty
    }
end

-- ============================================================
-- 11b. Audio Insertion Test Commands (Phase 1.6)
-- ============================================================

local TEST_CLIP_STATE_FILE = "edited_music_test_clip.json"

-- ============================================================
-- ffmpeg path resolver (Mac launchd-stripped PATH workaround)
-- ============================================================
-- DaVinci Resolve on macOS is launched by launchd; its child PATH is just
-- /usr/bin:/bin:/usr/sbin:/sbin, which excludes Homebrew (/opt/homebrew/bin
-- on Apple Silicon, /usr/local/bin on Intel) and MacPorts (/opt/local/bin).
-- Probe known install locations once and cache the result so bare `ffmpeg`
-- calls don't silently fail on Mac.
local _ffmpegPath = nil
local function resolveFfmpegPath()
    if _ffmpegPath ~= nil then return _ffmpegPath end
    if isWindows then
        _ffmpegPath = "ffmpeg"
        return _ffmpegPath
    end
    local candidates = {
        "/opt/homebrew/bin/ffmpeg",   -- Apple Silicon Homebrew
        "/usr/local/bin/ffmpeg",      -- Intel Homebrew
        "/opt/local/bin/ffmpeg",      -- MacPorts
        "/usr/bin/ffmpeg",            -- (unlikely on Mac, but check)
    }
    for _, p in ipairs(candidates) do
        local f = io.open(p, "rb")
        if f then f:close(); _ffmpegPath = p; return p end
    end
    -- Last-ditch: try bare `ffmpeg` via PATH (works if user manually set PATH).
    _ffmpegPath = "ffmpeg"
    return _ffmpegPath
end

-- Generate a test WAV file at the given path using ffmpeg.
-- freq: frequency in Hz (e.g. 440 or 880), duration: seconds.
-- Returns true on success, false + error string on failure.
local function generateTestWav(path, freq, duration)
    local devNull = isWindows and "nul" or "/dev/null"
    local ffmpegExe = resolveFfmpegPath()
    local ffmpegCmd = string.format(
        '%s -y -f lavfi -i "sine=frequency=%d:duration=%d" -ar 48000 -ac 2 -c:a pcm_s16le "%s" >%s 2>&1',
        ffmpegExe, freq, duration, path, devNull
    )
    local ffmpegCmd2 = string.format(
        '%s -y -f lavfi -i "sine=frequency=%d:duration=%d" -ar 48000 -ac 2 -c:a pcm_s16le %s >%s 2>&1',
        ffmpegExe, freq, duration, path, devNull
    )
    if isWindows and createProcessHidden then
        -- Hidden path (the normal case): NOTHING flashes. CreateProcessA does
        -- not understand shell redirection (>nul 2>&1), so wrap in cmd.exe /c
        -- and wait INFINITE. Retry the unquoted-path form if the file is missing,
        -- still hidden — we never fall through to a visible os.execute when FFI
        -- is present.
        createProcessHidden('cmd.exe /c "' .. ffmpegCmd:gsub('"', '""') .. '"', 0xFFFFFFFF)
        if not fileHasContent(path) then
            createProcessHidden('cmd.exe /c "' .. ffmpegCmd2:gsub('"', '""') .. '"', 0xFFFFFFFF)
        end
    else
        if isWindows then
            print("[EdiTedMusic] WARNING: FFI launcher unavailable — generateTestWav via visible os.execute (a console may flash)")
        end
        local exitCode = os.execute(ffmpegCmd)
        if not (exitCode == 0 or exitCode == true) then
            os.execute(ffmpegCmd2)
        end
    end
    return fileHasContent(path)
end

function testInsertAudio()
    print("[EdiTedMusic] testInsertAudio called")

    -- Ensure project/timeline available
    if not project or not timeline or not mediaPool then
        return {ok = false, error = "No project/timeline active — open a timeline first"}
    end

    -- Generate 440 Hz test WAV if not present
    local wavPath = tempDir .. sep .. "edited_music_test_a.wav"
    if not fileHasContent(wavPath) then
        print("[EdiTedMusic] Generating 440 Hz test WAV: " .. wavPath)
        local genOk = generateTestWav(wavPath, 440, 3)
        if not genOk then
            return {ok = false, error = "ffmpeg unavailable or failed to generate test WAV at: " .. wavPath}
        end
        print("[EdiTedMusic] Test WAV generated")
    else
        print("[EdiTedMusic] Reusing existing test WAV: " .. wavPath)
    end

    -- Find an empty audio track (or create one)
    local trackResult = findOrCreateAudioTrack()
    if trackResult.error then
        return {ok = false, error = "Track error: " .. trackResult.error}
    end
    local trackIndex = trackResult.trackIndex

    -- Insert at timeline start (Resolve Lua API has no stable playhead getter)
    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local startFrame = timeline:GetStartFrame() or 0
    local endFrame = timeline:GetEndFrame() or 0
    local startSec = 0

    local importArgs = {
        filePath    = wavPath,
        filename    = "edited_music_test_a",
        trackIndex  = trackIndex,
        startSec    = startSec,
        endSec      = startSec + 3  -- 3s test WAV
    }

    local result = importAudioToTimeline(importArgs)
    if result.error then
        return {ok = false, error = "importAudioToTimeline failed: " .. result.error}
    end

    -- Compute end frame from clip duration (3s WAV)
    local usedTrack = result.trackIndex or trackIndex
    local clipDurationFrames = math.floor(3 * fps)
    local insertedStartFrame = startFrame + math.floor(startSec * fps)
    local insertedEndFrame   = insertedStartFrame + clipDurationFrames

    -- Persist state for testReplaceAudio
    local stateFilePath = tempDir .. sep .. TEST_CLIP_STATE_FILE
    local state = {
        track_index = usedTrack,
        start_frame = insertedStartFrame,
        end_frame   = insertedEndFrame,
        clip_name   = result.clipName or "edited_music_test_a",
        file_path   = wavPath,
        frame_rate  = fps,
        timestamp   = os.time()
    }
    writeAll(stateFilePath, generateJSON(state))
    print("[EdiTedMusic] Test clip state saved to: " .. stateFilePath)

    return {
        ok          = true,
        track_index = usedTrack,
        start_frame = insertedStartFrame,
        end_frame   = insertedEndFrame,
        file_path   = wavPath,
        clip_name   = result.clipName or "edited_music_test_a",
        message     = string.format("Inserted 440 Hz WAV on audio track %d at frame %d", usedTrack, insertedStartFrame)
    }
end

function testReplaceAudio()
    print("[EdiTedMusic] testReplaceAudio called")

    -- Load saved state
    local stateFilePath = tempDir .. sep .. TEST_CLIP_STATE_FILE
    local stateRaw = readAll(stateFilePath)
    if not stateRaw then
        return {ok = false, error = "No previous test clip found — run Insert test audio first."}
    end
    local ok, state = pcall(JSON.decode, stateRaw)
    if not ok or not state then
        return {ok = false, error = "Corrupt state file — run Insert test audio first."}
    end

    -- Ensure project/timeline available
    if not project or not timeline then
        return {ok = false, error = "No project/timeline active — open a timeline first"}
    end

    -- Generate 880 Hz test WAV if not present
    local wavPath = tempDir .. sep .. "edited_music_test_b.wav"
    if not fileHasContent(wavPath) then
        print("[EdiTedMusic] Generating 880 Hz test WAV: " .. wavPath)
        local genOk = generateTestWav(wavPath, 880, 3)
        if not genOk then
            return {ok = false, error = "ffmpeg unavailable or failed to generate replacement WAV at: " .. wavPath}
        end
        print("[EdiTedMusic] Replacement WAV generated")
    else
        print("[EdiTedMusic] Reusing existing replacement WAV: " .. wavPath)
    end

    local trackIndex = tonumber(state.track_index) or 1
    local clipName   = state.clip_name or ""
    local startSec   = 0
    if state.frame_rate and state.frame_rate > 0 and state.start_frame then
        local tlStart = timeline:GetStartFrame() or 0
        startSec = (tonumber(state.start_frame) - tlStart) / tonumber(state.frame_rate)
    end

    local replaceArgs = {
        filePath    = wavPath,
        filename    = "edited_music_test_b",
        trackIndex  = trackIndex,
        clipName    = clipName,
        startSec    = startSec
    }

    local result = replaceAudioOnTimeline(replaceArgs)
    if result.error then
        return {ok = false, error = "replaceAudioOnTimeline failed: " .. result.error}
    end

    -- Update state file with new file path
    state.file_path = wavPath
    state.clip_name = result.clipName or "edited_music_test_b"
    state.timestamp = os.time()
    writeAll(stateFilePath, generateJSON(state))

    return {
        ok           = true,
        track_index  = trackIndex,
        start_frame  = state.start_frame,
        replaced_with = wavPath,
        clip_name    = result.clipName or "edited_music_test_b",
        message      = string.format("Replaced clip on track %d with 880 Hz WAV", trackIndex)
    }
end

function testBatchImport(args)
    print("[EdiTedMusic] testBatchImport called")

    if not project or not timeline or not mediaPool then
        return {ok = false, error = "No project/timeline active — open a timeline first"}
    end

    -- Default: keep clips on timeline. Pass {keepClips=false} to auto-clean.
    local keepClips = true
    if args and args.keepClips == false then keepClips = false end
    print(string.format("[EdiTedMusic] testBatchImport keepClips=%s (args=%s)",
        tostring(keepClips), tostring(args and args.keepClips)))

    local freqs = {440, 660, 880}
    local wavPaths = {}
    for i = 1, 3 do
        local p = tempDir .. sep .. string.format("edited_music_batch_%d.wav", i)
        if not fileHasContent(p) then
            if not generateTestWav(p, freqs[i], 2) then
                return {ok = false, error = "ffmpeg failed for batch WAV " .. i}
            end
        end
        wavPaths[i] = p
    end

    local trackResult = findOrCreateAudioTrack()
    if trackResult.error then
        return {ok = false, error = "Track error: " .. trackResult.error}
    end
    local trackIndex = trackResult.trackIndex

    local preItems = timeline:GetItemListInTrack("audio", trackIndex)
    if preItems and #preItems > 0 then
        timeline:DeleteClips(preItems)
        busyWait(0.3)
    end

    local startSecs = {0, 5, 10}
    local importArgs = {
        trackIndex = trackIndex,
        clips = {
            {filePath = wavPaths[1], filename = "edited_music_batch_1", startSec = startSecs[1], endSec = startSecs[1] + 2},
            {filePath = wavPaths[2], filename = "edited_music_batch_2", startSec = startSecs[2], endSec = startSecs[2] + 2},
            {filePath = wavPaths[3], filename = "edited_music_batch_3", startSec = startSecs[3], endSec = startSecs[3] + 2},
        }
    }

    local result = importAudioToTimeline(importArgs)
    if not result or not result.ok or not result.results or #result.results ~= 3 then
        return {ok = false, error = "Batch import did not return 3 results"}
    end
    for i, r in ipairs(result.results) do
        if not r.ok then
            return {ok = false, error = string.format("Clip %d failed: %s", i, tostring(r.error))}
        end
    end

    busyWait(0.3)
    local items = timeline:GetItemListInTrack("audio", trackIndex) or {}
    if #items ~= 3 then
        return {ok = false, error = string.format("Expected 3 items, found %d", #items)}
    end

    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local tlStart = timeline:GetStartFrame() or 0
    table.sort(items, function(a, b) return a:GetStart() < b:GetStart() end)
    local frames = {}
    for i, item in ipairs(items) do
        frames[i] = item:GetStart()
        local expected = tlStart + math.floor(startSecs[i] * fps)
        if math.abs(item:GetStart() - expected) > 1 then
            timeline:DeleteClips(items)
            return {ok = false, error = string.format(
                "Clip %d at frame %d, expected ~%d", i, item:GetStart(), expected)}
        end
    end

    if not keepClips then
        timeline:DeleteClips(items)
        busyWait(0.2)
    end

    return {
        ok          = true,
        track_index = trackIndex,
        clip_count  = 3,
        frames      = frames,
        kept_clips  = keepClips,
        message     = string.format("Batch-imported 3 WAVs on track %d at frames %d/%d/%d%s",
                                    trackIndex, frames[1], frames[2], frames[3],
                                    keepClips and " (clips kept on timeline)" or " (clips cleaned up)")
    }
end

-- Simulates the multi-song flow with In/Out marks set on the timeline:
-- pipeline returns segments with start_time/end_time RELATIVE to the In-point,
-- UI offsets them by inSeconds, then calls importAudioToTimeline per segment.
--
-- Invariant: the FIRST song's start frame must equal the In-point frame.
-- When no In/Out marks are set, offset = 0 and the first song lands at the
-- timeline start (parity with testBatchImport).
--
-- args: {keepClips=true|false} — same convention as testBatchImport
function testMultiSongInOut(args)
    print("[EdiTedMusic] testMultiSongInOut called")

    if not project or not timeline or not mediaPool then
        return {ok = false, error = "No project/timeline active — open a timeline first"}
    end

    local keepClips = true
    if args and args.keepClips == false then keepClips = false end

    local fps = tonumber(timeline:GetSetting("timelineFrameRate")) or 24
    local tlStart = timeline:GetStartFrame() or 0

    -- Read In/Out (mirrors getTimelineInfo / live-state polling)
    local offsetSec = 0
    local hasInOut = false
    local markIn, markOut = nil, nil
    local okMarks, markInOut = pcall(function() return timeline:GetMarkInOut() end)
    if okMarks and markInOut then
        markIn, markOut = parseMarkInOut(markInOut)
        if markIn and markOut and markOut > markIn then
            hasInOut = true
            offsetSec = framesToSeconds(markIn, fps, 0)
        end
    end
    print(string.format("[EdiTedMusic] testMultiSongInOut: hasInOut=%s offsetSec=%.3f",
        tostring(hasInOut), offsetSec))

    -- If In/Out span is too short for 3 songs, fail loudly so the user
    -- sets a wider range. Each song is 2s; we space them with 1s gaps:
    -- raw timestamps [0,2], [3,5], [6,8] — total 8s of coverage.
    local SONG_DUR = 2
    local rawStarts = {0, 3, 6}
    local rawEnds   = {SONG_DUR, 3 + SONG_DUR, 6 + SONG_DUR}
    local requiredSpan = rawEnds[#rawEnds]
    if hasInOut then
        local span = framesToSeconds(markOut - markIn, fps, 0)
        if span < requiredSpan then
            return {ok = false, error = string.format(
                "In/Out span %.2fs is shorter than required %ds — widen the In/Out range",
                span, requiredSpan)}
        end
    end

    -- Generate 3 distinct WAVs (440/660/880 Hz, 2s each)
    local freqs = {440, 660, 880}
    local wavPaths = {}
    for i = 1, 3 do
        local p = tempDir .. sep .. string.format("edited_music_multi_inout_%d.wav", i)
        if not fileHasContent(p) then
            if not generateTestWav(p, freqs[i], SONG_DUR) then
                return {ok = false, error = "ffmpeg failed for multi-song WAV " .. i}
            end
        end
        wavPaths[i] = p
    end

    local trackResult = findOrCreateAudioTrack()
    if trackResult.error then
        return {ok = false, error = "Track error: " .. trackResult.error}
    end
    local trackIndex = trackResult.trackIndex

    -- Wipe target track so our 3 clips are the only items
    local preItems = timeline:GetItemListInTrack("audio", trackIndex)
    if preItems and #preItems > 0 then
        timeline:DeleteClips(preItems)
        busyWait(0.3)
    end

    -- Apply the In-point offset (this is what the UI does via getMultiTimelineOffsetSec)
    local clips = {}
    for i = 1, 3 do
        clips[i] = {
            filePath  = wavPaths[i],
            filename  = string.format("edited_music_multi_inout_%d", i),
            startSec  = rawStarts[i] + offsetSec,
            endSec    = rawEnds[i]   + offsetSec,
        }
    end

    local result = importAudioToTimeline({trackIndex = trackIndex, clips = clips})
    if not result or not result.ok or not result.results or #result.results ~= 3 then
        return {ok = false, error = "Multi-song import did not return 3 results"}
    end
    for i, r in ipairs(result.results) do
        if not r.ok then
            return {ok = false, error = string.format("Song %d failed: %s", i, tostring(r.error))}
        end
    end

    busyWait(0.3)

    -- Verify exactly 3 clips on the track
    local items = timeline:GetItemListInTrack("audio", trackIndex) or {}
    if #items ~= 3 then
        return {ok = false, error = string.format("Expected 3 items, found %d", #items)}
    end
    table.sort(items, function(a, b) return a:GetStart() < b:GetStart() end)

    -- KEY INVARIANT: first song's start frame == In-point frame
    local firstStart = items[1]:GetStart()
    local expectedFirst = tlStart + secToFrames(offsetSec, fps)
    if math.abs(firstStart - expectedFirst) > 1 then
        if not keepClips then timeline:DeleteClips(items) end
        return {ok = false, error = string.format(
            "First song at frame %d, expected %d (In-point frame, offset=%.3fs)",
            firstStart, expectedFirst, offsetSec)}
    end

    -- Each song lands at its computed timeline position
    local frames = {}
    for i, item in ipairs(items) do
        frames[i] = item:GetStart()
        local expected = tlStart + secToFrames(rawStarts[i] + offsetSec, fps)
        if math.abs(item:GetStart() - expected) > 1 then
            if not keepClips then timeline:DeleteClips(items) end
            return {ok = false, error = string.format(
                "Song %d at frame %d, expected ~%d", i, item:GetStart(), expected)}
        end
    end

    -- Ordered & non-overlapping (start[i] >= end[i-1])
    for i = 2, 3 do
        if items[i]:GetStart() < items[i-1]:GetEnd() - 1 then
            if not keepClips then timeline:DeleteClips(items) end
            return {ok = false, error = string.format(
                "Song %d overlaps song %d (start=%d, prev end=%d)",
                i, i-1, items[i]:GetStart(), items[i-1]:GetEnd())}
        end
    end

    if not keepClips then
        timeline:DeleteClips(items)
        busyWait(0.2)
    end

    return {
        ok               = true,
        track_index      = trackIndex,
        clip_count       = 3,
        frames           = frames,
        has_in_out       = hasInOut,
        offset_sec       = offsetSec,
        first_start_frame = firstStart,
        expected_first_frame = expectedFirst,
        kept_clips       = keepClips,
        message          = string.format(
            "Placed 3 songs on track %d at frames %d/%d/%d (In-point offset=%.3fs)%s",
            trackIndex, frames[1], frames[2], frames[3], offsetSec,
            keepClips and " (clips kept)" or " (clips cleaned up)")
    }
end

function testReplaceById()
    print("[EdiTedMusic] testReplaceById called")

    if not project or not timeline or not mediaPool then
        return {ok = false, error = "No project/timeline active — open a timeline first"}
    end

    local wavA = tempDir .. sep .. "edited_music_idtest_a.wav"
    local wavB = tempDir .. sep .. "edited_music_idtest_b.wav"
    if not fileHasContent(wavA) then
        if not generateTestWav(wavA, 440, 2) then
            return {ok = false, error = "ffmpeg failed for 440 Hz WAV"}
        end
    end
    if not fileHasContent(wavB) then
        if not generateTestWav(wavB, 880, 2) then
            return {ok = false, error = "ffmpeg failed for 880 Hz WAV"}
        end
    end

    local trackResult = findOrCreateAudioTrack()
    if trackResult.error then
        return {ok = false, error = "Track error: " .. trackResult.error}
    end
    local trackIndex = trackResult.trackIndex

    local preItems = timeline:GetItemListInTrack("audio", trackIndex)
    if preItems and #preItems > 0 then
        timeline:DeleteClips(preItems)
        busyWait(0.3)
    end

    local insertResult = importAudioToTimeline({
        filePath   = wavA,
        filename   = "edited_music_idtest_a",
        trackIndex = trackIndex,
        startSec   = 0,
        endSec     = 2,  -- 2s test WAV
    })
    if not insertResult or not insertResult.success then
        return {ok = false, error = "Insert failed: " .. tostring(insertResult and insertResult.error or "unknown")}
    end
    local originalId = insertResult.clipUniqueId
    local originalName = insertResult.clipName
    if not originalId then
        return {ok = false, error = "GetUniqueId returned nil — API may not support it on this Resolve build"}
    end

    busyWait(0.2)
    local itemsBefore = timeline:GetItemListInTrack("audio", trackIndex) or {}
    if #itemsBefore ~= 1 then
        return {ok = false, error = string.format("Expected 1 clip before replace, found %d", #itemsBefore)}
    end
    local originalStartFrame = itemsBefore[1]:GetStart()

    local replaceResult = replaceAudioOnTimeline({
        filePath     = wavB,
        filename     = "edited_music_idtest_b",
        trackIndex   = trackIndex,
        clipUniqueId = originalId,
    })
    if not replaceResult or not replaceResult.success then
        return {ok = false, error = "Replace failed: " .. tostring(replaceResult and replaceResult.error or "unknown")}
    end

    busyWait(0.3)
    local itemsAfter = timeline:GetItemListInTrack("audio", trackIndex) or {}
    if #itemsAfter ~= 1 then
        for _, it in ipairs(itemsAfter) do timeline:DeleteClips({it}) end
        return {ok = false, error = string.format("Expected 1 clip after replace, found %d", #itemsAfter)}
    end
    local newStartFrame = itemsAfter[1]:GetStart()
    if math.abs(newStartFrame - originalStartFrame) > 1 then
        timeline:DeleteClips(itemsAfter)
        return {ok = false, error = string.format(
            "Start frame drifted: was %d, now %d", originalStartFrame, newStartFrame)}
    end
    local newName = replaceResult.clipName or ""
    local newId = replaceResult.clipUniqueId
    if newName == originalName then
        timeline:DeleteClips(itemsAfter)
        return {ok = false, error = "clipName unchanged after replace — new clip not actually placed"}
    end
    if not newId then
        timeline:DeleteClips(itemsAfter)
        return {ok = false, error = "New clipUniqueId missing in replace result"}
    end
    if tostring(newId) == tostring(originalId) then
        timeline:DeleteClips(itemsAfter)
        return {ok = false, error = "New clipUniqueId equals original — replace did not produce a new TimelineItem"}
    end

    timeline:DeleteClips(itemsAfter)
    busyWait(0.2)

    return {
        ok           = true,
        track_index  = trackIndex,
        original_id  = tostring(originalId),
        new_id       = tostring(newId),
        original_name = originalName,
        new_name     = newName,
        start_frame  = newStartFrame,
        message      = string.format("Replaced clip by ID on track %d (id %s -> %s)",
                                     trackIndex, tostring(originalId), tostring(newId))
    }
end

function testCleanupMusicTrack()
    print("[EdiTedMusic] testCleanupMusicTrack called (sweep-only mode)")

    if not project or not timeline then
        return {ok = false, error = "No project/timeline active — open a timeline first"}
    end

    local trackCount = timeline:GetTrackCount("audio") or 0
    local totalDeleted = 0
    local perTrack = {}
    local allNames = {}

    for trackIndex = 1, trackCount do
        local result = cleanupMusicTrack({trackIndex = trackIndex})
        if result and result.ok and (result.deletedCount or 0) > 0 then
            totalDeleted = totalDeleted + result.deletedCount
            table.insert(perTrack, {trackIndex = trackIndex, deletedCount = result.deletedCount})
            for _, n in ipairs(result.deletedNames or {}) do
                table.insert(allNames, n)
            end
        end
    end

    return {
        ok            = true,
        track_count   = trackCount,
        deleted_count = totalDeleted,
        per_track     = perTrack,
        deleted_names = allNames,
        message       = string.format("Swept %d audio track(s), removed %d EdiTed clip(s)",
                                      trackCount, totalDeleted)
    }
end

-- ============================================================
-- 12. Server Script Generator (PowerShell on Windows, Perl on macOS)
-- ============================================================
function writeServerScript()
    if isWindows then
    -- Windows: PowerShell server
    local ps = io.open(serverScriptPath, "w")
    if not ps then
        print("[EdiTedMusic] ERROR: Cannot write server script")
        return false
    end

    local errorLogPath = tempDir .. sep .. "edited_music_error.log"
    ps:write([=[
param($htmlFile, $liveFile, $commandFile, $resultFile, $readyFile, $stopFile, $port)
$errorLog = $readyFile -replace '[^\\\/]+$', 'edited_music_error.log'
$pidFile  = $readyFile -replace '[^\\\/]+$', 'edited_music_server.pid'
$portFile = $readyFile -replace '[^\\\/]+$', 'edited_music_server_port'
$basePort = [int]$port
$lastError = $null
$l = $null
for ($candidate = $basePort; $candidate -lt ($basePort + 20); $candidate++) {
    $candidateListener = $null
    try {
        $candidateListener = [Net.HttpListener]::new()
        # Register BOTH loopback prefixes. The browser UI navigates to
        # http://localhost:<port>/ (Host header = localhost) while the Lua client
        # calls http://127.0.0.1:<port>/ (Host header = 127.0.0.1). HttpListener
        # matches the prefix hostname against the request's Host header, so if we
        # register only one, the other client gets HTTP 400 "Invalid Hostname".
        # Both are loopback => no netsh urlacl / admin reservation required.
        $candidateListener.Prefixes.Add("http://localhost:${candidate}/")
        $candidateListener.Prefixes.Add("http://127.0.0.1:${candidate}/")
        $candidateListener.Start()
        $l = $candidateListener
        $port = $candidate
        [IO.File]::WriteAllText($pidFile, [string]$PID)
        [IO.File]::WriteAllText($portFile, [string]$candidate)
        [IO.File]::WriteAllText($readyFile, "ready")
        break
    } catch {
        $lastError = $_.Exception.Message
        if ($candidateListener) {
            try { $candidateListener.Close() } catch { }
        }
    }
}
if (-not $l) {
    [IO.File]::WriteAllText($errorLog, "Server start failed on ports $basePort-$($basePort + 19): $lastError")
    exit 1
}

# --- In-memory bridge state (no files needed for command/result/live) ---
# $pendingCommands: FIFO list of command JSON strings posted by the browser.
# A single pending slot matches the existing app-layer serialization
# (sendCommand awaits each call), but a list is slightly more robust.
$pendingCommands = [System.Collections.Generic.Queue[string]]::new()
# $results: id -> JSON string.  Retained ~300s after store (NOT deleted on read)
# so a dropped/garbled client read can retry — delete-on-read orphaned the
# browser's 5-min wait on a single lost GET (v0.18).
$results = @{}
$resultTimes = @{}   # id -> [DateTime] stored-at, for TTL purge
# $liveState: last JSON pushed by Lua via POST /lua-live.
$liveState = '{"status":"idle"}'

# --- No global outbound HTTP changes ---
# Any global ServicePointManager mutation (Expect100Continue, SecurityProtocol)
# has been observed to break Epidemic Sound MCP downloads (HTTP 406).
# All outbound HTTP hardening is now scoped per-endpoint inside Handle-AuthProxy.

function Write-JsonResponse($resp, $statusCode, $jsonText) {
    $resp.StatusCode = $statusCode
    $resp.ContentType = "application/json; charset=utf-8"
    $bytes = [Text.Encoding]::UTF8.GetBytes($jsonText)
    $resp.OutputStream.Write($bytes, 0, $bytes.Length)
}

# Upload progress slots keyed by client-provided progress_id (UUID-ish).
# Each slot: @{ bytes_sent; total_bytes; done; error; status; body; content_type;
#               started_at_ms; finished_at_ms }. Slots survive ~30s after done
# so a slow final poll can still read the result. Mutated from a background
# runspace during async uploads and read from the main listener thread —
# Synchronized() guards reads/writes.
$uploadProgress = [hashtable]::Synchronized(@{})

# Runspace pool used ONLY for async upload forwarding (one in-flight upload
# per submit is the realistic case, but allow a small pool for safety).
$uploadRsPool = [runspacefactory]::CreateRunspacePool(1, 4)
$uploadRsPool.Open()

function Json-EscapeString($s) {
    if ($null -eq $s) { return '' }
    $out = [string]$s
    $out = $out -replace '\\', '\\\\'
    $out = $out -replace '"', '\"'
    $out = $out -replace "`r", '\r'
    $out = $out -replace "`n", '\n'
    $out = $out -replace "`t", '\t'
    return $out
}

function Prune-UploadProgress {
    # Drop slots that finished more than 30s ago so the table doesn't grow
    # unbounded across many runs of the extension.
    $cutoff = [DateTimeOffset]::Now.ToUnixTimeMilliseconds() - 30000
    $stale = @()
    foreach ($k in @($uploadProgress.Keys)) {
        $slot = $uploadProgress[$k]
        if ($slot -and $slot.done -and $slot.finished_at_ms -and $slot.finished_at_ms -lt $cutoff) {
            $stale += $k
        }
    }
    foreach ($k in $stale) { [void]$uploadProgress.Remove($k) }
}

$cloudDefault = "]=] .. CLOUD_SERVER .. [=["
function Get-CloudServer {
    $configPath = $liveFile -replace '[^\\\/]+$', 'edited_music_config.json'
    if (Test-Path $configPath) {
        try {
            $cfg = Get-Content $configPath -Raw | ConvertFrom-Json
            if ($cfg.cloud_server -and $cfg.cloud_server -ne '') {
                return $cfg.cloud_server
            }
        } catch { }
    }
    return $cloudDefault
}

function Test-TransientNetError($err) {
    # True only for transport-level blips worth retrying (DNS/connect/timeout).
    # HTTP protocol errors (4xx/5xx) carry a real .Response and are NOT transient.
    try {
        $st = $err.Exception.Status
        if ($null -eq $st) { return $false }
        return @(
            [System.Net.WebExceptionStatus]::NameResolutionFailure,
            [System.Net.WebExceptionStatus]::ConnectFailure,
            [System.Net.WebExceptionStatus]::Timeout,
            [System.Net.WebExceptionStatus]::ConnectionClosed,
            [System.Net.WebExceptionStatus]::ReceiveFailure,
            [System.Net.WebExceptionStatus]::SendFailure,
            [System.Net.WebExceptionStatus]::KeepAliveFailure,
            [System.Net.WebExceptionStatus]::PipelineFailure
        ) -contains $st
    } catch { return $false }
}

function Get-NetFriendly($err) {
    # Map a transport-level WebException to {code, message} the UI can show
    # instead of the raw ".NET remote name could not be resolved" text.
    $code = 'network'
    try {
        switch ($err.Exception.Status) {
            ([System.Net.WebExceptionStatus]::NameResolutionFailure) { $code = 'dns' }
            ([System.Net.WebExceptionStatus]::ConnectFailure)        { $code = 'connect' }
            ([System.Net.WebExceptionStatus]::Timeout)               { $code = 'timeout' }
            ([System.Net.WebExceptionStatus]::TrustFailure)          { $code = 'tls' }
            ([System.Net.WebExceptionStatus]::SecureChannelFailure)  { $code = 'tls' }
            default { $code = 'network' }
        }
    } catch { $code = 'network' }
    switch ($code) {
        'dns'     { $msg = "Couldn't reach the EdiTed server (DNS lookup failed). Check your internet/VPN and try again - the server may have been waking up." }
        'connect' { $msg = "Couldn't connect to the EdiTed server. Check your internet connection and try again." }
        'timeout' { $msg = "The EdiTed server took too long to respond (it may be waking up). Please try again." }
        'tls'     { $msg = "Secure connection to the EdiTed server failed. Check your clock/date and any VPN or proxy." }
        default   { $msg = "Network error reaching the EdiTed server. Please check your connection and try again." }
    }
    return @{ code = $code; message = $msg }
}

function Start-AsyncUpload($target, $tmpBodyFile, $authHeader, $progressId, $progressState, $rsPool) {
    # Spawns a runspace that streams $tmpBodyFile to $target with progress
    # updates written to $progressState[$progressId]. Returns immediately.
    # The runspace owns $tmpBodyFile and deletes it when done.
    $fileLen = (Get-Item $tmpBodyFile).Length
    $nowMs = [DateTimeOffset]::Now.ToUnixTimeMilliseconds()
    $progressState[$progressId] = @{
        bytes_sent     = 0
        total_bytes    = $fileLen
        done           = $false
        error          = $null
        status         = 0
        body           = $null
        content_type   = $null
        started_at_ms  = $nowMs
        finished_at_ms = 0
    }

    $script = {
        param($target, $tmpBodyFile, $authHeader, $progressId, $progressState)
        $req = $null
        $sent = 0L
        try {
            $req = [System.Net.HttpWebRequest]::Create($target)
            $req.Method = 'POST'
            $req.ContentType = 'application/json'
            $req.Timeout = 600000
            $req.ReadWriteTimeout = 600000
            $req.AllowWriteStreamBuffering = $false
            $req.ServicePoint.Expect100Continue = $false
            if ($authHeader -and $authHeader -ne '') {
                $req.Headers['Authorization'] = $authHeader
            }
            $fs = [IO.File]::OpenRead($tmpBodyFile)
            try {
                $req.ContentLength = $fs.Length
                $reqStream = $req.GetRequestStream()
                try {
                    $buf = New-Object byte[] 65536
                    $lastUpdate = 0L
                    while ($true) {
                        $n = $fs.Read($buf, 0, $buf.Length)
                        if ($n -le 0) { break }
                        $reqStream.Write($buf, 0, $n)
                        $sent += $n
                        # Throttle hashtable writes to ~every 64KB.
                        if (($sent - $lastUpdate) -ge 65536) {
                            $slot = $progressState[$progressId]
                            if ($slot) { $slot.bytes_sent = $sent }
                            $lastUpdate = $sent
                        }
                    }
                    $reqStream.Flush()
                } finally {
                    $reqStream.Close()
                }
            } finally {
                $fs.Close()
            }
            # Make sure the final byte count is visible to pollers.
            $slot = $progressState[$progressId]
            if ($slot) { $slot.bytes_sent = $sent }

            # Read the response.
            $resp = $req.GetResponse()
            $status = [int]$resp.StatusCode
            $ct = $resp.ContentType
            $sr = New-Object IO.StreamReader($resp.GetResponseStream())
            $body = $sr.ReadToEnd()
            $sr.Close()
            $resp.Close()

            $slot = $progressState[$progressId]
            if ($slot) {
                $slot.status         = $status
                $slot.body           = $body
                $slot.content_type   = $ct
                $slot.done           = $true
                $slot.finished_at_ms = [DateTimeOffset]::Now.ToUnixTimeMilliseconds()
            }
        } catch {
            # Capture upstream error response if available so the client can
            # still surface the real HTTP status. WebException thrown from a
            # background runspace is typically wrapped in MethodInvocationException,
            # so walk the InnerException chain to find the WebResponse.
            $status = 0
            $body = $null
            $ct = $null
            $webResp = $null
            $exWalk = $_.Exception
            while ($exWalk -ne $null -and $webResp -eq $null) {
                try { if ($exWalk.Response) { $webResp = $exWalk.Response } } catch { }
                $exWalk = $exWalk.InnerException
            }
            if ($webResp) {
                try { $status = [int]$webResp.StatusCode } catch { $status = 500 }
                try {
                    $sr = New-Object IO.StreamReader($webResp.GetResponseStream())
                    $body = $sr.ReadToEnd()
                    $sr.Close()
                    $ct = $webResp.ContentType
                } catch { }
            }
            # When there's no upstream HTTP response, this was a transport blip
            # (DNS/connect/timeout). Replace the raw .NET text with a friendly line.
            $errMsg = $_.Exception.Message
            if (-not $webResp) {
                try {
                    switch ($_.Exception.Status) {
                        ([System.Net.WebExceptionStatus]::NameResolutionFailure) { $errMsg = "Couldn't reach the EdiTed server (DNS lookup failed). Check your internet/VPN and try again." }
                        ([System.Net.WebExceptionStatus]::ConnectFailure)        { $errMsg = "Couldn't connect to the EdiTed server. Check your connection and try again." }
                        ([System.Net.WebExceptionStatus]::Timeout)               { $errMsg = "The EdiTed server took too long to respond. Please try again." }
                    }
                } catch { }
            }
            $slot = $progressState[$progressId]
            if ($slot) {
                $slot.error          = $errMsg
                $slot.status         = if ($status -gt 0) { $status } else { 502 }
                $slot.body           = $body
                $slot.content_type   = $ct
                $slot.done           = $true
                $slot.finished_at_ms = [DateTimeOffset]::Now.ToUnixTimeMilliseconds()
            }
        } finally {
            if ($tmpBodyFile -and (Test-Path $tmpBodyFile)) {
                try { Remove-Item $tmpBodyFile -Force -ErrorAction SilentlyContinue } catch { }
            }
        }
    }

    $ps = [powershell]::Create()
    $ps.RunspacePool = $rsPool
    [void]$ps.AddScript($script)
    [void]$ps.AddArgument($target)
    [void]$ps.AddArgument($tmpBodyFile)
    [void]$ps.AddArgument($authHeader)
    [void]$ps.AddArgument($progressId)
    [void]$ps.AddArgument($progressState)
    [void]$ps.BeginInvoke()
}

function Handle-AuthProxy($req, $resp) {
    $endpoint = $req.Headers['X-EdiTedMusic-Endpoint']
    if ([string]::IsNullOrWhiteSpace($endpoint) -or -not $endpoint.StartsWith('/')) {
        Write-JsonResponse $resp 400 '{"error":"Invalid or missing X-EdiTedMusic-Endpoint header"}'
        return
    }

    $httpMethod = $req.Headers['X-EdiTedMusic-Method']
    if ([string]::IsNullOrWhiteSpace($httpMethod)) { $httpMethod = 'POST' }

    $allowed = @('/api/login', '/api/signup', '/api/me', '/api/generate-from-scene/submit', '/api/generate-from-scene/confirm', '/api/generate-from-scene/result', '/api/process-track', '/api/generate-from-scene/follow-up', '/api/generate-multiple/submit', '/api/generate-multiple/result', '/api/generate-multiple/followup', '/api/process-multi-tracks', '/api/es/track-audio', '/api/es/preview-audio', '/api/es/cover-image', '/api/health', '/api/auth/google/device-code')
    # Allow result polling with dynamic IDs
    $isAllowed = $false
    foreach ($a in $allowed) {
        if ($endpoint -eq $a -or $endpoint.StartsWith("$a/")) {
            $isAllowed = $true
            break
        }
    }
    if (-not $isAllowed) {
        Write-JsonResponse $resp 400 '{"error":"Endpoint not allowed"}'
        return
    }

    $target = "$(Get-CloudServer)$endpoint"

    $authArgs = @{}
    $authHeader = $req.Headers['Authorization']
    if (-not [string]::IsNullOrWhiteSpace($authHeader)) {
        $authArgs['Headers'] = @{ 'Authorization' = $authHeader }
    }

    # Disable Expect: 100-continue for THIS endpoint only (Render's edge proxy
    # drops the connection during large uploads when the client sends Expect:100-continue).
    # Scoped per-ServicePoint so it does NOT affect ES MCP or other targets.
    try {
        $sp = [Net.ServicePointManager]::FindServicePoint([Uri]$target)
        $sp.Expect100Continue = $false
    } catch { }

    # ---- Async upload path -------------------------------------------------
    # If the client supplied an X-EdiTedMusic-Progress-Id header AND this is a
    # POST to the multi-song submit endpoint, fork the upload to a background
    # runspace and reply immediately with HTTP 202 + the progress_id. The
    # client polls /upload-progress?id=<id> to watch bytes fly and to pick up
    # the final Render response when done=true. Old clients (no header) fall
    # through to the original synchronous path below.
    $progressId = $req.Headers['X-EdiTedMusic-Progress-Id']
    if ($httpMethod -ne 'GET' -and -not [string]::IsNullOrWhiteSpace($progressId) `
        -and $endpoint -eq '/api/generate-multiple/submit') {
        $asyncBodyFile = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'edited_proxy_async_' + [Guid]::NewGuid().ToString('N') + '.json')
        try {
            $fs = [IO.File]::Create($asyncBodyFile)
            try { $req.InputStream.CopyTo($fs) } finally { $fs.Close() }
        } catch {
            if (Test-Path $asyncBodyFile) { try { Remove-Item $asyncBodyFile -Force -ErrorAction SilentlyContinue } catch { } }
            try { Write-JsonResponse $resp 500 '{"error":"Failed to buffer upload body"}' } catch { }
            return
        }

        Prune-UploadProgress
        Start-AsyncUpload $target $asyncBodyFile $authHeader $progressId $uploadProgress $uploadRsPool

        $jsonOut = '{"async":true,"progress_id":"' + (Json-EscapeString $progressId) + '"}'
        Write-JsonResponse $resp 202 $jsonOut
        return
    }

    # Stream the incoming body straight to a temp file (no string conversion).
    # PowerShell 5.1's ConvertFrom-Json / ConvertTo-Json on multi-MB base64
    # payloads can take minutes; raw byte copy is O(N) memcpy.
    $tmpBodyFile = $null
    try {
        if ($httpMethod -ne 'GET') {
            $tmpBodyFile = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'edited_proxy_' + [Guid]::NewGuid().ToString('N') + '.json')
            $fs = [IO.File]::Create($tmpBodyFile)
            try {
                $req.InputStream.CopyTo($fs)
            } finally {
                $fs.Close()
            }
        }
        # Retry transient DNS/connect/timeout blips (Render cold-start or a stale
        # local DNS cache). The temp body file is re-readable, so POST retries are
        # safe; HTTP protocol errors (4xx/5xx) carry a .Response and are NOT retried.
        $r = $null
        $netAttempt = 0
        while ($true) {
            $netAttempt++
            try {
                if ($httpMethod -eq 'GET') {
                    $r = Invoke-WebRequest -Uri $target -Method GET -ContentType 'application/json' @authArgs -UseBasicParsing -TimeoutSec 600
                } else {
                    $r = Invoke-WebRequest -Uri $target -Method POST -ContentType 'application/json' -InFile $tmpBodyFile @authArgs -UseBasicParsing -TimeoutSec 600
                }
                break
            } catch {
                if ((Test-TransientNetError $_) -and $netAttempt -lt 3) {
                    Start-Sleep -Milliseconds (500 * $netAttempt)
                    continue
                }
                throw
            }
        }
        $resp.StatusCode = [int]$r.StatusCode
        $ct = $r.Headers['Content-Type']
        if ([string]::IsNullOrWhiteSpace($ct)) { $ct = 'application/json; charset=utf-8' }
        $resp.ContentType = $ct
        $bytes = [Text.Encoding]::UTF8.GetBytes($r.Content)
        $resp.OutputStream.Write($bytes, 0, $bytes.Length)
    } catch {
        $webResp = $_.Exception.Response
        if ($webResp) {
            try {
                try {
                    $resp.StatusCode = [int]$webResp.StatusCode
                } catch { $resp.StatusCode = 500 }
                $sr = New-Object IO.StreamReader($webResp.GetResponseStream())
                $errBody = $sr.ReadToEnd()
                $sr.Close()
                if ([string]::IsNullOrWhiteSpace($errBody)) { $errBody = '{"error":"Upstream error"}' }
                $resp.ContentType = 'application/json; charset=utf-8'
                $bytes = [Text.Encoding]::UTF8.GetBytes($errBody)
                $resp.OutputStream.Write($bytes, 0, $bytes.Length)
            } catch { }
        } else {
            # No HTTP response = transport failure (DNS/connect/timeout/TLS).
            # Return a clean message + machine-readable net_error so the UI shows
            # something friendly instead of the raw .NET exception text.
            $nf = Get-NetFriendly $_
            try { Write-JsonResponse $resp 502 ('{"error":"' + (Json-EscapeString $nf.message) + '","net_error":"' + $nf.code + '"}') } catch { }
        }
    } finally {
        if ($tmpBodyFile -and (Test-Path $tmpBodyFile)) {
            try { Remove-Item $tmpBodyFile -Force -ErrorAction SilentlyContinue } catch { }
        }
    }
}

function Handle-UploadProgress($req, $resp) {
    # Local-only endpoint (the whole server is loopback-bound). Returns the
    # live progress slot for $progressId. The slot also embeds the final
    # response body once done=true so the client can pick it up without a
    # second hop.
    $qid = ''
    $query = $req.Url.Query
    if ($query -match '(?:^|[?&])id=([^&]+)') { $qid = [Uri]::UnescapeDataString($matches[1]) }
    if ([string]::IsNullOrWhiteSpace($qid)) {
        Write-JsonResponse $resp 400 '{"error":"Missing id"}'
        return
    }
    $slot = $uploadProgress[$qid]
    if (-not $slot) {
        # No slot yet — could be a poll that raced ahead of Start-AsyncUpload's
        # first hashtable write, OR the slot was pruned 30s after completion.
        # Reply with a benign "not yet" payload so the client retries.
        Write-JsonResponse $resp 404 '{"error":"Unknown progress_id","bytes_sent":0,"total_bytes":0,"done":false}'
        return
    }
    $now = [DateTimeOffset]::Now.ToUnixTimeMilliseconds()
    $startedAt = [int64]($slot.started_at_ms)
    $elapsed = [int64]($now - $startedAt)
    $bytesSent = [int64]($slot.bytes_sent)
    $totalBytes = [int64]($slot.total_bytes)
    $done = if ($slot.done) { 'true' } else { 'false' }
    $errPart = ''
    if ($slot.error) {
        $errPart = ',"error":"' + (Json-EscapeString $slot.error) + '"'
    }
    $respPart = ''
    if ($slot.done) {
        $bodyEsc = Json-EscapeString $slot.body
        $ctEsc = Json-EscapeString $slot.content_type
        $respPart = ',"response_status":' + [int]$slot.status `
                  + ',"response_content_type":"' + $ctEsc + '"' `
                  + ',"response_body":"' + $bodyEsc + '"'
    }
    $jsonOut = '{"bytes_sent":' + $bytesSent `
             + ',"total_bytes":' + $totalBytes `
             + ',"done":' + $done `
             + ',"elapsed_ms":' + $elapsed `
             + $errPart + $respPart + '}'
    Write-JsonResponse $resp 200 $jsonOut
}

try {
while (-not (Test-Path $stopFile)) {
    try {
        $ar = $l.BeginGetContext($null, $null)
    } catch {
        $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
        Add-Content -Path $errorLog -Value "[$stamp] BeginGetContext failed: $($_.Exception.Message)"
        Start-Sleep -Milliseconds 200
        continue
    }
    while (-not $ar.AsyncWaitHandle.WaitOne(500)) {
        if (Test-Path $stopFile) { break }
    }
    if (Test-Path $stopFile) { break }

    try { $ctx = $l.EndGetContext($ar) } catch { continue }
    $req = $ctx.Request
    $resp = $ctx.Response
    $resp.AddHeader("Access-Control-Allow-Origin", "*")
    $resp.AddHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
    $resp.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
    $u = $req.Url.AbsolutePath

    try {
        # Handle CORS preflight
        if ($req.HttpMethod -eq 'OPTIONS') {
            $resp.StatusCode = 200
            $resp.ContentType = 'text/plain'
            $bytes = [Text.Encoding]::UTF8.GetBytes('')
            $resp.OutputStream.Write($bytes, 0, $bytes.Length)
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/') {
            # Serve main HTML (no-cache so edits always reload)
            $b = [IO.File]::ReadAllBytes($htmlFile)
            $resp.ContentType = 'text/html; charset=utf-8'
            $resp.Headers.Add('Cache-Control', 'no-store, no-cache, must-revalidate')
            $resp.Headers.Add('Pragma', 'no-cache')
            $resp.OutputStream.Write($b, 0, $b.Length)
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/live') {
            # Serve live state from in-memory $liveState (set by Lua via POST /lua-live)
            Write-JsonResponse $resp 200 $liveState
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/preview') {
            # No-key preview proxy. The public lqmp3 preview URL is CORS-locked to
            # epidemicsound.com (its Access-Control-Allow-Origin is www.epidemicsound.com),
            # so the browser can neither fetch() it (the no-key import) nor reliably
            # <audio>-play it from http://localhost. Fetch it SERVER-SIDE (no CORS) and
            # stream the bytes back same-origin. Host-restricted to the epidemicsound CDN
            # so this can never be used as an open proxy.
            $purl = ''
            $pq = $req.Url.Query
            if ($pq -match '(?:^|[?&])url=([^&]+)') { $purl = [Uri]::UnescapeDataString($matches[1]) }
            if ([string]::IsNullOrWhiteSpace($purl) -or ($purl -notmatch '^https://([A-Za-z0-9-]+\.)*epidemicsound\.com/')) {
                Write-JsonResponse $resp 400 '{"error":"Missing or disallowed preview url"}'
            } else {
                try {
                    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
                    # Use Invoke-WebRequest, NOT WebClient.DownloadData: the latter
                    # truncated the body to a hard 1 MiB cap on this host (a 2.64 MB
                    # lqmp3 came back as exactly 1048576 bytes). RawContentStream holds
                    # the FULL raw bytes regardless of content type; set ContentLength64
                    # explicitly so the response is a clean, complete non-chunked body.
                    $ir = Invoke-WebRequest -Uri $purl -UseBasicParsing -TimeoutSec 25 -Headers @{ 'Referer' = 'https://www.epidemicsound.com/'; 'User-Agent' = 'Mozilla/5.0' }
                    $pbytes = $ir.RawContentStream.ToArray()
                    $resp.ContentType = 'audio/mpeg'
                    $resp.Headers.Add('Cache-Control', 'no-store')
                    $resp.ContentLength64 = $pbytes.Length
                    $resp.OutputStream.Write($pbytes, 0, $pbytes.Length)
                } catch {
                    try { Write-JsonResponse $resp 502 '{"error":"Preview proxy fetch failed"}' } catch { }
                }
            }
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/es-media') {
            # Authed binary proxy for Library cover-image / preview-audio. The browser
            # talks only to localhost, so it can't reach the cloud's BINARY ES media
            # endpoints directly (and our /auth-proxy JSON-parses, which would corrupt
            # bytes). The endpoint rides the X-EdiTedMusic-Endpoint header (must be a
            # preview-audio/cover-image path); the user's Bearer token rides the
            # Authorization header. We GET it server-side and stream the raw bytes
            # back same-origin so <img>/<audio> blobs work. Prefix-locked so it can
            # never be used as an open proxy.
            $ep = $req.Headers['X-EdiTedMusic-Endpoint']
            $okEp = $false
            if (-not [string]::IsNullOrWhiteSpace($ep) -and $ep.StartsWith('/')) {
                if ($ep.StartsWith('/api/es/preview-audio/') -or $ep.StartsWith('/api/es/cover-image/')) { $okEp = $true }
            }
            if (-not $okEp) {
                Write-JsonResponse $resp 400 '{"error":"Disallowed media endpoint"}'
            } else {
                try {
                    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
                    $mtarget = "$(Get-CloudServer)$ep"
                    $mhead = @{}
                    $mauth = $req.Headers['Authorization']
                    if (-not [string]::IsNullOrWhiteSpace($mauth)) { $mhead['Authorization'] = $mauth }
                    # RawContentStream holds the FULL bytes regardless of content type
                    # (mirrors the /preview rationale — WebClient truncated at 1 MiB).
                    # Retry transient DNS/connect/timeout blips (idempotent GET).
                    $mr = $null
                    $mAttempt = 0
                    while ($true) {
                        $mAttempt++
                        try {
                            $mr = Invoke-WebRequest -Uri $mtarget -UseBasicParsing -TimeoutSec 30 -Headers $mhead
                            break
                        } catch {
                            if ((Test-TransientNetError $_) -and $mAttempt -lt 3) {
                                Start-Sleep -Milliseconds (400 * $mAttempt)
                                continue
                            }
                            throw
                        }
                    }
                    $mbytes = $mr.RawContentStream.ToArray()
                    $mct = [string]$mr.Headers['Content-Type']
                    if ([string]::IsNullOrWhiteSpace($mct)) { $mct = 'application/octet-stream' }
                    $resp.ContentType = $mct
                    $resp.Headers.Add('Cache-Control', 'private, max-age=3600')
                    $resp.ContentLength64 = $mbytes.Length
                    $resp.OutputStream.Write($mbytes, 0, $mbytes.Length)
                } catch {
                    # 404 (e.g. ES tracks have no cover) + upstream HTTP errors → 502
                    # as before (the UI keeps its note/placeholder fallback). Transport
                    # blips (DNS/connect/timeout) get a friendly, retryable message.
                    if ($_.Exception.Response) {
                        try { Write-JsonResponse $resp 502 '{"error":"ES media proxy fetch failed"}' } catch { }
                    } else {
                        $nf = Get-NetFriendly $_
                        try { Write-JsonResponse $resp 502 ('{"error":"' + (Json-EscapeString $nf.message) + '","net_error":"' + $nf.code + '"}') } catch { }
                    }
                }
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/command') {
            # Receive command from browser; enqueue in memory for Lua to poll.
            # Contract unchanged: respond {"ok":true}.
            $rd = [IO.StreamReader]::new($req.InputStream)
            $cmdBody = $rd.ReadToEnd()
            $pendingCommands.Enqueue($cmdBody)
            Write-JsonResponse $resp 200 '{"ok":true}'
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/result') {
            # Serve per-id result from in-memory $results; consume once.
            $qid = ''
            $query = $req.Url.Query
            if ($query -match '(?:^|[?&])id=([^&]+)') { $qid = [Uri]::UnescapeDataString($matches[1]) }
            if ([string]::IsNullOrWhiteSpace($qid)) {
                Write-JsonResponse $resp 400 '{"error":"Missing id"}'
            } else {
                if ($results.ContainsKey($qid)) {
                    # Do NOT delete on read — retained until TTL purge (see /lua-result)
                    # so a dropped/garbled client read can simply retry. (v0.18)
                    $stored = $results[$qid]
                    $resp.ContentType = 'application/json; charset=utf-8'
                    $bytes = [Text.Encoding]::UTF8.GetBytes($stored)
                    $resp.OutputStream.Write($bytes, 0, $bytes.Length)
                } else {
                    Write-JsonResponse $resp 404 '{"error":"No result"}'
                }
            }
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/lua-poll') {
            # At-least-once delivery: PEEK the head (do NOT remove). A command is
            # only removed once its result is in $results (Lua finished it) — so a
            # lost/garbled scratch-file read on the Lua side just re-serves the same
            # command next poll instead of dropping it. (The old Dequeue-then-send
            # lost ~20% of commands to that read race.) Self-clean done heads first.
            while ($pendingCommands.Count -gt 0) {
                $head = $pendingCommands.Peek()
                $headId = $null
                try { $headId = [string]($head | ConvertFrom-Json).id } catch { $headId = $null }
                if ($headId -and $results.ContainsKey($headId)) {
                    [void]$pendingCommands.Dequeue()   # already completed; drop it
                } else {
                    break
                }
            }
            if ($pendingCommands.Count -gt 0) {
                Write-JsonResponse $resp 200 $pendingCommands.Peek()
            } else {
                $resp.StatusCode = 204
                $resp.ContentType = 'application/json; charset=utf-8'
                $resp.OutputStream.Write([byte[]]@(), 0, 0)
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/lua-result') {
            # Lua pushes result JSON (must contain "id" field); store in $results.
            $rd = [IO.StreamReader]::new($req.InputStream)
            $resBody = $rd.ReadToEnd()
            try {
                $parsed = $resBody | ConvertFrom-Json
                $rid = [string]$parsed.id
                if (-not [string]::IsNullOrWhiteSpace($rid)) {
                    # Purge results older than the client's max polling window (~300s)
                    # so the table can't grow unbounded now that reads don't delete.
                    $cutoff = (Get-Date).AddSeconds(-300)
                    foreach ($k in @($resultTimes.Keys)) {
                        if ($resultTimes[$k] -lt $cutoff) { [void]$results.Remove($k); [void]$resultTimes.Remove($k) }
                    }
                    $results[$rid] = $resBody
                    $resultTimes[$rid] = (Get-Date)
                    Write-JsonResponse $resp 200 '{"ok":true}'
                } else {
                    Write-JsonResponse $resp 400 '{"error":"Missing id in result body"}'
                }
            } catch {
                Write-JsonResponse $resp 400 '{"error":"Invalid JSON in result body"}'
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/lua-live') {
            # Lua pushes live state JSON; store in $liveState for browser polls.
            $rd = [IO.StreamReader]::new($req.InputStream)
            $liveState = $rd.ReadToEnd()
            Write-JsonResponse $resp 200 '{"ok":true}'
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/save-session') {
            $rd = [IO.StreamReader]::new($req.InputStream)
            $sessionData = $rd.ReadToEnd()
            $sessionPath = $liveFile -replace '[^\\\/]+$', 'edited_music_session.json'
            [IO.File]::WriteAllText($sessionPath, $sessionData)
            Write-JsonResponse $resp 200 '{"ok":true}'
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/load-session') {
            $sessionPath = $liveFile -replace '[^\\\/]+$', 'edited_music_session.json'
            if (Test-Path $sessionPath) {
                $b = [IO.File]::ReadAllBytes($sessionPath)
                $resp.ContentType = 'application/json; charset=utf-8'
                $resp.OutputStream.Write($b, 0, $b.Length)
            } else {
                Write-JsonResponse $resp 200 '{}'
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/save-config') {
            $rd = [IO.StreamReader]::new($req.InputStream)
            $configData = $rd.ReadToEnd()
            $configPath = $liveFile -replace '[^\\\/]+$', 'edited_music_config.json'
            [IO.File]::WriteAllText($configPath, $configData)
            Write-JsonResponse $resp 200 '{"ok":true}'
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/get-config') {
            $configPath = $liveFile -replace '[^\\\/]+$', 'edited_music_config.json'
            if (Test-Path $configPath) {
                $b = [IO.File]::ReadAllBytes($configPath)
                $resp.ContentType = 'application/json; charset=utf-8'
                $resp.OutputStream.Write($b, 0, $b.Length)
            } else {
                Write-JsonResponse $resp 200 '{}'
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/es-download-and-process') {
            $rd = [IO.StreamReader]::new($req.InputStream)
            $bodyText = $rd.ReadToEnd()
            try {
                $params = $bodyText | ConvertFrom-Json
            } catch {
                Write-JsonResponse $resp 400 '{"error":"Invalid JSON"}'
                $resp.Close()
                continue
            }

            $esKey = $params.es_api_key
            $recId = $params.recording_id
            $reqId = $params.request_id
            $authToken = $params.auth_token

            if (-not $esKey -or -not $recId -or -not $reqId) {
                Write-JsonResponse $resp 400 '{"error":"Missing es_api_key, recording_id, or request_id"}'
                $resp.Close()
                continue
            }

            $progressId = $req.Headers['X-EdiTedMusic-Progress-Id']
            if (-not [string]::IsNullOrWhiteSpace($progressId)) {
                Prune-UploadProgress
                $nowMs = [DateTimeOffset]::Now.ToUnixTimeMilliseconds()
                $uploadProgress[$progressId] = @{
                    bytes_sent     = 0
                    total_bytes    = 0
                    done           = $false
                    error          = $null
                    status         = 0
                    body           = $null
                    content_type   = $null
                    started_at_ms  = $nowMs
                    finished_at_ms = 0
                }

                $esScript = {
                    param($esKey, $recId, $reqId, $authToken, $progressId, $progressState, $cloudServer)
                    $tmpBodyFile = $null
                    try {
                        # Step 1: Initialize MCP session
                        $mcpUrl = 'https://www.epidemicsound.com/a/mcp-service/mcp'
                        $mcpHeaders = @{
                            'Content-Type' = 'application/json'
                            'Accept' = 'application/json, text/event-stream'
                            'Authorization' = "Bearer $esKey"
                        }
                        $initBody = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve","version":"0.13"}}}'
                        $initResp = Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $mcpHeaders -Body $initBody -ContentType 'application/json' -UseBasicParsing -TimeoutSec 30
                        $sessionId = $initResp.Headers['Mcp-Session-Id']
                        if ($sessionId) { $mcpHeaders['Mcp-Session-Id'] = $sessionId }

                        # Send initialized notification
                        $notifBody = '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
                        Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $mcpHeaders -Body $notifBody -ContentType 'application/json' -UseBasicParsing -TimeoutSec 10 | Out-Null

                        # Step 2: Call DownloadRecording tool
                        $dlBody = ('{"jsonrpc":"2.0","id":' + [DateTimeOffset]::Now.ToUnixTimeMilliseconds() + ',"method":"tools/call","params":{"name":"DownloadRecording","arguments":{"id":"' + $recId + '","options":{"fileType":"MP3","stemType":"FULL"}}}}')
                        $dlResp = Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $mcpHeaders -Body $dlBody -ContentType 'application/json' -UseBasicParsing -TimeoutSec 60

                        # Step 3: Parse SSE response for assetUrl
                        $assetUrl = $null
                        $dlText = $dlResp.Content
                        foreach ($line in $dlText -split "`n") {
                            $trimmed = $line.Trim()
                            if ($trimmed.StartsWith('data: ')) {
                                $jsonStr = $trimmed.Substring(6)
                                try {
                                    $parsed = $jsonStr | ConvertFrom-Json
                                    if ($parsed.result -and $parsed.result.content) {
                                        foreach ($item in $parsed.result.content) {
                                            if ($item.type -eq 'text' -and $item.text) {
                                                try {
                                                    $inner = $item.text | ConvertFrom-Json
                                                    if ($inner.data.recordingDownload.assetUrl) { $assetUrl = $inner.data.recordingDownload.assetUrl }
                                                    elseif ($inner.data.downloadRecording.assetUrl) { $assetUrl = $inner.data.downloadRecording.assetUrl }
                                                    elseif ($inner.assetUrl) { $assetUrl = $inner.assetUrl }
                                                } catch {}
                                            }
                                        }
                                    }
                                } catch {}
                            }
                        }
                        # Fallback: try parsing as raw JSON
                        if (-not $assetUrl) {
                            try {
                                $raw = $dlText | ConvertFrom-Json
                                if ($raw.result.content) {
                                    foreach ($item in $raw.result.content) {
                                        if ($item.type -eq 'text') {
                                            $inner = $item.text | ConvertFrom-Json
                                            if ($inner.data.recordingDownload.assetUrl) { $assetUrl = $inner.data.recordingDownload.assetUrl }
                                            elseif ($inner.data.downloadRecording.assetUrl) { $assetUrl = $inner.data.downloadRecording.assetUrl }
                                        }
                                    }
                                }
                            } catch {}
                        }

                        if (-not $assetUrl) {
                            throw "Could not extract download URL from ES"
                        }

                        # Step 4: Download the MP3 file
                        $mp3Resp = Invoke-WebRequest -Uri $assetUrl -Method GET -UseBasicParsing -TimeoutSec 120
                        $mp3Bytes = $mp3Resp.Content
                        $mp3B64 = [Convert]::ToBase64String($mp3Bytes)

                        # Write upload body to a temp file
                        $tmpBodyFile = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'edited_es_upload_' + [Guid]::NewGuid().ToString('N') + '.json')
                        $fs = [IO.File]::Create($tmpBodyFile)
                        try {
                            $prefixBytes = [Text.Encoding]::UTF8.GetBytes('{"audio_base64":"')
                            $suffixBytes = [Text.Encoding]::UTF8.GetBytes('"}')
                            $b64Bytes = [Text.Encoding]::UTF8.GetBytes($mp3B64)
                            $fs.Write($prefixBytes, 0, $prefixBytes.Length)
                            $fs.Write($b64Bytes, 0, $b64Bytes.Length)
                            $fs.Write($suffixBytes, 0, $suffixBytes.Length)
                        } finally {
                            $fs.Close()
                        }

                        $fileLen = (Get-Item $tmpBodyFile).Length
                        $slot = $progressState[$progressId]
                        if ($slot) {
                            $slot.total_bytes = $fileLen
                        }

                        $processTarget = "$cloudServer/api/process-track/$reqId"
                        $req = [System.Net.HttpWebRequest]::Create($processTarget)
                        $req.Method = 'POST'
                        $req.ContentType = 'application/json'
                        $req.Timeout = 600000
                        $req.ReadWriteTimeout = 600000
                        $req.AllowWriteStreamBuffering = $false
                        $req.ServicePoint.Expect100Continue = $false
                        if ($authToken -and $authToken -ne '') {
                            $req.Headers['Authorization'] = "Bearer $authToken"
                        }

                        $fs = [IO.File]::OpenRead($tmpBodyFile)
                        try {
                            $req.ContentLength = $fs.Length
                            $reqStream = $req.GetRequestStream()
                            try {
                                $buf = New-Object byte[] 65536
                                $sent = 0L
                                $lastUpdate = 0L
                                while ($true) {
                                    $n = $fs.Read($buf, 0, $buf.Length)
                                    if ($n -le 0) { break }
                                    $reqStream.Write($buf, 0, $n)
                                    $sent += $n
                                    if (($sent - $lastUpdate) -ge 65536) {
                                        $slot = $progressState[$progressId]
                                        if ($slot) { $slot.bytes_sent = $sent }
                                        $lastUpdate = $sent
                                    }
                                }
                                $reqStream.Flush()
                            } finally {
                                $reqStream.Close()
                            }
                        } finally {
                            $fs.Close()
                        }

                        $slot = $progressState[$progressId]
                        if ($slot) { $slot.bytes_sent = $sent }

                        $webResp = $req.GetResponse()
                        $status = [int]$webResp.StatusCode
                        $ct = $webResp.ContentType
                        $sr = New-Object IO.StreamReader($webResp.GetResponseStream())
                        $body = $sr.ReadToEnd()
                        $sr.Close()
                        $webResp.Close()

                        $slot = $progressState[$progressId]
                        if ($slot) {
                            $slot.status         = $status
                            $slot.body           = $body
                            $slot.content_type   = $ct
                            $slot.done           = $true
                            $slot.finished_at_ms = [DateTimeOffset]::Now.ToUnixTimeMilliseconds()
                        }
                    } catch {
                        $status = 0
                        $body = $null
                        $ct = $null
                        $webResp = $null
                        $exWalk = $_.Exception
                        while ($exWalk -ne $null -and $webResp -eq $null) {
                            try { if ($exWalk.Response) { $webResp = $exWalk.Response } } catch { }
                            $exWalk = $exWalk.InnerException
                        }
                        if ($webResp) {
                            try { $status = [int]$webResp.StatusCode } catch { $status = 500 }
                            try {
                                $sr = New-Object IO.StreamReader($webResp.GetResponseStream())
                                $body = $sr.ReadToEnd()
                                $sr.Close()
                                $ct = $webResp.ContentType
                            } catch { }
                        }
                        $slot = $progressState[$progressId]
                        if ($slot) {
                            $slot.error          = $_.Exception.Message
                            $slot.status         = if ($status -gt 0) { $status } else { 500 }
                            $slot.body           = $body
                            $slot.content_type   = $ct
                            $slot.done           = $true
                            $slot.finished_at_ms = [DateTimeOffset]::Now.ToUnixTimeMilliseconds()
                        }
                    } finally {
                        if ($tmpBodyFile -and (Test-Path $tmpBodyFile)) {
                            try { Remove-Item $tmpBodyFile -Force -ErrorAction SilentlyContinue } catch { }
                        }
                    }
                }

                $ps = [powershell]::Create()
                $ps.RunspacePool = $uploadRsPool
                [void]$ps.AddScript($esScript)
                [void]$ps.AddArgument($esKey)
                [void]$ps.AddArgument($recId)
                [void]$ps.AddArgument($reqId)
                [void]$ps.AddArgument($authToken)
                [void]$ps.AddArgument($progressId)
                [void]$ps.AddArgument($uploadProgress)
                [void]$ps.AddArgument((Get-CloudServer))
                [void]$ps.BeginInvoke()

                $jsonOut = '{"async":true,"progress_id":"' + (Json-EscapeString $progressId) + '"}'
                Write-JsonResponse $resp 202 $jsonOut
                $resp.Close()
                continue
            }

            # Sync fallback removed. The legacy inline path forwarded a
            # multi-MB base64 body to /api/process-track on the single-
            # threaded listener thread, stalling /live polling and every
            # other UI request for 30–60s. The browser UI always sets
            # X-EdiTedMusic-Progress-Id (see doMatchMusic in EdiTedMusicUI.html),
            # so this branch only fires for stale/non-standard callers.
            Write-JsonResponse $resp 400 '{"error":"X-EdiTedMusic-Progress-Id header required for /es-download-and-process — reload the extension UI"}'
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/es-test-connection') {
            $rd = [IO.StreamReader]::new($req.InputStream)
            $body = $rd.ReadToEnd()
            try {
                $params = $body | ConvertFrom-Json
            } catch {
                Write-JsonResponse $resp 400 '{"error":"Invalid JSON"}'
                $resp.Close()
                continue
            }
            $esKey = $params.es_api_key
            if (-not $esKey) {
                Write-JsonResponse $resp 400 '{"error":"Missing es_api_key"}'
                $resp.Close()
                continue
            }
            try {
                $mcpUrl = 'https://www.epidemicsound.com/a/mcp-service/mcp'
                $initBody = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve-Test","version":"0.13"}}}'
                $headers = @{
                    'Content-Type' = 'application/json'
                    'Accept' = 'application/json, text/event-stream'
                    'Authorization' = "Bearer $esKey"
                }
                $initResp = Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $headers -Body $initBody -UseBasicParsing -TimeoutSec 30
                $sessionId = ''
                try { $sessionId = $initResp.Headers['Mcp-Session-Id'] } catch { }
                if (-not $sessionId) { $sessionId = '' }
                $jsonOut = '{"ok":true,"session_id":"' + $sessionId + '","status":' + [int]$initResp.StatusCode + '}'
                Write-JsonResponse $resp 200 $jsonOut
            } catch {
                $errMsg = $_.Exception.Message -replace '"', '\"'
                try { Write-JsonResponse $resp 500 ('{"error":"ES connection failed: ' + $errMsg + '"}') } catch { }
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/es-test-download') {
            $rd = [IO.StreamReader]::new($req.InputStream)
            $body = $rd.ReadToEnd()
            try {
                $params = $body | ConvertFrom-Json
            } catch {
                Write-JsonResponse $resp 400 '{"error":"Invalid JSON"}'
                $resp.Close()
                continue
            }
            $esKey = $params.es_api_key
            $recId = $params.recording_id
            if (-not $esKey -or -not $recId) {
                Write-JsonResponse $resp 400 '{"error":"Missing es_api_key or recording_id"}'
                $resp.Close()
                continue
            }
            try {
                $mcpUrl = 'https://www.epidemicsound.com/a/mcp-service/mcp'
                $initHdr = @{
                    'Content-Type' = 'application/json'
                    'Accept' = 'application/json, text/event-stream'
                    'Authorization' = "Bearer $esKey"
                }
                $initBody = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve-Test","version":"0.13"}}}'
                $initResp = Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $initHdr -Body $initBody -UseBasicParsing -TimeoutSec 30
                $sessionId = ''
                try { $sessionId = $initResp.Headers['Mcp-Session-Id'] } catch { }

                $sessHdr = @{
                    'Content-Type' = 'application/json'
                    'Accept' = 'application/json, text/event-stream'
                    'Authorization' = "Bearer $esKey"
                }
                if ($sessionId) { $sessHdr['Mcp-Session-Id'] = $sessionId }
                $notifBody = '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
                try { Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $sessHdr -Body $notifBody -UseBasicParsing -TimeoutSec 10 | Out-Null } catch { }

                $ts = [int64](Get-Date -UFormat %s) * 1000
                $dlBody = '{"jsonrpc":"2.0","id":' + $ts + ',"method":"tools/call","params":{"name":"DownloadRecording","arguments":{"id":"' + $recId + '","options":{"fileType":"MP3","stemType":"FULL"}}}}'
                $dlResp = Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $sessHdr -Body $dlBody -UseBasicParsing -TimeoutSec 60
                $dlText = $dlResp.Content
                $assetUrl = ''
                $matches = [regex]::Matches($dlText, '"assetUrl"\s*:\s*"([^"]+)"')
                if ($matches.Count -gt 0) { $assetUrl = $matches[0].Groups[1].Value }
                if (-not $assetUrl) {
                    Write-JsonResponse $resp 500 '{"error":"Could not extract asset URL from ES response"}'
                    $resp.Close()
                    continue
                }

                $mp3Resp = Invoke-WebRequest -Uri $assetUrl -Method GET -UseBasicParsing -TimeoutSec 120
                $mp3Bytes = $mp3Resp.Content
                $mp3Size = $mp3Bytes.Length
                $mp3B64 = [Convert]::ToBase64String($mp3Bytes)
                $b64Len = $mp3B64.Length

                $jsonOut = '{"ok":true,"asset_url":"' + ($assetUrl -replace '"', '\"') + '","mp3_size_bytes":' + $mp3Size + ',"mp3_b64_length":' + $b64Len + ',"session_id":"' + $sessionId + '"}'
                Write-JsonResponse $resp 200 $jsonOut
            } catch {
                $errMsg = $_.Exception.Message -replace '"', '\"'
                try { Write-JsonResponse $resp 500 ('{"error":"ES download test failed: ' + $errMsg + '"}') } catch { }
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/es-download-raw') {
            # ES MCP download only — returns raw audio_base64. Used by multi-song flow
            # which bulk-processes via /api/process-multi-tracks/<request_id>.
            $rd = [IO.StreamReader]::new($req.InputStream)
            $bodyText = $rd.ReadToEnd()
            try {
                $params = $bodyText | ConvertFrom-Json
            } catch {
                Write-JsonResponse $resp 400 '{"error":"Invalid JSON"}'
                $resp.Close()
                continue
            }
            $esKey = $params.es_api_key
            $recId = $params.recording_id
            if (-not $esKey -or -not $recId) {
                Write-JsonResponse $resp 400 '{"error":"Missing es_api_key or recording_id"}'
                $resp.Close()
                continue
            }
            try {
                $mcpUrl = 'https://www.epidemicsound.com/a/mcp-service/mcp'
                $mcpHeaders = @{
                    'Content-Type' = 'application/json'
                    'Accept' = 'application/json, text/event-stream'
                    'Authorization' = "Bearer $esKey"
                }
                $initBody = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve-Multi","version":"0.13"}}}'
                $initResp = Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $mcpHeaders -Body $initBody -ContentType 'application/json' -UseBasicParsing -TimeoutSec 30
                $sessionId = $initResp.Headers['Mcp-Session-Id']
                if ($sessionId) { $mcpHeaders['Mcp-Session-Id'] = $sessionId }
                $notifBody = '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
                Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $mcpHeaders -Body $notifBody -ContentType 'application/json' -UseBasicParsing -TimeoutSec 10 | Out-Null

                $dlBody = ('{"jsonrpc":"2.0","id":' + [DateTimeOffset]::Now.ToUnixTimeMilliseconds() + ',"method":"tools/call","params":{"name":"DownloadRecording","arguments":{"id":"' + $recId + '","options":{"fileType":"MP3","stemType":"FULL"}}}}')
                $dlResp = Invoke-WebRequest -Uri $mcpUrl -Method POST -Headers $mcpHeaders -Body $dlBody -ContentType 'application/json' -UseBasicParsing -TimeoutSec 60

                $assetUrl = $null
                $dlText = $dlResp.Content
                foreach ($line in $dlText -split "`n") {
                    $trimmed = $line.Trim()
                    if ($trimmed.StartsWith('data: ')) {
                        $jsonStr = $trimmed.Substring(6)
                        try {
                            $parsed = $jsonStr | ConvertFrom-Json
                            if ($parsed.result -and $parsed.result.content) {
                                foreach ($item in $parsed.result.content) {
                                    if ($item.type -eq 'text' -and $item.text) {
                                        try {
                                            $inner = $item.text | ConvertFrom-Json
                                            if ($inner.data.recordingDownload.assetUrl) { $assetUrl = $inner.data.recordingDownload.assetUrl }
                                            elseif ($inner.data.downloadRecording.assetUrl) { $assetUrl = $inner.data.downloadRecording.assetUrl }
                                            elseif ($inner.assetUrl) { $assetUrl = $inner.assetUrl }
                                        } catch {}
                                    }
                                }
                            }
                        } catch {}
                    }
                }
                if (-not $assetUrl) {
                    $matches = [regex]::Matches($dlText, '"assetUrl"\s*:\s*"([^"]+)"')
                    if ($matches.Count -gt 0) { $assetUrl = $matches[0].Groups[1].Value }
                }
                if (-not $assetUrl) {
                    Write-JsonResponse $resp 500 '{"error":"Could not extract download URL from ES"}'
                    $resp.Close()
                    continue
                }

                $mp3Resp = Invoke-WebRequest -Uri $assetUrl -Method GET -UseBasicParsing -TimeoutSec 120
                $mp3Bytes = $mp3Resp.Content
                $mp3B64 = [Convert]::ToBase64String($mp3Bytes)

                # Build JSON manually to avoid massive ConvertTo-Json overhead on large strings
                $resp.StatusCode = 200
                $resp.ContentType = 'application/json; charset=utf-8'
                $prefix = '{"audio_base64":"'
                $suffix = '"}'
                $prefixBytes = [Text.Encoding]::UTF8.GetBytes($prefix)
                $b64Bytes   = [Text.Encoding]::UTF8.GetBytes($mp3B64)
                $suffixBytes= [Text.Encoding]::UTF8.GetBytes($suffix)
                $resp.OutputStream.Write($prefixBytes, 0, $prefixBytes.Length)
                $resp.OutputStream.Write($b64Bytes, 0, $b64Bytes.Length)
                $resp.OutputStream.Write($suffixBytes, 0, $suffixBytes.Length)
            } catch {
                $errMsg = $_.Exception.Message -replace '"', '\"'
                try { Write-JsonResponse $resp 500 ('{"error":"ES raw download failed: ' + $errMsg + '"}') } catch { }
            }
        } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/auth-proxy') {
            # Proxy to cloud server. Framing is now via headers
            # (X-EdiTedMusic-Endpoint, X-EdiTedMusic-Method, Authorization).
            # The request body IS the payload and is streamed straight to a
            # temp file — never converted to a string. This avoids
            # PowerShell 5.1's catastrophic ConvertFrom-Json/ConvertTo-Json
            # cost on multi-MB base64 audio uploads.
            Handle-AuthProxy $req $resp
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/upload-progress') {
            # Local-only: read live upload progress for an async submit.
            # The actual upload runs on a background runspace fired off by
            # Handle-AuthProxy so this poll is fast (just a hashtable read).
            Handle-UploadProgress $req $resp
        } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/shutdown') {
            # Graceful shutdown
            Write-JsonResponse $resp 200 '{"ok":true}'
            [IO.File]::WriteAllText($stopFile, "stop")
        } else {
            Write-JsonResponse $resp 404 '{"error":"Not found"}'
        }
    } catch {
        try { Write-JsonResponse $resp 500 ('{"error":"' + ($_.Exception.Message -replace '"', '\\"') + '"}') } catch { }
    }

    try { $resp.Close() } catch { }
}
} finally {
    try { $l.Stop() } catch { }
    try { $l.Close() } catch { }
    try { $uploadRsPool.Close() } catch { }
    try { $uploadRsPool.Dispose() } catch { }
    if (Test-Path $stopFile) { Remove-Item $stopFile -Force -ErrorAction SilentlyContinue }
    if (Test-Path $readyFile) { Remove-Item $readyFile -Force -ErrorAction SilentlyContinue }
    if (Test-Path $pidFile) { Remove-Item $pidFile -Force -ErrorAction SilentlyContinue }
    if (Test-Path $portFile) { Remove-Item $portFile -Force -ErrorAction SilentlyContinue }
}
]=])
    ps:close()
    print("[EdiTedMusic] PowerShell server script written to: " .. serverScriptPath)
    return true

    else
        -- macOS: Write Perl server
        local pl = io.open(serverScriptPath, "w")
        if not pl then
            print("[EdiTedMusic] ERROR: Cannot write server script")
            return false
        end
        pl:write([=[#!/usr/bin/perl
use strict;
use warnings;
use IO::Socket::INET;
use IO::Select;
use JSON::PP;
use MIME::Base64;

$SIG{PIPE} = 'IGNORE';  # prevent client-disconnect from killing the server process

my ($html_file, $live_file, $command_file, $result_file,
    $ready_file, $stop_file, $port) = @ARGV;

# Mirror the Windows port-fallback: try 20 consecutive ports starting at
# $port. Without this loop, macOS hard-fails on launch if anything else is
# bound to 48201 (Resolve never recovers and the UI never shows up).
my $base_port = $port + 0;
my $server;
my $last_err = '';
my $bound_port;
for my $candidate ($base_port .. $base_port + 19) {
    $server = IO::Socket::INET->new(
        LocalAddr => '127.0.0.1', LocalPort => $candidate,
        Proto => 'tcp', Reuse => 1, Listen => 10,
    );
    if ($server) { $bound_port = $candidate; last; }
    $last_err = $!;
}
die "Cannot bind any port in $base_port-" . ($base_port + 19) . ": $last_err\n" unless $server;
$port = $bound_port;

my $pid_file = $ready_file;
$pid_file =~ s/[^\/]+$/edited_music_server.pid/;
my $port_file = $ready_file;
$port_file =~ s/[^\/]+$/edited_music_server_port/;
{ open my $fh, '>', $pid_file or die; print $fh $$; close $fh; }
{ open my $fh, '>', $port_file or die; print $fh $port; close $fh; }
{ open my $fh, '>', $ready_file or die; print $fh 'ready'; close $fh; }

my $cloud_default = "]=] .. CLOUD_SERVER .. [=[";
sub get_cloud_server {
    my $cfg_path = $live_file;
    $cfg_path =~ s/[^\/]+$/edited_music_config.json/;
    if (-f $cfg_path) {
        if (open(my $fh, '<', $cfg_path)) {
            my $raw = do { local $/; <$fh> }; close $fh;
            if ($raw =~ /"cloud_server"\s*:\s*"([^"]*)"/) {
                return $1 if $1 ne '';
            }
        }
    }
    return $cloud_default;
}

# --- In-memory bridge state (no files needed for command/result/live) ---
# @pending_commands: FIFO queue of command JSON strings posted by the browser.
# %results:         id -> JSON string, retained ~300s (NOT deleted on read) so a
#                   dropped client read can retry — delete-on-read hung the
#                   browser on one lost GET (v0.18).
# $live_state:      last JSON pushed by Lua via POST /lua-live.
my @pending_commands;
my %results;
my %result_times;   # id -> epoch seconds stored-at, for TTL purge
my $live_state = '{"status":"idle"}';

my $sel = IO::Select->new($server);

while (!-e $stop_file) {
    my @ready = $sel->can_read(0.5);
    for my $sock (@ready) {
        next unless $sock == $server;
        my $client = $server->accept() or next;
        $client->autoflush(1);
        eval { handle_client($client) };
        eval { $client->close() };
    }
}

$server->close();
unlink $stop_file  if -e $stop_file;
unlink $ready_file if -e $ready_file;
unlink $pid_file   if -e $pid_file;
unlink $port_file  if -e $port_file;

sub shell_esc { my $s = shift; $s =~ s/'/'\\''/g; return "'$s'"; }

sub json_esc {
    my $s = shift // '';
    $s =~ s/\\/\\\\/g;
    $s =~ s/"/\\"/g;
    $s =~ s/\n/\\n/g;
    $s =~ s/\r/\\r/g;
    $s =~ s/\t/\\t/g;
    return $s;
}

sub send_resp {
    my ($sock, $code, $ct, $body) = @_;
    my %st = (200=>'OK', 204=>'No Content', 400=>'Bad Request',
              404=>'Not Found', 500=>'Internal Server Error');
    print $sock "HTTP/1.1 $code " . ($st{$code}||'OK') . "\r\n"
              . "Content-Type: $ct\r\n"
              . "Access-Control-Allow-Origin: *\r\n"
              . "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
              . "Access-Control-Allow-Headers: Content-Type, Authorization\r\n"
              . "Content-Length: " . length($body) . "\r\n"
              . "Connection: close\r\n\r\n" . $body;
}

sub json_resp { send_resp($_[0], $_[1], 'application/json; charset=utf-8', $_[2]); }

sub handle_client {
    my ($sock) = @_;
    my $rl = <$sock>; return unless defined $rl;
    $rl =~ s/\r?\n//;
    my ($meth, $path) = $rl =~ /^(\w+)\s+(\S+)/ or return;

    # Split off query string
    my $query = '';
    if ($path =~ /^([^?]*)\?(.*)$/) { $path = $1; $query = $2; }

    my %h;
    while (defined(my $ln = <$sock>)) {
        $ln =~ s/\r?\n//; last if $ln eq '';
        $h{lc $1} = $2 if $ln =~ /^([^:]+):\s*(.*)$/;
    }

    if ($meth eq 'OPTIONS') {
        print $sock "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\n"
                  . "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
                  . "Access-Control-Allow-Headers: Content-Type, Authorization\r\n"
                  . "Content-Length: 0\r\nConnection: close\r\n\r\n";
        return;
    }

    my $body = '';
    my $cl = ($h{'content-length'} // 0) + 0;
    if ($cl > 0) {
        my $got = 0;
        while ($got < $cl) {
            my $n = read($sock, $body, $cl - $got, $got);
            last unless defined $n && $n > 0;
            $got += $n;
        }
    }

    if ($meth eq 'GET' && $path eq '/') {
        open(my $fh, '<:raw', $html_file) or return json_resp($sock, 500, '{"error":"HTML unreadable"}');
        my $html = do { local $/; <$fh> }; close $fh;
        send_resp($sock, 200, 'text/html; charset=utf-8', $html);

    } elsif ($meth eq 'GET' && $path eq '/live') {
        # Serve live state from in-memory $live_state (set by Lua via POST /lua-live)
        json_resp($sock, 200, $live_state);

    } elsif ($meth eq 'GET' && $path eq '/preview') {
        # No-key preview proxy (see the PowerShell server for the full rationale):
        # the public lqmp3 is CORS-locked to epidemicsound.com, so fetch it
        # server-side and stream it back same-origin. Host-restricted to the
        # epidemicsound CDN so it can't be an open proxy.
        my $purl = '';
        if ($query =~ /(?:^|&)url=([^&]+)/) { $purl = $1; }
        $purl =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
        if ($purl eq '' || $purl !~ m{^https://([A-Za-z0-9-]+\.)*epidemicsound\.com/}) {
            json_resp($sock, 400, '{"error":"Missing or disallowed preview url"}');
        } else {
            my $ptmp = "/tmp/edited_music_preview_$$.mp3";
            system('curl', '-fsSL', '--max-time', '25',
                   '-H', 'Referer: https://www.epidemicsound.com/',
                   '-H', 'User-Agent: Mozilla/5.0',
                   '-o', $ptmp, $purl);
            if (-s $ptmp) {
                open(my $pf, '<:raw', $ptmp);
                my $pbytes = do { local $/; <$pf> }; close $pf;
                unlink $ptmp;
                send_resp($sock, 200, 'audio/mpeg', $pbytes);
            } else {
                unlink $ptmp;
                json_resp($sock, 502, '{"error":"Preview proxy fetch failed"}');
            }
        }

    } elsif ($meth eq 'GET' && $path eq '/es-media') {
        # Authed binary proxy for Library cover-image / preview-audio (see the
        # PowerShell server for the full rationale). The endpoint rides the
        # X-EdiTedMusic-Endpoint header; the Bearer token rides Authorization.
        # Prefix-locked so it can never be an open proxy.
        my $ep = $h{'x-editedmusic-endpoint'} // '';
        if ($ep !~ m{^/api/es/preview-audio/} && $ep !~ m{^/api/es/cover-image/}) {
            json_resp($sock, 400, '{"error":"Disallowed media endpoint"}');
        } else {
            my $auth = $h{'authorization'} // '';
            my $mtmp   = "/tmp/edited_music_esmedia_$$";
            my $hdrtmp = "/tmp/edited_music_esmedia_hdr_$$";
            my @cmd = ('curl', '-fsSL', '--max-time', '30', '-D', $hdrtmp);
            push @cmd, '-H', "Authorization: $auth" if $auth ne '';
            push @cmd, '-o', $mtmp, (get_cloud_server() . $ep);
            # Retry transient transport blips (curl 6=DNS, 7=connect, 28=timeout).
            my %estransient = map { $_ => 1 } (6, 7, 28, 35, 52, 55, 56);
            my $escc = 0;
            for my $attempt (1, 2, 3) {
                $escc = system(@cmd) >> 8;
                last if $escc == 0;
                last unless $estransient{$escc};
                select(undef, undef, undef, 0.4 * $attempt);
            }
            if (-s $mtmp) {
                open(my $mf, '<:raw', $mtmp);
                my $mbytes = do { local $/; <$mf> }; close $mf;
                # Sniff Content-Type from the saved response headers (default octet-stream).
                my $mct = 'application/octet-stream';
                if (open(my $hf, '<', $hdrtmp)) {
                    while (my $hl = <$hf>) { if ($hl =~ /^Content-Type:\s*([^\r\n;]+)/i) { $mct = $1; } }
                    close $hf;
                }
                unlink $mtmp; unlink $hdrtmp;
                send_resp($sock, 200, $mct, $mbytes);
            } else {
                unlink $mtmp; unlink $hdrtmp;
                if ($escc == 6 || $escc == 7 || $escc == 28 || $escc == 35) {
                    # Transport blip after retries → friendly, retryable message.
                    my $ncode = ($escc == 6) ? 'dns' : ($escc == 7) ? 'connect' : ($escc == 28) ? 'timeout' : 'tls';
                    my %fm = (
                        dns     => "Couldn't reach the EdiTed server (DNS lookup failed). Check your internet/VPN and try again.",
                        connect => "Couldn't connect to the EdiTed server. Check your connection and try again.",
                        timeout => "The EdiTed server took too long to respond. Please try again.",
                        tls     => "Secure connection to the EdiTed server failed.",
                    );
                    my $nmsg = $fm{$ncode}; $nmsg =~ s/(["\\])/\\$1/g;
                    json_resp($sock, 502, '{"error":"' . $nmsg . '","net_error":"' . $ncode . '"}');
                } else {
                    # 404 (ES tracks have no cover) / upstream HTTP errors; the UI
                    # keeps its note/placeholder fallback.
                    json_resp($sock, 502, '{"error":"ES media proxy fetch failed"}');
                }
            }
        }

    } elsif ($meth eq 'POST' && $path eq '/command') {
        # Receive command from browser; enqueue in memory for Lua to poll.
        push @pending_commands, $body;
        json_resp($sock, 200, '{"ok":true}');

    } elsif ($meth eq 'GET' && $path eq '/result') {
        # Serve per-id result from in-memory %results; consume once.
        my $qid = '';
        if ($query =~ /(?:^|&)id=([^&]+)/) { $qid = $1; }
        if ($qid eq '') {
            json_resp($sock, 400, '{"error":"Missing id"}');
        } else {
            if (exists $results{$qid}) {
                # Retain on read (no delete) so a dropped client read can retry. (v0.18)
                json_resp($sock, 200, $results{$qid});
            } else {
                json_resp($sock, 404, '{"error":"No result"}');
            }
        }

    } elsif ($meth eq 'GET' && $path eq '/lua-poll') {
        # At-least-once delivery: peek the head (do NOT shift). A command is only
        # removed once its result is stored in %results — so a lost Lua-side read
        # re-serves the same command next poll instead of dropping it. Self-clean
        # any already-completed heads first.
        while (@pending_commands) {
            my $head = $pending_commands[0];
            my $hid = '';
            if ($head =~ /"id"\s*:\s*"([^"]+)"/) { $hid = $1; }
            elsif ($head =~ /"id"\s*:\s*(\d+)/) { $hid = $1; }
            if ($hid ne '' && exists $results{$hid}) { shift @pending_commands; }
            else { last; }
        }
        if (@pending_commands) {
            json_resp($sock, 200, $pending_commands[0]);
        } else {
            send_resp($sock, 204, 'application/json; charset=utf-8', '');
        }

    } elsif ($meth eq 'POST' && $path eq '/lua-result') {
        # Lua pushes result JSON (must contain "id" field); store in %results.
        my $rid = '';
        if ($body =~ /"id"\s*:\s*"([^"]+)"/) { $rid = $1; }        # quoted string id
        elsif ($body =~ /"id"\s*:\s*(\d+)/) { $rid = $1; }          # unquoted numeric fallback id
        if ($rid ne '') {
            # Purge results older than ~300s (client max poll window) to bound memory.
            my $now = time();
            for my $k (keys %result_times) {
                if ($result_times{$k} < $now - 300) { delete $results{$k}; delete $result_times{$k}; }
            }
            $results{$rid} = $body;
            $result_times{$rid} = $now;
            json_resp($sock, 200, '{"ok":true}');
        } else {
            json_resp($sock, 400, '{"error":"Missing id in result body"}');
        }

    } elsif ($meth eq 'POST' && $path eq '/lua-live') {
        # Lua pushes live state JSON; store for browser GET /live polls.
        $live_state = $body;
        json_resp($sock, 200, '{"ok":true}');

    } elsif ($meth eq 'POST' && $path eq '/save-session') {
        my $session_path = $live_file;
        $session_path =~ s/[^\/]+$/edited_music_session.json/;
        open(my $fh, '>', $session_path) or return json_resp($sock, 500, '{"error":"Cannot save"}');
        print $fh $body; close $fh;
        json_resp($sock, 200, '{"ok":true}');

    } elsif ($meth eq 'GET' && $path eq '/load-session') {
        my $session_path = $live_file;
        $session_path =~ s/[^\/]+$/edited_music_session.json/;
        if (-e $session_path && open(my $fh, '<:raw', $session_path)) {
            my $d = do { local $/; <$fh> }; close $fh;
            json_resp($sock, 200, $d);
        } else {
            json_resp($sock, 200, '{}');
        }

    } elsif ($meth eq 'POST' && $path eq '/save-config') {
        my $cfg_path = $live_file;
        $cfg_path =~ s/[^\/]+$/edited_music_config.json/;
        if (open(my $fh, '>', $cfg_path)) {
            print $fh $body; close $fh;
            json_resp($sock, 200, '{"ok":true}');
        } else {
            json_resp($sock, 500, '{"error":"write failed"}');
        }

    } elsif ($meth eq 'GET' && $path eq '/get-config') {
        my $cfg_path = $live_file;
        $cfg_path =~ s/[^\/]+$/edited_music_config.json/;
        if (-f $cfg_path && open(my $fh, '<:raw', $cfg_path)) {
            my $d = do { local $/; <$fh> }; close $fh;
            json_resp($sock, 200, $d);
        } else {
            json_resp($sock, 200, '{}');
        }

    } elsif ($meth eq 'POST' && $path eq '/auth-proxy') {
        my ($code, $rb) = auth_proxy($body, \%h);
        json_resp($sock, $code, $rb);

    } elsif ($meth eq 'GET' && $path eq '/upload-progress') {
        # macOS stub: upload progress is not yet wired through the Perl
        # server (Windows-only feature). Return 501 so the UI falls back
        # to the existing indeterminate spinner gracefully.
        json_resp($sock, 501, '{"error":"upload-progress not supported on this platform","bytes_sent":0,"total_bytes":0,"done":false}');

    } elsif ($meth eq 'POST' && $path eq '/es-download-and-process') {
        my ($code, $rb) = es_download_and_process($body);
        json_resp($sock, $code, $rb);

    } elsif ($meth eq 'POST' && $path eq '/es-download-raw') {
        my ($code, $rb) = es_download_raw($body);
        json_resp($sock, $code, $rb);

    } elsif ($meth eq 'POST' && $path eq '/es-test-connection') {
        my ($code, $rb) = es_test_connection($body);
        json_resp($sock, $code, $rb);
    } elsif ($meth eq 'POST' && $path eq '/es-test-download') {
        my ($code, $rb) = es_test_download($body);
        json_resp($sock, $code, $rb);

    } elsif ($meth eq 'GET' && $path eq '/shutdown') {
        json_resp($sock, 200, '{"ok":true}');
        open(my $fh, '>', $stop_file) or return;
        print $fh 'stop'; close $fh;

    } else {
        json_resp($sock, 404, '{"error":"Not found"}');
    }
}

sub auth_proxy {
    my ($body, $h) = @_;

    # Framing is via headers: X-EdiTedMusic-Endpoint, X-EdiTedMusic-Method,
    # Authorization. The HTTP body IS the payload and is forwarded verbatim,
    # avoiding decode_json/encode_json on multi-MB base64 audio uploads.
    my $ep     = $h->{'x-editedmusic-endpoint'} // '';
    my $method = $h->{'x-editedmusic-method'} || 'POST';
    my $auth   = $h->{'authorization'} // '';

    my @ok = ('/api/login', '/api/signup', '/api/me',
              '/api/generate-from-scene/submit', '/api/generate-from-scene/confirm',
              '/api/generate-from-scene/result',
              '/api/process-track', '/api/generate-from-scene/follow-up',
              '/api/generate-multiple/submit', '/api/generate-multiple/result',
              '/api/generate-multiple/followup', '/api/process-multi-tracks',
              '/api/es/track-audio', '/api/es/preview-audio', '/api/es/cover-image',
              '/api/health', '/api/auth/google/device-code');
    my $is_allowed = 0;
    for my $a (@ok) {
        if ($ep eq $a || index($ep, "$a/") == 0) {
            $is_allowed = 1; last;
        }
    }
    return (400, '{"error":"Endpoint not allowed"}') unless $ep =~ m{^/} && $is_allowed;

    my ($ti, $to) = ("/tmp/edited_music_prx_in_$$.json", "/tmp/edited_music_prx_out_$$.txt");
    { open(my $fh, '>:raw', $ti) or return (500, '{"error":"tmp write failed"}'); print $fh $body; close $fh; }

    my $cloud = get_cloud_server() . $ep;
    my $auth_hdr = '';
    if ($auth ne '') {
        $auth_hdr = " -H " . shell_esc("Authorization: $auth");
    }

    my $curl;
    if ($method eq 'GET') {
        $curl = "curl -sS -X GET " . shell_esc($cloud)
             . " -H 'Content-Type: application/json'"
             . $auth_hdr
             . " --max-time 600"
             . " -w '\\n__STATUS__:%{http_code}'"
             . " > " . shell_esc($to) . " 2>/dev/null";
    } else {
        $curl = "curl -sS -X POST " . shell_esc($cloud)
             . " -H 'Content-Type: application/json'"
             . " --data-binary " . shell_esc("\@$ti")
             . $auth_hdr
             . " --max-time 600"
             . " -w '\\n__STATUS__:%{http_code}'"
             . " > " . shell_esc($to) . " 2>/dev/null";
    }

    # Retry transient transport blips (curl 6=DNS, 7=connect, 28=timeout, plus a
    # few TLS/transfer codes). HTTP error responses exit 0 here (no -f), so 4xx/5xx
    # fall straight through to the parser below and are never retried.
    my %transient = map { $_ => 1 } (6, 7, 28, 35, 52, 55, 56);
    my $cc = 0;
    for my $attempt (1, 2, 3) {
        $cc = system($curl) >> 8;
        last if $cc == 0;
        last unless $transient{$cc};
        select(undef, undef, undef, 0.4 * $attempt);
    }
    unlink $ti;

    if ($cc != 0) {
        unlink $to if -e $to;
        my $ncode = ($cc == 6) ? 'dns'
                  : ($cc == 7) ? 'connect'
                  : ($cc == 28) ? 'timeout'
                  : ($cc == 35 || $cc == 53 || $cc == 60) ? 'tls'
                  : 'network';
        my %fm = (
            dns     => "Couldn't reach the EdiTed server (DNS lookup failed). Check your internet/VPN and try again - the server may have been waking up.",
            connect => "Couldn't connect to the EdiTed server. Check your internet connection and try again.",
            timeout => "The EdiTed server took too long to respond (it may be waking up). Please try again.",
            tls     => "Secure connection to the EdiTed server failed. Check your clock/date and any VPN or proxy.",
            network => "Network error reaching the EdiTed server. Please check your connection and try again.",
        );
        my $nmsg = $fm{$ncode};
        $nmsg =~ s/(["\\])/\\$1/g;
        return (502, '{"error":"' . $nmsg . '","net_error":"' . $ncode . '"}');
    }

    open(my $fh, '<:raw', $to) or do { unlink $to; return (500, '{"error":"no curl output"}') };
    my $raw = do { local $/; <$fh> }; close $fh; unlink $to;

    return (int($2), $1) if $raw =~ /\A(.*)\n__STATUS__:(\d+)\s*\z/s;
    return (500, $raw || '{"error":"curl parse error"}');
}

sub es_download_and_process {
    my ($body) = @_;
    my $req = eval { decode_json($body) };
    return (400, '{"error":"Invalid JSON"}') if $@ || ref($req) ne 'HASH';

    my $es_key = $req->{es_api_key} // '';
    my $rec_id = $req->{recording_id} // '';
    my $req_id = $req->{request_id} // '';
    my $auth_token = $req->{auth_token} // '';

    return (400, '{"error":"Missing es_api_key, recording_id, or request_id"}')
        unless $es_key && $rec_id && $req_id;

    my $mcp_url = 'https://www.epidemicsound.com/a/mcp-service/mcp';
    my $tmp_pfx = "/tmp/edited_music_es_$$";

    # Step 1: Initialize MCP session
    my $init_body = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve","version":"0.13"}}}';
    my $init_out = "${tmp_pfx}_init.txt";
    my $init_hdr = "${tmp_pfx}_init_hdr.txt";
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . " -d " . shell_esc($init_body)
        . " -D " . shell_esc($init_hdr)
        . " --max-time 30"
        . " > " . shell_esc($init_out) . " 2>/dev/null");

    my $session_id = '';
    if (open(my $fh, '<', $init_hdr)) {
        while (<$fh>) {
            if (/^mcp-session-id:\s*(.+?)\r?$/i) {
                $session_id = $1; last;
            }
        }
        close $fh;
    }
    unlink $init_out; unlink $init_hdr;

    # Step 2: Send initialized notification
    my $notif_body = '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}';
    my $sess_hdr = $session_id ? " -H " . shell_esc("Mcp-Session-Id: $session_id") : "";
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . $sess_hdr
        . " -d " . shell_esc($notif_body)
        . " --max-time 10"
        . " > /dev/null 2>&1");

    # Step 3: Call DownloadRecording
    my $ts = int(time() * 1000);
    my $rec_id_je = json_esc($rec_id);
    my $dl_body = "{\"jsonrpc\":\"2.0\",\"id\":$ts,\"method\":\"tools/call\",\"params\":{\"name\":\"DownloadRecording\",\"arguments\":{\"id\":\"$rec_id_je\",\"options\":{\"fileType\":\"MP3\",\"stemType\":\"FULL\"}}}}";
    my $dl_out = "${tmp_pfx}_dl.txt";
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . $sess_hdr
        . " -d " . shell_esc($dl_body)
        . " --max-time 60"
        . " > " . shell_esc($dl_out) . " 2>/dev/null");

    # Parse response for assetUrl
    my $asset_url = '';
    if (open(my $fh, '<', $dl_out)) {
        my $raw = do { local $/; <$fh> }; close $fh;
        while ($raw =~ /data:\s*(\{.+\})/g) {
            if ($1 =~ /"assetUrl"\s*:\s*"([^"]+)"/) {
                $asset_url = $1; last;
            }
        }
        if (!$asset_url && $raw =~ /"assetUrl"\s*:\s*"([^"]+)"/) {
            $asset_url = $1;
        }
    }
    unlink $dl_out;

    return (500, '{"error":"Could not extract download URL from ES"}') unless $asset_url;

    # Step 4: Download MP3
    my $mp3_path = "${tmp_pfx}.mp3";
    system("curl -sS " . shell_esc($asset_url) . " --max-time 120 -o " . shell_esc($mp3_path) . " 2>/dev/null");
    return (500, '{"error":"MP3 download failed"}') unless -s $mp3_path;

    # Step 5: Base64 encode
    open(my $mp3_fh, '<:raw', $mp3_path) or return (500, '{"error":"Cannot read MP3"}');
    my $mp3_data = do { local $/; <$mp3_fh> }; close $mp3_fh;
    unlink $mp3_path;
    my $mp3_b64 = encode_base64($mp3_data, '');

    # Step 6: Send to backend process-track
    (my $req_id_safe = $req_id) =~ s/[^A-Za-z0-9_\-]//g;
    my $process_url = get_cloud_server() . "/api/process-track/$req_id_safe";
    my $process_file = "${tmp_pfx}_proc.json";
    open(my $pf, '>', $process_file) or return (500, '{"error":"Cannot write process body"}');
    print $pf "{\"audio_base64\":\"" . json_esc($mp3_b64) . "\"}";
    close $pf;

    my $auth_hdr = $auth_token ? " -H " . shell_esc("Authorization: Bearer $auth_token") : "";
    my $proc_out = "${tmp_pfx}_proc_out.txt";
    system("curl -sS -X POST " . shell_esc($process_url)
        . " -H 'Content-Type: application/json'"
        . $auth_hdr
        . " --data-binary " . shell_esc("\@$process_file")
        . " --max-time 120"
        . " -w '\\n__STATUS__:%{http_code}'"
        . " > " . shell_esc($proc_out) . " 2>/dev/null");
    unlink $process_file;

    if (open(my $fh, '<:raw', $proc_out)) {
        my $raw = do { local $/; <$fh> }; close $fh;
        unlink $proc_out;
        return (int($2), $1) if $raw =~ /\A(.*)\n__STATUS__:(\d+)\s*\z/s;
        return (200, $raw);
    }
    unlink $proc_out;
    return (500, '{"error":"Process-track call failed"}');
}

sub es_download_raw {
    my ($body) = @_;
    my $req = eval { decode_json($body) };
    return (400, '{"error":"Invalid JSON"}') if $@ || ref($req) ne 'HASH';

    my $es_key = $req->{es_api_key} // '';
    my $rec_id = $req->{recording_id} // '';
    return (400, '{"error":"Missing es_api_key or recording_id"}') unless $es_key && $rec_id;

    my $mcp_url = 'https://www.epidemicsound.com/a/mcp-service/mcp';
    (my $rec_id_safe = $rec_id) =~ s/[^A-Za-z0-9_\-]/_/g;
    my $tmp_pfx = "/tmp/em_raw_$$.${rec_id_safe}";

    # Step 1: Initialize MCP session
    my $init_body = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve-Multi","version":"0.13"}}}';
    my $init_out = "${tmp_pfx}_init.txt";
    my $init_hdr = "${tmp_pfx}_init_hdr.txt";
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . " -d " . shell_esc($init_body)
        . " -D " . shell_esc($init_hdr)
        . " --max-time 30"
        . " > " . shell_esc($init_out) . " 2>/dev/null");

    my $session_id = '';
    if (open(my $fh, '<', $init_hdr)) {
        while (<$fh>) {
            if (/^mcp-session-id:\s*(.+?)\r?$/i) {
                $session_id = $1; last;
            }
        }
        close $fh;
    }
    unlink $init_out; unlink $init_hdr;

    # Step 2: Send initialized notification
    my $sess_hdr = $session_id ? " -H " . shell_esc("Mcp-Session-Id: $session_id") : "";
    my $notif_body = '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}';
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . $sess_hdr
        . " -d " . shell_esc($notif_body)
        . " --max-time 10"
        . " > /dev/null 2>&1");

    # Step 3: Call DownloadRecording
    my $ts = int(time() * 1000);
    my $rec_id_je = json_esc($rec_id);
    my $dl_body = "{\"jsonrpc\":\"2.0\",\"id\":$ts,\"method\":\"tools/call\",\"params\":{\"name\":\"DownloadRecording\",\"arguments\":{\"id\":\"$rec_id_je\",\"options\":{\"fileType\":\"MP3\",\"stemType\":\"FULL\"}}}}";
    my $dl_out = "${tmp_pfx}_dl.txt";
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . $sess_hdr
        . " -d " . shell_esc($dl_body)
        . " --max-time 60"
        . " > " . shell_esc($dl_out) . " 2>/dev/null");

    # Parse response for assetUrl
    my $asset_url = '';
    if (open(my $fh, '<', $dl_out)) {
        my $raw = do { local $/; <$fh> }; close $fh;
        while ($raw =~ /data:\s*(\{.+\})/g) {
            if ($1 =~ /"assetUrl"\s*:\s*"([^"]+)"/) {
                $asset_url = $1; last;
            }
        }
        if (!$asset_url && $raw =~ /"assetUrl"\s*:\s*"([^"]+)"/) {
            $asset_url = $1;
        }
    }
    unlink $dl_out;

    return (500, '{"success":false,"error":"Could not extract download URL from ES"}') unless $asset_url;

    # Step 4: Download MP3 (follow redirects)
    my $mp3_path = "${tmp_pfx}.mp3";
    system("curl -sS -L " . shell_esc($asset_url) . " --max-time 120 -o " . shell_esc($mp3_path) . " 2>/dev/null");
    unless (-s $mp3_path) {
        unlink $mp3_path if -e $mp3_path;
        return (500, '{"success":false,"error":"MP3 download failed"}');
    }

    # Step 5: Read MP3 and base64-encode
    open(my $mp3_fh, '<:raw', $mp3_path) or do {
        unlink $mp3_path;
        return (500, '{"success":false,"error":"Cannot read MP3"}');
    };
    my $mp3_data = do { local $/; <$mp3_fh> }; close $mp3_fh;
    unlink $mp3_path;
    my $mp3_b64 = encode_base64($mp3_data, '');

    return (200, '{"audio_base64":"' . json_esc($mp3_b64) . '","success":true}');
}

sub es_test_connection {
    my ($body) = @_;
    my $req = eval { decode_json($body) };
    return (400, '{"error":"Invalid JSON"}') if $@ || ref($req) ne 'HASH';
    my $es_key = $req->{es_api_key} // '';
    return (400, '{"error":"Missing es_api_key"}') unless $es_key;

    my $mcp_url = 'https://www.epidemicsound.com/a/mcp-service/mcp';
    my $tmp_pfx = "/tmp/edited_music_es_test_$$";
    my $init_body = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve-Test","version":"0.13"}}}';
    my $init_out = "${tmp_pfx}_init.txt";
    my $init_hdr = "${tmp_pfx}_init_hdr.txt";
    my $rc = system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . " -d " . shell_esc($init_body)
        . " -D " . shell_esc($init_hdr)
        . " --max-time 30"
        . " > " . shell_esc($init_out) . " 2>/dev/null");

    my $session_id = '';
    if (open(my $fh, '<', $init_hdr)) {
        while (<$fh>) {
            if (/^mcp-session-id:\s*(.+?)\r?$/i) { $session_id = $1; last; }
        }
        close $fh;
    }
    unlink $init_out; unlink $init_hdr;

    if ($rc != 0) {
        return (500, '{"error":"curl failed during ES init"}');
    }
    my $session_esc = $session_id; $session_esc =~ s/"/\\"/g;
    return (200, '{"ok":true,"session_id":"' . $session_esc . '"}');
}

sub es_test_download {
    my ($body) = @_;
    my $req = eval { decode_json($body) };
    return (400, '{"error":"Invalid JSON"}') if $@ || ref($req) ne 'HASH';
    my $es_key = $req->{es_api_key} // '';
    my $rec_id = $req->{recording_id} // '';
    return (400, '{"error":"Missing es_api_key or recording_id"}') unless $es_key && $rec_id;

    my $mcp_url = 'https://www.epidemicsound.com/a/mcp-service/mcp';
    my $tmp_pfx = "/tmp/edited_music_es_test_$$";

    my $init_body = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"EdiTedMusic-Resolve-Test","version":"0.13"}}}';
    my $init_out = "${tmp_pfx}_init.txt";
    my $init_hdr = "${tmp_pfx}_init_hdr.txt";
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . " -d " . shell_esc($init_body)
        . " -D " . shell_esc($init_hdr)
        . " --max-time 30"
        . " > " . shell_esc($init_out) . " 2>/dev/null");
    my $session_id = '';
    if (open(my $fh, '<', $init_hdr)) {
        while (<$fh>) {
            if (/^mcp-session-id:\s*(.+?)\r?$/i) { $session_id = $1; last; }
        }
        close $fh;
    }
    unlink $init_out; unlink $init_hdr;

    my $sess_hdr = $session_id ? " -H " . shell_esc("Mcp-Session-Id: $session_id") : "";
    my $notif_body = '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}';
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . $sess_hdr
        . " -d " . shell_esc($notif_body)
        . " --max-time 10"
        . " > /dev/null 2>&1");

    my $ts = int(time() * 1000);
    my $dl_body = "{\"jsonrpc\":\"2.0\",\"id\":$ts,\"method\":\"tools/call\",\"params\":{\"name\":\"DownloadRecording\",\"arguments\":{\"id\":\"$rec_id\",\"options\":{\"fileType\":\"MP3\",\"stemType\":\"FULL\"}}}}";
    my $dl_out = "${tmp_pfx}_dl.txt";
    system("curl -sS -X POST " . shell_esc($mcp_url)
        . " -H 'Content-Type: application/json'"
        . " -H 'Accept: application/json, text/event-stream'"
        . " -H " . shell_esc("Authorization: Bearer $es_key")
        . $sess_hdr
        . " -d " . shell_esc($dl_body)
        . " --max-time 60"
        . " > " . shell_esc($dl_out) . " 2>/dev/null");

    my $asset_url = '';
    if (open(my $fh, '<', $dl_out)) {
        my $raw = do { local $/; <$fh> }; close $fh;
        while ($raw =~ /data:\s*(\{.+\})/g) {
            if ($1 =~ /"assetUrl"\s*:\s*"([^"]+)"/) { $asset_url = $1; last; }
        }
        if (!$asset_url && $raw =~ /"assetUrl"\s*:\s*"([^"]+)"/) {
            $asset_url = $1;
        }
    }
    unlink $dl_out;
    return (500, '{"error":"Could not extract asset URL"}') unless $asset_url;

    my $mp3_path = "${tmp_pfx}.mp3";
    system("curl -sS " . shell_esc($asset_url) . " --max-time 120 -o " . shell_esc($mp3_path) . " 2>/dev/null");
    return (500, '{"error":"MP3 download failed"}') unless -s $mp3_path;
    my $mp3_size = -s $mp3_path;

    open(my $mp3_fh, '<:raw', $mp3_path) or return (500, '{"error":"Cannot read MP3"}');
    my $mp3_data = do { local $/; <$mp3_fh> }; close $mp3_fh;
    unlink $mp3_path;
    my $mp3_b64 = encode_base64($mp3_data, '');
    my $b64_len = length($mp3_b64);

    my $url_esc = $asset_url; $url_esc =~ s/"/\\"/g;
    my $sess_esc = $session_id; $sess_esc =~ s/"/\\"/g;
    return (200, '{"ok":true,"asset_url":"' . $url_esc . '","mp3_size_bytes":' . $mp3_size . ',"mp3_b64_length":' . $b64_len . ',"session_id":"' . $sess_esc . '"}');
}
]=])
        pl:close()
        -- No chmod needed: script is invoked as `/usr/bin/perl <path>`, not executed directly.
        print("[EdiTedMusic] Perl server script written to: " .. serverScriptPath)
        return true
    end
end

-- ============================================================
-- 13. Server Start/Stop
-- ============================================================
local serverProcess

local function syncSelectedServerPort()
    local selectedPortRaw = readAll(portFilePath)
    local selectedPort = selectedPortRaw and tonumber(selectedPortRaw:match("(%d+)")) or nil
    if selectedPort then
        if selectedPort ~= PORT then
            print("[EdiTedMusic] Port " .. PORT .. " unavailable; using " .. selectedPort .. " instead")
        end
        PORT = selectedPort
    end
end

function startServer()
    print("[EdiTedMusic] Starting local server on port " .. PORT .. "...")

    -- Clean up old signal files
    deleteFile(stopFilePath)
    deleteFile(readyFilePath)
    deleteFile(portFilePath)
    deleteFile(commandFilePath)
    deleteFile(resultFilePath)
    deletePerIdFiles()

    -- Write server script
    if not writeServerScript() then
        error("Failed to write server script")
    end

    -- Hoisted to function scope so the URL-conflict retry below can reuse it.
    local psParams = nil
    if isWindows then
        -- Build PowerShell parameters
        psParams = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File '
            .. '"' .. serverScriptPath .. '" '
            .. '"' .. htmlPath .. '" '
            .. '"' .. liveDataPath .. '" '
            .. '"' .. commandFilePath .. '" '
            .. '"' .. resultFilePath .. '" '
            .. '"' .. readyFilePath .. '" '
            .. '"' .. stopFilePath .. '" '
            .. PORT

        -- Launch server. Preference order:
        --   1. FFI CreateProcessA with CREATE_NO_WINDOW (true zero-flash, console never allocated)
        --   2. FFI ShellExecuteA with SW_HIDE (zero-flash, but PowerShell may still allocate console briefly)
        --   3. Raw os.execute (visible flash; absolute last resort)
        if createProcessHidden then
            print("[EdiTedMusic] Launching via CreateProcessA + CREATE_NO_WINDOW...")
            createProcessHidden('powershell.exe ' .. psParams, nil)
        elseif shellExecA then
            print("[EdiTedMusic] Launching via ShellExecuteA...")
            shellExecA("open", "powershell.exe", psParams, nil, 0)
        else
            print("[EdiTedMusic] WARNING: no FFI launch path available; falling back to visible os.execute")
            os.execute('start "" /b powershell.exe ' .. psParams)
        end
    else
        -- macOS: Launch Perl server
        print("[EdiTedMusic] Launching Perl server...")
        os.execute('/usr/bin/perl ' .. shQuote(serverScriptPath)
            .. ' ' .. shQuote(htmlPath)
            .. ' ' .. shQuote(liveDataPath)
            .. ' ' .. shQuote(commandFilePath)
            .. ' ' .. shQuote(resultFilePath)
            .. ' ' .. shQuote(readyFilePath)
            .. ' ' .. shQuote(stopFilePath)
            .. ' ' .. PORT
            .. ' > /dev/null 2>&1 &')
    end

    -- Wait for ready signal (up to 15 seconds)
    local attempts = 0
    while not fileExists(readyFilePath) and attempts < 30 do
        busyWait(0.5)
        attempts = attempts + 1
        if attempts % 5 == 0 then
            print("[EdiTedMusic] Waiting for server... (" .. attempts * 0.5 .. "s)")
        end
    end

    if fileExists(readyFilePath) then
        syncSelectedServerPort()
        print("[EdiTedMusic] Server ready on http://localhost:" .. PORT)
        return true
    end

    -- Check error log: if it's a URL-prefix conflict, an orphan PowerShell
    -- from an earlier session is still holding the listener. Kill it and
    -- retry once before giving up. Catches the case where cleanupPreviousSession
    -- couldn't identify the orphan (stale/missing PID file).
    local errorLogPath = tempDir .. sep .. "edited_music_error.log"
    local errMsg = readAll(errorLogPath)
    local isUrlConflict = isWindows and errMsg and (
        errMsg:find("conflicts with an existing registration", 1, true) or
        errMsg:find("Failed to listen on prefix", 1, true)
    )

    if isUrlConflict then
        print("[EdiTedMusic] URL conflict on port " .. PORT .. " — killing orphan and retrying...")
        deleteFile(errorLogPath)
        killOrphanServer(PORT)
        busyWait(0.5)

        -- Relaunch the server (same preference order as the initial launch:
        -- hidden CreateProcessA, then ShellExecuteA, then a visible last resort).
        if createProcessHidden then
            createProcessHidden('powershell.exe ' .. psParams, nil)
        elseif shellExecA then
            shellExecA("open", "powershell.exe", psParams, nil, 0)
        else
            print("[EdiTedMusic] WARNING: no FFI launch path available; server relaunch via visible os.execute (a console may flash)")
            os.execute('start "" /b powershell.exe ' .. psParams)
        end

        attempts = 0
        while not fileExists(readyFilePath) and attempts < 30 do
            busyWait(0.5)
            attempts = attempts + 1
            if attempts % 5 == 0 then
                print("[EdiTedMusic] Retry: waiting for server... (" .. attempts * 0.5 .. "s)")
            end
        end

        if fileExists(readyFilePath) then
            syncSelectedServerPort()
            print("[EdiTedMusic] Server ready on http://localhost:" .. PORT .. " (after orphan-kill retry)")
            return true
        end
        errMsg = readAll(errorLogPath)
    end

    if errMsg then
        print("[EdiTedMusic] ERROR: " .. errMsg)
        deleteFile(errorLogPath)
    else
        print("[EdiTedMusic] ERROR: Server failed to start (no error log)")
    end
    return false
end

function stopServer()
    print("[EdiTedMusic] Stopping server...")
    writeAll(stopFilePath, "stop")
    busyWait(1)

    -- Clean up signal files
    deleteFile(stopFilePath)
    deleteFile(readyFilePath)
    deleteFile(portFilePath)
    deleteFile(commandFilePath)
    deleteFile(resultFilePath)
    deleteFile(liveDataPath)
    deletePerIdFiles()

    print("[EdiTedMusic] Server stopped")
end

-- ============================================================
-- 14. Browser Launcher
-- ============================================================
function launchBrowser()
    print("[EdiTedMusic] Generating launcher HTML...")

    -- Create launcher that polls for server readiness
    local launcherHTML = string.format([=[
<!DOCTYPE html>
<html>
<head>
    <title>EdiTed Music - Loading...</title>
    <style>
        body {
            margin: 0;
            padding: 20px;
            font-family: Arial, sans-serif;
            background: #1a1a1a;
            color: #fff;
            display: flex;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
        }
        .loader {
            text-align: center;
        }
        .spinner {
            border: 4px solid #333;
            border-top: 4px solid #00a0ff;
            border-radius: 50%%;
            width: 40px;
            height: 40px;
            animation: spin 1s linear infinite;
            margin: 0 auto 20px;
        }
        @keyframes spin {
            0%% { transform: rotate(0deg); }
            100%% { transform: rotate(360deg); }
        }
    </style>
</head>
<body>
    <div class="loader">
        <div class="spinner"></div>
        <p>Starting EdiTed Music...</p>
        <p><small>Version %s</small></p>
    </div>
    <script>
        var attempts = 0;
        var maxAttempts = 40;

        function checkServer() {
            fetch('http://localhost:%d/live')
                .then(function(r) { return r.json(); })
                .then(function() {
                    window.location.href = 'http://localhost:%d/';
                })
                .catch(function() {
                    attempts++;
                    if (attempts < maxAttempts) {
                        setTimeout(checkServer, 500);
                    } else {
                        document.body.innerHTML = '<div class="loader"><p>ERROR: Server failed to start</p><p>Check DaVinci Resolve console for details</p></div>';
                    }
                });
        }

        setTimeout(checkServer, 1000);
    </script>
</body>
</html>
]=], VERSION, PORT, PORT)

    if not writeAll(launcherPath, launcherHTML) then
        print("[EdiTedMusic] ERROR: Failed to write launcher HTML")
        return false
    end

    print("[EdiTedMusic] Opening browser...")

    -- Launch browser with zero flash on Windows. The browser window itself is
    -- visible (SW_SHOWNORMAL=1); only the launcher mechanism is hidden.
    if isWindows and shellExecA then
        -- winShellExecHidden's signature is (op, file, params, dir, show) — there
        -- is NO leading hwnd arg (that's hardcoded to nil inside). The first
        -- argument MUST be the verb. A stray leading nil shifts every argument
        -- left, making lpFile literally the string "open" — which ShellExecuteA
        -- cannot open (returns HINSTANCE 2), so the browser never appears even
        -- though the console logged "Opening browser...". show=1 = SW_SHOWNORMAL.
        local hinst = shellExecA("open", launcherPath, nil, nil, 1)
        local code = nil
        pcall(function() code = tonumber(require("ffi").cast("intptr_t", hinst)) end)
        if not code or code <= 32 then
            print("[EdiTedMusic] ShellExecuteA open returned " .. tostring(code)
                .. " (<=32 = failed) — falling back to cmd /c start")
            if createProcessHidden then
                createProcessHidden('cmd.exe /c start "" "' .. launcherPath .. '"', nil)
            else
                os.execute('start "" "' .. launcherPath .. '"')
            end
        else
            print("[EdiTedMusic] Browser opened (ShellExecuteA HINSTANCE=" .. tostring(code) .. ")")
        end
    elseif isWindows and createProcessHidden then
        -- cmd.exe /c start is the only way to use the OS file association for .html
        createProcessHidden('cmd.exe /c start "" "' .. launcherPath .. '"', nil)
    elseif isWindows then
        print("[EdiTedMusic] WARNING: FFI launcher unavailable — opening browser via visible os.execute (a console may flash)")
        os.execute('start "" "' .. launcherPath .. '"')
    else
        os.execute('open ' .. shQuote(launcherPath))
    end

    return true
end

-- ============================================================
-- 15. Stage HTML to Temp (dev override -> download -> cache)
-- ============================================================
-- Resolves the EdiTedMusicUI.html that the local server will serve to the
-- browser. Cache-first and version-gated so a normal launch is instant and
-- never blocks on the network:
--   1. Dev override:  <scriptDir>/EdiTedMusicUI.html   (commit-free iteration)
--   2. Valid cache + version check via <CLOUD_SERVER>/davinci/ui/version:
--        - cache version == server version -> serve cache, download nothing
--        - cache stale / version unknown    -> serve cache now, then refresh
--                                              in the background for next launch
--   3. No usable cache (first run): blocking download of /davinci/ui?v=<VERSION>
--        (validated by ETM-UI-OK sentinel + >1000 bytes), mirrored to cache.
--   4. Last-resort stale cache.
--   5. Fail.
local UI_SENTINEL = "ETM-UI-OK"

local function _resolveScriptDir()
    local scriptDir = debug.getinfo(1, "S").source:match("@?(.*[\\/])")
    if scriptDir then return scriptDir end
    if isWindows then
        return (os.getenv("APPDATA") or ".") .. "\\Blackmagic Design\\DaVinci Resolve\\Support\\Fusion\\Scripts\\Utility\\"
    else
        return (os.getenv("HOME") or ".") .. "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/"
    end
end

-- Returns true if `path` exists, is >1000 bytes, and the first ~64 bytes
-- contain the ETM-UI-OK sentinel. Used to reject error pages / truncated downloads.
local function _validateUIFile(path)
    local f = io.open(path, "rb")
    if not f then return false, "missing" end
    local size = f:seek("end") or 0
    if size <= 1000 then
        f:close()
        return false, "too small (" .. tostring(size) .. " bytes)"
    end
    f:seek("set", 0)
    local head = f:read(64) or ""
    f:close()
    if not head:find(UI_SENTINEL, 1, true) then
        return false, "sentinel missing"
    end
    return true, size
end

-- Run a shell command synchronously and hidden on Windows; plain os.execute on macOS.
local function _runSyncHidden(cmd, waitMs)
    if isWindows and createProcessHidden then
        createProcessHidden(cmd, waitMs or 10000)
    else
        if isWindows then
            print("[EdiTedMusic] WARNING: FFI launcher unavailable — _runSyncHidden via visible os.execute (a console may flash)")
        end
        os.execute(cmd)
    end
end

-- Pull the UI version out of the sentinel line, e.g. "<!-- ETM-UI-OK v0.12A -->"
-- -> "0.12A". Returns nil if no version token is present.
local function _parseUIVersionFromString(s)
    if not s then return nil end
    -- Hyphens are escaped: UI_SENTINEL ("ETM-UI-OK") can't be used directly as a
    -- Lua pattern because "-" is the magic lazy-quantifier there.
    return s:match("ETM%-UI%-OK%s+v?([%w%.%-]+)")
end

-- Version of the locally cached UI (read from its first line). nil if absent.
local function _readCachedUIVersion(path)
    local f = io.open(path, "rb")
    if not f then return nil end
    local head = f:read(256) or ""
    f:close()
    return _parseUIVersionFromString(head)
end

-- Ask the server what the current UI version is (GET /davinci/ui/version).
-- Short timeout; this is a tiny text response, NOT the full UI. Returns the
-- version string, or nil on any network/parse failure (caller treats nil as
-- "unknown" and falls back to serving the cache).
local function _fetchServerUIVersion()
    local vurl = CLOUD_SERVER .. "/davinci/ui/version"
    local vfile = tempDir .. sep .. "edited_music_uiver.txt"
    deleteFile(vfile)
    if isWindows then
        local psBody =
            "try { (Invoke-WebRequest -Uri '" .. vurl .. "'"
            .. " -UseBasicParsing -TimeoutSec 4 -ErrorAction Stop).Content"
            .. " | Out-File -FilePath '" .. vfile .. "' -Encoding ascii -NoNewline } catch { exit 1 }"
        local cmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "'
            .. psBody .. '"'
        _runSyncHidden(cmd, 6000)
    else
        local cmd = 'curl -fsSL --max-time 4 -o ' .. shQuote(vfile) .. ' ' .. shQuote(vurl)
        os.execute(cmd .. ' >/dev/null 2>&1')
    end
    local body = readAll(vfile)
    deleteFile(vfile)
    if not body then return nil end
    body = body:gsub("^%s+", ""):gsub("%s+$", "")
    if body == "" then return nil end
    -- Accept plain text ("0.12A"), JSON ({"version":"0.12A"}), or a sentinel line.
    return body:match('"version"%s*:%s*"([%w%.%-]+)"')
        or body:match("^v?([%w%.%-]+)$")
        or _parseUIVersionFromString(body)
end

-- Fire-and-forget background refresh of the cached UI. NEVER blocks the launch.
-- Downloads to a side file, validates (size + sentinel), then atomically moves
-- it over the cache so the NEXT launch picks up the new version. A torn/failed
-- download leaves the existing cache untouched.
local function _startBackgroundUIDownload(url, cachePath)
    local part = cachePath .. ".dl"
    if isWindows then
        local psBody =
            "$ErrorActionPreference='SilentlyContinue';"
            .. " try {"
            .. "   Invoke-WebRequest -Uri '" .. url .. "' -OutFile '" .. part .. "'"
            .. "     -UseBasicParsing -TimeoutSec 25 -ErrorAction Stop;"
            .. "   $fi = Get-Item '" .. part .. "';"
            .. "   if ($fi.Length -gt 1000) {"
            .. "     $h = [System.IO.File]::ReadAllText('" .. part .. "');"
            .. "     if ($h.Length -ge 16 -and $h.Substring(0,64) -like '*" .. UI_SENTINEL .. "*') {"
            .. "       Move-Item -Force '" .. part .. "' '" .. cachePath .. "'"
            .. "     }"
            .. "   }"
            .. " } catch {}"
            .. " Remove-Item -Force '" .. part .. "' -ErrorAction SilentlyContinue"
        local cmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "'
            .. psBody .. '"'
        if createProcessHidden then
            createProcessHidden(cmd, nil)   -- waitMs=nil => detached, returns immediately
        else
            os.execute('start "" /b ' .. cmd)
        end
    else
        local dl = 'curl -fsSL --max-time 25 -o ' .. shQuote(part) .. ' ' .. shQuote(url)
            .. ' && mv -f ' .. shQuote(part) .. ' ' .. shQuote(cachePath)
        os.execute('( ' .. dl .. ' ) >/dev/null 2>&1 &')
    end
end

function copyHTMLToTemp()
    local scriptDir = _resolveScriptDir()
    local devOverride = scriptDir .. "EdiTedMusicUI.html"
    local cachePath = scriptDir .. "EdiTedMusicUI.cache.html"
    local url = CLOUD_SERVER .. "/davinci/ui?v=" .. VERSION

    -- 1. Dev override (next to the .lua) — always wins, for commit-free iteration.
    if fileExists(devOverride) then
        if copyFile(devOverride, htmlPath) then
            print("[EdiTedMusic] Using dev-override UI from " .. devOverride)
            return true
        else
            print("[EdiTedMusic] WARNING: dev override present but copy failed: " .. devOverride)
        end
    end

    -- 2. Cache-first, version-gated. If we already have a valid cached UI we
    --    NEVER block the launch on a download:
    --      * cache version == server version  -> serve cache, download nothing
    --      * cache outdated / version unknown  -> serve cache NOW, refresh in
    --                                             the background for next launch
    --    A full (blocking) download only happens on first run / no usable cache.
    local cacheOk = _validateUIFile(cachePath)
    if cacheOk then
        local cacheVer  = _readCachedUIVersion(cachePath)
        local serverVer = _fetchServerUIVersion()
        if serverVer and cacheVer and serverVer == cacheVer then
            if copyFile(cachePath, htmlPath) then
                print(string.format("[EdiTedMusic] UI up to date (v%s) — using cache, no download",
                    tostring(cacheVer)))
                return true
            end
            print("[EdiTedMusic] WARNING: cache valid but copy to temp failed")
        elseif copyFile(cachePath, htmlPath) then
            print(string.format(
                "[EdiTedMusic] UI cache v%s vs server v%s — using cache now, updating in background",
                tostring(cacheVer or "?"), tostring(serverVer or "?")))
            _startBackgroundUIDownload(url, cachePath)
            return true
        else
            print("[EdiTedMusic] WARNING: cache valid but copy to temp failed; will try direct download")
        end
    end

    -- 3. No usable cache (first run / corrupt cache): a blocking download is
    --    unavoidable because we have nothing to show yet.
    local partialPath = htmlPath .. ".partial"
    deleteFile(partialPath)
    print("[EdiTedMusic] No cached UI — fetching from " .. url)
    if isWindows then
        -- PowerShell Invoke-WebRequest, hidden, 12s timeout. -UseBasicParsing
        -- avoids the IE engine; -ErrorAction Stop so a non-200 throws and we
        -- detect it via the empty/partial file rather than parsing stderr.
        local psBody =
            "try { Invoke-WebRequest -Uri '" .. url .. "'"
            .. " -OutFile '" .. partialPath .. "'"
            .. " -UseBasicParsing -TimeoutSec 12 -ErrorAction Stop } catch { exit 1 }"
        local cmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "'
            .. psBody .. '"'
        _runSyncHidden(cmd, 15000)
    else
        local cmd = 'curl -fsSL --max-time 12 -o ' .. shQuote(partialPath) .. ' ' .. shQuote(url)
        os.execute(cmd .. ' >/dev/null 2>&1')
    end

    local ok, info = _validateUIFile(partialPath)
    if ok then
        if copyFile(partialPath, htmlPath) then
            -- Mirror to cache for offline reuse / version-gating on next launch.
            copyFile(partialPath, cachePath)
            deleteFile(partialPath)
            print(string.format("[EdiTedMusic] Downloaded UI from %s (%s bytes)",
                url, tostring(info)))
            return true
        end
        print("[EdiTedMusic] WARNING: downloaded UI but copy to temp failed")
    else
        print("[EdiTedMusic] Download failed or invalid: " .. tostring(info))
    end
    deleteFile(partialPath)

    -- 4. Last-resort stale cache (e.g. server unreachable on a run where the
    --    earlier copy-to-temp failed).
    if fileExists(cachePath) then
        if copyFile(cachePath, htmlPath) then
            print("[EdiTedMusic] OFFLINE: using cached UI from " .. cachePath)
            return true
        end
    end

    -- 5. No source.
    print("[EdiTedMusic] ERROR: could not stage UI HTML.")
    print("[EdiTedMusic]   Tried: dev override at " .. devOverride)
    print("[EdiTedMusic]   Tried: download from   " .. url)
    print("[EdiTedMusic]   Tried: cache at        " .. cachePath)
    print("[EdiTedMusic]   Open the URL in a browser to verify the server is reachable,")
    print("[EdiTedMusic]   or drop a working EdiTedMusicUI.html into the script folder.")
    return false
end

-- ============================================================
-- 16. Cleanup Previous Session
-- ============================================================

-- Kill whatever process holds http://localhost:<port>/ in HTTP.sys, regardless
-- of whether we have a matching PID file. Necessary because a previous EdiTed
-- run can crash or be force-killed without releasing the HttpListener — and
-- subsequent runs would overwrite the PID file, hiding the orphan from the
-- normal cleanup path. Uses `netsh http show servicestate` to query HTTP.sys
-- directly. No-op on macOS (Perl server doesn't register with HTTP.sys).
function killOrphanServer(port)
    if not isWindows then return end
    -- Single PowerShell -Command invocation. Two passes:
    --   (1) HTTP.sys port-registration holders for our URL (the active server).
    --   (2) ANY PowerShell still running our server script. When a prior Lua
    --       instance is force-killed by killOrphanLuaInstances, its paired
    --       server keeps running but loses the port -> pass (1) (which only
    --       finds the current registrant) misses it. Matching the server
    --       script's command line catches these orphans. Excludes our own
    --       $PID (this kill script's command line contains the script name).
    -- Guard $opid > 4 so we never try to kill PID 4 (System). Safe at every
    -- call site: cleanupPreviousSession runs before the new server spawns, and
    -- the URL-conflict retry path kills+relaunches a fresh server anyway.
    local psBody =
        "$me=$PID;"
        .. " $svc=(netsh http show servicestate 2>&1 | Out-String);"
        .. " foreach ($b in ($svc -split 'Request queue name:')) {"
        .. "   if ($b -match ('HTTP://LOCALHOST:" .. port .. "/')) {"
        .. "     foreach ($m in [regex]::Matches($b, 'ID:\\s*(\\d+)')) {"
        .. "       $opid=[int]$m.Groups[1].Value;"
        .. "       if ($opid -gt 4 -and $opid -ne $me) { try { Stop-Process -Id $opid -Force -ErrorAction SilentlyContinue } catch {} }"
        .. "     }"
        .. "   }"
        .. " }"
        .. " Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*" .. SERVER_SCRIPT .. "*' -and $_.ProcessId -ne $me -and $_.ProcessId -gt 4 } |"
        .. "   ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} }"
    local cmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "' .. psBody .. '"'
    if createProcessHidden then
        createProcessHidden(cmd, 5000)  -- wait up to 5s, synchronous, no window
    else
        print("[EdiTedMusic] WARNING: FFI launcher unavailable — orphan-cleanup via visible os.execute (a console may flash)")
        os.execute(cmd .. ' >nul 2>&1')
    end
end

-- Single-instance guard. Kills any OTHER EdiTedMusic Lua poll-loop processes
-- (fuscript.exe from prior menu launches). cleanupPreviousSession already stops
-- the old SERVER, but the old Lua POLL LOOPS keep running and all poll the same
-- port -> multiple consumers stealing each other's commands (the "4 zombies on
-- one port" chaos this fixes). We identify THIS process by walking up from the
-- spawned PowerShell's own parent chain to its fuscript ancestor and never kill
-- that one; if we can't identify ourselves we kill nothing (fail safe).
function killOrphanLuaInstances()
    if not isWindows then return end
    local psBody =
        "$all = Get-CimInstance Win32_Process;"
        .. " $cur=$PID; $self=0;"
        .. " for ($i=0; $i -lt 8 -and $cur -gt 0; $i++) {"
        .. "   $p = $all | Where-Object { $_.ProcessId -eq $cur } | Select-Object -First 1;"
        .. "   if (-not $p) { break };"
        .. "   if ($p.Name -eq 'fuscript.exe' -and $p.CommandLine -and $p.CommandLine -match 'EdiTedMusic') { $self=$p.ProcessId; break };"
        .. "   $cur=$p.ParentProcessId"
        .. " }"
        .. " if ($self -gt 0) {"
        .. "   $all | Where-Object { $_.Name -eq 'fuscript.exe' -and $_.CommandLine -and $_.CommandLine -match 'EdiTedMusic' -and $_.ProcessId -ne $self } |"
        .. "     ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} }"
        .. " }"
    local cmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "' .. psBody .. '"'
    if createProcessHidden then
        createProcessHidden(cmd, 5000)  -- synchronous, up to 5s, no window
    else
        print("[EdiTedMusic] WARNING: FFI launcher unavailable — instance-guard via visible os.execute (a console may flash)")
        os.execute(cmd .. ' >nul 2>&1')
    end
end

function cleanupPreviousSession()
    -- Single-instance guard: kill any OTHER EdiTedMusic Lua poll loops first, so
    -- only this freshly-launched instance drives the bridge.
    killOrphanLuaInstances()

    -- Check if a previous server is still running
    local liveContent = readAll(liveDataPath)
    local wasRunning = fileExists(readyFilePath) or fileExists(liveDataPath)

    if not wasRunning then
        -- No previous session detected, but a server can still be alive if its
        -- ready/live files were deleted. Signal first, then clean stale files.
        writeAll(stopFilePath, "stop")
        busyWait(1)
        deleteFile(stopFilePath)
        deleteFile(commandFilePath)
        deleteFile(resultFilePath)
        deleteFile(tempDir .. sep .. "edited_music_server.pid")
        deleteFile(portFilePath)
        deletePerIdFiles()
        -- Even when no signal files exist, an orphan from a force-killed
        -- earlier session may still hold the HttpListener registration.
        killOrphanServer(PORT)
        return
    end

    -- Check if the previous session is actively doing work (exporting, generating, etc.)
    local isBusy = false
    if liveContent then
        local ok, liveData = pcall(JSON.decode, liveContent)
        if ok and liveData and liveData.busy then
            isBusy = true
            print("[EdiTedMusic] Previous session is busy: " .. tostring(liveData.busyMessage or "working"))
            print("[EdiTedMusic] Waiting 3 seconds for it to finish...")
            busyWait(3)
        end
    end

    -- Send graceful stop signal
    print("[EdiTedMusic] Stopping previous server...")
    writeAll(stopFilePath, "stop")
    busyWait(2)

    -- If graceful stop didn't work, kill ONLY the recorded PID (no command-line dragnet)
    local pidFilePath = tempDir .. sep .. "edited_music_server.pid"
    local pidRaw = readAll(pidFilePath)
    local oldPid = pidRaw and tonumber(pidRaw:match("(%d+)")) or nil

    if oldPid then
        print(string.format("[EdiTedMusic] Killing previous server PID %d", oldPid))
        if isWindows then
            -- Synchronous taskkill avoids racing the new server. /T kills children too; /F is force.
            local killCmd = string.format('taskkill /PID %d /F /T', oldPid)
            if createProcessHidden then
                createProcessHidden(killCmd, 5000)  -- wait up to 5s, no window
            else
                print("[EdiTedMusic] WARNING: FFI launcher unavailable — taskkill via visible os.execute (a console may flash)")
                os.execute(killCmd .. ' >nul 2>&1')
            end
        elseif isMac then
            os.execute(string.format('kill -9 %d 2>/dev/null', oldPid))
        end
        busyWait(0.5)
    else
        print("[EdiTedMusic] No PID file from previous server; relying on graceful stop")
    end

    -- Clean up all signal files
    deleteFile(stopFilePath)
    deleteFile(readyFilePath)
    deleteFile(commandFilePath)
    deleteFile(resultFilePath)
    deleteFile(liveDataPath)
    deleteFile(pidFilePath)
    deleteFile(portFilePath)
    deleteFile(tempDir .. sep .. "edited_music_error.log")
    deletePerIdFiles()

    -- Safety net: if any orphan is still holding the port via HTTP.sys
    -- (PID file was stale or overwritten by an in-between run), kill it
    -- before the new server tries to bind.
    killOrphanServer(PORT)

    print("[EdiTedMusic] Previous session cleaned up")
end

-- ============================================================
-- 17. Command Dispatcher
-- ============================================================
function handleCommand(cmd, args)
    print("[EdiTedMusic] Handling command: " .. cmd)

    if cmd == "getTimelineInfo" then return getTimelineInfo()
    elseif cmd == "getClipCounts" then return getClipCounts()
    elseif cmd == "checkAudioTracks" then return checkAudioTracks()
    elseif cmd == "checkForEmptyTracks" then return checkForEmptyTracks()
    elseif cmd == "testInsertAudio" then return testInsertAudio()
    elseif cmd == "testReplaceAudio" then return testReplaceAudio()
    elseif cmd == "testBatchImport" then return testBatchImport(args)
    elseif cmd == "testMultiSongInOut" then return testMultiSongInOut(args)
    elseif cmd == "testReplaceById" then return testReplaceById()
    elseif cmd == "testCleanupMusicTrack" then return testCleanupMusicTrack()
    elseif cmd == "cleanupMusicTrack" then return cleanupMusicTrack(args)
    elseif cmd == "exportAudio" then return exportAudio(args)
    elseif cmd == "getRenderInfo" then return getRenderInfo(args)
    elseif cmd == "takeScreenshots" then return takeScreenshots(args)
    elseif cmd == "findOrCreateAudioTrack" then return findOrCreateAudioTrack()
    elseif cmd == "importAudioToTimeline" then return importAudioToTimeline(args)
    elseif cmd == "replaceAudioOnTimeline" then return replaceAudioOnTimeline(args)
    elseif cmd == "addMusicMarker" then return addMusicMarker(args)
    else return {error = "Unknown command: " .. cmd} end
end

-- ============================================================
-- 17. Main Function
-- ============================================================
function main()
    print("=== EdiTed Music v" .. VERSION .. " ===")
    print("")

    -- Clean up any previous session (kill orphaned servers, free port)
    cleanupPreviousSession()

    -- Connect to Resolve
    print("[EdiTedMusic] Connecting to DaVinci Resolve...")
    resolve = getResolve()
    if not resolve then
        error("ERROR: Could not connect to DaVinci Resolve")
    end
    print("  Connected successfully")

    -- Get project
    print("")
    print("[EdiTedMusic] Getting current project...")
    local projectManager = resolve:GetProjectManager()
    if not projectManager then
        error("ERROR: Could not access Project Manager")
    end

    -- No project / timeline / media pool is NON-FATAL: open the UI anyway and let
    -- it show a "No timeline" state. The polling loop below re-reads project /
    -- timeline / mediaPool every tick (pcall-wrapped) and attaches automatically
    -- the moment the user opens a project + timeline in Resolve — no relaunch
    -- needed. Hard-failing here used to abort BEFORE startServer/launchBrowser, so
    -- the UI never opened at all.
    project = projectManager:GetCurrentProject()
    if not project then
        print("  WARNING: No project open yet — opening the UI anyway; will attach when one is available.")
    else
        print("  Project: " .. (project:GetName() or "Untitled"))
    end

    -- Get timeline (non-fatal)
    print("")
    print("[EdiTedMusic] Getting current timeline...")
    timeline = project and project:GetCurrentTimeline() or nil
    if not timeline then
        print("  WARNING: No timeline active yet — the UI will prompt you to open one in Resolve.")
    else
        print("  Timeline: " .. (timeline:GetName() or "Untitled"))
    end

    -- Get media pool (non-fatal; only needed when placing audio)
    mediaPool = project and project:GetMediaPool() or nil
    if not mediaPool then
        print("  WARNING: Media Pool not available yet (no project/timeline).")
    end

    -- Copy HTML to temp
    print("")
    if not copyHTMLToTemp() then
        error("ERROR: Failed to copy HTML file")
    end

    -- Start server
    print("")
    if not startServer() then
        error("ERROR: Failed to start server")
    end

    -- Launch browser
    print("")
    if not launchBrowser() then
        print("WARNING: Failed to launch browser automatically")
        print("  Open manually: http://localhost:" .. PORT)
    end

    -- Initialize live state using the same builder as the polling loop so
    -- pre-existing timeline marks show up immediately rather than waiting
    -- for the first poll iteration (which left hasIn/hasOut/inSeconds/
    -- outSeconds/startSeconds missing from the initial JSON).
    writeLive(buildLiveState(project, timeline))

    print("")
    print("=== EdiTed Music Running ===")
    print("Server: http://localhost:" .. PORT)
    print("Temp directory: " .. tempDir)
    print("")
    print("Polling for commands... (Close browser or click Stop to exit)")

    -- Main polling loop
    local commandId = 0
    -- At-least-once dedup: the server now PEEKS commands (removes only when the
    -- result is stored), so a command can be re-served if our result POST was
    -- lost. Remember the last one processed and re-post its cached result instead
    -- of re-executing (which would double-place a mutating command).
    local lastProcessedId = nil
    local lastResult = nil
    while true do
        -- Refresh project/timeline references (in case user switches).
        -- Wrapped in pcall: if the user closes their project mid-session,
        -- GetProjectManager()/GetCurrentProject() can throw and would
        -- otherwise crash the entire script with no recovery.
        local refreshOk, newProject, newTimeline, newMediaPool = pcall(function()
            local pm = resolve:GetProjectManager()
            local p = pm and pm:GetCurrentProject() or nil
            return p,
                   p and p:GetCurrentTimeline() or nil,
                   p and p:GetMediaPool() or nil
        end)
        if refreshOk then
            project, timeline, mediaPool = newProject, newTimeline, newMediaPool
        else
            project, timeline, mediaPool = nil, nil, nil
        end

        local liveState = buildLiveState(project, timeline)
        writeLive(liveState)

        -- Check for commands from browser
        local cmd = readCommand()
        if cmd then
            local cmdKey = tostring(cmd.id or "")
            if cmdKey ~= "" and cmdKey == lastProcessedId then
                -- Server re-served this command (our previous result POST wasn't
                -- stored). Re-post the cached result WITHOUT re-running the handler
                -- — re-executing would double-place a mutating command.
                print(string.format("[EdiTedMusic] Re-posting cached result for already-processed id=%s", cmdKey))
                writeResult(cmd.id, lastResult or {error = "duplicate command; original result was lost"})
            else
                commandId = commandId + 1
                print(string.format("[EdiTedMusic] Command #%d: %s", commandId, cmd.cmd or "unknown"))

                -- Mark as busy in live state (so cleanup won't kill an active search)
                liveState.busy = true
                liveState.busyMessage = cmd.cmd
                writeLive(liveState)

                -- Execute command and write result
                local ok, result = pcall(handleCommand, cmd.cmd, cmd.args or {})
                local finalResult
                if ok then
                    finalResult = result
                else
                    finalResult = {error = "Command failed: " .. tostring(result)}
                    print(string.format("[EdiTedMusic] ERROR: Command #%d failed: %s", commandId, tostring(result)))
                end

                -- Cache BEFORE posting so a re-serve can re-post even if the
                -- result POST below is the call that gets lost.
                if cmdKey ~= "" then
                    lastProcessedId = cmdKey
                    lastResult = finalResult
                end
                writeResult(cmd.id or commandId, finalResult)

                -- Clear busy flag
                liveState.busy = false
                liveState.busyMessage = nil
                writeLive(liveState)

                print(string.format("[EdiTedMusic] Command #%d completed", commandId))
            end
        end

        -- Check for stop signal
        if fileExists(stopFilePath) then
            print("[EdiTedMusic] Stop signal detected")
            break
        end

        -- Wait before next poll
        busyWait(0.2)
    end

    -- Cleanup
    print("")
    print("=== EdiTed Music Stopping ===")
    stopServer()
    print("Goodbye!")
end

-- ============================================================
-- Execute
-- ============================================================

local function showError(errMsg)
    local msg = tostring(errMsg)
    if isWindows then
        -- Escape double-quotes for PowerShell -Command embedding (CMD rules: "" = literal ")
        local safe = msg:gsub('"', '""'):gsub("\n", " ")
        local psArgs = '-NoProfile -WindowStyle Hidden -Command "' ..
            'Add-Type -AssemblyName PresentationFramework; ' ..
            '[void][System.Windows.MessageBox]::Show(""' .. safe .. '"",""EdiTed Music"",""OK"",""Error"")"'
        pcall(function()
            if createProcessHidden then
                createProcessHidden('powershell.exe ' .. psArgs, 8000)
            elseif shellExecA then
                shellExecA("open", "powershell.exe", psArgs, nil, 0)
            else
                os.execute('powershell.exe ' .. psArgs)
            end
        end)
    else
        local safe = msg:gsub('"', '\\"'):gsub("'", "\\'")
        os.execute('osascript -e \'display dialog "' .. safe ..
            '" with title "EdiTed Music" buttons {"OK"} default button "OK" with icon stop\' &')
    end
end

local success, result = pcall(main)

if not success then
    print("")
    print("=== EdiTed Music Failed ===")
    print(result)
    showError(result)
end
