// Command-line front end for geocodec.
//
// Every subcommand is a thin wrapper over the library: it parses its arguments
// with the same total functions a caller would use, prints a short report, and
// never panics on bad input. The output is the contract recorded in README.md
// and docs/reproducible-demo.md, so keep it stable.

///|
fn main {
  let args = @env.args()
  // `moon run` may pass a runtime and a script path before the user's words
  // (node and main.js for the js backend, just the binary for native), so the
  // command is the first token that names one.
  let mut start = 1
  while start < args.length() && !is_command(args[start]) {
    start = start + 1
  }
  if start >= args.length() {
    print_usage()
    return
  }
  let command = args[start]
  let shifted : Array[String] = ["", ""]
  for i = start + 1; i < args.length(); i = i + 1 {
    shifted.push(args[i])
  }
  match command {
    "parse" => run_parse(arg_at(shifted, 2))
    "geohash" => run_geohash(shifted)
    "plus" => run_plus(shifted)
    "maidenhead" => run_maidenhead(shifted)
    "bbox" => run_bbox(arg_at(shifted, 2))
    "geouri" => run_geouri(arg_at(shifted, 2))
    "dms" => run_dms(shifted)
    "scan" => run_scan(join_from(shifted, 2))
    "cover" => run_cover(shifted)
    "info" => run_info(arg_at(shifted, 2))
    "convert" => run_convert(shifted)
    "json" => run_json(arg_at(shifted, 2))
    "demo" => run_demo()
    _ => print_usage()
  }
}

///|
fn is_command(text : String) -> Bool {
  match text {
    "parse"
    | "geohash"
    | "plus"
    | "maidenhead"
    | "bbox"
    | "geouri"
    | "dms"
    | "scan"
    | "cover"
    | "info"
    | "convert"
    | "json"
    | "demo" => true
    _ => false
  }
}

///|
fn print_usage() -> Unit {
  println(
    "usage: geocodec  [args]\n" +
    "  parse                      recognise any supported notation\n" +
    "  geohash   [precision]        encode a geohash (default 7)\n" +
    "  plus   [length]              encode a Plus Code (default 10)\n" +
    "  maidenhead   [length]        encode a Maidenhead locator (default 6)\n" +
    "  bbox                             bounds of a geohash, Plus Code or locator\n" +
    "  geouri                            parse an RFC 5870 geo: URI\n" +
    "  dms                    render decimal degrees as DMS\n" +
    "  scan                             find every code in a sentence\n" +
    "  cover      [max]\n" +
    "                                         geohash cells covering a box\n" +
    "  info                             format, point, bounds, ground size\n" +
    "  convert   \n" +
    "                                         explicit point/cell quantisation\n" +
    "  json                             one-line JSON (for scripts and agents)\n" +
    "  demo                                   a fixed tour of the codecs",
  )
}

///|
fn arg_at(args : Array[String], index : Int32) -> String {
  if index < args.length() {
    args[index]
  } else {
    ""
  }
}

///|
fn join_from(args : Array[String], index : Int32) -> String {
  let out = StringBuilder()
  let mut i = index
  while i < args.length() {
    if i > index {
      out.write_char(' ')
    }
    out.write_stringview(args[i])
    i = i + 1
  }
  out.to_string()
}

///|
fn point_from(lat_text : String, lng_text : String) -> @geocodec.LatLng? {
  let lat = @geocodec.parse_decimal(lat_text)
  let lng = @geocodec.parse_decimal(lng_text)
  match (lat, lng) {
    (Some(a), Some(b)) => @geocodec.LatLng::new(a, b)
    _ => None
  }
}

///|
fn show_point(p : @geocodec.LatLng) -> Unit {
  println("point: " + p.to_text(digits=6))
  println(
    "dms: " +
    @geocodec.format_dms(p.lat(), true) +
    " " +
    @geocodec.format_dms(p.lng(), false),
  )
}

///|
fn show_bounds(b : @geocodec.BoundingBox) -> Unit {
  println("bounds: " + b.to_text(digits=6))
  match b.center() {
    Some(c) => println("center: " + c.to_text(digits=6))
    None => println("center: (unavailable)")
  }
}

///|
fn show_location(loc : @geocodec.Location) -> Unit {
  println("format: " + loc.format.name())
  if loc.is_cell() {
    println("kind: cell")
  } else {
    println("kind: point")
  }
  show_point(loc.point)
  println("region: " + loc.region.to_text(digits=6))
  println("resolution_m: " + @geocodec.format_fixed(loc.resolution_m, 3))
  println(
    "uncertainty_m: " + optional_number(loc.uncertainty_m, "(unspecified)"),
  )
  println("cell_radius_m: " + optional_number(loc.cell_radius_m, "(none)"))
  println("uncertainty_known: " + loc.uncertainty_known().to_string())
  println("area_m2: " + @geocodec.format_fixed(loc.area_m2, 3))
  match loc.altitude_m {
    Some(v) => println("altitude_m: " + @geocodec.format_fixed(v, 3))
    None => println("altitude_m: (none)")
  }
  match loc.crs {
    Some(v) => println("crs: " + v)
    None => println("crs: wgs84")
  }
  println("raw: " + loc.raw)
  println("as_geouri: " + loc.to_geouri())
}

///|
fn run_parse(text : String) -> Unit {
  match @geocodec.parse_location(text) {
    None => println("parse: not recognised")
    Some(found) => show_location(found)
  }
}

///|
fn run_geohash(args : Array[String]) -> Unit {
  let p = match point_from(arg_at(args, 2), arg_at(args, 3)) {
    None => {
      println("geohash: need a latitude and longitude")
      return
    }
    Some(v) => v
  }
  let precision = integer_arg(arg_at(args, 4), 7)
  let code = match @geocodec.geohash_encode(p, precision) {
    None => {
      println("geohash: precision must be between 1 and 12")
      return
    }
    Some(v) => v
  }
  println("geohash: " + code)
  show_point(p)
  match @geocodec.geohash_bounds(code) {
    Some(b) => show_bounds(b)
    None => ()
  }
}

///|
fn run_plus(args : Array[String]) -> Unit {
  let p = match point_from(arg_at(args, 2), arg_at(args, 3)) {
    None => {
      println("plus: need a latitude and longitude")
      return
    }
    Some(v) => v
  }
  let length = integer_arg(arg_at(args, 4), 10)
  let code = match @geocodec.pluscode_encode(p, code_length=length) {
    None => {
      println("plus: length must be 2, 4, 6, 8, 10, 11, 12, 13, 14 or 15")
      return
    }
    Some(v) => v
  }
  println("plus: " + code)
  show_point(p)
  match @geocodec.pluscode_bounds(code) {
    Some(b) => show_bounds(b)
    None => ()
  }
}

///|
fn run_maidenhead(args : Array[String]) -> Unit {
  let p = match point_from(arg_at(args, 2), arg_at(args, 3)) {
    None => {
      println("maidenhead: need a latitude and longitude")
      return
    }
    Some(v) => v
  }
  let length = integer_arg(arg_at(args, 4), 6)
  let locator = match @geocodec.maidenhead_encode(p, precision=length) {
    None => {
      println("maidenhead: length must be even, from 2 to 10")
      return
    }
    Some(v) => v
  }
  println("maidenhead: " + locator)
  show_point(p)
  match @geocodec.maidenhead_bounds(locator) {
    Some(b) => show_bounds(b)
    None => ()
  }
}

///|
fn run_bbox(code : String) -> Unit {
  match @geocodec.pluscode_bounds(code) {
    Some(b) => {
      println("format: plus-code")
      show_bounds(b)
      return
    }
    None => ()
  }
  match @geocodec.maidenhead_bounds(code) {
    Some(b) => {
      println("format: maidenhead")
      show_bounds(b)
      return
    }
    None => ()
  }
  match @geocodec.geohash_bounds(code) {
    Some(b) => {
      println("format: geohash")
      show_bounds(b)
      return
    }
    None => ()
  }
  println("bbox: not a recognised code")
}

///|
fn run_geouri(text : String) -> Unit {
  match @geocodec.parse_location(text) {
    None => println("geouri: not a valid geo: URI")
    Some(loc) => {
      if loc.format != @geocodec.GeoUri {
        println("geouri: not a valid geo: URI")
        return
      }
      show_location(loc)
      println("formatted: " + loc.to_geouri())
    }
  }
}

///|
fn run_dms(args : Array[String]) -> Unit {
  let value = @geocodec.parse_decimal(arg_at(args, 2))
  let kind = @geocodec.ascii_upper(arg_at(args, 3))
  let is_lat = kind == "LAT" || kind == "LATITUDE"
  let is_lng = kind == "LNG" || kind == "LON" || kind == "LONGITUDE"
  match value {
    None => println("dms: need a decimal value")
    Some(v) =>
      if is_lat {
        println(@geocodec.format_dms(v, true))
      } else if is_lng {
        println(@geocodec.format_dms(v, false))
      } else {
        println("dms: the second argument must be lat or lng")
      }
  }
}

///|
fn run_scan(text : String) -> Unit {
  let found = @geocodec.parse_codes(text, max_results=20)
  println("found: " + found.length().to_string())
  for item in found {
    let kind = if item.is_cell() { "cell" } else { "point" }
    println(
      item.format.name() +
      ": " +
      item.point.to_text(digits=6) +
      " [" +
      kind +
      ", " +
      @geocodec.format_fixed(item.resolution_m, 1) +
      " m]",
    )
  }
}

///|
fn run_cover(args : Array[String]) -> Unit {
  let south = @geocodec.parse_decimal(arg_at(args, 2))
  let west = @geocodec.parse_decimal(arg_at(args, 3))
  let north = @geocodec.parse_decimal(arg_at(args, 4))
  let east = @geocodec.parse_decimal(arg_at(args, 5))
  let precision = integer_arg(arg_at(args, 6), 0)
  let max_cells = integer_arg(arg_at(args, 7), 1000)
  match (south, west, north, east) {
    (Some(s), Some(w), Some(n), Some(e)) => {
      let box = match @geocodec.BoundingBox::new(s, w, n, e) {
        None => {
          println("cover: not a valid box")
          return
        }
        Some(b) => b
      }
      let cells = match @geocodec.geohash_cover(box, precision, max_cells~) {
        None => {
          println("cover: too many cells, or an invalid precision")
          return
        }
        Some(v) => v
      }
      println("cells: " + cells.length().to_string())
      for code in cells {
        println(code)
      }
    }
    _ => println("cover: need south, west, north, east, precision")
  }
}

///|
fn run_info(code : String) -> Unit {
  match @geocodec.describe_code(code) {
    None => println("info: not a geohash, Plus Code or locator")
    Some(loc) => show_location(loc)
  }
}

///|
fn run_convert(args : Array[String]) -> Unit {
  let code = arg_at(args, 2)
  let format = @geocodec.ascii_lower(arg_at(args, 3))
  let precision = integer_arg(arg_at(args, 4), -1)
  if code == "" || format == "" || precision < 0 {
    println("convert: need a code, a target format and a precision")
    return
  }
  let target = match format {
    "geohash" => @geocodec.GeoHash
    "plus" | "plus-code" => @geocodec.PlusCode
    "maidenhead" => @geocodec.Maidenhead
    _ => {
      println("convert: target must be geohash, plus or maidenhead")
      return
    }
  }
  let source = match @geocodec.parse_location(code) {
    None => {
      println("convert: source is not a recognised location")
      return
    }
    Some(loc) => loc
  }
  println("source:")
  show_location(source)
  match source.convert_to_cell(target, precision) {
    None => println("convert: cannot encode at this precision")
    Some(conversion) => {
      println("converted:")
      show_location(conversion.target)
      match conversion.uncertainty_bound_m {
        Some(u) =>
          println(
            "source_uncertainty_bound_m: " + @geocodec.decimal_notation(u),
          )
        None => println("source_uncertainty_bound_m: (unspecified)")
      }
    }
  }
}

///|
fn run_json(code : String) -> Unit {
  let loc = match @geocodec.parse_location(code) {
    None => {
      println("{\"error\":\"not a recognised location\"}")
      return
    }
    Some(loc) => loc
  }
  let kind = if loc.is_cell() { "cell" } else { "point" }
  let altitude = match loc.altitude_m {
    Some(v) => json_number(v)
    None => "null"
  }
  let crs = match loc.crs {
    Some(v) => "\"" + json_escape(v) + "\""
    None => "\"wgs84\""
  }
  println(
    "{\"format\":\"" +
    loc.format.name() +
    "\",\"kind\":\"" +
    kind +
    "\",\"lat\":" +
    json_number(loc.point.lat()) +
    ",\"lng\":" +
    json_number(loc.point.lng()) +
    ",\"south\":" +
    json_number(loc.region.south()) +
    ",\"west\":" +
    json_number(loc.region.west()) +
    ",\"north\":" +
    json_number(loc.region.north()) +
    ",\"east\":" +
    json_number(loc.region.east()) +
    ",\"resolution_m\":" +
    json_number(loc.resolution_m) +
    ",\"uncertainty_m\":" +
    optional_number(loc.uncertainty_m, "null") +
    ",\"cell_radius_m\":" +
    optional_number(loc.cell_radius_m, "null") +
    ",\"uncertainty_known\":" +
    loc.uncertainty_known().to_string() +
    ",\"area_m2\":" +
    json_number(loc.area_m2) +
    ",\"altitude_m\":" +
    altitude +
    ",\"crs\":" +
    crs +
    ",\"raw\":\"" +
    json_escape(loc.raw) +
    "\",\"as_geouri\":\"" +
    json_escape(loc.to_geouri()) +
    "\"}",
  )
}

///|
fn json_escape(text : String) -> String {
  let out = StringBuilder()
  for ch in text {
    if ch == '"' {
      out.write_stringview("\\\"")
    } else if ch == '\\' {
      out.write_stringview("\\\\")
    } else if ch.to_int() < 32 {
      out.write_char(' ')
    } else {
      out.write_char(ch)
    }
  }
  out.to_string()
}

///|
fn integer_arg(text : String, fallback : Int32) -> Int32 {
  match @geocodec.parse_decimal(text) {
    None => fallback
    Some(v) => if v < 0.0 { fallback } else { v.to_int() }
  }
}

///|
fn run_demo() -> Unit {
  let places : Array[(String, @geocodec.LatLng)] = [
    ("Beijing", @geocodec.LatLng::new(39.9042, 116.4074).unwrap()),
    ("London", @geocodec.LatLng::new(51.5074, -0.1278).unwrap()),
    ("Sydney", @geocodec.LatLng::new(-33.8688, 151.2093).unwrap()),
  ]
  for entry in places {
    let name = entry.0
    let p = entry.1
    println("== " + name + " ==")
    show_point(p)
    match @geocodec.geohash_encode(p, 9) {
      Some(v) => println("geohash: " + v)
      None => ()
    }
    match @geocodec.pluscode_encode(p, code_length=10) {
      Some(v) => println("plus: " + v)
      None => ()
    }
    match @geocodec.maidenhead_encode(p, precision=8) {
      Some(v) => println("maidenhead: " + v)
      None => ()
    }
  }
}

///|
fn optional_number(value : Double?, absent : String) -> String {
  match value {
    None => absent
    Some(v) => json_number(v)
  }
}

///|
fn json_number(value : Double) -> String {
  if @geocodec.is_finite(value) {
    @geocodec.decimal_notation(value)
  } else {
    "null"
  }
}