//! The SVG renderer: a [`ResolvedLayoutIR`] to well-formed SVG 1.1.
//!
//! ## What this renderer is, and is not
//!
//! It is a **renderer**, not an engraver. Per the QUICKSTART non-overreach rule
//! (`spec/PHASE2_QUICKSTART.md`, Agent I), every emitted SVG element traces to a
//! [`ResolvedGlyph`] (and thus to its score-graph source) or to a declared
//! renderer wrapper (the `<svg>` root, a metadata comment, the y-flip group, a
//! per-layer `<g>`). Provenance traces are on by default; turning them off (when
//! [`RenderOptions::emit_provenance`] is `false`) is an explicit display-only mode
//! that the metadata comment *declares*, so a trace-free SVG announces itself
//! rather than passing as an archival one. The renderer makes *SVG-encoding*
//! choices only — grouping,
//! `<path>` vs `<rect>` fallback, the viewBox, the coordinate flip, colour
//! representation. It makes **no engraving-semantic** choices (stem direction,
//! spacing, beam slope, accidental placement, glyph selection): those are the
//! solver's and the IR's. If a glyph it is asked to draw has no bundled outline,
//! it surfaces a [`Diagnostic`] and falls back to a visible bounding-box rect —
//! it never invents geometry.
//!
//! ## Coordinate system
//!
//! The IR is in **staff spaces, y-up** (musical convention: larger `y` = higher
//! pitch). SVG is y-down. The renderer keeps every coordinate in staff-space,
//! y-up world units and applies a single y-flip on the content group
//! (`translate(-min_x, max_y) scale(1, -1)`), so a world point `(x, y)` maps to
//! screen `(x - min_x, max_y - y)`. The `viewBox` is `0 0 W H` in staff spaces;
//! `width`/`height` carry the display scale in px
//! ([`RenderOptions::px_per_staff_space`]). Glyph outlines are bundled in the
//! same staff-space/y-up frame, so each is placed with a plain
//! `translate(x, y)`.
//!
//! ## Determinism
//!
//! Outline `d` data is fixed bundled text; every computed number is formatted to
//! at most 4 decimals with `-0` normalised to `0`, so identical input yields
//! byte-identical SVG (the acceptance snapshot golden-locks this).

use std::collections::BTreeMap;
use std::fmt::Write as _;

use epiphany_layout_ir::{BoundingBox, Provenance, ResolvedGlyph, ResolvedLayoutIR, Transform2D};

use crate::font_subset_generated::{
    BRAVURA_SUBSET_FAMILY, BRAVURA_SUBSET_MIME, BRAVURA_SUBSET_OTF_BASE64,
};
use crate::outline::{outline, smufl_codepoint};
use crate::xml::{check_well_formed, escape_attr};

/// How glyphs are drawn.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub enum GlyphMode {
    /// Inline genuine Bravura outline `<path>`s (default). Self-contained: the
    /// SVG renders in any viewer with no font dependency (QUICKSTART, Agent I:
    /// "inline path outlines for golden fixtures and the demonstrable
    /// deliverable"). This is the byte-golden-locked, pixel-verified reference
    /// mode.
    #[default]
    PathOutline,
    /// Reference each glyph by its SMuFL codepoint with a `<text>` element, drawn
    /// from an `@font-face`-embedded subset of Bravura (the same SHA-pinned font
    /// the outlines come from, base64 in `font_subset_generated`). The result is
    /// self-contained — the font travels in the SVG — and text-selectable, at the
    /// cost of a larger file. Glyph placement is consistent with
    /// [`GlyphMode::PathOutline`] by construction (same origin, em = 4 staff
    /// spaces); exact glyph rasterisation is then the consumer's font renderer's.
    EmbeddedFont,
}

/// Renderer configuration. SVG-encoding choices only — nothing here changes
/// engraving.
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct RenderOptions {
    /// Display scale: points (px) per staff space, applied as the `<svg>`
    /// width/height while the viewBox stays in staff spaces.
    pub px_per_staff_space: f32,
    /// Blank margin around the content, in staff spaces.
    pub margin: f32,
    /// How glyphs are drawn.
    pub glyph_mode: GlyphMode,
    /// Emit `data-*` provenance attributes tracing each element to its source.
    /// `true` (the default) is the archival/traceable mode; `false` is an explicit
    /// display-only mode that the emitted SVG's metadata comment *declares*, so the
    /// absence of traces is announced rather than silently produced.
    pub emit_provenance: bool,
}

impl Default for RenderOptions {
    fn default() -> Self {
        RenderOptions {
            px_per_staff_space: 10.0,
            margin: 2.0,
            glyph_mode: GlyphMode::PathOutline,
            emit_provenance: true,
        }
    }
}

/// A coarse glyph category, used for the machine acceptance snapshot's
/// bounding-box-class counts.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub enum GlyphClass {
    Notehead,
    Clef,
    Accidental,
    Rest,
    Flag,
    TimeSignature,
    Barline,
    Dynamic,
    AugmentationDot,
    Other,
}

impl GlyphClass {
    /// Classifies a SMuFL glyph name by its category prefix.
    pub fn of(name: &str) -> GlyphClass {
        if name.starts_with("notehead") {
            GlyphClass::Notehead
        } else if name.ends_with("Clef") {
            GlyphClass::Clef
        } else if name.starts_with("accidental") {
            GlyphClass::Accidental
        } else if name.starts_with("rest") {
            GlyphClass::Rest
        } else if name.starts_with("flag") {
            GlyphClass::Flag
        } else if name.starts_with("timeSig") {
            GlyphClass::TimeSignature
        } else if name.starts_with("barline") {
            GlyphClass::Barline
        } else if name.starts_with("dynamic") {
            GlyphClass::Dynamic
        } else if name == "augmentationDot" {
            GlyphClass::AugmentationDot
        } else {
            GlyphClass::Other
        }
    }

    /// A short, stable token for the SVG `data-class` attribute and snapshots.
    pub fn token(self) -> &'static str {
        match self {
            GlyphClass::Notehead => "notehead",
            GlyphClass::Clef => "clef",
            GlyphClass::Accidental => "accidental",
            GlyphClass::Rest => "rest",
            GlyphClass::Flag => "flag",
            GlyphClass::TimeSignature => "timeSig",
            GlyphClass::Barline => "barline",
            GlyphClass::Dynamic => "dynamic",
            GlyphClass::AugmentationDot => "augmentationDot",
            GlyphClass::Other => "other",
        }
    }
}

/// A non-fatal note the renderer surfaces instead of papering over a problem
/// (QUICKSTART, Agent I: "surface it via a diagnostic, don't paper over it").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Diagnostic {
    pub message: String,
    pub glyph: Option<String>,
}

/// Machine-readable facts about a render, for the golden-locked acceptance
/// snapshot (object/glyph/path/provenance/class counts + bounds).
#[derive(Clone, PartialEq, Debug)]
pub struct RenderStats {
    /// Glyphs in the resolved layout (the renderer's input objects).
    pub glyph_count: usize,
    /// `<path>` elements emitted (glyphs drawn from a bundled outline, the
    /// default [`GlyphMode::PathOutline`]).
    pub path_count: usize,
    /// `<text>` elements emitted (glyphs set in the embedded font, the
    /// [`GlyphMode::EmbeddedFont`] mode). Zero in the default path mode.
    pub text_count: usize,
    /// Fallback `<rect>` elements emitted (glyphs with no bundled outline).
    pub fallback_rect_count: usize,
    /// `<line>` elements emitted (one per resolved stroke: staff line, stem, …).
    pub stroke_count: usize,
    /// Elements carrying a `data-prov` trace back to a score-graph source.
    pub provenance_count: usize,
    /// Distinct layers, each rendered as one `<g>` group.
    pub layer_count: usize,
    /// Per-class glyph counts.
    pub class_counts: BTreeMap<GlyphClass, usize>,
    /// The padded content bounds `[min_x, min_y, width, height]`, in staff
    /// spaces. Note this is the *content extent*, not the emitted `viewBox`
    /// attribute: the SVG is translated so its `viewBox` is always `0 0 W H`
    /// (the `min_x`/`min_y` here are folded into the y-flip group's translate).
    pub view_box: [f32; 4],
}

/// The result of a render: the SVG text, machine stats, and any diagnostics.
#[derive(Clone, PartialEq, Debug)]
pub struct RenderOutput {
    pub svg: String,
    pub stats: RenderStats,
    pub diagnostics: Vec<Diagnostic>,
}

impl RenderOutput {
    /// Whether the emitted SVG passes the in-crate well-formedness check.
    pub fn is_well_formed(&self) -> bool {
        check_well_formed(&self.svg).is_ok()
    }
}

/// Renders a resolved layout to SVG. Pure and deterministic.
pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOutput {
    let mut diagnostics = Vec::new();
    let mut class_counts: BTreeMap<GlyphClass, usize> = BTreeMap::new();
    for g in &resolved.glyphs {
        *class_counts
            .entry(GlyphClass::of(g.glyph.as_str()))
            .or_insert(0) += 1;
    }

    // World bounds over each glyph's drawn extent (its outline bbox, or the IR
    // bounding box for a glyph with no bundled outline).
    let bounds = content_bounds(resolved, options.margin);

    let (min_x, min_y, width, height) = match bounds {
        Some(b) => b,
        // Empty layout: a minimal, valid, honest empty canvas.
        None => {
            let svg = empty_svg(options.glyph_mode, options.emit_provenance);
            let well_formed = check_well_formed(&svg).is_ok();
            debug_assert!(well_formed);
            return RenderOutput {
                stats: RenderStats {
                    glyph_count: 0,
                    path_count: 0,
                    text_count: 0,
                    fallback_rect_count: 0,
                    stroke_count: 0,
                    provenance_count: 0,
                    layer_count: 0,
                    class_counts,
                    view_box: [0.0, 0.0, 1.0, 1.0],
                },
                svg,
                diagnostics,
            };
        }
    };
    let max_y = min_y + height;

    // Group glyphs and strokes by layer (ascending), preserving input order
    // within. Strokes draw before glyphs at the same layer, so a staff line sits
    // under the noteheads on it.
    let mut glyph_layers: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
    for (i, g) in resolved.glyphs.iter().enumerate() {
        glyph_layers.entry(g.layer).or_default().push(i);
    }
    let mut stroke_layers: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
    for (i, stroke) in resolved.strokes.iter().enumerate() {
        stroke_layers.entry(stroke.layer).or_default().push(i);
    }
    let layer_ids: std::collections::BTreeSet<i32> = glyph_layers
        .keys()
        .chain(stroke_layers.keys())
        .copied()
        .collect();

    let mut path_count = 0;
    let mut text_count = 0;
    let mut fallback_rect_count = 0;
    let mut stroke_count = 0;
    let mut provenance_count = 0;

    let mut s = String::new();
    s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    let _ = writeln!(
        s,
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">",
        num(width * options.px_per_staff_space),
        num(height * options.px_per_staff_space),
        num(width),
        num(height),
    );
    // Declared metadata wrapper (a comment — honest about what this is, including
    // how glyphs are drawn and whether provenance traces are present: suppressing
    // them is an explicit display-only choice the output announces, not drops).
    let _ = writeln!(
        s,
        "  <!-- epiphany-render-svg: {}; geometry is the resolved layout \
         verbatim, no engraving performed here; {} -->",
        glyph_note(options.glyph_mode),
        provenance_note(options.emit_provenance),
    );
    // In embedded-font mode, declare the Bravura subset once via `@font-face`; the
    // `<text>` glyphs below reference it by its (non-reserved) family name (see the
    // font subset's own header for its provenance and the OFL terms it carries).
    if options.glyph_mode == GlyphMode::EmbeddedFont {
        let _ = writeln!(
            s,
            "  <defs><style>@font-face {{ font-family: \"{}\"; \
             src: url(\"data:{};base64,{}\") format(\"opentype\"); }}</style></defs>",
            BRAVURA_SUBSET_FAMILY, BRAVURA_SUBSET_MIME, BRAVURA_SUBSET_OTF_BASE64,
        );
    }
    // The single y-flip wrapper: staff-space/y-up world -> SVG y-down.
    let _ = writeln!(
        s,
        "  <g transform=\"translate({} {}) scale(1 -1)\">",
        num(-min_x),
        num(max_y),
    );

    for layer in &layer_ids {
        let _ = writeln!(s, "    <g data-layer=\"{}\">", layer);

        // Strokes (staff lines, stems, barlines, …) — drawn first so glyphs on
        // the same layer sit over them.
        if let Some(indices) = stroke_layers.get(layer) {
            for &i in indices {
                let stroke = &resolved.strokes[i];
                let (stroke_fill, opacity) = stroke_colour(stroke.style.rgba);
                let prov = if options.emit_provenance {
                    provenance_count += 1;
                    stroke_provenance_attrs(&stroke.provenance)
                } else {
                    String::new()
                };
                stroke_count += 1;
                let _ = writeln!(
                    s,
                    "      <line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" \
                     stroke=\"{}\" stroke-width=\"{}\"{}{}/>",
                    num(stroke.from.x.0),
                    num(stroke.from.y.0),
                    num(stroke.to.x.0),
                    num(stroke.to.y.0),
                    stroke_fill,
                    num(stroke.thickness.0),
                    opacity,
                    prov,
                );
            }
        }

        if let Some(indices) = glyph_layers.get(layer) {
            for &i in indices {
                let g = &resolved.glyphs[i];
                let name = g.glyph.as_str();
                let (x, y) = (g.position.x.0, g.position.y.0);
                // The glyph's resolved transform (scale/rotate/skew about its
                // origin), composed after the placement translate. `None` ⇒ a bare
                // translate, the common case.
                let placement = placement_transform(x, y, &g.transform);
                if let Some(t) = &g.transform {
                    if !is_affine(t) {
                        diagnostics.push(Diagnostic {
                            message: "non-affine (projective) glyph transform is not \
                                      representable in SVG; rendered its affine projection"
                                .to_owned(),
                            glyph: Some(name.to_owned()),
                        });
                    }
                }
                let (fill, opacity) = colour(g.style.rgba);
                let prov = if options.emit_provenance {
                    provenance_count += 1;
                    provenance_attrs(&g.provenance, name, GlyphClass::of(name))
                } else {
                    String::new()
                };
                // The drawn element depends on the mode: an inline outline `<path>`
                // (the self-contained default) or a `<text>` referencing the embedded
                // Bravura by SMuFL codepoint. Both anchor at the same `(x, y)` origin,
                // so the two modes are geometrically consistent. `None` (no bundled
                // outline / no codepoint) falls through to the visible bbox rect.
                let element = match options.glyph_mode {
                    GlyphMode::PathOutline => outline(name).map(|o| {
                        path_count += 1;
                        format!(
                            "<path d=\"{}\" transform=\"{}\" fill=\"{}\"{}{}/>",
                            o.path, placement, fill, opacity, prov,
                        )
                    }),
                    GlyphMode::EmbeddedFont => smufl_codepoint(name).map(|cp| {
                        text_count += 1;
                        // The font glyph is drawn upright by a per-glyph counter-flip
                        // (`scale(1 -1)`, innermost) cancelling the outer y-flip; the
                        // em is four staff spaces (SMuFL), so `font-size="4"`.
                        format!(
                            "<text transform=\"{placement} scale(1 -1)\" \
                             font-family=\"{BRAVURA_SUBSET_FAMILY}\" font-size=\"4\" \
                             fill=\"{fill}\"{opacity}{prov}>&#x{cp:X};</text>",
                        )
                    }),
                };
                match element {
                    Some(el) => {
                        let _ = writeln!(s, "      {el}");
                    }
                    None => {
                        // Unrenderable in this mode: surface it and draw the IR
                        // bounding box so the missing glyph is visible, not silent.
                        fallback_rect_count += 1;
                        diagnostics.push(Diagnostic {
                            message: "no bundled Bravura glyph for this name; drew \
                                      bounding-box fallback"
                                .to_owned(),
                            glyph: Some(name.to_owned()),
                        });
                        let bb = g.bounding_box;
                        let _ = writeln!(
                            s,
                            "      <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
                             transform=\"{}\" fill=\"none\" stroke=\"#cc0000\" \
                             stroke-width=\"0.05\"{}/>",
                            num(bb.left.0),
                            num(bb.bottom.0),
                            num((bb.right.0 - bb.left.0).max(0.0)),
                            num((bb.top.0 - bb.bottom.0).max(0.0)),
                            placement,
                            prov,
                        );
                    }
                }
            }
        }
        s.push_str("    </g>\n");
    }

    s.push_str("  </g>\n");
    s.push_str("</svg>\n");

    RenderOutput {
        stats: RenderStats {
            glyph_count: resolved.glyphs.len(),
            path_count,
            text_count,
            fallback_rect_count,
            stroke_count,
            provenance_count,
            layer_count: layer_ids.len(),
            class_counts,
            view_box: [num_f(min_x), num_f(min_y), num_f(width), num_f(height)],
        },
        svg: s,
        diagnostics,
    }
}

/// The content bounds `(min_x, min_y, width, height)` in staff spaces, padded by
/// `margin`, or `None` if there is nothing to draw.
fn content_bounds(resolved: &ResolvedLayoutIR, margin: f32) -> Option<(f32, f32, f32, f32)> {
    let mut min_x = f32::INFINITY;
    let mut min_y = f32::INFINITY;
    let mut max_x = f32::NEG_INFINITY;
    let mut max_y = f32::NEG_INFINITY;
    let mut any = false;
    for g in &resolved.glyphs {
        let bb = drawn_bbox(g);
        let (x, y) = (g.position.x.0, g.position.y.0);
        // All four bbox corners mapped through the *same* placement transform the
        // renderer applies — a scale/rotation can push drawn geometry past the
        // untransformed axis-aligned extent, which would crop it.
        for (lx, ly) in [
            (bb.left.0, bb.bottom.0),
            (bb.right.0, bb.bottom.0),
            (bb.left.0, bb.top.0),
            (bb.right.0, bb.top.0),
        ] {
            let (px, py) = placed_point(x, y, &g.transform, lx, ly);
            if px.is_finite() && py.is_finite() {
                any = true;
                min_x = min_x.min(px);
                min_y = min_y.min(py);
                max_x = max_x.max(px);
                max_y = max_y.max(py);
            }
        }
    }
    // Strokes extend the bounds by a half-thickness box around each endpoint, so a
    // thick rule is not clipped perpendicular to its direction even at margin 0.
    for stroke in &resolved.strokes {
        let half = (stroke.thickness.0 * 0.5).max(0.0);
        for point in [stroke.from, stroke.to] {
            let (cx, cy) = (point.x.0, point.y.0);
            for (px, py) in [(cx - half, cy - half), (cx + half, cy + half)] {
                if px.is_finite() && py.is_finite() {
                    any = true;
                    min_x = min_x.min(px);
                    min_y = min_y.min(py);
                    max_x = max_x.max(px);
                    max_y = max_y.max(py);
                }
            }
        }
    }
    if !any {
        return None;
    }
    min_x -= margin;
    min_y -= margin;
    max_x += margin;
    max_y += margin;
    // Guard against a degenerate zero-area box (e.g. a single zero-size glyph).
    let width = (max_x - min_x).max(f32::EPSILON);
    let height = (max_y - min_y).max(f32::EPSILON);
    Some((num_f(min_x), num_f(min_y), num_f(width), num_f(height)))
}

/// The bounding box the renderer uses for a glyph: its real outline extent when
/// bundled (what is actually drawn), else its IR bounding box.
fn drawn_bbox(g: &ResolvedGlyph) -> BoundingBox {
    match outline(g.glyph.as_str()) {
        Some(o) => BoundingBox::new(o.bbox[0], o.bbox[1], o.bbox[2], o.bbox[3]),
        None => g.bounding_box,
    }
}

/// `data-*` provenance attributes tracing an element to its score-graph source.
fn provenance_attrs(p: &Provenance, glyph: &str, class: GlyphClass) -> String {
    format!(
        " data-prov=\"{:032x}\" data-source-kind=\"{}\" data-glyph=\"{}\" data-class=\"{}\"",
        p.stable_id.0,
        p.source.discriminant(),
        escape_attr(glyph),
        class.token(),
    )
}

/// `data-*` provenance attributes for a stroke (a non-glyph line primitive).
fn stroke_provenance_attrs(p: &Provenance) -> String {
    format!(
        " data-prov=\"{:032x}\" data-source-kind=\"{}\" data-kind=\"stroke\"",
        p.stable_id.0,
        p.source.discriminant(),
    )
}

/// Whether a [`Transform2D`] is a pure 2-D affine — its bottom row is `[0, 0, 1]`
/// (within an `f32` tolerance). SVG transforms are affine, so a non-affine
/// (projective) transform cannot be represented; the renderer diagnoses it and
/// renders its affine projection.
fn is_affine(transform: &Transform2D) -> bool {
    let bottom = transform.matrix[2];
    bottom[0].abs() < 1e-6 && bottom[1].abs() < 1e-6 && (bottom[2] - 1.0).abs() < 1e-6
}

/// A glyph-local point mapped to world space through the renderer's placement
/// transform — the glyph's resolved affine applied about the origin, then
/// translated to `(px, py)`. Matches [`placement_transform`]'s SVG output (it
/// uses the affine projection, dropping any projective bottom row).
fn placed_point(px: f32, py: f32, transform: &Option<Transform2D>, lx: f32, ly: f32) -> (f32, f32) {
    let (tx, ty) = match transform {
        None => (lx, ly),
        Some(t) => {
            let m = t.matrix;
            (
                m[0][0] * lx + m[0][1] * ly + m[0][2],
                m[1][0] * lx + m[1][1] * ly + m[1][2],
            )
        }
    };
    (px + tx, py + ty)
}

/// The SVG `transform` placing a glyph at `(x, y)` and applying its resolved
/// affine (`scale`/`rotate`/`skew` about the glyph origin) when present. The
/// affine is the inner (rightmost) transform, so the glyph's local outline is
/// transformed, then translated into place. `None` is the common case: a bare
/// translate, byte-identical to the pre-transform output. A non-affine transform
/// is rendered as its affine projection (the bottom row is dropped); the render
/// loop emits a diagnostic for that case.
fn placement_transform(x: f32, y: f32, transform: &Option<Transform2D>) -> String {
    match transform {
        None => format!("translate({} {})", num(x), num(y)),
        Some(t) => {
            let m = t.matrix;
            // SVG matrix(a b c d e f) is the affine [[a c e],[b d f],[0 0 1]];
            // map it from the row-major 3×3 (the bottom row is implicit).
            format!(
                "translate({} {}) matrix({} {} {} {} {} {})",
                num(x),
                num(y),
                num(m[0][0]),
                num(m[1][0]),
                num(m[0][1]),
                num(m[1][1]),
                num(m[0][2]),
                num(m[1][2]),
            )
        }
    }
}

/// Splits an `0xRRGGBBAA` colour into an SVG `stroke` value and an optional
/// `stroke-opacity` attribute (empty when fully opaque).
fn stroke_colour(rgba: u32) -> (String, String) {
    let r = (rgba >> 24) & 0xff;
    let g = (rgba >> 16) & 0xff;
    let b = (rgba >> 8) & 0xff;
    let a = rgba & 0xff;
    let stroke = format!("#{r:02x}{g:02x}{b:02x}");
    let opacity = if a == 0xff {
        String::new()
    } else {
        format!(" stroke-opacity=\"{}\"", num(a as f32 / 255.0))
    };
    (stroke, opacity)
}

/// Splits an `0xRRGGBBAA` colour into an SVG `fill` value and an optional
/// `fill-opacity` attribute (empty when fully opaque).
fn colour(rgba: u32) -> (String, String) {
    let r = (rgba >> 24) & 0xff;
    let g = (rgba >> 16) & 0xff;
    let b = (rgba >> 8) & 0xff;
    let a = rgba & 0xff;
    let fill = format!("#{r:02x}{g:02x}{b:02x}");
    let opacity = if a == 0xff {
        String::new()
    } else {
        format!(" fill-opacity=\"{}\"", num(a as f32 / 255.0))
    };
    (fill, opacity)
}

/// The glyph-mode clause of the metadata comment: which drawing strategy produced
/// the SVG. Shared by the main render and [`empty_svg`] so the declared mode
/// boundary is the same on the empty path.
fn glyph_note(mode: GlyphMode) -> &'static str {
    match mode {
        GlyphMode::PathOutline => "glyphs are genuine Bravura SMuFL outlines inlined as paths",
        GlyphMode::EmbeddedFont => "glyphs are Bravura SMuFL codepoints set in the embedded font",
    }
}

/// The provenance-state clause of the metadata comment. Archival mode declares
/// traces present; display-only mode declares them suppressed — so a trace-free
/// SVG (including the empty canvas) announces itself rather than passing as
/// archival. Shared by the main render and [`empty_svg`] so neither can drift.
fn provenance_note(emit_provenance: bool) -> &'static str {
    if emit_provenance {
        "every glyph and stroke carries a data-prov trace to its score-graph source"
    } else {
        "provenance traces suppressed (display-only output, not archival)"
    }
}

/// A minimal, valid empty SVG for a layout with nothing to draw — still declaring
/// its glyph mode and provenance state, so an empty render is honest like a full
/// one (the metadata is the same declared boundary on both paths).
fn empty_svg(glyph_mode: GlyphMode, emit_provenance: bool) -> String {
    format!(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
         <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\" viewBox=\"0 0 1 1\">\n\
         \x20\x20<!-- epiphany-render-svg: empty resolved layout (no glyphs); {}; {} -->\n\
         </svg>\n",
        glyph_note(glyph_mode),
        provenance_note(emit_provenance),
    )
}

/// Formats a coordinate to at most 4 decimals, trimming trailing zeros and
/// normalising `-0` to `0` for deterministic, compact output.
fn num(v: f32) -> String {
    let v = num_f(v);
    let mut s = format!("{v:.4}");
    if s.contains('.') {
        while s.ends_with('0') {
            s.pop();
        }
        if s.ends_with('.') {
            s.pop();
        }
    }
    s
}

/// Normalises a coordinate value: `-0.0` (which compares equal to `0.0`) maps to
/// `0.0`; every other value passes through unchanged. Keeps the formatted form
/// and the stored stats agreeing.
fn num_f(v: f32) -> f32 {
    if v == 0.0 {
        0.0
    } else {
        v
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use epiphany_core::generators::valid_score_rich;
    use epiphany_layout_ir::{
        to_constrained, to_logical, ConstraintSolver, GlyphStyle, Point, SolverConfig, StaffSpace,
        Stroke, StubSolver, Transform2D,
    };

    fn stub_layout(seed: u64) -> ResolvedLayoutIR {
        let constrained = to_constrained(&to_logical(&valid_score_rich(seed)));
        StubSolver
            .solve(&constrained, &SolverConfig::default())
            .layout
    }

    #[test]
    fn a_stroke_renders_as_a_traced_line() {
        let mut layout = stub_layout(11);
        let glyph_count = layout.glyphs.len();
        // The engraver already emits strokes (staff lines, stems, …); this adds
        // one more and checks it renders and traces on top of them.
        let base_strokes = layout.strokes.len();
        layout.strokes.push(Stroke {
            provenance: layout.glyphs[0].provenance.clone(),
            from: Point::new(0.0, 0.0),
            to: Point::new(4.0, 0.0),
            thickness: StaffSpace(0.13),
            layer: -1,
            style: GlyphStyle { rgba: 0x0000_00ff },
        });
        let out = render(&layout, &RenderOptions::default());
        assert!(
            out.is_well_formed(),
            "SVG with a stroke must be well-formed"
        );
        assert_eq!(out.stats.stroke_count, base_strokes + 1);
        assert!(
            out.svg.contains("<line "),
            "the stroke is drawn as a <line>"
        );
        assert!(
            out.svg.contains("data-kind=\"stroke\""),
            "the stroke carries a provenance trace"
        );
        assert_eq!(
            out.stats.provenance_count,
            glyph_count + base_strokes + 1,
            "every glyph and stroke is traced"
        );
    }

    #[test]
    fn a_glyph_transform_is_applied_not_dropped() {
        let mut layout = stub_layout(11);
        // A 2× scale about the glyph origin: the renderer must emit it, not ignore
        // it (otherwise a future solver's transform would silently disappear).
        layout.glyphs[0].transform = Some(Transform2D {
            matrix: [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 1.0]],
        });
        let out = render(&layout, &RenderOptions::default());
        assert!(out.is_well_formed());
        assert!(
            out.svg.contains("matrix(2 0 0 2 0 0)"),
            "the glyph's resolved transform is applied"
        );
    }

    #[test]
    fn glyph_transform_expands_the_view_box() {
        let base = stub_layout(11);
        let base_width = render(&base, &RenderOptions::default()).stats.view_box[2];
        let mut transformed = base.clone();
        // Translate one glyph far to the right via its transform; the bounds must
        // grow to contain it (untransformed bounds would crop it).
        transformed.glyphs[0].transform = Some(Transform2D {
            matrix: [[1.0, 0.0, 1000.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
        });
        let width = render(&transformed, &RenderOptions::default())
            .stats
            .view_box[2];
        assert!(
            width > base_width + 900.0,
            "a transformed glyph widens the viewBox ({width} vs {base_width})"
        );
    }

    #[test]
    fn thick_stroke_expands_bounds_by_half_width() {
        let mut layout = stub_layout(11);
        let provenance = layout.glyphs[0].provenance.clone();
        layout.glyphs.clear();
        layout.strokes.push(Stroke {
            provenance,
            from: Point::new(0.0, 0.0),
            to: Point::new(4.0, 0.0),
            thickness: StaffSpace(2.0),
            layer: 0,
            style: GlyphStyle::default(),
        });
        let options = RenderOptions {
            margin: 0.0,
            ..RenderOptions::default()
        };
        let height = render(&layout, &options).stats.view_box[3];
        // A horizontal rule of thickness 2 spans y ∈ [−1, 1]: a half-width each
        // side, so the perpendicular extent is at least the full thickness.
        assert!(
            height >= 2.0,
            "half-thickness expands the perpendicular extent (got {height})"
        );
    }

    #[test]
    fn non_affine_transform_is_diagnosed() {
        let mut layout = stub_layout(11);
        // A non-zero bottom row makes the transform projective, not affine.
        layout.glyphs[0].transform = Some(Transform2D {
            matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.5, 0.0, 1.0]],
        });
        let out = render(&layout, &RenderOptions::default());
        assert!(out.is_well_formed());
        assert!(
            out.diagnostics
                .iter()
                .any(|d| d.message.contains("non-affine")),
            "a projective transform is surfaced as a diagnostic, not silently mis-rendered"
        );
    }

    #[test]
    fn renders_well_formed_svg_with_one_path_per_glyph() {
        let layout = stub_layout(11);
        let out = render(&layout, &RenderOptions::default());
        assert!(out.is_well_formed(), "emitted SVG must be well-formed");
        assert_eq!(out.stats.glyph_count, layout.glyphs.len());
        // The stub pipeline only names bundled glyphs, so every glyph is a path.
        assert_eq!(out.stats.path_count, layout.glyphs.len());
        assert_eq!(out.stats.fallback_rect_count, 0);
        assert!(out.diagnostics.is_empty());
        assert_eq!(
            out.stats.provenance_count,
            layout.glyphs.len() + layout.strokes.len()
        );
        assert!(out.svg.contains("<svg"));
        assert!(out.svg.contains("data-prov="));
    }

    #[test]
    fn render_is_deterministic() {
        let layout = stub_layout(3);
        assert_eq!(
            render(&layout, &RenderOptions::default()).svg,
            render(&layout, &RenderOptions::default()).svg
        );
    }

    #[test]
    fn embedded_font_mode_sets_text_from_the_embedded_subset() {
        let layout = stub_layout(11);
        let out = render(
            &layout,
            &RenderOptions {
                glyph_mode: GlyphMode::EmbeddedFont,
                ..RenderOptions::default()
            },
        );
        assert!(
            out.is_well_formed(),
            "embedded-font SVG must be well-formed"
        );

        // The font is declared exactly once via @font-face with the base64 subset,
        // under its non-reserved family name (OFL: not the Reserved Font Name).
        assert_eq!(out.svg.matches("@font-face").count(), 1);
        assert!(out.svg.contains("font-family: \"EpiphanyBravuraSubset\""));
        assert!(!out.svg.contains("font-family: \"Bravura\""));
        assert!(out.svg.contains("data:font/otf;base64,"));

        // Every glyph is a `<text>` (the stub names only bundled glyphs), none a
        // path or a fallback rect, and each carries a SMuFL codepoint reference.
        assert_eq!(out.stats.text_count, layout.glyphs.len());
        assert_eq!(out.stats.path_count, 0);
        assert_eq!(out.stats.fallback_rect_count, 0);
        assert!(out.diagnostics.is_empty());
        assert_eq!(out.svg.matches("<text ").count(), layout.glyphs.len());
        assert_eq!(out.svg.matches("&#x").count(), layout.glyphs.len());

        // The metadata comment declares the embedded-font mode, and provenance is
        // preserved exactly as in path mode.
        assert!(out.svg.contains("set in the embedded font"));
        assert_eq!(
            out.stats.provenance_count,
            layout.glyphs.len() + layout.strokes.len()
        );
        assert!(out.svg.contains("data-prov="));
    }

    #[test]
    fn embedded_font_mode_is_deterministic_and_draws_every_glyph() {
        let layout = stub_layout(7);
        let a = render(
            &layout,
            &RenderOptions {
                glyph_mode: GlyphMode::EmbeddedFont,
                ..RenderOptions::default()
            },
        );
        let b = render(
            &layout,
            &RenderOptions {
                glyph_mode: GlyphMode::EmbeddedFont,
                ..RenderOptions::default()
            },
        );
        assert_eq!(a.svg, b.svg, "embedded-font render must be deterministic");
        // Every glyph is accounted for (text or fallback rect), none dropped — the
        // same no-silent-drop contract as path mode.
        assert_eq!(
            a.stats.text_count + a.stats.fallback_rect_count,
            a.stats.glyph_count
        );
        // The default path mode draws no `<text>`; the modes do not bleed.
        let path = render(&layout, &RenderOptions::default());
        assert_eq!(path.stats.text_count, 0);
        assert!(!path.svg.contains("@font-face"));
    }

    #[test]
    fn empty_layout_renders_a_valid_empty_canvas() {
        let layout = ResolvedLayoutIR {
            source: Default::default(),
            pages: vec![],
            glyphs: vec![],
            strokes: vec![],
            engraving_decisions: vec![],
            catalog: Default::default(),
        };
        let out = render(&layout, &RenderOptions::default());
        assert!(out.is_well_formed());
        assert_eq!(out.stats.glyph_count, 0);
        assert_eq!(out.stats.path_count, 0);

        // Even with nothing to draw, suppressing provenance is declared display-only
        // — the empty canvas is held to the same honesty contract as a full render.
        let suppressed = render(
            &layout,
            &RenderOptions {
                emit_provenance: false,
                ..RenderOptions::default()
            },
        );
        assert!(suppressed.is_well_formed());
        assert!(suppressed.svg.contains("provenance traces suppressed"));

        // The empty canvas also declares its glyph mode (the same boundary as a
        // full render), so an empty embedded render is not mistaken for a path one.
        let empty_embedded = render(
            &layout,
            &RenderOptions {
                glyph_mode: GlyphMode::EmbeddedFont,
                ..RenderOptions::default()
            },
        );
        assert!(empty_embedded.svg.contains("set in the embedded font"));
    }

    #[test]
    fn provenance_can_be_suppressed_but_is_declared() {
        let layout = stub_layout(5);
        let out = render(
            &layout,
            &RenderOptions {
                emit_provenance: false,
                ..RenderOptions::default()
            },
        );
        assert_eq!(out.stats.provenance_count, 0);
        assert!(!out.svg.contains("data-prov="));
        assert!(out.is_well_formed());
        // Suppression is announced, not silent: the metadata comment declares the
        // output display-only, so it can't be mistaken for a traceable/archival one.
        assert!(
            out.svg.contains("provenance traces suppressed"),
            "a trace-free SVG must declare itself display-only"
        );

        // The default (archival) render instead declares that traces are present.
        let archival = render(&layout, &RenderOptions::default());
        assert!(archival.svg.contains("data-prov trace"));
        assert!(!archival.svg.contains("provenance traces suppressed"));
    }

    #[test]
    fn classes_partition_the_glyphs() {
        let layout = stub_layout(7);
        let out = render(&layout, &RenderOptions::default());
        let total: usize = out.stats.class_counts.values().sum();
        assert_eq!(total, layout.glyphs.len());
    }
}
