///|
/// A flight plan from the command line.
///
/// Reads one of the winds and temperatures aloft products the demo carries,
/// plans a route across it at one altitude and airspeed, and prints the legs
/// with the wind that was forecast for each of them:
///
///     moon run cmd/main
///     moon run cmd/main -- --station ABR --altitude 12000 --airspeed 120 \
///         --legs 4 45,-98 44,-96 43,-94
///
/// Without waypoints it plans the demonstration route below, which crosses
/// the northern plains where `ABR` forecasts.

///|
/// The route planned when the command line gives none: three waypoints across
/// the northern plains, in South Dakota and Minnesota.
let demonstration_route : Array[@moonwind.Coordinate] = [
  @moonwind.Coordinate::new(45.0, -98.0),
  @moonwind.Coordinate::new(44.0, -96.0),
  @moonwind.Coordinate::new(43.0, -94.0),
]

///|
/// A route to plan, as the command line asked for it.
priv struct Request {
  product : String
  station : String
  altitude : Int
  airspeed : Double
  legs : Int
  waypoints : Array[@moonwind.Coordinate]
  /// Compare every forecast level instead of planning one.
  levels : Bool
  /// Fuel burnt per hour, when the command line asked for a fuel figure.
  burn : Double?
  /// Reserve fuel, in minutes of flying.
  reserve_minutes : Double
}

///|
/// What the command line asked for.
priv enum Command {
  Plan(Request)
  ListStations(String)
  Help
  Bad(String)
}

///|
fn main {
  match command(@env.args()) {
    Help => println(usage())
    Bad(message) => {
      println(message)
      println("")
      println(usage())
    }
    ListStations(product) => list_stations(product)
    Plan(request) => plan_route(request)
  }
}

///|
/// How the demo is used.
fn usage() -> String {
  [
    "moonwind — plan a route against a winds and temperatures aloft product", "",
    "  moon run cmd/main -- [options] [lat,lon ...]", "", "  --product NAME   the product to plan with: fd1us1 (default), fd2us1",
    "                   for the 12-hour forecast, or fd8us7 for the high", "                   level product of 45,000 and 53,000 ft",
    "  --station ID     the station to read the wind from; the first station", "                   of the product by default",
    "  --altitude FT    the altitude to plan at, in feet: 12000 by default", "  --airspeed KT    the true airspeed, in knots: 120 by default",
    "  --legs N         divide every hop into N legs, so that the course of", "                   each one stays close to the course at its start",
    "  --levels         instead of planning one altitude, compare every level", "                   the station forecasts and report the fastest",
    "  --burn RATE      fuel burnt per hour, which adds a fuel figure to the", "                   report; units are whatever the rate is given in",
    "  --reserve MIN    reserve fuel in minutes of flying: 45 by default", "  --list           list the stations the product reports",
    "  --help           this", "", "Waypoints are latitudes and longitudes in degrees, as in 45,-98, and at",
    "least two of them make a route. Without any, the demonstration route", "over the northern plains is planned.",
  ].join("\n")
}

///|
/// The command the arguments describe.
fn command(arguments : Array[String]) -> Command {
  let mut product = "fd1us1"
  let mut station = "ABR"
  let mut altitude = 12000
  let mut airspeed = 120.0
  let mut legs = 1
  let waypoints : Array[@moonwind.Coordinate] = []
  let mut list = false
  let mut levels = false
  let mut burn : Double? = None
  let mut reserve_minutes = 45.0
  let mut index = 0
  while index < arguments.length() {
    let argument = arguments[index]
    if argument == "--help" || argument == "-h" {
      return Help
    } else if argument == "--list" {
      list = true
      index = index + 1
    } else if argument == "--product" {
      match following(arguments, index) {
        Some(value) => product = value
        None => return Bad("--product needs the name of a product")
      }
      index = index + 2
    } else if argument == "--station" {
      match following(arguments, index) {
        Some(value) => station = value
        None => return Bad("--station needs a station identifier")
      }
      index = index + 2
    } else if argument == "--altitude" {
      match following(arguments, index) {
        Some(value) =>
          match whole_number(value) {
            Some(feet) => altitude = feet
            None => return Bad("\{value} is not an altitude in feet")
          }
        None => return Bad("--altitude needs an altitude in feet")
      }
      index = index + 2
    } else if argument == "--airspeed" {
      match following(arguments, index) {
        Some(value) =>
          match number(value) {
            Some(knots) => airspeed = knots
            None => return Bad("\{value} is not an airspeed in knots")
          }
        None => return Bad("--airspeed needs an airspeed in knots")
      }
      index = index + 2
    } else if argument == "--levels" {
      levels = true
      index = index + 1
    } else if argument == "--burn" {
      match following(arguments, index) {
        Some(value) =>
          match number(value) {
            Some(rate) => burn = Some(rate)
            None => return Bad("\{value} is not a fuel rate per hour")
          }
        None => return Bad("--burn needs a fuel rate per hour")
      }
      index = index + 2
    } else if argument == "--reserve" {
      match following(arguments, index) {
        Some(value) =>
          match whole_number(value) {
            Some(minutes) => reserve_minutes = minutes.to_double()
            None => return Bad("\{value} is not a number of minutes")
          }
        None => return Bad("--reserve needs a number of minutes")
      }
      index = index + 2
    } else if argument == "--legs" {
      match following(arguments, index) {
        Some(value) =>
          match whole_number(value) {
            Some(count) => legs = count
            None => return Bad("\{value} is not a number of legs")
          }
        None => return Bad("--legs needs a number of legs")
      }
      index = index + 2
    } else {
      match waypoint(argument) {
        Some(position) => waypoints.push(position)
        None =>
          // The first argument of a program is the program itself; anything
          // else that is not a position is a mistake worth reporting.
          if index > 0 {
            return Bad("\{argument} is not a latitude,longitude position")
          }
      }
      index = index + 1
    }
  }
  if list {
    return ListStations(product)
  }
  if waypoints.length() == 1 {
    return Bad("a route needs at least two waypoints")
  }
  if altitude < 0 {
    return Bad("an altitude cannot be below sea level")
  }
  if airspeed <= 0.0 {
    return Bad("an airspeed must be positive")
  }
  if legs < 1 {
    return Bad("a route cannot be divided into fewer than one leg")
  }
  Plan({
    product,
    station,
    altitude,
    airspeed,
    legs,
    waypoints,
    levels,
    burn,
    reserve_minutes,
  })
}

///|
/// The argument after an option, when the command line has one.
fn following(arguments : Array[String], index : Int) -> String? {
  if index + 1 < arguments.length() {
    Some(arguments[index + 1])
  } else {
    None
  }
}

///|
/// A waypoint written as a latitude and longitude in degrees, such as
/// `45,-98`.
fn waypoint(text : String) -> @moonwind.Coordinate? {
  let parts = text.split(",").to_array()
  if parts.length() != 2 {
    return None
  }
  match (degrees(parts[0]), degrees(parts[1])) {
    (Some(latitude), Some(longitude)) =>
      Some(@moonwind.Coordinate::new(latitude, longitude))
    _ => None
  }
}

///|
/// A latitude or longitude in degrees, which cannot lie outside the globe.
fn degrees(text : StringView) -> Double? {
  let value = @string.parse_double(text) catch { _ => return None }
  if value != value || value.abs() > 180.0 {
    return None
  }
  Some(value)
}

///|
/// A whole number.
fn whole_number(text : String) -> Int? {
  let value = @string.parse_int(text) catch { _ => return None }
  Some(value)
}

///|
/// A number.
fn number(text : String) -> Double? {
  Some(@string.parse_double(text)) catch {
    _ => None
  }
}

///|
/// The text of the product named on the command line.
fn product_text(name : String) -> String? {
  match name {
    "fd1us1" => Some(sample_fd1us1)
    "fd2us1" => Some(sample_fd2us1)
    "fd8us7" => Some(sample_fd8us7)
    _ => None
  }
}

///|
/// A number written with `decimals` digits after the point, which is how a
/// flight plan is read: 104.591137317 nm is 104.6 nm.
fn fixed(value : Double, decimals : Int) -> String {
  if decimals < 1 {
    return @math.round(value).to_string()
  }
  let scale = @math.pow(10.0, decimals.to_double())
  let rounded = @math.round(value * scale) / scale
  let text = rounded.to_string()
  match text.split_once(".") {
    Some((whole, fraction)) =>
      "\{whole}.\{fraction.to_owned().pad_end(decimals, '0')}"
    None => "\{text}.\{"".pad_start(decimals, '0')}"
  }
}

///|
/// A position written the way a chart has it: `44.50N 96.99W`.
fn position_text(position : @moonwind.Coordinate) -> String {
  let latitude = position.latitude()
  let longitude = position.longitude()
  let north_south = if latitude < 0.0 { "S" } else { "N" }
  let east_west = if longitude < 0.0 { "W" } else { "E" }
  "\{fixed(latitude.abs(), 2)}\{north_south} \{fixed(longitude.abs(), 2)}\{east_west}"
}

///|
/// A wind as it is reported: `150 at 14 kt`, or light and variable.
fn wind_text(wind : @moonwind.WindVector) -> String {
  let reported = wind.to_wind()
  match reported.direction() {
    Some(direction) =>
      "\{direction.to_string().pad_start(3, '0')} at \{reported.speed()} kt"
    None => "light and variable"
  }
}

///|
/// Hours written as hours and minutes, which is how a flight is read: 1.96
/// hours is 1 h 58 min.
fn hours_text(hours : Double) -> String {
  let minutes = @math.round(hours * 60.0).to_int()
  if minutes < 60 {
    "\{minutes} min"
  } else {
    "\{minutes / 60} h \{minutes % 60} min"
  }
}

///|
/// List the stations of a product, with the levels each of them reports.
fn list_stations(name : String) -> Unit {
  let text = match product_text(name) {
    Some(found) => found
    None => {
      println(
        "\{name} is not a product the demo carries: fd1us1, fd2us1, fd8us7",
      )
      return
    }
  }
  let bulletin = @moonwind.parse_bulletin(text) catch {
    _ => {
      println("the \{name} product could not be read")
      return
    }
  }
  println("")
  println("\{name}\{validity_text(bulletin)}")
  println("")
  for station in bulletin.stations() {
    let lowest = match station.lowest_altitude() {
      Some(altitude) => altitude
      None => 0
    }
    let highest = match station.highest_altitude() {
      Some(altitude) => altitude
      None => 0
    }
    println(
      "  \{station.station()}  \{lowest} to \{highest} ft, \{station.levels().length()} levels",
    )
  }
}

///|
/// The time a product is valid for, for a report.
fn validity_text(bulletin : @moonwind.Bulletin) -> String {
  let parts : Array[String] = []
  match bulletin.designator() {
    Some(designator) => parts.push(designator)
    None => ()
  }
  match bulletin.valid() {
    Some(valid) => parts.push("valid \{valid}")
    None => ()
  }
  if parts.length() == 0 {
    ""
  } else {
    "  " + parts.join("  ")
  }
}

///|
/// Plan a route and print it, leg by leg.
fn plan_route(request : Request) -> Unit {
  let text = match product_text(request.product) {
    Some(found) => found
    None => {
      println(
        "\{request.product} is not a product the demo carries: fd1us1, fd2us1, fd8us7",
      )
      return
    }
  }
  let bulletin = @moonwind.parse_bulletin(text) catch {
    _ => {
      println("the \{request.product} product could not be read")
      return
    }
  }
  let station = match bulletin.station(request.station) {
    Some(found) => found
    None => {
      println(
        "the \{request.product} product has no \{request.station}; --list shows the stations it has",
      )
      return
    }
  }
  let waypoints = if request.waypoints.length() == 0 {
    demonstration_route
  } else {
    request.waypoints
  }
  let route = @moonwind.Route::new(waypoints).subdivided(request.legs)
  if request.levels {
    print_levels(route, station, bulletin, request)
    return
  }
  let plan = route.plan(station, request.altitude, request.airspeed) catch {
    @moonwind.FlightError::NoForecast(altitude) => {
      println(
        "\{station.station()} forecasts no wind at \{altitude} ft: it reports \{level_range_text(station)}",
      )
      return
    }
    @moonwind.FlightError::NoGroundSpeed(speed) => {
      println(
        "the wind at \{request.altitude} ft beats the aircraft: a ground speed of \{fixed(speed, 1)} kt leaves no time to fly the leg",
      )
      return
    }
    _ => {
      println("the route could not be planned")
      return
    }
  }
  println("")
  println(
    "moonwind — a flight plan from a winds and temperatures aloft product",
  )
  println("")
  println("  product   \{request.product}\{validity_text(bulletin)}")
  println(
    "  station   \{station.station()} at \{request.altitude} ft: \{wind_text(station_wind(station, request.altitude))}\{temperature_text(station, request.altitude)}",
  )
  println(
    "  route     \{waypoints.length()} waypoints, \{plan.legs().length()} legs, \{fixed(plan.total_distance(), 1)} nm, at \{fixed(request.airspeed, 0)} kt",
  )
  println("")
  let legs = plan.legs()
  for index = 0; index < legs.length(); index = index + 1 {
    let leg = legs[index]
    println(
      "  leg \{(index + 1).to_string().pad_start(2, ' ')}   \{position_text(leg.segment().from())} -> \{position_text(leg.segment().to())}",
    )
    println(
      "          \{fixed(leg.distance(), 1)} nm on course \{fixed(leg.true_course(), 1)}, wind \{wind_text(leg.wind())}, heading \{fixed(leg.heading(), 1)} at \{fixed(leg.ground_speed(), 1)} kt, \{fixed(leg.time_hours(), 2)} h",
    )
    println(
      "          \{fixed(leg.crosswind(), 1)} kt across, \{fixed(leg.headwind(), 1)} kt along",
    )
  }
  println("")
  println(
    "  total     \{fixed(plan.total_distance(), 1)} nm in \{hours_text(plan.total_time_hours())}",
  )
  println(
    "  wind      \{signed_hours(plan.wind_effect_hours())} against \{hours_text(plan.still_air_time_hours())} in still air",
  )
  match request.burn {
    Some(rate) => fuel_line(plan, rate, request.reserve_minutes)
    None => ()
  }
  println("")
}

///|
/// A signed duration in hours, written as minutes: what the wind cost, or
/// gave back.
fn signed_hours(hours : Double) -> String {
  let magnitude = if hours < 0.0 { -hours } else { hours }
  let minutes = (magnitude * 60.0 + 0.5).to_int()
  if minutes == 0 {
    "no time either way"
  } else if hours > 0.0 {
    "+\{minutes} min"
  } else {
    "-\{minutes} min"
  }
}

///|
/// The fuel a plan needs, on one line.
fn fuel_line(
  plan : @moonwind.RoutePlan,
  burn : Double,
  reserve_minutes : Double,
) -> Unit {
  let fuel = plan.fuel(burn, reserve_minutes)
  println(
    "  fuel      \{fixed(fuel.trip_fuel(), 1)} for the flight, \{fixed(fuel.reserve_fuel(), 1)} reserve (\{fixed(reserve_minutes, 0)} min), \{fixed(fuel.total_fuel(), 1)} aboard",
  )
}

///|
/// Every level the station forecasts, fastest first, with what the wind is
/// worth at each.
fn print_levels(
  route : @moonwind.Route,
  station : @moonwind.StationWinds,
  bulletin : @moonwind.Bulletin,
  request : Request,
) -> Unit {
  let plans = route.plans_at_levels(station, request.airspeed)
  println("")
  println("moonwind — the levels this forecast makes available")
  println("")
  if plans.length() == 0 {
    println(
      "  no level \{station.station()} forecasts can be flown at \{fixed(request.airspeed, 0)} kt",
    )
    return
  }
  println("  product   \{request.product}\{validity_text(bulletin)}")
  println(
    "  route     \{fixed(plans[0].total_distance(), 1)} nm at \{fixed(request.airspeed, 0)} kt",
  )
  println("")
  println("  altitude   wind                     ground    time      wind")
  for plan in plans {
    println(
      "  \{plan.altitude().to_string().pad_start(6, ' ')} ft   \{pad_right(wind_text(station_wind(station, plan.altitude())), 22)} \{pad_right(fixed(plan.total_distance() / plan.total_time_hours(), 1) + " kt", 8)}  \{pad_right(hours_text(plan.total_time_hours()), 8)}  \{signed_hours(plan.wind_effect_hours())}",
    )
  }
  println("")
  println(
    "  fastest   \{plans[0].altitude()} ft, \{hours_text(plans[0].total_time_hours())}, \{signed_hours(plans[0].wind_effect_hours())}",
  )
  match request.burn {
    Some(rate) => fuel_line(plans[0], rate, request.reserve_minutes)
    None => ()
  }
  println("")
}

///|
/// `text` padded on the right, so a column of them lines up.
fn pad_right(text : String, width : Int) -> String {
  let mut out = text
  while out.length() < width {
    out = out + " "
  }
  out
}

///|
/// The wind a station forecasts at an altitude, which the plan has already
/// found to be there.
fn station_wind(
  station : @moonwind.StationWinds,
  altitude : Int,
) -> @moonwind.WindVector {
  match station.vector_at(altitude) {
    Some(wind) => wind
    None => @moonwind.WindVector::from_components(0.0, 0.0)
  }
}

///|
/// The temperature a station forecasts at an altitude, when it forecasts one.
fn temperature_text(station : @moonwind.StationWinds, altitude : Int) -> String {
  match station.temperature_at(altitude) {
    Some(celsius) => ", \{fixed(celsius, 1)} C"
    None => ""
  }
}

///|
/// The levels a station reports, for a message about the ones it does not.
fn level_range_text(station : @moonwind.StationWinds) -> String {
  match (station.lowest_altitude(), station.highest_altitude()) {
    (Some(lowest), Some(highest)) => "\{lowest} to \{highest} ft"
    _ => "no levels"
  }
}