///|
/// A numeric CSV table with a mandatory unique header. No missing values,
/// implicit normalization or resampling is performed.
pub struct NumericCsv {
  headers : Array[String]
  rows : Array[Array[Double]]
} derive(@debug.Debug)

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

///|
fn csv_fields(line : String, row : Int) -> Array[String] raise TopologyError {
  let fields : Array[String] = []
  let mut field = ""
  let mut quoted = false
  let mut closed = false
  let chars = line.to_array()
  let mut i = 0
  while i < chars.length() {
    let c = chars[i]
    if quoted {
      if c == '"' {
        if i + 1 < chars.length() && chars[i + 1] == '"' {
          field += "\""
          i += 1
        } else {
          quoted = false
          closed = true
        }
      } else {
        field += c.to_string()
      }
    } else if c == ',' {
      fields.push(field.trim().to_owned())
      field = ""
      closed = false
    } else if c == '"' && !closed && field.trim().to_owned() == "" {
      field = ""
      quoted = true
    } else if c == '"' || (closed && c != ' ' && c != '\t' && c != '\r') {
      raise TopologyError("CSV row \{row}: unexpected text or quote")
    } else {
      field += c.to_string()
    }
    if field.length() > 256 || fields.length() >= 32 {
      raise TopologyError(
        "CSV fields must be <=256 characters and columns <=32",
      )
    }
    i += 1
  }
  if quoted {
    raise TopologyError(
      "CSV row \{row}: unclosed quote; multiline fields unsupported",
    )
  }
  fields.push(field.trim().to_owned())
  fields
}

///|
/// Read comma-separated numeric data. Supports BOM, CRLF, quoted fields and
/// escaped quotes in headers; numeric cells use JSON decimal/exponent syntax.
pub fn parse_numeric_csv(
  text : String,
  columns? : Array[String] = [],
) -> NumericCsv raise TopologyError {
  if text.length() == 0 || text.length() > 1000000 {
    raise TopologyError("CSV input must contain 1..1000000 characters")
  }
  let headers : Array[String] = []
  let rows : Array[Array[Double]] = []
  let selected : Array[Int] = []
  let width : Ref[Int] = Ref(0)
  for line_index, raw in text.split("\n").to_array() {
    let line = if line_index == 0 {
      raw.to_owned().trim_start(chars="\uFEFF").to_owned()
    } else {
      raw.to_owned()
    }
    if line.trim().to_owned() == "" {
      continue
    }
    let fields = csv_fields(line, line_index + 1)
    if headers.length() == 0 {
      for field in fields {
        if field == "" || headers.contains(field) {
          raise TopologyError("CSV headers must be nonempty and unique")
        }
        headers.push(field)
      }
      width.val = headers.length()
      if columns.length() == 0 {
        for i = 0; i < headers.length(); i = i + 1 {
          selected.push(i)
        }
      } else {
        let names : Array[String] = []
        for name in columns {
          if names.contains(name) || !headers.contains(name) {
            raise TopologyError(
              "CSV selected columns must be unique existing names",
            )
          }
          names.push(name)
          for i, header in headers {
            if header == name {
              selected.push(i)
            }
          }
        }
        headers.clear()
        for name in names {
          headers.push(name)
        }
      }
      continue
    }
    if fields.length() != width.val {
      raise TopologyError(
        "CSV row \{line_index + 1}: inconsistent column count",
      )
    }
    let values : Array[Double] = []
    for col in selected {
      let field = fields[col]
      let value : Double = @json.from_json(@json.parse(field)) catch {
        _ =>
          raise TopologyError(
            "CSV row \{line_index + 1}, column \{col + 1}: expected decimal number",
          )
      }
      if !finite(value) || value.abs() > 1.0e100 {
        raise TopologyError(
          "CSV row \{line_index + 1}, column \{col + 1}: number out of range",
        )
      }
      values.push(value)
    }
    rows.push(values)
    if rows.length() > 10000 {
      raise TopologyError("CSV supports at most 10000 data rows")
    }
  }
  if rows.length() == 0 {
    raise TopologyError(
      "CSV requires a header and at least one numeric data row",
    )
  }
  { headers, rows, }
}

///|
pub fn csv_preview_json(table : NumericCsv) -> Json {
  {
    "schema_version": 1,
    "row_count": table.rows.length(),
    "column_count": table.headers.length(),
    "headers": table.headers,
    "preview_rows": Array::makei(minimum_int(5, table.rows.length()), fn(i) {
      table.rows[i]
    }),
    "normalization": "none",
  }
}

///|
/// Convert a CSV into an ordinary reproducible analysis request.
pub fn csv_request_json(
  text : String,
  kind : String,
  threshold~ : Double,
  dimension? : Int = 2,
  lag? : Int = 1,
  stride? : Int = 1,
  columns? : Array[String] = [],
  normalization? : String = "none",
  sample_size? : Int = 0,
  max_cells? : Int = 4000,
) -> Json raise TopologyError {
  let table = transform_numeric_csv(
    parse_numeric_csv(text, columns~),
    normalization~,
  )
  if !finite(threshold) || threshold < 0.0 || threshold > 1.0e100 {
    raise TopologyError("CSV Rips threshold must be finite and nonnegative")
  }
  check_budget(0, max_cells)
  if kind == "points" || kind == "points_sparse" {
    if kind == "points_sparse" && sample_size != 0 {
      raise TopologyError("choose exact sparse or sampling, not both")
    }
    let points = if sample_size == 0 {
      table.rows
    } else {
      sample_points(table.rows, size=sample_size).points
    }
    if kind == "points_sparse" {
      ignore(rips_sparse(points, threshold~, max_cells~))
    } else {
      ignore(distance_matrix(points))
    }
    {
      "kind": kind,
      "threshold": threshold,
      "points": points,
      "max_cells": max_cells,
    }
  } else if kind == "series" {
    if sample_size != 0 {
      raise TopologyError("sampling is supported for points only")
    }
    if table.headers.length() != 1 {
      raise TopologyError("CSV series requires exactly one value column")
    }
    let series = table.rows.map(fn(row) { row[0] })
    ignore(delay_embed(series, dimension~, lag~, stride~))
    {
      "kind": "series",
      "threshold": threshold,
      "series": series,
      "dimension": dimension,
      "lag": lag,
      "stride": stride,
      "max_cells": max_cells,
    }
  } else {
    raise TopologyError("CSV kind must be points or series")
  }
}

///|
/// Transform selected numeric columns explicitly. Constant columns become zero.
pub fn transform_numeric_csv(
  table : NumericCsv,
  normalization? : String = "none",
) -> NumericCsv raise TopologyError {
  let info = csv_transform_json(table, normalization~)
  let fields = match info {
    Object(fields) => fields
    _ => raise TopologyError("invalid transform")
  }
  let offsets : Array[Double] = @json.from_json(
    required_field(fields, "offsets"),
  ) catch {
    _ => raise TopologyError("invalid offsets")
  }
  let scales : Array[Double] = @json.from_json(required_field(fields, "scales")) catch {
    _ => raise TopologyError("invalid scales")
  }
  {
    headers: table.headers.copy(),
    rows: table.rows.map(fn(row) {
      Array::makei(row.length(), fn(c) { (row[c] - offsets[c]) / scales[c] })
    }),
  }
}

///|
/// Reproducible per-column affine transform; zscore uses population deviation.
pub fn csv_transform_json(
  table : NumericCsv,
  normalization? : String = "none",
) -> Json raise TopologyError {
  if normalization != "none" &&
    normalization != "zscore" &&
    normalization != "minmax" {
    raise TopologyError("normalization must be none, zscore or minmax")
  }
  if table.rows.length() == 0 ||
    table.rows.length() > 10000 ||
    table.headers.length() == 0 ||
    table.headers.length() > 32 {
    raise TopologyError(
      "numeric table requires 1..10000 rows and 1..32 columns",
    )
  }
  let names : Array[String] = []
  for name in table.headers {
    if name == "" || names.contains(name) {
      raise TopologyError("numeric table headers must be unique and nonempty")
    }
    names.push(name)
  }
  for row in table.rows {
    if row.length() != table.headers.length() {
      raise TopologyError("inconsistent numeric table")
    }
    for v in row {
      if !finite(v) || v.abs() > 1.0e100 {
        raise TopologyError("numeric table value out of range")
      }
    }
  }
  let offsets : Array[Double] = []
  let scales : Array[Double] = []
  let constants : Array[String] = []
  for c = 0; c < table.headers.length(); c = c + 1 {
    let mut low = table.rows[0][c]
    let mut high = low
    let mut mean = 0.0
    for row in table.rows {
      low = if row[c] < low { row[c] } else { low }
      high = maximum(high, row[c])
      mean += row[c] / table.rows.length().to_double()
    }
    let mut deviation = 0.0
    let root_count = table.rows.length().to_double().sqrt()
    for row in table.rows {
      let delta = row[c] - mean
      deviation = @math.hypot(deviation, delta / root_count)
    }
    let offset = if normalization == "zscore" {
      if high == low {
        low
      } else {
        mean
      }
    } else if normalization == "minmax" {
      low
    } else {
      0.0
    }
    let scale = if normalization == "zscore" {
      if high == low {
        0.0
      } else {
        deviation
      }
    } else if normalization == "minmax" {
      high - low
    } else {
      1.0
    }
    offsets.push(offset)
    scales.push(if scale == 0.0 { 1.0 } else { scale })
    if high == low {
      constants.push(table.headers[c])
    }
  }
  {
    "normalization": normalization,
    "headers": table.headers,
    "offsets": offsets,
    "scales": scales,
    "constant_columns": constants,
    "formula": "(value - offset) / scale",
    "variance_convention": "population",
    "data_row_indices": Array::makei(table.rows.length(), fn(i) { i }),
    "row_count": table.rows.length(),
    "column_count": table.headers.length(),
    "preview_rows": Array::makei(minimum_int(5, table.rows.length()), fn(i) {
      table.rows[i]
    }),
  }
}

///|
/// Metadata maps analyzed vertices to zero-based nonblank CSV data rows.
pub fn csv_preparation_json(
  text : String,
  columns? : Array[String] = [],
  normalization? : String = "none",
  sample_size? : Int = 0,
) -> Json raise TopologyError {
  let raw = parse_numeric_csv(text, columns~)
  let transformed = transform_numeric_csv(raw, normalization~)
  let indices = if sample_size == 0 {
    Array::makei(raw.rows.length(), fn(i) { i })
  } else {
    sample_points(transformed.rows, size=sample_size).source_indices
  }
  let fields = match csv_transform_json(raw, normalization~) {
    Object(fields) => fields
    _ => raise TopologyError("invalid preparation metadata")
  }
  fields.set("data_row_indices", indices.to_json())
  fields.set("selected_raw_rows", indices.map(fn(i) { raw.rows[i] }).to_json())
  fields.set(
    "sampling",
    if sample_size == 0 {
      Json::null()
    } else {
      point_sample_json(sample_points(transformed.rows, size=sample_size))
    },
  )
  fields.set(
    "index_meaning",
    "zero-based nonblank data row, excluding header".to_json(),
  )
  fields.to_json()
}

///|
/// Source windows for delay-embedded CSV signals, before numeric transforms.
pub fn csv_series_preparation_json(
  text : String,
  columns? : Array[String] = [],
  normalization? : String = "none",
  dimension? : Int = 2,
  lag? : Int = 1,
  stride? : Int = 1,
) -> Json raise TopologyError {
  let raw = parse_numeric_csv(text, columns~)
  if raw.headers.length() != 1 {
    raise TopologyError("CSV series requires exactly one value column")
  }
  let windows = delay_embed(
    raw.rows.map(fn(row) { row[0] }),
    dimension~,
    lag~,
    stride~,
  )
  let fields = match csv_transform_json(raw, normalization~) {
    Object(fields) => fields
    _ => raise TopologyError("invalid series metadata")
  }
  fields.set(
    "data_row_indices",
    Array::makei(windows.length(), fn(i) { i * stride }).to_json(),
  )
  fields.set(
    "window_data_row_indices",
    Array::makei(windows.length(), fn(i) {
      Array::makei(dimension, fn(c) { i * stride + c * lag })
    }).to_json(),
  )
  fields.set("selected_raw_rows", windows.to_json())
  fields.set(
    "index_meaning",
    "zero-based nonblank data row indices of each delay window".to_json(),
  )
  fields.to_json()
}