--- Bounded project-wide text search and result navigation
-- @module plugins.projectsearch

local core = require("core")
local common = require("core.common")
local keymap = require("core.keymap")
local command = require("core.command")
local Doc = require("core.doc")
local style = require("core.style")
local text_search = require("core.text.search")
local View = require("core.ui.view")

local ResultsView = View:extend()
-- Bound both row objects and their repeated filename and line strings
local MAX_RESULT_COUNT = 10000
local MAX_RESULT_BYTES = 8 * 1024 * 1024
local MAX_SEARCH_LINE_BYTES = 256 * 1024
local SEARCH_READ_CHUNK_BYTES = 64 * 1024
local SEARCH_YIELD_BYTES = 256 * 1024
local SEARCH_YIELD_SECONDS = 0.005

-- Expose ResultsView as a plugin capability so dependent plugins can
-- gate commands with requires = { "@project_search_results" }
core.capabilities.provide("project_search_results", {
	matches = function(view)
		return view and view.is and view:is(ResultsView) or false
	end,
})

-- Creates a search-results view and starts asynchronous project search
function ResultsView:new(text, fn, search_error)
	ResultsView.super.new(self)
	self.scrollable = true
	self.brightness = 0
	self:begin_search(text, fn, search_error)
end

-- Returns tab title for the results view
function ResultsView:get_name()
	return "Search Results"
end

--- Return key binding contexts for the project-search results surface
-- Result navigation belongs only to the focused results view
-- @return table contexts ordered from most specific to broadest
function ResultsView:get_keymap_contexts()
	return { "project_search", "global" }
end

--- Scan one file and retain matches within the view's shared limits
-- @param view table search results view
-- @param filename string file to scan
-- @param fn function line matcher
-- @return boolean complete
local function find_all_matches_in_file(view, filename, fn)
	local fp = io.open(filename)
	if not fp then
		return true
	end
	local ok, complete_or_error = pcall(function()
		local n = 1
		local line = ""
		local oversized = false
		local bytes_since_yield = 0
		local slice_started = system.get_time()

		--- Yield after bounded file-read work
		-- @param bytes integer newly consumed bytes
		local function account_read_bytes(bytes)
			bytes_since_yield = bytes_since_yield + bytes
			if bytes_since_yield >= SEARCH_YIELD_BYTES
				or system.get_time() - slice_started >= SEARCH_YIELD_SECONDS
			then
				coroutine.yield()
				bytes_since_yield = 0
				slice_started = system.get_time()
			end
		end

		--- Match one complete bounded line and retain its result
		-- @param complete_line string line text without its line ending
		-- @return boolean complete whether scanning may continue
		local function process_line(complete_line)
			local s
			s = fn(complete_line)

			if s then
				local result_bytes = #filename + #complete_line

				if #view.results >= MAX_RESULT_COUNT
					or result_bytes > MAX_RESULT_BYTES - view.result_bytes
				then
					view.results_truncated = true

					return false
				end

				view.results[#view.results + 1] = { file = filename, text = complete_line, line = n, col = s }
				view.result_bytes = view.result_bytes + result_bytes
				core.redraw = true
			end

			n = n + 1
			core.redraw = true

			return true
		end

		while true do
			local chunk = fp:read(SEARCH_READ_CHUNK_BYTES)
			if not chunk then
				break
			end

			local segment_start = 1
			while segment_start <= #chunk do
				local newline = chunk:find("\n", segment_start, true)
				local segment_end = newline and newline - 1 or #chunk
				local segment = chunk:sub(segment_start, segment_end)

				if not oversized then
					local remaining = MAX_SEARCH_LINE_BYTES - #line
					if #segment <= remaining then
						line = line .. segment
						view.peak_line_buffer_bytes = math.max(view.peak_line_buffer_bytes, #line)
					else
						line = ""
						oversized = true
						view.skipped_oversized_lines = view.skipped_oversized_lines + 1
					end
				end

				if newline then
					if not oversized then
						line = line:sub(-1) == "\r" and line:sub(1, -2) or line
						if not process_line(line) then
							return false
						end
					else
						n = n + 1
					end

					line = ""
					oversized = false
					segment_start = newline + 1
				else
					break
				end
			end

			account_read_bytes(#chunk)
		end

		if oversized then
			n = n + 1
		elseif #line > 0 and not process_line(line) then
			return false
		end

		return true
	end)

	fp:close()

	if not ok then
		error(complete_or_error, 0)
	end

	return complete_or_error
end

--- Start a full project search and stream bounded matches into this view
-- @param text string visible query
-- @param fn function line matcher
-- @param search_error string|nil existing validation error
function ResultsView:begin_search(text, fn, search_error)
	-- A stable view key lets refresh cancel the previous worker before resetting state
	core.remove_coroutine(self)
	self.search_args = { text, fn, search_error }
	self.results = {}
	self.result_bytes = 0
	self.results_truncated = false
	self.skipped_oversized_lines = 0
	self.peak_line_buffer_bytes = 0
	self.last_file_idx = 0
	self.query = text
	self.search_error = search_error
	self.selected_idx = 0
	local files = {}

	for index, file in ipairs(core.project_files) do
		files[index] = file
	end

	self.total_files = #files
	self.searching = self.total_files > 0 and not self.search_error

	if not self.searching then
		self.brightness = 100
		self.scroll.to.y = 0
		core.redraw = true

		return
	end

	-- Background worker that iterates project files and collects matches
	core.add_coroutine(function()
		local ok, worker_error = pcall(function()
			for i, file in ipairs(files) do
				local complete = true

				if file.type == "file" then
					complete = find_all_matches_in_file(self, file.filename, fn)
				end

				self.last_file_idx = i

				if not complete then
					break
				end
			end
		end)

		if not ok then
			self.search_error = tostring(worker_error)
		end

		self.searching = false
		self.brightness = 100
		core.redraw = true
	end, self)

	self.scroll.to.y = 0
end

-- Re-runs the previous query with stored search arguments
function ResultsView:refresh()
	self:begin_search(table.unpack(self.search_args))
end

-- Updates hovered result selection from mouse position
function ResultsView:on_mouse_move(mx, my, ...)
	ResultsView.super.on_mouse_move(self, mx, my, ...)
	self.selected_idx = 0
	for i, item, x, y, w, h in self:each_visible_result() do
		if mx >= x and my >= y and mx < x + w and my < y + h then
			self.selected_idx = i
			break
		end
	end
end

-- Opens selected result when clicking in the results view
function ResultsView:on_mouse_down(...)
	local caught = ResultsView.super.on_mouse_down(self, ...)
	if not caught then
		self:open_selected_result()
	end
end

-- Opens selected match location in an editor and scrolls to it
function ResultsView:open_selected_result()
	local res = self.results[self.selected_idx]
	if not res then
		return
	end
	-- Guards file-open navigation so failures are surfaced through core.try
	core.try(function()
		local dv = core.workspace_view:open_doc(Doc.open_file(res.file))
		if not dv then
			return
		end
		core.workspace_view.root_node:update_layout()
		dv.doc:set_selection(res.line, res.col)
		dv:scroll_to_line(res.line, false, true)
	end)
end

-- Updates highlight animation state for status/header rendering
function ResultsView:update()
	self:move_towards("brightness", 0, 0.1)
	ResultsView.super.update(self)
end

-- Returns vertical offset where result rows begin
function ResultsView:get_results_yoffset()
	return style.font:get_height() + style.padding.y * 3
end

-- Returns per-result row height
function ResultsView:get_line_height()
	return style.padding.y + style.font:get_height()
end

-- Returns total scrollable content height for this view
function ResultsView:get_scrollable_size()
	return self:get_results_yoffset() + #self.results * self:get_line_height()
end

-- Computes index range of results currently visible in viewport
function ResultsView:get_visible_results_range()
	local lh = self:get_line_height()
	local oy = self:get_results_yoffset()
	local min = math.max(1, math.floor((self.scroll.y - oy) / lh))
	return min, min + math.floor(self.size.y / lh) + 1
end

-- Iterates visible result rows with layout geometry for drawing/hit-testing
function ResultsView:each_visible_result()
	-- Produces a coroutine iterator over visible rows only
	return coroutine.wrap(function()
		local lh = self:get_line_height()
		local x, y = self:get_content_offset()
		local min, max = self:get_visible_results_range()
		y = y + self:get_results_yoffset() + lh * (min - 1)
		for i = min, max do
			local item = self.results[i]
			if not item then
				break
			end
			coroutine.yield(i, item, x, y, self.size.x, lh)
			y = y + lh
		end
	end)
end

-- Adjusts scroll target so selected row remains in view
function ResultsView:scroll_to_make_selected_visible()
	local h = self:get_line_height()
	local y = self:get_results_yoffset() + h * (self.selected_idx - 1)
	self.scroll.to.y = math.min(self.scroll.to.y, y)
	self.scroll.to.y = math.max(self.scroll.to.y, y + h - self.size.y)
end

--- Return the current search progress or result summary
-- @return string text
function ResultsView:get_status_text()
	local progress = self.total_files > 0 and self.last_file_idx / self.total_files or 0

	if self.search_error then
		return string.format("Search failed for %q: %s", self.query, self.search_error)
	elseif self.searching then
		return string.format(
			"Searching %d%% (%d of %d files, %d matches) for %q...",
			progress * 100,
			self.last_file_idx,
			self.total_files,
			#self.results,
			self.query
		)
	elseif self.results_truncated then
		return string.format(
			"Showing first %d matches (limit reached) for %q",
			#self.results,
			self.query
		)
	elseif self.skipped_oversized_lines > 0 then
		return string.format(
			"Found %d matches for %q (%d oversized lines skipped)",
			#self.results,
			self.query,
			self.skipped_oversized_lines
		)
	end

	return string.format("Found %d matches for %q", #self.results, self.query)
end

--- Render search status, progress, and visible result rows
function ResultsView:draw()
	self:draw_background(style.background)

	-- status
	local ox, oy = self:get_content_offset()
	local x, y = ox + style.padding.x, oy + style.padding.y
	local per = self.total_files > 0 and self.last_file_idx / self.total_files or 0
	local text = self:get_status_text()
	local color = common.lerp(style.text, style.accent, self.brightness / 100)
	renderer.draw_text(style.font, text, x, y, color)

	-- horizontal line
	local yoffset = self:get_results_yoffset()
	local x = ox + style.padding.x
	local w = self.size.x - style.padding.x * 2
	local h = style.divider_size
	local color = common.lerp(style.dim, style.text, self.brightness / 100)
	renderer.draw_rect(x, oy + yoffset - style.padding.y, w, h, color)
	if self.searching then
		renderer.draw_rect(x, oy + yoffset - style.padding.y, w * per, h, style.text)
	end

	-- results
	local y1, y2 = self.position.y, self.position.y + self.size.y
	for i, item, x, y, w, h in self:each_visible_result() do
		local color = style.text
		if i == self.selected_idx then
			color = style.accent
			renderer.draw_rect(x, y, w, h, style.line_highlight)
		end
		x = x + style.padding.x
		local text = string.format("%s at line %d (col %d): ", item.file, item.line, item.col)
		x = common.draw_text(style.font, style.dim, text, "left", x, y, w, h)
		x = common.draw_text(style.code_font, color, item.text, "left", x, y, w, h)
	end

	self:draw_scrollbar()
end

-- Creates and attaches a new results view for a non-empty query
local function begin_search(text, fn, search_error)
	if text == "" then
		core.error("Expected non-empty string")
		return
	end
	local rv = ResultsView(text, fn, search_error)
	core.workspace_view:get_active_node():add_view(rv)
end

command.register({
	-- Prompts for plain text and searches project files case-insensitively
	["project-search:find"] = function()
		-- Collects query text for plain-text project search
		core.command_view:enter({
			caption = "Find Text In Project",
			submit = function(text)
				-- Report the first original byte column after one shared Unicode query fold
				begin_search(text, text_search.plain_first_matcher(text, true))
			end,
		})
	end,

	-- Prompts for Lua pattern and searches project files using pattern matching
	["project-search:find-pattern"] = function()
		-- Collects query text for pattern-based project search
		core.command_view:enter({
			caption = "Find Pattern In Project",
			submit = function(text)
				local valid, pattern_error = pcall(string.find, "", text)

				if not valid then
					begin_search(text, nil, "Invalid Lua pattern: " .. tostring(pattern_error))

					return
				end

				-- Finds first Lua-pattern match position in each line
				begin_search(text, function(line_text)
					return line_text:find(text)
				end)
			end,
		})
	end,

	-- Prompts for fuzzy query and searches project files by fuzzy line match
	["project-search:fuzzy-find"] = function()
		-- Collects query text for fuzzy project search
		core.command_view:enter({
			caption = "Fuzzy Find Text In Project",
			submit = function(text)
				-- Marks fuzzy matches with a synthetic column for result navigation
				begin_search(text, function(line_text)
					return common.fuzzy_match(line_text, text) and 1
				end)
			end,
		})
	end,
})

command.register({
	-- Selects the previous result row
	["project-search:select-previous"] = {
		requires = { "@project_search_results" },
		perform = function()
		local view = core.active_view
		view.selected_idx = math.max(view.selected_idx - 1, 1)
		view:scroll_to_make_selected_visible()
		end,
	},

	-- Selects the next result row
	["project-search:select-next"] = {
		requires = { "@project_search_results" },
		perform = function()
		local view = core.active_view
		view.selected_idx = math.min(view.selected_idx + 1, #view.results)
		view:scroll_to_make_selected_visible()
		end,
	},

	-- Opens the currently selected result
	["project-search:open-selected"] = {
		requires = { "@project_search_results" },
		perform = function()
		core.active_view:open_selected_result()
		end,
	},

	-- Re-runs the current results view search
	["project-search:refresh"] = {
		requires = { "@project_search_results" },
		perform = function()
		core.active_view:refresh()
		end,
	},
})

keymap.bind("plugin", {
	global = {
		["ctrl+shift+f"] = "project-search:find",
	},
	project_search = {
		["f5"] = "project-search:refresh",
		["up"] = "project-search:select-previous",
		["down"] = "project-search:select-next",
		["return"] = "project-search:open-selected",
	},
})
