///|
pub(all) struct RouteMissionWindowCandidate {
  route_id : String
  route_label : String
  time_start_utc : String
  time_end_utc : String
  sample_offset : Int
  window_hours : Int
  spherical_sunlit_hours : Double
  terrain_sunlit_hours : Double
  terrain_shadowed_hours : Double
  longest_dark_hours : Double
  terrain_available_energy_wh : Double
  minimum_solar_clearance_deg : Double
  maximum_solar_clearance_deg : Double
  meets_illumination_constraint : Bool
  decision : TraverseDecision
  reasons : Array[String]
} derive(Debug, Eq, ToJson, FromJson)

///|
pub(all) struct MissionWindowSearchEvidence {
  evidence_id : String
  site_id : String
  selected_route_id : String
  selected_candidate_index : Int
  source_path : String
  orientation_source_path : String
  search_start_utc : String
  search_end_utc : String
  window_hours : Int
  stride_hours : Int
  windows_per_route : Int
  generated_by : String
  candidates : Array[RouteMissionWindowCandidate]
} derive(Debug, Eq, ToJson, FromJson)

///|
fn mission_window_iso_timestamp(unix_seconds : Int64) -> String {
  let time = try! @time.unix(unix_seconds)
  let month = time.month().to_string().pad_start(2, '0')
  let day = time.day().to_string().pad_start(2, '0')
  let hour = time.hour().to_string().pad_start(2, '0')
  "\{time.year()}-\{month}-\{day}T\{hour}:00:00Z"
}

///|
fn mission_window_candidate(
  route : RouteCandidate,
  exposure : RouteSolarExposure,
  search_start_unix_s : Int64,
  sample_offset : Int,
  window_hours : Int,
  constraint : IlluminationConstraint,
) -> RouteMissionWindowCandidate {
  let enough_sunlight = exposure.terrain_sunlit_hours >=
    constraint.min_sunlit_hours
  let darkness_bounded = exposure.longest_dark_hours <=
    constraint.max_dark_hours
  let meets_illumination_constraint = enough_sunlight && darkness_bounded
  let reasons : Array[String] = []
  add_reason_if(
    reasons,
    !enough_sunlight,
    "terrain-visible sunlight is below \{constraint.min_sunlit_hours} hours",
  )
  add_reason_if(
    reasons,
    !darkness_bounded,
    "longest darkness exceeds \{constraint.max_dark_hours} hours",
  )
  add_reason_if(
    reasons,
    exposure.terrain_shadowed_hours > 0.0,
    "terrain blocks \{exposure.terrain_shadowed_hours} spherical-sunlit hours",
  )
  {
    route_id: route.route_id,
    route_label: route.label,
    time_start_utc: mission_window_iso_timestamp(
      search_start_unix_s + Int64::from_int(sample_offset) * 3600L,
    ),
    time_end_utc: mission_window_iso_timestamp(
      search_start_unix_s +
      Int64::from_int(sample_offset + window_hours) * 3600L,
    ),
    sample_offset,
    window_hours,
    spherical_sunlit_hours: exposure.spherical_sunlit_hours,
    terrain_sunlit_hours: exposure.terrain_sunlit_hours,
    terrain_shadowed_hours: exposure.terrain_shadowed_hours,
    longest_dark_hours: exposure.longest_dark_hours,
    terrain_available_energy_wh: exposure.terrain_available_energy_wh,
    minimum_solar_clearance_deg: exposure.minimum_solar_clearance_deg,
    maximum_solar_clearance_deg: exposure.maximum_solar_clearance_deg,
    meets_illumination_constraint,
    decision: if meets_illumination_constraint {
      Allow
    } else if exposure.terrain_sunlit_hours > 0.0 {
      Review
    } else {
      Block
    },
    reasons,
  }
}

///|
fn mission_window_better(
  candidate : RouteMissionWindowCandidate,
  current : RouteMissionWindowCandidate,
) -> Bool {
  let energy_delta = candidate.terrain_available_energy_wh -
    current.terrain_available_energy_wh
  if candidate.meets_illumination_constraint !=
    current.meets_illumination_constraint {
    candidate.meets_illumination_constraint
  } else if energy_delta.abs() > 1.0e-9 {
    energy_delta > 0.0
  } else if candidate.longest_dark_hours != current.longest_dark_hours {
    candidate.longest_dark_hours < current.longest_dark_hours
  } else if candidate.terrain_sunlit_hours != current.terrain_sunlit_hours {
    candidate.terrain_sunlit_hours > current.terrain_sunlit_hours
  } else {
    candidate.minimum_solar_clearance_deg > current.minimum_solar_clearance_deg
  }
}

///|
fn best_route_mission_window(
  route : RouteCandidate,
  solar_track : @lunar_ephemeris.SolarGeometryTrack,
  search_start_unix_s : Int64,
  window_hours : Int,
  stride_hours : Int,
  constraint : IlluminationConstraint,
) -> RouteMissionWindowCandidate {
  let horizons = route.illumination.local_horizon.azimuth_samples
  let sample_count = solar_track.sun_altitude_deg
    .length()
    .min(solar_track.sun_azimuth_deg.length())
  let mut best : RouteMissionWindowCandidate? = None
  for sample_offset = 0
      sample_offset + window_hours <= sample_count
      sample_offset = sample_offset + stride_hours {
    let exposure = route_solar_exposure_range(
      horizons, solar_track, sample_offset, window_hours,
    )
    let candidate = mission_window_candidate(
      route, exposure, search_start_unix_s, sample_offset, window_hours, constraint,
    )
    match best {
      Some(current) =>
        if mission_window_better(candidate, current) {
          best = Some(candidate)
        }
      None => best = Some(candidate)
    }
  }
  best.unwrap()
}

///|
pub fn rank_route_mission_windows(
  site_id : String,
  selected_route_id : String,
  routes : Array[RouteCandidate],
  solar_track : @lunar_ephemeris.SolarGeometryTrack,
  search_start_unix_s : Int64,
  window_hours : Int,
  stride_hours : Int,
  source_path : String,
  orientation_source_path : String,
  constraint : IlluminationConstraint,
) -> Result[MissionWindowSearchEvidence, String] {
  let sample_count = solar_track.sun_altitude_deg
    .length()
    .min(solar_track.sun_azimuth_deg.length())
  if routes.is_empty() {
    return Err("mission-window search requires at least one route")
  }
  if solar_track.step_minutes != 60 {
    return Err("mission-window search requires an hourly solar track")
  }
  if window_hours <= 0 || stride_hours <= 0 || sample_count < window_hours {
    return Err("mission-window search range is smaller than its window")
  }
  let candidates : Array[RouteMissionWindowCandidate] = []
  let mut selected_candidate_index = -1
  for route in routes {
    if route.route_id == selected_route_id {
      selected_candidate_index = candidates.length()
    }
    candidates.push(
      best_route_mission_window(
        route, solar_track, search_start_unix_s, window_hours, stride_hours, constraint,
      ),
    )
  }
  if selected_candidate_index < 0 {
    return Err("mission-window search cannot find selected route")
  }
  Ok({
    evidence_id: "\{site_id}-mission-window-search-v1",
    site_id,
    selected_route_id,
    selected_candidate_index,
    source_path,
    orientation_source_path,
    search_start_utc: mission_window_iso_timestamp(search_start_unix_s),
    search_end_utc: mission_window_iso_timestamp(
      search_start_unix_s + Int64::from_int(sample_count) * 3600L,
    ),
    window_hours,
    stride_hours,
    windows_per_route: (sample_count - window_hours) / stride_hours + 1,
    generated_by: "vectie/moonmoon/src/mission/window_search.mbt",
    candidates,
  })
}

///|
pub fn first_trusted_square_window_search_evidence() -> MissionWindowSearchEvidence {
  generated_first_trusted_square_window_search_evidence()
}