///|
/// Magic 0x377f0683 checksums words as big-endian; 0x377f0682 uses
/// little-endian words. All structural fields remain big-endian.
pub(all) enum WalChecksumOrder {
  BigEndian
  LittleEndian
} derive(Eq, Debug)

///|
pub(all) struct WalChecksum {
  first : UInt64
  second : UInt64
} derive(Eq, Debug)

///|
pub(all) struct WalHeader {
  magic : UInt64
  format_version : UInt64
  page_size : Int
  checkpoint_sequence : UInt64
  salt_first : UInt64
  salt_second : UInt64
  checksum : WalChecksum
  computed_checksum : WalChecksum
  checksum_order : WalChecksumOrder
  checksum_valid : Bool
} derive(Eq, Debug)

///|
pub(all) struct WalFrame {
  index : Int
  offset : Int
  page_number : UInt64
  database_size_after_commit : UInt64
  salt_first : UInt64
  salt_second : UInt64
  checksum : WalChecksum
  computed_checksum : WalChecksum
  page_data : Bytes
  salt_valid : Bool
  checksum_valid : Bool
} derive(Eq, Debug)

///|
pub fn WalFrame::is_commit(self : WalFrame) -> Bool {
  self.database_size_after_commit != 0UL
}

///|
pub fn WalFrame::is_valid(self : WalFrame) -> Bool {
  self.page_number != 0UL && self.salt_valid && self.checksum_valid
}

///|
pub(all) struct WalTransaction {
  ordinal : Int
  first_frame : Int
  last_frame : Int
  frame_count : Int
  committed : Bool
  database_size_after_commit : UInt64?
} derive(Eq, Debug)

///|
pub(all) struct WalFile {
  header : WalHeader
  frames : Array[WalFrame]
  transactions : Array[WalTransaction]
  diagnostics : Array[Diagnostic]
  trailing_bytes : Int
  last_commit_frame : Int?
} derive(Debug)

///|
pub fn WalFile::valid_frame_count(self : WalFile) -> Int {
  let mut count = 0
  for frame in self.frames {
    if frame.is_valid() {
      count = count + 1
    }
  }
  count
}

///|
fn wal_read_word(
  bytes : Bytes,
  offset : Int,
  order : WalChecksumOrder,
) -> UInt64 raise ParseError {
  let reader = BinaryReader::range(bytes, offset, 4, "WAL checksum word")
  match order {
    BigEndian => reader.read_u32_be()
    LittleEndian => reader.read_u32_le()
  }
}

///|
/// Apply SQLite's rolling two-word WAL checksum. Initial values allow frame
/// checksums to continue the state established by the header and prior frames.
pub fn wal_checksum(
  bytes : Bytes,
  order : WalChecksumOrder,
  initial_first? : UInt64 = 0UL,
  initial_second? : UInt64 = 0UL,
) -> WalChecksum raise ParseError {
  guard bytes.length() % 8 == 0 else {
    raise InvalidValue(
      0,
      "WAL checksum input length",
      bytes.length().to_string() + " is not a multiple of 8",
    )
  }
  let mut first = initial_first & 0xffffffffUL
  let mut second = initial_second & 0xffffffffUL
  for offset = 0; offset < bytes.length(); offset = offset + 8 {
    let word_first = wal_read_word(bytes, offset, order)
    let word_second = wal_read_word(bytes, offset + 4, order)
    first = (first + word_first + second) & 0xffffffffUL
    second = (second + word_second + first) & 0xffffffffUL
  }
  { first, second }
}

///|
fn valid_wal_page_size(size : UInt64) -> Bool {
  if size < 512UL || size > 65536UL {
    return false
  }
  (size & (size - 1UL)) == 0UL
}

///|
pub fn parse_wal_header(data : Bytes) -> WalHeader raise ParseError {
  guard data.length() >= 32 else {
    raise UnexpectedEnd(0, 32, data.length(), "SQLite WAL header")
  }
  let reader = BinaryReader::range(data, 0, 32, "SQLite WAL header")
  let magic = reader.read_u32_be()
  let checksum_order = match magic {
    0x377f0683UL => BigEndian
    0x377f0682UL => LittleEndian
    _ => raise InvalidMagic("0x377f0682 or 0x377f0683", magic.to_string())
  }
  let format_version = reader.read_u32_be()
  let raw_page_size = reader.read_u32_be()
  let page_size = if raw_page_size == 1UL {
    65536
  } else {
    guard valid_wal_page_size(raw_page_size) else {
      raise InvalidValue(8, "WAL page size", raw_page_size.to_string())
    }
    raw_page_size.to_int()
  }
  let checkpoint_sequence = reader.read_u32_be()
  let salt_first = reader.read_u32_be()
  let salt_second = reader.read_u32_be()
  let checksum : WalChecksum = {
    first: reader.read_u32_be(),
    second: reader.read_u32_be(),
  }
  let prefix = Bytes::makei(24, fn(index) { data[index] })
  let computed_checksum = wal_checksum(prefix, checksum_order)
  {
    magic,
    format_version,
    page_size,
    checkpoint_sequence,
    salt_first,
    salt_second,
    checksum,
    computed_checksum,
    checksum_order,
    checksum_valid: checksum == computed_checksum,
  }
}

///|
fn frame_checksum_input(
  data : Bytes,
  frame_offset : Int,
  page_size : Int,
) -> Bytes {
  Bytes::makei(page_size + 8, fn(index) {
    if index < 8 {
      data[frame_offset + index]
    } else {
      data[frame_offset + 24 + index - 8]
    }
  })
}

///|
fn group_wal_transactions(frames : Array[WalFrame]) -> Array[WalTransaction] {
  let transactions : Array[WalTransaction] = []
  let mut start = 0
  let mut ordinal = 1
  for index = 0; index < frames.length(); index = index + 1 {
    if frames[index].is_commit() {
      transactions.push({
        ordinal,
        first_frame: start + 1,
        last_frame: index + 1,
        frame_count: index - start + 1,
        committed: true,
        database_size_after_commit: Some(
          frames[index].database_size_after_commit,
        ),
      })
      ordinal = ordinal + 1
      start = index + 1
    }
  }
  if start < frames.length() {
    transactions.push({
      ordinal,
      first_frame: start + 1,
      last_frame: frames.length(),
      frame_count: frames.length() - start,
      committed: false,
      database_size_after_commit: None,
    })
  }
  transactions
}

///|
/// Parse a complete WAL, retaining invalid frames and attaching diagnostics so
/// an analyst can distinguish structural damage from uncommitted tail data.
pub fn parse_wal(data : Bytes) -> WalFile raise ParseError {
  let header = parse_wal_header(data)
  let frames : Array[WalFrame] = []
  let diagnostics : Array[Diagnostic] = []
  if !header.checksum_valid {
    diagnostics.push(
      Diagnostic::error("WAL_HEADER_CHECKSUM", "WAL header checksum is invalid"),
    )
  }
  if header.format_version != 3007000UL {
    diagnostics.push(
      Diagnostic::warning(
        "WAL_FORMAT_VERSION",
        "unexpected WAL format version " + header.format_version.to_string(),
        offset=4,
      ),
    )
  }
  let frame_size = 24 + header.page_size
  let body_length = data.length() - 32
  let complete_frames = body_length / frame_size
  let trailing_bytes = body_length % frame_size
  let mut rolling = header.computed_checksum
  for frame_index = 0
      frame_index < complete_frames
      frame_index = frame_index + 1 {
    let offset = 32 + frame_index * frame_size
    let reader = BinaryReader::range(data, offset, 24, "WAL frame header")
    let page_number = reader.read_u32_be()
    let database_size_after_commit = reader.read_u32_be()
    let salt_first = reader.read_u32_be()
    let salt_second = reader.read_u32_be()
    let checksum : WalChecksum = {
      first: reader.read_u32_be(),
      second: reader.read_u32_be(),
    }
    let checksum_input = frame_checksum_input(data, offset, header.page_size)
    rolling = wal_checksum(
      checksum_input,
      header.checksum_order,
      initial_first=rolling.first,
      initial_second=rolling.second,
    )
    let page_data = Bytes::makei(header.page_size, fn(index) {
      data[offset + 24 + index]
    })
    let salt_valid = salt_first == header.salt_first &&
      salt_second == header.salt_second
    let checksum_valid = checksum == rolling
    frames.push({
      index: frame_index + 1,
      offset,
      page_number,
      database_size_after_commit,
      salt_first,
      salt_second,
      checksum,
      computed_checksum: rolling,
      page_data,
      salt_valid,
      checksum_valid,
    })
    if page_number == 0UL {
      diagnostics.push(
        Diagnostic::error(
          "WAL_FRAME_PAGE_ZERO",
          "WAL frame references page zero",
          offset~,
        ),
      )
    }
    if !salt_valid {
      diagnostics.push(
        Diagnostic::error(
          "WAL_FRAME_SALT",
          "WAL frame salt does not match the WAL header",
          offset=offset + 8,
          page_number~,
        ),
      )
    }
    if !checksum_valid {
      diagnostics.push(
        Diagnostic::error(
          "WAL_FRAME_CHECKSUM",
          "WAL frame checksum is invalid",
          offset=offset + 16,
          page_number~,
        ),
      )
    }
  }
  if trailing_bytes != 0 {
    diagnostics.push(
      Diagnostic::warning(
        "WAL_TRUNCATED_FRAME",
        "WAL ends with " + trailing_bytes.to_string() + " byte(s) of a frame",
        offset=32 + complete_frames * frame_size,
      ),
    )
  }
  let transactions = group_wal_transactions(frames)
  let mut last_commit_frame : Int? = None
  for transaction in transactions {
    if transaction.committed {
      last_commit_frame = Some(transaction.last_frame)
    } else {
      diagnostics.push(
        Diagnostic::warning(
          "WAL_UNCOMMITTED_FRAMES",
          "WAL contains " +
          transaction.frame_count.to_string() +
          " uncommitted frame(s)",
        ),
      )
    }
  }
  {
    header,
    frames,
    transactions,
    diagnostics,
    trailing_bytes,
    last_commit_frame,
  }
}

///|
/// Return the newest valid occurrence of a page not newer than `max_frame`.
/// Frame indexes are one-based, matching the CLI and SQLite documentation.
pub fn WalFile::latest_frame_for_page(
  self : WalFile,
  page_number : UInt64,
  max_frame? : Int,
) -> WalFrame? {
  let limit = match max_frame {
    Some(value) => value
    None =>
      match self.last_commit_frame {
        Some(value) => value
        None => 0
      }
  }
  for index = self.frames.length() - 1; index >= 0; index = index - 1 {
    let frame = self.frames[index]
    if frame.index <= limit &&
      frame.page_number == page_number &&
      frame.is_valid() {
      return Some(frame)
    }
  }
  None
}