///|
/// Retained problem trace. Offsets refer to this Dataset's encoded bytes, not
/// a rewritten selection. Header identifiers are raw and need not be unique.
/// statistics is absent if ANY sample cannot be analyzed; no partial mean.
/// integer_range_samples counts |integer|>2^53, a conservative analysis
/// boundary, NOT a claim that every such integer is inexact or malformed.
pub struct TraceQuality {
  trace : Int
  header_offset : Int
  sample_offset : Int
  sequence_line : Int
  sequence_file : Int
  field_record : Int
  ensemble : Int
  identification : Int
  samples : Int
  interval_us : Double
  weighting : Int
  flags : Array[String]
  nonfinite_samples : Int
  first_nonfinite_sample : Int?
  integer_range_samples : Int
  first_integer_range_sample : Int?
  weighting_range_samples : Int
  first_weighting_range_sample : Int?
  statistics : TraceStatistics?
}

///|
fn quality_index_json(value : Int?) -> Json {
  match value {
    Some(x) => x.to_json()
    None => null
  }
}

///|
pub impl ToJson for TraceQuality with fn to_json(self) {
  {
    "trace": self.trace.to_json(),
    "header_offset": self.header_offset.to_json(),
    "sample_offset": self.sample_offset.to_json(),
    "sequence_line": self.sequence_line.to_json(),
    "sequence_file": self.sequence_file.to_json(),
    "field_record": self.field_record.to_json(),
    "ensemble": self.ensemble.to_json(),
    "identification": self.identification.to_json(),
    "samples": self.samples.to_json(),
    "interval_us": self.interval_us.to_json(),
    "weighting": self.weighting.to_json(),
    "flags": self.flags.to_json(),
    "nonfinite_samples": self.nonfinite_samples.to_json(),
    "first_nonfinite_sample": quality_index_json(self.first_nonfinite_sample),
    "integer_range_samples": self.integer_range_samples.to_json(),
    "first_integer_range_sample": quality_index_json(
      self.first_integer_range_sample,
    ),
    "weighting_range_samples": self.weighting_range_samples.to_json(),
    "first_weighting_range_sample": quality_index_json(
      self.first_weighting_range_sample,
    ),
    "statistics": match self.statistics {
      Some(x) => x.to_json()
      None => null
    },
  }
}

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

///|
/// Counts cover ALL selected traces, even when details are truncated.
/// analyzed_* and pooled statistics exclude a WHOLE trace if any amplitude
/// is unavailable. Nonfinite/integer-range/weighting-range counts are sample counts; negative
/// weighting counts traces. dead/clipped counts use fully analyzable traces.
pub struct QualitySummary {
  traces : Int
  samples : Int
  analyzed_traces : Int
  analyzed_samples : Int
  problem_traces : Int
  nonfinite_samples : Int
  integer_range_samples : Int
  negative_weighting_traces : Int
  weighting_range_samples : Int
  dead_traces : Int
  header_dead_traces : Int
  clipped_traces : Int
  clipped_samples : Int
  minimum_samples : Int
  maximum_samples : Int
  minimum_interval_us : Double
  maximum_interval_us : Double
  statistics : AmplitudeStatistics?
}

///|
pub impl ToJson for QualitySummary with fn to_json(self) {
  {
    "traces": self.traces.to_json(),
    "samples": self.samples.to_json(),
    "analyzed_traces": self.analyzed_traces.to_json(),
    "analyzed_samples": self.analyzed_samples.to_json(),
    "problem_traces": self.problem_traces.to_json(),
    "nonfinite_samples": self.nonfinite_samples.to_json(),
    "integer_range_samples": self.integer_range_samples.to_json(),
    "negative_weighting_traces": self.negative_weighting_traces.to_json(),
    "weighting_range_samples": self.weighting_range_samples.to_json(),
    "dead_traces": self.dead_traces.to_json(),
    "header_dead_traces": self.header_dead_traces.to_json(),
    "clipped_traces": self.clipped_traces.to_json(),
    "clipped_samples": self.clipped_samples.to_json(),
    "minimum_samples": self.minimum_samples.to_json(),
    "maximum_samples": self.maximum_samples.to_json(),
    "minimum_interval_us": self.minimum_interval_us.to_json(),
    "maximum_interval_us": self.maximum_interval_us.to_json(),
    "statistics": match self.statistics {
      Some(x) => x.to_json()
      None => null
    },
  }
}

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

///|
/// Groups are unscaled header values, in first SELECTED occurrence order.
/// No geometric grouping, coordinate-unit conversion or CRS inference.
pub struct QualityGroup {
  value : Int
  first_trace : Int
  summary : QualitySummary
} derive(ToJson)

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

///|
/// status clear/issues refers only to the explicitly enabled checks. Neither
/// is instrument certification, a geological conclusion or acceptance proof.
/// Details keep the first max_details problem traces in selection order.
pub struct QualityReport {
  status : String
  format_assumptions : Array[String]
  source_trace_count : Int
  weighted : Bool
  dead_threshold : Double
  clip_threshold : Double
  clipping_checked : Bool
  group_by : String?
  summary : QualitySummary
  groups : Array[QualityGroup]
  details : Array[TraceQuality]
  max_details : Int
  details_truncated : Bool
}

///|
pub impl ToJson for QualityReport with fn to_json(self) {
  {
    "status": self.status.to_json(),
    "format_assumptions": self.format_assumptions.to_json(),
    "source_trace_count": self.source_trace_count.to_json(),
    "weighted": self.weighted.to_json(),
    "dead_threshold": self.dead_threshold.to_json(),
    "clip_threshold": self.clip_threshold.to_json(),
    "clipping_checked": self.clipping_checked.to_json(),
    "group_by": match self.group_by {
      Some(x) => x.to_json()
      None => null
    },
    "summary": self.summary.to_json(),
    "groups": self.groups.to_json(),
    "details": self.details.to_json(),
    "max_details": self.max_details.to_json(),
    "details_truncated": self.details_truncated.to_json(),
  }
}

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

///|
priv struct QualityTotals {
  mut traces : Int
  mut samples : Int
  mut analyzed_traces : Int
  mut problem_traces : Int
  mut nonfinite_samples : Int
  mut integer_range_samples : Int
  mut negative_weighting_traces : Int
  mut weighting_range_samples : Int
  mut dead_traces : Int
  mut header_dead_traces : Int
  mut clipped_traces : Int
  mut clipped_samples : Int
  mut minimum_samples : Int
  mut maximum_samples : Int
  mut minimum_interval_us : Double
  mut maximum_interval_us : Double
  moments : QualityMoments
}

///|
fn QualityTotals::new() -> QualityTotals {
  {
    traces: 0,
    samples: 0,
    analyzed_traces: 0,
    problem_traces: 0,
    nonfinite_samples: 0,
    integer_range_samples: 0,
    negative_weighting_traces: 0,
    weighting_range_samples: 0,
    dead_traces: 0,
    header_dead_traces: 0,
    clipped_traces: 0,
    clipped_samples: 0,
    minimum_samples: 0,
    maximum_samples: 0,
    minimum_interval_us: 0.0,
    maximum_interval_us: 0.0,
    moments: QualityMoments::new(),
  }
}

///|
fn QualityTotals::add(
  self : QualityTotals,
  item : TraceQuality,
  moments : QualityMoments,
) -> Unit {
  if self.traces == 0 {
    self.minimum_samples = item.samples
    self.minimum_interval_us = item.interval_us
  }
  self.minimum_samples = self.minimum_samples.min(item.samples)
  self.maximum_samples = self.maximum_samples.max(item.samples)
  self.minimum_interval_us = self.minimum_interval_us.min(item.interval_us)
  self.maximum_interval_us = self.maximum_interval_us.max(item.interval_us)
  self.traces += 1
  self.samples += item.samples
  self.nonfinite_samples += item.nonfinite_samples
  self.integer_range_samples += item.integer_range_samples
  self.weighting_range_samples += item.weighting_range_samples
  if !item.flags.is_empty() {
    self.problem_traces += 1
  }
  if item.flags.contains("negative_weighting") {
    self.negative_weighting_traces += 1
  }
  if item.identification == 2 {
    self.header_dead_traces += 1
  }
  if item.statistics is Some(stats) {
    self.analyzed_traces += 1
    self.moments.merge(moments)
    if stats.dead {
      self.dead_traces += 1
    }
    if stats.clipped_samples > 0 {
      self.clipped_traces += 1
    }
    self.clipped_samples += stats.clipped_samples
  }
}

///|
fn QualityTotals::finish(self : QualityTotals) -> QualitySummary {
  {
    traces: self.traces,
    samples: self.samples,
    analyzed_traces: self.analyzed_traces,
    analyzed_samples: self.moments.count,
    problem_traces: self.problem_traces,
    nonfinite_samples: self.nonfinite_samples,
    integer_range_samples: self.integer_range_samples,
    negative_weighting_traces: self.negative_weighting_traces,
    weighting_range_samples: self.weighting_range_samples,
    dead_traces: self.dead_traces,
    header_dead_traces: self.header_dead_traces,
    clipped_traces: self.clipped_traces,
    clipped_samples: self.clipped_samples,
    minimum_samples: self.minimum_samples,
    maximum_samples: self.maximum_samples,
    minimum_interval_us: self.minimum_interval_us,
    maximum_interval_us: self.maximum_interval_us,
    statistics: self.moments.finish(),
  }
}

///|
fn quality_trace(
  trace : Trace,
  number : Int,
  index : TraceIndex,
  dead : Double,
  clip : Double,
  weighted : Bool,
) -> (TraceQuality, QualityMoments) raise {
  let weight = trace.field("weighting")
  let bad_weight = weighted && weight < 0
  let factor = if weighted && !bad_weight {
    @math.pow(2.0, -weight.to_double())
  } else {
    1.0
  }
  let moments = QualityMoments::new()
  let mut nonfinite = 0
  let mut inexact = 0
  let mut range = 0
  let mut first_nonfinite = None
  let mut first_inexact = None
  let mut first_range = None
  let mut clipped = 0
  for i in 0.. {
        nonfinite += 1
        if first_nonfinite is None {
          first_nonfinite = Some(i)
        }
        continue
      }
      Signed(x) if x < -9007199254740992L || x > 9007199254740992L => {
        inexact += 1
        if first_inexact is None {
          first_inexact = Some(i)
        }
        continue
      }
      Real(x) => x
      Signed(x) => x.to_double()
    }
    if bad_weight {
      continue
    }
    let value = raw * factor
    if !finite(value) || (raw != 0.0 && value == 0.0) {
      range += 1
      if first_range is None {
        first_range = Some(i)
      }
      continue
    }
    moments.add(value)
    if clip > 0.0 && value.abs() >= clip {
      clipped += 1
    }
  }
  let identification = trace.field("identification")
  let flags : Array[String] = []
  if nonfinite > 0 {
    flags.push("nonfinite_samples")
  }
  if inexact > 0 {
    flags.push("integer_range_samples")
  }
  if bad_weight {
    flags.push("negative_weighting")
  }
  if range > 0 {
    flags.push("weighting_range")
  }
  let statistics : TraceStatistics? = if flags.is_empty() {
    guard moments.finish() is Some(stats) else {
      raise Failure("empty trace analysis")
    }
    let is_dead = stats.minimum.abs().max(stats.maximum.abs()) <= dead
    if is_dead {
      flags.push("dead_amplitude")
    }
    if clipped > 0 {
      flags.push("clip_threshold")
    }
    Some({
      samples: stats.samples,
      minimum: stats.minimum,
      maximum: stats.maximum,
      mean: stats.mean,
      rms: stats.rms,
      standard_deviation: stats.standard_deviation,
      dead: is_dead,
      clipped_samples: clipped,
      clipping_checked: clip > 0.0,
    })
  } else {
    None
  }
  // Standard trace header bytes 29-30: code 2 means declared dead. This is
  // independent of an amplitude threshold (a constant nonzero trace is not zero).
  if identification == 2 {
    flags.push("header_dead")
  }
  (
    {
      trace: number,
      header_offset: index.header_offset,
      sample_offset: index.sample_offset,
      sequence_line: trace.field("sequence_line"),
      sequence_file: trace.field("sequence_file"),
      field_record: trace.field("field_record"),
      ensemble: trace.field("ensemble"),
      identification,
      samples: trace.count,
      interval_us: trace.dt,
      weighting: weight,
      flags,
      nonfinite_samples: nonfinite,
      first_nonfinite_sample: first_nonfinite,
      integer_range_samples: inexact,
      first_integer_range_sample: first_inexact,
      weighting_range_samples: range,
      first_weighting_range_sample: first_range,
      statistics,
    },
    moments,
  )
}

///|
/// Scan the whole dataset or a unique nonempty original-index selection.
/// Continue after un-analyzable amplitudes; structural parse errors still raise.
/// Thresholds use raw or explicitly weighting-scaled amplitudes: dead if ALL
/// |x|<=dead_threshold; clip if |x|>=clip_threshold (0 disables clipping).
/// Statistics exclude whole un-analyzable traces, not just their bad samples.
/// Limits: max_details 0..100000, max_groups 1..100000; data limits unchanged.
pub fn Dataset::quality_check(
  self : Dataset,
  traces? : Array[Int],
  group_by? : String,
  dead_threshold? : Double = 0.0,
  clip_threshold? : Double = 0.0,
  weighted? : Bool = false,
  max_details? : Int = 256,
  max_groups? : Int = 65536,
) -> QualityReport raise {
  if !finite(dead_threshold) ||
    !finite(clip_threshold) ||
    dead_threshold < 0.0 ||
    clip_threshold < 0.0 {
    raise Failure("invalid amplitude threshold")
  }
  if max_details < 0 ||
    max_details > 100000 ||
    max_groups < 1 ||
    max_groups > 100000 {
    raise Failure("quality limits: max_details 0..100000, max_groups 1..100000")
  }
  let selected = traces.unwrap_or_else(() => {
    Array::makei(self.trace_count(), i => i)
  })
  if selected.is_empty() || selected.length() > self.trace_count() {
    raise Failure("empty/oversized quality selection")
  }
  let seen : Map[Int, Bool] = Map([])
  for i in selected {
    if i < 0 || i >= self.trace_count() || seen.contains(i) {
      raise Failure("invalid/duplicate quality trace index")
    }
    seen[i] = true
  }
  if group_by is Some(field) {
    ignore(self.trace(selected[0]).field(field))
  }
  let totals = QualityTotals::new()
  let group_lookup : Map[Int, Int] = Map([])
  let grouped : Array[(Int, Int, QualityTotals)] = []
  let details : Array[TraceQuality] = []
  for i in selected {
    let trace = self.trace(i)
    let group = match group_by {
      Some(field) => {
        let value = trace.field(field)
        let pos = match group_lookup.get(value) {
          Some(pos) => pos
          None => {
            if grouped.length() >= max_groups {
              raise Failure("quality group budget exceeded")
            }
            let pos = grouped.length()
            group_lookup[value] = pos
            grouped.push((value, i, QualityTotals::new()))
            pos
          }
        }
        Some(grouped[pos].2)
      }
      None => None
    }
    let (item, moments) = quality_trace(
      trace,
      i,
      self.index[i],
      dead_threshold,
      clip_threshold,
      weighted,
    )
    totals.add(item, moments)
    if group is Some(total) {
      total.add(item, moments)
    }
    if !item.flags.is_empty() && details.length() < max_details {
      details.push(item)
    }
  }
  {
    status: if totals.problem_traces > 0 {
      "issues"
    } else {
      "clear"
    },
    source_trace_count: self.trace_count(),
    format_assumptions: if self.header.assumed_legacy_revision_one {
      [
        "big-endian revision word 0x0001 explicitly interpreted as revision 1.0; raw bytes retained",
      ]
    } else {
      []
    },
    weighted,
    dead_threshold,
    clip_threshold,
    clipping_checked: clip_threshold > 0.0,
    group_by,
    summary: totals.finish(),
    groups: grouped.map(g => {
      value: g.0,
      first_trace: g.1,
      summary: g.2.finish(),
    }),
    details,
    max_details,
    details_truncated: totals.problem_traces > details.length(),
  }
}

///|
/// Retained problem-trace rows only. Use JSON for aggregate/group statistics,
/// exclusions, format assumptions and truncation; an empty CSV is NOT evidence
/// of a clear dataset. Keep the JSON report alongside an exported issue CSV.
pub fn QualityReport::issues_csv(self : QualityReport) -> String {
  let out = StringBuilder()
  out.write_string(
    "trace,header_offset,sample_offset,sequence_line,sequence_file,field_record,ensemble,samples,interval_us,flags,nonfinite_samples,first_nonfinite_sample,integer_range_samples,first_integer_range_sample,weighting_range_samples,first_weighting_range_sample,statistics_available,dead,clipped_samples,mean,rms\n",
  )
  let index = (x : Int?) => {
    match x {
      Some(v) => v.to_string()
      None => ""
    }
  }
  for d in self.details {
    out.write_string(
      "\{d.trace},\{d.header_offset},\{d.sample_offset},\{d.sequence_line},\{d.sequence_file},\{d.field_record},\{d.ensemble},\{d.samples},\{d.interval_us},\{d.flags.join(";")},\{d.nonfinite_samples},\{index(d.first_nonfinite_sample)},\{d.integer_range_samples},\{index(d.first_integer_range_sample)},\{d.weighting_range_samples},\{index(d.first_weighting_range_sample)},",
    )
    match d.statistics {
      Some(s) =>
        out.write_string(
          "true,\{s.dead},\{s.clipped_samples},\{s.mean},\{s.rms}\n",
        )
      None => out.write_string("false,,,,\n")
    }
  }
  out.to_string()
}