-- 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.

local core = require("core")
local common = require("core.common")
local Doc = require("core.doc")
local style = require("core.style")
local View = require("core.ui.view")

local LogView = View:extend()

local function get_log_font()
	-- Resolve log font dynamically so post-user font reloads take effect
	return style.code_font
end

-- Returns index of the newest error item matching an optional predicate
local function get_latest_error_index(predicate)
	for i = #core.log_items, 1, -1 do
		local item = core.log_items[i]
		if item and item.level == "error" and (not predicate or predicate(item)) then
			return i
		end
	end
	return nil
end

-- Formats log timestamp as 'YYmmdd HHiiss,mmm'
local function format_log_time(item)
	local sec = tonumber(item and item.time) or os.time()
	local msec = tonumber(item and item.msec) or 0
	if msec < 0 then
		msec = 0
	end
	if msec > 999 then
		msec = 999
	end
	--return string.format("%s,%03d", os.date("%y/%m/%d %H:%M:%S", sec), msec)
	return string.format("%s.%03d", os.date("%H:%M:%S", sec), msec)
end

-- Initializes log view state and scroll behavior
function LogView:new()
	LogView.super.new(self)
	self.last_item = core.log_items[#core.log_items]
	self.scrollable = true
	self.yoffset = 0
	self.hovered_item = nil
	self.follow_latest = true
	self.pinned_item_id = nil
	self.scroll_item_id = nil
	self.item_layouts = {}
	self.item_layout_count = 0
end

-- Returns the tab title for the log view
function LogView:get_name()
	return "Log"
end

--- Return key binding contexts for the log surface
-- Log navigation must not replace arrow and paging keys in unrelated views
-- @return table contexts ordered from most specific to broadest
function LogView:get_keymap_contexts()
	return { "log_view", "global" }
end

--- Append word-wrapped rows while splitting words wider than the view
-- @param rows table destination rows
-- @param font userdata log font
-- @param text string source text
-- @param width number available row width
-- @param dim boolean use metadata color
local function append_wrapped_rows(rows, font, text, width, dim)
	-- Explicit newlines remain row boundaries while ordinary spacing becomes one readable gap
	for line in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do
		-- Keep leading spaces and tabs as visual indentation
		local indent = line:match("^[ \t]*") or ""
		local row = indent
		local has_text = false
		for word in line:gmatch("%S+") do
			local candidate = row .. (has_text and " " or "") .. word
			if font:get_width(candidate) <= width then
				row = candidate
				has_text = true
			else
				if has_text then
					rows[#rows + 1] = { text = row, dim = dim }
					row = indent
					has_text = false
				end

				candidate = row .. word
				if font:get_width(candidate) <= width then
					row = candidate
					has_text = true
				else
					-- Long paths and encoded values still wrap when they contain no spaces
					for character in common.utf8_chars(word) do
						candidate = row .. character
						if has_text and font:get_width(candidate) > width then
							rows[#rows + 1] = { text = row, dim = dim }
							row = indent .. character
						else
							row = candidate
						end
						has_text = true
					end
				end
			end
		end
		rows[#rows + 1] = { text = row, dim = dim }
	end
end

--- Refresh width-sensitive layout state and return current measurements
-- @return userdata font
-- @return number row_width
-- @return number timestamp_width
local function get_layout_context(view)
	local font = get_log_font()
	local timestamp_width = font:get_width("00:00:00.000")
	local row_width = math.max(
		font:get_width("MMMMMMMM"),
		view.size.x - timestamp_width - style.padding.x * 4 - (style.scrollbar_size or 0)
	)
	local key = table.concat({
		tostring(font),
		tostring(font:get_height()),
		tostring(timestamp_width),
		tostring(row_width),
		tostring(style.padding.x),
		tostring(style.padding.y),
	}, ":")

	if view.layout_key ~= key then
		view.layout_key = key
		view.item_layouts = {}
		view.item_layout_count = 0
		view.layout_changed = true
	end

	return font, row_width, timestamp_width
end

--- Return one cached wrapped item layout
-- @param view core.log.view
-- @param item table log item
-- @param font userdata log font
-- @param row_width number available row width
-- @return table layout
local function get_item_layout(view, item, font, row_width)
	local layout = view.item_layouts[item]
	if layout then
		return layout
	end

	layout = { rows = {}, time = format_log_time(item) }
	append_wrapped_rows(layout.rows, font, item and item.text, row_width, false)
	if item and item.hide_source ~= true then
		append_wrapped_rows(layout.rows, font, "at " .. tostring(item.at or "?"), row_width, true)
	end
	if item and item.info then
		append_wrapped_rows(layout.rows, font, item.info, row_width, true)
	end
	layout.height = #layout.rows * font:get_height() + style.padding.y
	view.item_layouts[item] = layout
	view.item_layout_count = view.item_layout_count + 1
	if view.item_layout_count > math.max(1000, #core.log_items * 2) then
		-- Old removed entries must not leave the view cache growing forever
		view.item_layouts = { [item] = layout }
		view.item_layout_count = 1
	end

	return layout
end

-- Returns the full scrollable content height for keyboard/page navigation
function LogView:get_scrollable_size()
	local font, row_width = get_layout_context(self)
	local h = style.padding.y
	for i = #core.log_items, 1, -1 do
		h = h + get_item_layout(self, core.log_items[i], font, row_width).height
	end
	return math.max(self.size.y, h)
end

-- Returns the log item under a mouse point, or nil when no item is hit
function LogView:get_item_at(px, py)
	local x1, y1 = self.position.x, self.position.y
	local x2, y2 = x1 + self.size.x, y1 + self.size.y
	if px < x1 or px >= x2 or py < y1 or py >= y2 then
		return nil
	end

	local font, row_width = get_layout_context(self)
	local _, oy = self:get_content_offset()
	local y = oy + style.padding.y + self.yoffset

	for i = #core.log_items, 1, -1 do
		local item = core.log_items[i]
		local top = y
		local bottom = y + get_item_layout(self, item, font, row_width).height
		if py >= top and py < bottom then
			return item, i
		end
		y = bottom
	end
	return nil, nil
end

--- Format one log item as clipboard-friendly plain text
-- @param item table|nil log item
-- @return string text
local function format_item_for_clipboard(item)
	if not item then
		return ""
	end
	local out = {}
	out[#out + 1] = string.format("[%s] %s", format_log_time(item), tostring(item.text or ""))
	if item.hide_source ~= true then
		out[#out + 1] = "at " .. tostring(item.at or "?")
	end
	if item.info and item.info ~= "" then
		out[#out + 1] = tostring(item.info)
	end
	local text = table.concat(out, "\n")
	-- Ensure copied entry always ends with a linefeed
	if text ~= "" and text:sub(-1) ~= "\n" then
		text = text .. "\n"
	end
	return text
end

-- Normalize candidate path and resolve absolute path when possible
local function resolve_openable_path(path)
	if type(path) ~= "string" then
		return nil
	end

	local p = path:gsub("^%s+", ""):gsub("%s+$", "")

	if p == "" then
		return nil
	end

	p = p:gsub('^["\']', ""):gsub('["\']$', "")

	if p == "" then
		return nil
	end

	local abs = system.absolute_path(p) or p

	if system.is_file(abs) == true then
		return abs
	end

	return nil
end

-- Open file reference in the editor and optionally jump to line/column
local function open_file_reference(path, line, col)
	local target = resolve_openable_path(path)

	if not target then
		return false
	end

	local doc = Doc.open_file(target)

	if not doc then
		return false
	end

	local view = core.workspace_view:open_doc(doc)

	if view then
		core.set_active_view(view)
	end

	if line then
		local target_line = math.max(1, math.floor(tonumber(line) or 1))
		local target_col = math.max(1, math.floor(tonumber(col) or 1))

		if type(doc.lines) == "table" and #doc.lines > 0 then
			target_line = math.min(target_line, #doc.lines)
		end

		if type(doc.set_selection) == "function" then
			doc:set_selection(target_line, target_col)
		end

		if view and type(view.scroll_to_line) == "function" then
			view:scroll_to_line(target_line, false, true)
		end
	end

	return true
end

-- Try to open the first file reference found in log item text/info/at
local function try_open_log_item_reference(item)
	if type(item) ~= "table" then
		return false
	end

	local chunks = {}

	if type(item.text) == "string" and item.text ~= "" then
		chunks[#chunks + 1] = item.text
	end

	if type(item.info) == "string" and item.info ~= "" then
		chunks[#chunks + 1] = item.info
	end

	if type(item.at) == "string" and item.at ~= "" then
		chunks[#chunks + 1] = item.at
	end

	for _, text in ipairs(chunks) do
		-- Pattern breakdown:
		-- ([%a]:[\\/][^:\n]+) captures absolute Windows path C:\... or C:/...
		-- :(%d+):(%d+):? captures line and column
		for path, line, col in text:gmatch("([%a]:[\\/][^:\n]+):(%d+):(%d+):?") do
			if open_file_reference(path, line, col) then
				return true
			end
		end

		-- Pattern breakdown:
		-- ([%a]:[\\/][^:\n]+) captures absolute Windows path
		-- :(%d+):? captures line number
		for path, line in text:gmatch("([%a]:[\\/][^:\n]+):(%d+):?") do
			if open_file_reference(path, line, 1) then
				return true
			end
		end

		-- Pattern breakdown:
		-- ([\\/][^:\n]+) captures absolute Unix-like path
		-- :(%d+):(%d+):? captures line and column
		for path, line, col in text:gmatch("([\\/][^:\n]+):(%d+):(%d+):?") do
			if open_file_reference(path, line, col) then
				return true
			end
		end

		-- Pattern breakdown:
		-- ([\\/][^:\n]+) captures absolute Unix-like path
		-- :(%d+):? captures line number
		for path, line in text:gmatch("([\\/][^:\n]+):(%d+):?") do
			if open_file_reference(path, line, 1) then
				return true
			end
		end

		-- Pattern breakdown:
		-- ([%w%._%-]+[\\/][^:\n]+) captures relative path with at least one slash
		-- :(%d+):(%d+):? captures line and column
		for path, line, col in text:gmatch("([%w%._%-]+[\\/][^:\n]+):(%d+):(%d+):?") do
			if open_file_reference(path, line, col) then
				return true
			end
		end

		-- Pattern breakdown:
		-- ([%w%._%-]+[\\/][^:\n]+) captures relative path with at least one slash
		-- :(%d+):? captures line number
		for path, line in text:gmatch("([%w%._%-]+[\\/][^:\n]+):(%d+):?") do
			if open_file_reference(path, line, 1) then
				return true
			end
		end

		-- Pattern breakdown:
		-- ['\"]([^'\"]+)['\"] captures quoted path-like values
		for quoted in text:gmatch("['\"]([^'\"]+)['\"]") do
			if open_file_reference(quoted) then
				return true
			end
		end

		-- Pattern breakdown:
		-- ([%w%._%-]+[\\/][^%s]+) captures path-like tokens with at least one separator
		for token in text:gmatch("([%w%._%-]+[\\/][^%s]+)") do
			if open_file_reference(token) then
				return true
			end
		end
	end

	return false
end

-- Updates hover target and cursor when moving over copyable log entries
function LogView:on_mouse_move(x, y, dx, dy)
	LogView.super.on_mouse_move(self, x, y, dx, dy)
	local item = select(1, self:get_item_at(x, y))
	self.hovered_item = item
	self.mouse_cursor = (item and not self:scrollbar_overlaps_point(x, y)) and "hand" or "arrow"
end

-- Copies clicked log entry text to clipboard while preserving scrollbar behavior
function LogView:on_mouse_down(button, x, y, clicks)
	local caught = LogView.super.on_mouse_down(self, button, x, y, clicks)
	if caught then
		-- Manual scrollbar movement releases an automatic error target
		self.pinned_item_id = nil
		self.scroll_item_id = nil
		return true
	end

	if button == "middle" then
		local item = select(1, self:get_item_at(x, y))
		if not item then
			return false
		end

		if try_open_log_item_reference(item) then
			return true
		end

		if core.status_view then
			core.status_view:show_warning("No file reference found in log entry")
		end
		return true
	end

	if button ~= "left" then
		return false
	end

	local item = select(1, self:get_item_at(x, y))
	if not item then
		return false
	end

	system.set_clipboard(format_item_for_clipboard(item))
	if core.status_view then
		core.status_view:show_success("Copied log entry")
	end
	return true
end

-- Resets scroll when new log items arrive and advances view animation state
function LogView:update()
	local item = core.log_items[#core.log_items]
	if self.last_item ~= item then
		self.last_item = item
		if self.pinned_item_id then
			local idx = self:get_item_index_by_id(self.pinned_item_id)
			if idx then
				self:scroll_to_item(idx)
			else
				self.pinned_item_id = nil
			end
		elseif self.follow_latest then
			self.scroll.to.y = 0
			self.yoffset = -(get_log_font():get_height() + style.padding.y)
		end
	end

	self:move_towards("yoffset", 0)

	LogView.super.update(self)

	if self.layout_changed then
		self.layout_changed = false
		local idx = self:get_item_index_by_id(self.scroll_item_id)
		if idx then
			-- Width and font changes must keep the requested error at the same place
			self:scroll_to_item(idx)
		end
	end
end

--- Scroll with the mouse wheel and release any automatic error target
-- @param ... any wheel event arguments
function LogView:on_mouse_wheel(...)
	self.pinned_item_id = nil
	self.scroll_item_id = nil

	return LogView.super.on_mouse_wheel(self, ...)
end

-- Scrolls by N rendered text rows
function LogView:scroll_rows(n)
	self.pinned_item_id = nil
	self.scroll_item_id = nil
	self.follow_latest = false
	local step = math.max(1, get_log_font():get_height())
	self.scroll.to.y = self.scroll.to.y + (step * n)
end

-- Scrolls by roughly one viewport page
function LogView:scroll_page(n)
	self.pinned_item_id = nil
	self.scroll_item_id = nil
	self.follow_latest = false
	local step = math.max(1, self.size.y - get_log_font():get_height())
	self.scroll.to.y = self.scroll.to.y + (step * n)
end

-- Jumps to newest entries at top
function LogView:scroll_home()
	self.pinned_item_id = nil
	self.scroll_item_id = nil
	self.follow_latest = true
	self.scroll.to.y = 0
end

-- Jumps to oldest entries at bottom
function LogView:scroll_end()
	self.pinned_item_id = nil
	self.scroll_item_id = nil
	self.follow_latest = false
	local max = math.max(0, self:get_scrollable_size() - self.size.y)
	self.scroll.to.y = max
end

-- Returns index of newest error-level log item, or nil when no errors exist
function LogView:get_latest_error_index()
	return get_latest_error_index()
end

-- Returns index of newest startup-tagged error log item, or nil when absent
function LogView:get_latest_startup_error_index()
	return get_latest_error_index(function(item)
		return item.startup_error == true
	end)
end

-- Returns current index for a specific log item ID
function LogView:get_item_index_by_id(target_id)
	if type(target_id) ~= "number" then
		return nil
	end
	for i = #core.log_items, 1, -1 do
		if core.log_items[i] and core.log_items[i].id == target_id then
			return i
		end
	end
	return nil
end

-- Scrolls so the target item is visible at the top of the log viewport
function LogView:scroll_to_item(index)
	if type(index) ~= "number" then
		return false
	end
	index = math.floor(index)
	if index < 1 or index > #core.log_items then
		return false
	end
	local font, row_width = get_layout_context(self)
	local offset = 0
	for i = #core.log_items, index + 1, -1 do
		offset = offset + get_item_layout(self, core.log_items[i], font, row_width).height
	end
	self.yoffset = 0
	-- Place the focused item below the top edge (~1/3 down) for better context
	local anchor_y = self:get_scroll_viewport_height() / 3
	local target = style.padding.y + offset - anchor_y
	self.follow_latest = false
	self.scroll_item_id = core.log_items[index].id
	self.scroll.to.y = target
	self.scroll.y = target
	self:clamp_scroll_position()
	return true
end

-- Pins a specific item ID and keeps it visible even as newer log entries arrive
function LogView:pin_item_by_id(target_id)
	local idx = self:get_item_index_by_id(target_id)
	if not idx then
		return false
	end
	self.pinned_item_id = target_id
	return self:scroll_to_item(idx)
end

-- Renders log entries from newest to oldest with timestamps and metadata
function LogView:draw()
	local font, row_width, timestamp_width = get_layout_context(self)
	self:draw_background(style.background)

	local ox, oy = self:get_content_offset()
	local th = font:get_height()
	local y = oy + style.padding.y + self.yoffset
	local viewport_top = self.position.y
	local viewport_bottom = viewport_top + self:get_scroll_viewport_height()
	local text_x = ox + style.padding.x * 2 + timestamp_width

	for i = #core.log_items, 1, -1 do
		local item = core.log_items[i]
		local layout = get_item_layout(self, item, font, row_width)
		local item_bottom = y + layout.height
		local text_color = style.text -- Default log entry text color
		if item.level == "error" then
			text_color = style.error or style.text
		elseif item.level == "warning" then
			text_color = style.warning or style.error or style.text
		elseif item.level == "notice" then
			text_color = style.notice or style.text
		elseif item.level == "success" then
			text_color = style.success or style.text
		end
		if item_bottom >= viewport_top and y <= viewport_bottom then
			if item == self.hovered_item then
				renderer.draw_rect(ox, y, self.size.x, layout.height, style.line_highlight)
			end
			renderer.draw_text(font, layout.time, ox + style.padding.x, y, style.dim)
			for _, row in ipairs(layout.rows) do
				renderer.draw_text(font, row.text, text_x, y, row.dim and style.dim or text_color)
				y = y + th
			end
			y = y + style.padding.y
		else
			y = item_bottom
		end

		-- Older entries only move farther below the visible viewport
		if y > viewport_bottom then
			break
		end
	end

	self:draw_scrollbar()
end

return LogView
