///|
/// Incremental graph primitives for recommendation, similarity, and
/// dependency analysis.
///
/// The graph stores directed weighted edges and maintains enough aggregate
/// state for bounded online updates.  It is suitable for co-visitation graphs,
/// feature-dependency graphs, and lightweight personalized retrieval.
pub struct GraphNeighbor {
  node : String
  weight : Double
}

///|
pub fn GraphNeighbor::new(node : String, weight : Double) -> GraphNeighbor {
  { node, weight }
}

///|
pub fn GraphNeighbor::node(self : GraphNeighbor) -> String {
  self.node
}

///|
pub fn GraphNeighbor::weight(self : GraphNeighbor) -> Double {
  self.weight
}

///|
pub struct GraphNodeScore {
  node : String
  score : Double
  rank : Int
}

///|
pub fn GraphNodeScore::new(
  node : String,
  score : Double,
  rank : Int,
) -> GraphNodeScore {
  { node, score, rank }
}

///|
pub fn GraphNodeScore::node(self : GraphNodeScore) -> String {
  self.node
}

///|
pub fn GraphNodeScore::score(self : GraphNodeScore) -> Double {
  self.score
}

///|
pub fn GraphNodeScore::rank(self : GraphNodeScore) -> Int {
  self.rank
}

///|
pub struct GraphSummary {
  nodes : Int
  edges : Int
  total_weight : Double
  density : Double
  average_degree : Double
  self_loops : Int
}

///|
pub fn GraphSummary::nodes(self : GraphSummary) -> Int {
  self.nodes
}

///|
pub fn GraphSummary::edges(self : GraphSummary) -> Int {
  self.edges
}

///|
pub fn GraphSummary::total_weight(self : GraphSummary) -> Double {
  self.total_weight
}

///|
pub fn GraphSummary::density(self : GraphSummary) -> Double {
  self.density
}

///|
pub fn GraphSummary::average_degree(self : GraphSummary) -> Double {
  self.average_degree
}

///|
pub fn GraphSummary::self_loops(self : GraphSummary) -> Int {
  self.self_loops
}

///|
pub struct OnlineGraph {
  adjacency : Map[String, Map[String, Double]]
  mut edges : Int
  mut total_weight : Double
  mut self_loops : Int
  mut updates : Int
}

///|
pub fn OnlineGraph::new() -> OnlineGraph {
  { adjacency: {}, edges: 0, total_weight: 0.0, self_loops: 0, updates: 0 }
}

///|
pub fn OnlineGraph::add_edge(
  self : OnlineGraph,
  source : String,
  target : String,
  weight? : Double = 1.0,
) -> Bool {
  if source == "" || target == "" {
    false
  } else {
    let safe_weight = if weight.is_nan() || weight.is_inf() {
      0.0
    } else {
      weight
    }
    let neighbors = self.adjacency.get_or_init(source, () => {})
    let existed = neighbors.contains(target)
    let previous = neighbors.get(target).unwrap_or(0.0)
    neighbors[target] = previous + safe_weight
    if !existed {
      self.edges += 1
    }
    self.total_weight += safe_weight
    if source == target && !existed {
      self.self_loops += 1
    }
    self.updates += 1
    true
  }
}

///|
pub fn OnlineGraph::set_edge(
  self : OnlineGraph,
  source : String,
  target : String,
  weight : Double,
) -> Bool {
  if source == "" || target == "" {
    false
  } else {
    let safe_weight = if weight.is_nan() || weight.is_inf() {
      0.0
    } else {
      weight
    }
    let neighbors = self.adjacency.get_or_init(source, () => {})
    let existed = neighbors.contains(target)
    let previous = neighbors.get(target).unwrap_or(0.0)
    neighbors[target] = safe_weight
    if !existed {
      self.edges += 1
    }
    self.total_weight += safe_weight - previous
    if source == target && !existed {
      self.self_loops += 1
    }
    self.updates += 1
    true
  }
}

///|
pub fn OnlineGraph::remove_edge(
  self : OnlineGraph,
  source : String,
  target : String,
) -> Bool {
  match self.adjacency.get(source) {
    None => false
    Some(neighbors) =>
      match neighbors.get(target) {
        None => false
        Some(weight) => {
          neighbors.remove(target)
          if neighbors.is_empty() {
            self.adjacency.remove(source)
          }
          self.edges -= 1
          self.total_weight -= weight
          if source == target {
            self.self_loops -= 1
          }
          true
        }
      }
  }
}

///|
pub fn OnlineGraph::weight(
  self : OnlineGraph,
  source : String,
  target : String,
) -> Double? {
  match self.adjacency.get(source) {
    Some(neighbors) => neighbors.get(target)
    None => None
  }
}

///|
pub fn OnlineGraph::neighbors(
  self : OnlineGraph,
  source : String,
  limit? : Int = 0,
) -> Array[GraphNeighbor] {
  match self.adjacency.get(source) {
    None => []
    Some(neighbors) => {
      let result : Array[GraphNeighbor] = []
      neighbors.each((node, weight) => {
        result.push(GraphNeighbor::new(node, weight))
      })
      result.sort_by((left, right) => {
        if left.weight > right.weight {
          -1
        } else if left.weight < right.weight {
          1
        } else {
          left.node.compare(right.node)
        }
      })
      if limit <= 0 || limit >= result.length() {
        result
      } else {
        result[:limit].to_owned()
      }
    }
  }
}

///|
pub fn OnlineGraph::nodes(self : OnlineGraph) -> Array[String] {
  let names : Array[String] = []
  self.adjacency.each((source, _) => names.push(source))
  let incoming : Map[String, Bool] = Map([])
  self.adjacency.each((source, neighbors) => {
    let _ = source
    neighbors.each((target, _) => incoming[target] = true)
  })
  incoming.each((target, _) => if !names.contains(target) { names.push(target) })
  names.sort()
  names
}

///|
pub fn OnlineGraph::node_count(self : OnlineGraph) -> Int {
  self.nodes().length()
}

///|
pub fn OnlineGraph::edge_count(self : OnlineGraph) -> Int {
  self.edges
}

///|
pub fn OnlineGraph::updates(self : OnlineGraph) -> Int {
  self.updates
}

///|
pub fn OnlineGraph::out_degree(self : OnlineGraph, node : String) -> Int {
  self.adjacency.get(node).map(neighbors => neighbors.length()).unwrap_or(0)
}

///|
pub fn OnlineGraph::weighted_degree(
  self : OnlineGraph,
  node : String,
) -> Double {
  self.adjacency
  .get(node)
  .map(neighbors => {
    let total = Ref(0.0)
    neighbors.each((_, value) => total.val += value)
    total.val
  })
  .unwrap_or(0.0)
}

///|
pub fn OnlineGraph::summary(self : OnlineGraph) -> GraphSummary {
  let node_count = self.node_count()
  let possible = node_count * (node_count - 1)
  {
    nodes: node_count,
    edges: self.edges,
    total_weight: self.total_weight,
    density: if possible <= 0 {
      0.0
    } else {
      self.edges.to_double() / possible.to_double()
    },
    average_degree: if node_count == 0 {
      0.0
    } else {
      self.edges.to_double() / node_count.to_double()
    },
    self_loops: self.self_loops,
  }
}

///|
pub fn OnlineGraph::page_rank(
  self : OnlineGraph,
  iterations? : Int = 20,
  damping? : Double = 0.85,
) -> Array[GraphNodeScore] {
  let names = self.nodes()
  if names.is_empty() {
    []
  } else {
    let count = names.length()
    let safe_iterations = if iterations < 0 { 0 } else { iterations }
    let alpha = clamp(damping, 0.0, 1.0)
    let scores = Array::make(count, 1.0 / count.to_double())
    let next = Array::make(count, 0.0)
    let index : Map[String, Int] = Map([])
    for i in 0.. {
            match index.get(target) {
              Some(target_index) =>
                next[target_index] += alpha *
                  scores[source_index] *
                  weight /
                  total
              None => ()
            }
          })
        }
      }
      for i in 0.. {
      GraphNodeScore::new(names[i], scores[i], i)
    })
    result.sort_by((left, right) => {
      if left.score > right.score {
        -1
      } else if left.score < right.score {
        1
      } else {
        left.node.compare(right.node)
      }
    })
    for i in 0.. Unit {
  self.adjacency.clear()
  self.edges = 0
  self.total_weight = 0.0
  self.self_loops = 0
  self.updates = 0
}

///|
/// Maintains item co-occurrence weights from sessions or baskets.  Pair keys
/// are kept canonical so (a,b) and (b,a) share one statistic.
pub struct ItemCooccurrenceModel {
  pair_weights : Map[String, Double]
  item_counts : Map[String, Double]
  mut sessions : Int
  mut events : Int
}

///|
pub fn ItemCooccurrenceModel::new() -> ItemCooccurrenceModel {
  { pair_weights: Map([]), item_counts: Map([]), sessions: 0, events: 0 }
}

///|
fn cooccurrence_order(left : String, right : String) -> (String, String) {
  if left.compare(right) <= 0 {
    (left, right)
  } else {
    (right, left)
  }
}

///|
fn cooccurrence_key(left : String, right : String) -> String {
  let pair = cooccurrence_order(left, right)
  "\{pair.0}|\{pair.1}"
}

///|
pub fn ItemCooccurrenceModel::observe(
  self : ItemCooccurrenceModel,
  items : Array[String],
) -> Unit {
  let unique : Map[String, Bool] = Map([])
  for item in items {
    if item != "" && !unique.contains(item) {
      unique[item] = true
      self.item_counts.update_or_default(item, 0.0, count => count + 1.0)
      self.events += 1
    }
  }
  let names : Array[String] = []
  unique.each((name, _) => names.push(name))
  for i in 0.. Double {
  if left == right {
    0.0
  } else {
    self.pair_weights.get(cooccurrence_key(left, right)).unwrap_or(0.0)
  }
}

///|
pub fn ItemCooccurrenceModel::similarity(
  self : ItemCooccurrenceModel,
  left : String,
  right : String,
) -> Double {
  let shared = self.score(left, right)
  let left_count = self.item_counts.get(left).unwrap_or(0.0)
  let right_count = self.item_counts.get(right).unwrap_or(0.0)
  if shared <= 0.0 || left_count <= 0.0 || right_count <= 0.0 {
    0.0
  } else {
    shared / (left_count * right_count).sqrt()
  }
}

///|
pub fn ItemCooccurrenceModel::recommend(
  self : ItemCooccurrenceModel,
  history : Array[String],
  limit? : Int = 10,
) -> Array[GraphNeighbor] {
  let seen : Map[String, Bool] = Map([])
  for item in history {
    seen[item] = true
  }
  let result : Array[GraphNeighbor] = []
  self.item_counts.each((candidate, _) => {
    if !seen.contains(candidate) {
      let total = Ref(0.0)
      for seed in history {
        total.val += self.score(seed, candidate)
      }
      if total.val > 0.0 {
        result.push(GraphNeighbor::new(candidate, total.val))
      }
    }
  })
  result.sort_by((left, right) => {
    if left.weight > right.weight {
      -1
    } else {
      1
    }
  })
  if limit <= 0 || limit >= result.length() {
    result
  } else {
    result[:limit].to_owned()
  }
}

///|
pub fn ItemCooccurrenceModel::item_count(
  self : ItemCooccurrenceModel,
  item : String,
) -> Double {
  self.item_counts.get(item).unwrap_or(0.0)
}

///|
pub fn ItemCooccurrenceModel::items(
  self : ItemCooccurrenceModel,
) -> Array[String] {
  let result : Array[String] = []
  self.item_counts.each((item, _) => result.push(item))
  result
}

///|
pub fn ItemCooccurrenceModel::sessions(self : ItemCooccurrenceModel) -> Int {
  self.sessions
}

///|
pub fn ItemCooccurrenceModel::events(self : ItemCooccurrenceModel) -> Int {
  self.events
}

///|
pub fn ItemCooccurrenceModel::reset(self : ItemCooccurrenceModel) -> Unit {
  self.pair_weights.clear()
  self.item_counts.clear()
  self.sessions = 0
  self.events = 0
}

///|
/// A compact user-item preference store with popularity and cold-start
/// fallbacks.  Scores are weighted by the caller, so clicks, purchases, and
/// negative feedback can share one serving interface.
pub struct UserItemRecommender {
  preferences : Map[String, Map[String, Double]]
  popularity : Map[String, Double]
  mut interactions : Int
}

///|
pub fn UserItemRecommender::new() -> UserItemRecommender {
  { preferences: {}, popularity: {}, interactions: 0 }
}

///|
pub fn UserItemRecommender::observe(
  self : UserItemRecommender,
  user : String,
  item : String,
  value? : Double = 1.0,
) -> Bool {
  if user == "" || item == "" {
    false
  } else {
    let safe = if value.is_nan() || value.is_inf() { 0.0 } else { value }
    let row = self.preferences.get_or_init(user, () => {})
    row.update_or_default(item, 0.0, previous => previous + safe)
    self.popularity.update_or_default(item, 0.0, previous => previous + safe)
    self.interactions += 1
    true
  }
}

///|
pub fn UserItemRecommender::score(
  self : UserItemRecommender,
  user : String,
  item : String,
) -> Double {
  match self.preferences.get(user) {
    Some(row) => row.get(item).unwrap_or(0.0)
    None => 0.0
  }
}

///|
pub fn UserItemRecommender::recommend(
  self : UserItemRecommender,
  user : String,
  limit? : Int = 10,
) -> Array[GraphNeighbor] {
  let consumed = self.preferences.get(user).unwrap_or({})
  let result : Array[GraphNeighbor] = []
  self.popularity.each((item, popularity) => {
    if !consumed.contains(item) {
      result.push(GraphNeighbor::new(item, popularity))
    }
  })
  result.sort_by((left, right) => {
    if left.weight > right.weight {
      -1
    } else {
      1
    }
  })
  if limit <= 0 || limit >= result.length() {
    result
  } else {
    result[:limit].to_owned()
  }
}

///|
pub fn UserItemRecommender::users(self : UserItemRecommender) -> Array[String] {
  let result : Array[String] = []
  self.preferences.each((user, _) => result.push(user))
  result
}

///|
pub fn UserItemRecommender::items(self : UserItemRecommender) -> Array[String] {
  let result : Array[String] = []
  self.popularity.each((item, _) => result.push(item))
  result
}

///|
pub fn UserItemRecommender::interactions(self : UserItemRecommender) -> Int {
  self.interactions
}

///|
pub fn UserItemRecommender::reset(self : UserItemRecommender) -> Unit {
  self.preferences.clear()
  self.popularity.clear()
  self.interactions = 0
}