-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Highly experimental WAV worker preview proof of concept
-- This view tests whether a plugin can stay responsive while pure Lua analysis runs on another OS thread
-- @module plugins.wavpreview.view

local core = require("core")
local style = require("core.style")
local DocView = require("core.doc.view")
local wav = require("plugins.wavpreview.wav")
local worker = require("worker")

local WavPreview = DocView:extend()

-- Keep both graphs large enough to read while retaining a compact preview
local OUTER_PADDING = 20
local GRAPH_HEIGHT = 180
local GRAPH_LABEL_GAP = 8
local MIN_WAVEFORM_COLUMN_HEIGHT = 2
local SECTION_GAP = 24
-- Set to 1 to spread low FFT frequencies across more horizontal space
local USE_LOG_SCALE = 0

--- Insert grouping commas into one non-negative integer
-- @param value number integer value
-- @return string formatted
local function format_integer(value)
	local digits = tostring(math.floor(value))
	local grouped = digits:reverse():gsub("(%d%d%d)", "%1,"):reverse()

	return grouped:gsub("^,", "")
end

--- Return a short clock-style duration
-- @param seconds number duration in seconds
-- @return string formatted
local function format_duration(seconds)
	local milliseconds = math.floor(seconds * 1000 + 0.5)
	local hours = milliseconds // 3600000
	local minutes = milliseconds // 60000 % 60
	local whole_seconds = milliseconds // 1000 % 60
	local remainder = milliseconds % 1000

	if hours > 0 then
		return string.format("%d:%02d:%02d.%03d", hours, minutes, whole_seconds, remainder)
	end

	return string.format("%d:%02d.%03d", minutes, whole_seconds, remainder)
end

--- Return a friendly channel layout name
-- @param channels number channel count
-- @return string label
local function get_channel_label(channels)
	if channels == 1 then
		return "Mono"
	end
	if channels == 2 then
		return "Stereo"
	end

	return string.format("%d channels", channels)
end

--- Return the stored and effective sample width
-- @param audio table parsed WAV audio
-- @return string label
local function get_bit_label(audio)
	if audio.valid_bits and audio.valid_bits > 0 and audio.valid_bits ~= audio.bits_per_sample then
		return string.format("%d-bit in %d-bit", audio.valid_bits, audio.bits_per_sample)
	end

	return string.format("%d-bit", audio.bits_per_sample)
end

--- Return the file information shown above the graphs
-- @param self table WAV preview
-- @return table lines
local function get_header_lines(self)
	local audio = self.audio

	return {
		"Name: " .. self:get_name(),
		string.format("Type: %s WAV", audio.encoding_name),
		string.format(
			"Audio: %s · %s Hz · %s",
			get_channel_label(audio.channels),
			format_integer(audio.sample_rate),
			get_bit_label(audio)
		),
		string.format(
			"Length: %s · %s frames · %s bytes",
			format_duration(audio.duration),
			format_integer(audio.frame_count),
			format_integer(audio.file_size)
		),
	}
end

--- Return the information panel height including its divider
-- @param self table WAV preview
-- @return number height
local function get_header_height(self)
	local lines = get_header_lines(self)
	local text_height = #lines * style.font:get_height()
		+ math.max(0, #lines - 1) * style.padding.y / 2
	local divider_height = math.max(1, style.divider_size or 1)

	return style.padding.y + text_height + OUTER_PADDING + divider_height
end

--- Draw one graph backdrop and its quiet horizontal baseline
-- @param x number graph left edge
-- @param y number graph top edge
-- @param width number graph width
-- @param height number graph height
local function draw_graph_background(x, y, width, height)
	renderer.draw_rect(x, y, width, height, style.background2 or style.background)
	renderer.draw_rect(
		x,
		y + math.floor(height / 2),
		width,
		math.max(1, style.divider_size or 1),
		style.divider or style.dim or style.text
	)
end

--- Draw centered progress text inside one finished-size graph panel
-- @param x number graph left edge
-- @param y number graph top edge
-- @param width number graph width
local function draw_calculating_graph(x, y, width)
	local text = "Calculating..."
	local text_x = x + math.max(0, math.floor((width - style.font:get_width(text)) / 2))
	local text_y = y + math.max(0, math.floor((GRAPH_HEIGHT - style.font:get_height()) / 2))

	draw_graph_background(x, y, width, GRAPH_HEIGHT)
	renderer.draw_text(style.font, text, text_x, text_y, style.dim or style.text)
end

--- Draw all-channel minimum and maximum samples across the available width
-- @param self table WAV preview
-- @param x number graph left edge
-- @param y number graph top edge
-- @param width number graph width
local function draw_waveform(self, x, y, width)
	local columns = math.max(1, math.floor(width))

	if self.analysis_job then
		draw_calculating_graph(x, y, columns)
		return
	end

	local bucket_count = #self.waveform_low
	local half_height = GRAPH_HEIGHT / 2
	local color = style.accent or style.text
	local previous_low
	local previous_high

	draw_graph_background(x, y, columns, GRAPH_HEIGHT)

	for column = 0, columns - 1 do
		local first = math.floor(column * bucket_count / columns) + 1
		local last = math.max(first, math.floor((column + 1) * bucket_count / columns))
		local low = self.waveform_low[first]
		local high = self.waveform_high[first]

		for bucket = first, math.min(last, bucket_count) do
			low = math.min(low, self.waveform_low[bucket])
			high = math.max(high, self.waveform_high[bucket])
		end

		-- Bridge only the gap between neighboring envelopes
		local draw_low = low
		local draw_high = high

		if previous_high and low > previous_high then
			draw_low = previous_high
		elseif previous_low and high < previous_low then
			draw_high = previous_low
		end

		local top = math.floor(y + half_height - draw_high * half_height)
		local bottom = math.ceil(y + half_height - draw_low * half_height)
		local height = bottom - top

		if height < MIN_WAVEFORM_COLUMN_HEIGHT then
			local middle = (top + bottom) / 2
			top = math.floor(middle - MIN_WAVEFORM_COLUMN_HEIGHT / 2)
			height = MIN_WAVEFORM_COLUMN_HEIGHT
		end

		renderer.draw_rect(x + column, top, 1, height, color)
		previous_low = low
		previous_high = high
	end
end

--- Draw the averaged FFT using the selected horizontal frequency scale
-- @param self table WAV preview
-- @param x number graph left edge
-- @param y number graph top edge
-- @param width number graph width
local function draw_spectrum(self, x, y, width)
	local columns = math.max(1, math.floor(width))

	if self.analysis_job then
		draw_calculating_graph(x, y, columns)
		return
	end

	local bin_count = #self.spectrum
	local use_log_scale = USE_LOG_SCALE ~= 0
	local log_frequency_bins = use_log_scale and math.log(math.max(1, bin_count - 1)) or 0
	local color = style.accent or style.text
	local path = self.spectrum_path

	draw_graph_background(x, y, columns, GRAPH_HEIGHT)

	if not path
		or path.x ~= x
		or path.y ~= y
		or path.columns ~= columns
		or path.spectrum ~= self.spectrum
		or path.log_scale ~= use_log_scale
	then
		local points = {}

		for bin = 1, bin_count do
			local point_x

			if bin_count == 1 or columns == 1 then
				point_x = x
			elseif not use_log_scale or log_frequency_bins == 0 then
				point_x = x + (bin - 1) * (columns - 1) / (bin_count - 1)
			elseif bin == 1 then
				-- Give the zero-frequency result one pixel before the logarithmic audio bins
				point_x = x
			else
				point_x = x + 1
					+ math.log(bin - 1) * math.max(0, columns - 2) / log_frequency_bins
			end

			points[#points + 1] = point_x
			points[#points + 1] = y + GRAPH_HEIGHT - self.spectrum[bin] * GRAPH_HEIGHT
		end

		if bin_count == 1 then
			points[#points + 1] = x + 1
			points[#points + 1] = points[2]
		end

		path = {
			x = x,
			y = y,
			columns = columns,
			spectrum = self.spectrum,
			log_scale = use_log_scale,
			points = points,
		}
		self.spectrum_path = path
	end

	renderer.draw_polyline(path.points, 1, color)
end

--- Build a read-only preview for one supported WAV document
-- @param doc table WAV document
-- @param audio table|nil compact audio metadata saved by the handler probe
function WavPreview:new(doc, audio)
	WavPreview.super.new(self, doc)
	self.is_wav_preview = true
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
	self.audio = audio
	self.load_error = nil
	self.analysis_job = nil

	if not self.audio then
		self.audio, self.load_error = wav.read_file_info(doc.filename)
	end

	if self.audio then
		-- Run sample decoding and FFT work in a Lua state owned by another OS thread
		local submit_error
		self.analysis_job, submit_error = worker.submit(
			"plugins.wavpreview.wav",
			"analyze_file",
			system.absolute_path(doc.filename) or doc.filename
		)
		if not self.analysis_job then
			self.audio = nil
			self.load_error = submit_error
		end
	end
end

--- Collect completed worker analysis without touching its Lua state
function WavPreview:update()
	WavPreview.super.update(self)

	if not self.analysis_job or not self.analysis_job:ready() then
		return
	end

	local analysis, analysis_error = self.analysis_job:result()
	self.analysis_job = nil

	if not analysis then
		self.audio = nil
		self.load_error = analysis_error
	else
		self.audio = analysis.audio
		self.waveform_low = analysis.waveform_low
		self.waveform_high = analysis.waveform_high
		self.spectrum = analysis.spectrum
	end

	core.redraw = true
end

--- Cancel obsolete analysis before closing this preview
-- @param do_close function completes the view removal
function WavPreview:try_close(do_close)
	if self.analysis_job then
		self.analysis_job:cancel()
		self.analysis_job = nil
	end

	WavPreview.super.try_close(self, do_close)
end

--- Return the visible file information header lines
-- @return table lines
function WavPreview:get_header_lines()
	return self.audio and get_header_lines(self) or {}
end

--- Return a stable scroll step
-- @return number line_height
function WavPreview:get_line_height()
	return style.font:get_height()
end

--- Return total preview height
-- @return number content_height
function WavPreview:get_scrollable_size()
	if not self.audio then
		return self.size.y
	end

	local label_height = style.font:get_height() + GRAPH_LABEL_GAP

	return get_header_height(self) + OUTER_PADDING + label_height + GRAPH_HEIGHT
		+ SECTION_GAP + label_height + GRAPH_HEIGHT + OUTER_PADDING
end

--- Draw the WAV header, waveform, and combined frequency spectrum
function WavPreview:draw()
	-- Collapsing panes can briefly report unusable geometry between layout passes
	if self.size.x <= 0 or self.size.y <= 0 then
		return
	end

	self:draw_background(style.background)

	if not self.audio then
		renderer.draw_text(
			style.font,
			"Unable to preview this WAV: " .. tostring(self.load_error),
			self.position.x + style.padding.x,
			self.position.y + style.padding.y,
			style.error or style.accent or style.text
		)
		self:draw_scrollbar()
		return
	end

	local scroll_y = self.scroll.y or 0
	local header_height = get_header_height(self)
	local divider_height = math.max(1, style.divider_size or 1)
	local content_x, _, content_width = self:get_content_viewport_rect()
	local graph_x = content_x + style.padding.x
	local graph_width = math.max(1, content_width - style.padding.x * 2)
	local header_y = self.position.y + style.padding.y - scroll_y

	core.push_clip_rect(self.position.x, self.position.y, self.size.x, self.size.y)
	-- Match the other preview plugins' highlighted information panel
	renderer.draw_rect(
		self.position.x,
		self.position.y - scroll_y,
		self.size.x,
		header_height,
		style.line_highlight or style.background2 or style.background
	)

	local lines = get_header_lines(self)

	for index = 1, #lines do
		renderer.draw_text(style.font, lines[index], graph_x, header_y, style.text)
		header_y = header_y + style.font:get_height()

		if index < #lines then
			header_y = header_y + style.padding.y / 2
		end
	end

	renderer.draw_rect(
		graph_x,
		self.position.y + header_height - divider_height - scroll_y,
		graph_width,
		divider_height,
		style.text
	)

	local y = self.position.y + header_height + OUTER_PADDING - scroll_y
	renderer.draw_text(style.font, "Waveform", graph_x, y, style.text)
	y = y + style.font:get_height() + GRAPH_LABEL_GAP
	draw_waveform(self, graph_x, y, graph_width)
	y = y + GRAPH_HEIGHT + SECTION_GAP
	local nyquist = self.audio.sample_rate / 2000
	local frequency_scale = USE_LOG_SCALE ~= 0 and "logarithmic frequency" or "linear frequency"
	renderer.draw_text(
		style.font,
		string.format("Combined FFT · %s · DC–%.1f kHz", frequency_scale, nyquist),
		graph_x,
		y,
		style.text
	)
	y = y + style.font:get_height() + GRAPH_LABEL_GAP
	draw_spectrum(self, graph_x, y, graph_width)

	core.pop_clip_rect()
	self:draw_scrollbar()
end

--- Return whether one document is a file-backed WAV
-- @param doc table|nil candidate document
-- @return boolean supported
function WavPreview.supports_doc(doc)
	return doc
		and doc.filename ~= nil
		and type(doc.format) == "table"
		and doc.format._module_name == "format.wav"
		or false
end

return WavPreview
