// Command line option parsing (Ruby `Asciidoctor::Cli::Options`, built on
// Ruby's OptionParser). `parse_args` is pure: it does not touch the file
// system or the streams; `main` checks the input files and runs the command.

///|
let usage : String =
  #|Usage: asciidoctor [OPTION]... FILE...
  #|Convert the AsciiDoc input FILE(s) to the backend output format (e.g., HTML 5, DocBook 5, etc.)
  #|Unless specified otherwise, the output is written to a file whose name is derived from the input file.
  #|Application log messages are printed to STDERR.
  #|Example: asciidoctor input.adoc
  #|
  #|    -b, --backend BACKEND            set backend output format: [html5, xhtml5, docbook5, manpage] (default: html5)
  #|                                     additional backends are supported via extended converters (e.g., pdf, epub3)
  #|    -d, --doctype DOCTYPE            document type to use when converting document: [article, book, manpage, inline] (default: article)
  #|    -e, --embedded                   suppress enclosing document structure and output an embedded document (default: false)
  #|    -o, --out-file FILE              output file (default: based on path of input file); use - to output to STDOUT
  #|        --safe                       set safe mode level to safe (default: unsafe)
  #|                                     enables include directives, but prevents access to ancestor paths of source file
  #|                                     provided for compatibility with the asciidoc command
  #|    -S, --safe-mode SAFE_MODE        set safe mode level explicitly: [unsafe, safe, server, secure] (default: unsafe)
  #|                                     disables potentially dangerous macros in source files, such as include::[]
  #|        --sourcemap                  add source location information to each parsed block (default: false)
  #|    -s, --no-header-footer           suppress enclosing document structure and output an embedded document (default: false)
  #|    -n, --section-numbers            auto-number section titles in the HTML backend; disabled by default
  #|        --eruby ERUBY                specify eRuby implementation to use when rendering custom ERB templates: [erb, erubi, erubis] (default: erb)
  #|    -a, --attribute name[=value]     a document attribute to set in the form of name, name!, or name=value pair
  #|                                     that takes precedence over the same attribute defined in the source document
  #|                                     unless either the name or value ends in @ (i.e., name@=value or name=value@)
  #|                                     may be specified more than once
  #|    -T, --template-dir DIR           a directory containing custom converter templates that override the built-in converter (requires tilt gem)
  #|                                     may be specified more than once
  #|    -E, --template-engine NAME       template engine to use for the custom converter templates (loads gem on demand)
  #|    -B, --base-dir DIR               base directory containing the document and resources (default: directory of source file)
  #|    -R, --source-dir DIR             source root directory (used for calculating path in destination directory)
  #|    -D, --destination-dir DIR        destination output directory (default: directory of source file)
  #|    -I, --load-path DIRECTORY        add a directory to the $LOAD_PATH
  #|                                     may be specified more than once
  #|    -r, --require LIBRARY            require the specified library before executing the processor (using require)
  #|                                     may be specified more than once
  #|        --log-level LEVEL            set minimum level of log messages that get logged: [DEBUG, INFO, WARN, ERROR, FATAL] (default: WARN)
  #|        --failure-level LEVEL        set minimum log level that yields a non-zero exit code: [INFO, WARN, ERROR, FATAL] (default: FATAL)
  #|    -q, --quiet                      silence application log messages and script warnings (default: false)
  #|        --trace                      include backtrace information when reporting errors (default: false)
  #|    -v, --verbose                    show all application log messages, including DEBUG and INFO levels (default: false)
  #|    -w, --warnings                   turn on script warnings (default: false)
  #|    -t, --timings                    print timings report (default: false)
  #|    -h, --help [TOPIC]               print a help message
  #|                                     show this usage if TOPIC is not specified or recognized
  #|                                     show an overview of the AsciiDoc syntax if TOPIC is syntax
  #|                                     dump the Asciidoctor man page (in troff/groff format) if TOPIC is manpage
  #|    -V, --version                    display the version and runtime environment (or -v if no other flags or arguments)
  #|

///|
/// The version report (Ruby `Options#print_version`), naming this port as the
/// runtime environment.
fn version_text() -> String {
  "Asciidoctor \{@core.VERSION} [https://asciidoctor.org]\nRuntime Environment (MoonBit port, \{target_name()}) (lc:UTF-8 fs:UTF-8 in:UTF-8 ex:UTF-8)\n"
}

///|
#cfg(target="native")
fn target_name() -> String {
  "native"
}

///|
#cfg(not(target="native"))
fn target_name() -> String {
  "wasm"
}

///|
/// The options of a conversion run (Ruby `Cli::Options` hash).
priv struct CliOptions {
  /// Document attributes in insertion order (a Ruby Hash: a repeated name
  /// keeps its first position and takes the last value). `-b` and `-d` are
  /// stored here as `backend` and `doctype`, like Ruby.
  attributes : Array[(String, String)]
  input_files : Array[String]
  mut output_file : String?
  mut safe : Int
  mut standalone : Bool
  template_dirs : Array[String]
  /// 0 (`-q`), 1 (default) or 2 (`-v`)
  mut verbose : Int
  load_paths : Array[String]
  requires : Array[String]
  mut base_dir : String?
  mut source_dir : String?
  mut destination_dir : String?
  mut log_level : @core.Severity?
  mut failure_level : @core.Severity
  mut sourcemap : Bool
  mut trace : Bool
  mut timings : Bool
  /// Warnings to print on stderr before converting.
  notices : Array[String]
}

///|
fn CliOptions::new() -> CliOptions {
  {
    attributes: [],
    input_files: [],
    output_file: None,
    safe: @core.SAFE_UNSAFE,
    standalone: true,
    template_dirs: [],
    verbose: 1,
    load_paths: [],
    requires: [],
    base_dir: None,
    source_dir: None,
    destination_dir: None,
    log_level: None,
    failure_level: Fatal,
    sourcemap: false,
    trace: false,
    timings: false,
    notices: [],
  }
}

///|
fn CliOptions::set_attribute(
  self : CliOptions,
  name : String,
  value : String,
) -> Unit {
  match self.attributes.search_by(entry => entry.0 == name) {
    Some(i) => self.attributes[i] = (name, value)
    None => self.attributes.push((name, value))
  }
}

///|
/// What the command line asks for.
priv enum CliCommand {
  /// Convert the input files (`-` is standard input).
  Convert(CliOptions)
  /// `-h [TOPIC]`: print the usage (or the topic) on stdout.
  Help(String?)
  /// `-V`, or `-v` without input files: print the version on stdout.
  Version
  /// No input files: print the usage on stderr and fail.
  Usage
}

///|
/// An invalid command line. The message is the line Ruby prints on stderr
/// (followed by the usage on stdout).
priv suberror CliError {
  CliError(String)
}

///|
priv enum ArgKind {
  NoArg
  Required
  Optional
}

///|
priv struct Switch {
  short : Char?
  long : String
  arg : ArgKind
  /// The accepted values of an enumerated argument (empty: any value).
  values : Array[String]
}

///|
let log_levels : Array[String] = [
  "debug", "DEBUG", "info", "INFO", "warning", "WARNING", "error", "ERROR", "fatal",
  "FATAL",
]

///|
/// The switches in Ruby's definition order.
let switches : Array[Switch] = [
  { short: Some('b'), long: "backend", arg: Required, values: [], },
  {
    short: Some('d'),
    long: "doctype",
    arg: Required,
    values: ["article", "book", "manpage", "inline"],
  },
  { short: Some('e'), long: "embedded", arg: NoArg, values: [], },
  { short: Some('o'), long: "out-file", arg: Required, values: [], },
  { short: None, long: "safe", arg: NoArg, values: [], },
  {
    short: Some('S'),
    long: "safe-mode",
    arg: Required,
    values: ["unsafe", "safe", "server", "secure"],
  },
  { short: None, long: "sourcemap", arg: NoArg, values: [], },
  { short: Some('s'), long: "no-header-footer", arg: NoArg, values: [], },
  { short: Some('n'), long: "section-numbers", arg: NoArg, values: [], },
  {
    short: None,
    long: "eruby",
    arg: Required,
    values: ["erb", "erubi", "erubis"],
  },
  { short: Some('a'), long: "attribute", arg: Required, values: [], },
  { short: Some('T'), long: "template-dir", arg: Required, values: [], },
  { short: Some('E'), long: "template-engine", arg: Required, values: [], },
  { short: Some('B'), long: "base-dir", arg: Required, values: [], },
  { short: Some('R'), long: "source-dir", arg: Required, values: [], },
  { short: Some('D'), long: "destination-dir", arg: Required, values: [], },
  { short: Some('I'), long: "load-path", arg: Required, values: [], },
  { short: Some('r'), long: "require", arg: Required, values: [], },
  { short: None, long: "log-level", arg: Required, values: log_levels, },
  {
    short: None,
    long: "failure-level",
    arg: Required,
    values: log_levels[2:].to_owned(),
  },
  { short: Some('q'), long: "quiet", arg: NoArg, values: [], },
  { short: None, long: "trace", arg: NoArg, values: [], },
  { short: Some('v'), long: "verbose", arg: NoArg, values: [], },
  { short: Some('w'), long: "warnings", arg: NoArg, values: [], },
  { short: Some('t'), long: "timings", arg: NoArg, values: [], },
  { short: Some('h'), long: "help", arg: Optional, values: [], },
  { short: Some('V'), long: "version", arg: NoArg, values: [], },
]

///|
priv enum Completion {
  NoMatch
  Match(String)
  Ambiguous
}

///|
/// OptionParser's completion: the candidates starting with `key` (sorted by
/// length) must all extend the shortest one, which is chosen.
fn complete(
  candidates : Array[String],
  key : String,
  icase~ : Bool,
) -> Completion {
  let fold = fn(s : String) { if icase { @rb.downcase(s) } else { s } }
  let k = fold(key)
  let matches = candidates.filter(c => abbreviates(k, fold(c)))
  guard !matches.is_empty() else { return NoMatch }
  matches.sort_by_key(c => c.length())
  let canon = matches[0]
  for other in matches[1:] {
    guard other.has_prefix(canon) else { return Ambiguous }
  }
  Match(canon)
}

///|
fn is_word_char(c : UInt16) -> Bool {
  (c >= 'a' && c <= 'z') ||
  (c >= 'A' && c <= 'Z') ||
  (c >= '0' && c <= '9') ||
  c == '_'
}

///|
/// Whether `key` abbreviates `name`: OptionParser matches `/\A#{key}/` with
/// `\w*` after each word of the key, so every word may be shortened
/// (`--b-d` is `--base-dir`).
fn abbreviates(key : String, name : String) -> Bool {
  let mut j = 0
  for i in 0.. (Switch?, Bool) {
  match switches.search_by(sw => sw.long == name) {
    Some(i) => return (Some(switches[i]), false)
    None => ()
  }
  match complete(switches.map(sw => sw.long), name, icase~) {
    Match(l) =>
      (switches.search_by(sw => sw.long == l).map(i => switches[i]), false)
    NoMatch => (None, false)
    Ambiguous => (None, true)
  }
}

///|
fn severity_of(level : String) -> @core.Severity {
  match @rb.upcase(level) {
    "DEBUG" => Debug
    "INFO" => Info
    "WARNING" => Warn
    "ERROR" => Error
    _ => Fatal
  }
}

///|
/// Parses the command line arguments (without the program name), like Ruby's
/// `Cli::Options.parse!`: options may follow the input files, long options may
/// be abbreviated and written `--name=value`, short options may be clustered
/// (`-sn`) or take an attached value (`-bdocbook5`), and `--` ends the options.
/// Input files are not checked here.
fn parse_args(args : Array[String]) -> CliCommand raise CliError {
  let opts = CliOptions::new()
  let inputs = []
  // pending arguments, processed front to back (short clusters are pushed back)
  let queue = args.rev()
  while queue.pop() is Some(arg) {
    if arg == "--" {
      while queue.pop() is Some(a) {
        inputs.push(a)
      }
      break
    }
    if arg.has_prefix("--") {
      let (name0, eq, value) = @rb.partition(@rb.from(arg, 2), "=")
      let name = name0.replace_all(old="_", new="-")
      let (sw, ambiguous) = find_long(name, icase=true)
      guard sw is Some(sw) else {
        if ambiguous {
          raise CliError("asciidoctor: ambiguous option: \{arg}")
        }
        raise CliError("asciidoctor: invalid option: \{arg}")
      }
      let attached = if eq == "" { None } else { Some(value) }
      match apply(opts, sw, arg, attached, queue) {
        Some(command) => return command
        None => ()
      }
    } else if arg.has_prefix("-") && arg.length() > 1 {
      let c = arg[1].to_int().unsafe_to_char()
      let rest = @rb.from(arg, 2)
      let sw = match switches.search_by(sw => sw.short == Some(c)) {
        Some(i) => switches[i]
        None => {
          // no such short option: OptionParser completes it as a long one
          let (sw, ambiguous) = find_long(c.to_string(), icase=false)
          guard sw is Some(sw) else {
            if ambiguous {
              raise CliError("asciidoctor: ambiguous option: \{arg}")
            }
            raise CliError("asciidoctor: invalid option: \{arg}")
          }
          sw
        }
      }
      match sw.arg {
        NoArg if rest.has_prefix("=") =>
          raise CliError("asciidoctor: needless argument: \{arg}")
        NoArg => {
          if rest != "" {
            // the rest of the cluster holds more short options
            queue.push("-\{rest}")
          }
          match apply(opts, sw, "-\{c}", None, queue) {
            Some(command) => return command
            None => ()
          }
        }
        _ => {
          let attached = if rest == "" { None } else { Some(rest) }
          let label = if rest == "" { "-\{c}" } else { arg }
          match apply(opts, sw, label, attached, queue) {
            Some(command) => return command
            None => ()
          }
        }
      }
    } else {
      inputs.push(arg)
    }
  }
  if inputs.is_empty() {
    return if opts.verbose == 2 { Version } else { Usage }
  }
  if inputs is ["-"] {
    opts.input_files.push("-")
  } else {
    for file in inputs {
      if file.has_prefix("-") {
        // warn, but don't panic; we may have enough to proceed
        let unparsed = inputs.join("', '")
        opts.notices.push(
          "asciidoctor: WARNING: extra arguments detected (unparsed arguments: '\{unparsed}') or incorrect usage of stdin",
        )
      } else {
        opts.input_files.push(file)
      }
    }
  }
  Convert(opts)
}

///|
/// Applies a switch. `label` is the option as written (with the value when it
/// was attached); a required value that is not attached is taken from the
/// queue. Returns a command when the switch ends the parsing (`-h`, `-V`).
fn apply(
  opts : CliOptions,
  sw : Switch,
  label : String,
  attached : String?,
  queue : Array[String],
) -> CliCommand? raise CliError {
  let value = match sw.arg {
    NoArg => {
      if attached is Some(_) {
        raise CliError("asciidoctor: needless argument: \{label}")
      }
      ""
    }
    Optional =>
      match attached {
        Some(v) => v
        None =>
          match queue.last() {
            Some(next) if !next.has_prefix("-") => queue.unsafe_pop()
            _ => ""
          }
      }
    Required => {
      let (v, shown) = match attached {
        Some(v) => (v, label)
        None =>
          match queue.pop() {
            Some(v) => (v, "\{label} \{v}")
            None =>
              raise CliError("asciidoctor: option missing argument: \{label}")
          }
      }
      if sw.values.is_empty() {
        v
      } else {
        match complete(sw.values, v, icase=false) {
          Match(m) => m
          NoMatch => raise CliError("asciidoctor: invalid argument: \{shown}")
          Ambiguous =>
            raise CliError("asciidoctor: ambiguous argument: \{shown}")
        }
      }
    }
  }
  match sw.long {
    "backend" => opts.set_attribute("backend", value)
    "doctype" => opts.set_attribute("doctype", value)
    "embedded" | "no-header-footer" => opts.standalone = false
    "out-file" => opts.output_file = Some(value)
    "safe" => opts.safe = @core.SAFE_SAFE
    "safe-mode" =>
      opts.safe = @core.safe_mode_value_for_name(value).unwrap_or(
        @core.SAFE_SECURE,
      )
    "sourcemap" => opts.sourcemap = true
    "section-numbers" => opts.set_attribute("sectnums", "")
    // eRuby, template engines and Ruby warnings only matter to Ruby
    "eruby" | "template-engine" | "warnings" => ()
    "attribute" => {
      let attr = @rb.rstrip(value)
      if attr != "" && attr != "=" {
        let (name, _, val) = @rb.partition(attr, "=")
        opts.set_attribute(name, val)
      }
    }
    "template-dir" => opts.template_dirs.push(value)
    "base-dir" => opts.base_dir = Some(value)
    "source-dir" => opts.source_dir = Some(value)
    "destination-dir" => opts.destination_dir = Some(value)
    "load-path" => opts.load_paths.push_iter(@rb.split(value, ":").iter())
    "require" => opts.requires.push(value)
    "log-level" => opts.log_level = Some(severity_of(value))
    "failure-level" => opts.failure_level = severity_of(value)
    "quiet" => opts.verbose = 0
    "trace" => opts.trace = true
    "verbose" => opts.verbose = 2
    "timings" => opts.timings = true
    "help" => return Some(Help(if value == "" { None } else { Some(value) }))
    "version" => return Some(Version)
    _ => ()
  }
  None
}