//! Whole-document Text Projection conformance vectors.
//!
//! The committed corpus is generated here, inside `epiphany-textproj`, because
//! malformed documents must be constructed without going through the public
//! projection API that canonicalizes outbound data. Each row carries a complete
//! UTF-8 document and a normative accept/reject verdict.
//!
//! The accept contract is the byte-checkable, text-quantified equation
//!
//! `project(serialize(parse(T))) == T`.
//!
//! It is deliberately not bundle identity. Bundle -> text -> bundle collapses
//! duplicate blobs, regenerates physical layout (offsets, compression, and
//! chunking), and drops non-canonical accelerators. Those erasures preserve
//! semantics but make a bundle-byte comparison both false and misleading.

use std::collections::BTreeMap;

use epiphany_bundle::{
    ChunkKind, DocumentId, ExtensionId, FileUuid, FrontierBytes, LineageId, MemStore,
    ProfileConstraints, ProfileDeclaration, ProfileId, ProfileRegistryId,
    ReductionAlgorithmVersion, SchemaVersion, SemVer, SnapshotId,
};
use epiphany_core::{OperationId, RegionId, ReplicaId, WallClockTime};
use epiphany_ops::{
    AuthorId, CausalContext, DeleteRegionOp, HybridLogicalClock, OperationEnvelope, OperationKind,
    OperationPayload, OperationStamp,
};

use crate::parse::parse_document;
use crate::project::{project_bundle, project_text_document};
use crate::serialize::serialize_document;
use crate::{TextCanonicalBase, TextChunk, TextDocument, TextExtension};

/// The corpus surface owned by this crate.
pub const SURFACE: &str = "textproj.document";
/// The committed whole-document corpus.
pub const COMMITTED: &str = include_str!("../../../spec/vectors/textproj_document_vectors.txt");
/// The path regenerated by the vector generator, relative to the workspace root.
pub const PATH: &str = "spec/vectors/textproj_document_vectors.txt";

const HEADER: &str = "\
# Epiphany Text Projection document vectors — format version 1
#
# Generated. Regenerate with:
#     cargo run -q -p epiphany-testkit --example generate_vectors
# `epiphany_textproj::vectors::the_committed_corpus_matches_the_generator` fails
# on drift, so a projection change must land here deliberately.
#
# One vector per line, space-separated:
#
#     <surface>  <verdict>  <class>  <name>  <utf8-hex>
#
# NORMATIVE: Text Projection companion, `req:textproj:roundtrip` and
# `req:textproj:strict-parse`. Every `accept` text must satisfy the exact-byte
# equation project(serialize(parse(T))) == T. Every `reject` text must be
# refused, not accepted and normalized to a different canonical spelling.
#
# The equation quantifies over TEXTS, not bundle bytes. Binary layout is freely
# regenerated, duplicate blobs collapse, and non-canonical accelerators vanish;
# all three preserve semantics and none weakens this text identity check.
#
# `class` names the rejection exercised. It is informative; the verdict and
# document bytes are normative. `<utf8-hex>` is lowercase with no separators.
";

/// One generated vector: `(surface, verdict, class, name, UTF-8 bytes)`.
pub type TextVector = (
    &'static str,
    &'static str,
    &'static str,
    &'static str,
    Vec<u8>,
);

/// The typed outcome of checking one whole document.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckVerdict {
    /// The parser refused the text.
    Rejected(String),
    /// The parser accepted it and the byte-checkable equation held exactly.
    AcceptedCanonical,
    /// The parser accepted it, but serialization/projection either failed or
    /// produced different bytes. This is acceptance, never rejection.
    AcceptedNonCanonical(String),
}

/// One parsed row from the committed text file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
    /// Owning conformance surface.
    pub surface: String,
    /// Normative `accept` or `reject` verdict.
    pub verdict: String,
    /// Informative rejection class, or `-` for accepts.
    pub class: String,
    /// Stable vector name.
    pub name: String,
    /// Complete UTF-8 document bytes.
    pub text: Vec<u8>,
}

/// Coverage counts asserted by the corpus gate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReachCounts {
    /// Number of accepted documents containing at least one extension.
    pub extensions: usize,
    /// Number containing a canonical base.
    pub canonical_bases: usize,
    /// Number declaring a custom profile.
    pub custom_profiles: usize,
    /// Number carrying a lineage id.
    pub lineages: usize,
    /// Number carrying more than one envelope.
    pub multi_envelope: usize,
    /// Actually rejected vectors per declared rejection class.
    pub reject_classes: BTreeMap<String, usize>,
}

/// The exact non-vacuity contract of this corpus: four accepted documents, two
/// reaching each optional/rich feature, and one actually rejected vector for
/// each of the nine distinct rejection classes implemented by this layer.
pub fn expected_reach() -> ReachCounts {
    ReachCounts {
        extensions: 2,
        canonical_bases: 2,
        custom_profiles: 2,
        lineages: 2,
        multi_envelope: 2,
        reject_classes: [
            ("blob-line", 1),
            ("extension-chunk-order", 1),
            ("extension-declaration-order", 1),
            ("missing-trailing-lf", 1),
            ("operation-envelope-order", 1),
            ("out-of-order-sections", 1),
            ("profile-declaration-order", 1),
            ("repeated-singular-section", 1),
            ("wrong-header-version", 1),
        ]
        .into_iter()
        .map(|(class, count)| (class.to_owned(), count))
        .collect(),
    }
}

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),
        })),
    }
}

fn profiles(custom: bool) -> Vec<ProfileDeclaration> {
    let mut profiles = vec![ProfileDeclaration::full()];
    if custom {
        profiles.push(ProfileDeclaration {
            profile_id: ProfileId::Custom(ProfileRegistryId([0xCC; 16])),
            version: SemVer::new(1, 2, 3),
            constraints: ProfileConstraints::DEFAULT_FULL,
        });
    }
    profiles
}

fn extension(id_byte: u8, payloads: &[u8]) -> TextExtension {
    TextExtension {
        extension_id: ExtensionId([id_byte; 16]),
        version: SemVer::new(1, 0, u32::from(id_byte)),
        required: false,
        chunks: payloads
            .iter()
            .map(|byte| TextChunk {
                kind: ChunkKind::ExtensionData,
                schema_version: SchemaVersion::V0,
                payload: vec![*byte],
            })
            .collect(),
        affected_object_kinds: vec![id_byte],
        edit_barriers: vec![id_byte, id_byte.wrapping_add(1)],
    }
}

fn base(snapshot_byte: u8) -> TextCanonicalBase {
    TextCanonicalBase {
        snapshot_id: SnapshotId([snapshot_byte; 16]),
        covers_causal_frontier: FrontierBytes::from_bytes(vec![1, 2, snapshot_byte]),
        reduction_algorithm_version: ReductionAlgorithmVersion(1),
        profile_id: ProfileId::Full,
        root_schema_version: SchemaVersion::V0,
        root_payload: vec![0xA0, snapshot_byte],
    }
}

fn accept_documents() -> Vec<(&'static str, String)> {
    let minimal = TextDocument {
        document_id: DocumentId([1; 16]),
        manifest_schema_version: SchemaVersion::V0,
        lineage_id: None,
        profiles: profiles(false),
        extensions: Vec::new(),
        canonical_base: None,
        blobs: Vec::new(),
        envelopes: Vec::new(),
    };
    let lineage_custom = TextDocument {
        document_id: DocumentId([2; 16]),
        manifest_schema_version: SchemaVersion::V0,
        lineage_id: Some(LineageId([0x12; 16])),
        profiles: profiles(true),
        extensions: Vec::new(),
        canonical_base: None,
        blobs: Vec::new(),
        envelopes: vec![sample_envelope(1, 100)],
    };
    let extension_base_multi = TextDocument {
        document_id: DocumentId([3; 16]),
        // A non-baseline carried version, so the corpus exercises the
        // `document` line's schema field at a value other than the
        // ubiquitous baseline (G-minor pin 8/11).
        manifest_schema_version: SchemaVersion::new(0, 8),
        lineage_id: None,
        profiles: profiles(false),
        extensions: vec![extension(1, &[1, 2])],
        canonical_base: Some(base(3)),
        blobs: Vec::new(),
        envelopes: vec![sample_envelope(2, 200), sample_envelope(3, 300)],
    };
    let rich = TextDocument {
        document_id: DocumentId([4; 16]),
        manifest_schema_version: SchemaVersion::V0,
        lineage_id: Some(LineageId([0x14; 16])),
        profiles: profiles(true),
        extensions: vec![extension(1, &[1, 2]), extension(2, &[3])],
        canonical_base: Some(base(4)),
        blobs: Vec::new(),
        envelopes: vec![
            sample_envelope(4, 400),
            sample_envelope(5, 500),
            sample_envelope(6, 600),
        ],
    };

    vec![
        ("minimal", project_text_document(&minimal)),
        (
            "lineage_custom_profile",
            project_text_document(&lineage_custom),
        ),
        (
            "extension_base_two_envelopes",
            project_text_document(&extension_base_multi),
        ),
        ("rich_document", project_text_document(&rich)),
    ]
}

fn replace_once(text: &str, anchor: &str, replacement: &str) -> String {
    assert!(
        text.contains(anchor),
        "vector-construction anchor is absent: {anchor}"
    );
    text.replacen(anchor, replacement, 1)
}

fn lines_with_final_lf(lines: &[String]) -> String {
    let mut text = lines.join("\n");
    text.push('\n');
    text
}

fn swap_first_lines(text: &str, first_head: &str, second_head: &str) -> String {
    let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
    let first = lines
        .iter()
        .position(|line| line.starts_with(first_head))
        .unwrap_or_else(|| panic!("vector-construction line is absent: {first_head}"));
    let second = lines
        .iter()
        .position(|line| line.starts_with(second_head))
        .unwrap_or_else(|| panic!("vector-construction line is absent: {second_head}"));
    lines.swap(first, second);
    lines_with_final_lf(&lines)
}

fn swap_first_two_lines(text: &str, head: &str) -> String {
    let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
    let matches: Vec<usize> = lines
        .iter()
        .enumerate()
        .filter_map(|(index, line)| line.starts_with(head).then_some(index))
        .collect();
    assert!(
        matches.len() >= 2,
        "vector construction needs two `{head}` lines"
    );
    lines.swap(matches[0], matches[1]);
    lines_with_final_lf(&lines)
}

fn duplicate_first_line(text: &str, head: &str) -> String {
    let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
    let at = lines
        .iter()
        .position(|line| line.starts_with(head))
        .unwrap_or_else(|| panic!("vector-construction line is absent: {head}"));
    let duplicate = lines[at].clone();
    lines.insert(at + 1, duplicate);
    lines_with_final_lf(&lines)
}

/// Every whole-document vector in stable committed order.
pub fn document_vectors() -> Vec<TextVector> {
    let accepts = accept_documents();
    let minimal = &accepts[0].1;
    let lineage_custom = &accepts[1].1;
    let extension_base_multi = &accepts[2].1;
    let rich = &accepts[3].1;

    let mut vectors: Vec<TextVector> = accepts
        .iter()
        .map(|(name, text)| (SURFACE, "accept", "-", *name, text.as_bytes().to_vec()))
        .collect();

    // The rejected version must be one this crate does NOT implement. The
    // G-minor rung moved `COMPANION_VERSION` to 0.10.0, which had been this
    // vector's "future" version — leaving it would have made the negative
    // vector assert that the *correct* header is rejected. It now names
    // 0.9.0, the immediately superseded companion, which is the better test
    // anyway: rejecting the version right behind you is exactly the deferred
    // migrate-on-read posture (`req:textproj:header-version`).
    let wrong_version = replace_once(
        minimal,
        "(text-projection (0 10 0))",
        "(text-projection (0 9 0))",
    );
    vectors.push((
        SURFACE,
        "reject",
        "wrong-header-version",
        "superseded_companion_version",
        wrong_version.into_bytes(),
    ));

    let blob = replace_once(
        minimal,
        "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))\n",
        "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))\n\
         (blob \"application/octet-stream\" () #x01)\n",
    );
    vectors.push((
        SURFACE,
        "reject",
        "blob-line",
        "unreferenced_blob",
        blob.into_bytes(),
    ));

    vectors.push((
        SURFACE,
        "reject",
        "out-of-order-sections",
        "canonical_base_before_extension",
        swap_first_lines(extension_base_multi, "(extension ", "(canonical-base ").into_bytes(),
    ));

    vectors.push((
        SURFACE,
        "reject",
        "repeated-singular-section",
        "lineage_repeated",
        duplicate_first_line(lineage_custom, "(lineage ").into_bytes(),
    ));

    vectors.push((
        SURFACE,
        "reject",
        "operation-envelope-order",
        "envelopes_reversed",
        swap_first_two_lines(extension_base_multi, "(envelope ").into_bytes(),
    ));

    vectors.push((
        SURFACE,
        "reject",
        "profile-declaration-order",
        "profiles_reversed",
        swap_first_two_lines(lineage_custom, "(profile ").into_bytes(),
    ));

    vectors.push((
        SURFACE,
        "reject",
        "extension-declaration-order",
        "extensions_reversed",
        swap_first_two_lines(rich, "(extension ").into_bytes(),
    ));

    let chunks_reversed = replace_once(
        extension_base_multi,
        "((chunk extension-data (schema 0 1) #x01) (chunk extension-data (schema 0 1) #x02))",
        "((chunk extension-data (schema 0 1) #x02) (chunk extension-data (schema 0 1) #x01))",
    );
    vectors.push((
        SURFACE,
        "reject",
        "extension-chunk-order",
        "extension_chunks_reversed",
        chunks_reversed.into_bytes(),
    ));

    let mut missing_lf = minimal.as_bytes().to_vec();
    assert_eq!(missing_lf.pop(), Some(b'\n'), "accept text ends in LF");
    vectors.push((
        SURFACE,
        "reject",
        "missing-trailing-lf",
        "final_lf_missing",
        missing_lf,
    ));

    vectors
}

fn to_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

fn from_hex(hex: &str) -> Option<Vec<u8>> {
    if hex.len() % 2 != 0 {
        return None;
    }
    (0..hex.len())
        .step_by(2)
        .map(|at| u8::from_str_radix(&hex[at..at + 2], 16).ok())
        .collect()
}

/// Renders the drift-locked corpus file.
pub fn render() -> String {
    let mut out = String::from(HEADER);
    out.push_str("\n# textproj.document\n");
    for (surface, verdict, class, name, text) in document_vectors() {
        out.push_str(&format!(
            "{surface} {verdict} {class} {name} {}\n",
            to_hex(&text)
        ));
    }
    out
}

/// Parses a corpus file, skipping comments and blank lines.
pub fn parse(text: &str) -> Result<Vec<Row>, String> {
    let mut rows = Vec::new();
    for (index, line) in text.lines().enumerate() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let fields: Vec<&str> = line.split_whitespace().collect();
        if fields.len() != 5 {
            return Err(format!(
                "line {}: expected 5 columns, got {}",
                index + 1,
                fields.len()
            ));
        }
        rows.push(Row {
            surface: fields[0].to_owned(),
            verdict: fields[1].to_owned(),
            class: fields[2].to_owned(),
            name: fields[3].to_owned(),
            text: from_hex(fields[4]).ok_or_else(|| format!("line {}: bad hex", index + 1))?,
        });
    }
    Ok(rows)
}

/// Checks one corpus surface without collapsing parser rejection with accepted
/// normalization. `None` means this crate does not own `surface`.
pub fn check(surface: &str, text: &[u8]) -> Option<CheckVerdict> {
    if surface != SURFACE {
        return None;
    }
    let text = match std::str::from_utf8(text) {
        Ok(text) => text,
        Err(error) => return Some(CheckVerdict::Rejected(format!("non-UTF-8 text: {error}"))),
    };
    let document = match parse_document(text) {
        Ok(document) => document,
        Err(error) => return Some(CheckVerdict::Rejected(error.to_string())),
    };
    let bundle = match serialize_document(&document, MemStore::new(), FileUuid([0x7E; 16])) {
        Ok(bundle) => bundle,
        Err(error) => {
            return Some(CheckVerdict::AcceptedNonCanonical(format!(
                "parse accepted, but serialization failed: {error}"
            )))
        }
    };
    let projected = match project_bundle(&bundle) {
        Ok(projected) => projected,
        Err(error) => {
            return Some(CheckVerdict::AcceptedNonCanonical(format!(
                "parse accepted, but re-projection failed: {error}"
            )))
        }
    };
    if projected.as_bytes() == text.as_bytes() {
        Some(CheckVerdict::AcceptedCanonical)
    } else {
        Some(CheckVerdict::AcceptedNonCanonical(
            "parse accepted text that re-projected to different bytes".to_owned(),
        ))
    }
}

fn verify_rows(rows: &[Row]) -> Vec<String> {
    let mut failures = Vec::new();
    for row in rows {
        let Some(actual) = check(&row.surface, &row.text) else {
            failures.push(format!("{}: no checker owns this surface", row.surface));
            continue;
        };
        match (row.verdict.as_str(), actual) {
            ("accept", CheckVerdict::AcceptedCanonical) | ("reject", CheckVerdict::Rejected(_)) => {
            }
            ("accept", CheckVerdict::AcceptedNonCanonical(detail)) => failures.push(format!(
                "{}/{}: accepted, but not canonical: {detail}",
                row.surface, row.name
            )),
            ("reject", CheckVerdict::AcceptedNonCanonical(detail)) => failures.push(format!(
                "{}/{} ({}): declared reject, but was ACCEPTED and normalized: {detail}",
                row.surface, row.name, row.class
            )),
            ("reject", CheckVerdict::AcceptedCanonical) => failures.push(format!(
                "{}/{} ({}): declared reject, but was ACCEPTED as canonical",
                row.surface, row.name, row.class
            )),
            ("accept", CheckVerdict::Rejected(error)) => failures.push(format!(
                "{}/{}: declared accept, but was rejected: {error}",
                row.surface, row.name
            )),
            (other, actual) => failures.push(format!(
                "{}/{}: unknown verdict {other}, got {actual:?}",
                row.surface, row.name
            )),
        }
    }
    failures
}

/// Measures the corpus's actual reach. Reject counts include only vectors the
/// parser really refused; an accidentally canonicalized reject therefore drops
/// its class to zero instead of receiving credit from its label.
pub fn reach(text: &str) -> Result<ReachCounts, Vec<String>> {
    let rows = parse(text).map_err(|error| vec![error])?;
    let mut reach = ReachCounts {
        extensions: 0,
        canonical_bases: 0,
        custom_profiles: 0,
        lineages: 0,
        multi_envelope: 0,
        reject_classes: BTreeMap::new(),
    };
    let mut failures = Vec::new();

    for row in &rows {
        match row.verdict.as_str() {
            "accept" => {
                let text = match std::str::from_utf8(&row.text) {
                    Ok(text) => text,
                    Err(error) => {
                        failures.push(format!("{}/{}: {error}", row.surface, row.name));
                        continue;
                    }
                };
                let document = match parse_document(text) {
                    Ok(document) => document,
                    Err(error) => {
                        failures.push(format!("{}/{}: {error}", row.surface, row.name));
                        continue;
                    }
                };
                reach.extensions += usize::from(!document.extensions.is_empty());
                reach.canonical_bases += usize::from(document.canonical_base.is_some());
                reach.custom_profiles += usize::from(
                    document
                        .profiles
                        .iter()
                        .any(|profile| matches!(profile.profile_id, ProfileId::Custom(_))),
                );
                reach.lineages += usize::from(document.lineage_id.is_some());
                reach.multi_envelope += usize::from(document.envelopes.len() > 1);
            }
            "reject" => {
                if matches!(
                    check(&row.surface, &row.text),
                    Some(CheckVerdict::Rejected(_))
                ) {
                    *reach.reject_classes.entry(row.class.clone()).or_default() += 1;
                }
            }
            other => failures.push(format!(
                "{}/{}: unknown verdict {other}",
                row.surface, row.name
            )),
        }
    }

    if failures.is_empty() {
        Ok(reach)
    } else {
        Err(failures)
    }
}

/// Verifies every verdict and the exact non-vacuity reach contract, returning
/// the number of checked whole documents or all disagreements.
pub fn verify(text: &str) -> Result<usize, Vec<String>> {
    let rows = parse(text).map_err(|error| vec![error])?;
    let mut failures = verify_rows(&rows);
    match reach(text) {
        Ok(actual) if actual == expected_reach() => {}
        Ok(actual) => failures.push(format!(
            "corpus reach mismatch: expected {:?}, got {actual:?}",
            expected_reach()
        )),
        Err(mut errors) => failures.append(&mut errors),
    }
    if failures.is_empty() {
        Ok(rows.len())
    } else {
        Err(failures)
    }
}

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

    #[test]
    fn the_committed_corpus_matches_the_generator() {
        assert_eq!(
            COMMITTED,
            render(),
            "\n{PATH} is stale. Regenerate:\n    \
             cargo run -q -p epiphany-testkit --example generate_vectors\n"
        );
    }

    #[test]
    fn the_reference_implementation_agrees_with_every_vector() {
        match verify(COMMITTED) {
            Ok(count) => assert_eq!(count, 13, "the corpus has unexpectedly thinned"),
            Err(failures) => panic!(
                "{} disagreement(s):\n{}",
                failures.len(),
                failures.join("\n")
            ),
        }
    }

    /// This test intentionally names TEXT identity. Comparing either bundle's
    /// bytes would be wrong: duplicate blobs collapse, layout is regenerated,
    /// and non-canonical accelerators vanish while semantics remain unchanged.
    #[test]
    fn every_accept_vector_satisfies_the_text_quantified_byte_equation() {
        for row in parse(COMMITTED)
            .expect("the committed corpus parses")
            .into_iter()
            .filter(|row| row.verdict == "accept")
        {
            assert_eq!(
                check(&row.surface, &row.text),
                Some(CheckVerdict::AcceptedCanonical),
                "{}/{} does not satisfy project(serialize(parse(T))) == T",
                row.surface,
                row.name
            );
        }
    }

    #[test]
    fn the_corpus_reach_is_exact_and_non_vacuous() {
        assert_eq!(
            reach(COMMITTED).expect("the committed corpus has measurable reach"),
            expected_reach(),
            "the corpus must reach 2 extensions, 2 canonical bases, 2 custom profiles, \
             2 lineages, 2 multi-envelope documents, and one real rejection in each class"
        );
    }
}
