#include "fesa/io/abaqus/domain_mapper.hpp"
#include "fesa/io/abaqus/input_reader.hpp"

#include <gtest/gtest.h>

#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

namespace {

class TemporaryInputFile {
public:
    TemporaryInputFile(const std::string& stem, const std::string& content)
        : path_{std::filesystem::temp_directory_path() /
                ("fesa-domain-mapper-" + stem + ".inp")} {
        std::ofstream stream{path_, std::ios::binary | std::ios::trunc};
        stream.write(content.data(), static_cast<std::streamsize>(content.size()));
        if (!stream) {
            throw std::runtime_error{"Unable to create domain mapper fixture."};
        }
    }

    ~TemporaryInputFile() {
        std::error_code error;
        std::filesystem::remove(path_, error);
    }

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

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

fesa::Result<fesa::Domain> mapText(
    const std::string& stem,
    const std::string& content) {
    const TemporaryInputFile input{stem, content};
    auto parsed = fesa::AbaqusInputReader{}.read(input.path());
    if (!parsed.hasValue()) {
        return fesa::Result<fesa::Domain>::failure(parsed.status());
    }
    return fesa::AbaqusDomainMapper{}.map(parsed.value());
}

std::string readExactBytes(const std::filesystem::path& path) {
    std::ifstream stream{path, std::ios::binary};
    if (!stream) {
        throw std::runtime_error{"Unable to read legacy mapper fixture."};
    }
    return std::string{
        std::istreambuf_iterator<char>{stream},
        std::istreambuf_iterator<char>{}};
}

std::filesystem::path repositoryRoot() {
    auto path = std::filesystem::path{__FILE__}.parent_path();
    for (int parent = 0; parent < 4; ++parent) {
        path = path.parent_path();
    }
    return path;
}

const fesa::Diagnostic* findDiagnostic(
    const fesa::Status& status,
    const std::string& code) {
    const auto found = std::find_if(
        status.diagnostics().begin(),
        status.diagnostics().end(),
        [&code](const fesa::Diagnostic& diagnostic) {
            return diagnostic.code == code;
        });
    return found == status.diagnostics().end() ? nullptr : &*found;
}

std::string replaceOnce(
    std::string text,
    const std::string& from,
    const std::string& to) {
    const auto position = text.find(from);
    if (position == std::string::npos) {
        throw std::logic_error{"Mapper test mutation source was not found."};
    }
    text.replace(position, from.size(), to);
    return text;
}

std::string minimalDeck() {
    return R"inp(*Part, name=BeamPart
*Node
1, 0., 0., 0.
2, 1., 0., 0.
*Element, type=B33
1, 1, 2
*Elset, elset=BeamSet
1
*Beam General Section, elset=BeamSet, material=Steel, section=GENERAL
1., 1., 0., 1., 1.
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
*Boundary
Root, 1, 6
*Step, name=Load, nlgeom=NO
*Static
0.1, 1., 0.01, 1.
*Cload
Tip, 2, -1.
*End Step
)inp";
}

std::string shellDeck() {
    return R"inp(*Part, name=ShellPart
*Node
1, 0., 0., 0.
2, 1., 0., 0.
3, 1., 1., 0.
4, 0., 1., 0.
5, 2., 0., 0.
6, 2., 1., 0.
*Element, type=S4
0010, 1, 2, 3, 4
*Element, type=S4R
0020, 2, 5, 6, 3
*Elset, elset=ShellS4
10
*Elset, elset=ShellS4R
20
*Shell Section, elset=ShellS4, material=Steel
0.1, 5
*Shell Section, elset=ShellS4R, material=Aluminum
0.2
*End Part
*Assembly, name=Assembly
*Instance, name=First, part=ShellPart
*End Instance
*Instance, name=Second, part=ShellPart
*End Instance
*Nset, nset=RootFirst, instance=First
1
*Nset, nset=TipSecond, instance=Second
6
*End Assembly
*Material, name=Steel
*Elastic
210000., 0.3
*Material, name=Aluminum
*Elastic
70000., 0.25
*Boundary
RootFirst, 1, 6
*Step, name=Load, nlgeom=NO
*Static
0.1, 1., 0.01, 1.
*Cload
TipSecond, 6, 1.
*End Step
)inp";
}

std::string supportedInventoryDeck(bool includeNoOps) {
    const std::string preprint = includeNoOps
        ? "*Preprint, echo=NO, model=NO, history=NO, contact=NO\n"
        : "";
    const std::string shear = includeNoOps
        ? "*Transverse Shear Stiffness\n1., 2., 3.\n"
        : "";
    const std::string outputs = includeNoOps
        ? R"inp(*Restart, write, frequency=0
*Output, field
*Node Output
U, RF
*Element Output, directions=YES
S, SF
*Contact Output, variable=PRESELECT
*Output, history, variable=PRESELECT
)inp"
        : "";

    return std::string{R"inp(*hEaDiNg
Every supported keyword
)inp"} + preprint + R"inp(*pArT, NaMe=BeamPart
*nOdE
0001, 0., 0., 0.
0002, 2., 0., 0.
*eLeMeNt, TyPe=b33
0007, 0001, 0002
*nSeT, NsEt=RootLocal
0001
*Nset, nset=AllLocal, generate
1, 2, 1
*Elset, elset=BeamExplicit
0007
*eLsEt, ElSeT=BeamAll, GeNeRaTe
7, 7, 1
*Beam General Section, ELSET=beamall, MATERIAL=steel, SECTION=general
2., 3., 0., 4., 5.
0., 1., 0.
*Section Points
-0.5, 0.25
0.5, -0.25
)inp" + shear + R"inp(*eNd PaRt
*aSsEmBlY, name=Assembly
*iNsTaNcE, NAME=Beam-1, PART=beampart
*eNd InStAnCe
*Nset, nset=RootAssembly, instance=beam-1
0001
*Elset, elset=BeamAssembly, instance=BEAM-1
0007
*eNd AsSeMbLy
*mAtErIaL, NaMe=Steel
*eLaStIc
210000., 0.75
*bOuNdArY
rootassembly, 1, 1, 0.125
*sTeP, name=User-Spelling, nLgEoM=no
*sTaTiC
0.25, 1.5, 0.01, 1.5
*Boundary
RootAssembly, 2, 2
*cLoAd
RootAssembly, 6, -12.5
)inp" + outputs + "*eNd StEp\n";
}

} // namespace

TEST(InpDomainMapping, MapsEverySupportedKeywordAndLegacyDeck) {
    auto result = mapText("supported-inventory", supportedInventoryDeck(true));
    ASSERT_TRUE(result.hasValue());
    const fesa::Domain& domain = result.value();

    ASSERT_EQ(domain.nodes().size(), 2U);
    EXPECT_EQ(domain.nodes()[0].sourceId.instanceName, "Beam-1");
    EXPECT_EQ(domain.nodes()[0].sourceId.sourceLabel, 1);
    EXPECT_EQ(domain.nodes()[0].sourceId.sourceLabelText, "0001");
    EXPECT_EQ(domain.nodes()[1].sourceId.sourceLabelText, "0002");
    EXPECT_DOUBLE_EQ(domain.nodes()[1].coordinates[0], 2.0);

    ASSERT_EQ(domain.elements().size(), 1U);
    EXPECT_EQ(domain.elements()[0].sourceId.sourceLabelText, "0007");
    EXPECT_EQ(domain.elements()[0].nodeIndices[0], 0U);
    EXPECT_EQ(domain.elements()[0].nodeIndices[1], 1U);

    ASSERT_EQ(domain.materials().size(), 1U);
    EXPECT_EQ(domain.materials()[0].name, "Steel");
    EXPECT_DOUBLE_EQ(domain.materials()[0].youngsModulus, 210000.0);
    EXPECT_DOUBLE_EQ(domain.materials()[0].poissonRatio, 0.75);

    ASSERT_EQ(domain.sections().size(), 1U);
    const auto& section = domain.sections()[0];
    EXPECT_DOUBLE_EQ(section.area, 2.0);
    EXPECT_DOUBLE_EQ(section.i11, 3.0);
    EXPECT_DOUBLE_EQ(section.i12, 0.0);
    EXPECT_DOUBLE_EQ(section.i22, 4.0);
    EXPECT_DOUBLE_EQ(section.torsionalConstant, 5.0);
    EXPECT_EQ(section.firstAxis, (std::array<double, 3>{0.0, 1.0, 0.0}));
    EXPECT_EQ(
        section.sectionPoints,
        (std::vector<std::array<double, 2>>{{-0.5, 0.25}, {0.5, -0.25}}));
    EXPECT_EQ(domain.elements()[0].materialIndex, 0U);
    EXPECT_EQ(domain.elements()[0].sectionIndex, 0U);

    ASSERT_EQ(domain.steps().size(), 1U);
    const auto& step = domain.steps()[0];
    EXPECT_EQ(step.name, "Step-1");
    EXPECT_DOUBLE_EQ(step.initialIncrement, 0.25);
    EXPECT_DOUBLE_EQ(step.timePeriod, 1.5);
    EXPECT_DOUBLE_EQ(step.minimumIncrement, 0.01);
    EXPECT_DOUBLE_EQ(step.maximumIncrement, 1.5);
    ASSERT_EQ(step.boundaries.size(), 2U);
    EXPECT_EQ(step.boundaries[0].target, "rootassembly");
    EXPECT_EQ(step.boundaries[0].firstDof, 1);
    EXPECT_DOUBLE_EQ(step.boundaries[0].value, 0.125);
    EXPECT_EQ(step.boundaries[1].lastDof, 2);
    EXPECT_DOUBLE_EQ(step.boundaries[1].value, 0.0);
    ASSERT_EQ(step.loads.size(), 1U);
    EXPECT_EQ(step.loads[0].target, "RootAssembly");
    EXPECT_EQ(step.loads[0].dof, 6);
    EXPECT_DOUBLE_EQ(step.loads[0].magnitude, -12.5);
    EXPECT_EQ(domain.warnings().size(), 8U);

    const auto legacyPath = repositoryRoot() / "reference" /
        "cantilever beam" / "cantilever beam.inp";
    const auto bytesBefore = readExactBytes(legacyPath);
    const auto timestampBefore = std::filesystem::last_write_time(legacyPath);
    auto parsedLegacy = fesa::AbaqusInputReader{}.read(legacyPath);
    ASSERT_TRUE(parsedLegacy.hasValue());
    auto legacy = fesa::AbaqusDomainMapper{}.map(parsedLegacy.value());
    ASSERT_TRUE(legacy.hasValue());
    EXPECT_EQ(legacy.value().nodes().size(), 11U);
    EXPECT_EQ(legacy.value().elements().size(), 10U);
    EXPECT_EQ(legacy.value().materials().size(), 1U);
    EXPECT_EQ(legacy.value().sections().size(), 1U);
    EXPECT_EQ(legacy.value().steps().size(), 1U);
    EXPECT_EQ(legacy.value().warnings().size(), 7U);
    EXPECT_EQ(readExactBytes(legacyPath), bytesBefore);
    EXPECT_EQ(std::filesystem::last_write_time(legacyPath), timestampBefore);
}

TEST(InpDomainMapping, ExpandsSetsAndMultipleIdentityInstancesDeterministically) {
    const std::string deck = R"inp(*Part, name=P
*Node
10, 0., 0., 0.
20, 1., 0., 0.
*Element, type=B33
30, 10, 20
*Nset, nset=Ends, generate
10, 20, 10
*Elset, elset=AllElements
30
*Beam General Section, elset=AllElements, material=M, section=GENERAL
1., 2., 0., 3., 4.
0., 1., 0.
*End Part
*Assembly, name=A
*Instance, name=First, part=P
*End Instance
*Instance, name=Second, part=P
*End Instance
*Nset, nset=OnlySecond, instance=Second
20
*Elset, elset=OnlySecondBeam, instance=Second
30
*End Assembly
*Material, name=M
*Elastic
1000., 0.25
*Step
*Static
1., 1., 1., 1.
*Boundary
OnlySecond, 1, 1
*Cload
OnlySecond, 2, 5.
*End Step
)inp";

    auto result = mapText("multiple-instances", deck);
    ASSERT_TRUE(result.hasValue());
    const fesa::Domain& domain = result.value();

    ASSERT_EQ(domain.nodes().size(), 4U);
    EXPECT_EQ(domain.nodes()[0].sourceId.instanceName, "First");
    EXPECT_EQ(domain.nodes()[0].sourceId.sourceLabel, 10);
    EXPECT_EQ(domain.nodes()[1].sourceId.instanceName, "First");
    EXPECT_EQ(domain.nodes()[1].sourceId.sourceLabel, 20);
    EXPECT_EQ(domain.nodes()[2].sourceId.instanceName, "Second");
    EXPECT_EQ(domain.nodes()[2].sourceId.sourceLabel, 10);
    EXPECT_EQ(domain.nodes()[3].sourceId.instanceName, "Second");
    EXPECT_EQ(domain.nodes()[3].sourceId.sourceLabel, 20);

    ASSERT_EQ(domain.elements().size(), 2U);
    EXPECT_EQ(domain.elements()[0].sourceId.instanceName, "First");
    EXPECT_EQ(domain.elements()[0].nodeIndices,
              (std::array<fesa::EntityIndex, 2>{0U, 1U}));
    EXPECT_EQ(domain.elements()[1].sourceId.instanceName, "Second");
    EXPECT_EQ(domain.elements()[1].nodeIndices,
              (std::array<fesa::EntityIndex, 2>{2U, 3U}));

    ASSERT_EQ(domain.nodeSets().size(), 3U);
    EXPECT_EQ(domain.nodeSets()[0].name, "Ends");
    EXPECT_EQ(domain.nodeSets()[0].instanceName, std::optional<std::string>{"First"});
    EXPECT_EQ(domain.nodeSets()[0].nodeIndices,
              (std::vector<fesa::EntityIndex>{0U, 1U}));
    EXPECT_EQ(domain.nodeSets()[1].instanceName, std::optional<std::string>{"Second"});
    EXPECT_EQ(domain.nodeSets()[1].nodeIndices,
              (std::vector<fesa::EntityIndex>{2U, 3U}));
    EXPECT_EQ(domain.nodeSets()[2].name, "OnlySecond");
    EXPECT_EQ(domain.nodeSets()[2].nodeIndices,
              (std::vector<fesa::EntityIndex>{3U}));

    ASSERT_EQ(domain.elementSets().size(), 3U);
    EXPECT_EQ(domain.elementSets()[0].instanceName,
              std::optional<std::string>{"First"});
    EXPECT_EQ(domain.elementSets()[0].elementIndices,
              (std::vector<fesa::EntityIndex>{0U}));
    EXPECT_EQ(domain.elementSets()[1].instanceName,
              std::optional<std::string>{"Second"});
    EXPECT_EQ(domain.elementSets()[1].elementIndices,
              (std::vector<fesa::EntityIndex>{1U}));
    EXPECT_EQ(domain.elementSets()[2].name, "OnlySecondBeam");
    EXPECT_EQ(domain.elementSets()[2].elementIndices,
              (std::vector<fesa::EntityIndex>{1U}));

    ASSERT_EQ(domain.steps().size(), 1U);
    EXPECT_EQ(domain.steps()[0].boundaries[0].target, "OnlySecond");
    EXPECT_EQ(domain.steps()[0].loads[0].target, "OnlySecond");

    auto direct = mapText(
        "direct-node-labels",
        replaceOnce(
            replaceOnce(minimalDeck(), "Root, 1, 6", "1, 1, 6"),
            "Tip, 2, -1.",
            "2, 2, -1."));
    ASSERT_TRUE(direct.hasValue());
    EXPECT_EQ(direct.value().steps()[0].boundaries[0].target, "1");
    EXPECT_EQ(direct.value().steps()[0].loads[0].target, "2");

    auto aboveThresholds = mapText(
        "above-geometry-thresholds",
        replaceOnce(
            replaceOnce(minimalDeck(), "2, 1., 0., 0.", "2, 2e-12, 0., 0."),
            "0., 1., 0.",
            "1., 2e-12, 0."));
    ASSERT_TRUE(aboveThresholds.hasValue());

    auto largeFinite = mapText(
        "large-finite-geometry",
        replaceOnce(
            replaceOnce(
                replaceOnce(minimalDeck(), "1, 0., 0., 0.", "1, 1e308, 0., 0."),
                "2, 1., 0., 0.",
                "2, 1e308, 1e297, 0."),
            "0., 1., 0.",
            "1e308, 0., 0."));
    ASSERT_TRUE(largeFinite.hasValue());
}

TEST(InpDomainMapping, KeepsNodeAndElementSetNamesInSeparateNamespaces) {
    auto sharedName = mapText(
        "separate-part-set-namespaces",
        replaceOnce(
            shellDeck(),
            "*Elset, elset=ShellS4\n10",
            "*Nset, nset=ShellS4\n1, 2, 3, 4\n*Elset, elset=ShellS4\n10"));

    ASSERT_TRUE(sharedName.hasValue());
    const auto& domain = sharedName.value();
    EXPECT_TRUE(std::any_of(
        domain.nodeSets().begin(), domain.nodeSets().end(),
        [](const fesa::NodeSet& set) { return set.name == "ShellS4"; }));
    EXPECT_TRUE(std::any_of(
        domain.elementSets().begin(), domain.elementSets().end(),
        [](const fesa::ElementSet& set) { return set.name == "ShellS4"; }));

    auto duplicateNodeSet = mapText(
        "duplicate-part-node-set",
        replaceOnce(
            shellDeck(),
            "*Elset, elset=ShellS4\n10",
            "*Nset, nset=Shared\n1\n*Nset, nset=Shared\n2\n"
            "*Elset, elset=ShellS4\n10"));
    ASSERT_FALSE(duplicateNodeSet.hasValue());
    EXPECT_NE(findDiagnostic(duplicateNodeSet.status(), "duplicate-entity"), nullptr);

    auto duplicateElementSet = mapText(
        "duplicate-part-element-set",
        replaceOnce(
            shellDeck(),
            "*Elset, elset=ShellS4\n10",
            "*Elset, elset=Repeated\n10\n*Elset, elset=Repeated\n20\n"
            "*Elset, elset=ShellS4\n10"));
    ASSERT_FALSE(duplicateElementSet.hasValue());
    EXPECT_NE(findDiagnostic(duplicateElementSet.status(), "duplicate-entity"), nullptr);

    auto assemblySharedName = mapText(
        "separate-assembly-set-namespaces",
        replaceOnce(
            replaceOnce(
                minimalDeck(),
                "*Elset, elset=BeamSet\n1",
                "*Nset, nset=Root\n1, 2\n*Elset, elset=BeamSet\n1"),
            "*Nset, nset=Root, instance=Beam-1\n1",
            "*Nset, nset=Root, instance=Beam-1\n1\n"
            "*Elset, elset=Root, instance=Beam-1\n1"));
    ASSERT_TRUE(assemblySharedName.hasValue());
    const auto& assemblyDomain = assemblySharedName.value();
    const auto rootNodeSetCount = std::count_if(
        assemblyDomain.nodeSets().begin(), assemblyDomain.nodeSets().end(),
        [](const fesa::NodeSet& set) { return set.name == "Root"; });
    EXPECT_EQ(rootNodeSetCount, 1);
    const auto rootNodeSet = std::find_if(
        assemblyDomain.nodeSets().begin(), assemblyDomain.nodeSets().end(),
        [](const fesa::NodeSet& set) { return set.name == "Root"; });
    ASSERT_NE(rootNodeSet, assemblyDomain.nodeSets().end());
    EXPECT_EQ(rootNodeSet->nodeIndices, (std::vector<fesa::EntityIndex>{0U}));
    EXPECT_EQ(std::count_if(
        assemblyDomain.elementSets().begin(), assemblyDomain.elementSets().end(),
        [](const fesa::ElementSet& set) { return set.name == "Root"; }), 1);
}

TEST(InpDomainMapping, NoOpAllowlistWarnsWithoutSemanticEffect) {
    auto plain = mapText("without-no-ops", supportedInventoryDeck(false));
    auto withNoOps = mapText("with-no-ops", supportedInventoryDeck(true));
    ASSERT_TRUE(plain.hasValue());
    ASSERT_TRUE(withNoOps.hasValue());

    EXPECT_TRUE(plain.value().warnings().empty());
    ASSERT_EQ(withNoOps.value().warnings().size(), 8U);
    EXPECT_EQ(withNoOps.value().warnings()[0].code, "ignored-input-keyword");
    EXPECT_EQ(withNoOps.value().warnings()[0].keyword, "PREPRINT");
    EXPECT_EQ(withNoOps.value().warnings()[1].keyword,
              "TRANSVERSE SHEAR STIFFNESS");
    EXPECT_EQ(withNoOps.value().warnings()[2].keyword, "RESTART");
    EXPECT_EQ(withNoOps.value().warnings()[3].keyword, "OUTPUT");
    EXPECT_EQ(withNoOps.value().warnings()[4].keyword, "NODE OUTPUT");
    EXPECT_EQ(withNoOps.value().warnings()[5].keyword, "ELEMENT OUTPUT");
    EXPECT_EQ(withNoOps.value().warnings()[6].keyword, "CONTACT OUTPUT");
    EXPECT_EQ(withNoOps.value().warnings()[7].keyword, "OUTPUT");
    for (const auto& warning : withNoOps.value().warnings()) {
        EXPECT_EQ(warning.severity, fesa::Severity::warning);
    }

    EXPECT_EQ(withNoOps.value().nodes().size(), plain.value().nodes().size());
    EXPECT_EQ(withNoOps.value().elements().size(), plain.value().elements().size());
    EXPECT_EQ(withNoOps.value().materials().size(), plain.value().materials().size());
    EXPECT_EQ(withNoOps.value().sections().size(), plain.value().sections().size());
    EXPECT_EQ(withNoOps.value().nodeSets().size(), plain.value().nodeSets().size());
    EXPECT_EQ(withNoOps.value().elementSets().size(), plain.value().elementSets().size());
    EXPECT_EQ(withNoOps.value().steps().size(), plain.value().steps().size());
    EXPECT_EQ(withNoOps.value().steps()[0].boundaries.size(),
              plain.value().steps()[0].boundaries.size());
    EXPECT_EQ(withNoOps.value().steps()[0].loads.size(),
              plain.value().steps()[0].loads.size());
}

// MITC4-MAP-001
TEST(InpDomainMapping, MapsS4AndS4rThroughOneMitc4Identity) {
    auto result = mapText("mitc4-map-001", shellDeck());

    ASSERT_TRUE(result.hasValue());
    const fesa::Domain& domain = result.value();
    EXPECT_TRUE(domain.elements().empty());
    ASSERT_EQ(domain.shellElements().size(), 4U);
    EXPECT_EQ(domain.shellElements()[0].sourceId.instanceName, "First");
    EXPECT_EQ(domain.shellElements()[0].sourceId.sourceLabelText, "0010");
    EXPECT_EQ(
        domain.shellElements()[0].sourceType,
        fesa::ShellSourceElementType::s4);
    EXPECT_EQ(
        domain.shellElements()[0].nodeIndices,
        (std::array<fesa::EntityIndex, 4>{0U, 1U, 2U, 3U}));
    EXPECT_EQ(domain.shellElements()[0].sectionIndex, 0U);
    EXPECT_EQ(domain.shellElements()[0].materialIndex, 0U);

    EXPECT_EQ(domain.shellElements()[1].sourceId.instanceName, "First");
    EXPECT_EQ(
        domain.shellElements()[1].sourceType,
        fesa::ShellSourceElementType::s4r);
    EXPECT_EQ(
        domain.shellElements()[1].nodeIndices,
        (std::array<fesa::EntityIndex, 4>{1U, 4U, 5U, 2U}));
    EXPECT_EQ(domain.shellElements()[1].sectionIndex, 1U);
    EXPECT_EQ(domain.shellElements()[1].materialIndex, 1U);

    EXPECT_EQ(domain.shellElements()[2].sourceId.instanceName, "Second");
    EXPECT_EQ(
        domain.shellElements()[2].nodeIndices,
        (std::array<fesa::EntityIndex, 4>{6U, 7U, 8U, 9U}));
    EXPECT_EQ(domain.shellElements()[3].sourceId.instanceName, "Second");
    EXPECT_EQ(
        fesa::kMitc4InternalFormulation,
        std::string_view{"FESA-MITC4"});

    ASSERT_EQ(domain.shellSections().size(), 2U);
    EXPECT_EQ(domain.shellSections()[0].name, "ShellS4");
    EXPECT_DOUBLE_EQ(domain.shellSections()[0].thickness, 0.1);
    EXPECT_EQ(domain.shellSections()[0].materialIndex, 0U);
    EXPECT_EQ(domain.shellSections()[1].name, "ShellS4R");
    EXPECT_DOUBLE_EQ(domain.shellSections()[1].thickness, 0.2);
    EXPECT_EQ(domain.shellSections()[1].materialIndex, 1U);

    ASSERT_EQ(domain.shellNodeInitialFrames().size(), domain.nodes().size());
    EXPECT_EQ(domain.shellNodeInitialFrames()[0].nodeIndex, 0U);
    EXPECT_EQ(
        domain.shellNodeInitialFrames()[0].director,
        (std::array<double, 3>{0.0, 0.0, 1.0}));
    EXPECT_EQ(
        domain.shellNodeInitialFrames()[0].tangentA,
        (std::array<double, 3>{1.0, 0.0, 0.0}));
    EXPECT_EQ(
        domain.shellNodeInitialFrames()[0].tangentB,
        (std::array<double, 3>{0.0, 1.0, 0.0}));

    ASSERT_EQ(domain.steps().size(), 1U);
    ASSERT_EQ(domain.steps()[0].boundaries.size(), 1U);
    EXPECT_EQ(domain.steps()[0].boundaries[0].lastDof, 6);
    ASSERT_EQ(domain.steps()[0].loads.size(), 1U);
    EXPECT_EQ(domain.steps()[0].loads[0].dof, 6);
}

// MITC4-MAP-002
TEST(InpDomainMapping, RejectsInvalidShellAssignmentsAndProperties) {
    struct InvalidCase {
        std::string name;
        std::string deck;
        std::string expectedCode;
        fesa::FailureCategory category;
    };

    const std::string base = shellDeck();
    const std::vector<InvalidCase> cases{
        {"unresolved-material",
         replaceOnce(base, "material=Steel", "material=Missing"),
         "unresolved-shell-section", fesa::FailureCategory::input},
        {"unresolved-elset",
         replaceOnce(base, "elset=ShellS4, material=Steel",
                     "elset=Missing, material=Steel"),
         "unresolved-shell-section", fesa::FailureCategory::input},
        {"missing-assignment",
         replaceOnce(
             base,
             "*Shell Section, elset=ShellS4R, material=Aluminum\n0.2\n",
             ""),
         "invalid-shell-section-assignment", fesa::FailureCategory::input},
        {"conflicting-assignment",
         replaceOnce(base, "elset=ShellS4R, material=Aluminum",
                     "elset=ShellS4, material=Aluminum"),
         "invalid-shell-section-assignment", fesa::FailureCategory::input},
        {"invalid-thickness",
         replaceOnce(base, "0.2\n*End Part", "0.\n*End Part"),
         "invalid-shell-thickness", fesa::FailureCategory::model},
        {"invalid-material",
         replaceOnce(base, "70000., 0.25", "70000., 0.5"),
         "invalid-shell-material", fesa::FailureCategory::model}};

    for (const auto& testCase : cases) {
        SCOPED_TRACE(testCase.name);
        auto result = mapText("mitc4-map-002-" + testCase.name, testCase.deck);
        ASSERT_FALSE(result.hasValue());
        EXPECT_EQ(result.status().failureCategory(), testCase.category);
        ASSERT_NE(findDiagnostic(result.status(), testCase.expectedCode), nullptr);
    }
}

// MITC4-MAP-003
TEST(InpDomainMapping, RejectsInvalidShellConnectivityOptionsAndMixedModels) {
    struct InvalidCase {
        std::string name;
        std::string deck;
        std::string expectedCode;
    };

    const std::string base = shellDeck();
    const std::vector<InvalidCase> cases{
        {"wrong-arity", replaceOnce(base, "0010, 1, 2, 3, 4", "0010, 1, 2, 3"),
         "invalid-shell-connectivity"},
        {"repeated-node", replaceOnce(base, "0010, 1, 2, 3, 4", "0010, 1, 2, 2, 4"),
         "invalid-shell-connectivity"},
        {"dangling-node", replaceOnce(base, "0010, 1, 2, 3, 4", "0010, 1, 2, 3, 99"),
         "invalid-shell-connectivity"},
        {"unsupported-section-option",
         replaceOnce(base, "material=Steel\n0.1", "material=Steel, offset=0.1\n0.1"),
         "unsupported-shell-section-option"},
        {"unsupported-element",
         replaceOnce(base, "type=S4R", "type=S8R"),
         "unsupported-element-formulation"},
        {"mixed-beam-shell",
         replaceOnce(base,
                     "0020, 2, 5, 6, 3\n*Elset",
                     "0020, 2, 5, 6, 3\n*Element, type=B33\n30, 1, 2\n*Elset"),
         "unsupported-mixed-element-model"}};

    for (const auto& testCase : cases) {
        SCOPED_TRACE(testCase.name);
        auto result = mapText("mitc4-map-003-" + testCase.name, testCase.deck);
        ASSERT_FALSE(result.hasValue());
        EXPECT_EQ(
            result.status().failureCategory(),
            fesa::FailureCategory::input);
        ASSERT_NE(findDiagnostic(result.status(), testCase.expectedCode), nullptr);
    }
}

// MITC4-MAP-004
TEST(InpDomainMapping, PreservesProcedureLoadAndOutputRequestBoundariesForShells) {
    const std::string withNoOps = replaceOnce(
        shellDeck(),
        "*End Step\n",
        "*Output, field\n*Node Output\nU, RF\n*Element Output\nS\n*End Step\n");
    auto valid = mapText("mitc4-map-004-no-ops", withNoOps);
    ASSERT_TRUE(valid.hasValue());
    ASSERT_EQ(valid.value().warnings().size(), 3U);
    EXPECT_EQ(valid.value().warnings()[0].keyword, "OUTPUT");
    EXPECT_EQ(valid.value().warnings()[1].keyword, "NODE OUTPUT");
    EXPECT_EQ(valid.value().warnings()[2].keyword, "ELEMENT OUTPUT");

    struct InvalidCase {
        std::string name;
        std::string deck;
        std::string expectedCode;
    };
    const std::string base = shellDeck();
    const std::vector<InvalidCase> cases{
        {"second-step",
         base + "*Step\n*Static\n1., 1., 1., 1.\n*End Step\n",
         "unsupported-multiple-step"},
        {"nonlinear-step",
         replaceOnce(base, "nlgeom=NO", "nlgeom=YES"),
         "unsupported-nonlinear-geometry"},
        {"other-procedure",
         replaceOnce(base, "*Static\n0.1, 1., 0.01, 1.",
                     "*Dynamic\n0.1, 1., 0.01, 1."),
         "unsupported-keyword"},
        {"distributed-load",
         replaceOnce(base, "*Cload\nTipSecond, 6, 1.",
                     "*Dload\nShellS4, P, 1."),
         "unsupported-distributed-load"}};

    for (const auto& testCase : cases) {
        SCOPED_TRACE(testCase.name);
        auto result = mapText("mitc4-map-004-" + testCase.name, testCase.deck);
        ASSERT_FALSE(result.hasValue());
        EXPECT_EQ(
            result.status().failureCategory(),
            fesa::FailureCategory::input);
        ASSERT_NE(findDiagnostic(result.status(), testCase.expectedCode), nullptr);
    }
}

TEST(InpDomainMapping, RejectsUnsupportedAndInvalidPortfolio) {
    struct InvalidCase {
        std::string name;
        std::string deck;
        std::string expectedCode;
        fesa::FailureCategory category;
    };

    const std::string base = minimalDeck();
    const std::vector<InvalidCase> cases{
        {"b31", replaceOnce(base, "type=B33", "type=B31"),
         "unsupported-element-formulation", fesa::FailureCategory::input},
        {"transform", replaceOnce(base, "*End Instance\n", "1., 0., 0.\n*End Instance\n"),
         "unsupported-instance-transform", fesa::FailureCategory::input},
        {"nested-assembly", replaceOnce(base, "*End Assembly\n", "*Assembly, name=Nested\n*End Assembly\n*End Assembly\n"),
         "unsupported-nested-assembly", fesa::FailureCategory::input},
        {"multiple-step", base + "*Step\n*Static\n1., 1., 1., 1.\n*End Step\n",
         "unsupported-multiple-step", fesa::FailureCategory::input},
        {"late-part", replaceOnce(base, "*End Assembly\n*Material", "*End Assembly\n*Part, name=Late\n*End Part\n*Material"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"material-before-assembly", replaceOnce(base, "*Assembly, name=Assembly", "*Material, name=Early\n*Elastic\n50., 0.2\n*Assembly, name=Assembly"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"material-after-model-boundary", replaceOnce(base,
             "*Material, name=Steel\n*Elastic\n100., 0.25\n*Boundary\nRoot, 1, 6",
             "*Boundary\nRoot, 1, 6\n*Material, name=Steel\n*Elastic\n100., 0.25"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"keyword-after-step", base + "*Preprint, echo=NO\n",
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"assembly-instance-after-set", replaceOnce(base,
             "*Instance, name=Beam-1, part=BeamPart\n*End Instance\n*Nset, nset=Root, instance=Beam-1\n1",
             "*Nset, nset=Root, instance=Beam-1\n1\n*Instance, name=Beam-1, part=BeamPart\n*End Instance"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"element-before-node", replaceOnce(base,
             "*Node\n1, 0., 0., 0.\n2, 1., 0., 0.\n*Element, type=B33\n1, 1, 2",
             "*Element, type=B33\n1, 1, 2\n*Node\n1, 0., 0., 0.\n2, 1., 0., 0."),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"shear-before-section-context", replaceOnce(base,
             "*Beam General Section",
             "*Transverse Shear Stiffness\n1., 2., 3.\n*Beam General Section"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"incomplete-part", replaceOnce(base,
             base.substr(base.find("*Part"), base.find("*End Part") + std::string{"*End Part\n"}.size() - base.find("*Part")),
             "*Part, name=BeamPart\n*End Part\n"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"empty-assembly", replaceOnce(base,
             base.substr(base.find("*Assembly"), base.find("*End Assembly") + std::string{"*End Assembly\n"}.size() - base.find("*Assembly")),
             "*Assembly, name=Assembly\n*End Assembly\n"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"cload-before-static", replaceOnce(base,
             "*Static\n0.1, 1., 0.01, 1.\n*Cload\nTip, 2, -1.",
             "*Cload\nTip, 2, -1.\n*Static\n0.1, 1., 0.01, 1."),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"static-after-boundary", replaceOnce(
             replaceOnce(base, "*Boundary\nRoot, 1, 6\n*Step", "*Step"),
             "*Static\n0.1, 1., 0.01, 1.",
             "*Boundary\nRoot, 1, 6\n*Static\n0.1, 1., 0.01, 1."),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"boundary-after-cload", replaceOnce(
             replaceOnce(base, "*Boundary\nRoot, 1, 6\n*Step", "*Step"),
             "Tip, 2, -1.\n*End Step",
             "Tip, 2, -1.\n*Boundary\nRoot, 1, 6\n*End Step"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"cload-after-no-op", replaceOnce(base, "*Cload", "*Restart, write, frequency=0\n*Cload"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"step-without-static", replaceOnce(base,
             "*Static\n0.1, 1., 0.01, 1.\n*Cload\nTip, 2, -1.\n*End Step",
             "*End Step"),
         "invalid-keyword-location", fesa::FailureCategory::input},
        {"dependent-instance", replaceOnce(base, "part=BeamPart", "part=BeamPart, dependent=YES"),
         "unsupported-instance-mesh-semantics", fesa::FailureCategory::input},
        {"coupled-section", replaceOnce(base, "1., 1., 0., 1., 1.", "1., 1., 0.5, 1., 1."),
         "unsupported-coupled-section", fesa::FailureCategory::model},
        {"zero-length", replaceOnce(base, "2, 1., 0., 0.", "2, 0., 0., 0."),
         "invalid-beam-length", fesa::FailureCategory::model},
        {"parallel-guide", replaceOnce(base, "0., 1., 0.", "1., 0., 0."),
         "invalid-beam-guide-vector", fesa::FailureCategory::model},
        {"nonpositive-area", replaceOnce(base, "1., 1., 0., 1., 1.", "0., 1., 0., 1., 1."),
         "invalid-beam-property", fesa::FailureCategory::model},
        {"nonpositive-derived-shear", replaceOnce(base, "100., 0.25", "100., -1.25"),
         "invalid-beam-property", fesa::FailureCategory::model},
        {"nonfinite-elastic", replaceOnce(base, "100., 0.25", "inf, 0.25"),
         "invalid-beam-property", fesa::FailureCategory::model},
        {"nonfinite-section-property", replaceOnce(base, "1., 1., 0., 1., 1.", "nan, 1., 0., 1., 1."),
         "invalid-beam-property", fesa::FailureCategory::model},
        {"nonfinite-guide", replaceOnce(base, "0., 1., 0.", "0., inf, 0."),
         "invalid-beam-guide-vector", fesa::FailureCategory::model},
        {"length-at-threshold", replaceOnce(base, "2, 1., 0., 0.", "2, 1e-12, 0., 0."),
         "invalid-beam-length", fesa::FailureCategory::model},
        {"guide-at-threshold", replaceOnce(base, "0., 1., 0.", "1., 1e-12, 0."),
         "invalid-beam-guide-vector", fesa::FailureCategory::model},
        {"duplicate-elastic", replaceOnce(base, "100., 0.25\n*Boundary", "100., 0.25\n*Elastic\n100., 0.25\n*Boundary"),
         "duplicate-entity", fesa::FailureCategory::input},
        {"duplicate-node-label", replaceOnce(base, "2, 1., 0., 0.", "1, 1., 0., 0."),
         "duplicate-entity", fesa::FailureCategory::input},
        {"dangling-connectivity", replaceOnce(base, "1, 1, 2", "1, 1, 9"),
         "unresolved-reference", fesa::FailureCategory::input},
        {"invalid-dof", replaceOnce(base, "Root, 1, 6", "Root, 1, 7"),
         "invalid-dof", fesa::FailureCategory::input},
        {"nonfinite-coordinate", replaceOnce(base, "1., 0., 0.", "nan, 0., 0."),
         "invalid-numeric-value", fesa::FailureCategory::input},
        {"malformed-node-arity", replaceOnce(base, "1, 0., 0., 0.", "1, 0., 0."),
         "invalid-data-arity", fesa::FailureCategory::input},
        {"nlgeom", replaceOnce(base, "nlgeom=NO", "nlgeom=YES"),
         "unsupported-nonlinear-geometry", fesa::FailureCategory::model},
        {"unknown-keyword", replaceOnce(base, "*Assembly, name=Assembly", "*Density\n1.\n*Assembly, name=Assembly"),
         "unsupported-keyword", fesa::FailureCategory::input},
        {"invalid-static-arity", replaceOnce(base, "0.1, 1., 0.01, 1.", "0.1, 1., 0.01"),
         "invalid-static-data", fesa::FailureCategory::input},
        {"invalid-static-range", replaceOnce(base, "0.1, 1., 0.01, 1.", "0.1, 1., 2., 1."),
         "invalid-static-data", fesa::FailureCategory::input},
        {"invalid-generate", replaceOnce(base, "*Elset, elset=BeamSet\n1", "*Elset, elset=BeamSet, generate\n1, 1, 0"),
         "invalid-set-range", fesa::FailureCategory::input},
        {"nonlanding-generate", replaceOnce(base, "*Elset, elset=BeamSet\n1", "*Elset, elset=BeamSet, generate\n1, 2, 2"),
         "invalid-set-range", fesa::FailureCategory::input},
        {"ambiguous-direct-label", replaceOnce(
             replaceOnce(base,
                 "*End Instance\n*Nset, nset=Root",
                 "*End Instance\n*Instance, name=Beam-2, part=BeamPart\n*End Instance\n*Nset, nset=Root"),
             "Root, 1, 6",
             "1, 1, 6"),
         "unresolved-reference", fesa::FailureCategory::input},
        {"ambiguous-part-set", replaceOnce(
             replaceOnce(
                 replaceOnce(base,
                     "*Elset, elset=BeamSet",
                     "*Nset, nset=Local\n1\n*Elset, elset=BeamSet"),
                 "*End Instance\n*Nset, nset=Root",
                 "*End Instance\n*Instance, name=Beam-2, part=BeamPart\n*End Instance\n*Nset, nset=Root"),
             "Root, 1, 6",
             "Local, 1, 6"),
         "unresolved-reference", fesa::FailureCategory::input},
        {"direct-set-conflict", replaceOnce(base,
             "*Step, name=Load",
             "*Boundary\n1, 1, 1, 2.\n*Step, name=Load"),
         "conflicting-boundary-condition", fesa::FailureCategory::input},
        {"dangling-boundary-target", replaceOnce(base, "Root, 1, 6", "Missing, 1, 6"),
         "unresolved-reference", fesa::FailureCategory::input},
        {"conflicting-boundary", replaceOnce(base, "*Step, name=Load", "*Boundary\nRoot, 1, 1, 2.\n*Step, name=Load"),
         "conflicting-boundary-condition", fesa::FailureCategory::input}};

    for (const auto& testCase : cases) {
        SCOPED_TRACE(testCase.name);
        auto result = mapText("invalid-" + testCase.name, testCase.deck);
        ASSERT_FALSE(result.hasValue());
        EXPECT_EQ(result.status().failureCategory(), testCase.category);
        const auto* diagnostic = findDiagnostic(result.status(), testCase.expectedCode);
        ASSERT_NE(diagnostic, nullptr);
        EXPECT_EQ(diagnostic->severity, fesa::Severity::error);
        EXPECT_FALSE(diagnostic->location.file.empty());
        EXPECT_GT(diagnostic->location.line, 0U);
    }

    struct SameTokenAmbiguityCase {
        std::string name;
        std::string deck;
        std::string expectedKeyword;
        std::size_t expectedLine;
    };
    const std::string sameTokenBase = replaceOnce(
        base,
        "*Elset, elset=BeamSet",
        "*Nset, nset=1\n2\n*Elset, elset=BeamSet");
    const std::vector<SameTokenAmbiguityCase> sameTokenCases{
        {"boundary", replaceOnce(sameTokenBase, "Root, 1, 6", "1, 1, 6"),
         "BOUNDARY", 27U},
        {"cload", replaceOnce(sameTokenBase, "Tip, 2, -1.", "1, 2, -1."),
         "CLOAD", 32U}};

    for (const auto& testCase : sameTokenCases) {
        SCOPED_TRACE("same-token-" + testCase.name);
        auto result = mapText("same-token-" + testCase.name, testCase.deck);
        ASSERT_FALSE(result.hasValue());
        EXPECT_EQ(
            result.status().failureCategory(),
            fesa::FailureCategory::input);
        const auto* diagnostic =
            findDiagnostic(result.status(), "unresolved-reference");
        ASSERT_NE(diagnostic, nullptr);
        EXPECT_EQ(diagnostic->severity, fesa::Severity::error);
        EXPECT_EQ(diagnostic->keyword, testCase.expectedKeyword);
        EXPECT_EQ(diagnostic->entityIdentity, "1");
        EXPECT_EQ(diagnostic->location.line, testCase.expectedLine);
        EXPECT_EQ(
            diagnostic->location.file.filename().string(),
            "fesa-domain-mapper-same-token-" + testCase.name + ".inp");
    }
}

TEST(InpDomainMapping, RejectsDloadWithoutDistributedLoadObject) {
    const auto deck = replaceOnce(
        minimalDeck(),
        "*Cload\nTip, 2, -1.\n",
        "*Dload\nBeamSet, PY, -1.\n");
    auto result = mapText("dload", deck);

    ASSERT_FALSE(result.hasValue());
    EXPECT_EQ(result.status().failureCategory(), fesa::FailureCategory::input);
    const auto* diagnostic = findDiagnostic(result.status(), "unsupported-keyword");
    ASSERT_NE(diagnostic, nullptr);
    EXPECT_EQ(diagnostic->keyword, "DLOAD");
}
