#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 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()));
}

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

fesa::Domain make_domain() {
    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{4.0, 5.5, -6.25},
    });
    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},
        {},
    });
    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.set_step({"Load/Case", {}, {}});

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

fesa::ResultDatabase make_database() {
    return {
        "1.0.0",
        {{
            "Load/Case",
            {{
                1.25,
                {
                    {fesa::NodeId{7}, fesa::NodeId{42}},
                    {
                        {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},
                    },
                },
                {},
            }},
        }},
    };
}

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);
    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, "1.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.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, 4.0);
    EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.y, 5.5);
    EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.z, -6.25);

    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(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);

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

TEST(Hdf5, RejectsFrameDiagnosticsThatSchemaCannotRepresent) {
    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);

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

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();
    database.steps[0].frames[0].nodal.node_ids[0] = fesa::NodeId{999};

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

    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());
    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, RejectsResultNodeMissingFromSerializedModel) {
    const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
                      "Testing" / "Temporary" /
                      "fesa-invalid-result-node.h5";
    std::filesystem::remove(path);
    const auto write_diagnostics =
        fesa::write_hdf5(path, make_domain(), make_database());
    ASSERT_TRUE(write_diagnostics.empty());
    const std::array<std::int64_t, 2> node_ids{999, 42};
    write_int64_dataset(
        path,
        "/results/steps/0/frames/0/nodal/node_ids",
        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()).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()).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()).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()).empty());
    const std::array<double, 6> coordinates{
        std::numeric_limits<double>::quiet_NaN(),
        -2.5,
        3.75,
        4.0,
        5.5,
        -6.25,
    };
    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, 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()).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()).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
