#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify it
#### under the terms of the GNU General Public License as published the Free
#### Software Foundation, either version 3 of the License, or (at your option)
#### any later version.
####
#### This program is distributed in the hope that it will be useful, but WITHOUT
#### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#### FITNESS FOR A PARTICULAR PURPOSE.See the GNU General Public License for
#### more details.
####
#### You should have received a copy of the GNU General Public License along
#### with this program.If not, see<http:####www.gnu.org/licenses/.>
require "./arg-parsing"

module RemiLib::Args
  # Represents a situation where a `CommandParser` is attempts to parse an empty
  # set of arguments.
  class NoCommandError < ArgumentError
  end

  # Represents a situation where a `CommandParser` encounters an unknown
  # command.
  class UnknownCommandError < ArgumentError
  end

  # A `CommandParser` is a way to associate command names (specified as the
  # first argument on the command line) with a function.  A common use case for
  # this is a command line program that has multiple "modes", and each mode has
  # its own `ArgParser` instance.
  #
  # Similar to the `ArgParser` class, the `CommandParser` will automatically
  # handle "help" and "version" commands.  This behavior can be customized.
  class CommandParser
    # A function that is called for a command.
    alias CmdHandler = Proc(String, Array(String), Nil)

    # This is used when a help command (e.g. `./myprogram help`) is found on the
    # command line.
    alias CmdHelpPrinterFunc = Proc(CommandParser, Nil)

    # This is used when a version command (e.g. `./myprogram version`) is found
    # on the command line.
    alias CmdVerPrinterFunc = Proc(CommandParser, Nil)

    @commands : Hash(String, CmdHandler) = {} of String => CmdHandler
    @commandHelps : Hash(String, String?) = {} of String => String?

    # When `true`, then command names are case-insensitive during parsing.  Note
    # that they are still case sensitive when referencing them in code.
    property? caseInsensitive : Bool = false

    # This will be called when a help command (e.g. `./myprogram help`) is
    # found on the command line.
    property helpPrinter : CmdHelpPrinterFunc = ->CommandParser.defaultHelpPrinter(CommandParser)

    # This will be called when a version command (e.g. `./myprogram version`) is
    # found on the command line.
    property verPrinter : CmdVerPrinterFunc  = ->CommandParser.defaultVerPrinter(CommandParser)

    # The name of the program using this parser.  Used when a version command is
    # found on the command line.
    property progName : String

    # The version of the program using this parser.  Used when a version command
    # is found on the command line.
    property progVersion : String

    # When non-nil, this will be used for the "Usage:" line instead of
    # the generated one when a help command is called.
    property usageLine : String? = nil

    # A string that is to be printed after the "Usage:" line but before the list
    # of arguments when a help command is found on the command line.  Two
    # newlines will appear between this text and the "Commands:" line.
    property preHelpText : String = ""

    # A string that is to be printed after the list of arguments when a help
    # command is found on the command line.  A newline will be both prepended
    # and appended automatically.
    property postHelpText : String = ""

    # A string that is to be printed before version information when a version
    # command is found on the command line.  A newline will be appended
    # automatically.
    property preVerText : String = ""

    # A string that is to be printed after the version information when a
    # version command is found on the command line.  A newline will be appended
    # automatically.
    property postVerText : String = ""

    # When `true`, `#parse` will call `exit(0)` after printing help when a help
    # command is found on the command line.
    property? quitOnHelp : Bool = true

    # When `true`, `#parse` will call `exit(0)` after printing version
    # information when a version command is found on the command line.
    property? quitOnVer : Bool = true

    # When `true`, the help command will be processed.  Otherwise it is ignored.
    property? detectHelp : Bool = true

    # When `true`, the version command will be processed.  Otherwise it is
    # ignored.
    property? detectVer : Bool = true

    # The name of the binary using this `CommandParser`.  This is used when
    # printing the default `#usageLine`.
    property progBinName : String

    # Creates a new `CommandParser`.
    def initialize(@progName : String, @progVersion : String,
                   *, @helpName : String = "help", @verName : String = "version",
                   @progBinName : String = PROGRAM_NAME)
      add("help", ->doHelp(String, Array(String)), help: "Display this help.")
      add("version", ->doVersion(String, Array(String)), help: "Display version information.")
    end

    # Returns the handler function associated with a command.  If the command
    # does not exist, this will raise a `KeyError`.
    def [](cmd : String) : CmdHandler
      @commands[cmd]
    end

    # Returns the handler function associated with a command.  If the command
    # does not exist, this returns `nil`.
    def []?(cmd : String) : CmdHandler?
      @commands[cmd]?
    end

    # Associates a `CmdHandler` function with a name, thereby adding a new
    # command to this instance.  This will overwrite any commands with the same
    # name.  If *help* is provided, then it will be saved as the help text
    # associated with this command.
    #
    # Note that *cmd* is added in a case-sensitive way internally, regardless of
    # the value of `#caseInsensitive?`.
    def add(cmd : String, fn : CmdHandler, *, help : String? = nil) : Nil
      @commands[cmd] = fn
      @commandHelps[cmd] = help
    end

    # Returns all defined command names.
    #
    # Note that the returned names are handled in a case-sensitive way
    # internally, regardless of the value of `#caseInsensitive?`.
    def names : Array(String)
      @commands.keys
    end

    # Looks up the help string, if any, associated with *cmd*.
    #
    # Note that *cmd* is added in a case-sensitive way internally, regardless of
    # the value of `#caseInsensitive?`.
    def help(cmd : String) : String?
      @commandHelps[cmd]
    end

    # Parses the default set of arguments, as stored in the global `ARGV`.  The
    # command name must appear first, while all remaining arguments are passed
    # verbatim to the associated `CmdHandler` function.
    #
    # If no command is provided, then a `NoCommandError` is raised.  If the
    # command is not known, then an `UnknownCommandError` is raised.
    def parse : Nil
      parse(ARGV)
    end

    # Parses the given set of arguments.  The command name must appear first,
    # while all remaining arguments are passed verbatim to the associated
    # `CmdHandler` function.
    #
    # If no command is provided, then a `NoCommandError` is raised.  If the
    # command is not known, then an `UnknownCommandError` is raised.
    def parse(theseArgs : Array(String)) : Nil
      raise NoCommandError.new("No command given") if theseArgs.empty?

      cmdName : String = if @caseInsensitive
                           theseArgs[0].downcase
                         else
                           theseArgs[0]
                         end
      hasKey : Bool = if @caseInsensitive
                        @commands.keys.any? do |k|
                          k.downcase == cmdName
                        end
                      else
                        @commands.has_key?(cmdName)
                      end

      unless hasKey
        raise UnknownCommandError.new("Unknown command: #{cmdName}")
      end

      @commands[cmdName].call(cmdName, theseArgs[1..])
    end

    private def doHelp(cmdName : String, argv : Array(String)) : Nil
      @helpPrinter.call(self)
    end

    private def doVersion(cmdName : String, argv : Array(String)) : Nil
      @verPrinter.call(self)
    end

    # The default printer for the help command.
    #
    # The default output looks something like this (depending on the values of
    # `#preHelpText` and `#postHelpText`:
    #
    # ```
    # Usage: /path/to/binary <command> [options]
    #
    # Commands:
    #   * help    : Display this help.
    #   * version : Display version information.
    # ```
    def self.defaultHelpPrinter(parser : CommandParser) : Nil
      names : Array(String) = parser.names
      raise "No help items!" if names.empty?

      longestLong : Int32 = names.max_by(&.size).size
      finalStr : String = String.build do |str|
        if parser.usageLine.nil?
          str << "Usage: #{parser.progBinName} <command> [options]"
        else
          str << parser.usageLine
        end

        unless parser.preHelpText.empty?
          str << '\n' << parser.preHelpText
        end

        str << "\n\nCommands:\n"
        parser.names.each do |key|
          str << "  * #{key}"
          str << (" " * (longestLong - key.size))
          str << " : "
          parser.help(key).try { |help| RemiLib.buildWrapped(help, str, indent: longestLong + 4 + 3) }
          str << '\n'
        end

        unless parser.postHelpText.empty?
          str << '\n' << parser.postHelpText << '\n'
        end
      end

      STDOUT << finalStr
    end

    # The default printer for the version command The default output looks
    # something like this (depending on the values of `#preVerText` and
    # `#postVerText`:
    #
    # ```
    # My Program v0.1.0
    # ```
    def self.defaultVerPrinter(parser : CommandParser) : Nil
      finalStr : String = String.build do |str|
        str << parser.preVerText << '\n' if parser.preVerText != ""
        str << "#{parser.progName} v#{parser.progVersion}\n"
        str << parser.postVerText << '\n' if parser.postVerText != ""
      end

      STDOUT << finalStr
    end
  end
end
