--!native
--!strict
local msgpack = {}

local band = bit32.band
local bor = bit32.bor
local bufferCreate = buffer.create
local bufferLen = buffer.len
local bufferCopy = buffer.copy
local bufferToString = buffer.tostring
local readstring = buffer.readstring
local writestring = buffer.writestring
local readu8 = buffer.readu8
local readi8 = buffer.readi8
local writeu8 = buffer.writeu8
local writei8 = buffer.writei8
local lshift = bit32.lshift
local extract = bit32.extract
local floor = math.floor
local modf = math.modf
local sign = math.sign
local sbyte = string.byte
local tableCreate = table.create

-- MsgPack numbers are big-endian, buffer methods are little-endian
-- Will need to reverse 16 and 32 bit ints, floats and doubles
local function reverse(b: buffer, offset: number, count: number): ()
	for i = 1, count // 2 do
		local byte = readu8(b, offset + i - 1)

		bufferCopy(b, offset + i - 1, b, offset + count - i, 1)
		writeu8(b, offset + count - i, byte)
	end
end

-- Split a 64-bit integer into two 32-bit integers
local function split(value: number): (number, number)
	return value // 0x100000000, value % 0x100000000
end

-- Join two 32-bit integers into a 64-bit integer
local function join(hi: number, lo: number): number
	if lo < 0 then
		lo += 0x100000000
	end

	return hi * 0x100000000 + lo
end

local function writeu16(b: buffer, offset: number, value: number): ()
	buffer.writeu16(b, offset, value)
	reverse(b, offset, 2)
end

local function writei16(b: buffer, offset: number, value: number): ()
	buffer.writei16(b, offset, value)
	reverse(b, offset, 2)
end

local function writeu32(b: buffer, offset: number, value: number): ()
	buffer.writeu32(b, offset, value)
	reverse(b, offset, 4)
end

local function writei32(b: buffer, offset: number, value: number): ()
	buffer.writei32(b, offset, value)
	reverse(b, offset, 4)
end

local function writef64(b: buffer, offset: number, value: number): ()
	buffer.writef64(b, offset, value)
	reverse(b, offset, 8)
end

local function readu16(b: buffer, offset: number): number
	reverse(b, offset, 2)
	return buffer.readu16(b, offset)
end

local function readi16(b: buffer, offset: number): number
	reverse(b, offset, 2)
	return buffer.readi16(b, offset)
end

local function readu32(b: buffer, offset: number): number
	reverse(b, offset, 4)
	return buffer.readu32(b, offset)
end

local function readi32(b: buffer, offset: number): number
	reverse(b, offset, 4)
	return buffer.readi32(b, offset)
end

local function readf32(b: buffer, offset: number): number
	reverse(b, offset, 4)
	return buffer.readf32(b, offset)
end

local function readf64(b: buffer, offset: number): number
	reverse(b, offset, 8)
	return buffer.readf64(b, offset)
end

local function parse(message: buffer, offset: number): (any, number)
	local byte = readu8(message, offset)

	if byte == 0xC0 then -- nil
		return nil, offset + 1
	elseif byte == 0xC2 then -- false
		return false, offset + 1
	elseif byte == 0xC3 then -- true
		return true, offset + 1
	elseif byte == 0xC4 then -- bin 8
		local length = readu8(message, offset + 1)
		local newBuf = bufferCreate(length)
		bufferCopy(newBuf, 0, message, offset + 2, length)
		return newBuf, offset + 2 + length
	elseif byte == 0xC5 then -- bin 16
		local length = readu16(message, offset + 1)
		local newBuf = bufferCreate(length)
		bufferCopy(newBuf, 0, message, offset + 3, length)
		return newBuf, offset + 3 + length
	elseif byte == 0xC6 then -- bin 32
		local length = readu32(message, offset + 1)
		local newBuf = bufferCreate(length)
		bufferCopy(newBuf, 0, message, offset + 5, length)
		return newBuf, offset + 5 + length
	elseif byte == 0xC7 then -- ext 8
		local length = readu8(message, offset + 1)
		local newBuf = bufferCreate(length)
		bufferCopy(newBuf, 0, message, offset + 3, length)
		return msgpack.Extension.new(readu8(message, offset + 2), newBuf), offset + 2 + length
	elseif byte == 0xC8 then -- ext 16
		local length = readu16(message, offset + 1)
		local newBuf = bufferCreate(length)
		bufferCopy(newBuf, 0, message, offset + 4, length)
		return msgpack.Extension.new(readu8(message, offset + 3), newBuf), offset + 3 + length
	elseif byte == 0xC9 then -- ext 32
		local length = readu32(message, offset + 1)
		local newBuf = bufferCreate(length)
		bufferCopy(newBuf, 0, message, offset + 6, length)
		return msgpack.Extension.new(readu8(message, offset + 5), newBuf), offset + 5 + length
	elseif byte == 0xCA then -- float 32
		return readf32(message, offset + 1), offset + 5
	elseif byte == 0xCB then -- float 64
		return readf64(message, offset + 1), offset + 9
	elseif byte == 0xCC then -- uint 8
		return readu8(message, offset + 1), offset + 2
	elseif byte == 0xCD then -- uint 16
		return readu16(message, offset + 1), offset + 3
	elseif byte == 0xCE then -- uint 32
		return readu32(message, offset + 1), offset + 5
	elseif byte == 0xCF then -- uint 64
		return join(readu32(message, offset + 1), readu32(message, offset + 5)), offset + 9
	elseif byte == 0xD0 then -- int 8
		return readi8(message, offset + 1), offset + 2
	elseif byte == 0xD1 then -- int 16
		return readi16(message, offset + 1), offset + 3
	elseif byte == 0xD2 then -- int 32
		return readi32(message, offset + 1), offset + 5
	elseif byte == 0xD3 then -- int 64
		return join(readi32(message, offset + 1), readi32(message, offset + 5)), offset + 9
	elseif byte == 0xD4 then -- fixext 1
		local newBuf = bufferCreate(1)
		bufferCopy(newBuf, 0, message, offset + 2, 1)
		return msgpack.Extension.new(readu8(message, offset + 1), newBuf), offset + 3
	elseif byte == 0xD5 then -- fixext 2
		local newBuf = bufferCreate(2)
		bufferCopy(newBuf, 0, message, offset + 2, 2)
		return msgpack.Extension.new(readu8(message, offset + 1), newBuf), offset + 4
	elseif byte == 0xD6 then -- fixext 4
		local newBuf = bufferCreate(4)
		bufferCopy(newBuf, 0, message, offset + 2, 4)
		return msgpack.Extension.new(readu8(message, offset + 1), newBuf), offset + 6
	elseif byte == 0xD7 then -- fixext 8
		local newBuf = bufferCreate(8)
		bufferCopy(newBuf, 0, message, offset + 2, 8)
		return msgpack.Extension.new(readu8(message, offset + 1), newBuf), offset + 10
	elseif byte == 0xD8 then -- fixext 16
		local newBuf = bufferCreate(16)
		bufferCopy(newBuf, 0, message, offset + 2, 16)
		return msgpack.Extension.new(readu8(message, offset + 1), newBuf), offset + 18
	elseif byte == 0xD9 then -- str 8
		local length = readu8(message, offset + 1)
		return readstring(message, offset + 2, length), offset + 2 + length
	elseif byte == 0xDA then -- str 16
		local length = readu16(message, offset + 1)
		return readstring(message, offset + 3, length), offset + 3 + length
	elseif byte == 0xDB then -- str 32
		local length = readu32(message, offset + 1)
		return readstring(message, offset + 5, length), offset + 5 + length
	elseif byte == 0xDC then -- array 16
		local length = readu16(message, offset + 1)
		local array = tableCreate(length)
		local newOffset = offset + 3

		for i = 1, length do
			array[i], newOffset = parse(message, newOffset)
		end

		return array, newOffset
	elseif byte == 0xDD then -- array 32
		local length = readu32(message, offset + 1)
		local array = tableCreate(length)
		local newOffset = offset + 5

		for i = 1, length do
			array[i], newOffset = parse(message, newOffset)
		end

		return array, newOffset
	elseif byte == 0xDE then -- map 16
		local length = readu16(message, offset + 1)
		local dictionary = {}
		local newOffset = offset + 3
		local key

		for _ = 1, length do
			key, newOffset = parse(message, newOffset)
			dictionary[key], newOffset = parse(message, newOffset)
		end

		return dictionary, newOffset
	elseif byte == 0xDF then -- map 32
		local length = readu32(message, offset + 1)
		local dictionary = {}
		local newOffset = offset + 5
		local key

		for _ = 1, length do
			key, newOffset = parse(message, newOffset)
			dictionary[key], newOffset = parse(message, newOffset)
		end

		return dictionary, newOffset
	elseif byte >= 0xE0 then -- negative fixint
		return byte - 256, offset + 1
	elseif byte <= 0x7F then -- positive fixint
		return byte, offset + 1
	elseif byte - 0x80 <= 0x8F - 0x80 then -- fixmap
		local length = band(byte, 0xF)
		local dictionary = {}
		local newOffset = offset + 1
		local key

		for _ = 1, length do
			key, newOffset = parse(message, newOffset)
			dictionary[key], newOffset = parse(message, newOffset)
		end

		return dictionary, newOffset
	elseif byte - 0x90 <= 0x9F - 0x90 then -- fixarray
		local length = band(byte, 0xF)
		local array = tableCreate(length)
		local newOffset = offset + 1

		for i = 1, length do
			array[i], newOffset = parse(message, newOffset)
		end

		return array, newOffset
	elseif byte - 0xA0 <= 0xBF - 0xA0 then -- fixstr
		local length = byte - 0xA0
		return readstring(message, offset + 1, length), offset + 1 + length
	end

	error("Not all decoder cases are handled, report as bug to msgpack-luau maintainer")
end

local function computeLength(data: any, tableSet: { [any]: boolean }): number
	local dtype = type(data)

	if data == nil then
		return 1
	elseif dtype == "boolean" then
		return 1
	elseif dtype == "string" then
		local length = #data

		if length <= 31 then
			return 1 + length
		elseif length <= 0xFF then
			return 2 + length
		elseif length <= 0xFFFF then
			return 3 + length
		elseif length <= 0xFFFFFFFF then
			return 5 + length
		end

		error("Could not encode - too long string")
	elseif dtype == "buffer" then
		local length = bufferLen(data)

		if length <= 0xFF then
			return 2 + length
		elseif length <= 0xFFFF then
			return 3 + length
		elseif length <= 0xFFFFFFFF then
			return 5 + length
		end

		error("Could not encode - too long binary buffer")
	elseif dtype == "number" then
		-- represents NaN, Inf, -Inf as float 32 to save space
		if data == 0 then
			return 1
		elseif data ~= data then -- NaN
			return 5
		elseif data == math.huge then
			return 5
		elseif data == -math.huge then
			return 5
		end

		local integral, fractional = modf(data)

		if fractional ~= 0 or integral > 0xFFFFFFFF or integral < -0x80000000 then
			return 9 -- float 64 and int 64
		end

		if sign(data) > 0 then
			if integral <= 127 then -- positive fixint
				return 1
			elseif integral <= 0xFF then -- uint 8
				return 2
			elseif integral <= 0xFFFF then -- uint 16
				return 3
			elseif integral <= 0xFFFFFFFF then -- uint 32
				return 5
			end
		else
			if integral >= -0x20 then -- negative fixint
				return 1
			elseif integral >= -0x80 then -- int 8
				return 2
			elseif integral >= -0x8000 then -- int 16
				return 3
			elseif integral >= -0x80000000 then -- int 32
				return 5
			end
		end

		error(string.format('Could not encode - unhandled number "%s"', typeof(data)))
	elseif dtype == "table" then
		local msgpackType = data._msgpackType

		if msgpackType then
			if msgpackType == msgpack.Extension then
				local length = bufferLen(data.data)

				if length == 1 then
					return 3
				elseif length == 2 then
					return 4
				elseif length == 4 then
					return 6
				elseif length == 8 then
					return 10
				elseif length == 16 then
					return 18
				elseif length <= 0xFF then
					return 3 + length
				elseif length <= 0xFFFF then
					return 4 + length
				elseif length <= 0xFFFFFFFF then
					return 6 + length
				end

				error("Could not encode - too long extension data")
			end
		end

		if tableSet[data] then
			error("Can not serialize cyclic table")
		else
			tableSet[data] = true
		end

		local length = #data
		local mapLength = 0

		for _, _ in pairs(data) do
			mapLength += 1
		end

		local headerLen

		if mapLength <= 15 then
			headerLen = 1
		elseif mapLength <= 0xFFFF then
			headerLen = 3
		elseif mapLength <= 0xFFFFFFFF then
			headerLen = 5
		else
			if length == mapLength then
				error("Could not encode - too long array")
			else
				error("Could not encode - too long map")
			end
		end

		if length == mapLength then -- array
			local contentLen = 0

			for _, v in ipairs(data) do
				contentLen += computeLength(v, tableSet)
			end

			return headerLen + contentLen
		else -- map
			local contentLen = 0

			for k, v in pairs(data) do
				contentLen += computeLength(k, tableSet)
				contentLen += computeLength(v, tableSet)
			end

			return headerLen + contentLen
		end
	end

	error(string.format('Could not encode - unsupported datatype "%s"', typeof(data)))
end

local extensionTypeLUT = {
	[1] = 0xD4,
	[2] = 0xD5,
	[4] = 0xD6,
	[8] = 0xD7,
	[16] = 0xD8,
}

local function encode(result: buffer, offset: number, data: any): number
	local dtype = type(data)

	if data == nil then
		writestring(result, offset, "\xC0")
		return offset + 1
	elseif data == false then
		writestring(result, offset, "\xC2")
		return offset + 1
	elseif data == true then
		writestring(result, offset, "\xC3")
		return offset + 1
	elseif dtype == "string" then
		local length = #data

		if length <= 31 then
			writeu8(result, offset, bor(0xA0, length))
			writestring(result, offset + 1, data)
			return offset + 1 + length
		elseif length <= 0xFF then
			writeu8(result, offset, 0xD9)
			writeu8(result, offset + 1, length)
			writestring(result, offset + 2, data)
			return offset + 2 + length
		elseif length <= 0xFFFF then
			writeu8(result, offset, 0xDA)
			writeu16(result, offset + 1, length)
			writestring(result, offset + 3, data)
			return offset + 3 + length
		elseif length <= 0xFFFFFFFF then
			writeu8(result, offset, 0xDB)
			writeu32(result, offset + 1, length)
			writestring(result, offset + 5, data)
			return offset + 5 + length
		end

		error("Could not encode - too long string")
	elseif dtype == "buffer" then
		local length = bufferLen(data)

		if length <= 0xFF then
			writeu8(result, offset, 0xC4)
			writeu8(result, offset + 1, length)
			bufferCopy(result, offset + 2, data)
			return offset + 2 + length
		elseif length <= 0xFFFF then
			writeu8(result, offset, 0xC5)
			writeu16(result, offset + 1, length)
			bufferCopy(result, offset + 3, data)
			return offset + 3 + length
		elseif length <= 0xFFFFFFFF then
			writeu8(result, offset, 0xC6)
			writeu32(result, offset + 1, length)
			bufferCopy(result, offset + 5, data)
			return offset + 5 + length
		end

		error("Could not encode - too long binary buffer")
	elseif dtype == "number" then
		-- represents NaN, Inf, -Inf as float 32 to save space
		if data == 0 then
			writeu8(result, offset, 0)
			return offset + 1
		elseif data ~= data then -- NaN
			writestring(result, offset, "\xCA\x7F\x80\x00\x01")
			return offset + 5
		elseif data == math.huge then
			writestring(result, offset, "\xCA\x7F\x80\x00\x00")
			return offset + 5
		elseif data == -math.huge then
			writestring(result, offset, "\xCA\xFF\x80\x00\x00")
			return offset + 5
		end

		local integral, fractional = modf(data)

		if fractional ~= 0 then -- float 64
			writeu8(result, offset, 0xCB)
			writef64(result, offset + 1, data)
			return offset + 9
		end

		if sign(data) > 0 then
			if integral <= 127 then -- positive fixint
				writeu8(result, offset, integral)
				return offset + 1
			elseif integral <= 0xFF then -- uint 8
				writeu8(result, offset, 0xCC)
				writeu8(result, offset + 1, integral)
				return offset + 2
			elseif integral <= 0xFFFF then -- uint 16
				writeu8(result, offset, 0xCD)
				writeu16(result, offset + 1, integral)
				return offset + 3
			elseif integral <= 0xFFFFFFFF then -- uint 32
				writeu8(result, offset, 0xCE)
				writeu32(result, offset + 1, integral)
				return offset + 5
			elseif integral <= 0xFFFFFFFFFFFFFFFF then -- uint 64
				local high, low = split(integral)

				writeu8(result, offset, 0xCF)
				writeu32(result, offset + 1, high)
				writeu32(result, offset + 5, low)

				return offset + 9
			end
		else
			if integral >= -0x20 then -- negative fixint
				writeu8(result, offset, bor(0xE0, extract(integral, 0, 5)))
				return offset + 1
			elseif integral >= -0x80 then -- int 8
				writeu8(result, offset, 0xD0)
				writei8(result, offset + 1, integral)
				return offset + 2
			elseif integral >= -0x8000 then -- int 16
				writeu8(result, offset, 0xD1)
				writei16(result, offset + 1, integral)
				return offset + 3
			elseif integral >= -0x80000000 then -- int 32
				writeu8(result, offset, 0xD2)
				writei32(result, offset + 1, integral)
				return offset + 5
			elseif integral >= -0x8000000000000000 then -- int 64
				local high, low = split(integral)

				writeu8(result, offset, 0xD3)
				writei32(result, offset + 1, high)
				writei32(result, offset + 5, low)

				return offset + 9
			end
		end

		-- Catch large floats as modf does not work for them
		writeu8(result, offset, 0xCB)
		writef64(result, offset + 1, data)
		return offset + 9
	elseif dtype == "table" then
		local msgpackType = data._msgpackType

		if msgpackType then
			if msgpackType == msgpack.Extension then
				local length = bufferLen(data.data)
				local extType = extensionTypeLUT[length]

				if extType then
					writeu8(result, offset, extType)
					writeu8(result, offset + 1, data.type)
					bufferCopy(result, offset + 2, data.data)
					return offset + 2 + length
				end

				if length <= 0xFF then
					writeu8(result, offset, 0xC7)
					writeu8(result, offset + 1, length)
					writeu8(result, offset + 2, data.type)
					bufferCopy(result, offset + 3, data.data)
					return offset + 3 + length
				elseif length <= 0xFFFF then
					writeu8(result, offset, 0xC8)
					writeu16(result, offset + 1, length)
					writeu8(result, offset + 3, data.type)
					bufferCopy(result, offset + 4, data.data)
					return offset + 4 + length
				elseif length <= 0xFFFFFFFF then
					writeu8(result, offset, 0xC9)
					writeu32(result, offset + 1, length)
					writeu8(result, offset + 5, data.type)
					bufferCopy(result, offset + 6, data.data)
					return offset + 6 + length
				end

				error("Could not encode - too long extension data")
			end
		end

		local length = #data
		local mapLength = 0

		for _, _ in pairs(data) do
			mapLength += 1
		end

		if length == mapLength then -- array
			local newOffset = offset

			if length <= 15 then
				writeu8(result, offset, bor(0x90, mapLength))
				newOffset += 1
			elseif length <= 0xFFFF then
				writeu8(result, offset, 0xDC)
				writeu16(result, offset + 1, length)
				newOffset += 3
			elseif length <= 0xFFFFFFFF then
				writeu8(result, offset, 0xDD)
				writeu32(result, offset + 1, length)
				newOffset += 5
			else
				error("Could not encode - too long array")
			end

			for _, v in ipairs(data) do
				newOffset = encode(result, newOffset, v)
			end

			return newOffset
		else -- map
			local newOffset = offset

			if mapLength <= 15 then
				writeu8(result, offset, bor(0x80, mapLength))
				newOffset += 1
			elseif mapLength <= 0xFFFF then
				writeu8(result, offset, 0xDE)
				writeu16(result, offset + 1, mapLength)
				newOffset += 3
			elseif mapLength <= 0xFFFFFFFF then
				writeu8(result, offset, 0xDF)
				writeu32(result, offset + 1, mapLength)
				newOffset += 5
			else
				error("Could not encode - too long map")
			end

			for k, v in pairs(data) do
				newOffset = encode(result, newOffset, k)
				newOffset = encode(result, newOffset, v)
			end

			return newOffset
		end
	end

	error(string.format('Could not encode - unsupported datatype "%s"', typeof(data)))
end

msgpack.Extension = {}

function msgpack.Extension.new(extensionType: number, blob: buffer): Extension
	return {
		_msgpackType = msgpack.Extension,
		type = extensionType,
		data = blob,
	}
end

function msgpack.utf8Encode(message: string): string
	local messageLength = #message
	local nBytes = math.ceil(messageLength * (8 / 7))
	local result = bufferCreate(nBytes)

	local bitPointer = 0

	for i = 1, nBytes do
		local j = 1 + floor(bitPointer / 8)
		local bitRemainder = bitPointer % 8
		local byte = sbyte(message, j)

		if bitRemainder == 0 then
			writeu8(result, i - 1, extract(byte, 1, 7))
		elseif bitRemainder == 1 then
			writeu8(result, i - 1, extract(byte, 0, 7))
		else
			local nextByte = sbyte(message, j + 1) or 0

			writeu8(
				result,
				i - 1,
				bor(
					lshift(extract(byte, 0, 8 - bitRemainder), bitRemainder - 1),
					extract(nextByte, 9 - bitRemainder, bitRemainder - 1)
				)
			)
		end

		bitPointer += 7
	end

	return bufferToString(result)
end

function msgpack.utf8Decode(message: string): string
	local nBytes = floor(#message * 7 / 8)
	local result = bufferCreate(nBytes)

	local bitPointer = 0

	for i = 1, nBytes do
		local bitRemainder = bitPointer % 7
		local byte = sbyte(message, 1 + floor(bitPointer / 7))
		local nextByte = sbyte(message, 2 + floor(bitPointer / 7))

		writeu8(
			result,
			i - 1,
			bor(
				lshift(extract(byte, 0, 7 - bitRemainder), bitRemainder + 1),
				extract(nextByte, 6 - bitRemainder, 1 + bitRemainder)
			)
		)

		bitPointer += 8
	end

	return bufferToString(result)
end

function msgpack.decode(message: string): any
	if message == "" then
		error("Could not decode - input string is too short")
	end

	local messageBuf = buffer.fromstring(message)

	return (parse(messageBuf, 0))
end

function msgpack.encode(data: any): string
	local length = computeLength(data, {})
	local result = bufferCreate(length)

	encode(result, 0, data)

	return bufferToString(result)
end

export type Extension = { _msgpackType: typeof(msgpack.Extension), type: number, data: buffer }

return msgpack

--[[
MIT License

Copyright (c) 2024 Valts Liepiņš

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]


local plugin = script:FindFirstAncestorWhichIsA("Plugin")

local Argon = script:FindFirstAncestor("Argon")

local Util = require(Argon.Util)
local Log = require(Argon.Log)

export type Level = "Place" | "Game" | "Global"
export type Setting =
	"InitialSyncPriority"
	| "Host"
	| "Port"
	| "AutoConnect"
	| "AutoReconnect"
	| "LiveHydrate"
	| "KeepUnknowns"
	| "TwoWaySync"
	| "SyncbackProperties"
	| "OnlyCodeMode"
	| "OpenInEditor"
	| "DisplayPrompts"
	| "ChangesThreshold"
	| "LogLevel"
	| "OverridePackages"

type Config = {
	__configs: { [Level]: { [Setting]: any } },
	__callbacks: { [Setting]: { (value: any) -> () } },
	load: () -> (),
	get: (self: Config, setting: Setting, level: Level?, exact: boolean?) -> any,
	getDefault: (self: Config, setting: Setting) -> any,
	set: (self: Config, setting: Setting, value: any, level: Level) -> (),
	restoreDefaults: (self: Config, level: Level) -> (),
	onChanged: (self: Config, setting: Setting, callback: (value: any) -> ()) -> () -> (),
}

local DEFAULTS = {
	InitialSyncPriority = "Server",
	Host = "localhost",
	Port = 8000,
	AutoConnect = true,
	AutoReconnect = false,
	LiveHydrate = true,
	KeepUnknowns = false,
	TwoWaySync = false,
	SyncbackProperties = false,
	OnlyCodeMode = true,
	OpenInEditor = false,
	DisplayPrompts = "Always",
	ChangesThreshold = 5,
	LogLevel = "Warn",
	OverridePackages = true,
}

local CONFIGS_ORDERED = { "Place", "Game", "Global" }
local CONFIGS = {
	Place = "ArgonConfigPlace_" .. game.PlaceId,
	Game = "ArgonConfigGame_" .. game.GameId,
	Global = "ArgonConfigGlobal",
}

local Config: Config = {
	__configs = {},
	__callbacks = {},
} :: Config

function Config.load()
	for level, config in CONFIGS do
		config = plugin:GetSetting(config)

		if config and type(config) == "table" then
			Config.__configs[level] = config
		else
			Config.__configs[level] = {}
		end
	end
end

function Config:get(setting, level, exact)
	local default = DEFAULTS[setting]
	local relevant = not level

	if default == nil then
		Log.error(`Setting '{setting}' does not exist!`)
	end

	if exact then
		return self.__configs[level][setting]
	end

	if level and level ~= "Global" then
		local index = table.find(CONFIGS_ORDERED, level)

		if not index then
			Log.error(`Config level '{level}' does not exist!`)
		end

		level = CONFIGS_ORDERED[index + 1]
	end

	for _, config in CONFIGS_ORDERED do
		if not relevant then
			if config == level then
				relevant = true
			else
				continue
			end
		end

		local value = self.__configs[config][setting]

		if value ~= nil then
			return value
		end
	end

	return default
end

function Config:getDefault(setting)
	local default = DEFAULTS[setting]

	if default == nil then
		Log.error(`Setting '{setting}' does not exist!`)
	end

	return default
end

function Config:set(setting, value, level)
	local default = DEFAULTS[setting]
	local config = self.__configs[level]

	if default == nil then
		Log.error(`Setting '{setting}' does not exist!`)
	end

	if value == config[setting] then
		return
	end

	value = Util.cast(value, type(default))
	config[setting] = if value == default then nil else value

	if self.__callbacks[setting] then
		for _, callback in self.__callbacks[setting] do
			callback(self:get(setting))
		end
	end

	if not next(config) then
		plugin:SetSetting(CONFIGS[level], nil)
	else
		plugin:SetSetting(CONFIGS[level], config)
	end
end

function Config:restoreDefaults(level)
	self.__configs[level] = {}

	for setting, _ in self.__configs[level] do
		if self.__callbacks[setting] then
			for _, callback in self.__callbacks[setting] do
				callback(self:getDefault(setting))
			end
		end
	end

	plugin:SetSetting(CONFIGS[level], nil)
end

function Config:onChanged(setting, callback)
	if not self.__callbacks[setting] then
		self.__callbacks[setting] = {}
	end

	local id = Util.generateGUID()
	self.__callbacks[setting][id] = callback

	return function()
		self.__callbacks[setting][id] = nil
	end
end

return Config


local Argon = script:FindFirstAncestor("Argon")

local Types = require(Argon.Types)

local Changes = {}
Changes.__index = Changes

function Changes.new()
	local self = {
		additions = {} :: { Types.AddedSnapshot },
		updates = {} :: { Types.UpdatedSnapshot },
		removals = {} :: { Types.Ref | Instance },
	}

	return setmetatable(self, Changes)
end

function Changes:add(snapshot: Types.Snapshot, parent: Types.Ref?)
	local addedSnapshot = snapshot
	addedSnapshot.parent = parent or addedSnapshot.parent

	table.insert(self.additions, addedSnapshot)
end

function Changes:update(snapshot: Types.UpdatedSnapshot)
	table.insert(self.updates, snapshot)
end

function Changes:remove(object: Types.Ref | Instance)
	table.insert(self.removals, object)
end

function Changes:join(other: Types.Changes, check: boolean?)
	for _, addition in other.additions do
		if check then
			self:removePrevious("additions", addition.id)
		end

		self:add(addition)
	end

	for _, update in other.updates do
		if check then
			self:removePrevious("updates", update.id)
		end

		self:update(update)
	end

	for _, removal in other.removals do
		if check then
			self:removePrevious("removals", removal)
		end

		self:remove(removal)
	end
end

function Changes:removePrevious(kind: "additions" | "updates" | "removals", id: Types.Ref)
	local changeId = buffer.tostring(id)

	for index, change in self[kind] do
		if buffer.tostring(kind == "removals" and change or change.id) == changeId then
			table.remove(self[kind], index)
		end
	end
end

function Changes:total(): number
	return #self.additions + #self.updates + #self.removals
end

function Changes:isEmpty(): boolean
	return self:total() == 0
end

function Changes.Total(changes: Types.Changes): number
	return #changes.additions + #changes.updates + #changes.removals
end

return Changes


local Argon = script:FindFirstAncestor("Argon")

local Util = require(Argon.Util)

export type Error = {
	message: string,
	kind: string,
}

local Error = {
	Unknown = "Unknown Core error: $1",
	-- Initial prompts
	GameId = "Current GameId: $1 does not match the server gameId: $2",
	PlaceIds = "Current PlaceId: $1 is not included in the server placeIds: $2",
	Version = "Client version: $1 is not compatible with the server version: $2",
	TooManyChanges = "There are $1 additions, $2 updates, $3 deletions compared to the server",
	Terminated = "Terminated connection by the user",
	-- Core errors
	UnknownEvent = "Received an unknown event from the server: $1, with data: $2",
	Disconnected = "Disconnected from the server: $1",
	-- Process errors
	DecodeFailed = "Failed to decode snapshot property: $1 with value: $2",
	EncodeFailed = "Failed to encode snapshot property: $1 with value: $2",
	ReadFailed = "Failed to read property: $1 from instance: $2, reason: $3",
	WriteFailed = "Failed to write property: $1 for instance: $2, reason: $3",
	NoInstanceAdd = "Tried to add an instance whose parent that does not exist in the tree: $1",
	NoInstanceUpdate = "Tried to update an instance that does not exist in the tree: $1",
	NoInstanceRemove = "Tried to remove an instance that does not exist in the tree: $1",
	NotCreatable = "Tried to create an instance of class: $1 that is not creatable",
	NotRemovable = "Tried to remove an instance of class: $1 that is not removable",
	HydrationFailed = "Failed to hydrate root service, snapshot: $1",
	MeshLoadFailed = "Failed to load MeshPart: $1. If you just updated Roblox Studio, please restart Argon session!",
}

function Error.__new(message: string, kind: string): Error
	local err = setmetatable({
		message = message,
		kind = kind,
	}, Error)

	return err
end

function Error.new(err: Error, ...): Error
	err = table.clone(err)

	for i, v in { ... } do
		err.message = err.message:gsub("$" .. i, Util.stringify(v))
	end

	return err
end

function Error:__eq(other: Error): boolean
	return self.kind == other.kind
end

function Error:__tostring(): string
	return self.message
end

-- Convert all strings to Error objects
for kind, message in Error do
	if type(message) == "string" then
		Error[kind] = Error.__new(message, kind)
	end
end

return Error


local Argon = script:FindFirstAncestor("Argon")

local Types = require(Argon.Types)

local Meta = {}
Meta.__index = Meta

function Meta.new(): Types.Meta
	local self = setmetatable({}, Meta)

	self.keepUnknowns = false
	self.meshSource = nil

	return self
end

return Meta


local Argon = script:FindFirstAncestor("Argon")

local Types = require(Argon.Types)
local Dom = require(Argon.Dom)
local Log = require(Argon.Log)
local Config = require(Argon.Config)

local equals = require(Argon.Helpers.equals)
local generateRef = require(Argon.Helpers.generateRef)

local Snapshot = require(script.Parent.Parent.Snapshot)
local Error = require(script.Parent.Parent.Error)

local function isScriptRelated(instance: Instance): boolean
	return instance:IsA("LuaSourceContainer") or instance:FindFirstChildWhichIsA("LuaSourceContainer", true) ~= nil
end

local ReadProcessor = {}
ReadProcessor.__index = ReadProcessor

function ReadProcessor.new(tree)
	return setmetatable({
		tree = tree,
	}, ReadProcessor)
end

function ReadProcessor:onAdd(instance: Instance, __parentId: Types.Ref?): Types.AddedSnapshot?
	Log.trace("Detected addition of", instance)

	if self.tree:getId(instance) then
		Log.trace("Instance already tracked. Skipping..")
		return nil
	end

	if not isScriptRelated(instance) and Config:get("OnlyCodeMode") then
		Log.trace("Instance is not script related (Only Code Mode). Skipping..")
		return nil
	end

	local parentId = __parentId or self.tree:getId(instance.Parent)

	if not parentId then
		Log.trace("Unknown instance. Skipping..")
		return nil
	end

	local id = generateRef()
	local properties = {}
	local children = {}

	if self.shouldSyncProperties(instance) then
		for property, default in Dom.getDefaultProperties(instance.ClassName) do
			local readSuccess, instanceValue = Dom.readProperty(instance, property)

			if not readSuccess then
				local err = Error.new(Error.ReadFailed, property, instance, instanceValue.kind)
				Log.warn(err)

				continue
			end

			local _, defaultValue = Dom.EncodedValue.decode(default)

			if not equals(instanceValue, defaultValue) then
				local propertyType = next(default)
				local encodeSuccess, encodedValue = Dom.EncodedValue.encode(instanceValue, propertyType)

				if not encodeSuccess then
					local err = Error.new(Error.EncodeFailed, property, instanceValue)
					Log.warn(err)

					continue
				end

				properties[property] = encodedValue
			end
		end
	end

	for _, child in instance:GetChildren() do
		local snapshot = self:onAdd(child, id)

		if snapshot then
			table.insert(children, snapshot)
		end
	end

	local snapshot = Snapshot.new(id)
		:withName(instance.Name)
		:withClass(instance.ClassName)
		:withProperties(properties)
		:withChildren(children)

	self.tree:insertInstance(instance, id, snapshot.meta)

	if __parentId then
		return snapshot
	end

	if parentId then
		snapshot.parent = parentId
	else
		local currentInstance = instance
		local currentSnapshot = nil
		local rootId = nil

		repeat
			parentId = generateRef()
			currentInstance = currentInstance.Parent
			rootId = self.tree:getId(currentInstance)

			if rootId then
				break
			end

			-- In theory this should never happen
			if currentInstance == game then
				Log.warn("Failed to find root instance for", instance)
				return nil
			end

			currentSnapshot = Snapshot.new(parentId)
				:withName(currentInstance.Name)
				:withClass(currentInstance.ClassName)
				:withChildren({ snapshot })

			self.tree:insertInstance(currentInstance, parentId, currentSnapshot.meta)
		until rootId

		currentSnapshot.parent = rootId
		snapshot = currentSnapshot
	end

	return snapshot
end

function ReadProcessor:onChange(instance: Instance, property: string?): Types.UpdatedSnapshot?
	Log.trace("Detected change of", instance, property)

	local id = self.tree:getId(instance)

	if not id then
		Log.trace("Unknown instance. Skipping..")
		return nil
	end

	if not isScriptRelated(instance) and Config:get("OnlyCodeMode") then
		Log.trace("Instance is not script related (Only Code Mode). Skipping..")
		return nil
	end

	if property and not self.shouldSyncProperties(instance, property) then
		Log.trace("Instance does not pass sync filter. Skipping..")
		return nil
	end

	if property == "Name" then
		return Snapshot.newUpdated(id):withName(instance.Name)
	end

	local properties = {}

	for property, default in Dom.getDefaultProperties(instance.ClassName) do
		local readSuccess, instanceValue = Dom.readProperty(instance, property)

		if not readSuccess then
			local err = Error.new(Error.ReadFailed, property, instance, instanceValue.kind)
			Log.warn(err)

			continue
		end

		local _, defaultValue = Dom.EncodedValue.decode(default)

		if not equals(instanceValue, defaultValue) then
			local propertyType = next(default)
			local encodeSuccess, encodedValue = Dom.EncodedValue.encode(instanceValue, propertyType)

			if not encodeSuccess then
				local err = Error.new(Error.EncodeFailed, property, instanceValue)
				Log.warn(err)

				continue
			end

			properties[property] = encodedValue
		end
	end

	return Snapshot.newUpdated(id):withProperties(properties)
end

function ReadProcessor:onRemove(instance: Instance): Types.Ref?
	Log.trace("Detected removal of", instance)

	local id = self.tree:getId(instance)

	if not id then
		Log.trace("Unknown instance. Skipping..")
		return nil
	end

	self.tree:removeById(id)

	return id
end

function ReadProcessor.shouldSyncProperties(instance: Instance, property: string?): boolean
	if instance:IsA("LuaSourceContainer") or property == "Name" then
		return true
	end

	if property and not Dom.getDefaultProperties(instance.ClassName)[property] then
		return false
	end

	return Config:get("SyncbackProperties")
end

return ReadProcessor


local Argon = script:FindFirstAncestor("Argon")

local Dom = require(Argon.Dom)
local Log = require(Argon.Log)
local Types = require(Argon.Types)
local Config = require(Argon.Config)

local equals = require(Argon.Helpers.equals)
local setWaypoint = require(Argon.Helpers.setWaypoint)

local Error = require(script.Parent.Parent.Error)

local MESH_RELOAD_PROPERTIES = { "MeshContent", "InitialSize" }

local function loadMesh(path: string)
	local success, mesh = pcall(function()
		return game:GetObjects("rbxasset://argon/" .. path)[1]
	end)

	if not success then
		local err = Error.new(Error.MeshLoadFailed, mesh)
		Log.warn(err)

		return nil
	end

	return mesh
end

local function shouldReloadMesh(mesh: MeshPart, properties: Types.Properties?)
	if not properties then
		return false
	end

	for _, property in MESH_RELOAD_PROPERTIES do
		local value = properties[property]

		if not value then
			continue
		end

		local decodeSuccess, snapshotValue = Dom.EncodedValue.decode(value)

		if not decodeSuccess then
			local err = Error.new(Error.DecodeFailed, property, value)
			Log.warn(err)

			continue
		end

		property = property == "InitialSize" and "MeshSize" or property

		if not equals(mesh[property], snapshotValue) then
			return true
		end
	end

	return false
end

local function findChild(parent: Instance, name: string, class: string): Instance?
	for _, child in parent:GetChildren() do
		if child.Name == name and child.ClassName == class then
			return child
		end
	end

	return nil
end

local function warnNotCreatable(class: string)
	local err = Error.new(Error.NotCreatable, class)

	if class == "PackageLink" and not Config:get("OverridePackages") then
		Log.info(err)
	else
		Log.warn(err)
	end
end

local WriteProcessor = {}
WriteProcessor.__index = WriteProcessor

function WriteProcessor.new(tree, client)
	return setmetatable({
		tree = tree,
		client = client,
	}, WriteProcessor)
end

function WriteProcessor:applyChanges(changes: Types.Changes, initial: boolean?)
	Log.trace("Applying changes..")

	setWaypoint("Sync")

	for _, snapshot in changes.additions do
		self:applyAddition(snapshot, initial)
	end

	for _, snapshot in changes.updates do
		self:applyUpdate(snapshot, initial)
	end

	for _, object in changes.removals do
		self:applyRemoval(object, initial)
	end

	if initial then
		setWaypoint("Initial Sync")
	end
end

function WriteProcessor:applyAddition(
	snapshot: Types.AddedSnapshot,
	initial: boolean?,
	skipExisting: boolean?
): Instance?
	Log.trace("Applying addition of", snapshot)

	local parent = self.tree:getInstance(snapshot.parent)

	if not parent then
		if Config:get("LiveHydrate") then
			local addedSnapshot = self.client:getSnapshot(snapshot.parent):expect()

			if addedSnapshot then
				parent = self:applyAddition(addedSnapshot, initial, true)
				skipExisting = true

				Log.info("Live hydrated parent:", parent)
			end
		end

		if not parent then
			local err = Error.new(Error.NoInstanceAdd, snapshot)
			Log.warn(err)

			return
		end
	end

	if skipExisting then
		local instance = findChild(parent, snapshot.name, snapshot.class)

		if instance then
			return instance
		end
	end

	local instance
	local isService = false

	if Dom.isCreatable(snapshot.class) then
		instance = Instance.new(snapshot.class)

		if snapshot.meta.meshSource then
			instance = loadMesh(snapshot.meta.meshSource)

			if not instance then
				return
			end
		end
	elseif parent == game then
		local service = game:FindFirstChildOfClass(snapshot.class)

		if service then
			instance = service
			isService = true
		else
			local err = Error.new(Error.HydrationFailed, snapshot)
			Log.warn(err)

			return
		end
	else
		warnNotCreatable(snapshot.class)
		return
	end

	instance.Name = snapshot.name

	for property, value in snapshot.properties do
		local decodeSuccess, decodedValue = Dom.EncodedValue.decode(value)

		if not decodeSuccess then
			local err = Error.new(Error.DecodeFailed, property, value)
			Log.warn(err)

			continue
		end

		local writeSuccess, reason = Dom.writeProperty(instance, property, decodedValue)

		if not writeSuccess then
			local err = Error.new(Error.WriteFailed, property, instance, reason.kind)
			Log.warn(err)
		end
	end

	if not isService then
		instance.Parent = parent
	end

	self.tree:insertInstance(instance, snapshot.id, snapshot.meta)

	for _, child in snapshot.children do
		child.parent = snapshot.id
		self:applyAddition(child, initial)
	end

	if not initial then
		setWaypoint("Add", instance.Name)
	end

	return instance
end

function WriteProcessor:applyUpdate(snapshot: Types.UpdatedSnapshot, initial: boolean?): Instance?
	Log.trace("Applying update of", snapshot)

	local instance = self.tree:getInstance(snapshot.id)

	if not instance then
		if Config:get("LiveHydrate") then
			local addedSnapshot = self.client:getSnapshot(snapshot.id):expect()

			if addedSnapshot then
				instance = self:applyAddition(addedSnapshot, initial)
				Log.info("Live hydrated instance:", instance)
			end
		end

		if not instance then
			local err = Error.new(Error.NoInstanceUpdate, snapshot)
			Log.warn(err)

			return
		end
	end

	local defaultProperties

	if snapshot.meta then
		local meta = self.tree:getMeta(snapshot.id)

		if
			meta.keepUnknowns ~= snapshot.meta.keepUnknowns
			and (not snapshot.meta.keepUnknowns and not Config:get("KeepUnknowns"))
		then
			for _, child in instance:GetChildren() do
				if self.tree:getId(child) then
					continue
				end

				self:applyRemoval(child)
			end
		end

		self.tree:updateMeta(snapshot.id, snapshot.meta)
	end

	if
		snapshot.class
		or (snapshot.meta and snapshot.meta.meshSource and shouldReloadMesh(instance, snapshot.properties))
	then
		local newInstance

		if snapshot.class then
			if not Dom.isCreatable(snapshot.class) then
				warnNotCreatable(snapshot.class)
				return
			end

			newInstance = Instance.new(snapshot.class)
			defaultProperties = Dom.getDefaultProperties(snapshot.class)
		else
			newInstance = loadMesh(snapshot.meta.meshSource :: string)

			if not newInstance then
				return
			end

			defaultProperties = Dom.getDefaultProperties(newInstance.ClassName)
		end

		newInstance.Name = instance.Name

		for property in defaultProperties do
			local readSuccess, instanceValue = Dom.readProperty(instance, property)

			if readSuccess then
				local writeSuccess, reason = Dom.writeProperty(newInstance, property, instanceValue)

				if not writeSuccess then
					local err = Error.new(Error.WriteFailed, property, newInstance, reason.kind)
					Log.warn(err)
				end
			end
		end

		for _, child in instance:GetChildren() do
			child.Parent = newInstance
		end

		newInstance.Parent = instance.Parent
		instance.Parent = nil

		self.tree:updateInstance(snapshot.id, newInstance)

		instance = newInstance
	end

	if snapshot.name then
		instance.Name = snapshot.name
	end

	if snapshot.properties then
		if initial then
			for property, value in snapshot.properties do
				local decodeSuccess, decodedValue = Dom.EncodedValue.decode(value)

				if not decodeSuccess then
					local err = Error.new(Error.DecodeFailed, property, value)
					Log.warn(err)

					continue
				end

				local writeSuccess, reason = Dom.writeProperty(instance, property, decodedValue)

				if not writeSuccess then
					local err = Error.new(Error.WriteFailed, property, instance, reason.kind)
					Log.warn(err)
				end
			end
		else
			for property, default in (defaultProperties or Dom.getDefaultProperties(instance.ClassName)) do
				local value = snapshot.properties[property]

				if value then
					local decodeSuccess, snapshotValue = Dom.EncodedValue.decode(value)

					if not decodeSuccess then
						local err = Error.new(Error.DecodeFailed, property, value)
						Log.warn(err)

						continue
					end

					local writeSuccess, reason = Dom.writeProperty(instance, property, snapshotValue)

					if not writeSuccess then
						local err = Error.new(Error.WriteFailed, property, instance, reason.kind)
						Log.warn(err)
					end
				else
					local _, defaultValue = Dom.EncodedValue.decode(default)
					local writeSuccess, reason = Dom.writeProperty(instance, property, defaultValue)

					if not writeSuccess then
						local err = Error.new(Error.WriteFailed, property, instance, reason.kind)
						Log.warn(err)
					end
				end
			end
		end
	end

	if not initial then
		setWaypoint("Update", instance.Name)
	end

	return instance
end

function WriteProcessor:applyRemoval(object: Types.Ref | Instance, initial: boolean?): Instance?
	Log.trace("Applying removal of", object)

	if typeof(object) == "Instance" then
		if not Dom.isCreatable(object.ClassName) then
			return
		end

		self.tree:removeByInstance(object)
		object.Parent = nil
	else
		local instance = self.tree:getInstance(object)

		if not instance then
			local err = Error.new(Error.NoInstanceRemove, object)
			Log.warn(err)

			return
		end

		if not Dom.isCreatable(instance.ClassName) then
			local err = Error.new(Error.NotRemovable, instance.ClassName)
			Log.warn(err)

			return
		end

		self.tree:removeById(object)
		instance.Parent = nil

		object = instance
	end

	if not initial then
		setWaypoint("Remove", (object :: Instance).Name)
	end

	return object
end

return WriteProcessor


local Argon = script:FindFirstAncestor("Argon")

local Dom = require(Argon.Dom)
local Log = require(Argon.Log)
local Util = require(Argon.Util)
local Types = require(Argon.Types)
local Config = require(Argon.Config)
local equals = require(Argon.Helpers.equals)

local Error = require(script.Parent.Error)
local Changes = require(script.Parent.Changes)

local ReadProcessor = require(script.Read)
local WriteProcessor = require(script.Write)

local function isPackageDescendant(instance: Instance): boolean
	repeat
		if instance:FindFirstChildWhichIsA("PackageLink") then
			return true
		end

		instance = instance.Parent or game
	until instance == game

	return false
end

local Processor = {}
Processor.__index = Processor

function Processor.new(tree, client)
	local self = {
		tree = tree,
		read = ReadProcessor.new(tree),
		write = WriteProcessor.new(tree, client),
	}

	return setmetatable(self, Processor)
end

function Processor:init(snapshot: Types.Snapshot, initialSyncPriority: string): Types.Changes
	Log.trace("Hydrating initial snapshot..")

	self:hydrate(snapshot, game)

	if initialSyncPriority == "None" then
		return Changes.new()
	end

	Log.trace("Diffing initial snapshot..")

	local changes = Changes.new()

	for _, child in snapshot.children do
		changes:join(self:diff(child, snapshot.id, initialSyncPriority == "Client"))
	end

	if initialSyncPriority == "Server" and not Config:get("OverridePackages") then
		local temp = {}

		for _, snapshot in changes.additions do
			local instance = self.tree:getInstance(snapshot.parent)

			if instance and isPackageDescendant(instance) then
				table.insert(temp, snapshot)
			end
		end

		for i, snapshot in temp do
			table.remove(changes.additions, table.find(changes.additions, snapshot))
			temp[i] = nil
		end

		for _, snapshot in changes.updates do
			local instance = self.tree:getInstance(snapshot.id)

			if instance and isPackageDescendant(instance) then
				table.insert(temp, snapshot)
			end
		end

		for i, snapshot in temp do
			table.remove(changes.updates, table.find(changes.updates, snapshot))
			temp[i] = nil
		end

		for _, instance in changes.removals do
			if isPackageDescendant(instance) then
				table.insert(temp, instance)
			end
		end

		for i, instance in temp do
			table.remove(changes.removals, table.find(changes.removals, instance))
			temp[i] = nil
		end
	end

	return changes
end

function Processor:hydrate(snapshot: Types.Snapshot, instance: Instance)
	self.tree:insertInstance(instance, snapshot.id, snapshot.meta)

	local children = instance:GetChildren()
	local hydrated = table.create(#children, false)

	for _, snapshotChild in snapshot.children do
		for index, child in children do
			if hydrated[index] then
				continue
			end

			if child.Name == snapshotChild.name and child.ClassName == snapshotChild.class then
				self:hydrate(snapshotChild, child)
				hydrated[index] = true
				break
			end
		end
	end
end

function Processor:diff(snapshot: Types.Snapshot, parent: Types.Ref, clientPriority: boolean): Types.Changes
	local changes = Changes.new()

	local instance = self.tree:getInstance(snapshot.id)

	-- Check if snapshot is new
	if not instance then
		changes:add(snapshot, parent)
		return changes
	end

	-- Diff properties, find updated ones
	if not clientPriority or ReadProcessor.shouldSyncProperties(instance) then
		local updatedProperties = {}

		for property, default in Dom.getDefaultProperties(instance.ClassName) do
			local value = snapshot.properties[property]

			if value then
				local readSuccess, instanceValue = Dom.readProperty(instance, property)

				if not readSuccess then
					local err = Error.new(Error.ReadFailed, property, instance, instanceValue.kind)
					Log.warn(err)

					continue
				end

				local decodeSuccess, snapshotValue = Dom.EncodedValue.decode(value)

				if not decodeSuccess then
					local err = Error.new(Error.DecodeFailed, property, value)
					Log.warn(err)

					continue
				end

				if not equals(instanceValue, snapshotValue) then
					updatedProperties[property] = value
				end

				-- If snapshot does not have the property we want it to be default
			else
				local readSuccess, instanceValue = Dom.readProperty(instance, property)

				if not readSuccess then
					local err = Error.new(Error.ReadFailed, property, instance, instanceValue.kind)
					Log.warn(err)

					continue
				end

				local _, defaultValue = Dom.EncodedValue.decode(default)

				if not equals(instanceValue, defaultValue) then
					updatedProperties[property] = default
				end
			end
		end

		if next(updatedProperties) then
			changes:update({
				id = snapshot.id,
				properties = updatedProperties,
				meta = snapshot.meta.meshSource and snapshot.meta or nil,
			})
		end
	end

	-- Diff snapshot children, find new ones
	for _, child in snapshot.children do
		local childInstance = self.tree:getInstance(child.id)

		if not childInstance then
			changes:add(child, snapshot.id)
		end
	end

	-- Diff instance children, find removed ones
	for _, child in instance:GetChildren() do
		local childId = self.tree:getId(child)

		if childId then
			local childSnapshot = Util.find(snapshot.children, function(child)
				return equals(child.id, childId)
			end)

			changes:join(self:diff(childSnapshot, snapshot.id, clientPriority))
		elseif
			(clientPriority or not (snapshot.meta.keepUnknowns or Config:get("KeepUnknowns")))
			and Dom.isCreatable(child.ClassName)
		then
			changes:remove(child)
		end
	end

	return changes
end

function Processor:reverseChanges(changes: Types.Changes): Types.Changes
	Log.trace("Reversing changes..")

	local reversed = Changes.new()

	for _, snapshot in changes.additions do
		reversed:remove(snapshot.id)
	end

	for _, snapshot in changes.updates do
		snapshot = self.read:onChange(self.tree:getInstance(snapshot.id))

		if snapshot then
			reversed:update(snapshot)
		end
	end

	for _, instance in changes.removals do
		local snapshot = self.read:onAdd(instance)

		if snapshot then
			reversed:add(snapshot)
		end
	end

	return reversed
end

return Processor


local Argon = script:FindFirstAncestor("Argon")

local Types = require(Argon.Types)

local Meta = require(script.Parent.Meta)

-- Temporary solution for empty Luau maps being serialized as arrays
local function validateProperties(properties: Types.Properties): Types.Properties
	if not next(properties) then
		properties.ArgonEmpty = {
			Bool = true,
		}
	end

	return properties
end

local Snapshot = {}
Snapshot.__index = Snapshot

function Snapshot.new(id: Types.Ref)
	local self = setmetatable({}, Snapshot)

	self.id = id
	self.meta = Meta.new()
	self.parent = nil
	self.name = ""
	self.class = ""
	self.properties = validateProperties({})
	self.children = {}

	return self
end

function Snapshot.newUpdated(id: Types.Ref)
	local self = setmetatable({}, Snapshot)

	self.id = id
	self.meta = nil
	self.name = nil
	self.class = nil
	self.properties = nil

	return self
end

function Snapshot:withMeta(meta: Types.Meta)
	self.meta = meta
	return self
end

function Snapshot:withParent(parent: Types.Ref)
	self.parent = parent
	return self
end

function Snapshot:withName(name: string)
	self.name = name
	return self
end

function Snapshot:withClass(class: string)
	self.class = class
	return self
end

function Snapshot:withProperties(properties: Types.Properties)
	self.properties = validateProperties(properties)
	return self
end

function Snapshot:withChildren(children: { Types.Snapshot })
	self.children = children
	return self
end

return Snapshot


--!native

local Argon = script:FindFirstAncestor("Argon")

local Types = require(Argon.Types)

local Tree = {}
Tree.__index = Tree

function Tree.new()
	local self = {
		instanceMap = {},
		metaMap = {},
		idMap = {},
	}

	return setmetatable(self, Tree)
end

function Tree:insertInstance(instance: Instance, id: Types.Ref, meta: Types.Meta)
	local strId = buffer.tostring(id)

	self.instanceMap[strId] = instance
	self.idMap[instance] = strId

	self:insertMeta(id, meta)
end

function Tree:updateInstance(id: Types.Ref, instance: Instance)
	local strId = buffer.tostring(id)
	local old = self.instanceMap[strId]

	if not old then
		return
	end

	self.idMap[old] = nil

	self.instanceMap[strId] = instance
	self.idMap[instance] = strId
end

function Tree:getId(instance: Instance): Types.Ref?
	local strId = self.idMap[instance]
	return strId and buffer.fromstring(strId)
end

function Tree:getInstance(id: Types.Ref): Instance?
	return self.instanceMap[buffer.tostring(id)]
end

function Tree:removeByInstance(instance: Instance): Types.Ref?
	local strId = self.idMap[instance]

	if not strId then
		return nil
	end

	self.instanceMap[strId] = nil
	self.idMap[instance] = nil

	local id = buffer.fromstring(strId)

	self:removeMeta(id)

	return id
end

function Tree:removeById(id: Types.Ref): Instance?
	local strId = buffer.tostring(id)
	local instance = self.instanceMap[strId]

	if not instance then
		return nil
	end

	self.instanceMap[strId] = nil
	self.idMap[instance] = nil

	self:removeMeta(id)

	return instance
end

function Tree:insertMeta(id: Types.Ref, meta: Types.Meta)
	self.metaMap[buffer.tostring(id)] = meta
end

function Tree:getMeta(id: Types.Ref): Types.Meta?
	return self.metaMap[buffer.tostring(id)]
end

function Tree:updateMeta(id: Types.Ref, meta: Types.Meta)
	local strId = buffer.tostring(id)
	local old = self.metaMap[strId]

	if not old then
		return
	end

	self.metaMap[strId] = meta
end

function Tree:removeMeta(id: Types.Ref): Types.Meta?
	local strId = buffer.tostring(id)
	local meta = self.metaMap[strId]

	self.metaMap[strId] = nil

	return meta
end

return Tree


local base64 = require(script.Parent.base64)

local function identity(...)
	return ...
end

local function unpackDecoder(f)
	return function(value)
		return f(unpack(value))
	end
end

local ALL_AXES = { "X", "Y", "Z" }
local ALL_FACES = { "Right", "Top", "Back", "Left", "Bottom", "Front" }

local EncodedValue = {}

local types
types = {
	Attributes = {
		fromPod = function(pod)
			local output = {}

			for key, value in pairs(pod) do
				local ok, result = EncodedValue.decode(value)

				if ok then
					output[key] = result
				else
					local warning = ("Could not decode attribute value of type %q: %s"):format(
						typeof(value),
						tostring(result)
					)
					warn(warning)
				end
			end

			return output
		end,
		toPod = function(roblox)
			local output = {}

			for key, value in pairs(roblox) do
				local ok, result = EncodedValue.encodeNaive(value)

				if ok then
					output[key] = result
				else
					local warning = ("Could not encode attribute value of type %q: %s"):format(
						typeof(value),
						tostring(result)
					)
					warn(warning)
				end
			end

			return output
		end,
	},

	Axes = {
		fromPod = function(pod)
			local axes = {}

			for index, axisName in ipairs(pod) do
				axes[index] = Enum.Axis[axisName]
			end

			return Axes.new(unpack(axes))
		end,

		toPod = function(roblox)
			local json = {}

			for _, axis in ipairs(ALL_AXES) do
				if roblox[axis] then
					table.insert(json, axis)
				end
			end

			return json
		end,
	},

	BinaryString = {
		fromPod = base64.decode,
		toPod = base64.encode,
	},

	Bool = {
		fromPod = identity,
		toPod = identity,
	},

	BrickColor = {
		fromPod = function(pod)
			return BrickColor.new(pod)
		end,

		toPod = function(roblox)
			return roblox.Number
		end,
	},

	CFrame = {
		fromPod = function(pod)
			local pos = pod.position
			local orient = pod.orientation

			--stylua: ignore
			return CFrame.new(
				pos[1], pos[2], pos[3],
				orient[1][1], orient[1][2], orient[1][3],
				orient[2][1], orient[2][2], orient[2][3],
				orient[3][1], orient[3][2], orient[3][3]
			)
		end,

		toPod = function(roblox)
			local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = roblox:GetComponents()

			return {
				position = { x, y, z },
				orientation = {
					{ r00, r01, r02 },
					{ r10, r11, r12 },
					{ r20, r21, r22 },
				},
			}
		end,
	},

	Color3 = {
		fromPod = unpackDecoder(Color3.new),

		toPod = function(roblox)
			return { roblox.r, roblox.g, roblox.b }
		end,
	},

	Color3uint8 = {
		fromPod = unpackDecoder(Color3.fromRGB),

		toPod = function(roblox)
			return {
				math.round(roblox.R * 255),
				math.round(roblox.G * 255),
				math.round(roblox.B * 255),
			}
		end,
	},

	ColorSequence = {
		fromPod = function(pod)
			local keypoints = {}

			for index, keypoint in ipairs(pod.keypoints) do
				keypoints[index] = ColorSequenceKeypoint.new(keypoint.time, types.Color3.fromPod(keypoint.color))
			end

			return ColorSequence.new(keypoints)
		end,

		toPod = function(roblox)
			local keypoints = {}

			for index, keypoint in ipairs(roblox.Keypoints) do
				keypoints[index] = {
					time = keypoint.Time,
					color = types.Color3.toPod(keypoint.Value),
				}
			end

			return {
				keypoints = keypoints,
			}
		end,
	},

	Content = {
		fromPod = function(pod): Content
			if type(pod) == "string" then
				if pod == "None" then
					return Content.none
				else
					-- error(`unexpected Content value '{pod}'`)
					return Content.fromUri(pod) -- temporary
				end
			else
				local ty, value = next(pod)
				if ty == "Uri" then
					return Content.fromUri(value)
				elseif ty == "Object" then
					error("Object deserializing is not currently implemented")
				else
					error(`Unknown Content type '{ty}' (could not deserialize)`)
				end
			end
		end,
		toPod = function(roblox: Content)
			if roblox.SourceType == Enum.ContentSourceType.None then
				return "None"
			elseif roblox.SourceType == Enum.ContentSourceType.Uri then
				return { Uri = roblox.Uri }
			elseif roblox.SourceType == Enum.ContentSourceType.Object then
				error("Object serializing is not currently implemented")
			else
				error(`Unknown Content type '{roblox.SourceType} (could not serialize)`)
			end
		end,
	},

	ContentId = {
		fromPod = identity,
		toPod = identity,
	},

	Enum = {
		fromPod = identity,

		toPod = function(roblox)
			-- FIXME: More robust handling of enums
			if typeof(roblox) == "number" then
				return roblox
			else
				return roblox.Value
			end
		end,
	},

	EnumItem = {
		fromPod = function(pod)
			return Enum[pod.type]:FromValue(pod.value)
		end,

		toPod = function(roblox)
			return {
				type = tostring(roblox.EnumType),
				value = roblox.Value,
			}
		end,
	},

	Faces = {
		fromPod = function(pod)
			local faces = {}

			for index, faceName in ipairs(pod) do
				faces[index] = Enum.NormalId[faceName]
			end

			return Faces.new(unpack(faces))
		end,

		toPod = function(roblox)
			local pod = {}

			for _, face in ipairs(ALL_FACES) do
				if roblox[face] then
					table.insert(pod, face)
				end
			end

			return pod
		end,
	},

	Float32 = {
		fromPod = identity,
		toPod = identity,
	},

	Float64 = {
		fromPod = identity,
		toPod = identity,
	},

	Font = {
		fromPod = function(pod)
			return Font.new(
				pod.family,
				if pod.weight ~= nil then Enum.FontWeight[pod.weight] else nil,
				if pod.style ~= nil then Enum.FontStyle[pod.style] else nil
			)
		end,
		toPod = function(roblox)
			return {
				family = roblox.Family,
				weight = roblox.Weight.Name,
				style = roblox.Style.Name,
			}
		end,
	},

	Int32 = {
		fromPod = identity,
		toPod = identity,
	},

	Int64 = {
		fromPod = identity,
		toPod = identity,
	},

	MaterialColors = {
		fromPod = function(pod: { [string]: { number } })
			local real = {}
			for name, color in pod do
				real[Enum.Material[name]] = Color3.fromRGB(color[1], color[2], color[3])
			end
			return real
		end,
		toPod = function(roblox: { [Enum.Material]: Color3 })
			local pod = {}
			for material, color in roblox do
				pod[material.Name] = {
					math.round(math.clamp(color.R, 0, 1) * 255),
					math.round(math.clamp(color.G, 0, 1) * 255),
					math.round(math.clamp(color.B, 0, 1) * 255),
				}
			end
			return pod
		end,
	},

	NumberRange = {
		fromPod = unpackDecoder(NumberRange.new),

		toPod = function(roblox)
			return { roblox.Min, roblox.Max }
		end,
	},

	NumberSequence = {
		fromPod = function(pod)
			local keypoints = {}

			for index, keypoint in ipairs(pod.keypoints) do
				-- TODO: Add a test for NaN or Infinity values and envelopes
				-- Right now it isn't possible because it'd fail the roundtrip.
				-- It's more important that it works right now, though.
				local value = keypoint.value or 0
				local envelope = keypoint.envelope or 0
				keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, value, envelope)
			end

			return NumberSequence.new(keypoints)
		end,

		toPod = function(roblox)
			local keypoints = {}

			for index, keypoint in ipairs(roblox.Keypoints) do
				keypoints[index] = {
					time = keypoint.Time,
					value = keypoint.Value,
					envelope = keypoint.Envelope,
				}
			end

			return {
				keypoints = keypoints,
			}
		end,
	},

	PhysicalProperties = {
		fromPod = function(pod)
			if pod == "Default" then
				return nil
			else
				-- Passing `nil` instead of not passing anything gives
				-- different results, so we have to branch here.
				if pod.acousticAbsorption then
					return (PhysicalProperties.new :: any)(
						pod.density,
						pod.friction,
						pod.elasticity,
						pod.frictionWeight,
						pod.elasticityWeight,
						pod.acousticAbsorption
					)
				else
					return PhysicalProperties.new(
						pod.density,
						pod.friction,
						pod.elasticity,
						pod.frictionWeight,
						pod.elasticityWeight
					)
				end
			end
		end,

		toPod = function(roblox)
			if roblox == nil then
				return "Default"
			else
				return {
					Custom = {
						density = roblox.Density,
						friction = roblox.Friction,
						elasticity = roblox.Elasticity,
						frictionWeight = roblox.FrictionWeight,
						elasticityWeight = roblox.ElasticityWeight,
						acousticAbsorption = roblox.AcousticAbsorption,
					},
				}
			end
		end,
	},

	Ray = {
		fromPod = function(pod)
			return Ray.new(types.Vector3.fromPod(pod.origin), types.Vector3.fromPod(pod.direction))
		end,

		toPod = function(roblox)
			return {
				origin = types.Vector3.toPod(roblox.Origin),
				direction = types.Vector3.toPod(roblox.Direction),
			}
		end,
	},

	Rect = {
		fromPod = function(pod)
			return Rect.new(types.Vector2.fromPod(pod[1]), types.Vector2.fromPod(pod[2]))
		end,

		toPod = function(roblox)
			return {
				types.Vector2.toPod(roblox.Min),
				types.Vector2.toPod(roblox.Max),
			}
		end,
	},

	Ref = {
		fromPod = function(_)
			error("Ref cannot be decoded on its own")
		end,

		toPod = function(_)
			error("Ref can not be encoded on its own")
		end,
	},

	Region3 = {
		fromPod = function(_)
			error("Region3 is not implemented")
		end,

		toPod = function(_)
			error("Region3 is not implemented")
		end,
	},

	Region3int16 = {
		fromPod = function(pod)
			return Region3int16.new(types.Vector3int16.fromPod(pod[1]), types.Vector3int16.fromPod(pod[2]))
		end,

		toPod = function(roblox)
			return {
				types.Vector3int16.toPod(roblox.Min),
				types.Vector3int16.toPod(roblox.Max),
			}
		end,
	},

	SharedString = {
		fromPod = function(_pod)
			error("SharedString is not supported")
		end,

		toPod = function(_roblox)
			error("SharedString is not supported")
		end,
	},

	String = {
		fromPod = identity,
		toPod = identity,
	},

	UDim = {
		fromPod = unpackDecoder(UDim.new),

		toPod = function(roblox)
			return { roblox.Scale, roblox.Offset }
		end,
	},

	UDim2 = {
		fromPod = function(pod)
			return UDim2.new(types.UDim.fromPod(pod[1]), types.UDim.fromPod(pod[2]))
		end,

		toPod = function(roblox)
			return {
				types.UDim.toPod(roblox.X),
				types.UDim.toPod(roblox.Y),
			}
		end,
	},

	Tags = {
		fromPod = identity,
		toPod = identity,
	},

	Vector2 = {
		fromPod = unpackDecoder(Vector2.new),

		toPod = function(roblox)
			return {
				identity(roblox.X),
				identity(roblox.Y),
			}
		end,
	},

	Vector2int16 = {
		fromPod = unpackDecoder(Vector2int16.new),

		toPod = function(roblox)
			return { roblox.X, roblox.Y }
		end,
	},

	Vector3 = {
		fromPod = unpackDecoder(Vector3.new),

		toPod = function(roblox)
			return {
				identity(roblox.X),
				identity(roblox.Y),
				identity(roblox.Z),
			}
		end,
	},

	Vector3int16 = {
		fromPod = unpackDecoder(Vector3int16.new),

		toPod = function(roblox)
			return { roblox.X, roblox.Y, roblox.Z }
		end,
	},
}

types.OptionalCFrame = {
	fromPod = function(pod)
		if pod == nil then
			return nil
		else
			return types.CFrame.fromPod(pod)
		end
	end,

	toPod = function(roblox)
		if roblox == nil then
			return nil
		else
			return types.CFrame.toPod(roblox)
		end
	end,
}

function EncodedValue.decode(encodedValue)
	local ty, value = next(encodedValue)

	if ty == nil then
		-- If the encoded pair is empty, assume it is an unoccupied optional value
		return true, nil
	end

	local typeImpl = types[ty]
	if typeImpl == nil then
		return false, "Couldn't decode value " .. tostring(ty)
	end

	return true, typeImpl.fromPod(value)
end

function EncodedValue.encode(rbxValue, propertyType)
	assert(propertyType ~= nil, "Property type descriptor is required")

	local typeImpl = types[propertyType]
	if typeImpl == nil then
		return false, ("Missing encoder for property type %q"):format(propertyType)
	end

	return true, {
		[propertyType] = typeImpl.toPod(rbxValue),
	}
end

local propertyTypeRenames = {
	number = "Float64",
	boolean = "Bool",
	string = "String",
}

function EncodedValue.encodeNaive(rbxValue)
	local propertyType = typeof(rbxValue)
	if propertyTypeRenames[propertyType] ~= nil then
		propertyType = propertyTypeRenames[propertyType]
	end

	return EncodedValue.encode(rbxValue, propertyType)
end

return EncodedValue


local Error = {}
Error.__index = Error

Error.Kind = {
	UnknownProperty = "UnknownProperty",
	PropertyNotReadable = "PropertyNotReadable",
	PropertyNotWritable = "PropertyNotWritable",
	CannotParseBinaryString = "CannotParseBinaryString",
	Roblox = "Roblox",
}

setmetatable(Error.Kind, {
	__index = function(_, key)
		error(("%q is not a valid member of Error.Kind"):format(tostring(key)), 2)
	end,
})

function Error.new(kind, extra)
	return setmetatable({
		kind = kind,
		extra = extra,
	}, Error)
end

function Error:__tostring()
	return ("Error(%s: %s)"):format(self.kind, tostring(self.extra))
end

return Error


local Error = require(script.Parent.Error)
local customProperties = require(script.Parent.customProperties)

-- A wrapper around a property descriptor from the reflection database with some
-- extra convenience methods.
--
-- The aim of this API is to facilitate looking up a property once, then reading
-- from it or writing to it multiple times. It's also useful when a consumer
-- wants to check additional constraints on the property before trying to use
-- it, like scriptability.
local PropertyDescriptor = {}
PropertyDescriptor.__index = PropertyDescriptor

local function get(container, key)
	return container[key]
end

local function set(container, key, value)
	container[key] = value
end

function PropertyDescriptor.fromRaw(data, className, propertyName)
	local key, value = next(data.DataType)

	return setmetatable({
		-- The meanings of the key and value in DataType differ when the type of
		-- the property is Enum. When the property is of type Enum, the key is
		-- the name of the type:
		--
		-- { Enum = "<name of enum>" }
		--
		-- When the property is not of type Enum, the value is the name of the
		-- type:
		--
		-- { Value = "<data type>" }
		dataType = key == "Enum" and key or value,

		scriptability = data.Scriptability,
		className = className,
		name = propertyName,
	}, PropertyDescriptor)
end

function PropertyDescriptor:read(instance)
	if self.scriptability == "ReadWrite" or self.scriptability == "Read" then
		local success, value = xpcall(get, debug.traceback, instance, self.name)

		if success then
			return success, value
		else
			return false, Error.new(Error.Kind.Roblox, value)
		end
	end

	if self.scriptability == "Custom" then
		if customProperties[self.className] == nil then
			local fullName = ("%s.%s"):format(instance.className, self.name)
			return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
		end

		local interface = customProperties[self.className][self.name]

		return interface.read(instance, self.name)
	end

	if self.scriptability == "None" or self.scriptability == "Write" then
		local fullName = ("%s.%s"):format(instance.className, self.name)

		return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
	end

	error(("Internal error: unexpected value of 'scriptability': %s"):format(tostring(self.scriptability)), 2)
end

function PropertyDescriptor:write(instance, value)
	if self.scriptability == "ReadWrite" or self.scriptability == "Write" then
		local success, err = xpcall(set, debug.traceback, instance, self.name, value)

		if success then
			return success
		else
			return false, Error.new(Error.Kind.Roblox, err)
		end
	end

	if self.scriptability == "Custom" then
		if customProperties[self.className] == nil then
			local fullName = ("%s.%s"):format(instance.className, self.name)
			return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
		end

		local interface = customProperties[self.className][self.name]

		return interface.write(instance, self.name, value)
	end

	if self.scriptability == "None" or self.scriptability == "Read" then
		local fullName = ("%s.%s"):format(instance.className, self.name)

		return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
	end

	error(("Internal error: unexpected value of 'scriptability': %s"):format(tostring(self.scriptability)), 2)
end

return PropertyDescriptor


local EncodingService = game:GetService("EncodingService")

return {
	decode = function(input: string)
		return buffer.tostring(EncodingService:Base64Decode(buffer.fromstring(input)))
	end,
	encode = function(input: string)
		return buffer.tostring(EncodingService:Base64Encode(buffer.fromstring(input)))
	end,
}


local CollectionService = game:GetService("CollectionService")
local ScriptEditorService = game:GetService("ScriptEditorService")

local Error = require(script.Parent.Error)

--- A list of `Enum.Material` values that are used for Terrain.MaterialColors
local TERRAIN_MATERIAL_COLORS = {
	Enum.Material.Grass,
	Enum.Material.Slate,
	Enum.Material.Concrete,
	Enum.Material.Brick,
	Enum.Material.Sand,
	Enum.Material.WoodPlanks,
	Enum.Material.Rock,
	Enum.Material.Glacier,
	Enum.Material.Snow,
	Enum.Material.Sandstone,
	Enum.Material.Mud,
	Enum.Material.Basalt,
	Enum.Material.Ground,
	Enum.Material.CrackedLava,
	Enum.Material.Asphalt,
	Enum.Material.Cobblestone,
	Enum.Material.Ice,
	Enum.Material.LeafyGrass,
	Enum.Material.Salt,
	Enum.Material.Limestone,
	Enum.Material.Pavement,
}

local function isAttributeNameValid(attributeName)
	-- For SetAttribute to succeed, the attribute name must be less than or
	-- equal to 100 characters...
	return #attributeName <= 100
		-- ...and must only contain alphanumeric characters, periods, hyphens,
		-- underscores, or forward slashes.
		and attributeName:match("[^%w%.%-_/]") == nil
end

local function isAttributeNameReserved(attributeName)
	-- For SetAttribute to succeed, attribute names must not use the RBX
	-- prefix, which is reserved by Roblox.
	return attributeName:sub(1, 3) == "RBX"
end

-- Defines how to read and write properties that aren't directly scriptable.
--
-- The reflection database refers to these as having scriptability = "Custom"
return {
	Instance = {
		Attributes = {
			read = function(instance)
				return true, instance:GetAttributes()
			end,
			write = function(instance, _, value)
				if typeof(value) ~= "table" then
					return false, Error.new(Error.Kind.CannotParseBinaryString)
				end

				local existing = instance:GetAttributes()
				local didAllWritesSucceed = true

				for attributeName, attributeValue in pairs(value) do
					if isAttributeNameReserved(attributeName) then
						-- If the attribute name is reserved, then we don't
						-- really care about reporting any failures about
						-- it.
						continue
					end

					if not isAttributeNameValid(attributeName) then
						didAllWritesSucceed = false
						continue
					end

					instance:SetAttribute(attributeName, attributeValue)
				end

				for existingAttributeName in pairs(existing) do
					if isAttributeNameReserved(existingAttributeName) then
						continue
					end

					if not isAttributeNameValid(existingAttributeName) then
						didAllWritesSucceed = false
						continue
					end

					if value[existingAttributeName] == nil then
						instance:SetAttribute(existingAttributeName, nil)
					end
				end

				return didAllWritesSucceed
			end,
		},
		Tags = {
			read = function(instance)
				return true, CollectionService:GetTags(instance)
			end,
			write = function(instance, _, value)
				local existingTags = CollectionService:GetTags(instance)

				local unseenTags = {}
				for _, tag in ipairs(existingTags) do
					unseenTags[tag] = true
				end

				for _, tag in ipairs(value) do
					unseenTags[tag] = nil
					CollectionService:AddTag(instance, tag)
				end

				for tag in pairs(unseenTags) do
					CollectionService:RemoveTag(instance, tag)
				end

				return true
			end,
		},
	},
	LocalizationTable = {
		Contents = {
			read = function(instance, _)
				return true, instance:GetContents()
			end,
			write = function(instance, _, value)
				instance:SetContents(value)
				return true
			end,
		},
	},
	Model = {
		Scale = {
			read = function(instance, _, _)
				return true, instance:GetScale()
			end,
			write = function(instance, _, value)
				return true, instance:ScaleTo(value)
			end,
		},
		WorldPivotData = {
			read = function(instance)
				return true, instance.WorldPivot
			end,
			write = function(instance, _, value)
				if value == nil then
					return true, nil
				else
					instance.WorldPivot = value
					return true
				end
			end,
		},
	},
	Terrain = {
		MaterialColors = {
			read = function(instance: Terrain)
				-- There's no way to get a list of every color, so we have to
				-- make one.
				local colors = {}
				for _, material in TERRAIN_MATERIAL_COLORS do
					colors[material] = instance:GetMaterialColor(material)
				end

				return true, colors
			end,
			write = function(instance: Terrain, _, value: { [Enum.Material]: Color3 })
				if typeof(value) ~= "table" then
					return false, Error.new(Error.Kind.CannotParseBinaryString)
				end

				for material, color in value do
					instance:SetMaterialColor(material, color)
				end

				return true
			end,
		},
	},
	Script = {
		Source = {
			read = function(instance: Script)
				return true, ScriptEditorService:GetEditorSource(instance)
			end,
			write = function(instance: Script, _, value: string)
				task.spawn(function()
					ScriptEditorService:UpdateSourceAsync(instance, function()
						return value
					end)
				end)
				return true
			end,
		},
	},
	ModuleScript = {
		Source = {
			read = function(instance: ModuleScript)
				return true, ScriptEditorService:GetEditorSource(instance)
			end,
			write = function(instance: ModuleScript, _, value: string)
				task.spawn(function()
					ScriptEditorService:UpdateSourceAsync(instance, function()
						return value
					end)
				end)
				return true
			end,
		},
	},
	StyleRule = {
		PropertiesSerialize = {
			read = function(instance: StyleRule)
				return true, instance:GetProperties()
			end,
			write = function(instance: StyleRule, _, value: { [any]: any })
				if typeof(value) ~= "table" then
					return false, Error.new(Error.Kind.CannotParseBinaryString)
				end

				local existing = instance:GetProperties()

				for itemName, itemValue in pairs(value) do
					instance:SetProperty(itemName, itemValue)
				end

				for existingItemName in pairs(existing) do
					if value[existingItemName] == nil then
						instance:SetProperty(existingItemName, nil)
					end
				end

				return true
			end,
		},
	},
	MeshPart = {
		MeshContent = {
			read = function(instance: MeshPart)
				return true, instance.MeshContent
			end,
			write = function()
				-- Argon handles this in a custom way
				return true
			end,
		},
		InitialSize = {
			read = function(instance: MeshPart)
				return true, instance.MeshSize
			end,
			write = function()
				-- Argon handles this in a custom way
				return true
			end,
		},
	},
}


local database = require(script.database)
local Error = require(script.Error)
local PropertyDescriptor = require(script.PropertyDescriptor)

local function findCanonicalPropertyDescriptor(className, propertyName)
	local currentClassName = className

	repeat
		local currentClass = database.Classes[currentClassName]

		if currentClass == nil then
			return currentClass
		end

		local propertyData = currentClass.Properties[propertyName]
		if propertyData ~= nil then
			local canonicalData = propertyData.Kind.Canonical
			if canonicalData ~= nil then
				return PropertyDescriptor.fromRaw(propertyData, currentClassName, propertyName)
			end

			local aliasData = propertyData.Kind.Alias
			if aliasData ~= nil then
				return PropertyDescriptor.fromRaw(
					currentClass.Properties[aliasData.AliasFor],
					currentClassName,
					aliasData.AliasFor
				)
			end

			return nil
		end

		currentClassName = currentClass.Superclass
	until currentClassName == nil

	return nil
end

local function getDefaultProperties(className)
	local class = database.Classes[className]
	local properties = {}

	if not class then
		return nil
	end

	for property, default in pairs(class.DefaultProperties) do
		local descriptor = findCanonicalPropertyDescriptor(className, property)

		if
			descriptor
			and (descriptor.scriptability == "ReadWrite" or descriptor.scriptability == "Custom")
			and next(default)
		then
			properties[property] = default
		end
	end

	return properties
end

local function isCreatable(className)
	local class = database.Classes[className]

	if not class then
		return false
	end

	return (table.find(class.Tags, "NotCreatable") or table.find(class.Tags, "Service")) == nil
end

local function readProperty(instance, propertyName)
	local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)

	if descriptor == nil then
		local fullName = ("%s.%s"):format(instance.className, propertyName)

		return false, Error.new(Error.Kind.UnknownProperty, fullName)
	end

	return descriptor:read(instance)
end

local function writeProperty(instance, propertyName, value)
	local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)

	if descriptor == nil then
		local fullName = ("%s.%s"):format(instance.className, propertyName)

		return false, Error.new(Error.Kind.UnknownProperty, fullName)
	end

	return descriptor:write(instance, value)
end

return {
	isCreatable = isCreatable,
	readProperty = readProperty,
	writeProperty = writeProperty,
	findCanonicalPropertyDescriptor = findCanonicalPropertyDescriptor,
	getDefaultProperties = getDefaultProperties,
	Error = Error,
	EncodedValue = require(script.EncodedValue),
}


--!strict
local Argon = script:FindFirstAncestor("Argon")
local App = Argon.App

local Types = require(Argon.Types)
local Theme = require(App.Theme)

local function s(len: number): string
	return len > 1 and "s" or ""
end

local function hex(kind: string): string
	return "#" .. Theme.Colors.Diff[kind]:ToHex()
end

return function(changes: Types.Changes): string
	local additions = #changes.additions
	local updates = #changes.updates
	local removals = #changes.removals

	local text = "There will be "

	if additions > 0 then
		text ..= `<font color="{hex("Add")}">{additions} addition{s(additions)}</font>`

		if updates > 0 or removals > 0 then
			text ..= ", "
		end
	end

	if updates > 0 then
		text ..= `<font color="{hex("Update")}">{updates} update{s(updates)}</font>`

		if removals > 0 then
			text ..= ", "
		end
	end

	if removals > 0 then
		text ..= `<font color="{hex("Remove")}">{removals} removal{s(removals)}</font>`
	end

	text ..= " applied compared to the server"

	return text
end


--!strict
local Argon = script:FindFirstAncestor("Argon")

local Util = require(Argon.Util)

local function fuzzy(a: number, b: number): boolean
	return math.abs(a - b) < 0.0001
end

local function componentEq(a: { number }, b: { number }): boolean
	for i, aComp in a do
		if not fuzzy(aComp, b[i]) then
			return false
		end
	end

	return true
end

local function equals(a: any, b: any): boolean
	if a == b then
		return true
	end

	local tA, tB = typeof(a), typeof(b)

	if tA == "number" and tB == "number" then
		return fuzzy(a, b)
	elseif tA == "CFrame" and tB == "CFrame" then
		return componentEq({ a:components() }, { b:components() })
	elseif tA == "Vector3" and tB == "Vector3" then
		return componentEq({ a.X, a.Y, a.Z }, { b.X, b.Y, b.Z })
	elseif tA == "Vector2" and tB == "Vector2" then
		return componentEq({ a.X, a.Y }, { b.X, b.Y })
	elseif tA == "Color3" and tB == "Color3" then
		return componentEq({ a.R, a.G, a.B }, { b.R, b.G, b.B })
	elseif tA == "EnumItem" and tB == "number" then
		return a.Value == b
	elseif tA == "number" and tB == "EnumItem" then
		return a == b.Value
	elseif tA == "table" and tB == "table" then
		if #a ~= #b or Util.len(a) ~= Util.len(b) then
			return false
		end

		for kA, vA in a do
			local found = false

			for kB, vB in b do
				if equals(kA, kB) then
					if not equals(vA, vB) then
						return false
					end

					found = true
					break
				end
			end

			if not found then
				return false
			end
		end

		return true
	elseif tA == "buffer" and tB == "buffer" then
		return buffer.tostring(a) == buffer.tostring(b)
	end

	return false
end

return equals


--!strict
local Argon = script:FindFirstAncestor("Argon")

local Types = require(Argon.Types)

return function(): Types.Ref
	local buf = buffer.create(16)

	for i = 0, 15 do
		buffer.writeu8(buf, i, math.random(0, 255))
	end

	return buf
end


local ChangeHistoryService = game:GetService("ChangeHistoryService")

return function(action: string, name: string?)
	name = name and ` "{name}"` or ""
	ChangeHistoryService:SetWaypoint(`Argon: {action}{name} [{DateTime.now():FormatLocalTime("LTS", "en-us")}]`)
end


--!strict
local Argon = script:FindFirstAncestor("Argon")

local Util = require(Argon.Util)

local function format(...: any): string
	local args = ""

	for _, arg in { ... } do
		args ..= " " .. Util.stringify(arg)
	end

	return args
end

local Log = {
	Level = {
		Off = 0,
		Error = 1,
		Warn = 2,
		Info = 3,
		Debug = 4,
		Trace = 5,
	},
	__level = 2,
}

function Log.__setLevel(level: string)
	local num = Log.Level[level]

	if not num then
		Log.error("Invalid log level:", level)
	end

	Log.__level = num
end

function Log.trace(...: any)
	if Log.__level >= Log.Level.Trace then
		print("TRACE:" .. format(...))
	end
end

function Log.debug(...: any)
	if Log.__level >= Log.Level.Debug then
		print("DEBUG:" .. format(...))
	end
end

function Log.info(...: any)
	if Log.__level >= Log.Level.Info then
		print("INFO:" .. format(...))
	end
end

function Log.warn(...: any)
	if Log.__level >= Log.Level.Warn then
		warn("WARN:" .. format(...))
	end
end

function Log.error(...: any)
	if Log.__level >= Log.Level.Error then
		error("ERROR:" .. format(...), 0)
	end
end

return Log


--!strict
local MarketPlaceService = game:GetService("MarketplaceService")

local name = game.Name
local isFile = (name:find(".rbxl") or name:find(".rbxlx")) ~= nil

if not isFile then
	pcall(function()
		name = MarketPlaceService:GetProductInfo(game.PlaceId).Name
	end)
end

return {
	Name = name,
	IsFile = isFile,
	Version = game.PlaceVersion,
}


--!strict
type SemVer = typeof(setmetatable(
	{} :: {
		major: number,
		minor: number,
		patch: number,
	},
	{} :: SemVerImpl
))

type SemVerImpl = {
	__index: SemVerImpl,
	new: (major: number, minor: number, patch: number) -> SemVer,
	parse: (version: string) -> SemVer,
	toString: (self: SemVer) -> string,
	isCompatible: (self: SemVer, other: SemVer) -> boolean,
	__eq: (self: SemVer, other: SemVer) -> boolean,
	__lt: (self: SemVer, other: SemVer) -> boolean,
	__le: (self: SemVer, other: SemVer) -> boolean,
}

local SemVer: SemVerImpl = {} :: SemVerImpl
SemVer.__index = SemVer

function SemVer.new(major, minor, patch)
	return setmetatable({
		major = major,
		minor = minor,
		patch = patch,
	}, SemVer)
end

function SemVer.parse(version)
	local major, minor, patch = version:match("(%d+)%.(%d+)%.(%d+)")

	return SemVer.new(tonumber(major) or 0, tonumber(minor) or 0, tonumber(patch) or 0)
end

function SemVer:toString()
	return ("%d.%d.%d"):format(self.major, self.minor, self.patch)
end

function SemVer:isCompatible(other)
	return self.major == other.major and self.minor == other.minor
end

function SemVer:__eq(other)
	return self.major == other.major and self.minor == other.minor and self.patch == other.patch
end

function SemVer:__lt(other)
	if self.major < other.major then
		return true
	elseif self.major > other.major then
		return false
	end

	if self.minor < other.minor then
		return true
	elseif self.minor > other.minor then
		return false
	end

	return self.patch < other.patch
end

function SemVer:__le(other)
	return self == other or self < other
end

return SemVer


-- General structs

export type Ref = buffer
export type ClassName = string

export type Properties = {
	[string]: any,
}

export type Meta = {
	keepUnknowns: boolean,
	meshSource: string?,
}

-- Snapshots

export type Snapshot = {
	id: Ref,
	meta: Meta,
	name: string,
	class: ClassName,
	properties: Properties,
	children: { Snapshot },
}

export type AddedSnapshot = {
	id: Ref,
	meta: Meta,
	parent: Ref,
	name: string,
	class: ClassName,
	properties: Properties,
	children: { Snapshot },
}

export type UpdatedSnapshot = {
	id: Ref,
	meta: Meta?,
	name: string?,
	class: ClassName?,
	properties: Properties?,
}

-- Other structs

export type WatcherEvent = {
	kind: "Added" | "Removed" | "Changed",
	instance: Instance,
	property: string?,
}

export type Changes = {
	additions: { AddedSnapshot },
	updates: { UpdatedSnapshot },
	removals: { Ref | Instance },
}

export type Project = {
	name: string,
	version: string,
	gameId: number?,
	placeIds: { number },
	rootRefs: { Ref },
}

-- Messages

export type Message = SyncChanges | SyncDetails | ExecuteCode | Disconnect
export type MessageKind = "SyncChanges" | "SyncDetails" | "ExecuteCode" | "Disconnect"

export type SyncChanges = {
	SyncChanges: Changes,
}

export type SyncDetails = {
	SyncDetails: Project,
}

export type ExecuteCode = {
	code: string,
}

export type Disconnect = {
	message: string,
}

return nil


--!strict
local HttpService = game:GetService("HttpService")

local Util = {}

--- Get length of non-numerically indexed table
function Util.len(table: { [any]: any }): number
	local count = 0

	for _ in table do
		count += 1
	end

	return count
end

--- Join second table to the first one
function Util.join(table1: { any }, table2: { any }): { any }
	for _, value in table2 do
		table.insert(table1, value)
	end

	return table1
end

--- Convert dictionary to array, ignoring keys
function Util.dictionaryToArray(dictionary: { [any]: any }, __lastArray: { any }?): { any }
	local array = __lastArray or {}

	for _, value in dictionary do
		if type(value) == "table" then
			Util.dictionaryToArray(value, array)
		else
			table.insert(array, value)
		end
	end

	return array
end

--- Find the value and key of the first element that passes the filter
function Util.find<K, V>(table: { [K]: V }, compare: (value: V, key: K) -> boolean): (V?, K?)
	for key, value in table do
		if compare(value, key) then
			return value, key
		end
	end

	return nil
end

--- Stringify the value (pretty print)
function Util.stringify(value: any, __indent: number?): string
	if type(value) == "table" then
		local meta = getmetatable(value)

		if meta and meta.__tostring then
			return tostring(value)
		end

		if not next(value) then
			return "{}"
		end

		local first = next(value)

		if type(first) == "number" then
			local containsTable = false

			for _, v in value do
				if type(v) == "table" then
					containsTable = true
					break
				end
			end

			if not containsTable then
				return "{" .. table.concat(value, ", ") .. "}"
			end

			local str = "{\n"
			local indent = __indent and __indent + 1 or 1

			for i, v in value do
				str ..= str.rep("\t", indent)
				str ..= `[{i}] = {Util.stringify(v, indent)},\n`
			end

			return str .. string.rep("\t", indent - 1) .. "}"
		else
			local str = "{\n"
			local indent = __indent and __indent + 1 or 1

			for k, v in value do
				local key = type(k) == "string" and `"{k}"` or Util.stringify(k, indent)
				local val = type(v) == "string" and `"{v}"` or Util.stringify(v, indent)

				str ..= str.rep("\t", indent)
				str ..= `[{key}] = {val},\n`
			end

			return str .. string.rep("\t", indent - 1) .. "}"
		end
	elseif typeof(value) == "Instance" then
		return value:GetFullName()
	elseif type(value) == "buffer" then
		return buffer.tostring(value) .. " (buffer)"
	else
		return tostring(value)
	end
end

--- Generate a GUID, example: 04AEBFEA-87FC-480F-A98B-E5E221007A90
function Util.generateGUID(): string
	return HttpService:GenerateGUID(false)
end

--- Cast the value to the provided type (number, string, boolean, any)
function Util.cast(value: any, target: string): any
	if type(value) == target then
		return value
	end

	if target == "number" then
		return tonumber(value)
	elseif target == "string" then
		return tostring(value)
	elseif target == "boolean" then
		return value == "true"
	else
		return value
	end
end

--- Clean all provided connections and instances
function Util.clean(...: () -> () | RBXScriptConnection | Instance | { [any]: any })
	local function clean(object: any)
		local kind = typeof(object)

		if kind == "function" then
			object()
		elseif kind == "RBXScriptConnection" then
			object:Disconnect()
		elseif kind == "Instance" then
			object:Destroy()
		elseif kind == "table" then
			if typeof(object.Destroy) == "function" then
				object:Destroy()
			else
				for _, object in object do
					clean(object)
				end
			end
		end
	end

	for _, arg in { ... } do
		clean(arg)
	end
end

return Util


--!strict
local CollectionService = game:GetService("CollectionService")

local Argon = script:FindFirstAncestor("Argon")

local Signal = require(Argon.Packages.Signal)

local Types = require(Argon.Types)
local Log = require(Argon.Log)

type Watcher = typeof(setmetatable(
	{} :: {
		isActive: boolean,
		connections: { [Instance]: { RBXScriptConnection } },
		tagConnections: { [string | CollectionService]: { RBXScriptConnection } },
		signal: Signal.Signal,
	},
	{} :: WatcherImpl
))

type WatcherImpl = {
	__index: WatcherImpl,
	new: () -> Watcher,
	start: (self: Watcher, rootInstances: { Instance }) -> (),
	stop: (self: Watcher) -> (),
	listen: (self: Watcher) -> Types.WatcherEvent,
	restart: (self: Watcher, instances: { Instance }) -> (),
	__connectEvents: (self: Watcher, instance: Instance) -> (),
	__watchTag: (self: Watcher, tag: string) -> (),
	__unwatchTag: (self: Watcher, tag: string) -> (),
	__onAdded: (self: Watcher, instance: Instance) -> (),
	__onChanged: (self: Watcher, instance: Instance, property: string) -> (),
	__onRemoved: (self: Watcher, instance: Instance) -> (),
}

local Watcher: WatcherImpl = {} :: WatcherImpl
Watcher.__index = Watcher

function Watcher.new()
	return setmetatable({
		isActive = false,
		connections = {},
		tagConnections = {},
		signal = Signal.new(),
	}, Watcher)
end

function Watcher:start(rootInstances)
	Log.trace("Starting watcher..")

	self.isActive = true

	for _, instance in rootInstances do
		self:__connectEvents(instance)
	end

	local tagConnections = {}

	table.insert(
		tagConnections,
		CollectionService.TagAdded:Connect(function(tag)
			self:__watchTag(tag)

			for _, instance in CollectionService:GetTagged(tag) do
				self:__onChanged(instance, "Tags")
			end
		end)
	)

	table.insert(
		tagConnections,
		CollectionService.TagRemoved:Connect(function(tag)
			self:__unwatchTag(tag)
		end)
	)

	self.tagConnections[CollectionService] = tagConnections
end

function Watcher:stop()
	Log.trace("Stopping watcher..")

	for _, connections in self.connections do
		for _, connection in connections do
			connection:Disconnect()
		end
	end

	for _, tagConnections in self.tagConnections do
		for _, tagConnection in tagConnections do
			tagConnection:Disconnect()
		end
	end

	self.isActive = false
	self.connections = {}
	self.tagConnections = {}
end

function Watcher:restart(instances)
	if not self.isActive then
		return
	end

	Log.trace("Restarting watcher..")

	self:stop()
	self:start(instances)
end

function Watcher:listen()
	return self.signal:Wait()
end

function Watcher:__connectEvents(instance)
	if instance == workspace.CurrentCamera then
		return
	end

	local connections = {}
	local isValue = instance:IsA("ValueBase")

	table.insert(
		connections,
		instance.ChildAdded:Connect(function(child)
			self:__onAdded(child)
		end)
	)

	table.insert(
		connections,
		instance.ChildRemoved:Connect(function(child)
			self:__onRemoved(child)
		end)
	)

	table.insert(
		connections,
		instance.Changed:Connect(function(property)
			if property ~= "Parent" then
				self:__onChanged(instance, isValue and "Value" or property)
			end
		end)
	)

	table.insert(
		connections,
		instance.AttributeChanged:Connect(function(_attribute)
			self:__onChanged(instance, "Attributes")
		end)
	)

	for _, tag in instance:GetTags() do
		self:__watchTag(tag)
	end

	self.connections[instance] = connections

	for _, child in instance:GetChildren() do
		self:__connectEvents(child)
	end
end

function Watcher:__watchTag(tag)
	if self.tagConnections[tag] then
		return
	end

	local tagConnections = {}

	table.insert(
		tagConnections,
		CollectionService:GetInstanceAddedSignal(tag):Connect(function(instance)
			self:__onChanged(instance, "Tags")
		end)
	)

	table.insert(
		tagConnections,
		CollectionService:GetInstanceRemovedSignal(tag):Connect(function(instance)
			self:__onChanged(instance, "Tags")
		end)
	)

	self.tagConnections[tag] = tagConnections
end

function Watcher:__unwatchTag(tag)
	local tagConnections = self.tagConnections[tag]

	if tagConnections then
		for _, tagConnection in tagConnections do
			tagConnection:Disconnect()
		end

		self.tagConnections[tag] = nil
	end
end

function Watcher:__onAdded(instance)
	self:__connectEvents(instance)

	self.signal:Fire({
		kind = "Add",
		instance = instance,
	})
end

function Watcher:__onChanged(instance, property)
	self.signal:Fire({
		kind = "Change",
		instance = instance,
		property = property,
	})
end

function Watcher:__onRemoved(instance)
	local connections = self.connections[instance]

	if connections then
		for _, connection in connections do
			connection:Disconnect()
		end

		self.connections[instance] = nil
	end

	self.signal:Fire({
		kind = "Remove",
		instance = instance,
	})
end

return Watcher


--!strict
local RunService = game:GetService("RunService")

if RunService:IsRunning() then
	return
end

local Config = require(script.Config)
local Log = require(script.Log)
local App = require(script.App)

Config.load()
Log.__setLevel(Config:get("LogLevel"))
App.new()

Config:onChanged("LogLevel", function(level)
	Log.__setLevel(level)
end)


export type AnimatedComponent = {
	_Animator: Animator,
	_AnimationTracks: { [Animation]: AnimationTrack },
	_Animations: { [string]: Animation },

	PlayAnimation: (self: any, AnimationId: string) -> Animation,
	StopAnimation: (self: any, AnimationId: string) -> (),
	StopAnimations: (self: any) -> (),
	GetAnimationTrack: (self: any, AnimationId: string, Strict: boolean?) -> AnimationTrack,
	SetAnimator: (self: any, Animator: Animator) -> (),
}

local AnimatedComponent = {}

function AnimatedComponent.Stopping(Component: AnimatedComponent)
	Component:StopAnimations()
end

function AnimatedComponent.Constructing(Component: AnimatedComponent)
	Component._AnimationTracks = {}
	Component._Animations = {}

	local function AnimationFromId(AnimationId: string): Animation
		local Animation = Component._Animations[AnimationId]

		if Animation == nil then
			Animation = Instance.new("Animation")
			Animation.AnimationId = AnimationId
			Component._Animations[AnimationId] = Animation
		end

		return Animation
	end

	function Component:PlayAnimation(AnimationId: string)
		local AnimationTrack = self:GetAnimationTrack(AnimationId)
		if AnimationTrack then
			AnimationTrack:Play()

			return AnimationTrack
		end
	end

	function Component:StopAnimation(AnimationId: string)
		local AnimationTrack = self:GetAnimationTrack(AnimationId, true)
		if AnimationTrack then
			AnimationTrack:Stop()
		end
	end

	function Component:StopAnimations()
		for _, AnimationTrack in pairs(self._AnimationTracks) do
			AnimationTrack:Stop()
		end
	end

	function Component:CleanAnimations()
		self:StopAnimations()

		for _, AnimationTrack in pairs(self._AnimationTracks) do
			AnimationTrack:Destroy()
		end

		for _, Animation in pairs(self._Animations) do
			Animation:Destroy()
		end
	end

	function Component:GetAnimationTrack(AnimationId: string, Strict: boolean?): AnimationTrack
		local Animator = self._Animator
		if Animator then
			local AnimationTrack = self._AnimationTracks[AnimationId]

			if not Strict then
				if AnimationTrack == nil then
					AnimationTrack = Animator:LoadAnimation(AnimationFromId(AnimationId))
					self._AnimationTracks[AnimationId] = AnimationTrack
				end
			end

			return AnimationTrack
		end
	end

	function Component:SetAnimator(Animator: Animator | nil)
		self:CleanAnimations()

		self._Animator = Animator
	end
end

return AnimatedComponent