///|
fn usage() -> String {
  let text =
    #|moonthrift - Apache Thrift IDL and protocol tools for MoonBit
    #|
    #|Usage:
    #|  moon run cmd/main
    #|  moon run cmd/main -- check 
    #|  moon run cmd/main -- inspect 
    #|  moon run cmd/main -- generate  [output.mbt]
    #|  moon run cmd/main -- diff  
    #|  moon run cmd/main -- compat [--policy backward|forward|full]
    #|    [--format text|json|markdown|github] [--suppress MTHC###]
    #|    [--report output]  
    #|
    #|With no arguments, the command runs a schema and compact-protocol demo.
    #|
  text
}

///|
fn read_schema(path : String) -> @lib.Schema raise {
  @lib.parse_idl(@fs.read_file_to_string(path), source=path)
}

///|
fn read_workspace(
  path : String,
) -> (@lib.SchemaWorkspace, Array[@lib.Diagnostic]) raise {
  @lib.compile_workspace(path, candidate => {
    try @fs.read_file_to_string(candidate) catch {
      _ => None
    } noraise {
      content => Some(content)
    }
  })
}

///|
fn print_diagnostics(diagnostics : Array[@lib.Diagnostic]) -> Unit {
  for item in diagnostics {
    println(item.to_text())
  }
}

///|
fn run_check(path : String) -> Unit raise {
  let (workspace, diagnostics) = read_workspace(path)
  print_diagnostics(diagnostics)
  if @lib.has_errors(diagnostics) {
    println("check failed with \{diagnostics.length()} diagnostic(s)")
    @sys.exit(2)
  }
  let definition_count = workspace.documents.fold(init=0, (count, schema) => {
    count + schema.definitions.length()
  })
  println(
    "OK: \{workspace.documents.length()} document(s), \{definition_count} definition(s), no semantic errors",
  )
}

///|
fn run_demo() -> Unit raise {
  let idl =
    #|namespace mbt demo
    #|struct User { 1: required i64 id, 2: optional string name }
    #|service Users { User get(1: i64 id) }
    #|
  let (schema, diagnostics) = @lib.compile_idl(idl, source="")
  println(schema.to_outline())
  print_diagnostics(diagnostics)
  let value : @protocol.Value = StructValue([
    { id: 1, value: I64Value(7L), },
    { id: 2, value: BinaryValue(b"MoonBit"), },
  ])
  let encoded = @protocol.encode_compact(value)
  let decoded = @protocol.decode_compact(encoded, Struct)
  println(
    "compact round trip: \{encoded.length()} bytes, equal=\{decoded == value}",
  )
}

///|
fn parse_policy(value : String) -> @lib.CompatibilityPolicy raise {
  match value {
    "backward" => Backward
    "forward" => Forward
    "full" => Full
    _ => raise Failure("unknown compatibility policy `\{value}`")
  }
}

///|
fn parse_format(value : String) -> @lib.CompatibilityFormat raise {
  match value {
    "text" => Text
    "json" => JsonFormat
    "markdown" => Markdown
    "github" => GithubAnnotations
    _ => raise Failure("unknown compatibility format `\{value}`")
  }
}

///|
fn known_compatibility_rule(value : String) -> Bool {
  match value {
    "MTHC101"
    | "MTHC102"
    | "MTHC103"
    | "MTHC104"
    | "MTHC105"
    | "MTHC106"
    | "MTHC107"
    | "MTHC108"
    | "MTHC109"
    | "MTHC110"
    | "MTHC111"
    | "MTHC112"
    | "MTHC113"
    | "MTHC114"
    | "MTHC115"
    | "MTHC116"
    | "MTHC117" => true
    _ => false
  }
}

///|
fn collect_thrift_files(
  root : String,
  relative : String,
  files : Map[String, String],
  depth : Int,
) -> Unit raise {
  if depth > 32 {
    raise Failure("schema directory depth exceeds 32: \{root}")
  }
  let directory = if relative == "" { root } else { "\{root}/\{relative}" }
  let names = @fs.read_dir(directory).to_owned()
  names.sort()
  for name in names {
    if name != ".git" && name != "_build" && name != ".mooncakes" {
      let child = if relative == "" { name } else { "\{relative}/\{name}" }
      let path = "\{root}/\{child}"
      if @fs.is_dir(path) {
        collect_thrift_files(root, child, files, depth + 1)
      } else if name.has_suffix(".thrift") && @fs.is_file(path) {
        files[child] = path
      }
    }
  }
}

///|
fn schema_or_empty(path : String?, source : String) -> @lib.Schema raise {
  match path {
    Some(path) => {
      let (workspace, diagnostics) = read_workspace(path)
      if @lib.has_errors(diagnostics) {
        print_diagnostics(diagnostics)
        raise Failure("invalid schema `\{path}`")
      }
      workspace.documents[0]
    }
    None => @lib.parse_idl("", source~)
  }
}

///|
fn run_compat(args : Array[String]) -> Unit raise {
  let mut policy : @lib.CompatibilityPolicy = Backward
  let mut format : @lib.CompatibilityFormat = Text
  let suppressed : Array[String] = []
  let mut report : String? = None
  let inputs : Array[String] = []
  let mut index = 2
  while index < args.length() {
    let option = args[index]
    match option {
      "--policy" | "--format" | "--suppress" | "--report" => {
        if index + 1 >= args.length() {
          raise Failure("\{option} requires a value")
        }
        let value = args[index + 1]
        match option {
          "--policy" => policy = parse_policy(value)
          "--format" => format = parse_format(value)
          "--suppress" => {
            if !known_compatibility_rule(value) {
              raise Failure("unknown suppression rule `\{value}`")
            }
            suppressed.push(value)
          }
          "--report" => report = Some(value)
          _ => ()
        }
        index += 2
      }
      _ => {
        if option.has_prefix("--") {
          raise Failure("unknown option `\{option}`")
        }
        inputs.push(option)
        index += 1
      }
    }
  }
  if inputs.length() != 2 {
    raise Failure(
      "compat expects before and after .thrift files or directories",
    )
  }
  let before = inputs[0]
  let after = inputs[1]
  let before_is_dir = @fs.is_dir(before)
  let after_is_dir = @fs.is_dir(after)
  if before_is_dir != after_is_dir {
    raise Failure("compat inputs must both be files or both be directories")
  }
  let findings : Array[@lib.CompatibilityFinding] = []
  if before_is_dir {
    let before_files : Map[String, String] = Map([])
    let after_files : Map[String, String] = Map([])
    collect_thrift_files(before, "", before_files, 0)
    collect_thrift_files(after, "", after_files, 0)
    let names : Array[String] = []
    for name, _ in before_files {
      names.push(name)
    }
    for name, _ in after_files {
      if !names.contains(name) {
        names.push(name)
      }
    }
    names.sort()
    for name in names {
      let source = "\{after}/\{name}".replace_all(old="\\", new="/")
      let old_schema = schema_or_empty(before_files.get(name), source)
      let new_schema = schema_or_empty(after_files.get(name), source)
      findings.append(
        @lib.compare_schemas_with_policy(
          old_schema,
          new_schema,
          policy,
          source~,
          suppressed~,
        ),
      )
    }
  } else {
    if !@fs.is_file(before) || !@fs.is_file(after) {
      raise Failure("compat file inputs must exist")
    }
    let source = after.replace_all(old="\\", new="/")
    findings.append(
      @lib.compare_schemas_with_policy(
        schema_or_empty(Some(before), source),
        schema_or_empty(Some(after), source),
        policy,
        source~,
        suppressed~,
      ),
    )
  }
  let output = @lib.render_compatibility_report(findings, policy, format)
  match report {
    Some(path) => {
      @fs.write_string_to_file(path, "\{output}\n")
      println("compatibility report written to \{path}")
    }
    None => println(output)
  }
  if @lib.has_blocking_compatibility_findings(findings) {
    @sys.exit(3)
  }
}

///|
fn run(args : Array[String]) -> Unit raise {
  if args.length() <= 1 {
    run_demo()
    return
  }
  match args[1] {
    "help" | "--help" | "-h" => println(usage())
    "check" =>
      if args.length() != 3 {
        raise Failure("check expects one .thrift file")
      } else {
        run_check(args[2])
      }
    "inspect" =>
      if args.length() != 3 {
        raise Failure("inspect expects one .thrift file")
      } else {
        let (workspace, diagnostics) = read_workspace(args[2])
        for schema in workspace.documents {
          println("== \{schema.source} ==")
          println(schema.to_outline())
        }
        print_diagnostics(diagnostics)
      }
    "generate" =>
      if args.length() < 3 || args.length() > 4 {
        raise Failure("generate expects an input and optional output path")
      } else {
        let input = args[2]
        let output = if args.length() == 4 { args[3] } else { "generated.mbt" }
        let (workspace, diagnostics) = read_workspace(input)
        if @lib.has_errors(diagnostics) {
          print_diagnostics(diagnostics)
          raise Failure("refusing to generate from an invalid schema")
        }
        let generated = @codegen.generate_workspace(workspace, path=output)
        @fs.write_string_to_file(output, generated.content)
        println("generated \{output}")
      }
    "diff" =>
      if args.length() != 4 {
        raise Failure("diff expects an old and new .thrift file")
      } else {
        let changes = @lib.compare_schemas(
          read_schema(args[2]),
          read_schema(args[3]),
        )
        for item in changes {
          println(item.to_text())
        }
        if @lib.has_breaking_changes(changes) {
          @sys.exit(3)
        }
      }
    "compat" => run_compat(args)
    command => raise Failure("unknown command `\{command}`; run with --help")
  }
}

///|
fn main {
  try run(@env.args()) catch {
    error => {
      println("moonthrift: \{error}")
      @sys.exit(1)
    }
  } noraise {
    _ => ()
  }
}