use super::super::error::WalError;
use crate::replication::{RegionId, ShardId};

// ── Signal batch format ─────────────────────────────────────────────────────

/// Magic bytes identifying a tidalDB WAL batch frame: "TIDL" in LE byte order.
///
/// Stored as `[0x44, 0x4C, 0x49, 0x54]` which is `0x54494C44` as a u32 LE.
/// This allows `u32::from_le_bytes(magic) == 0x54494C44` to validate.
pub const MAGIC: [u8; 4] = [0x44, 0x4C, 0x49, 0x54];

/// Wire format version 1: original single-node format. Bytes 28-31 are
/// zero-padded reserved space. Event records are 21 bytes.
pub const FORMAT_VERSION_V1: u8 = 1;

/// Wire format version 2: adds `shard_id` and `region_id` to the header.
///
/// Bytes 28-29 carry `shard_id` (u16 LE) and bytes 30-31 carry `region_id`
/// (u16 LE) for multi-node replication. Backward compatible with v1
/// because v1 always wrote zeros at bytes 28-31, which decode as
/// `ShardId::SINGLE` and `RegionId::SINGLE`. Event records are 21 bytes.
pub const FORMAT_VERSION_V2: u8 = 2;

/// Wire format version 3 (M9): widens the per-event record from 21 to 32 bytes
/// to carry governance metadata — `scope`, `writer_agent`, `share_policy_version`
/// and `membership_epoch`.
///
/// The 64-byte header is unchanged (shard/region still at bytes 28-31). Only the
/// event payload grows. v1/v2 batches (21-byte events) still decode: the new
/// governance fields default to zero, i.e. [`SignalScope::Local`], a direct
/// (non-agent) writer, share-policy version 0, and membership epoch 0 — exactly
/// the semantics those events had before scopes existed.
///
/// [`SignalScope::Local`]: crate::governance::scope::SignalScope::Local
pub const FORMAT_VERSION_V3: u8 = 3;

/// Current wire format version. All new batches are encoded as v3.
pub const FORMAT_VERSION: u8 = FORMAT_VERSION_V3;

/// Record type discriminant for signal events.
pub const RECORD_TYPE_SIGNAL: u8 = 0x01;

/// Size of the batch header in bytes (one cache line).
pub const HEADER_SIZE: usize = 64;

/// Byte offset of the `payload_len` (u32 LE) field within the batch header.
///
/// The header layout is owned here (see [`BatchHeader`]); readers that need to
/// size a batch before a full decode must reference this constant rather than
/// re-hardcoding the offset, so the framing can never drift between the encoder
/// and the recovery scanner.
pub const HEADER_PAYLOAD_LEN_OFFSET: usize = 24;

/// Length in bytes of the `payload_len` field (a u32).
const PAYLOAD_LEN_FIELD_SIZE: usize = 4;

/// Size of a v1/v2 event record in bytes (the legacy, pre-governance layout).
pub const EVENT_SIZE: usize = 21;

/// Size of a v3 event record in bytes (legacy 21 + 11 governance bytes).
pub const EVENT_SIZE_V3: usize = 32;

/// Maximum number of events in a single batch.
pub const MAX_EVENTS_PER_BATCH: u16 = 256;

/// Read the `payload_len` field from a slice positioned at a batch header.
///
/// `header` must begin at the batch's magic bytes and contain at least
/// [`HEADER_SIZE`] bytes. This is the single owner of the `payload_len` byte
/// offset, used by the recovery scanner to bound a batch before a full
/// [`decode_batch`] so the offset cannot drift from the header layout.
///
/// # Errors
///
/// Returns `WalError::Corruption` if `header` is shorter than the field.
pub fn payload_len_from_header(header: &[u8]) -> Result<u32, WalError> {
    let end = HEADER_PAYLOAD_LEN_OFFSET + PAYLOAD_LEN_FIELD_SIZE;
    let field = header
        .get(HEADER_PAYLOAD_LEN_OFFSET..end)
        .ok_or_else(|| WalError::Corruption {
            message: "failed to read payload_len".into(),
        })?;
    Ok(u32::from_le_bytes(field.try_into().map_err(|_| {
        WalError::Corruption {
            message: "failed to read payload_len".into(),
        }
    })?))
}

/// The on-wire event-record size for a given format version.
#[must_use]
pub const fn event_size_for_version(version: u8) -> usize {
    match version {
        FORMAT_VERSION_V3 => EVENT_SIZE_V3,
        // v1 and v2 (and, defensively, anything else accepted by decode) use the
        // legacy 21-byte layout.
        _ => EVENT_SIZE,
    }
}

/// Decoded batch header.
///
/// # Wire layout (64 bytes, one cache line)
///
/// ```text
/// Bytes  0- 3: MAGIC [0x44, 0x4C, 0x49, 0x54] ("TIDL")
/// Byte   4:    format version (u8)
/// Byte   5:    flags (u8, reserved)
/// Bytes  6- 7: event_count (u16 LE)
/// Bytes  8-15: first_seq (u64 LE)
/// Bytes 16-23: batch_timestamp (u64 LE)
/// Bytes 24-27: payload_len (u32 LE)
/// Bytes 28-29: shard_id (u16 LE) -- v2+; zero in v1 and single-node
/// Bytes 30-31: region_id (u16 LE) -- v2+; zero in v1 and single-node
/// Bytes 32-63: BLAKE3 checksum (32 bytes)
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchHeader {
    pub version: u8,
    pub flags: u8,
    pub event_count: u16,
    pub first_seq: u64,
    pub batch_timestamp: u64,
    pub payload_len: u32,
    pub checksum: [u8; 32],
    /// Shard that produced this batch. `ShardId::SINGLE` (0) in single-node
    /// deployments and when reading v1 batches.
    pub shard_id: ShardId,
    /// Region that produced this batch. `RegionId::SINGLE` (0) in single-node
    /// deployments and when reading v1 batches.
    pub region_id: RegionId,
}

/// A single signal event record in wire format.
///
/// The first four fields (`entity_id`, `signal_type`, `weight`,
/// `timestamp_nanos`) are the original v1/v2 payload. The remaining fields are
/// the v3 governance envelope (M9); when reading a v1/v2 batch they decode to
/// their zero defaults (local scope, direct writer, no share policy, epoch 0).
#[derive(Debug, Clone, PartialEq)]
pub struct EventRecord {
    pub entity_id: u64,
    pub signal_type: u8,
    pub weight: f32,
    pub timestamp_nanos: u64,
    /// v3 — [`SignalScope`](crate::governance::scope::SignalScope) discriminant.
    pub scope: u8,
    /// v3 — interned writer-agent id (0 = direct user write).
    pub writer_agent: u16,
    /// v3 — share-policy version in effect at write time.
    pub share_policy_version: u16,
    /// v3 — membership epoch in effect (0 when not community-scoped).
    pub membership_epoch: u32,
}

impl EventRecord {
    /// Construct a default-scope (local) event — the common case.
    #[must_use]
    pub const fn signal(
        entity_id: u64,
        signal_type: u8,
        weight: f32,
        timestamp_nanos: u64,
    ) -> Self {
        Self {
            entity_id,
            signal_type,
            weight,
            timestamp_nanos,
            scope: 0,
            writer_agent: 0,
            share_policy_version: 0,
            membership_epoch: 0,
        }
    }

    /// Construct an event carrying explicit governance metadata.
    ///
    /// The eight parameters are the flat v3 wire fields; this is the raw
    /// record constructor, so they are passed positionally rather than via a
    /// wrapper struct.
    #[must_use]
    #[allow(clippy::too_many_arguments)]
    pub const fn scoped(
        entity_id: u64,
        signal_type: u8,
        weight: f32,
        timestamp_nanos: u64,
        scope: u8,
        writer_agent: u16,
        share_policy_version: u16,
        membership_epoch: u32,
    ) -> Self {
        Self {
            entity_id,
            signal_type,
            weight,
            timestamp_nanos,
            scope,
            writer_agent,
            share_policy_version,
            membership_epoch,
        }
    }

    /// Serialize this event into the 32-byte v3 wire format.
    #[must_use]
    pub fn to_bytes(&self) -> [u8; EVENT_SIZE_V3] {
        let mut buf = [0u8; EVENT_SIZE_V3];
        buf[0..8].copy_from_slice(&self.entity_id.to_le_bytes());
        buf[8] = self.signal_type;
        buf[9..13].copy_from_slice(&self.weight.to_le_bytes());
        buf[13..21].copy_from_slice(&self.timestamp_nanos.to_le_bytes());
        // v3 governance envelope.
        buf[21] = self.scope;
        buf[22..24].copy_from_slice(&self.writer_agent.to_le_bytes());
        buf[24..26].copy_from_slice(&self.share_policy_version.to_le_bytes());
        buf[26..30].copy_from_slice(&self.membership_epoch.to_le_bytes());
        // buf[30..32] reserved (zero).
        buf
    }

    /// Serialize this event into the legacy 21-byte v1/v2 wire format.
    ///
    /// Drops the governance envelope. Used only for constructing legacy batches
    /// (e.g. backward-compatibility tests); the live write path always emits v3.
    #[must_use]
    pub fn to_bytes_legacy(&self) -> [u8; EVENT_SIZE] {
        let mut buf = [0u8; EVENT_SIZE];
        buf[0..8].copy_from_slice(&self.entity_id.to_le_bytes());
        buf[8] = self.signal_type;
        buf[9..13].copy_from_slice(&self.weight.to_le_bytes());
        buf[13..21].copy_from_slice(&self.timestamp_nanos.to_le_bytes());
        buf
    }

    /// Deserialize an event, auto-detecting the layout by slice length.
    ///
    /// A 21-byte slice is a legacy v1/v2 record (governance fields decode to
    /// zero / [`SignalScope::Local`]); a 32-byte slice is a v3 record.
    ///
    /// [`SignalScope::Local`]: crate::governance::scope::SignalScope::Local
    ///
    /// # Errors
    ///
    /// Returns `WalError::Corruption` if the slice is neither 21 nor 32 bytes.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, WalError> {
        match bytes.len() {
            EVENT_SIZE => Self::from_bytes_legacy(bytes),
            EVENT_SIZE_V3 => Self::from_bytes_v3(bytes),
            other => Err(WalError::Corruption {
                message: format!(
                    "event record: expected {EVENT_SIZE} or {EVENT_SIZE_V3} bytes, got {other}"
                ),
            }),
        }
    }

    /// Parse the shared base fields (bytes 0..21).
    fn parse_base(bytes: &[u8]) -> Result<(u64, u8, f32, u64), WalError> {
        let entity_id =
            u64::from_le_bytes(bytes[0..8].try_into().map_err(|_| WalError::Corruption {
                message: "event record: invalid entity_id bytes".into(),
            })?);
        let signal_type = bytes[8];
        let weight =
            f32::from_le_bytes(bytes[9..13].try_into().map_err(|_| WalError::Corruption {
                message: "event record: invalid weight bytes".into(),
            })?);
        let timestamp_nanos =
            u64::from_le_bytes(bytes[13..21].try_into().map_err(|_| WalError::Corruption {
                message: "event record: invalid timestamp bytes".into(),
            })?);
        Ok((entity_id, signal_type, weight, timestamp_nanos))
    }

    /// Deserialize a legacy 21-byte record (governance defaults to zero).
    fn from_bytes_legacy(bytes: &[u8]) -> Result<Self, WalError> {
        let (entity_id, signal_type, weight, timestamp_nanos) = Self::parse_base(bytes)?;
        Ok(Self::signal(
            entity_id,
            signal_type,
            weight,
            timestamp_nanos,
        ))
    }

    /// Deserialize a v3 32-byte record (with governance envelope).
    fn from_bytes_v3(bytes: &[u8]) -> Result<Self, WalError> {
        let (entity_id, signal_type, weight, timestamp_nanos) = Self::parse_base(bytes)?;
        let scope = bytes[21];
        let writer_agent =
            u16::from_le_bytes(bytes[22..24].try_into().map_err(|_| WalError::Corruption {
                message: "event record: invalid writer_agent bytes".into(),
            })?);
        let share_policy_version =
            u16::from_le_bytes(bytes[24..26].try_into().map_err(|_| WalError::Corruption {
                message: "event record: invalid share_policy_version bytes".into(),
            })?);
        let membership_epoch =
            u32::from_le_bytes(bytes[26..30].try_into().map_err(|_| WalError::Corruption {
                message: "event record: invalid membership_epoch bytes".into(),
            })?);
        // Bytes [30..32] are reserved and the encoder always writes them zero
        // (see `to_bytes`). They are covered by the batch BLAKE3, so a flip here
        // already fails the checksum at the batch level; this per-record check is
        // the second line of defense for the rarer case where the reserved space
        // is non-zero yet checksum-consistent (e.g. a future writer that started
        // using these bytes, or a hand-crafted frame). Surfacing it as corruption
        // — rather than silently discarding the bytes — keeps the reserved space
        // genuinely reserved: an old reader will refuse a record whose semantics
        // it cannot represent instead of decoding it with the new field dropped.
        if bytes[30] != 0 || bytes[31] != 0 {
            return Err(WalError::Corruption {
                message: "event record: non-zero v3 reserved bytes [30..32]".into(),
            });
        }
        Ok(Self::scoped(
            entity_id,
            signal_type,
            weight,
            timestamp_nanos,
            scope,
            writer_agent,
            share_policy_version,
            membership_epoch,
        ))
    }
}

/// Encode a batch of events into the WAL wire format (single-node convenience).
///
/// Equivalent to `encode_batch_with_shard(events, first_seq, batch_ts,
/// ShardId::SINGLE, RegionId::SINGLE)`.
///
/// # Errors
///
/// Returns `WalError::Corruption` if `events` is empty or exceeds
/// `MAX_EVENTS_PER_BATCH`.
pub fn encode_batch(
    events: &[EventRecord],
    first_seq: u64,
    batch_ts: u64,
) -> Result<Vec<u8>, WalError> {
    encode_batch_with_shard(
        events,
        first_seq,
        batch_ts,
        ShardId::SINGLE,
        RegionId::SINGLE,
    )
}

/// Encode a batch of events with explicit shard and region identity.
///
/// Produces a byte vector containing the 64-byte header followed by tightly
/// packed 32-byte v3 event records. The BLAKE3 checksum covers
/// `header[0..32] || event_bytes`, which includes the shard/region bytes.
///
/// # Errors
///
/// Returns `WalError::Corruption` if `events` is empty or exceeds
/// `MAX_EVENTS_PER_BATCH`.
pub fn encode_batch_with_shard(
    events: &[EventRecord],
    first_seq: u64,
    batch_ts: u64,
    shard_id: ShardId,
    region_id: RegionId,
) -> Result<Vec<u8>, WalError> {
    let event_count = events.len();
    if event_count == 0 || event_count > usize::from(MAX_EVENTS_PER_BATCH) {
        return Err(WalError::Corruption {
            message: format!(
                "batch event count {event_count} out of range [1, {MAX_EVENTS_PER_BATCH}]"
            ),
        });
    }

    let payload_len = event_count * EVENT_SIZE_V3;
    let total_len = HEADER_SIZE + payload_len;
    let mut buf = vec![0u8; total_len];

    // Write header fields [0..32]
    buf[0..4].copy_from_slice(&MAGIC);
    buf[4] = FORMAT_VERSION;
    buf[5] = 0; // flags: reserved
    let count_u16 = event_count as u16;
    buf[6..8].copy_from_slice(&count_u16.to_le_bytes());
    buf[8..16].copy_from_slice(&first_seq.to_le_bytes());
    buf[16..24].copy_from_slice(&batch_ts.to_le_bytes());
    let payload_len_u32 = payload_len as u32;
    buf[24..28].copy_from_slice(&payload_len_u32.to_le_bytes());
    // shard_id and region_id at bytes 28-31.
    buf[28..30].copy_from_slice(&shard_id.0.to_le_bytes());
    buf[30..32].copy_from_slice(&region_id.0.to_le_bytes());

    // Write event records starting at offset 64
    for (i, event) in events.iter().enumerate() {
        let offset = HEADER_SIZE + i * EVENT_SIZE_V3;
        buf[offset..offset + EVENT_SIZE_V3].copy_from_slice(&event.to_bytes());
    }

    // Compute BLAKE3 over header[0..32] || event_bytes
    let checksum = compute_checksum(&buf[0..32], &buf[HEADER_SIZE..]);
    buf[32..64].copy_from_slice(checksum.as_bytes());

    Ok(buf)
}

/// Decode a batch from raw bytes.
///
/// Two-phase validation:
/// - Phase 1: magic bytes, version, payload length bounds
/// - Phase 2: BLAKE3 checksum verification
///
/// The per-event record size is selected from the header's format version
/// ([`event_size_for_version`]), so v1/v2 (21-byte) and v3 (32-byte) batches all
/// decode through the same path.
///
/// # Errors
///
/// Returns `WalError::Corruption` on any validation failure.
pub fn decode_batch(bytes: &[u8]) -> Result<(BatchHeader, Vec<EventRecord>), WalError> {
    if bytes.len() < HEADER_SIZE {
        return Err(WalError::Corruption {
            message: format!(
                "batch too short for header: {} bytes, need {HEADER_SIZE}",
                bytes.len()
            ),
        });
    }

    // Phase 1: structural validation
    if bytes[0..4] != MAGIC {
        return Err(WalError::Corruption {
            message: "invalid magic bytes".into(),
        });
    }

    let version = bytes[4];
    if version != FORMAT_VERSION_V1 && version != FORMAT_VERSION_V2 && version != FORMAT_VERSION_V3
    {
        return Err(WalError::Corruption {
            message: format!("unsupported format version: {version}"),
        });
    }
    let event_size = event_size_for_version(version);

    let flags = bytes[5];
    let event_count =
        u16::from_le_bytes(bytes[6..8].try_into().map_err(|_| WalError::Corruption {
            message: "invalid event_count bytes".into(),
        })?);

    if event_count == 0 || event_count > MAX_EVENTS_PER_BATCH {
        return Err(WalError::Corruption {
            message: format!("event count {event_count} out of range [1, {MAX_EVENTS_PER_BATCH}]"),
        });
    }

    let first_seq =
        u64::from_le_bytes(bytes[8..16].try_into().map_err(|_| WalError::Corruption {
            message: "invalid first_seq bytes".into(),
        })?);

    let batch_timestamp =
        u64::from_le_bytes(bytes[16..24].try_into().map_err(|_| WalError::Corruption {
            message: "invalid batch_timestamp bytes".into(),
        })?);

    let payload_len = payload_len_from_header(bytes)?;

    // v2+ fields at bytes 28-31. For v1 batches these bytes are always zero,
    // which correctly decodes as ShardId::SINGLE and RegionId::SINGLE.
    let shard_id = ShardId(u16::from_le_bytes(bytes[28..30].try_into().map_err(
        |_| WalError::Corruption {
            message: "invalid shard_id bytes".into(),
        },
    )?));
    let region_id = RegionId(u16::from_le_bytes(bytes[30..32].try_into().map_err(
        |_| WalError::Corruption {
            message: "invalid region_id bytes".into(),
        },
    )?));

    let expected_payload = u32::from(event_count) * event_size as u32;
    if payload_len != expected_payload {
        return Err(WalError::Corruption {
            message: format!(
                "payload_len {payload_len} != event_count {event_count} * {event_size}"
            ),
        });
    }

    let total_len = HEADER_SIZE + payload_len as usize;
    if bytes.len() < total_len {
        return Err(WalError::Corruption {
            message: format!(
                "batch truncated: have {} bytes, need {total_len}",
                bytes.len()
            ),
        });
    }

    // Extract stored checksum
    let mut checksum = [0u8; 32];
    checksum.copy_from_slice(&bytes[32..64]);

    // Phase 2: BLAKE3 verification
    let event_bytes = &bytes[HEADER_SIZE..total_len];
    let computed = compute_checksum(&bytes[0..32], event_bytes);
    if computed.as_bytes() != &checksum {
        return Err(WalError::Corruption {
            message: "BLAKE3 checksum mismatch".into(),
        });
    }

    // Parse event records
    let mut events = Vec::with_capacity(usize::from(event_count));
    for i in 0..usize::from(event_count) {
        let offset = i * event_size;
        let event = EventRecord::from_bytes(&event_bytes[offset..offset + event_size])?;
        events.push(event);
    }

    let header = BatchHeader {
        version,
        flags,
        event_count,
        first_seq,
        batch_timestamp,
        payload_len,
        checksum,
        shard_id,
        region_id,
    };

    Ok((header, events))
}

/// Compute the BLAKE3 checksum for a batch.
///
/// Input: `header_prefix[0..32] || event_bytes`.
/// The hash field at `[32..64]` is NOT part of the hash input.
fn compute_checksum(header_prefix: &[u8], event_bytes: &[u8]) -> blake3::Hash {
    let mut hasher = blake3::Hasher::new();
    hasher.update(header_prefix);
    hasher.update(event_bytes);
    hasher.finalize()
}

/// Compute the per-event content hash used for deduplication.
///
/// Returns the first 128 bits of the BLAKE3 hash of the canonical 32-byte v3
/// record image. A legacy (v1/v2) event decodes with zeroed governance fields,
/// so it hashes identically to a fresh v3 *local* event with the same base
/// fields — retry-dedup is therefore version-stable for local signals, while
/// events that differ only in governance (e.g. local vs community scope) hash
/// distinctly.
///
/// # Panics
///
/// Cannot panic. The `expect` is on a `try_into` converting a 16-byte slice
/// (from a 32-byte BLAKE3 hash) into `[u8; 16]`, which is infallible.
#[must_use]
pub fn event_content_hash(event: &EventRecord) -> u128 {
    let bytes = event.to_bytes();
    let hash = blake3::hash(&bytes);
    let hash_bytes: &[u8; 32] = hash.as_bytes();
    u128::from_le_bytes(
        hash_bytes[..16]
            .try_into()
            .expect("BLAKE3 hash is always 32 bytes; first 16 is infallible"),
    )
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[path = "batch_tests.rs"]
mod tests;

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod header_layout_tests {
    use super::*;

    #[test]
    fn v3_event_reserved_bytes_are_zero() {
        // Bytes [30..32] of a v3 event record are reserved and MUST be written
        // zero. They are covered by the batch BLAKE3, so a flip is already
        // rejected on decode as corruption; this pins the writer-side invariant
        // so the reserved space stays available for a future format version.
        let event = EventRecord::scoped(42, RECORD_TYPE_SIGNAL, 1.0, 1000, 1, 7, 3, 9);
        let bytes = event.to_bytes();
        assert_eq!(
            &bytes[30..32],
            &[0u8, 0u8],
            "v3 reserved bytes [30..32] must be zero on encode"
        );
    }

    #[test]
    fn payload_len_accessor_matches_decode() {
        // The header accessor must read the exact same field decode_batch reads,
        // so the recovery scanner and the full decoder can never disagree on a
        // batch's payload size.
        let events = vec![EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 1)];
        let encoded = encode_batch(&events, 1, 1).expect("encode should succeed");
        let from_accessor = payload_len_from_header(&encoded).expect("accessor should succeed");
        let (header, _) = decode_batch(&encoded).expect("decode should succeed");
        assert_eq!(from_accessor, header.payload_len);
        assert_eq!(from_accessor as usize, EVENT_SIZE_V3);
    }

    #[test]
    fn payload_len_accessor_rejects_short_header() {
        let short = [0u8; HEADER_PAYLOAD_LEN_OFFSET + 1];
        assert!(payload_len_from_header(&short).is_err());
    }
}
