local options = require "luacheck.options"
local utils = require "luacheck.utils"

local stage = {}

stage.warnings = {
   -- Also produced during filtering for options that did not pass validation.
   ["021"] = {message_format = "{msg}", fields = {"msg"}},
   ["022"] = {message_format = "unpaired push directive", fields = {}},
   ["023"] = {message_format = "unpaired pop directive", fields = {}}
}

stage.inline_option_fields = {"line", "pop_count", "options", "column", "end_column"}

local limit_opts = utils.array_to_set({"max_line_length", "max_code_line_length", "max_string_line_length",
   "max_comment_line_length", "max_cyclomatic_complexity"})

local function is_valid_option_name(name)
   if name == "std" or options.variadic_inline_options[name] then
      return true
   end

   name = name:gsub("^no_", "")
   return options.nullary_inline_options[name] or limit_opts[name]
end

-- Splits a token array for an inline option invocation into
-- option name and argument array, or nil if invocation is invalid.
local function split_invocation(tokens)
   -- Name of the option can be split into several space separated tokens.
   -- Since some valid names are prefixes of some other names
   -- (e.g. `unused` and `unused arguments`), the longest prefix of token
   -- array that is a valid option name should be considered.
   local cur_name
   local last_valid_name
   local last_valid_name_end_index

   for i, token in ipairs(tokens) do
      cur_name = cur_name and (cur_name .. "_" .. token) or token

      if is_valid_option_name(cur_name) then
         last_valid_name = cur_name
         last_valid_name_end_index = i
      end
   end

   if not last_valid_name then
      return
   end

   local args = {}

   for i = last_valid_name_end_index + 1, #tokens do
      table.insert(args, tokens[i])
   end

   return last_valid_name, args
end

local function unexpected_num_args(name, args, expected)
   return ("inline option '%s' expects %d argument%s, %d given"):format(
      name, expected, expected == 1 and "" or "s", #args)
end

-- Parses inline option body, returns options or nil and error message.
local function parse_options(body)
   local opts = {}

   local parts = utils.split(body, ",")

   for _, name_and_args in ipairs(parts) do
      local tokens = utils.split(name_and_args)
      local name, args = split_invocation(tokens)

      if not name then
         if #tokens == 0 then
            return nil, (#parts == 1) and "empty inline option" or "empty inline option invocation"
         else
            return nil, ("unknown inline option '%s'"):format(table.concat(tokens, " "))
         end
      end

      if name == "std" then
         if #args ~= 1 then
            return nil, unexpected_num_args(name, args, 1)
         end

         opts.std = args[1]
      elseif name == "ignore" and #args == 0 then
         opts.ignore = {".*"}
      elseif options.variadic_inline_options[name] then
         opts[name] = args
      else
         local full_name = name:gsub("_", " ")
         local subs
         name, subs = name:gsub("^no_", "")
         local flag = subs == 0

         if options.nullary_inline_options[name] then
            if #args ~= 0 then
               return nil, unexpected_num_args(full_name, args, 0)
            end

            opts[name] = flag
         else
            assert(limit_opts[name])

            if flag then
               if #args ~= 1 then
                  return nil, unexpected_num_args(full_name, args, 1)
               end

               local value = tonumber(args[1])

               if not value then
                  return nil, ("inline option '%s' expects number as argument"):format(name)
               end

               opts[name] = value
            else
               if #args ~= 0 then
                  return nil, unexpected_num_args(full_name, args, 0)
               end

               opts[name] = false
            end
         end
      end
   end

   return opts
end

-- Parses comment contents, returns up to two `options` values (tables or "push" or "pop").
-- On an invalid inline comment returns nil and an error message.
local function parse_inline_comment(comment_contents)
   local body = utils.after(utils.strip(comment_contents), "^luacheck:")

   if not body then
      return
   end

   local opts1, opts2

   -- Remove comments in balanced parens.
   body = utils.strip((body:gsub("%b()", " ")))
   local after_push = body:match("^push%s+(.*)")

   if after_push then
      opts2 = "push"
      body = after_push
   elseif body == "push" or body == "pop" then
      return body
   end

   local err_msg
   opts1, err_msg = parse_options(body)
   return opts1, err_msg or opts2
end

-- Returns an array of tables with column range info and an `options` field
-- containing a table of options or "push" or "pop".
-- Warns about invalid inline option comments.
local function parse_inline_comments(chstate)
   local res = {}

   for _, comment in ipairs(chstate.comments) do
      local opts1, opts2 = parse_inline_comment(comment.contents)

      if opts1 then
         table.insert(res, {
            line = comment.line,
            column = chstate:offset_to_column(comment.line, comment.offset),
            end_column = chstate:offset_to_column(comment.line, comment.end_offset),
            options = opts1
         })

         if opts2 then
            table.insert(res, {
               line = comment.line,
               column = chstate:offset_to_column(comment.line, comment.offset),
               end_column = chstate:offset_to_column(comment.line, comment.end_offset),
               options = opts2
            })
         end
      elseif opts2 then
         chstate:warn_range("021", comment, {msg = opts2})
      end
   end

   return res
end

-- Adds a table with `line`, `column`, and `options` fields to given array.
-- For each function a table with `options` set to "push" for the function start
-- and a table with `options` set to "pop" for the function end are added.
local function add_function_boundaries(inline_options_and_boundaries, chstate)
   for _, line in ipairs(chstate.top_line.lines) do
      local fn_node = line.node

      table.insert(inline_options_and_boundaries, {
         line = fn_node.line,
         column = chstate:offset_to_column(fn_node.line, fn_node.offset),
         options = "push"
      })

      table.insert(inline_options_and_boundaries, {
         line = fn_node.end_range.line,
         column = chstate:offset_to_column(fn_node.end_range.line, fn_node.end_range.offset),
         options = "pop"
      })
   end
end

local function get_order(t)
   if t.options == "push" then
      return 1
   elseif t.options == "pop" then
      return 3
   else
      return 2
   end
end

local function options_and_boundaries_comparator(t1, t2)
   if t1.line ~= t2.line then
      return t1.line < t2.line
   end

   -- For options and boundaries on the same line, all pushes are applied before options before pops.
   -- (Valid pops will be moved to the start of the next line later.)
   local order1 = get_order(t1)
   local order2 = get_order(t2)

   if order1 ~= order2 then
      return order1 < order2
   else
      return t1.column < t2.column
   end
end

-- Applies bounadaries withing `inline_options_and_boundaries` to replace them with pop count
-- instructions in the resulting array.
-- Comments on lines with code are popped at the end of line.
-- Warns about unpaired push and pop directives.
local function apply_boundaries(chstate, inline_options_and_boundaries)
   local res = {}
   local res_last

   -- While iterating over inline options and boundaries track push
   -- boundaries that were not popped yet plus the number of options
   -- that would be on the option stack after applying all already
   -- processed option table pushes and pops.
   local pushes = utils.Stack()
   local push_option_counts = utils.Stack()
   local option_count = 0

   for _, item in ipairs(inline_options_and_boundaries) do
      if item.options == "push" then
         pushes:push(item)
         push_option_counts:push(option_count)
      elseif item.options == "pop" then
         -- Function boundaries are implicit, don't allow inline options to pop
         -- them, don't allow function boundaries to pop inline option pushes either.
         -- Inline options boundaries have end_column, function boundaries don't.
         if not pushes.top or (item.end_column and not pushes.top.end_column) then
            -- Inline option pop against nothing or a function push, mark as unpaired.
            chstate:warn_column_range("023", item)
         else
            if not item.end_column then
               -- Function pop, remove any unpaired inline option pushes.
               while pushes.top and pushes.top.end_column do
                  chstate:warn_column_range("022", pushes.top)
                  pushes:pop()
                  push_option_counts:pop()
               end
            end

            pushes:pop()
            local prev_option_count = push_option_counts:pop()
            local pop_count = option_count - prev_option_count

            if pop_count > 0 then
               -- Place the pop instruction at the start of the next line so that getting option stack
               -- for a line amounts to applying both the pop instruction and the option push for the line.
               local line = item.line + 1

               -- Collapse with a previous table if it's on the same line. It can only be a pop count table.
               if res_last and res_last.line == line then
                  res_last.pop_count = res_last.pop_count + pop_count
               else
                  res_last = {
                     line = line,
                     pop_count = pop_count
                  }

                  table.insert(res, res_last)
               end
            end

            -- Update option stack size for this pop.
            option_count = prev_option_count
         end
      else
         -- Inline options table. Check if there is a pop count table for this line already.
         if res_last and res_last.line == item.line then
            res_last.options = item.options
            res_last.column = item.column
            res_last.end_column = item.end_column
         else
            res_last = item
            table.insert(res, item)
         end

         if chstate.code_lines[item.line] then
            -- Inline comment on a line with some code, immediately pop it.
            res_last = {
               line = item.line + 1,
               pop_count = 1
            }
            table.insert(res, res_last)
         else
            option_count = option_count + 1
         end
      end
   end

   -- Any remaining pushes are unpaired inline comments from the main chunk.
   while pushes.top do
      chstate:warn_column_range("022", pushes:pop())
   end

   return res
end

-- Warns about invalid inline options.
-- Sets `chstate.inline_options` to an array of tables that describe the way inline option tables
-- are pushed onto and popped from the option stack when iterating over lines.
-- Each table has field `line` that the array is sorted by and also ether or both sets of fields:
-- * `pop_count` - refers to a number of option tables that should be popped from the stack before processing
--   warnings on this line.
-- * `options`, `column`, `end_column` - refers to an option table that should be pushed onto the stack
--   before processing warnings on this line but after popping tables if `pop_count` is present.
function stage.run(chstate)
   local inline_options_and_boundaries = parse_inline_comments(chstate)
   add_function_boundaries(inline_options_and_boundaries, chstate)
   table.sort(inline_options_and_boundaries, options_and_boundaries_comparator)
   chstate.inline_options = apply_boundaries(chstate, inline_options_and_boundaries)
end

return stage
