--[[ Cut Corners: batch auto-crop of film scans.

Flow: confirm film type -> export downsized JPEGs to temp -> run detect.py
per frame -> apply crops in a single undoable catalog write -> report.
]]

local LrApplication = import 'LrApplication'
local LrBinding = import 'LrBinding'
local LrDialogs = import 'LrDialogs'
local LrFileUtils = import 'LrFileUtils'
local LrFunctionContext = import 'LrFunctionContext'
local LrPathUtils = import 'LrPathUtils'
local LrPrefs = import 'LrPrefs'
local LrProgressScope = import 'LrProgressScope'
local LrView = import 'LrView'

local CropApply = require 'CropApply'
local Detect = require 'Detect'
local Export = require 'Export'
local FilmFormats = require 'FilmFormats'
local FilmTypes = require 'FilmTypes'
local License = require 'License'
local Log = require 'Logger'
local Preview = require 'Preview'
local Profiles = require 'Profiles'

local prefs = LrPrefs.prefsForPlugin()

-- A format preset is sugar for the aspect-target tunable; "custom" leaves
-- the tuned value alone.
local function applyFormat(format)
	prefs.filmFormat = format
	local aspect = FilmFormats.aspect[format]
	if aspect then
		prefs.aspectTarget = aspect
	end
end

local function showRunDialog(context, photos)
	local f = LrView.osFactory()
	local props = LrBinding.makePropertyTable(context)
	props.filmType = prefs.filmType
	props.filmFormat = prefs.filmFormat
	props.dryRun = prefs.dryRun
	props.overwriteCropped = prefs.overwriteCropped
	props.rollRescue = prefs.rollRescue and true or false
	props.saveDiagnostics = false

	-- Profile recall is immediate: the tunables it carries aren't visible
	-- here and this dialog writes prefs on OK anyway, so apply straight into
	-- prefs and refresh the two fields that are shown. Profiles are managed
	-- (saved/deleted) in Settings & Preview.
	props.profile = ''
	props:addObserver('profile', function()
		local profile = Profiles.get(props.profile)
		if profile then
			Profiles.applyTo(prefs, profile)
			props.filmType = prefs.filmType
			props.filmFormat = prefs.filmFormat
		end
	end)

	-- The Settings & Preview and license buttons close this dialog with a
	-- custom verdict (a modal cannot present another modal from a button
	-- action); the loop reopens it after the other dialog returns.
	while true do
		local profileItems = { { title = '(current settings)', value = '' } }
		for _, name in ipairs(Profiles.list()) do
			profileItems[#profileItems + 1] = { title = name, value = name }
		end
		local content = {
			bind_to_object = props,
			spacing = f:control_spacing(),
			f:static_text {
				title = string.format('Auto-crop %d selected photo%s.',
					#photos, #photos == 1 and '' or 's'),
			},
			f:row {
				f:static_text { title = 'Profile:', width = LrView.share 'label' },
				f:popup_menu {
					items = profileItems,
					value = LrView.bind 'profile',
					enabled = #profileItems > 1,
					tooltip = 'Recall a saved scanning-setup profile'
						.. ' (manage them in Settings & Preview)',
				},
			},
			f:row {
				f:static_text { title = 'Film type:', width = LrView.share 'label' },
				f:popup_menu {
					items = FilmTypes,
					value = LrView.bind 'filmType',
				},
				f:push_button {
					title = 'Settings & Preview...',
					action = function(button)
						LrDialogs.stopModalWithResult(button, 'preview')
					end,
				},
			},
			f:row {
				f:static_text { title = 'Film format:', width = LrView.share 'label' },
				f:popup_menu {
					items = FilmFormats.items,
					value = LrView.bind 'filmFormat',
				},
			},
			f:checkbox {
				title = 'Dry run (detect and report only, change nothing)',
				value = LrView.bind 'dryRun',
			},
			f:checkbox {
				title = 'Re-crop photos that already have a crop',
				value = LrView.bind 'overwriteCropped',
			},
			f:checkbox {
				title = 'Rescue skipped frames using the roll\'s consensus geometry',
				value = LrView.bind 'rollRescue',
				tooltip = 'Frames that fail detection get a second pass seeded'
					.. ' with the median frame size/position of the roll.'
					.. ' Stands down automatically when the roll is not'
					.. ' consistent enough to trust',
			},
			f:checkbox {
				title = 'Save diagnostic images (keeps temp folder with detection overlays)',
				value = LrView.bind 'saveDiagnostics',
			},
		}
		if not License.isLicensed() then
			content[#content + 1] = f:row {
				f:static_text {
					title = License.statusText(),
					font = '<system/small>',
				},
				f:push_button {
					title = 'Enter License Key...',
					action = function(button)
						LrDialogs.stopModalWithResult(button, 'license')
					end,
				},
			}
		end
		local result = LrDialogs.presentModalDialog({
			title = 'Cut Corners',
			contents = f:column(content),
			actionVerb = 'Crop',
		})
		if result == 'preview' then
			prefs.filmType = props.filmType
			applyFormat(props.filmFormat)
			Preview.run(context, photos)
			props.filmType = prefs.filmType
			props.filmFormat = prefs.filmFormat
		elseif result == 'license' then
			License.promptForKey(context)
		elseif result == 'ok' then
			break
		else
			return nil
		end
	end

	prefs.filmType = props.filmType
	prefs.dryRun = props.dryRun
	prefs.overwriteCropped = props.overwriteCropped
	prefs.rollRescue = props.rollRescue
	applyFormat(props.filmFormat)

	local function num(v, fallback)
		return tonumber(v) or fallback
	end
	return {
		filmType = props.filmType,
		dryRun = props.dryRun,
		overwriteCropped = props.overwriteCropped,
		rollRescue = props.rollRescue,
		debug = props.saveDiagnostics,
		edgeBand = num(prefs.edgeBand, 0.10),
		inset = num(prefs.inset, 0.005),
		border = num(prefs.border, 0),
		aspectTarget = num(prefs.aspectTarget, 1.5),
		aspectTolerance = num(prefs.aspectTolerance, 0.08),
		minArea = num(prefs.minArea, 0.64),
		maxArea = num(prefs.maxArea, 0.99),
		manualThreshold = num(prefs.manualThreshold, 0),
		darkEdgeMask = prefs.darkEdgeMask and true or false,
	}
end

local function summarize(cropped, skipped, dryRun, diagnosticsDir, rescued)
	local lines = {}
	if dryRun then
		lines[#lines + 1] = 'Dry run - no changes were made.'
	end
	if rescued and #rescued > 0 then
		lines[#lines + 1] = string.format(
			'Cropped: %d (%d rescued by roll consensus)', cropped, #rescued)
		local shownRescued = math.min(#rescued, 10)
		for i = 1, shownRescued do
			lines[#lines + 1] = '  rescued ' .. rescued[i]
		end
		if #rescued > shownRescued then
			lines[#lines + 1] = string.format('  ... and %d more',
				#rescued - shownRescued)
		end
	else
		lines[#lines + 1] = string.format('Cropped: %d', cropped)
	end
	lines[#lines + 1] = string.format('Skipped: %d', #skipped)
	local shown = math.min(#skipped, 15)
	for i = 1, shown do
		lines[#lines + 1] = '  ' .. skipped[i].name .. ' - ' .. skipped[i].reason
	end
	if #skipped > shown then
		lines[#lines + 1] = string.format('  ... and %d more', #skipped - shown)
	end
	if diagnosticsDir then
		lines[#lines + 1] = ''
		lines[#lines + 1] = 'Diagnostic images kept in:'
		lines[#lines + 1] = diagnosticsDir
	end
	LrDialogs.message('Cut Corners finished', table.concat(lines, '\n'))
end

LrFunctionContext.postAsyncTaskWithContext('CutCornersAutoCrop', function(context)
	LrDialogs.attachErrorDialogToFunctionContext(context)

	local catalog = LrApplication.activeCatalog()
	local photos = catalog:getTargetPhotos()
	if #photos == 0 then
		LrDialogs.message('Cut Corners', 'Select at least one photo first.')
		return
	end

	local opts = showRunDialog(context, photos)
	if opts == nil then
		return
	end

	-- Trial gate: applying crops consumes the trial; dry runs never do, so
	-- detection stays evaluable after the trial runs out. Truncation happens
	-- here, before the existing-crop reset below touches anything.
	if not opts.dryRun and not License.isLicensed() then
		local remaining = License.trialRemaining()
		if remaining == 0 then
			local choice = LrDialogs.confirm('Cut Corners',
				'The free trial (' .. License.TRIAL_LIMIT .. ' cropped images)'
					.. ' is used up. Enter a license key to keep cropping, or'
					.. ' use Dry run to keep evaluating detection.',
				'Enter License Key...', 'Cancel')
			if choice ~= 'ok' or not License.promptForKey(context) then
				return
			end
		elseif #photos > remaining then
			local choice = LrDialogs.confirm('Cut Corners',
				string.format('The trial has %d image%s left, but %d are'
						.. ' selected. Crop the first %d now, or enter a'
						.. ' license key to crop them all.',
					remaining, remaining == 1 and '' or 's', #photos, remaining),
				string.format('Crop First %d', remaining),
				'Cancel', 'Enter License Key...')
			if choice == 'other' then
				if not License.promptForKey(context) then
					return
				end
			elseif choice == 'ok' then
				local trimmed = {}
				for i = 1, remaining do
					trimmed[i] = photos[i]
				end
				photos = trimmed
			else
				return
			end
		end
	end

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

	local scope = LrProgressScope({
		title = 'Cut Corners: auto-cropping ' .. #photos .. ' photos',
		functionContext = context,
	})
	scope:setCancelable(true)

	-- Photos with an existing crop export as the cropped view, which would
	-- make detection coordinates wrong; reset them first if requested,
	-- otherwise skip them.
	local skipped = {} -- { {photo=..., name=..., reason=...} }
	local toProcess = {}
	local toReset = {}
	for _, photo in ipairs(photos) do
		if CropApply.hasCrop(photo) then
			if opts.overwriteCropped and not opts.dryRun then
				toReset[#toReset + 1] = photo
				toProcess[#toProcess + 1] = photo
			else
				skipped[#skipped + 1] = {
					photo = photo,
					name = photo:getFormattedMetadata('fileName'),
					reason = opts.overwriteCropped
						and 'already cropped (dry run never resets crops)'
						or 'already cropped',
				}
			end
		else
			toProcess[#toProcess + 1] = photo
		end
	end
	if #toReset > 0 then
		catalog:withWriteAccessDo('Cut Corners reset crops', function()
			for _, photo in ipairs(toReset) do
				CropApply.reset(photo)
			end
		end, { timeout = 60 })
	end

	if #toProcess == 0 then
		summarize(0, skipped, opts.dryRun, nil)
		return
	end

	scope:setCaption('Exporting previews...')
	local rendered = Export.previews(toProcess, tempDir, scope)

	local detections = {} -- { {photo=..., result=...}, ... }
	for i, item in ipairs(rendered) do
		if scope:isCanceled() then
			break
		end
		local name = item.photo:getFormattedMetadata('fileName')
		scope:setCaption(string.format('Detecting %s (%d of %d)', name, i, #rendered))
		scope:setPortionComplete(i, #rendered)

		if item.failure then
			skipped[#skipped + 1] = { photo = item.photo, name = name, reason = item.failure }
		else
			local result, err = Detect.run(prefs.pythonPath, item.path, opts)
			if result == nil then
				skipped[#skipped + 1] = { photo = item.photo, name = name, reason = err }
			elseif result.skip then
				skipped[#skipped + 1] = {
					photo = item.photo,
					name = name,
					reason = result.reason or 'no frame found',
					-- kept so the roll-consensus rescue pass can re-detect
					-- this preview without re-exporting it
					path = item.path,
					landscape = (result.width or 0) > (result.height or 0),
				}
			else
				detections[#detections + 1] = { photo = item.photo, result = result }
			end
		end
	end

	-- Roll-consensus rescue: camera-scanned strips are extremely consistent
	-- frame-to-frame, so a frame that failed on its own gets a second pass
	-- seeded with the roll's median frame geometry (detect.py snaps the hint
	-- to the frame's actual edges and re-validates). The median is only
	-- trusted when at least 4 frames agree tightly; otherwise the rescue
	-- stands down, which is what makes it safe to leave on by default.
	local rescued = {}
	if opts.rollRescue and not scope:isCanceled() then
		local function median(values)
			table.sort(values)
			local n = #values
			if n % 2 == 1 then
				return values[(n + 1) / 2]
			end
			return (values[n / 2] + values[n / 2 + 1]) / 2
		end
		-- Median and median absolute deviation of one rect field.
		local function stat(rects, field)
			local vals = {}
			for i, r in ipairs(rects) do
				vals[i] = r[field]
			end
			local med = median(vals)
			local devs = {}
			for i, v in ipairs(vals) do
				devs[i] = math.abs(v - med)
			end
			return med, median(devs)
		end

		-- Rotated previews would median transposed geometry into garbage, so
		-- consensus is per orientation.
		local groups = { landscape = {}, portrait = {} }
		for _, d in ipairs(detections) do
			if d.result.rect then
				local g = groups[(d.result.width or 0) > (d.result.height or 0)
					and 'landscape' or 'portrait']
				g[#g + 1] = d.result.rect
			end
		end
		local hints = {}
		for key, rects in pairs(groups) do
			if #rects >= 4 then
				local hint, consistent = {}, true
				for _, field in ipairs({ 'cx', 'cy', 'w', 'h' }) do
					local med, mad = stat(rects, field)
					hint[field] = med
					if mad > 0.02 then
						Log.info('rescue stands down (%s): %s varies across'
							.. ' the roll (MAD %.3f)', key, field, mad)
						consistent = false
					end
				end
				-- Angle is deliberately not guarded: per-frame tilt varies
				-- legitimately, and edge snapping corrects it per frame.
				hint.angle = stat(rects, 'angle')
				if consistent then
					hints[key] = hint
				end
			end
		end

		if next(hints) ~= nil then
			local remaining = {}
			for _, s in ipairs(skipped) do
				local hint = s.path
					and hints[s.landscape and 'landscape' or 'portrait']
				local result
				if hint and not scope:isCanceled() then
					scope:setCaption('Rescue: ' .. s.name)
					local withHint = {}
					for k, v in pairs(opts) do
						withHint[k] = v
					end
					withHint.hintCx = hint.cx
					withHint.hintCy = hint.cy
					withHint.hintW = hint.w
					withHint.hintH = hint.h
					withHint.hintAngle = hint.angle
					local err
					result, err = Detect.run(prefs.pythonPath, s.path, withHint)
					if result == nil or result.skip then
						local why = result and result.reason or err
						if why then
							s.reason = s.reason
								.. ' (roll-consensus rescue: ' .. why .. ')'
						end
						result = nil
					end
				end
				if result then
					detections[#detections + 1] = {
						photo = s.photo, result = result, rescued = true,
					}
					rescued[#rescued + 1] = s.name
				else
					remaining[#remaining + 1] = s
				end
			end
			skipped = remaining
		end
	end

	local cropped = 0
	if not opts.dryRun and (#detections > 0 or #skipped > 0) then
		scope:setCaption('Applying crops...')
		catalog:withWriteAccessDo('Cut Corners auto-crop', function()
			for _, d in ipairs(detections) do
				local ok, reason = CropApply.apply(d.photo, d.result)
				if ok then
					cropped = cropped + 1
				else
					skipped[#skipped + 1] = {
						photo = d.photo,
						name = d.photo:getFormattedMetadata('fileName'),
						reason = reason,
					}
				end
			end
			if prefs.skipLabel ~= 'none' then
				for _, s in ipairs(skipped) do
					s.photo:setRawMetadata('colorNameForLabel', prefs.skipLabel)
				end
			end
		end, { timeout = 60 })
	else
		cropped = #detections
	end
	if not opts.dryRun then
		License.consumeTrial(cropped)
	end
	Log.info('run finished: %d cropped (%d rescued), %d skipped (film type %s%s)',
		cropped, #rescued, #skipped, opts.filmType,
		opts.dryRun and ', dry run' or '')
	for _, s in ipairs(skipped) do
		Log.info('skipped %s: %s', s.name, s.reason)
	end

	scope:done()
	if opts.debug then
		summarize(cropped, skipped, opts.dryRun, tempDir, rescued)
	else
		LrFileUtils.delete(tempDir)
		summarize(cropped, skipped, opts.dryRun, nil, rescued)
	end
end)
