///|
const PASS_SCAN_STEP_SECONDS : Int = 60

///|
/// Predict visibility windows using the current two-body preview propagator.
///
/// The returned event times have one-second resolution. AOS and LOS are
/// refined by elevation-threshold bisection, while TCA is selected by a
/// one-second search around the sampled elevation maximum.
pub fn predict_passes(
  tle : Tle,
  station : GroundStation,
  from : UtcDateTime,
  duration_hours : Int,
  minimum_elevation_deg : Double,
) -> Result[Array[PassWindow], SatError] {
  let elements = match orbit_elements(tle) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  predict_passes_from_elements(
    elements, station, from, duration_hours, minimum_elevation_deg,
  )
}

///|
/// Predict visibility windows from already-decoded orbital elements.
pub fn predict_passes_from_elements(
  elements : OrbitElements,
  station : GroundStation,
  from : UtcDateTime,
  duration_hours : Int,
  minimum_elevation_deg : Double,
) -> Result[Array[PassWindow], SatError] {
  if duration_hours <= 0 {
    return Err(
      SatError::new(OutOfRange, "pass prediction duration must be positive"),
    )
  }
  if minimum_elevation_deg < -90.0 || minimum_elevation_deg > 90.0 {
    return Err(
      SatError::new(
        OutOfRange,
        "minimum elevation must be between -90 and 90 degrees",
      ),
    )
  }
  let duration_seconds = duration_hours * 3600
  let passes : Array[PassWindow] = []
  let mut previous_offset = 0
  let mut previous_elevation = match pass_elevation(elements, station, from) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let mut in_pass = previous_elevation >= minimum_elevation_deg
  let mut aos_offset = 0
  let mut maximum_offset = 0
  let mut maximum_elevation = previous_elevation
  let mut offset = PASS_SCAN_STEP_SECONDS
  while offset <= duration_seconds {
    let current_elevation = match
      pass_elevation(elements, station, from.add_seconds(offset)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if !in_pass {
      if previous_elevation < minimum_elevation_deg &&
        current_elevation >= minimum_elevation_deg {
        aos_offset = match
          refine_horizon_crossing(
            elements, station, from, previous_offset, offset, minimum_elevation_deg,
            true,
          ) {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        maximum_offset = offset
        maximum_elevation = current_elevation
        in_pass = true
      }
    } else if current_elevation >= minimum_elevation_deg {
      if current_elevation > maximum_elevation {
        maximum_offset = offset
        maximum_elevation = current_elevation
      }
    } else {
      let los_offset = match
        refine_horizon_crossing(
          elements, station, from, previous_offset, offset, minimum_elevation_deg,
          false,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      let window = match
        make_pass_window(
          elements, station, from, minimum_elevation_deg, aos_offset, los_offset,
          maximum_offset, maximum_elevation,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      passes.push(window)
      in_pass = false
      maximum_elevation = current_elevation
    }
    previous_offset = offset
    previous_elevation = current_elevation
    offset = offset + PASS_SCAN_STEP_SECONDS
  }
  if previous_offset < duration_seconds {
    let current_elevation = match
      pass_elevation(elements, station, from.add_seconds(duration_seconds)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if !in_pass {
      if previous_elevation < minimum_elevation_deg &&
        current_elevation >= minimum_elevation_deg {
        aos_offset = match
          refine_horizon_crossing(
            elements, station, from, previous_offset, duration_seconds, minimum_elevation_deg,
            true,
          ) {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        maximum_offset = duration_seconds
        maximum_elevation = current_elevation
        in_pass = true
      }
    } else if current_elevation >= minimum_elevation_deg {
      if current_elevation > maximum_elevation {
        maximum_offset = duration_seconds
        maximum_elevation = current_elevation
      }
    } else {
      let los_offset = match
        refine_horizon_crossing(
          elements, station, from, previous_offset, duration_seconds, minimum_elevation_deg,
          false,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      let window = match
        make_pass_window(
          elements, station, from, minimum_elevation_deg, aos_offset, los_offset,
          maximum_offset, maximum_elevation,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      passes.push(window)
      in_pass = false
    }
    previous_offset = duration_seconds
    previous_elevation = current_elevation
  }
  if in_pass {
    let window = match
      make_pass_window(
        elements, station, from, minimum_elevation_deg, aos_offset, duration_seconds,
        maximum_offset, maximum_elevation,
      ) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    passes.push(window)
  }
  Ok(passes)
}

///|
fn pass_elevation(
  elements : OrbitElements,
  station : GroundStation,
  instant : UtcDateTime,
) -> Result[Double, SatError] {
  let state = match propagate_two_body(elements, instant) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  Ok(observe(state, station).elevation_deg)
}

///|
fn refine_horizon_crossing(
  elements : OrbitElements,
  station : GroundStation,
  from : UtcDateTime,
  lower_offset : Int,
  upper_offset : Int,
  minimum_elevation_deg : Double,
  rising : Bool,
) -> Result[Int, SatError] {
  let mut lower = lower_offset
  let mut upper = upper_offset
  while upper - lower > 1 {
    let middle = lower + (upper - lower) / 2
    let elevation = match
      pass_elevation(elements, station, from.add_seconds(middle)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if rising {
      if elevation >= minimum_elevation_deg {
        upper = middle
      } else {
        lower = middle
      }
    } else if elevation >= minimum_elevation_deg {
      lower = middle
    } else {
      upper = middle
    }
  }
  Ok(if rising { upper } else { lower })
}

///|
fn refine_pass_maximum(
  elements : OrbitElements,
  station : GroundStation,
  from : UtcDateTime,
  lower_offset : Int,
  upper_offset : Int,
  seed_offset : Int,
  seed_elevation : Double,
) -> Result[(Int, Double), SatError] {
  let search_radius = PASS_SCAN_STEP_SECONDS
  let mut lower = seed_offset - search_radius
  if lower < lower_offset {
    lower = lower_offset
  }
  let mut upper = seed_offset + search_radius
  if upper > upper_offset {
    upper = upper_offset
  }
  let mut best_offset = seed_offset
  let mut best_elevation = seed_elevation
  let mut cursor = lower
  while cursor <= upper {
    let elevation = match
      pass_elevation(elements, station, from.add_seconds(cursor)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if elevation > best_elevation {
      best_offset = cursor
      best_elevation = elevation
    }
    cursor = cursor + 1
  }
  Ok((best_offset, best_elevation))
}

///|
fn make_pass_window(
  elements : OrbitElements,
  station : GroundStation,
  from : UtcDateTime,
  minimum_elevation_deg : Double,
  aos_offset : Int,
  los_offset : Int,
  maximum_offset : Int,
  maximum_elevation : Double,
) -> Result[PassWindow, SatError] {
  let (tca_offset, refined_maximum) = match
    refine_pass_maximum(
      elements, station, from, aos_offset, los_offset, maximum_offset, maximum_elevation,
    ) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  if refined_maximum < minimum_elevation_deg {
    return Err(
      SatError::new(OutOfRange, "pass maximum elevation is below threshold"),
    )
  }
  Ok({
    aos: from.add_seconds(aos_offset),
    tca: from.add_seconds(tca_offset),
    los: from.add_seconds(los_offset),
    maximum_elevation_deg: refined_maximum,
  })
}

///|
/// Predict visibility windows with the near-earth SGP4 propagator.
///
/// This API is the high-fidelity counterpart to predict_passes, which keeps
/// its transparent two-body preview behavior for compatibility. A deep-space
/// TLE returns UnsupportedOrbit because SDP4 is not implemented yet.
pub fn predict_passes_sgp4(
  tle : Tle,
  station : GroundStation,
  from : UtcDateTime,
  duration_hours : Int,
  minimum_elevation_deg : Double,
) -> Result[Array[PassWindow], SatError] {
  if duration_hours <= 0 {
    return Err(
      SatError::new(OutOfRange, "pass prediction duration must be positive"),
    )
  }
  if minimum_elevation_deg < -90.0 || minimum_elevation_deg > 90.0 {
    return Err(
      SatError::new(
        OutOfRange,
        "minimum elevation must be between -90 and 90 degrees",
      ),
    )
  }
  let duration_seconds = duration_hours * 3600
  let passes : Array[PassWindow] = []
  let mut previous_offset = 0
  let mut previous_elevation = match sgp4_pass_elevation(tle, station, from) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let mut in_pass = previous_elevation >= minimum_elevation_deg
  let mut aos_offset = 0
  let mut maximum_offset = 0
  let mut maximum_elevation = previous_elevation
  let mut offset = PASS_SCAN_STEP_SECONDS
  while offset <= duration_seconds {
    let current_elevation = match
      sgp4_pass_elevation(tle, station, from.add_seconds(offset)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if !in_pass {
      if previous_elevation < minimum_elevation_deg &&
        current_elevation >= minimum_elevation_deg {
        aos_offset = match
          refine_sgp4_horizon_crossing(
            tle, station, from, previous_offset, offset, minimum_elevation_deg, true,
          ) {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        maximum_offset = offset
        maximum_elevation = current_elevation
        in_pass = true
      }
    } else if current_elevation >= minimum_elevation_deg {
      if current_elevation > maximum_elevation {
        maximum_offset = offset
        maximum_elevation = current_elevation
      }
    } else {
      let los_offset = match
        refine_sgp4_horizon_crossing(
          tle, station, from, previous_offset, offset, minimum_elevation_deg, false,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      let window = match
        make_sgp4_pass_window(
          tle, station, from, minimum_elevation_deg, aos_offset, los_offset, maximum_offset,
          maximum_elevation,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      passes.push(window)
      in_pass = false
      maximum_elevation = current_elevation
    }
    previous_offset = offset
    previous_elevation = current_elevation
    offset = offset + PASS_SCAN_STEP_SECONDS
  }
  if previous_offset < duration_seconds {
    let current_elevation = match
      sgp4_pass_elevation(tle, station, from.add_seconds(duration_seconds)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if !in_pass {
      if previous_elevation < minimum_elevation_deg &&
        current_elevation >= minimum_elevation_deg {
        aos_offset = match
          refine_sgp4_horizon_crossing(
            tle, station, from, previous_offset, duration_seconds, minimum_elevation_deg,
            true,
          ) {
          Ok(value) => value
          Err(error) => return Err(error)
        }
        maximum_offset = duration_seconds
        maximum_elevation = current_elevation
        in_pass = true
      }
    } else if current_elevation >= minimum_elevation_deg {
      if current_elevation > maximum_elevation {
        maximum_offset = duration_seconds
        maximum_elevation = current_elevation
      }
    } else {
      let los_offset = match
        refine_sgp4_horizon_crossing(
          tle, station, from, previous_offset, duration_seconds, minimum_elevation_deg,
          false,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      let window = match
        make_sgp4_pass_window(
          tle, station, from, minimum_elevation_deg, aos_offset, los_offset, maximum_offset,
          maximum_elevation,
        ) {
        Ok(value) => value
        Err(error) => return Err(error)
      }
      passes.push(window)
      in_pass = false
    }
  }
  if in_pass {
    let window = match
      make_sgp4_pass_window(
        tle, station, from, minimum_elevation_deg, aos_offset, duration_seconds,
        maximum_offset, maximum_elevation,
      ) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    passes.push(window)
  }
  Ok(passes)
}

///|
fn sgp4_pass_elevation(
  tle : Tle,
  station : GroundStation,
  instant : UtcDateTime,
) -> Result[Double, SatError] {
  let state = match propagate_sgp4(tle, instant) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  Ok(observe(state, station).elevation_deg)
}

///|
fn refine_sgp4_horizon_crossing(
  tle : Tle,
  station : GroundStation,
  from : UtcDateTime,
  lower_offset : Int,
  upper_offset : Int,
  minimum_elevation_deg : Double,
  rising : Bool,
) -> Result[Int, SatError] {
  let mut lower = lower_offset
  let mut upper = upper_offset
  while upper - lower > 1 {
    let middle = lower + (upper - lower) / 2
    let elevation = match
      sgp4_pass_elevation(tle, station, from.add_seconds(middle)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if rising {
      if elevation >= minimum_elevation_deg {
        upper = middle
      } else {
        lower = middle
      }
    } else if elevation >= minimum_elevation_deg {
      lower = middle
    } else {
      upper = middle
    }
  }
  Ok(if rising { upper } else { lower })
}

///|
fn refine_sgp4_pass_maximum(
  tle : Tle,
  station : GroundStation,
  from : UtcDateTime,
  lower_offset : Int,
  upper_offset : Int,
  seed_offset : Int,
  seed_elevation : Double,
) -> Result[(Int, Double), SatError] {
  let mut lower = seed_offset - PASS_SCAN_STEP_SECONDS
  if lower < lower_offset {
    lower = lower_offset
  }
  let mut upper = seed_offset + PASS_SCAN_STEP_SECONDS
  if upper > upper_offset {
    upper = upper_offset
  }
  let mut best_offset = seed_offset
  let mut best_elevation = seed_elevation
  let mut cursor = lower
  while cursor <= upper {
    let elevation = match
      sgp4_pass_elevation(tle, station, from.add_seconds(cursor)) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    if elevation > best_elevation {
      best_offset = cursor
      best_elevation = elevation
    }
    cursor = cursor + 1
  }
  Ok((best_offset, best_elevation))
}

///|
fn make_sgp4_pass_window(
  tle : Tle,
  station : GroundStation,
  from : UtcDateTime,
  minimum_elevation_deg : Double,
  aos_offset : Int,
  los_offset : Int,
  maximum_offset : Int,
  maximum_elevation : Double,
) -> Result[PassWindow, SatError] {
  let (tca_offset, refined_maximum) = match
    refine_sgp4_pass_maximum(
      tle, station, from, aos_offset, los_offset, maximum_offset, maximum_elevation,
    ) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  if refined_maximum < minimum_elevation_deg {
    return Err(
      SatError::new(OutOfRange, "pass maximum elevation is below threshold"),
    )
  }
  Ok({
    aos: from.add_seconds(aos_offset),
    tca: from.add_seconds(tca_offset),
    los: from.add_seconds(los_offset),
    maximum_elevation_deg: refined_maximum,
  })
}