-- Common functions used by different modules of the static analyzer explcheck.

-- Convert a byte number in a file to a line and column number in a file.
local function convert_byte_to_line_and_column(line_starting_byte_numbers, byte_number)
  local line_number = 0
  for _, line_starting_byte_number in ipairs(line_starting_byte_numbers) do
    if line_starting_byte_number > byte_number then
      break
    end
    line_number = line_number + 1
  end
  assert(line_number > 0)
  local line_starting_byte_number = line_starting_byte_numbers[line_number]
  assert(line_starting_byte_number <= byte_number)
  local column_number = byte_number - line_starting_byte_number + 1
  return line_number, column_number
end

-- Get all non-empty prefixes of a text.
local function get_prefixes(text)
  local i = 0
  return function()
    i = i + 1
    if i <= #text then
      return text:sub(1, i)
    else
      return nil
    end
  end
end

-- Convert a pathname of a file to the suffix of the file.
local function get_suffix(pathname)
  return pathname:gsub(".*%.", "."):lower()
end

-- Convert a pathname of a file to the base name of the file.
local function get_basename(pathname)
  return pathname:gsub(".*[\\/]", "")
end

-- Convert a pathname of a file to the stem of the file.
local function get_stem(pathname)
  return get_basename(pathname):gsub("%..*", "")
end

-- Convert a pathname of a file to the pathname of its parent directory.
local function get_parent(pathname)
  if pathname:find("[\\/]") then
    return pathname:gsub("(.*)[\\/].*", "%1")
  else
    return "."
  end
end

-- Makes a shallow copy of a Lua table.
local function make_shallow_copy(original_table)
  assert(type(original_table) == "table")
  local copied_table = {}
  for key, value in pairs(original_table) do
    copied_table[key] = value
  end
  return copied_table
end

-- Return all parameters unchanged, mostly used for no-op map-back and map-forward functions.
local function identity(...)
  return ...
end

-- Check that a pathname specifies a file that we can process.
local function check_pathname(pathname)
  local suffix = get_suffix(pathname)
  if suffix == ".ins" then
    local basename = get_basename(pathname)
    if basename:find(" ") then
      basename = "'" .. basename .. "'"
    end
    return
      false,
      "explcheck can't currently process .ins files directly\n"
      .. 'Use a command such as "luatex ' .. basename .. '" '
      .. "to generate .tex, .cls, and .sty files and process these files instead."
  elseif suffix == ".dtx" then
    local parent = get_parent(pathname)
    local basename = "*.ins"
    local has_lfs, lfs = pcall(require, "lfs")
    if has_lfs then
      for candidate_basename in lfs.dir(parent) do
        local candidate_suffix = get_suffix(candidate_basename)
        if candidate_suffix == ".ins" then
          basename = candidate_basename
          if basename:find(" ") then
            basename = "'" .. candidate_basename .. "'"
          end
          break
        end
      end
    end
    return
      false,
      "explcheck can't currently process .dtx files directly\n"
      .. 'Use a command such as "luatex ' .. basename .. '" '
      .. "to generate .tex, .cls, and .sty files and process these files instead."
  end
  return true
end

-- Group pathnames passed to the command-line interface.
local function group_pathnames(pathnames, options, allow_pathname_separators)
  assert(allow_pathname_separators == nil or #pathnames == #allow_pathname_separators)

  -- Require packages.
  local get_option = require("explcheck-config").get_option

  -- Get options.
  local group_files = get_option("group_files", options)
  local max_grouped_files_per_directory = get_option("max_grouped_files_per_directory", options)

  -- Set up variables.
  local pathname_groups, current_group = {}, {}
  local group_next, ungroup_next = false, false
  local previous_pathname, num_files_from_current_directory = nil, 0

  -- Close the current group by adding it to a list of groups, if nonempty, and opening the next group.
  local function close_current_group()
    if #current_group > 0 then
      table.insert(pathname_groups, current_group)
    end
    current_group = {}
  end

  -- Explode the current group by creating single-element groups out of it and adding them to the list of groups.
  local function explode_current_group()
    for _, pathname in ipairs(current_group) do
      table.insert(pathname_groups, {pathname})
    end
    current_group = {}
  end

  for pathname_number, current_pathname in ipairs(pathnames) do
    -- Process a grouping argument, such as "+" or ",".
    local allow_separator = allow_pathname_separators == nil or allow_pathname_separators[pathname_number] == true
    if allow_separator and (current_pathname == "+" or current_pathname == ",") then  -- a grouping argument
      if group_next or ungroup_next then
        error('Two arguments "+" or "," in a row')
      end
      if current_pathname == "+" then
        group_next = true
      else
        ungroup_next = true
      end
    else
      assert(not (group_next and ungroup_next))
      -- Process the pathname argument.
      if group_files == false then
        if not group_next then
          close_current_group()
        end
      elseif group_files == true then
        if ungroup_next then
          close_current_group()
        end
      elseif group_files == "auto" then
        if group_next then
          if num_files_from_current_directory > max_grouped_files_per_directory then
            explode_current_group()
          end
          num_files_from_current_directory = 0
        elseif ungroup_next then
          if num_files_from_current_directory > max_grouped_files_per_directory then
            explode_current_group()
          else
            close_current_group()
          end
          num_files_from_current_directory = 0
        elseif previous_pathname == nil or get_parent(previous_pathname) == get_parent(current_pathname) then
          num_files_from_current_directory = num_files_from_current_directory + 1
        else
          if num_files_from_current_directory > max_grouped_files_per_directory then
            explode_current_group()
          else
            close_current_group()
          end
          num_files_from_current_directory = 0
        end
      else
        error('Unexpected grouping strategy "' .. group_files .. '"')
      end
      group_next, ungroup_next = false, false
      previous_pathname = current_pathname
      table.insert(current_group, current_pathname)
    end
  end

  -- Close or explode any trailing group.
  if group_files == "auto" and num_files_from_current_directory > max_grouped_files_per_directory then
    explode_current_group()
  else
    close_current_group()
  end

  return pathname_groups
end

-- Run all processing steps on a group of files.
local function process_files(pathnames, options)
  -- Require packages.
  local get_option = require("explcheck-config").get_option
  local new_issues = require("explcheck-issues").new_issues

  -- Prepare empty processing states for all files in the group.
  local states = {
    results = {},  -- Allow the processing steps to also record group-wide results.
  }
  for _, pathname in ipairs(pathnames) do
    local file = assert(io.open(pathname, "r"))
    local content = assert(file:read("*a"))
    assert(file:close())
    local state = {
      pathname = pathname,
      content = content,
      issues = new_issues(pathname, options),
      results = {},
    }
    table.insert(states, state)
  end
  assert(#states == #pathnames)

  -- Run all processing steps.
  local step_filenames = {'preprocessing', 'lexical-analysis', 'syntactic-analysis', 'semantic-analysis', 'flow-analysis'}
  for step_number, step_filename in ipairs(step_filenames) do
    local step = require(string.format('explcheck-%s', step_filename))
    -- Process all files in the group with this step.
    for substep_number, process_with_substep in ipairs(step.substeps) do
      local is_last_substep = step_number == #step_filenames and substep_number == #step.substeps
      -- Process all files in the group with this substep.
      for file_number, state in ipairs(states) do
        -- Get options.
        local fail_fast = get_option('fail_fast', options, state.pathname)
        local stop_after = get_option('stop_after', options, state.pathname)
        local stop_early_when_confused = get_option('stop_early_when_confused', options, state.pathname)
        -- If we stopped early for this file, skip this (sub)step for this file also.
        local is_confused, reason
        if state.results.stopped_early ~= nil then
          goto continue
        end
        -- If the step is confused by this file, skip it and all following steps.
        if substep_number == 1 and stop_early_when_confused then
          is_confused, reason = step.is_confused(state.pathname, state.results, options)
          if is_confused then
            assert(reason ~= nil)
            state.results.stopped_early = {
              when = string.format("before the %s", step.name),
              reason = reason,
            }
            goto continue
          end
        end
        -- Run the substep for this file.
        process_with_substep(states, file_number, options)
        if substep_number == #step.substeps then
          -- If the step ended with errors for this file, skip all following steps for this file.
          if is_last_substep then
            state.issues:close()
          else
            state.issues:commit()
          end
          if step_number < #step_filenames and fail_fast and #state.issues.errors > 0 then
            state.results.stopped_early = {
              when = string.format("after %s", step.name),
              reason = "it ended with errors and the option `fail_fast` was enabled",
            }
            state.results.failed_fast = true
            goto continue
          end
          -- If the step is supposed to be the last step, skip all following steps.
          if step_number < #step_filenames and (stop_after == step_filename or stop_after == step.name) then
            state.results.stopped_early = {
              when = string.format("after %s", step.name),
              reason = "that was the final step according to the option `stop_after`",
            }
            goto continue
          end
        end
        ::continue::
      end
    end
  end

  -- Close all issue registries.
  for _, state in ipairs(states) do
    state.issues:close()
  end

  return states
end

return {
  check_pathname = check_pathname,
  convert_byte_to_line_and_column = convert_byte_to_line_and_column,
  get_basename = get_basename,
  get_parent = get_parent,
  get_prefixes = get_prefixes,
  get_stem = get_stem,
  get_suffix = get_suffix,
  group_pathnames = group_pathnames,
  identity = identity,
  make_shallow_copy = make_shallow_copy,
  process_files = process_files,
}
