#include "fesa/app/fesa_application.hpp"

#include <gtest/gtest.h>
#include <hdf5.h>

#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

namespace {

constexpr const char* kStepRoot = "/steps/Step-1/frames/0";

class TempDirectory {
public:
    explicit TempDirectory(const std::string& label) {
        static std::atomic<unsigned long long> sequence{0U};
        const auto tick = std::chrono::steady_clock::now()
                              .time_since_epoch()
                              .count();
        path_ = std::filesystem::temp_directory_path() /
            ("fesa-step24-app-" + label + "-" +
             std::to_string(tick) + "-" +
             std::to_string(sequence.fetch_add(1U)));
        std::error_code error;
        if (!std::filesystem::create_directory(path_, error) || error) {
            throw std::runtime_error{"Unable to create the Step 24 app fixture."};
        }
    }

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

    ~TempDirectory() {
        std::error_code ignored;
        std::filesystem::remove_all(path_, ignored);
    }

    const std::filesystem::path& path() const noexcept { return path_; }

private:
    std::filesystem::path path_;
};

class CurrentDirectoryGuard {
public:
    explicit CurrentDirectoryGuard(const std::filesystem::path& replacement)
        : original_{std::filesystem::current_path()} {
        std::filesystem::current_path(replacement);
    }

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

    ~CurrentDirectoryGuard() {
        std::error_code ignored;
        std::filesystem::current_path(original_, ignored);
    }

private:
    std::filesystem::path original_;
};

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(Hdf5Handle&& other) noexcept
        : value_{other.value_}, closer_{other.closer_} {
        other.value_ = -1;
        other.closer_ = nullptr;
    }
    ~Hdf5Handle() {
        if (value_ >= 0 && closer_ != nullptr) {
            (void)closer_(value_);
        }
    }

    hid_t get() const noexcept { return value_; }

private:
    hid_t value_{-1};
    Closer closer_{nullptr};
};

void writeText(const std::filesystem::path& path, const std::string& text) {
    std::ofstream stream{path, std::ios::binary | std::ios::trunc};
    stream.write(text.data(), static_cast<std::streamsize>(text.size()));
    if (!stream) {
        throw std::runtime_error{"Unable to write the Step 24 app input."};
    }
}

std::string axialDeck(
    const bool constrained,
    const bool zeroLength,
    const bool outputRequests) {
    const std::string secondNode = zeroLength
        ? "2, 0., 0., 0.\n"
        : "2, 2., 0., 0.\n";
    const std::string boundaries = constrained
        ? "*Boundary\nRoot, 1, 6\nTip, 2, 6\n"
        : "";
    const std::string outputs = outputRequests
        ? R"inp(*Output, field
*Node Output
U, RF
*Element Output, directions=YES
S, SF
*Output, history
*Contact Output
)inp"
        : "";

    return std::string{R"inp(*Part, name=BeamPart
*Node
1, 0., 0., 0.
)inp"} + secondNode + R"inp(*Element, type=B33
1, 1, 2
*Elset, elset=BeamSet
1
*Beam General Section, elset=BeamSet, material=Steel, section=GENERAL
2., 0.5, 0., 0.75, 0.25
0., 1., 0.
*End Part
*Assembly, name=Assembly
*Instance, name=Beam-1, part=BeamPart
*End Instance
*Nset, nset=Root, instance=Beam-1
1
*Nset, nset=Tip, instance=Beam-1
2
*End Assembly
*Material, name=Steel
*Elastic
100., 0.25
)inp" + boundaries + R"inp(*Step, name=Load, nlgeom=NO
*Static
0.1, 1., 0.01, 1.
*Cload
Tip, 1, 10.
)inp" + outputs + R"inp(*End Step
)inp";
}

std::string allConstrainedShellDeck() {
    return R"inp(*Part, name=ShellPart
*Node
1, 0., 0., 0.
2, 1., 0., 0.
3, 1., 1., 0.
4, 0., 1., 0.
*Element, type=S4
1, 1, 2, 3, 4
*Elset, elset=ShellSet
1
*Shell Section, elset=ShellSet, material=Steel
0.1
*End Part
*Assembly, name=Assembly
*Instance, name=Shell-1, part=ShellPart
*End Instance
*Nset, nset=All, instance=Shell-1
1, 2, 3, 4
*End Assembly
*Material, name=Steel
*Elastic
1000., 0.25
*Boundary
All, 1, 6
*Step, name=Load, nlgeom=NO
*Static
0.1, 1., 0.01, 1.
*End Step
)inp";
}

Hdf5Handle openFile(const std::filesystem::path& path) {
    const hid_t file = H5Fopen(
        path.string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT);
    if (file < 0) {
        throw std::runtime_error{"Unable to open the CLI HDF5 artifact."};
    }
    return Hdf5Handle{file, H5Fclose};
}

Hdf5Handle openDataset(const hid_t file, const std::string& path) {
    const hid_t dataset = H5Dopen2(file, path.c_str(), H5P_DEFAULT);
    if (dataset < 0) {
        throw std::runtime_error{"Unable to open mandatory dataset: " + path};
    }
    return Hdf5Handle{dataset, H5Dclose};
}

std::vector<hsize_t> datasetDimensions(
    const hid_t file, const std::string& path) {
    const auto dataset = openDataset(file, path);
    Hdf5Handle space{H5Dget_space(dataset.get()), H5Sclose};
    const int rank = H5Sget_simple_extent_ndims(space.get());
    if (space.get() < 0 || rank < 0) {
        throw std::runtime_error{"Unable to inspect mandatory dataset dimensions."};
    }
    std::vector<hsize_t> dimensions(static_cast<std::size_t>(rank));
    if (rank > 0 &&
        H5Sget_simple_extent_dims(space.get(), dimensions.data(), nullptr) < 0) {
        throw std::runtime_error{"Unable to read mandatory dataset dimensions."};
    }
    return dimensions;
}

std::vector<double> readDoubleDataset(
    const hid_t file, const std::string& path) {
    const auto dimensions = datasetDimensions(file, path);
    std::size_t count = 1U;
    for (const hsize_t dimension : dimensions) {
        count *= static_cast<std::size_t>(dimension);
    }
    const auto dataset = openDataset(file, path);
    std::vector<double> values(count);
    if (!values.empty() &&
        H5Dread(dataset.get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
                H5P_DEFAULT, values.data()) < 0) {
        throw std::runtime_error{"Unable to read mandatory numeric results."};
    }
    return values;
}

std::string readStringAttribute(const hid_t object, const char* name) {
    Hdf5Handle attribute{H5Aopen(object, name, H5P_DEFAULT), H5Aclose};
    Hdf5Handle type{H5Aget_type(attribute.get()), H5Tclose};
    if (attribute.get() < 0 || type.get() < 0 ||
        H5Tget_class(type.get()) != H5T_STRING ||
        H5Tis_variable_str(type.get()) <= 0) {
        throw std::runtime_error{"Expected a variable-length string attribute."};
    }
    char* raw = nullptr;
    if (H5Aread(attribute.get(), type.get(), &raw) < 0 || raw == nullptr) {
        throw std::runtime_error{"Unable to read the HDF5 identity attribute."};
    }
    const std::string value{raw};
    (void)H5free_memory(raw);
    return value;
}

void expectMandatoryInventory(const hid_t file) {
    for (const char* path : {
             "/metadata",
             "/model/nodes",
             "/model/elements",
             "/steps/Step-1/frames/0/nodal/displacement",
             "/steps/Step-1/frames/0/nodal/reaction",
             "/steps/Step-1/frames/0/element/end_force_local",
             "/steps/Step-1/frames/0/element/section_resultant",
             "/steps/Step-1/frames/0/element/generalized_strain",
             "/steps/Step-1/frames/0/element/generalized_resultant",
             "/steps/Step-1/frames/0/element/stress_s11",
             "/diagnostics"}) {
        EXPECT_GT(H5Lexists(file, path, H5P_DEFAULT), 0) << path;
    }
}

void expectFesaHdf5Identity(
    const std::filesystem::path& output,
    const std::filesystem::path& input) {
    ASSERT_TRUE(std::filesystem::exists(output));
    ASSERT_GT(H5Fis_hdf5(output.string().c_str()), 0);
    const auto file = openFile(output);
    expectMandatoryInventory(file.get());
    Hdf5Handle metadata{
        H5Gopen2(file.get(), "/metadata", H5P_DEFAULT), H5Gclose};
    ASSERT_GE(metadata.get(), 0);
    EXPECT_EQ(
        readStringAttribute(metadata.get(), "feature_id"),
        "linear-static-3d-euler-beam");
    const std::string normalizedInput =
        std::filesystem::absolute(input).lexically_normal().generic_u8string();
    EXPECT_EQ(
        readStringAttribute(metadata.get(), "source_input_identity").find(
            "path=" + normalizedInput + ";content_identity="),
        0U);
}

void expectShellHdf5Identity(
    const std::filesystem::path& output,
    const std::filesystem::path& input) {
    ASSERT_TRUE(std::filesystem::exists(output));
    ASSERT_GT(H5Fis_hdf5(output.string().c_str()), 0);
    const auto file = openFile(output);
    Hdf5Handle metadata{
        H5Gopen2(file.get(), "/metadata", H5P_DEFAULT), H5Gclose};
    ASSERT_GE(metadata.get(), 0);
    EXPECT_EQ(
        readStringAttribute(metadata.get(), "feature_id"),
        "linear-static-mitc4-shell");
    EXPECT_GT(
        H5Lexists(
            file.get(),
            "/steps/Step-1/frames/0/element/shell/generalized_strain",
            H5P_DEFAULT),
        0);
    EXPECT_EQ(
        datasetDimensions(
            file.get(),
            "/steps/Step-1/frames/0/nodal/displacement"),
        (std::vector<hsize_t>{4U, 6U}));
    const std::string normalizedInput =
        std::filesystem::absolute(input).lexically_normal().generic_u8string();
    EXPECT_EQ(
        readStringAttribute(metadata.get(), "source_input_identity").find(
            "path=" + normalizedInput + ";content_identity="),
        0U);
}

struct AppRun {
    int exitCode;
    std::string standardError;
};

AppRun runApplication(const std::vector<std::string>& arguments) {
    testing::internal::CaptureStderr();
    try {
        const int exitCode = fesa::FesaApplication{}.run(arguments);
        return {exitCode, testing::internal::GetCapturedStderr()};
    } catch (...) {
        (void)testing::internal::GetCapturedStderr();
        throw;
    }
}

void expectDiagnosticFieldOrder(const std::string& text) {
    ASSERT_FALSE(text.empty());
    std::size_t cursor = 0U;
    for (const char* field : {
             "severity", "code", "file", "line", "keyword",
             "entity_identity", "message"}) {
        const auto position = text.find(field, cursor);
        ASSERT_NE(position, std::string::npos)
            << "Missing or out-of-order diagnostic field: " << field
            << "\nstderr:\n" << text;
        cursor = position + std::string{field}.size();
    }
}

std::vector<std::string> explicitOutputArguments(
    const std::filesystem::path& input,
    const std::filesystem::path& output) {
    // FesaApplication receives argv[0]-excluded operands and options.
    return {input.string(), "--output", output.string()};
}

} // namespace

TEST(LinearStaticCli, DefaultAndExplicitOutputProduceFesaHdf5) {
    TempDirectory directory{"paths"};
    const auto input = directory.path() / "model.inp";
    writeText(input, axialDeck(true, false, false));

    const auto defaultOutput = directory.path() / "results.h5";
    {
        CurrentDirectoryGuard currentDirectory{directory.path()};
        const auto result = runApplication({input.string()});
        ASSERT_EQ(result.exitCode, 0) << result.standardError;
    }
    expectFesaHdf5Identity(defaultOutput, input);

    const auto explicitOutput = directory.path() / "named-output.h5";
    const auto result = runApplication(
        explicitOutputArguments(input, explicitOutput));
    ASSERT_EQ(result.exitCode, 0) << result.standardError;
    expectFesaHdf5Identity(explicitOutput, input);
}

TEST(LinearStaticCli, ReturnsEveryExactExitCodeAndOrderedDiagnostic) {
    TempDirectory directory{"exit-codes"};
    const auto validInput = directory.path() / "valid.inp";
    const auto modelInput = directory.path() / "invalid-model.inp";
    const auto solverInput = directory.path() / "singular.inp";
    writeText(validInput, axialDeck(true, false, false));
    writeText(modelInput, axialDeck(true, true, false));
    writeText(solverInput, axialDeck(false, false, false));

    const auto success = runApplication(explicitOutputArguments(
        validInput, directory.path() / "success.h5"));
    const auto usage = runApplication({});
    const auto missingInput = directory.path() / "missing.inp";
    const auto input = runApplication({missingInput.string()});
    const auto repeatedOutput = runApplication(
        {missingInput.string(), "--output", "--output"});
    const auto unknownOutputOption = runApplication(
        {missingInput.string(), "--output", "--bogus"});
    const auto model = runApplication(explicitOutputArguments(
        modelInput, directory.path() / "model-failure.h5"));
    const auto solver = runApplication(explicitOutputArguments(
        solverInput, directory.path() / "solver-failure.h5"));
    const auto output = runApplication(explicitOutputArguments(
        validInput,
        directory.path() / "nonexistent-parent" / "results.h5"));

    EXPECT_EQ(success.exitCode, 0) << success.standardError;
    EXPECT_EQ(usage.exitCode, 2);
    EXPECT_EQ(input.exitCode, 3);
    EXPECT_EQ(model.exitCode, 4);
    EXPECT_EQ(solver.exitCode, 5);
    EXPECT_EQ(output.exitCode, 6);

    expectDiagnosticFieldOrder(usage.standardError);
    expectDiagnosticFieldOrder(input.standardError);
    expectDiagnosticFieldOrder(model.standardError);
    expectDiagnosticFieldOrder(solver.standardError);
    expectDiagnosticFieldOrder(output.standardError);
    EXPECT_EQ(repeatedOutput.exitCode, 2);
    expectDiagnosticFieldOrder(repeatedOutput.standardError);
    EXPECT_NE(repeatedOutput.standardError.find("code=cli-usage"),
              std::string::npos);
    EXPECT_EQ(unknownOutputOption.exitCode, 2);
    expectDiagnosticFieldOrder(unknownOutputOption.standardError);
    EXPECT_NE(unknownOutputOption.standardError.find("code=cli-usage"),
              std::string::npos);
}

TEST(LinearStaticCli, OutputRequestsDoNotFilterMandatoryResults) {
    TempDirectory directory{"output-requests"};
    const auto plainInput = directory.path() / "plain.inp";
    const auto requestedInput = directory.path() / "requested.inp";
    const auto plainOutput = directory.path() / "plain.h5";
    const auto requestedOutput = directory.path() / "requested.h5";
    writeText(plainInput, axialDeck(true, false, false));
    writeText(requestedInput, axialDeck(true, false, true));

    const auto plain = runApplication(
        explicitOutputArguments(plainInput, plainOutput));
    const auto requested = runApplication(
        explicitOutputArguments(requestedInput, requestedOutput));
    ASSERT_EQ(plain.exitCode, 0) << plain.standardError;
    ASSERT_EQ(requested.exitCode, 0) << requested.standardError;

    const auto plainFile = openFile(plainOutput);
    const auto requestedFile = openFile(requestedOutput);
    expectMandatoryInventory(plainFile.get());
    expectMandatoryInventory(requestedFile.get());

    for (const char* suffix : {
             "/nodal/displacement",
             "/nodal/reaction",
             "/element/end_force_local",
             "/element/section_resultant",
             "/element/generalized_strain",
             "/element/generalized_resultant"}) {
        const std::string path = std::string{kStepRoot} + suffix;
        EXPECT_EQ(
            readDoubleDataset(requestedFile.get(), path),
            readDoubleDataset(plainFile.get(), path))
            << path;
    }
    EXPECT_EQ(datasetDimensions(plainFile.get(), "/diagnostics"),
              std::vector<hsize_t>({0U}));
    const auto requestedDiagnostics =
        datasetDimensions(requestedFile.get(), "/diagnostics");
    ASSERT_EQ(requestedDiagnostics.size(), 1U);
    EXPECT_GT(requestedDiagnostics[0U], 0U);
}

// MITC4-FLOW-001: shell input uses the unchanged application route and syntax.
TEST(Mitc4ShellCli, WritesShellHdf5ThroughTheExistingApplicationRoute) {
    TempDirectory directory{"shell-route"};
    const auto input = directory.path() / "shell.inp";
    const auto output = directory.path() / "shell-results.h5";
    writeText(input, allConstrainedShellDeck());

    const auto result = runApplication(explicitOutputArguments(input, output));
    ASSERT_EQ(result.exitCode, 0) << result.standardError;
    expectShellHdf5Identity(output, input);
}
