local core_utils = require("luacheck.core_utils")
local decoder = require("luacheck.decoder")
local options = require("luacheck.options")
local utils = require("luacheck.utils")
local filter = {}

-- Returns two optional booleans indicating if warning matches pattern by code and name.
local function match(pattern, code, name)
	local matches_code, matches_name
	local code_pattern, name_pattern = pattern[1], pattern[2]

	if code_pattern then matches_code = utils.pmatch(code, code_pattern) end

	if name_pattern then
		if not name then
			-- Warnings without name field can't match by name.
			matches_name = false
		else
			matches_name = utils.pmatch(name, name_pattern)
		end
	end

	return matches_code, matches_name
end

local function passes_rules_filter(rules, code, name)
	-- A warning is enabled when its code and name are enabled.
	local enabled_code, enabled_name = false, false

	for _, rule in ipairs(rules) do
		local matches_one = false

		for _, pattern in ipairs(rule[1]) do
			local matches_code, matches_name = match(pattern, code, name)

			-- If a factor is enabled, warning can't be disabled by it.
			if enabled_code then matches_code = rule[2] ~= "disable" end

			if enabled_name then matches_code = rule[2] ~= "disable" end

			if
				(
					matches_code and
					matches_name ~= false
				)
				or
				(
					matches_name and
					matches_code ~= false
				)
			then
				matches_one = true
			end

			if rule[2] == "enable" then
				if matches_code then enabled_code = true end

				if matches_name then enabled_name = true end

				if enabled_code and enabled_name then
					-- Enable as matching to some `enable` pattern by code and to another by name.
					return true
				end
			elseif rule[2] == "disable" then
				if matches_one then
					-- Disable as matching to `disable` pattern.
					return false
				end
			end
		end

		if rule[2] == "only" and not matches_one then
			-- Disable as not matching to any of `only` patterns.
			return false
		end
	end

	-- Enable by default.
	return true
end

local function get_field_string(warning)
	local parts = {}

	if warning.indexing then
		for _, index in ipairs(warning.indexing) do
			local part

			if type(index) == "string" then
				local chars = decoder.decode(index)
				part = chars:get_printable_substring(1, chars:get_length())
			else
				part = "?"
			end

			table.insert(parts, part)
		end
	end

	return table.concat(parts, ".")
end

local function get_field_status(opts, warning, depth)
	local def = opts.std
	local defined = true
	local read_only = true

	for i = 1, depth or (warning.indexing and #warning.indexing or 0) + 1 do
		local index_string = i == 1 and warning.name or warning.indexing[i - 1]

		if index_string == true then
			-- Indexing with something that may or may not be a string.
			if (def.fields and next(def.fields)) or def.other_fields then
				if def.deep_read_only then
					read_only = true
				else
					read_only = false
				end
			else
				defined = false
			end

			break
		elseif index_string == false then
			-- Indexing with not a string.
			if not def.other_fields then defined = false end

			break
		else
			-- Indexing with a constant string.
			if def.fields and def.fields[index_string] then
				-- The field is defined, recurse into it.
				def = def.fields[index_string]

				if def.read_only ~= nil then read_only = def.read_only end
			else
				-- The field is not defined, but it may be okay to index if `other_fields` is true.
				if not def.other_fields then defined = false end

				break
			end
		end
	end

	return defined and (read_only and "read_only" or "global") or "undefined"
end

-- Checks if a warning passes options filter. May add some fields required for formatting.
local function passes_filter(normalized_options, warning)
	if warning.code == "561" then
		local max_complexity = normalized_options.max_cyclomatic_complexity

		if not max_complexity or warning.complexity <= max_complexity then
			return false
		end

		warning.max_complexity = max_complexity
	elseif warning.code:find("^[234]") and warning.name == "_" and not warning.useless then
		return false
	elseif warning.code:find("^1[14]") then
		if
			warning.indirect and
			get_field_status(normalized_options, warning, warning.previous_indexing_len) == "undefined"
		then
			return false
		end

		if
			not warning.module and
			get_field_status(normalized_options, warning) ~= "undefined"
		then
			return false
		end
	end

	if warning.code:find("^1[24][23]") then
		warning.field = get_field_string(warning)
	end

	if warning.secondary and not normalized_options.unused_secondaries then
		return false
	end

	if warning.self and not normalized_options.self then return false end

	return passes_rules_filter(normalized_options.rules, warning.code, warning.name)
end

local empty_options = {}

-- Updates option_stack for given line with next_index pointing to the inline option past the previous line.
-- Adds warnings for invalid inline options to check_result, filtered_warnings.
-- Returns updated next_index.
local function update_option_stack_for_new_line(check_result, stds, option_stack, line, next_index)
	local inline_option = check_result.inline_options[next_index]

	if not inline_option or inline_option.line > line then
		-- No inline options on this line, option stack for the line is ready.
		return next_index
	end

	next_index = next_index + 1

	if inline_option.pop_count then
		for _ = 1, inline_option.pop_count do
			table.remove(option_stack)
		end
	end

	if not inline_option.options then
		-- No inline option push on this line, option stack for the line is ready.
		return next_index
	end

	local options_ok, err_msg = options.validate(options.all_options, inline_option.options, stds)

	if not options_ok then
		-- Warn about invalid inline option, push a dummy empty table instead to keep pop counts correct.
		inline_option.options = nil
		inline_option.code = "021"
		inline_option.msg = err_msg
		table.insert(check_result.filtered_warnings, inline_option)
		-- Reuse empty table identity so that normalized option caching works better.
		table.insert(option_stack, empty_options)
	else
		table.insert(option_stack, inline_option.options)
	end

	return next_index
end

-- Warns (adds to check_result.filtered_warnings) about a line if it's too long
-- and the warning is not filtered out by options.
local function check_line_length(check_result, normalized_options, line)
	local line_length = check_result.line_lengths[line]
	local line_type = check_result.line_endings[line]
	local max_length = normalized_options["max_" .. (line_type or "code") .. "_line_length"]

	if max_length and line_length > max_length then
		if passes_rules_filter(normalized_options.rules, "631") then
			table.insert(
				check_result.filtered_warnings,
				{
					code = "631",
					line = line,
					column = max_length + 1,
					end_column = line_length,
					max_length = max_length,
					line_ending = line_type,
				}
			)
		end
	end
end

-- Adds warnings passing filtering and not related to globals to check_result.filtered_warnings.
-- If there is a global related warning on this line, sets check_results[line] to normalized_optuons.
local function filter_warnings_on_new_line(check_result, normalized_options, line, next_index)
	while true do
		local warning = check_result.warnings[next_index]

		if not warning or warning.line > line then
			-- No more warnings on this line.
			break
		end

		if warning.code:find("^1") then
			check_result.normalized_options[line] = normalized_options
		elseif passes_filter(normalized_options, warning) then
			table.insert(check_result.filtered_warnings, warning)
		end

		next_index = next_index + 1
	end

	return next_index
end

-- Normalizing options is relatively expensive because full std definitions are quite large.
-- `CachingOptionsNormalizer` implements a caching layer that reduces number of `options.normalize` calls.
-- Caching is done based on identities of option tables.
local CachingOptionsNormalizer = utils.class()

function CachingOptionsNormalizer:__init()
	self.result_trie = {}
end

function CachingOptionsNormalizer:normalize_options(stds, option_stack)
	local result_node = self.result_trie

	for _, option_table in ipairs(option_stack) do
		if not result_node[option_table] then result_node[option_table] = {} end

		result_node = result_node[option_table]
	end

	if result_node.result then return result_node.result end

	local result = options.normalize(option_stack, stds)
	result_node.result = result
	return result
end

-- May mutate base_opts_stack.
local function filter_not_global_related_in_file(check_result, options_normalizer, stds, option_stack)
	check_result.filtered_warnings = {}
	check_result.normalized_options = {}
	-- Iterate over lines, warnings, and inline options at the same time, keeping opts_stack up to date.
	local next_warning_index = 1
	local next_inline_option_index = 1

	for line in ipairs(check_result.line_lengths) do
		next_inline_option_index = update_option_stack_for_new_line(check_result, stds, option_stack, line, next_inline_option_index)
		local normalized_options = options_normalizer:normalize_options(stds, option_stack)
		check_line_length(check_result, normalized_options, line)
		next_warning_index = filter_warnings_on_new_line(check_result, normalized_options, line, next_warning_index)
	end
end

local function may_have_options(opts_table)
	for key in pairs(opts_table) do
		if type(key) == "string" then return true end
	end

	return false
end

local function get_option_stack(opts, file_index)
	local res = {opts}

	if opts and opts[file_index] then
		-- Don't add useless per-file option tables, that messes up normalized option caching
		-- since it memorizes based on option table identities.
		if may_have_options(opts[file_index]) then
			table.insert(res, opts[file_index])
		end

		for _, nested_opts in ipairs(opts[file_index]) do
			table.insert(res, nested_opts)
		end
	end

	return res
end

-- For each file check result:
-- * Stores invalid inline options, not filtered out not global-related warnings, and newly created line length warnings
--   in .filtered_warnings.
-- * Stores a map from line numbers to normalized options for lines of global-related warnings in .normalized_options.
local function filter_not_global_related(check_results, opts, stds)
	local caching_options_normalizer = CachingOptionsNormalizer()

	for file_index, check_result in ipairs(check_results) do
		if not check_result.fatal then
			if check_result.warnings[1] and check_result.warnings[1].code == "011" then
				-- Special case syntax errors, they don't have line numbers so normal filtering does not work.
				check_result.filtered_warnings = check_result.warnings
				check_result.normalized_options = {}
			else
				local base_file_option_stack = get_option_stack(opts, file_index)
				filter_not_global_related_in_file(check_result, caching_options_normalizer, stds, base_file_option_stack)
			end
		end
	end
end

-- A global is implicitly defined in a file if opts.allow_defined == true and it is set anywhere in the file,
--    or opts.allow_defined_top == true and it is set in the top level function scope.
-- By default, accessing and setting globals in a file is allowed for explicitly defined globals (standard and custom)
--    for that file and implicitly defined globals from that file and
--    all other files except modules (files with opts.module == true).
-- Accessing other globals results in "accessing undefined variable" warning.
-- Setting other globals results in "setting non-standard global variable" warning.
-- Unused implicitly defined global results in "unused global variable" warning.
-- For modules, accessing globals uses same rules as normal files, however,
--    setting globals is only allowed for implicitly defined globals from the module.
-- Setting a global not defined in the module results in "setting non-module global variable" warning.
local function is_definition(normalized_options, warning)
	return normalized_options.allow_defined or
		(
			normalized_options.allow_defined_top and
			warning.top
		)
end

-- Extracts sets of defined, exported and used globals from a file check result.
local function get_implicit_globals_in_file(check_result)
	local defined = {}
	local exported = {}
	local used = {}

	for _, warning in ipairs(check_result.warnings) do
		if warning.code:find("^11") then
			if warning.code == "111" then
				local normalized_options = check_result.normalized_options[warning.line]

				if is_definition(normalized_options, warning) then
					if normalized_options.module then
						defined[warning.name] = true
					else
						exported[warning.name] = true
					end
				end
			else
				used[warning.name] = true
			end
		end
	end

	return defined, exported, used
end

-- Returns set of globals defines across all files except modules, a set of globals used across all files,
-- and an array of sets of globals defined per file, parallel to the check results array.
local function get_implicit_globals(check_results)
	local globally_defined = {}
	local globally_used = {}
	local locally_defined = {}

	for file_index, check_result in ipairs(check_results) do
		if not check_result.fatal then
			local defined, exported, used = get_implicit_globals_in_file(check_result)
			utils.update(globally_defined, exported)
			utils.update(globally_used, used)
			locally_defined[file_index] = defined
		end
	end

	return globally_defined, globally_used, locally_defined
end

-- Mutates the warning and returns it or discards it by returning nothing if it's filtered out.
local function apply_implicit_definitions(globally_defined, globally_used, locally_defined, normalized_options, warning)
	if not warning.code:find("^11") then return warning end

	if warning.code == "111" then
		if normalized_options.module then
			if locally_defined[warning.name] then return end

			warning.module = true
		else
			if is_definition(normalized_options, warning) then
				if globally_used[warning.name] then return end

				warning.code = "131"
				warning.top = nil
			else
				if globally_defined[warning.name] then return end
			end
		end
	else
		if globally_defined[warning.name] or locally_defined[warning.name] then
			return
		end
	end

	return warning
end

local function filter_global_related_in_file(check_result, globally_defined, globally_used, locally_defined)
	for _, warning in ipairs(check_result.warnings) do
		if warning.code:find("^1") then
			local normalized_options = check_result.normalized_options[warning.line]
			warning = apply_implicit_definitions(globally_defined, globally_used, locally_defined, normalized_options, warning)

			if warning then
				if
					warning.code:find("^11[12]") and
					not warning.module and
					get_field_status(normalized_options, warning) == "read_only"
				then
					warning.code = "12" .. warning.code:sub(3, 3)
				elseif
					warning.code:find("^11[23]") and
					get_field_status(normalized_options, warning, 1) ~= "undefined"
				then
					warning.code = "14" .. warning.code:sub(3, 3)
				end

				if
					warning.code:match("11[23]") and
					get_field_status(normalized_options, warning, 1) ~= "undefined"
				then
					warning.code = "14" .. warning.code:sub(3, 3)
				end

				if passes_filter(normalized_options, warning) then
					table.insert(check_result.filtered_warnings, warning)
				end
			end
		end
	end
end

local function filter_global_related(check_results)
	local globally_defined, globally_used, locally_defined = get_implicit_globals(check_results)

	for file_index, check_result in ipairs(check_results) do
		if not check_result.fatal then
			filter_global_related_in_file(check_result, globally_defined, globally_used, locally_defined[file_index])
		end
	end
end

-- Processes an array of results of the check stage (or tables with .fatal field) into the final report.
-- `opts[i]`, if present, is used as options when processing `report[i]` together with options in its array part.
-- This function may mutate check results or reuse its parts in the return value.
function filter.filter(check_results, opts, stds)
	filter_not_global_related(check_results, opts, stds)
	filter_global_related(check_results)
	local report = {}

	for file_index, check_result in ipairs(check_results) do
		if check_result.fatal then
			report[file_index] = check_result
		else
			core_utils.sort_by_location(check_result.filtered_warnings)
			report[file_index] = check_result.filtered_warnings
		end
	end

	return report
end

return filter