///|
/// Structural metrics for monitoring model growth and propagation quality.
pub struct SolverMetrics {
  variables : Int
  constraints : Int
  total_candidates : Int
  singleton_variables : Int
  empty_variables : Int
  global_constraints : Int
  arithmetic_constraints : Int
  scheduling_constraints : Int
}

///|
/// Calculate structural metrics before solving.
pub fn Solver::metrics(self : Solver) -> SolverMetrics {
  let mut total_candidates = 0
  let mut singleton_variables = 0
  let mut empty_variables = 0
  for variable in self.variables {
    let size = variable.domain.size()
    total_candidates += size
    if size == 1 {
      singleton_variables += 1
    }
    if size == 0 {
      empty_variables += 1
    }
  }
  let mut global_constraints = 0
  let mut arithmetic_constraints = 0
  let mut scheduling_constraints = 0
  for constraint in self.constraints {
    match constraint_kind(constraint) {
      GlobalConstraint => global_constraints += 1
      ArithmeticConstraint => arithmetic_constraints += 1
      SchedulingConstraint => scheduling_constraints += 1
      _ => ()
    }
  }
  {
    variables: self.variables.length(),
    constraints: self.constraints.length(),
    total_candidates,
    singleton_variables,
    empty_variables,
    global_constraints,
    arithmetic_constraints,
    scheduling_constraints,
  }
}

///|
/// Return average candidate count rounded down.
pub fn SolverMetrics::average_domain_size(self : SolverMetrics) -> Int {
  if self.variables == 0 {
    0
  } else {
    self.total_candidates / self.variables
  }
}

///|
/// Return the fraction of variables already fixed as a percentage.
pub fn SolverMetrics::fixed_percentage(self : SolverMetrics) -> Int {
  if self.variables == 0 {
    0
  } else {
    self.singleton_variables * 100 / self.variables
  }
}

///|
/// Return whether the model contains an empty domain.
pub fn SolverMetrics::has_empty_domain(self : SolverMetrics) -> Bool {
  self.empty_variables > 0
}

///|
/// Return a rough branching estimate.
pub fn SolverMetrics::branching_estimate(self : SolverMetrics) -> Int {
  if self.singleton_variables >= self.variables {
    1
  } else {
    let unassigned = self.variables - self.singleton_variables
    if unassigned == 0 {
      1
    } else {
      self.total_candidates / unassigned
    }
  }
}

///|
/// Return a stable metrics line.
pub fn SolverMetrics::describe(self : SolverMetrics) -> String {
  "variables=\{self.variables}, constraints=\{self.constraints}, candidates=\{self.total_candidates}, average_domain=\{self.average_domain_size()}, fixed=\{self.fixed_percentage()}%, branching=\{self.branching_estimate()}, global=\{self.global_constraints}, arithmetic=\{self.arithmetic_constraints}, scheduling=\{self.scheduling_constraints}"
}

///|
/// Calculate a simple model health score from 0 to 100.
pub fn SolverMetrics::health_score(self : SolverMetrics) -> Int {
  if self.has_empty_domain() {
    return 0
  }
  let mut score = 100
  if self.constraints == 0 && self.variables > 0 {
    score -= 20
  }
  if self.average_domain_size() > 1000 {
    score -= 10
  }
  if self.global_constraints == 0 &&
    self.arithmetic_constraints == 0 &&
    self.scheduling_constraints == 0 {
    score -= 10
  }
  if score < 0 {
    0
  } else {
    score
  }
}

///|
/// Render metrics and latest search statistics together.
pub fn Solver::performance_report(self : Solver) -> String {
  let metrics = self.metrics()
  "\{metrics.describe()}\nsearch=\{self.stats().describe()}\nhealth=\{metrics.health_score()}"
}

///|
/// Return a two-column map that can be emitted by a metrics adapter.
pub fn Solver::metrics_map(self : Solver) -> Map[String, Int] {
  let metrics = self.metrics()
  {
    "variables": metrics.variables,
    "constraints": metrics.constraints,
    "candidates": metrics.total_candidates,
    "fixed": metrics.singleton_variables,
    "global": metrics.global_constraints,
    "arithmetic": metrics.arithmetic_constraints,
    "scheduling": metrics.scheduling_constraints,
    "health": metrics.health_score(),
  }
}

///|
/// Return the number of candidate values removed by the last solve.
pub fn Solver::last_pruned_values(self : Solver) -> Int {
  self.last_stats.pruned_count()
}

///|
/// Return whether the last solve reached a budget limit.
pub fn Solver::last_solve_truncated(self : Solver) -> Bool {
  self.last_stats.is_truncated()
}