///|
pub(all) enum PlanChangeKind {
  RunMetadataChanged
  ReportAdded
  ReportRemoved
  ReportBindingChanged
  CaseAdded
  CaseRemoved
  CaseOrderChanged
} derive(Debug, Eq)

///|
/// Report additions/removals cover the whole report in the retained plans.
/// An empty report_id denotes run metadata; key is set only for case changes.
pub(all) struct PlanChange {
  kind : PlanChangeKind
  report_id : String
  key : @ledger.CaseKey?
} derive(Debug, Eq)

///|
pub extend PlanChangeKind with @debug.Debug::{to_repr}

///|
pub extend PlanChangeKind with Eq::{equal, not_equal}

///|
pub extend PlanChange with @debug.Debug::{to_repr}

///|
pub extend PlanChange with Eq::{equal, not_equal}

///|
/// A bounded, deterministic review of two complete declared inventories.
/// Its token binds both plans and every derived change; it is not a signature.
struct PlanReview {
  before : @ledger.RunPlan
  proposed : @ledger.RunPlan
  changes : Array[PlanChange]
  token : String
}

///|
fn copy_review_plan(plan : @ledger.RunPlan) -> @ledger.RunPlan {
  {
    ..plan,
    reports: plan.reports.map(report => {
      ..report,
      cases: report.cases.copy(),
    }),
  }
}

///|
pub fn PlanReview::before(self : PlanReview) -> @ledger.RunPlan {
  copy_review_plan(self.before)
}

///|
pub fn PlanReview::proposed(self : PlanReview) -> @ledger.RunPlan {
  copy_review_plan(self.proposed)
}

///|
pub fn PlanReview::changes(self : PlanReview) -> Array[PlanChange] {
  self.changes.copy()
}

///|
pub fn PlanReview::token(self : PlanReview) -> String {
  self.token
}

///|
/// Compare reviewed declarations, never received reports or observed outcomes.
/// Reordering reports follows RunPlan's existing canonical report ordering;
/// changed case order remains visible because it affects evidence identity.
pub fn review_plan(
  before : @ledger.RunPlan,
  proposed : @ledger.RunPlan,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> PlanReview raise @ledger.InputError {
  let before = @ledger.RunPlan::new(
    before.run_id,
    before.revision,
    before.reports,
    limits~,
  )
  let proposed = @ledger.RunPlan::new(
    proposed.run_id,
    proposed.revision,
    proposed.reports,
    limits~,
  )
  let changes : Array[PlanChange] = []
  if before.run_id != proposed.run_id || before.revision != proposed.revision {
    changes.push({ kind: RunMetadataChanged, report_id: "", key: None, })
  }
  let old : Map[String, @ledger.ExpectedReport] = Map([])
  let next : Map[String, @ledger.ExpectedReport] = Map([])
  let ids : Set[String] = Set([])
  for report in before.reports {
    old[report.id] = report
    ids.add(report.id)
  }
  for report in proposed.reports {
    next[report.id] = report
    ids.add(report.id)
  }
  let ordered = ids.to_array()
  ordered.sort_by((a, b) => a[:].lexical_compare(b[:]))
  for id in ordered {
    match (old.get(id), next.get(id)) {
      (None, Some(_)) =>
        changes.push({ kind: ReportAdded, report_id: id, key: None, })
      (Some(_), None) =>
        changes.push({ kind: ReportRemoved, report_id: id, key: None, })
      (Some(left), Some(right)) => {
        if left.environment != right.environment ||
          left.shard != right.shard ||
          left.attempt != right.attempt {
          changes.push({ kind: ReportBindingChanged, report_id: id, key: None, })
        }
        let old_keys = Set::from_array(left.cases)
        let new_keys = Set::from_array(right.cases)
        let keys = old_keys.to_array()
        for key in right.cases {
          if !old_keys.contains(key) {
            keys.push(key)
          }
        }
        keys.sort_by((a, b) => {
          let classname = a.classname[:].lexical_compare(b.classname[:])
          if classname != 0 {
            classname
          } else {
            a.name[:].lexical_compare(b.name[:])
          }
        })
        let mut same_set = true
        for key in keys {
          if !old_keys.contains(key) {
            same_set = false
            changes.push({ kind: CaseAdded, report_id: id, key: Some(key), })
          } else if !new_keys.contains(key) {
            same_set = false
            changes.push({ kind: CaseRemoved, report_id: id, key: Some(key), })
          }
        }
        if same_set && left.cases != right.cases {
          changes.push({ kind: CaseOrderChanged, report_id: id, key: None, })
        }
      }
      (None, None) => ()
    }
  }
  let token = sha256_text(
    "moontestledger/plan-review-v1\n" +
    Json::object(review_fields(before, proposed, changes)).stringify(indent=2),
  )
  let result : PlanReview = { before, proposed, changes, token, }
  ignore(result.encode(limits~))
  result
}

///|
/// Acknowledge this exact review against an unchanged baseline, for a fresh run.
/// Returns a copy of the proposed plan. Existing evidence is never rewritten.
/// The caller must obtain approval before supplying the displayed token; this
/// checks content/staleness, not the identity or presence of a human reviewer.
pub fn PlanReview::approve(
  self : PlanReview,
  current : @ledger.RunPlan,
  approval~ : String,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> @ledger.RunPlan raise @ledger.InputError {
  ignore(self.encode(limits~))
  let current = @ledger.RunPlan::new(
    current.run_id,
    current.revision,
    current.reports,
    limits~,
  )
  if encode_plan(current) != encode_plan(self.before) {
    raise bad(
      "ReviewStale", "/before", "Current baseline differs from the reviewed inventory; create and review a new proposal",
    )
  }
  if approval != self.token {
    raise bad(
      "ReviewApproval", "/approval", "Supply the exact displayed review token only after reviewing the complete before/proposed plans and changes",
    )
  }
  if self.proposed.run_id == self.before.run_id {
    raise bad(
      "ReviewRunReuse", "/proposed/run_id", "An approved inventory must use a fresh run ID; do not shrink an existing run to hide missing or failed evidence",
    )
  }
  copy_review_plan(self.proposed)
}