///|
/// Schema constraints inferred or supplied for a signal.
pub enum SchemaConstraint {
  ScalarOnly
  VectorOnly
  RealOnly
  WidthExact
  WidthAtMost
  KnownValuesOnly
  MonotonicTime
} derive(Debug, Eq)

///|
/// A versioned signal schema entry.
pub struct SignalSchema {
  signal_id : String
  path : String
  declared_width : Int
  observed_width : Int
  kind : String
  constraints : Array[SchemaConstraint]
  samples : Int
  valid : Bool
} derive(Debug)

///|
/// Result of validating a trace against a schema.
pub struct SchemaFinding {
  signal_id : String
  constraint : SchemaConstraint
  valid : Bool
  observed : String
  expected : String
  message : String
} derive(Debug)

///|
/// A schema snapshot and validation findings.
pub struct TraceSchemaReport {
  version : String
  entries : Array[SignalSchema]
  findings : Array[SchemaFinding]
  valid : Bool
} derive(Debug)

///|
/// List supported schema constraints.
pub fn schema_constraint_catalog() -> Array[SchemaConstraint] {
  [
    ScalarOnly,
    VectorOnly,
    RealOnly,
    WidthExact,
    WidthAtMost,
    KnownValuesOnly,
    MonotonicTime,
  ]
}

///|
/// Infer a schema from declarations and observed values.
pub fn infer_trace_schema(trace : Trace) -> TraceSchemaReport {
  let entries : Array[SignalSchema] = []
  for signal in trace.signals {
    let changes = trace.changes.filter(fn(item) { item.id == signal.id })
    let observed_width = changes.fold(init=0, fn(current, item) {
      Int::max(current, item.value.width())
    })
    let constraints = schema_constraints_for(signal, changes)
    entries.push({
      signal_id: signal.id,
      path: signal_path(signal),
      declared_width: Int::max(1, signal.width),
      observed_width: Int::max(1, observed_width),
      kind: signal.kind,
      constraints,
      samples: changes.length(),
      valid: schema_entry_valid(signal, changes),
    })
  }
  let findings = schema_findings(trace, entries)
  {
    version: "1.0",
    entries,
    findings,
    valid: findings.filter(fn(item) { !item.valid }).length() == 0,
  }
}

///|
/// Validate a trace against an expected schema report.
pub fn validate_trace_schema(
  trace : Trace,
  expected : TraceSchemaReport,
) -> TraceSchemaReport {
  let actual = infer_trace_schema(trace)
  let findings = actual.findings.copy()
  for entry in expected.entries {
    match schema_find_entry(actual.entries, entry.signal_id) {
      Some(current) =>
        if current.declared_width != entry.declared_width {
          findings.push({
            signal_id: entry.signal_id,
            constraint: WidthExact,
            valid: false,
            observed: current.declared_width.to_string(),
            expected: entry.declared_width.to_string(),
            message: "declared width changed",
          })
        }
      None =>
        findings.push({
          signal_id: entry.signal_id,
          constraint: WidthExact,
          valid: false,
          observed: "missing",
          expected: "present",
          message: "schema signal is absent",
        })
    }
  }
  {
    ..actual,
    findings,
    valid: findings.filter(fn(item) { !item.valid }).length() == 0,
  }
}

///|
/// Find a schema entry without allocating a filtered array.
fn schema_find_entry(
  entries : Array[SignalSchema],
  signal_id : String,
) -> SignalSchema? {
  for entry in entries {
    if entry.signal_id == signal_id {
      return Some(entry)
    }
  }
  None
}

///|
/// Return a schema entry by signal identifier.
pub fn TraceSchemaReport::find(
  self : TraceSchemaReport,
  signal_id : String,
) -> SignalSchema? {
  for entry in self.entries {
    if entry.signal_id == signal_id {
      return Some(entry)
    }
  }
  None
}

///|
/// Return the number of schema violations.
pub fn TraceSchemaReport::violation_count(self : TraceSchemaReport) -> Int {
  self.findings.filter(fn(item) { !item.valid }).length()
}

///|
/// Export schema entries as CSV.
pub fn schema_entries_csv(entries : Array[SignalSchema]) -> String {
  let out = StringBuilder()
  out.write_string(
    "signal,path,declared_width,observed_width,kind,samples,valid,constraints\n",
  )
  for entry in entries {
    out.write_string(
      escape_text(entry.signal_id) +
      "," +
      escape_text(entry.path) +
      "," +
      entry.declared_width.to_string() +
      "," +
      entry.observed_width.to_string() +
      "," +
      escape_text(entry.kind) +
      "," +
      entry.samples.to_string() +
      "," +
      entry.valid.to_string() +
      "," +
      entry.constraints.map(schema_constraint_name).join("|") +
      "\n",
    )
  }
  out.to_string()
}

///|
/// Export schema findings as CSV.
pub fn schema_findings_csv(findings : Array[SchemaFinding]) -> String {
  let out = StringBuilder()
  out.write_string("signal,constraint,valid,observed,expected,message\n")
  for finding in findings {
    out.write_string(
      escape_text(finding.signal_id) +
      "," +
      schema_constraint_name(finding.constraint) +
      "," +
      finding.valid.to_string() +
      "," +
      escape_text(finding.observed) +
      "," +
      escape_text(finding.expected) +
      "," +
      escape_text(finding.message) +
      "\n",
    )
  }
  out.to_string()
}

///|
/// Render a schema report as a compact text summary.
pub fn TraceSchemaReport::to_text(self : TraceSchemaReport) -> String {
  "schema=" +
  self.version +
  " entries=" +
  self.entries.length().to_string() +
  " findings=" +
  self.findings.length().to_string() +
  " valid=" +
  self.valid.to_string()
}

///|
/// Render schema metadata as JSON.
pub fn TraceSchemaReport::to_json(self : TraceSchemaReport) -> String {
  let entries = self.entries.map(schema_entry_json).join(",")
  let findings = self.findings.map(schema_finding_json).join(",")
  "{\"version\":\"" +
  self.version +
  "\",\"valid\":" +
  self.valid.to_string() +
  ",\"entries\":[" +
  entries +
  "],\"findings\":[" +
  findings +
  "]}"
}

///|
/// Infer constraints from value kinds, widths and ordering.
fn schema_constraints_for(
  signal : Signal,
  changes : Array[Change],
) -> Array[SchemaConstraint] {
  let result : Array[SchemaConstraint] = [WidthExact, MonotonicTime]
  let mut scalar = false
  let mut vector = false
  let mut real = false
  let mut known = true
  for change in changes {
    match change.value {
      Scalar(_) => scalar = true
      Vector(_) => vector = true
      Real(_) => real = true
    }
    if change.value.is_unknown() {
      known = false
    }
  }
  if scalar && !vector && !real {
    result.push(ScalarOnly)
  }
  if vector && !scalar && !real {
    result.push(VectorOnly)
  }
  if real && !scalar && !vector {
    result.push(RealOnly)
  }
  if known {
    result.push(KnownValuesOnly)
  }
  if signal.width <= 1 && vector {
    result.push(WidthAtMost)
  }
  result
}

///|
/// Validate observed values against a declaration.
fn schema_entry_valid(signal : Signal, changes : Array[Change]) -> Bool {
  changes.all(fn(change) { change.value.width() <= Int::max(1, signal.width) })
}

///|
/// Produce findings for every inferred schema entry.
fn schema_findings(
  trace : Trace,
  entries : Array[SignalSchema],
) -> Array[SchemaFinding] {
  let result : Array[SchemaFinding] = []
  for entry in entries {
    if !entry.valid {
      result.push({
        signal_id: entry.signal_id,
        constraint: WidthExact,
        valid: false,
        observed: entry.observed_width.to_string(),
        expected: entry.declared_width.to_string(),
        message: "observed value exceeds declaration",
      })
    }
    let changes = trace.changes.filter(fn(item) { item.id == entry.signal_id })
    let mut previous : Int64? = None
    for change in changes {
      match previous {
        Some(time) if change.time < time =>
          result.push({
            signal_id: entry.signal_id,
            constraint: MonotonicTime,
            valid: false,
            observed: change.time.to_string(),
            expected: time.to_string(),
            message: "change timestamps are not monotonic",
          })
        _ => ()
      }
      previous = Some(change.time)
    }
  }
  result
}

///|
/// Name a schema constraint.
pub fn schema_constraint_name(value : SchemaConstraint) -> String {
  match value {
    ScalarOnly => "scalar-only"
    VectorOnly => "vector-only"
    RealOnly => "real-only"
    WidthExact => "width-exact"
    WidthAtMost => "width-at-most"
    KnownValuesOnly => "known-values-only"
    MonotonicTime => "monotonic-time"
  }
}

///|
/// Encode an entry as JSON.
fn schema_entry_json(entry : SignalSchema) -> String {
  "{\"signal\":\"" +
  escape_text(entry.signal_id) +
  "\",\"path\":\"" +
  escape_text(entry.path) +
  "\",\"declared_width\":" +
  entry.declared_width.to_string() +
  ",\"observed_width\":" +
  entry.observed_width.to_string() +
  ",\"kind\":\"" +
  escape_text(entry.kind) +
  "\",\"samples\":" +
  entry.samples.to_string() +
  ",\"valid\":" +
  entry.valid.to_string() +
  "}"
}

///|
/// Encode a finding as JSON.
fn schema_finding_json(item : SchemaFinding) -> String {
  "{\"signal\":\"" +
  escape_text(item.signal_id) +
  "\",\"constraint\":\"" +
  schema_constraint_name(item.constraint) +
  "\",\"valid\":" +
  item.valid.to_string() +
  ",\"observed\":\"" +
  escape_text(item.observed) +
  "\",\"expected\":\"" +
  escape_text(item.expected) +
  "\",\"message\":\"" +
  escape_text(item.message) +
  "\"}"
}