///|
pub(all) struct CaseSelection {
  require_activities : Array[String]
  exclude_activities : Array[String]
  minimum_cycle : Int64?
  maximum_cycle : Int64?
  starts_from : Int64?
  starts_before : Int64?
}

///|
pub fn CaseSelection::all() -> CaseSelection {
  {
    require_activities: [],
    exclude_activities: [],
    minimum_cycle: None,
    maximum_cycle: None,
    starts_from: None,
    starts_before: None,
  }
}

///|
/// Select WHOLE cases, never truncate histories or synthesize missing events.
/// Window is start-inclusive/end-exclusive in the caller's interval time unit.
pub fn EventLog::select_cases(
  self : EventLog,
  selection : CaseSelection,
) -> EventLog raise ProcessError {
  if selection.require_activities.length() +
    selection.exclude_activities.length() >
    256 {
    raise Capacity(resource="selection activities", limit=256)
  }
  for
    option in [
      selection.minimum_cycle,
      selection.maximum_cycle,
      selection.starts_from,
      selection.starts_before,
    ] {
    if option is Some(value) {
      model_require(value >= 0L, "selection", "negative selection bound")
    }
  }
  if (selection.minimum_cycle, selection.maximum_cycle)
    is (Some(low), Some(high)) {
    model_require(low <= high, "selection", "inverted duration bounds")
  }
  if (selection.starts_from, selection.starts_before) is (Some(low), Some(high)) {
    model_require(low <= high, "selection", "inverted time window")
  }
  for activity in selection.require_activities {
    process_label(activity, "selection.activity")
  }
  for activity in selection.exclude_activities {
    process_label(activity, "selection.activity")
  }
  let traces = []
  let mut events = 0
  for trace in self.traces {
    let activities = trace.activities()
    let duration = trace.cycle_time()
    if !selection.require_activities.all(a => activities.contains(a)) ||
      selection.exclude_activities.any(a => activities.contains(a)) {
      continue
    }
    if selection.minimum_cycle is Some(minimum) {
      if duration < minimum {
        continue
      }
    }
    if selection.maximum_cycle is Some(maximum) {
      if duration > maximum {
        continue
      }
    }
    if selection.starts_from is Some(low) {
      if trace.events.is_empty() || trace.events[0].started < low {
        continue
      }
    }
    if selection.starts_before is Some(high) {
      if trace.events.is_empty() || trace.events[0].started >= high {
        continue
      }
    }
    traces.push(trace)
    events += trace.events.length()
  }
  { traces, event_count: events, }
}

///|
pub(all) struct ResourceHandoff {
  source : String
  target : String
  occurrences : Int32
  cases : Int32
}

///|
/// Adjacent observed resource handovers, excluding self-handover and missing
/// resources. Descriptive only: adjacency does not prove a causal transfer.
pub fn EventLog::resource_handoffs(self : EventLog) -> Array[ResourceHandoff] {
  let edges : Map[String, ResourceHandoff] = Map([])
  for trace in self.traces {
    let seen : Map[String, Bool] = Map([])
    for i in 1.. {
    let order = a.source.lexical_compare(b.source)
    if order == 0 {
      a.target.lexical_compare(b.target)
    } else {
      order
    }
  })
  result
}

///|
pub(all) struct VariantDelta {
  activities : Array[String]
  before_cases : Int32
  after_cases : Int32
}

///|
pub(all) struct CohortComparison {
  before_cases : Int32
  after_cases : Int32
  total_variation : Double
  variants : Array[VariantDelta]
}

///|
/// Compare normalized variant distributions (total variation distance 0..1).
/// Requires two nonempty cohorts; this is descriptive drift, not a p-value or
/// a causal explanation. Frequency and full variant sequence are retained.
pub fn EventLog::compare_cohort(
  self : EventLog,
  next : EventLog,
) -> CohortComparison raise ProcessError {
  model_require(
    !self.traces.is_empty() && !next.traces.is_empty(),
    "cohort",
    "nonempty cohorts required",
  )
  let variants : Map[String, VariantDelta] = Map([])
  for variant in self.discover().variants {
    variants[path_identity(variant.activities)] = {
      activities: variant.activities,
      before_cases: variant.cases.length(),
      after_cases: 0,
    }
  }
  for variant in next.discover().variants {
    let key = path_identity(variant.activities)
    let row = variants
      .get(key)
      .unwrap_or({
        activities: variant.activities,
        before_cases: 0,
        after_cases: 0,
      })
    variants[key] = { ..row, after_cases: variant.cases.length(), }
  }
  let keys = variants.keys().collect()
  keys.sort_by((a, b) => a.lexical_compare(b))
  let result = []
  let mut difference = 0.0
  for key in keys {
    let row = variants[key]
    difference += (row.before_cases.to_double() /
    self.traces.length().to_double() -
    row.after_cases.to_double() / next.traces.length().to_double()).abs()
    result.push(row)
  }
  {
    before_cases: self.traces.length(),
    after_cases: next.traces.length(),
    total_variation: difference / 2.0,
    variants: result,
  }
}