//! Projection of canonical bundle documents to their normative text form.
//!
//! This module implements the `Bundle<S> -> TextDocument -> text` half of the
//! companion: [`document_from_bundle`] reads a bundle's manifest and its
//! reachable chunk/blob payloads into a [`TextDocument`], and the `project_*`
//! functions turn each grammar production of `spec/text_projection.tex`'s
//! Grammar chapter into its canonical [`Sexp`]. [`project_text_document`]
//! assembles a whole document's lines in the normative `projection` sequence,
//! and [`project_bundle`] composes both stages.
//!
//! # Grammar-directed, not value-directed
//!
//! `req:textproj:operation-vocabulary` established, for the operation layer,
//! that the grammar's productions govern rather than the mechanical
//! `TextValue` rule; the document layer is the same shape
//! (`CONTRACT_TEXTPROJ_DOCUMENT.md`). No document-line production contains a
//! `value` position, so every function here is a **free function**, never a
//! `TextValue` impl: `epiphany-bundle` does not depend on `epiphany-core`, and
//! implementing a foreign trait for a foreign type would be the orphan rule
//! violation the contract calls out.
//!
//! Envelope lines are the one exception: they are already a solved problem one
//! layer down. [`epiphany_ops::project_envelope`] projects an
//! [`OperationEnvelope`] line, and [`epiphany_ops::canonical_reduction_order`]
//! is the single function that orders the whole operation set. Both are used
//! here, not reimplemented.
//!
//! # What is deliberately absent
//!
//! Per `req:textproj:derive-or-carry`, nothing here carries a chunk's or
//! blob's `offset`, `compressed_length`, `compression`, or
//! `uncompressed_length`, nor a `ChunkId`, `ContentHash`, or `BlobId`: every
//! one is either a serializer's free physical choice or a value re-derived
//! from content. The non-canonical accelerators
//! (`operation_index_root`, `acceleration_snapshots`, `text_projection_root`,
//! `integrity_root`, `operation_block_summaries`) are never read here either.
//! A bundle that round-trips through text comes back without them; that is
//! correct; it is not data loss, because none of the five contributes to
//! canonical document semantics.

use std::collections::BTreeSet;

use epiphany_bundle::{
    BlobId, BlockStore, Bundle, BundleError, ChunkKind, DocumentId, LineageId, ProfileConstraints,
    ProfileDeclaration, ProfileId, RetentionPolicy, SchemaVersion, SemVer,
};
use epiphany_core::textvalue::Sexp;
use epiphany_ops::{
    canonical_reduction_order, decode_envelope, project_envelope, EnvelopeDecodeError,
    OperationEnvelope,
};

use crate::{
    TextBlob, TextCanonicalBase, TextChunk, TextDocument, TextExtension, COMPANION_VERSION,
};

// ===========================================================================
// Errors.
// ===========================================================================

/// A failure encountered projecting a bundle to its Text Projection text.
#[derive(Debug)]
pub enum ProjectError {
    /// Reading a chunk, an operation-envelope block, or a blob through the
    /// bundle failed.
    Bundle(BundleError),
    /// A stored operation-envelope block held bytes that do not decode to a
    /// canonical [`OperationEnvelope`].
    Envelope(EnvelopeDecodeError),
}

impl core::fmt::Display for ProjectError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            ProjectError::Bundle(e) => write!(f, "bundle read failed: {e}"),
            ProjectError::Envelope(e) => write!(f, "operation envelope failed to decode: {e}"),
        }
    }
}

impl std::error::Error for ProjectError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ProjectError::Bundle(e) => Some(e),
            ProjectError::Envelope(e) => Some(e),
        }
    }
}

impl From<BundleError> for ProjectError {
    fn from(e: BundleError) -> Self {
        ProjectError::Bundle(e)
    }
}

impl From<EnvelopeDecodeError> for ProjectError {
    fn from(e: EnvelopeDecodeError) -> Self {
        ProjectError::Envelope(e)
    }
}

// ===========================================================================
// Small shared helpers.
// ===========================================================================

/// The grammar's `bool` terminal: `true` or `false`, spelled as a symbol.
fn project_bool(value: bool) -> Sexp {
    Sexp::sym(if value { "true" } else { "false" })
}

/// The grammar's `option` terminal: `()` when absent, `(some <value>)` when
/// present, projecting the payload with `project`.
fn project_option<T>(value: Option<T>, project: impl FnOnce(T) -> Sexp) -> Sexp {
    match value {
        None => Sexp::none(),
        Some(v) => Sexp::some(project(v)),
    }
}

/// The grammar's shared `version` shape, `(<major> <minor> <patch>)`: used
/// verbatim by the header (the companion version) and, restated as a
/// [`SemVer`], by profile/extension declarations and the canonical base.
fn project_version_triple(major: u32, minor: u32, patch: u32) -> Sexp {
    Sexp::List(vec![Sexp::int(major), Sexp::int(minor), Sexp::int(patch)])
}

fn project_semver(version: &SemVer) -> Sexp {
    project_version_triple(version.major, version.minor, version.patch)
}

/// Orders and de-duplicates already-projected elements by the **UTF-8 bytes of
/// their rendered form**, ascending, keeping at most one per distinct
/// rendering (`req:textproj:derived-ordering`). Used for exactly the two
/// sequences the requirement names: the `(blob ...)` lines, and one
/// extension's preserved chunk lines. Every other projected sequence keeps
/// whatever order its caller already put it in (the manifest's binary order,
/// which for those sequences is itself a function only of preserved data).
///
/// This is deliberately a *projection-time* operation rather than a
/// construction-time one: it must produce the correct text even when handed
/// an out-of-order or duplicated slice, which is exactly what
/// `derived_ordering_sorts_and_dedups_blobs_and_chunks` below constructs and
/// checks.
fn ordered_by_projected_form<T>(items: &[T], project: impl Fn(&T) -> Sexp) -> Vec<Sexp> {
    let mut rendered: Vec<(String, Sexp)> = items
        .iter()
        .map(|item| {
            let sexp = project(item);
            let text = sexp.render();
            (text, sexp)
        })
        .collect();
    rendered.sort_by(|(a, _), (b, _)| a.cmp(b));
    rendered.dedup_by(|(a, _), (b, _)| a == b);
    rendered.into_iter().map(|(_, sexp)| sexp).collect()
}

// ===========================================================================
// header, document, lineage.
// ===========================================================================

/// `header ::= "(text-projection " version ")" LF`.
///
/// Always [`COMPANION_VERSION`]: `req:textproj:header-version` fixes the
/// header to name the one companion version this crate implements.
pub fn project_header() -> Sexp {
    let (major, minor, patch) = COMPANION_VERSION;
    Sexp::List(vec![
        Sexp::sym("text-projection"),
        project_version_triple(major, minor, patch),
    ])
}

/// `document ::= "(document " bytes " " schema ")" LF`.
///
/// The `schema` field is the carried manifest `SchemaVersion` (G-minor,
/// `spec/PLAN_GMINOR_SCHEMA_MINOR.md` §4, pins 8/11; companion 0.10.0).
pub fn project_document(document_id: &DocumentId, manifest_schema_version: &SchemaVersion) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("document"),
        Sexp::Bytes(document_id.as_bytes().to_vec()),
        project_schema(manifest_schema_version),
    ])
}

/// `lineage ::= "(lineage " bytes ")" LF`.
pub fn project_lineage(lineage_id: &LineageId) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("lineage"),
        Sexp::Bytes(lineage_id.as_bytes().to_vec()),
    ])
}

// ===========================================================================
// profile, profile-id, constraints, retention.
// ===========================================================================

/// `profile-id ::= "full" | "read-only" | "lite" | "(custom " bytes ")"`.
///
/// `Custom` is the one profile identity that is not a bare symbol: it carries
/// a 16-byte registry id (`req:textproj:profile-id`).
pub fn project_profile_id(profile_id: &ProfileId) -> Sexp {
    match profile_id {
        ProfileId::Full => Sexp::sym("full"),
        ProfileId::ReadOnly => Sexp::sym("read-only"),
        ProfileId::Lite => Sexp::sym("lite"),
        ProfileId::Custom(registry_id) => Sexp::List(vec![
            Sexp::sym("custom"),
            Sexp::Bytes(registry_id.as_bytes().to_vec()),
        ]),
    }
}

/// `retention ::= "(retention " integer " " option " " bool ")"`.
///
/// Fields, positionally: `retain_previous_manifests`, `retain_duration`,
/// `retain_named_checkpoints`. `WallClockDuration` is a newtype over `i64`, so
/// it projects transparently as the bare integer inside the option, per the
/// value-projection rule for newtypes.
pub fn project_retention(retention: &RetentionPolicy) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("retention"),
        Sexp::int(retention.retain_previous_manifests),
        project_option(retention.retain_duration, |duration| Sexp::int(duration.0)),
        project_bool(retention.retain_named_checkpoints),
    ])
}

/// `constraints ::= "(constraints " integer " " retention ")"`.
pub fn project_constraints(constraints: &ProfileConstraints) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("constraints"),
        Sexp::int(constraints.max_uncompressed_block_size),
        project_retention(&constraints.retention_policy),
    ])
}

/// `profile ::= "(profile " profile-id " " version " " constraints ")" LF`.
pub fn project_profile(profile: &ProfileDeclaration) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("profile"),
        project_profile_id(&profile.profile_id),
        project_semver(&profile.version),
        project_constraints(&profile.constraints),
    ])
}

// ===========================================================================
// chunk, chunk-kind, schema, extension.
// ===========================================================================

/// `chunk-kind ::= "operation-envelope-block" | "operation-index" | "snapshot"
/// | "blob" | "extension-data" | "text-projection" | "layout-cache"
/// | "integrity-index" | "manifest"`.
///
/// Exhaustive over [`ChunkKind`]'s nine variants: adding a tenth to the bundle
/// crate is a compile error here until this match is extended, rather than a
/// silently-unprojectable kind.
pub fn project_chunk_kind(kind: ChunkKind) -> Sexp {
    Sexp::sym(match kind {
        ChunkKind::OperationEnvelopeBlock => "operation-envelope-block",
        ChunkKind::OperationIndex => "operation-index",
        ChunkKind::Snapshot => "snapshot",
        ChunkKind::Blob => "blob",
        ChunkKind::ExtensionData => "extension-data",
        ChunkKind::TextProjection => "text-projection",
        ChunkKind::LayoutCache => "layout-cache",
        ChunkKind::IntegrityIndex => "integrity-index",
        ChunkKind::Manifest => "manifest",
    })
}

/// `schema ::= "(schema " integer " " integer ")"`.
pub fn project_schema(schema: &SchemaVersion) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("schema"),
        Sexp::int(schema.major),
        Sexp::int(schema.minor),
    ])
}

/// `chunk ::= "(chunk " chunk-kind " " schema " " bytes ")"`.
///
/// The bytes are the chunk's **uncompressed payload**, never a `ChunkRef`:
/// the projection has no file to point into
/// (`req:textproj:extension-declaration`).
pub fn project_chunk(chunk: &TextChunk) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("chunk"),
        project_chunk_kind(chunk.kind),
        project_schema(&chunk.schema_version),
        Sexp::Bytes(chunk.payload.clone()),
    ])
}

/// `extension ::= "(extension " bytes " " version " " bool " (" chunk* ") "
/// bytes " " bytes ")" LF`, fields in the ratified declaration order: id,
/// version, required, chunks, affected-kinds, barriers.
///
/// `affected_object_kinds` and `edit_barriers` are opaque byte strings, never
/// structured sequences: the bundle preserves them without interpreting them,
/// and the projection interprets nothing the bundle does not
/// (`req:textproj:extension-declaration`). The preserved-chunk list is ordered
/// and de-duplicated by projected form (`req:textproj:derived-ordering`),
/// because `ChunkRef`'s binary order breaks ties on the physical offset.
pub fn project_extension(extension: &TextExtension) -> Sexp {
    let chunks = ordered_by_projected_form(&extension.chunks, project_chunk);
    Sexp::List(vec![
        Sexp::sym("extension"),
        Sexp::Bytes(extension.extension_id.as_bytes().to_vec()),
        project_semver(&extension.version),
        project_bool(extension.required),
        Sexp::List(chunks),
        Sexp::Bytes(extension.affected_object_kinds.clone()),
        Sexp::Bytes(extension.edit_barriers.clone()),
    ])
}

// ===========================================================================
// canonical-base.
// ===========================================================================

/// `canonical-base ::= "(canonical-base " bytes " " bytes " " integer " "
/// profile-id " " schema " " bytes ")" LF`: snapshot id, frontier, reduction
/// version, profile, root schema, root payload.
///
/// The `SnapshotId` is the one identity carried verbatim rather than derived
/// (`req:textproj:derive-or-carry`); the root chunk's kind is `Snapshot` by
/// role and is not written, and its `ChunkId`/hash are re-derived from the
/// schema and payload carried here (`req:textproj:base-snapshot-inline`).
pub fn project_canonical_base(base: &TextCanonicalBase) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("canonical-base"),
        Sexp::Bytes(base.snapshot_id.as_bytes().to_vec()),
        Sexp::Bytes(base.covers_causal_frontier.as_bytes().to_vec()),
        Sexp::int(base.reduction_algorithm_version.0),
        project_profile_id(&base.profile_id),
        project_schema(&base.root_schema_version),
        Sexp::Bytes(base.root_payload.clone()),
    ])
}

// ===========================================================================
// blob.
// ===========================================================================

/// `blob ::= "(blob " string " " option " " bytes ")" LF`: media type,
/// declared maximum uncompressed length, payload.
///
/// The `BlobId`, content hash, offset, lengths, and compression are all
/// re-derived or freely chosen by a serializer, never carried
/// (`req:textproj:derive-or-carry`, `req:textproj:canonical-blobs`).
pub fn project_blob(blob: &TextBlob) -> Sexp {
    Sexp::List(vec![
        Sexp::sym("blob"),
        Sexp::Str(blob.media_type.clone()),
        project_option(blob.declared_max_uncompressed_length, Sexp::int),
        Sexp::Bytes(blob.payload.clone()),
    ])
}

// ===========================================================================
// Canonical-blob reachability (`req:textproj:canonical-blobs`).
// ===========================================================================

/// The `BlobId`s canonically reachable from `envelopes`: referenced by a
/// canonical operation, or by canonical reduced state
/// (`req:textproj:canonical-blobs`; core specification Chapter 8
/// §"Canonical and Non-Canonical Manifest Roots"). A blob referenced only by
/// an acceleration structure is **not** in this set and MUSTNOT be projected.
///
/// This is a real predicate over the decoded canonical operations, not an
/// assertion of emptiness: it is written so that the day an operation payload
/// or a Chapter-5 value gains a field that names a blob, extending the walk
/// below is the one and only change needed to make that blob projectable.
///
/// That day has not arrived. Every [`OperationPayload`](epiphany_ops::OperationPayload)
/// variant and every [`OperationKind`](epiphany_ops::OperationKind) variant is
/// exhaustively enumerated by `epiphany-ops`, and canonical reduced state
/// (`epiphany_ops::MaterializedState`) is built exclusively from those same
/// payloads — and the token `BlobId` does not occur anywhere in
/// `epiphany-core` or `epiphany-ops` (verified by
/// `blobid_is_absent_from_core_and_ops_source`, below, which fails the moment
/// it does). So there is no field, on any canonical value or payload this
/// crate can name, to extract a blob reference from — and this function's
/// result is provably the empty set today.
fn canonically_reachable_blob_ids(_envelopes: &[OperationEnvelope]) -> BTreeSet<BlobId> {
    // Nothing to walk: see the doc comment above and the trip-wire test.
    BTreeSet::new()
}

/// The canonical blobs of a document, read through the bundle: the
/// [`TextBlob`]s of exactly the `BlobRef`s in `bundle.manifest().blob_roots`
/// whose id is canonically reachable from `envelopes`.
///
/// **Never** `bundle.manifest().blob_roots` wholesale — that is the plausible
/// wrong answer named by the contract: one line, looks right, and emits every
/// non-canonical blob the manifest happens to carry, in direct violation of
/// `req:textproj:canonical-blobs`. Filtering through
/// [`canonically_reachable_blob_ids`] is what keeps this correct even though,
/// today, that filter is always the empty set — so this function always
/// returns `Ok(Vec::new())` for a real bundle, and does so *because* nothing
/// resolves as reachable, not because the manifest's field is skipped by
/// construction.
fn canonical_blobs<S: BlockStore>(
    bundle: &Bundle<S>,
    envelopes: &[OperationEnvelope],
) -> Result<Vec<TextBlob>, ProjectError> {
    let reachable = canonically_reachable_blob_ids(envelopes);
    let mut blobs = Vec::new();
    for blob_ref in &bundle.manifest().blob_roots {
        if reachable.contains(&blob_ref.blob_id) {
            let payload = bundle.read_blob(blob_ref)?;
            blobs.push(TextBlob {
                media_type: blob_ref.media_type.clone(),
                declared_max_uncompressed_length: blob_ref.declared_max_uncompressed_length,
                payload,
            });
        }
    }
    Ok(blobs)
}

// ===========================================================================
// Bundle<S> -> TextDocument.
// ===========================================================================

/// Builds a [`TextDocument`] from a bundle's current manifest, reading every
/// chunk and blob payload the projection carries inline: each extension's
/// preserved chunks, the canonical base's root chunk, and (today, always
/// none) canonically reachable blobs.
///
/// Operation envelopes are decoded from **every** operation-envelope block the
/// manifest references (`manifest.operation_roots`; block boundaries are a
/// storage artifact, not semantic structure) via
/// [`epiphany_ops::decode_envelope`], then put into
/// [`epiphany_ops::canonical_reduction_order`] — computed once over the whole
/// gathered set, because the order is causal and cannot be decided per block.
pub fn document_from_bundle<S: BlockStore>(
    bundle: &Bundle<S>,
) -> Result<TextDocument, ProjectError> {
    let manifest = bundle.manifest();

    let mut decoded = Vec::new();
    for root in &manifest.operation_roots {
        for raw in bundle.read_operation_block(root)? {
            decoded.push(decode_envelope(&raw)?);
        }
    }
    let envelopes: Vec<OperationEnvelope> = {
        let refs: Vec<&OperationEnvelope> = decoded.iter().collect();
        canonical_reduction_order(&refs)
            .into_iter()
            .cloned()
            .collect()
    };

    let mut extensions = Vec::with_capacity(manifest.extension_declarations.len());
    for extension in &manifest.extension_declarations {
        let mut chunks = Vec::with_capacity(extension.preserved_chunk_roots.len());
        for chunk_ref in &extension.preserved_chunk_roots {
            let payload = bundle.read_chunk(chunk_ref)?;
            chunks.push(TextChunk {
                kind: chunk_ref.kind,
                schema_version: chunk_ref.schema_version,
                payload,
            });
        }
        extensions.push(TextExtension {
            extension_id: extension.extension_id,
            version: extension.version,
            required: extension.required,
            chunks,
            affected_object_kinds: extension.affected_object_kinds.clone(),
            edit_barriers: extension.edit_barriers.clone(),
        });
    }

    let canonical_base = match &manifest.canonical_base {
        Some(base) => {
            let root_payload = bundle.read_chunk(&base.root)?;
            Some(TextCanonicalBase {
                snapshot_id: base.snapshot_id,
                covers_causal_frontier: base.covers_causal_frontier.clone(),
                reduction_algorithm_version: base.reduction_algorithm_version,
                profile_id: base.profile_id,
                root_schema_version: base.root.schema_version,
                root_payload,
            })
        }
        None => None,
    };

    let blobs = canonical_blobs(bundle, &decoded)?;

    Ok(TextDocument {
        document_id: manifest.document_id,
        // Carried from the superblock, never derived (pin 8/11): `Manifest`
        // itself has no schema-version field (it lives in the superblock).
        manifest_schema_version: bundle.superblock().manifest_schema_version,
        lineage_id: manifest.lineage_id,
        // `Manifest::decode`'s own re-encode check (see `epiphany-bundle`)
        // guarantees a bundle's `profile_declarations` are already the
        // canonical (sorted, deduplicated) sequence; `canonical_profiles`
        // names that guarantee rather than leaning on it silently.
        profiles: manifest.canonical_profiles(),
        extensions,
        canonical_base,
        blobs,
        envelopes,
    })
}

// ===========================================================================
// TextDocument -> text.
// ===========================================================================

/// Projects a whole [`TextDocument`] to its canonical text: the header, then
/// every present section in the normative `projection` sequence (header,
/// document, lineage?, profile*, extension*, canonical-base?, blob*,
/// envelope*), one line per element, each terminated by a single LF
/// (`req:textproj:envelope-per-line`). Section order is normative and is
/// simply the order this function writes in; it introduces no ordering of its
/// own beyond `req:textproj:derived-ordering`'s blob sort.
pub fn project_text_document(document: &TextDocument) -> String {
    let mut lines: Vec<Sexp> = Vec::new();
    lines.push(project_header());
    lines.push(project_document(
        &document.document_id,
        &document.manifest_schema_version,
    ));
    if let Some(lineage_id) = &document.lineage_id {
        lines.push(project_lineage(lineage_id));
    }
    lines.extend(document.profiles.iter().map(project_profile));
    lines.extend(document.extensions.iter().map(project_extension));
    if let Some(base) = &document.canonical_base {
        lines.push(project_canonical_base(base));
    }
    lines.extend(ordered_by_projected_form(&document.blobs, project_blob));

    let mut out = String::new();
    for line in &lines {
        out.push_str(&line.render());
        out.push('\n');
    }
    for envelope in &document.envelopes {
        out.push_str(&project_envelope(envelope));
        out.push('\n');
    }
    out
}

/// Composes both stages: reads `bundle` into a [`TextDocument`], then projects
/// it to its canonical text.
pub fn project_bundle<S: BlockStore>(bundle: &Bundle<S>) -> Result<String, ProjectError> {
    Ok(project_text_document(&document_from_bundle(bundle)?))
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::{Path, PathBuf};

    use epiphany_bundle::{
        encode_block, BlobRef, CompressionAlgorithm, ExtensionDeclaration, ExtensionId, FileUuid,
        FrontierBytes, Manifest, MemStore, ProfileRegistryId, ReductionAlgorithmVersion,
        SnapshotId, SnapshotRef, StagedChunk,
    };
    use epiphany_core::textvalue::read_sexp;
    use epiphany_core::{OperationId, RegionId, ReplicaId, WallClockTime};
    use epiphany_determinism::CanonicalEncode;
    use epiphany_ops::{
        AuthorId, CausalContext, DeleteRegionOp, HybridLogicalClock, OperationKind,
        OperationPayload, OperationStamp,
    };

    use super::*;

    // -----------------------------------------------------------------
    // Pinned against the companion's worked example (Chapter "A Worked
    // Example"). These are the strongest checks in this file: literal
    // strings copied from the specification itself, not values this test
    // module invented.
    // -----------------------------------------------------------------

    #[test]
    fn header_matches_the_worked_example() {
        let (major, minor, patch) = crate::COMPANION_VERSION;
        assert_eq!(
            project_header().render(),
            format!("(text-projection ({major} {minor} {patch}))")
        );
    }

    #[test]
    fn document_id_matches_the_worked_example() {
        let id = DocumentId([0x05; 16]);
        assert_eq!(
            project_document(&id, &SchemaVersion::V0).render(),
            "(document #x05050505050505050505050505050505 (schema 0 1))"
        );
    }

    #[test]
    fn profile_matches_the_worked_example() {
        assert_eq!(
            project_profile(&ProfileDeclaration::full()).render(),
            "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))"
        );
    }

    #[test]
    fn canonical_base_matches_the_worked_example() {
        let mut snapshot_id_bytes = [0u8; 16];
        snapshot_id_bytes[0] = 0x1f;
        snapshot_id_bytes[1] = 0x8b;
        let base = TextCanonicalBase {
            snapshot_id: SnapshotId(snapshot_id_bytes),
            covers_causal_frontier: FrontierBytes::empty(),
            reduction_algorithm_version: ReductionAlgorithmVersion(1),
            profile_id: ProfileId::Full,
            root_schema_version: SchemaVersion::V0,
            root_payload: vec![0x00, 0x00],
        };
        assert_eq!(
            project_canonical_base(&base).render(),
            "(canonical-base #x1f8b0000000000000000000000000000 #x 1 full (schema 0 1) #x0000)"
        );
    }

    // -----------------------------------------------------------------
    // profile-id: the closed vocabulary, plus the one non-symbol case.
    // -----------------------------------------------------------------

    #[test]
    fn profile_id_projects_every_closed_vocabulary_symbol() {
        assert_eq!(project_profile_id(&ProfileId::Full).render(), "full");
        assert_eq!(
            project_profile_id(&ProfileId::ReadOnly).render(),
            "read-only"
        );
        assert_eq!(project_profile_id(&ProfileId::Lite).render(), "lite");
    }

    #[test]
    fn profile_id_custom_carries_its_sixteen_byte_registry_id() {
        let id = ProfileId::Custom(ProfileRegistryId([0xAB; 16]));
        assert_eq!(
            project_profile_id(&id).render(),
            "(custom #xabababababababababababababababab)"
        );
    }

    // -----------------------------------------------------------------
    // chunk-kind: exhaustive over all nine variants.
    // -----------------------------------------------------------------

    #[test]
    fn chunk_kind_projects_every_variant_to_its_grammar_symbol() {
        let expected = [
            (
                ChunkKind::OperationEnvelopeBlock,
                "operation-envelope-block",
            ),
            (ChunkKind::OperationIndex, "operation-index"),
            (ChunkKind::Snapshot, "snapshot"),
            (ChunkKind::Blob, "blob"),
            (ChunkKind::ExtensionData, "extension-data"),
            (ChunkKind::TextProjection, "text-projection"),
            (ChunkKind::LayoutCache, "layout-cache"),
            (ChunkKind::IntegrityIndex, "integrity-index"),
            (ChunkKind::Manifest, "manifest"),
        ];
        for (kind, symbol) in expected {
            assert_eq!(project_chunk_kind(kind).render(), symbol);
        }
    }

    #[test]
    fn schema_projects_major_then_minor() {
        assert_eq!(
            project_schema(&SchemaVersion::new(2, 9)).render(),
            "(schema 2 9)"
        );
    }

    // -----------------------------------------------------------------
    // extension: six fields in the ratified order, opaque byte strings for
    // affected-kinds/barriers (the `Vec<u8>`-is-not-a-sequence trap).
    // -----------------------------------------------------------------

    #[test]
    fn extension_projects_six_fields_in_declaration_order() {
        let extension = TextExtension {
            extension_id: ExtensionId([9; 16]),
            version: SemVer::new(1, 2, 3),
            required: true,
            chunks: vec![TextChunk {
                kind: ChunkKind::ExtensionData,
                schema_version: SchemaVersion::V0,
                payload: vec![0xAA],
            }],
            affected_object_kinds: vec![0x01, 0x02],
            edit_barriers: vec![0x03],
        };
        assert_eq!(
            project_extension(&extension).render(),
            "(extension #x09090909090909090909090909090909 (1 2 3) true \
             ((chunk extension-data (schema 0 1) #xaa)) #x0102 #x03)"
        );
    }

    #[test]
    fn extension_affected_kinds_and_barriers_are_byte_strings_not_sequences() {
        let extension = TextExtension {
            extension_id: ExtensionId([0; 16]),
            version: SemVer::new(0, 0, 0),
            required: false,
            chunks: Vec::new(),
            affected_object_kinds: vec![1, 2, 3],
            edit_barriers: vec![4, 5],
        };
        let Sexp::List(fields) = project_extension(&extension) else {
            panic!("an extension projection is a list");
        };
        // Positions: [0]=`extension`, [1]=id, [2]=version, [3]=required,
        // [4]=chunks, [5]=affected-kinds, [6]=barriers.
        assert_eq!(fields[5], Sexp::Bytes(vec![1, 2, 3]));
        assert_eq!(fields[6], Sexp::Bytes(vec![4, 5]));
        // Never the `Vec<u8>`-is-a-sequence spelling (a list of integers).
        assert_ne!(
            fields[5],
            Sexp::List(vec![Sexp::int(1), Sexp::int(2), Sexp::int(3)])
        );
    }

    // -----------------------------------------------------------------
    // blob: line-level project, tested against synthetic data (per the
    // contract: the emit side must be ready even though no real bundle can
    // populate it today).
    // -----------------------------------------------------------------

    #[test]
    fn blob_projects_media_type_declared_max_and_payload() {
        let blob = TextBlob {
            media_type: "audio/wav".to_owned(),
            declared_max_uncompressed_length: Some(1 << 20),
            payload: vec![0xDE, 0xAD],
        };
        assert_eq!(
            project_blob(&blob).render(),
            "(blob \"audio/wav\" (some 1048576) #xdead)"
        );
    }

    #[test]
    fn blob_with_no_declared_maximum_projects_an_absent_option() {
        let blob = TextBlob {
            media_type: "application/octet-stream".to_owned(),
            declared_max_uncompressed_length: None,
            payload: Vec::new(),
        };
        assert_eq!(
            project_blob(&blob).render(),
            "(blob \"application/octet-stream\" () #x)"
        );
    }

    /// The `(blob ...)` production round-trips through the s-expression
    /// reader (the lexical layer parse.rs will build its semantic parser on).
    /// This is not a substitute for parse.rs's own `parse_blob` and the
    /// parse-side rejection the contract requires of it; it proves the emit
    /// side is well-formed and ready.
    #[test]
    fn blob_projection_reads_back_through_the_sexp_reader() {
        let blob = TextBlob {
            media_type: "text/plain".to_owned(),
            declared_max_uncompressed_length: Some(4),
            payload: vec![1, 2, 3, 4],
        };
        let rendered = project_blob(&blob).render();
        let read_back = read_sexp(&rendered).expect("projected blob line is valid s-expression");
        assert_eq!(read_back, project_blob(&blob), "reading back is idempotent");
    }

    // -----------------------------------------------------------------
    // `req:textproj:derived-ordering`: sort AND de-duplicate by rendered
    // form, exercised against out-of-order, duplicated, in-memory data --
    // never against a fixture that was already sorted.
    // -----------------------------------------------------------------

    #[test]
    fn derived_ordering_sorts_and_dedups_blob_lines() {
        let a = TextBlob {
            media_type: "a/a".to_owned(),
            declared_max_uncompressed_length: None,
            payload: vec![1],
        };
        let b = TextBlob {
            media_type: "b/b".to_owned(),
            declared_max_uncompressed_length: None,
            payload: vec![2],
        };
        let a_duplicate = TextBlob {
            media_type: "a/a".to_owned(),
            declared_max_uncompressed_length: None,
            payload: vec![1],
        };
        // Deliberately out of order (b before a) and with a duplicate of a.
        let blobs = vec![b, a_duplicate, a];
        let ordered = ordered_by_projected_form(&blobs, project_blob);
        let rendered: Vec<String> = ordered.iter().map(Sexp::render).collect();
        assert_eq!(
            rendered,
            vec![
                "(blob \"a/a\" () #x01)".to_owned(),
                "(blob \"b/b\" () #x02)".to_owned(),
            ],
            "duplicate collapsed to one line, and the surviving two lines ordered ascending \
             by rendered form"
        );
    }

    #[test]
    fn derived_ordering_sorts_and_dedups_an_extensions_chunk_lines() {
        let x = TextChunk {
            kind: ChunkKind::ExtensionData,
            schema_version: SchemaVersion::V0,
            payload: vec![0xAA],
        };
        let y = TextChunk {
            kind: ChunkKind::LayoutCache,
            schema_version: SchemaVersion::V0,
            payload: vec![0xBB],
        };
        let x_duplicate = TextChunk {
            kind: ChunkKind::ExtensionData,
            schema_version: SchemaVersion::V0,
            payload: vec![0xAA],
        };
        let extension = TextExtension {
            extension_id: ExtensionId([1; 16]),
            version: SemVer::new(1, 0, 0),
            required: false,
            // Deliberately out of order (y before x) and with a duplicate of x.
            chunks: vec![y, x_duplicate, x],
            affected_object_kinds: Vec::new(),
            edit_barriers: Vec::new(),
        };
        let Sexp::List(fields) = project_extension(&extension) else {
            panic!("an extension projection is a list");
        };
        let Sexp::List(chunks) = &fields[4] else {
            panic!("the chunk field is a list");
        };
        assert_eq!(
            chunks.len(),
            2,
            "the duplicate chunk collapsed to a single line"
        );
        assert_eq!(
            chunks[0].render(),
            "(chunk extension-data (schema 0 1) #xaa)"
        );
        assert_eq!(chunks[1].render(), "(chunk layout-cache (schema 0 1) #xbb)");
    }

    // -----------------------------------------------------------------
    // The blob trap: a bundle whose manifest carries a real, non-empty
    // `blob_roots` must still project zero `(blob ...)` lines, because no
    // canonical operation or reduced state can reach a `BlobId` today.
    // -----------------------------------------------------------------

    /// Builds a bundle exercising every optional section at once: a lineage,
    /// one extension with one preserved chunk, a canonical base, one
    /// (deliberately unreachable) blob root, and two operation-envelope
    /// blocks staged so their envelopes are **not** already in canonical
    /// reduction order (physical time descending), across **two** separate
    /// blocks so `document_from_bundle` is proven to gather across all of
    /// `operation_roots`, not just the first.
    fn build_sample_bundle() -> Bundle<MemStore> {
        let env_late = sample_envelope(1, 200); // higher physical time
        let env_early = sample_envelope(2, 100); // lower physical time

        // Block 0 holds the later envelope, block 1 the earlier one: storage
        // order disagrees with canonical reduction order on purpose.
        let op_block_0 =
            StagedChunk::operation_block(encode_block(&[env_late.to_canonical_bytes()]));
        let op_block_1 =
            StagedChunk::operation_block(encode_block(&[env_early.to_canonical_bytes()]));

        let extension_chunk = StagedChunk {
            kind: ChunkKind::ExtensionData,
            schema_version: SchemaVersion::V0,
            payload: b"extension-chunk-payload".to_vec(),
        };

        let base_chunk = StagedChunk {
            kind: ChunkKind::Snapshot,
            schema_version: SchemaVersion::V0,
            payload: b"canonical-base-payload".to_vec(),
        };

        let blob_chunk = StagedChunk {
            kind: ChunkKind::Blob,
            schema_version: SchemaVersion::V0,
            payload: b"unreachable-blob-payload".to_vec(),
        };

        let mut initial = Manifest::empty(DocumentId([3; 16]));
        initial.lineage_id = Some(LineageId([4; 16]));
        let mut bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), initial)
            .expect("a freshly created bundle with no canonical roots is valid");

        bundle
            .commit(
                &[
                    op_block_0,
                    op_block_1,
                    extension_chunk,
                    base_chunk,
                    blob_chunk,
                ],
                |ctx| {
                    let mut manifest = ctx.previous_manifest.clone();
                    manifest.operation_roots = vec![ctx.new_chunks[0], ctx.new_chunks[1]];
                    manifest.extension_declarations = vec![ExtensionDeclaration {
                        extension_id: ExtensionId([9; 16]),
                        version: SemVer::new(1, 0, 0),
                        required: false,
                        preserved_chunk_roots: vec![ctx.new_chunks[2]],
                        affected_object_kinds: vec![0xAA],
                        edit_barriers: vec![0xBB, 0xCC],
                    }];
                    manifest.canonical_base = Some(SnapshotRef {
                        snapshot_id: SnapshotId([7; 16]),
                        covers_causal_frontier: FrontierBytes::from_bytes(vec![1, 2, 3]),
                        reduction_algorithm_version: ReductionAlgorithmVersion(1),
                        profile_id: ProfileId::Full,
                        root: ctx.new_chunks[3],
                        hash: ctx.new_chunks[3].hash,
                    });
                    manifest.blob_roots = vec![BlobRef {
                        blob_id: BlobId(ctx.new_chunks[4].hash),
                        media_type: "application/octet-stream".to_owned(),
                        offset: ctx.new_chunks[4].offset,
                        compressed_length: ctx.new_chunks[4].compressed_length,
                        uncompressed_length: ctx.new_chunks[4].uncompressed_length,
                        compression: CompressionAlgorithm::None,
                        hash: ctx.new_chunks[4].hash,
                        declared_max_uncompressed_length: None,
                    }];
                    manifest
                },
            )
            .expect("commit of a well-formed, self-consistent manifest succeeds");

        // Sanity: the trap this test exists to guard against is only live if
        // the manifest really does carry a non-empty, non-canonical blob root.
        assert_eq!(bundle.manifest().blob_roots.len(), 1);
        bundle
    }

    fn sample_envelope(counter: u64, physical_time: i64) -> OperationEnvelope {
        let id = OperationId::new(ReplicaId(1), counter);
        OperationEnvelope {
            id,
            author: AuthorId(0xAB),
            stamp: OperationStamp::new(
                HybridLogicalClock::new(WallClockTime(physical_time), 0),
                id,
            ),
            causal_context: CausalContext::new(),
            transaction: None,
            payload: OperationPayload::Primitive(OperationKind::DeleteRegion(DeleteRegionOp {
                region: RegionId::new(ReplicaId(1), counter),
            })),
        }
    }

    #[test]
    fn document_from_bundle_reads_every_section_and_orders_envelopes_canonically() {
        let bundle = build_sample_bundle();
        let document = document_from_bundle(&bundle).expect("bundle reads cleanly");

        assert_eq!(document.document_id, DocumentId([3; 16]));
        assert_eq!(document.lineage_id, Some(LineageId([4; 16])));
        assert_eq!(document.profiles, vec![ProfileDeclaration::full()]);

        assert_eq!(document.extensions.len(), 1);
        let extension = &document.extensions[0];
        assert_eq!(extension.extension_id, ExtensionId([9; 16]));
        assert_eq!(extension.chunks.len(), 1);
        assert_eq!(extension.chunks[0].kind, ChunkKind::ExtensionData);
        assert_eq!(extension.chunks[0].payload, b"extension-chunk-payload");

        let base = document.canonical_base.as_ref().expect("base was staged");
        assert_eq!(base.snapshot_id, SnapshotId([7; 16]));
        assert_eq!(base.root_payload, b"canonical-base-payload");

        // The trap: a non-empty `blob_roots` still yields zero canonical
        // blobs, because nothing can reach one yet.
        assert!(
            document.blobs.is_empty(),
            "no blob is canonically reachable today, regardless of manifest.blob_roots"
        );

        // Canonical reduction order is by ascending physical time; the raw
        // blocks stored the higher-physical-time envelope first.
        assert_eq!(document.envelopes.len(), 2);
        assert_eq!(document.envelopes[0].id, OperationId::new(ReplicaId(1), 2));
        assert_eq!(document.envelopes[1].id, OperationId::new(ReplicaId(1), 1));
    }

    #[test]
    fn projected_text_never_emits_a_blob_line_and_orders_sections_correctly() {
        let bundle = build_sample_bundle();
        let document = document_from_bundle(&bundle).expect("bundle reads cleanly");
        let text = project_text_document(&document);

        assert!(
            !text.lines().any(|line| line.starts_with("(blob ")),
            "projected text must never carry a blob line at this companion version:\n{text}"
        );

        // Section order is normative (`projection ::= header document lineage?
        // profile* extension* canonical-base? blob* envelope*`): assert the
        // literal sequence of line-heads, not just a count, so a section
        // written out of order fails this check rather than only a count.
        let lines: Vec<&str> = text.lines().collect();
        let heads: Vec<&str> = lines
            .iter()
            .map(|line| line.split(' ').next().expect("every line has a head token"))
            .collect();
        assert_eq!(
            heads,
            vec![
                "(text-projection",
                "(document",
                "(lineage",
                "(profile",
                "(extension",
                "(canonical-base",
                "(envelope",
                "(envelope",
            ],
            "section order is normative; got:\n{text}"
        );
    }

    #[test]
    fn project_bundle_composes_both_stages() {
        let bundle = build_sample_bundle();
        let via_two_stages =
            project_text_document(&document_from_bundle(&bundle).expect("bundle reads cleanly"));
        let via_one_call = project_bundle(&bundle).expect("bundle reads cleanly");
        assert_eq!(via_one_call, via_two_stages);
    }

    #[test]
    fn a_corrupt_operation_envelope_is_a_typed_error_not_a_panic() {
        let mut initial = Manifest::empty(DocumentId([5; 16]));
        initial.lineage_id = None;
        let mut bundle = Bundle::create(MemStore::new(), FileUuid([2; 16]), initial).unwrap();
        let garbage_block = StagedChunk::operation_block(encode_block(&[vec![0xFF; 4]]));
        bundle
            .commit(&[garbage_block], |ctx| {
                let mut manifest = ctx.previous_manifest.clone();
                manifest.operation_roots = vec![ctx.new_chunks[0]];
                manifest
            })
            .expect("the block's framing is well-formed even though its one envelope is not");

        match document_from_bundle(&bundle) {
            Err(ProjectError::Envelope(_)) => {}
            other => panic!("expected a decode error for garbage envelope bytes, got {other:?}"),
        }
    }

    #[test]
    fn a_corrupt_chunk_hash_is_a_bundle_error() {
        let bundle = build_sample_bundle();
        let extension_root = bundle.manifest().extension_declarations[0].preserved_chunk_roots[0];
        let mut image = bundle.image().to_vec();
        // Flip one byte inside the extension chunk's stored payload region.
        let corrupt_at = extension_root.offset as usize;
        image[corrupt_at] ^= 0xFF;

        let corrupted = Bundle::open(MemStore::from_bytes(image))
            .expect("corrupting a non-canonical chunk's payload does not stop the bundle opening");
        match document_from_bundle(&corrupted) {
            Err(ProjectError::Bundle(_)) => {}
            other => panic!("expected a bundle error for a hash-mismatched chunk, got {other:?}"),
        }
    }

    // -----------------------------------------------------------------
    // Suite reach: count, don't just claim, coverage of an extension, a
    // canonical base, and a multi-envelope document.
    // -----------------------------------------------------------------

    #[test]
    fn test_suite_reach_covers_extension_base_and_multi_envelope_documents() {
        let rich = document_from_bundle(&build_sample_bundle()).expect("bundle reads cleanly");
        let minimal = TextDocument {
            document_id: DocumentId([6; 16]),
            manifest_schema_version: SchemaVersion::V0,
            lineage_id: None,
            profiles: vec![ProfileDeclaration::full()],
            extensions: Vec::new(),
            canonical_base: None,
            blobs: Vec::new(),
            envelopes: vec![sample_envelope(1, 1)],
        };
        let documents = [rich, minimal];

        let with_extension = documents
            .iter()
            .filter(|d| !d.extensions.is_empty())
            .count();
        let with_canonical_base = documents
            .iter()
            .filter(|d| d.canonical_base.is_some())
            .count();
        let with_multi_envelope = documents.iter().filter(|d| d.envelopes.len() > 1).count();

        assert_eq!(
            with_extension, 1,
            "expected exactly one exercised document with an extension"
        );
        assert_eq!(
            with_canonical_base, 1,
            "expected exactly one exercised document with a canonical base"
        );
        assert_eq!(
            with_multi_envelope, 1,
            "expected exactly one exercised document with more than one envelope"
        );
    }

    // -----------------------------------------------------------------
    // The trip-wire: if `BlobId` ever appears in `epiphany-core` or
    // `epiphany-ops`, `canonically_reachable_blob_ids` above is no longer
    // provably empty, and both the emit side (that function) and the
    // parse-side `(blob ...)` rejection (parse.rs) need real
    // implementations.
    // -----------------------------------------------------------------

    fn rs_files_under(dir: &Path, out: &mut Vec<PathBuf>) {
        let entries = fs::read_dir(dir)
            .unwrap_or_else(|e| panic!("failed to read directory {}: {e}", dir.display()));
        for entry in entries {
            let path = entry.expect("directory entry is readable").path();
            if path.is_dir() {
                rs_files_under(&path, out);
            } else if path.extension().is_some_and(|ext| ext == "rs") {
                out.push(path);
            }
        }
    }

    #[test]
    fn blobid_is_absent_from_core_and_ops_source() {
        let dirs = [
            concat!(env!("CARGO_MANIFEST_DIR"), "/../epiphany-core/src"),
            concat!(env!("CARGO_MANIFEST_DIR"), "/../epiphany-ops/src"),
        ];
        for dir in dirs {
            let mut files = Vec::new();
            rs_files_under(Path::new(dir), &mut files);
            assert!(
                !files.is_empty(),
                "expected to find .rs sources under {dir}"
            );
            for path in files {
                let source = fs::read_to_string(&path)
                    .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
                assert!(
                    !source.contains("BlobId"),
                    "found `BlobId` in {}: `canonically_reachable_blob_ids` \
                     (crates/epiphany-textproj/src/project.rs) could provably return only the \
                     empty set because neither crate could name a BlobId -- that is no longer \
                     true, so both that predicate's emit side and parse.rs's `(blob ...)` \
                     rejection now need their real implementations",
                    path.display()
                );
            }
        }
    }
}
