---@diagnostic disable: inject-field
-- lx-bundle:true

local core = require("core")
local common = require("core.common")
local command = require("core.command")
local config = require("core.config")
local Doc = require("core.doc")
local keymap = require("core.keymap")
local project = require("core.project")
local style = require("core.style")
local View = require("core.ui.view")

config.treeview_size = 200 * SCALE
config.treeview_init_visible = false -- liqube
config.treeview_reuse_single_tab = config.treeview_reuse_single_tab ~= false

-- Returns path depth by counting slash/backslash separators
local function get_depth(filename)
	local n = 0
	for sep in filename:gmatch("[\\/]") do
		n = n + 1
	end
	return n
end

local TreeView = View:extend()

-- Logs treeview root/path details only when treeview debug logging is enabled
local function root_debug(fmt, ...)
	if config.debug_treeview ~= true then
		return
	end
	core.log_quiet("[treeview-root] " .. fmt, ...)
end

-- Normalizes a path string for stable prefix/equality checks
local function normalize_path(path)
	if type(path) ~= "string" or path == "" then
		return nil
	end
	local ap = system.absolute_path(path) or path
	ap = ap:gsub("\\", "/"):gsub("/+", "/")
	if #ap > 1 and not ap:match("^%a:/$") then
		ap = ap:gsub("/$", "")
	end
	if ap:match("^%a:$") then
		ap = ap .. "/"
	end
	if ap:match("^%a:/") then
		ap = ap:lower()
	end
	return ap
end

-- Returns directory part of a normalized path
local function path_dirname(path)
	if type(path) ~= "string" then
		return nil
	end
	if path:match("^%a:/$") or path:match("^%a:$") then
		return normalize_path(path)
	end
	local dir = path:match("^(.*)/[^/]*$")
	if not dir then
		return nil
	end
	if dir:match("^%a:$") then
		dir = dir .. "/"
	end
	return normalize_path(dir)
end

-- Returns true when path points to a filesystem root (e.g. C:/ or /)
local function is_root_directory(path)
	local p = normalize_path(path)
	if not p then
		return false
	end
	return p == "/" or p:match("^%a:/$") ~= nil
end

-- Initializes tree view state and per-file cache
function TreeView:new()
	TreeView.super.new(self)
	self.scrollable = true
	self.visible = config.treeview_init_visible == true
	self.init_size = true
	self.cache = {}
	self.doc_root = nil
	self.scanned_root = nil
	self.root_items = {}
	self.last_scan_time = 0
	self.next_scan_time = 0
	self.scan_interval = 0.6
	self.visible_items = nil
	self.preview_view = nil
	self.selected_item = nil
end

--- Return key binding contexts for the project tree
-- Tree navigation belongs to the focused tree surface and should not intercept document keys globally
-- @return table contexts ordered from most specific to broadest
function TreeView:get_keymap_contexts()
	return { "treeview", "global" }
end

-- Returns best-effort current document filename for tree synchronization
function TreeView:get_current_doc_filename()
	local function from_view(v)
		local d = v and v.doc
		return d and d.filename or nil
	end
	return from_view(core.active_view) or from_view(core.last_active_view)
end

-- Returns the active document directory (absolute, normalized) when available
function TreeView:get_active_base_dir()
	local function doc_filename_from_view(v)
		local d = v and v.doc
		if d and d.filename then
			return d.filename
		end
	end

	local filename = doc_filename_from_view(core.active_view)
		or doc_filename_from_view(core.last_active_view)
		or doc_filename_from_view(
			core.workspace_view and core.workspace_view:get_active_node() and core.workspace_view:get_active_node().active_view
		)

	local ap = normalize_path(filename)
	root_debug("active filename=%s normalized=%s", tostring(filename), tostring(ap))
	if not ap then
		return nil
	end
	local base = path_dirname(ap)
	root_debug("base dirname=%s", tostring(base))
	return base
end

-- Scans one directory into rows; recursion is optional
local function scan_dir(path, out, recursive)
	local all = system.list_dir(path) or {}
	local dirs, files = {}, {}
	for i = 1, #all do
		local name = all[i]
		if not common.match_pattern(name, config.ignore_files) then
			local full = path .. PATHSEP .. name
			local info = system.get_file_info(full)

			if info then
				info.filename = full
				if info.type == "dir" then
					dirs[#dirs + 1] = info
				else
					files[#files + 1] = info
				end
			end
		end
	end

	table.sort(dirs, function(a, b)
		return a.filename < b.filename
	end)
	table.sort(files, function(a, b)
		return a.filename < b.filename
	end)

	if recursive ~= false then
		for i = 1, #dirs do
			out[#out + 1] = dirs[i]
			scan_dir(dirs[i].filename, out, true)
		end
	end
	for i = 1, #files do
		out[#out + 1] = files[i]
	end
end

-- Refreshes the tree source to current-doc base dir
function TreeView:refresh_doc_root_files()
	if not self.visible then
		return
	end
	local root = self:get_active_base_dir()
	local now = system.get_time()

	if config.treeview_scan_once and root and root == self.scanned_root then
		root_debug("refresh skipped: scan-once root=%s", tostring(root))
		return
	end

	if not config.treeview_scan_once and root == self.doc_root and now < (self.next_scan_time or 0) then
		root_debug(
			"refresh skipped: throttle root=%s now=%.3f next=%.3f",
			tostring(root),
			now,
			self.next_scan_time or 0
		)
		return
	end
	local root_changed = root ~= self.doc_root
	self.doc_root = root
	self.last_scan_time = now

	if not root then
		root_debug("refresh no root")
		self.root_items = {}
		self.scanned_root = nil
		self.visible_items = nil
		if root_changed then
			self.selected_item = nil
		end
		self.next_scan_time = now + self.scan_interval
		return
	end

	local root_info = system.get_file_info(root)

	root_debug(
		"root info path=%s type=%s size=%s",
		tostring(root),
		tostring(root_info and root_info.type),
		tostring(root_info and root_info.size)
	)
	if not root_info or root_info.type ~= "dir" then
		self.root_items = {}
		self.scanned_root = nil
		self.visible_items = nil
		if root_changed then
			self.selected_item = nil
		end
		self.next_scan_time = now + self.scan_interval
		return
	end

	local t = {}
	local all = system.list_dir(root) or {}

	root_debug(
		"list_dir root=%s count=%d sample=%s",
		tostring(root),
		#all,
		table.concat({ tostring(all[1]), tostring(all[2]), tostring(all[3]), tostring(all[4]), tostring(all[5]) }, ", ")
	)
	local t0 = system.get_time()
	local recursive = not is_root_directory(root)

	root_debug("scan root=%s recursive=%s", tostring(root), tostring(recursive))
	scan_dir(root, t, recursive)

	local t1 = system.get_time()
	local dt_ms = (t1 - t0) * 1000
	self.last_scan_time = t1
	self.next_scan_time = t1 + self.scan_interval

	local dbg = config.debug_treeview == true
	if dbg then
		core.log_quiet("[treeview-scan] dt=%.1fms root=%s items=%d", dt_ms, tostring(root), #t)
	end

	self.root_items = t
	self.scanned_root = root
	self.visible_items = nil

	if root_changed then
		self.selected_item = nil
	end
end

-- Builds list shown by treeview (always rooted at current document directory)
function TreeView:get_visible_items()
	if self.visible_items then
		return self.visible_items.items, self.visible_items.base_depth, self.doc_root
	end

	local files = self.root_items or {}

	if #files == 0 then
		self.visible_items = {
			items = files,
			base_depth = 0,
		}
		return files, 0, self.doc_root
	end

	local base_depth = get_depth(self.doc_root or files[1].filename) + 1
	local visible_items = {}
	local index = 1

	while index <= #files do
		local item = files[index]
		local cached = self:get_cached(item)

		cached.rel_depth = math.max(0, cached.depth - base_depth)
		visible_items[#visible_items + 1] = cached
		index = index + 1

		if not cached.expanded then
			local depth = cached.depth

			while index <= #files do
				local filename = files[index].filename
				if get_depth(filename) <= depth then
					break
				end
				index = index + 1
			end
		end
	end

	self.visible_items = {
		items = visible_items,
		base_depth = base_depth,
	}

	return visible_items, base_depth, self.doc_root
end

--- Return cached count of rows that are currently visible in the flattened tree
-- @return number visible item count
function TreeView:get_visible_item_count()
	local visible_items = self:get_visible_items()

	return #visible_items
end

-- Picks/validates the reused preview view
function TreeView:get_preview_target_view()
	local root = core.workspace_view.root_node

	if self.preview_view and root:get_node_for_view(self.preview_view) then
		return self.preview_view
	end

	local candidates = { core.last_active_view, core.active_view }

	for i = 1, #candidates do
		local v = candidates[i]

		if v and v.doc and root:get_node_for_view(v) then
			self.preview_view = v
			return v
		end
	end
end

-- Opens file from tree using one reusable doc tab by default
function TreeView:open_doc_from_tree(filename)
	local doc = Doc.open_file(filename)

	if not doc then
		return
	end

	if not config.treeview_reuse_single_tab then
		local opened = core.workspace_view:open_doc(doc)

		if opened and opened.doc then
			self.preview_view = opened
		end
		return
	end

	local target = self:get_preview_target_view()

	if not target then
		target = core.workspace_view:open_doc(doc)
		if target and target.doc then
			self.preview_view = target
		end
		return
	end

	-- Reuse only selectable document editors with selection-aware docs
	if not core.view_can_select(target) or type(doc.get_selection) ~= "function" then
		target = core.workspace_view:open_doc(doc)
		if target and target.doc then
			self.preview_view = target
		end
		return
	end

	target.doc = doc
	target.last_line = nil
	target.last_col = nil
	target.last_x_offset = {}
	target.blink_timer = 0

	if target.scroll_to_line and type(doc.get_selection) == "function" then
		target:scroll_to_line(doc:get_selection(), true, true)
	end

	local node = core.workspace_view.root_node:get_node_for_view(target)

	if node then
		node:set_active_view(target)
	else
		core.set_active_view(target)
	end
end

-- Returns cached metadata for a project file entry
function TreeView:get_cached(item)
	local t = self.cache[item.filename]
	if not t then
		t = {}
		t.filename = item.filename
		t.abs_filename = system.absolute_path(item.filename)
		t.name = t.filename:match("[^\\/]+$")
		t.depth = get_depth(t.filename)
		t.type = item.type
		self.cache[t.filename] = t
	end
	return t
end

-- Returns tab title for the project tree pane
function TreeView:get_name()
	if not self.visible then
		return "Project"
	end
	local _, _, base_dir = self:get_visible_items()
	if not base_dir then
		return "Project"
	end
	local name = base_dir:match("([^/\\]+)$") or base_dir
	if name == "" then
		return "Project"
	end
	return "Project: " .. name
end

-- Returns row height used for each tree item
function TreeView:get_item_height()
	return style.font:get_height() + style.padding.y
end

--- Return total scrollable content height for the current tree rows
-- @return number scrollable height
function TreeView:get_scrollable_size()
	local row_height = self:get_item_height()
	local row_count = self:get_visible_item_count()

	return style.padding.y * 2 + row_count * row_height
end

--- Iterate flattened tree rows and optionally cull them to the viewport
-- Skip collapsed descendants cheaply and stop once rows fall below the viewport during drawing
-- @param only_visible boolean|nil yield only viewport-visible rows when true
function TreeView:each_item(only_visible)
	return coroutine.wrap(function()
		local items = self:get_visible_items()
		local ox, oy = self:get_content_offset()
		local y = oy + style.padding.y
		local w = self.size.x
		local h = self:get_item_height()
		local viewport_top
		local viewport_bottom

		if only_visible then
			local _, viewport_y, _, viewport_height = self:get_content_viewport_rect()
			viewport_top = viewport_y
			viewport_bottom = viewport_y + viewport_height
		end

		for index = 1, #items do
			local cached = items[index]
			if not only_visible or (y + h > viewport_top and y < viewport_bottom) then
				coroutine.yield(cached, ox, y, w, h)
			end
			y = y + h

			if only_visible and y >= viewport_bottom then
				break
			end
		end
	end)
end

-- Returns currently visible items as a flat list
function TreeView:get_item_list()
	local list = {}
	for item in self:each_item() do
		list[#list + 1] = item
	end
	return list
end

-- Keeps selection valid against current visible items
function TreeView:ensure_selection()
	local items = self:get_item_list()
	if #items == 0 then
		self.selected_item = nil
		return items, nil
	end
	local idx = 1
	if self.selected_item then
		for i = 1, #items do
			if items[i] == self.selected_item then
				idx = i
				break
			end
		end
	end
	self.selected_item = items[idx]
	return items, idx
end

-- Moves keyboard selection by 'delta' rows
function TreeView:move_selection(delta)
	local items, idx = self:ensure_selection()
	if not idx then
		return false
	end
	idx = common.clamp(idx + delta, 1, #items)
	self.selected_item = items[idx]
	if self.selected_item and self.selected_item.type == "file" then
		self:open_doc_from_tree(self.selected_item.filename)
	end
	core.redraw = true
	return true
end

-- Moves keyboard selection by one visible page
function TreeView:move_selection_page(delta_pages)
	local step = math.max(1, math.floor(self.size.y / self:get_item_height()))
	return self:move_selection(step * delta_pages)
end

-- Jumps keyboard selection to first/last item
function TreeView:move_selection_edge(to_end)
	local items = self:get_item_list()
	if #items == 0 then
		self.selected_item = nil
		return false
	end
	self.selected_item = items[to_end and #items or 1]
	if self.selected_item and self.selected_item.type == "file" then
		self:open_doc_from_tree(self.selected_item.filename)
	end
	core.redraw = true
	return true
end

-- Selects the row corresponding to the current active document, when present
function TreeView:sync_selection_to_active_doc()
	local filename = self:get_current_doc_filename()
	local active_abs = normalize_path(filename)
	if not active_abs then
		return false
	end

	-- Find matching file in full scanned list (even if currently collapsed)
	local items = self.root_items or {}
	local file_item, file_idx
	for i = 1, #items do
		local item = items[i]
		if item.type == "file" and normalize_path(item.filename) == active_abs then
			file_item, file_idx = item, i
			break
		end
	end
	if not file_item then
		return false
	end

	-- Expand all ancestor directories so selection is visible
	local file_depth = get_depth(file_item.filename)
	for i = file_idx - 1, 1, -1 do
		local parent = items[i]
		if parent.type == "dir" then
			local parent_depth = get_depth(parent.filename)
			if parent_depth < file_depth then
				local parent_abs = normalize_path(parent.filename)
				if parent_abs and active_abs:sub(1, #parent_abs + 1) == parent_abs .. "/" then
					self:get_cached(parent).expanded = true
					self.visible_items = nil
					file_depth = parent_depth
				end
			end
		end
		if file_depth <= 0 then
			break
		end
	end

	self.selected_item = self:get_cached(file_item)
	return true
end

-- Updates hovered tree item from current mouse coordinates
function TreeView:on_mouse_move(px, py)
	self.hovered_item = nil
	for item, x, y, w, h in self:each_item(true) do
		if px > x and py > y and px <= x + w and py <= y + h then
			self.hovered_item = item
			break
		end
	end
end

-- Toggles directories or opens file entries on mouse press
function TreeView:on_mouse_down(button, x, y)
	if not self.hovered_item then
		return
	end

	self.selected_item = self.hovered_item
	if self.hovered_item.type == "dir" then
		self.hovered_item.expanded = not self.hovered_item.expanded
		self.visible_items = nil
	else
		-- Opens file items safely via core.try
		core.try(function()
			self:open_doc_from_tree(self.hovered_item.filename)
		end)
	end
end

-- Animates tree pane width based on visibility state
function TreeView:update()
	if self.visible then
		self:refresh_doc_root_files()
		if not self.selected_item then
			if not self:sync_selection_to_active_doc() then
				self:ensure_selection()
			end
		end
	end
	-- update width
	local dest = self.visible and config.treeview_size or 0
	if self.init_size then
		if not config.treeview_init_visible then -- liqube
			self.size.x = 0
			self.visible = false -- liqube
		else
			self.size.x = dest
		end -- liqube
		self.init_size = false
	else
		self:move_towards(self.size, "x", dest)
	end

	TreeView.super.update(self)
end

-- Draws tree background, row highlights, icons, and filenames
function TreeView:draw()
	self:draw_background(style.background2)

	-- Skip row rendering entirely while the pane is hidden so close animation does not keep repainting the tree contents
	if not self.visible then
		return
	end

	if self.size.x <= 0 then
		return
	end

	local icon_width = style.icon_font:get_width("D")
	local spacing = style.font:get_width(" ") * 2

	local av = core.active_view
	local doc = av and av.doc
	local active_filename = doc and system.absolute_path(doc.filename or "")

	for item, x, y, w, h in self:each_item(true) do
		local color = style.text

		-- highlight active_view doc
		if item.abs_filename == active_filename then
			color = style.accent
		end

		if item == self.selected_item then
			renderer.draw_rect(x, y, w, h, style.selection)
			color = style.accent
		end

		-- hovered item background
		if item == self.hovered_item then
			renderer.draw_rect(x, y, w, h, style.line_highlight)
			color = style.accent
		end

		-- icons
		x = x + (item.rel_depth or item.depth) * style.padding.x + style.padding.x
		if item.type == "dir" then
			local icon1 = item.expanded and "-" or "+"
			local icon2 = item.expanded and "D" or "d"
			common.draw_text(style.icon_font, color, icon1, nil, x, y, 0, h)
			x = x + style.padding.x
			common.draw_text(style.icon_font, color, icon2, nil, x, y, 0, h)
			x = x + icon_width
		else
			x = x + style.padding.x
			common.draw_text(style.icon_font, color, "f", nil, x, y, 0, h)
			x = x + icon_width
		end

		-- text
		x = x + spacing
		x = common.draw_text(style.font, color, item.name, nil, x, y, 0, h)
	end
end

-- Creates tree view and docks it to the left side of the active node
local view = TreeView()
local node = core.workspace_view:get_active_node()
node:split("left", view, true)
if view.visible then
	project.enable_scan("treeview", true)
end

-- register commands and keymap
command.add({
	-- Toggles tree pane visibility
	["treeview:toggle"] = function()
		view.visible = not view.visible
		if view.visible then
			project.enable_scan("treeview", true)
			view:refresh_doc_root_files()
			view:sync_selection_to_active_doc()
		else
			project.disable_scan("treeview")
		end
	end,
	-- Shows the tree pane
	["treeview:show"] = function()
		view.visible = true
		project.enable_scan("treeview", true)
		view:refresh_doc_root_files()
		view:sync_selection_to_active_doc()
	end, -- liqube
	-- Hides the tree pane
	["treeview:hide"] = function()
		view.visible = false
		project.disable_scan("treeview")
	end, -- liqube
	-- Select previous visible tree entry
	["treeview:select-previous"] = function()
		if not view.visible then
			return false
		end
		return view:move_selection(-1)
	end,
	-- Select next visible tree entry
	["treeview:select-next"] = function()
		if not view.visible then
			return false
		end
		return view:move_selection(1)
	end,
	-- Select previous page in tree entries
	["treeview:page-up"] = function()
		if not view.visible then
			return false
		end
		return view:move_selection_page(-1)
	end,
	-- Select next page in tree entries
	["treeview:page-down"] = function()
		if not view.visible then
			return false
		end
		return view:move_selection_page(1)
	end,
	-- Select first tree entry
	["treeview:select-first"] = function()
		if not view.visible then
			return false
		end
		return view:move_selection_edge(false)
	end,
	-- Select last tree entry
	["treeview:select-last"] = function()
		if not view.visible then
			return false
		end
		return view:move_selection_edge(true)
	end,
})

keymap.add({
	-- Keep the tree toggle globally reachable regardless of the active surface
	["f4"] = "treeview:toggle",
}, { layer = "plugin" })

keymap.add({
	["ctrl+up"] = "treeview:select-previous",
	["ctrl+down"] = "treeview:select-next",
	["ctrl+pageup"] = "treeview:page-up",
	["ctrl+pagedown"] = "treeview:page-down",
	["ctrl+home"] = "treeview:select-first",
	["ctrl+end"] = "treeview:select-last",
}, {
	-- Scope tree navigation to the focused tree view instead of intercepting it application-wide
	layer = "default",
	context = "treeview",
})
