--[[ Cut Corners: interactive Settings & Preview dialog.

Exports downsized previews of the first few selected photos, then shows a
modal dialog where the film type and detection tunables can be adjusted
with a live overlay of what detect.py finds (red = candidate mask, blue =
film area, green = detected frame, magenta = final crop). Nothing is
written to the plugin preferences until the user confirms.
]]

local LrBinding = import 'LrBinding'
local LrDialogs = import 'LrDialogs'
local LrFileUtils = import 'LrFileUtils'
local LrPathUtils = import 'LrPathUtils'
local LrPrefs = import 'LrPrefs'
local LrTasks = import 'LrTasks'
local LrView = import 'LrView'

local Detect = require 'Detect'
local Export = require 'Export'
local FilmFormats = require 'FilmFormats'
local FilmTypes = require 'FilmTypes'
local Profiles = require 'Profiles'

local prefs = LrPrefs.prefsForPlugin()

-- Enough photos to spot-check a tricky roll without exporting all of it.
local MAX_PHOTOS = 8

local TUNABLES = {
	{ key = 'edgeBand', title = 'Edge band:', min = 0.02, max = 0.30,
		default = 0.10,
		tooltip = 'Corners must lie within this fraction of each edge' },
	{ key = 'inset', title = 'Crop inset:', min = 0.0, max = 0.03,
		default = 0.005,
		tooltip = 'Shrink the detected frame by this fraction to exclude the rebate edge' },
	{ key = 'border', title = 'Keep border:', min = 0.0, max = 0.10,
		default = 0.0,
		tooltip = 'Margin of rebate kept outside the detected frame, as a'
			.. ' fraction of its short side; 0 = off. When set, crop inset'
			.. ' is ignored' },
	{ key = 'aspectTarget', title = 'Aspect target:', min = 1.0, max = 2.8,
		default = 1.5,
		tooltip = 'Long/short side target; 1.5 for 35mm full frame' },
	{ key = 'aspectTolerance', title = 'Aspect tolerance:', min = 0.02, max = 0.25,
		default = 0.08,
		tooltip = 'Relative tolerance before a frame is flagged' },
	{ key = 'minArea', title = 'Min frame area:', min = 0.30, max = 0.95,
		default = 0.64,
		tooltip = 'Smallest accepted frame as a fraction of the film area' },
	{ key = 'maxArea', title = 'Max frame area:', min = 0.80, max = 1.00,
		default = 0.99,
		tooltip = 'Largest accepted frame as a fraction of the whole scan' },
	{ key = 'manualThreshold', title = 'Manual threshold:', min = 0, max = 255,
		default = 0, fmt = '%.0f',
		tooltip = 'Fixed brightness cutoff replacing the adaptive threshold;'
			.. ' 0 = adaptive (recommended). Ignored in auto-detect mode.' },
}

local function describe(result, err)
	if result == nil then
		return 'Error: ' .. tostring(err)
	end
	if result.skip then
		return 'Skipped: ' .. (result.reason or 'no frame found')
	end
	-- Whether enforcement actually fired is the feedback that makes the
	-- checkbox tunable: it declines silently whenever every edge already looks
	-- like a frame edge, and that is worth seeing here.
	return string.format('Frame found (%s strategy%s), confidence %.2f',
		result.strategy or '?',
		result.aspect_snapped and ', aspect enforced' or '',
		result.confidence or 0)
end

local Preview = {}

-- Shows the dialog for up to MAX_PHOTOS of `photos`. Returns true after
-- writing the tuned values to prefs, or nil if the user canceled. Must be
-- called from an async task (it exports and presents a modal dialog).
function Preview.run(context, photos)
	local f = LrView.osFactory()

	local tempDir = LrPathUtils.child(
		LrPathUtils.getStandardFilePath('temp'),
		'cut-corners-preview-' .. os.time())
	LrFileUtils.createAllDirectories(tempDir)
	context:addCleanupHandler(function()
		LrFileUtils.delete(tempDir)
	end)

	local sample = {}
	for i = 1, math.min(#photos, MAX_PHOTOS) do
		sample[i] = photos[i]
	end
	local items = {}
	for _, r in ipairs(Export.previews(sample, tempDir)) do
		if r.path then
			items[#items + 1] = {
				path = r.path,
				name = r.photo:getFormattedMetadata('fileName'),
			}
		end
	end
	if #items == 0 then
		LrFileUtils.delete(tempDir)
		LrDialogs.message('Cut Corners',
			'Could not export a preview to tune with.')
		return nil
	end

	local props = LrBinding.makePropertyTable(context)
	props.filmType = prefs.filmType
	props.filmFormat = prefs.filmFormat
	props.darkEdgeMask = prefs.darkEdgeMask and true or false
	props.enforceAspect = prefs.enforceAspect and true or false
	-- Not shown in this dialog, but carried through props so profiles can
	-- save and load it under the same confirm-on-OK contract as the rest.
	props.maxPixels = tonumber(prefs.maxPixels) or 1500
	for _, t in ipairs(TUNABLES) do
		props[t.key] = tonumber(prefs[t.key]) or t.default
	end
	props.previewImage = items[1].path
	props.statusText = 'Detecting...'
	props.photoLabel = ''

	-- One generation per requested detection; a bumped counter invalidates
	-- any sleeping (debounce) or in-flight run, so only the newest result
	-- ever reaches the UI.
	local state = { generation = 0, index = 1 }

	local function scheduleDetect(delay)
		state.generation = state.generation + 1
		local gen = state.generation
		LrTasks.startAsyncTask(function()
			if delay > 0 then
				LrTasks.sleep(delay)
			end
			if gen ~= state.generation then
				return
			end
			props.statusText = 'Detecting...'
			local item = items[state.index]
			local opts = {
				filmType = props.filmType,
				edgeBand = props.edgeBand,
				inset = props.inset,
				border = props.border,
				aspectTarget = props.aspectTarget,
				aspectTolerance = props.aspectTolerance,
				minArea = props.minArea,
				maxArea = props.maxArea,
				manualThreshold = props.manualThreshold,
				darkEdgeMask = props.darkEdgeMask,
				enforceAspect = props.enforceAspect,
				debug = true,
			}
			local result, err = Detect.run(prefs.pythonPath, item.path, opts)
			if gen ~= state.generation then
				return
			end
			-- The overlay is copied to a per-generation name because the
			-- picture control does not reload a path it is already showing.
			local overlay = item.path .. '.debug.jpg'
			if LrFileUtils.exists(overlay) then
				local shown = item.path .. '.gen' .. gen .. '.jpg'
				LrFileUtils.copy(overlay, shown)
				props.previewImage = shown
			else
				props.previewImage = item.path
			end
			props.statusText = describe(result, err)
		end)
	end

	local function setIndex(i)
		state.index = i
		props.photoLabel = string.format('%s (%d of %d)',
			items[i].name, i, #items)
		props.previewImage = items[i].path
		scheduleDetect(0)
	end

	local function onChange()
		scheduleDetect(0.3)
	end
	props:addObserver('filmType', onChange)
	props:addObserver('darkEdgeMask', onChange)
	props:addObserver('enforceAspect', onChange)
	for _, t in ipairs(TUNABLES) do
		props:addObserver(t.key, onChange)
	end

	-- Format preset <-> aspect slider sync. The guard stops the pair of
	-- observers from feeding each other: picking a preset moves the slider
	-- without flipping the preset back to custom, while a hand-moved slider
	-- does flip it.
	local syncing = false
	props:addObserver('filmFormat', function()
		local aspect = FilmFormats.aspect[props.filmFormat]
		if aspect and not syncing then
			syncing = true
			props.aspectTarget = aspect
			syncing = false
		end
	end)
	props:addObserver('aspectTarget', function()
		if not syncing then
			syncing = true
			props.filmFormat = 'custom'
			syncing = false
		end
	end)

	-- Profile management. Loading writes into props, not prefs, so Cancel
	-- still discards it; saving persists immediately (an explicit act).
	local function profileItems()
		local list = { { title = '(select a profile)', value = '' } }
		for _, name in ipairs(Profiles.list()) do
			list[#list + 1] = { title = name, value = name }
		end
		return list
	end
	props.profile = ''
	props.profileName = ''
	props.profileItems = profileItems()

	local function loadProfile()
		local profile = Profiles.get(props.profile)
		if profile then
			-- syncing silences only the format/aspect observer pair; the
			-- tunable observers still fire and the debounce collapses them
			-- into a single re-detect.
			syncing = true
			Profiles.applyTo(props, profile)
			syncing = false
		end
	end

	local function saveProfile()
		local name = props.profileName
		if name == nil or name:match('^%s*$') then
			name = props.profile -- blank name field: overwrite the selection
		end
		name = Profiles.save(name, props)
		if name then
			props.profileItems = profileItems()
			props.profile = name
			props.profileName = ''
		end
	end

	local function deleteProfile()
		if props.profile ~= '' then
			Profiles.delete(props.profile)
			props.profileItems = profileItems()
			props.profile = ''
		end
	end

	local function sliderRow(t)
		return f:row {
			f:static_text { title = t.title, width = LrView.share 'pv_label' },
			f:slider {
				value = LrView.bind(t.key),
				min = t.min,
				max = t.max,
				width = 240,
				tooltip = t.tooltip,
			},
			f:static_text {
				title = LrView.bind {
					key = t.key,
					transform = function(v)
						return string.format(t.fmt or '%.3f', tonumber(v) or 0)
					end,
				},
				width_in_chars = 6,
			},
		}
	end

	local rows = {
		bind_to_object = props,
		spacing = f:control_spacing(),
		f:row {
			f:push_button {
				title = '< Prev',
				enabled = #items > 1,
				action = function()
					setIndex(state.index == 1 and #items or state.index - 1)
				end,
			},
			f:static_text {
				title = LrView.bind 'photoLabel',
				width = 420,
				alignment = 'center',
			},
			f:push_button {
				title = 'Next >',
				enabled = #items > 1,
				action = function()
					setIndex(state.index == #items and 1 or state.index + 1)
				end,
			},
		},
		f:picture {
			value = LrView.bind 'previewImage',
			width = 620,
			height = 420,
		},
		f:static_text { title = LrView.bind 'statusText', width = 620 },
		f:row {
			f:static_text { title = 'Film type:', width = LrView.share 'pv_label' },
			f:popup_menu {
				items = FilmTypes,
				value = LrView.bind 'filmType',
			},
		},
		f:row {
			f:static_text { title = 'Film format:', width = LrView.share 'pv_label' },
			f:popup_menu {
				items = FilmFormats.items,
				value = LrView.bind 'filmFormat',
			},
			f:checkbox {
				title = 'Mask dark film holder edges',
				value = LrView.bind 'darkEdgeMask',
				tooltip = 'Treat dark scan surround (film holder / carrier)'
					.. ' as background, not film',
			},
		},
		f:row {
			f:static_text { title = '', width = LrView.share 'pv_label' },
			f:checkbox {
				title = 'Enforce aspect ratio',
				value = LrView.bind 'enforceAspect',
				tooltip = 'When one crop edge sits on a bright streak in the'
					.. ' rebate (light past a slide mount, halation) instead of the'
					.. ' frame, use the aspect target to find the real film edge'
					.. ' and move it there',
			},
		},
		f:row {
			f:static_text { title = 'Profile:', width = LrView.share 'pv_label' },
			f:popup_menu {
				items = LrView.bind 'profileItems',
				value = LrView.bind 'profile',
				width = 200,
				tooltip = 'Saved scanning-setup profiles (film type, format'
					.. ' and every tunable below)',
			},
			f:push_button {
				title = 'Load',
				action = loadProfile,
			},
			f:push_button {
				title = 'Delete',
				action = deleteProfile,
			},
			f:edit_field {
				value = LrView.bind 'profileName',
				width_in_chars = 14,
				immediate = true,
				tooltip = 'Name to save the current settings under; leave'
					.. ' blank to overwrite the selected profile',
			},
			f:push_button {
				title = 'Save',
				action = saveProfile,
			},
		},
	}
	for _, t in ipairs(TUNABLES) do
		rows[#rows + 1] = sliderRow(t)
	end

	setIndex(1)
	local result = LrDialogs.presentModalDialog({
		title = 'Cut Corners: Settings & Preview',
		contents = f:column(rows),
		actionVerb = 'Use these settings',
	})

	-- Invalidate any pending detection so it stops touching the temp dir.
	state.generation = state.generation + 1
	LrFileUtils.delete(tempDir)

	if result ~= 'ok' then
		return nil
	end
	prefs.filmType = props.filmType
	prefs.filmFormat = props.filmFormat
	prefs.darkEdgeMask = props.darkEdgeMask and true or false
	prefs.enforceAspect = props.enforceAspect and true or false
	prefs.maxPixels = tonumber(props.maxPixels) or prefs.maxPixels
	for _, t in ipairs(TUNABLES) do
		prefs[t.key] = props[t.key]
	end
	return true
end

return Preview
