local username = settings.get "username" or "gollark"
local ni = peripheral.wrap "back"
local speaker = peripheral.find "speaker"
local modem = peripheral.find "modem"
local offload_laser = settings.get "offload_laser"
local w, h = ni.canvas().getSize()
if _G.thing_group then
	pcall(_G.thing_group.remove)
end
if _G.canvas3d_group then
    pcall(_G.canvas3d_group.clear)
    pcall(_G.canvas3d_group.remove)
end
local group
local group_3d
local function initialize_group_thing()
	if group then pcall(group.remove) end
    if group_3d then pcall(group_3d.remove) end
	group = ni.canvas().addGroup({ w - 70, 10 })
    ni.canvas3d().clear()
    group_3d = ni.canvas3d().create()
	_G.thing_group = group
    _G.canvas3d_group = group_3d
end
initialize_group_thing()

local targets = {}

local use_spudnet = offload_laser

local spudnet_send, spudnet_background
if use_spudnet then
    print "SPUDNET interface loading."
    spudnet_send, spudnet_background = require "ni-ctl_spudnet_interface"()
end

local function offload_protocol(...)
    spudnet_send { "exec", {...} }
end

local function is_target(name)
	for target, type in pairs(targets) do
		if name:lower():match(target) then return type end
	end
end

local function vector_sqlength(self)
	return self.x * self.x + self.y * self.y + self.z * self.z
end

local function project(line_start, line_dir, point)
	local t = (point - line_start):dot(line_dir) / vector_sqlength(line_dir)
	return line_start + line_dir * t, t
end

local function calc_yaw_pitch(v)
	local x, y, z = v.x, v.y, v.z
    local pitch = -math.atan2(y, math.sqrt(x * x + z * z))
    local yaw = math.atan2(-x, z)
    return math.deg(yaw), math.deg(pitch)
end

local settings_cfg = {
    brake = { type = "bool", default = true, shortcode = "b" },
    counter = { type = "bool", default = false, shortcode = "c" }, -- counterattack
    highlight = { type = "bool", default = false, shortcode = "h" },
    dodge = { type = "bool", default = true, shortcode = "d" },
    power = { type = "number", default = 5, max = 5, min = 0.5 }, -- laser power
    flight = { type = "string", default = "std", shortcode = "f", alias = { fly = true } },
    drill = { type = "bool", default = false, shortcode = "D", persist = false },
    show_acceleration = { type = "bool", default = false },
    pitch_controls = { type = "bool", default = false },
    ext_highlight = { type = "bool", default = false }
}
local SAVEFILE = "ni-ctl-settings"
if fs.exists(SAVEFILE) then
    local f = fs.open(SAVEFILE, "r")
    for key, value in pairs(textutils.unserialise(f.readAll())) do
        settings_cfg[key].value = value
    end
    f.close()
end
local gsettings = {}
setmetatable(gsettings, {
    __index = function(_, key)
        local cfg = settings_cfg[key]
        if cfg.value == nil then return cfg.default else return cfg.value end
    end,
    __newindex = function(_, key, value)
        print("set", key, "to", value)
        settings_cfg[key].value = value
        if settings_cfg[key].persist ~= false then
            local kv = {}
            for key, cfg in pairs(settings_cfg) do
                kv[key] = cfg.value
            end
            local f = fs.open(SAVEFILE, "w")
            f.write(textutils.serialise(kv))
            f.close()
        end
        os.queueEvent "settings_change"
    end
})

local work_queue = {}

local addressed_lasers = {}
local function bool_to_yn(b)
	if b == true then return "y"
	elseif b == false then return "n"
	else return "?" end
end

local status_lines = {}
local notices = {}
local function push_notice(t)
	table.insert(notices, { t, os.epoch "utc" })
end

local function lase(entity)
	local target_location = entity.s
	for i = 1, 5 do
		target_location = entity.s + entity.v * (target_location:length() / 1.5)
	end
	local y, p = calc_yaw_pitch(target_location)
	if offload_laser then offload_protocol("fire", y, p, gsettings.power) else ni.fire(y, p, gsettings.power) end
end

local user_meta
local fast_mode_reqs = {}

local colortheme = {
    status = 0xFFFFFFFF,
    notice = 0xFF8800FF,
    follow = 0xFF00FFFF,
    watch =  0xFFFF00FF,
    laser =  0xFF0000FF,
    entity = 0x00FFFFFF,
    select = 0x00FF00FF
}

local function schedule(fn, time, uniquename)
    if uniquename then 
        work_queue[uniquename] = { os.clock() + time, fn }
    else
        table.insert(work_queue, { os.clock() + time, fn })
    end
end

local function direction_vector(yaw, pitch)
    return vector.new(
        -math.sin(math.rad(yaw)) * math.cos(math.rad(pitch)),
        -math.sin(math.rad(pitch)),
        math.cos(math.rad(yaw)) * math.cos(math.rad(pitch))
    )
end

--[[
local function ni.launch(yaw, pitch, power)
    ni.ni.launch(yaw, pitch, power)
    if user_meta then
        local impulse = vector.new(
            -math.sin(math.rad(yaw)) * math.cos(math.rad(pitch)),
            math.cos(math.rad(yaw)) * math.cos(math.rad(pitch)),
            -math.sin(math.rad(pitch))
        )
        if user_meta.isElytraFlying then
            impulse = 0.4 * impulse
        end
        user_meta.velocity = user_meta.velocity + (impulse * power)
    end
end]]

local gravity_motion_offset = 0.07840001

--[[
local inav_position = nil
local inav_delta = nil
local scaler = 20

local function navigation()
    while true do
        local real_pos = vector.new(gps.locate())
        if inav_position then
            print(inav_position - real_pos)
            local real_delta = (inav_position - real_pos):length()
            local delta_size = inav_delta:length()
            print("calculated delta was", delta_size / real_delta, "of real")
        end
        inav_position = real_pos
        inav_delta = vector.new(0, 0, 0)
        sleep(3)
    end
end
]]

local hud_entities = {}
local function render_hud()
    local flags = {}
    for key, cfg in pairs(settings_cfg) do
        if cfg.shortcode and cfg.type == "bool" then
            if gsettings[key] then
                table.insert(flags, cfg.shortcode)
            end 
        end
    end
    table.sort(flags)
    status_lines.flags = "Flags: " .. table.concat(flags)

    local i = 0
    local ok, err = pcall(group.clear)
    if not ok then
        initialize_group_thing()
    end
    local time = os.epoch "utc"
    for _, text in pairs(status_lines) do
        group.addText({ 0, i * 7 }, text, colortheme.status, 0.6)
        i = i + 1
    end
    for ix, info in pairs(notices) do
        if time >= (info[2] + 2000) then notices[ix] = nil end
        group.addText({ 0, i * 7 }, info[1], colortheme.notice, 0.6)
        i = i + 1
    end
    for thing, count in pairs(hud_entities) do
        local text = thing
        if count ~= 1 then text = text .. " " .. count end
        group.addText({ 0, i * 7 }, text, colortheme[is_target(thing) or "entity"], 0.6)
        i = i + 1
    end
end

local last_velocity
local last_time
local integrated_position = vector.new(0, 0, 0)

local function update_motion_vars(new_meta)
    local time = os.clock()
    if user_meta then
        -- walking hack
        if not (user_meta.isFlying or user_meta.isElytraFlying) then
            if user_meta.isInWater then new_meta.motionY = new_meta.motionY + 0.02
            else new_meta.motionY = new_meta.motionY + gravity_motion_offset end
        end
        user_meta.velocity = vector.new(new_meta.motionX, new_meta.motionY, new_meta.motionZ)
        user_meta.real_velocity = vector.new(new_meta.deltaPosX, new_meta.deltaPosY, new_meta.deltaPosZ)
        integrated_position = integrated_position + user_meta.real_velocity
        user_meta.motionX = new_meta.motionX
        user_meta.motionY = new_meta.motionY
        user_meta.motionZ = new_meta.motionZ
        user_meta.pitch = new_meta.pitch
        user_meta.yaw = new_meta.yaw
        if last_time and last_velocity then
            local timestep = time - last_time
            user_meta.acceleration = (user_meta.velocity - last_velocity) / timestep
        end
        last_velocity = user_meta.velocity
    end
    last_time = time
end

local function scan_entities()
	while true do
		fast_mode_reqs.laser = false
		fast_mode_reqs.acting = false
		local entities = ni.sense()
		local maybe_players = {}
		hud_entities = {}
		local lasers = {}
        
		for _, entity in pairs(entities) do
			entity.s = vector.new(entity.x, entity.y, entity.z)
			entity.v = vector.new(entity.motionX, entity.motionY, entity.motionZ)
            if entity.deltaPosX then
                entity.v = vector.new(entity.deltaPosX, entity.deltaPosY, entity.deltaPosZ)
            end
			if entity.displayName ~= username then
				hud_entities[entity.displayName] = (hud_entities[entity.displayName] or 0) + 1
			end
			if entity.displayName ~= username and entity.displayName == entity.name and (math.floor(entity.yaw) ~= entity.yaw and math.floor(entity.pitch) ~= entity.pitch) then -- player, quite possibly
				entity.v = entity.v + vector.new(0, gravity_motion_offset, 0)
				table.insert(maybe_players, entity)
			end
            if entity.name == "plethora:laser" then
                fast_mode_reqs.laser = true
            end
			if entity.name == "plethora:laser" and not addressed_lasers[entity.id] then
				local closest_approach, param = project(entity.s, entity.v - user_meta.velocity, vector.new(0, 0, 0))
				if param > 0 and vector_sqlength(closest_approach) < 5 then
					push_notice "Laser detected"
					fast_mode_reqs.laser = true
					local time_to_impact = (entity.s:length() / (entity.v - user_meta.velocity):length()) / 20
					print("got inbound laser", time_to_impact, vector_sqlength(closest_approach), param)
					addressed_lasers[entity.id] = true
                    if gsettings.dodge then
                        schedule(function() 
                            push_notice "Executing dodging"
                            local dir2d = vector.new(entity.motionX - user_meta.motionX, 0, entity.motionZ - user_meta.motionZ)
                            local perpendicular_dir2d = vector.new(1, 0, -dir2d.x / dir2d.z)
                            -- NaN contingency measures
                            if perpendicular_dir2d.x ~= perpendicular_dir2d.x or perpendicular_dir2d.z ~= perpendicular_dir2d.z then
                                perpendicular_dir2d = vector.new(-dir2d.z / dir2d.x, 0, 1)
                            end
                            if perpendicular_dir2d.x ~= perpendicular_dir2d.x or perpendicular_dir2d.z ~= perpendicular_dir2d.z then
                                local theta = math.random() * math.pi * 2
                                perpendicular_dir2d = vector.new(math.cos(theta), 0, math.sin(theta))
                            end
                            local y, p = calc_yaw_pitch(perpendicular_dir2d)
                            if math.random(1, 2) == 1 then p = -p end
                            ni.launch(y, p, 3)
                        end, math.max(0, time_to_impact / 2 - 0.1))
                    end
					schedule(function() addressed_lasers[entity.id] = false end, 15)
					table.insert(lasers, entity)
				end
			end
		end
		for _, laser in pairs(lasers) do
			for _, player in pairs(maybe_players) do
				local closest_approach, param = project(laser.s, laser.v, player.s)
				print(player.displayName, closest_approach, param)
				if param < 0 and vector_sqlength(closest_approach - player.s) < 8 and gsettings.counter then
					print("execute counterattack", player.displayName)
					push_notice(("Counterattack %s"):format(player.displayName))
					targets[player.displayName:lower()] = "laser"
				end
			end
		end
		
        render_hud()

        pcall(function()
            group_3d.clear()
            group_3d.recenter()
        end)

        for _, entity in pairs(entities) do
            local action = is_target(entity.displayName)
            if action then
                if action == "laser" then
                    schedule(function() lase(entity) end, 0, entity.id)
                elseif action == "watch" then
                    schedule(function() ni.look(calc_yaw_pitch(entity.s)) end, 0, entity.id)
                elseif action == "follow" then
                    schedule(function()
                        local y, p = calc_yaw_pitch(entity.s)
                        ni.launch(y, p, math.min(entity.s:length() / 24, 2))
                    end, 0, entity.id)
                end
                fast_mode_reqs.acting = true
            end
            if gsettings.highlight and hud_entities[entity.displayName] and hud_entities[entity.displayName] < 20 then
                local color = colortheme[action or "entity"]
                local object = group_3d.addBox(entity.x - 0.25, entity.y - 0.25, entity.z - 0.25)
                object.setColor(color)
                object.setAlpha(128)
                object.setDepthTested(false)
                object.setSize(0.5, 0.5, 0.5)
                if gsettings.ext_highlight then
                    local frame = group_3d.addFrame({entity.x - 0.25, entity.y + 0.25, entity.z - 0.25})
                    frame.setDepthTested(false)
                    frame.addText({0, 0}, entity.displayName, nil, 3)
                end
            end
        end

        local fast_mode = false
        for _, m in pairs(fast_mode_reqs) do
            fast_mode = fast_mode or m
        end

        --status_lines.fast_mode = "Fast scan: " .. bool_to_yn(fast_mode)

        if fast_mode then sleep(0.1) else sleep(0.2) end
    end
end

local flight_shortcodes = {
    o = "off",
    b = "brake",
    h = "hpower",
    l = "lpower",
    s = "std",
    a = "align",
    v = "hover"
}

local flight_powers = {
    std = 1,
    lpower = 0.5,
    hpower = 4,
    align = 1,
    hover = 1
}

local flight_target = nil

local function xz_plane(v)
    return vector.new(v.x, 0, v.z)
end

-- As far as I can tell, a speed of more than 10 in the X/Z plane causes a reset of your velocity by the server and thus horrible rubberbanding.
local function maxvel_compensatory_launch(yaw, pitch, power)
    local effective_power = (user_meta and user_meta.isElytraFlying) and (power * 0.4) or power
    local impulse = direction_vector(yaw, pitch) * effective_power
    local power_over_velocity_limit = math.max(xz_plane(user_meta.velocity + impulse):length() - 10, 0)
    if user_meta and user_meta.isElytraFlying then
        power = power - power_over_velocity_limit / 0.4
    else
        power = power - power_over_velocity_limit
    end
    power = math.min(math.max(power, 0), 4)
    if power > 0 then
        ni.launch(yaw, pitch, power)
    end
end

local function run_flight()
    if flight_shortcodes[gsettings.flight] then gsettings.flight = flight_shortcodes[gsettings.flight] end
    local disp = gsettings.flight
    if user_meta.deltaPosY < -0.3 and gsettings.brake then
            ni.launch(0, 270, math.max(0.4, math.min(4, -user_meta.motionY / 1.5)))
            --ni.launch(0, 270, 0.4)
        --end
        fast_mode_reqs.flying = true
        disp = disp .. " F"
    else
        fast_mode_reqs.flying = false
    end
    if gsettings.flight ==  "std" or gsettings.flight == "hpower" or gsettings.flight == "lpower" or gsettings.flight == "align" or gsettings.flight == "hover" then
        if user_meta.isElytraFlying or user_meta.isSneaking then
            fast_mode_reqs.flying = true
        end
        if user_meta.isElytraFlying ~= user_meta.isSneaking then
            if not user_meta.isAirborne and user_meta.pitch < -15 then
                push_notice "Fast takeoff"
                ni.launch(0, 270, 1)
            end
            local power = flight_powers[gsettings.flight]
            if user_meta.isInWater then
                power = power * 2
            end
            local yaw, pitch = user_meta.yaw, user_meta.pitch
            if pitch == 90 and gsettings.pitch_controls then
                local y, p = calc_yaw_pitch(-user_meta.velocity)
                ni.launch(y, p, math.min(user_meta.velocity:length(), 4))
            else
                local raw_direction = direction_vector(yaw, pitch)
                local impulse = vector.new(raw_direction.x, raw_direction.y * 1.5, raw_direction.z) * power
                impulse = impulse + vector.new(0, 0.1, 0)
                local y, p = calc_yaw_pitch(impulse)
                maxvel_compensatory_launch(y, (gsettings.flight ~= "align" and p) or 10, math.min(4, impulse:length()))
            end
        end
    elseif gsettings.flight == "brake" then
        local y, p = calc_yaw_pitch(-user_meta.velocity)
        ni.launch(y, p, math.min(user_meta.velocity:length(), 1))
        fast_mode_reqs.flying = true
    end
    status_lines.flight = "Flight: " .. disp
end

local function ll_flight_control()
    while true do
        local ok, user_meta_temp
        if ni.getMetaOwner then
		    ok, user_meta_temp = pcall(ni.getMetaOwner, username)
        else
            ok, user_meta_temp = pcall(ni.getMetaByName, username)
        end
        if not ok or not user_meta_temp then
            speaker.playSound("entity.enderdragon.death")
            user_meta = nil
            for name, cfg in pairs(settings_cfg) do
                if cfg.persist == false then
                    cfg.value = nil
                end
            end
            work_queue = {}
            ni = peripheral.wrap "back"
            ni.canvas().clear()
            error("Failed to fetch user metadata (assuming death): " .. tostring(user_meta_temp))
        end
        user_meta = user_meta_temp
        update_motion_vars(user_meta)
        if user_meta.acceleration and gsettings.show_acceleration then
            status_lines.acceleration = ("Acc: %.2f/%.2f"):format(user_meta.acceleration:length(), user_meta.acceleration.y)
        end

		status_lines.vel = ("Vel: %.2f/%.2f"):format(user_meta.velocity:length(), user_meta.motionY)

        render_hud()

        local fast_mode = false
        for _, m in pairs(fast_mode_reqs) do
            fast_mode = fast_mode or m
        end

        --status_lines.fast_mode = "Fast scan: " .. bool_to_yn(fast_mode)

        schedule(run_flight, 0, "flight")

        if not fast_mode then sleep(0.1) end
    end
end

local function queue_handler()
    while true do
        local init = os.clock()
        for index, arg in pairs(work_queue) do
            if arg[1] <= os.clock() then
                arg[2]()
                work_queue[index] = nil
            end
        end
        if os.clock() == init then sleep() end
    end
end

local function estimate_tps()
    while true do
        local game_time_start = os.epoch "utc"
        sleep(5)
        local game_time_end = os.epoch "utc"
        local utc_elapsed_seconds = (game_time_end - game_time_start) / 5000
        status_lines.tps = ("TPS: %.0f"):format(20 / utc_elapsed_seconds)
    end
end

local function within_epsilon(a, b)
    return math.abs(a - b) < 1
end

-- TODO: unified navigation framework
local function fly_to_target()
    local last_s, last_t
    while true do
        while not user_meta do sleep() end
        if flight_target then
            local x, y, z = gps.locate()
            if not y then push_notice "GPS error"
            else
                if y < 256 then
                    ni.launch(0, 270, 4)
                end
                local position = vector.new(x, 0, z)
                local curr_t = os.clock()
                local displacement = flight_target - position
                status_lines.flight_target = ("%d from %d %d"):format(displacement:length(), flight_target.x, flight_target.z)
                local real_displacement = displacement
                if last_t then
                    local delta_t = curr_t - last_t
                    local delta_s = displacement - last_s
                    local deriv = delta_s * (1/delta_t)
                    displacement = displacement + deriv
                end
                local pow = math.max(math.min(4, displacement:length() / 40), 0)
                local yaw, pitch = calc_yaw_pitch(displacement)
                maxvel_compensatory_launch(yaw, pitch, pow)
                --sleep(0)
                last_t = curr_t
                last_s = real_displacement
                if within_epsilon(position.x, flight_target.x) and within_epsilon(position.z, flight_target.z) then flight_target = nil end
            end
        else
            status_lines.flight_target = nil
        end
        sleep(0.1)
    end
end

local function handle_commands()
    while true do
        local _, user, command, args = os.pullEvent "command"
        if user == username then
            if command == "lase" then
                if args[1] then
                    targets[table.concat(args, " "):lower()] = "laser"
                end
            elseif command == "ctg" then
                args[1] = args[1] or ".*"
                local arg = table.concat(args, " ")
                for k, v in pairs(targets) do
                    if k:lower():match(arg) then
                        chatbox.tell(user, k .. ": " .. v)
                        targets[k:lower()] = nil
                    end
                end
            elseif command == "watch" then
                if args[1] then
                    targets[table.concat(args, " "):lower()] = "watch"
                end
            elseif command == "select" then
                if args[1] then
                    targets[table.concat(args, " "):lower()] = "select"
                end
            elseif command == "follow" then
                if args[1] then
                    targets[table.concat(args, " "):lower()] = "follow"
                end
            elseif command == "notice_test" then
                push_notice(table.concat(args, " "))
            elseif command == "flyto" then
                if args[1] == "cancel" or args[1] == nil then
                    flight_target = nil
                else
                    local x, z = tonumber(args[1]), tonumber(args[2])
                    if type(x) ~= "number" or type(z) ~= "number" then
                        chatbox.tell(user, "Usage: \\flyto x z")
                    else
                        flight_target = vector.new(x, 0, z)
                    end
                end
            elseif command == "update" then
                local h, e = http.get "https://osmarks.net/stuff/ni-ctl.lua"
                assert(h, "HTTP: " .. (e or ""))
                local data = h.readAll()
                h.close()
                local file = fs.open(shell.getRunningProgram(), "w")
                file.write(data)
                file.close()
                chatbox.tell(user, "Update updated.")
            else
                for key, cfg in pairs(settings_cfg) do
                    if key == command or cfg.shortcode == command or (cfg.alias and cfg.alias[command]) then
                        if cfg.type == "bool" then
                            if args[1] and (args[1]:match "y" or args[1]:match "t" or args[1]:match "on") then
                                gsettings[key] = true
                            elseif args[1] and (args[1]:match "f" or args[1]:match "^n") then
                                gsettings[key] = false
                            else
                                gsettings[key] = not gsettings[key]
                            end
                            chatbox.tell(user, ("%s: %s"):format(key, tostring(gsettings[key])))
                        elseif cfg.type == "number" then
                            local value = tonumber(args[1])
                            if not value then chatbox.tell(user, "Not a number") end
                            if cfg.max and value > cfg.max then chatbox.tell(user, ("Max is %d"):format(cfg.max)) end
                            if cfg.min and value < cfg.min then chatbox.tell(user, ("Max is %d"):format(cfg.min)) end
                            gsettings[key] = value
                        else
                            gsettings[key] = args[1]
                        end
                        break
                    end
                end
            end
        end
    end
end

local function drill()
    while true do
        if gsettings.drill then
            repeat sleep() until user_meta
            if offload_laser then
                offload_protocol("fire", user_meta.yaw, user_meta.pitch, gsettings.power)
            else
                schedule(function() repeat sleep() until user_meta ni.fire(user_meta.yaw, user_meta.pitch, gsettings.power) end, 0, "drill")
            end
            sleep(0.1)
        else
            os.pullEvent "settings_change"
        end
    end
end

while true do
    local cmds = {ll_flight_control, queue_handler, scan_entities, handle_commands, estimate_tps, fly_to_target, drill}
    if spudnet_background then
        table.insert(cmds, spudnet_background)
    end
    local ok, err = pcall(parallel.waitForAny, unpack(cmds))
    if err == "Terminated" then break end
    printError(err)
    sleep(0.2)
end
