local LrApplication = import "LrApplication"
local LrBinding = import "LrBinding"
local LrDate = import "LrDate"
local LrDialogs = import "LrDialogs"
local LrDevelopController = import "LrDevelopController"
local LrFunctionContext = import "LrFunctionContext"
local LrPathUtils = import "LrPathUtils"
local LrPrefs = import "LrPrefs"
local LrTasks = import "LrTasks"
local LrView = import "LrView"

local function pluginLoad(relativePath)
    return dofile(LrPathUtils.child(_PLUGIN.path, relativePath))
end

local config = pluginLoad("lib/FilmEngineConfig.lua")
local bridge = pluginLoad("lib/EngineBridge.lua")
local panelsRuntime = pluginLoad("lib/PanelsRuntime.lua")
local catalogWrite = pluginLoad("lib/CatalogWrite.lua")
local publicCatalog = pluginLoad("lib/PublicCatalog.lua")
local labCameraProfile = pluginLoad("lib/LabCameraProfilePolicy.lua")
local ORIGINAL_EMULSION_ID = "__original__"
local PANEL_BUILD = 186
local PANEL1_UI_PREF_PREFIX = "panel1Ui_"
local PANEL1_UI_VERSION = 1
local PANEL1_UI_STRING_KEYS = {
    "sourceScale", "emulsion", "format", "lutIntensity",
    "smartMatchWorkflow",
}
local PANEL1_UI_NUMBER_KEYS = {
    "ssg_toe_grain", "ssg_body_grain", "ssg_shoulder_grain", "ssg_resolving_loss",
    "lomoPurple", "lomoTurquoise", "metropolis", "redscale",
    "portaSkinGuard", "kodachromeK14",
    "filmSoup",
}
local PANEL1_UI_BOOL_KEYS = {
    "weddingSync",
}

local function clampNumber(v, minV, maxV)
    if v < minV then return minV end
    if v > maxV then return maxV end
    return v
end

local function roundNearest(value)
    if value >= 0 then return math.floor(value + 0.5) end
    return math.ceil(value - 0.5)
end


local GRAIN_FORMAT_SCALE_FACTORS = {
    ["35mm"] = { amount = 1.00, size = 1.00, roughness = 1.00 },
    ["mf_120"] = { amount = 0.74, size = 0.82, roughness = 0.82 },
    ["lf_4x5"] = { amount = 0.52, size = 0.66, roughness = 0.68 },
    ["lf_8x10"] = { amount = 0.38, size = 0.54, roughness = 0.56 },
}

local function normalizeGrainFormatId(formatId)
    local id = tostring(formatId or "auto")
    if id == "120" then return "mf_120" end
    if id == "4x5" then return "lf_4x5" end
    if id == "8x10" then return "lf_8x10" end
    return id
end

local function readPhotoPixelSize(photo)
    if not photo then return nil, nil end
    local function readRawNumber(key)
        local ok, value = pcall(function()
            return photo:getRawMetadata(key)
        end)
        if ok and tonumber(value) then
            return tonumber(value)
        end
        return nil
    end
    local width = readRawNumber("croppedWidth") or readRawNumber("width")
    local height = readRawNumber("croppedHeight") or readRawNumber("height")
    if width and height and width > 0 and height > 0 then
        return width, height
    end
    local okDim, dimText = pcall(function()
        return photo:getFormattedMetadata("dimensions")
    end)
    if okDim and type(dimText) == "string" then
        local a, b = string.match(dimText, "(%d+)%D+(%d+)")
        local w = tonumber(a)
        local h = tonumber(b)
        if w and h and w > 0 and h > 0 then
            return w, h
        end
    end
    return nil, nil
end

local function inferGrainFormatIdFromPhoto(photo)
    if not photo then
        return nil, false
    end

    local cameraMake = string.lower(tostring((function()
        local ok, v = pcall(function() return photo:getFormattedMetadata("cameraMake") end)
        return ok and v or ""
    end)() or ""))
    local cameraModel = string.lower(tostring((function()
        local ok, v = pcall(function() return photo:getFormattedMetadata("cameraModel") end)
        return ok and v or ""
    end)() or ""))
    local haystack = (cameraMake .. " " .. cameraModel):gsub("%s+", " ")

    if haystack ~= "" and haystack ~= " " then
        if string.find(haystack, "8x10", 1, true)
            or string.find(haystack, "8 x 10", 1, true)
            or string.find(haystack, "ultra large", 1, true)
        then
            return "lf_8x10", true
        end
        if string.find(haystack, "4x5", 1, true)
            or string.find(haystack, "4 x 5", 1, true)
            or string.find(haystack, "large format", 1, true)
            or string.find(haystack, "sinar", 1, true)
            or string.find(haystack, "linhof", 1, true)
        then
            return "lf_4x5", true
        end
        if string.find(haystack, "gfx", 1, true)
            or string.find(haystack, "hasselblad", 1, true)
            or string.find(haystack, "phase one", 1, true)
            or string.find(haystack, "pentax 645", 1, true)
            or string.find(haystack, "645z", 1, true)
            or string.find(haystack, "x1d", 1, true)
            or string.find(haystack, "120", 1, true)
            or string.find(haystack, "medium format", 1, true)
        then
            return "mf_120", true
        end
    end

    -- D3 fix: pixel/aspect-ratio inference removed. Standard 3:2 digital frames
    -- (ratio ~1.5, >=18MP) were misclassified as 4x5/8x10 large format and given
    -- the finest grain scale. Detection now relies on camera make/model only;
    -- when the body is not a known MF/LF system we stay on 35mm.
    return nil, false
end

local function resolveGrainFormatId(formatProp, photo)
    local normalized = normalizeGrainFormatId(formatProp)
    if normalized ~= "auto" then
        return normalized
    end
    local inferred, confident = inferGrainFormatIdFromPhoto(photo)
    if confident and inferred then
        return inferred
    end
    return "35mm"
end

local function makeSceneFormatItems()
    local items = {
        { title = "Auto (detect from photo metadata)", value = "auto" },
    }
    local labels = {
        ["35mm"] = "35mm — standard grain scale",
        ["mf_120"] = "120 / Medium Format — finer grain",
        ["lf_4x5"] = "4×5 Large Format — finer grain",
        ["lf_8x10"] = "8×10 Ultra Large Format — finest grain",
    }
    for _, row in ipairs(config.formats or {}) do
        local id = tostring(row.id or "")
        items[#items + 1] = {
            title = labels[id] or tostring(row.label or id),
            value = id,
        }
    end
    return items
end

local function makeSourceScaleItemsForPanel()
    local labels = {
        ["auto"] = "Auto (detect from camera)",
        ["full_frame"] = "Full Frame",
        ["aps_c"] = "APS-C",
        ["micro_four_thirds"] = "Micro 4/3",
        ["compact_small"] = "Small sensor / compact",
        ["digital_mf"] = "Digital Medium Format",
    }
    local items = {}
    for _, row in ipairs(config.sourceScales or {}) do
        local id = tostring(row.id or "")
        items[#items + 1] = {
            title = labels[id] or tostring(row.label or id),
            value = id,
        }
    end
    return items
end


local function applySsgManualTrim(settings, baseline, props, formatId)
    local toe = clampNumber(tonumber(props.ssg_toe_grain) or 0, 0, 100) / 100.0
    local body = clampNumber(tonumber(props.ssg_body_grain) or 0, 0, 100) / 100.0
    local shoulder = clampNumber(tonumber(props.ssg_shoulder_grain) or 0, 0, 100) / 100.0
    local resolving = clampNumber(tonumber(props.ssg_resolving_loss) or 0, 0, 100) / 100.0
    if toe <= 0 and body <= 0 and shoulder <= 0 and resolving <= 0 then
        return settings or {}
    end

    local output = settings or {}
    local base = baseline or {}
    local resolvedFormat = normalizeGrainFormatId(formatId)
    if resolvedFormat == "auto" then
        resolvedFormat = "35mm"
    end
    local f = GRAIN_FORMAT_SCALE_FACTORS[resolvedFormat] or GRAIN_FORMAT_SCALE_FACTORS["35mm"]

    local function addKey(key, delta, minV, maxV)
        if delta == 0 then return end
        local current = tonumber(output[key])
        if current == nil then current = tonumber(base[key]) or 0 end
        output[key] = roundNearest(clampNumber(current + delta, minV, maxV))
    end

    addKey("GrainAmount", ((toe * 30) + (body * 26) + (shoulder * 22)) * f.amount, 0, 100)
    addKey("GrainSize", ((toe * 4) + (body * 9) + (shoulder * 7) + (resolving * 5)) * f.size, 0, 100)
    addKey("GrainFrequency", ((toe * 34) + (body * 22) + (shoulder * 18)) * f.roughness, 0, 100)
    addKey("Blacks2012", toe * -5, -100, 100)
    addKey("Shadows2012", toe * -3, -100, 100)
    addKey("Texture", (toe * 5) + (body * 8) + (shoulder * 2) - (resolving * 24), -100, 100)
    addKey("Clarity2012", (body * 3) - (shoulder * 2) - (resolving * 18), -100, 100)
    addKey("Highlights2012", shoulder * -7, -100, 100)
    addKey("Whites2012", shoulder * -4, -100, 100)
    addKey("Dehaze", resolving * -4, -50, 50)

    return output
end

local function levelItems()
    return {
        { title = "Reset (0)", value = "base" },
        { title = "Level I", value = "l1" },
        { title = "Level II", value = "l2" },
        { title = "Level III", value = "l3" },
    }
end

local function displayEmulsionLabel(v)
    local label = tostring((v and v.clientFacingLabel) or (v and v.publicLabel) or (v and v.legacyLabel) or (v and v.label) or (v and v.id) or "")
    label = label:gsub("^%s*[A-Z][A-Z0-9_%-]+%s*=%s*", "")
    label = label:gsub("^%s*[A-Z][A-Z0-9_%-]+%s*|%s*", "")
    return label
end

local EMULSION_PROCESS_ORDER = {
    "color_negative",
    "slide",
    "cine",
    "bw",
    "camera_sim",
    "creative",
}

local EMULSION_PROCESS_PREFIX = {
    color_negative = "C-41 | ",
    slide = "E-6/K-14 | ",
    cine = "CINE | ",
    bw = "B&W | ",
    camera_sim = "SIM | ",
    creative = "CREATIVE | ",
}

local PANEL1_CORE_PRIORITY_ORDER = {
    portra_400_accurate_prototype = 1,
    portra_160_accurate_prototype = 2,
    portra_800_accurate_prototype = 3,
    fuji_400h_accurate_prototype = 4,
    gold_200 = 5,
    colorplus_200 = 6,
    ultramax_400 = 7,
    ektar_100 = 8,
    fuji_superia_400 = 9,
}

local PANEL1_SECTION_ORDER = {
    "core_master",
    "kodak_pro_c41",
    "fuji_c41",
    "consumer_c41",
    "slide_chrome",
    "cine_motion",
    "bw",
    "creative_misc",
    "portrait_tools",
    "lab_system",
}

local LEGACY_WORKFLOW_ALIAS_TO_CODE = {
    portrait = "por",
    wedding = "zg",
    editorial = "fa",
    travel = "kra",
    night = "noc",
    product = "fa",
    street = "rep",
    vintage = "vin",
    experimental = "exp",
}

local function workflowItems()
    return {
        { title = "Auto Lab Match (Scene 0)", value = "auto" },
        { title = "Portrait", value = "por" },
        { title = "Golden Hour", value = "zg" },
        { title = "Landscape", value = "kra" },
        { title = "Night", value = "noc" },
        { title = "Reportage", value = "rep" },
        { title = "Cinema", value = "kin" },
        { title = "Fine Art", value = "fa" },
        { title = "Vintage", value = "vin" },
        { title = "Experimental", value = "exp" },
    }
end

local function normalizeSmartMatchWorkflow(value)
    local workflow = string.lower(tostring(value or "rep"))
    workflow = LEGACY_WORKFLOW_ALIAS_TO_CODE[workflow] or workflow
    if workflow == "auto" then return "auto" end
    if workflow == "gh" then workflow = "zg" end
    if workflow == "lnd" then workflow = "kra" end
    if workflow == "ngt" then workflow = "noc" end
    if workflow == "cin" then workflow = "kin" end
    if workflow == "por" or workflow == "zg" or workflow == "kra"
        or workflow == "noc" or workflow == "rep" or workflow == "kin"
        or workflow == "fa" or workflow == "vin" or workflow == "exp"
    then
        return workflow
    end
    return "auto"
end

local function resetCreativeLabSliders(props)
    -- Always start these three lab groups from zero on every plugin entry.
    props.lomoPurple = 0
    props.lomoTurquoise = 0
    props.metropolis = 0
    props.redscale = 0
    props.portaSkinGuard = 0
    props.kodachromeK14 = 0
    props.filmSoup = 0
end

local function resetSsgSliders(props)
    props.ssg_toe_grain = 0
    props.ssg_body_grain = 0
    props.ssg_shoulder_grain = 0
    props.ssg_resolving_loss = 0
end

local PANEL1_SECTION_HEADER = {
    core_master = "── A) Core / Master Stocks ──",
    kodak_pro_c41 = "── B) Kodak Professional / C-41 ──",
    fuji_c41 = "── C) Fuji C-41 ──",
    consumer_c41 = "── D) Consumer C-41 / Nostalgic ──",
    slide_chrome = "── E) Slide / E-6 / Chrome ──",
    cine_motion = "── F) Cine / Motion Picture ──",
    bw = "── G) B&W ──",
    creative_misc = "── H) Creative by MindfulLens ──",
    portrait_tools = "── I) Portrait / Skin Tools ──",
    lab_system = "── J) Lab / System / Hidden ──",
}

local CREATIVE_COLLECTION_MENU_CODE = {
    creative_main = "MAIN",
    creative_vibes = "VIB",
    creative_digital_sim = "DSIM",
    creative_landscape = "LAND",
    creative_social = "SOC",
    creative_experimental = "CEXP",
}

local CREATIVE_COLLECTION_HEADER_VALUE = "__ml_creative_collection_header__"

local function appendMenuHeader(items, title)
    items[#items + 1] = {
        title = tostring(title or ""),
        value = CREATIVE_COLLECTION_HEADER_VALUE,
        enabled = false,
    }
end

local function creativeCollectionMenuPrefix(collectionId)
    local code = CREATIVE_COLLECTION_MENU_CODE[tostring(collectionId or "")] or "CRE"
    return code .. " | CREATIVE | "
end

local function creativeCollectionOrder()
    if type(publicCatalog.getCreativeCollectionOrder) == "function" then
        return publicCatalog.getCreativeCollectionOrder()
    end
    return {}
end

local function getCreativeCollectionBrowseId(emulsion)
    if type(publicCatalog.getCreativeCollectionId) == "function" then
        return publicCatalog.getCreativeCollectionId(emulsion and emulsion.id)
    end
    return nil
end

local function emulsionCategoryCode(emulsion)
    local categoryId = tostring((emulsion and emulsion.publicCategoryId) or "")
    if categoryId == "" and type(publicCatalog.getCategoryId) == "function" then
        categoryId = tostring(publicCatalog.getCategoryId(emulsion and emulsion.id, emulsion and emulsion.type, emulsion and emulsion.bw == true) or "experimental")
    end
    if type(publicCatalog.getCategoryCode) == "function" then
        return publicCatalog.getCategoryCode(categoryId)
    end
    return "EXP"
end

local function emulsionCategoryDisplayCode(emulsion)
    return string.upper(tostring(emulsionCategoryCode(emulsion) or "EXP"))
end

local function categorizeEmulsionProcess(emulsion)
    local id = string.lower(tostring(emulsion and emulsion.id or ""))
    local label = string.lower(tostring((emulsion and emulsion.legacyLabel) or (emulsion and emulsion.label) or ""))

    local function hasToken(token)
        return string.find(id, token, 1, true) or string.find(label, token, 1, true)
    end

    if emulsion and emulsion.bw == true
        or hasToken("bw_")
        or hasToken("tri-x")
        or hasToken("t-max")
        or hasToken("delta")
        or hasToken("hp5")
        or hasToken("xp2")
        or hasToken("foma")
        or hasToken("arista")
        or hasToken("acros")
        or hasToken("kosmo")
    then
        return "bw"
    end
    if hasToken("vision3")
        or hasToken("cinestill")
        or hasToken("cinema")
        or hasToken("eterna")
        or hasToken("t200")
        or hasToken("reala")
    then
        return "cine"
    end
    if hasToken("velvia")
        or hasToken("provia")
        or hasToken("astia")
        or hasToken("ektachrome")
        or hasToken("kodachrome")
        or hasToken("fortia")
        or hasToken("precisa")
        or hasToken("leicachrome")
        or hasToken("agfa_rsx")
        or hasToken("ut21")
        or hasToken("polachrome")
    then
        return "slide"
    end
    if hasToken("classic chrome")
        or hasToken("nostalgic neg")
        or hasToken("pro neg")
        or hasToken("sony_")
        or hasToken("sony ")
        or hasToken("fuji nostalgic")
        or hasToken("sony classic")
    then
        return "camera_sim"
    end
    if hasToken("redscale")
        or hasToken("infra")
        or hasToken("dream")
        or hasToken("acid")
        or hasToken("crimson")
        or hasToken("phenomena")
        or hasToken("magic")
        or hasToken("neo")
        or hasToken("zero")
        or hasToken("oktar")
        or hasToken("vektro")
        or hasToken("senova")
        or hasToken("evpro")
        or hasToken("ayon")
        or hasToken("vespera")
        or hasToken("veniliqum")
        or hasToken("zetra")
        or hasToken("rose spectra")
        or hasToken("asteroid city")
        or hasToken("blue velvet")
        or hasToken("chroma fade")
        or hasToken("x-tarr")
        or hasToken("amarelo")
        or hasToken("gold luxe")
        or hasToken("procolor")
        or hasToken("phoenix")
        or hasToken("gaf")
    then
        return "creative"
    end
    return "color_negative"
end

local function normalizedTagSet(emulsion)
    local tags = {}
    for _, tag in ipairs((emulsion and emulsion.publicTags) or {}) do
        local key = string.lower(tostring(tag or ""))
        if key ~= "" then
            tags[key] = true
        end
    end
    return tags
end

local function isLabSystemEmulsion(emulsion)
    local tags = normalizedTagSet(emulsion)
    if tags["lab-only"]
        or tags["internal"]
        or tags["do-not-ship"]
        or tags["blocked-pending-fork"]
        or tags["borrower-risk"]
        or tags["legacy-control"]
        or tags["legacy-duplicate"]
        or tags["deprecated-public-row"]
        or tags["superseded-by-accurate"]
    then
        return true
    end
    local publicLabel = string.lower(tostring((emulsion and emulsion.publicLabel) or ""))
    local clientLabel = string.lower(tostring((emulsion and emulsion.clientFacingLabel) or ""))
    if string.find(publicLabel, "[lab]", 1, true)
        or string.find(publicLabel, "legacy control", 1, true)
        or string.find(clientLabel, "internal lab control", 1, true)
    then
        return true
    end
    return false
end

local function isPortraitToolEmulsion(emulsion)
    local id = tostring((emulsion and emulsion.id) or "")
    if id == "portra_400_skinsafe_prototype" then
        return true
    end
    local tags = normalizedTagSet(emulsion)
    return tags["skin-safe"] == true or tags["optional-branch"] == true
end

local function classifyPanelISection(processId, emulsion)
    local id = string.lower(tostring((emulsion and emulsion.id) or ""))
    local tags = normalizedTagSet(emulsion)
    if isLabSystemEmulsion(emulsion) then
        return "lab_system"
    end
    if isPortraitToolEmulsion(emulsion) then
        return "portrait_tools"
    end
    if PANEL1_CORE_PRIORITY_ORDER[id] ~= nil then
        return "core_master"
    end
    if processId == "slide" then
        return "slide_chrome"
    end
    if processId == "cine" then
        if id == "fuji_reala_500d" or string.find(id, "reala", 1, true) then
            return "fuji_c41"
        end
        return "cine_motion"
    end
    if processId == "bw" then
        return "bw"
    end
    if processId == "camera_sim" or processId == "creative" then
        return "creative_misc"
    end
    if string.find(id, "portra", 1, true)
        or string.find(id, "ektar", 1, true)
        or string.find(id, "pro_image", 1, true)
        or string.find(id, "aerocolor", 1, true)
        or string.find(id, "gold", 1, true)
        or string.find(id, "colorplus", 1, true)
        or string.find(id, "ultramax", 1, true)
    then
        return "kodak_pro_c41"
    end
    if string.find(id, "fuji_", 1, true) then
        return "fuji_c41"
    end
    if string.find(id, "konica", 1, true)
        or tags["consumer"] == true
        or tags["nostalgic"] == true
        or string.find(id, "natura", 1, true)
    then
        return "consumer_c41"
    end
    return "consumer_c41"
end

local function makePanel1EmulsionItems(source)
    local grouped = {}
    for _, processId in ipairs(EMULSION_PROCESS_ORDER) do
        grouped[processId] = {}
    end
    local groupedCreative = {}
    for _, collectionId in ipairs(creativeCollectionOrder()) do
        groupedCreative[collectionId] = {}
    end
    for _, value in ipairs(source or {}) do
        local collectionId = getCreativeCollectionBrowseId(value)
        if collectionId then
            if not groupedCreative[collectionId] then
                groupedCreative[collectionId] = {}
            end
            groupedCreative[collectionId][#groupedCreative[collectionId] + 1] = {
                title = displayEmulsionLabel(value),
                value = value.id,
                sortLabel = displayEmulsionLabel(value),
                emulsion = value,
            }
        else
            local processId = categorizeEmulsionProcess(value)
            if not grouped[processId] then grouped[processId] = {} end
            grouped[processId][#grouped[processId] + 1] = {
                title = displayEmulsionLabel(value),
                value = value.id,
                sortLabel = displayEmulsionLabel(value),
                emulsion = value,
                processId = processId,
            }
        end
    end

    local items = {
        { title = "[ORIGINAL] No film emulsion (Lab tools active)", value = ORIGINAL_EMULSION_ID },
    }
    local sectionBuckets = {}
    for _, sectionId in ipairs(PANEL1_SECTION_ORDER) do sectionBuckets[sectionId] = {} end

    local function pushSectionItem(sectionId, groupedItem)
        if not sectionBuckets[sectionId] then sectionBuckets[sectionId] = {} end
        sectionBuckets[sectionId][#sectionBuckets[sectionId] + 1] = groupedItem
    end

    for _, processId in ipairs(EMULSION_PROCESS_ORDER) do
        if processId ~= "creative" then
            for _, groupedItem in ipairs(grouped[processId] or {}) do
                local sectionId = classifyPanelISection(processId, groupedItem.emulsion)
                pushSectionItem(sectionId, groupedItem)
            end
        end
    end
    for _, groupedItem in ipairs(grouped.creative or {}) do
        local sectionId = classifyPanelISection("creative", groupedItem.emulsion)
        pushSectionItem(sectionId, groupedItem)
    end

    local function sortSectionItems(sectionId, bucket)
        table.sort(bucket, function(a, b)
            if sectionId == "core_master" then
                local aid = tostring(a.value or "")
                local bid = tostring(b.value or "")
                local arank = PANEL1_CORE_PRIORITY_ORDER[aid] or 9999
                local brank = PANEL1_CORE_PRIORITY_ORDER[bid] or 9999
                if arank ~= brank then return arank < brank end
            end
            return tostring(a.sortLabel or "") < tostring(b.sortLabel or "")
        end)
    end

    local function appendSectionRows(sectionId, withHeader)
        local bucket = sectionBuckets[sectionId] or {}
        if #bucket == 0 then return end
        sortSectionItems(sectionId, bucket)
        if withHeader ~= false then
            appendMenuHeader(items, PANEL1_SECTION_HEADER[sectionId] or sectionId)
        end
        for _, groupedItem in ipairs(bucket) do
            items[#items + 1] = { title = groupedItem.title, value = groupedItem.value }
        end
    end

    appendSectionRows("core_master", true)
    appendSectionRows("kodak_pro_c41", true)
    appendSectionRows("fuji_c41", true)
    appendSectionRows("consumer_c41", true)
    appendSectionRows("slide_chrome", true)
    appendSectionRows("cine_motion", true)
    appendSectionRows("bw", true)

    local creativeSectionCount = 0
    for _, collectionId in ipairs(creativeCollectionOrder()) do
        creativeSectionCount = creativeSectionCount + #(groupedCreative[collectionId] or {})
    end
    if creativeSectionCount > 0 or #(sectionBuckets.creative_misc or {}) > 0 then
        appendMenuHeader(items, PANEL1_SECTION_HEADER.creative_misc)
        for _, collectionId in ipairs(creativeCollectionOrder()) do
            local bucket = groupedCreative[collectionId] or {}
            if #bucket > 0 then
                local sectionTitle = tostring(collectionId)
                if type(publicCatalog.getCreativeCollectionMeta) == "function" then
                    local meta = publicCatalog.getCreativeCollectionMeta(collectionId)
                    if meta and meta.title then sectionTitle = tostring(meta.title) end
                end
                appendMenuHeader(items, "— " .. sectionTitle .. " —")
                table.sort(bucket, function(a, b) return tostring(a.sortLabel or "") < tostring(b.sortLabel or "") end)
                for _, groupedItem in ipairs(bucket) do
                    items[#items + 1] = { title = groupedItem.title, value = groupedItem.value }
                end
            end
        end
        local creativeMisc = sectionBuckets.creative_misc or {}
        if #creativeMisc > 0 then
            appendMenuHeader(items, "— Additional Creative / Digital Sim —")
            sortSectionItems("creative_misc", creativeMisc)
            for _, groupedItem in ipairs(creativeMisc) do
                items[#items + 1] = { title = groupedItem.title, value = groupedItem.value }
            end
        end
    end
    appendSectionRows("portrait_tools", true)
    appendSectionRows("lab_system", true)
    if type(config.getDemoLockedEmulsionItems) == "function" then
        local lockedItems = config.getDemoLockedEmulsionItems() or {}
        if #lockedItems > 0 then
            appendMenuHeader(items, "── Locked in Demo / Full Collection ──")
            for _, locked in ipairs(lockedItems) do
                items[#items + 1] = {
                    title = "🔒 " .. tostring(locked.title or ""),
                    value = tostring(locked.value or CREATIVE_COLLECTION_HEADER_VALUE),
                    enabled = false,
                }
            end
        end
    end
    return items
end

local function makeItems(source, defaultId)
    local items = {}
    for _, v in ipairs(source or {}) do
        items[#items + 1] = { title = displayEmulsionLabel(v), value = tostring(v.id) }
    end
    if #items == 0 and defaultId then
        items[1] = { title = tostring(defaultId), value = tostring(defaultId) }
    end
    return items
end

local WORKFLOW_BY_CATEGORY_CODE = {
    POR = "por",
    ZG = "zg",
    GH = "zg",
    KRA = "kra",
    LND = "kra",
    NOC = "noc",
    NGT = "noc",
    REP = "rep",
    KIN = "kin",
    CIN = "kin",
    FA = "fa",
    VIN = "vin",
    EXP = "exp",
}

local function smartWorkflowForEmulsionId(emulsionId)
    local em = config.findEmulsion and config.findEmulsion(emulsionId) or nil
    if not em then
        return tostring(config.defaultSmartMatchWorkflow or "rep")
    end
    local categoryId = tostring((em and em.publicCategoryId) or "")
    if categoryId == "" and publicCatalog and type(publicCatalog.getCategoryId) == "function" then
        categoryId = tostring(publicCatalog.getCategoryId(em.id, em.type, em.bw == true) or "")
    end
    local code = "EXP"
    if publicCatalog and type(publicCatalog.getCategoryCode) == "function" then
        code = string.upper(tostring(publicCatalog.getCategoryCode(categoryId) or "EXP"))
    end
    return WORKFLOW_BY_CATEGORY_CODE[code] or tostring(config.defaultSmartMatchWorkflow or "rep")
end

local function resolveCanonicalEmulsionId(rawId, props)
    local id = tostring(rawId or "")
    if labCameraProfile and type(labCameraProfile.resolveCanonicalEmulsionId) == "function" then
        local resolved = labCameraProfile.resolveCanonicalEmulsionId(id, {
            props = props,
            config = config,
        })
        if resolved and tostring(resolved) ~= "" then
            return tostring(resolved)
        end
    end
    return id
end

local function resolveApplyCameraProfile(canonicalId, props)
    local emulsion = config.findEmulsion and config.findEmulsion(canonicalId) or nil
    if labCameraProfile and type(labCameraProfile.resolveLabProfileCheckboxState) == "function" then
        local state = labCameraProfile.resolveLabProfileCheckboxState(props, canonicalId, emulsion, {
            props = props,
            config = config,
            pluginPath = (_PLUGIN and _PLUGIN.path) or nil,
        })
        return state and state.applyCameraProfile_resolved == true
    end
    return false
end

local function makeLutIntensityItems()
    local levels = { 0, 25, 50, 75, 100, 125, 150 }
    local out = {}
    for _, v in ipairs(levels) do
        out[#out + 1] = { title = tostring(v) .. "%", value = tostring(v) }
    end
    return out
end

local function coerceBoolean(value, defaultValue)
    if value == nil then
        return defaultValue == true
    end
    if value == true then
        return true
    end
    if value == false then
        return false
    end
    local lowered = string.lower(tostring(value or ""))
    if lowered == "1" or lowered == "true" or lowered == "yes" then
        return true
    end
    if lowered == "0" or lowered == "false" or lowered == "no" then
        return false
    end
    return defaultValue == true
end

local function prefKey(name)
    return PANEL1_UI_PREF_PREFIX .. tostring(name or "")
end

local function savePanel1UiState(prefs, props)
    if type(prefs) ~= "table" or type(props) ~= "table" then
        return
    end
    prefs[prefKey("version")] = tostring(PANEL1_UI_VERSION)
    for _, key in ipairs(PANEL1_UI_STRING_KEYS) do
        prefs[prefKey(key)] = tostring(props[key] or "")
    end
    for _, key in ipairs(PANEL1_UI_NUMBER_KEYS) do
        local value = tonumber(props[key])
        if value ~= nil then
            prefs[prefKey(key)] = tostring(value)
        end
    end
    for _, key in ipairs(PANEL1_UI_BOOL_KEYS) do
        prefs[prefKey(key)] = (props[key] == true)
    end
end

local function restorePanel1UiState(prefs, props)
    if type(prefs) ~= "table" or type(props) ~= "table" then
        return
    end
    local version = tonumber(prefs[prefKey("version")]) or 0
    if version <= 0 then
        return
    end
    for _, key in ipairs(PANEL1_UI_STRING_KEYS) do
        local value = prefs[prefKey(key)]
        if value ~= nil and tostring(value) ~= "" then
            props[key] = tostring(value)
        end
    end
    for _, key in ipairs(PANEL1_UI_NUMBER_KEYS) do
        local value = tonumber(prefs[prefKey(key)])
        if value ~= nil then
            props[key] = value
        end
    end
    for _, key in ipairs(PANEL1_UI_BOOL_KEYS) do
        local value = prefs[prefKey(key)]
        if value ~= nil then
            props[key] = (value == true)
        end
    end
end

local function loadPanel2StateFromPrefs(prefs)
    local source = prefs or {}
    return {
        baseFamily = tostring(config.defaultBaseFamilyId or "neutral_cmos"),
        baseStrength = tonumber(source.panel2BaseStrength) or 100,
        colorProcess = tostring(source.panel2ColorProcess or config.defaultColorProcess or "refined"),
        lutIntensity = tostring(source.panel2LutIntensity or config.defaultLutIntensity or "100"),
        saturationTrim = tostring(source.panel2SaturationTrim or config.defaultSaturationTrim or "0"),
        labGlow = tostring(source.panel2LabGlow or config.defaultLabGlow or "0"),
        labFade = tostring(source.panel2LabFade or config.defaultLabFade or "0"),
        softHighs = coerceBoolean(source.panel2SoftHighs, config.defaultSoftHighs == true),
        softLows = coerceBoolean(source.panel2SoftLows, config.defaultSoftLows == true),
        whiteClip = tostring(source.panel2WhiteClip or config.defaultWhiteClip or "0"),
        blackClip = tostring(source.panel2BlackClip or config.defaultBlackClip or "0"),
    }
end

local function renderSliderRow(f, bind, key, label, options)
    options = options or {}
    local row = {
        spacing = 4,
        f:static_text({
            title = label,
            width = 96,
            alignment = "right",
            size = "small",
            mouse_down = options.mouse_down,
        }),
        f:slider({
            value = bind(key),
            min = tonumber(options.min) or 0,
            max = tonumber(options.max) or 100,
            integral = (options.integral ~= false),
            width = 160,
            size = "small",
            mouse_down = options.mouse_down,
        }),
        f:edit_field({
            value = bind(key),
            min = tonumber(options.min) or 0,
            max = tonumber(options.max) or 100,
            precision = tonumber(options.precision) or 0,
            width = 42,
            size = "small",
            mouse_down = options.mouse_down,
        }),
    }
    if type(options.on_reset) == "function" then
        row[#row + 1] = f:push_button({
            title = "0",
            width = 22,
            size = "small",
            action = options.on_reset,
        })
    end
    return f:row(row)
end

local function clamp(v, lo, hi)
    if v < lo then return lo end
    if v > hi then return hi end
    return v
end

-- Slider strength 0..1 from UI value 0..100 (one step = +1 on the control).
local function fxK(rawValue)
    return clamp((tonumber(rawValue) or 0) / 100, 0, 1)
end

-- Linear tone scale (no k*k): avoids late-slider PV2012 shadow-lift pop on Blacks2012/Shadows2012.
local function fxToneK(rawValue)
    return fxK(rawValue)
end

-- PV2012 lifts midtones abruptly when Blacks2012 crosses ~+12; ease only the top of the slider.
local function shadowBlacksK(rawValue, maxBlacks)
    local k = fxK(rawValue)
    maxBlacks = tonumber(maxBlacks) or 0
    if maxBlacks <= 0 or k <= 0 then
        return 0
    end
    if maxBlacks <= 10 then
        return maxBlacks * k
    end
    local kneeK = 10 / maxBlacks
    if k <= kneeK then
        return maxBlacks * k
    end
    local t = (k - kneeK) / (1 - kneeK)
    return 10 + (maxBlacks - 10) * t * t
end

local function addDelta(out, baseline, key, delta)
    local current = tonumber(out[key]) or 0
    out[key] = current + delta
end

local function buildPanel7Settings(props, baseline)
    local out = {}
    local k = fxK(props.lomoPurple)
    if k > 0 then
        addDelta(out, baseline, "HueAdjustmentGreen", -120 * k)
        addDelta(out, baseline, "HueAdjustmentYellow", -45 * k)
        addDelta(out, baseline, "HueAdjustmentAqua", -95 * k)
        addDelta(out, baseline, "HueAdjustmentBlue", 35 * k)
        addDelta(out, baseline, "HueAdjustmentPurple", 20 * k)
        addDelta(out, baseline, "HueAdjustmentMagenta", 15 * k)
        addDelta(out, baseline, "SaturationAdjustmentGreen", -40 * k)
        addDelta(out, baseline, "SaturationAdjustmentAqua", 24 * k)
        addDelta(out, baseline, "SaturationAdjustmentBlue", 18 * k)
        addDelta(out, baseline, "SaturationAdjustmentPurple", 16 * k)
        addDelta(out, baseline, "SaturationAdjustmentMagenta", 12 * k)
        addDelta(out, baseline, "LuminanceAdjustmentGreen", -12 * k)
        addDelta(out, baseline, "LuminanceAdjustmentBlue", -6 * k)
        addDelta(out, baseline, "Vibrance", 8 * k)
    end

    k = fxK(props.lomoTurquoise)
    if k > 0 then
        addDelta(out, baseline, "HueAdjustmentRed", 120 * k)
        addDelta(out, baseline, "HueAdjustmentOrange", 90 * k)
        addDelta(out, baseline, "HueAdjustmentYellow", 60 * k)
        addDelta(out, baseline, "HueAdjustmentGreen", 20 * k)
        addDelta(out, baseline, "SaturationAdjustmentRed", 10 * k)
        addDelta(out, baseline, "SaturationAdjustmentOrange", 15 * k)
        addDelta(out, baseline, "SaturationAdjustmentYellow", 8 * k)
        addDelta(out, baseline, "ColorGradeShadowsHue", 190 * k)
        addDelta(out, baseline, "ColorGradeShadowsSat", 18 * k)
    end

    k = fxK(props.metropolis)
    if k > 0 then
        addDelta(out, baseline, "Contrast2012", 40 * k)
        addDelta(out, baseline, "Clarity2012", 30 * k)
        addDelta(out, baseline, "Shadows2012", -40 * k)
        addDelta(out, baseline, "Blacks2012", -25 * k)
        addDelta(out, baseline, "SaturationAdjustmentOrange", -30 * k)
        addDelta(out, baseline, "SaturationAdjustmentYellow", -40 * k)
        addDelta(out, baseline, "SaturationAdjustmentBlue", -35 * k)
        addDelta(out, baseline, "SaturationAdjustmentGreen", -50 * k)
        addDelta(out, baseline, "SaturationAdjustmentAqua", -45 * k)
        addDelta(out, baseline, "SaturationAdjustmentPurple", -40 * k)
        addDelta(out, baseline, "SaturationAdjustmentMagenta", -30 * k)
        addDelta(out, baseline, "LuminanceAdjustmentGreen", -12 * k)
        addDelta(out, baseline, "LuminanceAdjustmentBlue", -8 * k)
    end

    k = fxK(props.redscale)
    if k > 0 then
        addDelta(out, baseline, "SaturationAdjustmentBlue", -80 * k)
        addDelta(out, baseline, "SaturationAdjustmentAqua", -70 * k)
        addDelta(out, baseline, "SaturationAdjustmentGreen", -60 * k)
        addDelta(out, baseline, "LuminanceAdjustmentBlue", -30 * k)
        addDelta(out, baseline, "LuminanceAdjustmentGreen", -20 * k)
        addDelta(out, baseline, "HueAdjustmentBlue", -100 * k)
        addDelta(out, baseline, "HueAdjustmentAqua", -80 * k)
        addDelta(out, baseline, "ColorGradeHighlightsHue", 30 * k)
        addDelta(out, baseline, "ColorGradeHighlightsSat", 25 * k)
        addDelta(out, baseline, "ColorGradeShadowsHue", 20 * k)
        addDelta(out, baseline, "ColorGradeShadowsSat", 15 * k)
        addDelta(out, baseline, "Whites2012", 12 * k)
        addDelta(out, baseline, "Highlights2012", 8 * k)
    end

    return out
end

local function buildPanel8Settings(props, baseline)
    local out = {}
    k = fxK(props.portaSkinGuard)
    if k > 0 then
        addDelta(out, baseline, "LuminanceAdjustmentOrange", 14 * k)
        addDelta(out, baseline, "SaturationAdjustmentOrange", -10 * k)
        addDelta(out, baseline, "LuminanceAdjustmentYellow", 8 * k)
        addDelta(out, baseline, "SaturationAdjustmentYellow", -6 * k)
        addDelta(out, baseline, "ColorGradeShadowsHue", 195 * k)
        addDelta(out, baseline, "ColorGradeShadowsSat", 12 * k)
        addDelta(out, baseline, "Shadows2012", 10 * k)
    end

    k = fxK(props.kodachromeK14)
    if k > 0 then
        addDelta(out, baseline, "SaturationAdjustmentRed", 25 * k)
        addDelta(out, baseline, "HueAdjustmentRed", -8 * k)
        addDelta(out, baseline, "SaturationAdjustmentBlue", -12 * k)
        addDelta(out, baseline, "LuminanceAdjustmentBlue", 16 * k)
        addDelta(out, baseline, "SaturationAdjustmentGreen", -8 * k)
        addDelta(out, baseline, "SaturationAdjustmentAqua", -10 * k)
        addDelta(out, baseline, "ColorGradeShadowsHue", 230 * k)
        addDelta(out, baseline, "ColorGradeShadowsSat", 18 * k)
        addDelta(out, baseline, "Shadows2012", -12 * k)
        addDelta(out, baseline, "Blacks2012", -10 * k)
    end
    return out
end

local function buildPanel9Settings(props, baseline)
    local out = {}
    local k = fxK(props.filmSoup)
    if k > 0 then
        addDelta(out, baseline, "ColorGradeMidtoneHue", 80 * k)
        addDelta(out, baseline, "ColorGradeMidtoneSat", 30 * k)
        addDelta(out, baseline, "HueAdjustmentGreen", 35 * k)
        addDelta(out, baseline, "HueAdjustmentOrange", -25 * k)
        addDelta(out, baseline, "SaturationAdjustmentGreen", 20 * k)
        addDelta(out, baseline, "SaturationAdjustmentOrange", -18 * k)
        addDelta(out, baseline, "SaturationAdjustmentBlue", -25 * k)
        addDelta(out, baseline, "Clarity2012", -20 * k)
        addDelta(out, baseline, "Texture", -15 * k)
        addDelta(out, baseline, "Shadows2012", 12 * k)
        addDelta(out, baseline, "ColorGradeShadowsHue", 110 * k)
        addDelta(out, baseline, "ColorGradeShadowsSat", 15 * k)
        addDelta(out, baseline, "LuminanceAdjustmentOrange", -10 * k)
        addDelta(out, baseline, "LuminanceAdjustmentGreen", -8 * k)
    end

    return out
end

local function mergeOut(base, patch)
    for key, value in pairs(patch or {}) do
        local current = tonumber(base[key])
        if current == nil then current = 0 end
        base[key] = current + (tonumber(value) or 0)
    end
end

local function showDialog()
    LrFunctionContext.callWithContext("AnalogSignaturePanelOneMenu", function(context)
        local f = LrView.osFactory()
        local bind = LrView.bind
        local props = LrBinding.makePropertyTable(context)
        local catalog = LrApplication.activeCatalog()
        local prefs = LrPrefs.prefsForPlugin()

        props.sourceScale = tostring(config.defaultSourceScaleId or "auto")
        props.emulsion = ORIGINAL_EMULSION_ID
        props.format = "auto"
        props.smartMatchWorkflow = "auto"
        props.lutIntensity = tostring(config.defaultLutIntensity or "100")
        props.weddingSync = false

        props.hd_curve = "base"
        props.photon_scattering = "base"
        props.anti_halation_bloom = "base"
        props.film_damage = "base"
        props.printerExposureEV = 0
        props.colorHeadKelvin = 5500
        props.colorHeadTint = 0

        props.ssg_toe_grain = 0
        props.ssg_body_grain = 0
        props.ssg_shoulder_grain = 0
        props.ssg_resolving_loss = 0
        props.lomoPurple = 0
        props.lomoTurquoise = 0
        props.metropolis = 0
        props.redscale = 0
        props.portaSkinGuard = 0
        props.kodachromeK14 = 0
        props.filmSoup = 0
        props.grainIntensity = 100
        -- Safe-open policy: never write Develop settings on panel open.
        -- Live Preview stays ON by default, but preview runs only after user interaction.
        props.livePreview = true
        props.livePreviewStatus = "Live Preview: armed"

        do
            local function readDevelopNumeric(seed, seedKey, controllerKey)
                if type(seed) == "table" and tonumber(seed[seedKey]) ~= nil then
                    return tonumber(seed[seedKey])
                end
                local okCtrl, ctrlValue = LrTasks.pcall(function()
                    return LrDevelopController.getValue(controllerKey)
                end)
                if okCtrl and tonumber(ctrlValue) ~= nil then
                    return tonumber(ctrlValue)
                end
                return nil
            end
            local target = catalog:getTargetPhoto()
            if target then
                local okSeed, seed = LrTasks.pcall(function()
                    return target:getDevelopSettings() or {}
                end)
                if okSeed and type(seed) == "table" then
                    local exposure = readDevelopNumeric(seed, "Exposure2012", "Exposure")
                    if exposure ~= nil then
                        props.printerExposureEV = exposure
                    end
                    local temperature = readDevelopNumeric(seed, "Temperature", "Temperature")
                    -- Only seed when Temperature is a real Kelvin value. For non-RAW
                    -- files (and relative WB) Lightroom reports Temperature on a
                    -- -100..100 scale, which would clamp up to 2000 and wrongly cool
                    -- the slider on open. In that case keep the 5500 default.
                    if temperature ~= nil and temperature >= 1000 then
                        props.colorHeadKelvin = clampNumber(temperature, 2000, 10000)
                    end
                    local tint = readDevelopNumeric(seed, "Tint", "Tint")
                    if tint ~= nil then
                        props.colorHeadTint = tint
                    end
                else
                    local exposure = readDevelopNumeric(nil, "Exposure2012", "Exposure")
                    if exposure ~= nil then
                        props.printerExposureEV = exposure
                    end
                    local temperature = readDevelopNumeric(nil, "Temperature", "Temperature")
                    if temperature ~= nil and temperature >= 1000 then
                        props.colorHeadKelvin = clampNumber(temperature, 2000, 10000)
                    end
                    local tint = readDevelopNumeric(nil, "Tint", "Tint")
                    if tint ~= nil then
                        props.colorHeadTint = tint
                    end
                end
            end
        end

        local previewState = {
            photo = nil,
            baseline = nil,
            active = false,
            worker = false,
            dirty = false,
            applying = false,
            closing = false,
            mutated = false,
            immediate = false,
            engineBaseline = nil,
            engineSignature = nil,
            coreDirty = false,
            suppressObservers = false,
            isRestoringState = true,
            isResetting = false,
            isInitializing = true,
            userInteracted = false,
            applyCommitted = false,
            emulsionSelectionArmed = false,
            sessionBaselineMap = {},
            forceFullPreview = false,
            entryRestoreBaseline = nil,
            touchedControls = {},
            applyPending = false,
            originalLabBaseline = nil,
        }

        restorePanel1UiState(prefs, props)
        -- Scene and Emulsion start from Level 0 on plugin entry, or are auto-detected from photo.
        props.emulsion = ORIGINAL_EMULSION_ID
        props.smartMatchWorkflow = "auto"
        do
            local target = catalog:getTargetPhoto()
            if target then
                local okSeed, seed = LrTasks.pcall(function()
                    return target:getDevelopSettings() or {}
                end)
                if okSeed and type(seed) == "table" and seed.CameraProfile then
                    local currentProfile = tostring(seed.CameraProfile or "")
                    if currentProfile ~= "" then
                        for _, em in ipairs(config.emulsions or {}) do
                            if em.profile and em.profile ~= "" then
                                local matched = false
                                if labCameraProfile and type(labCameraProfile.profilesMatch) == "function" then
                                    matched = labCameraProfile.profilesMatch(em.profile, currentProfile)
                                else
                                    matched = (currentProfile:lower() == em.profile:lower() or currentProfile:lower():find(em.profile:lower(), 1, true) ~= nil)
                                end
                                if matched then
                                    props.emulsion = em.id
                                    props.smartMatchWorkflow = normalizeSmartMatchWorkflow(smartWorkflowForEmulsionId(em.id))
                                    break
                                end
                            end
                        end
                    end
                end
            end
        end
        -- H&D always starts from level 0 on each plugin entry.
        props.hd_curve = "base"
        -- Photon / Halation / Aging always start from level 0 on each plugin entry.
        props.photon_scattering = "base"
        props.anti_halation_bloom = "base"
        props.film_damage = "base"
        -- SSG always starts from level 0 on each plugin entry.
        resetSsgSliders(props)
        resetCreativeLabSliders(props)
        props.lutIntensity = "100"
        props.format = "auto"
        props.grainIntensity = 100

        local function syncAutoGrainFormatFromPhoto(photo)
            if tostring(props.format or "") ~= "auto" then
                return
            end
            local inferred, confident = inferGrainFormatIdFromPhoto(photo)
            if confident and inferred and inferred ~= "" then
                previewState.suppressObservers = true
                props.format = inferred
                previewState.suppressObservers = false
            end
        end

        local function getPhotoKey(photo)
            if not photo then return nil end
            local okId, localId = LrTasks.pcall(function()
                return photo.localIdentifier
            end)
            if okId and localId ~= nil then
                return tostring(localId)
            end
            return tostring(photo)
        end

        local function captureDevelopSnapshot(photo)
            local okCap, snap = LrTasks.pcall(function()
                return photo:getDevelopSettings() or {}
            end)
            if not okCap or type(snap) ~= "table" then
                return nil
            end
            local out = {}
            for k, v in pairs(snap) do
                out[k] = v
            end
            return out
        end

        local function copyTable(source)
            local out = {}
            for key, value in pairs(source or {}) do
                out[key] = value
            end
            return out
        end

        local function ensureSessionBaseline(photo)
            local key = getPhotoKey(photo)
            if not key then
                return nil
            end
            local baseline = previewState.sessionBaselineMap[key]
            if type(baseline) == "table" then
                return baseline
            end
            baseline = captureDevelopSnapshot(photo)
            if type(baseline) == "table" then
                previewState.sessionBaselineMap[key] = baseline
            end
            return baseline
        end

        local function ensureOriginalLabBaseline(photo)
            if type(previewState.originalLabBaseline) == "table" then
                return previewState.originalLabBaseline
            end
            local session = previewState.entryRestoreBaseline
            if type(session) ~= "table" then
                session = ensureSessionBaseline(photo)
            end
            if type(session) == "table" then
                previewState.originalLabBaseline = copyTable(session)
            end
            return previewState.originalLabBaseline
        end

        local function coreSignature()
            return table.concat({
                tostring(props.emulsion or ""),
                tostring(props.sourceScale or ""),
                tostring(props.format or ""),
                tostring(props.lutIntensity or ""),
            }, "|")
        end

        local function safeDemoFallbackEmulsionId()
            local fallbackId = tostring(config.defaultEmulsionId or "")
            if fallbackId ~= "" and fallbackId ~= ORIGINAL_EMULSION_ID
                and config.findEmulsion and config.findEmulsion(fallbackId) then
                return fallbackId
            end
            for _, em in ipairs(config.emulsions or {}) do
                local emId = tostring(em and em.id or "")
                if emId ~= "" and config.findEmulsion and config.findEmulsion(emId) then
                    return emId
                end
            end
            return ORIGINAL_EMULSION_ID
        end

        local function buildOutputForPhoto(photo, isPreview, forceEngineRun)
            local requestedEmulsionId = resolveCanonicalEmulsionId(props.emulsion, props)
            local applyFilmEngine = (requestedEmulsionId ~= ORIGINAL_EMULSION_ID)
            local canonicalEmulsionId = requestedEmulsionId
            if applyFilmEngine and not (config.findEmulsion and config.findEmulsion(canonicalEmulsionId)) then
                local fallbackId = safeDemoFallbackEmulsionId()
                canonicalEmulsionId = fallbackId
                previewState.suppressObservers = true
                props.emulsion = fallbackId
                previewState.suppressObservers = false
            end
            local signature = coreSignature()
            local shouldRunEngine = applyFilmEngine and (forceEngineRun == true
                or previewState.engineBaseline == nil
                or previewState.engineSignature ~= signature
                or previewState.coreDirty == true)

            local engineAppliedSettings = nil
            if shouldRunEngine then
                local panel2State = loadPanel2StateFromPrefs(prefs)
                local runParams = {
                    photo = photo,
                    emulsionId = canonicalEmulsionId,
                    formatId = resolveGrainFormatId(props.format, photo),
                    sourceScaleId = tostring(props.sourceScale),
                    profileMode = tostring(config.defaultProfileMode or "production"),
                    conversionMode = tostring(config.defaultConversionMode or "analyze"),
                    colorProcess = tostring(panel2State.colorProcess),
                    baseFamily = tostring(panel2State.baseFamily),
                    baseStrength = tonumber(panel2State.baseStrength) or 100,
                    nightBoostLevel = tostring(config.defaultNightBoostLevel or "off"),
                    autoDmin = false,
                    debugKeepTemp = false,
                    applyFoundation = true,
                    useAnalyzerWB = false,
                    manualWbTemp = "as_shot",
                    smartMatch = true,
                    smartMatchStrength = tostring(config.defaultSmartMatchStrength or "balanced_film"),
                    smartMatchAmount = tonumber(config.defaultSmartMatchAmount) or 40,
                    smartMatchWorkflow = (
                        normalizeSmartMatchWorkflow(props.smartMatchWorkflow) == "auto"
                        and smartWorkflowForEmulsionId(canonicalEmulsionId)
                        or normalizeSmartMatchWorkflow(props.smartMatchWorkflow)
                    ),
                    analogizerProfile = tostring(config.defaultAnalogizerProfile or "auto_ektar"),
                    useAnalyzerOverrides = false,
                    applyCameraProfile = resolveApplyCameraProfile(canonicalEmulsionId, props),
                    -- Panel Apply must not block the UI on external backup TIFF render.
                    enableBackupRender = false,
                    backupOnProfileReject = (config.defaultBackupOnProfileReject ~= false),
                    allowGenericFallback = true,
                    previewFast = (isPreview == true),
                    -- Panel always computes in-engine; preview/apply each do one catalog write.
                    previewComputeOnly = true,
                    lutIntensity = tonumber(props.lutIntensity) or 100,
                    saturationTrim = tonumber(panel2State.saturationTrim) or 0,
                    labGlow = tonumber(panel2State.labGlow) or 0,
                    labFade = tonumber(panel2State.labFade) or 0,
                    softHighs = (panel2State.softHighs == true),
                    softLows = (panel2State.softLows == true),
                    whiteClip = tonumber(panel2State.whiteClip) or 0,
                    blackClip = tonumber(panel2State.blackClip) or 0,
                    pushPullEv = 0,
                }
                local summary = bridge.run(runParams)
                if type(summary) ~= "table" then
                    error("EngineBridge run failed")
                end
                if type(summary.appliedSettings) == "table" then
                    engineAppliedSettings = summary.appliedSettings
                end
            end

            -- Part V: Lab deltas always apply to a frozen baseline (engine or ORIGINAL entry).
            -- Never use photo:getDevelopSettings() here — live preview writes mutate the catalog
            -- and reading them back causes accumulative feedback (deltas stack on deltas).
            local baseline = {}
            if shouldRunEngine and type(engineAppliedSettings) == "table" then
                baseline = copyTable(engineAppliedSettings)
            elseif not applyFilmEngine then
                local origBase = ensureOriginalLabBaseline(photo)
                if type(origBase) == "table" then
                    baseline = copyTable(origBase)
                end
            elseif type(previewState.engineBaseline) == "table" then
                baseline = copyTable(previewState.engineBaseline)
            end

            if shouldRunEngine and type(baseline) == "table" and next(baseline) ~= nil then
                previewState.engineBaseline = copyTable(baseline)
                previewState.engineSignature = signature
                previewState.coreDirty = false
            elseif applyFilmEngine and type(previewState.engineBaseline) == "table" then
                baseline = copyTable(previewState.engineBaseline)
            end

            local controls = panelsRuntime.controlsFromSource(function(key)
                if key == "hd_curve" then return props.hd_curve end
                if key == "photon_scattering" then return props.photon_scattering end
                if key == "anti_halation_bloom" then return props.anti_halation_bloom end
                if key == "film_damage" then return props.film_damage end
                return "base"
            end)
            local _, _, out = panelsRuntime.applyToSettings(baseline, controls, {
                baselineSettings = baseline,
                formatId = resolveGrainFormatId(props.format, photo),
                panelKeys = { "hd_curve", "photon_scattering", "anti_halation_bloom", "film_damage" },
            }) 
            out = applySsgManualTrim(out, baseline, props, resolveGrainFormatId(props.format, photo))

            -- Grain Intensity: scale GrainAmount relative to what the engine/SSG set.
            local grainK = clampNumber(tonumber(props.grainIntensity) or 100, 0, 100) / 100.0
            if grainK < 1.0 then
                local currentGrain = tonumber(out.GrainAmount)
                if currentGrain == nil then currentGrain = tonumber(baseline.GrainAmount) or 0 end
                out.GrainAmount = roundNearest(clampNumber(currentGrain * grainK, 0, 100))
            end

            -- Only override develop WB/exposure when the user moved those sliders.
            -- Forcing seeded props (e.g. 5500 K default on JPEG) on every preview/apply
            -- overwrote the engine look and made the panel appear to change only exposure.
            if previewState.touchedControls.printerExposureEV == true then
                out.Exposure2012 = clampNumber(tonumber(props.printerExposureEV) or 0, -5.0, 5.0)
            end
            if previewState.touchedControls.colorHeadKelvin == true then
                out.Temperature = roundNearest(clampNumber(tonumber(props.colorHeadKelvin) or 5500, 2000, 10000))
            end
            if previewState.touchedControls.colorHeadTint == true then
                out.Tint = roundNearest(clampNumber(tonumber(props.colorHeadTint) or 0, -150, 150))
            end

            mergeOut(out, buildPanel7Settings(props, baseline))
            mergeOut(out, buildPanel8Settings(props, baseline))
            mergeOut(out, buildPanel9Settings(props, baseline))

            return out
        end

        local function applyToPhoto(photo)
            ensureSessionBaseline(photo)
            local out = buildOutputForPhoto(photo, false, true)

            local okApply, errApply = catalogWrite.run(catalog, "Analog Signature Film Engine", function()
                photo:applyDevelopSettings(out)
            end, { attempts = 8, sleep = 0.08, sleepMax = 0.35 })
            
            if not okApply then
                error(tostring(errApply or "apply failed"))
            end
        end

        local function restoreLivePreviewIfNeeded()
            if previewState.applyCommitted == true or previewState.applyPending == true then
                return
            end
            if previewState.active ~= true then
                return
            end
            local photo = previewState.photo or catalog:getTargetPhoto()
            if not photo then return end
            local restoreSettings = previewState.entryRestoreBaseline
            if type(restoreSettings) ~= "table" then
                restoreSettings = previewState.baseline
            end
            if type(restoreSettings) ~= "table" then
                return
            end
            local okRestore = catalogWrite.run(catalog, "Analog Signature Film Engine Preview Restore", function()
                photo:applyDevelopSettings(copyTable(restoreSettings))
            end, { attempts = 4, sleep = 0.04, sleepMax = 0.16, timeout = 1.2 })
            if not okRestore then
                LrDialogs.message("Analog Signature Film Engine", "Live Preview restore failed.", "warning")
            end
            previewState.baseline = nil
            previewState.photo = nil
            previewState.active = false
            previewState.engineBaseline = nil
            previewState.engineSignature = nil
            previewState.coreDirty = true
        end

        local function clearPreviewTransientState()
            previewState.photo = nil
            previewState.baseline = nil
            previewState.active = false
            previewState.mutated = false
            previewState.engineBaseline = nil
            previewState.engineSignature = nil
            previewState.coreDirty = true
            previewState.dirty = false
            previewState.immediate = false
        end

        local function restorePanelEntryBaseline(photo, statusLabel)
            if not photo then
                props.livePreviewStatus = "Photo reset: no photo"
                return false, "no_photo"
            end
            local baseline = ensureSessionBaseline(photo)
            if type(baseline) ~= "table" then
                props.livePreviewStatus = "Photo reset: baseline unavailable"
                return false, "baseline_unavailable"
            end
            previewState.suppressObservers = true
            previewState.isRestoringState = true
            previewState.isResetting = true
            local okReset = catalogWrite.run(catalog, "Analog Signature Film Engine Baseline Restore", function()
                photo:applyDevelopSettings(copyTable(baseline))
            end, { attempts = 5, sleep = 0.04, sleepMax = 0.16, timeout = 1.5 })
            clearPreviewTransientState()
            previewState.isResetting = false
            previewState.isRestoringState = false
            previewState.suppressObservers = false
            if okReset then
                props.livePreviewStatus = tostring(statusLabel or "Photo reset complete")
                return true, nil
            end
            props.livePreviewStatus = "Photo reset failed"
            return false, "restore_failed"
        end

        local function resetCurrentPhotoToSessionBaseline()
            local photo = catalog:getTargetPhoto()
            restorePanelEntryBaseline(photo, "Photo reset complete")
        end

        local function haltPreviewWorker(maxWaitSec)
            previewState.closing = true
            previewState.dirty = false
            previewState.immediate = false
            local deadline = LrDate.currentTime() + (tonumber(maxWaitSec) or 4)
            while (previewState.worker or previewState.applying) and LrDate.currentTime() < deadline do
                LrTasks.sleep(0.05)
            end
            previewState.worker = false
            previewState.applying = false
            previewState.dirty = false
        end

        local function resumePreviewWorker()
            previewState.closing = false
        end

        local function resetPanelUiDefaults()
            previewState.suppressObservers = true
            props.emulsion = ORIGINAL_EMULSION_ID
            props.smartMatchWorkflow = "auto"
            props.sourceScale = tostring(config.defaultSourceScaleId or "auto")
            props.format = "auto"
            props.lutIntensity = tostring(config.defaultLutIntensity or "100")
            props.weddingSync = false
            props.hd_curve = "base"
            props.photon_scattering = "base"
            props.anti_halation_bloom = "base"
            props.film_damage = "base"
            resetSsgSliders(props)
            resetCreativeLabSliders(props)
            props.printerExposureEV = 0
            props.colorHeadKelvin = 5500
            props.colorHeadTint = 0
            props.grainIntensity = 100
            previewState.touchedControls = {}
            previewState.engineBaseline = nil
            previewState.engineSignature = nil
            previewState.originalLabBaseline = nil
            previewState.coreDirty = true
            previewState.forceFullPreview = false
            previewState.lastPreviewEmulsion = nil
            previewState.suppressObservers = false
        end

        local function applyLivePreview()
            if previewState.closing or props.livePreview ~= true or previewState.isRestoringState or previewState.isResetting or previewState.isInitializing then
                return
            end
            local photo = previewState.photo or catalog:getTargetPhoto()
            if not photo then
                props.livePreviewStatus = "Live Preview: no photo"
                return
            end
            ensureSessionBaseline(photo)
            if previewState.photo and previewState.photo ~= photo then
                restoreLivePreviewIfNeeded()
                previewState.engineBaseline = nil
                previewState.engineSignature = nil
                previewState.coreDirty = true
            end
            if previewState.baseline == nil then
                local okCap, snap = LrTasks.pcall(function()
                    return photo:getDevelopSettings() or {}
                end)
                if not okCap or type(snap) ~= "table" then
                    props.livePreviewStatus = "Live Preview error: capture failed"
                    return
                end
                previewState.baseline = snap
                previewState.photo = photo
            end
            local usePreviewFast = (previewState.forceFullPreview ~= true)
            local out = buildOutputForPhoto(photo, usePreviewFast, previewState.coreDirty == true)
            local writeOpts = { attempts = 3, sleep = 0.03, sleepMax = 0.12, timeout = 0.9 }
            if previewState.forceFullPreview == true then
                writeOpts = { attempts = 8, sleep = 0.08, sleepMax = 0.35, timeout = 5.0 }
            end
            local okPrev, errPrev = catalogWrite.run(catalog, "Analog Signature Film Engine Live Preview", function()
                photo:applyDevelopSettings(out)
            end, writeOpts)
            
            if not okPrev then
                props.livePreviewStatus = "Live Preview error: " .. tostring(errPrev or "failed")
                return
            end
            previewState.active = true
            previewState.mutated = true
            previewState.forceFullPreview = false
            previewState.lastPreviewEmulsion = tostring(props.emulsion or "")
            props.livePreviewStatus = "Live Preview: OK"

            if usePreviewFast == false then
                LrTasks.startAsyncTask(function()
                    local LrDevelopController = import "LrDevelopController"
                    local okExp, curExp = pcall(LrDevelopController.getValue, "Exposure")
                    if okExp and tonumber(curExp) ~= nil then
                        pcall(LrDevelopController.setValue, "Exposure", tonumber(curExp))
                    end
                end)
            end
        end

        local function requestLivePreview(immediate, forceFull)
            if previewState.applyPending == true or previewState.applyCommitted == true then
                return
            end
            if previewState.closing or props.livePreview ~= true or previewState.suppressObservers or previewState.isRestoringState or previewState.isResetting or previewState.isInitializing then
                if props.livePreview ~= true then
                    props.livePreviewStatus = "Live Preview: off"
                end
                return
            end
            if forceFull == true then
                previewState.forceFullPreview = true
            end
            if immediate == true then
                previewState.immediate = true
            end
            previewState.dirty = true
            if previewState.worker then
                return
            end
            previewState.worker = true
            LrTasks.startAsyncTask(function()
                while previewState.dirty and not previewState.closing do
                    previewState.dirty = false
                    local delay = previewState.immediate and 0.02 or 0.12
                    previewState.immediate = false
                    LrTasks.sleep(delay)
                    if not previewState.dirty then
                        if previewState.applying then
                            previewState.dirty = true
                        else
                            previewState.applying = true
                            applyLivePreview()
                            previewState.applying = false
                        end
                    end
                end
                previewState.worker = false
            end)
        end

        local sliderDefaults = {
            ssg_toe_grain = 0,
            ssg_body_grain = 0,
            ssg_shoulder_grain = 0,
            ssg_resolving_loss = 0,
            lomoPurple = 0,
            lomoTurquoise = 0,
            metropolis = 0,
            redscale = 0,
            portaSkinGuard = 0,
            kodachromeK14 = 0,
            filmSoup = 0,
            printerExposureEV = 0,
            grainIntensity = 100,
        }
        local clickState = {}
        local lastSliderValues = {}
        local function resetSliderToDefault(key, defaultValue)
            previewState.userInteracted = true
            previewState.suppressObservers = true
            local resetValue = tonumber(defaultValue) or 0
            props[key] = resetValue
            lastSliderValues[key] = resetValue
            clickState[key] = -1
            if key == "printerExposureEV" or key == "colorHeadKelvin" or key == "colorHeadTint" then
                previewState.touchedControls[key] = false
            end
            previewState.suppressObservers = false
            requestLivePreview(true)
        end
        local function makeDoubleResetHandler(key, defaultValue)
            -- Label and value field share one click timer. Timer is cleared only when the
            -- slider VALUE changes (drag). Lightroom SDK consumes mouse_down on the thumb.
            return function()
                local now = LrDate.currentTime()
                local last = clickState[key] or -1
                clickState[key] = now
                if last >= 0 and (now - last) <= 0.75 then
                    clickState[key] = -1
                    resetSliderToDefault(key, defaultValue)
                end
            end
        end
        local function sliderOpts(key, overrides)
            overrides = overrides or {}
            local def = overrides.default
            if def == nil then
                def = sliderDefaults[key] or 0
            end
            local opt = {
                mouse_down = makeDoubleResetHandler(key, def),
                on_reset = function()
                    resetSliderToDefault(key, def)
                end,
            }
            for k, v in pairs(overrides) do
                if k ~= "default" then
                    opt[k] = v
                end
            end
            return opt
        end
        local function noteSliderValueChange(key)
            if previewState.suppressObservers or previewState.isResetting or previewState.isRestoringState then
                return
            end
            local cur = tonumber(props[key])
            if cur == nil then
                cur = props[key]
            end
            local prev = lastSliderValues[key]
            if prev ~= nil and prev ~= cur then
                clickState[key] = -1
            end
            lastSliderValues[key] = cur
        end

        local function runAsync(taskName, fn)
            LrFunctionContext.postAsyncTaskWithContext(taskName, function()
                local ok, err = LrTasks.pcall(fn)
                if not ok then
                    LrDialogs.message("Analog Signature Film Engine", tostring(err or "Task failed"), "critical")
                end
            end)
        end

        local function markEmulsionSelectionIntent()
            previewState.emulsionSelectionArmed = true
        end

        local function onReset()
            haltPreviewWorker(5)
            resetPanelUiDefaults()
            local photo = catalog:getTargetPhoto()
            if photo then
                local restore = previewState.entryRestoreBaseline
                if type(restore) == "table" then
                    previewState.suppressObservers = true
                    previewState.isResetting = true
                    catalogWrite.run(catalog, "Analog Signature Panel Reset", function()
                        photo:applyDevelopSettings(copyTable(restore))
                    end, { attempts = 5, sleep = 0.04, sleepMax = 0.16, timeout = 1.5 })
                    previewState.originalLabBaseline = copyTable(restore)
                    previewState.isResetting = false
                    previewState.suppressObservers = false
                else
                    restorePanelEntryBaseline(photo, "Panel reset complete")
                end
                clearPreviewTransientState()
            end
            resumePreviewWorker()
            props.livePreviewStatus = "Panel reset: UI + photo"
            if props.livePreview == true then
                requestLivePreview(true, true)
            end
            LrDialogs.showBezel("Analog Signature: panel reset", 1.2)
        end

        local function onApply()
            -- Must run inside an async task (LrTasks.sleep is illegal from the modal dialog).
            haltPreviewWorker(8)
            previewState.suppressObservers = true
            LrDialogs.showBezel("Analog Signature: applying…", 1)
            local photo = catalog:getTargetPhoto()
            if not photo then
                previewState.applyPending = false
                previewState.suppressObservers = false
                previewState.closing = false
                previewState.worker = false
                previewState.applying = false
                LrDialogs.message("Analog Signature Film Engine", "No selected photo.", "critical")
                return
            end
            local ok, err = LrTasks.pcall(function()
                applyToPhoto(photo)
                if props.weddingSync == true then
                    local selected = catalog:getTargetPhotos() or {}
                    for _, p in ipairs(selected) do
                        if p ~= photo then
                            applyToPhoto(p)
                        end
                    end
                end
            end)
            if not ok then
                previewState.applyPending = false
                previewState.suppressObservers = false
                previewState.closing = false
                previewState.worker = false
                previewState.applying = false
                LrDialogs.message("Analog Signature Film Engine", tostring(err or "Apply failed"), "critical")
                return
            end
            previewState.baseline = nil
            previewState.photo = nil
            previewState.active = false
            previewState.mutated = false
            previewState.engineBaseline = nil
            previewState.engineSignature = nil
            previewState.coreDirty = true
            previewState.applyCommitted = true
            previewState.applyPending = false
            previewState.entryRestoreBaseline = nil
            prefs.panel1LivePreview = (props.livePreview == true)
            savePanel1UiState(prefs, props)
            previewState.suppressObservers = false
            previewState.closing = false
            previewState.worker = false
            previewState.applying = false
            local bezelLabel = "Analog Signature: Film Engine applied"
            if tostring(props.emulsion or "") == ORIGINAL_EMULSION_ID then
                bezelLabel = "Analog Signature: ORIGINAL + Lab applied"
            end
            LrDialogs.showBezel(bezelLabel, 1.5)
        end

        local livePreviewKeys = {
            "sourceScale", "format", "lutIntensity", "weddingSync",
            "hd_curve", "photon_scattering", "anti_halation_bloom", "film_damage",
            "printerExposureEV", "colorHeadKelvin", "colorHeadTint",
            "ssg_toe_grain", "ssg_body_grain", "ssg_shoulder_grain", "ssg_resolving_loss",
            "lomoPurple","lomoTurquoise","metropolis","redscale",
            "portaSkinGuard","kodachromeK14",
            "filmSoup",
            "grainIntensity",
        }
        local corePreviewKeys = {
            sourceScale = true,
            format = true,
            lutIntensity = true,
        }
        local fastPreviewKeys = {
            printerExposureEV = true,
            colorHeadKelvin = true,
            colorHeadTint = true,
            grainIntensity = true,
        }
        local labTonePreviewKeys = {
            lomoPurple = true,
            lomoTurquoise = true,
            metropolis = true,
            redscale = true,
            portaSkinGuard = true,
            kodachromeK14 = true,
            filmSoup = true,
        }
        props:addObserver("emulsion", function()
            if previewState.suppressObservers or previewState.isRestoringState or previewState.isResetting or previewState.isInitializing then
                return
            end
            local emulsionId = tostring(props.emulsion or "")
            previewState.emulsionSelectionArmed = false
            if emulsionId ~= ORIGINAL_EMULSION_ID
                and not (config.findEmulsion and config.findEmulsion(emulsionId)) then
                local fallbackId = safeDemoFallbackEmulsionId()
                previewState.userInteracted = true
                previewState.originalLabBaseline = nil
                previewState.coreDirty = true
                previewState.suppressObservers = true
                props.emulsion = fallbackId
                props.smartMatchWorkflow = normalizeSmartMatchWorkflow(smartWorkflowForEmulsionId(fallbackId))
                previewState.suppressObservers = false
                props.livePreviewStatus = "Demo: locked emulsion"
                requestLivePreview(true, true)
                return
            end
            if emulsionId == ORIGINAL_EMULSION_ID then
                previewState.userInteracted = true
                previewState.engineBaseline = nil
                previewState.engineSignature = nil
                previewState.originalLabBaseline = nil
                previewState.coreDirty = true
                previewState.suppressObservers = true
                props.smartMatchWorkflow = "auto"
                previewState.suppressObservers = false
                runAsync("AnalogSignaturePanelOneOriginalLab", function()
                    local photo = previewState.photo or catalog:getTargetPhoto()
                    if photo and type(previewState.entryRestoreBaseline) == "table" then
                        previewState.suppressObservers = true
                        catalogWrite.run(catalog, "Analog Signature ORIGINAL baseline", function()
                            photo:applyDevelopSettings(copyTable(previewState.entryRestoreBaseline))
                        end, { attempts = 4, sleep = 0.04, sleepMax = 0.16, timeout = 1.2 })
                        previewState.originalLabBaseline = copyTable(previewState.entryRestoreBaseline)
                        previewState.suppressObservers = false
                    end
                    requestLivePreview(true, true)
                end)
                return
            end
            previewState.originalLabBaseline = nil
            previewState.userInteracted = true
            previewState.suppressObservers = true
            props.smartMatchWorkflow = normalizeSmartMatchWorkflow(smartWorkflowForEmulsionId(emulsionId))
            previewState.suppressObservers = false
            previewState.coreDirty = true
            requestLivePreview(true, true)
            -- Confirm pass after popup commits selection focus. Acts only as a safety
            -- net: if the first preview already landed for the now-selected emulsion we
            -- skip it, otherwise a second forced full render ~150ms later renders the
            -- same look twice and causes the visible flicker on switching.
            runAsync("AnalogSignaturePanelOneEmulsionConfirmPreview", function()
                LrTasks.sleep(0.15)
                if previewState.active == true
                    and previewState.lastPreviewEmulsion == tostring(props.emulsion or "") then
                    return
                end
                requestLivePreview(true, true)
            end)
        end)
        props:addObserver("format", function()
            if previewState.suppressObservers or previewState.isRestoringState or previewState.isResetting or previewState.isInitializing then
                return
            end
            previewState.userInteracted = true
            if tostring(props.format or "") == "auto" then
                syncAutoGrainFormatFromPhoto(previewState.photo or catalog:getTargetPhoto())
            end
            previewState.coreDirty = true
            requestLivePreview(true)
        end)
        props:addObserver("smartMatchWorkflow", function()
            if previewState.suppressObservers or previewState.isRestoringState or previewState.isResetting or previewState.isInitializing then
                return
            end
            props.smartMatchWorkflow = normalizeSmartMatchWorkflow(props.smartMatchWorkflow)
            previewState.userInteracted = true
            previewState.coreDirty = true
            requestLivePreview(true, true)
        end)
        
        for _, key in ipairs(livePreviewKeys) do
            props:addObserver(key, function()
                if previewState.suppressObservers or previewState.isRestoringState or previewState.isResetting or previewState.isInitializing then
                    return
                end
                noteSliderValueChange(key)
                previewState.userInteracted = true
                previewState.touchedControls[key] = true
                if corePreviewKeys[key] == true then
                    previewState.coreDirty = true
                    requestLivePreview(true, true)
                elseif fastPreviewKeys[key] == true or labTonePreviewKeys[key] == true then
                    -- Lab/creative sliders: no engine rerun; fast preview avoids 120ms snap.
                    requestLivePreview(true)
                else
                    requestLivePreview(false)
                end
            end)
        end
        props:addObserver("livePreview", function()
            if previewState.suppressObservers or previewState.isRestoringState or previewState.isResetting or previewState.isInitializing then
                return
            end
            previewState.userInteracted = true
            if props.livePreview == true then
                props.livePreviewStatus = "Live Preview: active"
                requestLivePreview(true)
            else
                runAsync("AnalogSignaturePanelOnePreviewRestore", function()
                    restoreLivePreviewIfNeeded()
                end)
                props.livePreviewStatus = "Live Preview: off"
            end
            prefs.panel1LivePreview = (props.livePreview == true)
        end)
        if props.livePreview == true then
            props.livePreviewStatus = "Live Preview: armed"
        else
            props.livePreviewStatus = "Live Preview: off"
        end

        do
            local target = catalog:getTargetPhoto()
            if target then
                previewState.photo = target
                local entryBaseline = ensureSessionBaseline(target)
                if type(entryBaseline) == "table" then
                    previewState.entryRestoreBaseline = copyTable(entryBaseline)
                end
                -- Do not seed engineBaseline from the entry snapshot: that made slider
                -- overlays apply on top of the raw photo without running the film engine.
                previewState.engineBaseline = nil
                previewState.engineSignature = nil
                previewState.coreDirty = true
                syncAutoGrainFormatFromPhoto(target)
            end
        end

        local contents = f:scrolled_view({
            width = 380,
            height = 720,
            vertical_scrolling = true,
            horizontal_scrolling = false,
            vertical_scroller = true,
            f:column({
                bind_to_object = props,
                spacing = 6,
                margin = 8,
                f:static_text({ title = "Sensor", font = "<system/bold>", size = "small" }),
                f:popup_menu({ value = bind("sourceScale"), items = makeSourceScaleItemsForPanel(), width = 220, size = "small" }),
                f:checkbox({ title = "Live Preview", value = bind("livePreview"), size = "small" }),
                f:static_text({ title = bind("livePreviewStatus"), size = "small" }),
                f:push_button({
                    title = "Reset Panel",
                    width = 110,
                    size = "small",
                    action = function()
                        runAsync("AnalogSignaturePanelOneReset", onReset)
                    end,
                }),

                f:static_text({ title = "Photochemical Trim", font = "<system/bold>", size = "small" }),
                renderSliderRow(f, bind, "printerExposureEV", "Printer Exposure", sliderOpts("printerExposureEV", { min = -5, max = 5, precision = 2, integral = false, default = 0 })),
                renderSliderRow(f, bind, "colorHeadKelvin", "Color Head Kelvin", sliderOpts("colorHeadKelvin", { min = 2000, max = 10000, default = 5500 })),
                renderSliderRow(f, bind, "colorHeadTint", "Green-Magenta Bias", sliderOpts("colorHeadTint", { min = -150, max = 150, default = 0 })),

                f:static_text({ title = "Emulsion + LUT", font = "<system/bold>", size = "small" }),
                f:popup_menu({ value = bind("emulsion"), items = makePanel1EmulsionItems((type(config.getCustomerEmulsions) == "function") and config.getCustomerEmulsions() or config.emulsions), width = 300, size = "small", mouse_down = markEmulsionSelectionIntent }),
                f:static_text({ title = "Scene", font = "<system/bold>", size = "small" }),
                f:static_text({ title = "Smart Match Workflow", size = "small" }),
                f:popup_menu({ value = bind("smartMatchWorkflow"), items = workflowItems(), width = 220, size = "small" }),
                f:static_text({ title = "Film Format (Grain Scale)", size = "small" }),
                f:popup_menu({ value = bind("format"), items = makeSceneFormatItems(), width = 300, size = "small" }),
                f:static_text({
                    title = "Larger film formats = finer grain structure (less visible grain).",
                    size = "small",
                }),
                f:static_text({ title = "LUT Intensity", size = "small" }),
                f:popup_menu({ value = bind("lutIntensity"), items = makeLutIntensityItems(), width = 220, size = "small" }),

                f:static_text({ title = "Wedding Sync", font = "<system/bold>", size = "small" }),
                f:checkbox({ title = "Apply to selected photos", value = bind("weddingSync"), size = "small" }),

                f:separator({ fill_horizontal = 1 }),
                f:static_text({ title = "H&D Curve", font = "<system/bold>", size = "small" }),
                f:popup_menu({ value = bind("hd_curve"), items = levelItems(), width = 220, size = "small" }),

                f:static_text({ title = "SSG", font = "<system/bold>", size = "small" }),
                renderSliderRow(f, bind, "grainIntensity", "Grain Intensity", sliderOpts("grainIntensity", { min = 0, max = 100, default = 100 })),
                renderSliderRow(f, bind, "ssg_toe_grain", "Toe", sliderOpts("ssg_toe_grain")),
                renderSliderRow(f, bind, "ssg_body_grain", "Body", sliderOpts("ssg_body_grain")),
                renderSliderRow(f, bind, "ssg_shoulder_grain", "Should", sliderOpts("ssg_shoulder_grain")),
                renderSliderRow(f, bind, "ssg_resolving_loss", "Resolve", sliderOpts("ssg_resolving_loss")),

                f:static_text({ title = "Photon Scattering", font = "<system/bold>", size = "small" }),
                f:popup_menu({ value = bind("photon_scattering"), items = levelItems(), width = 220, size = "small" }),

                f:static_text({ title = "Anti-Halation Bloom", font = "<system/bold>", size = "small" }),
                f:popup_menu({ value = bind("anti_halation_bloom"), items = levelItems(), width = 220, size = "small" }),

                f:static_text({ title = "Material Aging", font = "<system/bold>", size = "small" }),
                f:popup_menu({ value = bind("film_damage"), items = levelItems(), width = 220, size = "small" }),

                f:separator({ fill_horizontal = 1 }),
                f:static_text({ title = "Cross Process Lab", font = "<system/bold>", size = "small" }),
                renderSliderRow(f, bind, "lomoPurple", "Lomo Purple", sliderOpts("lomoPurple")),
                renderSliderRow(f, bind, "lomoTurquoise", "Lomo Turquoise", sliderOpts("lomoTurquoise")),
                renderSliderRow(f, bind, "metropolis", "Metropolis", sliderOpts("metropolis")),
                renderSliderRow(f, bind, "redscale", "Redscale", sliderOpts("redscale")),

                f:separator({ fill_horizontal = 1 }),
                f:static_text({ title = "Stock Character", font = "<system/bold>", size = "small" }),
                renderSliderRow(f, bind, "portaSkinGuard", "Portra Skin Guard", sliderOpts("portaSkinGuard")),
                renderSliderRow(f, bind, "kodachromeK14", "Kodachrome K-14", sliderOpts("kodachromeK14")),

                f:separator({ fill_horizontal = 1 }),
                f:static_text({ title = "Lab Patina", font = "<system/bold>", size = "small" }),
                renderSliderRow(f, bind, "filmSoup", "Film Soup", sliderOpts("filmSoup")),
                f:separator({ fill_horizontal = 1 }),
                f:static_text({ title = string.format("Customer UI · Build %d", PANEL_BUILD), size = "small" }),
            }),
        })

        -- Release observer gates only after full UI/state initialization is complete.
        previewState.isRestoringState = false
        previewState.isInitializing = false
        for _, key in ipairs(livePreviewKeys) do
            lastSliderValues[key] = tonumber(props[key]) or props[key]
        end

        -- Part VI: fire initial preview AFTER the modal is fully open.
        -- Do NOT call requestLivePreview here (before presentModalDialog) —
        -- the async task fires in the 20ms window while the modal is still
        -- rendering its widgets; catalog:getTargetPhoto() is unreliable then,
        -- causing an inconsistent "emulsion does not apply on open" race.
        -- Instead we use a standalone async task with two attempts (0.35s + 1.2s)
        -- so the first fires well after the dialog is established, and the
        -- second acts as a safety net for slow systems or large catalogs.
        if props.livePreview == true then
            LrTasks.startAsyncTask(function()
                -- First attempt: 350ms after modal starts presenting.
                LrTasks.sleep(0.35)
                if not previewState.closing and props.livePreview == true
                    and previewState.active ~= true then
                    previewState.forceFullPreview = true
                    requestLivePreview(true, true)
                end
                -- Second attempt: fallback 1.2s later in case bridge was busy.
                LrTasks.sleep(1.2)
                if not previewState.closing and props.livePreview == true
                    and previewState.active ~= true then
                    previewState.forceFullPreview = true
                    requestLivePreview(true, true)
                end
            end)
        end

        previewState.closing = false
        local result = LrDialogs.presentModalDialog({
            title = string.format("Analog Signature Film Engine · Build %d", PANEL_BUILD),
            actionVerb = "Apply",
            cancelVerb = "Close",
            resizable = false,
            contents = contents,
            actionBinding = nil,
        })
        previewState.closing = true
        previewState.dirty = false

        if result == "ok" then
            previewState.closing = true
            previewState.dirty = false
            -- Block preview-restore while Apply runs in the background; do not call
            -- LrTasks.sleep here (modal dialog context -> "We can only wait from within a task").
            previewState.applyPending = true
            previewState.mutated = false
            runAsync("AnalogSignaturePanelOneApply", onApply)
        elseif previewState.mutated == true and previewState.applyPending ~= true then
            LrTasks.startAsyncTask(function()
                local deadline = LrDate.currentTime() + 2.0
                while (previewState.worker or previewState.applying) and LrDate.currentTime() < deadline do
                    LrTasks.sleep(0.05)
                end
                restoreLivePreviewIfNeeded()
            end)
        end
    end)
end

local ok, err = pcall(showDialog)
if not ok then
    LrDialogs.message("Analog Signature Film Engine", tostring(err or "Unknown error"), "critical")
end
