#include "reference_comparison.hpp"

#include "fesa/app/fesa_application.hpp"

#include <hdf5.h>

#include <gtest/gtest.h>

#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <vector>

#ifndef FESA_TEST_SOURCE_DIR
#error FESA_TEST_SOURCE_DIR must identify the repository root.
#endif

#ifndef FESA_TEST_BINARY_DIR
#error FESA_TEST_BINARY_DIR must identify the CMake binary root.
#endif

namespace {

constexpr const char* kStressPath =
    "/steps/Step-1/frames/0/element/stress_s11";
constexpr std::size_t kExpectedRowCount = 176U;
constexpr std::size_t kExpectedMetricCount = 16U;

class Hdf5Handle {
public:
    using Closer = herr_t (*)(hid_t);

    Hdf5Handle(const hid_t value, Closer closer)
        : value_{value}, closer_{closer} {}
    Hdf5Handle(const Hdf5Handle&) = delete;
    Hdf5Handle& operator=(const Hdf5Handle&) = delete;
    ~Hdf5Handle() {
        if (value_ >= 0 && closer_ != nullptr) {
            (void)closer_(value_);
        }
    }

    hid_t get() const noexcept { return value_; }

private:
    hid_t value_;
    Closer closer_;
};

struct ReferenceSnapshotEntry {
    std::filesystem::path relativePath;
    bool isDirectory;
    std::string bytes;
    std::filesystem::file_time_type lastWriteTime;
};

std::string readBytes(const std::filesystem::path& path) {
    std::ifstream stream{path, std::ios::binary};
    if (!stream) {
        throw std::runtime_error{"Unable to read reference evidence: " +
                                 path.string()};
    }
    return {std::istreambuf_iterator<char>{stream},
            std::istreambuf_iterator<char>{}};
}

std::vector<ReferenceSnapshotEntry> snapshotTree(
    const std::filesystem::path& root) {
    std::vector<ReferenceSnapshotEntry> entries;
    for (const auto& entry : std::filesystem::recursive_directory_iterator{root}) {
        const bool isDirectory = entry.is_directory();
        if (!isDirectory && !entry.is_regular_file()) {
            throw std::runtime_error{"Unexpected reference-tree entry type."};
        }
        entries.push_back({
            std::filesystem::relative(entry.path(), root),
            isDirectory,
            isDirectory ? std::string{} : readBytes(entry.path()),
            entry.last_write_time()});
    }
    std::sort(
        entries.begin(),
        entries.end(),
        [](const ReferenceSnapshotEntry& left,
           const ReferenceSnapshotEntry& right) {
            return left.relativePath.generic_string() <
                   right.relativePath.generic_string();
        });
    return entries;
}

void expectTreeUnchanged(
    const std::vector<ReferenceSnapshotEntry>& before,
    const std::vector<ReferenceSnapshotEntry>& after) {
    ASSERT_EQ(after.size(), before.size());
    for (std::size_t index = 0U; index < before.size(); ++index) {
        EXPECT_EQ(after[index].relativePath, before[index].relativePath);
        EXPECT_EQ(after[index].isDirectory, before[index].isDirectory);
        EXPECT_EQ(after[index].bytes, before[index].bytes)
            << before[index].relativePath.string();
        EXPECT_EQ(after[index].lastWriteTime, before[index].lastWriteTime)
            << before[index].relativePath.string();
    }
}

double norm(const std::array<double, 3>& value) {
    return std::sqrt(
        value[0U] * value[0U] + value[1U] * value[1U] +
        value[2U] * value[2U]);
}

std::array<double, 3> sum(
    const std::array<double, 3>& left,
    const std::array<double, 3>& right) {
    return {
        left[0U] + right[0U],
        left[1U] + right[1U],
        left[2U] + right[2U]};
}

std::size_t stressRowCount(const std::filesystem::path& results) {
    const hid_t fileId =
        H5Fopen(results.string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT);
    if (fileId < 0) {
        throw std::runtime_error{"Unable to open authoritative HDF5 output."};
    }
    const Hdf5Handle file{fileId, H5Fclose};
    if (H5Lexists(file.get(), kStressPath, H5P_DEFAULT) <= 0) {
        throw std::runtime_error{"Mandatory stress_s11 dataset is missing."};
    }
    const hid_t datasetId = H5Dopen2(file.get(), kStressPath, H5P_DEFAULT);
    if (datasetId < 0) {
        throw std::runtime_error{"Unable to open mandatory stress_s11 dataset."};
    }
    const Hdf5Handle dataset{datasetId, H5Dclose};
    const hid_t spaceId = H5Dget_space(dataset.get());
    if (spaceId < 0) {
        throw std::runtime_error{"Unable to inspect stress_s11 dataspace."};
    }
    const Hdf5Handle space{spaceId, H5Sclose};
    if (H5Sget_simple_extent_ndims(space.get()) != 1) {
        throw std::runtime_error{"stress_s11 must be a flat row dataset."};
    }
    hsize_t count = 0U;
    if (H5Sget_simple_extent_dims(space.get(), &count, nullptr) < 0) {
        throw std::runtime_error{"Unable to read stress_s11 extent."};
    }
    return static_cast<std::size_t>(count);
}

} // namespace

TEST(B33ReferenceComparison,
     GeneratesAuthoritativeHdf5AndComparisonEvidence) {
    const std::filesystem::path sourceRoot{FESA_TEST_SOURCE_DIR};
    const std::filesystem::path binaryRoot{FESA_TEST_BINARY_DIR};
    const auto referenceDirectory =
        sourceRoot / "reference" / "cantilever beam";
    const auto input = referenceDirectory / "cantilever beam.inp";
    const auto outputDirectory =
        binaryRoot / "reference" / "cantilever-beam-b33";
    const auto results = outputDirectory / "results.h5";
    const auto comparison = outputDirectory / "comparison.json";

    // Only the exact build-local evidence directory is reset; the approved
    // reference tree is snapshotted and subsequently opened read-only.
    std::error_code error;
    std::filesystem::remove_all(outputDirectory, error);
    error.clear();
    ASSERT_TRUE(std::filesystem::create_directories(outputDirectory, error));
    ASSERT_FALSE(error);
    const auto referenceBefore = snapshotTree(referenceDirectory);
    ASSERT_EQ(referenceBefore.size(), 4U);
    EXPECT_EQ(
        referenceBefore[0U].relativePath,
        std::filesystem::path{"cantilever beam displacements.csv"});
    EXPECT_EQ(
        referenceBefore[1U].relativePath,
        std::filesystem::path{"cantilever beam elemental forces.csv"});
    EXPECT_EQ(
        referenceBefore[2U].relativePath,
        std::filesystem::path{"cantilever beam reactions.csv"});
    EXPECT_EQ(
        referenceBefore[3U].relativePath,
        std::filesystem::path{"cantilever beam.inp"});

    fesa::FesaApplication application;
    // FesaApplication receives application operands/options; main strips argv[0].
    ASSERT_EQ(
        application.run(
            {input.string(), "--output", results.string()}),
        0);
    ASSERT_TRUE(std::filesystem::is_regular_file(results));
    ASSERT_GT(H5Fis_hdf5(results.string().c_str()), 0);
    EXPECT_GT(stressRowCount(results), 0U);

    auto comparisonResult = fesa::test::ReferenceComparison::compare(
        results, referenceDirectory);
    ASSERT_TRUE(comparisonResult.hasValue());
    const auto& report = comparisonResult.value();
    ASSERT_TRUE(report.passed);
    ASSERT_EQ(report.rows.size(), kExpectedRowCount);
    ASSERT_EQ(report.metrics.size(), kExpectedMetricCount);
    EXPECT_TRUE(std::all_of(
        report.rows.begin(),
        report.rows.end(),
        [](const fesa::test::RowDecision& row) {
            return row.passed && std::isfinite(row.absoluteError) &&
                   std::isfinite(row.tolerance) && row.tolerance > 0.0 &&
                   row.fesa.modelId == "cantilever-beam-b33" &&
                   row.reference.modelId == "cantilever-beam-b33" &&
                   row.fesa.stepName == "Step-1" &&
                   row.reference.stepName == "Step-1" &&
                   row.fesa.frameIndex == 0U &&
                   row.reference.frameIndex == 0U &&
                   row.fesa.instanceName == "PART-1_1-1" &&
                   row.reference.instanceName == "PART-1_1-1" &&
                   !row.fesa.hdf5DatasetPath.empty();
        }));
    EXPECT_TRUE(std::all_of(
        report.metrics.begin(),
        report.metrics.end(),
        [](const fesa::test::ComponentMetrics& metric) {
            return std::isfinite(metric.referenceScale) &&
                   std::isfinite(metric.maximumAbsoluteError) &&
                   std::isfinite(metric.maximumNormalizedError) &&
                   std::isfinite(metric.rmsError) &&
                   std::isfinite(metric.normError) &&
                   metric.maximumNormalizedError <= 1.0;
        }));

    EXPECT_FALSE(report.stressComparisonApplicable);
    EXPECT_NE(report.stressComparisonReason.find("N/A"), std::string::npos);
    EXPECT_NE(report.stressComparisonReason.find("HDF5"), std::string::npos);
    EXPECT_TRUE(report.physicsEvidence.endpointConsistencyPassed);
    EXPECT_TRUE(std::isfinite(report.physicsEvidence.freeResidualNorm));
    EXPECT_LE(report.physicsEvidence.freeResidualNorm, 1.0e-3);
    EXPECT_LE(
        norm(sum(
            report.physicsEvidence.appliedForce,
            report.physicsEvidence.reactionForce)),
        1.0e-3);
    EXPECT_LE(
        norm(sum(
            report.physicsEvidence.appliedMomentAboutOrigin,
            report.physicsEvidence.reactionMomentAboutOrigin)),
        1.0e-2);

    ASSERT_TRUE(
        fesa::test::ReferenceComparison::writeDeterministicJson(
            report, comparison)
            .isOk());
    ASSERT_TRUE(std::filesystem::is_regular_file(comparison));
    const std::string json = readBytes(comparison);
    EXPECT_NE(json.find("\"stress_comparison_applicable\":false"),
              std::string::npos);
    EXPECT_NE(json.find("\"physics_evidence\""), std::string::npos);

    std::vector<std::string> generatedNames;
    for (const auto& entry :
         std::filesystem::directory_iterator{outputDirectory}) {
        generatedNames.push_back(entry.path().filename().string());
    }
    std::sort(generatedNames.begin(), generatedNames.end());
    EXPECT_EQ(
        generatedNames,
        (std::vector<std::string>{"comparison.json", "results.h5"}));

    expectTreeUnchanged(referenceBefore, snapshotTree(referenceDirectory));
}
