///|
/// Compare two plans and report what changed.
///
/// A plan revision is reviewed by a human, so the diff is written the way a
/// reviewer reads it: what was added, what was removed, which record changed in
/// which field, and which conflicts the new revision introduces or clears.
///
/// Records are matched by id. Two records with the same id but different
/// content count as one modification, not as a removal plus an addition.

///|
/// What happened to one record between two revisions.
pub(all) enum ChangeKind {
  Added
  Removed
  Modified
} derive(Eq)

///|
pub extend ChangeKind with Eq::{equal}

///|
pub extend ChangeKind with Eq::{not_equal}

///|
/// Lower case label: `added`, `removed` or `modified`.
pub fn ChangeKind::label(self : ChangeKind) -> String {
  match self {
    ChangeKind::Added => "added"
    ChangeKind::Removed => "removed"
    ChangeKind::Modified => "modified"
  }
}

///|
/// One record level difference.
pub(all) struct WindowChange {
  id : String
  kind : ChangeKind
  detail : String
}

///|
/// Render as `added api (2026-10-01T22:00 -> 2026-10-01T23:00)` or as
/// `modified api (start 2026-10-01T22:00 -> 2026-10-01T22:15, risk low -> high)`.
pub fn WindowChange::to_line(self : WindowChange) -> String {
  self.kind.label() + " " + self.id + " (" + self.detail + ")"
}

///|
/// The differences between two revisions of a plan.
///
/// `new_overlaps` and `new_freeze_conflicts` carry the pairs the new revision
/// introduces; `resolved_overlaps` and `resolved_freeze_conflicts` count the
/// pairs the old revision had and the new one does not. A pair that overlaps in
/// both revisions is reported by neither, because revision review is about
/// change, not about the state of the world.
pub(all) struct PlanDiff {
  changes : Array[WindowChange]
  added_windows : Int
  removed_windows : Int
  modified_windows : Int
  new_overlaps : Array[Overlap]
  resolved_overlaps : Int
  new_freeze_conflicts : Array[FreezeConflict]
  resolved_freeze_conflicts : Int
}

///|
/// True when the two revisions are identical: no record changed and no conflict
/// appeared or disappeared.
pub fn PlanDiff::is_empty(self : PlanDiff) -> Bool {
  self.changes.length() == 0 &&
  self.new_overlaps.length() == 0 &&
  self.new_freeze_conflicts.length() == 0 &&
  self.resolved_overlaps == 0 &&
  self.resolved_freeze_conflicts == 0
}

///|
/// `depends` rendered as `a+b`, or `-` when the list is empty.
fn depends_text(depends : Array[String]) -> String {
  if depends.length() == 0 {
    "-"
  } else {
    join(depends, "+")
  }
}

///|
/// `service` rendered as `-` when it is empty.
fn service_text(service : String) -> String {
  if service == "" {
    "-"
  } else {
    service
  }
}

///|
/// Compare one field of two records and append a `  -> ` note
/// when they differ. `parts` collects the notes in the order the fields are
/// visited, which is what makes the detail text stable.
fn note_change(
  parts : Array[String],
  field : String,
  before : String,
  after : String,
) -> Unit {
  if before != after {
    parts.push(field + " " + before + " -> " + after)
  }
}

///|
/// Every field of a record that differs from its earlier revision, in the fixed
/// order service, start, end, risk, depends, kind.
fn modification_detail(before : Window, after : Window) -> String {
  let parts : Array[String] = []
  note_change(
    parts,
    "service",
    service_text(before.service),
    service_text(after.service),
  )
  note_change(
    parts,
    "start",
    format_instant(before.start),
    format_instant(after.start),
  )
  note_change(
    parts,
    "end",
    format_instant(before.end),
    format_instant(after.end),
  )
  note_change(parts, "risk", before.risk.label(), after.risk.label())
  note_change(
    parts,
    "depends",
    depends_text(before.depends),
    depends_text(after.depends),
  )
  note_change(parts, "kind", before.kind.label(), after.kind.label())
  if parts.length() == 0 {
    "no field changed"
  } else {
    join(parts, ", ")
  }
}

///|
/// True when two overlap pairs name the same two records, in any order.
fn same_overlap_pair(
  overlap : Overlap,
  first : String,
  second : String,
) -> Bool {
  (overlap.first == first && overlap.second == second) ||
  (overlap.first == second && overlap.second == first)
}

///|
/// True when the pair is already an overlap of the other report.
fn overlap_known(
  overlaps : Array[Overlap],
  first : String,
  second : String,
) -> Bool {
  for overlap in overlaps {
    if same_overlap_pair(overlap, first, second) {
      return true
    }
  }
  false
}

///|
/// Freeze conflicts of the new revision that the old one did not have.
fn new_freeze_conflicts(
  before : Array[FreezeConflict],
  after : Array[FreezeConflict],
) -> Array[FreezeConflict] {
  let fresh : Array[FreezeConflict] = []
  for conflict in after {
    let mut known = false
    for previous in before {
      if previous.window_id == conflict.window_id &&
        previous.freeze_id == conflict.freeze_id {
        known = true
      }
    }
    if !known {
      fresh.push(conflict)
    }
  }
  fresh
}

///|
/// Number of freeze conflicts the old revision had and the new one does not.
fn resolved_freeze_conflicts(
  before : Array[FreezeConflict],
  after : Array[FreezeConflict],
) -> Int {
  let mut resolved = 0
  for conflict in before {
    let mut known = false
    for current in after {
      if current.window_id == conflict.window_id &&
        current.freeze_id == conflict.freeze_id {
        known = true
      }
    }
    if !known {
      resolved = resolved + 1
    }
  }
  resolved
}

///|
/// Compare two revisions of a plan.
///
/// The records are matched by id, so a record present in both plans with any
/// different field is one modification. Removals come first, then additions,
/// then modifications, each in the order of the plan they come from.
pub fn diff_plans(before : Plan, after : Plan) -> PlanDiff {
  let changes : Array[WindowChange] = []
  let mut removed = 0
  for window in before.windows {
    match after.find(window.id) {
      Some(_) => ()
      None => {
        removed = removed + 1
        changes.push({
          id: window.id,
          kind: ChangeKind::Removed,
          detail: format_instant(window.start) +
          " -> " +
          format_instant(window.end),
        })
      }
    }
  }
  let mut added = 0
  for window in after.windows {
    match before.find(window.id) {
      Some(_) => ()
      None => {
        added = added + 1
        changes.push({
          id: window.id,
          kind: ChangeKind::Added,
          detail: format_instant(window.start) +
          " -> " +
          format_instant(window.end),
        })
      }
    }
  }
  let mut modified = 0
  for window in after.windows {
    match before.find(window.id) {
      None => ()
      Some(previous) => {
        let detail = modification_detail(previous, window)
        if detail != "no field changed" {
          modified = modified + 1
          changes.push({ id: window.id, kind: ChangeKind::Modified, detail, })
        }
      }
    }
  }
  let before_report = audit(before)
  let after_report = audit(after)
  let fresh_overlaps : Array[Overlap] = []
  for overlap in after_report.overlaps {
    if !overlap_known(before_report.overlaps, overlap.first, overlap.second) {
      fresh_overlaps.push(overlap)
    }
  }
  let mut resolved_overlaps = 0
  for overlap in before_report.overlaps {
    if !overlap_known(after_report.overlaps, overlap.first, overlap.second) {
      resolved_overlaps = resolved_overlaps + 1
    }
  }
  {
    changes,
    added_windows: added,
    removed_windows: removed,
    modified_windows: modified,
    new_overlaps: fresh_overlaps,
    resolved_overlaps,
    new_freeze_conflicts: new_freeze_conflicts(
      before_report.freeze_conflicts,
      after_report.freeze_conflicts,
    ),
    resolved_freeze_conflicts: resolved_freeze_conflicts(
      before_report.freeze_conflicts,
      after_report.freeze_conflicts,
    ),
  }
}

///|
/// Render a diff as a stable text block.
///
/// The text ends with exactly one newline.
pub fn render_diff(diff : PlanDiff) -> String {
  let builder = StringBuilder()
  builder.write_string("Plan diff\n")
  builder.write_string(
    "added: " +
    diff.added_windows.to_string() +
    "  removed: " +
    diff.removed_windows.to_string() +
    "  modified: " +
    diff.modified_windows.to_string() +
    "\n",
  )
  builder.write_string(
    "new overlaps: " +
    diff.new_overlaps.length().to_string() +
    "  resolved overlaps: " +
    diff.resolved_overlaps.to_string() +
    "\n",
  )
  builder.write_string(
    "new freeze conflicts: " +
    diff.new_freeze_conflicts.length().to_string() +
    "  resolved freeze conflicts: " +
    diff.resolved_freeze_conflicts.to_string() +
    "\n",
  )
  builder.write_string("\nCHANGES\n")
  if diff.changes.length() == 0 {
    builder.write_string("  (none)\n")
  } else {
    for change in diff.changes {
      builder.write_string("  ")
      builder.write_string(change.to_line())
      builder.write_char('\n')
    }
  }
  builder.write_string("\nNEW OVERLAPS\n")
  if diff.new_overlaps.length() == 0 {
    builder.write_string("  (none)\n")
  } else {
    for overlap in diff.new_overlaps {
      builder.write_string("  ")
      builder.write_string(overlap.to_line())
      builder.write_char('\n')
    }
  }
  builder.write_string("\nNEW FREEZE CONFLICTS\n")
  if diff.new_freeze_conflicts.length() == 0 {
    builder.write_string("  (none)\n")
  } else {
    for conflict in diff.new_freeze_conflicts {
      builder.write_string("  ")
      builder.write_string(conflict.to_line())
      builder.write_char('\n')
    }
  }
  builder.to_string()
}