///|
/// Error classes produced while parsing CLI flags.
///
/// The CLI distinguishes between usage mistakes made by the caller and support
/// errors where the syntax is valid but the requested feature/value is not
/// recognized. The distinction maps directly to different exit codes.
priv enum CliErrorKind {
  Usage
  Support
}

///|
/// Rich parse error used internally by the CLI layer before it is converted
/// into a `CliOutcome`.
priv struct CliError {
  kind : CliErrorKind
  message : String
}

///|
/// Three-state color selection used by `--color`.
///
/// `Auto` defers the decision to environment inspection so the parser can keep
/// rendering policy separate from string formatting logic.
priv enum CliColorOption {
  Auto
  Always
  Never
}

///|
/// Fully normalized command-line configuration consumed by `@driver.run_cli`.
///
/// All stringly CLI arguments are converted here into typed checker and
/// formatter options so the driver only executes already-validated requests.
priv struct CliConfig {
  spec : @checker.CheckSpec
  format : @formatter.OutputFormat
  format_options : @formatter.FormatOptions
  files : Array[String]
  action : @driver.CliAction
}

///|
/// Parses raw CLI arguments into a typed configuration.
///
/// The implementation performs a single left-to-right scan over argv. Long
/// options and short-option bundles share helpers so the resulting semantics
/// stay consistent regardless of spellings such as `--format=json` or `-fjson`.
fn parse_cli_args(args : Array[String]) -> Result[CliConfig, CliError] {
  // Start from the library defaults so CLI flags only need to describe
  // differences from the default checking behavior.
  let mut spec = @checker.CheckSpec::default()
  let mut format = @formatter.OutputFormat::default()
  // Once the user picked a format explicitly, later aliases must not silently
  // overwrite it.
  let mut format_locked = false
  let mut color = CliColorOption::Auto
  let mut wiki_link_count = 3
  let files : Array[String] = []
  let mut action = @driver.CliAction::Run
  let mut i = 0
  while i < args.length() {
    let arg = args[i]
    if arg == "--" {
      // `--` terminates option parsing. Everything after it is treated
      // literally, even if it starts with `-`.
      let mut j = i + 1
      while j < args.length() {
        files.push(args[j])
        j += 1
      }
      break
    }
    if arg == "-" || !arg.has_prefix("-") {
      // A bare `-` means stdin, and any non-flag token is a positional file.
      files.push(arg)
      i += 1
      continue
    }
    if arg.has_prefix("--") {
      // Long options either carry `--flag=value` inline or consume the next
      // argv element via `read_required_value`.
      match split_flag_value(arg) {
        ("--format", value) =>
          match read_required_value(value, args, i, "--format") {
            Ok((raw, next)) => {
              i = next
              if !format_locked {
                if @formatter.parse_output_format(raw) is Some(parsed) {
                  format = parsed
                  format_locked = true
                } else {
                  return Err(unknown_format_error(raw))
                }
              }
            }
            Err(error) => return Err(error)
          }
        ("--shell", value) =>
          match read_required_value(value, args, i, "--shell") {
            Ok((shell, next)) => {
              i = next
              if parse_shell(shell) is Some(parsed) {
                spec = spec.with_shell_override(Some(parsed))
              } else {
                return Err(support_error("Unknown shell: " + shell))
              }
            }
            Err(error) => return Err(error)
          }
        ("--severity", value) =>
          match read_required_value(value, args, i, "--severity") {
            Ok((severity, next)) => {
              i = next
              if parse_severity(severity) is Some(parsed) {
                spec = spec.with_min_severity(parsed)
              } else {
                return Err(unknown_severity_error())
              }
            }
            Err(error) => return Err(error)
          }
        ("--include", value) =>
          match read_required_value(value, args, i, "--include") {
            Ok((codes, next)) => {
              i = next
              match parse_code_list(codes) {
                Ok(parsed) =>
                  spec = spec.with_include_codes(
                    append_ints(spec.include_codes, parsed),
                  )
                Err(error) => return Err(error)
              }
            }
            Err(error) => return Err(error)
          }
        ("--exclude", value) =>
          match read_required_value(value, args, i, "--exclude") {
            Ok((codes, next)) => {
              i = next
              match parse_code_list(codes) {
                Ok(parsed) =>
                  spec = spec.with_exclude_codes(
                    append_ints(spec.exclude_codes, parsed),
                  )
                Err(error) => return Err(error)
              }
            }
            Err(error) => return Err(error)
          }
        ("--enable", value) =>
          match read_required_value(value, args, i, "--enable") {
            Ok((names, next)) => {
              i = next
              spec = spec.with_optional_checks(
                append_optional_checks(spec.optional_checks, split_csv(names)),
              )
            }
            Err(error) => return Err(error)
          }
        ("--source-path", value) =>
          match read_required_value(value, args, i, "--source-path") {
            Ok((path, next)) => {
              i = next
              for entry in split_source_paths(path) {
                spec = spec.add_source_path(entry)
              }
            }
            Err(error) => return Err(error)
          }
        ("--rcfile", value) =>
          match read_required_value(value, args, i, "--rcfile") {
            Ok((path, next)) => {
              i = next
              spec = spec.with_config_path_override(Some(path))
            }
            Err(error) => return Err(error)
          }
        ("--color", value) =>
          match parse_color_option(value) {
            Ok(parsed) => color = parsed
            Err(error) => return Err(error)
          }
        ("--wiki-link-count", value) =>
          match read_required_value(value, args, i, "--wiki-link-count") {
            Ok((count, next)) => {
              i = next
              match parse_nonnegative_int(count) {
                Ok(parsed) => wiki_link_count = parsed
                Err(error) => return Err(error)
              }
            }
            Err(error) => return Err(error)
          }
        ("--norc", _) => spec = spec.with_ignore_rc(true)
        ("--check-sourced", _) => spec = spec.with_check_sourced(true)
        ("--external-sources", _) => spec = spec.with_external_sources(true)
        ("--extended-analysis", value) =>
          match read_required_value(value, args, i, "--extended-analysis") {
            Ok((enabled, next)) => {
              i = next
              if parse_bool_value(enabled) is Some(parsed) {
                spec = spec.with_extended_analysis(parsed)
              } else {
                return Err(
                  usage_error(
                    "Invalid boolean, expected true/false: " + enabled,
                  ),
                )
              }
            }
            Err(error) => return Err(error)
          }
        ("--list-optional", _) => action = choose_action(action, ListOptional)
        ("--version", _) => action = choose_action(action, ShowVersion)
        ("--help", _) => action = choose_action(action, ShowHelp)
        (flag, _) => return Err(usage_error("Unknown option: " + flag))
      }
      // Most long options consume at most one argv slot beyond the current one,
      // so the outer loop can safely advance by one after updating `i`.
      i += 1
      continue
    }
    // Single-dash options may be bundled (`-axV`) or may end with a value
    // carrier (`-fjson`, `-W2`, `-s bash`).
    match parse_short_options(arg, args, i, spec, format_locked) {
      Ok((next, parsed)) => {
        i = next
        spec = parsed.0
        if parsed.1 is Some(parsed_format) {
          if !format_locked {
            format = parsed_format
            format_locked = true
          }
        }
        if parsed.2 is Some(parsed_color) {
          color = parsed_color
        }
        if parsed.3 is Some(count) {
          wiki_link_count = count
        }
        action = choose_action(action, parsed.4)
      }
      Err(error) => return Err(error)
    }
    i += 1
  }
  if files.length() == 0 && action is Run {
    // Match ShellCheck's CLI behavior: a plain run with no files reads stdin.
    files.push("-")
  }
  Ok({
    spec,
    format,
    // Color and wiki-link settings are formatter concerns, so they are
    // packaged separately from the checker spec.
    format_options: @formatter.FormatOptions::default()
    .with_wiki_link_count(wiki_link_count)
    .with_enable_color(resolve_color(color)),
    files,
    action,
  })
}

///|
/// Parses a short-option cluster such as `-ax`, `-fjson`, or `-S error`.
///
/// Flags that require a value consume either the remainder of the current token
/// or the next argv element and then return immediately, because any trailing
/// characters belong to that value rather than to more flags.
fn parse_short_options(
  arg : String,
  args : Array[String],
  index : Int32,
  current_spec : @checker.CheckSpec,
  ignore_format : Bool,
) -> Result[
  (
    Int32,
    (
      @checker.CheckSpec,
      @formatter.OutputFormat?,
      CliColorOption?,
      Int32?,
      @driver.CliAction,
    ),
  ),
  CliError,
] {
  let mut spec = current_spec
  let mut format : @formatter.OutputFormat? = None
  let mut color : CliColorOption? = None
  let mut wiki_link_count : Int32? = None
  let mut action = @driver.CliAction::Run
  let flags = arg.view(start_offset=1).to_owned()
  guard flags != "" else { return Err(usage_error("Unknown option: " + arg)) }
  let view = flags[:]
  let mut j = 0
  let mut next = index
  while j < view.length() {
    let flag = view[j]
    let rest = flags.view(start_offset=j + 1).to_owned()
    match flag {
      'a' => spec = spec.with_check_sourced(true)
      'x' => spec = spec.with_external_sources(true)
      'V' => action = ShowVersion
      'C' => {
        // `-Cauto` and `-C auto` both funnel through the same parser. A bare
        // `-C` means "force color on", which matches `--color` without value.
        match parse_color_option(if rest == "" { None } else { Some(rest) }) {
          Ok(parsed) => color = Some(parsed)
          Err(error) => return Err(error)
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      'f' => {
        // Options with values stop bundle processing because the remaining text
        // belongs to the value, not to another single-character flag.
        let (raw, consumed) = match read_short_value(rest, args, index, "-f") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        if !ignore_format {
          if @formatter.parse_output_format(raw) is Some(parsed) {
            format = Some(parsed)
          } else {
            return Err(unknown_format_error(raw))
          }
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      's' => {
        let (raw, consumed) = match read_short_value(rest, args, index, "-s") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        if parse_shell(raw) is Some(parsed) {
          spec = spec.with_shell_override(Some(parsed))
        } else {
          return Err(support_error("Unknown shell: " + raw))
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      'S' => {
        let (raw, consumed) = match read_short_value(rest, args, index, "-S") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        if parse_severity(raw) is Some(parsed) {
          spec = spec.with_min_severity(parsed)
        } else {
          return Err(unknown_severity_error())
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      'i' => {
        let (raw, consumed) = match read_short_value(rest, args, index, "-i") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        match parse_code_list(raw) {
          Ok(parsed) =>
            spec = spec.with_include_codes(
              append_ints(spec.include_codes, parsed),
            )
          Err(error) => return Err(error)
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      'e' => {
        let (raw, consumed) = match read_short_value(rest, args, index, "-e") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        match parse_code_list(raw) {
          Ok(parsed) =>
            spec = spec.with_exclude_codes(
              append_ints(spec.exclude_codes, parsed),
            )
          Err(error) => return Err(error)
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      'o' => {
        let (raw, consumed) = match read_short_value(rest, args, index, "-o") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        spec = spec.with_optional_checks(
          append_optional_checks(spec.optional_checks, split_csv(raw)),
        )
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      'P' => {
        let (raw, consumed) = match read_short_value(rest, args, index, "-P") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        for entry in split_source_paths(raw) {
          spec = spec.add_source_path(entry)
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      'W' => {
        let (raw, consumed) = match read_short_value(rest, args, index, "-W") {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        next = consumed
        match parse_nonnegative_int(raw) {
          Ok(parsed) => wiki_link_count = Some(parsed)
          Err(error) => return Err(error)
        }
        return Ok((next, (spec, format, color, wiki_link_count, action)))
      }
      _ => return Err(usage_error("Unknown option: -" + flag.to_string()))
    }
    j += 1
  }
  Ok((next, (spec, format, color, wiki_link_count, action)))
}

///|
/// Keeps the first requested non-run action.
///
/// This makes `--help --version` deterministic: the first terminal action wins
/// and later ones are ignored instead of fighting over output.
fn choose_action(
  current : @driver.CliAction,
  next : @driver.CliAction,
) -> @driver.CliAction {
  match current {
    Run => next
    _ => current
  }
}

///|
/// Splits a long option into the flag name and optional inline value at the
/// first `=`.
fn split_flag_value(arg : String) -> (String, String?) {
  let view = arg[:]
  let mut i = 0
  while i < view.length() {
    if view[i] == '=' {
      return (
        arg.view(end_offset=i).to_owned(),
        Some(arg.view(start_offset=i + 1).to_owned()),
      )
    }
    i += 1
  }
  (arg, None)
}

///|
/// Reads the required value for a long option from either `--flag=value` or the
/// next argv entry.
fn read_required_value(
  inline : String?,
  args : Array[String],
  index : Int32,
  flag : String,
) -> Result[(String, Int32), CliError] {
  if inline is Some(value) {
    Ok((value, index))
  } else if index + 1 < args.length() {
    Ok((args[index + 1], index + 1))
  } else {
    Err(usage_error("Missing value for " + flag))
  }
}

///|
/// Reads the value for a short option from the remainder of the current token
/// or the next argv entry.
fn read_short_value(
  inline : String,
  args : Array[String],
  index : Int32,
  flag : String,
) -> Result[(String, Int32), CliError] {
  if inline != "" {
    return Ok((inline, index))
  }
  if index + 1 < args.length() {
    return Ok((args[index + 1], index + 1))
  }
  Err(usage_error("Missing value for " + flag))
}

///|
/// Parses `--color` / `-C` semantics.
///
/// The slightly unusual `None => Always` branch is intentional: a present
/// flag without a value means "turn color on", not "auto-detect".
fn parse_color_option(value : String?) -> Result[CliColorOption, CliError] {
  match value {
    None => Ok(Always)
    Some("auto") => Ok(Auto)
    Some("always") => Ok(Always)
    Some("never") => Ok(Never)
    Some(_) =>
      Err(
        support_error(
          "Unknown value for --color. Valid options are: auto, always, never",
        ),
      )
  }
}

///|
/// Resolves the user-facing color mode into a concrete enable/disable flag.
///
/// `Auto` follows the common CLI convention of honoring `NO_COLOR` first and
/// otherwise enabling color only for non-dumb terminals.
fn resolve_color(color : CliColorOption) -> Bool {
  match color {
    Always => true
    Never => false
    Auto =>
      if @env.get_env_var("NO_COLOR") is Some(_) {
        false
      } else if @env.get_env_var("TERM") is Some(term) {
        term != "" && term != "dumb"
      } else {
        false
      }
  }
}

///|
/// Parses a strict boolean literal used by `--extended-analysis`.
fn parse_bool_value(text : String) -> Bool? {
  match text {
    "true" => Some(true)
    "false" => Some(false)
    _ => None
  }
}

///|
/// Parses a shell dialect name accepted by the CLI.
fn parse_shell(text : String) -> @semantics.ShellDialect? {
  match text {
    "sh" => Some(Sh)
    "bash" => Some(Bash)
    "dash" => Some(Dash)
    "ksh" => Some(Ksh)
    "busybox-sh" | "busybox" => Some(BusyboxSh)
    _ => None
  }
}

///|
/// Delegates severity parsing to the checker layer so the CLI and library API
/// share one canonical severity vocabulary.
fn parse_severity(text : String) -> @checker.DiagnosticSeverity? {
  @checker.parse_diagnostic_severity(text)
}

///|
/// Parses a comma-separated list of ShellCheck codes.
///
/// Each item may optionally include the `SC` prefix, but anything that is not a
/// clean non-negative integer is rejected.
fn parse_code_list(text : String) -> Result[Array[Int32], CliError] {
  let codes : Array[Int32] = []
  for value in split_csv(text) {
    if parse_code(value) is Some(code) {
      codes.push(code)
    } else {
      return Err(usage_error("Invalid number: " + value))
    }
  }
  Ok(codes)
}

///|
/// Parses one ShellCheck code in either `1234` or `SC1234` form.
fn parse_code(text : String) -> Int32? {
  let trimmed = text.trim().to_owned()
  let digits = if trimmed.has_prefix("SC") {
    trimmed.view(start_offset=2).to_owned()
  } else {
    trimmed
  }
  if digits == "" {
    return None
  }
  let view = digits[:]
  let mut value = 0
  let mut i = 0
  while i < view.length() {
    let ch = view[i]
    if ch < '0' || ch > '9' {
      return None
    }
    // Manual accumulation avoids depending on any looser integer parser that
    // might accept signs or whitespace the CLI does not want to allow.
    value = value * 10 + (ch - '0').to_int()
    i += 1
  }
  Some(value)
}

///|
/// Parses a non-negative integer and reuses the same validation rules as code
/// parsing so diagnostics stay consistent.
fn parse_nonnegative_int(text : String) -> Result[Int32, CliError] {
  if parse_code(text) is Some(value) {
    Ok(value)
  } else {
    Err(usage_error("Invalid number: " + text))
  }
}

///|
/// Splits a comma-separated list and discards empty / whitespace-only entries.
fn split_csv(text : String) -> Array[String] {
  let values : Array[String] = []
  for value in text.split(",") {
    let trimmed = value.to_owned().trim().to_owned()
    if trimmed != "" {
      values.push(trimmed)
    }
  }
  values
}

///|
/// Splits `--source-path` values.
///
/// ShellCheck accepts both `:` and `;` separated forms, so this parser uses `;`
/// when present and otherwise falls back to `:`.
fn split_source_paths(text : String) -> Array[String] {
  let separator = if text.contains(";") { ";" } else { ":" }
  let values : Array[String] = []
  for value in text.split(separator) {
    let trimmed = value.to_owned().trim().to_owned()
    if trimmed != "" {
      values.push(trimmed)
    }
  }
  values
}

///|
/// Returns a fresh array containing the contents of `lhs` followed by `rhs`.
fn append_ints(lhs : Array[Int32], rhs : Array[Int32]) -> Array[Int32] {
  let values : Array[Int32] = []
  for value in lhs {
    values.push(value)
  }
  for value in rhs {
    values.push(value)
  }
  values
}

///|
/// Appends optional-check names while preserving existing order and uniqueness.
///
/// The special `all` token expands to every supported optional check exactly
/// once at the point where it appears.
fn append_optional_checks(
  current : Array[String],
  additions : Array[String],
) -> Array[String] {
  let values : Array[String] = []
  for value in current {
    values.push(value)
  }
  let supported = @checker.supported_optional_checks()
  for value in additions {
    if value == "all" {
      for name in supported {
        if !values.contains(name) {
          values.push(name)
        }
      }
    } else if !values.contains(value) {
      values.push(value)
    }
  }
  values
}

///|
/// Constructs a usage error that should terminate with the CLI usage exit code.
fn usage_error(message : String) -> CliError {
  { kind: Usage, message, }
}

///|
/// Constructs a support error for syntactically valid but unsupported input.
fn support_error(message : String) -> CliError {
  { kind: Support, message, }
}

///|
/// Formats the list of supported renderer names for an unknown `--format`
/// request.
fn unknown_format_error(format : String) -> CliError {
  support_error(
    "Unknown format " +
    format +
    (
      #|
      #|Supported formats:
      #|  checkstyle
      #|  diff
      #|  gcc
      #|  json
      #|  json1
      #|  quiet
      #|  tty
    ),
  )
}

///|
/// Reuses ShellCheck's severity vocabulary in the error text.
fn unknown_severity_error() -> CliError {
  support_error(
    "Unknown value for --severity. Valid options are: error, warning, info, style",
  )
}

///|
/// Maps parser error classes to shell exit codes.
fn cli_exit_code(kind : CliErrorKind) -> Int32 {
  match kind {
    Usage => 3
    Support => 4
  }
}