///|
/// Errors raised while reading or writing winds-aloft (FD) bulletins.
pub suberror FdError {
  /// A required header line (the `FT` column header) is missing.
  MissingLevelHeader
  /// The `FT` header lists no usable levels.
  BadLevelHeader(String)
  /// A station line is too short or its identifier is malformed.
  BadStationLine(String)
  /// A wind group is not four digits, or decodes outside the valid range.
  BadWindGroup(String)
  /// A temperature field is malformed.
  BadTemperature(String)
}

///|
/// One upper-air wind: the direction the wind blows *from*, in degrees true,
/// and its speed in knots.
///
/// `direction` is `None` for a light and variable wind (encoded `9900`),
/// which is a wind below 5 knots with no useful direction. Directions are
/// always multiples of 10 degrees, as the wire format carries tens of
/// degrees.
pub struct Wind {
  direction : Int32?
  speed : Int32
}

///|
/// One forecast level of a station: the altitude in feet above mean sea
/// level, the wind, and the temperature in degrees Celsius. `temperature` is
/// `None` when the product does not forecast one, which is always the case at
/// 3,000 ft and at levels close to the station elevation.
pub struct Level {
  altitude : Int32
  wind : Wind
  temperature : Int32?
}

///|
/// The winds and temperatures aloft forecast for one station.
///
/// `levels` holds only the altitudes the product actually reports for this
/// station; a missing level is simply absent, exactly as a blank column in
/// the bulletin.
pub struct StationWinds {
  station : String
  levels : Array[Level]
}

///|
/// A parsed winds and temperatures aloft bulletin, such as the `FD1US1`
/// product.
///
/// The header lines are kept verbatim so that `format_bulletin` reproduces
/// the original text byte for byte.
pub struct Bulletin {
  /// Every line before the `FT` column header, verbatim.
  header : Array[String]
  /// The `FT` column header line, verbatim.
  level_header : String
  /// Forecast levels in column order, in feet MSL, parsed from `level_header`.
  altitudes : Array[Int32]
  /// The WMO abbreviated heading, such as `FBUS31 KWNO 221358`.
  wmo_header : String?
  /// The product designator, such as `FD1US1`.
  designator : String?
  /// The `DATA BASED ON ...Z` observation time, when present.
  based_on : String?
  /// The `VALID ...Z` valid time, when present.
  valid : String?
  /// The `FOR USE ...Z` usable window, when present.
  use_window : String?
  /// The altitude above which temperatures are reported without a sign,
  /// parsed from `TEMPS NEG ABV n`; 24,000 ft in current products.
  temps_negative_above : Int32?
  stations : Array[StationWinds]
  /// `stations` by identifier, so that finding one station does not look at
  /// the whole product.
  index : @hashmap.HashMap[String, StationWinds]
  /// Lines after the last station line, verbatim. Present in some products,
  /// which end with a list of stations that were not received.
  trailing : Array[String]
}

///|
/// A wind from the direction it blows from and its speed, which is how one is
/// reported: `Wind::new(Some(150), 14)` is 150 degrees at 14 kt, and
/// `Wind::new(None, 0)` is light and variable.
pub fn Wind::new(direction : Int32?, speed : Int32) -> Wind {
  { direction, speed, }
}

///|
/// Accessors.
pub fn Wind::direction(self : Wind) -> Int32? {
  self.direction
}

///|
pub fn Wind::speed(self : Wind) -> Int32 {
  self.speed
}

///|
/// Whether this wind is a light and variable report (`9900`).
pub fn Wind::is_light_and_variable(self : Wind) -> Bool {
  self.direction is None && self.speed == 0
}

///|
pub fn Level::altitude(self : Level) -> Int32 {
  self.altitude
}

///|
pub fn Level::wind(self : Level) -> Wind {
  self.wind
}

///|
pub fn Level::temperature(self : Level) -> Int32? {
  self.temperature
}

///|
pub fn StationWinds::station(self : StationWinds) -> String {
  self.station
}

///|
pub fn StationWinds::levels(self : StationWinds) -> Array[Level] {
  self.levels
}

///|
/// The level at exactly `altitude` feet, if the station reports one.
pub fn StationWinds::level_at(self : StationWinds, altitude : Int32) -> Level? {
  for level in self.levels {
    if level.altitude == altitude {
      return Some(level)
    }
  }
  None
}

///|
pub fn Bulletin::stations(self : Bulletin) -> Array[StationWinds] {
  self.stations
}

///|
/// The levels the product forecasts, in feet MSL, as the `FT` header lists
/// them. Stations do not all report every one of them.
pub fn Bulletin::altitudes(self : Bulletin) -> Array[Int32] {
  self.altitudes
}

///|
/// The WMO abbreviated heading of the product, such as `FBUS31 KWNO 221358`,
/// when it carries one.
pub fn Bulletin::wmo_header(self : Bulletin) -> String? {
  self.wmo_header
}

///|
/// The product designator, such as `FD1US1`, when the product carries one.
pub fn Bulletin::designator(self : Bulletin) -> String? {
  self.designator
}

///|
/// The observation time the forecast is based on, as the product writes it.
pub fn Bulletin::based_on(self : Bulletin) -> String? {
  self.based_on
}

///|
/// The time the forecast is valid for, as the product writes it.
pub fn Bulletin::valid(self : Bulletin) -> String? {
  self.valid
}

///|
/// The window the forecast is meant to be used in, as the product writes it.
pub fn Bulletin::use_window(self : Bulletin) -> String? {
  self.use_window
}

///|
/// The altitude above which temperatures are reported without a sign, in feet
/// MSL, from `TEMPS NEG ABV n`; 24,000 ft in current products.
pub fn Bulletin::temps_negative_above(self : Bulletin) -> Int32? {
  self.temps_negative_above
}

///|
/// The forecast for one station, by its three-character identifier.
pub fn Bulletin::station(self : Bulletin, id : String) -> StationWinds? {
  self.index.get(id)
}