#include <fesa/io/hdf5/writer.hpp>
#include <fesa/model/domain_builder.hpp>

#include <hdf5.h>

#include <algorithm>
#include <array>
#include <cstdint>
#include <filesystem>
#include <limits>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

#include <gtest/gtest.h>

namespace {

class TestHdf5Handle final {
public:
    using CloseFunction = herr_t (*)(hid_t);

    TestHdf5Handle(hid_t id, CloseFunction close)
        : id_{id}, close_{close} {
        if (id_ < 0) {
            throw std::runtime_error{"Failed to open test HDF5 resource."};
        }
    }

    ~TestHdf5Handle() {
        if (id_ >= 0) {
            close_(id_);
        }
    }

    TestHdf5Handle(const TestHdf5Handle&) = delete;
    TestHdf5Handle& operator=(const TestHdf5Handle&) = delete;

    TestHdf5Handle(TestHdf5Handle&& other) noexcept
        : id_{std::exchange(other.id_, H5I_INVALID_HID)},
          close_{other.close_} {}

    TestHdf5Handle& operator=(TestHdf5Handle&&) = delete;

    [[nodiscard]] hid_t get() const noexcept {
        return id_;
    }

private:
    hid_t id_;
    CloseFunction close_;
};

std::string hdf5_path(const std::filesystem::path& path) {
    const std::u8string value = path.u8string();
    return {reinterpret_cast<const char*>(value.data()), value.size()};
}

void require_hdf5_status(const herr_t status) {
    if (status < 0) {
        throw std::runtime_error{"Test HDF5 mutation failed."};
    }
}

void replace_step_time_with_vector(const std::filesystem::path& path) {
    const std::string encoded_path = hdf5_path(path);
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    TestHdf5Handle frame{
        H5Gopen2(
            file.get(), "/results/steps/0/frames/0", H5P_DEFAULT),
        &H5Gclose,
    };
    require_hdf5_status(H5Adelete(frame.get(), "step_time"));
    const std::array<hsize_t, 1> dimensions{2U};
    TestHdf5Handle space{
        H5Screate_simple(1, dimensions.data(), nullptr),
        &H5Sclose,
    };
    TestHdf5Handle attribute{
        H5Acreate2(
            frame.get(),
            "step_time",
            H5T_IEEE_F64LE,
            space.get(),
            H5P_DEFAULT,
            H5P_DEFAULT),
        &H5Aclose,
    };
    const std::array<double, 2> values{1.25, 2.5};
    require_hdf5_status(
        H5Awrite(attribute.get(), H5T_NATIVE_DOUBLE, values.data()));
}

void delete_link(
    const std::filesystem::path& path,
    const std::string_view link_path) {
    const std::string encoded_path = hdf5_path(path);
    const std::string owned_link_path{link_path};
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    require_hdf5_status(
        H5Ldelete(file.get(), owned_link_path.c_str(), H5P_DEFAULT));
}

void copy_object(
    const std::filesystem::path& path,
    const std::string_view source_path,
    const std::string_view target_path) {
    const std::string encoded_path = hdf5_path(path);
    const std::string owned_source_path{source_path};
    const std::string owned_target_path{target_path};
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    require_hdf5_status(H5Ocopy(
        file.get(),
        owned_source_path.c_str(),
        file.get(),
        owned_target_path.c_str(),
        H5P_DEFAULT,
        H5P_DEFAULT));
}

void write_double_attribute(
    const std::filesystem::path& path,
    const std::string_view object_path,
    const std::string_view attribute_name,
    const double value) {
    const std::string encoded_path = hdf5_path(path);
    const std::string owned_object_path{object_path};
    const std::string owned_attribute_name{attribute_name};
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    TestHdf5Handle object{
        H5Oopen(file.get(), owned_object_path.c_str(), H5P_DEFAULT),
        &H5Oclose,
    };
    TestHdf5Handle attribute{
        H5Aopen(object.get(), owned_attribute_name.c_str(), H5P_DEFAULT),
        &H5Aclose,
    };
    require_hdf5_status(
        H5Awrite(attribute.get(), H5T_NATIVE_DOUBLE, &value));
}

void write_int64_dataset(
    const std::filesystem::path& path,
    const std::string_view dataset_path,
    const std::span<const std::int64_t> values) {
    const std::string encoded_path = hdf5_path(path);
    const std::string owned_dataset_path{dataset_path};
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    TestHdf5Handle dataset{
        H5Dopen2(
            file.get(), owned_dataset_path.c_str(), H5P_DEFAULT),
        &H5Dclose,
    };
    require_hdf5_status(H5Dwrite(
        dataset.get(),
        H5T_NATIVE_INT64,
        H5S_ALL,
        H5S_ALL,
        H5P_DEFAULT,
        values.data()));
}

void write_double_dataset(
    const std::filesystem::path& path,
    const std::string_view dataset_path,
    const std::span<const double> values) {
    const std::string encoded_path = hdf5_path(path);
    const std::string owned_dataset_path{dataset_path};
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    TestHdf5Handle dataset{
        H5Dopen2(
            file.get(), owned_dataset_path.c_str(), H5P_DEFAULT),
        &H5Dclose,
    };
    require_hdf5_status(H5Dwrite(
        dataset.get(),
        H5T_NATIVE_DOUBLE,
        H5S_ALL,
        H5S_ALL,
        H5P_DEFAULT,
        values.data()));
}

void write_uint8_dataset(
    const std::filesystem::path& path,
    const std::string_view dataset_path,
    const std::span<const std::uint8_t> values) {
    const std::string encoded_path = hdf5_path(path);
    const std::string owned_dataset_path{dataset_path};
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    TestHdf5Handle dataset{
        H5Dopen2(
            file.get(), owned_dataset_path.c_str(), H5P_DEFAULT),
        &H5Dclose,
    };
    require_hdf5_status(H5Dwrite(
        dataset.get(),
        H5T_NATIVE_UINT8,
        H5S_ALL,
        H5S_ALL,
        H5P_DEFAULT,
        values.data()));
}

void write_root_string_attribute(
    const std::filesystem::path& path,
    const std::string_view name,
    const std::string_view value) {
    const std::string encoded_path = hdf5_path(path);
    const std::string owned_name{name};
    const std::string owned_value{value};
    TestHdf5Handle file{
        H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
        &H5Fclose,
    };
    TestHdf5Handle attribute{
        H5Aopen(file.get(), owned_name.c_str(), H5P_DEFAULT),
        &H5Aclose,
    };
    TestHdf5Handle type{H5Aget_type(attribute.get()), &H5Tclose};
    const char* pointer = owned_value.c_str();
    require_hdf5_status(H5Awrite(attribute.get(), type.get(), &pointer));
}

std::filesystem::path round_trip_path() {
    return std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" /
           "Temporary" / "fesa-round-trip.h5";
}

std::filesystem::path self_contained_path() {
    return std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" /
           "Temporary" / "fesa-self-contained.h5";
}

const fesa::Hdf5InputIdentity& test_input_identity() {
    static const fesa::Hdf5InputIdentity identity{
        "beam model.inp",
        "fnv1a64:0123456789abcdef",
    };
    return identity;
}

fesa::Domain make_domain(const bool add_unreported_node = false) {
    fesa::DomainBuilder builder;
    builder.add_node({
        fesa::NodeId{42},
        fesa::EntityOrigin{"BeamPart", "Beam-1", 1001},
        fesa::Vec3{1.25, -2.5, 3.75},
    });
    builder.add_node({
        fesa::NodeId{7},
        fesa::EntityOrigin{"BeamPart", "Beam-1", 1002},
        fesa::Vec3{2.25, -2.5, 3.75},
    });
    if (add_unreported_node) {
        builder.add_node({
            fesa::NodeId{99},
            fesa::EntityOrigin{"BeamPart", "Beam-1", 1003},
            fesa::Vec3{8.0, 0.0, 0.0},
        });
    }
    builder.add_material({
        fesa::MaterialId{6},
        "Steel",
        210.0e9,
        0.3,
    });
    builder.add_section({
        fesa::SectionId{4},
        "InputShear",
        0.04,
        1.2e-4,
        1.4e-4,
        2.0e-4,
        0.031,
        0.032,
        fesa::ShearPropertySource::input,
        fesa::Vec3{0.0, 1.0, 0.0},
        {{0.25, -0.5}, {-0.75, 0.125}},
    });
    builder.add_section({
        fesa::SectionId{12},
        "DefaultShear",
        0.06,
        1.5e-4,
        1.7e-4,
        2.2e-4,
        0.05,
        0.05,
        fesa::ShearPropertySource::phase1_default,
        fesa::Vec3{0.0, 1.0, 0.0},
        {},
    });
    builder.add_beam_element({
        fesa::ElementId{9},
        fesa::EntityOrigin{"BeamPart", "Beam-1", 2001},
        {fesa::NodeId{7}, fesa::NodeId{42}},
        fesa::MaterialId{6},
        fesa::SectionId{4},
    });
    builder.add_beam_element({
        fesa::ElementId{17},
        fesa::EntityOrigin{"BeamPart", "Beam-1", 2002},
        {fesa::NodeId{42}, fesa::NodeId{7}},
        fesa::MaterialId{6},
        fesa::SectionId{4},
    });
    builder.add_node_set({"Fixed", {fesa::NodeId{42}}});
    builder.add_node_set(
        {"Loaded", {fesa::NodeId{7}, fesa::NodeId{42}}});
    builder.add_element_set(
        {"AllBeams", {fesa::ElementId{9}, fesa::ElementId{17}}});
    builder.set_step({
        "Load/Case",
        {
            {fesa::NodeId{42}, 1, 0.0},
            {fesa::NodeId{7}, 6, 0.125},
        },
        {{
            fesa::NodeId{7},
            {100.0, -200.0, 300.0, -400.0, 500.0, -600.0},
        }},
    });

    auto built = std::move(builder).build();
    EXPECT_TRUE(built.domain.has_value());
    EXPECT_TRUE(built.diagnostics.empty());
    return std::move(*built.domain);
}

fesa::BeamSectionResult make_end_result(
    double xi,
    fesa::NodeId node,
    double offset);

fesa::ResultDatabase make_database() {
    fesa::ResultDatabase database{
        "2.0.0",
        {{
            "Load/Case",
            {{
                1.25,
                {
                    {fesa::NodeId{7}, fesa::NodeId{42}},
                    {
                        fesa::EntityOrigin{
                            "BeamPart", "Beam-1", 1002},
                        fesa::EntityOrigin{
                            "BeamPart", "Beam-1", 1001},
                    },
                    {
                        {1.0, 2.0, 3.0, 4.0, 5.0, 6.0},
                        {-1.0, -2.0, -3.0, -4.0, -5.0, -6.0},
                    },
                    {
                        {10.0, 20.0, 30.0, 40.0, 50.0, 60.0},
                        {-10.0, -20.0, -30.0, -40.0, -50.0, -60.0},
                    },
                },
                {},
                {},
            }},
        }},
    };
    database.steps[0].frames[0].element.beams = {
        {
            fesa::ElementId{9},
            {"BeamPart", "Beam-1", 2001},
            {
                {-1.0, 0.0, 0.0},
                {0.0, 1.0, 0.0},
                {0.0, 0.0, -1.0},
            },
            {
                make_end_result(-1.0, fesa::NodeId{7}, 0.0),
                make_end_result(1.0, fesa::NodeId{42}, 100.0),
            },
        },
        {
            fesa::ElementId{17},
            {"BeamPart", "Beam-1", 2002},
            {
                {1.0, 0.0, 0.0},
                {0.0, 1.0, 0.0},
                {0.0, 0.0, 1.0},
            },
            {
                make_end_result(-1.0, fesa::NodeId{42}, 200.0),
                make_end_result(1.0, fesa::NodeId{7}, 300.0),
            },
        },
    };
    return database;
}

fesa::BeamSectionResult make_end_result(
    const double xi,
    const fesa::NodeId node,
    const double offset) {
    return {
        xi,
        node,
        {
            offset + 1.0,
            offset + 2.0,
            offset + 3.0,
            offset + 4.0,
            offset + 5.0,
            offset + 6.0,
        },
        {
            offset + 10.0,
            offset + 20.0,
            offset + 30.0,
            offset + 40.0,
            offset + 50.0,
            offset + 60.0,
        },
        offset + 70.0,
        {offset + 80.0, offset + 90.0},
    };
}

fesa::ResultDatabase make_complete_database() {
    fesa::ResultDatabase database = make_database();
    auto& frame = database.steps[0].frames[0];
    frame.diagnostics = {
        {
            fesa::DiagnosticStage::solver,
            fesa::Severity::warning,
            "solver.residual",
            "Residual diagnostic",
            fesa::SourceLocation{"beam model.inp", 41, 7},
        },
        {
            fesa::DiagnosticStage::results,
            fesa::Severity::error,
            "results.equilibrium",
            "Equilibrium diagnostic",
            std::nullopt,
        },
    };
    return database;
}

bool has_results_error(
    const std::vector<fesa::Diagnostic>& diagnostics,
    const std::string_view code) {
    return std::ranges::any_of(
        diagnostics,
        [code](const fesa::Diagnostic& diagnostic) {
            return diagnostic.stage == fesa::DiagnosticStage::results &&
                   diagnostic.severity == fesa::Severity::error &&
                   diagnostic.code == code;
        });
}

TEST(ResultRoundTrip, PreservesMinimalSchemaModelAndNodalResults) {
    const auto path = round_trip_path();
    std::filesystem::create_directories(path.parent_path());
    std::filesystem::remove(path);
    const auto domain = make_domain();
    const auto database = make_database();

    const auto write_diagnostics = fesa::write_hdf5(
        path, domain, database, test_input_identity());
    ASSERT_TRUE(write_diagnostics.empty());

    const auto read = fesa::read_hdf5_results(path);
    ASSERT_TRUE(read.diagnostics.empty());
    ASSERT_TRUE(read.database.has_value());
    ASSERT_TRUE(read.model.has_value());

    EXPECT_EQ(read.database->schema_version, "2.0.0");
    ASSERT_EQ(read.database->steps.size(), 1U);
    const auto& step = read.database->steps[0];
    EXPECT_EQ(step.name, "Load/Case");
    ASSERT_EQ(step.frames.size(), 1U);
    const auto& frame = step.frames[0];
    EXPECT_DOUBLE_EQ(frame.step_time, 1.25);
    EXPECT_EQ(
        frame.nodal.node_ids,
        (std::vector<fesa::NodeId>{fesa::NodeId{7}, fesa::NodeId{42}}));
    EXPECT_EQ(
        frame.nodal.origins,
        (std::vector<fesa::EntityOrigin>{
            {"BeamPart", "Beam-1", 1002},
            {"BeamPart", "Beam-1", 1001},
        }));
    EXPECT_EQ(
        frame.nodal.displacement[0],
        (std::array<double, 6>{1.0, 2.0, 3.0, 4.0, 5.0, 6.0}));
    EXPECT_EQ(
        frame.nodal.displacement[1],
        (std::array<double, 6>{-1.0, -2.0, -3.0, -4.0, -5.0, -6.0}));
    EXPECT_EQ(
        frame.nodal.reaction[0],
        (std::array<double, 6>{10.0, 20.0, 30.0, 40.0, 50.0, 60.0}));
    EXPECT_EQ(
        frame.nodal.reaction[1],
        (std::array<double, 6>{
            -10.0, -20.0, -30.0, -40.0, -50.0, -60.0}));
    EXPECT_TRUE(frame.diagnostics.empty());

    ASSERT_EQ(read.model->nodes.size(), 2U);
    EXPECT_EQ(read.model->nodes[0].dense_index, 0U);
    EXPECT_EQ(read.model->nodes[0].id, fesa::NodeId{42});
    EXPECT_EQ(
        read.model->nodes[0].origin,
        (fesa::EntityOrigin{"BeamPart", "Beam-1", 1001}));
    EXPECT_DOUBLE_EQ(read.model->nodes[0].coordinates.x, 1.25);
    EXPECT_DOUBLE_EQ(read.model->nodes[0].coordinates.y, -2.5);
    EXPECT_DOUBLE_EQ(read.model->nodes[0].coordinates.z, 3.75);
    EXPECT_EQ(read.model->nodes[1].dense_index, 1U);
    EXPECT_EQ(read.model->nodes[1].id, fesa::NodeId{7});
    EXPECT_EQ(
        read.model->nodes[1].origin,
        (fesa::EntityOrigin{"BeamPart", "Beam-1", 1002}));
    EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.x, 2.25);
    EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.y, -2.5);
    EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.z, 3.75);

    ASSERT_EQ(read.model->elements.size(), 2U);
    EXPECT_EQ(read.model->elements[0].dense_index, 0U);
    EXPECT_EQ(read.model->elements[0].id, fesa::ElementId{9});
    EXPECT_EQ(
        read.model->elements[0].connectivity,
        (std::array<std::uint64_t, 2>{1U, 0U}));
    EXPECT_EQ(read.model->elements[0].section, fesa::SectionId{4});
    EXPECT_EQ(read.model->elements[1].dense_index, 1U);
    EXPECT_EQ(read.model->elements[1].id, fesa::ElementId{17});
    EXPECT_EQ(
        read.model->elements[1].connectivity,
        (std::array<std::uint64_t, 2>{0U, 1U}));
    EXPECT_EQ(read.model->elements[1].section, fesa::SectionId{4});

    ASSERT_EQ(read.model->sections.size(), 2U);
    EXPECT_EQ(read.model->sections[0].id, fesa::SectionId{4});
    EXPECT_DOUBLE_EQ(read.model->sections[0].shear_area_y, 0.031);
    EXPECT_DOUBLE_EQ(read.model->sections[0].shear_area_z, 0.032);
    EXPECT_EQ(
        read.model->sections[0].shear_source,
        fesa::ShearPropertySource::input);
    EXPECT_EQ(read.model->sections[1].id, fesa::SectionId{12});
    EXPECT_DOUBLE_EQ(read.model->sections[1].shear_area_y, 0.05);
    EXPECT_DOUBLE_EQ(read.model->sections[1].shear_area_z, 0.05);
    EXPECT_EQ(
        read.model->sections[1].shear_source,
        fesa::ShearPropertySource::phase1_default);
}

TEST(SelfContainedHdf5, PublicReaderReconstructsCompletePhase1Contract) {
    const auto path = self_contained_path();
    std::filesystem::create_directories(path.parent_path());
    std::filesystem::remove(path);

    ASSERT_TRUE(fesa::write_hdf5(
        path,
        make_domain(),
        make_complete_database(),
        test_input_identity())
                    .empty());

    const fesa::Hdf5ReadResult read = fesa::read_hdf5_results(path);
    ASSERT_TRUE(read.diagnostics.empty());
    ASSERT_TRUE(read.metadata.has_value());
    ASSERT_TRUE(read.model.has_value());
    ASSERT_TRUE(read.analysis.has_value());
    ASSERT_TRUE(read.database.has_value());

    EXPECT_EQ(read.metadata->schema_version, "2.0.0");
    EXPECT_EQ(read.metadata->fesa_version, "0.1.0");
    EXPECT_EQ(
        read.metadata->unit_policy,
        "consistent_input_units_no_conversion");
    EXPECT_EQ(read.metadata->input_source, "beam model.inp");
    EXPECT_EQ(
        read.metadata->input_fingerprint,
        "fnv1a64:0123456789abcdef");

    ASSERT_EQ(read.model->nodes.size(), 2U);
    ASSERT_EQ(read.model->elements.size(), 2U);
    EXPECT_EQ(
        read.model->elements[0].origin,
        (fesa::EntityOrigin{"BeamPart", "Beam-1", 2001}));
    EXPECT_EQ(read.model->elements[0].material, fesa::MaterialId{6});
    ASSERT_EQ(read.model->materials.size(), 1U);
    EXPECT_EQ(read.model->materials[0].name, "Steel");
    EXPECT_DOUBLE_EQ(read.model->materials[0].young, 210.0e9);
    EXPECT_DOUBLE_EQ(read.model->materials[0].poisson, 0.3);
    ASSERT_EQ(read.model->sections.size(), 2U);
    EXPECT_EQ(read.model->sections[0].name, "InputShear");
    EXPECT_DOUBLE_EQ(read.model->sections[0].area, 0.04);
    EXPECT_DOUBLE_EQ(read.model->sections[0].iy, 1.2e-4);
    EXPECT_DOUBLE_EQ(read.model->sections[0].iz, 1.4e-4);
    EXPECT_DOUBLE_EQ(read.model->sections[0].torsion_j, 2.0e-4);
    EXPECT_DOUBLE_EQ(read.model->sections[0].orientation.y, 1.0);
    EXPECT_EQ(
        read.model->sections[0].recovery_points,
        (std::vector<std::array<double, 2>>{
            {0.25, -0.5}, {-0.75, 0.125}}));
    ASSERT_EQ(read.model->node_sets.size(), 2U);
    EXPECT_EQ(read.model->node_sets[0].name, "Fixed");
    EXPECT_EQ(
        read.model->node_sets[1].members,
        (std::vector<fesa::NodeId>{
            fesa::NodeId{7}, fesa::NodeId{42}}));
    ASSERT_EQ(read.model->element_sets.size(), 1U);
    EXPECT_EQ(
        read.model->element_sets[0].members,
        (std::vector<fesa::ElementId>{
            fesa::ElementId{9}, fesa::ElementId{17}}));

    EXPECT_EQ(read.analysis->step.name, "Load/Case");
    ASSERT_EQ(read.analysis->step.prescribed_dofs.size(), 2U);
    EXPECT_EQ(
        read.analysis->step.prescribed_dofs[1].node,
        fesa::NodeId{7});
    EXPECT_EQ(read.analysis->step.prescribed_dofs[1].dof, 6U);
    EXPECT_DOUBLE_EQ(
        read.analysis->step.prescribed_dofs[1].value, 0.125);
    ASSERT_EQ(read.analysis->step.nodal_loads.size(), 1U);
    EXPECT_DOUBLE_EQ(
        read.analysis->step.nodal_loads[0].values[5], -600.0);
    EXPECT_EQ(read.analysis->solver.backend, "mkl_pardiso");
    EXPECT_EQ(
        read.analysis->solver.constraint_method,
        "essential_dof_elimination");
    EXPECT_EQ(read.analysis->solver.assembly, "deterministic_serial");

    const auto& frame = read.database->steps[0].frames[0];
    ASSERT_EQ(frame.element.beams.size(), 2U);
    EXPECT_EQ(frame.element.beams[0].element, fesa::ElementId{9});
    EXPECT_DOUBLE_EQ(frame.element.beams[0].local_frame.ex.x, -1.0);
    EXPECT_EQ(
        frame.element.beams[0].end_results[0].end_node,
        fesa::NodeId{7});
    EXPECT_EQ(
        frame.element.beams[0].end_results[1].section_force,
        (std::array<double, 6>{
            110.0, 120.0, 130.0, 140.0, 150.0, 160.0}));
    EXPECT_EQ(
        frame.element.beams[1].end_results[0].sigma_xx,
        (std::vector<double>{280.0, 290.0}));
    ASSERT_EQ(frame.diagnostics.size(), 2U);
    EXPECT_EQ(frame.diagnostics[0].stage, fesa::DiagnosticStage::solver);
    EXPECT_EQ(frame.diagnostics[0].severity, fesa::Severity::warning);
    ASSERT_TRUE(frame.diagnostics[0].source.has_value());
    EXPECT_EQ(frame.diagnostics[0].source->file, "beam model.inp");
    EXPECT_EQ(frame.diagnostics[0].source->line, 41U);
    EXPECT_EQ(frame.diagnostics[0].source->column, 7U);
    EXPECT_FALSE(frame.diagnostics[1].source.has_value());
}

TEST(SelfContainedHdf5, RejectsMissingResultFrameBeforeWriting) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-missing-result-frame.h5";
    std::filesystem::remove(path);
    auto database = make_complete_database();
    database.steps[0].frames.clear();

    const auto diagnostics = fesa::write_hdf5(
        path, make_domain(), database, test_input_identity());

    EXPECT_TRUE(has_results_error(
        diagnostics, "hdf5.incomplete_result_frame"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(SelfContainedHdf5, RejectsIncompleteNodalCoverageBeforeWriting) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-incomplete-nodal-results.h5";
    std::filesystem::remove(path);
    const auto database = make_complete_database();

    const auto diagnostics = fesa::write_hdf5(
        path, make_domain(true), database, test_input_identity());

    EXPECT_TRUE(has_results_error(
        diagnostics, "hdf5.incomplete_result_frame"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(SelfContainedHdf5, RejectsIncompleteBeamCoverageBeforeWriting) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-incomplete-beam-results.h5";
    std::filesystem::remove(path);
    auto database = make_complete_database();
    database.steps[0].frames[0].element.beams.pop_back();

    const auto diagnostics = fesa::write_hdf5(
        path, make_domain(), database, test_input_identity());

    EXPECT_TRUE(has_results_error(
        diagnostics, "hdf5.incomplete_result_frame"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(SelfContainedHdf5, RejectsFiniteLocalFrameThatDisagreesWithModel) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-wrong-local-frame.h5";
    std::filesystem::remove(path);
    auto database = make_complete_database();
    database.steps[0].frames[0].element.beams[0].local_frame = {
        {1.0, 0.0, 0.0},
        {0.0, 1.0, 0.0},
        {0.0, 0.0, 1.0},
    };

    const auto diagnostics = fesa::write_hdf5(
        path, make_domain(), database, test_input_identity());

    EXPECT_TRUE(has_results_error(
        diagnostics, "hdf5.result_element_mismatch"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(Hdf5, RejectsVersion1AfterMajorSchemaChange) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" / "fesa-schema-1.h5";
    std::filesystem::remove(path);
    auto database = make_database();
    database.schema_version = "1.0.0";

    const auto diagnostics = fesa::write_hdf5(
        path, make_domain(), database, test_input_identity());

    EXPECT_TRUE(has_results_error(diagnostics, "hdf5.unsupported_schema"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(Hdf5, RejectsMissingInputIdentityBeforeWriting) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-missing-input-identity.h5";
    std::filesystem::remove(path);

    const auto diagnostics = fesa::write_hdf5(
        path, make_domain(), make_database(), {"", ""});

    EXPECT_TRUE(has_results_error(
        diagnostics, "hdf5.invalid_input_identity"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(Hdf5, RejectsUnlistedMinorSchemaVersion) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" / "fesa-schema-2-1.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    write_root_string_attribute(path, "schema_version", "2.1.0");

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_FALSE(read.metadata.has_value());
    EXPECT_FALSE(read.analysis.has_value());
    EXPECT_TRUE(has_results_error(
        read.diagnostics, "hdf5.unsupported_schema"));
}

TEST(Hdf5, RejectsInvalidResultDatabaseBeforeWriting) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" / "fesa-invalid.h5";
    std::filesystem::remove(path);
    const auto domain = make_domain();
    auto database = make_database();
    database.steps[0].frames[0].nodal.reaction.pop_back();

    const auto diagnostics =
        fesa::write_hdf5(path, domain, database, test_input_identity());

    EXPECT_TRUE(
        has_results_error(diagnostics, "results.nodal_size_mismatch"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(Hdf5, PreservesFrameDiagnosticsRepresentedBySchema) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-unsupported-diagnostics.h5";
    std::filesystem::remove(path);
    const auto domain = make_domain();
    auto database = make_database();
    database.steps[0].frames[0].diagnostics.push_back({
        fesa::DiagnosticStage::solver,
        fesa::Severity::warning,
        "solver.residual",
        "Residual diagnostic",
        std::nullopt,
    });

    const auto diagnostics =
        fesa::write_hdf5(path, domain, database, test_input_identity());

    ASSERT_TRUE(diagnostics.empty());

    const auto read = fesa::read_hdf5_results(path);
    ASSERT_TRUE(read.diagnostics.empty());
    ASSERT_TRUE(read.database.has_value());
    const auto& stored = read.database->steps[0].frames[0].diagnostics;
    ASSERT_EQ(stored.size(), 1U);
    EXPECT_EQ(stored[0].stage, fesa::DiagnosticStage::solver);
    EXPECT_EQ(stored[0].severity, fesa::Severity::warning);
    EXPECT_EQ(stored[0].code, "solver.residual");
    EXPECT_EQ(stored[0].message, "Residual diagnostic");
    EXPECT_FALSE(stored[0].source.has_value());
}

TEST(Hdf5, RejectsResultNodeMissingFromDomainBeforeWriting) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-missing-result-node.h5";
    std::filesystem::remove(path);
    const auto domain = make_domain();
    auto database = make_database();
    auto& nodal = database.steps[0].frames[0].nodal;
    nodal.node_ids.push_back(fesa::NodeId{999});
    nodal.origins.push_back({"BeamPart", "Beam-1", 1999});
    nodal.displacement.push_back({});
    nodal.reaction.push_back({});

    const auto diagnostics =
        fesa::write_hdf5(path, domain, database, test_input_identity());

    EXPECT_TRUE(has_results_error(
        diagnostics, "hdf5.result_node_not_in_model"));
    EXPECT_FALSE(std::filesystem::exists(path));
}

TEST(Hdf5, ReportsMissingFileAtResultsStage) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" / "fesa-missing.h5";
    std::filesystem::remove(path);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(has_results_error(read.diagnostics, "hdf5.open_failed"));
}

TEST(Hdf5, RejectsNonScalarStepTimeAttribute) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-nonscalar-step-time.h5";
    std::filesystem::remove(path);
    const auto write_diagnostics = fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity());
    ASSERT_TRUE(write_diagnostics.empty());
    replace_step_time_with_vector(path);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(has_results_error(read.diagnostics, "hdf5.read_failed"));
}

TEST(Hdf5, RejectsSerializedResultWithoutRequiredFrame) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-missing-serialized-frame.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    delete_link(path, "/results/steps/0/frames/0");

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_FALSE(read.metadata.has_value());
    EXPECT_FALSE(read.analysis.has_value());
    EXPECT_TRUE(has_results_error(
        read.diagnostics, "hdf5.incomplete_result_frame"));
}

TEST(Hdf5, RejectsSerializedResultWithExtraFrame) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-extra-serialized-frame.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    copy_object(
        path,
        "/results/steps/0/frames/0",
        "/results/steps/0/frames/1");
    write_double_attribute(
        path, "/results/steps/0/frames/1", "step_time", 2.5);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_FALSE(read.metadata.has_value());
    EXPECT_FALSE(read.analysis.has_value());
    EXPECT_TRUE(has_results_error(
        read.diagnostics, "hdf5.incomplete_result_frame"));
}

TEST(Hdf5, RejectsInvalidSerializedInputFingerprint) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-invalid-input-fingerprint.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    write_root_string_attribute(
        path, "input_fingerprint", "sha256:not-the-contract");

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_FALSE(read.metadata.has_value());
    EXPECT_FALSE(read.analysis.has_value());
    EXPECT_TRUE(has_results_error(
        read.diagnostics, "hdf5.invalid_input_identity"));
}

TEST(Hdf5, RejectsResultNodeMissingFromSerializedModel) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-invalid-result-node.h5";
    std::filesystem::remove(path);
    auto database = make_database();
    auto& nodal = database.steps[0].frames[0].nodal;
    nodal.node_ids.push_back(fesa::NodeId{99});
    nodal.origins.push_back({"BeamPart", "Beam-1", 1003});
    nodal.displacement.push_back({});
    nodal.reaction.push_back({});
    const auto write_diagnostics = fesa::write_hdf5(
        path, make_domain(true), database, test_input_identity());
    ASSERT_TRUE(write_diagnostics.empty());
    const std::array<std::int64_t, 3> node_ids{42, 7, 100};
    write_int64_dataset(path, "/model/nodes/internal_id", node_ids);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(has_results_error(
        read.diagnostics, "hdf5.result_node_not_in_model"));
}

TEST(Hdf5, RejectsDuplicateSerializedNodeIds) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-duplicate-node-ids.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<std::int64_t, 2> ids{42, 42};
    write_int64_dataset(path, "/model/nodes/internal_id", ids);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsDuplicateSerializedElementIds) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-duplicate-element-ids.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<std::int64_t, 2> ids{9, 9};
    write_int64_dataset(path, "/model/elements/internal_id", ids);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsDuplicateSerializedSectionIds) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-duplicate-section-ids.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<std::int64_t, 2> ids{4, 4};
    write_int64_dataset(path, "/model/sections/internal_id", ids);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsNonfiniteSerializedCoordinates) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-nonfinite-coordinates.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<double, 6> coordinates{
        std::numeric_limits<double>::quiet_NaN(),
        -2.5,
        3.75,
        2.25,
        -2.5,
        3.75,
    };
    write_double_dataset(path, "/model/nodes/coordinates", coordinates);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsSerializedZeroLengthElement) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-zero-length-element.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<double, 6> coordinates{
        1.25, -2.5, 3.75, 1.25, -2.5, 3.75};
    write_double_dataset(path, "/model/nodes/coordinates", coordinates);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsSerializedOrientationParallelToElement) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-parallel-orientation.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<double, 6> orientations{
        1.0, 0.0, 0.0, 0.0, 1.0, 0.0};
    write_double_dataset(
        path, "/model/sections/orientation", orientations);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsDuplicateSerializedNodeOrigins) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-duplicate-node-origins.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<std::int64_t, 2> labels{1001, 1001};
    write_int64_dataset(path, "/model/nodes/local_label", labels);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsDuplicateSerializedBoundaryConditions) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-duplicate-boundary-conditions.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<std::int64_t, 2> node_ids{42, 42};
    const std::array<std::uint8_t, 2> dofs{1, 1};
    write_int64_dataset(
        path,
        "/analysis/steps/0/boundary_conditions/node_ids",
        node_ids);
    write_uint8_dataset(
        path, "/analysis/steps/0/boundary_conditions/dofs", dofs);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsSerializedLocalFrameThatDisagreesWithModel) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-serialized-wrong-local-frame.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<double, 18> local_frames{
        1.0, 0.0, 0.0,
        0.0, 1.0, 0.0,
        0.0, 0.0, 1.0,
        1.0, 0.0, 0.0,
        0.0, 1.0, 0.0,
        0.0, 0.0, 1.0,
    };
    write_double_dataset(
        path,
        "/results/steps/0/frames/0/element/beam/local_frame",
        local_frames);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(has_results_error(
        read.diagnostics, "hdf5.result_element_mismatch"));
}

TEST(Hdf5, RejectsNonfiniteSerializedShearArea) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-nonfinite-shear-area.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<double, 2> shear_areas{
        std::numeric_limits<double>::infinity(),
        0.05,
    };
    write_double_dataset(path, "/model/sections/shear_area_y", shear_areas);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

TEST(Hdf5, RejectsNonpositiveSerializedShearArea) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-nonpositive-shear-area.h5";
    std::filesystem::remove(path);
    ASSERT_TRUE(fesa::write_hdf5(
        path, make_domain(), make_database(), test_input_identity())
                    .empty());
    const std::array<double, 2> shear_areas{-0.031, 0.05};
    write_double_dataset(path, "/model/sections/shear_area_y", shear_areas);

    const auto read = fesa::read_hdf5_results(path);

    EXPECT_FALSE(read.database.has_value());
    EXPECT_FALSE(read.model.has_value());
    EXPECT_TRUE(
        has_results_error(read.diagnostics, "hdf5.invalid_model_data"));
}

}  // namespace
