//  ************************************************************************************************
//
//  BornAgain: simulate and fit reflection and scattering
//
//! @file      Tests/Unit/Sample/ParticleSlicingTest.cpp
//! @brief     Tests the single-slice cutter Refine::particlesInSlice: how a positioned
//!            particle is clamped to one slice's z-limits, and its membership/volume.
//!
//! @homepage  http://www.bornagainproject.org
//! @license   GNU General Public License v3 or higher (see COPYING)
//! @copyright Forschungszentrum Jülich GmbH 2026
//! @authors   Scientific Computing Group at MLZ (see CITATION, AUTHORS)
//
//  ************************************************************************************************

#include "Base/Const/Units.h"
#include "Base/Type/Span.h"
#include "Base/Vector/WavevectorInfo.h"
#include "Resample/Refine/ParticlesInSlice.h"
#include "Resample/Sample/ReSample.h"
#include "Sample/Aggregate/Dilute2D.h"
#include "Sample/Aggregate/Struct.h"
#include "Sample/Aggregate/Struct2D.h"
#include "Sample/HardParticle/HardParticles.h"
#include "Sample/Material/RefractiveMaterial.h"
#include "Sample/Material/Vacuum.h"
#include "Sample/Multilayer/Layer.h"
#include "Sample/Multilayer/Slice.h"
#include "Sample/Option/SimulationOptions.h"
#include "Sample/Particle/Compound.h"
#include "Sample/Particle/Mixture.h"
#include "Sample/Particle/Particle.h"
#include "Sample/Particle/Rotations.h"
#include "Sample/Sample/Sample.h"
#include "Sample/SoftParticle/SoftParticles.h"
#include "Tests/GTestWrapper/google_test.h"
#include <algorithm>
#include <cmath>
#include <ff/Polyhedron.h>
#include <ff/Topology.h>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>

namespace {

//! Cuts `particle` to the z-interval `limits` via the production single-slice cutter, and
//! returns the single resulting fragment, or nullptr if the particle does not reach the slice.
//! The slice's own span is irrelevant here: clamping is governed by `limits`.
std::unique_ptr<IParticle> sliceToLimits(const IParticle& particle, const Span& limits)
{
    const Vacuum vacuum;
    const Layer layer(vacuum);
    const Slice slice(0, layer, Span(-Span::inf, Span::inf), R3());
    auto fragments = Refine::particlesInSlice(&particle, limits, &slice, vacuum, MesoOptions{});
    if (fragments.empty())
        return nullptr;
    return std::move(fragments.front());
}

//! Verifies that slicing preserves the Born form factor: the amplitude of the whole particle must
//! equal the sum of the amplitudes of the fragments obtained by cutting it at the given ascending
//! z-planes (partition (-inf,c0),(c0,c1),...,(cn,+inf), in slice-local coordinates). This is the
//! exact additivity of F(q)=integral exp(i q.r) over a partition of the body, and is the rigorous
//! correctness criterion for slicing (it tests geometry, phase, and orientation together). The
//! particle must carry a non-vacuum material and a vacuum ambient, matching what the cutter sets.
void expectFragmentsReconstructFF(const IParticle& whole, const std::vector<double>& cuts,
                                  double rel_eps = 1e-10)
{
    std::vector<Span> spans;
    double low = -Span::inf;
    for (const double c : cuts) {
        spans.emplace_back(low, c);
        low = c;
    }
    spans.emplace_back(low, Span::inf);

    const std::vector<std::pair<R3, R3>> kikf = {{R3(0.10, 0.05, -0.20), R3(-0.15, 0.10, 0.30)},
                                                 {R3(0.30, -0.20, -0.10), R3(0.05, 0.10, 0.25)},
                                                 {R3(0.0, 0.0, -0.20), R3(0.0, 0.0, 0.20)}};
    for (const auto& [ki, kf] : kikf) {
        const WavevectorInfo wv(ki, kf, 1.0);
        const complex_t whole_ff = whole.theFF(wv);
        complex_t sum = 0;
        for (const Span& s : spans)
            if (const std::unique_ptr<IParticle> frag = ::sliceToLimits(whole, s))
                sum += frag->theFF(wv);
        EXPECT_LE(std::abs(sum - whole_ff), 1e-12 + rel_eps * std::abs(whole_ff));
    }
}

//! Builds a particle from `ff` (non-vacuum material, vacuum ambient) and verifies form-factor
//! additivity (whole == sum of fragments) for a cut at ~1/3 and ~2/3 of its z-extent. Cross-checks
//! each shape's slicer -- analytic or generic clip -- against the whole. With `rot` (a non-z tilt)
//! the shape routes through the generic clipper instead. A clip plane grazing a vertex is a known
//! libformfactor limitation (DegenerateClipRaisesClearError); nudge the cuts off it and retry.
void expectSliceAdditive(const Formfactor& ff, const Rotation* rot = nullptr,
                         double rel_eps = 1e-10)
{
    SCOPED_TRACE(ff.className());
    const RefractiveMaterial mat("m", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    Particle p(mat, ff);
    if (rot)
        p.rotate(*rot);
    p.setAmbientMaterial(Vacuum{});
    const double z0 = p.bottomZ();
    const double h = p.topZ() - z0;
    for (const double shift : {0.0, 0.017, -0.023, 0.041, -0.047}) {
        try {
            ::expectFragmentsReconstructFF(
                p, {z0 + (1. / 3 + shift) * h, z0 + (2. / 3 + shift) * h}, rel_eps);
            return;
        } catch (const std::runtime_error& e) {
            // retry only the known grazing-vertex degeneracy (its libformfactor signature);
            // rethrow any other error so a real failure is not masked
            if (std::string(e.what()).find("non-vanishing edges") == std::string::npos)
                throw;
        }
    }
    ADD_FAILURE() << "clip degeneracy at all candidate cut positions";
}

//! One sliced fragment of a resampled particle: its host slice and absolute z extent/volume.
struct Frag {
    size_t slice;
    double bottom;
    double top;
    double volume;
};

//! Deposits a Box(2,2,thickness) that exactly fills a buried layer of the given thickness,
//! subdivided into n_slices, resamples with material averaging on, and returns the resulting
//! fragments. The box fills the layer, so it must be cut into exactly n_slices fragments.
std::vector<Frag> fragmentsOfFillingBox(double thickness, unsigned n_slices)
{
    const Vacuum vacuum;
    const RefractiveMaterial mat("m", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    const Particle box(mat, Box(2., 2., thickness));

    Layer top(vacuum);
    Layer middle(vacuum, thickness);
    middle.setNumberOfSlices(n_slices);
    middle.deposit2D(Dilute2D(1e-3, box));

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(middle);
    sample.addLayer(Layer(vacuum));

    SimulationOptions opt;
    opt.setUseAvgMaterials(true);
    const auto re_sample = ReSample::processedSample(sample, opt);

    std::vector<Frag> result;
    for (const auto& s : re_sample.structs())
        for (const auto& leaf : s->particle()->decompose())
            result.push_back(
                {leaf->sliceNumber(), leaf->bottomZ(), leaf->topZ(), leaf->volumeToAdmix()});
    return result;
}

} // namespace

// A Box(2,2,10) occupies z in [0,10] in its own frame, with volume 40. The single-slice cutter
// clamps it to the given z-limits, keeping a box of the surviving height (volume scales with it).

//! Verifies that a particle fully inside the slice limits is kept whole.
TEST(ParticleSlicing, BoxFullyContained)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.));
    const std::unique_ptr<IParticle> f = ::sliceToLimits(box, Span(-Span::inf, Span::inf));
    ASSERT_TRUE(f);
    EXPECT_NEAR(f->bottomZ(), 0., 1e-12);
    EXPECT_NEAR(f->topZ(), 10., 1e-12);
    EXPECT_NEAR(f->volumeToAdmix(), 40., 1e-12);
}

//! Verifies clamping when the slice cuts the particle from below.
TEST(ParticleSlicing, BoxCutFromBottom)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.));
    const std::unique_ptr<IParticle> f = ::sliceToLimits(box, Span(2., Span::inf));
    ASSERT_TRUE(f);
    EXPECT_NEAR(f->bottomZ(), 2., 1e-12);
    EXPECT_NEAR(f->topZ(), 10., 1e-12);
    EXPECT_NEAR(f->volumeToAdmix(), 2. * 2. * 8., 1e-12);
}

//! Verifies clamping when the slice cuts the particle from above.
TEST(ParticleSlicing, BoxCutFromTop)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.));
    const std::unique_ptr<IParticle> f = ::sliceToLimits(box, Span(-Span::inf, 8.));
    ASSERT_TRUE(f);
    EXPECT_NEAR(f->bottomZ(), 0., 1e-12);
    EXPECT_NEAR(f->topZ(), 8., 1e-12);
    EXPECT_NEAR(f->volumeToAdmix(), 2. * 2. * 8., 1e-12);
}

//! Verifies clamping when the slice cuts the particle from both sides.
TEST(ParticleSlicing, BoxCutFromBoth)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.));
    const std::unique_ptr<IParticle> f = ::sliceToLimits(box, Span(2., 8.));
    ASSERT_TRUE(f);
    EXPECT_NEAR(f->bottomZ(), 2., 1e-12);
    EXPECT_NEAR(f->topZ(), 8., 1e-12);
    EXPECT_NEAR(f->volumeToAdmix(), 2. * 2. * 6., 1e-12);
}

//! Verifies that a particle entirely above the slice does not contribute (no fragment).
TEST(ParticleSlicing, BoxFullyAboveSliceIsEmpty)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.));
    EXPECT_FALSE(::sliceToLimits(box, Span(20., 30.)));
}

//! Verifies that a particle entirely below the slice does not contribute (no fragment).
TEST(ParticleSlicing, BoxFullyBelowSliceIsEmpty)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.));
    EXPECT_FALSE(::sliceToLimits(box, Span(-30., -20.)));
}

//! Verifies that a particle whose top exactly touches the slice's lower limit is not counted
//! in that slice (half-open ownership prevents double counting at shared boundaries).
TEST(ParticleSlicing, BoxTouchingSliceBottomIsEmpty)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.));
    EXPECT_FALSE(::sliceToLimits(box, Span(10., 20.)));
}

//! Verifies the complementary boundary-ownership rule: a particle whose bottom exactly touches
//! the slice's upper limit is not counted in that slice. Together with BoxTouchingSliceBottom*
//! this freezes the half-open [low, hig) ownership at both ends.
TEST(ParticleSlicing, BoxTouchingSliceTopIsEmpty)
{
    const Vacuum vacuum;
    const Particle box(vacuum, Box(2., 2., 10.)); // z in [0,10]
    EXPECT_FALSE(::sliceToLimits(box, Span(-20., 0.)));
}

//! Verifies that a rotation about the z axis (z-invariant) leaves the clamped fragment
//! unchanged: the z-extent and surviving volume are identical to the unrotated case.
TEST(ParticleSlicing, RotationAboutZDoesNotChangeClamp)
{
    const Vacuum vacuum;
    Particle box(vacuum, Box(2., 2., 10.));
    box.rotate(RotationZ(37 * Units::deg));
    const std::unique_ptr<IParticle> f = ::sliceToLimits(box, Span(2., 8.));
    ASSERT_TRUE(f);
    EXPECT_NEAR(f->bottomZ(), 2., 1e-12);
    EXPECT_NEAR(f->topZ(), 8., 1e-12);
    EXPECT_NEAR(f->volumeToAdmix(), 2. * 2. * 6., 1e-12);
}

//! Verifies that a tilted (non-z-invariant) polyhedron is partially sliced by the generic clipping
//! path: the surviving fragment is clamped exactly to the slice limits in the lab frame, while the
//! particle keeps its rotation. The y-tilted box spans z ~[-0.71, 7.78], so the span [1, 5] forces
//! a partial cut; the fragment's lab z-extent must be exactly [1, 5].
TEST(ParticleSlicing, TiltedBoxClampedToSlice)
{
    const Vacuum vacuum;
    Particle box(vacuum, Box(2., 2., 10.));
    box.rotate(RotationY(45 * Units::deg));
    const std::unique_ptr<IParticle> f = ::sliceToLimits(box, Span(1., 5.));
    ASSERT_TRUE(f);
    EXPECT_NEAR(f->bottomZ(), 1., 1e-9);
    EXPECT_NEAR(f->topZ(), 5., 1e-9);
    EXPECT_GT(f->volumeToAdmix(), 0.);
    EXPECT_LT(f->volumeToAdmix(), 40.); // strictly less than the whole box
}

//! Verifies that cutting a tilted box from the bottom only leaves its (tilted) apex untouched: the
//! fragment's lab z-extent is [cut, original top], where the original top is (10+1)/sqrt(2).
TEST(ParticleSlicing, TiltedBoxCutFromBottomKeepsTop)
{
    const Vacuum vacuum;
    Particle box(vacuum, Box(2., 2., 10.));
    box.rotate(RotationY(45 * Units::deg));
    const std::unique_ptr<IParticle> f = ::sliceToLimits(box, Span(2., Span::inf));
    ASSERT_TRUE(f);
    EXPECT_NEAR(f->bottomZ(), 2., 1e-9);
    EXPECT_NEAR(f->topZ(), 11. / std::sqrt(2.), 1e-9);
}

//! Verifies that generic polyhedral clipping conserves volume: slicing a tilted box into two
//! complementary slabs yields fragments whose volumes sum to the whole box volume (40).
TEST(ParticleSlicing, TiltedBoxVolumeConserved)
{
    const Vacuum vacuum;
    Particle box(vacuum, Box(2., 2., 10.));
    box.rotate(RotationY(45 * Units::deg));
    const std::unique_ptr<IParticle> lower = ::sliceToLimits(box, Span(-Span::inf, 3.));
    const std::unique_ptr<IParticle> upper = ::sliceToLimits(box, Span(3., Span::inf));
    ASSERT_TRUE(lower);
    ASSERT_TRUE(upper);
    EXPECT_NEAR(lower->volumeToAdmix() + upper->volumeToAdmix(), 40., 1e-9);
}

//! Verifies that a tilted non-polyhedral shape (here a rotated cylinder) still cannot be partially
//! sliced and throws: the generic clipping path applies only to polyhedra, so the
//! canSliceAnalytically contract is preserved for everything else.
TEST(ParticleSlicing, TiltedCylinderPartialSliceThrows)
{
    const Vacuum vacuum;
    Particle cyl(vacuum, Cylinder(1., 10.));
    cyl.rotate(RotationY(45 * Units::deg));
    EXPECT_THROW(::sliceToLimits(cyl, Span(1., 5.)), std::runtime_error);
}

//! Verifies form-factor additivity for a tilted box sliced by the clipping path: the whole
//! particle's amplitude equals the sum over fragments, for a single cut and for two cuts. This
//! confirms the clipped fragments carry correct geometry, orientation, and phase.
TEST(ParticleSlicing, TiltedBoxFragmentsReconstructFF)
{
    const RefractiveMaterial mat("m", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    Particle box(mat, Box(8., 8., 8.));
    box.rotate(RotationY(45 * Units::deg));
    box.translate(R3(0., 0., -5.));
    box.setAmbientMaterial(Vacuum{});
    ::expectFragmentsReconstructFF(box, {-2.});       // one cut
    ::expectFragmentsReconstructFF(box, {-4., -1.5}); // two cuts -> three fragments
}

//! Verifies form-factor additivity for tilted polyhedra that have no shape-specific analytic
//! slicer (a frustum routed through clipping by the tilt, and a Dodecahedron). Confirms the
//! generic clipping path reconstructs the whole amplitude for arbitrary polyhedra.
TEST(ParticleSlicing, TiltedGenericPolyhedronFragmentsReconstructFF)
{
    const RefractiveMaterial mat("m", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);

    Particle pyramid(mat, Pyramid4(10., 6., 80 * Units::deg));
    pyramid.rotate(RotationY(50 * Units::deg));
    pyramid.translate(R3(0., 0., -4.));
    pyramid.setAmbientMaterial(Vacuum{});
    ::expectFragmentsReconstructFF(pyramid, {-3.});

    Particle dodeca(mat, Dodecahedron(4.));
    dodeca.rotate(RotationX(40 * Units::deg));
    dodeca.translate(R3(0., 0., -5.));
    dodeca.setAmbientMaterial(Vacuum{});
    ::expectFragmentsReconstructFF(dodeca, {-5.});
}

//! Cross-checks the generic clipping path against the trusted analytic slicer on identical
//! geometry. A box cut by a horizontal plane is, analytically, a smaller box (exactly what doSlice
//! returns: Box(length, width, cut)). Clipping the box's polyhedron at the same plane via
//! libformfactor must reproduce the same form factor. The flag-clearing mirrors the production
//! helper (symmetry_Ci/S2 are origin-relative and invalid once vertices are bottom-shifted).
TEST(ParticleSlicing, AnalyticSliceMatchesGenericClip)
{
    const double L = 4., W = 6., H = 10.;
    for (const double cut : {2., 5., 7.5}) {
        const Box analytic(L, W, cut); // what doSlice returns when cutting the top down to `cut`

        const auto poly = Box(L, W, H).getPolyhedron();
        const double zb = poly->z_bottom();
        std::vector<R3> verts;
        for (const R3& v : poly->vertices())
            verts.emplace_back(v.x(), v.y(), v.z() - zb); // bottom-origin
        ff::Topology topo = poly->topology();
        topo.symmetry_Ci = false;
        for (ff::FacialTopology& f : topo.faces)
            f.symmetry_S2 = false;
        const auto clipped =
            std::unique_ptr<ff::Polyhedron>(ff::Polyhedron(topo, verts).clipped(-1., cut));

        for (const C3& q : {C3(0.1, 0.2, 0.3), C3(0.4, -0.2, 0.15), C3(0., 0., 0.5)}) {
            const complex_t analytic_ff = analytic.formfactorAtBottom(q);
            const complex_t clipped_ff = clipped->formfactor(q);
            EXPECT_LE(std::abs(analytic_ff - clipped_ff), 1e-12 + 1e-11 * std::abs(analytic_ff));
        }
    }
}

//! Verifies the clear-error guard around the libformfactor clipper. A cut plane through the
//! second-lowest vertices of a tilted box makes libformfactor leave a face with fewer than three
//! edges (it drops sub-tolerance edges instead of merging near-duplicate vertices). Slicing must
//! surface an actionable error naming the limitation, not the raw "non-vanishing edges" message.
//! Freezes the current behavior: if libformfactor is fixed to merge such vertices, this case will
//! stop throwing and the test should be updated.
TEST(ParticleSlicing, DegenerateClipRaisesClearError)
{
    const RefractiveMaterial mat("m", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    Particle box(mat, Box(8., 8., 8.));
    const double angle = 20 * Units::deg;
    box.rotate(RotationY(angle));
    box.setAmbientMaterial(Vacuum{});

    const double second_lowest_vertex_z = 4. * std::sin(angle);
    try {
        ::sliceToLimits(box, Span(second_lowest_vertex_z, Span::inf));
        FAIL() << "expected a degenerate-clip error";
    } catch (const std::runtime_error& e) {
        EXPECT_NE(std::string(e.what()).find("libformfactor"), std::string::npos);
    }
}

//! Verifies that a polyhedron without a shape-specific analytic slicer (here a Dodecahedron) is
//! sliced by the generic clipping fallback instead of throwing: each fragment is clamped to the
//! slice limit, and the volumes are conserved across a complementary split.
TEST(ParticleSlicing, UnsupportedPolyhedronSlicedByClipping)
{
    const Vacuum vacuum;
    const Particle dodeca(vacuum, Dodecahedron(2.));
    const std::unique_ptr<IParticle> whole = ::sliceToLimits(dodeca, Span(-Span::inf, Span::inf));
    ASSERT_TRUE(whole);
    const double full_volume = whole->volumeToAdmix();
    const double mid = whole->topZ() / 2.;

    const std::unique_ptr<IParticle> lower = ::sliceToLimits(dodeca, Span(-Span::inf, mid));
    const std::unique_ptr<IParticle> upper = ::sliceToLimits(dodeca, Span(mid, Span::inf));
    ASSERT_TRUE(lower);
    ASSERT_TRUE(upper);
    EXPECT_NEAR(lower->topZ(), mid, 1e-9);
    EXPECT_NEAR(upper->bottomZ(), mid, 1e-9);
    EXPECT_NEAR(lower->volumeToAdmix() + upper->volumeToAdmix(), full_volume, 1e-9);
}

//! Verifies the central material-averaging contract of slicing: a particle suspended from a
//! finite film's top so that it crosses the film/substrate interface is split into exactly two
//! fragments, one per host layer. Each fragment keeps the particle material; its ambient equals
//! the layer material of its host slice (the basis for slice material averaging); the surviving
//! heights match the layer overlaps; and the fragment volumes sum to the whole particle volume.
TEST(ParticleSlicing, BoxCrossingInterfaceSplitsWithLayerAmbients)
{
    const MaterialColor color(0.5, 0.5, 0.5);
    const RefractiveMaterial particle_mat("p", color, 5e-5, 0.);
    const RefractiveMaterial film_mat("film", color, 2e-5, 0.);
    const RefractiveMaterial substrate_mat("sub", color, 3e-5, 0.);
    const Particle box(particle_mat, Box(2., 2., 12.)); // cross section 4, volume 48

    Layer top(Vacuum{});
    Layer film(film_mat, 10.);
    Layer substrate(substrate_mat);
    // top anchored at the film's top interface (z=0): the box hangs to z=-12 and crosses the
    // film/substrate interface at z=-10. Film overlap is 10, substrate overlap is 2.
    film.suspend2D(Dilute2D(1e-3, box));

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(film);
    sample.addLayer(substrate);

    SimulationOptions opt;
    opt.setUseAvgMaterials(true);
    const auto re_sample = ReSample::processedSample(sample, opt);

    struct CrossFrag {
        size_t slice;
        double height;
        double volume;
        double material_sld;
        double ambient_sld;
    };
    std::vector<CrossFrag> frags;
    for (const auto& s : re_sample.structs())
        for (const auto& leaf : s->particle()->decompose()) {
            const auto* p = dynamic_cast<const Particle*>(leaf.get());
            ASSERT_TRUE(p);
            frags.push_back({leaf->sliceNumber(), leaf->topZ() - leaf->bottomZ(),
                             leaf->volumeToAdmix(), p->material()->rawData().real(),
                             p->ambientMaterial()->rawData().real()});
        }

    ASSERT_EQ(frags.size(), 2u);

    double total_volume = 0.;
    bool seen_film = false;
    bool seen_substrate = false;
    for (const CrossFrag& f : frags) {
        total_volume += f.volume;
        EXPECT_DOUBLE_EQ(f.material_sld, 5e-5); // particle keeps its own material
        // the fragment's ambient is the layer material of its host slice
        EXPECT_DOUBLE_EQ(f.ambient_sld,
                         re_sample.slices()[f.slice]->layerMaterial()->rawData().real());
        if (f.ambient_sld == 2e-5) { // film overlap: 10 of the 12 height -> volume 40
            seen_film = true;
            EXPECT_NEAR(f.height, 10., 1e-9);
            EXPECT_NEAR(f.volume, 40., 1e-9);
        } else if (f.ambient_sld == 3e-5) { // substrate overlap: 2 -> volume 8
            seen_substrate = true;
            EXPECT_NEAR(f.height, 2., 1e-9);
            EXPECT_NEAR(f.volume, 8., 1e-9);
        } else {
            FAIL() << "unexpected fragment ambient sld " << f.ambient_sld;
        }
    }
    EXPECT_TRUE(seen_film);
    EXPECT_TRUE(seen_substrate);
    EXPECT_NEAR(total_volume, 48., 1e-9); // volume conserved across the split
}

//! Verifies the non-averaging branch: with useAvgMaterials(false) a particle crossing an
//! interface is NOT sliced. It stays one full-height fragment, assigned whole to the slice that
//! contains its center, with that layer as ambient. This is the deliberate contrast with the
//! averaging split in BoxCrossingInterfaceSplitsWithLayerAmbients (same geometry).
TEST(ParticleSlicing, CrossingBoxIsNotSlicedWithoutAveraging)
{
    const MaterialColor color(0.5, 0.5, 0.5);
    const RefractiveMaterial particle_mat("p", color, 5e-5, 0.);
    const RefractiveMaterial film_mat("film", color, 2e-5, 0.);
    const RefractiveMaterial substrate_mat("sub", color, 3e-5, 0.);
    // box spans z [-12,0]; its center z=-6 lies inside the film [-10,0], away from the interface
    const Particle box(particle_mat, Box(2., 2., 12.));

    Layer top(Vacuum{});
    Layer film(film_mat, 10.);
    Layer substrate(substrate_mat);
    film.suspend2D(Dilute2D(1e-3, box));

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(film);
    sample.addLayer(substrate);

    SimulationOptions opt;
    opt.setUseAvgMaterials(false);
    const auto re_sample = ReSample::processedSample(sample, opt);

    size_t fragment_count = 0;
    size_t host_slice = 0;
    double height = 0.;
    double volume = 0.;
    double material_sld = 0.;
    double ambient_sld = 0.;
    for (const auto& s : re_sample.structs())
        for (const auto& leaf : s->particle()->decompose()) {
            const auto* p = dynamic_cast<const Particle*>(leaf.get());
            ASSERT_TRUE(p);
            ++fragment_count;
            host_slice = leaf->sliceNumber();
            height = leaf->topZ() - leaf->bottomZ();
            volume = leaf->volumeToAdmix();
            material_sld = p->material()->rawData().real();
            ambient_sld = p->ambientMaterial()->rawData().real();
        }

    EXPECT_EQ(fragment_count, 1u);  // not sliced
    EXPECT_NEAR(height, 12., 1e-9); // full, unclamped height
    EXPECT_NEAR(volume, 48., 1e-9); // full volume
    EXPECT_DOUBLE_EQ(material_sld, 5e-5);
    EXPECT_DOUBLE_EQ(ambient_sld, 2e-5); // film, the layer containing the box center
    EXPECT_DOUBLE_EQ(ambient_sld,
                     re_sample.slices()[host_slice]->layerMaterial()->rawData().real());
}

// Mixture alternatives are anchored independently; rigid composites are anchored as a whole.

//! Verifies that a Mixture anchors each alternative independently on deposit: every
//! alternative's lowest point is placed at the bottom interface.
TEST(ParticleSlicing, MixtureAnchorsEachAlternativeIndependently)
{
    const Vacuum vacuum;
    Particle a(vacuum, Box(2., 2., 8.));
    a.rotate(RotationY(30 * Units::deg));
    Particle b(vacuum, Box(2., 2., 8.));
    b.rotate(RotationY(60 * Units::deg));
    Mixture mix;
    mix.addParticle(a, 0.5);
    mix.addParticle(b, 0.5);

    Layer layer(vacuum);
    layer.deposit2D(Dilute2D(1e-3, mix));
    const auto* m = dynamic_cast<const Mixture*>(layer.structsAtBottom().front()->particle());
    ASSERT_TRUE(m);
    const auto alternatives = m->particles();
    ASSERT_EQ(alternatives.size(), 2u);
    for (const IParticle* alt : alternatives)
        EXPECT_NEAR(alt->bottomZ(), 0., 1e-9);
}

//! Verifies that a Mixture anchors each alternative independently on suspend: every
//! alternative's highest point is placed at the top interface.
TEST(ParticleSlicing, MixtureSuspendAnchorsEachAlternativeTop)
{
    const Vacuum vacuum;
    Particle a(vacuum, Box(2., 2., 8.));
    a.rotate(RotationY(30 * Units::deg));
    Particle b(vacuum, Box(2., 2., 8.));
    b.rotate(RotationY(60 * Units::deg));
    Mixture mix;
    mix.addParticle(a, 0.5);
    mix.addParticle(b, 0.5);

    Layer layer(vacuum);
    layer.suspend2D(Dilute2D(1e-3, mix));
    const auto* m = dynamic_cast<const Mixture*>(layer.structsAtTop().front()->particle());
    ASSERT_TRUE(m);
    const auto alternatives = m->particles();
    ASSERT_EQ(alternatives.size(), 2u);
    for (const IParticle* alt : alternatives)
        EXPECT_NEAR(alt->topZ(), 0., 1e-9);
}

//! A one-component Mixture rotated at the Mixture level must anchor its alternative like the same
//! particle rotated and deposited directly. The non-z rotation makes the rotated z-extent differ
//! from the upright one, so anchoring before the Mixture rotation would leave the box at z=-1.
TEST(ParticleSlicing, RotatedOneComponentMixtureAnchorsLikeBareParticle)
{
    const RefractiveMaterial mat("A", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    const Vacuum vacuum;
    SimulationOptions opt;
    opt.setUseAvgMaterials(false);

    auto resampledBottom = [&](const IParticle& particle) {
        Layer layer(vacuum);
        layer.deposit2D(Dilute2D(1e-3, particle));
        Sample sample;
        sample.addLayer(layer);
        sample.addLayer(Layer(vacuum));
        const auto rs = ReSample::processedSample(sample, opt);
        return rs.structs().front()->particle()->bottomZ();
    };

    // bare: particle rotated, then deposited directly
    Particle bare(mat, Box(2., 2., 6.));
    bare.rotate(RotationY(90 * Units::deg));
    const double bare_bottom = resampledBottom(bare);

    // one-component Mixture with the rotation stored on the Mixture node
    Particle inner(mat, Box(2., 2., 6.));
    Mixture mix;
    mix.addParticle(inner, 1.0);
    mix.rotate(RotationY(90 * Units::deg));
    const double mix_bottom = resampledBottom(mix);

    EXPECT_NEAR(bare_bottom, 0., 1e-12);         // bare rotated particle rests on the interface
    EXPECT_NEAR(mix_bottom, bare_bottom, 1e-12); // the one-component Mixture must match it
}

//! Same anchoring under material averaging (the default), whose layer subdivision recovers spans
//! separately: a rotated one-component Mixture must resolve like the bare particle. A z rotation
//! is required -- averaging slices the particle, which a tilted shape does not support; for a z
//! rotation the correction is a plain z shift, consistent with that span recovery.
TEST(ParticleSlicing, RotatedOneComponentMixtureAnchorsWithMaterialAveraging)
{
    const RefractiveMaterial mat("A", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    const Vacuum vacuum;
    SimulationOptions opt; // default useAvgMaterials(true)

    auto resampledBottom = [&](const IParticle& particle) {
        Layer top(vacuum);
        Layer middle(vacuum, 20.);
        middle.deposit2D(Dilute2D(1e-3, particle));
        Sample sample;
        sample.addLayer(top);
        sample.addLayer(middle);
        sample.addLayer(Layer(vacuum));
        const auto rs = ReSample::processedSample(sample, opt);
        return rs.structs().front()->particle()->bottomZ();
    };

    Particle bare(mat, Box(2., 2., 6.));
    bare.rotate(RotationZ(37 * Units::deg));
    const double bare_bottom = resampledBottom(bare);

    Particle inner(mat, Box(2., 2., 6.));
    Mixture mix;
    mix.addParticle(inner, 1.0);
    mix.rotate(RotationZ(37 * Units::deg));
    const double mix_bottom = resampledBottom(mix);

    EXPECT_NEAR(mix_bottom, bare_bottom, 1e-12);
}

//! The dangerous integration: a one-component Mixture under material averaging with a *non-z*
//! rotation. The tilted box exercises Mixture anchoring (#1417), the SubdivideLayers span recovery
//! for the rotated alternative (#1413), and tilted slicing via the clipper (#1305) at once. It must
//! still resolve to the same placement as the bare tilted particle.
TEST(ParticleSlicing, TiltedOneComponentMixtureAnchorsWithMaterialAveraging)
{
    const RefractiveMaterial mat("A", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    const Vacuum vacuum;
    SimulationOptions opt; // default useAvgMaterials(true)

    auto resampledBottom = [&](const IParticle& particle) {
        Layer top(vacuum);
        Layer middle(vacuum, 20.);
        middle.deposit2D(Dilute2D(1e-3, particle));
        Sample sample;
        sample.addLayer(top);
        sample.addLayer(middle);
        sample.addLayer(Layer(vacuum));
        const auto rs = ReSample::processedSample(sample, opt);
        return rs.structs().front()->particle()->bottomZ();
    };

    Particle bare(mat, Box(2., 2., 6.));
    bare.rotate(RotationY(90 * Units::deg));
    const double bare_bottom = resampledBottom(bare);

    Particle inner(mat, Box(2., 2., 6.));
    Mixture mix;
    mix.addParticle(inner, 1.0);
    mix.rotate(RotationY(90 * Units::deg));
    const double mix_bottom = resampledBottom(mix);

    EXPECT_NEAR(mix_bottom, bare_bottom, 1e-12);
}

//! suspend2D counterpart of the deposit case above: a rotated Mixture's alternative hangs from
//! the top interface like the same particle suspended directly (the intrinsicTopZ reference).
TEST(ParticleSlicing, RotatedOneComponentMixtureSuspendsLikeBareParticle)
{
    const RefractiveMaterial mat("A", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    const Vacuum vacuum;
    SimulationOptions opt;
    opt.setUseAvgMaterials(false);

    auto resampledTop = [&](const IParticle& particle) {
        Layer fronting(vacuum);
        Layer middle(vacuum, 20.);
        middle.suspend2D(Dilute2D(1e-3, particle));
        Sample sample;
        sample.addLayer(fronting);
        sample.addLayer(middle);
        sample.addLayer(Layer(vacuum));
        const auto rs = ReSample::processedSample(sample, opt);
        return rs.structs().front()->particle()->topZ();
    };

    Particle bare(mat, Box(2., 2., 6.));
    bare.rotate(RotationY(90 * Units::deg));
    const double bare_top = resampledTop(bare);

    Particle inner(mat, Box(2., 2., 6.));
    Mixture mix;
    mix.addParticle(inner, 1.0);
    mix.rotate(RotationY(90 * Units::deg));
    const double mix_top = resampledTop(mix);

    EXPECT_NEAR(bare_top, 0., 1e-12);      // bare rotated particle hangs from the interface
    EXPECT_NEAR(mix_top, bare_top, 1e-12); // the one-component Mixture must match it
}

//! Nested Mixtures: the accumulated outer-then-inner rotation must drive the anchoring, so the
//! leaf rests on the interface like the same particle rotated by that composite and deposited.
TEST(ParticleSlicing, NestedRotatedMixtureAnchorsLikeBareParticle)
{
    const RefractiveMaterial mat("A", MaterialColor(0.5, 0.5, 0.5), 1e-5, 0.);
    const Vacuum vacuum;
    SimulationOptions opt;
    opt.setUseAvgMaterials(false);

    auto resampledBottom = [&](const IParticle& particle) {
        Layer layer(vacuum);
        layer.deposit2D(Dilute2D(1e-3, particle));
        Sample sample;
        sample.addLayer(layer);
        sample.addLayer(Layer(vacuum));
        const auto rs = ReSample::processedSample(sample, opt);
        return rs.structs().front()->particle()->bottomZ();
    };

    // bare particle carrying the composite orientation outer-after-inner = Ry(90) * Rx(90)
    Particle bare(mat, Box(2., 2., 6.));
    bare.rotate(RotationX(90 * Units::deg)); // inner
    bare.rotate(RotationY(90 * Units::deg)); // outer
    const double bare_bottom = resampledBottom(bare);

    Particle leaf(mat, Box(2., 2., 6.));
    Mixture inner;
    inner.addParticle(leaf, 1.0);
    inner.rotate(RotationX(90 * Units::deg));
    Mixture outer;
    outer.addParticle(inner, 1.0);
    outer.rotate(RotationY(90 * Units::deg));
    const double nested_bottom = resampledBottom(outer);

    EXPECT_NEAR(bare_bottom, 0., 1e-12);
    EXPECT_NEAR(nested_bottom, bare_bottom, 1e-12);
}

//! Verifies that a Compound anchors as one rigid whole: only the assembly's lowest point reaches
//! the interface, while components keep their relative offsets.
TEST(ParticleSlicing, CompoundAnchorsWholeAssemblyRigidly)
{
    const Vacuum vacuum;
    Particle a(vacuum, Box(2., 2., 8.));
    a.rotate(RotationY(30 * Units::deg));
    Particle b(vacuum, Box(2., 2., 8.));
    b.rotate(RotationY(60 * Units::deg));
    Compound comp;
    comp.addComponent(a);
    comp.addComponent(b, R3(0., 0., 5.)); // offset upward

    Layer layer(vacuum);
    layer.deposit2D(Dilute2D(1e-3, comp));
    const auto* c = dynamic_cast<const Compound*>(layer.structsAtBottom().front()->particle());
    ASSERT_TRUE(c);
    EXPECT_NEAR(c->bottomZ(), 0., 1e-9); // whole assembly anchored at the interface

    std::vector<double> bottoms;
    for (const auto& leaf : c->decompose())
        bottoms.push_back(leaf->bottomZ());
    ASSERT_EQ(bottoms.size(), 2u);
    std::sort(bottoms.begin(), bottoms.end());
    EXPECT_NEAR(bottoms[0], 0., 1e-9); // lowest component touches the interface
    EXPECT_GT(bottoms[1], 1.);         // the offset component is not independently re-anchored
}

//! Verifies the subdivision mechanics of the full pipeline: a box that exactly fills a buried
//! layer subdivided into N slices is cut into N fragments with contiguous slice numbers, each of
//! thickness layer/N, and the fragment volumes sum to the whole box volume — for several N.
TEST(ParticleSlicing, FillingBoxSubdividedAcrossSlices)
{
    for (const unsigned n : {2u, 3u, 5u}) {
        const std::vector<Frag> frags = ::fragmentsOfFillingBox(10., n);
        ASSERT_EQ(frags.size(), n);

        std::vector<size_t> slices;
        double total_volume = 0.;
        for (const Frag& f : frags) {
            slices.push_back(f.slice);
            EXPECT_NEAR(f.top - f.bottom, 10. / n, 1e-9);
            total_volume += f.volume;
        }
        std::sort(slices.begin(), slices.end());
        std::vector<size_t> expected(n);
        for (unsigned i = 0; i < n; ++i)
            expected[i] = i + 1;
        EXPECT_EQ(slices, expected);          // contiguous host slices 1..n
        EXPECT_NEAR(total_volume, 40., 1e-9); // volume conserved
    }
}

// ============================================================================================
// Issue #241: slicing cross-checks for all form factors.
// ============================================================================================

//! Form-factor additivity (whole == sum of sliced fragments) for every form factor that supports
//! slicing, cut without rotation. Cross-checks each shape-specific analytic slicer, and the generic
//! polyhedron clipper, against the whole-particle amplitude.
TEST(ParticleSlicing, SlicingIsAdditiveForAllShapes)
{
    using Units::deg;
    // polyhedra with shape-specific analytic slicers
    ::expectSliceAdditive(Box(4., 6., 10.));
    ::expectSliceAdditive(Prism3(8., 10.));
    ::expectSliceAdditive(Prism6(4., 9.));
    ::expectSliceAdditive(Pyramid2(10., 8., 5., 80 * deg));
    ::expectSliceAdditive(Pyramid3(8., 10., 80 * deg));
    ::expectSliceAdditive(Pyramid4(8., 9., 80 * deg));
    ::expectSliceAdditive(Pyramid6(18., 8., 40 * deg));
    ::expectSliceAdditive(Bipyramid4(8., 4.3, 0.5, 70 * deg));
    // polyhedra without an analytic slicer (sliced by the generic clipper)
    ::expectSliceAdditive(PlatonicTetrahedron(12.));
    ::expectSliceAdditive(PlatonicOctahedron(10.));
    ::expectSliceAdditive(Dodecahedron(4.));
    ::expectSliceAdditive(Icosahedron(4.));
    ::expectSliceAdditive(CantellatedCube(12., 3.));
    ::expectSliceAdditive(TruncatedCube(12., 3.));
    // round / soft shapes with analytic slicers
    ::expectSliceAdditive(Cone(8., 9., 80 * deg));
    ::expectSliceAdditive(Cylinder(3., 9.));
    ::expectSliceAdditive(EllipsoidalCylinder(3., 4., 9.));
    ::expectSliceAdditive(HorizontalCylinder(5., 19.));
    ::expectSliceAdditive(HorizontalCylinder(5., 19., 1.5, 1.)); // truncated (flat-capped) variant
    ::expectSliceAdditive(Sphere(5.));
    ::expectSliceAdditive(Spheroid(4., 6.));
    ::expectSliceAdditive(Ellipsoid(5., 7., 9.));
    ::expectSliceAdditive(SphericalSegment(7., 1., 1.));
    ::expectSliceAdditive(SpheroidalSegment(7., 9., 1., 1.));
    ::expectSliceAdditive(EllipsoidalSegment(5., 7., 9., 1., 1.));
    ::expectSliceAdditive(LongBoxGauss(20., 4., 8.));
    ::expectSliceAdditive(LongBoxLorentz(20., 4., 8.));
}

//! Form-factor additivity under a non-z tilt, which routes every polyhedron through the generic
//! clipper (the analytic slicers handle only z-invariant rotations). Exercises each polyhedron's
//! clipped slicing -- different topologies, one clip path -- complementing the upright cases above.
TEST(ParticleSlicing, TiltedSlicingIsAdditiveForAllPolyhedra)
{
    using Units::deg;
    const RotationY tilt(35 * deg);
    ::expectSliceAdditive(Box(4., 6., 10.), &tilt);
    ::expectSliceAdditive(Prism3(8., 10.), &tilt);
    ::expectSliceAdditive(Prism6(4., 9.), &tilt);
    ::expectSliceAdditive(Pyramid2(10., 8., 5., 80 * deg), &tilt);
    ::expectSliceAdditive(Pyramid3(8., 10., 80 * deg), &tilt);
    ::expectSliceAdditive(Pyramid4(8., 9., 80 * deg), &tilt);
    ::expectSliceAdditive(Pyramid6(18., 8., 40 * deg), &tilt);
    ::expectSliceAdditive(Bipyramid4(8., 4.3, 0.5, 70 * deg), &tilt);
    ::expectSliceAdditive(PlatonicTetrahedron(12.), &tilt);
    ::expectSliceAdditive(PlatonicOctahedron(10.), &tilt);
    ::expectSliceAdditive(Dodecahedron(4.), &tilt);
    ::expectSliceAdditive(Icosahedron(4.), &tilt);
    ::expectSliceAdditive(CantellatedCube(12., 3.), &tilt);
    ::expectSliceAdditive(TruncatedCube(12., 3.), &tilt);
}

//! Form-factor additivity under a z-axis rotation: z-invariance keeps the analytic slicers usable,
//! while generic polyhedra still slice by axis-aligned clipping. Checks that both paths carry the
//! rotation through to the fragments for every sliceable shape.
TEST(ParticleSlicing, ZRotatedSlicingIsAdditiveForAllSliceableShapes)
{
    using Units::deg;
    const RotationZ rot(37 * deg);
    ::expectSliceAdditive(Box(4., 6., 10.), &rot);
    ::expectSliceAdditive(Prism3(8., 10.), &rot);
    ::expectSliceAdditive(Prism6(4., 9.), &rot);
    ::expectSliceAdditive(Pyramid2(10., 8., 5., 80 * deg), &rot);
    ::expectSliceAdditive(Pyramid3(8., 10., 80 * deg), &rot);
    ::expectSliceAdditive(Pyramid4(8., 9., 80 * deg), &rot);
    ::expectSliceAdditive(Pyramid6(18., 8., 40 * deg), &rot);
    ::expectSliceAdditive(Bipyramid4(8., 4.3, 0.5, 70 * deg), &rot);
    ::expectSliceAdditive(PlatonicTetrahedron(12.), &rot);
    ::expectSliceAdditive(PlatonicOctahedron(10.), &rot);
    ::expectSliceAdditive(Dodecahedron(4.), &rot);
    ::expectSliceAdditive(Icosahedron(4.), &rot);
    ::expectSliceAdditive(CantellatedCube(12., 3.), &rot);
    ::expectSliceAdditive(TruncatedCube(12., 3.), &rot);
    ::expectSliceAdditive(Cone(8., 9., 80 * deg), &rot);
    ::expectSliceAdditive(Cylinder(3., 9.), &rot);
    ::expectSliceAdditive(EllipsoidalCylinder(3., 4., 9.), &rot);
    ::expectSliceAdditive(HorizontalCylinder(5., 19.), &rot);
    ::expectSliceAdditive(HorizontalCylinder(5., 19., 1.5, 1.), &rot);
    ::expectSliceAdditive(Sphere(5.), &rot);
    ::expectSliceAdditive(Spheroid(4., 6.), &rot);
    ::expectSliceAdditive(Ellipsoid(5., 7., 9.), &rot);
    ::expectSliceAdditive(SphericalSegment(7., 1., 1.), &rot);
    ::expectSliceAdditive(SpheroidalSegment(7., 9., 1., 1.), &rot);
    ::expectSliceAdditive(EllipsoidalSegment(5., 7., 9., 1., 1.), &rot);
    ::expectSliceAdditive(LongBoxGauss(20., 4., 8.), &rot);
    ::expectSliceAdditive(LongBoxLorentz(20., 4., 8.), &rot);
}

//! The remaining form factors have no slicer and are not polyhedra: ripples, bars, and the soft
//! spheres. A partial slice must raise a clear "not supported" error rather than mis-slicing.
//! Together with SlicingIsAdditiveForAllShapes this covers every form factor.
TEST(ParticleSlicing, UnsliceableShapesRaiseClearError)
{
    const Vacuum vacuum;
    const auto expectNotSupported = [&](const Formfactor& ff) {
        SCOPED_TRACE(ff.className());
        const Particle p(vacuum, ff);
        const double mid = 0.5 * (p.bottomZ() + p.topZ());
        try {
            ::sliceToLimits(p, Span(-Span::inf, mid));
            ADD_FAILURE() << "expected a slicing error";
        } catch (const std::runtime_error& e) {
            EXPECT_NE(std::string(e.what()).find("not supported"), std::string::npos);
        }
    };
    expectNotSupported(BarGauss(20., 4., 8.));
    expectNotSupported(BarLorentz(20., 4., 8.));
    expectNotSupported(CosineRippleBox(20., 4., 8.));
    expectNotSupported(CosineRippleGauss(20., 4., 8.));
    expectNotSupported(CosineRippleLorentz(20., 4., 8.));
    expectNotSupported(SawtoothRippleBox(20., 4., 8., 0.3));
    expectNotSupported(SawtoothRippleGauss(20., 4., 8., 0.3));
    expectNotSupported(SawtoothRippleLorentz(20., 4., 8., 0.3));
    expectNotSupported(GaussSphere(5.));
    expectNotSupported(FuzzySphere(5., 0.5));
}
