-- Code is borrowed from:
-- https://github.com/lunarmodules/luacov/blob/master/src/luacov/linescanner.lua
-- MIT License by lunarmodules

-- coverage: disable
local ignoreComment = "coverage"

---@class LineScanner
---@field private first boolean
---@field private comment boolean
---@field private after_function boolean
---@field private enabled boolean
local LineScanner = {}
LineScanner.__index = LineScanner

---Creates new LineScanner instance
function LineScanner:new()
	return setmetatable({
		first = true,
		comment = false,
		after_function = false,
		enabled = true,
	}, self)
end

---Raw version of string.gsub
---@param s string
---@param old string
---@param new string
local function replace(s, old, new)
	old = old:gsub("%p", "%%%0")
	new = new:gsub("%%", "%%%%")
	return (s:gsub(old, new))
end

local fixups = {
	{ "=", " ?= ?" }, -- '=' may be surrounded by spaces
	{ "(", " ?%( ?" }, -- '(' may be surrounded by spaces
	{ ")", " ?%) ?" }, -- ')' may be surrounded by spaces
	{ "<FULLID>", "x ?[%[%.]? ?[ntfx0']* ?%]?" }, --[[ identifier, possibly
  indexed once]]
	{ "<IDS>", "x ?, ?x[x, ]*" }, -- at least two comma-separated identifiers
	{ "<FIELDNAME>", "%[? ?[ntfx0']+ ?%]?" }, -- field, possibly like ["this"]
	{ "<PARENS>", "[ %(]*" }, -- optional opening parentheses
}

-- Utility function to make patterns more readable
local function fixup(pat)
	for _, fixup_pair in ipairs(fixups) do
		pat = replace(pat, fixup_pair[1], fixup_pair[2])
	end
	return pat
end

--- Lines that are always excluded from accounting
local any_hits_exclusions = {
	"", -- Empty line
	"end[,; %)]*", -- Single "end"
	"else", -- Single "else"
	"repeat", -- Single "repeat"
	"do", -- Single "do"
	"if", -- Single "if"
	"then", -- Single "then"
	"while t do", -- "while true do" generates no code
	"if t then", -- "if true then" generates no code
	"local x", -- "local var"
	fixup("local x="), -- "local var ="
	fixup("local <IDS>"), -- "local var1, ..., varN"
	fixup("local <IDS>="), -- "local var1, ..., varN ="
	"local function x", -- "local function f (arg1, ..., argN)"
}

--- Lines that are only excluded from accounting when they have 0 hits
local zero_hits_exclusions = {
	"[ntfx0',= ]+,", -- "var1 var2," multi columns table stuff
	"{ ?} ?,", -- Empty table before comma leaves no trace in tables and calls
	fixup("<FIELDNAME>=.+[,;]"), -- "[123] = 23," "['foo'] = "asd","
	fixup("<FIELDNAME>=function"), -- "[123] = function(...)"
	fixup("<FIELDNAME>=<PARENS>'"), -- "[123] = [[", possibly with opening parens
	"return function", -- "return function(arg1, ..., argN)"
	"function", -- "function(arg1, ..., argN)"
	"[ntfx0]", --[[Single token expressions leave no trace in tables,
  function calls and sometimes assignments]]
	"''", -- Same for strings
	"{ ?}", -- Same for empty tables
	fixup("<FULLID>"), -- Same for local variables indexed once
	fixup("local x=function"), -- "local a = function(arg1, ..., argN)"
	fixup("local x=<PARENS>'"), -- "local a = [[", possibly with opening parens
	fixup("local x=(<PARENS>"), -- "local a = (", possibly with several parens
	fixup("local <IDS>=(<PARENS>"), --[["local a, b = (", possibly with several
  parens]]
	fixup("local x=n"), --[["local a = nil; local b = nil" produces no trace for
  the second statement]]
	fixup("<FULLID>=<PARENS>'"), -- "a.b = [[", possibly with opening parens
	fixup("<FULLID>=function"), -- "a = function(arg1, ..., argN)"
	"} ?,", -- "}," generates no trace if the table ends with a key-value pair
	"} ?, ?function", -- same with "}, function(...)"
	"break", -- "break" generates no trace in Lua 5.2+
	"{", -- "{" opening table
	"}?[ %)]*", -- optional closing paren, possibly with several closing parens
	"[ntf0']+ ?}[ %)]*", --[[
  a constant at the end of a table, possibly with closing parens
  (for LuaJIT) ]]
	"goto [%w_]+", -- goto statements
	"::[%w_]+::", -- labels
}

---@param exclusions table
---@param line string
---@return boolean
local function excluded(exclusions, line)
	for _, e in ipairs(exclusions) do
		if line:match("^ *" .. e .. " *$") then
			return true
		end
	end

	return false
end

---Finds the pattern in line
---@param pattern string
---@return number?, number?
function LineScanner:find(pattern)
	return self.line:find(pattern, self.i)
end

---Skips string literal with quote stored as self.quote.
---@return boolean indicating success.
function LineScanner:skip_string()
	-- Look for closing quote, possibly after even number of backslashes.
	local _, quote_i = self:find("^(\\*)%1" .. self.quote)
	if not quote_i then
		_, quote_i = self:find("[^\\](\\*)%1" .. self.quote)
	end

	if quote_i then
		self.i = quote_i + 1
		self.quote = nil
		table.insert(self.simple_line_buffer, "'")
		return true
	else
		return false
	end
end

---Skips long string literal with equal signs stored as self.equals.
---@return boolean indicating success.
function LineScanner:skip_long_string()
	local _, bracket_i = self:find("%]" .. self.equals .. "%]")

	if bracket_i then
		self.i = bracket_i + 1
		self.equals = nil

		if self.comment then
			self.comment = false
		else
			table.insert(self.simple_line_buffer, "'")
		end

		return true
	else
		return false
	end
end

---Skips function arguments.
---@return boolean indicating success.
function LineScanner:skip_args()
	local _, paren_i = self:find("%)")

	if paren_i then
		self.i = paren_i + 1
		self.args = nil
		return true
	else
		return false
	end
end

---Skip whitespaces.
function LineScanner:skip_whitespace()
	local next_i = self:find("%S") or #self.line + 1

	if next_i ~= self.i then
		self.i = next_i
		table.insert(self.simple_line_buffer, " ")
	end
end

function LineScanner:skip_number()
	if self:find("^0[xX]") then
		self.i = self.i + 2
	end

	local _
	_, _, self.i = self:find("^[%x%.]*()")

	if self:find("^[eEpP][%+%-]") then
		-- Skip exponent, too.
		self.i = self.i + 2
		_, _, self.i = self:find("^[%x%.]*()")
	end

	-- Skip LuaJIT number suffixes (i, ll, ull).
	_, _, self.i = self:find("^[iull]*()")
	table.insert(self.simple_line_buffer, "0")
end

local keywords = { ["nil"] = "n", ["true"] = "t", ["false"] = "f" }

for _, keyword in ipairs({
	"and",
	"break",
	"do",
	"else",
	"elseif",
	"end",
	"for",
	"function",
	"goto",
	"if",
	"in",
	"local",
	"not",
	"or",
	"repeat",
	"return",
	"then",
	"until",
	"while",
}) do
	keywords[keyword] = keyword
end

---Skips name.
function LineScanner:skip_name()
	-- It is guaranteed that the first character matches "%a_".
	local _, _, name = self:find("^([%w_]*)")
	self.i = self.i + #name

	if keywords[name] then
		name = keywords[name]
	else
		name = "x"
	end

	table.insert(self.simple_line_buffer, name)

	if name == "function" then
		-- This flag indicates that the next pair of parentheses
		-- (function args) must be skipped.
		self.after_function = true
	end
end

---Source lines can be explicitly ignored using `enable` and `disable`
---inline options. An inline option is a simple comment: `-- coverage: enable`
---or `-- coverage: disable`. Inline option parsing is not whitespace sensitive.
---All lines starting from a line containing `disable` option and up to a line
---containing `enable`option (or end of file) are excluded.
---`-- luacov: enable | disable` are also valid.
---@param comment_body string
function LineScanner:check_inline_options(comment_body)
	local isEnabled = comment_body:find(
		"^%s*" .. ignoreComment .. ":%s*enable%s*$"
	) or comment_body:find("^%s*luacov:%s*enable%s*$")
	local isDisabled = comment_body:find(
		"^%s*" .. ignoreComment .. ":%s*disable%s*$"
	) or comment_body:find("^%s*luacov:%s*disable%s*$")
	if isEnabled then
		self.enabled = true
	elseif isDisabled then
		self.enabled = false
	end
end

---Consumes and analyzes a line.
---@param line string
---@return boolean indicating whether line must be excluded.
---@return boolean indicating whether line must be excluded if not hit.
function LineScanner:consume(line)
	if self.first then
		self.first = false

		if line:match("^#!") then
			-- Ignore Unix hash-bang magic line.
			return true, true
		end
	end

	self.line = line
	-- As scanner goes through the line, it puts its simplified parts into buffer.
	-- Punctuation is preserved. Whitespace is replaced with single space.
	-- Literal strings are replaced with "''", so that a string literal
	-- containing special characters does not confuse exclusion rules.
	-- Numbers are replaced with "0".
	-- Identifiers are replaced with "x".
	-- Literal keywords (nil, true and false) are replaced with "n", "t" and "f",
	-- other keywords are preserved.
	-- Function declaration arguments are removed.
	self.simple_line_buffer = {}
	self.i = 1

	while self.i <= #line do
		-- One iteration of this loop handles one token, where
		-- string literal start and end are considered distinct tokens.
		if self.quote then
			if not self:skip_string() then
				-- String literal ends on another line.
				break
			end
		elseif self.equals then
			if not self:skip_long_string() then
				-- Long string literal or comment ends on another line.
				break
			end
		elseif self.args then
			if not self:skip_args() then
				-- Function arguments end on another line.
				break
			end
		else
			self:skip_whitespace()

			if self:find("^%.%d") then
				self.i = self.i + 1
			end

			if self:find("^%d") then
				self:skip_number()
			elseif self:find("^[%a_]") then
				self:skip_name()
			else
				if self:find("^%-%-") then
					self.comment = true
					self.i = self.i + 2
				end

				local _, bracket_i, equals = self:find("^%[(=*)%[")
				if equals then
					self.i = bracket_i + 1
					self.equals = equals

					if not self.comment then
						table.insert(self.simple_line_buffer, "'")
					end
				elseif self.comment then
					-- Simple comment, check if it contains inline options and skip line.
					self.comment = false
					local comment_body = self.line:sub(self.i)
					self:check_inline_options(comment_body)
					break
				else
					local char = line:sub(self.i, self.i)

					if char == "." then
						-- Dot can't be saved as one character because of
						-- ".." and "..." tokens and the fact that number literals
						-- can start with one.
						local _, _, dots = self:find("^(%.*)")
						self.i = self.i + #dots
						table.insert(self.simple_line_buffer, dots)
					else
						self.i = self.i + 1

						if char == "'" or char == '"' then
							table.insert(self.simple_line_buffer, "'")
							self.quote = char
						elseif self.after_function and char == "(" then
							-- This is the opening parenthesis of function declaration args.
							self.after_function = false
							self.args = true
						else
							-- Save other punctuation literally.
							-- This inserts an empty string when at the end of line,
							-- which is fine.
							table.insert(self.simple_line_buffer, char)
						end
					end
				end
			end
		end
	end

	if not self.enabled then
		-- Disabled by inline options, always exclude the line.
		return true, true
	end

	local simple_line = table.concat(self.simple_line_buffer)
	return excluded(any_hits_exclusions, simple_line),
		excluded(zero_hits_exclusions, simple_line)
end

return LineScanner
-- coverage: enable
