--[[
Archivist - Data management service for WoW Addons
Written in 2019 by Allen Faure (emptyrivers) afaure6@gmail.com

To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide.
This software is distributed without any warranty.
You should have received a copy of the CC0 Public Domain Dedication along with this software.
If not, see http://creativecommons.org/publicdomain/zero/1.0/.
]]

local embedder, namespace = ...
local addonName, Archivist = "Archivist", {}
-- Our only library!
local LibDeflate = LibStub("LibDeflate")
local Retail = LibStub("LibRetail")

do -- boilerplate & static values
	Archivist.buildDate = "20200208045410"
	Archivist.version = "v1.0.4"
	--[===[@debug@
		Archivist.debug = true
	--@end-debug@]===]

	Archivist.prototypes = {}
	Archivist.storeMap = {}
	Archivist.activeStores = {}
	namespace.Archivist = Archivist
	local unloader = CreateFrame("FRAME")
	unloader:RegisterEvent("PLAYER_LOGOUT")
	unloader:SetScript("OnEvent", function()
		Archivist:DeInitialize()
	end)
	if embedder == "Archivist" then
		-- Archivist is installed as a standalone addon.
		-- The Archive is in the default location, ACHV_DB
		_G.Archivist = Archivist
		local loader = CreateFrame("frame")
		loader:RegisterEvent("ADDON_LOADED")
		loader:SetScript("OnEvent", function(self, _, addon)
			if addon == addonName then
				if type(ACHV_DB) ~= "table" then
					ACHV_DB = {}
				end
				Archivist:Initialize(ACHV_DB)
				self:UnregisterEvent("ADDON_LOADED")
			end
		end)
	end
end

function Archivist:Assert(valid, pattern, ...)
	if not valid then
		if pattern then
		error(pattern:format(...), 2)
		else
		error("Archivist encountered an unknown error.", 2)
		end
	end
end

function Archivist:Warn(valid, pattern, ...) -- Like assert, but doesn't interrupt execution
	if not valid and self.debug then
		if pattern then
			print(pattern:format(...), 2)
		else
			print("Archivist encountered an unknown warning.")
		end
		return true
	end
end

function Archivist:IsInitialized()
	return self.initialized
end

-- Give Archivist its archive to play with. Called automatically, unless Archivist has been embedded.
function Archivist:Initialize(sv)
	do -- arg validation
		self:Assert(not self:IsInitialized(), "Archivist has already been initialized.")
		self:Assert(type(sv) == "table", "Attempt to initialize Archivist SavedVariables with a %q instead of a table.", type(sv))
	end

	self.sv = sv
	self.initialized = true
	for id, prototype in pairs(self.prototypes) do
		self.sv[id] = self.sv[id] or {}
		if prototype.Init then
			prototype:Init()
		end
	end
end

-- Shut Archivist down
function Archivist:DeInitialize()
	if self:IsInitialized() then
		self.initialized = false
		self:CloseAllStores()
		self.sv = nil
	end
end

-- register a store type with Archivist
-- prototype fields:
--  id - unique identifier. Preferably also a descriptive name, like "simple" or "snapshot".
--  version - positive integer. Used for version control, in case any data migrations are needed. Registration will fail if the prototype is outdated.
--  Init - function (optional). If provided, executes exactly once per session, before any other methods are called.
--  Create - function (required). Create a brand new active store object.
--  Update - function (optional). Massage archived data into a format that Open can accept. Useful for data migrations.
--  Open - function (requried). Create from the provided data an active store object. Prototype may assume ownership of the provided data however it wishes.
--  Commit - function (required). Return an image of the data that should be archived.
--  Close - function (required). Release ownership of active store object. Optionally, return image of data to write into archive.
-- Please note that Create, Open, Update (if provided), Commit, and Close may be called at any time if Archivist deems it necessary.
-- Thus, these methods should ideally be as close to purely functional as is practical, to minimize friction.
function Archivist:RegisterStoreType(prototype)
	do -- prototype validation
		self:Assert(type(prototype) == "table", "Invalid argument #1 to RegisterStoreType: Expected table, got %q instead.", type(prototype))
		-- prototype is now guaranteed to be indexable
		self:Assert(type(prototype.id) == "string", "Invalid prototype field 'id': Expected string, got %q instead.", type(prototype.id))
		self:Assert(type(prototype.version) == "number", "Invalid prototype field 'version': Expected number, got %q instead.", type(prototype.version))
		if self:Warn(prototype.version > 0 and prototype.version == math.floor(prototype.version),
			"Prototype %q version expected to be a positive integer, but got %d instead.", prototype.id, prototype.version) then
			return
		end
		local oldPrototype = self.prototypes[prototype.id]
		self:Assert(not oldPrototype or prototype.version >= oldPrototype.version, "Store type %q already exists with a higher version", oldPrototype and oldPrototype.version)
		-- prototype is now guaranteed to be either new or an Update to existing prototype
		self:Assert(prototype.Init == nil or type(prototype.Init) == "function", "Invalid prototype field 'Init': Expected function, got %q instead.", type(prototype.Init))
		self:Assert(type(prototype.Create) == "function", "Invalid prototype field 'Create': Expected function, got %q instead.", type(prototype.Create))
		self:Assert(type(prototype.Open) == "function", "Invalid prototype field 'Open': Expected function, got %q instead.", type(prototype.Open))
		self:Assert(prototype.Update == nil or type(prototype.Update) == "function", "Invalid prototype field 'Update': Expected function, got %q instead.", type(prototype.Update))
		self:Assert(type(prototype.Commit) == "function", "Invalid prototype field 'Commit': Expected function, got %q instead.", type(prototype.Commit))
		self:Assert(type(prototype.Close) == "function", "Invalid prototype field 'Close': Expected function, got %q instead.", type(prototype.Close))
		-- prototype is now guaranteed to have Init, Create, Open, Update functions, and is thus well-formed.
	end

	local oldPrototype = self.prototypes[prototype.id] -- need in case of closing active stores
	self.prototypes[prototype.id] = {
		id = prototype.id,
		version = prototype.version,
		Init = prototype.Init,
		Create = prototype.Create,
		Update = prototype.Update,
		Open = prototype.Open,
		Commit = prototype.Commit,
		Close = prototype.Close
	}
	self.activeStores[prototype.id] = self.activeStores[prototype.id] or {}
	if self:IsInitialized() then
		self.sv[prototype.id] = self.sv[prototype.id] or {}
		if prototype.Init then
			prototype:Init()
		end
		-- if prototype was previously registered, and Archivist is initialized, then there may be open stores of the old prototype.
		-- Close them, Update if necessary, then re-Open them with the new prototype.
		if oldPrototype then
			for storeID, store in pairs(self.activeStores[prototype.id]) do
				local image = oldPrototype:Close(store)
				local saved = self.sv[prototype.id][storeID]
				local shouldReArchive = image ~= nil
				if image == nil then
					image = saved.data
				end
				if prototype.Update then
					local newImage = prototype:Update(image, saved.version)
					if newImage ~= nil then
						image = newImage
						shouldReArchive = true
					end
					saved.version = prototype.version
				end
				self.activeStores[prototype.id][storeID] = prototype:Open(image)
				if shouldReArchive then
					-- a meaningful change to saved data has occurred.
					saved.data = self:Archive(image)
				end
			end
		end
	end
end

do -- function Archive:GenerateID()
	-- adapted from https://gist.github.com/jrus/3197011
	local function randomHex()
		return ('%x'):format(math.random(0, 0xf))
	end

	function Archivist:GenerateID()
		local template ='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
		return (template:gsub('x', randomHex))
	end
end

-- creates and opens a new store of the given store type and with the given id (if given)
-- store objects are lightly managed by Archivist. On PLAYER_LOGOUT, all open stores are Closed,
-- and the resultant data is compressed into the archive.
function Archivist:Create(storeType, id, ...)
	do -- arg validation
		self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered before loading data.")
		self:Assert(id == nil or type(id) == "string" and not self.sv[storeType][id], "A store already exists with that id. Did you mean to call Archivist:Open?")
	end

	local store, image = self.prototypes[storeType]:Create(...)
	do -- ensure that store exists and is unique
		self:Assert(store ~= nil, "Failed to create a new store of type %q.", storeType)
		self:Assert(self.storeMap[store] == nil, "Store Type %q produced an store object already registered with Archivist instead of creating a new one.", storeType)
	end

	if id == nil then
		id = self:GenerateID()
	end

	self.activeStores[storeType][id] = store
	self.storeMap[store] = {
		id = id,
		prototype = self.prototypes[storeType],
		type = storeType
	}

	if image == nil then
		-- save initial image via Commit
		image = self.prototypes[storeType]:Commit(store)
	end
	self:Assert(image ~= nil, "Create Verb failed to generate initial image for archive.")
	self.sv[storeType][id] = {
		timestamp = time(),
		version = self.prototypes[storeType].version,
		data = self:Archive(image)
	}

	return store, id
end

-- clones archived data and/or active store object to newId
-- also provides an active store object of the cloned data if openStore is set
function Archivist:Clone(storeType, id, newId, openStore)
	do -- arg validation
		self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered to clone a store.")
		self:Assert(type(id) == "string" and (self.sv[storeType][id] or self.activeStores[storeType][id]), "Unable to clone store: store not found.")
	end

	if type(newId) ~= "string" then
		newId = self:GenerateID()
	end

	self:Assert(not self.sv[storeType][newId], "Store with ID %q already exists. Choose a different ID.")
	if self.activeStores[storeType][id] then
		-- go ahead and commit active store
		self:Commit(storeType, id)
	end

	-- thankfully, strings are easy to copy
	self.sv[storeType][newId] = {
		version = self.prototypes[storeType].version,
		timestamp = time(),
		data = self.sv[storeType][id].data
	}
	if openStore then
		return self:Open(storeType, newId), newId
	else
		return nil, newId
	end
end

function Archivist:CloneStore(store, newId, openStore)
	self:Assert(self.storeMap[store], "Unrecognized store was provided.")
	local info = self.storeMap[store]
	return self:Clone(info.type, info.id, newId, openStore)
end

-- deletes archived data if store is not currently open
-- Deleting unregistered store types must be done via setting force
function Archivist:Delete(storeType, id, force)
	do -- arg validation
		self:Warn(force or type(storeType == "string") and self.sv[storeType], "There are no stores to delete.")
		self:Assert(force or self.prototypes[storeType], "Store type should be registered before deleting a store. Call Delete again with arg #3 == true to override this.")
	end

	if id and storeType and self.sv[storeType] then
		self.sv[storeType][id] = nil
	end
end

function Archivist:DeleteStore(store)
	self:Assert(self.storeMap[store], "Unrecognized store was provided.")
	local info = self.storeMap[store]
	return self:Delete(info.type, info.id)
end

-- unpacks data in the archive into an active store object
-- if store is already active, then returns active store object
function Archivist:Open(storeType, id, ...)
	do -- arg validation
		self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered before opening a store.")
		self:Assert(type(id) == "string" and (self.sv[storeType][id] or self.activeStores[storeType][id]), "Could not find a store with that ID. Did you mean to call Archivist:Create?")
	end

	local store = self.activeStores[storeType][id]
	if not store then
		local saved = self.sv[storeType][id]
		local data = self:DeArchive(saved.data)
		local prototype = self.prototypes[storeType]
		-- migrate data...
		if prototype.Update and prototype.version > saved.version then
			local newData = prototype:Update(data, saved.version)
			if newData ~= nil then
				saved.data = self:Archive(newData)
				saved.timestamp = time()
			end
			saved.version = prototype.version
		end
		-- create store object...
		store = prototype:Open(data, ...)
		-- cache it so that we can close it later..
		self.activeStores[storeType][id] = store
		self.storeMap[store] = {
			id = id,
			prototype = self.prototypes[storeType],
			type = storeType
		}
	end
	return store
end

-- DANGEROUS FUNCTION
-- Your data will be lost. All of it. No going back.
-- Don't say I didn't warn you
function Archivist:DeleteAll(storeType)
	if storeType then
		self.sv[storeType] = nil
		for id, store in pairs(self.activeStores[storeType]) do
			self.activeStores[storeType][id] = nil
			self.storeMap[store] = nil
		end
	else
		for id in pairs(self.prototypes) do
			self.sv[id] = {}
			self.activeStores[id] = {}
		end
	end
	self.storeMap = {}
end

-- deactivates store, with one last opportunity to commit data if the prototype chooses to do so
function Archivist:Close(storeType, id)
	do -- arg validation
		self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Closing a store of an unregistered store type doesn't make sense.")
		self:Warn(type(id) == "string" and self.activeStores[storeType][id], "No store with that ID can be found.")
	end

	local store = self.activeStores[storeType][id]
	local saved = self.sv[storeType][id]
	if store then
		local image = self.prototypes[storeType]:Close(store)
		if image ~= nil then
			saved.data = self:Archive(image)
			saved.timestamp = time()
		end
		self.activeStores[storeType][id] = nil
		self.storeMap[store] = nil
	end
end

function Archivist:CloseStore(store)
	self:Assert(self.storeMap[store], "Unrecognized store was provided.")
	local info = self.storeMap[store]
	return self:Close(info.type, info.id)
end

function Archivist:CloseAllStores()
	for storeType, prototype in pairs(self.prototypes) do
		for id, store in pairs(self.activeStores[storeType]) do
			local image = prototype:Close(store)
			local saved = self.sv[storeType][id]
			self.activeStores[storeType] = nil
			if image then
				saved.data = self:Archive(image)
				saved.timestamp = time()
			end
		end
	end
end

-- archives an image of the store object
function Archivist:Commit(storeType, id)
	do -- arg validation
		self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Committing a store of an unregistered store type doesn't make sense.")
		self:Assert(type(id) == "string" and self.activeStores[storeType][id], "No store with that ID can be found.")
	end

	local store = self.activeStores[storeType][id]
	local image = self.prototypes[storeType]:Commit(store)
	local saved = self.sv[storeType][id]
	if image ~= nil then
		saved.data = self:Archive(image)
		saved.timestamp = time()
	end
end

function Archivist:CommitStore(store)
	self:Assert(self.storeMap[store], "Unrecognized store was provided.")
	local info = self.storeMap[store]
	return self:Commit(info.type, info.id)
end

-- opens or creates a storeType, depending on what is appropriate
-- this is the main entry point for other addons who just want their saved data
function Archivist:Load(storeType, id)
	do -- arg validation
		self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered before loading data.")
		self:Assert(id == nil or type(id) == "string", "Store ID must be a string if provided.")
	end

	if id == nil or not self.sv[storeType][id] then
		return self:Create(storeType, id)
	elseif self.activeStores[storeType][id] then
		return self.activeStores[storeType][id]
	else
		return self:Open(storeType, id)
	end
end

function Archivist:Check(storeType, id)
	do -- arg validation
		self:Assert(type(storeType) == "string", "Expected string for storeType, got %q.", type(storeType))
		self:Assert(type(id) == "string", "Expected string for storeID, got %q.", type(id))
	end
	if self.sv[storeType] and self.sv[storeType][id] then
		return true
	else
		return false
	end
end

do -- function Archivist:Archive(data)
	local tinsert, tconcat = table.insert, table.concat
	-- serialized string looks like
	-- <obj1>,<obj2>,...,<objN>,<value>
	-- (in most cases <value> will be just &1)
	-- <objN> is a series of 0 or more ^<value>:<value> pairs
	-- the contents of the string between ^ or : and the next magic character is a string,
	-- unless the first char is the magic #, in which case it is a number.
	-- @ becomes boolean true, $ becomes false
	-- &N is a reference to <objN>
	-- when deserializing, the result of <value> is our result
	local function replace(c) return "\\"..c end
	local function serialize(object)
		local seenObjects = {}
		local serializedObjects = {}
		local function inner(val)
			local valType = type(val)
			if valType == "boolean" then
				return val and "@" or "$"
			elseif valType == "number" then
				return "#" .. val
			elseif valType == "string" then
				-- escape all characters that might be confused as magic otherwise
				return (val:gsub("[\\&,^@$#:]", replace))
			elseif valType == "table" then
				if not seenObjects[val] then
					-- cross referencing is a thing. Not to hard to serialize but do be careful
					local index = #serializedObjects + 1
					seenObjects[val] = index
					local serialized = {}
					serializedObjects[index] = "" -- so that later inserts go to the correct spot
					for k,v in pairs(val) do
						local key, value = inner(k), inner(v)
						if key ~= nil and value ~= nil then
							tinsert(serialized, "^" .. inner(k))
							tinsert(serialized, ":" .. inner(v))
						end
					end
					serializedObjects[index] = tconcat(serialized)
				end
				return "&" .. seenObjects[val]
			end
		end
		tinsert(serializedObjects, inner(object))
		-- ensure that serialized data ends with a comma
		tinsert(serializedObjects, "")
		return tconcat(serializedObjects, ',')
	end

	function Archivist:Archive(data)
		local serialized = serialize(data)
		local compressed = LibDeflate:CompressDeflate(serialized)
		local encoded = LibDeflate:EncodeForPrint(compressed)
		return encoded
	end
end

do -- function Archivist:DeArchive(encoded)
	local escape2unused = {
		["\\"] = "\001",
		["&"] = "\002",
		[","] = "\003",
		["^"] = "\004",
		["@"] = "\005",
		["$"] = "\006",
		["#"] = "\007",
		[":"] = "\008",
	}
	local unused2Escape = Retail.tInvert(escape2unused)
	local unused = "[\001-\008]"
	local function unusify(c)
		return escape2unused[c] or c
	end
	local function escapify(c)
		return unused2Escape[c] or c
	end
	local function parse(value, objectList)
		local firstChar = value:sub(1,1)
		local remainder = value:sub(2)
		if firstChar == "@" then
			return true, "BOOL", remainder
		elseif firstChar == "$" then
			return false, "BOOL", remainder
		elseif firstChar == "#" then
			local num, rest = remainder:match("([^\\&,^@$#:]*)(.*)")
			return tonumber(num), "NUMBER", rest
		elseif firstChar == "^" then
			local str, rest = remainder:match("([^:^,]*)(.*)")
			local key = parse(str, objectList)
			return key, "KEY", rest
		elseif firstChar == ":" then
			local str, rest = remainder:match("([^:^,]*)(.*)")
			local val = parse(str, objectList)
			return val, "VALUE", rest
		elseif firstChar == "&" then
			local num, rest = remainder:match("([^\\&,^@$#:]*)(.*)")
			return objectList[tonumber(num)], "OBJECT", rest
		else
			local str, rest = value:match("([^\\&,^@$#:]*)(.*)")
			return str:gsub(unused, escapify), "STRING", rest
		end
	end
	local function deserialize(value)
		-- first, convert escaped magic characters to chars that we'll likely never find naturally
		value = value:gsub("\\([\\&,^@$#:])", unusify)
		-- then, split by comma to get a list of objects
		local serializedObjects = {}
		for piece in value:gmatch("([^,]*),") do
			table.insert(serializedObjects, piece)
		end
		local objects = {}
		-- create one empty object for each object in the list
		for i = 1, #serializedObjects - 1 do
			objects[i] = {}
		end
		for index = 1, #serializedObjects - 1 do
			local str = serializedObjects[index]
			local object = objects[index]
			local mode = "KEY"
			local key
			local newValue, valueType
			while #str > 0 do
				newValue, valueType, str = parse(str, objects)
				Archivist:Assert(valueType == mode, "Encountered unexpected token type while parsing object. Expected %q but got %q.", mode, valueType)
				if valueType == "KEY" then
					key = newValue
					mode = "VALUE"
				else
					mode = "KEY"
					object[key] = newValue
				end
			end
			Archivist:Assert(mode == "KEY", "Encountered end of serialized token unexpectedly.")
		end
		local deserialized, _, remainder = parse(serializedObjects[#serializedObjects], objects)
		Archivist:Assert(#remainder == 0, "Unexpected token at end of serialized string. Expected EOF, got %q.", remainder:sub(1,10))
		return deserialized
	end

	function Archivist:DeArchive(encoded)
		local compressed = LibDeflate:DecodeForPrint(encoded)
		local serialized = LibDeflate:DecompressDeflate(compressed)
		local data = deserialize(serialized)
		return data
	end
end
-- /run WeakAuras.LoadFromArchive("Repository", "history")
