--
-- need a hook for the global window destroy / shutdown so we can send the
-- detach / shutdown command and not leave dangling gdb -i
--
local parse_dap =
	loadfile(string.format("%s/cat9/dev/support/parse_dap.lua", lash.scriptdir))()

local debugger =
	loadfile(string.format("%s/cat9/dev/support/debug_dap.lua", lash.scriptdir))()

local debug_arcan =
	loadfile(string.format("%s/cat9/dev/support/debug_arcan.lua", lash.scriptdir))()

--
-- split out the rendering and interaction code for each window
--
local view_factories =
{
	"thread",
	"breakpoint",
	"source",
	"disassembly",
	"registers",
	"variables",
	"files",
	"maps",
	"watches",
	"entrypoints"
}

for i=1,#view_factories do
	view_factories[view_factories[i]] = loadfile(
		string.format("%s/cat9/dev/support/%s_view.lua",
		lash.scriptdir, view_factories[i]
	))()
end

return
function(cat9, root, builtins, suggest, _, builtin_cfg)

local os_support =
	loadfile(string.format("%s/cat9/dev/support/debug_os.lua", lash.scriptdir))()(cat9, root)

local attach_block = os_support:can_attach()

local activejob
local errors = {
	bad_pid = "debug attach >pid< : couldn't find or bind to pid",
	attach_block = "debug: kernel blocks ptrace(pid), attach will fail",
	no_active = "debug: no active job",
	readmem = "debug: couldn't read memory at %s+%d",
	no_target = "debug launch >target< missing",
	no_job = "no active debug job",
	bad_ref = "debug >ref< ... is not a tied to a debugger",
	no_cmd = "debug: no valid command specified",
	bad_thread = "debug thread >id< ... invalid thread id",
	bad_thread_cmd = "debug thread (id) >cmd< unknown command: %s",
	bad_frame = "debug thread i cmd >frame< .. unknown or missing stack frame",
	no_source = "debug source >fn< .. missing source reference",
	no_pid = "debug >job< .. no process id assigned to job",
	no_break = "debug break job >target< ... no breakpoint target",
	no_append = "debug >job< append ... backend doesn't support append"
}

local cmds = {}
local cmd_sugg = {}
local views = {}

-- wrapper for tracking singleton or grouped windows (sources)
local function attach_window(key, fact, ...)
	return
	function(job, opts, th, frame)
		local group = opts.group or "windows"
		local wnd
		local dkey = opts.override_key or key

		if job[group][dkey] then
			return job[group][dkey], false
		end

		if type(fact) == "string" then
			wnd =
				cat9.import_job({
					short = fact,
					parent = job,
					data = job.debugger[key],
					check_status = cat9.always_active,
					block_edit = true
				})
		else
			wnd = fact(cat9, builtin_cfg, job, th, frame, opts)
		end

		job[group][dkey] = wnd

-- if the contents should be rebuilt on a hook like a stack frame becoming
-- invalid or updated, this will be called whenever a thread changes state from
-- running or stopped or when certain locally cached data has changed like
-- variables being set.
		local track
		if opts.invalidated then
			track = function()
				if wnd.invalidated then
					wnd:invalidated()
				end
			end
			table.insert(th.handlers.invalidated, track)
		end

		table.insert(
			wnd.hooks.on_destroy,
			function()
				job[group][dkey] = nil
				if opts.invalidated then
					local ih = th.handlers.invalidated

					for i=1,#ih do
						if ih[i] == track then
								table.remove(ih, i)
								break
						end
					end
				end
			end
		)

		return wnd, true
	end
end

views.stderr = attach_window("stderr", "Debug:stderr")
views.stdout = attach_window("stdout", "Debug:stdout")
views.errors = attach_window("errors", "Debug:errors")
views.threads = attach_window("threads", view_factories.thread)
views.breakpoints = attach_window("breakpoints", view_factories.breakpoint)
views.source = attach_window("source", view_factories.source)
views.disassembly = attach_window("disassembly", view_factories.disassembly)
views.registers = attach_window("registers", view_factories.registers)
views.variables = attach_window("variables", view_factories.variables)
views.files = attach_window("files", view_factories.files)
views.maps = attach_window("maps", view_factories.maps)
views.watches = attach_window("watches", view_factories.watches)
views.entrypoints = attach_window("entrypoints", view_factories.entrypoints)
views.vmstack = attach_window("vmstack", view_factories.vmstack)

local function spawn_views(job, set, opts)
	cat9.list_processes(function() end, true)

	activejob = job
	if opts then
		for i,v in ipairs(opts) do
			job.debugger:eval(
				v,
				"repl",
				function()
				end
			)
		end
	end

	if not set then
		set = {"stderr", "stdout", "threads", "errors", "breakpoints"}
	end

	for i,v in ipairs(set) do
		if views[v] then
			views[v](job, {})
		end
	end
end

function cmds.files(job, ...)
	local set = {...}
	local base = {}
	local ok, msg = cat9.expand_arg(base, set)
	if not ok then
		return false, msg
	end

	if not job or not job.debugger.pid then
		return false, errors.no_pid
	end

	views.files(job, {}, os_support, job.debugger.pid)
end

function cmds.maps(job, ...)
	local set = {...}
	local base = {}
	local ok, msg = cat9.expand_arg(base, set)
	if not ok then
		return false, msg
	end

	if not job or not job.debugger.pid then
		return false, errors.no_pid
	end

	views.maps(job, {}, os_support, job.debugger.pid)
end

local function add_watch_spread(wnd)
	local ob = cat9.builtin_name
	cat9.builtins["builtin"]("spreadsheet")
	cat9.parse_string(cat9.readline, "new")
	wnd.spreadsheet = {
		wnd = cat9.latestjob,
		clock = 0
	}

	if not wnd.spreadsheet.wnd then
		cat9.add_message("spreadsheet builtin not available")
		return
	end

	table.insert(wnd.spreadsheet.wnd.hooks.on_destroy,
	function()
		wnd.spreadsheet = nil
	end
	)

	local columns = {}
	for i,v in ipairs(wnd.threads) do
		table.insert(columns, string.format("\"Thread(%s)\"", tostring(v.id)))
		v.in_spread = #columns
	end
	for i,v in ipairs(wnd.registers) do
		table.insert(columns, '"' .. string.gsub(v.name, '"', "\\\"") .. '"')
		v.in_spread = #columns
	end
	for i,v in ipairs(wnd.variables) do
		table.insert(columns, v.name)
		v.in_spread = #columns
	end
	for i,v in ipairs(wnd.globals) do
		table.insert(columns, v.name)
		v.in_spread = #columns
	end

	wnd.spreadsheet.next_row = 2

	local str = string.format(
		"insert #%d 1 %s", wnd.spreadsheet.wnd.id, table.concat(columns, " "))

	cat9.parse_string(cat9.readline, str)

	cat9.builtins["builtin"](ob)
end

function cmd_sugg.thread(job, args, raw)
	local thread_cmds = {
		"stop", "continue", "next", "stepi", "in", "freerun", "out",
		"disassemble", "variabves", "registers", "vmstack",
		"arguments", "globals", "watches",
	}
	local thread_hints = {
		stop = "Pause execution",
		continue = "Resume execution",
		next = "Step to the next line of execution",
		stepi = "Step one instruction",
		["in"] = "Step into a function",
		freerun = "Sample at every line",
		out = "Continue until the current function has completed"
	}
end

function cmds.thread(job, ...)
	local set = {...}
	local base = {}
	local ok, msg = cat9.expand_arg(base, set)
	if not ok then
		return false, msg
	end

	local thid
	local fid = 0
	local th

-- thread [n | n:f] stop | continue
	if tonumber(base[1]) ~= nil then

		local set = string.split(base[1], ":")
		if tonumber(set[2]) then
			fid = tonumber(set[2])
		end

		thid = tonumber(table.remove(base, 1))
		th = job.debugger.data.threads[thid]
	end

-- apply to all threads unless this is set
	if base[1] == "stop" then
		job.debugger:continue(thid)
		return
	elseif base[1] == "continue" then
		job.debugger:pause(thid)
		return
	elseif th and base[1] == "next" then
		th:step()
		return
	elseif th and base[1] == "stepi" then
		th:stepi()
		return
	elseif th and base[1] == "in" then
		th:stepin()
		return
	elseif th and base[1] == "freerun" then
		th:freerun()
		return
	elseif th and base[1] == "out" then
		th:stepout()
		return
	end

-- thread [n]
	thid = thid or 1
	th = job.debugger.data.threads[thid]

	if not th then
		return false, errors.bad_thread
	end

	local frame
	local domains =
	{
		registers =
		function()
			local wnd = views.registers(job,
				{invalidated = frame}, th, frame)
		end,
		disassemble =
		function()
			views.disassembly(job, {}, th, frame)
		end,
		variables =
		function()
			local wnd, new = views.variables(job,
				{invalidated = frame, override_key = "locals"}, th, frame)
-- swap tracked scope
			if not new then
				wnd.frameid = frame
				wnd.thread = th
				wnd:invalidated()
			end
		end,
		vmstack =
		function()
			local wnd, new = views.variables(job,
				{invalidated = frame, override_key = "vmstack"}, th, -1)
		end,
		arguments =
		function()
			views.variables(job,
				{invalidated = frame,
				scope = "arguments", override_key = "args", silent = true}, th, frame)
		end,
		globals =
		function()
			views.variables(job,
				{invalidated = frame, scope = "globals",
				override_key = "globals", silent = true}, th, frame)
		end,
		watches =
		function()
			local wnd = views.watches(job, {invalidated = frame}, th, frame)
			if base[1] == "spreadsheet" then
				if not wnd.spreadsheet then
					add_watch_spread(wnd)
				end
				wnd:invalidated()
			else
				wnd:add_watch(base[1], base[2], th, frame, unpack(base, 3))
				wnd:invalidated()
			end
		end,
		set =
		function()
			th:locals(frame,
				function(locals)
					if locals.locals then
						for _,v in ipairs(locals.locals.variables) do
							if v.name == base[1] and base[2] == "=" then
								v:modify(base[3])
								return
						end
					end
					job.debugger.errors:add_line(job.debugger, "variable not in frame")
				end
			end)
		end
	}

	frame = 0
	if tonumber(base[1]) then
		frame = tonumber(base[1])
		table.remove(base, 1)
	end

	if not domains[base[1]] then
		return false, string.format(errors.bad_thread_cmd, base[1])
	end

	if not th:frame(frame) then
		return false, errors.bad_frame
	end

	domains[table.remove(base, 1)]()

	return true
end

cmds["break"] =
function(job, ...)
	local set = {...}
	local base = {}
	local ok, msg = cat9.expand_arg(base, set)
	if not ok then
		return false, msg
	end

-- ensure we reference a job with a debugger in its hierarchy
	if not job then
		return false, errors.no_job
	end

	if job.debugger then
	elseif job.parent and job.parent.debugger then
		job = job.parent
	else
		return false, errors.bad_ref
	end

	if not base[1] then
		return false, errors.no_break
	end

-- a trailing conditional expression would be nice ...

-- instruction, function, source:line
	if string.sub(base[1], 1, 2) == "0x" then
		job.debugger:break_addr(base[1])

	elseif string.find(base[1], ":") then
		local set = string.split(base[1], ":")
		job.debugger:break_at(set[1], set[2])

-- assume function
	else
		job.debugger:break_on(set[1])
	end
end

-- arguments:
--  base[1] source file:line
--  thread identifier
--  [frame identifier]
function cmds.source(job, ...)
	local set = {...}
	local base = {}
	local ok, msg = cat9.expand_arg(base, set)
	if not ok then
		return false, msg
	end

	if not job then
		return false, errors.no_job
	end

	if not base[1] then
		return false, errors.no_source
	end

	local ref = string.split(base[1], ":")

	job.debugger:source(ref[1],
		function(source)
			if not source or #source == 0 then
				job.debugger.output:add_line(job.debugger, "empty source for " .. ref[1])
				return
			end

-- do we bind to track a specific thread and/or frame?
			local thid = tonumber(base[2])
			local swnd = views.source(job, {}, source, ref[1])

			swnd:set_thread(thid)
			swnd.source_ref = ref[1]

			local line = tonumber(ref[2])
			if line then
				swnd:move_to(line)
			end

			if not thid then
				return
			end

			local th = job.debugger.data.threads[thid]
			if not th then
				return
			end

	-- do we have tracking for this window? then stop.
			swnd.thid = thid
			if swnd.track then
				return
			end

			local synch_markers =
			function()
				local marks = {}
				for i,v in ipairs(job.debugger.data.breakpoints) do
					if v.path == swnd.source_ref then
						marks[v.line[1]] = builtin_cfg.debug.breakpoint_line
					end
				end
				swnd.marks = marks
				cat9.flag_dirty(swnd)
			end

			local fid = tonumber(base[3]) or 1

			swnd.track =
			function(th)
				if th.stack[fid] and -- frame not dead while waiting?
					th.stack[fid].line >= 0 and -- referring to actual source?
					th.stack[fid].path ~= ref[1] then -- source changed from initial?
					job.debugger:source(th.stack[fid].path,
						function(source)
							th.stack[fid].source = th.stack[fid].line
							swnd.source_ref = th.stack[fid].path
						end
					)
				elseif th.stack[fid] then
					swnd:move_to(th.stack[fid].line)
				end
				synch_markers()
			end

			synch_markers()
			table.insert(th.handlers.invalidated, swnd.track)

-- detach tracking if we terminate
			table.insert(swnd.hooks.on_destroy,
				function()
					for i=1,#th.handlers.invalidated do
						if th.handlers[i] == swnd.track then
							table.remove(th.handlers.invalidated, i)
							break
						end
					end
					return
				end
			)
-- source closure
		end
	)
end

function cmds.memory(job, ...)
	local set = {...}
	local base = {}
	local ok, msg = cat9.expand_arg(base, set)
	if not ok then
		return false, msg
	end
	local len = 10

	if not job then
		return false, errors.no_job
	end

-- gdb will fail for the entire request if we try to read beyond a mapping,
-- the option is to figure out page alignment from base address and request
-- one page at a time until we've covered length.
	job.debugger:read_memory(base[1], len,
		function(data)
			if not data then
				job.debugger.output:add_line(job.debugger,
					string.format(errors.readmem, base[1], len))
				return
			end

			local new = {
				short = string.format("memory @ %s + %d", base[1], #data.data),
				data = {
					data.data,
					bytecount = #data.data,
					linecount = 1
				},
			}

			new["repeat"] =
			function(self)
				job.debugger:read_memory(
					base[1], len,
					function(data)
						if not data then
							return
						end

						new.data = {
							data.data,
							bytecount = #data.data,
							linecount = 1
						}
						cat9.flag_dirty(new)
					end
				)
			end

-- set repeat as a new request
			cat9.import_job(new)
		end
	)
end

local function attach_logging(debugger)
	local dbgin, dbgout
	if builtin_cfg.debug.log_in then
		local fin = root:fopen(builtin_cfg.debug.log_in, "w")
		dbgin = function(msg)
			fin:write(msg)
			fin:flush()
		end
	end

	if builtin_cfg.debug.log_out then
		local fout = root:fopen(builtin_cfg.debug.log_out, "w")
		dbgout = function(msg)
			fout:write(msg)
			fout:flush()
		end
	end

	debugger:set_log(dbgin, dbgout)
end

function cmds.launch(...)
	local set = {...}
	local outargs = {}
	local ok, msg = cat9.expand_arg(outargs, set)

	if not ok then
		return false, msg
	end

	if not outargs[1] then
		return false, errors.no_target
	end

	local view_set
	local opts = builtin_cfg.debug.options
	local dbgfn =
	function()
		return debugger(cat9, parse_dap, builtin_cfg.debug, outargs)
	end

	if outargs[1] == "arcan" then
		dbgfn =
		function()
			return debug_arcan(cat9, "launch", builtin_cfg.debug, outargs)
		end
		view_set = {
			"threads",
			"stdout",
			"stderr",
			"errors",
			"entrypoints",
			"breakpoints"
		}
		opts = {}
	end

	local debugger, error = dbgfn()
	if not debugger then
		return false, error
	end

	local job = {
		short = string.format("Debug:launch(%s)", outargs[1]),
		debugger = debugger,
		windows = {},
		check_status = cat9.always_active,
		["repeat"] = function(self)
			self.debugger:restart()
		end
	}

	attach_logging(job.debugger)

	job.data = job.debugger.output
	cat9.import_job(job)
	spawn_views(job, view_set, opts)
end

function cmds.append(job, ...)
	local set = {...}
	if not job.debugger.append then
		return false, errors.no_append
	end

	local outargs = {}
	local ok, msg = cat9.expand_arg(outargs, set)
	if not ok then
		return false, msg
	end

	return job.debugger:append(unpack(outargs))
end

function cmds.append(...)

end

function cmds.attach(...)
	local set = {...}
	local process = set[1]
	local pid
	local debugfn
	local view_set
	local opts = builtin_cfg.debug.options

	if type(process) == "string" then

-- attach arcan [infile] [outfile]
		if process == "arcan" then
			local outargs = {}
			print("attach to arcan process", set[1])
			local ok, msg = cat9.expand_arg(outargs, set)
			if not ok then
				return false, msg
			end
			table.remove(outargs, 1)
			debugfn = function()
				return debug_arcan(cat9, "attach", builtin_cfg.debug, outargs)
			end
			view_set = {
				"threads",
				"stdout",
				"stderr",
				"errors",
				"entrypoints",
				"breakpoints"
			}
			opts = {}
		else
			pid = tonumber(process)
		end

-- table arguments can be (1,2,3) or #1, the former has the parg attribute set
	elseif type(process) == "table" then
		if not process.parg then
			pid = process.pid

-- resolve into text-pid
		else
			local outargs = {}
			table.remove(set, 1)
			local ok, msg = cat9.expand_arg(outargs, set)
			if not ok then
				return false, msg
			end

			pid = tonumber(outargs[1])
		end
		debugfn = function()
			return debugger(cat9, parse_dap, builtin_cfg.debug, pid)
		end
	end

	if not debugfn then
		return false, errors.bad_pid
	end

	local dbg, msg = debugfn()
	if not dbg then
		return false, msg
	end

	local job = {
		short = "Debug:attach",
		debugger = dbg,
		windows = {},
		check_status = cat9.always_active,
		["repeat"] =
		function(self)
			self.debugger:restart()
		end
	}

-- this lets us swap out job.data between the different buffers, i.e. stderr,
-- telemetry, ... or have one in between that just gives us meta- information
-- of the debugger state itself, as well as spawning separate views for the
-- many data domains.
	job.data = job.debugger.output
	attach_logging(job.debugger)

	cat9.import_job(job)
	table.insert(job.hooks.on_destroy,
		function()
			job.debugger:terminate(false)
		end
	)

	spawn_views(job, view_set, opts)
end

function builtins.debug(...)
	local set = {...}
	local job = activejob

	if type(set[1]) == "table" then
		job = table.remove(set, 1)

		if job.debugger then
		elseif job.parent and job.parent.debugger then
			job = job.parent
		else
			return false, errors.bad_ref
		end
	end

	local cmd = table.remove(set, 1)

	if not cmds or not cmds[cmd] then
		return false, errors.no_cmd .. " " ..table.concat(set, " ")
	end

	if cmd == "attach" or cmd == "launch" then
		return cmds[cmd](unpack(set))
	else
		return cmds[cmd](job, unpack(set))
	end
end

local function get_debug_job(args)
	local job = activejob

	if type(args[1]) == "table" then
		if args[1].debugger then
			job = table.remove(args, 1)
		elseif args[1].parent and args[1].parent.debugger then
			job = (table.remove(args, 1)).parent
		end
	end

	return job
end

builtins["eval"] =
function(...)
	local args = {...}

	local job = get_debug_job(args)
	if not job then
		return false, errors.no_active
	end

	local oprompt = cat9.get_prompt
	if cat9.readline then
		job.root:revert()
	end

	local opts =
	{
		cancellable = true,
		forward_meta = false,
		forward_paste = false,
		forward_mouse = false
	}

	local rlover
	rlover =
	function(self, line)
		if #line == 0 then
			cat9.get_prompt = oprompt
			cat9.block_readline(root, false, false)
			cat9.reset()
			return
		else
			cat9.set_readline(job.root:readline(rlover, opts), "debug:eval")
		end

		job.debugger:eval(
			line, "repl",
			function()
			end
		)
	end

	cat9.set_readline(job.root:readline(rlover, opts), "debug:eval")
	cat9.block_readline(root, true, true)
	cat9.readline:suggest({})

	cat9.get_prompt =
		function()
			return {"(lua-repl)"}
		end

	return job
end

builtins["_default"] =
function(args)
	local job = get_debug_job(args)

	local base = {}
	local ok, msg = cat9.expand_arg(base, args)
	if not ok then
		return false, msg
	end

	if not job then
		return false, errors.no_active
	end

-- other options here is to assume debug #jobid command and when there is no
-- overlay command, fall back to eval
	job.debugger:eval(
		table.concat(base, " "), "repl",
		function()
			cat9.flag_dirty(activejob)
		end
	)
end

function suggest.debug(args, raw)
	if #raw == 5 then
		return
	end

	table.remove(args, 1)

	local set = {
		hint = {}
	}

-- these are a bit special in the sense that we can either go debug #job (where the
-- ID is any job with a debugger or parent.debugger OR go from the activejob and then
-- set active based on last one we inserted things into.
	local function append_dbg_commands()
		table.insert(set, "memory")
		table.insert(set.hint, "View memory at a specific address or reference")
		table.insert(set, "thread")
		table.insert(set.hint, "Specify a thread to control")
		table.insert(set, "break")
		table.insert(set.hint, "Set a breakpoint")
		table.insert(set, "files")
		table.insert(set.hint, "List files open by the debuggee")
		table.insert(set, "maps")
		table.insert(set.hint, "Extract the memory maps for the debuggee")
		table.insert(set, "source")
		table.insert(set.hint, "Open a source code reference")
	end

	if #args == 1 then
		set =
			{
				"attach",
				"launch",
				hint =
				{
					"Attach a new debugger to a job or process-identifer",
					"Create a new debugging session and have it launch a target",
				}
			}
		if activejob then
			append_dbg_commands(activejob)
		end

-- don't add all viable job targets as it just spams the completion list
		cat9.add_job_suggestions(set, false,
			function(job)
				return job.debugger ~= nil
			end
		)
	else
		if args[1] == "attach" and attach_block then
			cat9.add_message(errors.attach_block)
			return false, #"debug  "
		end

-- get list of known targets, otherwise create a new based on treating first
-- as normal executable completion and the rest as --args style forwarding
		if args[1] == "launch" then
		end

		if type(args[1]) == "table" then
			local at = args[1]
			if not at.debugger and not (at.parent and at.parent.debugger) then
				cat9.add_message(errors.bad_ref)
				return false, #"debug  "
			end
		end

		append_dbg_commands(args[1])

-- check if we reference an existing debugger session, then append_dbg_commands
	end

	cat9.readline:suggest(cat9.prefix_filter(set, args[#args]), "word")
end
end
