///|
/// Immutable counters. Queries count logical requests; resets and steps count
/// attempted target calls, including failed calls. Cache hits execute no target code.
pub(all) struct Statistics {
  queries : Int32
  cache_hits : Int32
  resets : Int32
  steps : Int32
  completed_queries : Int32
  cache_nodes : Int32
  output_code_units : Int32
} derive(Debug, ToJson)

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

///|
pub extend Statistics with ToJson::{to_json}

///|
priv struct TraceNode {
  children : Map[String, Int32]
  output : String?
}

///|
/// Bounded experiment session, with a prefix trace cache and consistency checks.
/// Do not run a session concurrently or mutate a target outside its adapter.
pub struct Experiment {
  priv system : System
  priv alphabet : Array[String]
  priv nodes : Array[TraceNode]
  priv max_queries : Int32
  priv max_steps : Int32
  priv max_word_length : Int32
  priv max_cache_nodes : Int32
  priv max_output_code_units : Int32
  priv use_cache : Bool
  priv recheck_every : Int32
  priv mut queries : Int32
  priv mut cache_hits : Int32
  priv mut resets : Int32
  priv mut steps : Int32
  priv mut completed_queries : Int32
  priv mut output_code_units : Int32
  priv mut in_flight : Bool
  priv mut conflict : (Array[String], Array[String], Array[String])?
}

///|
pub fn Experiment::new(
  system : System,
  alphabet : Array[String],
  max_queries~ : Int32 = 100000,
  max_steps~ : Int32 = 2000000,
  max_word_length~ : Int32 = 4096,
  max_cache_nodes~ : Int32 = 1000000,
  max_output_code_units~ : Int32 = 16777216,
  use_cache~ : Bool = true,
  recheck_every~ : Int32 = 0,
) -> Experiment raise LensError {
  validate_alphabet(alphabet)
  if max_queries < 0 ||
    max_steps < 0 ||
    max_word_length < 0 ||
    max_cache_nodes < 1 ||
    max_output_code_units < 0 ||
    recheck_every < 0 {
    raise InvalidArgument("invalid experiment budget")
  }
  {
    system,
    alphabet: alphabet.copy(),
    nodes: [{ children: Map([]), output: None, }],
    max_queries,
    max_steps,
    max_word_length,
    max_cache_nodes,
    max_output_code_units,
    use_cache,
    recheck_every,
    queries: 0,
    cache_hits: 0,
    resets: 0,
    steps: 0,
    completed_queries: 0,
    output_code_units: 0,
    in_flight: false,
    conflict: None,
  }
}

///|
pub fn Experiment::statistics(self : Experiment) -> Statistics {
  {
    queries: self.queries,
    cache_hits: self.cache_hits,
    resets: self.resets,
    steps: self.steps,
    completed_queries: self.completed_queries,
    cache_nodes: self.nodes.length(),
    output_code_units: self.output_code_units,
  }
}

///|
pub fn Experiment::alphabet(self : Experiment) -> Array[String] {
  self.alphabet.copy()
}

///|
/// Query one word. Every physical execution starts with reset and ends with close.
/// Bypass forces a new observation, checked against every previously observed prefix.
pub fn Experiment::query(
  self : Experiment,
  requested_word : Array[String],
  bypass_cache~ : Bool = false,
) -> Array[String] raise {
  if self.in_flight {
    raise InvalidArgument("reentrant experiment query")
  }
  self.in_flight = true
  defer {
    self.in_flight = false
  }
  if self.conflict is Some((word, expected, actual)) {
    raise InconsistentObservation(
      word=word.copy(),
      expected=expected.copy(),
      actual=actual.copy(),
    )
  }
  if requested_word.length() > self.max_word_length {
    raise LimitExceeded("word-length")
  }
  let word = requested_word.copy()
  for input in word {
    if !self.alphabet.contains(input) {
      raise UnknownInput(input)
    }
  }
  if self.queries == 2147483647 {
    raise LimitExceeded("logical-queries")
  }
  self.queries += 1
  let cached = []
  let mut node = 0
  for input in word {
    match self.nodes[node].children.get(input) {
      Some(next) => {
        node = next
        cached.push(self.nodes[node].output.unwrap())
      }
      None => break
    }
  }
  let force = bypass_cache ||
    (self.recheck_every > 0 && self.queries % self.recheck_every == 0)
  if self.use_cache && !force && cached.length() == word.length() {
    self.cache_hits += 1
    return cached
  }
  if self.resets >= self.max_queries {
    raise LimitExceeded("physical-queries")
  }
  if word.length() > self.max_steps - self.steps {
    raise LimitExceeded("target-steps")
  }
  if word.length() - cached.length() >
    self.max_cache_nodes - self.nodes.length() {
    raise LimitExceeded("cache-nodes")
  }
  let outputs = {
    errdefer ((self.system.close)() catch { _ => () })
    self.resets += 1
    (self.system.reset)()
    let result = []
    for input in word {
      self.steps += 1
      let output = (self.system.step)(input)
      if output.length() > self.max_output_code_units - self.output_code_units {
        raise LimitExceeded("output-code-units")
      }
      self.output_code_units += output.length()
      result.push(output)
    }
    result
  }
  (self.system.close)()
  self.completed_queries += 1
  // Check before inserting, so a conflicting trace cannot partially update the cache.
  for index in 0.. node = next
      None => {
        let next = self.nodes.length()
        self.nodes.push({ children: Map([]), output: Some(outputs[index]), })
        self.nodes[node].children[input] = next
        node = next
      }
    }
  }
  outputs
}

///|
/// Freshly replay a candidate word against both real targets.
/// None means the candidate did not reproduce a difference.
pub fn confirm_difference(
  left : Experiment,
  right : Experiment,
  inputs : Array[String],
) -> Difference? raise {
  let word = inputs.copy()
  let left_outputs = left.query(word, bypass_cache=true)
  let right_outputs = right.query(word, bypass_cache=true)
  if left_outputs == right_outputs {
    None
  } else {
    Some({ inputs: word, left_outputs, right_outputs, })
  }
}