-- 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 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)
	-- Virtual documents are monitored through the physical file that owns their bytes
	local parsed_filename = system.parse_file_reference(filename, false)
	local info = system.get_file_info(parsed_filename.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_signature = nil,
		next_check_time = 0,
		pending_prompt = false,
		last_notice_signature = nil,
		last_changed_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

--- Build a stable identity for the currently observed disk state
-- @param state table monitor state
-- @return string signature modification time and file size
local function disk_state_signature(state)
	return string.format("%s|%s", tostring(state.disk_modified), tostring(state.disk_size))
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, disk_state_signature(state), 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_signature = nil
	state.last_notice_signature = nil
	state.last_changed_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
		local changed_signature = disk_state_signature(state)
		-- 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_passive == false then
			-- Background notices can be disabled by config
		else
			show_notice(doc, state, "changed")
		end
		if state.last_changed_signature ~= changed_signature then
			state.last_changed_signature = changed_signature
			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
end

--- Confirm before reloading a document that has unsaved edits
-- @param doc table document model
-- @param opts table|nil reload options forwarded after confirmation
-- @return boolean handled true when reloaded or confirmation opened
function monitor.confirm_dirty_reload(doc, opts)
	opts = type(opts) == "table" and opts or {}
	if type(doc.is_dirty) ~= "function" or not doc:is_dirty() then
		local reload_opts = {}
		for key, value in pairs(opts) do
			reload_opts[key] = value
		end
		reload_opts.force = true

		return monitor.reload_doc(doc, reload_opts)
	end

	local core = get_core()
	if not core then
		return false
	end

	core.command_view:set_text("")
	core.command_view:enter({
		caption = "Document has unsaved changes. Reload anyway?",
		urgency = "warning",
		submit = function(text, item)
			local choice = (item and item.text) or text
			if choice ~= "Reload" then
				return
			end

			local reload_opts = {}
			for key, value in pairs(opts) do
				reload_opts[key] = value
			end
			reload_opts.force = true
			local ok, err = monitor.reload_doc(doc, reload_opts)
			if not ok and err then
				core.error("Reload failed: %s", tostring(err))
			end
		end,
		suggest = function(text)
			local items = { "Reload", "Keep Editing" }
			local out = {}

			for index = 1, #items do
				if text == "" or items[index]:lower():find(text:lower(), 1, true) == 1 then
					out[#out + 1] = items[index]
				end
			end

			return out
		end,
	})

	return true
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
	local prompt_signature = disk_state_signature(state)
	if state.last_prompted_signature == prompt_signature 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_signature = prompt_signature
	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.confirm_dirty_reload(doc, { 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

--- Refresh viewer-owned state for a binary or image document
-- @param doc table document model
-- @return boolean supported true when the document uses a viewer refresh path
local function reload_viewer_doc(doc)
	local core = get_core()
	if doc.is_binary_placeholder then
		local BinaryViewer = require("core.binary.viewer")
		BinaryViewer.reload_doc_state(doc)
		core.redraw = true

		return true
	end

	if doc.is_image then
		local views = core.workspace_view.root_node:get_children()
		for index = 1, #views do
			local view = views[index]
			if view and view.doc == doc and type(view.reload_image) == "function" then
				view:reload_image()
			end
		end
		core.redraw = true

		return true
	end

	return false
end

--- Reload one document from disk and refresh monitor baseline
-- @param doc table document model
-- @param opts table|nil reload options, with optional after_reload callback
-- @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

	local viewer_reload = doc.is_binary_placeholder == true or doc.is_image == true
	if not viewer_reload and type(doc.reopen) ~= "function" then
		return false, "unsupported"
	end
	if viewer_reload then
		-- Reuse metadata already collected for reload instead of asking each viewer to stat again
		doc.content_size_bytes = tonumber(info.size) or 0
		doc.content_size_change_time = system.get_time()
		doc.content_size_known = true
	end

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

	local ok, err = pcall(function()
		if not reload_viewer_doc(doc) then
			doc:reopen()
		end
	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,
	})

	-- Let the caller restore view state only after the new document is ready
	if type(opts.after_reload) == "function" then
		opts.after_reload(doc)
	end

	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
			-- Reusing live memory must preserve any external-change state already detected
			if doc and (payload.reused ~= true or payload.reloaded == true) 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

	if view.can_edit == false then
		-- Read-only document views reload stale files on their own update path
		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_time) or 0) then
				local active_doc = core.active_view and core.active_view.doc
				local is_active = active_doc == doc
				state.next_check_time = 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 coroutine 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_coroutine(function()
		while true do
			if enabled then
				poll_pass()
			end
			coroutine.yield(0.1)
		end
	end, monitor)
end

-- Expose one disk poll to focused monitor tests
monitor._poll_doc = poll_doc

return monitor
