///|
/// Errors raised while defining or evaluating bounded autonomy rules.
pub(all) suberror PolicyError {
  EmptyRuleId
  EmptyAction
  InvalidWindow
  InvalidMaxTriggers
  InvalidOfflineDuration
  InvalidTimestamp
  InvalidCheckpoint
  UnsupportedPolicyVersion
  PersistenceFailed
  DuplicateRuleId(String)
  ActionNotAllowed(CommandAction)
} derive(Debug, Eq)

///|
/// Conditions that can trigger a local offline rule.
pub(all) enum PolicyCondition {
  OfflineFor(Int32)
  HealthIs(NodeHealth)
} derive(Debug, Eq, ToJson, FromJson)

///|
pub extend PolicyCondition with ToJson::{to_json}

///|
pub extend PolicyCondition with @json.FromJson::{from_json}

///|
/// A bounded local action with a validity window and trigger budget.
pub(all) struct AutonomyRule {
  id : String
  condition : PolicyCondition
  action : CommandAction
  valid_from : Timestamp
  valid_until : Timestamp
  max_triggers : Int32
  mut fired_count : Int32
  policy_version : Int32
  mut last_evaluated_at : Timestamp?
} derive(Debug, Eq, ToJson, FromJson)

///|
pub extend AutonomyRule with ToJson::{to_json}

///|
pub extend AutonomyRule with @json.FromJson::{from_json}

///|
/// Creates a validated offline autonomy rule.
pub fn AutonomyRule::new(
  id~ : String,
  condition~ : PolicyCondition,
  action~ : CommandAction,
  valid_from~ : Timestamp,
  valid_until~ : Timestamp,
  max_triggers~ : Int32,
) -> AutonomyRule raise PolicyError {
  if id == "" {
    raise PolicyError::EmptyRuleId
  }
  if valid_until.unix_seconds < valid_from.unix_seconds {
    raise PolicyError::InvalidWindow
  }
  if max_triggers <= 0 {
    raise PolicyError::InvalidMaxTriggers
  }
  match condition {
    PolicyCondition::OfflineFor(seconds) if seconds < 0 =>
      raise PolicyError::InvalidOfflineDuration
    _ => ()
  }
  match action {
    CommandAction::Execute(name) if name == "" => raise PolicyError::EmptyAction
    _ => ()
  }
  {
    id,
    condition,
    action,
    valid_from,
    valid_until,
    max_triggers,
    fired_count: 0,
    policy_version: 1,
    last_evaluated_at: None,
  }
}

///|
/// The observations available to the offline policy engine.
pub(all) struct AutonomyContext {
  lifecycle : NodeLifecycle
  health : NodeHealth
  offline_for : Int32
} derive(Debug, Eq)

///|
/// Creates a validated policy evaluation context.
pub fn AutonomyContext::new(
  lifecycle~ : NodeLifecycle,
  health~ : NodeHealth,
  offline_for~ : Int32,
) -> AutonomyContext raise PolicyError {
  if offline_for < 0 {
    raise PolicyError::InvalidOfflineDuration
  }
  { lifecycle, health, offline_for, }
}

///|
/// The safe result when no bounded rule may run.
pub(all) enum PolicyDecision {
  SafeDefault
  Execute(String, CommandAction)
} derive(Debug, Eq)

///|
/// Stateful evaluator for a node's offline autonomy rules.
pub(all) struct AutonomyEngine {
  allowlist : CommandAllowlist
  mut rules : Array[AutonomyRule]
} derive(Debug, Eq)

///|
/// Creates an evaluator and verifies every rule is allowlisted.
pub fn AutonomyEngine::new(
  allowlist~ : CommandAllowlist,
  rules~ : Array[AutonomyRule],
) -> AutonomyEngine raise PolicyError {
  for i in 0.. rule.max_triggers ||
      rule.valid_from.unix_seconds < 0 ||
      rule.valid_until.unix_seconds < rule.valid_from.unix_seconds {
      raise PolicyError::InvalidCheckpoint
    }
    match rule.last_evaluated_at {
      Some(previous) if previous.unix_seconds < 0 =>
        raise PolicyError::InvalidCheckpoint
      _ => ()
    }
    if i > 0 && rule.last_evaluated_at != rules[0].last_evaluated_at {
      raise PolicyError::InvalidCheckpoint
    }
    if !allowlist.allows(action=rule.action) {
      raise PolicyError::ActionNotAllowed(rule.action)
    }
    for j in 0.. PolicyDecision raise PolicyError {
  self.evaluate_durable(context~, at~, persist=_rules => true)
}

///|
/// Persists a consumed trigger budget before returning an action decision.
pub fn AutonomyEngine::evaluate_durable(
  self : AutonomyEngine,
  context~ : AutonomyContext,
  at~ : Timestamp,
  persist~ : (Array[AutonomyRule]) -> Bool,
) -> PolicyDecision raise PolicyError {
  if at.unix_seconds < 0 {
    raise PolicyError::InvalidTimestamp
  }
  if self.rules.length() > 0 {
    match self.rules[0].last_evaluated_at {
      Some(previous) if at.unix_seconds < previous.unix_seconds =>
        raise PolicyError::InvalidTimestamp
      Some(previous) if at.unix_seconds == previous.unix_seconds =>
        return PolicyDecision::SafeDefault
      _ => ()
    }
  }
  for index in 0..= rule.max_triggers {
      continue
    }
    if at.unix_seconds < rule.valid_from.unix_seconds ||
      at.unix_seconds > rule.valid_until.unix_seconds {
      continue
    }
    if !condition_matches(rule.condition, context) {
      continue
    }
    self.rules[index].fired_count = rule.fired_count + 1
    if !persist(self.checkpoint()) {
      raise PolicyError::PersistenceFailed
    }
    return PolicyDecision::Execute(rule.id, rule.action)
  }
  PolicyDecision::SafeDefault
}

///|
/// Captures trigger budgets for persistence across process restarts.
pub fn AutonomyEngine::checkpoint(self : AutonomyEngine) -> Array[AutonomyRule] {
  copy_rules(self.rules)
}

///|
/// Restores an evaluator from persisted rule counters.
pub fn AutonomyEngine::restore(
  allowlist~ : CommandAllowlist,
  rules~ : Array[AutonomyRule],
) -> AutonomyEngine raise PolicyError {
  AutonomyEngine::new(allowlist~, rules~)
}

///|
fn condition_matches(
  condition : PolicyCondition,
  context : AutonomyContext,
) -> Bool {
  match condition {
    PolicyCondition::OfflineFor(seconds) => context.offline_for >= seconds
    PolicyCondition::HealthIs(health) => context.health == health
  }
}

///|
fn copy_rules(rules : Array[AutonomyRule]) -> Array[AutonomyRule] {
  let copy : Array[AutonomyRule] = []
  for rule in rules {
    copy.push({
      id: rule.id,
      condition: rule.condition,
      action: rule.action,
      valid_from: rule.valid_from,
      valid_until: rule.valid_until,
      max_triggers: rule.max_triggers,
      fired_count: rule.fired_count,
      policy_version: rule.policy_version,
      last_evaluated_at: rule.last_evaluated_at,
    })
  }
  copy
}