///|
/// `scat`: `cat` with syntax highlighting.
///
///     scat [-l LANG] [-s STYLE] [-n] [-p] [FILE ...]
///
/// The language is taken from `-l`, else from the file name, else guessed
/// from the contents, else plain text. Without `-s` the output uses the
/// terminal's own 16-color palette (readable on dark and light themes); with
/// `-s STYLE` it uses 24-bit color when `COLORTERM` says so, 256 colors
/// otherwise.
async fn main {
  let code = run(@env.args()[1:].to_owned()) catch {
    e => {
      @stdio.stderr.write("scat: \{error_text(e)}\n")
      1
    }
  }
  @sys.exit(code)
}

///|
let usage : String =
  #|usage: scat [-l LANG] [-s STYLE] [-n] [-p] [--light] [FILE ...]
  #|
  #|Print files with syntax highlighting (standard input if no FILE or FILE is -).
  #|
  #|  -l LANG     language (a lexer alias, e.g. python, rust, moonbit)
  #|  -s STYLE    color style (e.g. monokai, dracula, github-dark); see --styles
  #|  -n          number lines
  #|  -p          plain: no colors
  #|  --light     colors for a light terminal background (without -s)
  #|  --langs     list language aliases
  #|  --styles    list styles
  #|  -h, --help  show this help
  #|

///|
priv struct Config {
  mut lang : String?
  mut style : String?
  mut numbers : Bool
  mut plain : Bool
  mut light : Bool
  files : Array[String]
}

///|
fn error_text(e : Error) -> String {
  match e {
    @lexers.ClassNotFound(msg) => msg
    @formatters.ClassNotFound(msg) => msg
    @styles.ClassNotFound(_) => "unknown style (see scat --styles)"
    @os_error.OSError(_) as e => os_message(e)
    _ => Repr(e).to_string()
  }
}

///|
async fn run(argv : Array[String]) -> Int32 {
  let cfg : Config = {
    lang: None,
    style: None,
    numbers: false,
    plain: false,
    light: false,
    files: [],
  }
  let mut i = 0
  while i < argv.length() {
    let a = argv[i]
    i += 1
    match a {
      "-h" | "--help" => {
        @stdio.stdout.write(usage)
        return 0
      }
      "--langs" => {
        let names = @lexers.all_lexers
          .filter(e => !e.0.aliases.is_empty())
          .map(e => "\{e.0.aliases.join(", ")}: \{e.0.name}")
        names.sort()
        @stdio.stdout.write(names.join("\n") + "\n")
        return 0
      }
      "--styles" => {
        @stdio.stdout.write(@styles.get_all_styles().join("\n") + "\n")
        return 0
      }
      "-n" => cfg.numbers = true
      "-p" => cfg.plain = true
      "--light" => cfg.light = true
      "-l" | "-s" => {
        if i >= argv.length() {
          @stdio.stderr.write("scat: option \{a} needs an argument\n" + usage)
          return 2
        }
        if a == "-l" {
          cfg.lang = Some(argv[i])
        } else {
          cfg.style = Some(argv[i])
        }
        i += 1
      }
      _ =>
        if a.has_prefix("-") && a != "-" {
          @stdio.stderr.write("scat: unknown option \{a}\n" + usage)
          return 2
        } else {
          cfg.files.push(a)
        }
    }
  }
  if cfg.files.is_empty() {
    cfg.files.push("-")
  }
  let formatter = make_formatter(cfg)
  let mut status = 0
  for file in cfg.files {
    let text = if file == "-" {
      decode(@stdio.stdin.read_all().binary())
    } else {
      decode(
        (@fs.read_file(file) catch {
          e => {
            @stdio.stderr.write("scat: \{file}: \{error_text(e)}\n")
            status = 1
            continue
          }
        }).binary(),
      )
    }
    let lexer = pick_lexer(cfg.lang, file, text)
    @stdio.stdout.write(formatter.format(lexer.get_tokens(text)))
  }
  status
}

///|
/// The lexer for `file`: `-l`, then the file name, then a content guess,
/// then plain text.
fn pick_lexer(
  lang : String?,
  file : String,
  text : String,
) -> @lexer.Lexer raise {
  let opts : @lexer.Options = Map::from_array([("stripnl", "False")])
  match lang {
    Some(l) => return @lexers.get_lexer_by_name(l, options=opts)
    None => ()
  }
  if file != "-" {
    match @lexers.find_lexer_class_for_filename(file, code=text) {
      Some(entry) => return (entry.1)(opts)
      None => ()
    }
  }
  @lexers.guess_lexer(text, options=opts) catch {
    _ => @lexers.text_lexer(options=opts)
  }
}

///|
fn make_formatter(cfg : Config) -> @formatters.Formatter raise {
  let opts : Map[String, String] = Map([])
  if cfg.numbers {
    opts["linenos"] = "True"
  }
  if cfg.plain {
    return @formatters.get_formatter_by_name("text", options=opts)
  }
  match cfg.style {
    None => {
      if cfg.light {
        opts["bg"] = "light"
      }
      @formatters.get_formatter_by_name("terminal", options=opts)
    }
    Some(style) => {
      opts["style"] = style
      let colorterm = @env.get_env_var("COLORTERM").unwrap_or("")
      let name = if colorterm == "truecolor" || colorterm == "24bit" {
        "terminal16m"
      } else {
        "terminal256"
      }
      @formatters.get_formatter_by_name(name, options=opts)
    }
  }
}

///|
/// UTF-8 (dropping a BOM), falling back to Latin-1.
fn decode(data : Bytes) -> String {
  @utf8.decode(data, ignore_bom=true) catch {
    _ => {
      let sb = StringBuilder()
      for b in data {
        sb.write_char(b.to_int().unsafe_to_char())
      }
      sb.to_string()
    }
  }
}

///|
/// The system message of an `OSError` ("No such file or directory").
fn os_message(e : @os_error.OSError) -> String {
  let s = e.to_string()
  let msg = match s.rev_find(": ") {
    Some(i) => s.unsafe_substring(start=i + 2, end=s.length())
    None => s
  }
  // `to_string` renders `OSError("context: message")`
  if msg.has_suffix("\")") {
    msg.unsafe_substring(start=0, end=msg.length() - 2)
  } else {
    msg
  }
}