---@diagnostic disable: duplicate-set-field
-- 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.

--- Plot previews for whitespace-delimited .2d files compatible with Liqube Visualizer2
-- @module plugins.2dplot

local core = require("core")
local command = require("core.command")
local common = require("core.common")
local config = require("core.config")
local style = require("core.style")
local TextEditor = require("core.text.editor")

local state = {
	-- weak keys: doc -> true
	enabled_docs = setmetatable({}, { __mode = "k" }),
	-- weak keys: doc -> cached parse
	cache = setmetatable({}, { __mode = "k" }),
	palette = nil,
}

if config.plot2d_stretch_points == nil then
	config.plot2d_stretch_points = false
end

-- Removes trailing semicolon comments from one .2d line before parsing
local function split_comment(line)
	local idx = line:find(";", 1, true)
	if idx then
		return line:sub(1, idx - 1)
	end
	return line
end

-- Parses one whitespace-delimited numeric row into an array of numbers
local function parse_numbers(line)
	local t = {}
	-- Pattern breakdown:
	-- %S+ matches each run of non-whitespace characters
	for s in line:gmatch("%S+") do
		local n = tonumber(s)
		-- Native drawing only accepts finite coordinates
		if n and n == n and n ~= math.huge and n ~= -math.huge then
			t[#t + 1] = n
		else
			return nil
		end
	end
	return t
end

-- Parses all document rows into curve arrays and min or max bounds
local function parse_doc_points(doc)
	local curves = {}
	local min_x, max_x
	local min_y, max_y
	local rows = 0
	local skipped = 0

	for i = 1, #doc.lines do
		local raw = doc.lines[i] or ""
		local line = split_comment(raw)
		-- Pattern breakdown:
		-- %S checks whether the line contains any non-whitespace content
		if line:find("%S") then
			local nums = parse_numbers(line)
			if nums and #nums >= 2 then
				rows = rows + 1
				local x = nums[1]
				min_x = (not min_x) and x or math.min(min_x, x)
				max_x = (not max_x) and x or math.max(max_x, x)
				for c = 2, #nums do
					local y = nums[c]
					curves[c - 1] = curves[c - 1] or {}
					table.insert(curves[c - 1], { x = x, y = y })
					min_y = (not min_y) and y or math.min(min_y, y)
					max_y = (not max_y) and y or math.max(max_y, y)
				end
			else
				skipped = skipped + 1
			end
		end
	end

	return {
		curves = curves,
		rows = rows,
		skipped = skipped,
		min_x = min_x,
		max_x = max_x,
		min_y = min_y,
		max_y = max_y,
	}
end

-- Builds palette once using style colors and fallbacks
local function ensure_palette()
	if state.palette then
		return
	end
	state.palette = {
		common.color("#ffffff"),
		style.syntax["keyword"] or style.notice or style.text,
		style.syntax["keyword2"] or style.warning or style.text,
		style.syntax["number"] or style.accent or style.text,
		style.syntax["operator"] or style.success or style.text,
		style.syntax["string"] or style.remark or style.text,
		style.syntax["function"] or style.text,
	}
end

-- Returns palette color for curve index with wraparound
local function curve_color(i)
	ensure_palette()
	return state.palette[((i - 1) % #state.palette) + 1]
end

-- Returns parsed data for doc using change-id based cache invalidation
local function get_cached_data(doc)
	local entry = state.cache[doc]
	local change_id = doc:get_change_id()
	if not entry or entry.change_id ~= change_id then
		entry = {
			change_id = change_id,
			data = parse_doc_points(doc),
		}
		state.cache[doc] = entry
	end
	return entry.data
end

-- Returns true when doc name ends with .2d
local function is_2d_file(doc)
	local name = doc and doc.get_name and doc:get_name() or ""
	-- Pattern breakdown:
	-- %.2d matches literal '.2d'
	-- $ anchors match to end of string
	return type(name) == "string" and name:lower():match("%.2d$") ~= nil
end

-- Overlay is enabled for auto-detected .2d files or explicitly toggled docs
local function is_overlay_enabled(doc)
	local enabled = state.enabled_docs[doc]
	if enabled ~= nil then
		return enabled
	end

	return is_2d_file(doc)
end

-- Draws the plot panel and curves into the current doc view
local function draw_overlay(dv)
	local doc = dv.doc
	if not doc or not is_overlay_enabled(doc) then
		return
	end

	local d = get_cached_data(doc)
	local vx, vy = dv.position.x, dv.position.y
	local vw, vh = dv.size.x, dv.size.y
	local pad = math.floor(8 * SCALE + 0.5)
	local desired_ow = math.max(220, math.floor(vw * 0.38))
	local available_height = math.max(0, math.floor(vh - pad * 2))
	if available_height < 20 then
		return
	end
	local oh = math.min(math.max(160, math.floor(vh * 0.34)), available_height)
	local right_limit = dv.get_overlay_right_lane_limit and dv:get_overlay_right_lane_limit() or (vx + vw)
	local max_ow = right_limit - (vx + pad) - pad
	if max_ow < 120 then
		return
	end
	local ow = math.min(desired_ow, max_ow)
	local ox = right_limit - ow - pad
	local oy = vy + pad

	-- Pane clip keeps overlay drawing inside split layout
	core.push_clip_rect(vx, vy, vw, vh)
	local panel_bg = style.background2 or style.background
	if style.transparent then
		panel_bg = style.transparent(panel_bg, 0.10)
	end
	renderer.draw_rect(ox, oy, ow, oh, panel_bg)
	renderer.draw_rect(ox, oy, ow, 1, style.divider)
	renderer.draw_rect(ox, oy + oh - 1, ow, 1, style.divider)
	renderer.draw_rect(ox, oy, 1, oh, style.divider)
	renderer.draw_rect(ox + ow - 1, oy, 1, oh, style.divider)

	local tx = ox + style.padding.x
	local ty = oy + style.padding.y
	local lh = style.font:get_height()
	local curves_n = #d.curves
	renderer.draw_text(
		style.font,
		string.format("rows=%d curves=%d skipped=%d", d.rows or 0, curves_n, d.skipped or 0),
		tx,
		ty,
		style.text
	)

	local px = tx
	local py = ty + lh + style.padding.y
	local pw = ow - style.padding.x * 2
	local ph = oh - (py - oy) - style.padding.y

	if not d.min_x or not d.max_x or not d.min_y or not d.max_y or pw < 20 or ph < 20 then
		common.draw_text(style.font, style.dim, "No plottable data", "center", px, py, pw, ph)
		core.pop_clip_rect()
		return
	end

	local min_x, max_x = d.min_x, d.max_x
	local min_y, max_y = d.min_y, d.max_y
	if min_x == max_x then
		min_x = min_x - 1
		max_x = max_x + 1
	end
	if min_y == max_y then
		min_y = min_y - 1
		max_y = max_y + 1
	end

	local function sx(x)
		return px + (x - min_x) / (max_x - min_x) * pw
	end
	local function sx_stretch(idx, count)
		if count <= 1 then
			return px + pw * 0.5
		end
		return px + ((idx - 1) / (count - 1)) * pw
	end
	local function sy(y)
		return py + ph - (y - min_y) / (max_y - min_y) * ph
	end

	--- Reduce a dense source series while preserving both extremes in source order
	-- @param curve table source points
	-- @param bins number target horizontal bins
	-- @return table points reduced points
	local function build_stretched_series(curve, bins)
		local n = #curve
		if n == 0 then
			return {}
		end
		if bins < 2 then
			bins = 2
		end
		if n <= bins then
			-- Sparse data: keep original points and stretch them across full width
			return curve
		end
		-- Dense data keeps each bin's visible low and high points in their original order
		local out = {}
		for b = 1, bins do
			local i1 = math.floor((b - 1) * n / bins) + 1
			local i2 = math.floor(b * n / bins)
			if i2 < i1 then
				i2 = i1
			end
			local min_index = i1
			local max_index = i1
			for i = i1 + 1, i2 do
				if curve[i].y < curve[min_index].y then
					min_index = i
				end
				if curve[i].y > curve[max_index].y then
					max_index = i
				end
			end

			if min_index < max_index then
				out[#out + 1] = { y = curve[min_index].y }
				out[#out + 1] = { y = curve[max_index].y }
			elseif max_index < min_index then
				out[#out + 1] = { y = curve[max_index].y }
				out[#out + 1] = { y = curve[min_index].y }
			else
				out[#out + 1] = { y = curve[min_index].y }
			end
		end
		return out
	end

	renderer.draw_rect(px, py, pw, 1, style.divider)
	renderer.draw_rect(px, py + ph, pw, 1, style.divider)
	renderer.draw_rect(px, py, 1, ph, style.divider)
	renderer.draw_rect(px + pw, py, 1, ph + 1, style.divider)

	if false and min_x <= 0 and max_x >= 0 then
		local x0 = common.round(sx(0))
		renderer.draw_rect(x0, py, 1, ph, style.dim)
	end
	if min_y <= 0 and max_y >= 0 then
		local y0 = common.round(sy(0))
		renderer.draw_rect(px, y0, pw, 1, style.dim)
	end

	for i = 1, curves_n do
		local curve = d.curves[i]
		local color = curve_color(i)
		local stretch_points = config.plot2d_stretch_points ~= false
		local draw_curve = curve

		if stretch_points then
			local bins = math.max(2, math.floor(pw))
			draw_curve = build_stretched_series(curve, bins)
		end

		local draw_count = #draw_curve
		local points = {}

		for p = 1, draw_count do
			local point = draw_curve[p]
			local x = stretch_points and sx_stretch(p, draw_count) or sx(point.x)
			points[#points + 1] = x
			points[#points + 1] = sy(point.y)
		end

		if #points >= 4 then
			-- One native path keeps coverage and joins consistent across the complete curve
			renderer.draw_polyline(points, 1, color)
		end
	end

	core.pop_clip_rect()
end

-- Returns active doc view when focus is currently in a doc view
local function active_text_view()
	local av = core.get_active_view and core.get_active_view() or core.active_view
	if av and av.is and av:is(TextEditor) and av.doc then
		return av
	end
end

local original_text_editor_draw = TextEditor.draw

-- Preserves normal doc rendering and then draws optional 2d overlay panel
function TextEditor:draw(...)
	original_text_editor_draw(self, ...)
	draw_overlay(self)
end

command.register({
	-- Toggles overlay state for current doc and requests redraw
	["2dplot:open"] = {
		requires = { "doc" },
		predicate = active_text_view,
		perform = function()
			local av = active_text_view()
			if not av then
				return false
			end
			-- Toggle effective state so first click can disable automatic overlay
			state.enabled_docs[av.doc] = not is_overlay_enabled(av.doc)
			core.log("2dplot overlay %s for %s", state.enabled_docs[av.doc] and "enabled" or "disabled", av.doc:get_name())
			core.redraw = true
			return true
		end,
	},

	-- Alias command for consistency with other plugins
	["2dplot:toggle-overlay"] = {
		requires = { "doc" },
		predicate = active_text_view,
		perform = function()
			return command.perform("2dplot:open")
		end,
	},

	-- Invalidates parse cache for active doc and requests redraw
	["2dplot:refresh"] = {
		requires = { "doc" },
		predicate = active_text_view,
		perform = function()
			local av = active_text_view()
			if not av then
				return false
			end
			state.cache[av.doc] = nil
			core.redraw = true
			return true
		end,
	},
})
