--[[
Comments To Resolve Markers
===========================
Paste timecoded review comments and add them as timeline markers in DaVinci Resolve.

Accepted lines:
00:39 - Single-frame marker note
00:42 - 00:46 - Ranged marker note
01:02:03 - HH:MM:SS also works
--]]

local WINDOW_ID = "CommentsToResolveMarkers_20260530A"
local WINDOW_W, WINDOW_H = 1180, 760

local MARKER_COLORS = {
    "Blue", "Cyan", "Green", "Yellow", "Red", "Pink", "Purple", "Fuchsia",
    "Rose", "Lavender", "Sky", "Mint", "Lemon", "Sand", "Cocoa", "Cream",
    "Lime", "Navy", "Gray",
}

local C = {
    bg       = "#17191d",
    panel    = "#20242b",
    input    = "#111318",
    btn      = "#343a44",
    btn2     = "#2b3038",
    accent   = "#e08a2e",
    fg       = "#e8edf5",
    dim      = "#9aa4b2",
    border   = "#3a414d",
    ok       = "#66d17a",
    warn     = "#ffbd5a",
    err      = "#ff6b6b",
}

local SS = {
    win     = ("background-color:%s; color:%s;"):format(C.bg, C.fg),
    label   = ("color:%s; font-size:9pt;"):format(C.fg),
    dim     = ("color:%s; font-size:8pt;"):format(C.dim),
    input   = ("background-color:%s; color:%s; border:1px solid %s; font-size:9pt;"):format(C.input, C.fg, C.border),
    log     = ("background-color:%s; color:%s; border:1px solid %s; font-family:Consolas,monospace; font-size:8pt;"):format(C.input, C.fg, C.border),
    btn     = ("background-color:%s; color:%s; border:none; padding:5px 12px;"):format(C.btn, C.fg),
    btn_sec = ("background-color:%s; color:%s; border:none; padding:4px 10px;"):format(C.btn2, C.fg),
    btn_acc = ("background-color:%s; color:#ffffff; border:none; padding:5px 14px; font-weight:bold;"):format(C.accent),
    combo   = ("background-color:%s; color:%s; border:1px solid %s;"):format(C.input, C.fg, C.border),
    sep     = ("background-color:%s;"):format(C.border),
}

local SAMPLE = [[00:39 - Right now Cliffs TH position changes too early. Align the keyframes so that his TH position changes at the same time as the SR comes into the centre.
00:42 - 00:46 - Speed up the scroll on the SR here. Cliff should reach the Photos App right before he says "The Photos App right here".
03:42 - Here when the SR moves to the centre, the yellow line just disappears. Could we have it minimize with the animation the same way you have done at 00:41. Check every instance of this please. Additionally, centre Cliffs camera here with this motion, it will make sense why, with my next comment.
03:40 - 04:03 - Let's not switch to different cameras here under the SR, since TH is blurred out and we do not see Cliffs mouth it does not make sense to switch cameras, just stay on TH A (the wider TH)
03:44 - The cross on cellular data just disappears now, could we make sure it animates out?]]

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

local function html_escape(value)
    return tostring(value or ""):gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
end

local function strip_html(value)
    local text = tostring(value or "")
    text = text:gsub("<br%s*/?>", "\n")
    text = text:gsub("</p>", "\n")
    text = text:gsub("<[^>]+>", "")
    text = text:gsub("&lt;", "<"):gsub("&gt;", ">"):gsub("&amp;", "&")
    text = text:gsub("&quot;", '"'):gsub("&#39;", "'")
    return text
end

local function get_widget_text(widget)
    local ok, value = pcall(function() return widget.PlainText end)
    if ok and value and tostring(value) ~= "" then return tostring(value) end
    ok, value = pcall(function() return widget.Text end)
    if ok and value and tostring(value) ~= "" then return tostring(value) end
    ok, value = pcall(function() return widget.HTML end)
    if ok and value and tostring(value) ~= "" then return strip_html(value) end
    return ""
end

local function set_widget_text(widget, value)
    pcall(function() widget.PlainText = tostring(value or "") end)
    pcall(function() widget.Text = tostring(value or "") end)
end

local function log_append(widget, message, tag)
    local colors = { info=C.fg, ok=C.ok, warn=C.warn, err=C.err, head=C.accent, dim=C.dim }
    local color = colors[tag] or C.fg
    widget.HTML = tostring(widget.HTML or "") .. ('<span style="color:%s">%s</span><br>'):format(color, html_escape(message))
end

local function log_clear(widget)
    widget.HTML = ""
end

local function get_resolve()
    if fusion and fusion.GetResolve then
        local ok, resolve = pcall(function() return fusion:GetResolve() end)
        if ok and resolve then return resolve end
    end
    if Resolve then return Resolve() end
    if bmd and bmd.scriptapp then return bmd.scriptapp("Resolve") end
    return nil
end

local function parse_fps(value)
    local n = tonumber(tostring(value or ""):match("[%d%.]+"))
    if not n or n <= 0 then return nil end
    return n
end

local function current_timeline()
    local resolve = get_resolve()
    if not resolve then return nil, nil, 0, "Could not connect to Resolve." end
    local project_manager = resolve:GetProjectManager()
    local project = project_manager and project_manager:GetCurrentProject()
    if not project then return nil, nil, 0, "Open a Resolve project first." end
    local timeline = project:GetCurrentTimeline()
    if not timeline then return nil, nil, 0, "Open a timeline first." end
    local fps = parse_fps(timeline:GetSetting("timelineFrameRate")) or parse_fps(project:GetSetting("timelineFrameRate")) or 24
    local timeline_start = 0
    pcall(function() timeline_start = tonumber(timeline:GetStartFrame()) or 0 end)
    return timeline, fps, timeline_start, nil
end

local function resolve_marker_color(value)
    value = tostring(value or "")
    for _, color in ipairs(MARKER_COLORS) do
        if color:lower() == value:lower() then return color end
    end
    return "Blue"
end

local function color_from_index(index)
    local i = tonumber(index) or 0
    return MARKER_COLORS[i + 1] or "Blue"
end

local function seconds_from_timecode(raw)
    local text = trim(raw)
    local parts = {}
    for part in text:gmatch("[^:]+") do table.insert(parts, part) end
    if #parts < 2 or #parts > 3 then return nil end
    local h, m, s = 0, 0, 0
    if #parts == 2 then
        m = tonumber(parts[1])
        s = tonumber(parts[2])
    else
        h = tonumber(parts[1])
        m = tonumber(parts[2])
        s = tonumber(parts[3])
    end
    if not h or not m or not s then return nil end
    if m < 0 or s < 0 or m >= 60 or s >= 60 then return nil end
    return (h * 3600) + (m * 60) + s
end

local function display_time(seconds)
    seconds = math.max(0, tonumber(seconds) or 0)
    local whole = math.floor(seconds + 0.5)
    local h = math.floor(whole / 3600)
    local m = math.floor((whole % 3600) / 60)
    local s = whole % 60
    if h > 0 then return string.format("%d:%02d:%02d", h, m, s) end
    return string.format("%d:%02d", m, s)
end

local function split_lines(text)
    text = tostring(text or ""):gsub("\r\n", "\n"):gsub("\r", "\n")
    local rows = {}
    for line in (text .. "\n"):gmatch("(.-)\n") do
        if trim(line) ~= "" then table.insert(rows, line) end
    end
    return rows
end

local function parse_comment_line(line, index)
    local cleaned = trim(line)
    cleaned = cleaned:gsub("\226\128\147", "-"):gsub("\226\128\148", "-")
    local start_text, rest = cleaned:match("^([%d:%.]+)%s*%-%s*(.+)$")
    if not start_text then
        return nil, ("Line %d has no leading timecode: %s"):format(index, cleaned)
    end
    local start_seconds = seconds_from_timecode(start_text)
    if not start_seconds then
        return nil, ("Line %d has an invalid start time: %s"):format(index, start_text)
    end

    rest = trim(rest)
    local maybe_end, note_after_end = rest:match("^([%d:%.]+)%s*%-%s*(.+)$")
    local end_seconds = nil
    local note = rest
    if maybe_end then
        local parsed_end = seconds_from_timecode(maybe_end)
        if parsed_end and parsed_end > start_seconds then
            end_seconds = parsed_end
            note = trim(note_after_end)
        end
    end

    note = trim(note)
    if note == "" then
        return nil, ("Line %d has no comment text."):format(index)
    end

    return {
        line = index,
        start_seconds = start_seconds,
        end_seconds = end_seconds,
        note = note,
        ranged = end_seconds ~= nil,
    }, nil
end

local function parse_comments(text)
    local markers, warnings = {}, {}
    for index, line in ipairs(split_lines(text)) do
        local marker, err = parse_comment_line(line, index)
        if marker then
            table.insert(markers, marker)
        else
            table.insert(warnings, err)
        end
    end
    table.sort(markers, function(a, b)
        if a.start_seconds == b.start_seconds then return a.line < b.line end
        return a.start_seconds < b.start_seconds
    end)
    return markers, warnings
end

local function marker_title(marker, prefix, max_len)
    prefix = trim(prefix)
    max_len = tonumber(max_len) or 54
    local title = marker.ranged and "Review range" or "Review note"
    if prefix ~= "" then title = prefix end
    title = title .. " " .. display_time(marker.start_seconds)
    if marker.ranged then title = title .. "-" .. display_time(marker.end_seconds) end
    if #title > max_len then title = title:sub(1, max_len) end
    return title
end

local function marker_rows(text, fps, timeline_start, add_timeline_start, point_duration, title_prefix)
    local parsed, warnings = parse_comments(text)
    local rows = {}
    fps = tonumber(fps) or 24
    point_duration = math.max(1, math.floor(tonumber(point_duration) or 1))
    local offset = add_timeline_start and (tonumber(timeline_start) or 0) or 0

    for _, marker in ipairs(parsed) do
        local frame = math.floor((marker.start_seconds * fps) + 0.5) + offset
        local duration = point_duration
        if marker.ranged then
            duration = math.max(1, math.floor(((marker.end_seconds - marker.start_seconds) * fps) + 0.5))
        end
        table.insert(rows, {
            marker = marker,
            frame = frame,
            duration = duration,
            title = marker_title(marker, title_prefix),
        })
    end
    return rows, warnings
end

local function build_preview(rows, warnings, fps, timeline_start)
    local lines = {}
    table.insert(lines, ("Timeline fps: %.3f | start frame: %d"):format(tonumber(fps) or 24, tonumber(timeline_start) or 0))
    table.insert(lines, ("Parsed markers: %d"):format(#rows))
    table.insert(lines, "")
    for _, row in ipairs(rows) do
        local marker = row.marker
        local range_text = display_time(marker.start_seconds)
        if marker.ranged then range_text = range_text .. " - " .. display_time(marker.end_seconds) end
        table.insert(lines, ("%s | frame %d | duration %d | %s"):format(range_text, row.frame, row.duration, marker.note))
    end
    if #warnings > 0 then
        table.insert(lines, "")
        table.insert(lines, "Warnings:")
        for _, warning in ipairs(warnings) do table.insert(lines, warning) end
    end
    return table.concat(lines, "\n")
end

local function add_markers(timeline, rows, color)
    local added, failed = 0, 0
    color = resolve_marker_color(color)
    for _, row in ipairs(rows) do
        local custom = ("COMMENTS_TO_MARKERS|line=%d|start=%s"):format(row.marker.line, display_time(row.marker.start_seconds))
        local ok = timeline:AddMarker(row.frame, color, row.title, row.marker.note, row.duration, custom)
        if ok then added = added + 1 else failed = failed + 1 end
    end
    return added, failed
end

if not fusion or not bmd or not bmd.UIDispatcher then
    print("Comments To Resolve Markers needs Resolve/Fusion UIManager. Run it from DaVinci Resolve's Workspace > Scripts menu.")
    return
end

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 win = disp:AddWindow({
    ID = WINDOW_ID,
    WindowTitle = "Comments To Resolve Markers",
    Geometry = { 130, 80, WINDOW_W, WINDOW_H },
    MinimumSize = { WINDOW_W, WINDOW_H },
    StyleSheet = SS.win,
}, {
    ui:VGroup{Spacing=8, Margin=12, MinimumSize={WINDOW_W, WINDOW_H},
        ui:Label{Text="Comments To Resolve Markers", StyleSheet=SS.label, Weight=0},
        ui:Label{Text="Paste one comment per line. Ranges become duration markers; single timecodes become regular point markers.", WordWrap=true, StyleSheet=SS.dim, Weight=0},
        ui:HGroup{Weight=0, Spacing=8, MinimumSize={0,44}, MaximumSize={9999,44},
            ui:Label{Text="Marker color:", StyleSheet=SS.label, Weight=0, MinimumSize={90,44}, MaximumSize={90,44}},
            ui:ComboBox{ID="Color", StyleSheet=SS.combo, Weight=0, MinimumSize={150,38}, MaximumSize={150,38}},
            ui:Label{Text="Title prefix:", StyleSheet=SS.label, Weight=0, MinimumSize={74,44}, MaximumSize={74,44}},
            ui:LineEdit{ID="TitlePrefix", Text="Review", StyleSheet=SS.input, Weight=0, MinimumSize={190,38}, MaximumSize={260,38}},
            ui:Label{Text="Point duration frames:", StyleSheet=SS.label, Weight=0, MinimumSize={138,44}, MaximumSize={138,44}},
            ui:SpinBox{ID="PointDuration", Value=1, Minimum=1, Maximum=600, Weight=0, MinimumSize={90,38}, MaximumSize={90,38}},
            ui:CheckBox{ID="AddTimelineStart", Text="Add timeline start frame", Checked=true, StyleSheet=SS.label, Weight=0, MinimumSize={180,38}},
            ui:HGap(60),
        },
        ui:HGroup{Weight=1, Spacing=10,
            ui:VGroup{Weight=1, Spacing=6,
                ui:Label{Text="Comments:", StyleSheet=SS.label, Weight=0},
                ui:TextEdit{ID="Comments", StyleSheet=SS.log, Weight=1, MinimumSize={0,420}},
            },
            ui:VGroup{Weight=1, Spacing=6,
                ui:Label{Text="Preview:", StyleSheet=SS.label, Weight=0},
                ui:TextEdit{ID="Preview", ReadOnly=true, StyleSheet=SS.log, Weight=1, MinimumSize={0,420}},
            },
            ui:HGap(50),
        },
        sep(),
        ui:HGroup{Weight=0, Spacing=8, MinimumSize={0,44},
            ui:Button{ID="Sample", Text="Load Example", StyleSheet=SS.btn_sec, MinimumSize={130,36}, Weight=0},
            ui:Button{ID="Clear", Text="Clear", StyleSheet=SS.btn_sec, MinimumSize={90,36}, Weight=0},
            ui:Button{ID="PreviewButton", Text="Preview Markers", StyleSheet=SS.btn, MinimumSize={160,36}, Weight=0},
            ui:HGap(0),
            ui:Button{ID="Add", Text="Add Markers To Current Timeline", StyleSheet=SS.btn_acc, MinimumSize={270,36}, Weight=0},
            ui:HGap(50),
        },
        ui:TextEdit{ID="Log", ReadOnly=true, StyleSheet=SS.log, Weight=0, MinimumSize={0,108}, MaximumSize={9999,108}},
        ui:VGap(34),
    }
})

local itm = win:GetItems()
for _, color in ipairs(MARKER_COLORS) do itm.Color:AddItem(color) end
itm.Color.CurrentIndex = 4 -- Red
set_widget_text(itm.Comments, SAMPLE)

local state = {
    rows = {},
    warnings = {},
    fps = 24,
    timeline_start = 0,
}

local function refresh_preview()
    log_clear(itm.Log)
    local timeline, fps, timeline_start, err = current_timeline()
    if err then
        log_append(itm.Log, err, "warn")
        fps = 24
        timeline_start = 0
    end
    local text = get_widget_text(itm.Comments)
    local rows, warnings = marker_rows(
        text,
        fps,
        timeline_start,
        itm.AddTimelineStart.Checked == true,
        itm.PointDuration.Value,
        itm.TitlePrefix.Text
    )
    state.rows = rows
    state.warnings = warnings
    state.fps = fps
    state.timeline_start = timeline_start
    set_widget_text(itm.Preview, build_preview(rows, warnings, fps, timeline_start))

    if #rows == 0 then
        log_append(itm.Log, "No valid marker comments found yet.", "warn")
    else
        local ranged = 0
        for _, row in ipairs(rows) do if row.marker.ranged then ranged = ranged + 1 end end
        log_append(itm.Log, ("Ready: %d marker(s), %d ranged, %d regular."):format(#rows, ranged, #rows - ranged), "ok")
    end
    if #warnings > 0 then
        log_append(itm.Log, ("%d line(s) could not be parsed. See Preview warnings."):format(#warnings), "warn")
    end
    return timeline
end

win.On.Sample.Clicked = function()
    set_widget_text(itm.Comments, SAMPLE)
    refresh_preview()
end

win.On.Clear.Clicked = function()
    set_widget_text(itm.Comments, "")
    set_widget_text(itm.Preview, "")
    log_clear(itm.Log)
end

win.On.PreviewButton.Clicked = function()
    refresh_preview()
end

win.On.Add.Clicked = function()
    local timeline = refresh_preview()
    if not timeline then return end
    if #state.rows == 0 then
        log_append(itm.Log, "Nothing to add. Paste comments with timecodes first.", "warn")
        return
    end
    local color = color_from_index(itm.Color.CurrentIndex)
    local added, failed = add_markers(timeline, state.rows, color)
    if added > 0 then log_append(itm.Log, ("Added %d marker(s) to the current timeline."):format(added), "ok") end
    if failed > 0 then log_append(itm.Log, ("Failed to add %d marker(s)."):format(failed), "err") end
end

win.On.Close = function() disp:ExitLoop() end
win.On[WINDOW_ID].Close = function() disp:ExitLoop() end

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