#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/HardParticle/HardParticles.h"
#include "Sample/HardParticle/Polyhedra.h"
#include "Sample/HardParticle/Sphere.h"
#include "Sample/Lattice/Lattice3D.h"
#include "Sample/Material/SLDMaterial.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/CoreAndShell.h"
#include "Sample/Particle/Crystal.h"
#include "Sample/Particle/DwbaChannels.h"
#include "Sample/Particle/Mesocrystal.h"
#include "Sample/Particle/Mixture.h"
#include "Sample/Particle/Particle.h"
#include "Sample/Particle/Rotations.h"
#include "Sample/Sample/Sample.h"
#include "Tests/GTestWrapper/google_test.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <memory>
#include <stdexcept>
#include <vector>

namespace {

using NodeKey = std::array<double, 3>;

// Builds the scalar Born channel prefix used by reciprocal-moment tests.
DwbaChannels makeBornChannels(const WavevectorInfo& wavevectors)
{
    return {{{wavevectors, wavevectors, wavevectors, wavevectors}}, {1.0, 0.0, 0.0, 0.0}, 1};
}

// Enables reciprocal mesocrystal evaluation with the test radius factor.
MesoOptions reciprocalMesoOptions()
{
    MesoOptions result;
    result.use_reciprocal_sum = true;
    result.radius_factor = 2.5;
    return result;
}

// Prepares a mesocrystal for reciprocal-sum tests.
void prepareReciprocalMesocrystal(Mesocrystal& mc, const Material& ambient)
{
    mc.prepareForComputation(ambient, reciprocalMesoOptions());
}

// Converts a node position to a sortable key.
NodeKey toNodeKey(const R3& v)
{
    return {v.x(), v.y(), v.z()};
}

// Creates a simple basis particle with an optional translation.
std::unique_ptr<Particle> makeBasisParticle(const Formfactor& ff, const R3& shift = {})
{
    auto basis = std::make_unique<Particle>(
        SLDMaterial("particle", MaterialColor(0.86, 0.24, 0.18), 6e-6, 0.0), ff);
    if (shift != R3())
        basis->translate(shift);
    return basis;
}

// Creates the standard box-shaped mesocrystal used in volume tests.
Mesocrystal makeBoxMesocrystal(const IParticle& basis)
{
    const Lattice3D lattice(R3(5.0, 0.0, 0.0), R3(0.0, 5.0, 0.0), R3(0.0, 0.0, 5.0));
    const Crystal crystal(basis, lattice);
    return Mesocrystal(crystal, Box(40.0, 40.0, 50.0));
}

// Builds a sliced sample for mesocrystal resampling tests.
ReSample makeResampledSample(const IParticle& particle, size_t n_slices)
{
    const Vacuum vacuum;
    Layer top(vacuum);
    top.setNumberOfSlices(n_slices);
    top.deposit2D(Dilute2D(1e-4, particle));

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(Layer(vacuum));
    return ReSample::processedSample(sample, {});
}

// Collects absolute basis-node positions through nested mesocrystal structures.
void collectAbsoluteNodePositions(const IParticle& particle, const R3& parent_shift,
                                  std::vector<NodeKey>& result, const ReSample* re_sample = nullptr,
                                  bool apply_slice_shift = true)
{
    const R3 absolute_shift = parent_shift + particle.particlePosition();

    if (const auto* mc = dynamic_cast<const Mesocrystal*>(&particle)) {
        R3 slice_shift;
        if (re_sample && apply_slice_shift) {
            const Slice& slice = *re_sample->slices().at(mc->sliceNumber());
            const double slice_reference_z = std::isfinite(slice.hig()) ? slice.hig() : slice.low();
            slice_shift = R3(0.0, 0.0, slice_reference_z);
        }
        for (const R3& node : mc->calcBasisPositions())
            collectAbsoluteNodePositions(*mc->particleStructure().basis(),
                                         absolute_shift + slice_shift + node, result, re_sample,
                                         false);
        return;
    }

    if (const auto* compound = dynamic_cast<const Compound*>(&particle)) {
        for (const IParticle* child : compound->components())
            collectAbsoluteNodePositions(*child, absolute_shift, result, re_sample,
                                         apply_slice_shift);
        return;
    }

    if (dynamic_cast<const Particle*>(&particle)) {
        result.push_back(toNodeKey(absolute_shift));
        return;
    }

    ADD_FAILURE() << "unexpected particle type in mesocrystal node-position tree";
}

// Returns sorted absolute node positions without slice corrections.
std::vector<NodeKey> absoluteNodePositions(const IParticle& particle)
{
    std::vector<NodeKey> result;
    collectAbsoluteNodePositions(particle, R3(), result);
    std::sort(result.begin(), result.end());
    return result;
}

// Returns sorted absolute node positions with slice corrections applied.
std::vector<NodeKey> absoluteNodePositions(const IParticle& particle, const ReSample& re_sample)
{
    std::vector<NodeKey> result;
    collectAbsoluteNodePositions(particle, R3(), result, &re_sample);
    std::sort(result.begin(), result.end());
    return result;
}

// Compares two sorted node-position lists elementwise.
void expectSameNodePositions(const std::vector<NodeKey>& actual,
                             const std::vector<NodeKey>& expected)
{
    ASSERT_EQ(actual.size(), expected.size());
    for (size_t i = 0; i < actual.size(); ++i) {
        EXPECT_NEAR(actual[i][0], expected[i][0], 1e-12);
        EXPECT_NEAR(actual[i][1], expected[i][1], 1e-12);
        EXPECT_NEAR(actual[i][2], expected[i][2], 1e-12);
    }
}

// Collects all mesocrystal leaves in a nested particle tree.
void collectMesocrystalPieces(const IParticle& particle, std::vector<const Mesocrystal*>& result)
{
    if (const auto* mc = dynamic_cast<const Mesocrystal*>(&particle)) {
        result.push_back(mc);
        return;
    }

    if (const auto* compound = dynamic_cast<const Compound*>(&particle)) {
        for (const IParticle* child : compound->components())
            collectMesocrystalPieces(*child, result);
        return;
    }

    ADD_FAILURE() << "unexpected particle type in sliced mesocrystal tree";
}

// Counts basis nodes on a given z plane within tolerance.
size_t countNodesOnPlane(const std::vector<NodeKey>& positions, double z_plane)
{
    return std::count_if(positions.begin(), positions.end(),
                         [=](const NodeKey& pos) { return std::abs(pos[2] - z_plane) < 1e-12; });
}

// Sliced-mesocrystal fixture: cone with off-center basis, cut from both top
// and bottom so that the residual intra-cell shift is non-trivial.
class ParticleVolumeSlicedMesocrystal : public ::testing::Test {
protected:
    using R3Key = std::array<double, 3>;

    const Vacuum ambient;
    const SLDMaterial material{"particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0};

    // Source (full) mesocrystal and its sliced counterpart.
    Mesocrystal source{makeCrystal(), Cone(9.0, 18.0, 75.0 * Units::deg)};
    std::unique_ptr<IParticle> sliced_owner;
    Mesocrystal* sliced{nullptr};

    // Brute-force and mesocrystal-reported node positions (sorted).
    std::vector<R3Key> brute_positions;
    std::vector<R3Key> meso_positions;

    static R3Key toKey(const R3& v) { return {v.x(), v.y(), v.z()}; }

    void SetUp() override
    {
        // Slice the middle portion [5, 13] of the 18 nm-tall cone.
        Layer top_layer(ambient);
        top_layer.setNumberOfSlices(4);
        Slice slice(1, top_layer, Span(5.0, 13.0), R3{});

        std::unique_ptr<IParticle> shifted(source.clone());
        shifted->translate(R3(0.0, 0.0, -13.0));

        MesoOptions opts;
        auto pieces =
            Refine::particlesInSlice(shifted.get(), Span(-8.0, 0.0), &slice, ambient, opts);
        ASSERT_EQ(pieces.size(), 1u);
        sliced_owner = std::move(pieces.front());
        sliced = dynamic_cast<Mesocrystal*>(sliced_owner.get());
        ASSERT_TRUE(sliced);

        // Brute-force enumeration of nodes inside the sliced outer shape.
        const auto* ff = sliced->outerShape();
        const auto* lat = sliced->particleStructure().lattice();
        const R3 off = slicedBasisPosition();
        const int N = 15;
        for (int i = -N; i <= N; ++i)
            for (int j = -N; j <= N; ++j)
                for (int k = -N; k <= N; ++k) {
                    R3 pos = i * lat->basisVectorA() + j * lat->basisVectorB()
                             + k * lat->basisVectorC() + off;
                    if (ff->contains(pos))
                        brute_positions.push_back(toKey(pos));
                }
        std::sort(brute_positions.begin(), brute_positions.end());

        for (const R3& lp : sliced->calcBasisPositions())
            meso_positions.push_back(toKey(lp + off));
        std::sort(meso_positions.begin(), meso_positions.end());
    }

    R3 sourceBasisPosition() const
    {
        return dynamic_cast<const Particle*>(source.particleStructure().basis())
            ->particlePosition();
    }

    R3 slicedBasisPosition() const
    {
        return dynamic_cast<const Particle*>(sliced->particleStructure().basis())
            ->particlePosition();
    }

private:
    static Crystal makeCrystal()
    {
        Particle basis(SLDMaterial("particle", MaterialColor(0.86, 0.24, 0.18), 6e-6, 0.0),
                       Sphere(1.1));
        basis.translate(R3(1.1, -0.9, 1.6));
        return Crystal(basis, Lattice3D(R3(4, 0, 0), R3(0, 4, 0), R3(0, 0, 4)));
    }
};

} // namespace

// Compound volumeToAdmix must equal the sum of component admixed volumes.
TEST(ParticleVolume, CompoundIsSumOfComponents)
{
    const Vacuum material;
    Particle p1(material, Sphere(1.0));
    Particle p2(material, Sphere(2.0));

    Compound c{};
    c.addComponent(p1);
    c.addComponent(p2);

    EXPECT_DOUBLE_EQ(p1.volumeToAdmix() + p2.volumeToAdmix(), c.volumeToAdmix());
}

// CoreAndShell volumeToAdmix must equal shell plus core contributions.
TEST(ParticleVolume, CoreAndShellUsesOuterVolume)
{
    const Vacuum material;
    Particle core(material, Box(1.0, 1.0, 1.0));
    Particle shell(material, Box(2.0, 2.0, 2.0));
    shell.setVolumeToAdmix(shell.pFormfactor()->volume() - core.pFormfactor()->volume());

    CoreAndShell cs(core, shell);
    EXPECT_DOUBLE_EQ(shell.volumeToAdmix() + core.volumeToAdmix(), cs.volumeToAdmix());
}

// Mixture volumeToAdmix must be the abundance-weighted average of children.
TEST(ParticleVolume, MixtureIsAbundanceWeightedAverage)
{
    const Vacuum material;
    Particle p1(material, Box(1.0, 1.0, 1.0)); // volume 1
    Particle p2(material, Box(2.0, 1.0, 1.0)); // volume 2

    Mixture mix{};
    mix.addParticle(p1, 1.0);
    mix.addParticle(p2, 3.0);
    mix.validate();

    const double expected = (1.0 * p1.volumeToAdmix() + 3.0 * p2.volumeToAdmix()) / 4.0;
    EXPECT_DOUBLE_EQ(expected, mix.volumeToAdmix());
}

// Onion-like CoreAndShell must admix the outermost shell volume.
TEST(ParticleVolume, OnionCoreAndShellUsesOuterVolume)
{
    const Vacuum material;

    Particle core(material, Box(1.0, 1.0, 1.0));

    Particle mid_shell(material, Box(2.0, 2.0, 2.0));
    mid_shell.setVolumeToAdmix(mid_shell.pFormfactor()->volume() - core.pFormfactor()->volume());
    CoreAndShell inner(core, mid_shell);

    Particle outer_shell(material, Box(3.0, 3.0, 3.0));
    outer_shell.setVolumeToAdmix(outer_shell.pFormfactor()->volume()
                                 - mid_shell.pFormfactor()->volume());
    CoreAndShell onion(inner, outer_shell);

    EXPECT_DOUBLE_EQ(outer_shell.pFormfactor()->volume(), onion.volumeToAdmix());
}

// Mesocrystal volumeToAdmix must count basis nodes inside the outer shape.
TEST(ParticleVolume, MesocrystalCountsBasisInsideOuterShape)
{
    const Vacuum material;
    Particle basis(material, Box(1.0, 1.0, 1.0)); // volume 1
    Lattice3D lattice(R3(1.0, 0.0, 0.0), R3(0.0, 1.0, 0.0), R3(0.0, 0.0, 1.0));
    Crystal crystal(basis, lattice);

    Mesocrystal mc(crystal, Box(2.0, 2.0, 2.0));

    // For a unit lattice and an outer box of length/width/height = 2,
    // lattice points with x,y in [-1,1] and z in [0,2] are inside => 3*3*3 = 27.
    const double expected = 27.0 * basis.volumeToAdmix();
    EXPECT_DOUBLE_EQ(expected, mc.volumeToAdmix());
}

// Resampling must partition mesocrystal nodes across slices without
// duplicating nodes on shared slice interfaces.
TEST(ParticleVolume, ResampledMesocrystalPreservesTotalNodeCount)
{
    const Vacuum vacuum;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0);
    Particle basis(material, Sphere(2.3));
    const Lattice3D lattice(R3(5.0, 0.0, 0.0), R3(0.0, 5.0, 0.0), R3(0.0, 0.0, 5.0));
    const Crystal crystal(basis, lattice);
    Mesocrystal meso(crystal, Box(40.0, 40.0, 50.0));

    Layer top(vacuum);
    top.setNumberOfSlices(5);
    top.deposit2D(Dilute2D(1e-4, meso));

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

    const ReSample re_sample = ReSample::processedSample(sample, {});
    ASSERT_EQ(re_sample.structs().size(), 1);

    const auto count_nodes = [&](const IParticle& particle, const auto& self) -> size_t {
        if (const auto* mc = dynamic_cast<const Mesocrystal*>(&particle))
            return mc->numNodes();
        if (const auto* compound = dynamic_cast<const Compound*>(&particle)) {
            size_t result = 0;
            for (const IParticle* child : compound->components())
                result += self(*child, self);
            return result;
        }
        ADD_FAILURE() << "unexpected particle type in sliced mesocrystal tree";
        return 0;
    };

    EXPECT_EQ(count_nodes(*re_sample.structs().front()->particle(), count_nodes), meso.numNodes());
}

// Resampling must preserve the exact set of node positions when nodes sit on slice planes.
TEST(ParticleVolume, ResampledMesocrystalPreservesNodePositionSetOnBoundary)
{
    const auto basis = makeBasisParticle(Sphere(2.3));
    const Mesocrystal meso = makeBoxMesocrystal(*basis);
    const auto expected = absoluteNodePositions(meso);

    const ReSample re_sample = makeResampledSample(meso, 5);
    ASSERT_EQ(re_sample.structs().size(), 1);
    expectSameNodePositions(
        absoluteNodePositions(*re_sample.structs().front()->particle(), re_sample), expected);
}

// Off-center basis positions must stay unchanged even when no node lies on slice planes.
TEST(ParticleVolume, ResampledMesocrystalPreservesNodePositionSetOffCenter)
{
    const auto basis = makeBasisParticle(Sphere(2.3), R3(0.0, 0.0, 1.3));
    const Mesocrystal meso = makeBoxMesocrystal(*basis);
    const auto expected = absoluteNodePositions(meso);

    const ReSample re_sample = makeResampledSample(meso, 5);
    ASSERT_EQ(re_sample.structs().size(), 1);
    expectSameNodePositions(
        absoluteNodePositions(*re_sample.structs().front()->particle(), re_sample), expected);
}

// Basis alignment must not change the set of resampled reference node positions.
TEST(ParticleVolume, ResampledMesocrystalPreservesNodePositionSetForCenteredBasis)
{
    const auto basis = makeBasisParticle(Sphere(2.3));
    const Mesocrystal meso = makeBoxMesocrystal(*basis);
    const auto expected = absoluteNodePositions(meso);

    const ReSample re_sample = makeResampledSample(meso, 5);
    ASSERT_EQ(re_sample.structs().size(), 1);
    expectSameNodePositions(
        absoluteNodePositions(*re_sample.structs().front()->particle(), re_sample), expected);
}

// The node-position set must not depend on how many slices are used for resampling.
TEST(ParticleVolume, ResampledMesocrystalNodePositionsAreSliceCountInvariant)
{
    const auto basis = makeBasisParticle(Sphere(2.3));
    const Mesocrystal meso = makeBoxMesocrystal(*basis);
    const auto expected = absoluteNodePositions(meso);

    for (size_t n_slices : {1, 2, 5, 20}) {
        SCOPED_TRACE(::testing::Message() << "n_slices=" << n_slices);
        const ReSample re_sample = makeResampledSample(meso, n_slices);
        ASSERT_EQ(re_sample.structs().size(), 1);
        expectSameNodePositions(
            absoluteNodePositions(*re_sample.structs().front()->particle(), re_sample), expected);
    }
}

// Resampling must preserve the total effective admixed volume of a mesocrystal.
TEST(ParticleVolume, ResampledMesocrystalPreservesTotalAdmixedVolume)
{
    const Vacuum vacuum;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0);
    Particle basis(material, Box(1.0, 1.0, 4.0));
    const Lattice3D lattice(R3(5.0, 0.0, 0.0), R3(0.0, 5.0, 0.0), R3(0.0, 0.0, 5.0));
    const Crystal crystal(basis, lattice);
    Mesocrystal meso(crystal, Box(40.0, 40.0, 50.0));

    Layer top(vacuum);
    top.setNumberOfSlices(5);
    top.deposit2D(Dilute2D(1e-4, meso));

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

    const ReSample re_sample = ReSample::processedSample(sample, {});
    ASSERT_EQ(re_sample.structs().size(), 1);

    const auto sum_admix = [&](const IParticle& particle, const auto& self) -> double {
        if (const auto* mc = dynamic_cast<const Mesocrystal*>(&particle))
            return mc->volumeToAdmix();
        if (const auto* compound = dynamic_cast<const Compound*>(&particle)) {
            double result = 0.0;
            for (const IParticle* child : compound->components())
                result += self(*child, self);
            return result;
        }
        ADD_FAILURE() << "unexpected particle type in sliced mesocrystal tree";
        return 0.0;
    };

    const double total_admix = sum_admix(*re_sample.structs().front()->particle(), sum_admix);
    const size_t xy_nodes_per_plane = meso.numNodes() / 11;
    double expected_admix = 0.0;
    for (int z = 0; z <= 50; z += 5) {
        const double overlap_low = std::max(-2.0, -static_cast<double>(z));
        const double overlap_hig = std::min(2.0, 50.0 - static_cast<double>(z));
        expected_admix += xy_nodes_per_plane * (overlap_hig - overlap_low);
    }

    EXPECT_DOUBLE_EQ(total_admix, expected_admix);
}

// Only the internal shared top boundary must open; the outer top plane must stay owned.
TEST(ParticleVolume, ResampledMesocrystalKeepsOuterTopPlaneOwned)
{
    const auto basis = makeBasisParticle(Sphere(2.3));
    const Mesocrystal meso = makeBoxMesocrystal(*basis);
    const size_t plane_count = meso.numNodes() / 11;

    const ReSample re_sample = makeResampledSample(meso, 5);
    ASSERT_EQ(re_sample.structs().size(), 1);

    std::vector<const Mesocrystal*> pieces;
    collectMesocrystalPieces(*re_sample.structs().front()->particle(), pieces);
    ASSERT_EQ(pieces.size(), 5);

    const auto global_z = [&](const Mesocrystal& piece, bool top) {
        const Slice& slice = *re_sample.slices().at(piece.sliceNumber());
        const double slice_reference_z = std::isfinite(slice.hig()) ? slice.hig() : slice.low();
        return (top ? piece.topZ() : piece.bottomZ()) + slice_reference_z;
    };

    const auto topmost =
        std::max_element(pieces.begin(), pieces.end(), [&](const auto* left, const auto* right) {
            return global_z(*left, true) < global_z(*right, true);
        });
    ASSERT_NE(topmost, pieces.end());

    const auto below_topmost =
        std::max_element(pieces.begin(), pieces.end(), [=](const auto* left, const auto* right) {
            const double left_top = left == *topmost ? -Span::inf : global_z(*left, true);
            const double right_top = right == *topmost ? -Span::inf : global_z(*right, true);
            return left_top < right_top;
        });
    ASSERT_NE(below_topmost, pieces.end());

    const double internal_plane_z = global_z(**topmost, false);
    const double outer_top_plane_z = global_z(**topmost, true);

    const auto top_positions = absoluteNodePositions(**topmost, re_sample);
    const auto lower_positions = absoluteNodePositions(**below_topmost, re_sample);

    EXPECT_EQ(countNodesOnPlane(lower_positions, internal_plane_z), 0);
    EXPECT_EQ(countNodesOnPlane(top_positions, internal_plane_z), plane_count);
    EXPECT_EQ(countNodesOnPlane(top_positions, outer_top_plane_z), plane_count);
}

// Compound basis must preserve the exact node-position set after resampling.
TEST(ParticleVolume, ResampledCompoundBasisMesocrystalPreservesNodePositionSet)
{
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0);
    Particle left(material, Sphere(1.2));
    left.translate(R3(-0.8, 0.0, 0.4));
    Particle right(material, Box(1.5, 1.0, 2.0));
    right.translate(R3(0.9, -0.5, -0.6));

    Compound basis{};
    basis.addComponent(left);
    basis.addComponent(right);

    const Mesocrystal meso = makeBoxMesocrystal(basis);
    const auto expected = absoluteNodePositions(meso);

    const ReSample re_sample = makeResampledSample(meso, 5);
    ASSERT_EQ(re_sample.structs().size(), 1);
    expectSameNodePositions(
        absoluteNodePositions(*re_sample.structs().front()->particle(), re_sample), expected);
}

// Transforming a Mixture as a node must match transforming each child explicitly.
TEST(ParticleVolume, MesocrystalMixtureTransformEquivalentToChildTransform)
{
    const Vacuum ambient;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0);
    const R3 shift(0.31, -0.17, 0.23);
    const Box ff(1.2, 0.9, 0.7);

    Particle base_particle(material, ff);

    Mixture mix_with_parent_transform{};
    mix_with_parent_transform.addParticle(base_particle, 1.0);
    mix_with_parent_transform.translate(shift);

    Particle shifted_particle(material, ff);
    shifted_particle.translate(shift);
    Mixture mix_with_child_transform{};
    mix_with_child_transform.addParticle(shifted_particle, 1.0);

    const Lattice3D lattice(R3(100.0, 0.0, 0.0), R3(0.0, 100.0, 0.0), R3(0.0, 0.0, 100.0));
    const Crystal crystal_parent(mix_with_parent_transform, lattice);
    const Crystal crystal_child(mix_with_child_transform, lattice);

    Mesocrystal mc_parent(crystal_parent, Box(20.0, 20.0, 20.0));
    Mesocrystal mc_child(crystal_child, Box(20.0, 20.0, 20.0));

    MesoOptions meso_options;
    meso_options.use_reciprocal_sum = false;
    mc_parent.prepareForComputation(ambient, meso_options);
    mc_child.prepareForComputation(ambient, meso_options);

    // Compare complex form factors at the same wavevector.
    // We assert real and imaginary parts separately for clear diagnostics.
    const WavevectorInfo wavevectors(R3(0.0, 0.0, 0.0), R3(0.13, -0.21, 0.17), 1.54);
    const auto ff_parent = mc_parent.theFF(wavevectors);
    const auto ff_child = mc_child.theFF(wavevectors);

    EXPECT_NEAR((ff_parent - ff_child).real(), 0.0, 1e-12);
    EXPECT_NEAR((ff_parent - ff_child).imag(), 0.0, 1e-12);
}

// A double-nested mesocrystal basis must preserve the exact leaf-node set
// before and after resampling.
TEST(ParticleVolume, DoubleNestedMesocrystalPreservesNodePositionSetAfterResampling)
{
    const Vacuum ambient;
    Particle inner_basis(SLDMaterial("particle", MaterialColor(0.86, 0.24, 0.18), 6e-6, 0.0),
                         Box(1.0, 0.8, 0.6));
    inner_basis.setAmbientMaterial(ambient);
    inner_basis.translate(R3(0.4, -0.3, 0.7));

    Mesocrystal inner(Crystal(inner_basis, Lattice3D(R3(5.0, 0.0, 0.0), R3(0.0, 100.0, 0.0),
                                                     R3(0.0, 0.0, 100.0))),
                      Box(18.0, 18.0, 18.0));
    inner.translate(R3(0.8, -0.4, 1.6));

    Mesocrystal middle(
        Crystal(inner, Lattice3D(R3(9.0, 0.0, 0.0), R3(0.0, 100.0, 0.0), R3(0.0, 0.0, 4.0))),
        Box(24.0, 20.0, 18.0));
    middle.translate(R3(-0.6, 0.7, 0.9));

    const Mesocrystal nested(
        Crystal(middle, Lattice3D(R3(100.0, 0.0, 0.0), R3(0.0, 9.0, 0.0), R3(0.0, 0.0, 100.0))),
        Box(18.0, 24.0, 18.0));
    const auto expected = absoluteNodePositions(nested);

    const ReSample nested_sample = makeResampledSample(nested, 5);
    ASSERT_EQ(nested_sample.structs().size(), 1u);

    expectSameNodePositions(
        absoluteNodePositions(*nested_sample.structs().front()->particle(), nested_sample),
        expected);
}

// CoreAndShell basis processing must match an equivalent transformed Compound basis.
TEST(ParticleVolume, MesocrystalCoreAndShellBasisMatchesEquivalentCompound)
{
    const Vacuum ambient;
    const SLDMaterial shell_material("shell", MaterialColor(0.5, 0.5, 0.5), 6e-6, 1e-7);
    const SLDMaterial core_material("core", MaterialColor(0.5, 0.5, 0.5), 1.2e-5, 3e-7);
    const SLDMaterial core_diff_material("core_diff", MaterialColor(0.5, 0.5, 0.5), 6e-6, 2e-7);

    Particle core(core_material, Box(1.2, 0.9, 0.7));
    core.translate(R3(0.11, -0.07, 0.08));
    Particle shell(shell_material, Box(2.0, 1.7, 1.1));
    CoreAndShell core_shell_basis(core, shell);
    core_shell_basis.rotate(RotationZ(27 * Units::deg));
    core_shell_basis.translate(R3(0.31, -0.17, 0.23));

    Particle diff_core(core_diff_material, Box(1.2, 0.9, 0.7));
    diff_core.translate(R3(0.11, -0.07, 0.08));
    Particle compound_shell(shell_material, Box(2.0, 1.7, 1.1));
    Compound compound_basis{};
    compound_basis.addComponent(compound_shell);
    compound_basis.addComponent(diff_core);
    compound_basis.rotate(RotationZ(27 * Units::deg));
    compound_basis.translate(R3(0.31, -0.17, 0.23));

    const Lattice3D lattice(R3(100.0, 0.0, 0.0), R3(0.0, 100.0, 0.0), R3(0.0, 0.0, 100.0));
    const Crystal core_shell_crystal(core_shell_basis, lattice);
    const Crystal compound_crystal(compound_basis, lattice);

    Mesocrystal core_shell_meso(core_shell_crystal, Box(20.0, 20.0, 20.0));
    Mesocrystal compound_meso(compound_crystal, Box(20.0, 20.0, 20.0));

    MesoOptions meso_options;
    meso_options.use_reciprocal_sum = false;
    core_shell_meso.prepareForComputation(ambient, meso_options);
    compound_meso.prepareForComputation(ambient, meso_options);

    const WavevectorInfo wavevectors(R3(0.0, 0.0, 0.0), R3(0.13, -0.21, 0.17), 1.54);
    const auto ff_core_shell = core_shell_meso.theFF(wavevectors);
    const auto ff_compound = compound_meso.theFF(wavevectors);

    EXPECT_NEAR((ff_core_shell - ff_compound).real(), 0.0, 1e-12);
    EXPECT_NEAR((ff_core_shell - ff_compound).imag(), 0.0, 1e-12);
}

// Slicing must shift the basis position in z (because the slice cuts from
// below), but keep x and y unchanged.  The shifted position must stay
// within one unit cell of the lattice.
TEST_F(ParticleVolumeSlicedMesocrystal, ResidualBasisShiftIsIntraCell)
{
    const R3 src = sourceBasisPosition();
    const R3 dst = slicedBasisPosition();
    const double cell_z = sliced->particleStructure().lattice()->basisVectorC().z();

    EXPECT_NEAR(dst.x(), src.x(), 1e-12);
    EXPECT_NEAR(dst.y(), src.y(), 1e-12);
    EXPECT_NE(dst.z(), src.z());          // non-trivial shift
    EXPECT_LT(std::abs(dst.z()), cell_z); // stays inside one unit cell
}

// The two-pass index search must find exactly the same nodes as brute force.
TEST_F(ParticleVolumeSlicedMesocrystal, NodePositionsMatchBruteForce)
{
    ASSERT_FALSE(brute_positions.empty());
    EXPECT_EQ(sliced->numNodes(), brute_positions.size());
    ASSERT_EQ(meso_positions.size(), brute_positions.size());
    for (size_t n = 0; n < meso_positions.size(); ++n) {
        EXPECT_NEAR(meso_positions[n][0], brute_positions[n][0], 1e-12);
        EXPECT_NEAR(meso_positions[n][1], brute_positions[n][1], 1e-12);
        EXPECT_NEAR(meso_positions[n][2], brute_positions[n][2], 1e-12);
    }
}

// theFF of the sliced mesocrystal must equal the explicit phase-factor sum
// over brute-force node positions:  FF = sum_j exp(i q.r_j) * f_basis(q).
TEST_F(ParticleVolumeSlicedMesocrystal, FormFactorMatchesBruteForcePhaseSum)
{
    // Reconstruct an isolated basis particle identical to what sits inside the
    // crystal (same shape, vacuum ambient).
    Particle basis_alone(material, Sphere(1.1));
    basis_alone.setAmbientMaterial(ambient);

    const WavevectorInfo wv(R3(0, 0, 0), R3(0.173, -0.071, 0.113), 1.54);
    const complex_t f_basis = basis_alone.theFF(wv);
    complex_t ff_sum = 0.0;
    for (const auto& pos : brute_positions) {
        const R3 r = R3(pos[0], pos[1], pos[2]) + sliced->particlePosition();
        ff_sum += std::exp(complex_t(0, 1) * r.dot(wv.getQ())) * f_basis;
    }

    const complex_t ff_meso = sliced->theFF(wv);
    EXPECT_NEAR((ff_meso - ff_sum).real(), 0.0, 1e-12);
    EXPECT_NEAR((ff_meso - ff_sum).imag(), 0.0, 1e-12);
}

// Reciprocal mesocrystal keeps zero covariance and may leave inactive Born channels zero.
TEST(ParticleVolume, MesocrystalReciprocalMomentsDeterministicBasisHaveZeroVariance)
{
    const Vacuum ambient;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0);

    Particle basis(material, Sphere(1.2));
    const Lattice3D lattice(R3(8.0, 0.0, 0.0), R3(0.0, 8.0, 0.0), R3(0.0, 0.0, 8.0));
    const Crystal crystal(basis, lattice);
    Mesocrystal mc(crystal, Box(20.0, 20.0, 20.0));

    prepareReciprocalMesocrystal(mc, ambient);

    const WavevectorInfo wavevectors(R3(0.0, 0.0, 0.0), R3(0.13, -0.21, 0.17), 1.54);
    const auto channels = makeBornChannels(wavevectors);
    const auto moments = mc.channelMoments(channels);

    for (size_t m = 0; m < channels.n_active; ++m) {
        const auto diff = moments.mu[m] - mc.theFF(channels.q[m]);
        EXPECT_NEAR(diff.real(), 0.0, 1e-12);
        EXPECT_NEAR(diff.imag(), 0.0, 1e-12);
    }
    for (size_t m = channels.n_active; m < N_DWBA; ++m) {
        EXPECT_NEAR(moments.mu[m].real(), 0.0, 1e-14);
        EXPECT_NEAR(moments.mu[m].imag(), 0.0, 1e-14);
    }
    for (size_t m = 0; m < N_DWBA; ++m) {
        for (size_t n = 0; n < N_DWBA; ++n) {
            EXPECT_NEAR(moments.C[m][n].real(), 0.0, 1e-14);
            EXPECT_NEAR(moments.C[m][n].imag(), 0.0, 1e-14);
        }
    }
}

// Reciprocal-sum preparation must reject a stochastic Mixture basis.
TEST(ParticleVolume, MesocrystalReciprocalMomentsRejectMixtureBasis)
{
    const Vacuum ambient;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0);

    Particle small(material, Sphere(1.0));
    Particle large(material, Sphere(1.6));
    Mixture mixture{};
    mixture.addParticle(small, 1.0);
    mixture.addParticle(large, 1.0);
    mixture.validate();

    const Lattice3D lattice(R3(8.0, 0.0, 0.0), R3(0.0, 8.0, 0.0), R3(0.0, 0.0, 8.0));
    const Crystal crystal(mixture, lattice);
    Mesocrystal mc(crystal, Box(20.0, 20.0, 20.0));

    EXPECT_THROW(prepareReciprocalMesocrystal(mc, ambient), std::runtime_error);
}

// Reciprocal-sum preparation must reject a basis with a nested Mixture.
TEST(ParticleVolume, MesocrystalReciprocalMomentsRejectNestedMixtureBasis)
{
    const Vacuum ambient;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 6e-6, 0.0);

    Particle left(material, Sphere(1.0));
    Particle right(material, Sphere(1.6));
    Mixture stochastic_child{};
    stochastic_child.addParticle(left, 1.0);
    stochastic_child.addParticle(right, 1.0);
    stochastic_child.validate();

    Compound basis{};
    basis.addComponent(Particle(material, Sphere(0.8)));
    basis.addComponent(stochastic_child);

    const Lattice3D lattice(R3(8.0, 0.0, 0.0), R3(0.0, 8.0, 0.0), R3(0.0, 0.0, 8.0));
    const Crystal crystal(basis, lattice);
    Mesocrystal mc(crystal, Box(20.0, 20.0, 20.0));

    EXPECT_THROW(prepareReciprocalMesocrystal(mc, ambient), std::runtime_error);
}
