// mbt-filetype 命令行入口。
//
// 识别逻辑全部在库包 `Mr-Houjie/mbt-filetype`;这里只负责
// 参数解析、文件读写、输出与退出码。

///|
const VERSION : String = "0.2.1"

///|
fn usage() -> String {
  let s = StringBuilder()
  s.write_string(
    "mbt-filetype \{VERSION} — 文件类型识别与内容嗅探\n\n",
  )
  s.write_string("用法:\n")
  s.write_string("  mbt-filetype [选项] <文件...>\n")
  s.write_string("  mbt-filetype --scan <目录>\n\n")
  s.write_string("选项:\n")
  s.write_string("  --json                以单行 JSON 输出\n")
  s.write_string(
    "  --explain             额外显示签名偏移与字节数\n",
  )
  s.write_string(
    "  --expect   断言类型,不匹配时退出码为 1\n",
  )
  s.write_string(
    "  --scan <目录>         递归扫描并报告扩展名与内容不符的文件\n",
  )
  s.write_string("  --list-types          列出全部已知类型\n")
  s.write_string("  -v, --version         显示版本\n")
  s.write_string("  -h, --help            显示本帮助\n\n")
  s.write_string("退出码:\n")
  s.write_string("  0  成功(或 --expect 匹配)\n")
  s.write_string("  1  --expect 不匹配 / 扫描发现扩展名不符\n")
  s.write_string("  2  用法错误或读写失败\n")
  s.to_string()
}

///|
struct Options {
  paths : Array[String]
  mut json : Bool
  mut explain : Bool
  mut expect : String?
  mut scan : String?
  mut list_types : Bool
  mut help : Bool
  mut version : Bool
}

///|
fn Options::new() -> Options {
  {
    paths: [],
    json: false,
    explain: false,
    expect: None,
    scan: None,
    list_types: false,
    help: false,
    version: false,
  }
}

///|
/// 输出文本并去掉末尾换行,避免 `println` 追加空行。
fn output(text : String) -> Unit {
  let chars = text.iter().to_array()
  let mut n = chars.length()
  while n > 0 && (chars[n - 1] == '\n' || chars[n - 1] == '\r') {
    n = n - 1
  }
  if n == 0 {
    return
  }
  let buffer = StringBuilder()
  for i = 0; i < n; i = i + 1 {
    buffer.write_char(chars[i])
  }
  println(buffer.to_string())
}

///|
fn usage_error(message : String) -> Unit {
  println("错误: \{message}")
  println("")
  output(usage())
  @sys.exit(2)
}

///|
/// 输出诊断信息:JSON 模式下写 stderr,避免污染 stdout 的 JSON 流。
fn report(options : Options, message : String) -> Unit {
  if options.json {
    eprintln(message)
  } else {
    println(message)
  }
}

///|
fn next_arg(args : Array[String], i : Int, name : String) -> String {
  if i + 1 >= args.length() {
    usage_error("\{name} 需要一个参数")
  }
  args[i + 1]
}

///|
fn parse_args(args : Array[String]) -> Options {
  let options = Options::new()
  let mut i = 1
  while i < args.length() {
    let arg = args[i]
    match arg {
      "--json" => options.json = true
      "--explain" => options.explain = true
      "--list-types" => options.list_types = true
      "--help" | "-h" => options.help = true
      "--version" | "-v" => options.version = true
      "--expect" => {
        options.expect = Some(next_arg(args, i, "--expect"))
        i = i + 1
      }
      "--scan" => {
        options.scan = Some(next_arg(args, i, "--scan"))
        i = i + 1
      }
      _ =>
        if arg.has_prefix("-") {
          usage_error("未知选项「\{arg}」")
        } else {
          options.paths.push(arg)
        }
    }
    i = i + 1
  }
  options
}

///|
fn ascii_lower(s : String) -> String {
  let buffer = StringBuilder()
  for c in s {
    buffer.write_char(c.to_ascii_lowercase())
  }
  buffer.to_string()
}

///|
fn read_file(path : String) -> Result[Bytes, String] {
  Ok(@fs.read_file_to_bytes(path)) catch {
    _ => Err("读取文件失败")
  }
}

///|
fn matches_expect(detection : @lib.Detection, expect : String) -> Bool {
  match detection.file_type {
    None => false
    Some(file_type) => {
      let wanted = ascii_lower(expect)
      wanted == ascii_lower(file_type.id) ||
      wanted == ascii_lower(file_type.mime) ||
      wanted == ascii_lower(file_type.ext) ||
      wanted == "." + ascii_lower(file_type.ext)
    }
  }
}

///|
/// 处理单个文件:0 成功,1 断言不匹配,2 读写失败。
fn process_file(path : String, options : Options) -> Int {
  match read_file(path) {
    Err(message) => {
      println("无法读取「\{path}」: \{message}")
      2
    }
    Ok(bytes) => {
      let detection = @lib.detect(bytes)
      if options.json {
        output(@lib.render_json(path, detection))
      } else {
        output(@lib.render_text(path, detection, options.explain))
      }
      match options.expect {
        Some(expect) => if matches_expect(detection, expect) { 0 } else { 1 }
        None => 0
      }
    }
  }
}

///|
fn path_ext(path : String) -> String {
  let chars = path.iter().to_array()
  let mut last_dot = -1
  let mut last_separator = -1
  let mut i = 0
  while i < chars.length() {
    let c = chars[i]
    if c == '.' {
      last_dot = i
    }
    if c == '/' || c == '\\' {
      last_separator = i
    }
    i = i + 1
  }
  if last_dot > last_separator + 1 {
    let buffer = StringBuilder()
    let mut j = last_dot + 1
    while j < chars.length() {
      buffer.write_char(chars[j])
      j = j + 1
    }
    buffer.to_string()
  } else {
    ""
  }
}

///|
fn ext_matches(declared : String, file_type : @lib.FileType) -> Bool {
  let ext = ascii_lower(declared)
  if ext == ascii_lower(file_type.ext) {
    return true
  }
  match file_type.id {
    "jpeg" => ext == "jpg" || ext == "jpe" || ext == "jfif"
    "tiff" => ext == "tif"
    "html" => ext == "htm"
    "mp4" => ext == "m4v"
    "heic" => ext == "heif"
    "jp2" => ext == "j2k" || ext == "jpx" || ext == "jpf"
    "ts" => ext == "m2ts" || ext == "mts"
    "3gp" => ext == "3gpp"
    "pnm" => ext == "pgm" || ext == "ppm" || ext == "pbm"
    "pe" => ext == "exe" || ext == "dll" || ext == "sys"
    "macho" => ext == "dylib" || ext == "o"
    "ole2" => ext == "doc" || ext == "xls" || ext == "ppt"
    _ => false
  }
}

///|
fn join_path(directory : String, name : String) -> String {
  if directory.has_suffix("/") || directory.has_suffix("\\") {
    directory + name
  } else {
    directory + "/" + name
  }
}

///|
struct ScanStats {
  mut total : Int
  mut mismatches : Int
  mut io_error : Bool
}

///|
fn check_scan_file(path : String, options : Options, stats : ScanStats) -> Unit {
  let declared = path_ext(path)
  if declared.is_empty() {
    return
  }
  match read_file(path) {
    Err(_) => ()
    Ok(bytes) => {
      let detection = @lib.detect(bytes)
      match detection.file_type {
        None => ()
        Some(file_type) =>
          if @lib.is_text_category(file_type) ||
            ext_matches(declared, file_type) {
            ()
          } else {
            stats.mismatches = stats.mismatches + 1
            if options.json {
              output(@lib.render_json(path, detection))
            } else {
              println(
                "\{path}: 扩展名 .\{declared},实际 \{file_type.id} (\{file_type.mime})",
              )
            }
          }
      }
    }
  }
}

///|
fn scan_directory(
  directory : String,
  depth : Int,
  options : Options,
  stats : ScanStats,
) -> Unit {
  if depth > 32 {
    report(options, "扫描深度超过 32,跳过「\{directory}」")
    return
  }
  let entries = @fs.read_dir(directory) catch {
    _ => {
      report(options, "无法读取目录「\{directory}」")
      stats.io_error = true
      return
    }
  }
  for name in entries {
    let path = join_path(directory, name)
    let is_directory = @fs.is_dir(path) catch { _ => false }
    if is_directory {
      scan_directory(path, depth + 1, options, stats)
    } else {
      stats.total = stats.total + 1
      check_scan_file(path, options, stats)
    }
  }
}

///|
fn main {
  let args = @env.args()
  let options = parse_args(args)
  if options.help {
    output(usage())
    @sys.exit(0)
    return
  }
  if options.version {
    println("mbt-filetype \{VERSION}")
    @sys.exit(0)
    return
  }
  if options.list_types {
    output(@lib.render_types())
    @sys.exit(0)
    return
  }
  if options.paths.is_empty() && options.scan is None {
    usage_error("至少需要一个文件,或使用 --scan <目录>")
  }
  let mut code = 0
  for path in options.paths {
    let result = process_file(path, options)
    if result > code {
      code = result
    }
  }
  match options.scan {
    Some(directory) => {
      let stats = { total: 0, mismatches: 0, io_error: false, }
      scan_directory(directory, 0, options, stats)
      let summary = "scan: \{stats.total} files, \{stats.mismatches} extension mismatches"
      if options.json {
        eprintln(summary)
      } else {
        println("")
        println(summary)
      }
      if stats.mismatches > 0 && code < 1 {
        code = 1
      }
      if stats.io_error && code < 2 {
        code = 2
      }
    }
    None => ()
  }
  @sys.exit(code)
}