Модуль:MetaCat/Core

Материал из DZWIKI
Перейти к навигации Перейти к поиску
Документация


Описание

Ядро системы категоризации MetaCat. Содержит утилиты, фабрики навигации и работу с географическими данными.

Подмодули

  • Core — утилиты, фабрики
  • Year — общая логика, утилиты, фабрики
  • Decade — обработчик десятилетий
  • Century — обработчик веков
  • Millennium — обработчик тысячелетий
  • Geo — географический резолвер

Данные

-- ◆ MetaCat/Core
-- Унифицированная система обработки временных категорий

local Core = {}

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 1: КОНСТАНТЫ И КОНФИГУРАЦИЯ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ JSON файлы данных (консолидированные geo-* файлы)
Core.JSON_PATHS = {
	-- Консолидированные географические данные
	GEO_COUNTRY 		= 'Модуль:MetaCat/data/geo-country.json',
	GEO_CITY 			= 'Модуль:MetaCat/data/geo-city.json',
	GEO_REGION 			= 'Модуль:MetaCat/data/geo-region.json',
	GEO_CONTINENT 		= 'Модуль:MetaCat/data/geo-continent.json'
}

-- ▼ Константы префиксов
local PREFIX_QUESTION, PREFIX_FALLBACK = '?', '~'
local PREFIX_PATTERN = '[%?~]'

-- ▼ Кеш загруженных JSON файлов (ленивая загрузка)
local json_cache = {}

-- ▼ Единый кеш данных о странах (падежи, предлог, succession)
local country_unified_cache = {}

-- ▼ Конфигурация навигации
local NAVBOX_CONFIG = {
	BATCH_SIZE = 25,
	MAX_BATCHES = 4
}

-- ▼ Стили для блока разветвления
local FORK_STYLES = {
	base = 'display:inline-block;vertical-align:middle;text-align:center;line-height:1.4',
	border_left = ';border-left:2px solid #a2a9b1;padding-left:0.5em;margin-left:0.3em',
	border_right = ';border-right:2px solid #a2a9b1;padding-right:0.5em;margin-right:0.3em',
	border_both = ';border-left:2px solid #a2a9b1;border-right:2px solid #a2a9b1;padding:0 0.5em;margin:0 0.3em'
}

-- ▼ Дефолтные настройки режимов
Core.DEFAULTS = {
	SKIP_1CENTURY = false,
	SKIP_GAPS = false,
	SPLIT = false
}

-- ▼ Базовая конфигурация
Core.base_config = {
	errors = {
		country_not_found = 6,
		continent_not_found = 7,
		city_not_found = 8,
		region_not_found = 9
	},
	range_codes = { [2]=true, [3]=true, [4]=true, [5]=true }
}

-- ▼ Словарь типов времени
local TIME_TYPE_NAMES = {
	year = {
		nom = 'год',
		gen = 'года',
		placeholder = '<год>',
		root_category = 'Годы',
		detail_level = 3
	},
	decade = {
		nom = 'десятилетие',
		gen = 'десятилетия',
		placeholder = '<десятилетие>',
		root_category = 'Десятилетия',
		detail_level = 2
	},
	century = {
		nom = 'век',
		gen = 'века',
		placeholder = '<век>',
		root_category = 'Века',
		detail_level = 1
	},
	millennium = {
		nom = 'тысячелетие',
		gen = 'тысячелетия',
		placeholder = '<тысячелетие>',
		root_category = 'Тысячелетия',
		detail_level = 0
	}
}

-- ▼ Шаблоны сообщений для ошибки валидации типов
local TYPE_VALIDATION_MSG_MISMATCH = 'В категорию для %s вызывается шаблон для %s'

-- ▼ Конфигурация путей для геообъектов
local GEO_BOUNDS_CONFIG = {
	country = {
		json_path = Core.JSON_PATHS.GEO_COUNTRY,
		array_key = 'country',
		historical_key = 'historical'
	},
	region = {
		json_path = Core.JSON_PATHS.GEO_REGION,
		array_key = 'region',
		parent_key = 'country'
	},
	city = {
		json_path = Core.JSON_PATHS.GEO_CITY,
		array_key = 'city'
	}
}

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 2: ЗАВИСИМОСТИ
-- ══════════════════════════════════════════════════════════════════════════════

local getArgs = require('Модуль:Arguments').getArgs
local sparseIpairs = require('Модуль:TableTools').sparseIpairs
local getStyles = require('Модуль:Индекс категории').getStyles
local gsub = mw.ustring.gsub

-- Forward declaration (expand_all используется до определения)
local expand_all

-- ▼ Общие зависимости для экспорта в дочерние модули
Core.deps = {
	toroman = require('Module:Roman').convert,
	gsub = gsub
}

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 3: БАЗОВЫЕ УТИЛИТЫ
-- ══════════════════════════════════════════════════════════════════════════════

Core.utils = {}

-- ▼ Проверка типа таблицы
local function is_table(value) return type(value) == 'table' end
Core.utils.is_table = is_table

-- ▼ Получение первого символа строки
local function first_char(s) return s and s:sub(1, 1) or '' end

-- ▼ Поверхностное копирование таблицы
local function table_shallow_copy(t)
	local r = {}
	for k, v in pairs(t or {}) do r[k] = v end
	return r
end

-- ▼ Возврат первого непустого значения
Core.utils.first_non_empty = function(a, b, c)
	return (a and a ~= '' and a) or (b and b ~= '' and b) or c
end

-- ▼ Аккумулятор результатов с лимитом
Core.utils.new_result_accumulator = function(limit)
	local acc = { _seen = {}, _list = {}, _limit = limit or 2 }
	acc.add = function(self, value)
		if not value or value == '' or self._seen[value] or #self._list >= self._limit then return false end
		table.insert(self._list, value)
		self._seen[value] = true
		return true
	end
	acc.list = function(self) return self._list end
	return acc
end

-- ▼ Применение замен плейсхолдеров
Core.utils.apply_replacements = function(text, ...)
	local merged = {}
	for i = 1, select('#', ...) do
		local t = select(i, ...)
		if is_table(t) then for k, v in pairs(t) do merged[k] = v end end
	end
	for placeholder, value in pairs(merged) do text = gsub(text, placeholder, value) end
	return text
end

-- ▼ Проверка попадания года в период
Core.utils.is_year_in_period = function(start_year, end_year, type_name, time)
	local year = tonumber(time)
	if not year then return true end
	local s, e
	if type_name == 'century' then
		s, e = (year - 1) * 100 + 1, year * 100
	elseif type_name == 'decade' then
		s = math.floor(year / 10) * 10
		e = s + 9
	elseif type_name == 'millennium' then
		-- Формат навигации: 1=1-е н.э., 0=1-е до н.э., -1=2-е до н.э.
		-- Диапазоны: 1-е н.э.=[1,1000], 1-е до н.э.=[-1000,-1], 2-е до н.э.=[-2000,-1001]
		if year > 0 then
			s, e = (year - 1) * 1000 + 1, year * 1000
		else
			s, e = (year - 1) * 1000, year * 1000 - 1
		end
	else
		s, e = year, year
	end
	return (not end_year and start_year <= e) or (start_year <= e and end_year and end_year >= s)
end

-- ▼ Нормализация текста категории
Core.utils.normalize_category_text = function(text, had_exclamation_space)
	if type(text) ~= 'string' then return '' end
	local result = gsub(gsub(text, ' !', '!'), '[%!%s]+$', '')
	if had_exclamation_space and not result:find('!') then result = result .. '! ' end
	return result
end

-- ▼ Парсинг булевого параметра с дефолтом
function Core.parse_bool_param(param, default)
	if param == 'yes' or param == '1' then return true end
	if param == 'no' or param == '0' then return false end
	return default
end

-- ▼ Грамматические исправления для века (общая функция)
-- placeholder: '<век>' или '<1век>'
-- century: номер века (число)
-- bc: флаг до н.э. (boolean)
-- Возвращает текст с исправлениями и подстановкой римской цифры
function Core.expand_century_placeholder(text, placeholder, century, bc)
	if not text or not placeholder or not century then return text end
	
	local toroman = Core.deps.toroman
	local roman = toroman(century)
	local result = text
	-- Экранирование спецсимволов Lua-паттернов (< и > не являются спецсимволами)
	local esc_ph = placeholder:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1')
	
	-- Грамматические исправления предлогов
	if century == 2 then
		result = gsub(result, 'в ' .. esc_ph .. ' веке', 'во ' .. placeholder .. ' веке')
	end
	if century == 11 then
		result = gsub(result, 'о ' .. esc_ph .. ' веке', 'об ' .. placeholder .. ' веке')
	end
	
	-- Подстановка римской цифры с учётом до н.э.
	if bc then
		result = gsub(result, esc_ph .. ' (век[еа]?)', roman .. ' %1 до н. э.')
	end
	result = gsub(result, esc_ph, roman)
	
	return result
end

-- ▼ Конвертация года в век
-- Возвращает номер века (положительный для н.э., отрицательный для до н.э.)
function Core.year_to_century(year)
	if not year then return nil end
	if year <= 0 then
		return -(math.floor((-year) / 100) + 1)
	else
		return math.floor((year - 1) / 100) + 1
	end
end

-- ▼ Информация о веке из года
-- Возвращает: { century = число (всегда положительное), bc = boolean }
function Core.year_to_century_info(year)
	if not year then return nil end
	if year <= 0 then
		return { century = math.floor((-year) / 100) + 1, bc = true }
	else
		return { century = math.floor((year - 1) / 100) + 1, bc = false }
	end
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 4: ЛЕНИВАЯ ЗАГРУЗКА МОДУЛЕЙ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Кеш загруженных модулей (единый для всех)
local modules_cache = {}

-- ▼ Фабрика ленивой загрузки модулей
local function create_lazy_loader(module_path, on_load)
	return function()
		if modules_cache[module_path] ~= nil then
			return modules_cache[module_path]
		end
		local ok, mod = pcall(require, module_path)
		if ok and is_table(mod) then
			modules_cache[module_path] = mod
			if on_load then on_load(mod) end
		else
			modules_cache[module_path] = false
		end
		return modules_cache[module_path] or nil
	end
end

-- ▼ Lazy loaders для основных модулей
Core.utils.get_geo_module = create_lazy_loader('Модуль:MetaCat/Geo')
Core.utils.get_findtopic_module = create_lazy_loader('Модуль:Find topic')

-- ▼ Загрузка дочерних модулей MetaCat
local function get_module(module_name)
	local path = 'Модуль:MetaCat/' .. module_name
	return create_lazy_loader(path)()
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 5: ПЛЕЙСХОЛДЕРЫ
-- ══════════════════════════════════════════════════════════════════════════════

Core.placeholders = {}

-- ▼ Определения плейсхолдеров
Core.placeholders.PH = {
	country = { '<страна>', '<страны>', '<в стране>' },
	state = { '<государство>', '<государства>', '<в государстве>' },
	continent_simple = { '<часть света>', '<части света>', '<в части света>' },
	continent_parent = { '<континент>', '<континента>', '<на континенте>' },
	city = { '<город>', '<города>', '<в городе>' },
	region = { '<регион>', '<региона>', '<в регионе>' }
}

-- ▼ Проверка наличия любого плейсхолдера из списка
local function has_any(text, list)
	for _, ph in ipairs(list) do if text:find(ph, 1, true) then return true end end
	return false
end

-- ▼ Детекция плейсхолдеров в тексте
function Core.detect_placeholders(text)
	local PH = Core.placeholders.PH
	return {
		state = has_any(text, PH.state),
		country = has_any(text, PH.country),
		continent = has_any(text, PH.continent_simple) or has_any(text, PH.continent_parent),
		city = has_any(text, PH.city),
		region = has_any(text, PH.region)
	}
end

-- ▼ Замена плейсхолдеров с двоеточием
function Core.placeholders.replace_colon(text, entity, placeholder_patterns)
	if not entity or not entity.cases then return text end
	local function matches_required(entity_name, required_name)
		entity_name = gsub(entity_name or '', '%s+', ' ')
		required_name = gsub(required_name or '', '%s+', ' ')
		if required_name:match('^%-') then
			local excluded = {}
			for ex in required_name:gmatch('%-([^%-]+)') do excluded[gsub(ex, '%s+', ' ')] = true end
			return not excluded[entity_name]
		end
		return entity_name == required_name
	end
	for pattern, case_key in pairs(placeholder_patterns) do
		local has_v = case_key == 'предложный' and pattern:match('^<[^>]*в ')
		local has_na = case_key == 'предложный' and pattern:match('^<[^>]*на ')
		local case_val = entity.cases[case_key] or ''
		local prefix = ''
		if case_key == 'предложный' then
			if has_v then prefix = 'в ' elseif has_na then prefix = 'в ' end
		end
		text = text:gsub(pattern, function(required)
			if matches_required(entity.name or '', required, pattern) then
				if case_val ~= '' then return prefix .. case_val end
				return ''
			end
			return ''
		end)
	end
	return text
end

-- ▼ Проверка наличия плейсхолдеров месяца
local function has_month_placeholder(text)
	return type(text) == 'string' and (text:find('<месяц', 1, true) or text:find('<в месяце>', 1, true))
end
Core.has_month_placeholder = has_month_placeholder

-- ▼ Проверка географических плейсхолдеров
local function has_country_placeholders(s)
	if type(s) ~= 'string' then return false end
	if s:find('^%|?%[%+', 1) or s:find('^%|?%[%-', 1) then return true end
	local PH = Core.placeholders.PH
	for _, list in pairs(PH) do
		for _, ph in ipairs(list) do
			if s:find(ph, 1, true) then return true end
		end
	end
	return false
end
Core.has_country_placeholders = has_country_placeholders

-- ▼ Определение нужных типов геообъектов по плейсхолдерам
local function detect_needed_geo_types(args)
	if not args then return nil, false end
	local needed_types, has_geo_placeholders = nil, false
	local PH = Core.placeholders.PH
	local type_map = {
		city = 'city', region = 'region',
		country = 'country', state = 'country',
		continent_simple = 'country', continent_parent = 'country'
	}
	for _, arg in pairs(args) do
		if type(arg) == 'string' and has_country_placeholders(arg) then
			has_geo_placeholders = true
			needed_types = needed_types or {}
			for ph_type, list in pairs(PH) do
				for _, ph in ipairs(list) do
					if arg:find(ph, 1, true) then
						needed_types[type_map[ph_type]] = true
						break
					end
				end
			end
		end
	end
	return needed_types, has_geo_placeholders
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 6: СУЩНОСТИ (ENTITIES)
-- ══════════════════════════════════════════════════════════════════════════════

Core.entities = {}
local entity_cache = {}

-- ▼ Безопасное получение падежа
function Core.entities.get_safe_case(name, case_name, dataKey)
	if not name or name == '' then return '' end
	local key = table.concat({name, case_name or '', dataKey or ''}, '|')
	local cached = entity_cache[key]
	if cached ~= nil then return cached end
	local findTopic = Core.utils.get_findtopic_module()
	if not findTopic then return '' end
	local result = findTopic.findtopicinstring(name, case_name, dataKey)
	if not result or tostring(result):match('^Ошибка') then result = '' end
	entity_cache[key] = result
	return result
end

-- ▼ Построение падежей для сущности
-- Возвращает таблицу падежей и флаг mismatch если именительный падеж не совпадает с исходным именем
function Core.entities.build_cases(name, dataKey)
	if not name or name == '' then
		return { ['именительный']='', ['родительный']='', ['предложный']='', ['предлог']='' }
	end
	local nom = Core.entities.get_safe_case(name, 'именительный', dataKey) or ''
	local prepositional = Core.entities.get_safe_case(name, 'предложный', dataKey) or ''
	local pred_full = Core.entities.get_safe_case(name, 'предлог', dataKey) or ''
	local cases = {
		['именительный'] = nom,
		['родительный'] = Core.entities.get_safe_case(name, 'родительный', dataKey) or '',
		['предложный'] = prepositional,
		['предлог'] = pred_full
	}
	-- Проверка точного соответствия: если Find topic вернул другое название — несоответствие
	if nom ~= '' and nom ~= name then
		cases.mismatch = true
		cases.expected = name
		cases.found = nom
	end
	return cases
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 7: ЗАГРУЗКА ДАННЫХ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Ленивая загрузка JSON с кешированием
local function load_json_cached(path)
	if json_cache[path] then return json_cache[path] end
	local ok, data = pcall(mw.loadJsonData, path)
	if ok and type(data) == 'table' then
		json_cache[path] = data
		return data
	end
	return nil
end
Core.utils.load_json = load_json_cached

-- ▼ Хелпер: поиск элемента по имени или алиасу в массиве
local function find_item_by_name(items, entity_name)
	if type(items) ~= 'table' then return nil end
	for _, item in ipairs(items) do
		if item.name == entity_name then return item end
		if type(item.aliases) == 'table' then
			for _, alias in ipairs(item.aliases) do
				if alias == entity_name then return item end
			end
		end
	end
	return nil
end


-- ▼ Хелпер: итерация по geo-группам с callback
-- callback(items, arr_key, grp) → возвращает результат или nil для продолжения
local function iterate_geo_groups(data, array_keys, callback)
	if not data or type(data.group) ~= 'table' then return nil end
	for _, grp in ipairs(data.group) do
		for _, arr_key in ipairs(array_keys) do
			if is_table(grp[arr_key]) then
				local result = callback(grp[arr_key], arr_key, grp)
				if result then return result end
			end
		end
	end
	return nil
end


-- ▼ Универсальная загрузка данных о границах геообъектов
local function load_geo_bounds(entity_name, entity_type)
	if not entity_name or entity_name == '' or not entity_type then return nil end

	local config = GEO_BOUNDS_CONFIG[entity_type]
	if not config then return nil end

	local data = load_json_cached(config.json_path)
	if not data then return nil end

	-- Для регионов: group.country[].region[]
	if config.parent_key then
		return iterate_geo_groups(data, {config.parent_key}, function(parents)
			for _, parent_obj in ipairs(parents) do
				if is_table(parent_obj) and is_table(parent_obj[config.array_key]) then
					local result = find_item_by_name(parent_obj[config.array_key], entity_name)
					if result then return result end
				end
			end
			return nil
		end)
	end

	-- Для стран и городов: group.country[] / group.historical[] / group.city[]
	local keys = {config.array_key}
	if config.historical_key then table.insert(keys, config.historical_key) end

	return iterate_geo_groups(data, keys, function(items)
		return find_item_by_name(items, entity_name)
	end)
end

-- ▼ Алиасы для загрузки границ геообъектов
local load_country_bounds = function(n) return load_geo_bounds(n, 'country') end
local load_region_bounds = function(n) return load_geo_bounds(n, 'region') end
local load_city_bounds = function(n) return load_geo_bounds(n, 'city') end

-- ▼ Универсальный поиск геообъекта в заголовке
local function find_geo_bounds_for_title(title, needed_types)
	if not title or title == '' then return nil end

	local findTopic = Core.utils.get_findtopic_module()
	if not findTopic then return nil end

	local check_city = needed_types and needed_types.city
	local check_region = needed_types and needed_types.region
	local check_country = not needed_types or needed_types.country

	-- Приоритет 1: Город
	if check_city then
		local city_name = findTopic.findtopicinstring(title, 'именительный', 'city')
		if city_name and city_name ~= '' then
			local city_data = load_city_bounds(city_name)
			if city_data then
				return { data = city_data, name = city_name, type = 'city' }
			end
			return nil
		end
	end

	-- Приоритет 2: Регион
	if check_region then
		local region_name = findTopic.findtopicinstring(title, 'именительный', 'ate1')
		if region_name and region_name ~= '' then
			local region_data = load_region_bounds(region_name)
			if region_data then
				return { data = region_data, name = region_name, type = 'region' }
			end
			return nil
		end
	end

	-- Приоритет 3: Страна
	if check_country then
		local country_name = findTopic.findtopicinstring(title, 'именительный', 'country')
		if country_name and country_name ~= '' then
			local country_data = load_country_bounds(country_name)
			if country_data then
				return { data = country_data, name = country_name, type = 'country' }
			end
		end
	end

	return nil
end

-- ▼ Умная загрузка geo_data с проверкой плейсхолдеров
function Core.load_geo_data_if_needed(ctx, args)
	local force_geo = args and (args['force'] == 'geo')
	local needed_types, has_geo_placeholders = detect_needed_geo_types(args)

	-- Сохранение флагов плейсхолдеров всего шаблона
	if needed_types then
		ctx.template_has_city = needed_types.city or false
		ctx.template_has_region = needed_types.region or false
	end
	
	-- Сохранение флага force_geo для передачи в Geo.resolve
	ctx.force_geo = force_geo

	local geo_info = nil
	if has_geo_placeholders or force_geo then
		geo_info = find_geo_bounds_for_title(ctx.title, needed_types)
	end
	local geo_data = geo_info and geo_info.data or nil
	local geo_name = geo_info and geo_info.name or nil

	ctx.geo_data = geo_data

	if geo_info and geo_name then
		if geo_info.type == 'country' then
			ctx.country_name = geo_name
		elseif geo_info.type == 'city' then
			ctx.city_name = geo_name
		elseif geo_info.type == 'region' then
			ctx.region_name = geo_name
		end
	end

	return geo_data, geo_name
end

-- ▼ Конвертация JSON-массива в Lua-массив
local function json_array_to_lua(arr)
	if type(arr) ~= 'table' then return nil end
	local result = {}
	for k, v in pairs(arr) do
		local idx = tonumber(k)
		if idx then
			result[idx] = v
		end
	end
	return #result > 0 and result or nil
end

-- ▼ Парсинг дат из state-записи
-- Для split: "always" означает использование fallback дат (min/max страны)
local function parse_state_years(years, fallback_min, fallback_max)
	if years == 'always' then
		return fallback_min, fallback_max
	elseif is_table(years) then
		return tonumber(years[1]), years[2] == 'now' and nil or tonumber(years[2])
	end
	return nil, nil
end

-- ▼ Получение данных о связях страны (с кешированием)
local succession_cache = {}
local function get_country_succession(country_name)
	if not country_name or country_name == '' then return nil end
	if succession_cache[country_name] then return succession_cache[country_name] end
	local country_data = load_country_bounds(country_name)

	local states = {}
	if country_data and is_table(country_data.state) then
		for state_name, years in pairs(country_data.state) do
			-- Для "always" — min/max текущей страны
			local start_year, end_year = parse_state_years(years, country_data.min, country_data.max)
			if start_year then
				table.insert(states, { name = state_name, start_year = start_year, end_year = end_year })
			end
		end
	end

	local result = {
		name = country_name,
		min = country_data and country_data.min or nil,
		max = country_data and country_data.max or nil,
		past = country_data and json_array_to_lua(country_data.past) or nil,
		next = country_data and json_array_to_lua(country_data.next) or nil,
		state = country_data and country_data.state or nil,
		states = states
	}
	succession_cache[country_name] = result
	return result
end

-- ▼ Поиск стран которые входили в данное государство (для СССР, РИ и т.д.)
local function find_member_countries(state_name)
	local data = load_json_cached(Core.JSON_PATHS.GEO_COUNTRY)
	if not data then return {}, nil, nil end

	local members = {}
	local state_min, state_max

	iterate_geo_groups(data, {'country', 'historical'}, function(items)
		for _, c in ipairs(items) do
			if is_table(c) and c.name then
				if c.name == state_name then
					state_min, state_max = c.min, c.max
				elseif is_table(c.state) and c.state[state_name] then
					-- Для "always" — min/max страны-члена
					local their_start, their_end = parse_state_years(c.state[state_name], c.min, c.max)
					table.insert(members, { name = c.name, min = c.min, max = c.max, state_start = their_start, state_end = their_end })
				end
			end
		end
		return nil -- продолжить итерацию
	end)

	return members, state_min, state_max
end

-- ▼ Получение данных о стране из единого кеша (падежи + предлог)
local function get_country_data(country_name)
	if not country_name or country_name == '' then return nil end
	if country_unified_cache[country_name] then return country_unified_cache[country_name] end

	local findTopic = Core.utils.get_findtopic_module()
	local data = {
		cases = { imen = country_name, rod = country_name, pred = country_name },
		pred_full = 'в ' .. country_name
	}

	if findTopic then
		data.cases.rod = findTopic.findtopicinstring(country_name, 'родительный', 'country') or country_name
		if data.cases.rod ~= country_name then
			data.cases.pred = findTopic.findtopicinstring(country_name, 'предложный', 'country') or country_name
		end
		local pred_full = findTopic.findtopicinstring(country_name, 'предлог', 'country')
		if pred_full and pred_full ~= '' then
			data.pred_full = pred_full
		end
	end

	country_unified_cache[country_name] = data
	return data
end

-- ▼ Получение падежей страны для навбоксов
local function get_country_cases(country_name)
	local data = get_country_data(country_name)
	return data and data.cases or nil
end

-- ▼ Получение предложного падежа страны с предлогом
function Core.get_country_pred(country_name)
	local data = get_country_data(country_name)
	return data and data.pred_full or ''
end

-- ▼ Получение короткого названия страны для навигации
function Core.get_country_short(country_name)
	if not country_name or country_name == '' then return country_name end
	local country_data = load_country_bounds(country_name)
	return country_data and country_data.short or country_name
end

-- ▼ Экранирование спецсимволов Lua-паттернов для безопасной замены
local function escape_pattern(s)
	return (s:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1'))
end

-- ▼ Замена страны в заголовке категории
-- Логика: предлог перед → предложный с предлогом, pos=1 → именительный, иначе → родительный
function Core.replace_country_in_title(cat_title, country_name, country_pred, ctx)
	local old = get_country_cases(ctx.country_name)
	local new = get_country_cases(country_name)
	if not old or not new then return cat_title end

	-- Порядок поиска: сначала длинные формы (pred), потом короткие (imen)
	-- Это предотвращает частичную замену "Узбекистан" в "Узбекистане"
	local forms = { {old.pred, 'pred'}, {old.rod, 'rod'}, {old.imen, 'imen'} }
	for _, pair in ipairs(forms) do
		local form = pair[1]
		local case_type = pair[2]
		local pos = mw.ustring.find(cat_title, form, 1, true)
		if pos then
			-- Экранирование спецсимволов (например дефис в "Аль-Андалус")
			local form_escaped = escape_pattern(form)
			-- 1. Предлог перед страной (в, во, на) → предложный с предлогом
			local before = mw.ustring.sub(cat_title, math.max(1, pos - 3), pos - 1)
			local prep = before:match('(во) $') or before:match('(на) $') or before:match('(в) $')
			if prep then
				return mw.ustring.gsub(cat_title, prep .. ' ' .. form_escaped, country_pred, 1)
			end
			-- 2. Начало строки → именительный
			if pos == 1 then
				return mw.ustring.gsub(cat_title, form_escaped, new.imen, 1)
			end
			-- 3. Иначе → родительный
			return mw.ustring.gsub(cat_title, form_escaped, new.rod, 1)
		end
	end
	return cat_title
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 8: НАВИГАЦИЯ
-- ══════════════════════════════════════════════════════════════════════════════

-- ────────────────────────────────────────────────────────────────────────────────
-- 8.1 Базовые хелперы
-- ────────────────────────────────────────────────────────────────────────────────

-- Конвертация года в период (век, десятилетие и т.д.)
local function year_to_period(time_configs, year)
	return time_configs.year_to_period and time_configs.year_to_period(year) or year
end

-- ────────────────────────────────────────────────────────────────────────────────
-- 8.2 Skip-gaps режим (пропуск несуществующих категорий)
-- ────────────────────────────────────────────────────────────────────────────────

-- Проверка существования категорий батчами
local function check_categories_exist(category_titles)
	if not category_titles or #category_titles == 0 then return {} end
	local batch = mw.title.newBatch(category_titles, 14)
	local titles = batch:lookupExistence():getTitles()
	local exists = {}
	for i, title_obj in ipairs(titles) do
		if title_obj and title_obj.exists then
			exists[category_titles[i]] = true
		end
	end
	return exists
end

-- Сбор синих ссылок с пропуском пробелов
local function collect_links_skip_gaps(ctx, time_configs, bounds, current_time, direction)
	local links = {}
	local step = time_configs.step * direction
	local batch_size = NAVBOX_CONFIG.BATCH_SIZE
	local max_batches = NAVBOX_CONFIG.MAX_BATCHES
	local batches_used = 0
	local needed = ctx.range

	local function check_batch(start_time)
		if #links >= needed or batches_used >= max_batches then return false end

		local category_names = {}
		local time_to_name = {}

		for offset = 0, batch_size - 1 do
			local t = start_time + offset * step

			if (direction > 0 and t > bounds.max) or (direction < 0 and t < bounds.min) then
				break
			end

			-- Пропуск текущего и прошедших периодов
			local dominated = (direction > 0 and t <= current_time) or (direction < 0 and t >= current_time)
			if not dominated then
				local link_text = time_configs.format_link(t, ctx)
				local cat_name = link_text:match('%[%[:К:([^|%]]+)')

				if cat_name then
					table.insert(category_names, cat_name)
					time_to_name[cat_name] = {time = t, link = link_text}
				end
			end
		end

		if #category_names == 0 then return false end

		batches_used = batches_used + 1
		local exists = check_categories_exist(category_names)

		local found_any = false
		for _, cat_name in ipairs(category_names) do
			if #links >= needed then break end
			if exists[cat_name] then
				local info = time_to_name[cat_name]
				table.insert(links, {time = info.time, link = info.link})
				found_any = true
			end
		end

		return found_any
	end

	local start_time = current_time + step
	while #links < needed and batches_used < max_batches do
		local has_more = check_batch(start_time)

		if not has_more then
			local next_check = start_time + batch_size * step
			if (direction > 0 and next_check > bounds.max) or (direction < 0 and next_check < bounds.min) then
				break
			end
		end

		start_time = start_time + batch_size * step
	end

	return links
end

-- ▼ Добивание недостающих ссылок красными
local function fill_with_red_links(links, ctx, time_configs, bounds, current_time, direction)
	if #links >= ctx.range then return end

	local needed = ctx.range - #links
	table.sort(links, function(a, b) return a.time < b.time end)

	local start_time
	if direction < 0 then
		start_time = (#links > 0) and (links[1].time - time_configs.step) or (current_time - time_configs.step)
	else
		start_time = (#links > 0) and (links[#links].time + time_configs.step) or (current_time + time_configs.step)
	end

	local added = 0
	local t = start_time
	while added < needed do
		if direction < 0 and t < bounds.min then break end
		if direction > 0 and t > bounds.max then break end

		local link_text = time_configs.format_link(t, ctx)
		table.insert(links, {time = t, link = link_text})
		added = added + 1
		t = t + direction * time_configs.step
	end

	table.sort(links, function(a, b) return a.time < b.time end)
end

-- ────────────────────────────────────────────────────────────────────────────────
-- 8.3 Fork-система (разветвления для стран-преемников)
-- ────────────────────────────────────────────────────────────────────────────────

-- Типы связей для fork-системы
local FORK_TYPES = {
	STATE_JOIN = 'state_join',		-- Страна вошла в состав (+ для контейнера, ↑ для члена)
	STATE_LEAVE = 'state_leave',	-- Страна вышла из состава (− для контейнера, ↓ для члена)
	STATE_BOTH = 'state_both',		-- Вход и выход в одном периоде (↑↓ или ±)
	PAST = 'past',					-- Предшественник (← после названия)
	NEXT = 'next'					-- Преемник (→ перед названием)
}

-- Добавление страны в fork с типом связи
-- is_member: true если текущая страна — член (показывать ↑↓), false если контейнер (показывать +−)
-- event_year: год события (для сортировки внутри периода)
local function add_country_to_fork(forks, period, name, link_type, is_member, event_year)
	forks[period] = forks[period] or { countries = {} }

	-- Поиск существующей записи
	for _, existing in ipairs(forks[period].countries) do
		if existing.name == name and existing.is_member == is_member then
			-- Объединение типов: join + leave = both
			if (existing.link_type == FORK_TYPES.STATE_JOIN and link_type == FORK_TYPES.STATE_LEAVE) or
				(existing.link_type == FORK_TYPES.STATE_LEAVE and link_type == FORK_TYPES.STATE_JOIN) then
				existing.link_type = FORK_TYPES.STATE_BOTH
				-- Сохранение минимального года для сортировки
				if event_year and (not existing.event_year or event_year < existing.event_year) then
					existing.event_year = event_year
				end
			end
			return
		end
	end

	-- Новая запись
	local c_data = get_country_succession(name)
	table.insert(forks[period].countries, {
		name = name,
		min = c_data and c_data.min or nil,
		max = c_data and c_data.max or nil,
		link_type = link_type,
		is_member = is_member,
		event_year = event_year
	})
end

-- Расчёт разветвлений (state-based + past/next)
local function calculate_forks(succession, nav_start, nav_end, current_time, ctx, time_configs)
	local forks = {}
	if not succession then return forks, nav_end end

	-- Хелпер: добавление fork для входа/выхода
	local function add_state_fork(name, start_year, end_year, is_member)
		local events = {
			{year = start_year, fork_type = FORK_TYPES.STATE_JOIN},
			{year = end_year, fork_type = FORK_TYPES.STATE_LEAVE}
		}
		for _, ev in ipairs(events) do
			if ev.year then
				local period = year_to_period(time_configs, ev.year)
				if period >= nav_start and period <= nav_end then
					add_country_to_fork(forks, period, name, ev.fork_type, is_member, ev.year)
				end
			end
		end
	end

	-- 1. Государства в которые входила текущая страна (is_member=true → ↑↓)
	for _, s in ipairs(succession.states or {}) do
		add_state_fork(s.name, s.start_year, s.end_year, true)
	end

	-- 2. Страны которые входили в текущее государство (is_member=false → +−)
	for _, m in ipairs(find_member_countries(succession.name)) do
		add_state_fork(m.name, m.state_start, m.state_end, false)
	end

	-- 3. Past forks (предшественники на виртуальном периоде min-1)
	-- Past воспринимается как соседний год слева от текущей страны
	if succession.min and succession.past then
		local virtual_period = year_to_period(time_configs, succession.min) - time_configs.step
		-- Проверка видимости: виртуальный период в пределах range от текущего времени
		local range_start = current_time - ctx.range * time_configs.step
		if virtual_period >= range_start then
			for _, past_name in ipairs(succession.past) do
				local past_data = get_country_succession(past_name)
				if past_data and past_data.max then
					add_country_to_fork(forks, virtual_period, past_name, FORK_TYPES.PAST, nil, past_data.max)
				end
			end
		end
	end

	-- 4. Next forks (преемники на виртуальном периоде max+1)
	-- Next воспринимается как соседний год справа от текущей страны
	if succession.max and succession.next then
		local virtual_period = year_to_period(time_configs, succession.max) + time_configs.step
		-- Проверка видимости: виртуальный период в пределах range от текущего времени
		local range_end = current_time + ctx.range * time_configs.step
		if virtual_period <= range_end then
			for _, next_name in ipairs(succession.next) do
				local next_data = get_country_succession(next_name)
				if next_data and next_data.min then
					add_country_to_fork(forks, virtual_period, next_name, FORK_TYPES.NEXT, nil, next_data.min)
				end
			end
		end
	end

	return forks, nav_end
end

-- ────────────────────────────────────────────────────────────────────────────────
-- 8.4 Рендеринг навигации
-- ────────────────────────────────────────────────────────────────────────────────

-- Символы для типов связей
local FORK_SYMBOLS = {
	[FORK_TYPES.STATE_JOIN] = { member = '↑', container = '+' },
	[FORK_TYPES.STATE_LEAVE] = { member = '↓', container = '−' },
	[FORK_TYPES.STATE_BOTH] = { member = '↑↓', container = '±' },
	[FORK_TYPES.PAST] = '←',
	[FORK_TYPES.NEXT] = '→'
}

-- Приоритет типа связи для сортировки (меньше = выше приоритет)
local function get_fork_type_priority(c)
	if c.link_type == FORK_TYPES.PAST or c.link_type == FORK_TYPES.NEXT then return 1 end
	-- Сначала выход, потом оба, потом вход
	if c.link_type == FORK_TYPES.STATE_LEAVE then return 2 end
	if c.link_type == FORK_TYPES.STATE_BOTH then return 3 end
	if c.link_type == FORK_TYPES.STATE_JOIN then return 4 end
	return 5
end

-- Сортировка связанных стран: дата → тип → алфавит
local function sort_fork_countries(countries)
	table.sort(countries, function(a, b)
		local a_year, b_year = a.event_year or 0, b.event_year or 0
		if a_year ~= b_year then return a_year < b_year end
		local a_prio, b_prio = get_fork_type_priority(a), get_fork_type_priority(b)
		if a_prio ~= b_prio then return a_prio < b_prio end
		return a.name < b.name
	end)
end

-- ▼ Хелпер: формирование country_part по типу связи
local function format_country_part(country_short, link_type)
	if link_type == 'next' then
		return '(→ ' .. country_short .. ')'
	elseif link_type == 'past' then
		return '(' .. country_short .. ' ←)'
	else
		return '(' .. country_short .. ')'
	end
end

-- ▼ Хелпер: обёртка ссылки (90% для state, обычный для past/next с датой)
local function wrap_secondary_link(cat_title, display, full_size)
	local link = '[[:К:' .. cat_title .. '|' .. display .. ']]'
	if full_size then
		return link
	end
	return '<span style="font-size:90%">' .. link .. '</span>'
end

-- Экспорт хелперов для временных модулей
Core.format_country_part = format_country_part
Core.wrap_secondary_link = wrap_secondary_link

-- Создание ссылки на связанную страну
-- link_type: тип связи (state_join, state_leave, state_both, past, next)
-- is_same_period: true если past/next на том же периоде что и текущая страна (без даты)
local function make_country_link(time_configs, period, country_data, ctx, link_type, is_same_period)
	local country_name = type(country_data) == 'table' and country_data.name or country_data
	local country_short = Core.get_country_short(country_name)
	local is_member = type(country_data) == 'table' and country_data.is_member

	-- Определение символа
	local symbol = ''
	if link_type == FORK_TYPES.STATE_JOIN or link_type == FORK_TYPES.STATE_LEAVE or link_type == FORK_TYPES.STATE_BOTH then
		local sym_data = FORK_SYMBOLS[link_type]
		-- is_member=true → стрелки (↑↓), is_member=false → плюс/минус (+−)
		symbol = is_member and sym_data.member or sym_data.container
	elseif link_type == FORK_TYPES.PAST or link_type == FORK_TYPES.NEXT then
		symbol = FORK_SYMBOLS[link_type]
	end

	-- Формирование ссылки
	if link_type == FORK_TYPES.PAST or link_type == FORK_TYPES.NEXT then
		-- Для past/next: реальный год из event_year, конвертированный в период
		local real_year = (type(country_data) == 'table' and country_data.event_year) or period
		local real_period = time_configs.year_to_period and time_configs.year_to_period(real_year) or real_year
		if is_same_period then
			-- Past/Next на текущем периоде: без даты
			local display_text = link_type == FORK_TYPES.NEXT
				and (symbol .. ' ' .. country_short)
				or (country_short .. ' ' .. symbol)
			if time_configs.format_country_only then
				return time_configs.format_country_only(real_period, country_name, ctx, display_text)
			end
			return '<span style="font-size:90%">(' .. display_text .. ')</span>'
		else
			-- Past/Next на соседнем периоде: с датой
			if time_configs.format_link_with_country then
				return time_configs.format_link_with_country(real_period, country_name, ctx, false, link_type)
			end
			local display = link_type == FORK_TYPES.NEXT
				and (symbol .. ' ' .. country_short)
				or (country_short .. ' ' .. symbol)
			return tostring(real_period) .. ' (' .. display .. ')'
		end
	else
		-- State: без даты, только символ и название
		local display_text = symbol .. ' ' .. country_short
		if time_configs.format_country_only then
			return time_configs.format_country_only(period, country_name, ctx, display_text)
		end
		return '<span style="font-size:90%">(' .. display_text .. ')</span>'
	end
end

-- Определение позиции блока разветвления
local function get_fork_position(has_left, has_right)
	if has_left and has_right then return 'center'
	elseif has_left then return 'right'
	elseif has_right then return 'left'
	end
	return 'none'
end

-- Создание HTML-блока разветвления
local function create_fork_block(items, position)
	if #items == 1 then
		return items[1]
	end
	local style = FORK_STYLES.base
	if position == 'left' then style = style .. FORK_STYLES.border_right
	elseif position == 'right' then style = style .. FORK_STYLES.border_left
	elseif position == 'center' then style = style .. FORK_STYLES.border_both
	end
	return '<div style="' .. style .. '">' .. table.concat(items, '<br>') .. '</div>'
end

-- ────────────────────────────────────────────────────────────────────────────────
-- 8.5 Главная функция навигации
-- ────────────────────────────────────────────────────────────────────────────────

-- Создание навигационного блока
local function make_navbox(ctx, time_configs)
	local wt = mw.html.create('div'):addClass('ts-module-Индекс_категории hlist')
	local row = wt:tag('ul')

	local bounds = time_configs.calculate_bounds(ctx, ctx.geo_data)
	local current_time = time_configs.adjust_current(ctx)

	-- Пропуск несуществующих категорий (режим skip-gaps)
	if ctx.skip_gaps then
		local left_links = collect_links_skip_gaps(ctx, time_configs, bounds, current_time, -1)
		local right_links = collect_links_skip_gaps(ctx, time_configs, bounds, current_time, 1)
		fill_with_red_links(left_links, ctx, time_configs, bounds, current_time, -1)
		fill_with_red_links(right_links, ctx, time_configs, bounds, current_time, 1)

		table.sort(left_links, function(a, b) return a.time < b.time end)
		local start_index = math.max(1, #left_links - ctx.range + 1)
		for i = start_index, #left_links do
			row:tag('li'):wikitext(left_links[i].link)
		end

		local current_link = time_configs.format_link(current_time, ctx)
		local current_text = current_link:match('%|([^%]]+)%]%]') or ''
		row:tag('li'):tag('strong'):wikitext(current_text)

		for i = 1, math.min(#right_links, ctx.range) do
			row:tag('li'):wikitext(right_links[i].link)
		end
		return getStyles() .. tostring(wt)
	end

	-- Обычный режим (с поддержкой разветвлений)
	local country_name = ctx.country_name
	local enable_split = not ctx.skip_split
	local succession = enable_split and country_name and get_country_succession(country_name) or nil

	if succession and (succession.past or succession.next or (succession.states and #succession.states > 0)) and not ctx.country_pred then
		ctx.country_pred = Core.get_country_pred(country_name)
	end

	-- Границы страны (если нет ручных параметров)
	local country_bounds = nil
	if succession and (succession.min or succession.max) and not ctx.has_manual_min and not ctx.has_manual_max then
		country_bounds = {
			min = succession.min and year_to_period(time_configs, succession.min) or nil,
			max = succession.max and year_to_period(time_configs, succession.max) or nil
		}
	end

	-- Базовая линейка и обрезка
	local nav_start = current_time - ctx.range * time_configs.step
	local nav_end = current_time + ctx.range * time_configs.step
	local effective_min = (country_bounds and country_bounds.min) or bounds.min
	local effective_max = (country_bounds and country_bounds.max) or bounds.max
	nav_start = math.max(nav_start, effective_min)
	nav_end = math.min(nav_end, effective_max)

	if nav_end < nav_start then return '' end

	-- Расчёт разветвлений (только если включены)
	local forks = {}
	if enable_split and succession then
		forks, nav_end = calculate_forks(succession, nav_start, nav_end, current_time, ctx, time_configs)
	end

	-- Сбор всех периодов для навигации (включая fork-периоды вне основного диапазона)
	local all_periods = {}
	local periods_set = {}

	-- Основной диапазон
	for i = nav_start, nav_end, time_configs.step do
		if not periods_set[i] then
			table.insert(all_periods, i)
			periods_set[i] = true
		end
	end

	-- Добавление fork-периодов, которые могут быть вне диапазона
	for period, _ in pairs(forks) do
		if not periods_set[period] then
			table.insert(all_periods, period)
			periods_set[period] = true
		end
	end

	table.sort(all_periods)

	-- Генерация навигации
	for idx, i in ipairs(all_periods) do
		local has_left = idx > 1
		local has_right = idx < #all_periods

		local fork_data = forks[i]
		if fork_data and fork_data.countries and #fork_data.countries > 0 then
			-- Есть fork на этом периоде
			local items = {}
			-- Проверка существования текущей страны в этом периоде
			local current_exists = true
			if succession then
				local c_min = succession.min and year_to_period(time_configs, succession.min) or nil
				local c_max = succession.max and year_to_period(time_configs, succession.max) or nil
				current_exists = (not c_min or c_min <= i) and (not c_max or c_max >= i)
			end
			-- Сначала текущая страна (если существует) с датой
			if current_exists then
				table.insert(items, time_configs.format_link(i, ctx))
			end
			-- Сортировка и добавление связанных стран
			sort_fork_countries(fork_data.countries)
			for _, c in ipairs(fork_data.countries) do
				local is_past_next = (c.link_type == FORK_TYPES.PAST or c.link_type == FORK_TYPES.NEXT)
				table.insert(items, make_country_link(time_configs, i, c, ctx, c.link_type, is_past_next and current_exists))
			end
			-- Если есть хотя бы одна ссылка
			if #items > 0 then
				row:tag('li'):wikitext(create_fork_block(items, get_fork_position(has_left, has_right)))
			end
		else
			-- Обычная ссылка
			row:tag('li'):wikitext(time_configs.format_link(i, ctx))
		end
	end

	return getStyles() .. tostring(wt)
end

Core.make_navbox = make_navbox

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 9: КАТЕГОРИИ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Стандартное добавление категории
local function add_category_standard(_, ret, added, processed)
	local categories = mw.text.split(processed, '|')
	local cat_name = categories[1]
	if not added[cat_name] then
		ret = ret .. string.format('[[Категория:%s%s]]',
			cat_name,
			categories[2] and ('|' .. categories[2]) or ''
		)
		added[cat_name] = true
		return ret, true
	end
	return ret, false
end

Core.add_category_standard = add_category_standard

-- ▼ Добавление категории с проверкой диапазона
local function add_category_with_range(ctx, ret, added, processed)
	local categories = mw.text.split(processed, '|')
	local cat_name = categories[1]
	local sort_key = categories[2] or ''
	local cmin = tonumber(categories[3])
	local cmax = tonumber(categories[4])

	local current_time = (ctx.BC == 1) and -ctx.time or ctx.time
	if (not cmin or current_time >= cmin) and (not cmax or current_time <= cmax) then
		if not added[cat_name] then
			ret = ret .. string.format('[[Категория:%s%s]]',
				cat_name,
				sort_key ~= '' and ('|' .. sort_key) or ''
			)
			added[cat_name] = true
			return ret, true
		end
	end
	return ret, false
end

Core.add_category_with_range = add_category_with_range

-- ▼ Проверка существования категории
local function category_exists(category_name)
	if not category_name or category_name == '' then return false end
	category_name = mw.ustring.match(category_name, "^" .. PREFIX_PATTERN .. "*(.-)!") or category_name
	local title = mw.title.new('Категория:' .. category_name)
	return title and title.exists
end

Core.category_exists = category_exists

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 10: РАСШИРЕНИЕ ПЛЕЙСХОЛДЕРОВ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Расширение плейсхолдеров месяца
local month_case_cache = {}
local function expand_month_placeholders(text, title)
	if type(text) ~= 'string' or text == '' then return text end

	if not has_month_placeholder(text) then
		return text
	end

	local findTopic = Core.utils.get_findtopic_module()
	if not findTopic then return text end

	local starts_with_month = mw.ustring.find(text, '^%s*<месяц>') ~= nil
	local starts_with_month_gen = mw.ustring.find(text, '^%s*<месяца>') ~= nil
	local starts_with_pred = mw.ustring.find(text, '^%s*<в месяце>') ~= nil

	local function get_month_cases(mode)
		local key = (title or '') .. '|' .. (mode or '-')
		local c = month_case_cache[key]
		if not c then
			c = {
				imen = findTopic.findtopicinstring(title or '', 'именительный', 'month', mode) or '',
				rod = findTopic.findtopicinstring(title or '', 'родительный', 'month', mode) or '',
				pred = findTopic.findtopicinstring(title or '', 'предлог', 'month', mode) or ''
			}
			month_case_cache[key] = c
		end
		return c
	end

	local upper = get_month_cases('upper')
	local lower = get_month_cases('lower')

	if starts_with_pred then
		text = mw.ustring.gsub(text, '^(%s*)<в месяце>', function(spaces)
			local pred = lower.pred or ''
			pred = mw.ustring.gsub(pred, '^в', 'В', 1)
			return (spaces or '') .. pred
		end, 1)
	elseif starts_with_month then
		text = mw.ustring.gsub(text, '^(%s*)<месяц>', function(spaces)
			return (spaces or '') .. (upper.imen or '')
		end, 1)
	elseif starts_with_month_gen then
		text = mw.ustring.gsub(text, '^(%s*)<месяца>', function(spaces)
			return (spaces or '') .. (upper.rod or '')
		end, 1)
	end

	text = gsub(text, '<месяц>', lower.imen)
	text = gsub(text, '<месяца>', lower.rod)
	text = gsub(text, '<в месяце>', lower.pred)

	return text
end

-- ▼ Поддержка <ключ> для месяцев в сортировочном ключе
local MONTHS_MAP = {
	['январь'] = 1, ['февраль'] = 2, ['март'] = 3, ['апрель'] = 4,
	['май'] = 5, ['июнь'] = 6, ['июль'] = 7, ['август'] = 8,
	['сентябрь'] = 9, ['октябрь'] = 10, ['ноябрь'] = 11, ['декабрь'] = 12
}

local function get_month_key_from_title(title)
	local findTopic = Core.utils.get_findtopic_module()
	if not findTopic then return nil end
	local month_nom = findTopic.findtopicinstring(title or '', 'именительный', 'month', 'lower') or ''
	month_nom = mw.ustring.lower(month_nom or '')
	local idx = MONTHS_MAP[month_nom]
	if idx then
		return string.format('%02d', idx)
	end
	return nil
end

local function apply_month_key_placeholder(text, title)
	if type(text) ~= 'string' or text == '' then return text end
	if not mw.ustring.find(text, '<ключ>', 1, true) then return text end
	local mm = get_month_key_from_title(title)
	if not mm then return text end
	local result = text
	if mw.ustring.find(result, '|', 1, true) then
		result = mw.ustring.gsub(result, '^(.-)|(.*)$', function(cat, sort)
			sort = mw.ustring.gsub(sort, '<ключ>', mm)
			return (cat or '') .. '|' .. (sort or '')
		end, 1)
		return result
	end
	if mw.ustring.find(result, '!', 1, true) then
		result = mw.ustring.gsub(result, '(![^|]*)<ключ>', function(before)
			return (before or '') .. mm
		end)
		return result
	end
	return result
end

-- ▼ Унифицированное раскрытие всех плейсхолдеров
expand_all = function(text, ctx, conf, opts)
	opts = opts or {}
	local separator = opts.separator or '!'
	local result = conf.do_expand(ctx, separator == '!' and text:gsub('!', '|') or text)
	result = expand_month_placeholders(result, ctx.title)
	result = apply_month_key_placeholder(result, ctx.title)
	if not opts.skip_strip and ctx.skip_1century and ctx.is_single_century then
		local Geo = Core.utils.get_geo_module()
		if Geo and Geo.strip_century_references then
			result = Geo.strip_century_references(result)
		end
	end
	return result
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 11: ОБРАБОТКА ОШИБОК
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Создание трекера ошибок
local function make_error_tracker(conf)
	local error_list = {}
	local unique_errors = {}
	local range_aggregate = nil

	local function add_error(error_code, additional_info)
		local msg = conf.error_messages[error_code]
		if not msg then return end
		msg = msg:gsub('%%INFO%%', additional_info or "")
		if conf.range_codes and conf.range_codes[error_code] then
			if not range_aggregate then
				range_aggregate = {message = conf.range_prefix, details = {}}
				table.insert(error_list, range_aggregate)
			end
			table.insert(range_aggregate.details, msg)
		else
			local error_message = '<span class="error">' .. msg .. '</span>'
			if not unique_errors[error_message] then
				unique_errors[error_message] = true
				table.insert(error_list, {message = error_message})
			end
		end
	end

	local function publish_errors()
		if #error_list == 0 then return '' end
		local result = '<div class="error-list">'
		for i, err in ipairs(error_list) do
			if err.details then
				result = result .. '<span class="error">' .. err.message
				for _, detail in ipairs(err.details) do
					result = result .. ' ' .. detail
				end
				result = result .. '</span>'
			else
				result = result .. err.message
			end
			if i < #error_list then
				result = result .. '<br>'
			end
		end
		result = result .. '</div>'
		result = result .. string.format('[[Категория:Википедия:Страницы с некорректным использованием модуля %s]]', conf.module_name)
		return result
	end

	return add_error, publish_errors
end

Core.make_error_tracker = make_error_tracker

-- ▼ Общие сообщения об ошибках (не зависят от типа времени)
Core.common_error_messages = {
	[6] = 'Ошибка: страна не найдена.',
	[7] = 'Ошибка: часть света не найдена.',
	[8] = 'Ошибка: город не найден.',
	[9] = 'Ошибка: регион не найден.',
	[11] = 'Ошибка: часть света не найдена для страны %INFO%.',
	[12] = 'Ошибка: обнаружены неразвернутые плейсхолдеры в категории: %INFO%.',
	[13] = 'Ошибка: не удалось определить тип категории.',
	[14] = 'Ошибка: не удалось загрузить модуль MetaCat/%INFO%.',
	[15] = 'Ошибка: %INFO%.',
	[16] = 'Ошибка: регион для города %INFO% не найден.',
	[17] = 'Ошибка: государство "%INFO%" не найдено в справочнике Find topic.',
	[18] = 'Ошибка: фильтр "%INFO%" требует плейсхолдер <часть света> в строке.'
}

-- ▼ Формы рода для типов времени
local function gender_forms_for_time(time_type)
	local is_neuter = (time_type == 'decade' or time_type == 'millennium')
	return {
		not_found = is_neuter and 'не найдено' or 'не найден',
		adj_min = is_neuter and 'Минимальное' or 'Минимальный',
		adj_max = is_neuter and 'Максимальное' or 'Максимальный',
		part_limited = is_neuter and 'ограниченное' or 'ограниченный'
	}
end

-- ▼ Фабрика генераторов сообщений об ошибках
function Core.create_error_messages(time_type)
	local time_name = TIME_TYPE_NAMES[time_type] and TIME_TYPE_NAMES[time_type].nom or time_type
	local forms = gender_forms_for_time(time_type)
	local time_name_not_found = time_name .. ' ' .. forms.not_found
	local time_name_two = 'два ' .. (
		time_type == 'century' and 'века' or
		time_type == 'decade' and 'десятилетия' or
		time_type == 'millennium' and 'тысячелетия' or
		'года'
	)

	local messages = {
		[1] = 'Ошибка: ' .. time_name_not_found .. '.',
		[2] = forms.adj_min .. ' ' .. time_name .. ', ' .. forms.part_limited .. ' шаблоном: %INFO%.',
		[3] = forms.adj_max .. ' ' .. time_name .. ', ' .. forms.part_limited .. ' шаблоном: %INFO%.',
		[4] = forms.adj_min .. ' ' .. time_name .. ' для %INFO%.',
		[5] = forms.adj_max .. ' ' .. time_name .. ' для %INFO%.',
		[10] = 'Ошибка: обнаружено ' .. time_name_two .. '.'
	}

	for code, msg in pairs(Core.common_error_messages) do
		messages[code] = msg
	end

	return messages
end

-- ▼ Фабрика заголовка агрегированной ошибки диапазона
function Core.create_range_prefix(time_type)
	local time_name = TIME_TYPE_NAMES[time_type] and TIME_TYPE_NAMES[time_type].nom or time_type
	return 'Ошибка: ' .. time_name .. ' не попадает в заданный диапазон.'
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 12: ДИСПЕТЧЕРИЗАЦИЯ МОДУЛЕЙ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Диспетчеризация модулей (вызывается из диспетчера)
function Core.dispatch_to_module(frame, category_type, module_map, dispatcher_name)
	local error_config = {
		module_name = dispatcher_name or 'MetaCat',
		error_messages = Core.common_error_messages,
		range_codes = {}
	}
	local add_error, publish_errors = make_error_tracker(error_config)

	local module_name = module_map[category_type]
	if not module_name then
		add_error(13)
		return publish_errors()
	end

	local target_module = get_module(module_name)
	if not target_module or not target_module.main then
		add_error(14, module_name)
		return publish_errors()
	end

	return target_module.main(frame)
end

-- ▼ Expand через модуль (вызывается из диспетчера)
function Core.expand_with_module(frame, category_type, module_map)
	local module_name = module_map[category_type]
	if not module_name then
		return ''
	end

	local target_module = get_module(module_name)
	if not target_module or not target_module.expand then
		return ''
	end

	return target_module.expand(frame)
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 13: ДЕТЕКЦИЯ ТИПОВ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Плейсхолдеры временных типов (для детекции)
local TIME_PLACEHOLDERS = {
	year = {'<год>'},
	decade = {'<десятилетие>'},
	century = {'<век>'},
	millennium = {'<тысячелетие>', '<тысячелетия>', '<в тысячелетии>'}
}

-- ▼ Проверка наличия временных плейсхолдеров в строке (включая вложенные в функции)
local function detect_time_placeholders_in_string(str)
	local found = {}
	for time_type, placeholders in pairs(TIME_PLACEHOLDERS) do
		for _, ph in ipairs(placeholders) do
			if str:find(ph, 1, true) then
				found[time_type] = true
				break
			end
		end
	end
	return found
end

-- ▼ Сканирование сырого викитекста шаблона (общая логика для детекции плейсхолдеров)
-- Возвращает найденные временные типы из сырого викитекста страницы шаблона
local function scan_template_raw_content()
	local current_title = mw.title.getCurrentTitle()
	if current_title.namespace ~= 10 then return {} end
	
	local raw_content = current_title:getContent()
	if not raw_content then return {} end
	
	return detect_time_placeholders_in_string(raw_content)
end

-- ▼ Детектор типов плейсхолдеров для категоризации шаблонов
local function detect_placeholder_types(args)
	if not args then return {} end

	local types = { time = nil, geo = nil, has_month = false }

	-- Приоритет force над плейсхолдерами
	local force_type = args['force'] and mw.ustring.lower(args['force'])
	if force_type and (force_type == 'year' or force_type == 'decade' or force_type == 'century' or force_type == 'millennium') then
		types.time = force_type
	end

	local PH = Core.placeholders.PH
	local geo_map = {
		city = 'city', region = 'region', country = 'country', state = 'country',
		continent_simple = 'continent', continent_parent = 'continent'
	}
	-- Приоритет geo-типов: более специфичные (city, region) > общие (country, continent)
	-- Используется упорядоченный список для детерминированного поведения
	local geo_priority = {'city', 'region', 'country', 'state', 'continent_simple', 'continent_parent'}

	-- Сбор всех найденных временных типов из всех аргументов
	local found_time = { year = false, decade = false, century = false, millennium = false }
	local found_geo = {}

	for _, arg in pairs(args) do
		if type(arg) == 'string' then
			-- Накопление временных типов из всех аргументов
			local found = detect_time_placeholders_in_string(arg)
			for t, v in pairs(found) do
				found_time[t] = found_time[t] or v
			end
			-- Детекция месяца
			if not types.has_month and has_month_placeholder(arg) then
				types.has_month = true
			end
			-- Накопление гео-типов из всех аргументов
			for ph_type, list in pairs(PH) do
				for _, ph in ipairs(list) do
					if arg:find(ph, 1, true) then
						found_geo[ph_type] = true
						break
					end
				end
			end
		end
	end

	-- Для шаблонов: анализ сырого викитекста (плейсхолдеры могут быть внутри #ifexpr)
	local raw_found = scan_template_raw_content()
	for t, v in pairs(raw_found) do
		found_time[t] = found_time[t] or v
	end

	-- Выбор geo-типа по приоритету (более специфичные первыми)
	for _, ph_type in ipairs(geo_priority) do
		if found_geo[ph_type] then
			types.geo = geo_map[ph_type]
			break
		end
	end

	-- Выбор временного типа по приоритету (если не задан через force)
	if not types.time then
		for _, t in ipairs({'year', 'decade', 'century', 'millennium'}) do
			if found_time[t] then
				types.time = t
				break
			end
		end
	end

	return types
end

-- ▼ Определение типа категории по её названию
local function detect_category_time_type(title, args)
	if mw.ustring.match(title, '%d+%s*год[ау]?') then return 'year' end
	if mw.ustring.match(title, '%d*0%-[ех] год[ыоа][вх]?') then return 'decade' end
	if mw.ustring.match(title, '[IVXLCDM]+ век[еа]?') then return 'century' end
	if mw.ustring.match(title, '%d+%-[ем] тысячелети[ией]') or mw.ustring.match(title, '%d+%-го тысячелетия') then
		return 'millennium'
	end
	if args and args['force'] == 'year' and mw.ustring.match(title, '%d%d%d%d') then
		return 'year'
	end
	return nil
end

Core.detect_category_time_type = detect_category_time_type

-- ▼ Определение типа шаблона по плейсхолдерам в аргументах
-- Для шаблонов также анализируется сырой викитекст страницы (для поиска плейсхолдеров внутри #ifexpr и т.д.)
local function detect_template_time_type(args)
	if not args then return nil, false, false, false, false end

	local has = { year = false, decade = false, century = false, millennium = false }

	-- Поиск плейсхолдеров в аргументах
	for _, arg in pairs(args) do
		if type(arg) == 'string' then
			local found = detect_time_placeholders_in_string(arg)
			for t, v in pairs(found) do has[t] = has[t] or v end
		end
	end

	-- Для шаблонов: анализ сырого викитекста (общая функция)
	local raw_found = scan_template_raw_content()
	for t, v in pairs(raw_found) do has[t] = has[t] or v end

	-- Приоритет: year > decade > century > millennium
	local template_type = nil
	for _, t in ipairs({'year', 'decade', 'century', 'millennium'}) do
		if has[t] then template_type = t; break end
	end

	return template_type, has.year, has.decade, has.century, has.millennium
end

Core.detect_template_time_type = detect_template_time_type

-- ▼ Категории шаблонов (централизация)
function Core.template_module_categories(module_name, args)
	local cats = {}

	table.insert(cats, string.format('[[Категория:Шаблоны, использующие модуль %s]]', module_name))

	local types = detect_placeholder_types(args)

	local geo_names = {
		country = 'странам',
		city = 'городам',
		continent = 'частям света',
		region = 'административным единицам первого уровня'
	}

	local time_names = {
		year = 'годам',
		decade = 'десятилетиям',
		century = 'векам',
		millennium = 'тысячелетиям'
	}

	-- Месяцы добавляются как отдельная категория
	if types.has_month then
		table.insert(cats, '[[Категория:Шаблоны для категорий по месяцам]]')
	end

	-- Основная категория: география + время (без месяцев)
	local components = {}
	if types.geo then
		table.insert(components, geo_names[types.geo])
	end
	if types.time then
		table.insert(components, time_names[types.time])
	end

	if #components > 0 then
		local category_text = table.concat(components, ' и ')
		table.insert(cats, string.format('[[Категория:Шаблоны для категорий по %s]]', category_text))
	end

	table.insert(cats, '[[Категория:Шаблоны, использующие индекс категории (автоматический)]]')

	-- Отслеживающие категории на основе аргументов шаблона
	local tracking_cats = Core.check_template_tracking_rules(args, types.time, types.geo)
	for cat_name, sort_key in pairs(tracking_cats) do
		if sort_key and sort_key ~= '' and sort_key ~= true then
			table.insert(cats, string.format('[[Категория:%s|%s]]', cat_name, sort_key))
		else
			table.insert(cats, string.format('[[Категория:%s]]', cat_name))
		end
	end

	return table.concat(cats, '')
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 14: ВАЛИДАЦИЯ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Проверка соответствия типа шаблона и категории
local function validate_template_category_match(ctx, args, add_error)
	if args['skip-type-check'] == 'yes' or args['skip-type-check'] == '1' then
		return
	end

	-- При явном указании force пропуск проверки плейсхолдеров
	local force_type = args['force'] and mw.ustring.lower(args['force'])
	if force_type and TIME_TYPE_NAMES[force_type] then
		return
	end

	for _, type_info in pairs(TIME_TYPE_NAMES) do
		if ctx.title == type_info.root_category then
			return
		end
	end

	local category_type = detect_category_time_type(ctx.title, args)
	local template_type, has_year, has_decade, has_century, has_millennium = detect_template_time_type(args)

	local has_placeholder = {
		year = has_year,
		decade = has_decade,
		century = has_century,
		millennium = has_millennium
	}

	-- Проверка 1: Наличие соответствующего плейсхолдера для типа категории
	if category_type and not has_placeholder[category_type] then
		local type_info = TIME_TYPE_NAMES[category_type]
		local has_root_category = false

		if type_info and type_info.root_category then
			for _, arg in pairs(args) do
				if type(arg) == 'string' and arg == type_info.root_category then
					has_root_category = true
					break
				end
			end
		end

		if not has_root_category then
			local error_info = string.format(
				'В категории %s используется шаблон, в котором не передаётся переменная %s',
				type_info and type_info.gen or category_type,
				type_info and type_info.placeholder or category_type
			)
			add_error(15, error_info)
			return
		end
	end

	-- Проверка 2: Запрет более детальных плейсхолдеров
	if category_type and template_type and category_type ~= template_type then
		local cat_info = TIME_TYPE_NAMES[category_type]
		local tpl_info = TIME_TYPE_NAMES[template_type]
		local cat_level = cat_info and cat_info.detail_level or 0
		local tpl_level = tpl_info and tpl_info.detail_level or 0

		if tpl_level > cat_level then
			local error_info = string.format(
				TYPE_VALIDATION_MSG_MISMATCH,
				cat_info and cat_info.gen or category_type,
				tpl_info and tpl_info.gen or template_type
			)
			add_error(15, error_info)
		end
	end
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 15: ОБРАБОТКА КАТЕГОРИЙ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Зарезервированные ключи аргументов (не обрабатываются как категории)
local RESERVED_ARG_KEYS = {
	title = true, time = true, type = true, bc = true,
	min = true, max = true, range = true,
	noindex = true, nonav = true,
	['skip-gaps'] = true, skip_gaps = true,
	['skip-1century'] = true, skip_1century = true,
	['show-question'] = true, show_question = true,
	['skip-type-check'] = true,
	split = true, force = true
}

-- ▼ Получение префикса строки (? или ~)
local function get_line_prefix(line)
	-- Приоритет: original (надёжнее, т.к. result может быть skip с пустым text)
	if line.original then
		local after_cond = line.original:match('^%|?%[.-%](' .. PREFIX_PATTERN .. ')')
		if after_cond then return after_cond end
		return first_char(line.original)
	end
	if line.results and line.results[1] and line.results[1].text then
		return first_char(line.results[1].text)
	end
	return ''
end

-- ▼ Проверка, является ли строка fallback-строкой (~)
local function is_fallback_line(ln)
	return get_line_prefix(ln) == PREFIX_FALLBACK
end

-- ▼ Извлечение блоков условий из строки (возвращает строку условий или nil)
local function extract_conditions_string(text)
	if not text or type(text) ~= 'string' then return nil end
	local rest = text:match('^%|?(.*)') or text
	local conditions_str = ''
	while true do
		local block, after = rest:match('^(%[[^%]]+%])(.*)')
		if not block then break end
		conditions_str = conditions_str .. block
		rest = after
	end
	return conditions_str ~= '' and conditions_str or nil
end

-- ▼ Добавление унаследованных условий к fallback-строке
local function prepend_inherited_conditions(fallback_text, inherited_conditions)
	if not inherited_conditions then return fallback_text end
	if extract_conditions_string(fallback_text) then return fallback_text end
	local prefix, rest = fallback_text:match('^(%|?)(.*)')
	return prefix .. inherited_conditions .. rest
end

-- ▼ Обработка географических плейсхолдеров
local function process_country_placeholders(s, ctx, conf, add_error)
	if type(s) ~= 'string' then return {} end

	if not has_country_placeholders(s) then
		return {{text = expand_all(s, ctx, conf, {separator = '', skip_strip = true}), type = 'main'}}
	end

	local result_lines = {}
	local adjusted_time = conf.adjust_time_for_country and conf.adjust_time_for_country(ctx.time, ctx.BC) or ctx.time
	local Geo = Core.utils.get_geo_module()
	local country_result = nil
	if Geo and type(Geo.resolve) == 'function' then
		country_result = Geo.resolve({
			[1] = s,
			title = ctx.title,
			type = conf.type,
			time = adjusted_time,
			skip_1century = ctx.skip_1century,
			country_name = ctx.country_name,
			city_name = ctx.city_name,
			region_name = ctx.region_name,
			template_has_city = ctx.template_has_city,
			template_has_region = ctx.template_has_region,
			force_geo = ctx.force_geo
		})
	end
	if country_result then
		-- Обработка массива результатов
		if country_result.results then
			if #country_result.results == 0 and country_result.error == 0 then
				table.insert(result_lines, {text = '', type = 'skip'})
			else
				for idx, res_text in ipairs(country_result.results) do
					if res_text and res_text ~= '' then
						local res_type = (idx == 1) and 'main' or 'extra'
						table.insert(result_lines, {text = res_text, type = res_type})
					end
				end
			end
		end
		if country_result.error and country_result.error > 0 then
			local info
			if country_result.error == 11 or country_result.error == Core.base_config.errors.continent_not_found then
				info = ctx.country_name or ''
				if info == '' then
					local findTopic = Core.utils.get_findtopic_module()
					info = findTopic and findTopic.findtopicinstring(ctx.title, 'именительный', 'country') or ''
				end
			end
			add_error(country_result.error, info)
			ctx.country_error_flag = true
		end
		-- Несоответствия государств (ошибка 17)
		if country_result.state_mismatches and #country_result.state_mismatches > 0 then
			for _, state_name in ipairs(country_result.state_mismatches) do
				add_error(17, state_name)
			end
		end
		-- Фильтры без требуемого плейсхолдера (ошибка 18)
		if country_result.filter_errors and #country_result.filter_errors > 0 then
			for _, filter_name in ipairs(country_result.filter_errors) do
				add_error(18, filter_name)
			end
		end
	end
	return result_lines
end

-- ▼ Обработка строк категорий
local function cats(args, ctx, conf, add_error)
	local ret = ''
	local added_categories = {}
	local lines = {}

	-- Установка флагов плейсхолдеров шаблона для оптимизации Geo fallback
	local needed_types, _ = detect_needed_geo_types(args)
	if needed_types then
		ctx.template_has_city = needed_types.city or false
		ctx.template_has_region = needed_types.region or false
	end

	local need_country = ctx.skip_1century or not ctx.skip_split
	if need_country and not ctx.country_name then
		local findTopic = Core.utils.get_findtopic_module()
		if findTopic then
			ctx.country_name = findTopic.findtopicinstring(ctx.title, 'именительный', 'country') or nil
			if ctx.country_name == '' then ctx.country_name = nil end
		end
	end

	if ctx.skip_1century and ctx.country_name then
		local Geo = Core.utils.get_geo_module()
		if Geo and Geo.is_single_century_country then
			ctx.is_single_century = Geo.is_single_century_country(ctx.country_name)
		end
	end

	local function add_category(text)
		local processed = expand_all(text, ctx, conf)
		if not processed or processed:match('^%s*$') then return false end
		local unresolved = processed:match('<([^>]+)>')
		if unresolved then add_error(12, processed) end
		local new_ret, added = conf.add_category(ctx, ret, added_categories, processed)
		if new_ret ~= nil then ret = new_ret end
		return added
	end

	local max_line_idx = 0

	-- Обработка позиционных аргументов
	for i, arg in sparseIpairs(args) do
		if type(arg) == 'string' and arg ~= '' then
			local result = process_country_placeholders(arg, ctx, conf, add_error)
			lines[i] = {
				original = arg,
				results = result or {}
			}
			if i > max_line_idx then max_line_idx = i end
		end
	end

	-- Обработка именованных аргументов
	local line_idx = max_line_idx + 1
	for key, value in pairs(args) do
		if type(key) == 'string' and not RESERVED_ARG_KEYS[key] then
			if has_country_placeholders(key) or key:match('^' .. PREFIX_PATTERN .. '%|%[]') then
				local arg
				if type(value) == 'string' and value ~= '' then
					if key:match('[%+%^][^:]+$') and not key:match(':$') then
						arg = key .. ':' .. value
					else
						arg = key .. value
					end
				else
					arg = key
				end
				local result = process_country_placeholders(arg, ctx, conf, add_error)
				lines[line_idx] = {
					original = arg,
					results = result or {}
				}
				line_idx = line_idx + 1
			end
		end
	end
	max_line_idx = line_idx - 1

	local i = 1
	while i <= max_line_idx do
		local line = lines[i]
		if line then
			local prefix = get_line_prefix(line)
			local is_question = (prefix == PREFIX_QUESTION)
			local is_fallback_prefix = (prefix == PREFIX_FALLBACK)

			if is_question then
				-- Обработка условных категорий (?)
				local exists = {main = false, extra = false}
				local has_geo = Core.has_country_placeholders(line.original)

				for _, result in ipairs(line.results) do
					if result.type ~= 'skip' and result.text and first_char(result.text) == PREFIX_QUESTION then
						local text = result.text:sub(2):gsub('^%s+', '')
						local cat_name = expand_all(text:match('^(.-)!') or text, ctx, conf, {separator = ''})
						if category_exists(cat_name) then
							exists[result.type] = true
							add_category(text)
						elseif ctx.show_question then
							add_category(text)
						end
					end
				end

				-- Обработка fallback-строк (~) после условной
				local j = i + 1
				local produced_any = (exists.main or exists.extra)
				-- Извлечение условий из ?-строки для наследования
				local inherited_conditions = extract_conditions_string(line.original)

				while lines[j] and is_fallback_line(lines[j]) do
					local fallback_line = lines[j]
					local fallback_results = fallback_line.results
					
					-- Наследование условий: если у ~-строки нет своих условий и есть гео-плейсхолдеры
					if inherited_conditions and has_geo and not extract_conditions_string(fallback_line.original) then
						local modified_text = prepend_inherited_conditions(fallback_line.original, inherited_conditions)
						fallback_results = process_country_placeholders(modified_text, ctx, conf, add_error)
					end
					
					for _, result in ipairs(fallback_results) do
						if result.type ~= 'skip' and result.text and first_char(result.text) == PREFIX_FALLBACK then
							-- Fallback публикуется если:
							-- 1. Ни одна ?-категория не существует, ИЛИ
							-- 2. Есть гео-плейсхолдеры И для данного типа ?-категория не существует
							local should_publish = not produced_any or (has_geo and not exists[result.type])
							if should_publish then add_category(result.text:sub(2)) end
						end
					end
					j = j + 1
				end
				i = j - 1
			elseif not is_fallback_prefix then
				-- Обычные категории (без префикса)
				for _, result in ipairs(line.results) do
					if result.type ~= 'skip' and result.text and result.text ~= '' then
						add_category(result.text)
					end
				end
			end
		end
		i = i + 1
	end

	return ret
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 16: ОСНОВНОЙ ДВИЖОК
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Основной раннер MetaCat
function Core.run(frame, conf)
	local args = getArgs(frame)
	local ctx = {
		title = args['title'] or mw.title.getCurrentTitle().text,
		range = tonumber(args['range'] or conf.defaults.range),
		min = tonumber(args['min'] or conf.defaults.min),
		max = tonumber(args['max'] or conf.defaults.max),
		has_manual_min = args['min'] ~= nil,
		has_manual_max = args['max'] ~= nil,
		BC = 0,
		templ = nil,
		time = nil,
		country_error_flag = false,
		skip_gaps = Core.parse_bool_param(args['skip-gaps'], Core.DEFAULTS.SKIP_GAPS),
		skip_1century = Core.parse_bool_param(args['skip-1century'], Core.DEFAULTS.SKIP_1CENTURY),
		skip_split = not Core.parse_bool_param(args['split'], Core.DEFAULTS.SPLIT),
		show_question = Core.parse_bool_param(args['show-question'], false),
		country_name = nil,
		country_pred = nil
	}

	local add_error, publish_errors = make_error_tracker(conf)

	if mw.title.getCurrentTitle().namespace == 10 then
		return Core.template_module_categories(conf.module_name, args)
	end

	local ok = conf.parse(ctx, add_error, args)
	if not ok then
		return publish_errors()
	end

	validate_template_category_match(ctx, args, add_error)

	local categories
	if type(conf.custom_cats) == 'function' then
		categories = conf.custom_cats(ctx, args, add_error)
	else
		categories = cats(args, ctx, conf, add_error)
	end

	if conf.check_in_bounds then
		conf.check_in_bounds(ctx, args, add_error)
	end

	local output = ''
	if args['nonav'] ~= '1' then
		output = output .. conf.navbox(ctx)
	end
	if args['noindex'] ~= '1' then
		output = output .. mw.getCurrentFrame():preprocess('{{индекс категории (автоматический)}}\n')
	end

	if is_table(categories) then
		local flat_categories = {}
		for _, value in ipairs(categories) do
			table.insert(flat_categories, value.text)
		end
		categories = table.concat(flat_categories, '')
	end

	output = output .. publish_errors()
	return output .. (categories or '')
end

-- ▼ Раскрытие плейсхолдеров
function Core.expand(frame, conf)
	local args = getArgs(frame)
	local ctx = {
		title = args['title'] or mw.title.getCurrentTitle().text,
		BC = 0, time = nil, templ = nil,
		skip_1century = false, is_single_century = false,
		force_geo = args['force'] == 'geo'
	}
	local ok = conf.parse(ctx, function() end, args)
	if not ok then return '' end
	local text = args[1] or ''
	if conf.expand_pre then text = conf.expand_pre(ctx, text) end
	local result = expand_all(text, ctx, conf, {separator = ''})

	-- Обработка geo-плейсхолдеров через Geo.resolve
	if has_country_placeholders(result) then
		local Geo = Core.utils.get_geo_module()
		if Geo and Geo.resolve then
			local adjusted_time = conf.adjust_time_for_country
				and conf.adjust_time_for_country(ctx.time, ctx.BC)
				or ctx.time
			local geo_result = Geo.resolve({
				[1] = result,
				title = ctx.title,
				type = conf.type,
				time = adjusted_time,
				force_geo = ctx.force_geo
			})
			if geo_result and geo_result.results and geo_result.results[1] and geo_result.results[1] ~= '' then
				result = geo_result.results[1]
			else
				-- Очистка нераскрытых geo-плейсхолдеров
				local PH = Core.placeholders.PH
				for _, list in pairs(PH) do
					for _, ph in ipairs(list) do
						result = result:gsub(ph, '')
					end
				end
			end
		end
	end

	return result
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 17: ФАБРИКИ КОНФИГУРАЦИЙ
-- ══════════════════════════════════════════════════════════════════════════════

-- ▼ Создание конфигурации с наследованием
function Core.create_config(time_type, custom_config)
	local config = table_shallow_copy(Core.base_config)

	config.error_messages = Core.create_error_messages(time_type)
	config.adjust_time_for_country = Core.create_time_adjuster(time_type)
	config.add_category = Core.add_category_standard
	config.range_prefix = Core.create_range_prefix(time_type)

	for k, v in pairs(custom_config or {}) do
		config[k] = v
	end

	return config
end

-- ▼ Фабрика корректировщика времени для страны
function Core.create_time_adjuster(time_type)
	if time_type == 'year' or time_type == 'century' or time_type == 'millennium' then
		return function(time, BC)
			if BC == 1 then return 1 - time end
			return time
		end
	elseif time_type == 'decade' then
		return function(time, BC)
			if BC == 1 then return -time - 10 end
			return time
		end
	end
	-- Fallback для неизвестных типов
	return function(time) return time end
end

-- ▼ Создание стандартной функции main
function Core.create_main(conf)
	return function(frame)
		return Core.run(frame, conf)
	end
end

-- ▼ Создание стандартной функции expand
function Core.create_expand(conf)
	return function(frame)
		return Core.expand(frame, conf)
	end
end

-- ▼ Создание стандартных полей конфигурации для временного типа
function Core.create_time_config_fields(time_type)
	return {
		error_messages = Core.create_error_messages(time_type),
		range_codes = { [2]=true, [3]=true, [4]=true, [5]=true },
		range_prefix = Core.create_range_prefix(time_type),
		adjust_time_for_country = Core.create_time_adjuster(time_type)
	}
end

-- ▼ Объединение базовой конфигурации с пользовательской
function Core.merge_time_config(time_type, custom_config)
	local base = Core.create_time_config_fields(time_type)
	base.type = time_type
	for k, v in pairs(custom_config or {}) do
		base[k] = v
	end
	return base
end

-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 18: ОТСЛЕЖИВАЮЩИЕ КАТЕГОРИИ
-- ══════════════════════════════════════════════════════════════════════════════


-- Проверка наличия фильтрации геообъектов в квадратных скобках
local function has_geo_filtering(text)
	if type(text) ~= 'string' then return false end
	-- Паттерн: |[+...] или |[-...] или [+...] или [-...] в начале строки
	return text:match('^%|?%[[%+%-]') ~= nil
end

-- Ключи сортировки по типам модулей: Y=Year, D=Decade, C=Century, M=Millennium, G=Geo
local MODULE_SORT_KEYS = {
	year = 'Y',
	decade = 'D',
	century = 'C',
	millennium = 'M',
	country = 'G',
	city = 'G',
	continent = 'G',
	region = 'G'
}

-- Определение ключа сортировки по типу времени и географии
local function get_module_sort_key(time_type, geo_type)
	-- Приоритет: время > география
	if time_type then
		return MODULE_SORT_KEYS[time_type] or ''
	elseif geo_type then
		return MODULE_SORT_KEYS[geo_type] or 'G'
	end
	return ''
end

-- Проверка аргументов шаблона на использование различных параметров
function Core.check_template_tracking_rules(args, time_type, geo_type)
	local tracking_cats = {}
	if not args then return tracking_cats end

	local sort_key = get_module_sort_key(time_type, geo_type)

	-- Проверка именованных параметров
	if args['force'] then
		tracking_cats['Шаблоны, использующие модуль MetaCat с параметром force'] = sort_key
	end

	if args['min'] or args['max'] then
		tracking_cats['Шаблоны, использующие модуль MetaCat с параметрами min/max'] = sort_key
	end

	if args['range'] then
		tracking_cats['Шаблоны, использующие модуль MetaCat с параметром range'] = sort_key
	end

	if args['skip-gaps'] then
		tracking_cats['Шаблоны, использующие модуль MetaCat с параметром skip-gaps'] = sort_key
	end

	if args['skip-1century'] then
		tracking_cats['Шаблоны, использующие модуль MetaCat с параметром skip-1century'] = sort_key
	end

	if args['split'] then
		tracking_cats['Шаблоны, использующие модуль MetaCat с параметром split'] = sort_key
	end

	if args['show-question'] then
		tracking_cats['Шаблоны, использующие модуль MetaCat с параметром show-question'] = sort_key
	end

	-- Проверка позиционных и именованных аргументов на содержимое
	local has_geo_filter = false
	for key, arg in pairs(args) do
		if type(arg) == 'string' and arg ~= '' then

			-- Проверка фильтрации геообъектов
			if not has_geo_filter and has_geo_filtering(arg) then
				has_geo_filter = true
			end
		end

		-- Проверка ключей на фильтрацию (для именованных аргументов вида |[+страна:Россия]=...)
		if type(key) == 'string' and has_geo_filtering(key) then
			has_geo_filter = true
		end
	end

	if has_geo_filter then
		tracking_cats['Шаблоны, использующие модуль MetaCat с фильтрацией геообъектов'] = sort_key
	end

	return tracking_cats
end

return Core