-- A command-line interface for the static analyzer explcheck.

local evaluation = require("explcheck-evaluation")
local format = require("explcheck-format")
local get_option = require("explcheck-config").get_option
local utils = require("explcheck-utils")

local new_file_results = evaluation.new_file_results
local new_aggregate_results = evaluation.new_aggregate_results

local argument_types = {
  ARGUMENT_SEPARATOR = "a separator between mixed command-line options and filenames and only filenames",
  LONG_OPTION = "a long command-line option like `--porcelain`",
  SHORT_OPTION = "a long command-line option like `-p`",
  OTHER_ARGUMENT = "another unrecognized argument such as a pathname or a value for a long command-line option",
}

local ARGUMENT_SEPARATOR = argument_types.ARGUMENT_SEPARATOR
local LONG_OPTION = argument_types.LONG_OPTION
local SHORT_OPTION = argument_types.SHORT_OPTION
local OTHER_ARGUMENT = argument_types.OTHER_ARGUMENT

-- Process all input file groups.
local function process_file_groups(pathname_groups, options)
  local num_pathnames = 0
  for _, pathname_group in ipairs(pathname_groups) do
    num_pathnames = num_pathnames + #pathname_group
  end
  if not options.porcelain then
    print("Checking " .. num_pathnames .. " " .. format.pluralize("file", num_pathnames))
  end

  local aggregate_evaluation_results = new_aggregate_results()
  for pathname_group_number, pathname_group in ipairs(pathname_groups) do
    local is_last_group = pathname_group_number == #pathname_groups
    local is_ok, error_message = xpcall(function()
      -- Run all processing steps and collect issues and analysis results.
      local states = utils.process_files(pathname_group, options)
      assert(#states == #pathname_group)
      for pathname_number, state in ipairs(states) do
        assert(pathname_group[pathname_number] == state.pathname)
        -- Print warnings and errors.
        local file_evaluation_results = new_file_results(state)
        aggregate_evaluation_results:add(file_evaluation_results)
        local is_last_file = is_last_group and (pathname_number == #pathname_group)
        format.print_results(state, options, file_evaluation_results, is_last_file)
      end
    end, debug.traceback)
    if not is_ok then
      error("Failed to process " .. table.concat(pathname_group, ', ') .. ": " .. tostring(error_message), 0)
    end
  end

  format.print_summary(options, aggregate_evaluation_results)

  local num_errors = aggregate_evaluation_results.num_errors
  local num_warnings = aggregate_evaluation_results.num_warnings
  if(num_errors > 0) then
    return 1
  elseif(get_option("warnings_are_errors", options) and num_warnings > 0) then
    return 2
  else
    return 0
  end
end

-- Process all command-line arguments, if any, and take the appropriate actions.
local function process_arguments(arguments)

  -- Print information about the usage of the command-line interface.
  local function print_usage()
    print("Usage: " .. arg[0] .. " [OPTIONS] FILENAMES\n")
    print("Run static analysis on expl3 files.\n")
    local expl3_detection_strategy = get_option("expl3_detection_strategy")
    local max_line_length = tostring(get_option("max_line_length"))
    local config_files = table.concat(get_option("config_file"), ", ")
    print(
      "Options:\n\n"
      .. "\t--config-file FILENAMES    A comma-list of user config files. Defaults to FILENAMES=\"" .. config_files .. "\".\n"
      .. "\t                           See also --no-config-file.\n\n"
      .. "\t--error-format FORMAT      The Vim's quickfix errorformat used for the output with --porcelain enabled.\n"
      .. "\t                           The default format is FORMAT=\"" .. get_option("error_format") .. "\".\n\n"
      .. "\t--expl3-detection-strategy {never|always|precision|recall|auto}\n\n"
      .. "\t                           The strategy for detecting expl3 parts of the input files:\n\n"
      .. '\t                           - "never": Assume that no part of the input files is in expl3.\n'
      .. '\t                           - "always": Assume that the whole input files are in expl3.\n'
      .. '\t                           - "precision", "recall", and "auto": Analyze standard delimiters such as \n'
      .. '\t                             \\ExplSyntaxOn and Off. If no standard delimiters exist, assume either that:\n'
      .. '\t                               - "precision": No part of the input file is in expl3.\n'
      .. '\t                               - "recall": The entire input file is in expl3.\n'
      .. '\t                               - "auto": Use context cues to determine whether no part or the whole input file\n'
      .. "\t                                 is in expl3.\n\n"
      .. "\t                           The default setting is --expl3-detection-strategy " .. expl3_detection_strategy .. ".\n\n"
      .. "\t--files-from FILE          Read the list of FILENAMES from FILE.\n\n"
      .. '\t--group-files              Always group files into sets that are assumed to be used together unless "," is written\n'
      .. "\t                           between a pair of FILENAMES. See also --no-group-files.\n\n"
      .. "\t--ignored-issues ISSUES    A comma-list of issue identifiers (or just prefixes) that should not be reported.\n\n"
      .. '\t--make-at-letter           Tokenize "@" as a letter (catcode 11), like in LaTeX style files.\n\n'
      .. '\t--make-at-other            Tokenize "@" as an other character (catcode 12), like in plain TeX.\n\n'
      .. "\t--max-line-length N        The maximum line length before the warning S103 (Line too long) is produced.\n"
      .. "\t                           The default maximum line length is N=" .. max_line_length .. " characters.\n\n"
      .. "\t--no-config-file           Do not load a user config file. See also --config-file.\n\n"
      .. '\t--no-group-files           Never group files into sets that are assumed to be used together unless "+" is written\n'
      .. "\t                           between a pair of FILENAMES.\n\n"
      .. "\t--porcelain, -p            Produce machine-readable output. See also --error-format.\n\n"
      .. "\t--verbose                  Print additional information in non-machine-readable output. See also --porcelain.\n\n"
      .. "\t--warnings-are-errors      Produce a non-zero exit code if any warnings are produced by the analysis.\n"
    )
    print("The options are provisional and may be changed or removed before version 1.0.0.")
  end

  -- Print the versions of the expltools bundle and the explcheck package.
  local function print_version()
    print("explcheck (expltools 2026-05-03) v0.20.0")
    print("Copyright (c) 2024-2026 Vít Starý Novotný")
    print("Licenses: LPPL 1.3 or later, GNU GPL v2 or later")
  end

  -- Print an optional description of a parsing error, followed by information about the usage of the command-line interface.
  -- Then, terminate with a non-zero exit code.
  local function parse_error(description_template, ...)
    if description_template ~= nil then
      local description
      if select('#', ...) == 0 then
        description = description_template
      else
        description = string.format(description_template, ...)
      end
      print(string.format('%s\n', description))
    end
    print_usage()
    os.exit(1)
  end

  -- In the absence of command-line arguments, print information about the usage of the command-line interface and exit.
  if #arguments == 0 then
    parse_error('No command-line arguments were specified.')
  end

  -- Otherwise, define the recognized command-line options.
  local pathnames, allow_pathname_separators = {}, {}
  local only_pathnames_from_now_on = false
  local options = {}
  local long_options = {
    ["help"] = {
      action = function()
        print_usage()
        os.exit(0)
      end,
    },
    ["version"] = {
      action = function()
        print_version()
        os.exit(0)
      end,
    },
    ["config-file"] = {
      value_required = true,
      action = function(_, value)
        options.config_file = {}
        for config_file in value:gmatch('[^,]+') do
          table.insert(options.config_file, config_file)
        end
      end,
    },
    ["error-format"] = {
      value_required = true,
      action = function(_, value)
        options.error_format = value
      end,
    },
    ["expl3-detection-strategy"] = {
      value_required = true,
      field_name = "expl3_detection_strategy",
      action = function(_, value)
        options.expl3_detection_strategy = value
      end,
    },
    -- TODO: Remove `--expect-expl3-everywhere` in v1.0.0.
    ["expect-expl3-everywhere"] = {
      action = function()
        options.expl3_detection_strategy = "always"
      end,
    },
    ["files-from"] = {
      value_required = true,
      action = function(_, value)
        local file = assert(io.open(value, "r"))
        for pathname in file:lines() do
          table.insert(pathnames, pathname)
          table.insert(allow_pathname_separators, false)
        end
        assert(file:close())
      end,
    },
    ["group-files"] = {
      action = function(_, value)
        if value == nil then
          options.group_files = true
        else
          -- TODO: Remove `--group-files[={true|false|auto}]` in v1.0.0.
          if value == "true" then
            options.group_files = true
          elseif value == "false" then
            options.group_files = false
          else
            options.group_files = value
          end
        end
      end,
    },
    ["ignored-issues"] = {
      value_required = true,
      action = function(_, value)
        options.ignored_issues = {}
        for issue_identifier in value:gmatch('[^,]+') do
          table.insert(options.ignored_issues, issue_identifier)
        end
      end,
    },
    ["make-at-letter"] = {
      action = function(_, value)
        if value == nil then
          options.make_at_letter = true
        else
          -- TODO: Remove `--make-at-letter[={true|false|auto}]` in v1.0.0.
          if value == "true" then
            options.make_at_letter = true
          elseif value == "false" then
            options.make_at_letter = false
          else
            options.make_at_letter = value
          end
        end
      end,
    },
    ["make-at-other"] = {
      action = function()
        options.make_at_letter = false
      end,
    },
    ["max-line-length"] = {
      value_required = true,
      action = function(name, value)
        local max_line_length = tonumber(value)
        if max_line_length == nil then
          parse_error('Malformed numeric value "%s" for the option "%s".', value, name)
        end
        options.max_line_length = max_line_length
      end,
    },
    ["no-config-file"] = {
      action = function()
        options.config_file = {}
      end,
    },
    ["no-group-files"] = {
      action = function()
        options.group_files = false
      end,
    },
    ["porcelain"] = {
      action = function()
        options.porcelain = true
      end,
    },
    ["verbose"] = {
      action = function()
        options.verbose = true
      end,
    },
    ["warnings-are-errors"] = {
      action = function()
        options.warnings_are_errors = true
      end,
    },
  }

  local short_options = {
    h = long_options["help"],
    v = long_options["version"],
    p = long_options["porcelain"],
  }

  -- Parse the following command-line argument and determine its general type as well as any other relevant information.
  local function parse_argument(argument)
    if argument == "--" then
      return ARGUMENT_SEPARATOR
    elseif argument:sub(1, 2) == "--" then
      local option_name, option_value
      local pos = argument:find("=", 1, true)
      if pos then
        option_name = argument:sub(3, pos - 1)
        option_value = argument:sub(pos + 1)
      else
        option_name = argument:sub(3)
      end
      return LONG_OPTION, option_name, option_value
    elseif argument:sub(1, 1) == "-" and argument:len() == 2 then
      local option_name = argument:sub(2, 2)
      return SHORT_OPTION, option_name
    else
      return OTHER_ARGUMENT
    end
  end

  -- Then, process all arguments and collect all input file groups, if any.
  local argument_number = 1
  while argument_number <= #arguments do
    local argument = arguments[argument_number]
    if only_pathnames_from_now_on then
      table.insert(pathnames, argument)
      table.insert(allow_pathname_separators, true)
    else
      local argument_type, option_name, option_value = parse_argument(argument)
      if argument_type == ARGUMENT_SEPARATOR then
        only_pathnames_from_now_on = true
      elseif argument_type == LONG_OPTION then
        assert(option_name ~= nil)
        if long_options[option_name] == nil then
          parse_error('Unrecognized argument: "%s".', argument)
        end
        if long_options[option_name].value_required then
          if option_value == nil then
            -- Parse long option with separate value `--option VALUE`.
            if argument_number == #arguments then
              parse_error('No value provided for option "%s".', option_name)
            end
            assert(argument_number + 1 <= #arguments)
            local next_argument = arguments[argument_number + 1]
            local next_argument_type, next_option_name, _ = parse_argument(next_argument)
            if next_argument_type == LONG_OPTION and long_options[next_option_name] ~= nil or
                next_argument_type == SHORT_OPTION and short_options[next_option_name] ~= nil then
              parse_error('Ambiguous value provided for option "%s": "%s".', option_name, next_argument)
            end
            argument_number = argument_number + 1
            option_value = arguments[argument_number]
          end
          long_options[option_name].action(option_name, option_value)
        else
          if option_value ~= nil then
            parse_error('Option "%s" does not take a value but "%s" was provided.', option_name, option_value)
          end
          long_options[option_name].action(option_name)
        end
      elseif argument_type == SHORT_OPTION then
        -- TODO: Support merged short options, e.g. `-abc` as a shorthand for `-a -b -c`?
        assert(option_name ~= nil)
        if short_options[option_name] == nil then
          parse_error('Unrecognized argument: "%s".', argument)
        end
        short_options[option_name].action()
      elseif argument_type == OTHER_ARGUMENT then
        if argument:sub(1, 1) == "-" then
          -- TODO: Support long options with just a single leading dash, e.g. `-long-option` rather than `--long-option`?
          --       This is consistent with *TeX but mutually exclusive with support for merged short options, e.g. `-abc`
          --       as a shorthand for `-a -b -c`. See also <https://github.com/witiko/expltools/pull/185#discussion_r2904253886>.
          -- TODO: Support `-` as a short-hand for `/dev/stdin` but check that it has only occurred once in `pathnames`.
          parse_error('Unrecognized argument: "%s".', argument)
        else
          table.insert(pathnames, argument)
          table.insert(allow_pathname_separators, true)
        end
      else
        error('Unexpected argument type "' .. argument.type .. '"')
      end
    end
    argument_number = argument_number + 1
  end
  assert(#pathnames == #allow_pathname_separators)

  -- In the absence of file groups, print information about the usage of the command-line interface and exit.
  if #pathnames == 0 then
    parse_error('No input files were specified.')
  end

  -- Group pathnames.
  local pathname_groups = utils.group_pathnames(pathnames, options, allow_pathname_separators)

  -- Check pathnames.
  for _, pathname_group in ipairs(pathname_groups) do
    for _, pathname in ipairs(pathname_group) do
      local is_ok, error_message = utils.check_pathname(pathname)
      if not is_ok then
        print('Failed to process "' .. pathname .. '": ' .. error_message .. "\n")
        os.exit(1)
      end
    end
  end

  -- Run the analysis.
  local exit_code = process_file_groups(pathname_groups, options)
  os.exit(exit_code)
end

process_arguments(arg)
