--[[
Krabby Missing Reel Check

Created by Krabby Paddy
https://krabbypaddy.com

Checks whether camera files on disk are missing from the current Resolve
Media Pool bin. Disk filenames are compared by filename without extension
against Resolve clip Reel Name values.

Install:
  macOS:
    ~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/

Run from Resolve:
  Workspace > Scripts > Utility > KrabbyMissingReelCheck
]]

local WINDOW_ID = "KrabbyMissingReelCheckWindow"
local DEFAULT_REPORT_PATH = (os.getenv("HOME") or ".") .. "/Desktop/missing_reels_report.txt"

local INCLUDE_EXTENSIONS = {
  mov = true,
  mxf = true,
  mp4 = true,
  ari = true,
  arx = true,
  braw = true,
  r3d = true,
  crm = true,
}

local resolve = nil
if Resolve then
  resolve = Resolve()
end

if not resolve and bmd then
  resolve = bmd.scriptapp("Resolve")
end

if not resolve then
  error("Could not connect to DaVinci Resolve.")
end

local fusion = resolve:Fusion()
if not fusion and bmd then
  fusion = bmd.scriptapp("Fusion")
end

if not fusion then
  error("Could not connect to Fusion UI Manager.")
end

local ui = fusion.UIManager
local dispatcher = bmd.UIDispatcher(ui)
local win = nil

local function trim(value)
  if value == nil then
    return ""
  end

  return tostring(value):match("^%s*(.-)%s*$")
end

local function lower(value)
  return string.lower(trim(value))
end

local function shellQuote(path)
  return "'" .. tostring(path):gsub("'", "'\\''") .. "'"
end

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

local function fileStem(path)
  return (fileName(path):gsub("%.[^%.]+$", ""))
end

local function fileExtension(path)
  local ext = tostring(path):match("%.([^%.]+)$")

  if not ext then
    return ""
  end

  return string.lower(ext)
end

local function sortedKeys(source)
  local keys = {}

  for key, _ in pairs(source or {}) do
    table.insert(keys, key)
  end

  table.sort(keys)
  return keys
end

local function rawCount(source)
  local count = 0

  for _ in pairs(source or {}) do
    count = count + 1
  end

  return count
end

local function addLine(lines, text)
  table.insert(lines, text or "")
end

local function item(id)
  return win:Find(id)
end

local function setItemText(id, text)
  local target = item(id)

  if not target then
    return
  end

  target.PlainText = text
  target.Text = text
end

local function getItemText(id)
  local target = item(id)

  if not target then
    return ""
  end

  return trim(target.Text or target.PlainText)
end

local function isChecked(id)
  local target = item(id)

  if not target then
    return false
  end

  return target.Checked
end

local function setResult(message)
  setItemText("result_summary", message)
end

local function setDetails(message)
  setItemText("details", message)
end

local function setDetailsVisible(isVisible)
  local details = item("details")

  if details then
    details.Visible = isVisible
  end
end

local function getContext()
  local projectManager = resolve:GetProjectManager()
  local project = projectManager:GetCurrentProject()

  if not project then
    error("Open a Resolve project before running this script.")
  end

  local mediaPool = project:GetMediaPool()

  if not mediaPool then
    error("Could not access the project Media Pool.")
  end

  local currentFolder = mediaPool:GetCurrentFolder()

  if not currentFolder then
    error("Select a Media Pool bin before running the check.")
  end

  return project, mediaPool, currentFolder
end

local function getProperty(props, names)
  for _, name in ipairs(names) do
    local value = props[name]

    if trim(value) ~= "" then
      return trim(value)
    end
  end

  return ""
end

local function safeClipProperties(clip)
  local ok, props = pcall(function()
    return clip:GetClipProperty()
  end)

  if ok and type(props) == "table" then
    return props
  end

  return {}
end

local function safeClipName(clip, props)
  local name = getProperty(props, { "Clip Name", "Name", "File Name" })

  if name ~= "" then
    return name
  end

  local ok, clipName = pcall(function()
    return clip:GetName()
  end)

  if ok and trim(clipName) ~= "" then
    return trim(clipName)
  end

  return "Unnamed clip"
end

local function clipLooksLikeMulticam(clip, props)
  local fields = {
    getProperty(props, { "Type", "Clip Type", "Media Type" }),
    safeClipName(clip, props),
    getProperty(props, { "File Path", "File Name" }),
  }

  for _, value in ipairs(fields) do
    if lower(value):find("multicam", 1, true) then
      return true
    end
  end

  return false
end

local function normalizeCompareName(value, caseInsensitive)
  local name = trim(value)

  if caseInsensitive then
    return lower(name)
  end

  return name
end

local function collectReelsFromBin(folder, reels, stats, caseInsensitive)
  local clips = folder:GetClipList() or {}

  for _, clip in ipairs(clips) do
    local props = safeClipProperties(clip)

    if isChecked("skip_multicam") and clipLooksLikeMulticam(clip, props) then
      stats.multicamSkipped = stats.multicamSkipped + 1
    else
      stats.clipsChecked = stats.clipsChecked + 1

      local reel = getProperty(props, {
        "Reel Name",
        "Reel",
        "Tape Name",
        "Tape",
      })

      if reel == "" then
        stats.emptyReels = stats.emptyReels + 1
        table.insert(stats.emptyReelClips, safeClipName(clip, props))
      else
        local key = normalizeCompareName(reel, caseInsensitive)
        reels[key] = reel
      end
    end
  end

  local subfolders = folder:GetSubFolderList() or {}
  for _, subfolder in ipairs(subfolders) do
    collectReelsFromBin(subfolder, reels, stats, caseInsensitive)
  end
end

local function scanDisk(rootPath, caseInsensitive)
  local diskItems = {}
  local stats = {
    filesSeen = 0,
    mediaFilesSeen = 0,
  }
  local command = "find " .. shellQuote(rootPath) .. " -type f"
  local handle = io.popen(command)

  if not handle then
    error("Could not scan folder: " .. tostring(rootPath))
  end

  for path in handle:lines() do
    stats.filesSeen = stats.filesSeen + 1

    local ext = fileExtension(path)

    if INCLUDE_EXTENSIONS[ext] then
      stats.mediaFilesSeen = stats.mediaFilesSeen + 1

      local compareName = normalizeCompareName(fileStem(path), caseInsensitive)

      if compareName ~= "" then
        diskItems[compareName] = diskItems[compareName] or {
          displayName = fileStem(path),
          paths = {},
        }
        table.insert(diskItems[compareName].paths, path)
      end
    end
  end

  handle:close()
  return diskItems, stats
end

local function buildReport(project, folder, rootPath, projectReels, diskItems, missing, stats, diskStats, reportPath)
  local lines = {}
  local missingKeys = sortedKeys(missing)

  addLine(lines, "Krabby Missing Reel Check")
  addLine(lines, "=========================")
  addLine(lines, "Project: " .. trim(project:GetName()))
  addLine(lines, "Start bin: " .. trim(folder:GetName()))
  addLine(lines, "Disk folder: " .. rootPath)
  addLine(lines, "Compare rule: disk filename without extension equals Resolve Reel Name")
  addLine(lines, "")
  addLine(lines, "Resolve clips checked: " .. tostring(stats.clipsChecked))
  addLine(lines, "Multicam clips skipped: " .. tostring(stats.multicamSkipped))
  addLine(lines, "Resolve clips with no reel name: " .. tostring(stats.emptyReels))
  addLine(lines, "Unique Resolve reel names: " .. tostring(rawCount(projectReels)))
  addLine(lines, "Disk files seen: " .. tostring(diskStats.filesSeen))
  addLine(lines, "Disk media files checked: " .. tostring(diskStats.mediaFilesSeen))
  addLine(lines, "Unique disk reel names: " .. tostring(rawCount(diskItems)))
  addLine(lines, "Missing unique disk reel names: " .. tostring(#missingKeys))
  addLine(lines, "")
  addLine(lines, "Missing From Resolve")
  addLine(lines, "--------------------")

  if #missingKeys == 0 then
    addLine(lines, "None found.")
  else
    for _, key in ipairs(missingKeys) do
      addLine(lines, missing[key].displayName)

      for _, path in ipairs(missing[key].paths) do
        addLine(lines, "  " .. path)
      end
    end
  end

  if #stats.emptyReelClips > 0 then
    addLine(lines, "")
    addLine(lines, "Resolve Clips With No Reel Name")
    addLine(lines, "-------------------------------")

    table.sort(stats.emptyReelClips)
    for _, clipName in ipairs(stats.emptyReelClips) do
      addLine(lines, clipName)
    end
  end

  addLine(lines, "")
  addLine(lines, "Resolve Reel Names")
  addLine(lines, "------------------")

  for _, key in ipairs(sortedKeys(projectReels)) do
    addLine(lines, projectReels[key])
  end

  addLine(lines, "")
  addLine(lines, "Created by Krabby Paddy")
  addLine(lines, "https://krabbypaddy.com")
  addLine(lines, "Copyright (c) 2026 Krabby Paddy. All rights reserved.")

  local output = table.concat(lines, "\n")

  if isChecked("write_report") then
    local report = io.open(reportPath, "w")

    if not report then
      error("Could not write report to: " .. reportPath)
    end

    report:write(output)
    report:close()
  end

  return output
end

local function chooseFolderDialog()
  local currentPath = getItemText("folder_path")

  if currentPath == "" then
    currentPath = "/Volumes"
  end

  local attempts = {
    function()
      return fusion:RequestDir(currentPath)
    end,
    function()
      return fusion:RequestDir()
    end,
  }

  for _, attempt in ipairs(attempts) do
    local ok, result = pcall(attempt)

    if ok and trim(result) ~= "" then
      return trim(result)
    end
  end

  return ""
end

local function describeCurrentBin()
  local ok, message = pcall(function()
    local _, _, folder = getContext()
    local clips = folder:GetClipList() or {}
    local subfolders = folder:GetSubFolderList() or {}

    return string.format(
      "Current bin: %s\nDirect clips: %d\nDirect child bins: %d\nThe check will include this bin and all bins inside it.",
      trim(folder:GetName()),
      #clips,
      #subfolders
    )
  end)

  if ok then
    setItemText("bin_summary", message)
  else
    setItemText("bin_summary", tostring(message))
  end
end

local function runCheck()
  local rootPath = getItemText("folder_path")
  local reportPath = getItemText("report_path")

  if rootPath == "" then
    setResult("Choose the folder on disk first.")
    setDetails("Use Choose folder, or paste a mounted path such as /Volumes/CameraDrive/Day_01.")
    return
  end

  if isChecked("write_report") and reportPath == "" then
    setResult("Add a report path, or turn off Write report file.")
    setDetails("The report path is where the missing reel list will be saved.")
    return
  end

  local ok, message = pcall(function()
    local project, _, folder = getContext()
    local caseInsensitive = isChecked("case_insensitive")
    local projectReels = {}
    local stats = {
      clipsChecked = 0,
      multicamSkipped = 0,
      emptyReels = 0,
      emptyReelClips = {},
    }

    collectReelsFromBin(folder, projectReels, stats, caseInsensitive)

    if rawCount(projectReels) == 0 then
      setResult("No reel names found in the selected bin tree.")
      return "No Resolve Reel Name values were found in the selected bin tree."
    end

    local diskItems, diskStats = scanDisk(rootPath, caseInsensitive)
    local missing = {}

    for diskName, diskItem in pairs(diskItems) do
      if not projectReels[diskName] then
        missing[diskName] = diskItem
      end
    end

    local report = buildReport(
      project,
      folder,
      rootPath,
      projectReels,
      diskItems,
      missing,
      stats,
      diskStats,
      reportPath
    )

    local missingCount = #sortedKeys(missing)
    local summary = string.format(
      "Done. %d missing reel%s found from %d disk media file%s.",
      missingCount,
      missingCount == 1 and "" or "s",
      diskStats.mediaFilesSeen,
      diskStats.mediaFilesSeen == 1 and "" or "s"
    )

    if isChecked("write_report") then
      summary = summary .. "\nReport written to: " .. reportPath
    end

    setResult(summary)
    return report
  end)

  if ok then
    setDetails(message)
  else
    setResult("Could not finish the check.")
    setDetails(tostring(message))
  end
end

local existingWindow = ui:FindWindow(WINDOW_ID)
if existingWindow then
  existingWindow:Show()
  existingWindow:Raise()
  return
end

win = dispatcher:AddWindow(
  {
    ID = WINDOW_ID,
    Geometry = { 240, 80, 660, 760 },
    WindowTitle = "Krabby Missing Reel Check",
  },
  ui:VGroup {
    Spacing = 9,

    ui:Label {
      Text = "Krabby Missing Reel Check",
      Font = ui:Font { PixelSize = 22, Bold = true },
      StyleSheet = "color: #f0f0f0;",
      Weight = 0,
    },

    ui:Label {
      Text = "Compare Resolve Reel Name values in the current Media Pool bin tree against filenames inside a folder on disk.",
      WordWrap = true,
      StyleSheet = "color: #b8b8b8;",
      Weight = 0,
    },

    ui:TextEdit {
      ID = "bin_summary",
      ReadOnly = true,
      AcceptRichText = false,
      MinimumSize = { 600, 64 },
      MaximumSize = { 16777215, 82 },
      StyleSheet = "background-color: #20242b; border: 1px solid #4b5563; border-radius: 4px; color: #e7e7e7;",
      Weight = 0,
    },

    ui:HGroup {
      Weight = 0,
      ui:Button {
        ID = "refresh",
        Text = "Refresh current bin",
        MinimumSize = { 200, 30 },
        StyleSheet = "QPushButton { border: 1px solid #5b6472; border-radius: 14px; padding: 6px 14px; color: #d8d8d8; } QPushButton:hover { background-color: #333945; }",
      },
      ui:Button {
        ID = "choose_folder",
        Text = "Choose disk folder",
        MinimumSize = { 200, 30 },
        StyleSheet = "QPushButton { border: 1px solid #5b6472; border-radius: 14px; padding: 6px 14px; color: #d8d8d8; } QPushButton:hover { background-color: #333945; }",
      },
      ui:HGap(0, 1),
    },

    ui:Label {
      Text = "Disk folder",
      Font = ui:Font { PixelSize = 13, Bold = true },
      StyleSheet = "color: #f0f0f0;",
      Weight = 0,
    },

    ui:LineEdit {
      ID = "folder_path",
      Text = "",
      PlaceholderText = "/Volumes/CameraDrive/Day_01",
      MinimumSize = { 600, 28 },
      StyleSheet = "background-color: #20242b; border: 1px solid #4b5563; border-radius: 4px; color: #e7e7e7; padding: 4px 8px;",
      Weight = 0,
    },

    ui:Label {
      Text = "Report",
      Font = ui:Font { PixelSize = 13, Bold = true },
      StyleSheet = "color: #f0f0f0;",
      Weight = 0,
    },

    ui:LineEdit {
      ID = "report_path",
      Text = DEFAULT_REPORT_PATH,
      MinimumSize = { 600, 28 },
      StyleSheet = "background-color: #20242b; border: 1px solid #4b5563; border-radius: 4px; color: #e7e7e7; padding: 4px 8px;",
      Weight = 0,
    },

    ui:Label {
      Text = "Options",
      Font = ui:Font { PixelSize = 13, Bold = true },
      StyleSheet = "color: #f0f0f0;",
      Weight = 0,
    },

    ui:VGroup {
      Spacing = 4,
      Weight = 0,
      ui:CheckBox {
        ID = "skip_multicam",
        Text = "Skip multicam clips",
        Checked = true,
      },
      ui:CheckBox {
        ID = "case_insensitive",
        Text = "Case-insensitive matching",
        Checked = false,
      },
      ui:CheckBox {
        ID = "write_report",
        Text = "Write report file",
        Checked = true,
      },
    },

    ui:Label {
      Text = "Rule: A001_C001_0101AB.mov on disk matches Reel Name A001_C001_0101AB in Resolve.",
      WordWrap = true,
      StyleSheet = "color: #b8b8b8;",
      Weight = 0,
    },

    ui:Label {
      Text = "Result",
      Font = ui:Font { PixelSize = 13, Bold = true },
      StyleSheet = "color: #f0f0f0;",
      Weight = 0,
    },

    ui:TextEdit {
      ID = "result_summary",
      ReadOnly = true,
      AcceptRichText = false,
      MinimumSize = { 600, 52 },
      MaximumSize = { 16777215, 68 },
      StyleSheet = "background-color: #173c2a; border: 1px solid #2fa56f; border-radius: 5px; color: #ecfff5;",
      Weight = 0,
    },

    ui:HGroup {
      Weight = 0,
      ui:Button {
        ID = "run",
        Text = "Run check",
        MinimumSize = { 220, 36 },
        StyleSheet = "QPushButton { background-color: #2fa56f; border: 1px solid #48c58d; border-radius: 16px; padding: 8px 16px; color: #0b1710; font-weight: 700; } QPushButton:hover { background-color: #3fcf8f; }",
      },
      ui:Button {
        ID = "close",
        Text = "Close",
        MinimumSize = { 160, 36 },
        StyleSheet = "QPushButton { border: 1px solid #5b6472; border-radius: 16px; padding: 8px 16px; color: #d8d8d8; } QPushButton:hover { background-color: #333945; }",
      },
      ui:HGap(0, 1),
    },

    ui:CheckBox {
      ID = "show_details",
      Text = "Show details",
      Checked = false,
      Weight = 0,
    },

    ui:TextEdit {
      ID = "details",
      ReadOnly = true,
      AcceptRichText = false,
      MinimumSize = { 600, 96 },
      MaximumSize = { 16777215, 140 },
      Visible = false,
      StyleSheet = "background-color: #20242b; border: 1px solid #4b5563; border-radius: 4px; color: #d8d8d8;",
      Weight = 0,
    },

    ui:HGroup {
      Weight = 0,
      ui:Label {
        Text = "KRABBY\nPADDY",
        Font = ui:Font { PixelSize = 15, Bold = true },
        StyleSheet = "color: #f2f2f2; letter-spacing: 2px;",
        Weight = 0,
      },
      ui:VGroup {
        Weight = 0,
        ui:Label {
          Text = "KrabbyPaddy.com",
          Font = ui:Font { PixelSize = 10, Bold = true },
          StyleSheet = "color: #d8d8d8;",
          Weight = 0,
        },
        ui:Label {
          Text = "Copyright (c) 2026 Krabby Paddy. All rights reserved.",
          Font = ui:Font { PixelSize = 9 },
          StyleSheet = "color: #9ca3af;",
          Weight = 0,
        },
      },
      ui:HGap(0, 1),
    },
  }
)

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

win.On.refresh.Clicked = function()
  describeCurrentBin()
end

win.On.choose_folder.Clicked = function()
  local selectedFolder = chooseFolderDialog()

  if selectedFolder ~= "" then
    setItemText("folder_path", selectedFolder)
    setResult("Folder selected. Ready to run the check.")
  else
    setResult("Could not open a folder picker here. Paste the folder path into Disk folder.")
  end
end

win.On.run.Clicked = function()
  setResult("Working... scanning the selected Resolve bin tree and disk folder.")
  setDetails("Running check...")
  runCheck()
end

win.On.close.Clicked = function()
  dispatcher:ExitLoop()
end

win.On.show_details.Clicked = function()
  setDetailsVisible(item("show_details").Checked)
end

describeCurrentBin()
setResult("Ready. Choose the disk folder, then run the check.")
setDetails("Details and the full report will appear here after a run.")
setDetailsVisible(false)

win:Show()
dispatcher:RunLoop()
win:Hide()
