//! Core types shared across every.channel.

pub mod sim;

use serde::{Deserialize, Serialize};
use sha3::{Digest, Keccak256};
use std::collections::BTreeMap;
use std::fmt;

pub const MANIFEST_ID_ALG_BLAKE3: &str = "blake3";
pub const MANIFEST_ID_ALG_KECCAK256: &str = "keccak256";
pub const RECORD_ID_ALG_BLAKE3: &str = "blake3";
pub const RECORD_ID_ALG_KECCAK256: &str = "keccak256";
pub const RECORD_TYPE_COMMERCIAL_RANGE_SET: &str = "commercial_range_set";
pub const RECORD_TYPE_CLIP: &str = "clip";
pub const RECORD_TYPE_CHANNEL_METADATA: &str = "channel_metadata";
pub const RECORD_TYPE_FRIEND_COMMENT: &str = "friend_comment";
pub const RECORD_TYPE_OVERLAY: &str = "overlay";
pub const RECORD_TYPE_SOURCE_ATTESTATION: &str = "source_attestation";
pub const RECORD_VISIBILITY_PRIVATE: &str = "private";
pub const RECORD_VISIBILITY_FRIENDS: &str = "friends";
pub const RECORD_VISIBILITY_UNLISTED: &str = "unlisted";
pub const RECORD_VISIBILITY_PUBLIC: &str = "public";
pub const RECORD_CONTENT_HASH_BLAKE3: &str = "blake3";
pub const RECORD_CONTENT_HASH_SHA256: &str = "sha256";
pub const RECORD_CONTENT_HASH_IPFS_CID_V1: &str = "ipfs-cid-v1";
pub const RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES: usize = 16 * 1024;
pub const RECORD_NFT_PROJECTION_ERC721: &str = "erc721";
pub const RECORD_NFT_PROJECTION_ERC1155: &str = "erc1155";
pub const RECORD_UI_RECOGNITION_COMMERCIAL_RANGES: &str = "commercial-ranges";
pub const RECORD_UI_RECOGNITION_CLIP: &str = "clip";
pub const RECORD_UI_RECOGNITION_CHANNEL_METADATA: &str = "channel-metadata";
pub const RECORD_UI_RECOGNITION_FRIEND_COMMENT: &str = "friend-comment";
pub const RECORD_UI_RECOGNITION_OVERLAY: &str = "overlay";
pub const RECORD_UI_RECOGNITION_SOURCE_ATTESTATION: &str = "source-attestation";
pub const RECORD_SIG_ALG_SECP256K1_EIP712_BODY_V1: &str = "secp256k1-eip712-signed-record-body-v1";
pub const RECORD_ACTIVITYPUB_INLINE_CONTENT_MAX_BYTES: usize = 2 * 1024;
pub const MERKLE_PROOF_ALG_BLAKE3: &str = "merkle+blake3";
pub const MERKLE_PROOF_ALG_KECCAK256: &str = "merkle+keccak256";

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChannelId(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DeviceId(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StreamId(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainCommitment {
    pub chain: String,
    pub scheme: String,
    pub digest: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordTimeRange {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stream_id: Option<StreamId>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rendition_id: Option<String>,
    pub start_unix_ms: u64,
    pub end_unix_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordContentHash {
    pub alg: String,
    pub digest: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uri: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRecordBody {
    pub record_type: String,
    pub subject: String,
    #[serde(default)]
    pub time_ranges: Vec<RecordTimeRange>,
    #[serde(default)]
    pub content_hashes: Vec<RecordContentHash>,
    pub source: String,
    pub visibility: String,
    pub created_unix_ms: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub valid_from_unix_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub valid_until_unix_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub supersedes: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub revokes: Vec<String>,
    #[serde(default)]
    pub metadata: Vec<StreamMetadata>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRecordSignature {
    pub signer_id: String,
    pub alg: String,
    pub signature: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRecord {
    pub body: SignedRecordBody,
    pub record_id: String,
    #[serde(default)]
    pub signatures: Vec<SignedRecordSignature>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub commitments: Vec<ChainCommitment>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecordTypePolicy {
    pub record_type: &'static str,
    pub nft_projection: &'static str,
    pub ui_recognition: &'static str,
    pub requires_time_range: bool,
    pub default_visibility: &'static str,
    pub content_address_threshold_bytes: usize,
    pub activitypub_object_type: Option<&'static str>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordBlobPlacement {
    InlineRecord,
    ContentAddressedBlob,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRecordProjection {
    pub record_type: String,
    pub subject: String,
    pub visibility: String,
    pub nft_projection: String,
    pub ui_recognition: String,
    pub default_visibility: String,
    pub uses_default_visibility: bool,
    pub requires_time_range: bool,
    pub time_range_count: usize,
    pub content_hash_count: usize,
    pub primary_content_uri: Option<String>,
    pub primary_ipfs_uri: Option<String>,
    pub metadata_inline_bytes: usize,
    pub metadata_blob_placement: RecordBlobPlacement,
    pub has_content_addressed_blob: bool,
    pub has_ipfs_reference: bool,
    pub activitypub_object_type: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedRecordActivityPubObject {
    pub id: String,
    #[serde(rename = "type")]
    pub object_type: String,
    pub attributed_to: String,
    pub audience: String,
    pub subject: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    pub published_unix_ms: u64,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub time_ranges: Vec<RecordTimeRange>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRecordIndexCount {
    pub name: String,
    pub count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRecordIndexEntry {
    pub source_index: usize,
    pub record_id: String,
    pub record_type: String,
    pub subject: String,
    pub source: String,
    pub visibility: String,
    pub created_unix_ms: u64,
    pub valid_from_unix_ms: Option<u64>,
    pub valid_until_unix_ms: Option<u64>,
    pub supersedes: Vec<String>,
    pub revokes: Vec<String>,
    pub signature_count: usize,
    pub commitment_count: usize,
    pub projection: SignedRecordProjection,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub activitypub_object: Option<SignedRecordActivityPubObject>,
    pub requires_content_addressed_blob: bool,
    pub needs_content_addressed_blob: bool,
    pub needs_ipfs_reference: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRecordIndexRejection {
    pub source_index: usize,
    pub record_id: Option<String>,
    pub record_type: Option<String>,
    pub subject: Option<String>,
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRecordIndexSummary {
    pub total_records: usize,
    pub accepted_records: usize,
    pub rejected_records: usize,
    pub public_records: usize,
    pub activitypub_records: usize,
    pub records_requiring_content_addressed_blob: usize,
    pub records_missing_required_content_addressed_blob: usize,
    pub records_missing_ipfs_reference: usize,
    pub by_record_type: Vec<SignedRecordIndexCount>,
    pub by_nft_projection: Vec<SignedRecordIndexCount>,
    pub by_ui_recognition: Vec<SignedRecordIndexCount>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRecordIndex {
    pub schema: String,
    pub generated_unix_ms: u64,
    pub summary: SignedRecordIndexSummary,
    pub entries: Vec<SignedRecordIndexEntry>,
    pub rejected: Vec<SignedRecordIndexRejection>,
}

impl SignedRecordBody {
    pub fn record_id(&self) -> Result<String, serde_json::Error> {
        self.record_id_blake3()
    }

    pub fn record_id_blake3(&self) -> Result<String, serde_json::Error> {
        let bytes = serde_json::to_vec(self)?;
        Ok(blake3::hash(&bytes).to_hex().to_string())
    }

    pub fn record_id_keccak256(&self) -> Result<String, serde_json::Error> {
        let bytes = serde_json::to_vec(self)?;
        Ok(hex::encode(keccak256(&bytes)))
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignedRecordValidationError {
    EmptyRecordType,
    UnknownRecordType(String),
    EmptySubject,
    EmptySource,
    UnknownVisibility(String),
    MissingTimeRange(String),
    InvalidTimeRange {
        start_unix_ms: u64,
        end_unix_ms: u64,
    },
    EmptyContentHashAlg,
    UnknownContentHashAlg(String),
    InvalidContentHashDigest {
        alg: String,
        digest: String,
    },
    InvalidContentHashUri {
        alg: String,
        uri: String,
    },
    InvalidValidityWindow {
        valid_from_unix_ms: u64,
        valid_until_unix_ms: u64,
    },
    RecordIdEncoding(String),
    InvalidRecordId {
        expected: String,
        actual: String,
    },
    MissingSignature,
    EmptySignerId,
    InvalidSignatureAlg(String),
    InvalidSignatureHex(String),
}

impl fmt::Display for SignedRecordValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SignedRecordValidationError::EmptyRecordType => write!(f, "record_type is empty"),
            SignedRecordValidationError::UnknownRecordType(value) => {
                write!(f, "unknown record_type: {value}")
            }
            SignedRecordValidationError::EmptySubject => write!(f, "subject is empty"),
            SignedRecordValidationError::EmptySource => write!(f, "source is empty"),
            SignedRecordValidationError::UnknownVisibility(value) => {
                write!(f, "unknown visibility: {value}")
            }
            SignedRecordValidationError::MissingTimeRange(record_type) => {
                write!(f, "{record_type} records require at least one time range")
            }
            SignedRecordValidationError::InvalidTimeRange {
                start_unix_ms,
                end_unix_ms,
            } => write!(
                f,
                "invalid time range: end_unix_ms {end_unix_ms} must be after start_unix_ms {start_unix_ms}"
            ),
            SignedRecordValidationError::EmptyContentHashAlg => {
                write!(f, "content hash algorithm is empty")
            }
            SignedRecordValidationError::UnknownContentHashAlg(value) => {
                write!(f, "unknown content hash algorithm: {value}")
            }
            SignedRecordValidationError::InvalidContentHashDigest { alg, digest } => {
                write!(f, "invalid {alg} content hash digest: {digest}")
            }
            SignedRecordValidationError::InvalidContentHashUri { alg, uri } => {
                write!(f, "invalid {alg} content hash uri: {uri}")
            }
            SignedRecordValidationError::InvalidValidityWindow {
                valid_from_unix_ms,
                valid_until_unix_ms,
            } => write!(
                f,
                "invalid validity window: valid_until_unix_ms {valid_until_unix_ms} must be after valid_from_unix_ms {valid_from_unix_ms}"
            ),
            SignedRecordValidationError::RecordIdEncoding(value) => {
                write!(f, "record ID encoding failed: {value}")
            }
            SignedRecordValidationError::InvalidRecordId { expected, actual } => {
                write!(f, "invalid record_id: expected {expected}, got {actual}")
            }
            SignedRecordValidationError::MissingSignature => {
                write!(f, "signed record has no signatures")
            }
            SignedRecordValidationError::EmptySignerId => write!(f, "signature signer_id is empty"),
            SignedRecordValidationError::InvalidSignatureAlg(value) => {
                write!(f, "unsupported signature algorithm: {value}")
            }
            SignedRecordValidationError::InvalidSignatureHex(value) => {
                write!(f, "invalid signature hex: {value}")
            }
        }
    }
}

impl std::error::Error for SignedRecordValidationError {}

pub fn is_known_record_type(value: &str) -> bool {
    matches!(
        value,
        RECORD_TYPE_COMMERCIAL_RANGE_SET
            | RECORD_TYPE_CLIP
            | RECORD_TYPE_CHANNEL_METADATA
            | RECORD_TYPE_FRIEND_COMMENT
            | RECORD_TYPE_OVERLAY
            | RECORD_TYPE_SOURCE_ATTESTATION
    )
}

pub fn is_known_record_visibility(value: &str) -> bool {
    matches!(
        value,
        RECORD_VISIBILITY_PRIVATE
            | RECORD_VISIBILITY_FRIENDS
            | RECORD_VISIBILITY_UNLISTED
            | RECORD_VISIBILITY_PUBLIC
    )
}

pub fn record_type_policy(record_type: &str) -> Option<RecordTypePolicy> {
    let requires_time_range = record_type_requires_time_range(record_type);
    let content_address_threshold_bytes = RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES;
    match record_type {
        RECORD_TYPE_COMMERCIAL_RANGE_SET => Some(RecordTypePolicy {
            record_type: RECORD_TYPE_COMMERCIAL_RANGE_SET,
            nft_projection: RECORD_NFT_PROJECTION_ERC1155,
            ui_recognition: RECORD_UI_RECOGNITION_COMMERCIAL_RANGES,
            requires_time_range,
            default_visibility: RECORD_VISIBILITY_PUBLIC,
            content_address_threshold_bytes,
            activitypub_object_type: None,
        }),
        RECORD_TYPE_CLIP => Some(RecordTypePolicy {
            record_type: RECORD_TYPE_CLIP,
            nft_projection: RECORD_NFT_PROJECTION_ERC721,
            ui_recognition: RECORD_UI_RECOGNITION_CLIP,
            requires_time_range,
            default_visibility: RECORD_VISIBILITY_UNLISTED,
            content_address_threshold_bytes,
            activitypub_object_type: Some("Video"),
        }),
        RECORD_TYPE_CHANNEL_METADATA => Some(RecordTypePolicy {
            record_type: RECORD_TYPE_CHANNEL_METADATA,
            nft_projection: RECORD_NFT_PROJECTION_ERC721,
            ui_recognition: RECORD_UI_RECOGNITION_CHANNEL_METADATA,
            requires_time_range,
            default_visibility: RECORD_VISIBILITY_PUBLIC,
            content_address_threshold_bytes,
            activitypub_object_type: None,
        }),
        RECORD_TYPE_FRIEND_COMMENT => Some(RecordTypePolicy {
            record_type: RECORD_TYPE_FRIEND_COMMENT,
            nft_projection: RECORD_NFT_PROJECTION_ERC721,
            ui_recognition: RECORD_UI_RECOGNITION_FRIEND_COMMENT,
            requires_time_range,
            default_visibility: RECORD_VISIBILITY_FRIENDS,
            content_address_threshold_bytes,
            activitypub_object_type: Some("Note"),
        }),
        RECORD_TYPE_OVERLAY => Some(RecordTypePolicy {
            record_type: RECORD_TYPE_OVERLAY,
            nft_projection: RECORD_NFT_PROJECTION_ERC1155,
            ui_recognition: RECORD_UI_RECOGNITION_OVERLAY,
            requires_time_range,
            default_visibility: RECORD_VISIBILITY_PUBLIC,
            content_address_threshold_bytes,
            activitypub_object_type: Some("Object"),
        }),
        RECORD_TYPE_SOURCE_ATTESTATION => Some(RecordTypePolicy {
            record_type: RECORD_TYPE_SOURCE_ATTESTATION,
            nft_projection: RECORD_NFT_PROJECTION_ERC721,
            ui_recognition: RECORD_UI_RECOGNITION_SOURCE_ATTESTATION,
            requires_time_range,
            default_visibility: RECORD_VISIBILITY_PUBLIC,
            content_address_threshold_bytes,
            activitypub_object_type: None,
        }),
        _ => None,
    }
}

pub fn record_payload_blob_placement(
    record_type: &str,
    payload_bytes: usize,
) -> Option<RecordBlobPlacement> {
    let policy = record_type_policy(record_type)?;
    if payload_bytes > policy.content_address_threshold_bytes {
        Some(RecordBlobPlacement::ContentAddressedBlob)
    } else {
        Some(RecordBlobPlacement::InlineRecord)
    }
}

pub fn signed_record_inline_metadata_bytes(body: &SignedRecordBody) -> usize {
    body.metadata
        .iter()
        .map(|item| item.key.len() + item.value.len())
        .sum()
}

pub fn signed_record_metadata_blob_placement(
    body: &SignedRecordBody,
) -> Option<RecordBlobPlacement> {
    record_payload_blob_placement(&body.record_type, signed_record_inline_metadata_bytes(body))
}

pub fn signed_record_has_content_addressed_blob(body: &SignedRecordBody) -> bool {
    !body.content_hashes.is_empty()
}

pub fn signed_record_has_ipfs_reference(body: &SignedRecordBody) -> bool {
    body.content_hashes.iter().any(|hash| {
        hash.alg == RECORD_CONTENT_HASH_IPFS_CID_V1
            || hash
                .uri
                .as_deref()
                .map(|uri| uri.trim_start().starts_with("ipfs://"))
                .unwrap_or(false)
    })
}

pub fn signed_record_primary_content_uri(body: &SignedRecordBody) -> Option<String> {
    body.content_hashes.iter().find_map(record_hash_content_uri)
}

pub fn signed_record_primary_ipfs_uri(body: &SignedRecordBody) -> Option<String> {
    body.content_hashes.iter().find_map(record_hash_ipfs_uri)
}

pub fn signed_record_projection(body: &SignedRecordBody) -> Option<SignedRecordProjection> {
    let policy = record_type_policy(&body.record_type)?;
    let metadata_inline_bytes = signed_record_inline_metadata_bytes(body);
    let metadata_blob_placement =
        record_payload_blob_placement(&body.record_type, metadata_inline_bytes)?;
    let primary_ipfs_uri = signed_record_primary_ipfs_uri(body);
    Some(SignedRecordProjection {
        record_type: policy.record_type.to_string(),
        subject: body.subject.clone(),
        visibility: body.visibility.clone(),
        nft_projection: policy.nft_projection.to_string(),
        ui_recognition: policy.ui_recognition.to_string(),
        default_visibility: policy.default_visibility.to_string(),
        uses_default_visibility: body.visibility == policy.default_visibility,
        requires_time_range: policy.requires_time_range,
        time_range_count: body.time_ranges.len(),
        content_hash_count: body.content_hashes.len(),
        primary_content_uri: signed_record_primary_content_uri(body),
        primary_ipfs_uri: primary_ipfs_uri.clone(),
        metadata_inline_bytes,
        metadata_blob_placement,
        has_content_addressed_blob: signed_record_has_content_addressed_blob(body)
            || metadata_blob_placement == RecordBlobPlacement::ContentAddressedBlob,
        has_ipfs_reference: primary_ipfs_uri.is_some() || signed_record_has_ipfs_reference(body),
        activitypub_object_type: policy.activitypub_object_type.map(str::to_string),
    })
}

fn signed_record_metadata_value<'a>(body: &'a SignedRecordBody, keys: &[&str]) -> Option<&'a str> {
    body.metadata.iter().find_map(|item| {
        let key = item.key.trim();
        if keys
            .iter()
            .any(|candidate| key.eq_ignore_ascii_case(candidate))
        {
            let value = item.value.trim();
            if !value.is_empty() {
                return Some(value);
            }
        }
        None
    })
}

fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String {
    if value.len() <= max_bytes {
        return value.to_string();
    }
    let mut end = max_bytes;
    while end > 0 && !value.is_char_boundary(end) {
        end -= 1;
    }
    value[..end].to_string()
}

pub fn signed_record_activitypub_object(
    record: &SignedRecord,
) -> Option<SignedRecordActivityPubObject> {
    let projection = signed_record_projection(&record.body)?;
    let object_type = projection.activitypub_object_type.clone()?;
    let content = signed_record_metadata_value(&record.body, &["text", "comment", "content"])
        .map(|value| truncate_utf8_bytes(value, RECORD_ACTIVITYPUB_INLINE_CONTENT_MAX_BYTES));
    let name = signed_record_metadata_value(&record.body, &["title", "name", "label"])
        .map(|value| truncate_utf8_bytes(value, 256));

    Some(SignedRecordActivityPubObject {
        id: format!("urn:every-channel:signed-record:{}", record.record_id),
        object_type,
        attributed_to: record.body.source.clone(),
        audience: record.body.visibility.clone(),
        subject: record.body.subject.clone(),
        name,
        content,
        url: projection.primary_content_uri,
        published_unix_ms: record.body.created_unix_ms,
        time_ranges: record.body.time_ranges.clone(),
    })
}

fn count_rows(counts: BTreeMap<String, usize>) -> Vec<SignedRecordIndexCount> {
    counts
        .into_iter()
        .map(|(name, count)| SignedRecordIndexCount { name, count })
        .collect()
}

fn signed_record_index_entry(
    source_index: usize,
    record: &SignedRecord,
    projection: SignedRecordProjection,
) -> SignedRecordIndexEntry {
    let requires_content_addressed_blob =
        projection.metadata_blob_placement == RecordBlobPlacement::ContentAddressedBlob;
    let has_explicit_content_addressed_blob =
        signed_record_has_content_addressed_blob(&record.body);
    let has_ipfs_reference = projection.has_ipfs_reference;
    SignedRecordIndexEntry {
        source_index,
        record_id: record.record_id.clone(),
        record_type: record.body.record_type.clone(),
        subject: record.body.subject.clone(),
        source: record.body.source.clone(),
        visibility: record.body.visibility.clone(),
        created_unix_ms: record.body.created_unix_ms,
        valid_from_unix_ms: record.body.valid_from_unix_ms,
        valid_until_unix_ms: record.body.valid_until_unix_ms,
        supersedes: record.body.supersedes.clone(),
        revokes: record.body.revokes.clone(),
        signature_count: record.signatures.len(),
        commitment_count: record.commitments.len(),
        activitypub_object: signed_record_activitypub_object(record),
        projection,
        requires_content_addressed_blob,
        needs_content_addressed_blob: requires_content_addressed_blob
            && !has_explicit_content_addressed_blob,
        needs_ipfs_reference: has_explicit_content_addressed_blob && !has_ipfs_reference,
    }
}

fn signed_record_index_rejection(
    source_index: usize,
    record: &SignedRecord,
    reason: impl Into<String>,
) -> SignedRecordIndexRejection {
    let record_id = (!record.record_id.trim().is_empty()).then(|| record.record_id.clone());
    let record_type =
        (!record.body.record_type.trim().is_empty()).then(|| record.body.record_type.clone());
    let subject = (!record.body.subject.trim().is_empty()).then(|| record.body.subject.clone());
    SignedRecordIndexRejection {
        source_index,
        record_id,
        record_type,
        subject,
        reason: reason.into(),
    }
}

pub fn signed_record_index(records: &[SignedRecord], generated_unix_ms: u64) -> SignedRecordIndex {
    let mut entries = Vec::new();
    let mut rejected = Vec::new();

    for (source_index, record) in records.iter().enumerate() {
        if let Err(err) = validate_signed_record(record) {
            rejected.push(signed_record_index_rejection(
                source_index,
                record,
                err.to_string(),
            ));
            continue;
        }
        let Some(projection) = signed_record_projection(&record.body) else {
            rejected.push(signed_record_index_rejection(
                source_index,
                record,
                "record does not project",
            ));
            continue;
        };
        entries.push(signed_record_index_entry(source_index, record, projection));
    }

    entries.sort_by(|a, b| {
        a.created_unix_ms
            .cmp(&b.created_unix_ms)
            .then_with(|| a.record_id.cmp(&b.record_id))
            .then_with(|| a.source_index.cmp(&b.source_index))
    });

    let mut by_record_type = BTreeMap::<String, usize>::new();
    let mut by_nft_projection = BTreeMap::<String, usize>::new();
    let mut by_ui_recognition = BTreeMap::<String, usize>::new();
    let mut public_records = 0;
    let mut activitypub_records = 0;
    let mut records_requiring_content_addressed_blob = 0;
    let mut records_missing_required_content_addressed_blob = 0;
    let mut records_missing_ipfs_reference = 0;

    for entry in &entries {
        *by_record_type.entry(entry.record_type.clone()).or_default() += 1;
        *by_nft_projection
            .entry(entry.projection.nft_projection.clone())
            .or_default() += 1;
        *by_ui_recognition
            .entry(entry.projection.ui_recognition.clone())
            .or_default() += 1;
        if entry.visibility == RECORD_VISIBILITY_PUBLIC {
            public_records += 1;
        }
        if entry.projection.activitypub_object_type.is_some() {
            activitypub_records += 1;
        }
        if entry.requires_content_addressed_blob {
            records_requiring_content_addressed_blob += 1;
        }
        if entry.needs_content_addressed_blob {
            records_missing_required_content_addressed_blob += 1;
        }
        if entry.needs_ipfs_reference {
            records_missing_ipfs_reference += 1;
        }
    }

    SignedRecordIndex {
        schema: "https://every.channel/schemas/signed-record-index/v1".to_string(),
        generated_unix_ms,
        summary: SignedRecordIndexSummary {
            total_records: records.len(),
            accepted_records: entries.len(),
            rejected_records: rejected.len(),
            public_records,
            activitypub_records,
            records_requiring_content_addressed_blob,
            records_missing_required_content_addressed_blob,
            records_missing_ipfs_reference,
            by_record_type: count_rows(by_record_type),
            by_nft_projection: count_rows(by_nft_projection),
            by_ui_recognition: count_rows(by_ui_recognition),
        },
        entries,
        rejected,
    }
}

fn record_hash_content_uri(hash: &RecordContentHash) -> Option<String> {
    hash.uri
        .as_deref()
        .map(str::trim)
        .filter(|uri| !uri.is_empty())
        .map(str::to_string)
        .or_else(|| record_hash_ipfs_uri(hash))
}

fn record_hash_ipfs_uri(hash: &RecordContentHash) -> Option<String> {
    if let Some(uri) = hash
        .uri
        .as_deref()
        .map(str::trim)
        .filter(|uri| uri.starts_with("ipfs://"))
    {
        return Some(uri.to_string());
    }
    if hash.alg == RECORD_CONTENT_HASH_IPFS_CID_V1 {
        let digest = hash.digest.trim();
        if !digest.is_empty() {
            return Some(format!("ipfs://{digest}"));
        }
    }
    None
}

pub fn validate_record_content_hash(
    hash: &RecordContentHash,
) -> Result<(), SignedRecordValidationError> {
    let alg = hash.alg.as_str();
    if alg.trim().is_empty() {
        return Err(SignedRecordValidationError::EmptyContentHashAlg);
    }
    match alg {
        RECORD_CONTENT_HASH_BLAKE3 | RECORD_CONTENT_HASH_SHA256 => {
            if !is_hex_bytes(&hash.digest, 32) {
                return Err(SignedRecordValidationError::InvalidContentHashDigest {
                    alg: alg.to_string(),
                    digest: hash.digest.clone(),
                });
            }
        }
        RECORD_CONTENT_HASH_IPFS_CID_V1 => {
            let digest = hash.digest.trim();
            if digest.is_empty()
                || digest.chars().any(char::is_whitespace)
                || !(digest.starts_with('b') || digest.starts_with('z'))
            {
                return Err(SignedRecordValidationError::InvalidContentHashDigest {
                    alg: alg.to_string(),
                    digest: hash.digest.clone(),
                });
            }
            if let Some(uri) = &hash.uri {
                let trimmed = uri.trim();
                if trimmed.is_empty() || !trimmed.starts_with("ipfs://") {
                    return Err(SignedRecordValidationError::InvalidContentHashUri {
                        alg: alg.to_string(),
                        uri: uri.clone(),
                    });
                }
            }
        }
        _ => {
            return Err(SignedRecordValidationError::UnknownContentHashAlg(
                alg.to_string(),
            ))
        }
    }
    Ok(())
}

pub fn validate_signed_record_body(
    body: &SignedRecordBody,
) -> Result<(), SignedRecordValidationError> {
    let record_type = body.record_type.as_str();
    if record_type.trim().is_empty() {
        return Err(SignedRecordValidationError::EmptyRecordType);
    }
    if !is_known_record_type(record_type) {
        return Err(SignedRecordValidationError::UnknownRecordType(
            body.record_type.clone(),
        ));
    }
    if body.subject.trim().is_empty() {
        return Err(SignedRecordValidationError::EmptySubject);
    }
    if body.source.trim().is_empty() {
        return Err(SignedRecordValidationError::EmptySource);
    }
    let visibility = body.visibility.as_str();
    if !is_known_record_visibility(visibility) {
        return Err(SignedRecordValidationError::UnknownVisibility(
            body.visibility.clone(),
        ));
    }
    if record_type_requires_time_range(record_type) && body.time_ranges.is_empty() {
        return Err(SignedRecordValidationError::MissingTimeRange(
            record_type.to_string(),
        ));
    }
    for range in &body.time_ranges {
        if range.end_unix_ms <= range.start_unix_ms {
            return Err(SignedRecordValidationError::InvalidTimeRange {
                start_unix_ms: range.start_unix_ms,
                end_unix_ms: range.end_unix_ms,
            });
        }
    }
    for hash in &body.content_hashes {
        validate_record_content_hash(hash)?;
    }
    if let (Some(valid_from_unix_ms), Some(valid_until_unix_ms)) =
        (body.valid_from_unix_ms, body.valid_until_unix_ms)
    {
        if valid_until_unix_ms <= valid_from_unix_ms {
            return Err(SignedRecordValidationError::InvalidValidityWindow {
                valid_from_unix_ms,
                valid_until_unix_ms,
            });
        }
    }
    Ok(())
}

pub fn validate_signed_record(record: &SignedRecord) -> Result<(), SignedRecordValidationError> {
    validate_signed_record_body(&record.body)?;
    let expected = record
        .body
        .record_id()
        .map_err(|err| SignedRecordValidationError::RecordIdEncoding(err.to_string()))?;
    if record.record_id != expected {
        return Err(SignedRecordValidationError::InvalidRecordId {
            expected,
            actual: record.record_id.clone(),
        });
    }
    if record.signatures.is_empty() {
        return Err(SignedRecordValidationError::MissingSignature);
    }
    for signature in &record.signatures {
        if signature.signer_id.trim().is_empty() {
            return Err(SignedRecordValidationError::EmptySignerId);
        }
        if signature.alg != RECORD_SIG_ALG_SECP256K1_EIP712_BODY_V1 {
            return Err(SignedRecordValidationError::InvalidSignatureAlg(
                signature.alg.clone(),
            ));
        }
        if !is_hex_bytes(&signature.signature, 65) {
            return Err(SignedRecordValidationError::InvalidSignatureHex(
                signature.signature.clone(),
            ));
        }
    }
    Ok(())
}

fn record_type_requires_time_range(record_type: &str) -> bool {
    matches!(
        record_type,
        RECORD_TYPE_COMMERCIAL_RANGE_SET
            | RECORD_TYPE_CLIP
            | RECORD_TYPE_FRIEND_COMMENT
            | RECORD_TYPE_OVERLAY
    )
}

fn is_hex_bytes(value: &str, expected_bytes: usize) -> bool {
    let trimmed = value.trim();
    let hex_value = trimmed.strip_prefix("0x").unwrap_or(trimmed);
    hex_value.len() == expected_bytes * 2
        && hex_value
            .as_bytes()
            .iter()
            .all(|byte| byte.is_ascii_hexdigit())
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamDescriptor {
    pub id: StreamId,
    pub title: String,
    pub number: Option<String>,
    pub source: String,
    pub metadata: Vec<StreamMetadata>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub commitments: Vec<ChainCommitment>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamMetadata {
    pub key: String,
    pub value: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BroadcastId {
    pub standard: String,
    pub transport_stream_id: Option<u16>,
    pub program_number: Option<u16>,
    pub virtual_channel: Option<String>,
    pub callsign: Option<String>,
    pub region: Option<String>,
    pub frequency: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceId {
    pub kind: String,
    pub device_id: Option<String>,
    pub channel: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamKey {
    pub version: u16,
    pub broadcast: Option<BroadcastId>,
    pub source: Option<SourceId>,
    pub profile: Option<String>,
    pub variant: Option<String>,
}

impl StreamKey {
    pub fn for_channel_or_source(
        channel: Option<&Channel>,
        standard: Option<&str>,
        source: SourceId,
        profile: Option<String>,
        variant: Option<String>,
    ) -> Self {
        let broadcast = channel
            .and_then(|channel| standard.and_then(|standard| channel.broadcast_id(standard)));
        let source = if broadcast.is_some() {
            None
        } else {
            Some(source)
        };

        Self {
            version: 1,
            broadcast,
            source,
            profile,
            variant,
        }
    }

    pub fn to_stream_id(&self) -> StreamId {
        let mut parts = vec![
            "ec".to_string(),
            "stream".to_string(),
            format!("v{}", self.version),
        ];

        if let Some(broadcast) = &self.broadcast {
            parts.push("broadcast".to_string());
            parts.push(sanitize(&broadcast.standard));
            if let Some(tsid) = broadcast.transport_stream_id {
                parts.push(format!("tsid-{tsid}"));
            }
            if let Some(program) = broadcast.program_number {
                parts.push(format!("program-{program}"));
            }
            if let Some(channel) = &broadcast.virtual_channel {
                parts.push(format!("channel-{}", sanitize(channel)));
            }
            if let Some(callsign) = &broadcast.callsign {
                parts.push(format!("callsign-{}", sanitize(callsign)));
            }
            if let Some(region) = &broadcast.region {
                parts.push(format!("region-{}", sanitize(region)));
            }
            if let Some(freq) = &broadcast.frequency {
                parts.push(format!("freq-{}", sanitize(freq)));
            }
        } else if let Some(source) = &self.source {
            parts.push("source".to_string());
            parts.push(sanitize(&source.kind));
            if let Some(device) = &source.device_id {
                parts.push(format!("device-{}", sanitize(device)));
            }
            if let Some(channel) = &source.channel {
                parts.push(format!("channel-{}", sanitize(channel)));
            }
        } else {
            parts.push("unknown".to_string());
        }

        if let Some(profile) = &self.profile {
            parts.push(format!("profile-{}", sanitize(profile)));
        }
        if let Some(variant) = &self.variant {
            parts.push(format!("variant-{}", sanitize(variant)));
        }

        StreamId(parts.join("/"))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
    pub id: ChannelId,
    pub name: String,
    pub number: Option<String>,
    pub program_id: Option<u16>,
    pub metadata: Vec<ChannelMetadata>,
}

fn sanitize(value: &str) -> String {
    value
        .chars()
        .map(|c| match c {
            'a'..='z' | '0'..='9' | '-' | '_' => c,
            'A'..='Z' => c.to_ascii_lowercase(),
            _ => '_',
        })
        .collect()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChannelMetadata {
    Callsign(String),
    Network(String),
    Region(String),
    Frequency(String),
    Extra(String, String),
}

impl BroadcastId {
    pub fn is_usable(&self) -> bool {
        self.transport_stream_id.is_some()
            || self.program_number.is_some()
            || self
                .virtual_channel
                .as_ref()
                .is_some_and(|value| !value.trim().is_empty())
            || self
                .callsign
                .as_ref()
                .is_some_and(|value| !value.trim().is_empty())
    }
}

impl Channel {
    pub fn broadcast_id(&self, standard: &str) -> Option<BroadcastId> {
        let standard = standard.trim().to_ascii_lowercase();
        if standard.is_empty() {
            return None;
        }

        let callsign = channel_metadata_value(&self.metadata, "callsign").or_else(|| {
            let name = self.name.trim();
            (!name.is_empty()).then(|| name.to_string())
        });
        let region = channel_metadata_value(&self.metadata, "region");
        let frequency = channel_metadata_value(&self.metadata, "frequency");
        let transport_stream_id = channel_metadata_u16(&self.metadata, "transport_stream_id")
            .or_else(|| channel_metadata_u16(&self.metadata, "tsid"));
        let program_number = self
            .program_id
            .or_else(|| channel_metadata_u16(&self.metadata, "program_number"))
            .or_else(|| channel_metadata_u16(&self.metadata, "program_id"));
        let virtual_channel = self.number.as_ref().and_then(|value| {
            let trimmed = value.trim();
            (!trimmed.is_empty()).then(|| trimmed.to_string())
        });

        let broadcast = BroadcastId {
            standard,
            transport_stream_id,
            program_number,
            virtual_channel,
            callsign,
            region,
            frequency,
        };

        broadcast.is_usable().then_some(broadcast)
    }
}

fn channel_metadata_value(metadata: &[ChannelMetadata], key: &str) -> Option<String> {
    for item in metadata {
        match item {
            ChannelMetadata::Callsign(value) if key == "callsign" => {
                return Some(value.trim().to_string())
            }
            ChannelMetadata::Region(value) if key == "region" => {
                return Some(value.trim().to_string())
            }
            ChannelMetadata::Frequency(value) if key == "frequency" => {
                return Some(value.trim().to_string())
            }
            ChannelMetadata::Network(value) if key == "network" => {
                return Some(value.trim().to_string())
            }
            ChannelMetadata::Extra(extra_key, value) if extra_key.eq_ignore_ascii_case(key) => {
                let trimmed = value.trim_matches('"').trim().to_string();
                if !trimmed.is_empty() {
                    return Some(trimmed);
                }
            }
            _ => {}
        }
    }
    None
}

fn channel_metadata_u16(metadata: &[ChannelMetadata], key: &str) -> Option<u16> {
    channel_metadata_value(metadata, key).and_then(|value| value.parse().ok())
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PacketDigest {
    pub algorithm: String,
    pub hex: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeterminismProfile {
    pub name: String,
    pub description: String,
    pub encoder: String,
    pub encoder_args: Vec<String>,
    pub chunk_duration_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeDescriptor {
    pub node_id: String,
    pub human_name: String,
    pub location_hint: Option<String>,
    pub capabilities: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamEncryptionInfo {
    pub alg: String,
    pub key_id: String,
    pub nonce_scheme: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoqStreamDescriptor {
    pub endpoint: String,
    pub broadcast_name: String,
    pub track_name: String,
    pub encryption: Option<StreamEncryptionInfo>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamCatalogEntry {
    pub stream: StreamDescriptor,
    pub moq: Option<MoqStreamDescriptor>,
    pub manifest: Option<ManifestSummary>,
    pub updated_unix_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamCatalog {
    pub entries: Vec<StreamCatalogEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StreamTransportDescriptor {
    /// Stream is available via a MoQ relay over WebTransport/WebSocket.
    RelayMoq {
        url: String,
        broadcast_name: String,
        track_name: String,
    },
    /// Stream is available via iroh direct MoQ.
    IrohDirect {
        endpoint: String,
        broadcast_name: String,
        track_name: String,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamControlAnnouncement {
    pub stream: StreamDescriptor,
    pub transports: Vec<StreamTransportDescriptor>,
    pub updated_unix_ms: u64,
    /// Suggested freshness window for this announcement.
    pub ttl_ms: u64,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub commitments: Vec<ChainCommitment>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestSummary {
    pub manifest_id: String,
    pub merkle_root: String,
    pub epoch_id: String,
    pub total_chunks: u64,
    pub chunk_start_index: u64,
    pub encoder_profile_id: String,
    pub signed_by: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub commitments: Vec<ChainCommitment>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkId {
    pub stream_id: StreamId,
    pub epoch_id: String,
    pub chunk_index: u64,
    pub chunk_hash: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestVariant {
    pub variant_id: String,
    pub stream_id: StreamId,
    pub chunk_start_index: u64,
    pub total_chunks: u64,
    pub merkle_root: String,
    pub chunk_hashes: Vec<String>,
    #[serde(default)]
    pub metadata: Vec<StreamMetadata>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestBody {
    pub stream_id: StreamId,
    pub epoch_id: String,
    pub chunk_duration_ms: u64,
    pub total_chunks: u64,
    pub chunk_start_index: u64,
    pub encoder_profile_id: String,
    pub merkle_root: String,
    pub created_unix_ms: u64,
    pub metadata: Vec<StreamMetadata>,
    pub chunk_hashes: Vec<String>,
    #[serde(default)]
    pub variants: Option<Vec<ManifestVariant>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestSignature {
    pub signer_id: String,
    pub alg: String,
    pub signature: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
    pub body: ManifestBody,
    pub manifest_id: String,
    pub signatures: Vec<ManifestSignature>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub commitments: Vec<ChainCommitment>,
}

impl Manifest {
    pub fn summary(&self) -> ManifestSummary {
        ManifestSummary {
            manifest_id: self.manifest_id.clone(),
            merkle_root: self.body.merkle_root.clone(),
            epoch_id: self.body.epoch_id.clone(),
            total_chunks: self.body.total_chunks,
            chunk_start_index: self.body.chunk_start_index,
            encoder_profile_id: self.body.encoder_profile_id.clone(),
            signed_by: self
                .signatures
                .iter()
                .map(|sig| sig.signer_id.clone())
                .collect(),
            commitments: self.commitments.clone(),
        }
    }
}

#[derive(Debug, Clone)]
pub enum ManifestError {
    Empty,
    InvalidHash(String),
}

impl fmt::Display for ManifestError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ManifestError::Empty => write!(f, "no chunk hashes supplied"),
            ManifestError::InvalidHash(value) => write!(f, "invalid chunk hash: {value}"),
        }
    }
}

impl std::error::Error for ManifestError {}

impl ManifestBody {
    pub fn manifest_id(&self) -> Result<String, serde_json::Error> {
        self.manifest_id_blake3()
    }

    pub fn manifest_id_blake3(&self) -> Result<String, serde_json::Error> {
        let bytes = serde_json::to_vec(self)?;
        Ok(blake3::hash(&bytes).to_hex().to_string())
    }

    pub fn manifest_id_keccak256(&self) -> Result<String, serde_json::Error> {
        let bytes = serde_json::to_vec(self)?;
        Ok(hex::encode(keccak256(&bytes)))
    }
}

fn parse_hash32(value: &str) -> Result<[u8; 32], ManifestError> {
    let trimmed = value.trim().strip_prefix("0x").unwrap_or(value.trim());
    let bytes = hex::decode(trimmed).map_err(|_| ManifestError::InvalidHash(value.to_string()))?;
    if bytes.len() != 32 {
        return Err(ManifestError::InvalidHash(value.to_string()));
    }
    let mut out = [0u8; 32];
    out.copy_from_slice(&bytes);
    Ok(out)
}

fn keccak256(bytes: &[u8]) -> [u8; 32] {
    let mut hasher = Keccak256::new();
    hasher.update(bytes);
    let digest = hasher.finalize();
    let mut out = [0u8; 32];
    out.copy_from_slice(&digest);
    out
}

fn blake3_pair_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
    let mut hasher = blake3::Hasher::new();
    hasher.update(left);
    hasher.update(right);
    *hasher.finalize().as_bytes()
}

fn keccak_pair_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
    let mut merged = [0u8; 64];
    merged[..32].copy_from_slice(left);
    merged[32..].copy_from_slice(right);
    keccak256(&merged)
}

fn merkle_root_from_hashes_with(
    hashes: &[String],
    pair_hash: fn(&[u8; 32], &[u8; 32]) -> [u8; 32],
) -> Result<String, ManifestError> {
    if hashes.is_empty() {
        return Err(ManifestError::Empty);
    }
    let mut nodes: Vec<[u8; 32]> = Vec::with_capacity(hashes.len());
    for hash in hashes {
        nodes.push(parse_hash32(hash)?);
    }
    while nodes.len() > 1 {
        if nodes.len() % 2 == 1 {
            if let Some(last) = nodes.last().copied() {
                nodes.push(last);
            }
        }
        let mut parents = Vec::with_capacity(nodes.len() / 2);
        for pair in nodes.chunks(2) {
            parents.push(pair_hash(&pair[0], &pair[1]));
        }
        nodes = parents;
    }
    Ok(hex::encode(nodes[0]))
}

pub fn merkle_root_from_hashes(hashes: &[String]) -> Result<String, ManifestError> {
    blake3_merkle_root_from_hashes(hashes)
}

pub fn blake3_merkle_root_from_hashes(hashes: &[String]) -> Result<String, ManifestError> {
    merkle_root_from_hashes_with(hashes, blake3_pair_hash)
}

pub fn keccak_merkle_root_from_hashes(hashes: &[String]) -> Result<String, ManifestError> {
    merkle_root_from_hashes_with(hashes, keccak_pair_hash)
}

fn merkle_proof_for_index_with(
    hashes: &[String],
    index: usize,
    pair_hash: fn(&[u8; 32], &[u8; 32]) -> [u8; 32],
) -> Result<Vec<String>, ManifestError> {
    if hashes.is_empty() {
        return Err(ManifestError::Empty);
    }
    if index >= hashes.len() {
        return Err(ManifestError::InvalidHash(format!(
            "index {index} out of bounds"
        )));
    }

    let mut nodes: Vec<[u8; 32]> = Vec::with_capacity(hashes.len());
    for hash in hashes {
        nodes.push(parse_hash32(hash)?);
    }

    let mut proof = Vec::new();
    let mut pos = index;
    while nodes.len() > 1 {
        if nodes.len() % 2 == 1 {
            if let Some(last) = nodes.last().copied() {
                nodes.push(last);
            }
        }

        let sibling_index = if pos % 2 == 0 { pos + 1 } else { pos - 1 };
        let sibling = nodes
            .get(sibling_index)
            .ok_or_else(|| ManifestError::InvalidHash("missing sibling".to_string()))?;
        proof.push(hex::encode(sibling));

        let mut parents = Vec::with_capacity(nodes.len() / 2);
        for pair in nodes.chunks(2) {
            parents.push(pair_hash(&pair[0], &pair[1]));
        }
        nodes = parents;
        pos /= 2;
    }

    Ok(proof)
}

pub fn merkle_proof_for_index(
    hashes: &[String],
    index: usize,
) -> Result<Vec<String>, ManifestError> {
    blake3_merkle_proof_for_index(hashes, index)
}

pub fn blake3_merkle_proof_for_index(
    hashes: &[String],
    index: usize,
) -> Result<Vec<String>, ManifestError> {
    merkle_proof_for_index_with(hashes, index, blake3_pair_hash)
}

pub fn keccak_merkle_proof_for_index(
    hashes: &[String],
    index: usize,
) -> Result<Vec<String>, ManifestError> {
    merkle_proof_for_index_with(hashes, index, keccak_pair_hash)
}

fn verify_merkle_proof_with(
    leaf_hash: &str,
    mut index: usize,
    branch: &[String],
    expected_root: &str,
    pair_hash: fn(&[u8; 32], &[u8; 32]) -> [u8; 32],
) -> bool {
    let Ok(mut acc) = parse_hash32(leaf_hash) else {
        return false;
    };
    for sibling_hex in branch {
        let Ok(sibling) = parse_hash32(sibling_hex) else {
            return false;
        };
        let (left, right) = if index % 2 == 0 {
            (acc, sibling)
        } else {
            (sibling, acc)
        };
        acc = pair_hash(&left, &right);
        index /= 2;
    }
    match parse_hash32(expected_root) {
        Ok(root) => acc == root,
        Err(_) => false,
    }
}

pub fn verify_merkle_proof(
    leaf_hash: &str,
    index: usize,
    branch: &[String],
    expected_root: &str,
) -> bool {
    verify_blake3_merkle_proof(leaf_hash, index, branch, expected_root)
}

pub fn verify_blake3_merkle_proof(
    leaf_hash: &str,
    index: usize,
    branch: &[String],
    expected_root: &str,
) -> bool {
    verify_merkle_proof_with(leaf_hash, index, branch, expected_root, blake3_pair_hash)
}

pub fn verify_keccak_merkle_proof(
    leaf_hash: &str,
    index: usize,
    branch: &[String],
    expected_root: &str,
) -> bool {
    verify_merkle_proof_with(leaf_hash, index, branch, expected_root, keccak_pair_hash)
}

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

    #[test]
    fn manifest_id_changes_with_body() {
        let body = ManifestBody {
            stream_id: StreamId("s".to_string()),
            epoch_id: "e".to_string(),
            chunk_duration_ms: 2000,
            total_chunks: 1,
            chunk_start_index: 0,
            encoder_profile_id: "p".to_string(),
            merkle_root: "00".repeat(32),
            created_unix_ms: 1,
            metadata: Vec::new(),
            chunk_hashes: vec!["11".repeat(32)],
            variants: None,
        };
        let id1 = body.manifest_id().unwrap();
        let mut body2 = body.clone();
        body2.created_unix_ms = 2;
        let id2 = body2.manifest_id().unwrap();
        assert_ne!(id1, id2);
    }

    #[test]
    fn manifest_id_defaults_to_blake3() {
        let body = ManifestBody {
            stream_id: StreamId("s".to_string()),
            epoch_id: "e".to_string(),
            chunk_duration_ms: 2000,
            total_chunks: 1,
            chunk_start_index: 0,
            encoder_profile_id: "p".to_string(),
            merkle_root: "00".repeat(32),
            created_unix_ms: 1,
            metadata: Vec::new(),
            chunk_hashes: vec!["11".repeat(32)],
            variants: None,
        };
        let bytes = serde_json::to_vec(&body).unwrap();
        assert_eq!(
            body.manifest_id().unwrap(),
            blake3::hash(&bytes).to_hex().to_string()
        );
        assert_ne!(
            body.manifest_id().unwrap(),
            body.manifest_id_keccak256().unwrap()
        );
    }

    #[test]
    fn signed_record_id_defaults_to_blake3_and_changes_with_body() {
        let body = SignedRecordBody {
            record_type: "friend_comment".to_string(),
            subject: "channel:la-kcop".to_string(),
            time_ranges: vec![RecordTimeRange {
                stream_id: Some(StreamId("la-kcop".to_string())),
                rendition_id: Some("720p".to_string()),
                start_unix_ms: 1_771_000_000_000,
                end_unix_ms: 1_771_000_010_000,
            }],
            content_hashes: vec![RecordContentHash {
                alg: "ipfs-cid-v1".to_string(),
                digest: "bafybeigdyrzt".to_string(),
                uri: Some("ipfs://bafybeigdyrzt".to_string()),
            }],
            source: "did:key:z6MkFriend".to_string(),
            visibility: "friends".to_string(),
            created_unix_ms: 1_771_000_020_000,
            valid_from_unix_ms: None,
            valid_until_unix_ms: None,
            supersedes: Vec::new(),
            revokes: Vec::new(),
            metadata: vec![StreamMetadata {
                key: "text".to_string(),
                value: "funny moment".to_string(),
            }],
        };

        let id1 = body.record_id().unwrap();
        assert_eq!(id1, body.record_id_blake3().unwrap());
        assert_ne!(id1, body.record_id_keccak256().unwrap());

        let mut changed = body.clone();
        changed.visibility = "public".to_string();
        assert_ne!(id1, changed.record_id().unwrap());
    }

    fn sample_signed_record_body(record_type: &str) -> SignedRecordBody {
        SignedRecordBody {
            record_type: record_type.to_string(),
            subject: "channel:la-kcop".to_string(),
            time_ranges: vec![RecordTimeRange {
                stream_id: Some(StreamId("la-kcop".to_string())),
                rendition_id: Some("720p".to_string()),
                start_unix_ms: 1_771_000_000_000,
                end_unix_ms: 1_771_000_010_000,
            }],
            content_hashes: vec![RecordContentHash {
                alg: RECORD_CONTENT_HASH_BLAKE3.to_string(),
                digest: blake3::hash(b"clip").to_hex().to_string(),
                uri: Some("garage://every-channel-archive/la-kcop/clip.car".to_string()),
            }],
            source: "did:key:z6MkFriend".to_string(),
            visibility: RECORD_VISIBILITY_PUBLIC.to_string(),
            created_unix_ms: 1_771_000_020_000,
            valid_from_unix_ms: Some(1_771_000_000_000),
            valid_until_unix_ms: Some(1_771_100_000_000),
            supersedes: Vec::new(),
            revokes: Vec::new(),
            metadata: vec![StreamMetadata {
                key: "text".to_string(),
                value: "funny moment".to_string(),
            }],
        }
    }

    fn sample_signed_record(record_type: &str) -> SignedRecord {
        let body = sample_signed_record_body(record_type);
        let record_id = body.record_id().unwrap();
        SignedRecord {
            body,
            record_id,
            signatures: vec![SignedRecordSignature {
                signer_id: "did:key:z6MkFriend".to_string(),
                alg: RECORD_SIG_ALG_SECP256K1_EIP712_BODY_V1.to_string(),
                signature: format!("0x{}", "11".repeat(65)),
            }],
            commitments: Vec::new(),
        }
    }

    #[test]
    fn record_type_policy_maps_known_types_to_nft_and_ui_shapes() {
        let commercial = record_type_policy(RECORD_TYPE_COMMERCIAL_RANGE_SET).unwrap();
        assert_eq!(commercial.nft_projection, RECORD_NFT_PROJECTION_ERC1155);
        assert_eq!(
            commercial.ui_recognition,
            RECORD_UI_RECOGNITION_COMMERCIAL_RANGES
        );
        assert!(commercial.requires_time_range);
        assert_eq!(commercial.default_visibility, RECORD_VISIBILITY_PUBLIC);
        assert_eq!(commercial.activitypub_object_type, None);

        let clip = record_type_policy(RECORD_TYPE_CLIP).unwrap();
        assert_eq!(clip.nft_projection, RECORD_NFT_PROJECTION_ERC721);
        assert_eq!(clip.default_visibility, RECORD_VISIBILITY_UNLISTED);
        assert_eq!(clip.activitypub_object_type, Some("Video"));

        let metadata = record_type_policy(RECORD_TYPE_CHANNEL_METADATA).unwrap();
        assert_eq!(metadata.nft_projection, RECORD_NFT_PROJECTION_ERC721);
        assert!(!metadata.requires_time_range);
        assert_eq!(
            metadata.ui_recognition,
            RECORD_UI_RECOGNITION_CHANNEL_METADATA
        );

        let comment = record_type_policy(RECORD_TYPE_FRIEND_COMMENT).unwrap();
        assert_eq!(comment.default_visibility, RECORD_VISIBILITY_FRIENDS);
        assert_eq!(comment.activitypub_object_type, Some("Note"));

        let overlay = record_type_policy(RECORD_TYPE_OVERLAY).unwrap();
        assert_eq!(overlay.nft_projection, RECORD_NFT_PROJECTION_ERC1155);
        assert_eq!(overlay.activitypub_object_type, Some("Object"));

        let source = record_type_policy(RECORD_TYPE_SOURCE_ATTESTATION).unwrap();
        assert_eq!(
            source.ui_recognition,
            RECORD_UI_RECOGNITION_SOURCE_ATTESTATION
        );
        assert!(!source.requires_time_range);

        assert_eq!(record_type_policy("unknown"), None);
    }

    #[test]
    fn record_blob_policy_requires_content_address_for_large_payloads() {
        assert_eq!(
            record_payload_blob_placement(
                RECORD_TYPE_FRIEND_COMMENT,
                RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES
            ),
            Some(RecordBlobPlacement::InlineRecord)
        );
        assert_eq!(
            record_payload_blob_placement(
                RECORD_TYPE_FRIEND_COMMENT,
                RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES + 1
            ),
            Some(RecordBlobPlacement::ContentAddressedBlob)
        );
        assert_eq!(
            record_payload_blob_placement("unknown", RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES + 1),
            None
        );
    }

    #[test]
    fn signed_record_helpers_detect_ipfs_references_and_metadata_size() {
        let mut body = sample_signed_record_body(RECORD_TYPE_FRIEND_COMMENT);
        body.content_hashes.clear();
        body.metadata = vec![
            StreamMetadata {
                key: "text".to_string(),
                value: "funny moment".to_string(),
            },
            StreamMetadata {
                key: "author".to_string(),
                value: "friend".to_string(),
            },
        ];

        assert_eq!(
            signed_record_inline_metadata_bytes(&body),
            "text".len() + "funny moment".len() + "author".len() + "friend".len()
        );
        assert_eq!(
            signed_record_metadata_blob_placement(&body),
            Some(RecordBlobPlacement::InlineRecord)
        );
        assert!(!signed_record_has_content_addressed_blob(&body));
        assert!(!signed_record_has_ipfs_reference(&body));

        body.metadata = vec![StreamMetadata {
            key: "payload".to_string(),
            value: "x".repeat(RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES + 1),
        }];
        assert_eq!(
            signed_record_metadata_blob_placement(&body),
            Some(RecordBlobPlacement::ContentAddressedBlob)
        );

        body.content_hashes.push(RecordContentHash {
            alg: RECORD_CONTENT_HASH_IPFS_CID_V1.to_string(),
            digest: "bafybeigdyrzt".to_string(),
            uri: Some("ipfs://bafybeigdyrzt".to_string()),
        });
        assert!(signed_record_has_content_addressed_blob(&body));
        assert!(signed_record_has_ipfs_reference(&body));
    }

    #[test]
    fn signed_record_projection_summarizes_ui_nft_and_ipfs_refs() {
        let mut body = sample_signed_record_body(RECORD_TYPE_FRIEND_COMMENT);
        body.visibility = RECORD_VISIBILITY_FRIENDS.to_string();
        body.content_hashes = vec![RecordContentHash {
            alg: RECORD_CONTENT_HASH_IPFS_CID_V1.to_string(),
            digest: "bafybeigdyrzt".to_string(),
            uri: None,
        }];

        let projection = signed_record_projection(&body).unwrap();

        assert_eq!(projection.record_type, RECORD_TYPE_FRIEND_COMMENT);
        assert_eq!(projection.subject, "channel:la-kcop");
        assert_eq!(projection.visibility, RECORD_VISIBILITY_FRIENDS);
        assert_eq!(projection.nft_projection, RECORD_NFT_PROJECTION_ERC721);
        assert_eq!(
            projection.ui_recognition,
            RECORD_UI_RECOGNITION_FRIEND_COMMENT
        );
        assert_eq!(projection.default_visibility, RECORD_VISIBILITY_FRIENDS);
        assert!(projection.uses_default_visibility);
        assert!(projection.requires_time_range);
        assert_eq!(projection.time_range_count, 1);
        assert_eq!(projection.content_hash_count, 1);
        assert_eq!(
            projection.primary_content_uri.as_deref(),
            Some("ipfs://bafybeigdyrzt")
        );
        assert_eq!(
            projection.primary_ipfs_uri.as_deref(),
            Some("ipfs://bafybeigdyrzt")
        );
        assert_eq!(
            projection.metadata_blob_placement,
            RecordBlobPlacement::InlineRecord
        );
        assert!(projection.has_content_addressed_blob);
        assert!(projection.has_ipfs_reference);
        assert_eq!(projection.activitypub_object_type.as_deref(), Some("Note"));
    }

    #[test]
    fn signed_record_activitypub_object_projects_bounded_friend_comment() {
        let mut record = sample_signed_record(RECORD_TYPE_FRIEND_COMMENT);
        record.body.visibility = RECORD_VISIBILITY_FRIENDS.to_string();
        record.body.content_hashes = vec![RecordContentHash {
            alg: RECORD_CONTENT_HASH_IPFS_CID_V1.to_string(),
            digest: "bafybeigdyrzt".to_string(),
            uri: None,
        }];
        record.body.metadata = vec![
            StreamMetadata {
                key: "title".to_string(),
                value: "KCOP clip".to_string(),
            },
            StreamMetadata {
                key: "text".to_string(),
                value: "x".repeat(RECORD_ACTIVITYPUB_INLINE_CONTENT_MAX_BYTES + 16),
            },
        ];
        record.record_id = record.body.record_id().unwrap();

        let object = signed_record_activitypub_object(&record).unwrap();

        assert_eq!(
            object.id,
            format!("urn:every-channel:signed-record:{}", record.record_id)
        );
        assert_eq!(object.object_type, "Note");
        assert_eq!(object.attributed_to, "did:key:z6MkFriend");
        assert_eq!(object.audience, RECORD_VISIBILITY_FRIENDS);
        assert_eq!(object.subject, "channel:la-kcop");
        assert_eq!(object.name.as_deref(), Some("KCOP clip"));
        assert_eq!(
            object.content.as_ref().map(|value| value.len()),
            Some(RECORD_ACTIVITYPUB_INLINE_CONTENT_MAX_BYTES)
        );
        assert_eq!(object.url.as_deref(), Some("ipfs://bafybeigdyrzt"));
        assert_eq!(object.published_unix_ms, record.body.created_unix_ms);
        assert_eq!(object.time_ranges.len(), 1);

        let json = serde_json::to_value(&object).unwrap();
        assert_eq!(json["type"], "Note");
        assert_eq!(json["attributedTo"], "did:key:z6MkFriend");
    }

    #[test]
    fn signed_record_activitypub_object_skips_non_activitypub_records() {
        let record = sample_signed_record(RECORD_TYPE_CHANNEL_METADATA);
        assert_eq!(signed_record_activitypub_object(&record), None);
    }

    #[test]
    fn signed_record_projection_marks_large_metadata_for_blob_storage() {
        let mut body = sample_signed_record_body(RECORD_TYPE_CHANNEL_METADATA);
        body.content_hashes.clear();
        body.metadata = vec![StreamMetadata {
            key: "payload".to_string(),
            value: "x".repeat(RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES + 1),
        }];

        let projection = signed_record_projection(&body).unwrap();

        assert_eq!(projection.record_type, RECORD_TYPE_CHANNEL_METADATA);
        assert!(!projection.requires_time_range);
        assert_eq!(
            projection.metadata_blob_placement,
            RecordBlobPlacement::ContentAddressedBlob
        );
        assert!(projection.has_content_addressed_blob);
        assert!(!projection.has_ipfs_reference);
        assert_eq!(projection.primary_content_uri, None);
        assert_eq!(projection.primary_ipfs_uri, None);
    }

    #[test]
    fn signed_record_projection_rejects_unknown_record_types() {
        let body = sample_signed_record_body("unknown");
        assert_eq!(signed_record_projection(&body), None);
    }

    fn index_count<'a>(
        counts: &'a [SignedRecordIndexCount],
        name: &str,
    ) -> Option<&'a SignedRecordIndexCount> {
        counts.iter().find(|row| row.name == name)
    }

    #[test]
    fn signed_record_index_sorts_accepts_rejects_and_counts_policy_shapes() {
        let mut clip = sample_signed_record(RECORD_TYPE_CLIP);
        clip.body.created_unix_ms = 30;
        clip.record_id = clip.body.record_id().unwrap();

        let mut metadata = sample_signed_record(RECORD_TYPE_CHANNEL_METADATA);
        metadata.body.created_unix_ms = 10;
        metadata.body.time_ranges.clear();
        metadata.body.content_hashes.clear();
        metadata.record_id = metadata.body.record_id().unwrap();

        let mut comment = sample_signed_record(RECORD_TYPE_FRIEND_COMMENT);
        comment.body.created_unix_ms = 20;
        comment.body.visibility = RECORD_VISIBILITY_FRIENDS.to_string();
        comment.body.content_hashes = vec![RecordContentHash {
            alg: RECORD_CONTENT_HASH_IPFS_CID_V1.to_string(),
            digest: "bafybeigdyrzt".to_string(),
            uri: None,
        }];
        comment.record_id = comment.body.record_id().unwrap();

        let mut rejected = sample_signed_record(RECORD_TYPE_OVERLAY);
        rejected.body.time_ranges.clear();
        rejected.record_id = rejected.body.record_id().unwrap();

        let index = signed_record_index(&[clip, metadata, rejected, comment], 1234);

        assert_eq!(
            index.schema,
            "https://every.channel/schemas/signed-record-index/v1"
        );
        assert_eq!(index.generated_unix_ms, 1234);
        assert_eq!(index.summary.total_records, 4);
        assert_eq!(index.summary.accepted_records, 3);
        assert_eq!(index.summary.rejected_records, 1);
        assert_eq!(index.summary.public_records, 2);
        assert_eq!(index.summary.activitypub_records, 2);
        assert_eq!(
            index
                .entries
                .iter()
                .map(|entry| entry.source_index)
                .collect::<Vec<_>>(),
            vec![1, 3, 0]
        );
        assert_eq!(
            index.entries[1]
                .activitypub_object
                .as_ref()
                .map(|object| object.object_type.as_str()),
            Some("Note")
        );
        assert_eq!(
            index.entries[1]
                .activitypub_object
                .as_ref()
                .and_then(|object| object.url.as_deref()),
            Some("ipfs://bafybeigdyrzt")
        );
        assert_eq!(index.entries[0].activitypub_object, None);

        assert_eq!(
            index_count(&index.summary.by_record_type, RECORD_TYPE_CHANNEL_METADATA)
                .map(|row| row.count),
            Some(1)
        );
        assert_eq!(
            index_count(&index.summary.by_record_type, RECORD_TYPE_FRIEND_COMMENT)
                .map(|row| row.count),
            Some(1)
        );
        assert_eq!(
            index_count(
                &index.summary.by_nft_projection,
                RECORD_NFT_PROJECTION_ERC721
            )
            .map(|row| row.count),
            Some(3)
        );
        assert_eq!(
            index_count(
                &index.summary.by_ui_recognition,
                RECORD_UI_RECOGNITION_CHANNEL_METADATA
            )
            .map(|row| row.count),
            Some(1)
        );

        assert_eq!(index.rejected.len(), 1);
        assert_eq!(index.rejected[0].source_index, 2);
        assert_eq!(
            index.rejected[0].record_type.as_deref(),
            Some(RECORD_TYPE_OVERLAY)
        );
        assert!(index.rejected[0]
            .reason
            .contains("records require at least one time range"));
    }

    #[test]
    fn signed_record_index_flags_content_address_and_ipfs_followups() {
        let mut inline = sample_signed_record(RECORD_TYPE_CHANNEL_METADATA);
        inline.body.created_unix_ms = 10;
        inline.body.time_ranges.clear();
        inline.body.content_hashes.clear();
        inline.record_id = inline.body.record_id().unwrap();

        let mut large_metadata_without_blob = sample_signed_record(RECORD_TYPE_CHANNEL_METADATA);
        large_metadata_without_blob.body.created_unix_ms = 20;
        large_metadata_without_blob.body.time_ranges.clear();
        large_metadata_without_blob.body.content_hashes.clear();
        large_metadata_without_blob.body.metadata = vec![StreamMetadata {
            key: "payload".to_string(),
            value: "x".repeat(RECORD_CONTENT_ADDRESS_THRESHOLD_BYTES + 1),
        }];
        large_metadata_without_blob.record_id =
            large_metadata_without_blob.body.record_id().unwrap();

        let mut garage_blob_without_ipfs = sample_signed_record(RECORD_TYPE_CHANNEL_METADATA);
        garage_blob_without_ipfs.body.created_unix_ms = 30;
        garage_blob_without_ipfs.body.time_ranges.clear();
        garage_blob_without_ipfs.body.content_hashes = vec![RecordContentHash {
            alg: RECORD_CONTENT_HASH_BLAKE3.to_string(),
            digest: blake3::hash(b"metadata-car").to_hex().to_string(),
            uri: Some("garage://every-channel-records/channel-metadata.car".to_string()),
        }];
        garage_blob_without_ipfs.record_id = garage_blob_without_ipfs.body.record_id().unwrap();

        let index = signed_record_index(
            &[
                inline,
                large_metadata_without_blob,
                garage_blob_without_ipfs,
            ],
            5678,
        );

        assert_eq!(index.summary.accepted_records, 3);
        assert_eq!(index.summary.rejected_records, 0);
        assert_eq!(index.summary.records_requiring_content_addressed_blob, 1);
        assert_eq!(
            index
                .summary
                .records_missing_required_content_addressed_blob,
            1
        );
        assert_eq!(index.summary.records_missing_ipfs_reference, 1);

        let large = &index.entries[1];
        assert!(large.requires_content_addressed_blob);
        assert!(large.needs_content_addressed_blob);
        assert!(!large.needs_ipfs_reference);

        let garage = &index.entries[2];
        assert!(!garage.requires_content_addressed_blob);
        assert!(!garage.needs_content_addressed_blob);
        assert!(garage.needs_ipfs_reference);
    }

    #[test]
    fn signed_record_validation_accepts_public_clip_with_blob_hash() {
        let record = sample_signed_record(RECORD_TYPE_CLIP);
        validate_signed_record(&record).unwrap();
    }

    #[test]
    fn signed_record_validation_rejects_bad_time_ranges() {
        let mut body = sample_signed_record_body(RECORD_TYPE_COMMERCIAL_RANGE_SET);
        body.time_ranges[0].end_unix_ms = body.time_ranges[0].start_unix_ms;
        assert!(matches!(
            validate_signed_record_body(&body),
            Err(SignedRecordValidationError::InvalidTimeRange { .. })
        ));
    }

    #[test]
    fn signed_record_validation_rejects_mismatched_record_id() {
        let mut record = sample_signed_record(RECORD_TYPE_FRIEND_COMMENT);
        record.record_id = "00".repeat(32);
        assert!(matches!(
            validate_signed_record(&record),
            Err(SignedRecordValidationError::InvalidRecordId { .. })
        ));
    }

    #[test]
    fn signed_record_validation_rejects_unknown_hash_alg() {
        let mut body = sample_signed_record_body(RECORD_TYPE_CLIP);
        body.content_hashes[0].alg = "md5".to_string();
        assert!(matches!(
            validate_signed_record_body(&body),
            Err(SignedRecordValidationError::UnknownContentHashAlg(value))
                if value == "md5"
        ));
    }

    #[test]
    fn signed_record_validation_rejects_noncanonical_record_type() {
        let mut body = sample_signed_record_body(RECORD_TYPE_CLIP);
        body.record_type = format!(" {RECORD_TYPE_CLIP} ");
        assert!(matches!(
            validate_signed_record_body(&body),
            Err(SignedRecordValidationError::UnknownRecordType(value))
                if value == " clip "
        ));
    }

    #[test]
    fn signed_record_validation_rejects_missing_signature() {
        let mut record = sample_signed_record(RECORD_TYPE_CLIP);
        record.signatures.clear();
        assert!(matches!(
            validate_signed_record(&record),
            Err(SignedRecordValidationError::MissingSignature)
        ));
    }

    #[test]
    fn merkle_root_single_is_leaf() {
        let leaf = blake3::hash(b"leaf").to_hex().to_string();
        let root = merkle_root_from_hashes(&[leaf.clone()]).unwrap();
        assert_eq!(root, leaf);
        let keccak_root = keccak_merkle_root_from_hashes(&[leaf.clone()]).unwrap();
        assert_eq!(keccak_root, leaf);
    }

    #[test]
    fn merkle_root_rejects_invalid_hash() {
        let err = merkle_root_from_hashes(&["not-hex".to_string()]).unwrap_err();
        assert!(matches!(err, ManifestError::InvalidHash(_)));
    }

    #[test]
    fn merkle_proof_roundtrip_small_sets() {
        for size in 1..=9usize {
            let leaves = (0..size)
                .map(|i| blake3::hash(&[i as u8]).to_hex().to_string())
                .collect::<Vec<_>>();
            let root = merkle_root_from_hashes(&leaves).unwrap();
            for idx in 0..size {
                let proof = merkle_proof_for_index(&leaves, idx).unwrap();
                assert!(
                    verify_merkle_proof(&leaves[idx], idx, &proof, &root),
                    "size {size} idx {idx} failed"
                );
            }
        }
    }

    #[test]
    fn keccak_merkle_proof_roundtrip_small_sets() {
        for size in 1..=9usize {
            let leaves = (0..size)
                .map(|i| blake3::hash(&[i as u8]).to_hex().to_string())
                .collect::<Vec<_>>();
            let root = keccak_merkle_root_from_hashes(&leaves).unwrap();
            for idx in 0..size {
                let proof = keccak_merkle_proof_for_index(&leaves, idx).unwrap();
                assert!(
                    verify_keccak_merkle_proof(&leaves[idx], idx, &proof, &root),
                    "size {size} idx {idx} failed"
                );
            }
        }
    }

    #[test]
    fn merkle_proof_detects_tampering() {
        let leaves = (0..4usize)
            .map(|i| blake3::hash(&[i as u8]).to_hex().to_string())
            .collect::<Vec<_>>();
        let root = merkle_root_from_hashes(&leaves).unwrap();
        let mut proof = merkle_proof_for_index(&leaves, 2).unwrap();
        proof[0] = blake3::hash(b"evil").to_hex().to_string();
        assert!(!verify_merkle_proof(&leaves[2], 2, &proof, &root));
    }

    #[test]
    fn channel_broadcast_id_uses_typed_and_extra_metadata() {
        let channel = Channel {
            id: ChannelId("kcbs".to_string()),
            name: "KCBS-HD".to_string(),
            number: Some("2.1".to_string()),
            program_id: Some(3),
            metadata: vec![
                ChannelMetadata::Callsign("KCBS".to_string()),
                ChannelMetadata::Region("los-angeles".to_string()),
                ChannelMetadata::Extra("tsid".to_string(), "42".to_string()),
                ChannelMetadata::Extra("frequency".to_string(), "573000000".to_string()),
            ],
        };

        let broadcast = channel.broadcast_id("ATSC").unwrap();
        assert_eq!(broadcast.standard, "atsc");
        assert_eq!(broadcast.transport_stream_id, Some(42));
        assert_eq!(broadcast.program_number, Some(3));
        assert_eq!(broadcast.virtual_channel.as_deref(), Some("2.1"));
        assert_eq!(broadcast.callsign.as_deref(), Some("KCBS"));
        assert_eq!(broadcast.region.as_deref(), Some("los-angeles"));
        assert_eq!(broadcast.frequency.as_deref(), Some("573000000"));
    }

    #[test]
    fn stream_key_prefers_broadcast_scope_when_channel_identity_exists() {
        let channel = Channel {
            id: ChannelId("kcbs".to_string()),
            name: "KCBS-HD".to_string(),
            number: Some("2.1".to_string()),
            program_id: None,
            metadata: vec![ChannelMetadata::Callsign("KCBS".to_string())],
        };
        let source = SourceId {
            kind: "hdhr".to_string(),
            device_id: Some("ABCDEF01".to_string()),
            channel: Some("2.1".to_string()),
        };

        let key = StreamKey::for_channel_or_source(
            Some(&channel),
            Some("atsc"),
            source,
            Some("chunk-2000ms".to_string()),
            None,
        );

        assert!(key.broadcast.is_some());
        assert!(key.source.is_none());
        assert_eq!(
            key.to_stream_id().0,
            "ec/stream/v1/broadcast/atsc/channel-2_1/callsign-kcbs/profile-chunk-2000ms"
        );
    }

    #[test]
    fn stream_key_falls_back_to_source_scope_without_channel_identity() {
        let channel = Channel {
            id: ChannelId("unknown".to_string()),
            name: "".to_string(),
            number: None,
            program_id: None,
            metadata: Vec::new(),
        };
        let source = SourceId {
            kind: "ts".to_string(),
            device_id: None,
            channel: Some("file.ts".to_string()),
        };

        let key = StreamKey::for_channel_or_source(
            Some(&channel),
            Some("atsc"),
            source,
            Some("chunk-2000ms".to_string()),
            None,
        );

        assert!(key.broadcast.is_none());
        assert_eq!(
            key.to_stream_id().0,
            "ec/stream/v1/source/ts/channel-file_ts/profile-chunk-2000ms"
        );
    }
}
