///|
pub(all) struct ActivityNode {
  activity : String
  occurrences : Int32
  cases : Int32
  starts : Int32
  ends : Int32
} derive(Eq, @debug.Debug)

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

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

///|
pub(all) struct FlowEdge {
  source : String
  target : String
  occurrences : Int32
  cases : Int32
} derive(Eq, @debug.Debug)

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

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

///|
pub(all) struct ProcessVariant {
  activities : Array[String]
  cases : Array[String]
}

///|
pub(all) struct ProcessMap {
  nodes : Array[ActivityNode]
  edges : Array[FlowEdge]
  variants : Array[ProcessVariant]
  cases : Int32
  events : Int32
}

///|
fn path_identity(activities : Array[String]) -> String {
  let result = StringBuilder()
  for value in activities {
    result.write_string("\{value.length()}:\{value}")
  }
  result.to_string()
}

///|
/// Direct-follow means adjacency in each reconstructed case, not causality.
/// Self-loops are retained; the graph does not infer a Petri net or parallelism.
pub fn EventLog::discover(self : EventLog) -> ProcessMap {
  let nodes : Map[String, ActivityNode] = Map([])
  let edges : Map[String, FlowEdge] = Map([])
  let variants : Map[String, ProcessVariant] = Map([])
  for trace in self.traces {
    let seen_nodes : Map[String, Bool] = Map([])
    let seen_edges : Map[String, Bool] = Map([])
    let names = trace.activities()
    let identity = path_identity(names)
    match variants.get(identity) {
      Some(variant) => variant.cases.push(trace.id)
      None => variants.set(identity, { activities: names, cases: [trace.id], })
    }
    for i, event in trace.events {
      let name = event.activity
      let row = nodes
        .get(name)
        .unwrap_or({
          activity: name,
          occurrences: 0,
          cases: 0,
          starts: 0,
          ends: 0,
        })
      nodes.set(name, {
        activity: name,
        occurrences: row.occurrences + 1,
        cases: row.cases + (if seen_nodes.contains(name) { 0 } else { 1 }),
        starts: row.starts + (if i == 0 { 1 } else { 0 }),
        ends: row.ends + (if i + 1 == trace.events.length() { 1 } else { 0 }),
      })
      seen_nodes.set(name, true)
      if i > 0 {
        let previous = trace.events[i - 1].activity
        let key = path_identity([previous, name])
        let edge = edges
          .get(key)
          .unwrap_or({
            source: previous,
            target: name,
            occurrences: 0,
            cases: 0,
          })
        edges.set(key, {
          ..edge,
          occurrences: edge.occurrences + 1,
          cases: edge.cases + (if seen_edges.contains(key) { 0 } else { 1 }),
        })
        seen_edges.set(key, true)
      }
    }
  }
  let sorted_nodes = nodes.values().collect()
  sorted_nodes.sort_by((a, b) => a.activity.lexical_compare(b.activity))
  let sorted_edges = edges.values().collect()
  sorted_edges.sort_by((a, b) => {
    let source = a.source.lexical_compare(b.source)
    if source != 0 {
      source
    } else {
      a.target.lexical_compare(b.target)
    }
  })
  let sorted_variants = variants.values().collect()
  sorted_variants.sort_by((a, b) => {
    let frequency = b.cases.length().compare(a.cases.length())
    if frequency != 0 {
      frequency
    } else {
      path_identity(a.activities).lexical_compare(path_identity(b.activities))
    }
  })
  {
    nodes: sorted_nodes,
    edges: sorted_edges,
    variants: sorted_variants,
    cases: self.traces.length(),
    events: self.event_count,
  }
}

///|
/// Return a detached graph filtered by edge occurrence count. All observed
/// activity nodes remain so pruning cannot silently erase isolated activities.
pub fn ProcessMap::frequent_edges(
  self : ProcessMap,
  minimum : Int32,
) -> Array[FlowEdge] raise ProcessError {
  if minimum < 1 {
    raise InvalidLog(
      path="discovery.minimum",
      reason="expected positive frequency",
    )
  }
  self.edges.filter(edge => edge.occurrences >= minimum)
}

///|
pub fn process_map_to_json(map : ProcessMap) -> Json {
  let nodes : Array[Json] = map.nodes.map(node => {
    "activity": Json::string(node.activity),
    "occurrences": Json::number(node.occurrences.to_double()),
    "cases": Json::number(node.cases.to_double()),
    "starts": Json::number(node.starts.to_double()),
    "ends": Json::number(node.ends.to_double()),
  })
  let edges : Array[Json] = map.edges.map(edge => {
    "source": Json::string(edge.source),
    "target": Json::string(edge.target),
    "occurrences": Json::number(edge.occurrences.to_double()),
    "cases": Json::number(edge.cases.to_double()),
  })
  let variants : Array[Json] = map.variants.map(variant => {
    "activities": Json::array(variant.activities.map(Json::string)),
    "cases": Json::array(variant.cases.map(Json::string)),
  })
  {
    "cases": Json::number(map.cases.to_double()),
    "events": Json::number(map.events.to_double()),
    "nodes": Json::array(nodes),
    "edges": Json::array(edges),
    "variants": Json::array(variants),
  }
}