//! QF2-F — Target-doc guard expansion (Quality Factory V2).
//!
//! This file promotes load-bearing invariants discovered across XFA
//! Waves 1+2+3 and Quality Factory V1 (golden 15-doc suite + IRCC IMM
//! family + variables-text data items) into durable regression guards.
//!
//! Existing explicit corpus guards (kept untouched, cross-referenced here):
//! - `13275420.pdf` → ≥ 8 pages
//!   (`corpus_13275420_pagearea_expansion_holds`,
//!   `m3b_phaseD_layout2_pagearea_general.rs`)
//! - `2ff85101.pdf` → `inst_writes = 4`
//!   (`inst_mgr_real_mutations.rs`)
//! - `60df78fe.pdf` → caption `resolve_failures = 0`
//!   (`data_binding_longtail_residual.rs`,
//!   `longtail_caption_sentinel_does_not_bump_resolve_failures`)
//! - `0bf81862_imm5709e.pdf` golden byte-identical
//!   (`xfa-golden-tests`)
//!
//! New guards added here:
//! 1. **Golden 15-doc resolve-failure floor.** Flattening every committed
//!    golden PDF MUST report `js_resolve_failures == 0`. Pins QF1 Phase 1's
//!    measured invariant (Quality Factory V1 final report).
//! 2. **Golden 15-doc page-count floor vs oracle.** Flattening every
//!    committed golden PDF MUST emit ≥ `oracle.page_count` pages. Promotes
//!    the QF1-B `xfa_post_flatten_pagecount.py` smoke into a hard test.
//! 3. **Golden 15-doc runtime-error / OOM / timeout floor.** No golden
//!    document may flatten with `js_runtime_errors`, `js_oom`, or
//!    `js_timeouts` strictly positive. Sandbox-stability gate.
//! 4. **IMM family variables-text registration (W3-D pattern).** The
//!    IRCC `imm5709e` / `imm5257e` / `imm5710e` / `imm5645e` goldens MUST
//!    contain a `<text name="globValidatePressed"/>` data-item declaration
//!    in their template packet. Pins the W3-D RETRY landing surface (XFA
//!    3.3 §5.5.2): if the upstream forms ever stop declaring this variable,
//!    the cluster-residual fix loses its proof. Pure data-shape guard,
//!    no engine call — robust against any future engine refactor.
//!
//! All four guards operate exclusively on the committed `crates/xfa-
//! golden-tests/golden/*.pdf` fixtures (≤ 1 MB each, already in tree). No
//! VPS dependency, no corpus snapshot, no network.
//!
//! Cross-refs:
//! - `benchmarks/runs/xfa_enterprise_plan/quality_factory_v2/QF2_EXECUTION_PLAN.md`
//!   (track QF2-F)
//! - `benchmarks/runs/xfa_enterprise_plan/quality_factory_v2/PHASE0_BASELINE.md`
//! - `scripts/xfa_post_flatten_pagecount.py` (QF1-B)

use std::path::{Path, PathBuf};

use lopdf::Document;
use pdf_xfa::extract::extract_xfa_from_bytes;
use pdf_xfa::flatten_xfa_to_pdf_with_metadata;

/// Path to the 15-doc golden fixture directory, relative to this crate.
fn golden_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../xfa-golden-tests/golden")
}

/// Lightweight oracle row parsed out of the sidecar JSON. We avoid pulling
/// in `serde_json` as a test-only dep by parsing the two integer fields we
/// need with a hand-rolled grep — the JSON shape is stable and committed.
#[derive(Debug, Clone)]
struct GoldenEntry {
    pdf_filename: String,
    json_filename: String,
    oracle_page_count: usize,
}

/// Discover all committed golden PDFs with their sidecar oracle JSON. The
/// set is locked to the 15 files committed at `xfa/qf2-plan` tip — any new
/// addition will appear here automatically, any deletion will surface as a
/// missing-sidecar panic.
fn discover_goldens() -> Vec<GoldenEntry> {
    let dir = golden_dir();
    let mut entries = Vec::new();
    for entry in std::fs::read_dir(&dir).expect("read golden dir") {
        let entry = entry.expect("dir entry");
        let path = entry.path();
        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
            continue;
        };
        if ext != "pdf" {
            continue;
        }
        let pdf_filename = path
            .file_name()
            .and_then(|n| n.to_str())
            .expect("pdf filename utf-8")
            .to_string();
        let json_filename = pdf_filename.replace(".pdf", ".json");
        let json_path = dir.join(&json_filename);
        let json_text = std::fs::read_to_string(&json_path)
            .unwrap_or_else(|e| panic!("read sidecar {json_filename}: {e}"));
        let oracle_page_count = parse_page_count(&json_text)
            .unwrap_or_else(|| panic!("sidecar {json_filename} missing or unparseable page_count"));
        entries.push(GoldenEntry {
            pdf_filename,
            json_filename,
            oracle_page_count,
        });
    }
    entries.sort_by(|a, b| a.pdf_filename.cmp(&b.pdf_filename));
    assert!(
        entries.len() >= 15,
        "golden set must have ≥ 15 PDFs (committed baseline); found {}",
        entries.len()
    );
    entries
}

/// Tiny extractor for `"page_count": <usize>` in the sidecar JSON. The
/// committed sidecars are flat one-object-per-line JSON with the field
/// present unquoted; the extractor only accepts the canonical shape so
/// any oracle drift is loud.
fn parse_page_count(json_text: &str) -> Option<usize> {
    let key = "\"page_count\"";
    let idx = json_text.find(key)?;
    let rest = &json_text[idx + key.len()..];
    let after_colon = rest.split_once(':')?.1.trim_start();
    let digits: String = after_colon
        .chars()
        .take_while(|c| c.is_ascii_digit())
        .collect();
    digits.parse().ok()
}

// ---------------------------------------------------------------------------
// Guard 1 — Golden 15-doc resolve-failure floor (QF1 Phase 1 invariant)
// ---------------------------------------------------------------------------

/// Every committed golden PDF MUST flatten with `js_resolve_failures == 0`.
///
/// Quality Factory V1 Phase 1 measured this across the full set (see
/// `benchmarks/runs/xfa_enterprise_plan/quality_factory_v1/...` VERDICT_GREEN
/// final report). Until QF2-F this invariant lived only in the report; it
/// is now pinned in CI.
#[test]
fn golden_15_set_resolve_failures_zero() {
    let mut failures: Vec<String> = Vec::new();
    for entry in discover_goldens() {
        let pdf_path = golden_dir().join(&entry.pdf_filename);
        let pdf_bytes = std::fs::read(&pdf_path).expect("read golden pdf");
        let (_flat_bytes, metadata) = match flatten_xfa_to_pdf_with_metadata(&pdf_bytes) {
            Ok(v) => v,
            Err(e) => {
                failures.push(format!("{}: flatten failed: {e}", entry.pdf_filename));
                continue;
            }
        };
        if metadata.dynamic_scripts.js_resolve_failures != 0 {
            failures.push(format!(
                "{}: js_resolve_failures = {} (must be 0; QF1 Phase 1 invariant)",
                entry.pdf_filename, metadata.dynamic_scripts.js_resolve_failures,
            ));
        }
    }
    assert!(
        failures.is_empty(),
        "QF2-F guard 1 violated for {} doc(s):\n{}",
        failures.len(),
        failures.join("\n"),
    );
}

// ---------------------------------------------------------------------------
// Guard 2 — Golden 15-doc page-count floor vs oracle (QF1-B promotion)
// ---------------------------------------------------------------------------

/// Every committed golden PDF MUST flatten to ≥ `oracle.page_count` pages.
///
/// Promotes the QF1-B `scripts/xfa_post_flatten_pagecount.py` smoke into a
/// hard test. Using "≥ oracle" (rather than "== oracle") preserves the
/// freedom to over-paginate when the engine learns a new pageArea expansion
/// case — under-pagination is the bug to catch.
#[test]
fn golden_15_set_page_count_at_oracle_floor() {
    let mut failures: Vec<String> = Vec::new();
    for entry in discover_goldens() {
        let pdf_path = golden_dir().join(&entry.pdf_filename);
        let pdf_bytes = std::fs::read(&pdf_path).expect("read golden pdf");
        let (flat_bytes, _metadata) = match flatten_xfa_to_pdf_with_metadata(&pdf_bytes) {
            Ok(v) => v,
            Err(e) => {
                failures.push(format!("{}: flatten failed: {e}", entry.pdf_filename));
                continue;
            }
        };
        let flat_doc = match Document::load_mem(&flat_bytes) {
            Ok(d) => d,
            Err(e) => {
                failures.push(format!(
                    "{}: cannot reload flattened bytes: {e}",
                    entry.pdf_filename
                ));
                continue;
            }
        };
        let page_count = flat_doc.get_pages().len();
        if page_count < entry.oracle_page_count {
            failures.push(format!(
                "{}: flatten produced {} pages, oracle ({}) expects ≥ {}",
                entry.pdf_filename, page_count, entry.json_filename, entry.oracle_page_count,
            ));
        }
    }
    assert!(
        failures.is_empty(),
        "QF2-F guard 2 violated for {} doc(s):\n{}",
        failures.len(),
        failures.join("\n"),
    );
}

// ---------------------------------------------------------------------------
// Guard 3 — Golden 15-doc runtime-error / OOM / timeout floor
// ---------------------------------------------------------------------------

/// Every committed golden PDF MUST flatten with no QuickJS runtime errors,
/// no OOM, and no timeouts. This pins sandbox stability across the canonical
/// suite. A regression flipping any of these to ≥ 1 would surface as a
/// reliability cliff before reaching the corpus replay.
#[test]
fn golden_15_set_no_runtime_errors_or_oom_or_timeouts() {
    let mut failures: Vec<String> = Vec::new();
    for entry in discover_goldens() {
        let pdf_path = golden_dir().join(&entry.pdf_filename);
        let pdf_bytes = std::fs::read(&pdf_path).expect("read golden pdf");
        let (_flat_bytes, metadata) = match flatten_xfa_to_pdf_with_metadata(&pdf_bytes) {
            Ok(v) => v,
            Err(e) => {
                failures.push(format!("{}: flatten failed: {e}", entry.pdf_filename));
                continue;
            }
        };
        let outcome = metadata.dynamic_scripts;
        if outcome.js_runtime_errors != 0 || outcome.js_oom != 0 || outcome.js_timeouts != 0 {
            failures.push(format!(
                "{}: js_runtime_errors={} js_oom={} js_timeouts={} (all must be 0)",
                entry.pdf_filename, outcome.js_runtime_errors, outcome.js_oom, outcome.js_timeouts,
            ));
        }
    }
    assert!(
        failures.is_empty(),
        "QF2-F guard 3 violated for {} doc(s):\n{}",
        failures.len(),
        failures.join("\n"),
    );
}

// ---------------------------------------------------------------------------
// Guard 4 — IMM family `<text name="globValidatePressed"/>` declared (W3-D)
// ---------------------------------------------------------------------------

/// The IRCC `imm5709e` / `imm5257e` / `imm5710e` goldens MUST declare the
/// `globValidatePressed` `<variables>` text data item in their template
/// packet. The W3-D RETRY landing (XFA 3.3 §5.5.2 — variable data items) is
/// the fix for the `implicit_function` cluster residual on these exact
/// forms; if a future golden refresh ever pulls a form revision that drops
/// the declaration, the W3-D regression coverage silently degrades.
///
/// Scope note: `imm5645e` is in the IMM family but is NOT in the
/// `globValidatePressed` declaration set (see W3-D RETRY decision memo —
/// the 5645 template uses a different validation surface); it is therefore
/// deliberately excluded from the guard set below to keep the invariant
/// faithful to the W3-D landing scope.
///
/// This guard is a pure data-shape check — it does not invoke the engine —
/// so it survives every engine refactor and produces a deterministic signal.
#[test]
fn imm_family_goldens_declare_globvalidatepressed_data_item() {
    // The canonical IRCC golden filenames carrying the W3-D pattern at
    // `xfa/qf2-plan` tip.
    const IMM_FAMILY: &[&str] = &[
        "0bf81862_imm5709e.pdf",
        "5d6e30ad_imm5257e.pdf",
        "ff2723ed_imm5710e.pdf",
    ];

    let dir = golden_dir();
    let mut missing: Vec<String> = Vec::new();

    for filename in IMM_FAMILY {
        let pdf_path = dir.join(filename);
        let pdf_bytes = std::fs::read(&pdf_path).unwrap_or_else(|e| panic!("read {filename}: {e}"));
        let packets = match extract_xfa_from_bytes(pdf_bytes) {
            Ok(p) => p,
            Err(e) => panic!("{filename}: extract_xfa_from_bytes failed: {e}"),
        };
        let template = packets
            .template()
            .unwrap_or_else(|| panic!("{filename}: missing template packet"));

        // Look for the canonical W3-D RETRY pattern. XFA does not pin
        // whitespace, so we look for the attribute substring and verify
        // the surrounding tag context to avoid matching attribute-style
        // event scripts that mention the name.
        let has_text_decl = template.contains("name=\"globValidatePressed\"")
            || template.contains("name='globValidatePressed'");
        // Defence in depth: ensure the declaration sits inside a `<text>`
        // element (variables data item) rather than an unrelated container.
        // The merger code (`crates/pdf-xfa/src/merger.rs` ≈ L209) only
        // honours `<text name="X">` children of `<variables>`.
        let has_text_tag_context = template.contains("<text name=\"globValidatePressed\"")
            || template.contains("<text name='globValidatePressed'");

        if !(has_text_decl && has_text_tag_context) {
            missing.push(format!(
                "{filename}: template missing <text name=\"globValidatePressed\"/> \
                 variables data item (W3-D RETRY invariant; XFA 3.3 §5.5.2)"
            ));
        }
    }
    assert!(
        missing.is_empty(),
        "QF2-F guard 4 violated for {} IMM doc(s):\n{}",
        missing.len(),
        missing.join("\n"),
    );
}
