///|
/// Deterministic recovery and workload forecasting primitives.
/// Forecasts are deliberately bounded and accompanied by holdout error so
/// consumers can show uncertainty rather than treating a point estimate as fact.
pub(all) enum ForecastMethod {
  ForecastLastValue
  ForecastMovingAverage
  ForecastEwma
  ForecastLinearTrend
  ForecastEnsemble
} derive(FromJson, ToJson, Debug, Eq)

///|
pub(all) struct ForecastConfig {
  horizon : Int
  window : Int
  ewma_alpha : Double
  minimum_history : Int
  uncertainty_scale : Double
  lower_bound : Double
  upper_bound : Double
} derive(FromJson, ToJson, Debug, Eq)

///|
pub fn ForecastConfig::default() -> ForecastConfig {
  {
    horizon: 7,
    window: 7,
    ewma_alpha: 0.25,
    minimum_history: 3,
    uncertainty_scale: 1.0,
    lower_bound: 0.0,
    upper_bound: 100.0,
  }
}

///|
pub(all) struct ForecastPoint {
  step : Int
  value : Double
  lower : Double
  upper : Double
  confidence : Double
  algorithm : ForecastMethod
} derive(FromJson, ToJson, Debug, Eq)

///|
pub(all) struct OperationalForecastResult {
  algorithm : ForecastMethod
  history_count : Int
  points : Array[ForecastPoint]
  baseline : Double
  slope : Double
  mean_absolute_error : Double
  root_mean_squared_error : Double
  coverage : Double
  usable : Bool
} derive(FromJson, ToJson, Debug, Eq)

///|
pub(all) struct ForecastBacktest {
  folds : Int
  observations : Int
  mean_absolute_error : Double
  mean_absolute_percentage_error : Double
  directional_accuracy : Double
  worst_error : Double
  stable : Bool
} derive(FromJson, ToJson, Debug, Eq)

///|
pub(all) struct ForecastBundle {
  recovery : OperationalForecastResult
  load : OperationalForecastResult
  backtest : ForecastBacktest
  recommendation : String
} derive(FromJson, ToJson, Debug, Eq)

///|
fn forecast_bound(value : Double, low : Double, high : Double) -> Double {
  if value.is_nan() || value.is_inf() {
    low
  } else {
    value.clamp(min=low, max=high)
  }
}

///|
pub fn forecast_method_name(algorithm : ForecastMethod) -> String {
  match algorithm {
    ForecastLastValue => "last_value"
    ForecastMovingAverage => "moving_average"
    ForecastEwma => "ewma"
    ForecastLinearTrend => "linear_trend"
    ForecastEnsemble => "ensemble"
  }
}

///|
fn forecast_copy(values : Array[Double]) -> Array[Double] {
  let result = []
  for value in values {
    result.push(value)
  }
  result
}

///|
fn forecast_last(values : Array[Double]) -> Double {
  if values.length() == 0 {
    0.0
  } else {
    values[values.length() - 1]
  }
}

///|
fn forecast_window(values : Array[Double], window : Int) -> Array[Double] {
  let safe = window.max(1)
  let start = (values.length() - safe).max(0)
  let result = []
  for i in start.. Double {
  mean_value(forecast_window(values, window))
}

///|
pub fn forecast_ewma(values : Array[Double], alpha : Double) -> Double {
  let weight = forecast_bound(alpha, 0.01, 1.0)
  let mut result = 0.0
  for value in values {
    result = weight * value + (1.0 - weight) * result
  }
  result
}

///|
pub fn forecast_linear_next(values : Array[Double]) -> Double {
  if values.length() == 0 {
    0.0
  } else {
    let trend = fit_linear_trend(values)
    trend.intercept + trend.slope * values.length().to_double()
  }
}

///|
pub fn forecast_method_value(
  values : Array[Double],
  algorithm : ForecastMethod,
  config : ForecastConfig,
) -> Double {
  match algorithm {
    ForecastLastValue => forecast_last(values)
    ForecastMovingAverage => forecast_moving_average(values, config.window)
    ForecastEwma => forecast_ewma(values, config.ewma_alpha)
    ForecastLinearTrend => forecast_linear_next(values)
    ForecastEnsemble =>
      (
        forecast_last(values) +
        forecast_moving_average(values, config.window) +
        forecast_ewma(values, config.ewma_alpha) +
        forecast_linear_next(values)
      ) /
      4.0
  }
}

///|
fn forecast_residual_scale(values : Array[Double], baseline : Double) -> Double {
  if values.length() < 2 {
    0.0
  } else {
    let residuals = values.map(value => value - baseline)
    standard_deviation(residuals)
  }
}

///|
fn forecast_rmse(values : Array[Double], baseline : Double) -> Double {
  if values.length() == 0 {
    0.0
  } else {
    let mut total = 0.0
    for value in values {
      let error = value - baseline
      total += error * error
    }
    (total / values.length().to_double()).sqrt()
  }
}

///|
fn forecast_confidence(
  history_count : Int,
  error : Double,
  scale : Double,
) -> Double {
  let history_factor = (history_count.to_double() / 28.0)
    .sqrt()
    .clamp(min=0.15, max=1.0)
  let error_factor = if scale <= 0.000001 {
    1.0
  } else {
    (1.0 - error / (scale * 2.0)).clamp(min=0.0, max=1.0)
  }
  history_factor * error_factor
}

///|
pub fn forecast_point(
  step : Int,
  value : Double,
  scale : Double,
  confidence : Double,
  algorithm : ForecastMethod,
  config : ForecastConfig,
) -> ForecastPoint {
  let margin = scale *
    config.uncertainty_scale *
    (1.0 + step.to_double() * 0.08)
  {
    step,
    value: forecast_bound(value, config.lower_bound, config.upper_bound),
    lower: forecast_bound(
      value - margin,
      config.lower_bound,
      config.upper_bound,
    ),
    upper: forecast_bound(
      value + margin,
      config.lower_bound,
      config.upper_bound,
    ),
    confidence: forecast_bound(confidence, 0.0, 1.0),
    algorithm,
  }
}

///|
pub fn forecast_values(
  history : Array[Double],
  algorithm : ForecastMethod,
  config : ForecastConfig,
) -> OperationalForecastResult {
  let values = forecast_copy(history)
  let baseline = forecast_method_value(values, algorithm, config)
  let slope = if values.length() < 2 {
    0.0
  } else {
    fit_linear_trend(values).slope
  }
  let scale = forecast_residual_scale(values, baseline).max(0.5)
  let error = if values.length() < 2 {
    scale
  } else {
    mean_value(values.map(value => (value - baseline).abs()))
  }
  let confidence = forecast_confidence(values.length(), error, scale)
  let points = []
  let mut previous = baseline
  for step in 1..<(config.horizon.max(0) + 1) {
    let value = match algorithm {
      ForecastLinearTrend => baseline + slope * (step - 1).to_double()
      ForecastEnsemble => (baseline + previous) / 2.0
      _ => baseline
    }
    let bounded = forecast_bound(value, config.lower_bound, config.upper_bound)
    points.push(
      forecast_point(step, bounded, scale, confidence, algorithm, config),
    )
    previous = bounded
  }
  {
    algorithm,
    history_count: values.length(),
    points,
    baseline,
    slope,
    mean_absolute_error: error,
    root_mean_squared_error: forecast_rmse(values, baseline),
    coverage: if values.length() < config.minimum_history {
      0.0
    } else {
      1.0
    },
    usable: values.length() >= config.minimum_history && config.horizon >= 0,
  }
}

///|
pub fn forecast_result_is_usable(result : OperationalForecastResult) -> Bool {
  result.usable && result.points.length() > 0 && result.coverage >= 0.0
}

///|
pub fn forecast_result_feature_vector(
  result : OperationalForecastResult,
) -> Array[Double] {
  [
    result.history_count.to_double(),
    result.baseline,
    result.slope,
    result.mean_absolute_error,
    result.root_mean_squared_error,
    result.coverage,
    result.points.length().to_double(),
  ]
}

///|
pub fn forecast_points_csv(result : OperationalForecastResult) -> String {
  let grid = [["step", "value", "lower", "upper", "confidence", "algorithm"]]
  for point in result.points {
    grid.push([
      point.step.to_string(),
      point.value.to_string(),
      point.lower.to_string(),
      point.upper.to_string(),
      point.confidence.to_string(),
      forecast_method_name(point.algorithm),
    ])
  }
  to_csv(grid)
}

///|
pub fn forecast_backtest(
  history : Array[Double],
  algorithm : ForecastMethod,
  config : ForecastConfig,
  holdout : Int,
) -> ForecastBacktest {
  let safe_holdout = holdout.clamp(min=1, max=history.length().max(1))
  let start = (history.length() - safe_holdout).max(config.minimum_history)
  let errors = []
  let percentage_errors = []
  let directions = []
  let mut worst = 0.0
  let mut folds = 0
  for i in start.. 0 {
      let previous = history[i - 1]
      let predicted_direction = predicted - forecast_last(training)
      let actual_direction = actual - previous
      directions.push(
        (predicted_direction >= 0.0 && actual_direction >= 0.0) ||
        (predicted_direction < 0.0 && actual_direction < 0.0),
      )
    }
    if error > worst {
      worst = error
    }
    folds += 1
  }
  let accuracy = if directions.length() == 0 {
    0.0
  } else {
    directions.filter(value => value).length().to_double() /
    directions.length().to_double()
  }
  {
    folds,
    observations: errors.length(),
    mean_absolute_error: mean_value(errors),
    mean_absolute_percentage_error: mean_value(percentage_errors),
    directional_accuracy: accuracy,
    worst_error: worst,
    stable: errors.length() > 0 && mean_value(errors) <= worst.max(1.0) * 0.75,
  }
}

///|
pub fn forecast_backtest_csv(backtest : ForecastBacktest) -> String {
  to_csv([
    [
      "folds", "observations", "mae", "mape", "directional_accuracy", "worst_error",
      "stable",
    ],
    [
      backtest.folds.to_string(),
      backtest.observations.to_string(),
      backtest.mean_absolute_error.to_string(),
      backtest.mean_absolute_percentage_error.to_string(),
      backtest.directional_accuracy.to_string(),
      backtest.worst_error.to_string(),
      backtest.stable.to_string(),
    ],
  ])
}

///|
pub fn build_forecast_bundle(
  recovery_scores : Array[Double],
  load_values : Array[Double],
  config : ForecastConfig,
) -> ForecastBundle {
  let recovery = forecast_values(recovery_scores, ForecastEnsemble, config)
  let load = forecast_values(load_values, ForecastEwma, config)
  let backtest = forecast_backtest(
    recovery_scores,
    ForecastEnsemble,
    config,
    config.horizon,
  )
  let recommendation = if !recovery.usable {
    "Collect more recovery history before using a forecast."
  } else if recovery.slope < -1.0 {
    "Forecast indicates a declining recovery trend; lower planned intensity."
  } else if load.slope > 10.0 {
    "Forecast indicates rising load; add a controlled day."
  } else {
    "Forecast is stable enough for conservative planning."
  }
  { recovery, load, backtest, recommendation }
}

///|
pub fn forecast_bundle_feature_vector(bundle : ForecastBundle) -> Array[Double] {
  let result = []
  for value in forecast_result_feature_vector(bundle.recovery) {
    result.push(value)
  }
  for value in forecast_result_feature_vector(bundle.load) {
    result.push(value)
  }
  result.push(bundle.backtest.mean_absolute_error)
  result.push(bundle.backtest.directional_accuracy)
  result
}

///|
pub fn forecast_bundle_csv(bundle : ForecastBundle) -> String {
  let grid = [
    ["series", "method", "baseline", "slope", "mae", "coverage", "usable"],
    [
      "recovery",
      forecast_method_name(bundle.recovery.algorithm),
      bundle.recovery.baseline.to_string(),
      bundle.recovery.slope.to_string(),
      bundle.recovery.mean_absolute_error.to_string(),
      bundle.recovery.coverage.to_string(),
      bundle.recovery.usable.to_string(),
    ],
    [
      "load",
      forecast_method_name(bundle.load.algorithm),
      bundle.load.baseline.to_string(),
      bundle.load.slope.to_string(),
      bundle.load.mean_absolute_error.to_string(),
      bundle.load.coverage.to_string(),
      bundle.load.usable.to_string(),
    ],
  ]
  to_csv(grid)
}

///|
pub fn forecast_adjusted_intensity(
  forecast : OperationalForecastResult,
  requested : Double,
) -> Double {
  let direction = if forecast.slope < -1.0 {
    0.65
  } else if forecast.slope > 5.0 {
    0.80
  } else {
    1.0
  }
  forecast_bound(
    requested * direction * (0.75 + forecast.coverage * 0.25),
    0.10,
    1.0,
  )
}

///|
pub fn forecast_interval_width(point : ForecastPoint) -> Double {
  point.upper - point.lower
}

///|
pub fn forecast_average_interval(result : OperationalForecastResult) -> Double {
  mean_value(result.points.map(point => forecast_interval_width(point)))
}

///|
pub fn forecast_last_point(
  result : OperationalForecastResult,
) -> ForecastPoint? {
  if result.points.length() == 0 {
    None
  } else {
    Some(result.points[result.points.length() - 1])
  }
}

///|
pub fn forecast_point_at(
  result : OperationalForecastResult,
  step : Int,
) -> ForecastPoint? {
  for point in result.points {
    if point.step == step {
      return Some(point)
    }
  }
  None
}

///|
pub fn forecast_is_declining(result : OperationalForecastResult) -> Bool {
  result.slope < -1.0 && result.coverage >= 0.50
}