//! Strict parsing of canonical Text Projection documents.
//!
//! Parsing is grammar-directed over [`epiphany_core::textvalue::Sexp`] values
//! read by [`epiphany_core::textvalue::read_sexp`], reports
//! [`epiphany_core::textvalue::TextError`], and delegates envelope productions
//! to [`epiphany_ops::parse_envelope`].
//!
//! # Free functions, not `TextValue` impls
//!
//! `TextValue` is a core-crate trait and every document-line type
//! (`Manifest`, `ProfileDeclaration`, ...) belongs to `epiphany-bundle`, which
//! does not depend on `epiphany-core`. Implementing a foreign trait for a
//! foreign type from this third crate is exactly what the orphan rule
//! forbids, and no document-line production needs it anyway: every one
//! bottoms out in `bytes`, `integer`, `bool`, `string`, `option`, or a closed
//! vocabulary, never a Chapter-5 `value`. So this module is free functions
//! throughout, one pair (or one parser, where there is no corresponding
//! `project_*` in this file) per grammar production.
//!
//! # Two rejections, and why each is a rejection rather than a normalization
//!
//! Every check below rejects rather than repairs, per `req:textproj:strict-parse`.
//! Two are load-bearing enough to call out specifically:
//!
//! * **Any `(blob ...)` line is rejected**, unconditionally, in
//!   [`parse_document`]. At `crate::COMPANION_VERSION` no canonical operation
//!   and no canonical reduced state can carry a `BlobId` (a source scan --
//!   the companion `epiphany-core`/`epiphany-ops` crates never mention the
//!   type), so canonical state cannot reference a blob and every blob line is
//!   necessarily unreferenced (`req:textproj:reject-unreferenced-blobs`).
//!   Accepting one would stage a blob into the bundle that the very next
//!   projection silently drops -- losing data and falsifying
//!   `project(serialize(parse(T))) == T` for that text. The `(blob ...)`
//!   *production* itself still parses -- `parse_blob` is exercised directly
//!   against synthetic data below -- so the accept side is ready the moment
//!   the reachability predicate becomes non-empty; only the document-level
//!   decision to use it is a permanent "no".
//! * **Any header version other than `crate::COMPANION_VERSION` is rejected
//!   at line one** (`req:textproj:header-version`). Multi-version acceptance
//!   and migrate-on-read are deferred spec decisions; this parser does not
//!   speculate about them.
//!
//! # Section order is a sequence, not a set
//!
//! `projection ::= header document lineage? profile* extension*
//! canonical-base? blob* envelope*` is consumed by [`parse_document`] in
//! exactly that order, greedily and without backtracking: each stage takes
//! every line that belongs to it and stops at the first line that does not.
//! Anything left over once every stage has run -- a repeated singular
//! section, a section that shows up before the one it follows, or simple
//! garbage -- is rejected as a whole. A parser that instead recognised every
//! line by its own head symbol and sorted them into place would accept
//! exactly the disordered and repeated texts this design rejects; that is
//! the normalization `req:textproj:strict-parse` forbids, so this parser
//! does not do it.

use epiphany_bundle::{
    ChunkKind, DocumentId, ExtensionId, FrontierBytes, LineageId, ProfileConstraints,
    ProfileDeclaration, ProfileId, ProfileRegistryId, ReductionAlgorithmVersion, RetentionPolicy,
    SchemaVersion, SemVer, SnapshotId, WallClockDuration,
};
use epiphany_core::textvalue::{read_sexp, Sexp, TextError, TextValue};
use epiphany_ops::{canonical_reduction_order, parse_envelope, OperationEnvelope};

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

// ===========================================================================
// The document-level driver.
// ===========================================================================

/// Parses a complete canonical Text Projection into a [`TextDocument`],
/// rejecting anything that is not the canonical projection of the document it
/// denotes (`req:textproj:strict-parse`).
///
/// `text` must be exactly what `req:textproj:envelope-per-line` describes:
/// lines separated by a single U+000A, a final U+000A, and nothing else
/// trailing. Every line is one complete s-expression, and the sequence of
/// lines must follow `projection`'s section order precisely -- see the
/// module documentation for why an out-of-order or repeated section is a
/// rejection rather than something to sort back into place.
pub fn parse_document(text: &str) -> Result<TextDocument, TextError> {
    let raw_lines = split_lines(text)?;
    let mut lines = Lines {
        lines: raw_lines,
        pos: 0,
    };

    // header: mandatory, first (`req:textproj:header-version`).
    require_line(
        &mut lines,
        "text-projection",
        "a projection must begin with a header line naming the companion version",
        parse_header,
    )?;

    // document: mandatory, immediately after the header.
    let (document_id, manifest_schema_version) = require_line(
        &mut lines,
        "document",
        "a projection must carry a document line immediately after its header",
        parse_document_line,
    )?;

    // lineage?
    let lineage_id = take_line(&mut lines, "lineage", parse_lineage)?;

    // profile*, in canonical (profile_id, version) order -- no repeats.
    let mut profiles = Vec::new();
    while let Some(profile) = take_line(&mut lines, "profile", parse_profile)? {
        profiles.push(profile);
    }
    if profiles
        .windows(2)
        .any(|w| (w[0].profile_id, w[0].version) >= (w[1].profile_id, w[1].version))
    {
        return Err(TextError::NotStrictlyIncreasing(
            "profile declarations must be ordered, without repetition, by (profile_id, version)",
        ));
    }

    // extension*, in canonical (extension_id, version) order -- no repeats.
    let mut extensions = Vec::new();
    while let Some(extension) = take_line(&mut lines, "extension", parse_extension)? {
        extensions.push(extension);
    }
    if extensions
        .windows(2)
        .any(|w| (w[0].extension_id, w[0].version) >= (w[1].extension_id, w[1].version))
    {
        return Err(TextError::NotStrictlyIncreasing(
            "extension declarations must be ordered, without repetition, by (extension_id, version)",
        ));
    }

    // canonical-base?
    let canonical_base = take_line(&mut lines, "canonical-base", parse_canonical_base)?;
    // `CONTRACT_FORMAT_EPOCH_MAJOR1.md` pin 3b: text projection cannot mint a
    // canonical base. The grammar still defines the production — it is what
    // this refusal is defined against, and what a future rebuild/repack flow
    // will emit — but no document containing it parses. `NotCanonical`
    // matches the existing `(blob ...)` rejection just below: syntactically
    // well-formed, but never a text this companion can accept.
    if canonical_base.is_some() {
        return Err(TextError::NotCanonical(
            "a (canonical-base ...) line cannot be canonical text: no reduction-authority \
             validation exists for text projection to have produced it",
        ));
    }

    // blob*: collected only so the rejection below can fire; see the module
    // documentation and `req:textproj:reject-unreferenced-blobs`.
    let mut blobs = Vec::new();
    while let Some(blob) = take_line(&mut lines, "blob", parse_blob)? {
        blobs.push(blob);
    }
    if !blobs.is_empty() {
        return Err(TextError::NotCanonical(
            "a (blob ...) line is unreferenced by canonical state: no canonical operation or \
             reduced state can carry a BlobId at this companion version, so no blob line can \
             ever be canonical here",
        ));
    }

    // envelope*, in canonical reduction order.
    let mut envelopes = Vec::new();
    while let Some(line) = lines.peek() {
        let sexp = read_sexp(line)?;
        if line_head(&sexp) != Some("envelope") {
            break;
        }
        lines.advance();
        envelopes.push(parse_envelope(line)?);
    }
    check_envelope_order(&envelopes)?;

    // Anything left over is a line that belongs to no remaining stage: a
    // repeated singular section, a section reappearing after a later one, or
    // plain garbage after the envelopes. Every one of those is a rejection,
    // never a re-sort.
    if lines.peek().is_some() {
        return Err(TextError::Syntax(
            "a line appears out of the projection's normative section order, or a section that \
             may occur at most once is repeated",
        ));
    }

    Ok(TextDocument {
        document_id,
        manifest_schema_version,
        lineage_id,
        profiles,
        extensions,
        canonical_base,
        blobs: Vec::new(),
        envelopes,
    })
}

/// A cursor over a projection's lines, advanced only by [`take_line`] and
/// [`require_line`] so every stage of [`parse_document`] consumes lines in
/// exactly one pass.
struct Lines<'a> {
    lines: Vec<&'a str>,
    pos: usize,
}

impl<'a> Lines<'a> {
    fn peek(&self) -> Option<&'a str> {
        self.lines.get(self.pos).copied()
    }

    fn advance(&mut self) {
        self.pos += 1;
    }
}

/// Splits a whole projection into its lines, enforcing the layout half of
/// `req:textproj:envelope-per-line`: a single trailing U+000A and no other
/// trailing whitespace. Without this check a projection missing its final
/// U+000A would still split into the same lines by `str::split('\n')` and
/// parse identically -- the trailing-newline requirement would be silently
/// unenforced. An embedded blank line (a stray `U+000A U+000A`, anywhere)
/// needs no special case here: it becomes an empty line, and every stage
/// below rejects an empty line when it tries to read an s-expression from it.
fn split_lines(text: &str) -> Result<Vec<&str>, TextError> {
    let body = text.strip_suffix('\n').ok_or(TextError::Syntax(
        "a projection must end with exactly one U+000A and no other trailing whitespace",
    ))?;
    Ok(body.split('\n').collect())
}

/// The head symbol of a line's s-expression, or `None` if it is not headed by
/// one (a bare leaf line is never a valid section head, so this only ever
/// hides a genuine mismatch).
fn line_head(sexp: &Sexp) -> Option<&str> {
    sexp.as_list()?.first()?.as_symbol()
}

/// Consumes the next line and applies `parse` to it, but only if it is
/// present and its head symbol is `head`. Otherwise the cursor is left
/// untouched and `Ok(None)` is returned -- the mechanism by which every
/// optional or repeatable section in `projection` is optional, and by which a
/// line belonging to a later or earlier stage is left for another stage (or
/// the final leftover check) to deal with, rather than being consumed out of
/// place.
fn take_line<'a, T>(
    lines: &mut Lines<'a>,
    head: &str,
    parse: impl FnOnce(&Sexp) -> Result<T, TextError>,
) -> Result<Option<T>, TextError> {
    let Some(line) = lines.peek() else {
        return Ok(None);
    };
    let sexp = read_sexp(line)?;
    if line_head(&sexp) != Some(head) {
        return Ok(None);
    }
    lines.advance();
    Ok(Some(parse(&sexp)?))
}

/// As [`take_line`], but the line is mandatory: a missing or mismatched line
/// is `missing`, not `Ok(None)`. Used for the header and the document line,
/// the two productions `projection` requires unconditionally.
fn require_line<'a, T>(
    lines: &mut Lines<'a>,
    head: &str,
    missing: &'static str,
    parse: impl FnOnce(&Sexp) -> Result<T, TextError>,
) -> Result<T, TextError> {
    let Some(line) = lines.peek() else {
        return Err(TextError::Syntax(missing));
    };
    let sexp = read_sexp(line)?;
    if line_head(&sexp) != Some(head) {
        return Err(TextError::Syntax(missing));
    }
    lines.advance();
    parse(&sexp)
}

/// `req:textproj:derived-ordering`'s "every other sequence keeps the binary
/// order" names the envelopes' binary order as
/// [`epiphany_ops::canonical_reduction_order`] -- a deterministic function of
/// the envelopes' own causal contexts and stamps, not a free choice a writer
/// makes. So a text whose envelope lines are already in that order is the
/// only text `req:textproj:canonical-text` permits for that envelope set, and
/// accepting some other order here -- and silently keeping it -- would be
/// exactly the normalization `req:textproj:strict-parse` forbids: the next
/// projection re-sorts the same envelopes into canonical order, so
/// `project(serialize(parse(T))) == T` fails for precisely the malformed `T`
/// this rejects up front.
fn check_envelope_order(envelopes: &[OperationEnvelope]) -> Result<(), TextError> {
    let given: Vec<&OperationEnvelope> = envelopes.iter().collect();
    let canonical = canonical_reduction_order(&given);
    let already_canonical = given
        .iter()
        .zip(canonical.iter())
        .all(|(a, b)| std::ptr::eq(*a, *b));
    if !already_canonical {
        return Err(TextError::NotStrictlyIncreasing(
            "envelope lines must appear in canonical reduction order \
             (epiphany_ops::canonical_reduction_order)",
        ));
    }
    Ok(())
}

// ===========================================================================
// Leaves shared by several productions.
// ===========================================================================

/// The lexical class of `s`, for error messages. Mirrors the private helper
/// of the same name in `epiphany_core::textvalue` and in every `textproj_*`
/// module of `epiphany-ops`: `Sexp::class` is not public, so each grammar-
/// directed module restates this one match rather than depend on it.
fn class_of(s: &Sexp) -> &'static str {
    match s {
        Sexp::List(_) => "list",
        Sexp::Symbol(_) => "symbol",
        Sexp::Int(_) => "integer",
        Sexp::Bytes(_) => "byte string",
        Sexp::Str(_) => "string",
    }
}

/// Reads the grammar's opaque `bytes` terminal as a plain byte string.
/// `Vec<u8>`'s generic `TextValue` impl denotes a *sequence of integers*, a
/// different production, so it must never stand in for an opaque payload,
/// identifier, or hash -- the same trap the operation layer names in
/// `textproj_kind.rs`.
fn parse_bytes(s: &Sexp) -> Result<Vec<u8>, TextError> {
    match s {
        Sexp::Bytes(bytes) => Ok(bytes.clone()),
        _ => Err(TextError::Expected {
            expected: "byte string",
            found: class_of(s),
        }),
    }
}

/// Reads a fixed 16-byte opaque identifier: a `DocumentId`, `LineageId`,
/// `SnapshotId`, `ExtensionId`, or a custom `ProfileId`'s registry id. `what`
/// names the identifier for the rejection message.
fn parse_id16(s: &Sexp, what: &'static str) -> Result<[u8; 16], TextError> {
    parse_bytes(s)?
        .try_into()
        .map_err(|_| TextError::NotCanonical(what))
}

/// The grammar's anonymous `version ::= "(" integer " " integer " " integer
/// ")"` production, shared verbatim by the header (against
/// `crate::COMPANION_VERSION`) and by every `SemVer` field.
fn parse_version_triple(s: &Sexp) -> Result<(u32, u32, u32), TextError> {
    let items = s.as_list().ok_or(TextError::Expected {
        expected: "version",
        found: class_of(s),
    })?;
    let [major, minor, patch] = items else {
        return Err(TextError::Arity {
            type_name: "version",
            expected: 3,
            found: items.len(),
        });
    };
    Ok((u32::parse(major)?, u32::parse(minor)?, u32::parse(patch)?))
}

fn parse_semver(s: &Sexp) -> Result<SemVer, TextError> {
    let (major, minor, patch) = parse_version_triple(s)?;
    Ok(SemVer::new(major, minor, patch))
}

/// The grammar's `profile-id` production: a bare symbol for each
/// closed-vocabulary profile, and `(custom <bytes>)` -- the one profile
/// identity that is not a bare symbol -- for `ProfileId::Custom`, whose
/// registry id is 16 opaque bytes.
fn parse_profile_id(s: &Sexp) -> Result<ProfileId, TextError> {
    if let Some(name) = s.as_symbol() {
        return match name {
            "full" => Ok(ProfileId::Full),
            "read-only" => Ok(ProfileId::ReadOnly),
            "lite" => Ok(ProfileId::Lite),
            _ => Err(TextError::UnknownConstructor {
                type_name: "ProfileId",
                found: name.to_owned(),
            }),
        };
    }
    let fields = s.expect_struct("custom", 1)?;
    let registry_id = parse_id16(
        &fields[0],
        "a custom ProfileId registry id is exactly 16 bytes",
    )?;
    Ok(ProfileId::Custom(ProfileRegistryId(registry_id)))
}

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

/// `header ::= "(text-projection " version ")"`. Accepts exactly
/// `crate::COMPANION_VERSION`; any other version is a rejection at line one
/// (`req:textproj:header-version`). Multi-version acceptance and
/// migrate-on-read are deferred spec decisions this parser does not
/// speculate about.
fn parse_header(s: &Sexp) -> Result<(), TextError> {
    let fields = s.expect_struct("text-projection", 1)?;
    let version = parse_version_triple(&fields[0])?;
    if version != COMPANION_VERSION {
        return Err(TextError::NotCanonical(
            "the header names a companion version other than the one this crate implements",
        ));
    }
    Ok(())
}

/// `document ::= "(document " bytes " " schema ")"`.
///
/// The `schema` field is the carried manifest `SchemaVersion` (G-minor,
/// `spec/PLAN_GMINOR_SCHEMA_MINOR.md` §4, pins 8/11; companion 0.10.0):
/// parsed back verbatim, never re-derived — this companion has no
/// `epiphany-layout-ir` dependency and cannot decode edit-barrier bytes to
/// check it.
fn parse_document_line(s: &Sexp) -> Result<(DocumentId, SchemaVersion), TextError> {
    let fields = s.expect_struct("document", 2)?;
    let document_id = DocumentId(parse_id16(&fields[0], "a DocumentId is exactly 16 bytes")?);
    let manifest_schema_version = parse_schema_version(&fields[1])?;
    Ok((document_id, manifest_schema_version))
}

/// `lineage ::= "(lineage " bytes ")"`.
fn parse_lineage(s: &Sexp) -> Result<LineageId, TextError> {
    let fields = s.expect_struct("lineage", 1)?;
    Ok(LineageId(parse_id16(
        &fields[0],
        "a LineageId is exactly 16 bytes",
    )?))
}

// ===========================================================================
// profile, constraints, retention.
// ===========================================================================

/// `profile ::= "(profile " profile-id " " version " " constraints ")"`.
fn parse_profile(s: &Sexp) -> Result<ProfileDeclaration, TextError> {
    let fields = s.expect_struct("profile", 3)?;
    Ok(ProfileDeclaration {
        profile_id: parse_profile_id(&fields[0])?,
        version: parse_semver(&fields[1])?,
        constraints: parse_constraints(&fields[2])?,
    })
}

/// `constraints ::= "(constraints " integer " " retention ")"`.
fn parse_constraints(s: &Sexp) -> Result<ProfileConstraints, TextError> {
    let fields = s.expect_struct("constraints", 2)?;
    Ok(ProfileConstraints {
        max_uncompressed_block_size: u64::parse(&fields[0])?,
        retention_policy: parse_retention(&fields[1])?,
    })
}

/// `retention ::= "(retention " integer " " option " " bool ")"`.
///
/// The middle field is `Option<WallClockDuration>`, and `WallClockDuration` is
/// a newtype over `i64`: per `req:textproj:value-projection` clause 2 a
/// newtype projects transparently as its field alone, so the option wraps a
/// bare integer, not a `(wall-clock-duration <integer>)` struct. This is
/// **`epiphany_bundle::WallClockDuration`**, not `epiphany_core`'s
/// same-named type -- the retention policy is a bundle type through and
/// through.
fn parse_retention(s: &Sexp) -> Result<RetentionPolicy, TextError> {
    let fields = s.expect_struct("retention", 3)?;
    Ok(RetentionPolicy {
        retain_previous_manifests: u32::parse(&fields[0])?,
        retain_duration: Option::<i64>::parse(&fields[1])?.map(WallClockDuration),
        retain_named_checkpoints: bool::parse(&fields[2])?,
    })
}

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

/// `extension ::= "(extension " bytes " " version " " bool " (" chunk* ") "
/// bytes " " bytes ")"` -- id, version, required, chunks, affected-kinds,
/// barriers, the ratified declaration order. `affected_object_kinds` and
/// `edit_barriers` are opaque byte strings, never structured: the bundle
/// preserves them without interpreting them, and this projection interprets
/// nothing the bundle does not.
fn parse_extension(s: &Sexp) -> Result<TextExtension, TextError> {
    let fields = s.expect_struct("extension", 6)?;
    Ok(TextExtension {
        extension_id: ExtensionId(parse_id16(
            &fields[0],
            "an ExtensionId is exactly 16 bytes",
        )?),
        version: parse_semver(&fields[1])?,
        required: bool::parse(&fields[2])?,
        chunks: parse_chunks(&fields[3])?,
        affected_object_kinds: parse_bytes(&fields[4])?,
        edit_barriers: parse_bytes(&fields[5])?,
    })
}

/// `chunk ::= "(chunk " chunk-kind " " schema " " bytes ")"`: a preserved
/// extension chunk root projected as kind, schema version, and uncompressed
/// payload -- never a `ChunkRef`, which the projection has no file to point
/// into (`req:textproj:derive-or-carry`).
fn parse_chunk(s: &Sexp) -> Result<TextChunk, TextError> {
    let fields = s.expect_struct("chunk", 3)?;
    Ok(TextChunk {
        kind: parse_chunk_kind(&fields[0])?,
        schema_version: parse_schema_version(&fields[1])?,
        payload: parse_bytes(&fields[2])?,
    })
}

/// The chunk* sequence inside an `extension` line. `req:textproj:derived-ordering`
/// orders and de-duplicates an extension's preserved chunk roots by their
/// *projected form* -- the binary `ChunkRef` order breaks ties on the file
/// offset, which this projection erases, so the binary order cannot be
/// inherited here. Accepting a disordered or duplicated chunk list and
/// re-sorting it at serialize time would be normalization; this rejects it
/// instead.
fn parse_chunks(s: &Sexp) -> Result<Vec<TextChunk>, TextError> {
    let items = s.as_list().ok_or(TextError::Expected {
        expected: "chunk sequence",
        found: class_of(s),
    })?;
    let mut out = Vec::with_capacity(items.len());
    let mut previous_rendered: Option<String> = None;
    for item in items {
        let rendered = item.render();
        if previous_rendered
            .as_deref()
            .is_some_and(|previous| rendered.as_str() <= previous)
        {
            return Err(TextError::NotStrictlyIncreasing(
                "an extension's preserved chunk roots must be ordered, without repetition, by \
                 projected form",
            ));
        }
        out.push(parse_chunk(item)?);
        previous_rendered = Some(rendered);
    }
    Ok(out)
}

/// `chunk-kind`: a bare symbol per `ChunkKind` variant, in the same
/// declaration order as the discriminants `req:format:chunkkind-discriminants`
/// pins.
fn parse_chunk_kind(s: &Sexp) -> Result<ChunkKind, TextError> {
    match s.as_symbol() {
        Some("operation-envelope-block") => Ok(ChunkKind::OperationEnvelopeBlock),
        Some("operation-index") => Ok(ChunkKind::OperationIndex),
        Some("snapshot") => Ok(ChunkKind::Snapshot),
        Some("blob") => Ok(ChunkKind::Blob),
        Some("extension-data") => Ok(ChunkKind::ExtensionData),
        Some("text-projection") => Ok(ChunkKind::TextProjection),
        Some("layout-cache") => Ok(ChunkKind::LayoutCache),
        Some("integrity-index") => Ok(ChunkKind::IntegrityIndex),
        Some("manifest") => Ok(ChunkKind::Manifest),
        Some(name) => Err(TextError::UnknownConstructor {
            type_name: "ChunkKind",
            found: name.to_owned(),
        }),
        None => Err(TextError::Expected {
            expected: "chunk-kind",
            found: class_of(s),
        }),
    }
}

/// `schema ::= "(schema " integer " " integer ")"`.
fn parse_schema_version(s: &Sexp) -> Result<SchemaVersion, TextError> {
    let fields = s.expect_struct("schema", 2)?;
    Ok(SchemaVersion::new(
        u16::parse(&fields[0])?,
        u16::parse(&fields[1])?,
    ))
}

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

/// `canonical-base ::= "(canonical-base " bytes " " bytes " " integer " "
/// profile-id " " schema " " bytes ")"` -- snapshot id, frontier, reduction
/// version, profile, root schema, root payload. The `SnapshotId` is the one
/// identity `req:textproj:derive-or-carry` carries verbatim rather than
/// re-deriving (schema major 0 has no snapshot producer, so it has nothing to
/// derive from); the root chunk's own id and content hash are re-derived by
/// whoever serializes this back into a bundle, never read from the text.
fn parse_canonical_base(s: &Sexp) -> Result<TextCanonicalBase, TextError> {
    let fields = s.expect_struct("canonical-base", 6)?;
    Ok(TextCanonicalBase {
        snapshot_id: SnapshotId(parse_id16(&fields[0], "a SnapshotId is exactly 16 bytes")?),
        covers_causal_frontier: FrontierBytes::from_bytes(parse_bytes(&fields[1])?),
        reduction_algorithm_version: ReductionAlgorithmVersion(u32::parse(&fields[2])?),
        profile_id: parse_profile_id(&fields[3])?,
        root_schema_version: parse_schema_version(&fields[4])?,
        root_payload: parse_bytes(&fields[5])?,
    })
}

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

/// `blob ::= "(blob " string " " option " " bytes ")"` -- media type,
/// declared maximum uncompressed length, payload.
///
/// This is the production-level parser only. It accepts a well-formed
/// `(blob ...)` line unconditionally, exactly as every other production
/// parser here does; the decision that **no** accepted blob line may reach a
/// [`TextDocument`] is made once, at the document level, in
/// [`parse_document`] (`req:textproj:reject-unreferenced-blobs`). Keeping the
/// two apart -- this function parses, `parse_document` rejects -- means the
/// accept side is already correct and already tested the day the
/// reachability predicate stops returning the empty set, rather than needing
/// to be written from scratch alongside a spec bump.
fn parse_blob(s: &Sexp) -> Result<TextBlob, TextError> {
    let fields = s.expect_struct("blob", 3)?;
    Ok(TextBlob {
        media_type: String::parse(&fields[0])?,
        declared_max_uncompressed_length: Option::<u64>::parse(&fields[1])?,
        payload: parse_bytes(&fields[2])?,
    })
}

// ===========================================================================
// Tests.
// ===========================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use epiphany_core::{OperationId, ReplicaId, WallClockTime};
    use epiphany_ops::{
        project_envelope, AuthorId, CausalContext, EnvelopeHash, HybridLogicalClock,
        OperationStamp, ResolveEquivocationPayload,
    };

    // -----------------------------------------------------------------
    // Test fixtures.
    // -----------------------------------------------------------------

    /// Joins `lines` with a single U+000A each, including a final one --
    /// exactly `req:textproj:envelope-per-line`'s layout.
    fn projection(lines: &[&str]) -> String {
        let mut out = String::new();
        for line in lines {
            out.push_str(line);
            out.push('\n');
        }
        out
    }

    // Bumped with `COMPANION_VERSION` (0.7.0 → 0.8.0, genesis G1; 0.8.0 →
    // 0.9.0, genesis G2a; 0.9.0 → 0.10.0, G-minor; 0.10.0 → 0.11.0, genesis
    // G2b; 0.11.0 → 0.12.0, genesis G3a; 0.12.0 → 0.13.0, genesis G3b;
    // 0.13.0 → 0.14.0, `CONTRACT_FORMAT_EPOCH_MAJOR1.md` pin 3b). Kept
    // a literal because `projection` takes `&[&str]` and a formatted String
    // would ripple through every call site;
    // `the_test_header_tracks_the_implemented_version` below fails loudly if
    // the two ever drift.
    const HEADER: &str = "(text-projection (0 14 0))";
    const DOCUMENT: &str = "(document #x00000000000000000000000000000001 (schema 0 1))";

    /// A minimal but complete valid projection: just the two mandatory lines.
    fn minimal_valid_document() -> String {
        projection(&[HEADER, DOCUMENT])
    }

    /// `HEADER` is a literal, so nothing but this test stops it drifting from
    /// the version the crate actually implements. Without it a companion bump
    /// leaves every fixture below silently rejected by the version gate, and
    /// the failures point at section ordering rather than at the header.
    #[test]
    fn the_test_header_tracks_the_implemented_version() {
        let (major, minor, patch) = COMPANION_VERSION;
        assert_eq!(
            HEADER,
            format!("(text-projection ({major} {minor} {patch}))")
        );
    }

    /// A simple, independent (empty causal context) envelope, so several of
    /// these can be combined without any causal-order machinery beyond their
    /// HLC physical time.
    fn sample_envelope(replica: u64, counter: u64, physical_time: i64) -> OperationEnvelope {
        let id = OperationId::new(ReplicaId(replica), counter);
        OperationEnvelope {
            id,
            author: AuthorId(0x1122_3344),
            stamp: OperationStamp::new(
                HybridLogicalClock::new(WallClockTime(physical_time), 0),
                id,
            ),
            causal_context: CausalContext::new(),
            transaction: None,
            payload: epiphany_ops::OperationPayload::ResolveEquivocation(
                ResolveEquivocationPayload {
                    target: id,
                    chosen: EnvelopeHash([7; 32]),
                },
            ),
        }
    }

    // -----------------------------------------------------------------
    // The worked example: a real, spec-authored, multi-section document.
    // -----------------------------------------------------------------

    /// The Grammar chapter's worked example, read live from the companion so
    /// this test cannot drift from the document it claims to parse.
    fn worked_example() -> String {
        const SPEC: &str = include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../spec/text_projection.tex"
        ));
        SPEC.split_once("\\chapter{A Worked Example}")
            .expect("the specification contains the worked example")
            .1
            .split_once("\\begin{lstlisting}\n")
            .expect("the worked example contains a projection listing")
            .1
            .split_once("\\end{lstlisting}")
            .expect("the worked projection listing is closed")
            .0
            .to_owned()
    }

    #[test]
    fn the_worked_example_parses_to_its_documented_fields() {
        let text = worked_example();
        let document = parse_document(&text).expect("the worked example is a valid projection");

        assert_eq!(document.document_id, DocumentId([0x05; 16]));
        assert_eq!(document.lineage_id, None);
        assert_eq!(document.profiles.len(), 1);
        assert_eq!(document.profiles[0].profile_id, ProfileId::Full);
        assert_eq!(document.profiles[0].version, SemVer::new(0, 1, 0));
        assert_eq!(
            document.profiles[0].constraints.max_uncompressed_block_size,
            67_108_864
        );
        assert_eq!(
            document.profiles[0]
                .constraints
                .retention_policy
                .retain_previous_manifests,
            1
        );
        assert_eq!(
            document.profiles[0]
                .constraints
                .retention_policy
                .retain_duration,
            None
        );
        assert!(
            document.profiles[0]
                .constraints
                .retention_policy
                .retain_named_checkpoints
        );
        assert!(document.extensions.is_empty());
        // Pin 3b (`CONTRACT_FORMAT_EPOCH_MAJOR1.md`): the worked example no
        // longer carries a canonical base — the companion's own chapter
        // retired that spelling to a second, explicitly-refused listing
        // (`spec/text_projection.tex`, "A Worked Example").
        assert!(document.canonical_base.is_none());

        assert!(document.blobs.is_empty());
        assert_eq!(document.envelopes.len(), 1);
    }

    /// The base-bearing spelling `the_worked_example_parses_to_its_documented_fields`
    /// used to exercise, before pin 3b retired it from the worked example
    /// itself: still grammar-valid, no longer canonical text. Pinned as a
    /// literal here (rather than re-reading the spec's second listing) so
    /// this test does not depend on the exact prose the orchestrator chose to
    /// wrap it in.
    #[test]
    fn parsing_base_bearing_text_is_refused() {
        let base =
            "(canonical-base #x1f8b0000000000000000000000000000 #x 1 full (schema 0 1) #x0000)";
        let text = projection(&[HEADER, DOCUMENT, base]);
        let err = parse_document(&text).expect_err("base-bearing text must be refused");
        assert_eq!(
            err,
            TextError::NotCanonical(
                "a (canonical-base ...) line cannot be canonical text: no reduction-authority \
                 validation exists for text projection to have produced it",
            )
        );
    }

    // -----------------------------------------------------------------
    // A synthetic document exercising lineage, multiple profiles, an
    // extension with preserved chunks, a canonical base, and multiple
    // envelopes -- everything the worked example alone does not reach.
    // -----------------------------------------------------------------

    #[test]
    fn a_rich_synthetic_document_parses_every_section() {
        let lineage = "(lineage #x00000000000000000000000000000002)";
        let profile_full = "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))";
        let profile_read_only =
            "(profile read-only (0 1 0) (constraints 1024 (retention 0 (some 5) false)))";
        let extension = "(extension #x00000000000000000000000000000003 (1 0 0) true \
             ((chunk operation-envelope-block (schema 0 1) #xaa) (chunk snapshot (schema 0 1) #xbb)) \
             #xaabb #xccdd)";
        // Pin 3b (`CONTRACT_FORMAT_EPOCH_MAJOR1.md`): this document used to
        // also carry a `(canonical-base ...)` line here, exercising every
        // section at once. Base-bearing text is refused outright now — see
        // `parsing_base_bearing_text_is_refused` — so this fixture drops it
        // and keeps every other feature it was written to exercise together.

        let e1 = sample_envelope(1, 1, 10);
        let e2 = sample_envelope(2, 1, 20);
        let e1_line = project_envelope(&e1);
        let e2_line = project_envelope(&e2);

        let text = projection(&[
            HEADER,
            DOCUMENT,
            lineage,
            profile_full,
            profile_read_only,
            extension,
            &e1_line,
            &e2_line,
        ]);

        let document = parse_document(&text).expect("a well-formed rich document parses");
        assert_eq!(
            document.lineage_id,
            Some(LineageId([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]))
        );
        assert_eq!(document.profiles.len(), 2);
        assert_eq!(document.extensions.len(), 1);
        assert_eq!(document.extensions[0].chunks.len(), 2);
        assert_eq!(
            document.extensions[0].chunks[0].kind,
            ChunkKind::OperationEnvelopeBlock
        );
        assert_eq!(document.extensions[0].chunks[1].kind, ChunkKind::Snapshot);
        assert_eq!(
            document.extensions[0].affected_object_kinds,
            vec![0xaa, 0xbb]
        );
        assert_eq!(document.extensions[0].edit_barriers, vec![0xcc, 0xdd]);
        assert!(document.canonical_base.is_none());
        assert_eq!(document.envelopes, vec![e1, e2]);
    }

    // -----------------------------------------------------------------
    // Rejection classes. Each is mutation-verified (see the task report):
    // the anchor assertion below is deleted or inverted, a NAMED test in
    // this list is confirmed to fail, and the code is restored.
    // -----------------------------------------------------------------

    #[test]
    fn non_canonical_header_version_is_rejected() {
        let text = projection(&["(text-projection (0 6 0))", DOCUMENT]);
        assert_eq!(
            parse_document(&text),
            Err(TextError::NotCanonical(
                "the header names a companion version other than the one this crate implements"
            ))
        );
    }

    #[test]
    fn the_companion_version_itself_is_accepted() {
        // The mutation-testing counterpart of the above: the exact version
        // this crate implements must still parse.
        assert!(parse_document(&minimal_valid_document()).is_ok());
    }

    /// G-minor (`spec/PLAN_GMINOR_SCHEMA_MINOR.md`, s14): `(0 9 0)` — the
    /// version this crate implemented *before* this packet's `document`
    /// grammar extension — must now be rejected, not merely "some other
    /// version". This is the specific case
    /// `req:textproj:header-version`'s reject-all-others clause exists to
    /// guard, and it is the one a lenient "any `(0 x 0)`" parser would still
    /// pass.
    ///
    /// **Mutation:** widen the parser's version check from an exact `!=
    /// COMPANION_VERSION` to accept any `(0 _ 0)` (e.g. `major == 0`) -> this
    /// test dies, since `(0 8 0)` would then parse.
    #[test]
    fn the_immediately_superseded_companion_version_is_rejected() {
        let text = projection(&["(text-projection (0 9 0))", DOCUMENT]);
        assert_eq!(
            parse_document(&text),
            Err(TextError::NotCanonical(
                "the header names a companion version other than the one this crate implements"
            ))
        );
    }

    /// s14: the committed companion version parses, and the immediately
    /// superseded version is rejected — restated explicitly under its
    /// contract label (`spec/CONTRACT_GMINOR_IMPLEMENTATION.md`), on top of
    /// the two tests immediately above which already exercise both halves.
    #[test]
    fn s14_the_committed_corpus_parses_at_0_10_0_and_0_9_0_is_rejected() {
        assert!(parse_document(&minimal_valid_document()).is_ok());
        let superseded = projection(&["(text-projection (0 9 0))", DOCUMENT]);
        assert!(parse_document(&superseded).is_err());
    }

    #[test]
    fn a_blob_line_is_always_rejected() {
        let blob_line = "(blob \"audio/wav\" () #x00)";
        let text = projection(&[HEADER, DOCUMENT, blob_line]);
        assert!(matches!(
            parse_document(&text),
            Err(TextError::NotCanonical(_))
        ));
    }

    #[test]
    fn the_blob_production_itself_parses_synthetic_data() {
        // Line-level parse of `(blob ...)`, exercised directly against
        // synthetic data per the task contract: the accept side is ready
        // even though `parse_document` never lets it through.
        let sexp = read_sexp("(blob \"audio/wav\" (some 1024) #x0a0b)").unwrap();
        let blob = parse_blob(&sexp).expect("a well-formed blob production parses");
        assert_eq!(blob.media_type, "audio/wav");
        assert_eq!(blob.declared_max_uncompressed_length, Some(1024));
        assert_eq!(blob.payload, vec![0x0a, 0x0b]);
    }

    #[test]
    fn missing_trailing_lf_is_rejected() {
        let text = minimal_valid_document();
        let without_final_lf = text.strip_suffix('\n').unwrap();
        assert_eq!(
            parse_document(without_final_lf),
            Err(TextError::Syntax(
                "a projection must end with exactly one U+000A and no other trailing whitespace"
            ))
        );
    }

    #[test]
    fn an_extra_trailing_blank_line_is_rejected() {
        let mut text = minimal_valid_document();
        text.push('\n'); // a second, stray trailing U+000A
        assert!(parse_document(&text).is_err());
    }

    #[test]
    fn missing_header_is_rejected() {
        assert!(parse_document("").is_err());
    }

    #[test]
    fn missing_document_line_is_rejected() {
        let text = projection(&[HEADER]);
        assert_eq!(
            parse_document(&text),
            Err(TextError::Syntax(
                "a projection must carry a document line immediately after its header"
            ))
        );
    }

    #[test]
    fn a_repeated_header_line_is_rejected() {
        let text = projection(&[HEADER, HEADER, DOCUMENT]);
        assert!(parse_document(&text).is_err());
    }

    #[test]
    fn a_repeated_document_line_is_rejected() {
        let text = projection(&[HEADER, DOCUMENT, DOCUMENT]);
        assert_eq!(
            parse_document(&text),
            Err(TextError::Syntax(
                "a line appears out of the projection's normative section order, or a section \
                 that may occur at most once is repeated"
            ))
        );
    }

    #[test]
    fn out_of_order_sections_are_rejected_not_sorted() {
        let profile = "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))";
        let extension = "(extension #x00000000000000000000000000000003 (1 0 0) false () #x #x)";
        let base = "(canonical-base #x00000000000000000000000000000004 #x 1 full (schema 0 1) #x)";
        let lineage = "(lineage #x00000000000000000000000000000002)";
        let e1_line = project_envelope(&sample_envelope(1, 1, 10));

        let scenarios: &[&[&str]] = &[
            // A profile line before the (mandatory) document line.
            &[HEADER, profile, DOCUMENT],
            // Lineage after a profile, instead of before it.
            &[HEADER, DOCUMENT, profile, lineage],
            // An extension before the profile section.
            &[HEADER, DOCUMENT, extension, profile],
            // A canonical base before the extension section.
            &[HEADER, DOCUMENT, base, extension],
            // An envelope before the canonical base.
            &[HEADER, DOCUMENT, &e1_line, base],
        ];
        for lines in scenarios {
            let text = projection(lines);
            assert!(
                parse_document(&text).is_err(),
                "expected rejection for out-of-order lines: {lines:?}"
            );
        }
    }

    #[test]
    fn profile_declarations_must_be_ordered_and_unique() {
        let full = "(profile full (0 1 0) (constraints 1 (retention 0 () false)))";
        let read_only = "(profile read-only (0 1 0) (constraints 1 (retention 0 () false)))";

        // Correct ascending order accepts.
        let ok = projection(&[HEADER, DOCUMENT, full, read_only]);
        assert!(parse_document(&ok).is_ok());

        // Reversed order is rejected, not silently re-sorted.
        let reversed = projection(&[HEADER, DOCUMENT, read_only, full]);
        assert_eq!(
            parse_document(&reversed),
            Err(TextError::NotStrictlyIncreasing(
                "profile declarations must be ordered, without repetition, by (profile_id, version)"
            ))
        );

        // An exact repeat (same key) is rejected too.
        let repeated = projection(&[HEADER, DOCUMENT, full, full]);
        assert!(parse_document(&repeated).is_err());
    }

    #[test]
    fn extension_declarations_must_be_ordered_and_unique() {
        let ext_a = "(extension #x00000000000000000000000000000001 (1 0 0) false () #x #x)";
        let ext_b = "(extension #x00000000000000000000000000000002 (1 0 0) false () #x #x)";

        let ok = projection(&[HEADER, DOCUMENT, ext_a, ext_b]);
        assert!(parse_document(&ok).is_ok());

        let reversed = projection(&[HEADER, DOCUMENT, ext_b, ext_a]);
        assert_eq!(
            parse_document(&reversed),
            Err(TextError::NotStrictlyIncreasing(
                "extension declarations must be ordered, without repetition, by (extension_id, version)"
            ))
        );

        let repeated = projection(&[HEADER, DOCUMENT, ext_a, ext_a]);
        assert!(parse_document(&repeated).is_err());
    }

    #[test]
    fn extension_chunk_roots_must_be_ordered_and_deduplicated() {
        let chunk_a = "(chunk operation-envelope-block (schema 0 1) #xaa)";
        let chunk_b = "(chunk snapshot (schema 0 1) #xbb)";

        let ordered = format!(
            "(extension #x00000000000000000000000000000001 (1 0 0) false ({chunk_a} {chunk_b}) #x #x)"
        );
        let text = projection(&[HEADER, DOCUMENT, &ordered]);
        assert!(parse_document(&text).is_ok());

        let reversed = format!(
            "(extension #x00000000000000000000000000000001 (1 0 0) false ({chunk_b} {chunk_a}) #x #x)"
        );
        let text = projection(&[HEADER, DOCUMENT, &reversed]);
        assert_eq!(
            parse_document(&text),
            Err(TextError::NotStrictlyIncreasing(
                "an extension's preserved chunk roots must be ordered, without repetition, by \
                 projected form"
            ))
        );

        let duplicated = format!(
            "(extension #x00000000000000000000000000000001 (1 0 0) false ({chunk_a} {chunk_a}) #x #x)"
        );
        let text = projection(&[HEADER, DOCUMENT, &duplicated]);
        assert!(parse_document(&text).is_err());
    }

    #[test]
    fn envelope_lines_must_be_in_canonical_reduction_order() {
        let e1 = sample_envelope(1, 1, 10);
        let e2 = sample_envelope(2, 1, 20);
        let e1_line = project_envelope(&e1);
        let e2_line = project_envelope(&e2);

        // Ascending physical time is the canonical order: accepted.
        let ok = projection(&[HEADER, DOCUMENT, &e1_line, &e2_line]);
        let parsed = parse_document(&ok).expect("already-canonical envelope order is accepted");
        assert_eq!(parsed.envelopes, vec![e1, e2]);

        // Swapped: no longer canonical reduction order, rejected outright.
        let swapped = projection(&[HEADER, DOCUMENT, &e2_line, &e1_line]);
        assert_eq!(
            parse_document(&swapped),
            Err(TextError::NotStrictlyIncreasing(
                "envelope lines must appear in canonical reduction order \
                 (epiphany_ops::canonical_reduction_order)"
            ))
        );
    }

    #[test]
    fn profile_id_accepts_every_closed_symbol_and_the_custom_form() {
        for (text, expected) in [
            ("full", ProfileId::Full),
            ("read-only", ProfileId::ReadOnly),
            ("lite", ProfileId::Lite),
            (
                "(custom #x00000000000000000000000000000009)",
                ProfileId::Custom(ProfileRegistryId([
                    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9,
                ])),
            ),
        ] {
            let sexp = read_sexp(text).unwrap();
            assert_eq!(parse_profile_id(&sexp).unwrap(), expected);
        }
    }

    #[test]
    fn profile_id_rejects_an_unknown_symbol_and_a_malformed_custom_id() {
        assert!(parse_profile_id(&read_sexp("bespoke").unwrap()).is_err());
        assert!(parse_profile_id(&read_sexp("(custom #x00)").unwrap()).is_err());
    }

    #[test]
    fn chunk_kind_covers_exactly_the_nine_variants() {
        for (name, kind) in [
            (
                "operation-envelope-block",
                ChunkKind::OperationEnvelopeBlock,
            ),
            ("operation-index", ChunkKind::OperationIndex),
            ("snapshot", ChunkKind::Snapshot),
            ("blob", ChunkKind::Blob),
            ("extension-data", ChunkKind::ExtensionData),
            ("text-projection", ChunkKind::TextProjection),
            ("layout-cache", ChunkKind::LayoutCache),
            ("integrity-index", ChunkKind::IntegrityIndex),
            ("manifest", ChunkKind::Manifest),
        ] {
            let sexp = read_sexp(name).unwrap();
            assert_eq!(parse_chunk_kind(&sexp).unwrap(), kind);
        }
        assert!(parse_chunk_kind(&read_sexp("unknown-kind").unwrap()).is_err());
    }

    // -----------------------------------------------------------------
    // The suite's own reach.
    // -----------------------------------------------------------------

    /// Distinct rejection classes this file's tests exercise. Each entry
    /// corresponds to a check in `parse_document` or a leaf parser above that
    /// rejects rather than normalizes; the list documents, and the assertion
    /// pins, how many of them this file actually drives to a rejection.
    const EXERCISED_REJECTION_CLASSES: &[&str] = &[
        "non-canonical header version",
        "a (blob ...) line, wherever it appears",
        "missing final U+000A",
        "an extra trailing U+000A",
        "missing header line",
        "missing document line",
        "repeated header line",
        "repeated document line",
        "a section line before the mandatory document line",
        "a section reappearing after a later section (lineage after profile)",
        "an extension before the profile section",
        "a canonical-base before the extension section",
        "an envelope before the canonical base",
        "profile declarations out of (profile_id, version) order",
        "duplicate profile declaration key",
        "extension declarations out of (extension_id, version) order",
        "duplicate extension declaration key",
        "extension chunk roots out of projected-form order",
        "duplicate extension chunk roots",
        "envelope lines out of canonical reduction order",
        "an unknown ProfileId symbol",
        "a malformed custom ProfileId registry id",
        "an unknown ChunkKind symbol",
    ];

    #[test]
    fn the_suite_s_reach_is_counted() {
        assert_eq!(
            EXERCISED_REJECTION_CLASSES.len(),
            23,
            "update this count, and add a test, whenever a new rejection class is exercised"
        );
    }
}
