// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

// The PostgreSQL v3 frontend/backend protocol. Every message is framed as an
// optional one-byte type tag, a big-endian Int32 length that *counts itself*
// (payload length + 4), then the payload. The startup and SSL-request messages
// are the only ones without a type tag. Reference: PostgreSQL protocol §55.

///|
/// A growing buffer for a message payload with the big-endian integer and
/// C-string writers the wire format needs. Built up field by field, then
/// wrapped with a frame by [`frame`] / [`frame_startup`].
priv struct MsgWriter {
  buf : Buffer
}

///|
fn MsgWriter::new() -> MsgWriter {
  { buf: Buffer() }
}

///|
fn MsgWriter::byte(self : MsgWriter, b : Byte) -> Unit {
  self.buf.write_byte(b)
}

///|
fn MsgWriter::i16(self : MsgWriter, v : Int) -> Unit {
  self.buf.write_byte((v >> 8).to_byte())
  self.buf.write_byte(v.to_byte())
}

///|
fn MsgWriter::i32(self : MsgWriter, v : Int) -> Unit {
  self.buf.write_byte((v >> 24).to_byte())
  self.buf.write_byte((v >> 16).to_byte())
  self.buf.write_byte((v >> 8).to_byte())
  self.buf.write_byte(v.to_byte())
}

///|
fn MsgWriter::raw(self : MsgWriter, b : Bytes) -> Unit {
  self.buf.write_bytes(b[:])
}

///|
/// Write a UTF-8 string followed by its `NUL` terminator (a C string).
fn MsgWriter::cstring(self : MsgWriter, s : String) -> Unit {
  self.buf.write_bytes(@utf8.encode(s)[:])
  self.buf.write_byte(0)
}

///|
fn MsgWriter::to_bytes(self : MsgWriter) -> Bytes {
  self.buf.to_bytes()
}

///|
/// Frame a payload as a tagged message: `type` byte, Int32 length (payload + 4),
/// then payload. Used for every frontend message except startup.
fn frame(tag : Byte, payload : Bytes) -> Bytes {
  let w = MsgWriter::new()
  w.byte(tag)
  w.i32(payload.length() + 4)
  w.raw(payload)
  w.to_bytes()
}

///|
/// Frame the (untagged) startup message: Int32 length, then payload.
fn frame_startup(payload : Bytes) -> Bytes {
  let w = MsgWriter::new()
  w.i32(payload.length() + 4)
  w.raw(payload)
  w.to_bytes()
}

// --- frontend message builders -------------------------------------------------

///|
/// StartupMessage: protocol version 3.0 (`196608`) plus the `user` / `database`
/// parameters and a `client_encoding=UTF8` request, terminated by an empty key.
pub fn build_startup(user : String, database : String) -> Bytes {
  let w = MsgWriter::new()
  w.i32(196608)
  w.cstring("user")
  w.cstring(user)
  w.cstring("database")
  w.cstring(database)
  w.cstring("client_encoding")
  w.cstring("UTF8")
  w.byte(0)
  frame_startup(w.to_bytes())
}

///|
/// PasswordMessage ('p'): the auth response token (cleartext, or the `md5…`
/// digest), as a C string.
pub fn build_password(token : String) -> Bytes {
  let w = MsgWriter::new()
  w.cstring(token)
  frame(b'p', w.to_bytes())
}

///|
/// Simple Query ('Q'): one SQL string, no bound parameters.
pub fn build_query(sql : String) -> Bytes {
  let w = MsgWriter::new()
  w.cstring(sql)
  frame(b'Q', w.to_bytes())
}

///|
/// Parse ('P'): prepare the unnamed statement from `sql` (with `$n`
/// placeholders). Zero declared parameter types — the server infers them.
pub fn build_parse(sql : String) -> Bytes {
  let w = MsgWriter::new()
  w.cstring("") // unnamed prepared statement
  w.cstring(sql)
  w.i16(0) // no explicit parameter type OIDs
  frame(b'P', w.to_bytes())
}

///|
/// Bind ('B'): bind `params` (all text format) to the unnamed statement,
/// producing the unnamed portal, and request all result columns in text format.
pub fn build_bind(params : Array[Value]) -> Bytes {
  let w = MsgWriter::new()
  w.cstring("") // unnamed portal
  w.cstring("") // unnamed prepared statement
  w.i16(0) // zero parameter format codes ⇒ all text
  w.i16(params.length())
  for p in params {
    match encode_param(p) {
      None => w.i32(-1) // NULL
      Some(bytes) => {
        w.i32(bytes.length())
        w.raw(bytes)
      }
    }
  }
  w.i16(0) // zero result format codes ⇒ all text
  frame(b'B', w.to_bytes())
}

///|
/// Describe ('D') the unnamed portal, so the server sends a RowDescription
/// before the DataRows (giving column names + type OIDs for decoding).
pub fn build_describe_portal() -> Bytes {
  let w = MsgWriter::new()
  w.byte(b'P') // 'P' = portal (vs 'S' = statement)
  w.cstring("")
  frame(b'D', w.to_bytes())
}

///|
/// Execute ('E') the unnamed portal with no row limit (`0` = all rows).
pub fn build_execute() -> Bytes {
  let w = MsgWriter::new()
  w.cstring("") // unnamed portal
  w.i32(0) // unlimited rows
  frame(b'E', w.to_bytes())
}

///|
/// Sync ('S'): close the extended-query batch; the server replies
/// ReadyForQuery.
pub fn build_sync() -> Bytes {
  frame(b'S', b"")
}

///|
/// Terminate ('X'): ask the backend to close the connection.
pub fn build_terminate() -> Bytes {
  frame(b'X', b"")
}