///|
/// One timestamped reference height used for validation.
pub struct ReferencePrediction {
  time_jd : Double
  height : Double
} derive(Debug)

///|
/// Error statistics for a MoonTide prediction compared with an external source.
pub struct ValidationStats {
  sample_count : Int32
  bias : Double
  mean_absolute_error : Double
  root_mean_square_error : Double
  max_absolute_error : Double
  tolerance : Double
  passed : Bool
} derive(Debug)

///|
fn object_field(root : Json, name : String) -> Json? {
  match root {
    Json::Object(map) => map.get(name)
    _ => None
  }
}

///|
fn array_field(root : Json, name : String) -> Array[Json]? {
  match object_field(root, name) {
    Some(Json::Array(values)) => Some(values)
    _ => None
  }
}

///|
fn first_station(metadata : Json) -> Json? {
  match array_field(metadata, "stations") {
    Some(stations) if stations.length() > 0 => Some(stations[0])
    _ => None
  }
}

///|
fn noaa_datum_value(datums : Json, wanted : String) -> Double? {
  let mut result = None
  match array_field(datums, "datums") {
    Some(items) =>
      for item in items {
        if json_string(object_field(item, "name")) == Some(wanted) {
          result = json_number(object_field(item, "value"))
          break
        }
      }
    None => ()
  }
  result
}

///|
fn noaa_conversion_datum(datums : Json?) -> Double {
  match datums {
    Some(root) =>
      match (noaa_datum_value(root, "MSL"), noaa_datum_value(root, "MLLW")) {
        (Some(msl), Some(mllw)) => msl - mllw
        _ => 0.0
      }
    None => 0.0
  }
}

///|
/// Return NOAA constituent names that MoonTide's harmonic catalog cannot use.
pub fn noaa_unsupported_constituents(harcon : Json) -> Array[String] {
  let unsupported = Array(capacity=0)
  match array_field(harcon, "HarmonicConstituents") {
    Some(items) =>
      for item in items {
        match json_string(object_field(item, "name")) {
          Some(name) =>
            if find_constituent(name) is None && !unsupported.contains(name) {
              unsupported.push(name)
            }
          None => ()
        }
      }
    None => ()
  }
  unsupported
}

///|
/// Build a station from NOAA CO-OPS metadata, harmonic constants, and datums.
///
/// NOAA harmonic constants are referenced to MSL while the common prediction
/// API used in validation is referenced to MLLW. When datum data is supplied,
/// MoonTide records `MSL - MLLW` as the station datum so predictions use the
/// same vertical reference.
pub fn station_from_noaa(
  metadata : Json,
  harcon : Json,
  datums? : Json,
) -> Station? {
  let station_json = match first_station(metadata) {
    Some(value) => value
    None => return None
  }
  let id = match json_string(object_field(station_json, "id")) {
    Some(value) => value
    None => return None
  }
  let name = match json_string(object_field(station_json, "name")) {
    Some(value) => value
    None => return None
  }
  let lat = match json_number(object_field(station_json, "lat")) {
    Some(value) => value
    None => return None
  }
  let lon = match json_number(object_field(station_json, "lng")) {
    Some(value) => value
    None => return None
  }
  let raw_components = match array_field(harcon, "HarmonicConstituents") {
    Some(values) => values
    None => return None
  }
  let components = Array(capacity=raw_components.length())
  for item in raw_components {
    let component_name = match json_string(object_field(item, "name")) {
      Some(value) => value
      None => continue
    }
    let constituent = match find_constituent(component_name) {
      Some(value) => value
      None => continue
    }
    let amplitude = match json_number(object_field(item, "amplitude")) {
      Some(value) => value
      None => continue
    }
    let phase_lag = match json_number(object_field(item, "phase_GMT")) {
      Some(value) => value
      None => continue
    }
    components.push({ constituent, amplitude, phase_lag, })
  }
  if components.is_empty() {
    return None
  }
  let datum = noaa_conversion_datum(datums)
  Some({
    id,
    name,
    lat,
    lon,
    datum,
    components,
    source: "NOAA CO-OPS station \{id}; harmonic constants + published datums",
  })
}

///|
/// Parse NOAA CO-OPS prediction JSON (`time_zone=gmt`).
pub fn predictions_from_noaa(root : Json) -> Array[ReferencePrediction]? {
  let items = match array_field(root, "predictions") {
    Some(values) => values
    None => return None
  }
  let predictions = Array(capacity=items.length())
  for item in items {
    let timestamp = match json_string(object_field(item, "t")) {
      Some(value) => value
      None => return None
    }
    let height_text = match json_string(object_field(item, "v")) {
      Some(value) => value
      None => return None
    }
    let moment = match parse_local_datetime(timestamp) {
      Some(value) => value
      None => return None
    }
    let height = @string.parse_double(height_text) catch { _ => return None }
    predictions.push({ time_jd: moment_to_jd(moment, 0.0), height, })
  }
  if predictions.is_empty() {
    None
  } else {
    Some(predictions)
  }
}

///|
/// Compare modeled tide heights with externally supplied reference predictions.
pub fn compare_with_reference(
  station : Station,
  references : Array[ReferencePrediction],
  tolerance : Double,
) -> ValidationStats? {
  if references.is_empty() {
    return None
  }
  let mut bias = 0.0
  let mut absolute_error = 0.0
  let mut squared_error = 0.0
  let mut max_absolute_error = 0.0
  for reference in references {
    let error = tide_height(station, reference.time_jd) - reference.height
    let absolute = if error < 0.0 { -error } else { error }
    bias = bias + error
    absolute_error = absolute_error + absolute
    squared_error = squared_error + error * error
    if absolute > max_absolute_error {
      max_absolute_error = absolute
    }
  }
  let count = references.length().to_double()
  let mean_absolute_error = absolute_error / count
  Some({
    sample_count: references.length(),
    bias: bias / count,
    mean_absolute_error,
    root_mean_square_error: (squared_error / count).sqrt(),
    max_absolute_error,
    tolerance,
    passed: mean_absolute_error <= tolerance,
  })
}

///|
/// Render NOAA validation statistics as Markdown.
pub fn validation_stats_to_markdown(stats : ValidationStats) -> String {
  let result = if stats.passed { "PASS" } else { "FAIL" }
  "- Samples: \{stats.sample_count}\n" +
  "- Bias: \{format_meters(stats.bias)} m\n" +
  "- Mean absolute error: \{format_meters(stats.mean_absolute_error)} m\n" +
  "- Root mean square error: \{format_meters(stats.root_mean_square_error)} m\n" +
  "- Maximum absolute error: \{format_meters(stats.max_absolute_error)} m\n" +
  "- Mean-error tolerance: \{format_meters(stats.tolerance)} m\n" +
  "- Result: **\{result}**\n"
}