# Copyright © 2023-2026 Bartek Jasicki
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDERS AND CONTRIBUTORS ''AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

## Provides code for parse the program's configuration file

# Standard library imports
import std/[os, parseopt, sequtils]
# Internal modules imports
import rules, utils

type
  ConfigKind* = enum
    ## The types of configuration entries: a program's rule or a custom message
    rule, message

  ConfigOption = string
    ## The configuration's option's name

  ConfigData = object
    ## Contains information about the configuration of the program's rule or a
    ## custom message, depends on `kind` parameter. When the type of
    ## configuration entry is the program's rule, it has following fields:
    ##
    ## * name            - The name of the rule
    ## * options         - The options list provided by the user in a configuration
    ##                     file
    ## * negation        - If true, the rule is negation
    ## * ruleType        - The type of the rule
    ## * index           - The index of the rule
    ## * forceFixCommand - If true, force use setting fixCommand for the rule
    ##                     instead of the rule's fix code
    ## * explanation     - The explanation which will be show to the user if check
    ##                     or fix type of rule setting is violated by the checked
    ##                     code
    ##
    ## When the type of configuration entry is a custom message it has following
    ## field:
    ##
    ## * text - the text to show to the user
    case kind: ConfigKind
    of rule:
      name: RuleName
      options: seq[RuleOption]
      negation: bool
      ruleType: RuleTypes
      index: ExtendedNatural
      forceFixCommand: bool
      explanation: Message
    of ConfigKind.message:
      text: Message

template configGetter(name: untyped; typ: typedesc) =
  ## Set the getter for a field of ConfigData type
  ##
  ## * name - the name of the field for which the getter will be set
  ## * typ  - the type of the value of the field
  proc `name`*(config: ConfigData): `typ` {.sideEffect, raises: [], tags: [],
      contractual.} =
    ## The getter of a field of RuleOption type
    ##
    ## * opt - the options of the selected rule
    ##
    ## Returns the value of the selected field
    config.`name`

configGetter(name = kind, typ = ConfigKind)
configGetter(name = text, typ = Message)
configGetter(name = negation, typ = bool)
configGetter(name = ruleType, typ = RuleTypes)
configGetter(name = name, typ = RuleName)
configGetter(name = options, typ = seq[RuleOption])
configGetter(name = forceFixCommand, typ = bool)
configGetter(name = explanation, typ = Message)
configGetter(name = index, typ = ExtendedNatural)

proc initConfigData(kind: ConfigKind, name: RuleName = "", options: seq[
    RuleOption] = @[], negation: bool = false, ruleType: RuleTypes = none,
    index: ExtendedNatural = -1, forcefixcommand: bool = false;
    message: Message = ""): ConfigData {.sideEffect, raises: [], tags: [],
    contractual.} =
  ## Initialize a new instance of ConfigData object
  ##
  ## * kind            - The type of the object, rule or message
  ## * name            - The name of the rule
  ## * options         - The options list provided by the user in a configuration
  ##                     file
  ## * negation        - If true, the rule is negation
  ## * ruleType        - The type of the rule
  ## * index           - The index of the rule
  ## * forceFixCommand - If true, force use setting fixCommand for the rule
  ##                     instead of the rule's fix code
  ## * message         - The explanation which will be show to the user if check
  ##                     or fix type of rule setting is violated by the checked
  ##                     code or the text to show to the user if kind of object
  ##                     is set to message
  ##
  ## Returns the new instance of ConfigData object
  if kind == rule:
    result = ConfigData(kind: rule, name: name, options: options,
        negation: negation, ruleType: ruleType, index: index,
        forcefixcommand: forcefixcommand, explanation: message)
  else:
    result = ConfigData(kind: ConfigKind.message, text: message)

const
  fixCommand: FixCommand = when defined(macos) or defined(macosx) or defined(
    windows): "open" else: "xdg-open" & " {fileName}"
    ## The command executed when a fix type of rule encounter a problem. By
    ## default it try to open the selected file in the default editor.
  configOptions*: array[19, ConfigOption] = ["verbosity", "output", "source",
      "files", "directory", "check", "search", "count", "fixcommand", "fix",
      "reset", "message", "forcefixcommand", "maxreports", "explanation",
      "ignore", "showsummary", "ignoredir", "extensions"]
    ## The list of available the program's configuration's options

proc parseConfig*(configFile: FilePath; sections: var ExtendedNatural): tuple[
    sources: seq[FilePath]; rules: seq[ConfigData]; fixCommand: FixCommand;
    maxReports: Natural; showSummary: bool] {.sideEffect, raises: [], tags: [
    ReadIOEffect, RootEffect], contractual.} =
  ## Parse the configuration file and get all the program's settings
  ##
  ## * configFile - the path to the configuration file which will be parsed
  ## * sections   - the amount of sections in the configuration file. The
  ##                sections are separated with *reset* setting in the file.
  ##
  ## Returns tuple with the list of source code files to check, the list of
  ## the program's rules to check plus custom messages to show, the command
  ## executed when rule doesn't set own code for fix type of rules, and the
  ## maximum amount of the program's reports to show. Also returns the
  ## updated parameter sections. If the file was fully parsed, the
  ## parameter sections will have value -1. Otherwise, the parameter sections
  ## will be the number of the setting *reset* in the configuration file, so
  ## next time the procedure can start parsing from exactly this setting.
  require:
    configFile.len > 0
  body:

    type
      ConfigName = string
      ConfigValue = string

      ConfigSetting = object
        ## Contains information about the setting from the program's
        ## configuration's file.
        ##
        ## * name  - the name of the setting from the file
        ## * value - the value of the setting from the file
        ## * index - the index of the setting in configOptions list. If it is an
        ##           invalid option, it is -1.
        name: ConfigName
        value: ConfigValue
        index: ExtendedNatural

    proc initConfigSetting(name: ConfigName; value: ConfigValue;
        index: ExtendedNatural): ConfigSetting {.raises: [], tags: [],
        contractual.} =
      ## Initialize a new instance of ConfigSetting object
      ##
      ## * name  - the name of the setting from the file
      ## * value - the value of the setting from the file
      ## * index - the index of the setting in configOptions list. If it is an
      ##           invalid option, it is -1.
      ##
      ## Returns the new instance of ConfigSetting object
      return ConfigSetting(name: name, value: value, index: index)

    proc addFile(fileName: FilePath; sources: var seq[FilePath]) {.gcsafe,
        raises: [], tags: [RootEffect], contractual.} =
      ## Add the selected file as a source code to check for the program
      ##
      ## * fileName - the path to the file which will be added
      ## * sources  - the list of source code files to check
      ##
      ## Returns the updated parameter sources
      require:
        fileName.len > 0
      body:
        if fileName notin sources:
          sources.add(y = fileName)
          message(text = "Added the file '" & fileName &
              "' to the list of files to check.", level = lvlDebug)

    result.fixCommand = fixCommand
    result.maxReports = Positive.high
    result.showSummary = false
    try:
      # Read the program's configuration
      var
        configSection: Natural = sections
        forceFixCommand: bool = false
        lineNumber: Natural = 0
        extensions: seq[string] = @[".nim", ".nims"]
      for line in configFile.lines:
        lineNumber.inc
        let
          configLine: seq[string] = line.split
          setting: ConfigSetting = initConfigSetting(name = configLine[
              0].toLowerAscii, value = configLine[1..^1].join(sep = " "),
              index = configOptions.find(item = configLine[0].toLowerAscii))
        # Comment line, skip
        if line.startsWith(prefix = '#') or line.len == 0:
          continue
        # Check if the setting is a valid setting for the program
        if setting.index == -1:
          abortProgram(message = "An unknown setting in the configuration file: '" &
              setting.name & "', line: " & $lineNumber & ".")
        # If the configuration file contains a couple of sections of settings,
        # skip the current line until don't meet the proper section
        if configSection > 0:
          if configLine[0] == "reset":
            configSection.dec
            message(text = "Restarting parsing of the configuration file.",
                level = lvlDebug)
          continue
        case setting.name
        # If the configuration file contains "reset" setting, stop parsing it
        # and increase the amount of sections
        of "reset":
          sections.inc
          message(text = "Stopped parsing of the configuration file.",
              level = lvlDebug)
          return
        # Set the program's verbosity
        of "verbosity":
          try:
            setLogFilter(lvl = parseEnum[Level](s = setting.value))
            message(text = "Setting the program's verbosity to '" &
                setting.value & "'.", level = lvlDebug)
          except ValueError:
            abortProgram(message = "An invalid value set in the configuration file, line: " &
                $lineNumber & " for the program's verbosity level.")
        # Set the max amount of the reported problems
        of "maxreports":
          try:
            result.maxReports = parseInt(s = setting.value)
            message(text = "Setting the program's max reports to " &
                setting.value & ".", level = lvlDebug)
          except ValueError:
            abortProgram(message = "An invalid value set in the configuration file, line: " &
                $lineNumber & " for the maximum amount of the program's reports.")
        # Set the file to which the program's output will be logged
        of "output":
          let logMode: FileMode = (if setting.value.startsWith(
              prefix = "new ".toLowerAscii): fmWrite else: fmAppend)
          let fileName: FilePath = unixToNativePath(path = (if logMode ==
              fmWrite: setting.value[4..^1] else: setting.value))
          addHandler(handler = newFileLogger(filename = fileName,
              fmtStr = "[$time] - $levelname: ", mode = logMode))
          message(text = "Added the file '" & fileName & "' as a log file" & (
              if logMode ==
              fmWrite: " and remove its content before logging." else: "."),
              level = lvlDebug)
        # Set the command which will be executed when rule type fix encounter
        # a problem
        of "fixcommand":
          result.fixCommand = setting.value
          message(text = "The command to execute for fix rules' type set to '" &
              result.fixCommand & "'.", level = lvlDebug)
        # Set the source code file to check
        of "source":
          let fileName: FilePath = unixToNativePath(path = setting.value)
          addFile(fileName = fileName, sources = result.sources)
        # Set the source code files to check
        of "files":
          try:
            for fileName in walkFiles(pattern = setting.value):
              addFile(fileName = fileName, sources = result.sources)
          except OSError:
            abortProgram(message = "Can't parse the setting: '" & line &
                ", line: " & $lineNumber & "'. Reason: ",
                e = getCurrentException())
        # Set the source code files to check, the second option
        of "directory":
          try:
            for fileName in walkDirRec(dir = setting.value):
              let (_, _, ext) = splitFile(path = fileName)
              if ext notin extensions:
                continue
              addFile(fileName = fileName, sources = result.sources)
          except OSError:
            abortProgram(message = "Can't add files to check, line: " &
                $lineNumber & ". Reason: ", e = getCurrentException())
        # Remove the selected file from the list of source code files to check
        of "ignore":
          for index, fileName in result.sources:
            if fileName == setting.value:
              result.sources.del(i = index)
              message(text = "Removed the file '" & fileName &
                  "' from the list of files to check.", level = lvlDebug)
              break
        # Remove all files from the selected directory from the list of source
        # code files to check
        of "ignoredir":
          var index: ExtendedNatural = 0
          while index < result.sources.len:
            let fileName: FilePath = result.sources[index]
            if fileName.parentDir == setting.value:
              result.sources.del(i = index)
              message(text = "Removed the file '" & fileName &
                  "' from the list of files to check.", level = lvlDebug)
              index.dec
            index.inc
        # Set the message to show during the program's work
        of "message":
          if setting.value.len == 0:
            abortProgram(message = "Can't parse the 'message' setting in the configuration file, line: " &
                $lineNumber & ". No message's text set.")
          let newMessage: ConfigData = initConfigData(kind = message,
              message = setting.value)
          result.rules.add(y = newMessage)
          message(text = "Added the custom message: '" & result.rules[^1].text &
              "' to the program's output.", level = lvlDebug)
        # Set do the progam should force its rules to execute the command instead
        # of code for fix type of rules
        of "forcefixcommand":
          if setting.value.len == 0:
            abortProgram(message = "Can't parse the 'forcefixcommand' setting in the configuration file, line: " &
                $lineNumber & ". No value set, should be 0, 1, true or false.")
          if setting.value.toLowerAscii in ["0", "false"]:
            forceFixCommand = false
            message(text = "Disabled forcing the next rules to use a fix command instead of the code.",
                level = lvlDebug)
          else:
            forceFixCommand = true
            message(text = "Enabled forcing the next rules to use a fix command instead of the code.",
                level = lvlDebug)
        # Set the explanation for the previous rule
        of "explanation":
          if result.rules.len == 0:
            abortProgram(message = "The setting 'explanation', line: " &
                $lineNumber & " is set before any rule setting.")
          if setting.value.len == 0:
            abortProgram(message = "Can't parse the 'explanation' setting in the configuration file, line: " &
                $lineNumber & ". No explanation's text set.")
          result.rules[result.rules.high].explanation = setting.value
        # Set do the progam should show the summary information about the work
        # after analyzing the code
        of "showsummary":
          if setting.value.len == 0:
            abortProgram(message = "Can't parse the 'showsummary' setting in the configuration file, line: " &
                $lineNumber & ". No value set, should be 0, 1, true or false.")
          if setting.value.toLowerAscii in ["0", "false"]:
            result.showSummary = false
            message(text = "Disabled showing the program's summary information.",
                level = lvlDebug)
          else:
            result.showSummary = true
            message(text = "Enabled showing the program's summary information.",
                level = lvlDebug)
        # Add an additional files' extension to list of files to check
        of "extensions":
          if setting.value.len == 0:
            abortProgram(message = "Can't parse the 'extensions' setting in the configuration file, line: " &
                $lineNumber & ". No list of extensions set.")
          extensions &= setting.value.split(sep = ", ")
          message(text = "Added file extensions to check:" & setting.value &
              ".", level = lvlDebug)
        else:
          discard
        # Set the program's rule to test the code
        if availableRuleTypes.anyIt(pred = setting.name == it):
          var configRule: OptParser = initOptParser(cmdline = line)
          configRule.next
          let ruleType: RuleTypes = try:
              parseEnum[RuleTypes](s = configRule.key)
            except ValueError:
              none
          if ruleType == none:
            abortProgram(message = "Unknown type of the rule: '" &
                configRule.key & "'.")
          configRule.next
          var newRule: ConfigData = initConfigData(kind = rule,
              name = configRule.key.toLowerAscii, ruleType = ruleType,
              forceFixCommand = forceFixCommand)
          if newRule.name == "not":
            newRule.negation = true
            configRule.next
            newRule.name = configRule.key.toLowerAscii
          for index, rule in rulesList:
            if rule.name == newRule.name:
              newRule.index = index
              break
          if newRule.index == -1:
            abortProgram(message = "No rule named '" & newRule.name &
                "' available, line: " & $lineNumber & ".")
          while true:
            configRule.next()
            if configRule.kind == cmdEnd:
              break
            newRule.options.add(y = configRule.key)
          try:
            if not validateOptions(rule = rulesList[newRule.index],
                options = newRule.options):
              abortProgram(message = "Invalid options for rule '" &
                  newRule.name & "', line: " & $lineNumber & ".")
          except KeyError:
            abortProgram(message = "Can't validate the rule's parameters, line: " &
                $lineNumber & ". Reason: ", e = getCurrentException())
          result.rules.add(y = newRule)
          message(text = "Added the" & (if result.rules[
              ^1].negation: " negation " else: " ") & $result.rules[
              ^1].ruleType & " rule '" & result.rules[^1].name &
              "' with options: '" & result.rules[^1].options.join(sep = ", ") &
              "' to the list of rules to check.", level = lvlDebug)
      sections = -1
    except IOError:
      abortProgram(message = "The specified configuration file '" & configFile & "' doesn't exist.")
