///|
// ============================================================================
// Canonical typed columnar result model (issue #61)
// ============================================================================
//
// `Vector`/`VectorChunk` are the canonical internal representation of query
// results across all backends. Data lives in owned MoonBit typed arrays, so
// queries can be consumed without converting every cell to `String`. The
// `QueryResult`/`DataChunk` row-string APIs are a compatibility facade on top
// (see `to_query_result`/`to_data_chunk`).
//
// Ownership rules: a `Vector` owns its arrays once constructed — no aliasing
// with backend memory. Backends may keep borrowed handles (e.g. `NativeVector`
// wrapping a `duckdb_vector`) only transiently while materializing a chunk.
// `VectorChunk`s are plain values and outlive the result/stream they came from.

///|
/// Backend-neutral typed column storage for one result column.
///
/// Variants hold one owned array per column, laid out like a DuckDB vector.
/// `Logical/physical` split: `Int32` stores TINYINT/SMALLINT/INTEGER/
/// UTINYINT/USMALLINT/DATE payloads; `Int64` stores BIGINT/UINTEGER/TIME/
/// TIMESTAMP*/TIME_NS payloads (micros/nanos/seconds normalized by
/// `Vector::logical_type`); `HugeInt` stores HUGEINT/UHUGEINT/UUID 128-bit
/// payloads; `Any` is the fallback for types not yet vectorized (UNION, BIT,
/// TIME_TZ, BIGNUM, ...), holding per-cell `Value`s.
pub(all) enum VectorData {
  Bool(FixedArray[Bool])
  Int32(FixedArray[Int])
  Int64(FixedArray[Int64])
  UInt64(FixedArray[UInt64])
  Float(FixedArray[Float])
  Double(FixedArray[Double])
  Varchar(Array[String])
  Blob(Array[Bytes])
  Decimal(FixedArray[Decimal])
  Interval(FixedArray[Interval])
  HugeInt(lower~ : FixedArray[UInt64], upper~ : FixedArray[Int64])
  /// Row `i` is `child[offsets[i].. QueryResult {
  let columns : Array[String] = []
  let column_types : Array[ColumnType] = []
  let rows : Array[Array[String]] = []
  let nulls : Array[Array[Bool]] = []
  for chunk in chunks {
    if columns.length() == 0 {
      for name in chunk.columns {
        columns.push(name)
      }
      for vector in chunk.vectors {
        column_types.push(vector.logical_type)
      }
    }
    for row = 0; row < chunk.row_count(); row = row + 1 {
      let row_values : Array[String] = []
      let row_nulls : Array[Bool] = []
      for col = 0; col < chunk.column_count(); col = col + 1 {
        let vector = chunk.vectors[col]
        let is_null = !vector.validity[row]
        row_nulls.push(is_null)
        row_values.push(if is_null { "" } else { vector.string_at(row) })
      } nobreak {
        ()
      }
      rows.push(row_values)
      nulls.push(row_nulls)
    } nobreak {
      ()
    }
  } nobreak {
    ()
  }
  { columns, column_types, rows, nulls, }
}

// ============================================================================
// VectorChunk accessors
// ============================================================================

///|
/// Number of rows in this chunk.
pub fn VectorChunk::row_count(self : VectorChunk) -> Int {
  if self.vectors.length() == 0 {
    0
  } else {
    self.vectors[0].len()
  }
}

///|
/// Number of columns in this chunk.
pub fn VectorChunk::column_count(self : VectorChunk) -> Int {
  self.columns.length()
}

///|
/// The `i`-th column vector.
pub fn VectorChunk::column(self : VectorChunk, index : Int) -> Vector? {
  if index >= 0 && index < self.vectors.length() {
    Some(self.vectors[index])
  } else {
    None
  }
}

///|
/// The column vector named `name` (first match).
pub fn VectorChunk::column_by_name(
  self : VectorChunk,
  name : String,
) -> Vector? {
  for i, col in self.columns {
    if col == name && i < self.vectors.length() {
      return Some(self.vectors[i])
    }
  }
  None
}

///|
/// Logical type of column `col`.
pub fn VectorChunk::column_type(self : VectorChunk, col : Int) -> ColumnType {
  match self.column(col) {
    Some(vector) => vector.logical_type
    None => ColumnType::Invalid
  }
}

///|
/// The typed value at (`row`, `col`), or `None` when out of bounds or the
/// cell is NULL.
pub fn VectorChunk::get_value(
  self : VectorChunk,
  row : Int,
  col : Int,
) -> Value? {
  match self.column(col) {
    Some(vector) =>
      if row >= 0 && row < vector.len() && vector.validity[row] {
        Some(vector.value_at(row))
      } else {
        None
      }
    None => None
  }
}

///|
/// True when the cell at (`row`, `col`) is NULL.
pub fn VectorChunk::is_null(self : VectorChunk, row : Int, col : Int) -> Bool {
  match self.column(col) {
    Some(vector) =>
      if row >= 0 && row < vector.len() {
        !vector.validity[row]
      } else {
        true
      }
    None => true
  }
}

///|
/// Materialize this chunk as the string-based `QueryResult` compatibility
/// facade (column types come from the vectors).
pub fn VectorChunk::to_query_result(self : VectorChunk) -> QueryResult {
  chunks_to_query_result([self])
}

///|
/// Materialize this chunk as the string-based `DataChunk` compatibility
/// facade used by `ResultStream::next`.
pub fn VectorChunk::to_data_chunk(self : VectorChunk) -> DataChunk {
  let rows : Array[Array[String]] = []
  let nulls : Array[Array[Bool]] = []
  for row = 0; row < self.row_count(); row = row + 1 {
    let row_values : Array[String] = []
    let row_nulls : Array[Bool] = []
    for col = 0; col < self.column_count(); col = col + 1 {
      let vector = self.vectors[col]
      let is_null = !vector.validity[row]
      row_nulls.push(is_null)
      row_values.push(if is_null { "" } else { vector.string_at(row) })
    } nobreak {
      ()
    }
    rows.push(row_values)
    nulls.push(row_nulls)
  } nobreak {
    ()
  }
  { columns: self.columns, rows, nulls, }
}

///|
/// Convert directly to `TypedQueryResult` without a string round-trip.
pub fn VectorChunk::to_typed(self : VectorChunk) -> TypedQueryResult {
  let data : Array[Array[Value]] = []
  for vector in self.vectors {
    let column : Array[Value] = []
    for row = 0; row < vector.len(); row = row + 1 {
      if vector.validity[row] {
        column.push(vector.value_at(row))
      } else {
        column.push(Value::Null)
      }
    } nobreak {
      ()
    }
    data.push(column)
  } nobreak {
    ()
  }
  { columns: self.columns, data, }
}

// ============================================================================
// Vector accessors
// ============================================================================

///|
/// Number of rows in this vector.
pub fn Vector::len(self : Vector) -> Int {
  self.validity.length()
}

///|
/// True when row `row` is NULL.
pub fn Vector::is_null(self : Vector, row : Int) -> Bool {
  if row >= 0 && row < self.validity.length() {
    !self.validity[row]
  } else {
    true
  }
}

///|
/// The raw `FixedArray[Int]` payload for an `Int32` vector, or `None` for
/// other storage kinds. Combined with `validity` this exposes the column
/// without per-cell boxing.
pub fn Vector::ints(self : Vector) -> FixedArray[Int]? {
  match self.data {
    Int32(values) => Some(values)
    _ => None
  }
}

///|
/// The raw `FixedArray[Int64]` payload for an `Int64` vector, or `None`.
pub fn Vector::int64s(self : Vector) -> FixedArray[Int64]? {
  match self.data {
    Int64(values) => Some(values)
    _ => None
  }
}

///|
/// The raw `FixedArray[UInt64]` payload for a `UInt64` vector, or `None`.
pub fn Vector::uint64s(self : Vector) -> FixedArray[UInt64]? {
  match self.data {
    UInt64(values) => Some(values)
    _ => None
  }
}

///|
/// The raw `FixedArray[Double]` payload for a `Double` vector, or `None`.
/// `Float` vectors are widened into a fresh `FixedArray[Double]` copy.
pub fn Vector::doubles(self : Vector) -> FixedArray[Double]? {
  match self.data {
    Double(values) => Some(values)
    Float(values) =>
      Some(FixedArray::makei(values.length(), fn(i) { values[i].to_double() }))
    _ => None
  }
}

///|
/// The raw `FixedArray[Bool]` payload for a `Bool` vector, or `None`.
pub fn Vector::bools(self : Vector) -> FixedArray[Bool]? {
  match self.data {
    Bool(values) => Some(values)
    _ => None
  }
}

///|
/// The `Array[String]` payload for a `Varchar` vector, or `None`.
pub fn Vector::strings(self : Vector) -> Array[String]? {
  match self.data {
    Varchar(values) => Some(values)
    _ => None
  }
}

///|
/// The `Array[Bytes]` payload for a `Blob` vector, or `None`.
pub fn Vector::blobs(self : Vector) -> Array[Bytes]? {
  match self.data {
    Blob(values) => Some(values)
    _ => None
  }
}

///|
/// The `FixedArray[Decimal]` payload for a `Decimal` vector, or `None`.
pub fn Vector::decimals(self : Vector) -> FixedArray[Decimal]? {
  match self.data {
    Decimal(values) => Some(values)
    _ => None
  }
}

///|
/// The `FixedArray[Interval]` payload for an `Interval` vector, or `None`.
pub fn Vector::intervals(self : Vector) -> FixedArray[Interval]? {
  match self.data {
    Interval(values) => Some(values)
    _ => None
  }
}

///|
/// The child vector and per-row `offsets`/`lengths` for a `List` vector.
pub fn Vector::list_parts(
  self : Vector,
) -> (Vector, FixedArray[UInt64], FixedArray[UInt64])? {
  match self.data {
    List(child~, offsets~, lengths~) => Some((child, offsets, lengths))
    _ => None
  }
}

///|
/// Field names and child vectors for a `Struct` vector.
pub fn Vector::struct_parts(self : Vector) -> (Array[String], Array[Vector])? {
  match self.data {
    Struct(fields~, children~) => Some((fields, children))
    _ => None
  }
}

///|
/// Entry offsets/lengths plus key/value vectors for a `Map` vector.
pub fn Vector::map_parts(
  self : Vector,
) -> (FixedArray[UInt64], FixedArray[UInt64], Vector, Vector)? {
  match self.data {
    Map(offsets~, lengths~, keys~, values~) =>
      Some((offsets, lengths, keys, values))
    _ => None
  }
}

///|
/// Decoded per-cell values for an `Any` vector, or `None`.
pub fn Vector::any_cells(self : Vector) -> Array[Value]? {
  match self.data {
    Any(cells) => Some(cells)
    _ => None
  }
}

// ============================================================================
// Vector → Value decode
// ============================================================================

///|
/// The typed `Value` for row `row`. Caller must ensure the row is valid
/// (`!is_null(row)`); NULL rows return `Value::Null`.
pub fn Vector::value_at(self : Vector, row : Int) -> Value {
  if self.is_null(row) {
    return Value::Null
  }
  match self.data {
    Bool(values) => Value::Bool(values[row])
    Int32(values) =>
      match self.logical_type {
        ColumnType::Date => Value::Date(values[row])
        _ => Value::Int(values[row])
      }
    Int64(values) =>
      match self.logical_type {
        ColumnType::Timestamp
        | ColumnType::TimestampS
        | ColumnType::TimestampMs
        | ColumnType::TimestampTz =>
          // Stored as microseconds since epoch (see backend materializers).
          Value::Timestamp(values[row])
        ColumnType::TimestampNs =>
          // Stored as nanoseconds since epoch.
          Value::TimestampNs(values[row])
        _ => Value::Int64(values[row])
      }
    UInt64(values) => Value::UInt64(values[row])
    Float(values) => Value::Double(values[row].to_double())
    Double(values) => Value::Double(values[row])
    Varchar(values) => Value::String(values[row])
    Blob(values) => Value::Blob(values[row])
    Decimal(values) => Value::Decimal(values[row])
    Interval(values) => Value::Interval(values[row])
    HugeInt(lower~, upper~) =>
      Value::HugeInt(lower=lower[row], upper=upper[row])
    List(child~, offsets~, lengths~) => {
      let start = offsets[row].to_int()
      let len = lengths[row].to_int()
      let items : Array[Value] = []
      for i in start..<(start + len) {
        if i < child.len() {
          items.push(
            if child.validity[i] {
              child.value_at(i)
            } else {
              Value::Null
            },
          )
        }
      }
      Value::List(items)
    }
    Struct(fields~, children~) => {
      let values : Array[Value] = []
      for child in children {
        values.push(
          if row < child.len() && child.validity[row] {
            child.value_at(row)
          } else {
            Value::Null
          },
        )
      }
      Value::Struct(fields~, values~)
    }
    Map(offsets~, lengths~, keys~, values~) => {
      let start = offsets[row].to_int()
      let len = lengths[row].to_int()
      let ks : Array[Value] = []
      let vs : Array[Value] = []
      for i in start..<(start + len) {
        ks.push(
          if i < keys.len() && keys.validity[i] {
            keys.value_at(i)
          } else {
            Value::Null
          },
        )
        vs.push(
          if i < values.len() && values.validity[i] {
            values.value_at(i)
          } else {
            Value::Null
          },
        )
      }
      Value::Map(keys=ks, values=vs)
    }
    Any(cells) => if row < cells.length() { cells[row] } else { Value::Null }
  }
}

// ============================================================================
// DuckDB-style string rendering (compat materialization)
// ============================================================================

///|
/// Render row `row` of this vector as a DuckDB-style string (best-effort
/// match to `duckdb_value_varchar` output). Used by the compat facade; NULL
/// rows yield `""`.
pub fn Vector::string_at(self : Vector, row : Int) -> String {
  if self.is_null(row) {
    return ""
  }
  match self.data {
    Int32(values) =>
      match self.logical_type {
        ColumnType::Date => date_render(values[row])
        _ => values[row].to_string()
      }
    Int64(values) =>
      match self.logical_type {
        ColumnType::Time => time_micros_render(values[row])
        ColumnType::TimeNs => time_nanos_render(values[row])
        ColumnType::Timestamp
        | ColumnType::TimestampS
        | ColumnType::TimestampMs
        | ColumnType::TimestampTz =>
          // S/MS payloads are normalized to micros by the materializers.
          timestamp_micros_render(values[row])
        ColumnType::TimestampNs => timestamp_nanos_render(values[row])
        _ => values[row].to_string()
      }
    UInt64(values) => values[row].to_string()
    Float(values) => duckdb_double_render(values[row].to_double())
    Double(values) => duckdb_double_render(values[row])
    HugeInt(lower~, upper~) =>
      match self.logical_type {
        ColumnType::Uuid => uuid_render(lower[row], upper[row])
        _ => hugeint_render(lower[row], upper[row])
      }
    _ => value_to_duckdb_string(self.value_at(row))
  }
}

///|
/// Render a `Value` the way `duckdb_value_varchar` would (best effort).
fn value_to_duckdb_string(value : Value) -> String {
  match value {
    Value::Int(n) => n.to_string()
    Value::Int64(n) => n.to_string()
    Value::UInt64(n) => n.to_string()
    Value::Double(d) => duckdb_double_render(d)
    Value::Bool(b) => if b { "true" } else { "false" }
    Value::String(s) => s
    Value::Date(days) => date_render(days)
    Value::Timestamp(micros) => timestamp_micros_render(micros)
    Value::TimestampNs(nanos) => timestamp_nanos_render(nanos)
    Value::Decimal(dec) => decimal_render(dec)
    Value::Interval(iv) => interval_render(iv)
    Value::HugeInt(lower~, upper~) => hugeint_render(lower, upper)
    Value::Blob(bytes) => blob_render(bytes)
    Value::List(items) => {
      let sb = StringBuilder()
      sb.write_char('[')
      for i, item in items {
        if i > 0 {
          sb.write_view(", ")
        }
        sb.write_view(
          match item {
            Value::Null => "NULL"
            _ => value_to_duckdb_string(item)
          },
        )
      }
      sb.write_char(']')
      sb.to_string()
    }
    Value::Struct(fields~, values~) => {
      let sb = StringBuilder()
      sb.write_char('{')
      for i, field in fields {
        if i > 0 {
          sb.write_view(", ")
        }
        sb.write_view(field)
        sb.write_view(": ")
        if i < values.length() {
          sb.write_view(
            match values[i] {
              Value::Null => "NULL"
              other => value_to_duckdb_string(other)
            },
          )
        }
      }
      sb.write_char('}')
      sb.to_string()
    }
    Value::Map(keys~, values~) => {
      let sb = StringBuilder()
      sb.write_char('{')
      for i, key in keys {
        if i > 0 {
          sb.write_view(", ")
        }
        sb.write_view(
          match key {
            Value::Null => "NULL"
            _ => value_to_duckdb_string(key)
          },
        )
        sb.write_char('=')
        if i < values.length() {
          sb.write_view(
            match values[i] {
              Value::Null => "NULL"
              other => value_to_duckdb_string(other)
            },
          )
        }
      }
      sb.write_char('}')
      sb.to_string()
    }
    Value::Null => "NULL"
  }
}

///|
/// Render a Double in DuckDB's cast-to-VARCHAR style: lowercase `nan`/`inf`/
/// `-inf`, an `e+NN`/`e-NN` exponent, and a trailing `.0` for integral values.
fn duckdb_double_render(d : Double) -> String {
  if d.is_nan() {
    return "nan"
  }
  if d.is_inf() {
    return if d < 0.0 { "-inf" } else { "inf" }
  }
  let s = d.to_string()
  // MoonBit emits e.g. "1e300"; DuckDB emits "1e+300".
  if s.contains("e") {
    normalize_exponent(s)
  } else if !s.contains(".") {
    s + ".0"
  } else {
    s
  }
}

///|
/// Normalize a MoonBit exponent `NeN`/`Ne-N`/`Ne+N` to DuckDB's `Ne+NN`.
fn normalize_exponent(s : String) -> String {
  let buf = StringBuilder()
  let mut seen_e = false
  for c in s {
    if c == 'e' || c == 'E' {
      seen_e = true
      buf.write_char('e')
      continue
    }
    if seen_e {
      if c != '+' && c != '-' && !(c >= '0' && c <= '9') {
        continue
      }
      buf.write_char(c)
    } else {
      buf.write_char(c)
    }
  }
  let out = buf.to_string()
  // Ensure the exponent carries an explicit sign.
  if out.contains("e-") || out.contains("e+") {
    out
  } else {
    out.replace(old="e", new="e+")
  }
}

///|
/// `YYYY-MM-DD` for days since epoch.
fn date_render(days : Int) -> String {
  let (year, month, day) = days_to_ymd(days)
  "\{year}-\{pad2(month)}-\{pad2(day)}"
}

///|
fn pad2(n : Int) -> String {
  if n < 10 {
    "0\{n}"
  } else {
    n.to_string()
  }
}

///|
/// `HH:MM:SS[.ffffff]` for microseconds since midnight (DuckDB TIME format).
fn time_micros_render(micros : Int64) -> String {
  let hour = micros / 3600000000L
  let minute = micros % 3600000000L / 60000000L
  let second = micros % 60000000L / 1000000L
  let frac = micros % 1000000L
  let base = "\{pad2(hour.to_int())}:\{pad2(minute.to_int())}:\{pad2(second.to_int())}"
  if frac == 0L {
    base
  } else {
    base + "." + trim_frac(frac, 6)
  }
}

///|
/// `HH:MM:SS[.fffffffff]` for nanoseconds since midnight (DuckDB TIME_NS).
fn time_nanos_render(nanos : Int64) -> String {
  let hour = nanos / 3600000000000L
  let minute = nanos % 3600000000000L / 60000000000L
  let second = nanos % 60000000000L / 1000000000L
  let frac = nanos % 1000000000L
  let base = "\{pad2(hour.to_int())}:\{pad2(minute.to_int())}:\{pad2(second.to_int())}"
  if frac == 0L {
    base
  } else {
    base + "." + trim_frac(frac, 9)
  }
}

///|
/// `YYYY-MM-DD HH:MM:SS[.ffffff]` for microseconds since epoch.
fn timestamp_micros_render(micros : Int64) -> String {
  let (year, month, day, hour, minute, second, frac) = split_micros(micros)
  let base = "\{year}-\{pad2(month)}-\{pad2(day)} \{pad2(hour)}:\{pad2(minute)}:\{pad2(second)}"
  if frac == 0L {
    base
  } else {
    base + "." + trim_frac(frac, 6)
  }
}

///|
/// `YYYY-MM-DD HH:MM:SS[.fffffffff]` for nanoseconds since epoch.
fn timestamp_nanos_render(nanos : Int64) -> String {
  let mut micros = nanos / 1000L
  let mut extra = nanos % 1000L
  if extra < 0L {
    // Floor-divide so pre-epoch nanofracs stay non-negative.
    micros = micros - 1L
    extra = extra + 1000L
  }
  let (year, month, day, hour, minute, second, frac) = split_micros(micros)
  let nanofrac = frac * 1000L + extra
  let base = "\{year}-\{pad2(month)}-\{pad2(day)} \{pad2(hour)}:\{pad2(minute)}:\{pad2(second)}"
  if nanofrac == 0L {
    base
  } else {
    base + "." + trim_frac(nanofrac, 9)
  }
}

///|
/// Split microseconds since epoch into (year, month, day, hour, min, sec,
/// microfraction), floor-dividing for negative timestamps.
fn split_micros(micros : Int64) -> (Int, Int, Int, Int, Int, Int, Int64) {
  let total_seconds = micros / 1000000L
  let mut frac = micros % 1000000L
  let mut secs = total_seconds
  if frac < 0L {
    secs = secs - 1L
    frac = frac + 1000000L
  }
  let mut days = secs / 86400L
  let mut secs_in_day = secs % 86400L
  if secs_in_day < 0L {
    days = days - 1L
    secs_in_day = secs_in_day + 86400L
  }
  let (year, month, day) = days_to_ymd(days.to_int())
  (
    year,
    month,
    day,
    (secs_in_day / 3600L).to_int(),
    (secs_in_day % 3600L / 60L).to_int(),
    (secs_in_day % 60L).to_int(),
    frac,
  )
}

///|
/// A fractional part rendered with `width` digits then trailing zeros
/// stripped (DuckDB prints the minimal precision needed).
fn trim_frac(frac : Int64, width : Int) -> String {
  let mut digits = frac.to_string()
  for _ in digits.length().. 0 && digits[end - 1] == '0' {
    end = end - 1
  }
  if end == 0 {
    "0"
  } else {
    digits.view(start_offset=0, end_offset=end).to_owned()
  }
}

///|
/// Render a DuckDB INTERVAL (`months`, `days`, `micros`) in the style
/// `1 year 2 months 3 days 04:05:06.789`.
fn interval_render(iv : Interval) -> String {
  let parts : Array[String] = []
  let months = iv.months
  if months != 0 {
    let years = months / 12
    let rem = months % 12
    if years != 0 {
      parts.push("\{years} year\{if years.abs() == 1 { "" } else { "s" }}")
    }
    if rem != 0 {
      parts.push("\{rem} month\{if rem.abs() == 1 { "" } else { "s" }}")
    }
  }
  if iv.days != 0 {
    parts.push("\{iv.days} day\{if iv.days.abs() == 1 { "" } else { "s" }}")
  }
  if iv.micros != 0L {
    let micros = iv.micros
    let sign = if micros < 0L { "-" } else { "" }
    let abs = micros.abs()
    let hour = abs / 3600000000L
    let minute = abs % 3600000000L / 60000000L
    let second = abs % 60000000L / 1000000L
    let frac = abs % 1000000L
    let base = "\{sign}\{pad2(hour.to_int())}:\{pad2(minute.to_int())}:\{pad2(second.to_int())}"
    parts.push(if frac == 0L { base } else { base + "." + trim_frac(frac, 6) })
  }
  if parts.length() == 0 {
    "00:00:00"
  } else {
    parts.join(" ")
  }
}

///|
/// Render a signed 128-bit value (split as `lower`/`upper` halves).
fn hugeint_render(lower : UInt64, upper : Int64) -> String {
  ((BigInt::from_int64(upper) << 64) | BigInt::from_uint64(lower)).to_string()
}

///|
/// Render a UUID from its stored `uhugeint` payload (top bit flipped).
fn uuid_render(lower : UInt64, upper : Int64) -> String {
  let hi = upper.reinterpret_as_uint64()
  fn hex64(v : UInt64) -> String {
    let digits = v.to_string(radix=16)
    let mut s = digits
    for _ in digits.length()..<16 {
      s = "0" + s
    }
    s
  }

  let hi_hex = hex64(hi)
  let lo_hex = hex64(lower)
  "\{hi_hex.view(start_offset=0, end_offset=8)}-\{hi_hex.view(start_offset=8, end_offset=12)}-\{hi_hex.view(start_offset=12, end_offset=16)}-\{lo_hex.view(start_offset=0, end_offset=4)}-\{lo_hex.view(start_offset=4, end_offset=16)}"
}

///|
/// Render blob bytes the way `duckdb_value_varchar` does: printable ASCII
/// bytes verbatim, others as `\xNN` lowercase hex escapes.
fn blob_render(bytes : Bytes) -> String {
  let sb = StringBuilder()
  for b in bytes {
    let v = b.to_int()
    if v >= 0x20 && v < 0x7f {
      sb.write_char(v.unsafe_to_char())
    } else {
      sb.write_view("\\x")
      let hi = v / 16
      let lo = v % 16
      sb.write_char(hex_digit(hi))
      sb.write_char(hex_digit(lo))
    }
  }
  sb.to_string()
}

///|
fn hex_digit(n : Int) -> Char {
  if n < 10 {
    ('0'.to_int() + n).unsafe_to_char()
  } else {
    ('a'.to_int() + n - 10).unsafe_to_char()
  }
}