///|
pub(all) enum SpongeError {
  NonPositiveArea
  NegativeRainfall
  InvalidRunoffCoefficient
  InvalidCurveNumber
  InvalidInfiltrationRate
  InvalidStorageDepth
  InvalidSafetyFactor
  InvalidCellLimit
  InvalidWeight
  InvalidCandidateArea
  InvalidSedimentLoad
  InvalidFacilitySpec
  InvalidCostProfile
  InvalidScenarioInput
} derive(Eq, Debug)

///|
pub(all) enum SurfaceKind {
  Roof
  Pavement
  CompactedSoil
  Lawn
  Meadow
} derive(Eq, Debug)

///|
pub(all) enum PlanStatus {
  Ready
  NeedsReview
  Invalid
} derive(Eq, Debug)

///|
pub(all) enum PlanIssueKind {
  DrawdownTooSlow
  CaptureBelowTarget
  OversizedCell
  NoRunoff
} derive(Eq, Debug)

///|
pub(all) struct RainEvent {
  name : String
  depth_mm : Double
} derive(Eq, Debug)

///|
pub(all) struct Catchment {
  name : String
  area_m2 : Double
  runoff_coefficient : Double
} derive(Eq, Debug)

///|
pub(all) struct RunoffResult {
  depth_mm : Double
  volume_m3 : Double
  volume_liters : Double
} derive(Eq, Debug)

///|
pub(all) struct CurveNumberResult {
  rainfall_mm : Double
  curve_number : Double
  retention_mm : Double
  initial_abstraction_mm : Double
  runoff_depth_mm : Double
  runoff_volume_m3 : Double
} derive(Eq, Debug)

///|
pub(all) struct GardenSpec {
  ponding_depth_mm : Double
  media_depth_mm : Double
  media_porosity : Double
  infiltration_rate_mm_per_hr : Double
  safety_factor : Double
  target_drawdown_hours : Double
  target_capture_ratio : Double
  max_cell_area_m2 : Double
} derive(Eq, Debug)

///|
pub(all) struct PlanIssue {
  kind : PlanIssueKind
  message : String
} derive(Eq, Debug)

///|
pub(all) struct GardenPlan {
  status : PlanStatus
  event_name : String
  catchment_count : Int
  catchment_area_m2 : Double
  design_rain_mm : Double
  runoff_volume_m3 : Double
  storage_depth_mm : Double
  required_area_m2 : Double
  recommended_area_m2 : Double
  cell_count : Int
  cell_area_m2 : Double
  drawdown_hours : Double
  capture_ratio : Double
  issues : Array[PlanIssue]
} derive(Eq, Debug)

///|
pub fn rain_event(name : String, depth_mm : Double) -> RainEvent {
  { name, depth_mm }
}

///|
pub fn catchment(
  name : String,
  area_m2 : Double,
  runoff_coefficient : Double,
) -> Catchment {
  { name, area_m2, runoff_coefficient }
}

///|
pub fn catchment_from_surface(
  name : String,
  area_m2 : Double,
  kind : SurfaceKind,
) -> Catchment {
  catchment(name, area_m2, runoff_coefficient(kind))
}

///|
pub fn runoff_coefficient(kind : SurfaceKind) -> Double {
  match kind {
    Roof => 0.95
    Pavement => 0.85
    CompactedSoil => 0.55
    Lawn => 0.30
    Meadow => 0.15
  }
}

///|
pub fn default_garden_spec() -> GardenSpec {
  {
    ponding_depth_mm: 150.0,
    media_depth_mm: 450.0,
    media_porosity: 0.25,
    infiltration_rate_mm_per_hr: 25.0,
    safety_factor: 1.20,
    target_drawdown_hours: 24.0,
    target_capture_ratio: 0.90,
    max_cell_area_m2: 30.0,
  }
}

///|
pub fn effective_storage_depth_mm(
  spec : GardenSpec,
) -> Result[Double, SpongeError] {
  match validate_spec(spec) {
    Some(error) => Err(error)
    None =>
      Ok(spec.ponding_depth_mm + spec.media_depth_mm * spec.media_porosity)
  }
}

///|
pub fn runoff_volume(
  catchment : Catchment,
  event : RainEvent,
) -> Result[RunoffResult, SpongeError] {
  match validate_catchment(catchment) {
    Some(error) => Err(error)
    None =>
      if event.depth_mm < 0.0 {
        Err(NegativeRainfall)
      } else {
        let volume_m3 = catchment.area_m2 *
          event.depth_mm /
          1000.0 *
          catchment.runoff_coefficient
        Ok({
          depth_mm: event.depth_mm * catchment.runoff_coefficient,
          volume_m3,
          volume_liters: volume_m3 * 1000.0,
        })
      }
  }
}

///|
pub fn total_runoff_volume(
  catchments : Array[Catchment],
  event : RainEvent,
) -> Result[RunoffResult, SpongeError] {
  if event.depth_mm < 0.0 {
    return Err(NegativeRainfall)
  }
  let mut area = 0.0
  let mut volume = 0.0
  for catchment in catchments {
    match validate_catchment(catchment) {
      Some(error) => return Err(error)
      None => {
        area = area + catchment.area_m2
        volume = volume +
          catchment.area_m2 *
          event.depth_mm /
          1000.0 *
          catchment.runoff_coefficient
      }
    }
  }
  let depth = if area == 0.0 { 0.0 } else { volume / area * 1000.0 }
  Ok({ depth_mm: depth, volume_m3: volume, volume_liters: volume * 1000.0 })
}

///|
pub fn curve_number_runoff_depth(
  rainfall_mm : Double,
  curve_number : Double,
) -> Result[Double, SpongeError] {
  match validate_curve_number_input(rainfall_mm, curve_number) {
    Some(error) => Err(error)
    None => {
      let retention = 25400.0 / curve_number - 254.0
      let abstraction = 0.2 * retention
      if rainfall_mm <= abstraction {
        Ok(0.0)
      } else {
        let excess = rainfall_mm - abstraction
        Ok(excess * excess / (excess + retention))
      }
    }
  }
}

///|
pub fn curve_number_runoff(
  area_m2 : Double,
  rainfall_mm : Double,
  curve_number : Double,
) -> Result[CurveNumberResult, SpongeError] {
  if area_m2 <= 0.0 {
    return Err(NonPositiveArea)
  }
  match curve_number_runoff_depth(rainfall_mm, curve_number) {
    Err(error) => Err(error)
    Ok(depth) => {
      let retention = 25400.0 / curve_number - 254.0
      Ok({
        rainfall_mm,
        curve_number,
        retention_mm: retention,
        initial_abstraction_mm: retention * 0.2,
        runoff_depth_mm: depth,
        runoff_volume_m3: area_m2 * depth / 1000.0,
      })
    }
  }
}

///|
pub fn size_rain_garden(
  catchments : Array[Catchment],
  event : RainEvent,
  spec : GardenSpec,
) -> Result[GardenPlan, SpongeError] {
  match validate_spec(spec) {
    Some(error) => Err(error)
    None =>
      match total_runoff_volume(catchments, event) {
        Err(error) => Err(error)
        Ok(runoff) => {
          let storage_depth = spec.ponding_depth_mm +
            spec.media_depth_mm * spec.media_porosity
          let required_area = if storage_depth == 0.0 {
            0.0
          } else {
            runoff.volume_m3 / (storage_depth / 1000.0)
          }
          let recommended_area = required_area * spec.safety_factor
          let cell_count = if recommended_area <= 0.0 {
            0
          } else {
            ceil_to_int(recommended_area / spec.max_cell_area_m2)
          }
          let cell_area = if cell_count == 0 {
            0.0
          } else {
            recommended_area / cell_count.to_double()
          }
          let drawdown = storage_depth / spec.infiltration_rate_mm_per_hr
          let capture = if runoff.volume_m3 <= 0.0 {
            1.0
          } else {
            min_double(
              1.0,
              recommended_area * (storage_depth / 1000.0) / runoff.volume_m3,
            )
          }
          let issues = plan_issues(
            runoff.volume_m3,
            drawdown,
            capture,
            cell_area,
            spec,
          )
          let status = status_for_issues(issues)
          Ok({
            status,
            event_name: event.name,
            catchment_count: catchments.length(),
            catchment_area_m2: total_area(catchments),
            design_rain_mm: event.depth_mm,
            runoff_volume_m3: runoff.volume_m3,
            storage_depth_mm: storage_depth,
            required_area_m2: required_area,
            recommended_area_m2: recommended_area,
            cell_count,
            cell_area_m2: cell_area,
            drawdown_hours: drawdown,
            capture_ratio: capture,
            issues,
          })
        }
      }
  }
}

///|
pub fn GardenPlan::to_json(self : GardenPlan) -> String {
  let out = StringBuilder::new()
  out.write_string("{")
  out.write_string("\"status\":\"" + self.status.label() + "\",")
  out.write_string("\"event\":\"" + escape_json(self.event_name) + "\",")
  out.write_string(
    "\"catchment_count\":" + self.catchment_count.to_string() + ",",
  )
  out.write_string(
    "\"catchment_area_m2\":" + self.catchment_area_m2.to_string() + ",",
  )
  out.write_string(
    "\"design_rain_mm\":" + self.design_rain_mm.to_string() + ",",
  )
  out.write_string(
    "\"runoff_volume_m3\":" + self.runoff_volume_m3.to_string() + ",",
  )
  out.write_string(
    "\"storage_depth_mm\":" + self.storage_depth_mm.to_string() + ",",
  )
  out.write_string(
    "\"recommended_area_m2\":" + self.recommended_area_m2.to_string() + ",",
  )
  out.write_string("\"cell_count\":" + self.cell_count.to_string() + ",")
  out.write_string("\"cell_area_m2\":" + self.cell_area_m2.to_string() + ",")
  out.write_string(
    "\"drawdown_hours\":" + self.drawdown_hours.to_string() + ",",
  )
  out.write_string("\"capture_ratio\":" + self.capture_ratio.to_string() + ",")
  out.write_string("\"issues\":[")
  for index, issue in self.issues {
    if index > 0 {
      out.write_string(",")
    }
    out.write_string(plan_issue_to_json(issue))
  }
  out.write_string("]}")
  out.to_string()
}

///|
pub fn GardenPlan::to_markdown(self : GardenPlan) -> String {
  let out = StringBuilder::new()
  out.write_string("# SpongeKit Design Card\n\n")
  out.write_string("- Status: " + self.status.label() + "\n")
  out.write_string("- Event: " + self.event_name + "\n")
  out.write_string(
    "- Catchment area: " + self.catchment_area_m2.to_string() + " m2\n",
  )
  out.write_string(
    "- Runoff volume: " + self.runoff_volume_m3.to_string() + " m3\n",
  )
  out.write_string(
    "- Recommended rain garden area: " +
    self.recommended_area_m2.to_string() +
    " m2\n",
  )
  out.write_string(
    "- Cells: " +
    self.cell_count.to_string() +
    " x " +
    self.cell_area_m2.to_string() +
    " m2\n",
  )
  out.write_string("- Drawdown: " + self.drawdown_hours.to_string() + " h\n")
  if self.issues.length() > 0 {
    out.write_string("\n## Review Notes\n\n")
    for issue in self.issues {
      out.write_string("- " + issue.kind.label() + ": " + issue.message + "\n")
    }
  }
  out.to_string()
}

///|
pub fn PlanStatus::label(self : PlanStatus) -> String {
  match self {
    Ready => "ready"
    NeedsReview => "needs_review"
    Invalid => "invalid"
  }
}

///|
pub fn SpongeError::label(self : SpongeError) -> String {
  match self {
    NonPositiveArea => "non_positive_area"
    NegativeRainfall => "negative_rainfall"
    InvalidRunoffCoefficient => "invalid_runoff_coefficient"
    InvalidCurveNumber => "invalid_curve_number"
    InvalidInfiltrationRate => "invalid_infiltration_rate"
    InvalidStorageDepth => "invalid_storage_depth"
    InvalidSafetyFactor => "invalid_safety_factor"
    InvalidCellLimit => "invalid_cell_limit"
    InvalidWeight => "invalid_weight"
    InvalidCandidateArea => "invalid_candidate_area"
    InvalidSedimentLoad => "invalid_sediment_load"
    InvalidFacilitySpec => "invalid_facility_spec"
    InvalidCostProfile => "invalid_cost_profile"
    InvalidScenarioInput => "invalid_scenario_input"
  }
}

///|
pub fn PlanIssueKind::label(self : PlanIssueKind) -> String {
  match self {
    DrawdownTooSlow => "drawdown_too_slow"
    CaptureBelowTarget => "capture_below_target"
    OversizedCell => "oversized_cell"
    NoRunoff => "no_runoff"
  }
}

///|
fn validate_catchment(catchment : Catchment) -> SpongeError? {
  if catchment.area_m2 <= 0.0 {
    Some(NonPositiveArea)
  } else if catchment.runoff_coefficient < 0.0 ||
    catchment.runoff_coefficient > 1.0 {
    Some(InvalidRunoffCoefficient)
  } else {
    None
  }
}

///|
fn validate_curve_number_input(
  rainfall_mm : Double,
  curve_number : Double,
) -> SpongeError? {
  if rainfall_mm < 0.0 {
    Some(NegativeRainfall)
  } else if curve_number < 30.0 || curve_number > 100.0 {
    Some(InvalidCurveNumber)
  } else {
    None
  }
}

///|
fn validate_spec(spec : GardenSpec) -> SpongeError? {
  if spec.ponding_depth_mm < 0.0 ||
    spec.media_depth_mm < 0.0 ||
    spec.media_porosity < 0.0 ||
    spec.media_porosity > 0.8 {
    Some(InvalidStorageDepth)
  } else if spec.ponding_depth_mm + spec.media_depth_mm * spec.media_porosity <=
    0.0 {
    Some(InvalidStorageDepth)
  } else if spec.infiltration_rate_mm_per_hr <= 0.0 {
    Some(InvalidInfiltrationRate)
  } else if spec.safety_factor < 1.0 {
    Some(InvalidSafetyFactor)
  } else if spec.max_cell_area_m2 <= 0.0 {
    Some(InvalidCellLimit)
  } else {
    None
  }
}

///|
fn plan_issues(
  runoff_volume : Double,
  drawdown_hours : Double,
  capture_ratio : Double,
  cell_area : Double,
  spec : GardenSpec,
) -> Array[PlanIssue] {
  let issues : Array[PlanIssue] = []
  if runoff_volume == 0.0 {
    issues.push(issue(NoRunoff, "design event creates no runoff"))
  }
  if drawdown_hours > spec.target_drawdown_hours {
    issues.push(
      issue(
        DrawdownTooSlow,
        "storage drawdown is slower than the target window",
      ),
    )
  }
  if capture_ratio < spec.target_capture_ratio {
    issues.push(
      issue(
        CaptureBelowTarget,
        "recommended area does not meet the target capture ratio",
      ),
    )
  }
  if cell_area > spec.max_cell_area_m2 {
    issues.push(issue(OversizedCell, "single cell area exceeds the cell limit"))
  }
  issues
}

///|
fn status_for_issues(issues : Array[PlanIssue]) -> PlanStatus {
  if issues.length() == 0 {
    Ready
  } else {
    NeedsReview
  }
}

///|
fn issue(kind : PlanIssueKind, message : String) -> PlanIssue {
  { kind, message }
}

///|
fn plan_issue_to_json(issue : PlanIssue) -> String {
  "{\"kind\":\"" +
  issue.kind.label() +
  "\",\"message\":\"" +
  escape_json(issue.message) +
  "\"}"
}

///|
fn total_area(catchments : Array[Catchment]) -> Double {
  let mut area = 0.0
  for catchment in catchments {
    area = area + catchment.area_m2
  }
  area
}

///|
fn ceil_to_int(value : Double) -> Int {
  let mut n = 0
  while n.to_double() < value {
    n = n + 1
  }
  n
}

///|
fn min_double(left : Double, right : Double) -> Double {
  if left < right {
    left
  } else {
    right
  }
}

///|
fn escape_json(input : String) -> String {
  let out = StringBuilder::new()
  for ch in input {
    match ch {
      '"' => out.write_string("\\\"")
      '\\' => out.write_string("\\\\")
      '\n' => out.write_string("\\n")
      '\r' => out.write_string("\\r")
      '\t' => out.write_string("\\t")
      _ => out.write_char(ch)
    }
  }
  out.to_string()
}