//! M5.2 — Page Suppression Compat Rule Cluster: integration tests.
//!
//! Each test installs a global trace sink and exercises a synthetic XFA
//! PDF that drives one of the three rules migrated in this commit:
//!
//! - `SuppressEmptyPagesOnlyWhenRealDataBound`
//! - `ExcludeBindNoneFieldsFromPageDataSuppression`
//! - `TrimStaticXfafExcessPagesWhenLayoutIsSinglePageAndFormDomAllows`
//!   (brief alias *StaticXfafExcessPageTrimWithFormDomGuard*).
//!
//! All three rules' trace anchors must fire when the corresponding XDP
//! template is flattened. The tests serialise on the global sink lock to
//! avoid cross-thread races (same pattern as `ux1_trace_compat.rs`).

use std::sync::{Arc, Mutex};

use lopdf::{dictionary, Document, Object, Stream};
use pdf_xfa::flatten_xfa_to_pdf;
use xfa_layout_engine::trace::{with_global_sink, RecordingSink, TraceEvent};

fn global_sink_lock() -> &'static Mutex<()> {
    static LOCK: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn build_xfa_pdf(xdp: &str) -> Vec<u8> {
    let mut doc = Document::with_version("1.4");
    let xdp_bytes = xdp.as_bytes().to_vec();
    let xfa_stream = Stream::new(
        dictionary! { "Length" => Object::Integer(xdp_bytes.len() as i64) },
        xdp_bytes,
    );
    let xfa_id = doc.add_object(Object::Stream(xfa_stream));
    let pages_id = doc.new_object_id();
    let content_id = doc.add_object(Object::Stream(Stream::new(
        dictionary! { "Length" => Object::Integer(0_i64) },
        vec![],
    )));
    let page_id = doc.add_object(Object::Dictionary(dictionary! {
        "Type"     => Object::Name(b"Page".to_vec()),
        "Parent"   => Object::Reference(pages_id),
        "MediaBox" => Object::Array(vec![
            Object::Integer(0), Object::Integer(0),
            Object::Integer(612), Object::Integer(792),
        ]),
        "Contents" => Object::Reference(content_id),
    }));
    doc.objects.insert(
        pages_id,
        Object::Dictionary(dictionary! {
            "Type"  => Object::Name(b"Pages".to_vec()),
            "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
            "Count" => Object::Integer(1),
        }),
    );
    let acroform_id = doc.add_object(Object::Dictionary(dictionary! {
        "XFA" => Object::Reference(xfa_id),
    }));
    let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
        "Type"     => Object::Name(b"Catalog".to_vec()),
        "Pages"    => Object::Reference(pages_id),
        "AcroForm" => Object::Reference(acroform_id),
    }));
    doc.trailer.set("Root", Object::Reference(catalog_id));
    let mut out = Vec::new();
    doc.save_to(&mut out).expect("save lopdf");
    out
}

fn flatten_with_sink(pdf: &[u8]) -> (Vec<u8>, Vec<TraceEvent>) {
    let sink: Arc<RecordingSink> = Arc::new(RecordingSink::new());
    let out = with_global_sink(sink.clone() as _, || flatten_xfa_to_pdf(pdf))
        .expect("flatten should succeed");
    let events = sink.events();
    (out, events)
}

fn has_reason(events: &[TraceEvent], reason: &str) -> bool {
    events.iter().any(|e| e.reason.tag() == reason)
}

fn count_reason(events: &[TraceEvent], reason: &str) -> usize {
    events.iter().filter(|e| e.reason.tag() == reason).count()
}

// ── Rule 1: SuppressEmptyPagesOnlyWhenRealDataBound ────────────────────────

/// Two pages; page 1 is bound from datasets (CustomerName), page 2 is
/// empty. `any_data_bound=true` so the preflight rule must fire the
/// "gated" branch, and the empty page 2 must be dropped.
const XDP_DATA_BOUND_TWO_PAGE: &str = r#"<?xml version="1.0"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
  <template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
    <subform name="form1" layout="paginate" w="8.5in" h="11in">
      <pageSet>
        <pageArea name="Page1">
          <contentArea x="36pt" y="36pt" w="540pt" h="720pt"/>
          <medium stock="default" short="612pt" long="792pt"/>
        </pageArea>
        <pageArea name="Page2">
          <contentArea x="36pt" y="36pt" w="540pt" h="720pt"/>
          <medium stock="default" short="612pt" long="792pt"/>
        </pageArea>
      </pageSet>
      <subform name="section1" layout="tb" w="540pt">
        <field name="CustomerName" w="300pt" h="18pt">
          <ui><textEdit/></ui>
        </field>
      </subform>
      <subform name="section2" layout="tb" w="540pt">
        <breakBefore targetType="pageArea"/>
        <field name="EmptyOptional" w="300pt" h="18pt">
          <ui><textEdit/></ui>
        </field>
      </subform>
    </subform>
  </template>
  <xfa:datasets>
    <xfa:data>
      <form1>
        <CustomerName>Alice Johnson</CustomerName>
      </form1>
    </xfa:data>
  </xfa:datasets>
</xdp:xdp>"#;

#[test]
fn suppress_preflight_gated_when_data_bound() {
    let _guard = global_sink_lock().lock().unwrap_or_else(|e| e.into_inner());
    let pdf = build_xfa_pdf(XDP_DATA_BOUND_TWO_PAGE);
    let (out, events) = flatten_with_sink(&pdf);

    assert!(
        has_reason(&events, "suppress_gated_by_data_bound_signal"),
        "preflight rule must fire `gated` branch on data-bound input"
    );
    assert!(
        !has_reason(&events, "suppress_skipped_no_data_bound_signal"),
        "preflight rule must NOT fire `skipped` branch on data-bound input"
    );
    // Empty page 2 must be dropped — final output has exactly one page.
    let pages = Document::load_mem(&out)
        .expect("reload flattened pdf")
        .get_pages()
        .len();
    assert_eq!(pages, 1, "empty data-bound page must be dropped");
}

/// Same template shape but no `<xfa:datasets>` block — every field's
/// value comes from template defaults, so `any_data_bound=false`. The
/// preflight rule must fire `skipped` and BOTH pages must be retained.
const XDP_NO_DATASETS_TWO_PAGE: &str = r#"<?xml version="1.0"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
  <template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
    <subform name="form1" layout="paginate" w="8.5in" h="11in">
      <pageSet>
        <pageArea name="Page1">
          <contentArea x="36pt" y="36pt" w="540pt" h="720pt"/>
          <medium stock="default" short="612pt" long="792pt"/>
        </pageArea>
        <pageArea name="Page2">
          <contentArea x="36pt" y="36pt" w="540pt" h="720pt"/>
          <medium stock="default" short="612pt" long="792pt"/>
        </pageArea>
      </pageSet>
      <subform name="section1" layout="tb" w="540pt">
        <field name="A" w="300pt" h="18pt">
          <ui><textEdit/></ui>
        </field>
      </subform>
      <subform name="section2" layout="tb" w="540pt">
        <breakBefore targetType="pageArea"/>
        <field name="B" w="300pt" h="18pt">
          <ui><textEdit/></ui>
        </field>
      </subform>
    </subform>
  </template>
</xdp:xdp>"#;

#[test]
fn suppress_preflight_skipped_when_no_data_bound() {
    let _guard = global_sink_lock().lock().unwrap_or_else(|e| e.into_inner());
    let pdf = build_xfa_pdf(XDP_NO_DATASETS_TWO_PAGE);
    let (out, events) = flatten_with_sink(&pdf);

    assert!(
        has_reason(&events, "suppress_skipped_no_data_bound_signal"),
        "preflight rule must fire `skipped` branch on blank-form input"
    );
    let pages = Document::load_mem(&out)
        .expect("reload flattened pdf")
        .get_pages()
        .len();
    assert!(
        pages >= 2,
        "blank-form output must preserve template pages, got {pages}"
    );
}

// ── Rule 2: ExcludeBindNoneFieldsFromPageDataSuppression ───────────────────

/// Two-page form: page 1 has a `<bind match="none">` field (static label);
/// page 2 has a regular data-bound field. The rule must fire its
/// summary event because at least one bind=none field was excluded, and
/// page 1 must NOT be dropped (its only field is static).
const XDP_BIND_NONE_PAGE_KEPT: &str = r#"<?xml version="1.0"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
  <template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
    <subform name="form1" layout="paginate" w="8.5in" h="11in">
      <pageSet>
        <pageArea name="Page1">
          <contentArea x="36pt" y="36pt" w="540pt" h="720pt"/>
          <medium stock="default" short="612pt" long="792pt"/>
        </pageArea>
        <pageArea name="Page2">
          <contentArea x="36pt" y="36pt" w="540pt" h="720pt"/>
          <medium stock="default" short="612pt" long="792pt"/>
        </pageArea>
      </pageSet>
      <subform name="page1Content" layout="tb" w="540pt">
        <field name="StaticLabel" w="300pt" h="18pt">
          <ui><textEdit/></ui>
          <value><text>Static Label</text></value>
          <bind match="none"/>
        </field>
      </subform>
      <subform name="page2Content" layout="tb" w="540pt">
        <breakBefore targetType="pageArea"/>
        <field name="CustomerName" w="300pt" h="18pt">
          <ui><textEdit/></ui>
        </field>
      </subform>
    </subform>
  </template>
  <xfa:datasets>
    <xfa:data>
      <form1>
        <CustomerName>Alice Johnson</CustomerName>
      </form1>
    </xfa:data>
  </xfa:datasets>
</xdp:xdp>"#;

#[test]
fn bind_none_summary_fires_when_excluded() {
    let _guard = global_sink_lock().lock().unwrap_or_else(|e| e.into_inner());
    let pdf = build_xfa_pdf(XDP_BIND_NONE_PAGE_KEPT);
    let (out, events) = flatten_with_sink(&pdf);

    // Per-flatten summary must fire exactly once with a positive count.
    let summaries = count_reason(&events, "bind_none_field_excluded_from_data_check");
    assert_eq!(
        summaries, 1,
        "bind-none summary must fire exactly once when at least one field is excluded"
    );
    let summary = events
        .iter()
        .find(|e| e.reason.tag() == "bind_none_field_excluded_from_data_check")
        .expect("summary event present");
    let decision = summary.decision.as_deref().unwrap_or("");
    assert!(
        decision.contains("excluded_count="),
        "summary must carry excluded_count, got {decision:?}"
    );

    // Both pages must survive (page 1 has only a bind=none field, page 2 is bound).
    let pages = Document::load_mem(&out)
        .expect("reload flattened pdf")
        .get_pages()
        .len();
    assert_eq!(
        pages, 2,
        "page with only a bind=none field must not be dropped"
    );
}

#[test]
fn bind_none_summary_silent_when_no_exclusion() {
    // Reuse the data-bound two-page template — no field has bind=none, so
    // the summary must NOT fire.
    let _guard = global_sink_lock().lock().unwrap_or_else(|e| e.into_inner());
    let pdf = build_xfa_pdf(XDP_DATA_BOUND_TWO_PAGE);
    let (_out, events) = flatten_with_sink(&pdf);

    let summaries = count_reason(&events, "bind_none_field_excluded_from_data_check");
    assert_eq!(
        summaries, 0,
        "bind-none summary must stay silent when no field has <bind match=\"none\">"
    );
}

// ── Rule 3: StaticXfafExcessPageTrimWithFormDomGuard ───────────────────────
//
// The unit tests in `adobe_compat::rules` cover the branches of the pure
// decision (Phase 2 Wave 9 hardened the `None` arm to always block).
// Integration coverage exercises the trace anchor emission inside
// production flatten. The full
// `corpus_322faac4_seventeen_pages` / `corpus_7dbbe9d9_two_pages` /
// `corpus_fe5de953_one_page` regression guards in
// `m3b_phaseD_kappa_page_drop.rs` continue to act as the page-count
// behaviour-preservation checks for this rule.

const XDP_STATIC_XFAF_ONE_PAGE: &str = r#"<?xml version="1.0"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
  <template xmlns="http://www.xfa.org/schema/xfa-template/3.3/" baseProfile="interactiveForms">
    <subform name="form1" layout="position" w="8.5in" h="11in">
      <pageSet>
        <pageArea name="Page1">
          <contentArea x="36pt" y="36pt" w="540pt" h="720pt"/>
          <medium stock="default" short="612pt" long="792pt"/>
        </pageArea>
      </pageSet>
      <field name="A" w="300pt" h="18pt" x="36pt" y="36pt">
        <ui><textEdit/></ui>
      </field>
    </subform>
  </template>
</xdp:xdp>"#;

#[test]
fn static_trim_rule_anchor_fires_in_production() {
    let _guard = global_sink_lock().lock().unwrap_or_else(|e| e.into_inner());
    let pdf = build_xfa_pdf(XDP_STATIC_XFAF_ONE_PAGE);
    let (_out, events) = flatten_with_sink(&pdf);

    // The rule must have emitted one of its two outcome anchors.
    let allowed = has_reason(&events, "static_xfaf_trim_allowed");
    let blocked = has_reason(&events, "static_xfaf_trim_blocked");
    assert!(
        allowed || blocked,
        "static-XFAF trim rule must emit one of allowed/blocked when is_static_form=true"
    );
    // Phase 2 Wave 9: with no form DOM, the rule's None-arm always blocks
    // — host pages are authoritative when no form-DOM hint is available.
    assert!(
        blocked,
        "static-XFAF trim must be `blocked` for no-form-DOM static input (Wave 9: None-arm hardened)"
    );
}

#[test]
fn static_trim_rule_silent_for_non_static_form() {
    // Reuse the data-bound dynamic XDP — `baseProfile` is absent, so the
    // rule short-circuits without emitting.
    let _guard = global_sink_lock().lock().unwrap_or_else(|e| e.into_inner());
    let pdf = build_xfa_pdf(XDP_DATA_BOUND_TWO_PAGE);
    let (_out, events) = flatten_with_sink(&pdf);

    assert!(
        !has_reason(&events, "static_xfaf_trim_allowed"),
        "trim rule must be silent on non-static form"
    );
    assert!(
        !has_reason(&events, "static_xfaf_trim_blocked"),
        "trim rule must be silent on non-static form"
    );
}
