///|
/// A body whose length is known before multipart framing is emitted. The plan
/// intentionally stores metadata and a count only; body ownership stays with
/// the transport application.
pub struct PlannedPart {
  priv meta : @model.PartMeta
  priv body_length : Int64
} derive(Debug, Eq)

///|
/// Exact, validated multipart framing plan suitable for an HTTP
/// `Content-Length` header or admission-control decision.
pub struct MultipartPlan {
  priv boundary : String
  priv parts : Array[PlannedPart]
  priv limits : @model.Limits
  priv content_length : Int64
} derive(Debug, Eq)

///|
fn copy_meta(meta : @model.PartMeta) -> @model.PartMeta {
  { ..meta, headers: meta.headers.copy() }
}

///|
pub fn PlannedPart::new(
  meta : @model.PartMeta,
  body_length : Int64,
) -> PlannedPart raise @model.MultipartError {
  if body_length < 0L {
    raise LimitExceeded(kind=PartBytes, limit=0L, observed=body_length)
  }
  { meta: copy_meta(meta), body_length }
}

///|
fn plan_add(
  total : Int64,
  added : Int64,
  limits : @model.Limits,
) -> Int64 raise @model.MultipartError {
  if added < 0L ||
    added > limits.max_total_bytes ||
    total > limits.max_total_bytes - added {
    raise LimitExceeded(
      kind=TotalBytes,
      limit=limits.max_total_bytes,
      observed=if added < 0L { total } else { total + added },
    )
  }
  total + added
}

///|
fn header_length(meta : @model.PartMeta) -> Int64 raise @model.MultipartError {
  @syntax.format_part_headers(meta).length().to_int64()
}

///|
/// Calculate the exact output size for bodies that will be supplied later to
/// `MultipartWriter`. No body bytes are copied or buffered by this operation.
pub fn plan(
  boundary : String,
  parts : Array[PlannedPart],
  limits : @model.Limits,
) -> MultipartPlan raise @model.MultipartError {
  @syntax.validate_boundary(boundary)
  @model.validate_limits(limits)
  if parts.length() > limits.max_parts {
    raise LimitExceeded(
      kind=Parts,
      limit=limits.max_parts.to_int64(),
      observed=parts.length().to_int64(),
    )
  }
  let boundary_length = @utf8.encode(boundary).length().to_int64()
  let mut total = 0L
  let mut index = 0
  let snapshot : Array[PlannedPart] = []
  for part in parts {
    if part.body_length < 0L {
      raise LimitExceeded(kind=PartBytes, limit=0L, observed=part.body_length)
    }
    if part.body_length > limits.max_part_bytes {
      raise LimitExceeded(
        kind=PartBytes,
        limit=limits.max_part_bytes,
        observed=part.body_length,
      )
    }
    let normalized_meta = copy_meta({ ..part.meta, index, })
    let delimiter_length = if index == 0 {
      boundary_length + 4L
    } else {
      boundary_length + 6L
    }
    total = plan_add(total, delimiter_length, limits)
    total = plan_add(total, header_length(normalized_meta), limits)
    total = plan_add(total, 2L, limits)
    total = plan_add(total, part.body_length, limits)
    snapshot.push({ meta: normalized_meta, body_length: part.body_length })
    index += 1
  }
  let closing_length = if parts.is_empty() {
    boundary_length + 6L
  } else {
    boundary_length + 8L
  }
  total = plan_add(total, closing_length, limits)
  { boundary, parts: snapshot, limits, content_length: total }
}

///|
pub fn MultipartPlan::content_length(self : MultipartPlan) -> Int64 {
  self.content_length
}

///|
pub fn MultipartPlan::parts(self : MultipartPlan) -> Array[PlannedPart] {
  self.parts.map(fn(part) {
    { meta: copy_meta(part.meta), body_length: part.body_length }
  })
}