local _, GF = ...

-- GroupFinder owns list-column policy in one model. The historical global is
-- kept as an identity alias so callbacks captured by older GF builds continue
-- to resolve the same owner.
local ColumnLayoutModel = {}
GF.ColumnLayoutModel = ColumnLayoutModel
GF.ListColumns = ColumnLayoutModel

local PROFILE_BROWSE_PVE = "browse_pve"
local PROFILE_BROWSE_PVP = "browse_pvp"
local PROFILE_APPLICANT = "applicant"

ColumnLayoutModel.PROFILE_BROWSE_PVE = PROFILE_BROWSE_PVE
ColumnLayoutModel.PROFILE_BROWSE_PVP = PROFILE_BROWSE_PVP
ColumnLayoutModel.PROFILE_APPLICANT = PROFILE_APPLICANT

local LAYOUT_ORIGIN_X = 2
local HEADER_OVERLAP = 2
local WIDTH_FLOOR_MIN = 20
local WIDTH_FLOOR_MAX = 300
local WEIGHT_MIN = 0
local WEIGHT_MAX = 3

ColumnLayoutModel.LAYOUT_ORIGIN_X = LAYOUT_ORIGIN_X
ColumnLayoutModel.HEADER_OVERLAP = HEADER_OVERLAP
ColumnLayoutModel.MIN_COLUMN_WIDTH = WIDTH_FLOOR_MIN
ColumnLayoutModel.MAX_COLUMN_WIDTH = WIDTH_FLOOR_MAX
ColumnLayoutModel.MIN_COLUMN_WEIGHT = WEIGHT_MIN
ColumnLayoutModel.MAX_COLUMN_WEIGHT = WEIGHT_MAX

local function browseColumns(scoreLabelKey)
	return {
		{
			id = "title", labelKey = "COL_TITLE", fallbackLabel = "Title",
			minWidth = 120, weight = 1.20, align = "LEFT", noHide = true,
		},
		{
			id = "activity", labelKey = "COL_ACTIVITY",
			fallbackLabel = "Activity", minWidth = 140, weight = 1.40,
			align = "LEFT", sortable = true, noHide = true,
		},
		{
			id = "type", labelKey = "COL_TYPE", fallbackLabel = "Type",
			minWidth = 80, weight = 0.80, align = "CENTER", noHide = true,
		},
		{
			id = "roles", labelKey = "COL_ROLES", fallbackLabel = "Members",
			minWidth = 140, weight = 1.40, align = "CENTER",
			sortable = true, noHide = true,
		},
		{
			id = "ilvl", labelKey = "COL_REQUIREMENT", fallbackLabel = "Requirement",
			minWidth = 60, weight = 0.60, align = "CENTER",
			sortable = true, noHide = true,
		},
		{
			id = "leader", labelKey = "COL_LEADER", fallbackLabel = "Leader",
			minWidth = 80, weight = 0.80, align = "CENTER", noHide = true,
		},
		{
			id = "score", labelKey = scoreLabelKey, fallbackLabel = "Rating",
			minWidth = 60, weight = 0.60, align = "CENTER",
			sortable = true, noHide = true,
		},
		{
			id = "comment", labelKey = "COL_DETAIL", fallbackLabel = "Details",
			minWidth = 160, weight = 1.60, align = "LEFT", noHide = true,
		},
	}
end

local SCHEMAS = {
	[PROFILE_BROWSE_PVE] = {
		id = PROFILE_BROWSE_PVE,
		kind = "browse",
		columns = browseColumns("COL_SCORE"),
	},
	[PROFILE_BROWSE_PVP] = {
		id = PROFILE_BROWSE_PVP,
		kind = "browse",
		columns = browseColumns("COL_PVP_SCORE"),
	},
	[PROFILE_APPLICANT] = {
		id = PROFILE_APPLICANT,
		kind = "applicant",
		columns = {
			{
				id = "type", labelKey = "COL_APP_TYPE", fallbackLabel = "Type",
				minWidth = 70, weight = 0.70, align = "CENTER", noHide = true,
			},
			{
				id = "name", labelKey = "COL_APP_NAME", fallbackLabel = "Character",
				minWidth = 146, weight = 1.46, align = "CENTER",
			},
			{
				id = "role", labelKey = "COL_APP_ROLE", fallbackLabel = "Role",
				minWidth = 70, weight = 0.70, align = "CENTER", sortable = true,
			},
			{
				id = "class", labelKey = "COL_APP_CLASS", fallbackLabel = "Spec",
				minWidth = 45, weight = 0.45, align = "CENTER",
			},
			{
				id = "score", labelKey = "COL_APP_SCORE",
				fallbackLabel = "Rating / Level", minWidth = 128, weight = 1.28,
				align = "CENTER", sortable = true,
			},
			{
				id = "ilvl", labelKey = "COL_ILVL", fallbackLabel = "Item Level",
				minWidth = 45, weight = 0.45, align = "CENTER", sortable = true,
			},
			{
				id = "detail", labelKey = "COL_APP_DETAIL", fallbackLabel = "Note",
				minWidth = 266, weight = 2.66, align = "LEFT",
			},
			{
				id = "actions", labelKey = "COL_APP_ACTIONS", fallbackLabel = "Actions",
				minWidth = 70, weight = 0.70, align = "CENTER", noHide = true,
			},
		},
	},
}

local function indexSchemas()
	for _, schema in pairs(SCHEMAS) do
		schema.byId = {}
		for index, definition in ipairs(schema.columns) do
			definition.index = index
			schema.byId[definition.id] = definition
		end
	end
end
indexSchemas()

local function canonicalProfile(profile)
	if profile == PROFILE_APPLICANT then
		return PROFILE_APPLICANT
	end
	if profile == PROFILE_BROWSE_PVP or profile == "pvp" then
		return PROFILE_BROWSE_PVP
	end
	return PROFILE_BROWSE_PVE
end

local function schemaFor(profile)
	local canonical = canonicalProfile(profile)
	return SCHEMAS[canonical], canonical
end

local function copyDefinition(definition)
	if not definition then
		return nil
	end
	local copy = {}
	for key, value in pairs(definition) do
		if key ~= "index" then
			copy[key] = value
		end
	end
	copy.index = definition.index
	return copy
end

local function copyArray(values)
	local copy = {}
	for index, value in ipairs(values or {}) do
		copy[index] = value
	end
	return copy
end

local function copyBooleanMap(values)
	local copy = {}
	for key, value in pairs(values or {}) do
		copy[key] = value == true
	end
	return copy
end

local function database()
	local db
	if type(GF.GetDB) == "function" then
		local ok, value = pcall(GF.GetDB)
		if ok and type(value) == "table" then
			db = value
		end
	end
	if type(db) ~= "table" and type(GF.db) == "table" then
		db = GF.db
	end
	if type(db) ~= "table" and type(_G) == "table"
		and type(_G.GroupFinderDB) == "table"
	then
		db = _G.GroupFinderDB
	end
	if type(db) ~= "table" then
		db = {}
		GF.db = db
		if type(_G) == "table" then
			_G.GroupFinderDB = db
		end
	end
	return db
end

local function finiteNumber(value)
	local ok, number = pcall(tonumber, value)
	if not ok or number == nil or number ~= number
		or number == math.huge or number == -math.huge
	then
		return nil
	end
	return number
end

local function normalizeMinimum(value)
	local number = finiteNumber(value)
	if number == nil then
		return nil
	end
	number = math.floor(number + 0.5)
	if number < WIDTH_FLOOR_MIN or number > WIDTH_FLOOR_MAX then
		return nil
	end
	return number
end

local function normalizeWeight(value)
	local number = finiteNumber(value)
	if number == nil or number < WEIGHT_MIN or number > WEIGHT_MAX then
		return nil
	end
	return math.floor(number * 100 + 0.5) / 100
end

local function trim(value)
	if type(value) ~= "string" then
		return nil
	end
	return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end

local function denseArrayValues(raw)
	local values = {}
	local indices = {}
	if type(raw) == "table" then
		for key in pairs(raw) do
			if type(key) == "number" and key > 0 and key == math.floor(key) then
				indices[#indices + 1] = key
			end
		end
	end
	table.sort(indices)
	for _, index in ipairs(indices) do
		values[#values + 1] = raw[index]
	end
	return values
end

local function normalizeOrder(raw, schema)
	local order = {}
	local seen = {}
	for _, columnID in ipairs(denseArrayValues(raw)) do
		if type(columnID) == "string" and schema.byId[columnID]
			and not seen[columnID]
		then
			seen[columnID] = true
			order[#order + 1] = columnID
		end
	end
	for _, definition in ipairs(schema.columns) do
		if not seen[definition.id] then
			seen[definition.id] = true
			order[#order + 1] = definition.id
		end
	end
	return order
end

local function sameDenseArray(raw, normalized)
	if type(raw) ~= "table" then
		return false
	end
	local count = 0
	for key in pairs(raw) do
		if type(key) ~= "number" or key <= 0 or key ~= math.floor(key) then
			return false
		end
		count = count + 1
	end
	if count ~= #normalized then
		return false
	end
	for index, value in ipairs(normalized) do
		if raw[index] ~= value then
			return false
		end
	end
	return true
end

local function ensureBrowseStore(db, field)
	if type(db[field]) ~= "table" then
		db[field] = {}
	end
	return db[field]
end

local function rawOrder(db, profile)
	if profile == PROFILE_APPLICANT then
		return db.applicantColumnOrder
	end
	local profiles = db.browseColumnOrder
	return type(profiles) == "table" and profiles[profile] or nil
end

local function writeOrder(db, profile, order)
	local saved = copyArray(order)
	if profile == PROFILE_APPLICANT then
		db.applicantColumnOrder = saved
	else
		ensureBrowseStore(db, "browseColumnOrder")[profile] = saved
	end
end

local function ownedOrder(db, profile, schema)
	local raw = rawOrder(db, profile)
	local normalized = normalizeOrder(raw, schema)
	if not sameDenseArray(raw, normalized) then
		writeOrder(db, profile, normalized)
	end
	return normalized
end

local function defaultVisibility(schema)
	local visible = {}
	for _, definition in ipairs(schema.columns) do
		visible[definition.id] = true
	end
	return visible
end

local function rawVisibility(db, profile)
	if profile == PROFILE_APPLICANT then
		return db.applicantColumnVisible
	end
	local profiles = db.browseColumnVisible
	return type(profiles) == "table" and profiles[profile] or nil
end

local function normalizeVisibility(raw, schema)
	local visible = defaultVisibility(schema)
	if type(raw) == "table" then
		for _, definition in ipairs(schema.columns) do
			if raw[definition.id] == false and definition.noHide ~= true then
				visible[definition.id] = false
			end
		end
	end
	return visible
end

local function sameVisibility(raw, normalized, schema)
	if type(raw) ~= "table" then
		return false
	end
	local count = 0
	for key, value in pairs(raw) do
		if not schema.byId[key] or type(value) ~= "boolean" then
			return false
		end
		count = count + 1
	end
	if count ~= #schema.columns then
		return false
	end
	for _, definition in ipairs(schema.columns) do
		if raw[definition.id] ~= normalized[definition.id] then
			return false
		end
	end
	return true
end

local function writeVisibility(db, profile, visible)
	local saved = copyBooleanMap(visible)
	if profile == PROFILE_APPLICANT then
		db.applicantColumnVisible = saved
	else
		ensureBrowseStore(db, "browseColumnVisible")[profile] = saved
	end
end

local function ownedVisibility(db, profile, schema)
	local raw = rawVisibility(db, profile)
	local normalized = normalizeVisibility(raw, schema)
	if not sameVisibility(raw, normalized, schema) then
		writeVisibility(db, profile, normalized)
	end
	return normalized
end

local function layoutRoot(db)
	if type(db.browseColumnLayout) ~= "table" then
		db.browseColumnLayout = {}
	end
	return db.browseColumnLayout
end

local function normalizeOverrides(db, profile, schema)
	local root = layoutRoot(db)
	local raw = type(root[profile]) == "table" and root[profile] or nil
	local normalized = {}
	for _, definition in ipairs(schema.columns) do
		local saved = raw and raw[definition.id]
		if type(saved) == "table" then
			local minimum = normalizeMinimum(
				saved.minWidth or saved.minimum or saved.min)
			local weight = normalizeWeight(saved.weight)
			minimum = minimum or definition.minWidth
			weight = weight or definition.weight
			if minimum ~= definition.minWidth or weight ~= definition.weight then
				normalized[definition.id] = {
					minWidth = minimum,
					weight = weight,
				}
			end
		end
	end
	if next(normalized) == nil then
		root[profile] = nil
	else
		root[profile] = normalized
	end
	return normalized
end

local function effectiveDefinition(definition, overrides)
	local copy = copyDefinition(definition)
	local override = overrides and overrides[definition.id]
	if override then
		copy.minWidth = override.minWidth
		copy.weight = override.weight
	end
	return copy
end

local layoutCache = {}
local layoutCacheEntries = 0

function ColumnLayoutModel:InvalidateCache()
	layoutCache = {}
	layoutCacheEntries = 0
end

function ColumnLayoutModel:GetProfileIDs()
	return {
		PROFILE_BROWSE_PVE,
		PROFILE_BROWSE_PVP,
		PROFILE_APPLICANT,
	}
end

function ColumnLayoutModel:GetSchema(profile)
	local schema, canonical = schemaFor(profile)
	local snapshot = {
		id = canonical,
		kind = schema.kind,
		columns = {},
		byId = {},
	}
	for index, definition in ipairs(schema.columns) do
		local copy = copyDefinition(definition)
		snapshot.columns[index] = copy
		snapshot.byId[copy.id] = copy
	end
	return snapshot
end

function ColumnLayoutModel:GetDefaultColumnOrder(profile)
	local schema = schemaFor(profile)
	local order = {}
	for index, definition in ipairs(schema.columns) do
		order[index] = definition.id
	end
	return order
end

function ColumnLayoutModel:GetDefaultColumnVisible(profile)
	local schema = schemaFor(profile)
	return defaultVisibility(schema)
end

function ColumnLayoutModel:GetColumnOrder(profile)
	local schema, canonical = schemaFor(profile)
	return copyArray(ownedOrder(database(), canonical, schema))
end

function ColumnLayoutModel:SetColumnOrder(profile, requestedOrder)
	local schema, canonical = schemaFor(profile)
	if type(requestedOrder) ~= "table" then
		return false
	end
	local db = database()
	local current = ownedOrder(db, canonical, schema)
	local normalized = normalizeOrder(requestedOrder, schema)
	if sameDenseArray(current, normalized) then
		return false
	end
	writeOrder(db, canonical, normalized)
	self:InvalidateCache()
	return true
end

function ColumnLayoutModel:ResetColumnOrder(profile)
	return self:SetColumnOrder(profile, self:GetDefaultColumnOrder(profile))
end

function ColumnLayoutModel:GetColumnVisible(profile)
	local schema, canonical = schemaFor(profile)
	return copyBooleanMap(ownedVisibility(database(), canonical, schema))
end

function ColumnLayoutModel:SetColumnVisible(profile, columnID, visible)
	local schema, canonical = schemaFor(profile)
	local definition = schema.byId[columnID]
	if not definition or (definition.noHide == true and visible == false) then
		return false
	end
	local db = database()
	local current = ownedVisibility(db, canonical, schema)
	local requested = visible ~= false
	if current[columnID] == requested then
		return false
	end
	current[columnID] = requested
	writeVisibility(db, canonical, current)
	self:InvalidateCache()
	return true
end

function ColumnLayoutModel:ResetColumnVisibility(profile)
	local schema, canonical = schemaFor(profile)
	local db = database()
	local current = ownedVisibility(db, canonical, schema)
	local defaults = defaultVisibility(schema)
	if sameVisibility(current, defaults, schema) then
		return false
	end
	writeVisibility(db, canonical, defaults)
	self:InvalidateCache()
	return true
end

function ColumnLayoutModel:IsColumnNoHide(profile, columnID)
	local schema = schemaFor(profile)
	local definition = schema.byId[columnID]
	return definition ~= nil and definition.noHide == true
end

function ColumnLayoutModel:MoveColumn(profile, columnID, delta)
	local schema, canonical = schemaFor(profile)
	if not schema.byId[columnID] then
		return false
	end
	delta = finiteNumber(delta)
	if delta == nil then
		return false
	end
	delta = delta < 0 and math.ceil(delta) or math.floor(delta)
	if delta == 0 then
		return false
	end
	local db = database()
	local order = ownedOrder(db, canonical, schema)
	local source
	for index, current in ipairs(order) do
		if current == columnID then
			source = index
			break
		end
	end
	if source == nil then
		return false
	end
	local destination = source + delta
	if destination < 1 or destination > #order or destination == source then
		return false
	end
	order[source], order[destination] = order[destination], order[source]
	writeOrder(db, canonical, order)
	self:InvalidateCache()
	return true
end

function ColumnLayoutModel:GetColumnDef(profile, columnID)
	local schema, canonical = schemaFor(profile)
	local definition = schema.byId[columnID]
	if not definition then
		return nil
	end
	local overrides = normalizeOverrides(database(), canonical, schema)
	return effectiveDefinition(definition, overrides)
end

function ColumnLayoutModel:ClampMinWidth(value)
	local lower = tonumber(GF.COL_MIN_WIDTH_MIN) or WIDTH_FLOOR_MIN
	local upper = tonumber(GF.COL_MIN_WIDTH_MAX) or WIDTH_FLOOR_MAX
	local number = finiteNumber(value) or lower
	return math.max(lower, math.min(upper, math.floor(number + 0.5)))
end

function ColumnLayoutModel:ClampWeight(value)
	local lower = tonumber(GF.COL_WEIGHT_MIN) or WEIGHT_MIN
	local upper = tonumber(GF.COL_WEIGHT_MAX) or WEIGHT_MAX
	local number = finiteNumber(value) or lower
	number = math.floor(number * 100 + 0.5) / 100
	return math.max(lower, math.min(upper, number))
end

local function shouldScaleMinimum(profile, columnID)
	if canonicalProfile(profile) == PROFILE_APPLICANT then
		return columnID == "type" or columnID == "role" or columnID == "class"
	end
	return columnID == "type" or columnID == "roles"
end

local function scaledMinimum(profile, columnID, minimum)
	local scale = 1
	if type(GF.GetFontScale) == "function" then
		local ok, value = pcall(GF.GetFontScale)
		if ok and finiteNumber(value) then
			scale = value
		end
	end
	if scale <= 1 or not shouldScaleMinimum(profile, columnID) then
		return minimum
	end
	return math.max(1, math.floor(minimum * scale + 0.5))
end

function ColumnLayoutModel:GetEffectiveColumnDef(profile, columnID)
	local definition = self:GetColumnDef(profile, columnID)
	if not definition then
		return nil
	end
	definition.minWidth = scaledMinimum(
		profile, columnID, self:ClampMinWidth(definition.minWidth))
	definition.weight = self:ClampWeight(definition.weight)
	return definition
end

function ColumnLayoutModel:ParseMinWidthText(value)
	local number = finiteNumber(type(value) == "string" and trim(value) or value)
	if number == nil then
		return nil
	end
	local rounded = math.floor(number + 0.5)
	local lower = tonumber(GF.COL_MIN_WIDTH_MIN) or WIDTH_FLOOR_MIN
	local upper = tonumber(GF.COL_MIN_WIDTH_MAX) or WIDTH_FLOOR_MAX
	return rounded >= lower and rounded <= upper and rounded or nil
end

function ColumnLayoutModel:FormatMinWidth(value)
	return tostring(self:ClampMinWidth(value))
end

function ColumnLayoutModel:ParseWeightText(value)
	local number = finiteNumber(type(value) == "string" and trim(value) or value)
	if number == nil then
		return nil
	end
	local lower = tonumber(GF.COL_WEIGHT_MIN) or WEIGHT_MIN
	local upper = tonumber(GF.COL_WEIGHT_MAX) or WEIGHT_MAX
	if number < lower or number > upper then
		return nil
	end
	return math.floor(number * 100 + 0.5) / 100
end

function ColumnLayoutModel:FormatWeight(value)
	return string.format("%.2f", self:ClampWeight(value))
end

function ColumnLayoutModel:SetColumnLayout(profile, columnID, minWidth, weight)
	local schema, canonical = schemaFor(profile)
	local definition = schema.byId[columnID]
	if not definition then
		return false
	end
	local minimum = self:ClampMinWidth(minWidth)
	local normalizedWeight = self:ClampWeight(weight)
	local db = database()
	local overrides = normalizeOverrides(db, canonical, schema)
	local current = effectiveDefinition(definition, overrides)
	if current.minWidth == minimum and current.weight == normalizedWeight then
		return true
	end
	if minimum == definition.minWidth and normalizedWeight == definition.weight then
		overrides[columnID] = nil
	else
		overrides[columnID] = {
			minWidth = minimum,
			weight = normalizedWeight,
		}
	end
	local root = layoutRoot(db)
	root[canonical] = next(overrides) and overrides or nil
	self:InvalidateCache()
	return true
end

function ColumnLayoutModel:ResetColumnLayout(profile, columnID)
	local schema, canonical = schemaFor(profile)
	if not schema.byId[columnID] then
		return false
	end
	local db = database()
	local overrides = normalizeOverrides(db, canonical, schema)
	if overrides[columnID] ~= nil then
		overrides[columnID] = nil
		layoutRoot(db)[canonical] = next(overrides) and overrides or nil
		self:InvalidateCache()
	end
	return true
end

function ColumnLayoutModel:GetHeaderLabel(columnID, profile)
	local definition = self:GetColumnDef(profile, columnID)
	if not definition then
		return tostring(columnID or "")
	end
	local locale = GF.L or {}
	return locale[definition.labelKey]
		or definition.fallbackLabel
		or definition.id
end

function ColumnLayoutModel:IsSortable(columnID, profile)
	local schema = schemaFor(profile)
	local definition = schema.byId[columnID]
	return definition ~= nil and definition.sortable == true
end

local BROWSE_SORT_HINTS = {
	activity = "COL_SORT_ACTIVITY_TIP",
	roles = "COL_SORT_ROLES_TIP",
	ilvl = "COL_SORT_ILVL_TIP",
	score = "COL_SORT_SCORE_TIP",
}

function ColumnLayoutModel:GetSortHint(columnID, profile)
	if canonicalProfile(profile) == PROFILE_APPLICANT
		or not self:IsSortable(columnID, profile)
	then
		return nil
	end
	local localeKey = BROWSE_SORT_HINTS[columnID]
	local locale = GF.L or {}
	return localeKey and locale[localeKey] or nil
end

ColumnLayoutModel.GetSortTooltip = ColumnLayoutModel.GetSortHint

local function isPvpCategory(categoryID)
	if categoryID == 6 or categoryID == 7
		or categoryID == 8 or categoryID == 9
	then
		return true
	end
	for _, category in ipairs(GF.PVP_CATEGORIES or {}) do
		if categoryID == category.id then
			return true
		end
	end
	return false
end

function ColumnLayoutModel:IsPvpBrowseContext(node)
	if not node and GF.FindGroupTab
		and type(GF.FindGroupTab.GetSelection) == "function"
	then
		node = GF.FindGroupTab:GetSelection()
	end
	if not node and GF.MainFrame then
		node = GF.MainFrame.selection
	end
	if type(node) ~= "table" then
		return false
	end
	local pvpFilter = Enum and Enum.LFGListFilter
		and Enum.LFGListFilter.PvP
	return node.isPvp == true or node.isPvP == true
		or (pvpFilter ~= nil and node.preferredFilters == pvpFilter)
		or isPvpCategory(node.categoryID)
		or node.activityID == (GF.ACTIVITY_CUSTOM_PVP or 17)
		or node.navKind == "pvp"
		or node.groupID == "pvp"
		or node.groupID == "custom_pvp"
		or false
end

function ColumnLayoutModel:GetBrowseProfile(node)
	return self:IsPvpBrowseContext(node)
		and PROFILE_BROWSE_PVP or PROFILE_BROWSE_PVE
end

local function layoutWidth(value)
	local number = finiteNumber(value) or 1
	return math.max(1, math.floor(number + 0.5))
end

local function stateSnapshot(profile)
	local schema, canonical = schemaFor(profile)
	local db = database()
	local order = ownedOrder(db, canonical, schema)
	local visible = ownedVisibility(db, canonical, schema)
	local overrides = normalizeOverrides(db, canonical, schema)
	local fontScale = 100
	if type(GF.GetFontScalePct) == "function" then
		local ok, value = pcall(GF.GetFontScalePct)
		if ok and finiteNumber(value) then
			fontScale = value
		end
	end
	local signature = { canonical, "font=" .. tostring(fontScale) }
	for _, columnID in ipairs(order) do
		local definition = schema.byId[columnID]
		local effective = effectiveDefinition(definition, overrides)
		signature[#signature + 1] = table.concat({
			columnID,
			visible[columnID] and "1" or "0",
			tostring(effective.minWidth),
			tostring(effective.weight),
		}, ",")
	end
	return {
		profile = canonical,
		schema = schema,
		order = order,
		visible = visible,
		overrides = overrides,
		signature = table.concat(signature, ";"),
	}
end

function ColumnLayoutModel:LayoutOverrideSig(profile)
	local schema, canonical = schemaFor(profile)
	local overrides = normalizeOverrides(database(), canonical, schema)
	local entries = {}
	for columnID, values in pairs(overrides) do
		entries[#entries + 1] = table.concat({
			columnID,
			tostring(values.minWidth or ""),
			tostring(values.weight or ""),
		}, ":")
	end
	table.sort(entries)
	return table.concat(entries, ";")
end

function ColumnLayoutModel:BuildActiveIds(profile)
	local state = stateSnapshot(profile)
	local active = {}
	for _, columnID in ipairs(state.order) do
		if state.visible[columnID] ~= false then
			active[#active + 1] = columnID
		end
	end
	return active
end

local function calculateWidths(definitions, availableWidth)
	local minimumTotal = 0
	local weightTotal = 0
	for _, definition in ipairs(definitions) do
		minimumTotal = minimumTotal + definition.minWidth
		weightTotal = weightTotal + definition.weight
	end
	local widths = {}
	if minimumTotal <= 0 then
		return widths, minimumTotal, 1
	end
	if availableWidth < minimumTotal then
		local compression = availableWidth / minimumTotal
		for index, definition in ipairs(definitions) do
			widths[index] = math.max(
				1, math.floor(definition.minWidth * compression))
		end
		return widths, minimumTotal, compression
	end

	for index, definition in ipairs(definitions) do
		widths[index] = definition.minWidth
	end
	if weightTotal <= 0 then
		return widths, minimumTotal, 1
	end
	local spare = availableWidth - minimumTotal
	local allocated = 0
	local weighted = {}
	for index, definition in ipairs(definitions) do
		if definition.weight > 0 then
			weighted[#weighted + 1] = index
			local addition = math.floor(spare * definition.weight / weightTotal)
			widths[index] = widths[index] + addition
			allocated = allocated + addition
		end
	end
	local remainder = spare - allocated
	local cursor = 1
	while remainder > 0 and #weighted > 0 do
		local index = weighted[cursor]
		widths[index] = widths[index] + 1
		remainder = remainder - 1
		cursor = cursor == #weighted and 1 or cursor + 1
	end
	return widths, minimumTotal, 1
end

function ColumnLayoutModel:ResolveLayout(width, profile)
	local requestedWidth = layoutWidth(width)
	local state = stateSnapshot(profile)
	local cacheKey = table.concat({
		state.profile,
		tostring(requestedWidth),
		state.signature,
	}, "\031")
	local cached = layoutCache[cacheKey]
	if cached then
		return cached
	end

	local definitions = {}
	for _, columnID in ipairs(state.order) do
		if state.visible[columnID] ~= false then
			local definition = effectiveDefinition(
				state.schema.byId[columnID], state.overrides)
			definition.minWidth = scaledMinimum(
				state.profile,
				columnID,
				self:ClampMinWidth(definition.minWidth)
			)
			definition.weight = self:ClampWeight(definition.weight)
			definitions[#definitions + 1] = definition
		end
	end
	local availableWidth = math.max(
		1, requestedWidth - LAYOUT_ORIGIN_X - HEADER_OVERLAP)
	local widths, minimumTotal, scale = calculateWidths(definitions, availableWidth)
	local layout = {
		profile = state.profile,
		active = {},
		byId = {},
		headerLayout = {},
		contentWidth = requestedWidth,
		availableWidth = availableWidth,
		usedWidth = 0,
		minimumWidth = minimumTotal + LAYOUT_ORIGIN_X + HEADER_OVERLAP,
		scale = scale,
		signature = state.signature,
	}
	local x = LAYOUT_ORIGIN_X
	for index, definition in ipairs(definitions) do
		local columnWidth = widths[index]
		local column = copyDefinition(definition)
		column.index = index
		column.x = x
		column.width = columnWidth
		column.visible = true
		layout.active[index] = column.id
		layout.byId[column.id] = column
		layout.headerLayout[index] = { width = columnWidth + HEADER_OVERLAP }
		layout.usedWidth = layout.usedWidth + columnWidth
		x = x + columnWidth
	end

	if layoutCacheEntries >= 64 then
		layoutCache = {}
		layoutCacheEntries = 0
	end
	layoutCache[cacheKey] = layout
	layoutCacheEntries = layoutCacheEntries + 1
	return layout
end

function ColumnLayoutModel:GetRelayoutSig(width, profile)
	if not finiteNumber(width) or width <= 1 then
		return nil
	end
	local layout = self:ResolveLayout(width, profile)
	local signature = {
		layout.profile,
		tostring(layout.contentWidth),
		layout.signature,
	}
	for _, columnID in ipairs(layout.active) do
		local column = layout.byId[columnID]
		signature[#signature + 1] = columnID .. "=" .. tostring(column.width)
	end
	return table.concat(signature, "|")
end

local function validBrowseSortColumn(columnID)
	return SCHEMAS[PROFILE_BROWSE_PVE].byId[columnID] ~= nil
		and SCHEMAS[PROFILE_BROWSE_PVE].byId[columnID].sortable == true
end

local function validApplicantSortColumn(columnID)
	return SCHEMAS[PROFILE_APPLICANT].byId[columnID] ~= nil
		and SCHEMAS[PROFILE_APPLICANT].byId[columnID].sortable == true
end

function ColumnLayoutModel:GetBrowseSort()
	local saved = database().browseSort
	local columnID = type(saved) == "table" and saved.column or nil
	if not validBrowseSortColumn(columnID) then
		return { column = "title", asc = true }
	end
	return { column = columnID, asc = saved.asc ~= false }
end

function ColumnLayoutModel:SetBrowseSort(columnID, ascending)
	if not validBrowseSortColumn(columnID) then
		return false
	end
	database().browseSort = { column = columnID, asc = ascending == true }
	return true
end

function ColumnLayoutModel:ToggleBrowseSort(columnID)
	if not validBrowseSortColumn(columnID) then
		return false
	end
	local current = self:GetBrowseSort()
	local ascending
	if current.column == columnID then
		ascending = not current.asc
	else
		ascending = columnID == "title" or columnID == "activity"
	end
	return self:SetBrowseSort(columnID, ascending)
end

function ColumnLayoutModel:GetApplicantSort()
	local saved = database().applicantSort
	if type(saved) ~= "table" or not validApplicantSortColumn(saved.column) then
		return nil
	end
	return { column = saved.column, asc = saved.asc ~= false }
end

function ColumnLayoutModel:SetApplicantSort(columnID, ascending)
	if not validApplicantSortColumn(columnID) then
		return false
	end
	database().applicantSort = {
		column = columnID,
		asc = ascending == true,
	}
	return true
end

function ColumnLayoutModel:ToggleApplicantSort(columnID)
	if not validApplicantSortColumn(columnID) then
		return false
	end
	local current = self:GetApplicantSort()
	local ascending
	if current and current.column == columnID then
		ascending = not current.asc
	else
		ascending = columnID == "role"
	end
	return self:SetApplicantSort(columnID, ascending)
end
