//! Loose commit metadata for Sedimentree.

pub mod id;

use alloc::{collections::BTreeSet, vec::Vec};

use id::CommitId;

use crate::{
    blob::{Blob, BlobMeta, has_meta::HasBlobMeta},
    codec::{
        decode::{self, DecodeFields},
        encode::{self, EncodeFields},
        error::{Bijou64Error, BufferTooShort, DecodeError, ReadingType},
        schema::{self, Schema},
    },
    crypto::digest::Digest,
    id::SedimentreeId,
};

/// The smallest unit of metadata in a Sedimentree.
///
/// It includes metadata about the payload blob, plus pointers to any (causal) parents.
/// The `sedimentree_id` field cryptographically binds the commit to a specific
/// document, preventing replay attacks across documents.
///
/// The `head` field is a user-supplied opaque identifier for this commit.
/// It is _not_ computed by Sedimentree — the caller provides it at construction
/// time (e.g., from an Automerge `ChangeHash`). This is the identity used for
/// DAG traversal, fragment boundaries, and sync.
///
/// # Wire Format Limits
///
/// The parent count is encoded as a `u8`, limiting commits to **255 parents**.
/// This is sufficient for any realistic workload — hitting this limit would require
/// 255+ peers making concurrent changes without seeing each other's updates.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "bolero", derive(bolero::generator::TypeGenerator))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LooseCommit {
    sedimentree_id: SedimentreeId,
    head: CommitId,
    parents: BTreeSet<CommitId>,
    blob_meta: BlobMeta,
}

impl LooseCommit {
    /// Constructor for a [`LooseCommit`].
    #[must_use]
    pub const fn new(
        sedimentree_id: SedimentreeId,
        head: CommitId,
        parents: BTreeSet<CommitId>,
        blob_meta: BlobMeta,
    ) -> Self {
        Self {
            sedimentree_id,
            head,
            parents,
            blob_meta,
        }
    }

    /// The user-supplied identifier for this commit.
    #[must_use]
    pub const fn head(&self) -> CommitId {
        self.head
    }

    /// The [`SedimentreeId`] this commit belongs to.
    #[must_use]
    pub const fn sedimentree_id(&self) -> SedimentreeId {
        self.sedimentree_id
    }

    /// The (possibly empty) set of parent commits.
    #[must_use]
    pub const fn parents(&self) -> &BTreeSet<CommitId> {
        &self.parents
    }

    /// Metadata about the payload blob.
    #[must_use]
    pub const fn blob_meta(&self) -> &BlobMeta {
        &self.blob_meta
    }
}

impl HasBlobMeta for LooseCommit {
    type Args = (SedimentreeId, CommitId, BTreeSet<CommitId>);

    fn blob_meta(&self) -> BlobMeta {
        self.blob_meta
    }

    fn from_args((sedimentree_id, head, parents): Self::Args, blob_meta: BlobMeta) -> Self {
        Self::new(sedimentree_id, head, parents, blob_meta)
    }
}

/// Fixed fields size _excluding_ the variable-length blob size:
/// SedimentreeId(32) + Head(32) + Digest<Blob>(32) + |Parents|(1).
const CODEC_FIXED_FIELDS_SIZE: usize = 32 + 32 + 32 + 1;

/// Minimum fields size: fixed fields + smallest bijou64 (1 byte for values 0–247).
const CODEC_MIN_FIELDS_SIZE: usize = CODEC_FIXED_FIELDS_SIZE + 1;

/// Minimum signed message size: `Schema(4) + IssuerVK(32) + MinFields + Signature(64)`.
const CODEC_MIN_SIZE: usize = 4 + 32 + CODEC_MIN_FIELDS_SIZE + 64;

impl Schema for LooseCommit {
    const PREFIX: [u8; 2] = schema::SEDIMENTREE_PREFIX;
    const TYPE_BYTE: u8 = b'C';
    const VERSION: u8 = 0;
}

impl EncodeFields for LooseCommit {
    fn encode_fields(&self, buf: &mut Vec<u8>) {
        encode::array(self.sedimentree_id.as_bytes(), buf);
        encode::array(self.head.as_bytes(), buf);
        encode::array(self.blob_meta().digest().as_bytes(), buf);

        #[allow(clippy::cast_possible_truncation)]
        encode::u8(self.parents().len() as u8, buf);

        bijou64::encode(self.blob_meta().size_bytes(), buf);

        for parent in self.parents() {
            encode::array(parent.as_bytes(), buf);
        }
    }

    fn fields_size(&self) -> usize {
        CODEC_FIXED_FIELDS_SIZE
            + bijou64::encoded_len(self.blob_meta().size_bytes())
            + (self.parents().len() * 32)
    }
}

impl DecodeFields for LooseCommit {
    const MIN_SIGNED_SIZE: usize = CODEC_MIN_SIZE;

    fn try_decode_fields(buf: &[u8]) -> Result<(Self, usize), DecodeError> {
        if buf.len() < CODEC_MIN_FIELDS_SIZE {
            return Err(DecodeError::MessageTooShort {
                type_name: "LooseCommit",
                need: CODEC_MIN_FIELDS_SIZE,
                have: buf.len(),
            });
        }

        let mut offset = 0;

        let sedimentree_id_bytes: [u8; 32] = decode::array(buf, offset)?;
        let sedimentree_id = SedimentreeId::new(sedimentree_id_bytes);
        offset += 32;

        let head_bytes: [u8; 32] = decode::array(buf, offset)?;
        let head = CommitId::new(head_bytes);
        offset += 32;

        let blob_digest_bytes: [u8; 32] = decode::array(buf, offset)?;
        let blob_digest = Digest::<Blob>::force_from_bytes(blob_digest_bytes);
        offset += 32;

        let parent_count = decode::u8(buf, offset)? as usize;
        offset += 1;

        let (blob_size, consumed) = bijou64::decode(buf.get(offset..).unwrap_or_default())
            .map_err(|kind| Bijou64Error { offset, kind })?;
        offset += consumed;

        let parents_size = parent_count * 32;
        if buf.len() < offset + parents_size {
            return Err(BufferTooShort {
                reading: ReadingType::Slice { len: parents_size },
                offset,
                need: parents_size,
                have: buf.len().saturating_sub(offset),
            }
            .into());
        }

        let mut parent_arrays: Vec<[u8; 32]> = Vec::with_capacity(parent_count);
        for _ in 0..parent_count {
            let parent_bytes: [u8; 32] = decode::array(buf, offset)?;
            parent_arrays.push(parent_bytes);
            offset += 32;
        }

        decode::verify_sorted(&parent_arrays)?;

        let parents: BTreeSet<CommitId> = parent_arrays.into_iter().map(CommitId::new).collect();

        let blob_meta = BlobMeta::from_digest_size(blob_digest, blob_size);

        Ok((
            LooseCommit::new(sedimentree_id, head, parents, blob_meta),
            offset,
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::vec;

    fn make_sedimentree_id(byte: u8) -> SedimentreeId {
        SedimentreeId::new([byte; 32])
    }

    #[test]
    fn codec_unsorted_parents_rejected() {
        let id = make_sedimentree_id(0x01);

        let mut buf = Vec::new();
        encode::array(id.as_bytes(), &mut buf);
        encode::array(&[0x10; 32], &mut buf); // head
        encode::array(&[0x20; 32], &mut buf); // blob digest
        encode::u8(2, &mut buf);
        bijou64::encode(1024, &mut buf);
        encode::array(&[0x50; 32], &mut buf);
        encode::array(&[0x30; 32], &mut buf);

        let result = LooseCommit::try_decode_fields(&buf);
        assert!(matches!(result, Err(DecodeError::UnsortedArray(_))));
    }

    #[test]
    fn codec_buffer_too_short_rejected() {
        let buf = vec![0u8; 50];

        let result = LooseCommit::try_decode_fields(&buf);
        assert!(matches!(result, Err(DecodeError::MessageTooShort { .. })));
    }

    #[test]
    fn codec_schema_is_correct() {
        assert_eq!(LooseCommit::SCHEMA, *b"STC\x00");
    }

    #[test]
    fn codec_min_size_is_correct() {
        // Schema(4) + IssuerVK(32) + SedimentreeId(32) + Head(32) + BlobDigest(32) + ParentCnt(1) + BlobSize(bijou64 min=1) + Signature(64)
        assert_eq!(LooseCommit::MIN_SIGNED_SIZE, 198);
    }
}

#[cfg(all(test, feature = "bolero"))]
mod proptests {
    use super::*;
    use crate::codec::encode::Encode;

    /// Round-trip property: encode then decode yields the original value.
    #[test]
    #[allow(clippy::panic)]
    fn codec_round_trip() {
        bolero::check!()
            .with_arbitrary::<LooseCommit>()
            .for_each(|commit| {
                let mut buf = Vec::new();
                commit.encode_fields(&mut buf);
                match LooseCommit::try_decode_fields(&buf) {
                    Ok((decoded, consumed)) => {
                        assert_eq!(&decoded, commit);
                        assert_eq!(consumed, buf.len());
                    }
                    Err(e) => panic!("decode should succeed for valid encoded data: {e}"),
                }
            });
    }

    /// Fuzz the decoder with arbitrary bytes - should never panic.
    #[test]
    fn decode_does_not_panic() {
        bolero::check!()
            .with_arbitrary::<Vec<u8>>()
            .for_each(|bytes| {
                let _result = LooseCommit::try_decode_fields(bytes);
            });
    }

    /// Encoded size matches actual encoded length.
    #[test]
    fn fields_size_matches_encoded_length() {
        bolero::check!()
            .with_arbitrary::<LooseCommit>()
            .for_each(|commit| {
                let mut buf = Vec::new();
                commit.encode_fields(&mut buf);
                assert_eq!(buf.len(), commit.fields_size());
            });
    }

    /// Full encode (with schema) size matches `encoded_size()`.
    #[test]
    fn encoded_size_matches_actual() {
        bolero::check!()
            .with_arbitrary::<LooseCommit>()
            .for_each(|commit| {
                let encoded = commit.encode();
                assert_eq!(encoded.len(), commit.encoded_size());
            });
    }

    /// Decoded struct's `fields_size()` matches the consumed byte count
    /// returned by `try_decode_fields`.
    ///
    /// This is the invariant that the old `Signed::try_decode` assumed
    /// but never verified — if these disagree, `bytes.truncate()` corrupts
    /// the signed data.
    #[test]
    #[allow(clippy::panic)]
    fn decoded_fields_size_matches_consumed() {
        bolero::check!()
            .with_arbitrary::<LooseCommit>()
            .for_each(|commit| {
                let mut buf = Vec::new();
                commit.encode_fields(&mut buf);
                match LooseCommit::try_decode_fields(&buf) {
                    Ok((decoded, consumed)) => {
                        assert_eq!(
                            consumed,
                            decoded.fields_size(),
                            "consumed byte count must equal decoded struct's fields_size()"
                        );
                    }
                    Err(e) => panic!("decode should succeed for valid encoded data: {e}"),
                }
            });
    }
}
