///|
/// A small report tool: takes six pollutant concentrations and prints the
/// index they add up to, the way a monitoring station publishes one.
///
/// ```
/// moon run cmd/main
/// moon run cmd/main -- --realtime
/// moon run cmd/main -- --pm25 150 --pm10 250
/// ```

///|
/// A non-negative decimal, or `None` when `text` is not one.
fn parse_decimal(text : StringView) -> Double? {
  if text.length() == 0 {
    return None
  }
  let mut value = 0.0
  let mut i = 0
  let mut whole_digits = 0
  while i < text.length() && text.at(i) >= '0' && text.at(i) <= '9' {
    value = value * 10.0 + (text.at(i).to_int() - '0'.to_int()).to_double()
    i += 1
    whole_digits += 1
  }
  if i >= text.length() {
    if whole_digits == 0 {
      return None
    }
    return Some(value)
  }
  if text.at(i) != '.' {
    return None
  }
  i += 1
  let mut scale = 0.1
  let mut fraction_digits = 0
  while i < text.length() && text.at(i) >= '0' && text.at(i) <= '9' {
    value = value + (text.at(i).to_int() - '0'.to_int()).to_double() * scale
    scale = scale * 0.1
    i += 1
    fraction_digits += 1
  }
  if i != text.length() || (whole_digits == 0 && fraction_digits == 0) {
    return None
  }
  Some(value)
}

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

///|
/// The pollutant a flag names, or `None` when the flag is not one of the six.
fn pollutant_of(flag : StringView) -> @moonaqi.Pollutant? {
  let name = flag.to_owned()
  if name == "--pm25" {
    Some(@moonaqi.Pm25)
  } else if name == "--pm10" {
    Some(@moonaqi.Pm10)
  } else if name == "--so2" {
    Some(@moonaqi.So2)
  } else if name == "--no2" {
    Some(@moonaqi.No2)
  } else if name == "--co" {
    Some(@moonaqi.Co)
  } else if name == "--o3" {
    Some(@moonaqi.O3)
  } else {
    None
  }
}

///|
/// The default readings: a moderately polluted winter day.
fn default_concentrations() -> Array[(@moonaqi.Pollutant, Double)] {
  [
    (@moonaqi.Pm25, 115.0),
    (@moonaqi.Pm10, 120.0),
    (@moonaqi.So2, 50.0),
    (@moonaqi.No2, 40.0),
    (@moonaqi.Co, 4.0),
    (@moonaqi.O3, 160.0),
  ]
}

///|
/// Replace the concentration of `pollutant`.
fn set_concentration(
  concentrations : Array[(@moonaqi.Pollutant, Double)],
  pollutant : @moonaqi.Pollutant,
  value : Double,
) -> Unit {
  for i = 0; i < concentrations.length(); i = i + 1 {
    if concentrations[i].0 == pollutant {
      concentrations[i] = (pollutant, value)
    }
  }
}

///|
/// The report's readings, with any `--name value` overrides applied, or
/// `None` when an argument is not understood.
fn readings_from(
  args : Array[String],
  report : @moonaqi.Report,
) -> Array[@moonaqi.Reading]? {
  let concentrations = default_concentrations()
  let mut i = 0
  while i < args.length() {
    let arg = args[i]
    match pollutant_of(arg) {
      Some(pollutant) => {
        if i + 1 >= args.length() {
          return None
        }
        match parse_decimal(args[i + 1]) {
          Some(value) => {
            set_concentration(concentrations, pollutant, value)
            i += 2
          }
          None => return None
        }
      }
      None =>
        if arg == "--realtime" || arg == "--json" {
          i += 1
        } else {
          return None
        }
    }
  }
  let readings : Array[@moonaqi.Reading] = []
  for entry in concentrations {
    readings.push(@moonaqi.Reading::of(entry.0, report, entry.1))
  }
  Some(readings)
}

///|
/// Whether the arguments ask for the real-time report.
fn wants_realtime(args : Array[String]) -> Bool {
  for arg in args {
    if arg == "--realtime" {
      return true
    }
  }
  false
}

///|
/// Whether the arguments ask for JSON.
fn wants_json(args : Array[String]) -> Bool {
  for arg in args {
    if arg == "--json" {
      return true
    }
  }
  false
}

///|
/// Whether the arguments ask for the usage.
fn wants_help(args : Array[String]) -> Bool {
  for arg in args {
    if arg == "--help" {
      return true
    }
  }
  false
}

///|
/// A whole number, or `None` when the text is not one.
fn whole_number(text : String) -> Int32? {
  match parse_decimal(text) {
    Some(value) =>
      if value < 0.0 || value != value.to_int().to_double() {
        None
      } else {
        Some(value.to_int())
      }
    None => None
  }
}

///|
/// The pollutant a name gives, as the tables spell it: `PM2.5`, `PM10`,
/// `SO2`, `NO2`, `CO` or `O3`.
fn pollutant_named(name : String) -> @moonaqi.Pollutant? {
  if name == "PM2.5" {
    Some(@moonaqi.Pm25)
  } else if name == "PM10" {
    Some(@moonaqi.Pm10)
  } else if name == "SO2" {
    Some(@moonaqi.So2)
  } else if name == "NO2" {
    Some(@moonaqi.No2)
  } else if name == "CO" {
    Some(@moonaqi.Co)
  } else if name == "O3" {
    Some(@moonaqi.O3)
  } else {
    None
  }
}

///|
/// A day's worth of hours for one pollutant, written on the command line.
///
/// Elements are separated by commas. An element is a concentration, or a
/// concentration and a repeat count joined by `x`, or nothing at all for an
/// hour with no reading:
///
///     PM2.5=35x24
///     O3=100x12,160x12
///     NO2=,40x23
fn parse_series(text : String) -> Array[Double?]? {
  let hours : Array[Double?] = []
  let view : StringView = text
  let mut at = 0
  while at <= view.length() {
    // The element runs to the next comma, or to the end.
    let mut end = at
    while end < view.length() && view.at(end) != ',' {
      end += 1
    }
    let element = view[at:end]
    if element.length() == 0 {
      hours.push(None)
    } else {
      // `value` or `value xN`.
      let mut split = element.length()
      for i = 0; i < element.length(); i = i + 1 {
        if element.at(i) == 'x' || element.at(i) == 'X' {
          split = i
          break
        }
      }
      let value_text = element[0:split]
      match parse_decimal(value_text) {
        Some(value) => {
          let count = if split >= element.length() {
            1
          } else {
            match whole_number(element[split + 1:element.length()].to_owned()) {
              Some(found) => found
              None => return None
            }
          }
          for i = 0; i < count; i = i + 1 {
            hours.push(Some(value))
          }
        }
        None => return None
      }
    }
    if end >= view.length() {
      break
    }
    at = end + 1
  }
  Some(hours)
}

///|
/// Whether the command line is feeding a day of hours rather than one set of
/// readings.
fn has_series(args : Array[String]) -> Bool {
  for arg in args {
    if arg == "--series" {
      return true
    }
  }
  false
}

///|
/// A day of hours from the command line, and what to report it under.
priv struct DayRequest {
  station : String
  series : Array[(@moonaqi.Pollutant, Array[Double?])]
  standard : @moonaqi.Standard
  daily_minimum : Int32
  ozone_minimum : Int32
}

///|
/// Read the day's arguments, or `None` when one of them is not understood.
fn day_request(args : Array[String]) -> DayRequest? {
  let series : Array[(@moonaqi.Pollutant, Array[Double?])] = []
  let mut station = "STATION"
  let mut standard = @moonaqi.China
  let mut daily_minimum = 20
  let mut ozone_minimum = 6
  let mut index = 0
  while index < args.length() {
    let argument = args[index]
    if argument == "--series" {
      if index + 1 >= args.length() {
        return None
      }
      let specification = args[index + 1]
      let equals = match index_of_char(specification, '=') {
        Some(found) => found
        None => return None
      }
      let spelling : StringView = specification
      let pollutant = match pollutant_named(spelling[0:equals].to_owned()) {
        Some(found) => found
        None => return None
      }
      let hours = match
        parse_series(spelling[equals + 1:spelling.length()].to_owned()) {
        Some(found) => found
        None => return None
      }
      series.push((pollutant, hours))
      index += 2
    } else if argument == "--station" {
      if index + 1 >= args.length() {
        return None
      }
      station = args[index + 1]
      index += 2
    } else if argument == "--standard" {
      if index + 1 >= args.length() {
        return None
      }
      standard = if args[index + 1] == "epa" {
        @moonaqi.Epa
      } else if args[index + 1] == "china" {
        @moonaqi.China
      } else if args[index + 1] == "china2012" {
        @moonaqi.China2012
      } else {
        return None
      }
      index += 2
    } else if argument == "--daily-min" {
      match following_whole(args, index) {
        Some(found) => {
          daily_minimum = found
          index += 2
        }
        None => return None
      }
    } else if argument == "--ozone-min" {
      match following_whole(args, index) {
        Some(found) => {
          ozone_minimum = found
          index += 2
        }
        None => return None
      }
    } else {
      return None
    }
  }
  if series.length() == 0 {
    return None
  }
  Some({ station, series, standard, daily_minimum, ozone_minimum, })
}

///|
fn following_whole(args : Array[String], index : Int32) -> Int32? {
  if index + 1 >= args.length() {
    None
  } else {
    whole_number(args[index + 1])
  }
}

///|
/// The index of the first `c` in `text`, or `None`.
fn index_of_char(text : String, c : Char) -> Int32? {
  let view : StringView = text
  for i = 0; i < view.length(); i = i + 1 {
    if view.at(i).to_int() == c.to_int() {
      return Some(i)
    }
  }
  None
}

///|
/// Report a day of hours.
fn run_day(request : DayRequest) -> Unit {
  let day = @moonaqi.Day::new(request.station)
  for entry in request.series {
    for value in entry.1 {
      day.record(entry.0, value)
    }
  }
  let rules = @moonaqi.DayRules::new(
    @moonaqi.Window::new(24, request.daily_minimum),
    @moonaqi.Window::new(8, request.ozone_minimum),
  )
  let air = match day.aqi(request.standard, rules) {
    Some(found) => found
    None => {
      println("no indicator survived the averaging rules")
      return
    }
  }
  println("")
  println("moonaqi — a day of hours, averaged and indexed")
  println("")
  println("  station   \{request.station}\{standard_text(request.standard)}")
  println(
    "  rules     a day over 24 hours with at least \{request.daily_minimum} reported, ozone over its highest 8 with at least \{request.ozone_minimum}",
  )
  println("")
  for hours in day.hours() {
    let pollutant = hours.pollutant()
    let rule = if pollutant is @moonaqi.O3 {
      "8-hour maximum"
    } else {
      "24-hour average"
    }
    let indicator = match pollutant {
      @moonaqi.O3 =>
        hours.maximum_rolling(@moonaqi.Window::new(8, request.ozone_minimum))
      _ => hours.mean(request.daily_minimum)
    }
    let reported = hours.values().length()
    let valid = hours.valid_between(0, reported - 1)
    // The unit is the one the chosen standard measures the pollutant in, so
    // that a concentration fed in the other standard's units is visibly wrong
    // rather than quietly indexed.
    let unit = match request.standard {
      @moonaqi.Epa => @moonaqi.epa_unit(pollutant)
      _ => pollutant.unit()
    }
    let text = match indicator {
      Some(value) => "\{value} \{unit}"
      None => "left out"
    }
    println(
      "  " +
      pad_right(pollutant.name(), 8) +
      pad_right("\{reported} hours, \{valid} reported", 24) +
      pad_right(rule, 18) +
      text,
    )
  }
  println("")
  println(
    "  AQI \{air.index()}   \{air.category().name()} / \{air.category().chinese_name()}" +
    "   \{air.category().colour_name()} / \{air.category().chinese_colour_name()}",
  )
  if air.dominant().length() > 0 {
    println("  primary pollutant   \{names_of(air.dominant())}")
  }
  if air.non_attainment().length() > 0 {
    println("  over the limit      \{names_of(air.non_attainment())}")
  }
}

///|
fn standard_text(standard : @moonaqi.Standard) -> String {
  match standard {
    @moonaqi.China => " (HJ 633—2026)"
    @moonaqi.China2012 => " (HJ 633—2012)"
    @moonaqi.Epa => " (US EPA)"
  }
}

///|
/// A list of names as a JSON array.
fn json_names(names : Array[String]) -> String {
  let out = StringBuilder(size_hint=32)
  out.write_string("[")
  for i = 0; i < names.length(); i = i + 1 {
    if i > 0 {
      out.write_string(", ")
    }
    out.write_string("\"" + names[i] + "\"")
  }
  out.write_string("]")
  out.to_string()
}

///|
/// The pollutants in `pollutants`, as a JSON array of names.
fn json_pollutant_names(pollutants : Array[@moonaqi.Pollutant]) -> String {
  let names : Array[String] = []
  for pollutant in pollutants {
    names.push(pollutant.name())
  }
  json_names(names)
}

///|
/// The report as JSON.
fn report_json(
  air : @moonaqi.AirQuality,
  readings : Array[@moonaqi.Reading],
  report : @moonaqi.Report,
) -> String {
  let out = StringBuilder(size_hint=512)
  out.write_string("{\n")
  out.write_string(
    "  \"report\": \"" +
    (if report is @moonaqi.Daily { "daily" } else { "realtime" }) +
    "\",\n",
  )
  out.write_string("  \"aqi\": \{air.index()},\n")
  out.write_string("  \"category\": \"\{air.category().name()}\",\n")
  out.write_string(
    "  \"chinese_category\": \"\{air.category().chinese_name()}\",\n",
  )
  out.write_string("  \"colour\": \"\{air.category().colour_name()}\",\n")
  let rgb = air.category().rgb()
  out.write_string(
    "  \"colour_rgb\": [\{rgb.red()}, \{rgb.green()}, \{rgb.blue()}],\n",
  )
  out.write_string("  \"readings\": [\n")
  let entries = air.sub_indices()
  for i = 0; i < entries.length(); i = i + 1 {
    let pollutant = entries[i].0
    let mut value = 0.0
    for reading in readings {
      if reading.pollutant() == pollutant {
        value = reading.concentration()
      }
    }
    out.write_string(
      "    {\"pollutant\": \"" +
      pollutant.name() +
      "\", \"concentration\": " +
      value.to_string() +
      ", \"unit\": \"" +
      pollutant.unit() +
      "\", \"iaqi\": " +
      entries[i].1.to_string() +
      "}",
    )
    if i + 1 < entries.length() {
      out.write_string(",")
    }
    out.write_string("\n")
  }
  out.write_string("  ],\n")
  out.write_string(
    "  \"primary_pollutants\": " + json_pollutant_names(air.dominant()) + ",\n",
  )
  out.write_string(
    "  \"non_attainment\": " + json_pollutant_names(air.non_attainment()) + "\n",
  )
  out.write_string("}")
  out.to_string()
}

///|
/// The pollutants as a comma-separated list.
fn names_of(pollutants : Array[@moonaqi.Pollutant]) -> String {
  let out = StringBuilder(size_hint=16)
  let mut first = true
  for pollutant in pollutants {
    if !first {
      out.write_string(", ")
    }
    out.write_string(pollutant.name())
    first = false
  }
  out.to_string()
}

///|
fn usage() -> Unit {
  println("moonaqi — the air quality index from six pollutant concentrations")
  println("")
  println("  moon run cmd/main [--realtime] [--json] [--pm25 N] [--pm10 N]")
  println("                    [--so2 N] [--no2 N] [--co N] [--o3 N]")
  println("")
  println("  PM2.5, PM10, SO2, NO2 and O3 are ug/m3; CO is mg/m3.")
  println("  Without --realtime the readings are read as a daily report.")
}

///|
fn main {
  // The first argument is the program itself.
  let argv = @env.args()
  let args : Array[String] = []
  for i = 1; i < argv.length(); i = i + 1 {
    args.push(argv[i])
  }
  if wants_help(args) {
    usage()
    return
  }
  if has_series(args) {
    match day_request(args) {
      Some(request) => run_day(request)
      None => {
        println("the day's arguments could not be read")
        println("")
        usage()
      }
    }
    return
  }
  let report = if wants_realtime(args) {
    @moonaqi.RealTime
  } else {
    @moonaqi.Daily
  }
  let readings = match readings_from(args, report) {
    Some(readings) => readings
    None => {
      usage()
      return
    }
  }
  let air = match @moonaqi.aqi(readings) {
    Some(air) => air
    None => {
      println("no usable readings")
      return
    }
  }
  if wants_json(args) {
    println(report_json(air, readings, report))
    return
  }
  println(
    if report is @moonaqi.Daily {
      "daily report (日报)"
    } else {
      "real-time report (实时报)"
    },
  )
  println("")
  println(
    "  " + pad_right("pollutant", 11) + pad_right("concentration", 16) + "IAQI",
  )
  for entry in air.sub_indices() {
    let pollutant = entry.0
    let mut value = 0.0
    for reading in readings {
      if reading.pollutant() == pollutant {
        value = reading.concentration()
      }
    }
    println(
      "  " +
      pad_right(pollutant.name(), 11) +
      pad_right("\{value} \{pollutant.unit()}", 16) +
      entry.1.to_string(),
    )
  }
  println("")
  println(
    "  AQI \{air.index()}   \{air.category().name()} / \{air.category().chinese_name()}" +
    "   \{air.category().colour_name()} / \{air.category().chinese_colour_name()}",
  )
  if air.dominant().length() > 0 {
    println("  primary pollutant   \{names_of(air.dominant())}")
  }
  if air.non_attainment().length() > 0 {
    println("  over the limit      \{names_of(air.non_attainment())}")
  }
}