--[[
iPhone SR Timeline Placer for DaVinci Resolve
==============================================

Freelancer-facing utility for importing ranged SR markers from CSV, matching
the replacement clips by exact key, and placing them on a new video track.
]]

local IS_WINDOWS = package.config:sub(1, 1) == "\\"
local WINDOW_ID = "IPhoneSRTimelinePlacerV2"
local WINDOW_X, WINDOW_Y, WINDOW_W, WINDOW_H = 0, 0, 1320, 880
local RIGHT_GUTTER, BOTTOM_GUTTER = 56, 34
local APPDATA = os.getenv("APPDATA") or ""
local CONFIG_PATH = (APPDATA ~= "" and (APPDATA .. [[\iPhone_SR_Timeline_Placer_State.txt]])) or "iPhone_SR_Timeline_Placer_State.txt"

local C = {
    bg       = "#1a1a1a", bg_input = "#2e2e2e", bg_btn = "#3a3a3a",
    accent   = "#e85aa8", fg = "#e0e0e0", fg_dim = "#888888",
    fg_on_acc= "#ffffff", ok = "#4caf50", warn = "#ffcc66", err = "#f44336",
    border   = "#444444",
}

local SS = {
    win     = ("background-color:%s; color:%s;"):format(C.bg, C.fg),
    btn     = ("background-color:%s; color:%s; border:none; padding:4px 10px;"):format(C.bg_btn, C.fg),
    btn_acc = ("background-color:%s; color:%s; border:none; padding:4px 10px; font-weight:bold;"):format(C.accent, C.fg_on_acc),
    input   = ("background-color:%s; color:%s; border:1px solid %s;"):format(C.bg_input, C.fg, C.border),
    log     = ("background-color:%s; color:%s; border:1px solid %s; font-family:Consolas,monospace; font-size:8pt;"):format(C.bg_input, C.fg, C.border),
    label   = ("color:%s;"):format(C.fg),
    dim     = ("color:%s; font-size:8pt;"):format(C.fg_dim),
    sep     = ("background-color:%s;"):format(C.border),
}

local STATE = {
    placer_marker_csv = "",
    placer_source = "",
}

local function trim(s)
    return (s or ""):match("^%s*(.-)%s*$")
end

local function basename(path)
    return tostring(path or ""):match("[^/\\]+$") or "video"
end

local function stem_name(path)
    local name = basename(path)
    return (name:gsub("%.[^%.]+$", ""))
end

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

local function normalize_key(text)
    return tostring(text or ""):lower():gsub("_", "-")
end

local function normalize_path(path)
    path = tostring(path or ""):gsub("/", "\\")
    path = path:gsub("\\+", "\\")
    return path:lower()
end

local function html_escape(text)
    text = tostring(text or "")
    text = text:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
    text = text:gsub("\n", "<br>")
    return text
end

local function log_html(text, default_color)
    local html = {}
    for line in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do
        local color = default_color or C.ok
        if line:find("^FAILED", 1, true) then
            color = C.err
        elseif line:find("^MISSING", 1, true) or line:find("transform failed", 1, true) or line:find("source shorter than marker", 1, true) then
            color = C.warn
        elseif line:find("^MODE SWITCH", 1, true) or line:find("^Placing", 1, true) then
            color = C.fg_dim
        end
        table.insert(html, '<span style="color:' .. color .. '">' .. html_escape(line) .. '</span>')
    end
    return table.concat(html, "<br>") .. "<br>"
end

local function quote(path)
    if IS_WINDOWS then
        return '"' .. tostring(path):gsub('"', '\\"') .. '"'
    end
    return "'" .. tostring(path):gsub("'", "'\\''") .. "'"
end

local function load_state_config()
    local f = io.open(CONFIG_PATH, "r")
    if not f then return end
    for line in f:lines() do
        local key, value = line:match("^([%w_]+)=(.*)$")
        if key == "placer_marker_csv" and value then
            STATE.placer_marker_csv = value
        elseif key == "placer_source" and value then
            STATE.placer_source = value
        end
    end
    f:close()
end

local function save_state_config()
    local f = io.open(CONFIG_PATH, "w")
    if not f then return end
    f:write("placer_marker_csv=" .. tostring(STATE.placer_marker_csv or "") .. "\n")
    f:write("placer_source=" .. tostring(STATE.placer_source or "") .. "\n")
    f:close()
end

local function parse_fps(value)
    if not value then return nil end
    local text = tostring(value)
    local a, b = text:match("([%d%.]+)%s*/%s*([%d%.]+)")
    if a and b and tonumber(b) and tonumber(b) ~= 0 then return tonumber(a) / tonumber(b) end
    return tonumber(text)
end

local function tc_to_frames(tc, fps)
    local h, m, s, f = tostring(tc or ""):match("(%d+):(%d+):(%d+):(%d+)")
    if not h then return nil end
    fps = math.floor((fps or 24) + 0.5)
    return tonumber(f) + tonumber(s) * fps + tonumber(m) * 60 * fps + tonumber(h) * 3600 * fps
end

local function round_frame(value)
    return math.floor((tonumber(value) or 0) + 0.5)
end

local function one_hour_frames(fps)
    return math.floor(((parse_fps(fps) or 24) * 3600) + 0.5)
end

local function add_unique_frame(frames, value)
    local frame = round_frame(value)
    if frame < 0 then return end
    for _, existing in ipairs(frames) do
        if existing == frame then return end
    end
    table.insert(frames, frame)
end

local function normalize_csv_start_frame(start_frame, timeline_start, fps)
    local frame = tonumber(start_frame) or 0
    local tl_start = round_frame(timeline_start)
    local hour = one_hour_frames(fps)

    if tl_start > 0 and frame >= tl_start then
        return frame - tl_start, "timeline start"
    end
    if tl_start == 0 and hour > 0 and frame >= hour then
        return frame - hour, "one-hour CSV offset"
    end
    return frame, nil
end

local function placement_record_frame_candidates(marker_frame, timeline_start, fps)
    local marker = round_frame(marker_frame)
    local tl_start = round_frame(timeline_start)
    local hour = one_hour_frames(fps)
    local frames = {}

    if tl_start > 0 then
        if marker < tl_start then
            add_unique_frame(frames, tl_start + marker)
        else
            add_unique_frame(frames, marker)
        end
        add_unique_frame(frames, marker)
        if marker >= tl_start then add_unique_frame(frames, marker - tl_start) end
    else
        if hour > 0 and marker >= hour then add_unique_frame(frames, marker - hour) end
        add_unique_frame(frames, marker)
    end

    return frames
end

local function get_timeline_fps_and_start(resolve)
    local project = resolve:GetProjectManager():GetCurrentProject()
    local timeline = project and project:GetCurrentTimeline()
    if not timeline then return nil, nil, nil end
    local fps = parse_fps(timeline:GetSetting("timelineFrameRate")) or parse_fps(project:GetSetting("timelineFrameRate")) or 24
    local start_frame = 0
    pcall(function() start_frame = timeline:GetStartFrame() or 0 end)
    return timeline, fps, start_frame
end

local function parse_csv_line(line)
    local values = {}
    local value = {}
    local in_quotes = false
    local i = 1
    line = tostring(line or "")
    while i <= #line do
        local ch = line:sub(i, i)
        if in_quotes then
            if ch == '"' then
                if line:sub(i + 1, i + 1) == '"' then
                    table.insert(value, '"')
                    i = i + 1
                else
                    in_quotes = false
                end
            else
                table.insert(value, ch)
            end
        else
            if ch == '"' then
                in_quotes = true
            elseif ch == "," then
                table.insert(values, table.concat(value))
                value = {}
            else
                table.insert(value, ch)
            end
        end
        i = i + 1
    end
    table.insert(values, table.concat(value))
    return values
end

local function read_csv_rows(path)
    local f = io.open(path, "r")
    if not f then return nil, "Could not read CSV file:\n" .. tostring(path or "") end
    local header_line = f:read("*l")
    if not header_line then f:close(); return {}, nil end
    header_line = header_line:gsub("^\239\187\191", "")
    local headers = parse_csv_line(header_line)
    local rows = {}
    for line in f:lines() do
        if trim(line) ~= "" then
            local values = parse_csv_line(line)
            local row = {}
            for i, header in ipairs(headers) do
                row[trim(header)] = values[i] or ""
            end
            table.insert(rows, row)
        end
    end
    f:close()
    return rows, nil
end

local function parse_machine_tag(text)
    local data = {}
    text = tostring(text or "")
    local tag = text:match("(AUTO|[^%s]+)")
    if not tag then return data end
    for part in tag:gmatch("[^|]+") do
        local key, value = part:match("^([^=]+)=(.*)$")
        if key and value then data[key] = value end
    end
    return data
end

local function marker_custom_data(marker)
    if not marker then return "" end
    return marker.customData or marker.CustomData or marker.customdata or marker.custom_data or ""
end

local function clean_export_note(note, name)
    local text = trim(note or "")
    if text == "" then text = trim(name or "") end
    text = text:gsub("%s+|%s+source:.*$", "")
    text = text:gsub("%s+|%s+AUTO|.*$", "")
    text = text:gsub("%s+|%s+windows:.*$", "")
    text = text:gsub("%s+|%s+distance:.*$", "")
    text = text:gsub("%s+", " ")
    return trim(text)
end

local function token_from_text(text, tokens)
    local lowered = " " .. tostring(text or ""):lower():gsub("[_%-|:]", " ") .. " "
    for _, token in ipairs(tokens) do
        local pattern = " " .. token:gsub("%-", " ") .. " "
        if lowered:find(pattern, 1, true) then return token end
    end
    return ""
end

local function sr_phrase_tokens(note)
    local text = tostring(note or ""):lower()
    local function canon_mode(mode)
        if mode == "time-lapse" then return "timelapse" end
        return mode
    end
    local switch_orientation_new, switch_from_new, switch_to_new = text:match("replace%s+ios%s+18%s+(.+)%s+mode%s+switch%s+from%s+(.+)%s+to%s+(.+)%s+screen%s+recording")
    if switch_orientation_new and switch_from_new and switch_to_new then
        local orientation = token_from_text(switch_orientation_new, { "vertical", "horizontal" })
        local from_mode = token_from_text(switch_from_new, { "portrait", "burst-mode", "slo-mo", "timelapse", "time-lapse", "cinematic", "hidden-menu", "pano", "video", "photo" })
        local to_mode = token_from_text(switch_to_new, { "portrait", "burst-mode", "slo-mo", "timelapse", "time-lapse", "cinematic", "hidden-menu", "pano", "video", "photo" })
        if orientation ~= "" and from_mode ~= "" and to_mode ~= "" then
            return "mode-switch-" .. canon_mode(from_mode) .. "-to-" .. canon_mode(to_mode), orientation
        end
    end

    local phrase = text:match("replace%s+ios%s+18%s+([^%.]+)%s+screen%s+recording")
    if not phrase then return "", "" end
    local orientation = token_from_text(phrase, { "vertical", "horizontal" })
    local mode = token_from_text(phrase, { "portrait", "burst-mode", "slo-mo", "timelapse", "time-lapse", "cinematic", "hidden-menu", "pano", "video", "photo" })
    return canon_mode(mode), orientation
end

local function automation_from_marker(marker)
    local name = tostring(marker.name or marker.Name or "")
    local note = tostring(marker.note or marker.Note or "")
    local color = tostring(marker.color or marker.Color or "")
    local clean_note = clean_export_note(note, name)
    local note_lower = clean_note:lower()
    if not note_lower:match("^replace%s+") then return nil end
    if color ~= "Blue" and not note_lower:find("screen recording", 1, true) then return nil end

    local mode, orientation = sr_phrase_tokens(clean_note)
    local shutter = token_from_text(clean_note, { "left", "right", "bottom", "top" })
    if mode == "" or orientation == "" then return nil end
    if shutter ~= "left" and shutter ~= "right" and shutter ~= "bottom" and shutter ~= "top" then shutter = "unknown" end
    return { type = "sr", context = "camera", mode = mode, orientation = orientation, shutter = shutter }
end

local function replacement_key_from_marker(marker)
    local custom = parse_machine_tag(marker_custom_data(marker))
    if custom.replacement_key and custom.replacement_key ~= "" then
        return normalize_key(custom.replacement_key)
    end
    local auto = automation_from_marker(marker)
    if not auto then return nil end
    return normalize_key("ios26-camera-" .. tostring(auto.mode or "unknown") .. "-" ..
        tostring(auto.orientation or "unknown") .. "-" .. tostring(auto.shutter or "unknown"))
end

local function placement_keys_from_marker(marker)
    local custom = parse_machine_tag(marker_custom_data(marker))
    if custom.mode and tostring(custom.mode):find("^mode%-switch%-") then
        local from_mode, to_mode = tostring(custom.mode):match("^mode%-switch%-(.-)%-to%-(.+)$")
        local orientation = tostring(custom.orientation or "unknown")
        local shutter = tostring(custom.shutter or "unknown")
        if from_mode and to_mode then
            return {
                normalize_key("ios26-camera-" .. from_mode .. "-" .. orientation .. "-" .. shutter),
                normalize_key("ios26-camera-" .. to_mode .. "-" .. orientation .. "-" .. shutter),
                normalize_key("ios26-camera-mode-switch-" .. orientation .. "-" .. shutter),
            }, "mode-switch"
        end
    end
    local auto = automation_from_marker(marker)
    if not auto then
        local name = tostring(marker.name or marker.Name or "")
        local note = tostring(marker.note or marker.Note or "")
        local clean_note = clean_export_note(note, name)
        local phrase_mode, phrase_orientation = sr_phrase_tokens(clean_note)
        local phrase_shutter = token_from_text(clean_note, { "left", "right", "bottom", "top" })
        if phrase_mode:find("^mode%-switch%-") and phrase_orientation ~= "" and phrase_shutter ~= "" then
            auto = { type = "sr", context = "camera", mode = phrase_mode, orientation = phrase_orientation, shutter = phrase_shutter }
        end
    end
    if auto then
        local from_mode, to_mode = tostring(auto.mode or ""):match("^mode%-switch%-(.-)%-to%-(.+)$")
        if from_mode and to_mode then
            local orientation = tostring(auto.orientation or "unknown")
            local shutter = tostring(auto.shutter or "unknown")
            return {
                normalize_key("ios26-camera-" .. from_mode .. "-" .. orientation .. "-" .. shutter),
                normalize_key("ios26-camera-" .. to_mode .. "-" .. orientation .. "-" .. shutter),
                normalize_key("ios26-camera-mode-switch-" .. orientation .. "-" .. shutter),
            }, "mode-switch"
        end
    end
    local key = replacement_key_from_marker(marker)
    if key then return { key }, "single" end
    return nil, nil
end

local function resolve_marker_color(value)
    local color = trim(value or "")
    local allowed = {
        Blue = true, Cyan = true, Green = true, Yellow = true, Red = true,
        Pink = true, Purple = true, Fuchsia = true, Rose = true,
        Lavender = true, Sky = true, Mint = true, Lemon = true,
        Sand = true, Cocoa = true, Cream = true, Brown = true, Lime = true,
    }
    if allowed[color] then return color end
    local lower = color:lower()
    for name in pairs(allowed) do
        if name:lower() == lower then return name end
    end
    return "Blue"
end

local function sr_marker_color(mode, orientation, shutter)
    mode = tostring(mode or ""):lower()
    orientation = tostring(orientation or ""):lower()
    shutter = tostring(shutter or ""):lower()
    if mode == "hidden-menu" then return "Lime" end
    if mode == "" or mode == "none" or mode == "unknown" or orientation == "" or orientation == "unknown" then return "Cocoa" end
    if mode == "video" then return "Sky" end
    return "Blue"
end

local function marker_color_from_csv_row(row)
    local explicit = trim(row.marker_color or row.color or row.Color or "")
    if explicit ~= "" then return resolve_marker_color(explicit) end
    if trim(row.type or "") == "sr" or trim(row.replacement_key or "") ~= "" then
        return resolve_marker_color(sr_marker_color(row.mode, row.orientation, row.shutter_side or row.shutter))
    end
    return "Blue"
end

local function import_ranged_markers_from_csv(timeline, timeline_start, timeline_fps, csv_path)
    csv_path = trim(csv_path or "")
    if csv_path == "" or not file_exists(csv_path) then
        return false, "Select a valid marker CSV first."
    end
    local rows, err = read_csv_rows(csv_path)
    if err then return false, err end
    if #rows == 0 then return false, "CSV has no marker rows." end

    local added, skipped_validation, skipped_add, adjusted_timecode = 0, 0, 0, 0
    local skip_details = {}
    for row_index, row in ipairs(rows) do
        local start_frame = tonumber(row.start_frame or "")
        local duration = tonumber(row.duration_frames or "")
        local note = trim(row.human_note or row.note or "")
        local title = trim(row.marker_title or row.title or "")
        local key = trim(row.replacement_key or "")
        local color = marker_color_from_csv_row(row)

        if (not start_frame) or (not duration) or duration < 1 or note == "" then
            skipped_validation = skipped_validation + 1
            local reasons = {}
            if not start_frame then table.insert(reasons, "missing start_frame") end
            if not duration then table.insert(reasons, "missing duration_frames") end
            if duration and duration < 1 then table.insert(reasons, "duration<1") end
            if note == "" then table.insert(reasons, "empty human_note") end
            table.insert(skip_details, string.format("  row %d skipped: %s", row_index, table.concat(reasons, ", ")))
        else
            -- AddMarker uses frames relative to the timeline start. CSVs may come from a
            -- 00:00:00:00 export timeline or a 01:00:00:00 editorial timeline, so collapse
            -- those hour-based timecode offsets back to marker-relative frames before import.
            local normalized_start, adjustment = normalize_csv_start_frame(start_frame, timeline_start, timeline_fps)
            if adjustment then adjusted_timecode = adjusted_timecode + 1 end
            start_frame = normalized_start
            if title == "" then title = key ~= "" and ("SR: " .. key) or "Imported marker" end
            local row_type = trim(row.type or "")
            local custom = ""
            local parsed_mode, parsed_orientation = sr_phrase_tokens(note)
            local parsed_shutter = token_from_text(note, { "left", "right", "bottom", "top" })
            if key ~= "" then
                custom = "AUTO|type=sr|replacement_key=" .. key
                if parsed_mode:find("^mode%-switch%-") then
                    custom = custom .. "|mode=" .. parsed_mode ..
                        "|orientation=" .. (parsed_orientation ~= "" and parsed_orientation or trim(row.orientation or "")) ..
                        "|shutter=" .. (parsed_shutter ~= "" and parsed_shutter or trim(row.shutter_side or row.shutter or ""))
                end
            elseif row_type ~= "" then
                custom = "AUTO|type=" .. row_type
            end
            local marker_frame = math.floor(start_frame + 0.5)
            local marker_duration = math.max(1, math.floor(duration + 0.5))
            local ok = timeline:AddMarker(marker_frame, color, title, note, marker_duration, custom)
            if ok then
                added = added + 1
            else
                skipped_add = skipped_add + 1
                table.insert(skip_details, string.format(
                    "  row %d AddMarker rejected: frame=%d, duration=%d, color=%s (likely already a marker at this frame, or frame is outside the timeline)",
                    row_index, marker_frame, marker_duration, tostring(color)))
            end
        end
    end

    local total_skipped = skipped_validation + skipped_add
    local summary
    if total_skipped == 0 then
        summary = string.format("Imported %d ranged marker(s) from CSV.", added)
    else
        summary = string.format(
            "Imported %d ranged marker(s) from CSV. Skipped %d row(s) (%d failed validation, %d rejected by Resolve).",
            added, total_skipped, skipped_validation, skipped_add)
    end
    if #skip_details > 0 then
        summary = summary .. "\n" .. table.concat(skip_details, "\n")
    end
    if adjusted_timecode > 0 then
        summary = summary .. string.format("\nAdjusted %d row(s) for timeline/CSV timecode offset.", adjusted_timecode)
    end
    return true, summary
end

local function iter_media_files(root)
    root = trim(root or "")
    if root == "" then return {} end
    local files = {}
    if IS_WINDOWS then
        local cmd = 'dir /b /s /a-d ' .. quote(root .. [[\*.mp4]]) .. ' ' ..
            quote(root .. [[\*.mov]]) .. ' ' .. quote(root .. [[\*.m4v]]) .. ' 2>nul'
        local f = io.popen(cmd)
        if f then
            for line in f:lines() do
                if line and line ~= "" then table.insert(files, line) end
            end
            f:close()
        end
    else
        local cmd = "find " .. quote(root) .. " -type f \\( -iname '*.mp4' -o -iname '*.mov' -o -iname '*.m4v' \\)"
        local f = io.popen(cmd)
        if f then
            for line in f:lines() do
                if line and line ~= "" then table.insert(files, line) end
            end
            f:close()
        end
    end
    return files
end

local function build_replacement_file_map(root)
    local map = {}
    for _, path in ipairs(iter_media_files(root)) do
        local key = normalize_key(stem_name(path))
        if key ~= "" and not map[key] then map[key] = path end
    end
    return map
end

local function collect_sr_marker_placements(timeline, source_folder)
    local ok_markers, markers = pcall(function() return timeline:GetMarkers() end)
    if not ok_markers or not markers then return nil, "Could not read timeline markers." end
    local file_map = build_replacement_file_map(source_folder)
    local entries = {}
    for frame, marker in pairs(markers) do
        local keys, placement_type = placement_keys_from_marker(marker)
        if keys then
            local start_frame = tonumber(frame) or 0
            local duration = math.max(1, tonumber(marker.duration or marker.Duration or 1) or 1)
            local placements = {}
            local max_layers = placement_type == "mode-switch" and 3 or #keys
            for index, key in ipairs(keys) do
                if index > max_layers then break end
                key = normalize_key(key)
                if key ~= "" then
                    table.insert(placements, {
                        index = index,
                        key = key,
                        path = file_map[key] or "",
                    })
                end
            end
            if #placements > 0 then
                table.insert(entries, {
                    frame = start_frame,
                    start_frame = start_frame,
                    end_frame = start_frame + duration,
                    duration = duration,
                    key = keys[1],
                    placement_type = placement_type or "single",
                    placements = placements,
                })
            end
        end
    end
    table.sort(entries, function(a, b) return a.frame < b.frame end)
    return entries, nil
end

local function preview_sr_placements(timeline, source_folder)
    local entries, err = collect_sr_marker_placements(timeline, source_folder)
    if err then return false, err end
    local matched, missing = 0, 0
    local lines = {}
    for _, entry in ipairs(entries) do
        if entry.placement_type == "mode-switch" then
            table.insert(lines, "MODE SWITCH stack at frame " .. tostring(math.floor(entry.start_frame + 0.5)))
        end
        for _, placement in ipairs(entry.placements or {}) do
            if placement.path ~= "" then
                matched = matched + 1
                table.insert(lines, "OK      V" .. tostring(placement.index) .. " " .. placement.key .. " -> " .. basename(placement.path))
            else
                missing = missing + 1
                table.insert(lines, "MISSING V" .. tostring(placement.index) .. " " .. placement.key)
            end
        end
    end
    table.insert(lines, 1, string.format("Preview: %d marker(s), %d required clip(s), %d matched, %d missing.", #entries, matched + missing, matched, missing))
    return true, table.concat(lines, "\n")
end

local function timeline_item_path(item)
    if not item then return "" end
    local ok_media, media_item = pcall(function() return item:GetMediaPoolItem() end)
    if ok_media and media_item then
        local ok_path, path = pcall(function() return media_item:GetClipProperty("File Path") end)
        if ok_path and path and path ~= "" then return path end
    end
    local ok_clip_path, clip_path = pcall(function() return item:GetClipProperty("File Path") end)
    if ok_clip_path and clip_path and clip_path ~= "" then return clip_path end
    return ""
end

local function media_pool_item_path(item)
    if not item then return "" end
    local ok_path, path = pcall(function() return item:GetClipProperty("File Path") end)
    if ok_path and path and path ~= "" then return path end
    return ""
end

local function find_media_pool_item_by_path(media_pool, path)
    if not media_pool then return nil end
    local target_path = normalize_path(path or "")
    if target_path == "" then return nil end

    local function scan_folder(folder)
        if not folder then return nil end
        local ok_clips, clips = pcall(function() return folder:GetClipList() end)
        if ok_clips and clips then
            for _, clip in pairs(clips) do
                if normalize_path(media_pool_item_path(clip)) == target_path then return clip end
            end
        end
        local ok_subs, subs = pcall(function() return folder:GetSubFolderList() end)
        if ok_subs and subs then
            for _, subfolder in pairs(subs) do
                local found = scan_folder(subfolder)
                if found then return found end
            end
        end
        return nil
    end

    local ok_root, root = pcall(function() return media_pool:GetRootFolder() end)
    if ok_root and root then return scan_folder(root) end
    return nil
end

local function import_media_item(media_pool, path)
    local existing = find_media_pool_item_by_path(media_pool, path)
    if existing then return existing end
    local ok_import, imported = pcall(function() return media_pool:ImportMedia({ path }) end)
    if not ok_import or not imported then return nil end
    if type(imported) == "table" then
        for _, item in pairs(imported) do
            if media_pool_item_path(item) ~= "" then return item end
        end
    end
    return find_media_pool_item_by_path(media_pool, path)
end

local function media_item_fps(item, fallback_fps)
    local fallback = parse_fps(fallback_fps) or 24
    if not item then return fallback end
    local keys = { "FPS", "Frame Rate", "Frame rate", "Video Frame Rate", "Clip Frame Rate" }
    for _, key in ipairs(keys) do
        local ok, value = pcall(function() return item:GetClipProperty(key) end)
        local fps = ok and parse_fps(value) or nil
        if fps and fps > 0 then return fps end
    end
    return fallback
end

local function media_item_frame_count(item)
    if not item then return nil end
    for _, key in ipairs({ "Frames", "Frame Count", "Video Frames" }) do
        local ok, value = pcall(function() return item:GetClipProperty(key) end)
        if ok and value then
            local frames = tonumber(tostring(value):gsub(",", ""):match("(%d+)"))
            if frames and frames > 0 then return math.floor(frames + 0.5) end
        end
    end
    local fps = media_item_fps(item, nil)
    for _, key in ipairs({ "Duration", "Video Duration" }) do
        local ok, value = pcall(function() return item:GetClipProperty(key) end)
        if ok and value then
            local frames = tc_to_frames(value, fps)
            if frames and frames > 0 then return frames end
            local seconds = tonumber(tostring(value))
            if seconds and seconds > 0 and fps and fps > 0 then
                return math.floor((seconds * fps) + 0.5)
            end
        end
    end
    return nil
end

local function first_timeline_clip(result)
    if type(result) ~= "table" then return nil end
    for _, value in pairs(result) do
        if value then return value end
    end
    return nil
end

local function track_item_count(timeline, target_track)
    if not timeline or not target_track then return 0 end
    local ok_items, items = pcall(function() return timeline:GetItemListInTrack("video", target_track) end)
    if not ok_items or not items then return 0 end
    local count = 0
    for _, _ in pairs(items) do count = count + 1 end
    return count
end

local function clip_start_frame(item)
    if not item then return nil end
    local ok_start, start_frame = pcall(function() return item:GetStart() end)
    if ok_start and start_frame ~= nil then return tonumber(start_frame) end
    return nil
end

local function clip_matches_path(item, path)
    local normalized_path = normalize_path(path or "")
    if normalized_path == "" then return true end
    local item_path = normalize_path(timeline_item_path(item))
    return item_path == "" or item_path == normalized_path
end

local function find_clip_on_track_after_append(timeline, target_track, path, record_frame, tolerance)
    if not timeline or not target_track then return nil end
    local ok_items, items = pcall(function() return timeline:GetItemListInTrack("video", target_track) end)
    if not ok_items or not items then return nil end

    tolerance = tolerance or 2
    local best_clip, best_distance = nil, nil
    for _, item in pairs(items) do
        if clip_matches_path(item, path) then
            local start_frame = clip_start_frame(item)
            local distance = 0
            if start_frame and record_frame then
                distance = math.abs((tonumber(start_frame) or 0) - (tonumber(record_frame) or 0))
            end
            if distance <= tolerance and (not best_clip or distance < best_distance) then
                best_clip = item
                best_distance = distance
            end
        end
    end
    return best_clip
end

local function append_clip_attempt(timeline, media_pool, clip_info, path)
    local before_count = track_item_count(timeline, clip_info.trackIndex)
    local ok, result = pcall(function() return media_pool:AppendToTimeline({ clip_info }) end)
    if not ok then return nil, tostring(result) end
    local after_count = track_item_count(timeline, clip_info.trackIndex)
    local clip = first_timeline_clip(result)
    local track_clip = find_clip_on_track_after_append(timeline, clip_info.trackIndex, path, clip_info.recordFrame, 2)
    if track_clip and (after_count > before_count or clip) then return track_clip, "track lookup" end

    if clip and clip_matches_path(clip, path) then
        local start_frame = clip_start_frame(clip)
        if start_frame and math.abs(start_frame - (tonumber(clip_info.recordFrame) or 0)) <= 2 then
            return clip, nil
        end
    end
    if after_count > before_count then
        return nil, "Resolve appended a clip, but not at requested record frame " .. tostring(clip_info.recordFrame), true
    end
    if clip then
        return nil, "Resolve returned a clip object, but it was not found at requested record frame " .. tostring(clip_info.recordFrame)
    end
    return nil, "Resolve returned no clip"
end

local function sr_transform_for_key(key)
    key = normalize_key(key or "")
    if key:find("horizontal-right", 1, true) then
        return { zoom_x = 2.175, zoom_y = 2.175, pan = -1368, tilt = 0, rotation = 90 }
    elseif key:find("horizontal-left", 1, true) then
        return { zoom_x = 2.175, zoom_y = 2.175, pan = 1368, tilt = 0, rotation = -90 }
    elseif key:find("vertical-bottom", 1, true) then
        return { zoom_x = 1.135, zoom_y = 1.135, pan = 0, tilt = 75, rotation = 0 }
    end
    return nil
end

local function apply_sr_transform_to_clip(clip, key)
    local transform = sr_transform_for_key(key)
    if not clip or not transform then return true, nil end

    local function set_prop(prop, value)
        local ok, result = pcall(function() return clip:SetProperty(prop, value) end)
        return ok and result ~= false
    end

    -- ZoomGang links the X and Y zoom axes, and it is ON by default in Resolve.
    -- While it is enabled, Y mirrors X and SetProperty("ZoomY", ...) is rejected
    -- (returns false) -- this is the "transform failed: ZoomY" warning. Disable
    -- the gang first (best effort) so each axis can be set independently.
    pcall(function() clip:SetProperty("ZoomGang", false) end)

    local zoom_x_ok = set_prop("ZoomX", transform.zoom_x)
    local zoom_y_ok = set_prop("ZoomY", transform.zoom_y)
    -- If the gang could not be released, a still-ganged ZoomX has already mirrored
    -- its value onto ZoomY, so an equal-value ZoomY rejection is harmless.
    if not zoom_y_ok and zoom_x_ok and transform.zoom_x == transform.zoom_y then
        zoom_y_ok = true
    end
    if not zoom_x_ok then return false, "ZoomX" end
    if not zoom_y_ok then return false, "ZoomY" end

    -- Order matters: set the remaining axes deterministically (the original
    -- pairs() loop ran in arbitrary order).
    local ordered = {
        { "Pan", transform.pan },
        { "Tilt", transform.tilt },
        { "RotationAngle", transform.rotation },
    }
    for _, entry in ipairs(ordered) do
        if not set_prop(entry[1], entry[2]) then return false, entry[1] end
    end
    return true, transform
end

local function append_replacement_clip(timeline, media_pool, item, path, target_track, record_frames, source_end_frame)
    local attempts = {}
    if type(record_frames) ~= "table" then record_frames = { record_frames } end

    for _, frame in ipairs(record_frames) do
        table.insert(attempts, {
            label = "ranged video",
            frame = frame,
            info = { mediaPoolItem = item, trackIndex = target_track, recordFrame = frame, startFrame = 0, endFrame = source_end_frame, mediaType = 1 },
        })
        table.insert(attempts, {
            label = "ranged",
            frame = frame,
            info = { mediaPoolItem = item, trackIndex = target_track, recordFrame = frame, startFrame = 0, endFrame = source_end_frame },
        })
        table.insert(attempts, {
            label = "whole clip",
            frame = frame,
            info = { mediaPoolItem = item, trackIndex = target_track, recordFrame = frame },
        })
    end

    local last_error = nil
    for _, attempt in ipairs(attempts) do
        local clip, err, stop = append_clip_attempt(timeline, media_pool, attempt.info, path)
        if clip then return clip, attempt.label, attempt.frame end
        last_error = err
        if stop then return nil, last_error, attempt.frame end
    end
    return nil, last_error or "Resolve returned no clip"
end

local function place_sr_replacements(resolve, source_folder)
    local project = resolve:GetProjectManager():GetCurrentProject()
    local timeline = project and project:GetCurrentTimeline()
    local media_pool = project and project:GetMediaPool()
    if not timeline or not media_pool then return false, "No active timeline/media pool found." end
    local _, timeline_fps, timeline_start = get_timeline_fps_and_start(resolve)
    timeline_fps = parse_fps(timeline_fps) or 24
    timeline_start = timeline_start or 0

    local entries, err = collect_sr_marker_placements(timeline, source_folder)
    if err then return false, err end
    if #entries == 0 then return false, "No eligible Replace screen recording markers found." end

    local max_stack = 1
    for _, entry in ipairs(entries) do
        max_stack = math.max(max_stack, #(entry.placements or {}))
    end

    local before_tracks = 0
    pcall(function() before_tracks = timeline:GetTrackCount("video") or 0 end)
    for _ = 1, max_stack do
        pcall(function() timeline:AddTrack("video") end)
    end
    local target_tracks = {}
    for index = 1, max_stack do
        target_tracks[index] = before_tracks + index
    end
    pcall(function()
        local after_tracks = timeline:GetTrackCount("video") or before_tracks
        for index = 1, max_stack do
            target_tracks[index] = after_tracks - max_stack + index
        end
    end)

    local imported_by_path = {}
    local placed, missing, failed, transform_failed = 0, 0, 0, 0
    local lines = { "Placing replacements on " .. tostring(max_stack) .. " new video track(s)..." }

    for _, entry in ipairs(entries) do
        if entry.placement_type == "mode-switch" then
            table.insert(lines, "MODE SWITCH stack at frame " .. tostring(math.floor(entry.start_frame + 0.5)))
        end
        for _, placement in ipairs(entry.placements or {}) do
            local target_track = target_tracks[placement.index] or target_tracks[1]
            if placement.path == "" then
                missing = missing + 1
                table.insert(lines, "MISSING V" .. tostring(placement.index) .. " " .. placement.key)
            else
                local item = imported_by_path[placement.path]
                if not item then
                    item = import_media_item(media_pool, placement.path)
                    imported_by_path[placement.path] = item
                end
                if not item then
                    failed = failed + 1
                    table.insert(lines, "FAILED  import " .. placement.path)
                else
                    local source_fps = media_item_fps(item, timeline_fps)
                    local expected_source_duration = math.max(1, math.floor((entry.duration * source_fps / timeline_fps) + 0.5))
                    local source_duration = expected_source_duration
                    local available_frames = media_item_frame_count(item)
                    if available_frames and available_frames > 0 then source_duration = math.min(source_duration, available_frames) end
                    local source_end_frame = math.max(0, source_duration - 1)
                    local record_frames = placement_record_frame_candidates(entry.start_frame, timeline_start, timeline_fps)
                    local new_clip, append_mode, used_record_frame = append_replacement_clip(timeline, media_pool, item, placement.path, target_track, record_frames, source_end_frame)
                    if not new_clip then
                        failed = failed + 1
                        table.insert(lines, "FAILED  append V" .. tostring(placement.index) .. " " .. placement.key .. " (" .. tostring(append_mode) .. ")")
                    else
                        local target_start = used_record_frame or record_frames[1] or round_frame(entry.start_frame)
                        local target_end = target_start + round_frame(entry.duration)
                        pcall(function() if new_clip:GetStart() ~= target_start then new_clip:SetStart(target_start) end end)
                        pcall(function() if new_clip:GetEnd() ~= target_end then new_clip:SetEnd(target_end) end end)
                        local timeline_clip = find_clip_on_track_after_append(timeline, target_track, placement.path, target_start, 2) or new_clip
                        local transform_ok, transform_info = apply_sr_transform_to_clip(timeline_clip, placement.key)
                        placed = placed + 1
                        local placed_line = "PLACED  V" .. tostring(placement.index) .. " " .. placement.key .. " -> " .. basename(placement.path)
                        if transform_info and type(transform_info) == "table" then
                            placed_line = placed_line .. " [transform preset applied]"
                        elseif transform_ok == false then
                            placed_line = placed_line .. " [transform failed: " .. tostring(transform_info) .. "]"
                            transform_failed = transform_failed + 1
                        end
                        if append_mode and append_mode ~= "ranged video" then placed_line = placed_line .. " (" .. append_mode .. " append)" end
                        if target_start ~= round_frame(entry.start_frame) then placed_line = placed_line .. " [timeline start offset handled]" end
                        if available_frames and source_duration < expected_source_duration then placed_line = placed_line .. " [source shorter than marker]" end
                        table.insert(lines, placed_line)
                    end
                end
            end
        end
    end

    table.insert(lines, 1, string.format("Done: %d placed, %d missing, %d failed, %d transform warning(s).", placed, missing, failed, transform_failed))
    return failed == 0, table.concat(lines, "\n"), (missing > 0 or transform_failed > 0)
end

local function show_message(title, text)
    local ui = fusion.UIManager
    local disp = bmd.UIDispatcher(ui)
    local win = disp:AddWindow({
        ID = "Msg",
        WindowTitle = title,
        Geometry = { 520, 260, 620, 260 },
        StyleSheet = SS.win,
    }, {
        ui:VGroup{Spacing=12, Margin=18,
            ui:Label{Text=text, WordWrap=true, Weight=1, StyleSheet=SS.label},
            ui:HGroup{Weight=0, MinimumSize={0,48},
                ui:HGap(0),
                ui:Button{ID="OK", Text="OK", StyleSheet=SS.btn, MinimumSize={120,36}},
                ui:HGap(0),
            },
        },
    })
    win.On.OK.Clicked = function() disp:ExitLoop() end
    win.On.Close = function() disp:ExitLoop() end
    win.On.Msg.Close = function() disp:ExitLoop() end
    win:Show()
    disp:RunLoop()
    win:Hide()
end

local function request_settings(resolve)
    local ui = fusion.UIManager
    local disp = bmd.UIDispatcher(ui)
    local function sep()
        return ui:Label{Weight=0, MinimumSize={1,1}, MaximumSize={9999,1}, StyleSheet=SS.sep}
    end

    local function right_gutter()
        return ui:Label{Weight=0, MinimumSize={RIGHT_GUTTER,1}, MaximumSize={RIGHT_GUTTER,9999}}
    end

    local win = disp:AddWindow({
        ID = WINDOW_ID,
        WindowTitle = "iPhone SR Timeline Placer",
        Geometry = { WINDOW_X, WINDOW_Y, WINDOW_W, WINDOW_H },
        MinimumSize = { WINDOW_W, WINDOW_H },
        StyleSheet = SS.win,
    }, {
        ui:VGroup{Spacing=7, Margin=36,
            ui:Label{Text="iPhone SR Timeline Placer", StyleSheet=SS.label, Weight=0},
            ui:Label{Text="Imports ranged marker comments from CSV, matches iOS26 clips by exact key, and places replacements on a new video track.", WordWrap=true, StyleSheet=SS.dim, Weight=0},
            sep(),
            ui:Label{Text="Ranged marker CSV:", StyleSheet=SS.label, Weight=0},
            ui:HGroup{Weight=0, Spacing=6,
                ui:LineEdit{ID="PlacerMarkerCSV", Text=STATE.placer_marker_csv, PlaceholderText="Use ios26_sr_update_marker_ranges.csv or automation_markers CSV...", StyleSheet=SS.input, Weight=1, MinimumSize={0,38}},
                ui:Button{ID="BrowsePlacerMarkerCSV", Text="Browse...", StyleSheet=SS.btn, MinimumSize={140,34}, Weight=0},
                right_gutter(),
            },
            ui:HGroup{Weight=0, Spacing=8,
                ui:Button{ID="PlacerImportMarkers", Text="Import Ranged Markers From CSV", StyleSheet=SS.btn, MinimumSize={270,34}, Weight=0},
                ui:HGap(0),
                right_gutter(),
            },
            sep(),
            ui:Label{Text="iOS26 replacement source folder:", StyleSheet=SS.label, Weight=0},
            ui:HGroup{Weight=0, Spacing=6,
                ui:LineEdit{ID="PlacerSource", Text=STATE.placer_source, PlaceholderText="Folder containing copied iOS26 SR Update clips or the exact-key hub...", StyleSheet=SS.input, Weight=1, MinimumSize={0,38}},
                ui:Button{ID="BrowsePlacerSource", Text="Browse...", StyleSheet=SS.btn, MinimumSize={140,34}, Weight=0},
                right_gutter(),
            },
            ui:HGroup{Weight=0, Spacing=8,
                ui:Button{ID="PlacerPreview", Text="Preview Timeline Placements", StyleSheet=SS.btn, MinimumSize={220,34}, Weight=0},
                ui:Button{ID="PlacerRun", Text="Place Clips on New Video Track", StyleSheet=SS.btn_acc, MinimumSize={270,34}, Weight=0},
                ui:HGap(0),
                right_gutter(),
            },
            sep(),
            ui:HGroup{Weight=1, Spacing=0,
                ui:TextEdit{ID="PlacerLog", Weight=1, MinimumSize={0,390}, ReadOnly=true, StyleSheet=SS.log},
                right_gutter(),
            },
            ui:VGap(BOTTOM_GUTTER),
        },
    })

    local itm = win:GetItems()
    itm.PlacerLog.HTML = '<span style="color:' .. C.fg_dim .. '">Ready. Import or edit ranged markers first, then preview placements before placing clips.</span><br>'

    win.On.BrowsePlacerMarkerCSV.Clicked = function()
        local chosen = nil
        pcall(function() chosen = fusion:RequestFile(STATE.placer_marker_csv ~= "" and STATE.placer_marker_csv or nil) end)
        if chosen and chosen ~= "" then
            itm.PlacerMarkerCSV.Text = chosen
            STATE.placer_marker_csv = chosen
            save_state_config()
        else
            itm.PlacerLog.HTML = itm.PlacerLog.HTML .. '<span style="color:' .. C.warn .. '">If the file picker is unavailable, paste the marker CSV path into the box.</span><br>'
        end
    end

    win.On.BrowsePlacerSource.Clicked = function()
        local chosen = fusion:RequestDir(STATE.placer_source ~= "" and STATE.placer_source or nil)
        if chosen and chosen ~= "" then
            itm.PlacerSource.Text = chosen
            STATE.placer_source = chosen
            save_state_config()
        end
    end

    win.On.PlacerImportMarkers.Clicked = function()
        STATE.placer_marker_csv = trim(itm.PlacerMarkerCSV.Text)
        save_state_config()
        local timeline, timeline_fps, timeline_start = get_timeline_fps_and_start(resolve)
        if not timeline then
            itm.PlacerLog.HTML = '<span style="color:' .. C.err .. '">No active timeline found.</span><br>'
            return
        end
        local ok, out = import_ranged_markers_from_csv(timeline, timeline_start or 0, timeline_fps or 24, STATE.placer_marker_csv)
        local color = ok and C.ok or C.err
        itm.PlacerLog.HTML = log_html(out, color)
    end

    win.On.PlacerPreview.Clicked = function()
        STATE.placer_source = trim(itm.PlacerSource.Text)
        save_state_config()
        local timeline = get_timeline_fps_and_start(resolve)
        if not timeline then
            itm.PlacerLog.HTML = '<span style="color:' .. C.err .. '">No active timeline found.</span><br>'
            return
        end
        local ok, out = preview_sr_placements(timeline, STATE.placer_source)
        local color = ok and C.ok or C.err
        itm.PlacerLog.HTML = log_html(out, color)
    end

    win.On.PlacerRun.Clicked = function()
        STATE.placer_source = trim(itm.PlacerSource.Text)
        save_state_config()
        itm.PlacerLog.HTML = '<span style="color:' .. C.fg_dim .. '">Placing clips on a new video track...</span><br>'
        local ok, out, warning = place_sr_replacements(resolve, STATE.placer_source)
        local color = ok and C.ok or C.err
        itm.PlacerLog.HTML = log_html(out, color)
    end

    win.On.Close = function()
        STATE.placer_marker_csv = trim(itm.PlacerMarkerCSV.Text)
        STATE.placer_source = trim(itm.PlacerSource.Text)
        save_state_config()
        disp:ExitLoop()
    end
    win.On[WINDOW_ID].Close = win.On.Close

    win:Show()
    disp:RunLoop()
    win:Hide()
end

local function main()
    local resolve = fusion:GetResolve()
    if not resolve then
        show_message("iPhone SR Timeline Placer", "Could not connect to DaVinci Resolve.")
        return
    end
    local timeline = get_timeline_fps_and_start(resolve)
    if not timeline then
        show_message("iPhone SR Timeline Placer", "Open a timeline before running this script.")
        return
    end
    load_state_config()
    request_settings(resolve)
end

local ok, err = pcall(main)
if not ok then
    print("iPhone SR Timeline Placer error: " .. tostring(err))
    pcall(function() show_message("iPhone SR Timeline Placer Error", tostring(err)) end)
end
