///|
fn global_flags() -> Array[@argparse.FlagArg] {
  [
    FlagArg(
      "cram-compat",
      short='C',
      global=true,
      about="Be as compatible as possible with Cram: CRAM* environment variables, Cram glob rules, combined output and kept CRLF.",
    ),
    FlagArg(
      "combine-output",
      negatable=true,
      global=true,
      about="Combine STDOUT and STDERR into a single stream (per default only STDOUT is considered).",
    ),
    FlagArg(
      "keep-output-crlf",
      negatable=true,
      global=true,
      about="Keep CRLF line endings in outputs (per default they are converted to LF).",
    ),
    FlagArg(
      "keep-temporary-directories",
      global=true,
      conflicts_with=["work-directory"],
      about="Do not remove temporary directories after test execution.",
    ),
    FlagArg("no-color", global=true, about="Disable colo(u)r output."),
    FlagArg("no-colour", global=true, hidden=true),
  ]
}

///|
fn global_options() -> Array[@argparse.OptionArg] {
  [
    OptionArg(
      "escaping",
      short='e',
      global=true,
      about="Output escaping: `unicode` (default for Markdown) or `ascii` (default for Cram).",
    ),
    OptionArg(
      "shell",
      short='s',
      global=true,
      about="Accepted for compatibility and ignored: scrut runs programs without a shell.",
    ),
    OptionArg(
      "work-directory",
      short='w',
      global=true,
      about="Directory to run the tests in (per default a temporary directory per test document).",
    ),
    OptionArg(
      "timeout-seconds",
      global=true,
      about="Timeout in seconds for the whole execution of a document (0 for unlimited; default 900).",
    ),
  ]
}

///|
fn test_command() -> @argparse.Command {
  Command(
    "test",
    about="Run tests from files or directories",
    flags=[
      FlagArg("debug", hidden=true),
      FlagArg(
        "absolute-line-numbers",
        about="Use line numbers of the test document instead of relative ones.",
      ),
      FlagArg(
        "verbose",
        about="Print information that is not a warning or error.",
      ),
    ],
    options=[
      OptionArg(
        "prepend-test-file-paths",
        short='P',
        action=Append,
        about="Test documents to run before each test document (UNSTABLE).",
      ),
      OptionArg(
        "append-test-file-paths",
        short='A',
        action=Append,
        about="Test documents to run after each test document (UNSTABLE).",
      ),
      OptionArg("markdown-languages", action=Append, hidden=true),
      OptionArg(
        "match-cram",
        default_values=[@app.default_match_cram],
        about="Glob that identifies Cram documents.",
      ),
      OptionArg(
        "match-markdown",
        default_values=[@app.default_match_markdown],
        about="Glob that identifies Markdown documents.",
      ),
      OptionArg(
        "renderer",
        short='r',
        default_values=["auto"],
        about="Result format: auto, pretty, diff, json, yaml or junit.",
      ),
      OptionArg(
        "max-multiline-matched-lines",
        default_values=[@render.default_multiline_matched_lines.to_string()],
        about="Lines shown of multiline matches in failures (pretty renderer).",
      ),
    ],
    positionals=[
      PositionArg(
        "test_file_paths",
        num_args=ValueRange(lower=0),
        about="Test documents or directories",
      ),
    ],
  )
}

///|
fn update_command() -> @argparse.Command {
  Command(
    "update",
    about="Re-run all testcases in given file(s) and update the output expectations",
    flags=[
      FlagArg("debug", hidden=true),
      FlagArg(
        "assume-yes",
        short='y',
        about="Overwrite existing files without asking (with --replace: the original documents).",
      ),
      FlagArg("overwrite-all", hidden=true),
      FlagArg(
        "replace",
        short='r',
        about="Replace the contents of the documents (see --output-suffix).",
      ),
      FlagArg(
        "absolute-line-numbers",
        about="Use line numbers of the test document instead of relative ones.",
      ),
      FlagArg(
        "verbose",
        about="Print information that is not a warning or error.",
      ),
    ],
    options=[
      OptionArg("markdown-languages", action=Append, hidden=true),
      OptionArg(
        "output-suffix",
        short='o',
        default_values=[".new"],
        about="Suffix of the updated documents (existing files are overwritten).",
      ),
      OptionArg(
        "match-cram",
        default_values=[@app.default_match_cram],
        about="Glob that identifies Cram documents.",
      ),
      OptionArg(
        "match-markdown",
        default_values=[@app.default_match_markdown],
        about="Glob that identifies Markdown documents.",
      ),
      OptionArg(
        "max-multiline-matched-lines",
        default_values=[@render.default_multiline_matched_lines.to_string()],
        about="Lines shown of multiline matches in failures.",
      ),
      OptionArg(
        "convert",
        short='c',
        about="Convert documents to this format: markdown or cram (ignores --output-suffix).",
      ),
    ],
    positionals=[
      PositionArg(
        "paths",
        num_args=ValueRange(lower=1),
        about="Test documents or directories",
      ),
    ],
  )
}

///|
fn create_command() -> @argparse.Command {
  Command(
    "create",
    about="Create tests from provided shell expression",
    options=[
      OptionArg(
        "format",
        short='f',
        default_values=["markdown"],
        about="Test document format: markdown or cram.",
      ),
      OptionArg(
        "output",
        short='o',
        default_values=["-"],
        about="Where to write the test document (`-`: stdout).",
      ),
      OptionArg(
        "title",
        short='t',
        default_values=["Command executes successfully"],
        about="What the test is supposed to prove.",
      ),
    ],
    positionals=[
      PositionArg(
        "shell_expression",
        num_args=ValueRange(lower=1),
        allow_hyphen_values=true,
        about="Command THAT WILL BE EXECUTED to create a test from (`-`: read from stdin).",
      ),
    ],
  )
}

///|
fn parser_type(
  name : String,
  text : String,
) -> @document.ParserType raise UsageError {
  match text {
    "markdown" => Markdown
    "cram" => Cram
    other =>
      raise UsageError(
        "invalid value '\{other}' for '--\{name} <\{name.to_upper()}>'\n  [possible values: markdown, cram]",
      )
  }
}

///|
fn update_args(m : @argparse.Matches) -> @app.UpdateArgs raise UsageError {
  let languages = values(m, "markdown-languages")
  {
    ..@app.UpdateArgs::new(values(m, "paths")),
    markdown_languages: if languages.is_empty() {
      @app.default_markdown_languages.iter().collect()
    } else {
      languages
    },
    output_suffix: value(m, "output-suffix").unwrap_or(".new"),
    assume_yes: flag(m, "assume-yes") || flag(m, "overwrite-all"),
    match_cram: value(m, "match-cram").unwrap_or(@app.default_match_cram),
    match_markdown: value(m, "match-markdown").unwrap_or(
      @app.default_match_markdown,
    ),
    replace: flag(m, "replace"),
    absolute_line_numbers: flag(m, "absolute-line-numbers"),
    max_multiline_matched_lines: match value(m, "max-multiline-matched-lines") {
      Some(t) => parse_uint("max-multiline-matched-lines", t).to_int()
      None => @render.default_multiline_matched_lines
    },
    convert: match value(m, "convert") {
      Some(t) => Some(parser_type("convert", t))
      None => None
    },
    verbose: flag(m, "verbose"),
    global: global_args(m),
  }
}

///|
fn create_args(m : @argparse.Matches) -> @app.CreateArgs raise UsageError {
  {
    ..@app.CreateArgs::new(values(m, "shell_expression")),
    format: parser_type("format", value(m, "format").unwrap_or("markdown")),
    output: value(m, "output").unwrap_or("-"),
    title: value(m, "title").unwrap_or("Command executes successfully"),
    global: global_args(m),
  }
}

///|
/// Ask on the terminal whether to go ahead (default: no).
async fn confirm(question : String) -> Bool {
  if !@app.is_terminal(2) {
    raise UsageError("IO error: not a terminal")
  }
  @stdio.stderr.write("\{question} [y/N] ")
  let answer = @stdio.stdin.read_until("\n")
  match answer {
    Some(a) => {
      let a = a.trim(chars=" \t\r").to_lower()
      a == "y" || a == "yes"
    }
    None => false
  }
}

///|
async fn read_stdin() -> String {
  let data = @stdio.stdin.read_all()
  data.text()
}

///|
fn root_command() -> @argparse.Command {
  Command(
    "scrut",
    about="A testing toolkit to scrutinize CLI applications",
    version=@lib.version,
    flags=global_flags(),
    options=global_options(),
    subcommands=[create_command(), test_command(), update_command()],
    subcommand_required=true,
  )
}

///|
suberror UsageError {
  UsageError(String)
}

///|
fn value(m : @argparse.Matches, name : String) -> String? {
  match m.values.get(name) {
    Some(vs) if vs.length() > 0 => Some(vs[vs.length() - 1])
    _ => None
  }
}

///|
fn values(m : @argparse.Matches, name : String) -> Array[String] {
  m.values.get(name).unwrap_or([])
}

///|
fn flag(m : @argparse.Matches, name : String) -> Bool {
  m.flags.get(name).unwrap_or(false)
}

///|
fn parse_uint(name : String, text : String) -> UInt64 raise UsageError {
  guard text.length() > 0 && text.iter().all(c => c >= '0' && c <= '9') else {
    raise UsageError(
      "invalid value '\{text}' for '--\{name}': invalid digit found in string",
    )
  }
  let mut v = 0UL
  for c in text {
    let next = v * 10UL + (c.to_int() - '0'.to_int()).to_uint64()
    if next / 10UL != v {
      raise UsageError(
        "invalid value '\{text}' for '--\{name}': number too large",
      )
    }
    v = next
  }
  v
}

///|
fn global_args(m : @argparse.Matches) -> @app.GlobalArgs raise UsageError {
  let escaping : @text.Escaper? = match value(m, "escaping") {
    None => None
    Some("ascii") => Some(Ascii)
    Some("unicode") => Some(Unicode)
    Some(other) =>
      raise UsageError(
        "invalid value '\{other}' for '--escaping '\n  [possible values: ascii, unicode]",
      )
  }
  {
    cram_compat: flag(m, "cram-compat"),
    combine_output: m.flags.get("combine-output"),
    keep_output_crlf: m.flags.get("keep-output-crlf"),
    escaping,
    shell: value(m, "shell"),
    work_directory: value(m, "work-directory"),
    keep_temporary_directories: flag(m, "keep-temporary-directories"),
    timeout_seconds: match value(m, "timeout-seconds") {
      Some(t) => Some(parse_uint("timeout-seconds", t))
      None => None
    },
    no_color: flag(m, "no-color") || flag(m, "no-colour"),
  }
}

///|
fn test_args(m : @argparse.Matches) -> @app.TestArgs raise UsageError {
  let renderer : @app.RendererKind = match value(m, "renderer") {
    None | Some("auto") => Auto
    Some("pretty") => Pretty
    Some("diff") => Diff
    Some("json") => Json
    Some("yaml") => Yaml
    Some("junit") => Junit
    Some(other) =>
      raise UsageError(
        "invalid value '\{other}' for '--renderer '\n  [possible values: auto, pretty, diff, json, yaml, junit]",
      )
  }
  let languages = values(m, "markdown-languages")
  {
    ..@app.TestArgs::new(values(m, "test_file_paths")),
    prepend: values(m, "prepend-test-file-paths"),
    append: values(m, "append-test-file-paths"),
    markdown_languages: if languages.is_empty() {
      @app.default_markdown_languages.iter().collect()
    } else {
      languages
    },
    match_cram: value(m, "match-cram").unwrap_or(@app.default_match_cram),
    match_markdown: value(m, "match-markdown").unwrap_or(
      @app.default_match_markdown,
    ),
    renderer,
    absolute_line_numbers: flag(m, "absolute-line-numbers"),
    max_multiline_matched_lines: match value(m, "max-multiline-matched-lines") {
      Some(t) => parse_uint("max-multiline-matched-lines", t).to_int()
      None => @render.default_multiline_matched_lines
    },
    verbose: flag(m, "verbose"),
    global: global_args(m),
  }
}

///|
async fn write_stdout(text : String) -> Unit {
  @stdio.stdout.write(text)
}

///|
async fn write_stderr(text : String) -> Unit {
  @stdio.stderr.write(text)
}

///|
async fn main {
  let env = @env.get_env_vars()
  let argv = @env.args()
  let matches = root_command().parse(argv=argv[1:], env~)
  let term : @app.Terminal = {
    write_out: write_stdout,
    write_err: write_stderr,
    colors: @app.colors_enabled(env),
    progress: @app.is_terminal(2),
  }
  let code = try {
    match matches.subcommand {
      Some(("test", sub)) => @app.run_test(test_args(sub), term)
      Some(("update", sub)) => @app.run_update(update_args(sub), term, confirm)
      Some(("create", sub)) =>
        @app.run_create(create_args(sub), term, read_stdin)
      _ => {
        @stdio.stderr.write(root_command().render_help())
        2
      }
    }
  } catch {
    UsageError("IO error: not a terminal" as msg) => {
      @stdio.stderr.write("Error: \{msg}\n")
      1
    }
    UsageError(msg) => {
      @stdio.stderr.write(
        "error: \{msg}\n\nFor more information, try '--help'.\n",
      )
      2
    }
    e => {
      @stdio.stderr.write("Error: \{e}\n")
      1
    }
  }
  @sys.exit(code)
}