//! **Real quality-metric computation** — the nine normative axes of the
//! *Quality Metric Catalog* companion (v0.3.0, Chapter 3), measured over what
//! the pipeline already produced: the resolved world-frame geometry
//! ([`CastLayout`]), the constrained input (slot identity, vertical bands), and
//! the declared page geometry. This module implements the v0.3.0 unit set:
//! `spacing_distortion` over rhythmic columns (v0.2.0) and
//! `vertical_density_penalty` over each *realization* of a gap band (v0.3.0).
//! Both revisions narrowed what is measured over, not what it is normalized
//! against: the anchors, threshold tables, and warning fraction are the
//! catalog's, transcribed unchanged in [`epiphany_layout_ir::quality`].
//!
//! Every measurement here is a pure function of the solve's inputs and its
//! resolved output — no clocks, no entropy, fixed iteration order — so repeated
//! identical solves yield bitwise-identical vectors (catalog
//! `req:qmc:determinism`). Where a metric's contributing-unit set is empty the
//! axis is exactly `0.0` (the catalog's vacuous-geometry rule,
//! `req:qmc:vacuous`), never a sentinel.
//!
//! ## Where each axis's inputs come from
//!
//! * **`collision_penalty`** — full pairwise same-system sweep over resolved
//!   glyph ink boxes (positions from casting, boxes from the catalog metrics),
//!   excluding same-slot pairs (slot identity = the glyph's
//!   `horizontal_slot` in the constrained input; the resolved glyph list is
//!   index-parallel to it) and strokes (not glyphs, never swept).
//! * **`spacing_distortion`** — per-system advances between **rhythmic**
//!   columns: the distinct resolved x of each note/rest-bearing slot realized
//!   in the system (its first member's baseline — the spacing pass's own column
//!   reference). The clef/key/time lead and barlines bear no notehead or rest,
//!   so they contribute no column and a note-to-note advance spans them
//!   (catalog §`spacing_distortion` — measuring rhythmic spacing, not furniture).
//! * **`slur_shape_penalty`** — **measured** (Push 3): each drawn slur's arc
//!   ratio `ρ = apex height / chord length` is penalized by its distance
//!   outside the shallow-arc band `[0.08, 0.25]` (catalog §`slur_shape`),
//!   measured over the *spaced* whole curves (the drawn shape, one unit per
//!   slur — not the cast's per-system fragments). The Minimal tier's mid-span
//!   slurs sit at `ρ ≈ 0.16` (in band, 0), but its fixed height clamps push
//!   short slurs above the band (too bulgy) and very long ones below it (too
//!   flat) — a real non-zero value. A curve-free layout measures 0 by the
//!   vacuous-geometry rule. **`beam_slope_penalty`** stays
//!   **vacuous 0.0**: no beam geometry is drawn yet (beams exist logically, not
//!   as segments), so its contributing-unit set is empty.
//! * **`vertical_density_penalty`** — realized gaps against the band model's
//!   preferred heights: the constrained input's `InterStaffGap` bands
//!   (adjacent staff bands' resolved ink extents; the constrained stage's
//!   fixed staff stacking is preserved verbatim, so this measures what the
//!   resolved geometry actually shows), plus the casting pass's realized
//!   inter-system gaps (consecutive systems on a page) against
//!   [`VerticalBand::inter_system_gap`]'s preferred height — the same
//!   constructor the stacking consults. (`to_constrained` declares no
//!   `InterSystemGap` bands, so the realized page-tree gaps are the honest
//!   measurable unit set; see DECISIONS.)
//! * **`system_break_penalty`** — per-region non-final systems: `|W − w_s| / W`
//!   with `W` the declared content width and `w_s` the system's glyph-ink
//!   span.
//! * **`page_fill_efficiency`** — non-final pages: unfilled fraction of the
//!   declared content height, spans from the resolved page tree's system
//!   bounding boxes (top of first system to bottom of last).
//! * **`casting_off_quality`** — per-region CV of system glyph-ink widths,
//!   final system included (regions with ≥ 2 systems, all widths positive).
//! * **`symbol_density_uniformity`** — per-region CV of glyphs-per-width
//!   density over systems with positive width.

use std::collections::{BTreeMap, BTreeSet};

use epiphany_layout_ir::quality::{
    anchors, normalize, MetricThresholds, QUALITY_FLOOR_FRACTION, QUALITY_METRIC_KINDS,
};
use epiphany_layout_ir::{
    inter_staff_gap_id, ConstrainedLayoutIR, Curve, QualityMetricVector, SolverWarning,
    SolverWarningKind, SpringSlotId, VerticalBand, VerticalBandId, VerticalBandKind,
};

use crate::casting::{CastLayout, PageGeometry};

/// The population coefficient of variation (catalog §"The Measurement Domain"):
/// defined for `k >= 2` values with positive mean; `None` otherwise.
fn cv(values: &[f64]) -> Option<f64> {
    if values.len() < 2 {
        return None;
    }
    let mean = values.iter().sum::<f64>() / values.len() as f64;
    if mean <= 0.0 {
        return None;
    }
    let variance =
        values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / values.len() as f64;
    Some(variance.sqrt() / mean)
}

/// The arithmetic mean over a contributing-unit set, with the catalog's
/// vacuous-geometry rule in aggregate form: the mean over an empty set is `0`.
fn mean_or_zero(values: &[f64]) -> f64 {
    if values.is_empty() {
        0.0
    } else {
        values.iter().sum::<f64>() / values.len() as f64
    }
}

/// How many points a slur curve is sampled at to find its apex height. Even, so
/// a sample lands on the symmetric arc's `t = 0.5` apex exactly.
const SLUR_APEX_SAMPLES: usize = 32;

/// The raw `slur_shape_penalty` measurement (Quality Metric Catalog
/// §`slur_shape_penalty`): for each drawn slur curve with chord length `c > 0`
/// — the chord being the segment between the curve's endpoints and the apex
/// height `h` the maximum perpendicular distance from the curve to that chord —
/// the arc ratio `ρ = h / c` is penalized by its distance outside the shallow-
/// arc band `[0.08, 0.25]` (`max(0, 0.08 − ρ, ρ − 0.25)`); the axis raw value
/// is the arithmetic mean over units. A curve-free layout has no units, so the
/// mean is `0` (the vacuous-geometry rule) — not by construction but by
/// measurement.
///
/// Measured over the **whole spaced** slur curves — post-horizontal-remap (the
/// drawn shape) and pre-cast-split (one unit per slur) — *not* the cast
/// output's per-system fragments: casting splits a break-spanning slur into
/// sub-cubics whose diagonal chords each read flatter than the whole arc, which
/// would spuriously penalize (and double-count) a slur that is ideally shaped
/// as a whole. The catalog's property "a tier that draws the ideal shallow arc
/// for every slur measures 0" holds only when the whole arc is the unit.
/// Measuring the *spaced* (not constrained) curves means horizontal re-spacing
/// that flattens or steepens a drawn slur is honestly captured here rather than
/// hidden — the units are "drawn slurs" (catalog §`slur_shape`).
fn slur_shape_raw(spaced_curves: &[Curve]) -> f64 {
    let per_curve: Vec<f64> = spaced_curves
        .iter()
        .filter_map(|curve| {
            let cp = curve.control_points();
            let (a, b) = (point(cp[0]), point(cp[3]));
            let chord = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
            if chord <= 0.0 {
                return None; // c > 0 required
            }
            let apex = (0..=SLUR_APEX_SAMPLES)
                .map(|i| {
                    let t = i as f32 / SLUR_APEX_SAMPLES as f32;
                    perp_distance(a, b, cubic_point(cp, t))
                })
                .fold(0.0_f64, f64::max);
            let rho = apex / chord;
            Some((0.08 - rho).max(rho - 0.25).max(0.0))
        })
        .collect();
    mean_or_zero(&per_curve)
}

/// A layout point as exact `f64`.
fn point(p: epiphany_layout_ir::Point) -> (f64, f64) {
    (f64::from(p.x.0), f64::from(p.y.0))
}

/// The cubic-Bézier point at parameter `t`, as `f64`.
fn cubic_point(cp: [epiphany_layout_ir::Point; 4], t: f32) -> (f64, f64) {
    let (u, t) = (f64::from(1.0 - t), f64::from(t));
    let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
    (
        w[0] * f64::from(cp[0].x.0)
            + w[1] * f64::from(cp[1].x.0)
            + w[2] * f64::from(cp[2].x.0)
            + w[3] * f64::from(cp[3].x.0),
        w[0] * f64::from(cp[0].y.0)
            + w[1] * f64::from(cp[1].y.0)
            + w[2] * f64::from(cp[2].y.0)
            + w[3] * f64::from(cp[3].y.0),
    )
}

/// Perpendicular distance from point `p` to the line through `a` and `b`
/// (0 when `a == b`).
fn perp_distance(a: (f64, f64), b: (f64, f64), p: (f64, f64)) -> f64 {
    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
    let len = (dx * dx + dy * dy).sqrt();
    if len <= 0.0 {
        return 0.0;
    }
    ((p.0 - a.0) * dy - (p.1 - a.1) * dx).abs() / len
}

/// One glyph's resolved ink box `[left, bottom, right, top]` (f64, exact from
/// the f32 geometry).
fn ink_box(cast: &CastLayout, input: &ConstrainedLayoutIR, index: usize) -> [f64; 4] {
    let resolved = &cast.glyphs[index];
    let bounds = &input.glyphs[index].bounding_box;
    [
        f64::from(resolved.position.x.0 + bounds.left.0),
        f64::from(resolved.position.y.0 + bounds.bottom.0),
        f64::from(resolved.position.x.0 + bounds.right.0),
        f64::from(resolved.position.y.0 + bounds.top.0),
    ]
}

/// Per-system aggregates over the casting pass's own glyph→system assignment.
struct SystemCensus {
    /// Region each system slices (parallel to the other vectors).
    region: Vec<usize>,
    /// Glyph indices per system, in input order.
    members: Vec<Vec<usize>>,
    /// Glyph-ink span `w_s` per system (0 for a glyph-less system).
    width: Vec<f64>,
    /// Reference x of each **rhythmic** column (a slot bearing a notehead or
    /// rest) per system, ascending and distinct — the spacing axis's domain
    /// (catalog §`spacing_distortion`: the clef/key/time lead and barlines are
    /// furniture, not rhythmic columns).
    columns: Vec<Vec<f64>>,
}

/// Whether a glyph is a notehead or a rest — the mark that makes its slot a
/// **rhythmic column** (catalog §`spacing_distortion`).
fn is_rhythmic(name: &str) -> bool {
    name.starts_with("notehead") || name.starts_with("rest")
}

fn census(input: &ConstrainedLayoutIR, cast: &CastLayout) -> SystemCensus {
    let count = cast.region_of_system.len();
    let mut members: Vec<Vec<usize>> = vec![Vec::new(); count];
    let mut spans: Vec<Option<(f64, f64)>> = vec![None; count];
    // Rhythmic slots: those bearing a notehead or rest. Precomputed because a
    // slot's rhythmic status depends on *all* its members (a note's accidental
    // may precede its notehead in input order), and only rhythmic slots become
    // spacing columns (catalog §`spacing_distortion`, "rhythmic column").
    let rhythmic: BTreeSet<SpringSlotId> = input
        .glyphs
        .iter()
        .filter(|glyph| is_rhythmic(glyph.glyph.as_str()))
        .map(|glyph| glyph.horizontal_slot)
        .collect();
    // Column reference: the slot's first member (input order) — the same
    // convention the spacing and casting passes use for a slot's reference x.
    let mut columns: Vec<BTreeMap<SpringSlotId, f64>> = vec![BTreeMap::new(); count];
    for (index, glyph) in input.glyphs.iter().enumerate() {
        // W1 pin 7: the published attribution, not a second derivation from
        // `system_of_slot` — one glyph→system rule, computed once in casting.
        let Some(system) = cast.glyph_system[index] else {
            // A slot no region claimed: positioned by no system, so its glyphs
            // join no per-system aggregate (catalog §"The Measurement Domain").
            continue;
        };
        members[system].push(index);
        let [left, _, right, _] = ink_box(cast, input, index);
        spans[system] = Some(match spans[system] {
            Some((lo, hi)) => (lo.min(left), hi.max(right)),
            None => (left, right),
        });
        // Only rhythmic columns carry spacing advances; a note-to-note advance
        // spans any intervening barline or furniture (which contribute no column).
        if rhythmic.contains(&glyph.horizontal_slot) {
            columns[system]
                .entry(glyph.horizontal_slot)
                .or_insert_with(|| f64::from(cast.glyphs[index].position.x.0));
        }
    }
    let width = spans
        .iter()
        .map(|span| span.map(|(lo, hi)| (hi - lo).max(0.0)).unwrap_or(0.0))
        .collect();
    let columns = columns
        .into_iter()
        .map(|by_slot| {
            let mut xs: Vec<f64> = by_slot.into_values().collect();
            xs.sort_by(f64::total_cmp);
            xs.dedup();
            xs
        })
        .collect();
    SystemCensus {
        region: cast.region_of_system.clone(),
        members,
        width,
        columns,
    }
}

/// `collision_penalty` (catalog §`collision_penalty`): colliding unordered
/// same-system, different-slot glyph pairs per glyph. Ink boxes must intersect
/// with positive area in both axes; edge-touching boxes do not collide;
/// same-slot pairs (a column's internal cluster — chord heads, their
/// accidentals, dots) are excluded; strokes are not glyphs and join no pair.
fn collision_raw(input: &ConstrainedLayoutIR, cast: &CastLayout, census: &SystemCensus) -> f64 {
    let population = cast.glyphs.len();
    if population == 0 {
        return 0.0;
    }
    let mut colliding_pairs: u64 = 0;
    for members in &census.members {
        // Interval sweep over left edges: a pair can only overlap horizontally
        // while the candidate's left edge is inside the anchor's span.
        let mut boxes: Vec<(usize, [f64; 4])> = members
            .iter()
            .map(|&index| (index, ink_box(cast, input, index)))
            .collect();
        boxes.sort_by(|a, b| a.1[0].total_cmp(&b.1[0]).then(a.0.cmp(&b.0)));
        for i in 0..boxes.len() {
            let (index_a, a) = boxes[i];
            for &(index_b, b) in boxes.iter().skip(i + 1) {
                if b[0] >= a[2] {
                    break; // sorted by left edge: nothing further overlaps in x
                }
                if input.glyphs[index_a].horizontal_slot == input.glyphs[index_b].horizontal_slot {
                    continue; // same-column cluster: excluded by the catalog
                }
                let overlap_x = a[2].min(b[2]) - a[0].max(b[0]);
                let overlap_y = a[3].min(b[3]) - a[1].max(b[1]);
                if overlap_x > 0.0 && overlap_y > 0.0 {
                    colliding_pairs += 1;
                }
            }
        }
    }
    colliding_pairs as f64 / population as f64
}

/// `spacing_distortion` (catalog §`spacing_distortion`): mean per-system CV of
/// **rhythmic** column advances, over systems realizing at least three rhythmic
/// columns (note/rest-bearing slots — see [`census`]).
fn spacing_raw(census: &SystemCensus) -> f64 {
    let mut per_system = Vec::new();
    for columns in &census.columns {
        if columns.len() < 3 {
            continue;
        }
        let advances: Vec<f64> = columns.windows(2).map(|pair| pair[1] - pair[0]).collect();
        if let Some(value) = cv(&advances) {
            per_system.push(value);
        }
    }
    mean_or_zero(&per_system)
}

/// `vertical_density_penalty` (catalog §`vertical_density_penalty`): mean
/// relative deviation `|r − p| / p` over the realized inter-staff and
/// inter-system gaps (see the module docs for the unit reconstruction).
fn vertical_raw(input: &ConstrainedLayoutIR, cast: &CastLayout, census: &SystemCensus) -> f64 {
    let units = vertical_units(input, cast, census);
    mean_or_zero(&[units.inter_staff, units.inter_system].concat())
}

/// The axis's contributing units, split by kind (catalog §`vertical_density_penalty`:
/// "each *realization* … contributes one unit per system").
///
/// Split out so tests can assert the unit **set**, not merely its mean. Once the
/// inter-staff solve realizes each gap band's declared clearance exactly, every
/// inter-staff unit is `0` on a healthy solve — the axis is a self-check that
/// fires only on a solver or bake defect. Its mean therefore cannot distinguish
/// "measured every realization" from "measured one", which is precisely the
/// defect this split lets a regression catch.
struct VerticalUnits {
    inter_staff: Vec<f64>,
    inter_system: Vec<f64>,
}

fn vertical_units(
    input: &ConstrainedLayoutIR,
    cast: &CastLayout,
    census: &SystemCensus,
) -> VerticalUnits {
    let mut per_unit: Vec<f64> = Vec::new();

    // --- InterStaffGap bands declared by the constrained input -------------
    // The realized gap between two staves is measured over their FULL content —
    // every glyph, stroke, and curve the band owns — not over the band's glyph
    // `members` alone. A staff's ink is mostly not glyphs: ledger lines, stems,
    // and slurs routinely reach further from the staff than any notehead, and
    // the inter-staff solve separates staves until their *content* clears the
    // declared gap. Measuring glyphs only scored a correctly separated system
    // against a gap it never targeted, reporting the ledger and slur ink the
    // solve had already accounted for as crowding.
    //
    // Ownership comes from each primitive's declared `vertical_band`
    // (`req:layoutir:primitive-band-ownership`); the geometry is read back from
    // the BAKED output, so a shift the bake failed to apply to some primitive
    // class surfaces here as a real deviation rather than hiding behind the
    // solver's own intent.
    // W1 pin 7, same as the census: the published attribution, not a second
    // derivation. `glyph_system` is parallel to `input.glyphs`, exactly as the
    // `stroke_system`/`curve_system` reads below are to their arrays.
    let system_of_glyph = |index: usize| -> Option<usize> { cast.glyph_system[index] };
    let mut content: BTreeMap<(usize, VerticalBandId), (f64, f64)> = BTreeMap::new();
    {
        let mut add = |system: usize, band: VerticalBandId, lo: f64, hi: f64| {
            let entry = content
                .entry((system, band))
                .or_insert((f64::INFINITY, f64::NEG_INFINITY));
            entry.0 = entry.0.min(lo);
            entry.1 = entry.1.max(hi);
        };
        for (index, glyph) in input.glyphs.iter().enumerate() {
            if let Some(system) = system_of_glyph(index) {
                let ink = ink_box(cast, input, index);
                add(system, glyph.vertical_band, ink[1], ink[3]);
            }
        }
        for (index, stroke) in cast.strokes.iter().enumerate() {
            let Some(system) = cast.stroke_system[index] else {
                continue;
            };
            let half = f64::from(stroke.thickness.0.max(0.0)) * 0.5;
            let lo = f64::from(stroke.from.y.0.min(stroke.to.y.0)) - half;
            let hi = f64::from(stroke.from.y.0.max(stroke.to.y.0)) + half;
            add(system, stroke.vertical_band, lo, hi);
        }
        for (index, curve) in cast.curves.iter().enumerate() {
            let Some(system) = cast.curve_system[index] else {
                continue;
            };
            let half = f64::from(curve.thickness.0.max(0.0)) * 0.5;
            for point in curve.control_points() {
                let y = f64::from(point.y.0);
                add(system, curve.vertical_band, y - half, y + half);
            }
        }
    }

    for (region_index, region) in input.regions.iter().enumerate() {
        // The region's systems, in page order. A staff band is `to_constrained`'s
        // per-*manifestation* band — one per (staff, region) — so its content can
        // only land in its own region's systems, and "has content in one of this
        // region's systems" identifies the region's staff bands exactly.
        //
        // Membership is NOT read from `VerticalBand::members`. That list holds
        // glyphs, and a staff band is allowed to have none: a staff whose clef is
        // unbundled engraves to an anchor *stroke*, and its staff lines are
        // strokes regardless. Filtering by glyph members would silently drop such
        // a staff from the axis — the same glyph-members assumption this metric
        // exists to shed.
        let region_systems: Vec<usize> = census
            .region
            .iter()
            .enumerate()
            .filter(|&(_, &r)| r == region_index)
            .map(|(system, _)| system)
            .collect();
        let Some(&first_system) = region_systems.first() else {
            continue;
        };
        // Top staff first, ordered within the region's first system: a system
        // translates rigidly as a whole, so the staff ORDER is the same in each
        // (only the gaps between them are renegotiated per system).
        let mut staves: Vec<(f64, VerticalBandId)> = input
            .vertical_bands
            .iter()
            .filter(|band| matches!(band.kind, VerticalBandKind::Staff(_)))
            .filter_map(|band| {
                content
                    .get(&(first_system, band.id))
                    .map(|&(_, top)| (top, band.id))
            })
            .collect();
        staves.sort_by(|a, b| b.0.total_cmp(&a.0));

        // The region's declared inter-staff gap bands, by their derived ids
        // (gap g separates the region's staves g−1 and g, per to_constrained).
        let region_layout_id = region.provenance.stable_id;
        for gap in 1.. {
            let gap_id = inter_staff_gap_id(region_layout_id, gap);
            let Some(band) = input.vertical_bands.iter().find(|band| band.id == gap_id) else {
                break;
            };
            let preferred = f64::from(band.preferred_height.0);
            if preferred <= 0.0 || staves.len() <= gap {
                continue;
            }
            let (upper, lower) = (staves[gap - 1].1, staves[gap].1);
            // EVERY system realizing the pair contributes a unit, not just the
            // first. The inter-staff solve sizes each system's gaps from that
            // system's own content, so one band's realized height genuinely
            // differs across the systems of a region — a later system may carry
            // different pressure, or a bake bug touching one primitive class.
            // Measuring only the first would average away both.
            for &system in &region_systems {
                let (Some(&(upper_bottom, _)), Some(&(_, lower_top))) =
                    (content.get(&(system, upper)), content.get(&(system, lower)))
                else {
                    continue;
                };
                let realized = (upper_bottom - lower_top).max(0.0);
                per_unit.push((realized - preferred).abs() / preferred);
            }
        }
    }

    let inter_staff = std::mem::take(&mut per_unit);

    // --- Realized inter-system gaps (consecutive systems on a page) --------
    let preferred = f64::from(
        VerticalBand::inter_system_gap(VerticalBandId(0))
            .preferred_height
            .0,
    );
    if preferred > 0.0 {
        for page in &cast.pages {
            for pair in page.systems.windows(2) {
                let upper_bottom = f64::from(pair[0].bounding_box.origin.y.0);
                let lower_top =
                    f64::from(pair[1].bounding_box.origin.y.0 + pair[1].bounding_box.size.height.0);
                let realized = (upper_bottom - lower_top).max(0.0);
                per_unit.push((realized - preferred).abs() / preferred);
            }
        }
    }

    VerticalUnits {
        inter_staff,
        inter_system: per_unit,
    }
}

/// `system_break_penalty` (catalog §`system_break_penalty`): mean
/// `|W − w_s| / W` over each region's non-final systems, defined only for a
/// finite positive content width.
fn system_break_raw(census: &SystemCensus, content_width: f64) -> f64 {
    if !(content_width.is_finite() && content_width > 0.0) {
        return 0.0;
    }
    let mut per_unit = Vec::new();
    for (system, &region) in census.region.iter().enumerate() {
        let last_of_region = census.region.iter().rposition(|&r| r == region);
        if last_of_region == Some(system) {
            continue; // a short last line is not a break failure
        }
        per_unit.push((content_width - census.width[system]).abs() / content_width);
    }
    mean_or_zero(&per_unit)
}

/// `page_fill_efficiency` (catalog §`page_fill_efficiency`): mean unfilled
/// fraction over non-final pages, spans measured from the resolved page tree
/// (top of the first system's content extent to the bottom of the last's).
fn page_fill_raw(cast: &CastLayout, content_height: f64) -> f64 {
    if !(content_height.is_finite() && content_height > 0.0) || cast.pages.len() < 2 {
        return 0.0;
    }
    let mut per_unit = Vec::new();
    for page in &cast.pages[..cast.pages.len() - 1] {
        let (Some(first), Some(last)) = (page.systems.first(), page.systems.last()) else {
            continue;
        };
        let top = f64::from(first.bounding_box.origin.y.0 + first.bounding_box.size.height.0);
        let bottom = f64::from(last.bounding_box.origin.y.0);
        let fill = ((top - bottom) / content_height).min(1.0);
        per_unit.push(1.0 - fill);
    }
    mean_or_zero(&per_unit)
}

/// `casting_off_quality` (catalog §`casting_off_quality`): mean per-region CV
/// of system widths — final system included — over regions cast onto at least
/// two systems, each with positive width.
fn casting_off_raw(input: &ConstrainedLayoutIR, census: &SystemCensus) -> f64 {
    let mut per_region = Vec::new();
    for region in 0..input.regions.len() {
        let widths: Vec<f64> = census
            .region
            .iter()
            .zip(&census.width)
            .filter(|&(&r, _)| r == region)
            .map(|(_, &w)| w)
            .collect();
        if widths.len() < 2 || widths.iter().any(|&w| w <= 0.0) {
            continue;
        }
        if let Some(value) = cv(&widths) {
            per_region.push(value);
        }
    }
    mean_or_zero(&per_region)
}

/// `symbol_density_uniformity` (catalog §`symbol_density_uniformity`): mean
/// per-region CV of per-system symbol density (glyphs per staff space of
/// content width), over regions with at least two positive-width systems.
fn symbol_density_raw(input: &ConstrainedLayoutIR, census: &SystemCensus) -> f64 {
    let mut per_region = Vec::new();
    for region in 0..input.regions.len() {
        let densities: Vec<f64> = census
            .region
            .iter()
            .enumerate()
            .filter(|&(system, &r)| r == region && census.width[system] > 0.0)
            .map(|(system, _)| census.members[system].len() as f64 / census.width[system])
            .collect();
        if densities.len() < 2 {
            continue;
        }
        if let Some(value) = cv(&densities) {
            per_region.push(value);
        }
    }
    mean_or_zero(&per_region)
}

/// Computes the full nine-axis [`QualityMetricVector`] for a cast layout, per
/// the Quality Metric Catalog's formulas and pinned anchors. Pure and
/// deterministic: a function of the constrained input, the cast output, and
/// the declared page geometry.
pub(crate) fn measure(
    input: &ConstrainedLayoutIR,
    cast: &CastLayout,
    spaced_curves: &[Curve],
    geometry: &PageGeometry,
) -> QualityMetricVector {
    let census = census(input, cast);
    let content_width = f64::from(geometry.content_width());
    let content_height = f64::from(geometry.content_height());
    QualityMetricVector {
        collision_penalty: normalize(
            collision_raw(input, cast, &census),
            anchors::COLLISION_R_WORST,
        ),
        spacing_distortion: normalize(spacing_raw(&census), anchors::SPACING_R_WORST),
        // Slurs draw (E2), so their shape is now MEASURED (Push 3), not pinned:
        // each drawn slur's arc ratio ρ = apex height / chord length is
        // penalized by its distance outside the shallow-arc band [0.08, 0.25].
        // Measured on the SPACED whole curves — post-horizontal-remap (the
        // shape the reader sees), pre-cast-split (the whole slur, one unit) —
        // so re-spacing that visibly flattens or steepens a slur is caught, yet
        // a well-shaped slur that merely crosses a system break is not
        // penalized by its fragments' diagonal chords. A curve-free layout
        // measures 0 by the vacuous-geometry rule.
        slur_shape_penalty: normalize(slur_shape_raw(spaced_curves), anchors::SLUR_SHAPE_R_WORST),
        // Same vacuous rule: no drawn beam segments exist in this pipeline.
        beam_slope_penalty: normalize(0.0, anchors::BEAM_SLOPE_R_WORST),
        vertical_density_penalty: normalize(
            vertical_raw(input, cast, &census),
            anchors::VERTICAL_DENSITY_R_WORST,
        ),
        system_break_penalty: normalize(
            system_break_raw(&census, content_width),
            anchors::SYSTEM_BREAK_R_WORST,
        ),
        page_fill_efficiency: normalize(
            page_fill_raw(cast, content_height),
            anchors::PAGE_FILL_R_WORST,
        ),
        casting_off_quality: normalize(
            casting_off_raw(input, &census),
            anchors::CASTING_OFF_R_WORST,
        ),
        symbol_density_uniformity: normalize(
            symbol_density_raw(input, &census),
            anchors::SYMBOL_DENSITY_R_WORST,
        ),
        extension_metrics: Vec::new(),
    }
}

/// The `QualityFloorApproached` warnings a computed vector earns (catalog
/// §"The `QualityFloorApproached` Warning", `req:qmc:floor-warning`): one per
/// axis whose value exceeds [`QUALITY_FLOOR_FRACTION`] × the applicable
/// threshold — the column selected by the solve's profile. The warning is
/// diagnostic; per the catalog it does **not** change the solve's status.
pub(crate) fn floor_warnings(
    vector: &QualityMetricVector,
    thresholds: &MetricThresholds,
) -> Vec<SolverWarning> {
    QUALITY_METRIC_KINDS
        .iter()
        .filter_map(|&kind| {
            let value = vector.axis(kind).0;
            let threshold = thresholds.axis(kind);
            let floor = QUALITY_FLOOR_FRACTION * threshold;
            (value > floor).then(|| SolverWarning {
                kind: SolverWarningKind::QualityFloorApproached { metric: kind },
                affected_objects: Vec::new(),
                message: format!(
                    "quality metric {kind:?} at {value:.4} exceeds {QUALITY_FLOOR_FRACTION} x \
                     the profile's threshold {threshold:.2} (floor {floor:.3})"
                ),
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{census, vertical_units, VerticalUnits};
    use crate::Engraver;
    use epiphany_layout_ir::{
        to_constrained, to_logical, ConstrainedLayoutIR, ConstraintSolver, QualityMetricKind,
        QualityMetricVector, SolveStatus, SolverConfig, SolverProfile, SolverWarningKind,
        QUALITY_METRIC_KINDS,
    };

    /// The QUICKSTART ten-measure hand-off fixture: wraps into two systems
    /// under the default A4 geometry — the multi-system measurement case.
    fn ten_measure() -> ConstrainedLayoutIR {
        to_constrained(&to_logical(
            &epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
        ))
    }

    fn axes(vector: &QualityMetricVector) -> [f64; 9] {
        let mut values = [0.0; 9];
        for (slot, kind) in values.iter_mut().zip(QUALITY_METRIC_KINDS) {
            *slot = vector.axis(kind).0;
        }
        values
    }

    #[test]
    fn metric_vectors_are_bitwise_deterministic() {
        // Catalog `req:qmc:determinism`: identical solve inputs yield
        // bitwise-identical vectors within one implementation version — the
        // metrics are a pure function of the resolved output and the inputs.
        let input = ten_measure();
        let a = Engraver::default().solve(&input, &SolverConfig::default());
        let b = Engraver::default().solve(&input, &SolverConfig::default());
        assert_eq!(a.layout.canonical_bytes(), b.layout.canonical_bytes());
        for (x, y) in axes(&a.metric_vector).iter().zip(axes(&b.metric_vector)) {
            assert_eq!(
                x.to_bits(),
                y.to_bits(),
                "metric f64s must be bit-identical"
            );
        }
    }

    #[test]
    fn the_wrapping_fixture_is_measured_honestly() {
        // The ten-measure fixture under the default geometry, measured for
        // real (values pinned loosely; the goldens pin the geometry itself):
        // no cross-column collisions; regular spacing; a single page (the
        // page-fill axis degenerates to exactly 0.0). Greedy first-fit alone
        // would leave a two-measure stub last system (width CV 0.61 -> the
        // clamped worst 1.0); casting-off's widow-rebalance evens that into a
        // six/four split (system widths ~59.5 vs ~37.8 staff spaces, CV ~0.22),
        // so casting_off measures ~0.45 — comfortably inside the Minimal 0.90
        // threshold. The trade-off is on the break axis: the non-final system
        // is deliberately left ~66% full (not greedy's ~87%), so system_break
        // rises to ~0.68 — still well inside Minimal. Both axes sit above the
        // Standard profile's floor (0.8 x 0.35 = 0.28), so both fire the
        // SHOULD-level diagnostic, which per the catalog never changes status.
        let report = Engraver::default().solve(&ten_measure(), &SolverConfig::default());
        let vector = &report.metric_vector;
        assert_eq!(vector.collision_penalty.0, 0.0);
        assert!(vector.spacing_distortion.0 > 0.0 && vector.spacing_distortion.0 < 0.3);
        // The ten-measure fixture has no drawn slur curve, so the axis measures
        // 0.0 by the vacuous-geometry rule (an empty contributing-unit set).
        assert_eq!(vector.slur_shape_penalty.0, 0.0, "vacuous: no drawn slurs");
        assert_eq!(vector.beam_slope_penalty.0, 0.0, "vacuous: no drawn beams");
        assert_eq!(vector.page_fill_efficiency.0, 0.0, "vacuous: single page");
        // Per-system justification fills every NON-final system to the content
        // width, so the system-break axis (which measures a non-final system's
        // shortfall) collapses to near zero — the point of justification.
        assert!(
            vector.system_break_penalty.0 < 0.1,
            "the non-final system fills the width after justification: {}",
            vector.system_break_penalty.0
        );
        // The width-uniformity axes are non-zero: the justified non-final system
        // spans the full width while the last stays ragged-right, so the two
        // systems' ink widths (and their glyph densities) differ. Optimal
        // casting-off balances the split (5/4 measures, not greedy's 6-plus/stub),
        // which fills the final system more and pulls casting_off DOWN from the
        // greedy value (~0.80 → ~0.61) — the break search's payoff. Still honest
        // for a two-system score (one full line, one shorter last line); a metric
        // refinement that scores casting-off on natural widths or excludes the
        // intentionally-ragged final system is a follow-up (see DECISIONS).
        assert!(
            (0.5..0.7).contains(&vector.casting_off_quality.0),
            "the balanced split leaves a moderate full-vs-ragged contrast: {}",
            vector.casting_off_quality.0
        );
        assert!(
            (0.45..0.65).contains(&vector.symbol_density_uniformity.0),
            "density differs between the stretched and the ragged system: {}",
            vector.symbol_density_uniformity.0
        );
        // The casting-off axis exceeds 0.8 × its Standard threshold, so its
        // SHOULD-level floor diagnostic fires (per the catalog, never changing
        // status); the system-break axis, now near zero, no longer floors.
        let floored = |metric: QualityMetricKind| {
            report.warnings.iter().any(|w| {
                matches!(w.kind, SolverWarningKind::QualityFloorApproached { metric: m } if m == metric)
            })
        };
        assert!(floored(QualityMetricKind::CastingOff));
        assert!(!floored(QualityMetricKind::SystemBreak));
        assert_eq!(report.status, SolveStatus::Solved);
    }

    #[test]
    fn slur_shape_is_measured_penalizing_out_of_band_arcs() {
        use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle};
        use epiphany_layout_ir::to_constrained;

        let with_slur = |start: usize, end: usize| {
            let mut s = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE);
            let ev: Vec<_> = s.canvas.regions[0].staff_instances()[0].voices[0]
                .events
                .clone();
            s.cross_cutting.slurs.push(Slur {
                id: s.identity.mint::<SlurId>(),
                start_event: ev[start],
                end_event: ev[end],
                kind: SlurKind::Legato,
                curvature_override: None,
                style: SpanStyle::default(),
            });
            Engraver::default()
                .solve(&to_constrained(&to_logical(&s)), &SolverConfig::default())
                .metric_vector
                .slur_shape_penalty
                .0
        };
        // A slur over adjacent events: the min-height clamp forces a tall arc
        // over a tiny chord (ρ well above the 0.25 band), a real bulge penalty.
        assert!(
            with_slur(0, 1) > 0.0,
            "a bulgy short slur is penalized: {}",
            with_slur(0, 1)
        );
        // A slur over a wide span: the auto height gives ρ ≈ 0.16, inside the
        // ideal band [0.08, 0.25], so no penalty.
        assert_eq!(
            with_slur(0, 6),
            0.0,
            "an in-band (mid-span) slur is not penalized"
        );
    }

    #[test]
    fn a_break_spanning_in_band_slur_measures_zero_despite_splitting() {
        // A well-shaped (ρ ≈ 0.16, in-band) slur whose span crosses a system
        // break splits into per-system sub-curves. The shape axis measures the
        // WHOLE slur (the constrained curve), not the fragments — whose diagonal
        // chords would each read too flat — so the well-shaped slur still scores
        // 0, as the catalog's "ideal arc ⇒ 0" property requires.
        use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle, TypedObjectId};
        use epiphany_layout_ir::to_constrained;

        let mut score = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE);
        let ev: Vec<_> = score.canvas.regions[0].staff_instances()[0].voices[0]
            .events
            .clone();
        let id: SlurId = score.identity.mint();
        // Events 14→24 straddle the fixture's centred two-system break (optimal
        // casting-off balances to ~5/4 measures), wide enough that the height
        // clamp does not bind → ρ ≈ 0.16.
        score.cross_cutting.slurs.push(Slur {
            id,
            start_event: ev[14],
            end_event: ev[24],
            kind: SlurKind::Legato,
            curvature_override: None,
            style: SpanStyle::default(),
        });
        let report = Engraver::default().solve(
            &to_constrained(&to_logical(&score)),
            &SolverConfig::default(),
        );
        // The slur really did split (the fragment path would have fired)…
        let segments = report
            .layout
            .curves
            .iter()
            .filter(|c| c.provenance.source == TypedObjectId::Slur(id))
            .count();
        assert!(segments >= 2, "the slur splits, got {segments} segment(s)");
        // …yet the shape axis is 0: the whole arc is in-band.
        assert_eq!(
            report.metric_vector.slur_shape_penalty.0, 0.0,
            "a split but well-shaped slur is not spuriously penalized"
        );
    }

    #[test]
    fn floor_warnings_reference_the_profiles_threshold_column() {
        // The floor warning references the PROFILE'S threshold column, not a
        // fixed threshold: the Standard column's casting-off threshold is
        // tighter than the Minimal column's, so a value BETWEEN their floors
        // warns under Standard/Publication (the Standard column) but not under
        // Draft (the Minimal column). Tested directly on `floor_warnings` with a
        // synthetic value between the two floors — robust to how any particular
        // fixture's casting-off happens to land (justification, for one, now
        // drives the ten-measure fixture's casting-off above both floors).
        use epiphany_layout_ir::quality::{
            profile_thresholds, MINIMAL_THRESHOLDS, QUALITY_FLOOR_FRACTION, STANDARD_THRESHOLDS,
        };
        use epiphany_layout_ir::NormalizedMetric;

        let standard_floor = QUALITY_FLOOR_FRACTION * STANDARD_THRESHOLDS.casting_off_quality;
        let minimal_floor = QUALITY_FLOOR_FRACTION * MINIMAL_THRESHOLDS.casting_off_quality;
        assert!(
            standard_floor < minimal_floor,
            "the Standard casting-off column is tighter than the Minimal one"
        );
        let between = 0.5 * (standard_floor + minimal_floor);
        // Only casting_off matters here; the other axes' values are irrelevant
        // (the closure inspects the CastingOff warning alone).
        let vector = QualityMetricVector {
            casting_off_quality: NormalizedMetric(between),
            ..QualityMetricVector::unmeasured()
        };
        let castoff_warned = |profile: SolverProfile| {
            super::floor_warnings(&vector, profile_thresholds(profile))
                .iter()
                .any(|w| {
                    matches!(
                        w.kind,
                        SolverWarningKind::QualityFloorApproached {
                            metric: QualityMetricKind::CastingOff
                        }
                    )
                })
        };
        assert!(castoff_warned(SolverProfile::Standard));
        assert!(castoff_warned(SolverProfile::Publication));
        assert!(!castoff_warned(SolverProfile::Draft));
    }

    #[test]
    fn short_scores_do_not_trip_the_standard_spacing_floor() {
        // P12-I12 regression. Before `spacing_distortion` was scoped to rhythmic
        // columns, a short healthy line's wide clef-to-first-note lead advance
        // inflated the CV above the Standard warning floor (0.8 x 0.40 = 0.32),
        // so these three-to-eight-column corpus entries spuriously warned
        // (measured 0.36-0.41). Scoped to note/rest columns — the clef/key/time
        // lead bears no notehead, so it contributes no column — none does.
        let floor = 0.8 * 0.40;
        for name in ["b_flat_major_scale", "notes_and_rests", "meter_three_four"] {
            let score = epiphany_testkit::corpus::corpus()
                .into_iter()
                .find(|fixture| fixture.name == name)
                .expect("corpus entry exists");
            let input = to_constrained(&to_logical(&(score.build)()));
            let report = Engraver::default().solve(&input, &SolverConfig::default());
            assert!(
                report.metric_vector.spacing_distortion.0 < floor,
                "{name}: spacing {} still above the Standard floor {floor}",
                report.metric_vector.spacing_distortion.0
            );
            assert!(
                !report.warnings.iter().any(|w| matches!(
                    w.kind,
                    SolverWarningKind::QualityFloorApproached {
                        metric: QualityMetricKind::Spacing
                    }
                )),
                "{name}: spurious spacing floor warning under the default Standard profile"
            );
        }
    }

    #[test]
    fn a_malformed_input_stays_unmeasured() {
        // A structurally invalid input has no trustworthy geometry: the vector
        // is the honest all-worst placeholder, not a vacuous all-best zero.
        let mut input = ten_measure();
        input.glyphs[0].baseline = epiphany_layout_ir::Point::new(f32::NAN, 0.0);
        let report = Engraver::default().solve(&input, &SolverConfig::default());
        assert_eq!(report.status, SolveStatus::InternalError);
        assert_eq!(report.metric_vector, QualityMetricVector::unmeasured());
        // ... and no floor diagnostics are derived from a placeholder.
        assert!(!report
            .warnings
            .iter()
            .any(|w| matches!(w.kind, SolverWarningKind::QualityFloorApproached { .. })));
    }

    #[test]
    fn realized_inter_system_gaps_measure_the_band_models_preferred_height() {
        // The casting pass stacks systems at the vertical-band constructor's
        // preferred inter-system gap, so the vertical-density axis measures
        // realized == preferred (raw 0.0) on the wrapping fixture — the honest
        // near-zero the catalog's rationale describes, *measured* from the
        // resolved page tree rather than assumed.
        let report = Engraver::default().solve(&ten_measure(), &SolverConfig::default());
        assert_eq!(report.metric_vector.vertical_density_penalty.0, 0.0);
    }

    #[test]
    fn vertical_justification_trades_page_fill_for_inter_system_density() {
        use crate::PageGeometry;
        use epiphany_layout_ir::{Margins, Size2D, StaffSpace};
        // On a MULTI-page layout, vertical justification spreads a non-final
        // page's systems to fill the height (`page_fill` → ~0), which stretches
        // its inter-system gaps beyond the band model's preferred height — a
        // cost the vertical-density axis measures. The two axes trade: filling
        // the page is paid for in inter-system density. A catalog refinement
        // that scores only EXCESS stretch (or measures gap uniformity) is a
        // deferred follow-up, alongside the casting_off / justified-raggedness
        // note. Pinned so the interaction is not silent (review finding).
        let geometry = PageGeometry {
            size: Size2D {
                width: StaffSpace(40.0),
                height: StaffSpace(30.0),
            },
            margins: Margins {
                top: StaffSpace(5.0),
                right: StaffSpace(5.0),
                bottom: StaffSpace(5.0),
                left: StaffSpace(5.0),
            },
        };
        let report =
            Engraver::with_geometry(geometry).solve(&ten_measure(), &SolverConfig::default());
        assert!(
            report.layout.pages.len() >= 2,
            "the small page forces multiple pages"
        );
        // Justification fills the non-final pages…
        assert!(
            report.metric_vector.page_fill_efficiency.0 < 0.1,
            "non-final pages fill: {}",
            report.metric_vector.page_fill_efficiency.0
        );
        // …at the measured cost of stretched inter-system gaps.
        assert!(
            report.metric_vector.vertical_density_penalty.0 > 0.0,
            "stretched inter-system gaps are measured: {}",
            report.metric_vector.vertical_density_penalty.0
        );
    }

    /// Runs the real pipeline far enough to hand `vertical_units` a `CastLayout`
    /// — the same spacing + casting-off `Engraver::resolve` performs.
    fn units(score: &epiphany_core::Score) -> VerticalUnits {
        let input = to_constrained(&to_logical(score));
        let engraver = Engraver::default();
        let remap = crate::HorizontalRemap::build(&input);
        let (glyphs, strokes, curves) = (
            remap.glyphs(&input),
            remap.strokes(&input),
            remap.curves(&input),
        );
        let geometry = engraver.geometry();
        let cast = crate::casting::cast_off(&input, &glyphs, &strokes, &curves, &geometry);
        let census = census(&input, &cast);
        vertical_units(&input, &cast, &census)
    }

    /// A staff band owning no glyphs is still a staff of its region, so its gap
    /// contributes. The percussion placeholder's clef has no bundled glyph and it
    /// carries no notes, leaving its band's `members` (a glyph list) empty while
    /// the band owns its staff-line strokes. Identifying a region's staff bands by
    /// `members` drops it, and the axis loses the unit entirely.
    #[test]
    fn a_glyphless_staff_band_still_contributes_an_inter_staff_unit() {
        let u = units(&epiphany_testkit::fixtures::percussion_placeholder_staff(1));
        assert_eq!(
            u.inter_staff.len(),
            2,
            "one unit per system realizing the pair, not zero"
        );
        for value in &u.inter_staff {
            assert!(
                *value < 1e-4,
                "and the solve realizes the declared gap: {value}"
            );
        }
    }

    /// A gap band realized in several systems contributes one unit PER SYSTEM.
    /// The inter-staff solve sizes each system's gaps from that system's own
    /// content, so the realizations are independent measurements; the wrapping
    /// fixture's two systems sit at very different staff distances.
    #[test]
    fn each_system_realizing_a_gap_band_contributes_its_own_unit() {
        let u = units(&epiphany_testkit::fixtures::two_staff_wrapping_pressure(1));
        assert_eq!(u.inter_staff.len(), 2, "two systems, two realizations");
        for value in &u.inter_staff {
            assert!(*value < 1e-4, "each solved to its declared gap: {value}");
        }
        // Three staves in one system: two gap bands, one realization each.
        let u = units(&epiphany_testkit::fixtures::three_staff_close_content(1));
        assert_eq!(u.inter_staff.len(), 2, "two gap bands, one system");
        for value in &u.inter_staff {
            assert!(*value < 1e-4, "the cascade realizes both gaps: {value}");
        }
    }
}
