--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Core/Config.lua

	SavedVariables, profiles and the settings access API.

	  Boom.profile           active profile table (read freely)
	  Boom.global            account-wide table
	  Boom.Config:Get(path)  "appearance.fontSize" -> value
	  Boom.Config:Set(path, value)

	Set() fires two things:
	  BOOM_CONFIG_CHANGED (path, value, oldValue)   - fine grained
	  and, for paths that change geometry, BOOM_LAYOUT_CHANGED.

	Profiles auto-switch with specialization when the player has mapped one.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Config = Boom:NewModule("Config")

local type, pairs, next = type, pairs, next
local strsplit, tonumber = strsplit, tonumber

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

--- Settings paths that belong entirely to the user once a profile exists.
--- Back-filling defaults into these would resurrect entries the user removed -
--- delete the "1" click-cast binding, reload, and it would be back.
--- They are still seeded when the profile is first created, because that is a
--- copy of the defaults rather than a merge.
local USER_OWNED = {
	["clickcast.bindings"] = true,
	["auras.extraImportant"] = true,
	["auras.blacklist"] = true,
	["auras.buffWatch"] = true,
	["auras.extraHots"] = true,
	["support.extraTracked"] = true,
}

--- Recursively copy any key present in `src` but missing from `dst`, so a
--- profile written by an older version of Boom picks up newly added settings.
local function copyDefaults(dst, src, path)
	if type(dst) ~= "table" then return src end

	for key, value in pairs(src) do
		local childPath = path and (path .. "." .. tostring(key)) or tostring(key)

		if USER_OWNED[childPath] then
			-- Ensure the container exists, then leave its contents alone.
			if type(dst[key]) ~= "table" then dst[key] = {} end
		elseif type(value) == "table" then
			if type(dst[key]) ~= "table" then
				dst[key] = {}
			end
			copyDefaults(dst[key], value, childPath)
		elseif dst[key] == nil then
			dst[key] = value
		end
	end

	return dst
end

local function deepCopy(src)
	if type(src) ~= "table" then return src end
	local out = {}
	for k, v in pairs(src) do
		out[k] = type(v) == "table" and deepCopy(v) or v
	end
	return out
end

Config.DeepCopy = function(_, t) return deepCopy(t) end

--- Resolve "a.b.c" against a table, returning the container table and final key.
local function resolve(root, path)
	local node = root
	local parts = { strsplit(".", path) }
	for i = 1, #parts - 1 do
		local key = parts[i]
		-- Numeric segments address array entries.
		local numeric = tonumber(key)
		if numeric then key = numeric end
		node = node[key]
		if type(node) ~= "table" then return nil, nil end
	end
	local last = parts[#parts]
	return node, tonumber(last) or last
end

--------------------------------------------------------------------------------
-- Identity
--------------------------------------------------------------------------------

local function characterKey()
	local name = UnitName("player") or "Unknown"
	local realm = GetRealmName() or "Unknown"
	return name .. " - " .. realm
end

--- Current specialization ID (e.g. 257 for Holy Priest), or nil while unknown.
function Config:GetPlayerSpecID()
	local api = C_SpecializationInfo
	local index
	if api and api.GetSpecialization then
		index = api.GetSpecialization()
	elseif _G.GetSpecialization then
		index = _G.GetSpecialization()
	end
	if not index or index == 0 then return nil end

	if api and api.GetSpecializationInfo then
		return (api.GetSpecializationInfo(index))
	elseif _G.GetSpecializationInfo then
		return (_G.GetSpecializationInfo(index))
	end
end

--- Human readable name for the current spec, used when auto-creating profiles.
function Config:GetPlayerSpecName()
	local api = C_SpecializationInfo
	local index = api and api.GetSpecialization and api.GetSpecialization()
	if not index or index == 0 then return nil end
	local _, name = api.GetSpecializationInfo(index)
	return name
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function Config:OnInitialize()
	BoomDB = BoomDB or {}
	local db = BoomDB

	db.profiles     = db.profiles     or {}
	db.profileKeys  = db.profileKeys  or {}
	db.global       = db.global       or {}

	copyDefaults(db.global, Boom.globalDefaults)

	if not db.profiles["Default"] then
		db.profiles["Default"] = deepCopy(Boom.defaults)
	end

	Boom.db     = db
	Boom.global = db.global
	Boom.debugMode = db.global.debugMode and true or false

	self.charKey = characterKey()
	db.profileKeys[self.charKey] = db.profileKeys[self.charKey] or { default = "Default", perSpec = false }

	self:LoadProfile(self:ResolveProfileName(), true)
end

function Config:OnEnable()
	self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED", function(_, _, unit)
		if unit and unit ~= "player" then return end
		self:OnSpecChanged()
	end)
	-- Talent swaps can change spec too on some paths.
	self:RegisterEvent("PLAYER_ENTERING_WORLD", function()
		self:OnSpecChanged()
	end)
end

function Config:OnSpecChanged()
	local wanted = self:ResolveProfileName()
	if wanted ~= self.currentProfileName then
		self:LoadProfile(wanted)
	end
	Boom:SendMessage("BOOM_SPEC_CHANGED", self:GetPlayerSpecID())
end

--------------------------------------------------------------------------------
-- Profile resolution
--------------------------------------------------------------------------------

function Config:GetKeyTable()
	return Boom.db.profileKeys[self.charKey]
end

--- Which profile should be active right now?
function Config:ResolveProfileName()
	local keys = self:GetKeyTable()
	if keys.perSpec then
		local specID = self:GetPlayerSpecID()
		if specID and keys[specID] and Boom.db.profiles[keys[specID]] then
			return keys[specID]
		end
	end
	if keys.default and Boom.db.profiles[keys.default] then
		return keys.default
	end
	return "Default"
end

function Config:LoadProfile(name, silent)
	local profiles = Boom.db.profiles
	if not profiles[name] then
		profiles[name] = deepCopy(Boom.defaults)
	end

	-- Back-fill any settings added by a newer version of Boom.
	copyDefaults(profiles[name], Boom.defaults)

	self.currentProfileName = name
	Boom.profile = profiles[name]

	if not silent then
		Boom:SendMessage("BOOM_PROFILE_CHANGED", name)
	end
end

function Config:GetCurrentProfileName()
	return self.currentProfileName
end

function Config:GetProfileList()
	local list = {}
	for name in pairs(Boom.db.profiles) do
		list[#list + 1] = name
	end
	table.sort(list)
	return list
end

function Config:CreateProfile(name, copyFrom)
	if not name or name == "" or Boom.db.profiles[name] then return false end
	local source = copyFrom and Boom.db.profiles[copyFrom] or Boom.defaults
	Boom.db.profiles[name] = deepCopy(source)
	return true
end

function Config:SwitchProfile(name)
	if not Boom.db.profiles[name] then return false end
	local keys = self:GetKeyTable()
	if keys.perSpec then
		local specID = self:GetPlayerSpecID()
		if specID then keys[specID] = name end
	end
	keys.default = keys.perSpec and keys.default or name
	if not keys.perSpec then keys.default = name end
	self:LoadProfile(name)
	return true
end

function Config:DeleteProfile(name)
	if name == "Default" or not Boom.db.profiles[name] then return false end
	Boom.db.profiles[name] = nil

	-- Detach every character/spec mapping that pointed at it.
	for _, keys in pairs(Boom.db.profileKeys) do
		if keys.default == name then keys.default = "Default" end
		for k, v in pairs(keys) do
			if v == name and type(k) == "number" then keys[k] = nil end
		end
	end

	if self.currentProfileName == name then
		self:LoadProfile(self:ResolveProfileName())
	end
	return true
end

function Config:ResetProfile()
	Boom.db.profiles[self.currentProfileName] = deepCopy(Boom.defaults)
	self:LoadProfile(self.currentProfileName)
end

function Config:CopyFrom(name)
	if not Boom.db.profiles[name] or name == self.currentProfileName then return false end
	Boom.db.profiles[self.currentProfileName] = deepCopy(Boom.db.profiles[name])
	self:LoadProfile(self.currentProfileName)
	return true
end

--- Enable / disable automatic per-specialization profile switching.
function Config:SetPerSpec(enabled)
	local keys = self:GetKeyTable()
	keys.perSpec = enabled and true or false

	if enabled then
		local specID = self:GetPlayerSpecID()
		if specID and not keys[specID] then
			-- Seed a profile named after the spec so the user sees an obvious slot.
			local specName = self:GetPlayerSpecName()
			local profileName = specName and (specName .. " - " .. (UnitClass("player") or "")) or ("Spec " .. specID)
			if not Boom.db.profiles[profileName] then
				Boom.db.profiles[profileName] = deepCopy(Boom.profile or Boom.defaults)
			end
			keys[specID] = profileName
		end
	end

	self:LoadProfile(self:ResolveProfileName())
end

function Config:IsPerSpec()
	return self:GetKeyTable().perSpec and true or false
end

--------------------------------------------------------------------------------
-- Settings access
--------------------------------------------------------------------------------

function Config:Get(path)
	local container, key = resolve(Boom.profile, path)
	if not container then return nil end
	return container[key]
end

--- Paths whose prefix requires a full geometry rebuild rather than a repaint.
local LAYOUT_PREFIXES = {
	["layouts"]    = true,
	["indicators"] = true,
	["powerBar"]   = true,
}

function Config:Set(path, value)
	local container, key = resolve(Boom.profile, path)
	if not container then
		Boom:Debug("Config:Set - unknown path", path)
		return
	end

	local old = container[key]
	if old == value then return end
	container[key] = value

	Boom:SendMessage("BOOM_CONFIG_CHANGED", path, value, old)

	local root = strsplit(".", path)
	if LAYOUT_PREFIXES[root] then
		-- Coalesce: dragging a slider fires dozens of these per second.
		Boom:Throttle("config-layout", 0.05, function()
			Boom:SendMessage("BOOM_LAYOUT_CHANGED")
		end)
	else
		Boom:Throttle("config-appearance", 0.05, function()
			Boom:SendMessage("BOOM_APPEARANCE_CHANGED")
		end)
	end
end

--- Toggle helper for checkboxes.
function Config:Toggle(path)
	self:Set(path, not self:Get(path))
	return self:Get(path)
end

--------------------------------------------------------------------------------
-- Import / export (string round-trip via the game's own serializer)
--------------------------------------------------------------------------------

function Config:ExportProfile(name)
	local profile = Boom.db.profiles[name or self.currentProfileName]
	if not profile then return nil end

	if C_EncodingUtil and C_EncodingUtil.SerializeCBOR then
		local ok, serialized = pcall(C_EncodingUtil.SerializeCBOR, profile)
		if ok and serialized then
			local ok2, encoded = pcall(C_EncodingUtil.EncodeBase64, serialized)
			if ok2 then return "BOOM1!" .. encoded end
		end
	end
	return nil
end

function Config:ImportProfile(str, targetName)
	if type(str) ~= "string" then return false, "empty" end
	local payload = str:match("^BOOM1!(.+)$")
	if not payload then return false, "format" end

	if not (C_EncodingUtil and C_EncodingUtil.DecodeBase64) then return false, "unsupported" end

	local ok, decoded = pcall(C_EncodingUtil.DecodeBase64, payload)
	if not ok or not decoded then return false, "decode" end

	local ok2, profile = pcall(C_EncodingUtil.DeserializeCBOR, decoded)
	if not ok2 or type(profile) ~= "table" then return false, "deserialize" end

	local name = targetName or self.currentProfileName
	Boom.db.profiles[name] = copyDefaults(profile, Boom.defaults)
	self:LoadProfile(name)
	return true
end
