-- lx-bundle:true

local config = require("core.config")

--- Monitor open documents for external on-disk changes
-- @module core.doc.monitor
local monitor = {}

local state_by_doc = setmetatable({}, { __mode = "k" })
local started = false
local subscribed = false
local enabled = config.doc_monitor_enabled ~= false

-- Resolve TextViewer lazily so the monitor can detect viewer-only reload flow
local function get_text_viewer_class()
	local ok, view_class = pcall(require, "core.text.viewer")

	if ok then
		return view_class
	end

	return nil
end

--- Resolve loaded core module when available
-- @return table|nil core core module or nil
local function get_core()
	return package.loaded["core"]
end

--- Emit one doc.monitor hook stage when hook bus is available
-- @param stage string stage name
-- @param payload table|nil payload table
local function emit_monitor_stage(stage, payload)
	local core = get_core()
	local hook = core.hook
	hook.emit("doc.monitor", stage, payload or {})
end

--- Clamp and resolve active polling interval in seconds
-- @return number interval polling interval for active docs
local function get_active_interval()
	local interval = tonumber(config.doc_monitor_active_interval) or 1.0
	return math.max(0.1, interval)
end

--- Clamp and resolve passive polling interval in seconds
-- @return number interval polling interval for passive docs
local function get_passive_interval()
	local interval = tonumber(config.doc_monitor_passive_interval) or 2.0
	return math.max(0.1, interval)
end

--- Returns true when one doc points to an on-disk file path
-- @param doc table|nil document model
-- @return boolean has_file true when doc is backed by an actual on-disk file
local function doc_has_file(doc)
	if type(doc) ~= "table" then
		return false
	end

	if doc.is_actual_file ~= true then
		return false
	end

	return type(doc.filename) == "string" and doc.filename ~= ""
end

--- Resolve file info only for regular files
-- @param filename string absolute or relative file path
-- @return table|nil info file info when type is file
local function get_file_info_if_file(filename)
	local info = system.get_file_info(filename)
	if not info or info.type ~= "file" then
		return nil
	end
	return info
end

--- Build or return monitor state for one doc
-- @param doc table document model
-- @return table state monitor state table
local function ensure_state(doc)
	local state = state_by_doc[doc]
	if state then
		return state
	end
	state = {
		known_modified = nil,
		known_size = nil,
		disk_modified = nil,
		disk_size = nil,
		stale = false,
		deleted = false,
		last_prompted_modified = nil,
		next_check_at = 0,
		pending_prompt = false,
		last_notice_signature = nil,
	}
	state_by_doc[doc] = state
	return state
end

--- Remove monitor state for one doc
-- @param doc table|nil document model
local function clear_state(doc)
	if not doc then
		return
	end
	state_by_doc[doc] = nil
end

--- Show one status notice for document state change
-- @param doc table document model
-- @param state table monitor state
-- @param kind string notice kind
local function show_notice(doc, state, kind)
	local core = get_core()
	if not core then
		return
	end
	local filename = tostring(doc and doc.filename or "")
	local signature = string.format("%s|%s|%s", kind, tostring(state.disk_modified), tostring(filename))
	if state.last_notice_signature == signature then
		return
	end
	state.last_notice_signature = signature
	if kind == "deleted" then
		core.status_view:show_warning(string.format('File deleted on disk: "%s"', filename))
	elseif kind == "changed" then
		core.status_view:show_notice(string.format('File changed on disk: "%s"', filename))
	elseif kind == "restored" then
		core.status_view:show_success(string.format('File restored on disk: "%s"', filename))
	end
end

--- Seed baseline timestamps from current on-disk file state
-- @param doc table document model
-- @return table state monitor state
local function seed_baseline(doc)
	local state = ensure_state(doc)
	state.pending_prompt = false
	state.last_prompted_modified = nil
	state.last_notice_signature = nil

	if not doc_has_file(doc) then
		state.known_modified = nil
		state.known_size = nil
		state.disk_modified = nil
		state.disk_size = nil
		state.stale = false
		state.deleted = false
		return state
	end

	local info = get_file_info_if_file(doc.filename)
	if info then
		local was_deleted = state.deleted == true
		state.known_modified = info.modified
		state.known_size = info.size
		state.disk_modified = info.modified
		state.disk_size = info.size
		state.stale = false
		state.deleted = false
		if was_deleted then
			emit_monitor_stage("restored", {
				doc = doc,
				filename = doc.filename,
				modified = info.modified,
				size = info.size,
				source = "seed",
			})
			show_notice(doc, state, "restored")
		end
	else
		state.stale = true
		state.deleted = true
		state.disk_modified = nil
		state.disk_size = nil
	end
	return state
end

--- Apply one file-system poll update for one document
-- @param doc table document model
-- @param state table monitor state
-- @param is_active boolean true when doc is active in current view
local function poll_doc(doc, state, is_active)
	if not doc_has_file(doc) then
		state.stale = false
		state.deleted = false
		state.pending_prompt = false
		return
	end

	local info = get_file_info_if_file(doc.filename)
	if not info then
		if not state.deleted then
			state.deleted = true
			state.stale = true
			state.pending_prompt = false
			emit_monitor_stage("deleted", {
				doc = doc,
				filename = doc.filename,
				source = "poll",
			})
			show_notice(doc, state, "deleted")
		end
		return
	end

	if state.deleted then
		state.deleted = false
		emit_monitor_stage("restored", {
			doc = doc,
			filename = doc.filename,
			modified = info.modified,
			size = info.size,
			source = "poll",
		})
		show_notice(doc, state, "restored")
	end

	if state.known_modified == nil then
		state.known_modified = info.modified
		state.known_size = info.size
		state.disk_modified = info.modified
		state.disk_size = info.size
		state.stale = false
		state.pending_prompt = false
		return
	end

	if state.known_modified ~= info.modified or state.known_size ~= info.size then
		state.disk_modified = info.modified
		state.disk_size = info.size
		state.stale = true
		-- Queue prompt for stale docs so background-detected changes prompt on activation
		if config.doc_monitor_prompt_on_focus ~= false then
			state.pending_prompt = true
		end
		if (not is_active) and config.doc_monitor_notify_background == false then
			-- Background notices can be disabled by config
		else
			show_notice(doc, state, "changed")
		end
		emit_monitor_stage("changed", {
			doc = doc,
			filename = doc.filename,
			known_modified = state.known_modified,
			disk_modified = state.disk_modified,
			known_size = state.known_size,
			disk_size = state.disk_size,
			source = "poll",
		})
	end
end

--- Ask whether to reload one stale active document
-- @param doc table document model
-- @param state table monitor state
local function prompt_reload(doc, state)
	local core = get_core()
	if not core then
		return
	end
	if core.active_view == core.command_view then
		return
	end
	if state.last_prompted_modified == state.disk_modified then
		state.pending_prompt = false
		return
	end

	local old = tonumber(state.known_modified)
	local new = tonumber(state.disk_modified)
	local is_older = old and new and new < old

	local prompt = is_older
		and "The file on disk is older"
		or "The file on disk is newer"

	local items = is_older
		and { "Keep Current", "Reload", }
		or { "Reload", "Keep Current", }

	state.pending_prompt = false
	state.last_prompted_modified = state.disk_modified
	emit_monitor_stage("prompted", {
		doc = doc,
		filename = doc.filename,
		known_modified = state.known_modified,
		disk_modified = state.disk_modified,
		source = "focus",
	})

	core.command_view:enter({
		caption = prompt,
		urgency = "warning",
		submit = function(text, item)
			local choice = (item and item.text) or text
			if choice == "Reload" then
				monitor.reload_doc(doc, { force = true, prompted = true, })
			end
		end,
		suggest = function(text)
			local out = {}
			for i = 1, #items do
				local candidate = items[i]
				if text == "" or candidate:lower():find(text:lower(), 1, true) == 1 then
					out[#out + 1] = candidate
				end
			end
			return out
		end,
	})
end

--- Reload one document from disk and refresh monitor baseline
-- @param doc table document model
-- @param opts table|nil reload options
-- @return boolean ok true on success
-- @return string|nil err error code when failed
function monitor.reload_doc(doc, opts)
	opts = type(opts) == "table" and opts or {}
	if not doc_has_file(doc) then
		return false, "missing_filename"
	end

	local state = ensure_state(doc)
	local info = get_file_info_if_file(doc.filename)
	if not info then
		return false, "deleted"
	end

	local force = opts.force == true
	if not force and type(doc.is_dirty) == "function" and doc:is_dirty() then
		return false, "dirty"
	end

	-- Skip reload for document types that do not implement file reopening
	if type(doc.reopen) ~= "function" then
		return false, "unsupported"
	end

	local sel = nil
	if type(doc.get_selection) == "function" then
		sel = { doc:get_selection() }
	end

	local ok, err = pcall(function()
		doc:reopen()
	end)
	if not ok then
		return false, tostring(err)
	end

	if sel and type(doc.set_selection) == "function" then
		doc:set_selection(table.unpack(sel))
	end

	state = seed_baseline(doc)
	local source = type(opts.source) == "string" and opts.source
		or (opts.prompted and "prompt" or "command")

	emit_monitor_stage("reloaded", {
		doc = doc,
		filename = doc.filename,
		modified = state and state.known_modified or info.modified,
		size = state and state.known_size or info.size,
		source = source,
	})

	local core = get_core()
	if core and opts.silent_success ~= true then
		core.status_view:show_success(string.format('Reloaded "%s"', tostring(doc.filename)))
	end
	return true
end

--- Reload all stale clean file-backed documents
-- @param opts table|nil reload options
-- @return table summary operation counters
function monitor.reload_stale(opts)
	opts = type(opts) == "table" and opts or {}
	local core = get_core()
	local docs = core.docs
	local summary = {
		reloaded = 0,
		skipped_dirty = 0,
		skipped_deleted = 0,
		skipped_not_stale = 0,
		skipped_other = 0,
	}

	for i = 1, #docs do
		local doc = docs[i]
		local state = ensure_state(doc)
		if not state.stale then
			summary.skipped_not_stale = summary.skipped_not_stale + 1
		elseif state.deleted then
			summary.skipped_deleted = summary.skipped_deleted + 1
		elseif type(doc.is_dirty) == "function" and doc:is_dirty() and opts.force ~= true then
			summary.skipped_dirty = summary.skipped_dirty + 1
		else
			local ok, err = monitor.reload_doc(doc, { force = opts.force == true, })
			if ok then
				summary.reloaded = summary.reloaded + 1
			elseif err == "dirty" then
				summary.skipped_dirty = summary.skipped_dirty + 1
			elseif err == "deleted" then
				summary.skipped_deleted = summary.skipped_deleted + 1
			else
				summary.skipped_other = summary.skipped_other + 1
			end
		end
	end

	if core then
		core.status_view:show_notice(string.format(
			"Reload stale: %d reloaded, %d dirty skipped, %d deleted skipped",
			summary.reloaded,
			summary.skipped_dirty,
			summary.skipped_deleted
		))
	end

	return summary
end

--- Return monitor state for one document
-- @param doc table|nil document model
-- @return table|nil state state table or nil
function monitor.get_state(doc)
	if not doc then
		return nil
	end
	return state_by_doc[doc]
end

--- Return whether document monitor is currently enabled
-- @return boolean enabled_state true when monitor is enabled
function monitor.is_enabled()
	return enabled
end

--- Toggle monitor enabled state and return the new state
-- @return boolean enabled_state true when monitor is enabled
function monitor.toggle()
	enabled = not enabled
	emit_monitor_stage("toggle", {
		enabled = enabled,
		source = "command",
	})
	local core = get_core()
	if core then
		core.status_view:show_notice(enabled and "Document monitor enabled" or "Document monitor disabled")
	end
	return enabled
end

--- Subscribe monitor to core grouped document lifecycle hooks
local function subscribe_hooks()
	if subscribed then
		return
	end
	local core = get_core()
	local hook = core.hook

	hook.add("doc.open", {
		id = "doc.monitor.open",
		after = function(payload)
			local doc = payload and payload.doc
			if doc then
				seed_baseline(doc)
			end
		end,
	})

	hook.add("doc.save", {
		id = "doc.monitor.save",
		after = function(payload)
			local doc = payload and payload.doc
			if doc then
				seed_baseline(doc)
			end
		end,
	})

	hook.add("doc.close", {
		id = "doc.monitor.close",
		after = function(payload)
			local doc = payload and payload.doc
			if doc then
				clear_state(doc)
			end
		end,
	})

	subscribed = true
end

--- Check pending prompt for the active document when needed
local function maybe_prompt_active_doc()
	if config.doc_monitor_prompt_on_focus == false then
		return
	end
	local core = get_core()
	local view = core.active_view
	local doc = view and view.doc
	if not doc then
		return
	end
	local state = state_by_doc[doc]
	if not state or not state.stale or state.deleted or not state.pending_prompt then
		return
	end

	local text_viewer_class = get_text_viewer_class()
	if view and text_viewer_class and view.is and view:is(text_viewer_class) then
		-- TextViewer reloads stale files on its own update path and should stay silent
		return
	end

	prompt_reload(doc, state)
end

--- Run one passive polling loop pass over open docs
local function poll_pass()
	local core = get_core()
	local now = system.get_time()
	for i = 1, #core.docs do
		local doc = core.docs[i]
		if doc then
			local state = ensure_state(doc)
			if now >= (tonumber(state.next_check_at) or 0) then
				local active_doc = core.active_view and core.active_view.doc
				local is_active = active_doc == doc
				state.next_check_at = now + (is_active and get_active_interval() or get_passive_interval())
				poll_doc(doc, state, is_active)
			end
		end
	end
	maybe_prompt_active_doc()
end

--- Start passive polling thread and hook subscriptions once
function monitor.start()
	if started then
		return
	end
	subscribe_hooks()

	local core = get_core()

	-- Seed current docs before first poll pass
	for i = 1, #core.docs do
		local doc = core.docs[i]
		if doc then
			seed_baseline(doc)
		end
	end

	started = true
	core.add_thread(function()
		while true do
			if enabled then
				poll_pass()
			end
			coroutine.yield(0.1)
		end
	end, monitor)
end

return monitor
