//! End-to-end integration tests for CSS `transform`.
//!
//! These tests exercise the full HTML -> Blitz -> convert pipeline and verify
//! that a `TransformEntry` appears in `Drawables.transforms` with a correct
//! matrix and origin. Unit tests for the matrix math and the stylo parsing
//! live in-crate; this file is the only place where all three pieces (parser,
//! converter, Drawables wiring) are checked together.

use fulgur::config::{Margin, PageSize};
use fulgur::draw_primitives::Affine2D;
use fulgur::drawables::{Drawables, TransformEntry};
use fulgur::engine::Engine;
use fulgur::units::F32Units;

fn build_drawables(html: &str) -> Drawables {
    let engine = Engine::builder().build();
    engine.build_drawables_for_testing_no_gcpm(html)
}

/// Find the first `TransformEntry` in `Drawables.transforms`. The HTML
/// fixtures only ever produce a single transformed element, so picking
/// any entry is fine.
fn first_transform(drawables: &Drawables) -> Option<TransformEntry> {
    drawables.transforms.values().next().cloned()
}

fn entry_from(html: &str) -> TransformEntry {
    let drawables = build_drawables(html);
    first_transform(&drawables).expect("expected a TransformEntry in Drawables")
}

/// Reproduces the v1 `TransformWrapperPageable::effective_matrix(draw_x, draw_y)`
/// composition: translate the origin into the draw frame, conjugate the
/// raw matrix by it, so rotation / scale happen around the chosen origin.
fn effective_matrix(entry: &TransformEntry, draw_x: f32, draw_y: f32) -> Affine2D {
    let ox = draw_x + entry.origin.x.to_f32();
    let oy = draw_y + entry.origin.y.to_f32();
    Affine2D::translation(ox.as_pt(), oy.as_pt())
        * entry.matrix
        * Affine2D::translation((-ox).as_pt(), (-oy).as_pt())
}

fn approx(actual: f32, expected: f32, tol: f32, label: &str) {
    assert!(
        (actual - expected).abs() < tol,
        "{label}: expected {expected}, got {actual} (diff={})",
        (actual - expected).abs()
    );
}

const WRAP_HTML_PRE: &str = "<!DOCTYPE html><html><head><style>\
    .t { width: 100px; height: 100px; background: red; ";
const WRAP_HTML_POST: &str = " }</style></head><body><div class=\"t\">x</div></body></html>";

fn make_html(extra_style: &str) -> String {
    format!("{WRAP_HTML_PRE}{extra_style}{WRAP_HTML_POST}")
}

// --- Geometry tests ---------------------------------------------------

#[test]
fn translate_px() {
    let html = make_html("transform: translate(10px, 20px);");
    let entry = entry_from(&html);
    // For pure translations, T(ox, oy) * M * T(-ox, -oy) = M regardless of origin,
    // so the effective matrix at any draw point equals the raw matrix (plus the
    // draw-point's own translation, which we cancel by passing (0, 0)).
    let m = effective_matrix(&entry, 0.0, 0.0);
    approx(m.a, 1.0, 1e-5, "translate.a");
    approx(m.b, 0.0, 1e-5, "translate.b");
    approx(m.c, 0.0, 1e-5, "translate.c");
    approx(m.d, 1.0, 1e-5, "translate.d");
    // 10px * 0.75 = 7.5pt, 20px * 0.75 = 15.0pt (real px->pt fold, not a bare tag).
    approx(m.e.to_f32(), 7.5, 1e-5, "translate.e");
    approx(m.f.to_f32(), 15.0, 1e-5, "translate.f");
}

#[test]
fn translate_px_absolute_length_folds_to_pt() {
    // The issue's worked example: translate(20px) must land at 20 * 0.75 =
    // 15pt, not the buggy unconverted 20pt (a 4/3 over-shift).
    let html = make_html("transform: translate(20px, 0);");
    let entry = entry_from(&html);
    let m = effective_matrix(&entry, 0.0, 0.0);
    approx(m.e.to_f32(), 15.0, 1e-5, "translate20px.e");
    approx(m.f.to_f32(), 0.0, 1e-5, "translate20px.f");
}

#[test]
fn translate_percentage_unchanged_self_consistent() {
    // Percentage translate must keep resolving against the pt-basis feed
    // exactly as before (self-consistent, not a bug): .t is 100x100 CSS px
    // = 75x75 pt, so translate(50%, 50%) -> (37.5, 37.5) pt, unchanged by
    // this fix (only the absolute-length path changes).
    let html = make_html("transform: translate(50%, 50%);");
    let entry = entry_from(&html);
    let m = effective_matrix(&entry, 0.0, 0.0);
    approx(m.e.to_f32(), 37.5, 1e-5, "translate50pct.e");
    approx(m.f.to_f32(), 37.5, 1e-5, "translate50pct.f");
}

#[test]
fn rotate_90_at_top_left_origin() {
    let html = make_html("transform: rotate(90deg); transform-origin: 0 0;");
    let entry = entry_from(&html);
    let m = effective_matrix(&entry, 0.0, 0.0);
    // Apply m to the point (1, 0): a*1 + c*0 + e = a, b*1 + d*0 + f = b.
    // After a +90 deg rotation (1, 0) should land at (0, 1).
    let x1 = m.a * 1.0 + m.c * 0.0 + m.e.to_f32();
    let y1 = m.b * 1.0 + m.d * 0.0 + m.f.to_f32();
    approx(x1, 0.0, 1e-5, "rotate90.x");
    approx(y1, 1.0, 1e-5, "rotate90.y");
}

#[test]
fn rotate_90_at_default_center_origin_fixes_center() {
    let html = make_html("transform: rotate(90deg);");
    let entry = entry_from(&html);
    let m = effective_matrix(&entry, 0.0, 0.0);
    // .t is 100 x 100 CSS px = 75 x 75 pt; the default
    // `transform-origin: 50% 50%` should resolve to (37.5, 37.5) pt --
    // the fixed point of the rotation.
    let cx = 100.0 * 0.75 / 2.0;
    let cy = 100.0 * 0.75 / 2.0;
    let x = m.a * cx + m.c * cy + m.e.to_f32();
    let y = m.b * cx + m.d * cy + m.f.to_f32();
    approx(x, cx, 1e-4, "rotate90-center.x");
    approx(y, cy, 1e-4, "rotate90-center.y");
}

#[test]
fn transform_origin_absolute_length_folds_to_pt() {
    // Same root-cause bug as translate: an absolute-length transform-origin
    // must fold px->pt for real (20px -> 15pt, 10px -> 7.5pt), not tag
    // unconverted. `TransformEntry.origin` is read directly (not through
    // effective_matrix), so any non-identity op works here to keep
    // compute_transform from folding to `None`; scale(2) is the simplest.
    let html = make_html("transform: scale(2); transform-origin: 20px 10px;");
    let entry = entry_from(&html);
    approx(entry.origin.x.to_f32(), 15.0, 1e-5, "origin.x");
    approx(entry.origin.y.to_f32(), 7.5, 1e-5, "origin.y");
}

#[test]
fn scale_has_correct_diagonal() {
    let html = make_html("transform: scale(2, 3); transform-origin: 0 0;");
    let entry = entry_from(&html);
    let m = effective_matrix(&entry, 0.0, 0.0);
    approx(m.a, 2.0, 1e-5, "scale.a");
    approx(m.d, 3.0, 1e-5, "scale.d");
    approx(m.b, 0.0, 1e-5, "scale.b");
    approx(m.c, 0.0, 1e-5, "scale.c");
    approx(m.e.to_f32(), 0.0, 1e-5, "scale.e");
    approx(m.f.to_f32(), 0.0, 1e-5, "scale.f");
}

#[test]
fn matrix_preserved_with_origin_zero() {
    let html = make_html("transform: matrix(1, 2, 3, 4, 5, 6); transform-origin: 0 0;");
    let entry = entry_from(&html);
    // With origin (0, 0) the conjugation collapses to the identity on both
    // sides, so the stored raw matrix should round-trip verbatim. matrix()'s
    // tx,ty (5, 6) are always absolute CSS px per spec, so they fold to
    // pt (5*0.75=3.75, 6*0.75=4.5) same as an equivalent translate() would.
    assert_eq!(
        entry.matrix,
        Affine2D {
            a: 1.0,
            b: 2.0,
            c: 3.0,
            d: 4.0,
            e: 3.75_f32.as_pt(),
            f: 4.5_f32.as_pt(),
        }
    );
}

#[test]
fn skew_x_45_has_correct_shear() {
    let html = make_html("transform: skewX(45deg); transform-origin: 0 0;");
    let entry = entry_from(&html);
    let m = effective_matrix(&entry, 0.0, 0.0);
    // tan(45 deg) = 1.0 -> the c (xy-shear) component.
    approx(m.a, 1.0, 1e-5, "skewX.a");
    approx(m.b, 0.0, 1e-5, "skewX.b");
    approx(m.c, 1.0, 1e-4, "skewX.c");
    approx(m.d, 1.0, 1e-5, "skewX.d");
}

#[test]
fn composition_right_to_left() {
    let html = make_html("transform: translate(10px, 0) rotate(90deg); transform-origin: 0 0;");
    let entry = entry_from(&html);
    let m = effective_matrix(&entry, 0.0, 0.0);
    // CSS transforms apply right-to-left: rotate first, then translate.
    // point (1, 0) -> rotate90 -> (0, 1) -> translate(10px=7.5pt, 0) -> (7.5, 1).
    let x = m.a * 1.0 + m.c * 0.0 + m.e.to_f32();
    let y = m.b * 1.0 + m.d * 0.0 + m.f.to_f32();
    approx(x, 7.5, 1e-4, "compose.x");
    approx(y, 1.0, 1e-4, "compose.y");
}

#[test]
fn translate3d_does_not_panic_and_is_suppressed() {
    // translate3d is 3D-only and fulgur is a 2D PDF pipeline. The converter
    // should not panic; it should also not record a `TransformEntry`,
    // because compute_transform rejects 3D transforms as non-representable.
    let html = make_html("transform: translate3d(0, 0, 50px);");
    let drawables = build_drawables(&html);
    assert!(
        drawables.transforms.is_empty(),
        "translate3d should not produce a TransformEntry, got {:?}",
        drawables.transforms.keys().collect::<Vec<_>>(),
    );
}

#[test]
fn identity_transform_does_not_generate_entry() {
    let html = make_html("transform: translate(0, 0);");
    let drawables = build_drawables(&html);
    assert!(
        drawables.transforms.is_empty(),
        "identity transform should not produce a TransformEntry, got {:?}",
        drawables.transforms.keys().collect::<Vec<_>>(),
    );
}

// --- Pagination smoke test --------------------------------------------

#[test]
fn transformed_element_produces_expected_pagination() {
    // Small page (100x120 pt, 10pt margin -> 80x100 pt content area) with one
    // transformed element whose pre-transform height (150 pt) exceeds the
    // available content height (100 pt). The transformed subtree paints
    // atomically (it never splits across pages) so even though the element
    // does not fit, a single page is emitted. We assert the PDF bytes are
    // well-formed and that exactly one page is produced.
    //
    // NOTE: the small page size is configured on the `Engine` itself.
    // A `@page { size: ... }` rule inside a `<style>` block would be
    // overridden by the engine's default A4, in which case a 60x150 pt box
    // trivially fits on one page and the test becomes tautological.
    let html = r#"<!DOCTYPE html><html><head><style>
        .t { width: 60pt; height: 150pt; background: red;
             transform: rotate(45deg); transform-origin: 0 0; }
        </style></head><body><div class="t">x</div></body></html>"#;
    let engine = Engine::builder()
        .page_size(PageSize {
            width: 100.0,
            height: 120.0,
        })
        .margin(Margin::uniform(10.0))
        .build();
    let pdf = engine.render(html).expect("render should succeed");
    assert!(pdf.starts_with(b"%PDF-"), "PDF header missing");

    // Count `/Type /Page` occurrences, excluding `/Type /Pages`. Match the
    // prefix `/Type /Page` followed by a terminator that is not another
    // alphanumeric (so `/Pages` is rejected). This is still a substring
    // scan, but it handles both `/Type /Page\n` and `/Type /Page ` style
    // separators that different PDF writers emit.
    let prefix = b"/Type /Page";
    let mut count = 0usize;
    let mut i = 0;
    while i + prefix.len() < pdf.len() {
        if &pdf[i..i + prefix.len()] == prefix {
            let next = pdf[i + prefix.len()];
            // Reject `/Type /Pages` and any other identifier continuation.
            if !next.is_ascii_alphanumeric() {
                count += 1;
            }
            i += prefix.len();
        } else {
            i += 1;
        }
    }
    assert_eq!(
        count, 1,
        "expected exactly one page for an atomic transformed element, got {count}"
    );
}
