///|
/// Internal state for an excursion candidate.
priv struct EventAccumulator {
  sensor_id : String
  kind : ExcursionKind
  started_at : Int64
  last_timestamp : Int64
  last_temperature : Double
  sample_count : Int
  minimum_c : Double
  maximum_c : Double
  temperature_sum : Double
  degree_seconds : Double
  peak_deviation_c : Double
}

///|
/// Determine whether a sample begins a low or high excursion.
fn trigger_kind(
  temperature : Double,
  policy : TemperaturePolicy,
) -> ExcursionKind? {
  if temperature < policy.lower_c {
    Some(LowTemperature)
  } else if temperature > policy.upper_c {
    Some(HighTemperature)
  } else {
    None
  }
}

///|
/// Deviation beyond the relevant temperature threshold.
fn threshold_deviation(
  temperature : Double,
  kind : ExcursionKind,
  policy : TemperaturePolicy,
) -> Double {
  match kind {
    LowTemperature => clamp_double(policy.lower_c - temperature, 0.0, 10000.0)
    HighTemperature => clamp_double(temperature - policy.upper_c, 0.0, 10000.0)
    _ => 0.0
  }
}

///|
/// Recovery requires crossing the hysteresis boundary.
fn recovered_from(
  temperature : Double,
  kind : ExcursionKind,
  policy : TemperaturePolicy,
) -> Bool {
  match kind {
    LowTemperature => temperature >= policy.lower_c + policy.hysteresis_c
    HighTemperature => temperature <= policy.upper_c - policy.hysteresis_c
    _ => true
  }
}

///|
/// Start a candidate from one out-of-range sample.
fn start_accumulator(
  sample : Reading,
  kind : ExcursionKind,
  policy : TemperaturePolicy,
) -> EventAccumulator {
  let deviation = threshold_deviation(sample.temperature_c, kind, policy)
  {
    sensor_id: sample.sensor_id,
    kind,
    started_at: sample.timestamp,
    last_timestamp: sample.timestamp,
    last_temperature: sample.temperature_c,
    sample_count: 1,
    minimum_c: sample.temperature_c,
    maximum_c: sample.temperature_c,
    temperature_sum: sample.temperature_c,
    degree_seconds: 0.0,
    peak_deviation_c: deviation,
  }
}

///|
/// Advance a candidate with trapezoidal degree-second integration.
fn advance_accumulator(
  accumulator : EventAccumulator,
  sample : Reading,
  policy : TemperaturePolicy,
) -> EventAccumulator {
  let elapsed = sample.timestamp - accumulator.last_timestamp
  let previous_deviation = threshold_deviation(
    accumulator.last_temperature,
    accumulator.kind,
    policy,
  )
  let current_deviation = threshold_deviation(
    sample.temperature_c,
    accumulator.kind,
    policy,
  )
  let exposure = if elapsed > 0L {
    (previous_deviation + current_deviation) / 2.0 * elapsed.to_double()
  } else {
    0.0
  }
  {
    ..accumulator,
    last_timestamp: sample.timestamp,
    last_temperature: sample.temperature_c,
    sample_count: accumulator.sample_count + 1,
    minimum_c: if sample.temperature_c < accumulator.minimum_c {
      sample.temperature_c
    } else {
      accumulator.minimum_c
    },
    maximum_c: if sample.temperature_c > accumulator.maximum_c {
      sample.temperature_c
    } else {
      accumulator.maximum_c
    },
    temperature_sum: accumulator.temperature_sum + sample.temperature_c,
    degree_seconds: accumulator.degree_seconds + exposure,
    peak_deviation_c: if current_deviation > accumulator.peak_deviation_c {
      current_deviation
    } else {
      accumulator.peak_deviation_c
    },
  }
}

///|
/// Stable event identifier derived from sensor, kind and start timestamp.
fn event_identifier(accumulator : EventAccumulator) -> String {
  "\{accumulator.sensor_id}-\{excursion_kind_name(accumulator.kind)}-\{accumulator.started_at}"
}

///|
/// Convert an accumulator to an auditable event.
fn finalize_accumulator(
  accumulator : EventAccumulator,
  policy : TemperaturePolicy,
  reason : String,
) -> ExcursionEvent {
  let duration = accumulator.last_timestamp - accumulator.started_at
  let qualifies = duration >= policy.grace_seconds &&
    duration >= policy.minimum_event_seconds
  {
    event_id: event_identifier(accumulator),
    sensor_id: accumulator.sensor_id,
    kind: accumulator.kind,
    status: if qualifies {
      Confirmed
    } else {
      Suppressed
    },
    started_at: accumulator.started_at,
    ended_at: accumulator.last_timestamp,
    duration_seconds: duration,
    sample_count: accumulator.sample_count,
    minimum_c: Some(accumulator.minimum_c),
    maximum_c: Some(accumulator.maximum_c),
    mean_c: Some(
      accumulator.temperature_sum / accumulator.sample_count.to_double(),
    ),
    degree_seconds: accumulator.degree_seconds,
    peak_deviation_c: accumulator.peak_deviation_c,
    reason,
  }
}

///|
/// Detect excursion events for an already ordered single-sensor stream.
pub fn detect_sensor_excursions(
  readings : Array[Reading],
  policy : TemperaturePolicy,
) -> Array[ExcursionEvent] {
  let events : Array[ExcursionEvent] = []
  let mut active : EventAccumulator? = None
  for sample in readings {
    if !sample.is_usable() {
      continue
    }
    match active {
      None =>
        match trigger_kind(sample.temperature_c, policy) {
          Some(kind) => active = Some(start_accumulator(sample, kind, policy))
          None => ()
        }
      Some(accumulator) =>
        if recovered_from(sample.temperature_c, accumulator.kind, policy) {
          let closed = advance_accumulator(accumulator, sample, policy)
          events.push(
            finalize_accumulator(closed, policy, "temperature recovered"),
          )
          active = match trigger_kind(sample.temperature_c, policy) {
            Some(kind) => Some(start_accumulator(sample, kind, policy))
            None => None
          }
        } else {
          let next = advance_accumulator(accumulator, sample, policy)
          match trigger_kind(sample.temperature_c, policy) {
            Some(kind) =>
              if kind != accumulator.kind {
                events.push(
                  finalize_accumulator(
                    next, policy, "temperature crossed directly into opposite excursion",
                  ),
                )
                active = Some(start_accumulator(sample, kind, policy))
              } else {
                active = Some(next)
              }
            None => active = Some(next)
          }
        }
    }
  }
  match active {
    Some(accumulator) =>
      events.push(
        finalize_accumulator(accumulator, policy, "end of observation"),
      )
    None => ()
  }
  events
}

///|
/// Detect temperature events independently for every sensor.
pub fn detect_excursions(
  readings : Array[Reading],
  policy : TemperaturePolicy,
) -> Array[ExcursionEvent] {
  let events : Array[ExcursionEvent] = []
  let ordered = sort_readings(readings)
  for sensor_id in unique_sensor_ids(ordered) {
    events.append(
      detect_sensor_excursions(readings_for_sensor(ordered, sensor_id), policy),
    )
  }
  events.sort_by(fn(a, b) {
    if a.started_at < b.started_at {
      -1
    } else if a.started_at > b.started_at {
      1
    } else {
      a.sensor_id.compare(b.sensor_id)
    }
  })
  events
}

///|
/// Convert sampling gaps to events so reports share one event timeline.
pub fn gap_events(gaps : Array[SamplingGap]) -> Array[ExcursionEvent] {
  let events : Array[ExcursionEvent] = []
  for gap in gaps {
    events.push({
      event_id: "\{gap.sensor_id}-data_gap-\{gap.previous_timestamp}",
      sensor_id: gap.sensor_id,
      kind: DataGap,
      status: Confirmed,
      started_at: gap.previous_timestamp,
      ended_at: gap.next_timestamp,
      duration_seconds: gap.duration_seconds,
      sample_count: 0,
      minimum_c: None,
      maximum_c: None,
      mean_c: None,
      degree_seconds: 0.0,
      peak_deviation_c: 0.0,
      reason: "\{gap.estimated_missing_samples} samples estimated missing",
    })
  }
  events
}

///|
/// Combine temperature and data-gap events in chronological order.
pub fn build_event_timeline(
  readings : Array[Reading],
  gaps : Array[SamplingGap],
  policy : TemperaturePolicy,
) -> Array[ExcursionEvent] {
  let events = detect_excursions(readings, policy)
  events.append(gap_events(gaps))
  events.sort_by(fn(a, b) {
    if a.started_at < b.started_at {
      -1
    } else if a.started_at > b.started_at {
      1
    } else {
      a.event_id.compare(b.event_id)
    }
  })
  events
}

///|
/// Only confirmed events contribute to the primary risk score.
pub fn confirmed_events(
  events : Array[ExcursionEvent],
) -> Array[ExcursionEvent] {
  let output : Array[ExcursionEvent] = []
  for event in events {
    if event.status == Confirmed || event.status == Closed {
      output.push(event)
    }
  }
  output
}

///|
/// Total duration of confirmed events of one kind.
pub fn total_event_duration(
  events : Array[ExcursionEvent],
  kind : ExcursionKind,
) -> Int64 {
  let mut total = 0L
  for event in events {
    if event.kind == kind &&
      (event.status == Confirmed || event.status == Closed) {
      total = total + event.duration_seconds
    }
  }
  total
}

///|
/// Maximum peak deviation among confirmed temperature events.
pub fn maximum_peak_deviation(events : Array[ExcursionEvent]) -> Double {
  let mut maximum = 0.0
  for event in events {
    if event.status == Confirmed && event.peak_deviation_c > maximum {
      maximum = event.peak_deviation_c
    }
  }
  maximum
}

///|
/// Count events matching kind and status.
pub fn count_events(
  events : Array[ExcursionEvent],
  kind : ExcursionKind,
  status : EventStatus,
) -> Int {
  let mut count = 0
  for event in events {
    if event.kind == kind && event.status == status {
      count = count + 1
    }
  }
  count
}