///|
/// Parser configuration for delimited text.
pub(all) struct ParseConfig {
  delimiter : Char
  quote : Char
  has_header : Bool
  trim_unquoted : Bool
  skip_empty_lines : Bool
  strict_columns : Bool
  comment : Char?
} derive(Eq)

///|
/// Structured parse error with a stable code and source position.
pub(all) struct ParseError {
  code : String
  message : String
  line : Int
  column : Int
} derive(Eq)

///|
/// A parsed delimited table.
pub(all) struct Table {
  header : Array[String]
  rows : Array[Array[String]]
} derive(Eq)

///|
/// A row represented as header-name/value pairs.
pub(all) struct RowRecord {
  pairs : Array[(String, String)]
} derive(Eq)

///|
/// Supported validation kinds for schema rules.
pub(all) enum FieldKind {
  Text
  NonEmpty
  Integer
  Decimal
  Boolean
} derive(Eq)

///|
/// One column-level validation rule.
pub(all) struct ColumnRule {
  name : String
  kind : FieldKind
  required : Bool
} derive(Eq)

///|
/// A validation schema for header-based tables.
pub(all) struct Schema {
  columns : Array[ColumnRule]
  allow_extra_columns : Bool
} derive(Eq)

///|
/// One validation problem.
pub(all) struct ValidationError {
  row : Int
  column : String
  code : String
  message : String
} derive(Eq)

///|
/// Validation result. Empty `errors` means the table is valid.
pub(all) struct ValidationReport {
  errors : Array[ValidationError]
} derive(Eq)

///|
/// RFC-4180 style CSV defaults.
pub fn csv_config() -> ParseConfig {
  {
    delimiter: ',',
    quote: '"',
    has_header: true,
    trim_unquoted: false,
    skip_empty_lines: true,
    strict_columns: true,
    comment: None,
  }
}

///|
/// TSV defaults. Quoted fields are still supported for embedded tabs/newlines.
pub fn tsv_config() -> ParseConfig {
  {
    delimiter: '\t',
    quote: '"',
    has_header: true,
    trim_unquoted: false,
    skip_empty_lines: true,
    strict_columns: true,
    comment: None,
  }
}

///|
/// Build a required column rule.
pub fn required_column(name : String, kind : FieldKind) -> ColumnRule {
  { name, kind, required: true }
}

///|
/// Build an optional column rule.
pub fn optional_column(name : String, kind : FieldKind) -> ColumnRule {
  { name, kind, required: false }
}

///|
/// Build a closed schema: table headers must be covered by the schema.
pub fn closed_schema(columns : Array[ColumnRule]) -> Schema {
  { columns, allow_extra_columns: false }
}

///|
/// Build an open schema: extra headers are allowed.
pub fn open_schema(columns : Array[ColumnRule]) -> Schema {
  { columns, allow_extra_columns: true }
}

///|
/// Parse text with CSV defaults.
pub fn parse_csv(input : String) -> Result[Table, ParseError] {
  parse_with(input, csv_config())
}

///|
/// Parse text with TSV defaults.
pub fn parse_tsv(input : String) -> Result[Table, ParseError] {
  parse_with(input, tsv_config())
}

///|
/// Number of data rows, excluding the header.
pub fn Table::row_count(self : Table) -> Int {
  self.rows.length()
}

///|
/// Number of columns. Header width is preferred when present.
pub fn Table::column_count(self : Table) -> Int {
  if self.header.length() > 0 {
    self.header.length()
  } else if self.rows.length() > 0 {
    self.rows[0].length()
  } else {
    0
  }
}

///|
/// Return a cell by zero-based row and column indexes.
pub fn Table::cell(self : Table, row : Int, column : Int) -> String? {
  match self.rows.get(row) {
    Some(values) => values.get(column)
    None => None
  }
}

///|
/// Find a header column by name.
pub fn Table::find_column(self : Table, name : String) -> Int? {
  let mut i = 0
  while i < self.header.length() {
    if self.header[i] == name {
      return Some(i)
    }
    i = i + 1
  }
  None
}

///|
/// Return a cell by row index and header name.
pub fn Table::cell_by_name(self : Table, row : Int, name : String) -> String? {
  match self.find_column(name) {
    Some(column) => self.cell(row, column)
    None => None
  }
}

///|
/// Convert each row to a name/value record. Missing cells become empty strings.
pub fn Table::to_records(self : Table) -> Array[RowRecord] {
  let records : Array[RowRecord] = []
  let mut r = 0
  while r < self.rows.length() {
    let row = self.rows[r]
    let pairs : Array[(String, String)] = []
    let mut c = 0
    while c < self.header.length() {
      let value = match row.get(c) {
        Some(v) => v
        None => ""
      }
      pairs.push((self.header[c], value))
      c = c + 1
    }
    records.push({ pairs, })
    r = r + 1
  }
  records
}

///|
/// Look up a value from a row record.
pub fn RowRecord::get(self : RowRecord, name : String) -> String? {
  for pair in self.pairs {
    let (key, value) = pair
    if key == name {
      return Some(value)
    }
  }
  None
}

///|
/// Human-readable table summary used by examples and CLI smoke tests.
pub fn Table::summary(self : Table) -> String {
  let text =
    $|rows=\{self.row_count()}, columns=\{self.column_count()}
  text
}

///|
fn parse_error(
  code : String,
  message : String,
  line : Int,
  column : Int,
) -> ParseError {
  { code, message, line, column }
}

///|
fn is_line_break(c : Char) -> Bool {
  c == '\n' || c == '\r'
}

///|
fn field_value(raw : String, quoted : Bool, trim_unquoted : Bool) -> String {
  if quoted || !trim_unquoted {
    raw
  } else {
    raw.trim().to_owned()
  }
}

///|
fn is_blank_row(row : Array[String]) -> Bool {
  for field in row {
    if !field.trim().is_empty() {
      return false
    }
  }
  true
}

///|
fn push_row(
  rows : Array[Array[String]],
  row : Array[String],
  skip_empty_lines : Bool,
) -> Unit {
  if skip_empty_lines && is_blank_row(row) {
    ()
  } else {
    rows.push(row)
  }
}

///|
fn validate_header(header : Array[String]) -> ParseError? {
  let mut i = 0
  while i < header.length() {
    if header[i].trim().is_empty() {
      let msg =
        $|header column \{i + 1} is empty
      return Some(parse_error("empty_header", msg, 1, i))
    }
    let mut j = i + 1
    while j < header.length() {
      if header[i] == header[j] {
        let msg =
          $|duplicate header '\{header[i]}' at column \{j + 1}
        return Some(parse_error("duplicate_header", msg, 1, j))
      }
      j = j + 1
    }
    i = i + 1
  }
  None
}

///|
fn build_table(
  rows : Array[Array[String]],
  config : ParseConfig,
) -> Result[Table, ParseError] {
  let header : Array[String] = []
  let data : Array[Array[String]] = []
  if rows.length() == 0 {
    return Ok({ header, rows: data })
  }
  let expected = if config.has_header {
    let first = rows[0]
    let mut c = 0
    while c < first.length() {
      header.push(first[c])
      c = c + 1
    }
    match validate_header(header) {
      Some(err) => return Err(err)
      None => ()
    }
    header.length()
  } else {
    rows[0].length()
  }
  let start = if config.has_header { 1 } else { 0 }
  let mut r = start
  while r < rows.length() {
    let row = rows[r]
    if config.strict_columns && row.length() != expected {
      let msg =
        $|row \{r + 1} has \{row.length()} columns, expected \{expected}
      return Err(parse_error("column_count", msg, r + 1, row.length()))
    }
    data.push(row)
    r = r + 1
  }
  Ok({ header, rows: data })
}

///|
/// Parse delimited text with a custom configuration.
pub fn parse_with(
  input : String,
  config : ParseConfig,
) -> Result[Table, ParseError] {
  let rows : Array[Array[String]] = []
  let mut row : Array[String] = []
  let field = StringBuilder::StringBuilder()
  let mut in_quotes = false
  let mut quoted = false
  let mut after_quote = false
  let mut field_chars = 0
  let mut i = 0
  let mut line = 1
  let mut column = 0
  let mut at_row_start = true
  while i < input.length() {
    let c = match input.get_char(i) {
      Some(ch) => ch
      None =>
        return Err(
          parse_error("invalid_char", "invalid character offset", line, column),
        )
    }
    if at_row_start && field_chars == 0 && row.length() == 0 {
      match config.comment {
        Some(marker) =>
          if c == marker {
            while i < input.length() {
              let skip = match input.get_char(i) {
                Some(ch) => ch
                None => '\n'
              }
              i = i + 1
              if skip == '\n' {
                line = line + 1
                column = 0
                break
              } else if skip == '\r' {
                if i < input.length() && input.get_char(i) == Some('\n') {
                  i = i + 1
                }
                line = line + 1
                column = 0
                break
              }
            }
            continue
          }
        None => ()
      }
    }
    if in_quotes {
      if c == config.quote {
        if i + 1 < input.length() && input.get_char(i + 1) == Some(config.quote) {
          field.write_char(config.quote)
          field_chars = field_chars + 1
          i = i + 2
          column = column + 2
        } else {
          in_quotes = false
          after_quote = true
          i = i + 1
          column = column + 1
        }
      } else {
        field.write_char(c)
        field_chars = field_chars + 1
        i = i + 1
        if c == '\n' {
          line = line + 1
          column = 0
        } else if c == '\r' {
          if i < input.length() && input.get_char(i) == Some('\n') {
            field.write_char('\n')
            i = i + 1
          }
          line = line + 1
          column = 0
        } else {
          column = column + 1
        }
      }
    } else if after_quote {
      if c == config.delimiter {
        row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
        field.reset()
        quoted = false
        after_quote = false
        field_chars = 0
        at_row_start = false
        i = i + 1
        column = column + 1
      } else if is_line_break(c) {
        row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
        field.reset()
        quoted = false
        after_quote = false
        field_chars = 0
        push_row(rows, row, config.skip_empty_lines)
        row = []
        at_row_start = true
        i = i + 1
        if c == '\r' && i < input.length() && input.get_char(i) == Some('\n') {
          i = i + 1
        }
        line = line + 1
        column = 0
      } else if c == ' ' || c == '\t' {
        if config.trim_unquoted {
          i = i + 1
          column = column + 1
        } else {
          return Err(
            parse_error(
              "text_after_quote", "unexpected whitespace after closing quote", line,
              column,
            ),
          )
        }
      } else {
        return Err(
          parse_error(
            "text_after_quote", "unexpected text after closing quote", line, column,
          ),
        )
      }
    } else if c == config.quote {
      if field_chars == 0 {
        in_quotes = true
        quoted = true
        at_row_start = false
        i = i + 1
        column = column + 1
      } else {
        return Err(
          parse_error(
            "bare_quote", "quote appears inside an unquoted field", line, column,
          ),
        )
      }
    } else if c == config.delimiter {
      row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
      field.reset()
      quoted = false
      field_chars = 0
      at_row_start = false
      i = i + 1
      column = column + 1
    } else if is_line_break(c) {
      row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
      field.reset()
      quoted = false
      field_chars = 0
      push_row(rows, row, config.skip_empty_lines)
      row = []
      at_row_start = true
      i = i + 1
      if c == '\r' && i < input.length() && input.get_char(i) == Some('\n') {
        i = i + 1
      }
      line = line + 1
      column = 0
    } else {
      field.write_char(c)
      field_chars = field_chars + 1
      at_row_start = false
      i = i + 1
      column = column + 1
    }
  }
  if in_quotes {
    return Err(
      parse_error("unclosed_quote", "quoted field is not closed", line, column),
    )
  }
  if field_chars > 0 || quoted || row.length() > 0 || !config.skip_empty_lines {
    row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
    push_row(rows, row, config.skip_empty_lines)
  }
  build_table(rows, config)
}