///|
enum StreamState {
  Idle
  Writing
  Flushed
} derive(Eq)

///|
pub struct StreamCell {
  value : String
  value_type : CellValueType
  rich_text : Array[RichTextRun]?
  formula : String?
  style_id : Int
  /// Set only by StreamWriter::new_time_cell: the cell should receive the
  /// stream default date/time format unless a row/column style applies.
  priv needs_time_style : Bool
  /// Set only by StreamCell::new_blank: the slot advances the column
  /// without writing a cell, like a nil value in Go's stream SetRow.
  priv blank : Bool
}

///|
pub fn StreamCell::new(
  value : String,
  formula? : String = "",
  style_id? : Int = 0,
) -> StreamCell {
  let formula_value = if formula == "" { None } else { Some(formula) }
  {
    value,
    value_type: String,
    rich_text: None,
    formula: formula_value,
    style_id,
    needs_time_style: false,
    blank: false,
  }
}

///|
/// A blank slot in a stream row: the column position advances but no
/// cell element is written, mirroring a nil value in Go's stream
/// SetRow.
pub fn StreamCell::new_blank() -> StreamCell {
  {
    value: "",
    value_type: String,
    rich_text: None,
    formula: None,
    style_id: 0,
    needs_time_style: false,
    blank: true,
  }
}

///|
pub fn StreamCell::new_value(
  value : CellValue,
  formula? : String = "",
  style_id? : Int = 0,
) -> StreamCell {
  let formula_value = if formula == "" { None } else { Some(formula) }
  {
    value: cell_value_raw_string(value),
    value_type: cell_value_type(value),
    rich_text: None,
    formula: formula_value,
    style_id,
    needs_time_style: false,
    blank: false,
  }
}

///|
pub fn StreamCell::new_rich_text(
  runs : ArrayView[RichTextRun],
  formula? : String = "",
  style_id? : Int = 0,
) -> StreamCell raise XlsxError {
  let total = rich_text_total_units(runs)
  if total > max_cell_chars {
    raise CellTextTooLong(len=total)
  }
  let stored_runs : Array[RichTextRun] = []
  for run in runs {
    stored_runs.push(run)
  }
  let formula_value = if formula == "" { None } else { Some(formula) }
  {
    value: rich_text_plain_text(runs),
    value_type: String,
    rich_text: Some(stored_runs),
    formula: formula_value,
    style_id,
    needs_time_style: false,
    blank: false,
  }
}

///|
pub struct StreamWriter {
  sheet : Worksheet
  sheet_id : Int
  mut last_row : Int
  styles : Array[Style]
  mut closed : Bool
  /// Reads the workbook's current date-1904 setting; Go re-reads workbook
  /// props on every stream setCellTime call, so this must stay live.
  priv date1904_now : () -> Bool
  /// Lazily-registered style holding the default date/time number format
  /// (numFmt 22) applied to unstyled time cells; 0 until first use.
  priv mut time_style_id : Int
}

///|
fn StreamWriter::new(
  sheet : Worksheet,
  sheet_id : Int,
  styles : Array[Style],
  date1904_now? : () -> Bool = () => false,
) -> StreamWriter {
  {
    sheet,
    sheet_id,
    last_row: 0,
    styles,
    closed: false,
    date1904_now,
    time_style_id: 0,
  }
}

///|
/// Registers (once) and returns the stream default date/time style.
fn StreamWriter::ensure_time_style(self : StreamWriter) -> Int {
  if self.time_style_id == 0 {
    let style = {
      ..Style::new(),
      number_format: Some(Builtin(22)),
      num_fmt: Some(22),
    }
    self.styles.push(style)
    self.time_style_id = self.styles.length() - 1
  }
  self.time_style_id
}

///|
/// Builds a stream cell holding a datetime, mirroring the Go stream
/// writer's `time.Time` handling: the value becomes an Excel date serial
/// (reading the workbook's current date-1904 setting) and, when no
/// explicit `style_id` is given and no row or column style applies at
/// write time, the cell receives the default date/time format (numFmt
/// 22, registered once per stream writer). Pre-epoch datetimes are
/// stored as ISO-8601 text without a default style, like Go's RFC3339
/// fallback.
pub fn StreamWriter::new_time_cell(
  self : StreamWriter,
  value : @time.ZonedDateTime,
  formula? : String = "",
  style_id? : Int = 0,
) -> StreamCell {
  let serial = time_to_excel_date(value, use_1904_format=(self.date1904_now)())
  if serial > 0.0 {
    let formula_value = if formula == "" { None } else { Some(formula) }
    {
      value: cell_value_raw_string(Numeric(serial)),
      value_type: Number,
      rich_text: None,
      formula: formula_value,
      style_id,
      // Excelize applies numFmt 22 only when the effective style after
      // row/column inheritance is 0; that check happens in set_row_cells.
      needs_time_style: style_id == 0,
      blank: false,
    }
  } else {
    StreamCell::new_value(
      String(zoned_date_time_iso_string(value)),
      formula~,
      style_id~,
    )
  }
}

///|
/// Builds a stream cell holding a duration as a fraction of a day,
/// mirroring the Go stream writer's `time.Duration` handling: the value
/// is stored numerically and no default style is applied. The serial
/// keeps full double precision where Go formats through float32.
pub fn StreamWriter::new_duration_cell(
  self : StreamWriter,
  value : @time.Duration,
  formula? : String = "",
  style_id? : Int = 0,
) -> StreamCell {
  let _ = self
  let serial = value.to_nanoseconds().to_double() / nanos_per_day
  StreamCell::new_value(Numeric(serial), formula~, style_id~)
}

///|
pub fn StreamWriter::sheet_id(self : StreamWriter) -> Int {
  self.sheet_id
}

///|
fn StreamWriter::ensure_open(self : StreamWriter) -> Unit raise XlsxError {
  if self.closed {
    raise StreamWriterClosed
  }
}

///|
fn StreamWriter::ensure_before_rows(
  self : StreamWriter,
  action : String,
) -> Unit raise XlsxError {
  if self.last_row > 0 {
    raise StreamModeConflict(msg="\{action} must be called before set_row")
  }
}

///|
fn normalize_col_range(start : Int, end : Int) -> (Int, Int) raise XlsxError {
  let (min_col, max_col) = if start <= end {
    (start, end)
  } else {
    (end, start)
  }
  // Preflight the bounds here (the only entry for every stream column-range
  // setter) so a range past the grid is rejected before any column is written.
  if min_col < 1 || max_col > cell_ref_max_cols {
    raise InvalidCellRef(value="\{max_col}:\{min_col}")
  }
  (min_col, max_col)
}

///|
fn apply_row_opts(
  sheet : Worksheet,
  row : Int,
  opts : RowOpts,
) -> Unit raise XlsxError {
  if row <= 0 {
    raise InvalidCellRef(value="\{0}:\{row}")
  }
  if opts.height < 0.0 {
    raise InvalidSheetOperation(msg="row height is negative")
  }
  if opts.outline_level < 0 || opts.outline_level > 7 {
    raise InvalidSheetOperation(msg="row outline level out of range")
  }
  let dim = sheet.row_dimension_value(row)
  let height = if opts.height > 0.0 { Some(opts.height) } else { dim.height }
  let style = if opts.style_id > 0 { Some(opts.style_id) } else { None }
  let updated : RowDimension = {
    height,
    hidden: opts.hidden,
    outline_level: opts.outline_level,
    style_id: style,
  }
  sheet.set_row_dimension(row, updated)
}

///|
fn set_col_width_stream(
  sheet : Worksheet,
  col : Int,
  width : Double,
) -> Unit raise XlsxError {
  if col <= 0 {
    raise InvalidCellRef(value="\{col}:\{0}")
  }
  if width < 0.0 {
    raise InvalidSheetOperation(msg="column width is negative")
  }
  let dim = sheet.col_dimension_value(col)
  let updated : ColDimension = {
    width: Some(width),
    hidden: dim.hidden,
    outline_level: dim.outline_level,
    style_id: dim.style_id,
  }
  sheet.set_col_dimension(col, updated)
}

///|
fn set_col_visible_stream(
  sheet : Worksheet,
  col : Int,
  visible : Bool,
) -> Unit raise XlsxError {
  if col <= 0 {
    raise InvalidCellRef(value="\{col}:\{0}")
  }
  let dim = sheet.col_dimension_value(col)
  let updated : ColDimension = {
    width: dim.width,
    hidden: !visible,
    outline_level: dim.outline_level,
    style_id: dim.style_id,
  }
  sheet.set_col_dimension(col, updated)
}

///|
fn set_col_outline_level_stream(
  sheet : Worksheet,
  col : Int,
  level : Int,
) -> Unit raise XlsxError {
  if col <= 0 {
    raise InvalidCellRef(value="\{col}:\{0}")
  }
  if level < 0 || level > 7 {
    raise InvalidSheetOperation(msg="column outline level out of range")
  }
  let dim = sheet.col_dimension_value(col)
  let updated : ColDimension = {
    width: dim.width,
    hidden: dim.hidden,
    outline_level: level,
    style_id: dim.style_id,
  }
  sheet.set_col_dimension(col, updated)
}

///|
fn set_col_style_stream(
  sheet : Worksheet,
  col : Int,
  style_id : Int,
) -> Unit raise XlsxError {
  if col <= 0 {
    raise InvalidCellRef(value="\{col}:\{0}")
  }
  let dim = sheet.col_dimension_value(col)
  let style = if style_id > 0 { Some(style_id) } else { None }
  let updated : ColDimension = {
    width: dim.width,
    hidden: dim.hidden,
    outline_level: dim.outline_level,
    style_id: style,
  }
  sheet.set_col_dimension(col, updated)
}

///|
fn set_panes_stream(sheet : Worksheet, panes : Panes) -> Unit raise XlsxError {
  if sheet.sheet_views.length() == 0 {
    sheet.sheet_views.push(SheetView::new())
  }
  let view_index = sheet.sheet_views.length() - 1
  let view = sheet.sheet_views[view_index]
  apply_panes_for_write(view, panes)
}

///|
pub fn StreamWriter::set_row(
  self : StreamWriter,
  start_ref : String,
  values : ArrayView[String],
  row_opts? : RowOpts,
) -> Unit raise XlsxError {
  let cells : Array[StreamCell] = []
  for value in values {
    cells.push(StreamCell::new(value))
  }
  match row_opts {
    Some(value) => self.set_row_cells(start_ref, cells, row_opts=value)
    None => self.set_row_cells(start_ref, cells)
  }
}

///|
pub fn StreamWriter::set_row_cells(
  self : StreamWriter,
  start_ref : String,
  values : ArrayView[StreamCell],
  row_opts? : RowOpts,
) -> Unit raise XlsxError {
  self.ensure_open()
  let (row, start_col) = cell_ref_to_rc(start_ref)
  if row <= self.last_row {
    raise StreamRowOrder(last=self.last_row, next=row)
  }
  // Preflight the whole row so it is all-or-nothing: the anchor and the last
  // written column (start_col + values.length() - 1) must be in-grid.
  if row > cell_ref_max_rows || start_col > cell_ref_max_cols {
    raise InvalidCellRef(value=start_ref)
  }
  if values.length() > cell_ref_max_cols - start_col + 1 {
    raise InvalidCellRef(value=start_ref)
  }
  let row_style = match row_opts {
    Some(opts) if opts.style_id > 0 => opts.style_id
    _ => 0
  }
  // Validate every caller-provided style id against the style count seen
  // at row entry: the lazy time-style registration below must not widen
  // the valid range for later cells in the same row.
  let style_count = self.styles.length()
  for cell in values {
    if cell.style_id < 0 || cell.style_id >= style_count {
      raise InvalidStyleId(index=cell.style_id)
    }
  }
  // Apply (and validate) the row options BEFORE writing any cell, so a bad
  // height or outline level raises before mutation and the row stays
  // all-or-nothing. Row dimensions and cells are independent, so order is
  // observationally irrelevant on success.
  match row_opts {
    Some(opts) => apply_row_opts(self.sheet, row, opts)
    None => ()
  }
  let mut col = start_col
  for cell in values {
    if cell.blank {
      col = col + 1
      continue
    }
    let mut style_id = cell.style_id
    if cell.needs_time_style && style_id == 0 {
      // Excelize's prepareCellStyle precedence: an explicit row-opts or
      // column style wins; only a fully unstyled time cell receives the
      // stream default date/time format.
      let col_style = match self.sheet.col_dimension_value(col).style_id {
        Some(value) if value > 0 => value
        _ => 0
      }
      if row_style == 0 && col_style == 0 {
        style_id = self.ensure_time_style()
      }
    }
    let reference = cell_ref_from(row, col)
    self.sheet.cells.push({
      reference,
      row,
      col,
      value: cell.value,
      value_type: cell.value_type,
      rich_text: cell.rich_text,
      formula: cell.formula,
      formula_type: None,
      formula_ref: None,
      formula_shared_index: None,
      formula_value_present: cell.formula is Some(_) && cell.value != "",
      style_explicit: style_id != 0,
      style_id,
    })
    col = col + 1
  }
  // Streamed cells bypass the indexed insert path; a previously built cell
  // index (e.g. from a read on the then-empty sheet) would go stale and
  // make indexed getters miss every streamed cell.
  self.sheet.invalidate_cell_index()
  self.sheet.invalidate_shared_formula_masters_index()
  self.last_row = row
}

///|
pub fn StreamWriter::flush(self : StreamWriter) -> Unit raise XlsxError {
  self.ensure_open()
  self.sheet.stream_state = Flushed
  self.closed = true
}

///|
pub fn StreamWriter::insert_page_break(
  self : StreamWriter,
  cell : StringView,
) -> Unit raise XlsxError {
  self.ensure_open()
  self.sheet.insert_page_break(cell)
}

///|
pub fn StreamWriter::add_table(
  self : StreamWriter,
  range_ref : String,
  name : String,
  columns : ArrayView[String],
  display_name? : String = "",
  style_name? : String = "TableStyleMedium9",
  show_first_column? : Bool = false,
  show_last_column? : Bool = false,
  show_row_stripes? : Bool = true,
  show_column_stripes? : Bool = false,
  show_header_row? : Bool = true,
) -> Table raise XlsxError {
  self.ensure_open()
  self.sheet.add_table(
    range_ref,
    name,
    columns,
    display_name~,
    style_name~,
    show_first_column~,
    show_last_column~,
    show_row_stripes~,
    show_column_stripes~,
    show_header_row~,
  )
}

///|
pub fn StreamWriter::merge_cell(
  self : StreamWriter,
  top_left : StringView,
  bottom_right : StringView,
) -> Unit raise XlsxError {
  self.ensure_open()
  self.sheet.merge_cells("\{top_left}:\{bottom_right}")
}

///|
pub fn StreamWriter::set_col_width(
  self : StreamWriter,
  start_col : Int,
  end_col : Int,
  width : Double,
) -> Unit raise XlsxError {
  self.ensure_open()
  self.ensure_before_rows("set_col_width")
  let (min_col, max_col) = normalize_col_range(start_col, end_col)
  for col in min_col..<=max_col {
    set_col_width_stream(self.sheet, col, width)
  }
}

///|
pub fn StreamWriter::set_col_visible(
  self : StreamWriter,
  start_col : Int,
  end_col : Int,
  visible : Bool,
) -> Unit raise XlsxError {
  self.ensure_open()
  self.ensure_before_rows("set_col_visible")
  let (min_col, max_col) = normalize_col_range(start_col, end_col)
  for col in min_col..<=max_col {
    set_col_visible_stream(self.sheet, col, visible)
  }
}

///|
pub fn StreamWriter::set_col_outline_level(
  self : StreamWriter,
  col : Int,
  level : Int,
) -> Unit raise XlsxError {
  self.ensure_open()
  self.ensure_before_rows("set_col_outline_level")
  set_col_outline_level_stream(self.sheet, col, level)
}

///|
pub fn StreamWriter::set_col_style(
  self : StreamWriter,
  start_col : Int,
  end_col : Int,
  style_id : Int,
) -> Unit raise XlsxError {
  self.ensure_open()
  self.ensure_before_rows("set_col_style")
  if style_id < 0 || style_id >= self.styles.length() {
    raise InvalidStyleId(index=style_id)
  }
  let (min_col, max_col) = normalize_col_range(start_col, end_col)
  for col in min_col..<=max_col {
    set_col_style_stream(self.sheet, col, style_id)
  }
}

///|
pub fn StreamWriter::set_panes(
  self : StreamWriter,
  panes : Panes,
) -> Unit raise XlsxError {
  self.ensure_open()
  self.ensure_before_rows("set_panes")
  set_panes_stream(self.sheet, panes)
}

///|
pub struct RowStream {
  cells : Array[Cell]
  mut index : Int
}

///|
fn RowStream::new(sheet : Worksheet) -> RowStream {
  { cells: sheet.sorted_cells(), index: 0 }
}

///|
pub fn RowStream::next(self : RowStream) -> Array[String]? {
  if self.index >= self.cells.length() {
    return None
  }
  let row = self.cells[self.index].row
  let mut end_index = self.index
  let mut max_col = 0
  while end_index < self.cells.length() && self.cells[end_index].row == row {
    let col = self.cells[end_index].col
    if col > max_col {
      max_col = col
    }
    end_index = end_index + 1
  }
  let values : Array[String] = Array::make(max_col, "")
  let mut i = self.index
  while i < end_index {
    let cell = self.cells[i]
    values[cell.col - 1] = cell.value
    i = i + 1
  }
  self.index = end_index
  Some(values)
}

///|
pub fn Worksheet::row_stream(self : Worksheet) -> RowStream {
  RowStream::new(self)
}

///|
test "stream wb: apply_row_opts rejects non-positive row" {
  let sheet = Worksheet::new("Sheet1")
  let opts = RowOpts::with_values()
  let result : Result[Unit, Error] = Ok(apply_row_opts(sheet, 0, opts)) catch {
    e => Err(e)
  }
  debug_inspect(
    result,
    content=(
      #|Err(InvalidCellRef(value="0:0"))
    ),
  )
}