///|
pub fn active_phase(plan : RolloutPlan, minute : Int) -> RolloutPhase? {
  let mut chosen : RolloutPhase? = None
  for phase in plan.phases {
    let starts = minute >= phase.start_minute
    let ends = minute < phase.start_minute + phase.duration_minutes
    if starts && ends {
      chosen = Some(phase)
    }
  }
  chosen
}

///|
pub fn last_phase(plan : RolloutPlan) -> RolloutPhase? {
  let mut chosen : RolloutPhase? = None
  let mut latest = -1
  for phase in plan.phases {
    if phase.start_minute >= latest {
      latest = phase.start_minute
      chosen = Some(phase)
    }
  }
  chosen
}

///|
pub fn exposure_for_user(
  plan : RolloutPlan,
  user : UserProfile,
  minute : Int,
) -> ExposureDecision {
  match active_phase(plan, minute) {
    None =>
      {
        enabled: false,
        bucket_bp: rollout_bucket(user.id, plan.salt),
        mode: Shadow,
        phase_name: "",
        reason: "no active phase",
      }
    Some(phase) => {
      let bucket = rollout_bucket(user.id, plan.salt)
      if phase.mode == KillSwitch {
        return {
          enabled: false,
          bucket_bp: bucket,
          mode: KillSwitch,
          phase_name: phase.name,
          reason: "kill switch phase",
        }
      }
      if !matches_any_segment(user, plan.segments, salt=plan.salt) {
        return {
          enabled: false,
          bucket_bp: bucket,
          mode: phase.mode,
          phase_name: phase.name,
          reason: "outside target segment",
        }
      }
      let in_bucket = bucket < clamp(phase.percent_bp, 0, 10000)
      {
        enabled: in_bucket,
        bucket_bp: bucket,
        mode: phase.mode,
        phase_name: phase.name,
        reason: if in_bucket {
          "selected by stable bucket"
        } else {
          "above rollout bucket"
        },
      }
    }
  }
}

///|
pub fn exposed_users(
  plan : RolloutPlan,
  users : Array[UserProfile],
  minute : Int,
) -> Array[UserProfile] {
  let out = Array::new(capacity=users.length())
  for user in users {
    if exposure_for_user(plan, user, minute).enabled {
      out.push(user)
    }
  }
  out
}

///|
pub fn exposed_user_count(
  plan : RolloutPlan,
  users : Array[UserProfile],
  minute : Int,
) -> Int {
  let mut total = 0
  for user in users {
    if exposure_for_user(plan, user, minute).enabled {
      total += 1
    }
  }
  total
}

///|
pub fn exposed_traffic_units(
  plan : RolloutPlan,
  users : Array[UserProfile],
  minute : Int,
) -> Int {
  let mut total = 0
  for user in users {
    if exposure_for_user(plan, user, minute).enabled {
      total += user.traffic_units
    }
  }
  total
}

///|
pub fn plan_duration(plan : RolloutPlan) -> Int {
  let mut end_time = 0
  for phase in plan.phases {
    let candidate = phase.start_minute + phase.duration_minutes
    if candidate > end_time {
      end_time = candidate
    }
  }
  end_time
}

///|
pub fn phase_is_monotonic(
  previous : RolloutPhase,
  current : RolloutPhase,
) -> Bool {
  current.start_minute >= previous.start_minute &&
  current.percent_bp >= previous.percent_bp
}

///|
pub fn phase_overlap(a : RolloutPhase, b : RolloutPhase) -> Bool {
  let a_end = a.start_minute + a.duration_minutes
  let b_end = b.start_minute + b.duration_minutes
  a.start_minute < b_end && b.start_minute < a_end
}

///|
pub fn validate_plan(plan : RolloutPlan) -> Array[RolloutFinding] {
  let findings = Array::new(capacity=16)
  if has_text(plan.name) {
    findings.push(
      finding_pass("plan.name", PlanShape, "Plan has a name", plan.name),
    )
  } else {
    findings.push(
      finding_fail(
        "plan.name",
        PlanShape,
        "Plan name is missing",
        "empty",
        "Give the rollout plan a stable reviewer-facing name.",
        penalty=12,
      ),
    )
  }
  if has_text(plan.salt) {
    findings.push(
      finding_pass("plan.salt", PlanShape, "Plan has a stable salt", plan.salt),
    )
  } else {
    findings.push(
      finding_fail(
        "plan.salt",
        PlanShape,
        "Plan salt is missing",
        "empty",
        "Use a stable rollout salt so buckets do not reshuffle accidentally.",
        penalty=14,
      ),
    )
  }
  if plan.phases.length() > 0 {
    findings.push(
      finding_pass(
        "plan.phases",
        ScheduleGate,
        "Rollout has phases",
        plan.phases.length().to_string() + " phases",
      ),
    )
  } else {
    findings.push(
      finding_fail(
        "plan.phases",
        ScheduleGate,
        "Rollout phases are missing",
        "0 phases",
        "Add at least one shadow, dry-run or live phase.",
        penalty=20,
      ),
    )
  }
  push_phase_shape_findings(findings, plan.phases)
  push_phase_overlap_findings(findings, plan.phases)
  push_budget_findings(findings, plan.budget)
  push_identity_findings(findings, plan)
  findings
}

///|
fn push_phase_shape_findings(
  findings : Array[RolloutFinding],
  phases : Array[RolloutPhase],
) -> Unit {
  let mut bad_percent = 0
  let mut bad_duration = 0
  let mut manual_live = 0
  for phase in phases {
    if phase.percent_bp < 0 || phase.percent_bp > 10000 {
      bad_percent += 1
    }
    if phase.duration_minutes <= 0 {
      bad_duration += 1
    }
    if phase.mode == Live &&
      phase.percent_bp >= 5000 &&
      !phase.require_manual_approval {
      manual_live += 1
    }
  }
  if bad_percent == 0 {
    findings.push(
      finding_pass(
        "phase.percent",
        ScheduleGate,
        "Phase percentages are bounded",
        "0..10000 bp",
      ),
    )
  } else {
    findings.push(
      finding_fail(
        "phase.percent",
        ScheduleGate,
        "Some phase percentages are invalid",
        bad_percent.to_string() + " phases",
        "Keep percent_bp inside 0..10000.",
        penalty=10,
      ),
    )
  }
  if bad_duration == 0 {
    findings.push(
      finding_pass(
        "phase.duration",
        ScheduleGate,
        "Phase durations are positive",
        "all phases",
      ),
    )
  } else {
    findings.push(
      finding_fail(
        "phase.duration",
        ScheduleGate,
        "Some phase durations are invalid",
        bad_duration.to_string() + " phases",
        "Use positive duration_minutes for every phase.",
        penalty=10,
      ),
    )
  }
  if manual_live == 0 {
    findings.push(
      finding_pass(
        "phase.manual-approval",
        ScheduleGate,
        "Large live phases have approval evidence",
        "manual gates ok",
      ),
    )
  } else {
    findings.push(
      finding_warn(
        "phase.manual-approval",
        ScheduleGate,
        "Large live phases lack manual approval",
        manual_live.to_string() + " phases",
        "Mark >=50% live phases as manually approved.",
        penalty=5,
      ),
    )
  }
}

///|
fn push_phase_overlap_findings(
  findings : Array[RolloutFinding],
  phases : Array[RolloutPhase],
) -> Unit {
  let mut overlaps = 0
  for i in 0.. Unit {
  if budget.min_samples > 0 {
    findings.push(
      finding_pass(
        "budget.samples",
        RiskBudgetShape,
        "Risk budget requires samples",
        budget.min_samples.to_string(),
      ),
    )
  } else {
    findings.push(
      finding_warn(
        "budget.samples",
        RiskBudgetShape,
        "Risk budget has no minimum sample count",
        "0",
        "Require a nonzero sample count before advancing.",
        penalty=6,
      ),
    )
  }
  if budget.max_blast_radius_bp <= 10000 && budget.max_blast_radius_bp > 0 {
    findings.push(
      finding_pass(
        "budget.blast-radius",
        RiskBudgetShape,
        "Blast-radius budget is bounded",
        percent_text(budget.max_blast_radius_bp),
      ),
    )
  } else {
    findings.push(
      finding_fail(
        "budget.blast-radius",
        RiskBudgetShape,
        "Blast-radius budget is invalid",
        budget.max_blast_radius_bp.to_string(),
        "Keep max_blast_radius_bp inside 1..10000.",
        penalty=12,
      ),
    )
  }
}

///|
fn push_identity_findings(
  findings : Array[RolloutFinding],
  plan : RolloutPlan,
) -> Unit {
  if contains_ci(plan.repository, "github.com/EJJ-ai-nb/canaryring") {
    findings.push(
      finding_pass(
        "identity.repository",
        IdentityGate,
        "Repository points to CanaryRing",
        plan.repository,
      ),
    )
  } else {
    findings.push(
      finding_warn(
        "identity.repository",
        IdentityGate,
        "Repository link should point to EJJ-ai-nb/canaryring",
        plan.repository,
        "Use https://github.com/EJJ-ai-nb/canaryring for resubmission.",
        penalty=5,
      ),
    )
  }
  if plan.package_name == "EJJ-ai-nb/canaryring" {
    findings.push(
      finding_pass(
        "identity.package",
        IdentityGate,
        "Package namespace is aligned",
        plan.package_name,
      ),
    )
  } else {
    findings.push(
      finding_fail(
        "identity.package",
        IdentityGate,
        "Package namespace is not aligned",
        plan.package_name,
        "Set moon.mod name to EJJ-ai-nb/canaryring.",
        penalty=15,
      ),
    )
  }
}

///|
fn finding_pass(
  id : String,
  kind : ValidationKind,
  title : String,
  evidence : String,
) -> RolloutFinding {
  {
    id,
    kind,
    severity: Pass,
    title,
    detail: "Requirement satisfied.",
    repair: "",
    evidence,
    penalty: 0,
  }
}

///|
fn finding_warn(
  id : String,
  kind : ValidationKind,
  title : String,
  detail : String,
  repair : String,
  penalty? : Int = 4,
) -> RolloutFinding {
  { id, kind, severity: Warn, title, detail, repair, evidence: detail, penalty }
}

///|
fn finding_fail(
  id : String,
  kind : ValidationKind,
  title : String,
  detail : String,
  repair : String,
  penalty? : Int = 10,
) -> RolloutFinding {
  { id, kind, severity: Fail, title, detail, repair, evidence: detail, penalty }
}