///|
/// One lexical CSV row before domain conversion.
pub(all) struct CsvRow {
  line : Int
  fields : Array[String]
} derive(Debug, Eq)

///|
/// Low-level CSV result. Syntax diagnostics are retained instead of raised.
pub(all) struct CsvDocument {
  rows : Array[CsvRow]
  diagnostics : Array[Diagnostic]
} derive(Debug, Eq)

///|
/// Finish one field and reset the mutable field buffer.
fn csv_finish_field(fields : Array[String], builder : StringBuilder) -> Unit {
  fields.push(builder.to_string())
  builder.reset()
}

///|
/// Finish a CSV row. Completely empty trailing lines are ignored.
fn csv_finish_row(
  rows : Array[CsvRow],
  fields : Array[String],
  builder : StringBuilder,
  line : Int,
) -> Unit {
  csv_finish_field(fields, builder)
  if fields.length() == 1 && fields[0].is_empty() {
    fields.clear()
    return
  }
  rows.push({ line, fields: copy_array(fields) })
  fields.clear()
}

///|
/// Parse the RFC 4180 features needed by common temperature logger exports:
/// commas, quoted fields, doubled quotes and CRLF/LF line endings.
pub fn parse_csv_document(input : String) -> CsvDocument {
  let rows : Array[CsvRow] = []
  let diagnostics : Array[Diagnostic] = []
  let fields : Array[String] = []
  let field = StringBuilder::new()
  let chars : Array[Char] = []
  for char in input {
    chars.push(char)
  }
  let mut index = 0
  let mut line = 1
  let mut row_line = 1
  let mut quoted = false
  let mut after_quote = false
  while index < chars.length() {
    let char = chars[index]
    if quoted {
      if char == '"' {
        if index + 1 < chars.length() && chars[index + 1] == '"' {
          field.write_char('"')
          index = index + 2
          continue
        }
        quoted = false
        after_quote = true
      } else {
        field.write_char(char)
        if char == '\n' {
          line = line + 1
        }
      }
      index = index + 1
      continue
    }
    if after_quote {
      if char == ',' {
        csv_finish_field(fields, field)
        after_quote = false
      } else if char == '\n' {
        csv_finish_row(rows, fields, field, row_line)
        after_quote = false
        line = line + 1
        row_line = line
      } else if char == '\r' {
        ()
      } else if char == ' ' || char == '\t' {
        ()
      } else {
        diagnostics.push(
          warning_diagnostic(
            "csv.content_after_quote", "unexpected content after a closing quote",
          ).at_line(line),
        )
        field.write_char(char)
        after_quote = false
      }
      index = index + 1
      continue
    }
    if char == '"' {
      if field.is_empty() {
        quoted = true
      } else {
        diagnostics.push(
          warning_diagnostic(
            "csv.quote_in_unquoted_field", "quote appeared inside an unquoted field",
          ).at_line(line),
        )
        field.write_char(char)
      }
    } else if char == ',' {
      csv_finish_field(fields, field)
    } else if char == '\n' {
      csv_finish_row(rows, fields, field, row_line)
      line = line + 1
      row_line = line
    } else if char != '\r' {
      field.write_char(char)
    }
    index = index + 1
  }
  if quoted {
    diagnostics.push(
      error_diagnostic(
        "csv.unclosed_quote", "quoted field was not closed before end of input",
      ).at_line(row_line),
    )
  }
  if !field.is_empty() || fields.length() > 0 || after_quote {
    csv_finish_row(rows, fields, field, row_line)
  }
  { rows, diagnostics }
}

///|
/// Remove UTF-8 BOM materialized as U+FEFF from the first header.
fn strip_header_bom(value : String) -> String {
  let chars : Array[Char] = []
  for char in value {
    chars.push(char)
  }
  if chars.length() > 0 && chars[0] == '\u{FEFF}' {
    let builder = StringBuilder::new()
    for index = 1; index < chars.length(); index = index + 1 {
      builder.write_char(chars[index])
    }
    builder.to_string()
  } else {
    value
  }
}

///|
/// Normalize a header for matching.
fn normalize_header(value : String) -> String {
  strip_header_bom(value).trim().to_owned().to_lower()
}

///|
/// Find any of the accepted aliases in a header row.
fn find_header(header : Array[String], aliases : Array[String]) -> Int? {
  for index = 0; index < header.length(); index = index + 1 {
    let candidate = normalize_header(header[index])
    for accepted_name in aliases {
      if candidate == accepted_name {
        return Some(index)
      }
    }
  }
  None
}

///|
/// Read a cell by index, returning an empty string for short rows.
fn csv_cell(row : CsvRow, index : Int) -> String {
  if index < 0 || index >= row.fields.length() {
    ""
  } else {
    row.fields[index].trim().to_owned()
  }
}

///|
/// Parse a strict decimal without exponent notation.
pub fn parse_decimal(text : String) -> Double? {
  let view = text.trim()
  if view.is_empty() {
    return None
  }
  let chars : Array[Char] = []
  for char in view {
    chars.push(char)
  }
  let mut index = 0
  let mut sign = 1.0
  if chars[index] == '-' {
    sign = -1.0
    index = index + 1
  } else if chars[index] == '+' {
    index = index + 1
  }
  if index >= chars.length() {
    return None
  }
  let mut integer = 0.0
  let mut fraction = 0.0
  let mut scale = 0.1
  let mut digits = 0
  let mut decimal_seen = false
  while index < chars.length() {
    let char = chars[index]
    if char == '.' {
      if decimal_seen {
        return None
      }
      decimal_seen = true
    } else {
      let digit = match char {
        '0' => 0
        '1' => 1
        '2' => 2
        '3' => 3
        '4' => 4
        '5' => 5
        '6' => 6
        '7' => 7
        '8' => 8
        '9' => 9
        _ => return None
      }
      digits = digits + 1
      if decimal_seen {
        fraction = fraction + digit.to_double() * scale
        scale = scale * 0.1
      } else {
        integer = integer * 10.0 + digit.to_double()
      }
    }
    index = index + 1
  }
  if digits == 0 {
    None
  } else {
    Some(sign * (integer + fraction))
  }
}

///|
/// Parse an optional percentage and validate its physical range.
fn parse_optional_percent(
  text : String,
  field_name : String,
  line : Int,
  diagnostics : Array[Diagnostic],
) -> Double? {
  if text.trim().is_empty() {
    return None
  }
  match parse_decimal(text) {
    None => {
      diagnostics.push(
        warning_diagnostic(
          "csv.invalid_optional_number",
          "invalid numeric value in \{field_name}",
        ).at_line(line),
      )
      None
    }
    Some(value) => {
      if value < 0.0 || value > 100.0 {
        diagnostics.push(
          warning_diagnostic(
            "csv.percent_out_of_range",
            "\{field_name} should be between 0 and 100",
          ).at_line(line),
        )
      }
      Some(value)
    }
  }
}

///|
/// Parse logger CSV data into domain readings.
pub fn parse_readings_csv(input : String) -> ReadingBatch {
  let document = parse_csv_document(input)
  let diagnostics = copy_array(document.diagnostics)
  let readings : Array[Reading] = []
  if document.rows.length() == 0 {
    diagnostics.push(
      error_diagnostic("csv.empty", "CSV input contains no rows"),
    )
    return { readings, diagnostics }
  }
  let header = document.rows[0].fields
  let timestamp_index = find_header(header, ["timestamp", "time", "datetime"])
  let sensor_index = find_header(header, ["sensor_id", "sensor", "device_id"])
  let temperature_index = find_header(header, [
    "temperature_c", "temperature", "temp_c", "temp",
  ])
  let humidity_index = find_header(header, [
    "humidity_percent", "humidity", "rh_percent",
  ])
  let battery_index = find_header(header, [
    "battery_percent", "battery", "battery_pct",
  ])
  let status_index = find_header(header, ["status", "state"])
  if timestamp_index is None {
    diagnostics.push(
      error_diagnostic(
        "csv.missing_timestamp_header", "timestamp column is required",
      ),
    )
  }
  if sensor_index is None {
    diagnostics.push(
      error_diagnostic(
        "csv.missing_sensor_header", "sensor_id column is required",
      ),
    )
  }
  if temperature_index is None {
    diagnostics.push(
      error_diagnostic(
        "csv.missing_temperature_header", "temperature_c column is required",
      ),
    )
  }
  if timestamp_index is None ||
    sensor_index is None ||
    temperature_index is None {
    return { readings, diagnostics }
  }
  let timestamp_column = timestamp_index.unwrap()
  let sensor_column = sensor_index.unwrap()
  let temperature_column = temperature_index.unwrap()
  for row_index = 1
      row_index < document.rows.length()
      row_index = row_index + 1 {
    let row = document.rows[row_index]
    let timestamp_text = csv_cell(row, timestamp_column)
    let sensor_id = csv_cell(row, sensor_column)
    let temperature_text = csv_cell(row, temperature_column)
    if timestamp_text.is_empty() ||
      sensor_id.is_empty() ||
      temperature_text.is_empty() {
      diagnostics.push(
        error_diagnostic(
          "csv.missing_required_value", "timestamp, sensor_id and temperature_c are required",
        ).at_line(row.line),
      )
      continue
    }
    let timestamp = match parse_iso8601(timestamp_text) {
      Ok(parsed) => parsed.unix_seconds
      Err(error) => {
        diagnostics.push(
          error_diagnostic(
            "csv.invalid_timestamp",
            timestamp_error_message(error),
          )
          .at_line(row.line)
          .for_sensor(sensor_id),
        )
        continue
      }
    }
    let temperature = match parse_decimal(temperature_text) {
      Some(value) => value
      None => {
        diagnostics.push(
          error_diagnostic(
            "csv.invalid_temperature", "temperature_c must be a decimal number",
          )
          .at_line(row.line)
          .for_sensor(sensor_id),
        )
        continue
      }
    }
    let humidity = match humidity_index {
      Some(column) =>
        parse_optional_percent(
          csv_cell(row, column),
          "humidity_percent",
          row.line,
          diagnostics,
        )
      None => None
    }
    let battery = match battery_index {
      Some(column) =>
        parse_optional_percent(
          csv_cell(row, column),
          "battery_percent",
          row.line,
          diagnostics,
        )
      None => None
    }
    let status = match status_index {
      Some(column) => {
        let value = csv_cell(row, column)
        if value.is_empty() {
          "ok"
        } else {
          value
        }
      }
      None => "ok"
    }
    readings.push({
      timestamp,
      sensor_id,
      temperature_c: temperature,
      humidity_percent: humidity,
      battery_percent: battery,
      status,
      origin: Recorded,
      flags: [],
      source_line: Some(row.line),
    })
  }
  { readings, diagnostics }
}

///|
/// Escape one CSV cell according to RFC 4180.
pub fn csv_escape(value : String) -> String {
  let mut needs_quotes = false
  for char in value {
    if char == ',' || char == '"' || char == '\n' || char == '\r' {
      needs_quotes = true
    }
  }
  if !needs_quotes {
    return value
  }
  let builder = StringBuilder::new()
  builder.write_char('"')
  for char in value {
    if char == '"' {
      builder.write_string("\"\"")
    } else {
      builder.write_char(char)
    }
  }
  builder.write_char('"')
  builder.to_string()
}

///|
/// Serialize normalized readings to a stable CSV representation.
pub fn readings_to_csv(readings : Array[Reading]) -> String {
  let builder = StringBuilder::new()
  builder.write_string(
    "timestamp,sensor_id,temperature_c,humidity_percent,battery_percent,status,origin,flags\n",
  )
  for sample in readings {
    builder.write_string(format_iso8601(sample.timestamp))
    builder.write_char(',')
    builder.write_string(csv_escape(sample.sensor_id))
    builder.write_char(',')
    builder.write_object(sample.temperature_c)
    builder.write_char(',')
    match sample.humidity_percent {
      Some(value) => builder.write_object(value)
      None => ()
    }
    builder.write_char(',')
    match sample.battery_percent {
      Some(value) => builder.write_object(value)
      None => ()
    }
    builder.write_char(',')
    builder.write_string(csv_escape(sample.status))
    builder.write_char(',')
    builder.write_string(
      match sample.origin {
        Recorded => "recorded"
        Interpolated => "interpolated"
        Simulated => "simulated"
      },
    )
    builder.write_char(',')
    for index = 0; index < sample.flags.length(); index = index + 1 {
      if index > 0 {
        builder.write_char('|')
      }
      builder.write_string(quality_flag_name(sample.flags[index]))
    }
    builder.write_char('\n')
  }
  builder.to_string()
}