#include "catch.hpp"

#include <algorithm>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <system_error>
#include <vector>

#include "BuildingType.h"
#include "ConfigLoader.h"
#include "UnlocksConfig.h"

namespace
{

// Writes content to a file at path; creates parent directories if needed.
// Used to materialize malformed TOML for error-path coverage.
void writeFile(const std::filesystem::path& path, const std::string& content)
{
    std::filesystem::create_directories(path.parent_path());
    std::ofstream out(path);
    out << content;
}

// RAII temp directory: allocated in ctor, recursively removed in dtor.
class TempConfigDir
{
public:
    TempConfigDir()
    {
        const std::filesystem::path base = std::filesystem::temp_directory_path();
        for (int i = 0; i < 10000; ++i)
        {
            const std::filesystem::path candidate =
                base / ("dota_factory_test_" + std::to_string(i) + "_"
                        + std::to_string(reinterpret_cast<std::uintptr_t>(this)));
            if (!std::filesystem::exists(candidate))
            {
                std::filesystem::create_directories(candidate);
                m_path = candidate;
                return;
            }
        }
        throw std::runtime_error("Could not allocate temp directory for test");
    }

    ~TempConfigDir()
    {
        std::error_code ec;
        std::filesystem::remove_all(m_path, ec);
    }

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

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

private:
    std::filesystem::path m_path;
};

} // namespace


TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[config]")
{
    const std::string configDir = CONFIG_DIR;
    const GameConfig cfg = ConfigLoader::loadFromDirectory(configDir);

    // world.toml
    REQUIRE(cfg.world.heightTiles == 60);
    REQUIRE(cfg.world.refundPercentage == 75);
    REQUIRE(cfg.world.beltSpeed_tps == Approx(2.0));
    REQUIRE(cfg.world.regions.asteroidWidth_tiles == 40);
    REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10);
    REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15);
    REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10);
    REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(0) == Approx(400.0));
    REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(1) == Approx(800.0));
    REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
    REQUIRE(cfg.world.orbitFactor == Approx(0.8));
    REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
    REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));

    // Optional header artifact tooltip (REQ-UI-ARTIFACTS-TOOLTIP).
    REQUIRE(cfg.world.artifactTooltip.has_value());
    REQUIRE(*cfg.world.artifactTooltip ==
            "Choose the artifact reward when destroying enemy stations; collect enough to win.");

    // Spot-check that a config-derived formula computes as expected.
    // threat_rate_formula = "x": evaluates to the input value.
    REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));
    REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0));

    // targeting: distance score 1/(1+x) and overclaim penalty max(0.5, 1-0.1*x).
    REQUIRE(cfg.world.targeting.hysteresis == Approx(0.10));
    REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(0.0) == Approx(1.0));
    REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(1.0) == Approx(0.5));
    REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(0.0) == Approx(1.0));
    REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(5.0) == Approx(0.5));

    // buildings.toml
    REQUIRE(cfg.buildings.buildings.size() >= 8);
    const auto minerIt = std::find_if(
        cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
        [](const BuildingDef& b) { return b.type == BuildingType::Miner; });
    REQUIRE(minerIt != cfg.buildings.buildings.end());
    REQUIRE(minerIt->cost == 15);
    REQUIRE(minerIt->surfaceMask.size() == 2);
    // Miner has no output-buffer-capacity override; the Salvage Bay does.
    REQUIRE_FALSE(minerIt->outputBufferCapacity.has_value());

    const auto salvageBayIt = std::find_if(
        cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
        [](const BuildingDef& b) { return b.type == BuildingType::SalvageBay; });
    REQUIRE(salvageBayIt != cfg.buildings.buildings.end());
    REQUIRE(salvageBayIt->outputBufferCapacity.has_value());
    REQUIRE(*salvageBayIt->outputBufferCapacity == 20);

    // Optional per-building tooltip (REQ-UI-BUILD-TOOLTIP): the Salvage Bay
    // defines one; the Miner leaves it unset.
    REQUIRE(salvageBayIt->tooltip.has_value());
    REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
    REQUIRE_FALSE(minerIt->tooltip.has_value());

    // recipes.toml -- the reprocessing cycle is written as three weighted output groups,
    // each yielding one item (REQ-MAT-OUTPUT-GROUP).
    const auto reproIt = std::find_if(
        cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
        [](const RecipeDef& r) { return r.id == "reprocessing_cycle"; });
    REQUIRE(reproIt != cfg.recipes.recipes.end());
    REQUIRE(reproIt->building == BuildingType::ReprocessingPlant);
    REQUIRE(reproIt->outputGroups.size() == 3);
    REQUIRE(reproIt->outputGroups[0].probability.has_value());
    REQUIRE(reproIt->outputGroups[0].items.size() == 1);

    // The `outputs = [...]` shorthand loads as one group carrying no weight: with a single
    // group nothing is picked, so there is nothing to weigh.
    const auto ironIngotIt = std::find_if(
        cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
        [](const RecipeDef& r) { return r.id == "iron_ingot"; });
    REQUIRE(ironIngotIt != cfg.recipes.recipes.end());
    REQUIRE(ironIngotIt->outputGroups.size() == 1);
    REQUIRE(ironIngotIt->outputGroups[0].items.size() == 1);
    REQUIRE_FALSE(ironIngotIt->outputGroups[0].probability.has_value());

    // ships.toml — combat ships have default_modules with a weapon; salvage ships don't.
    const auto interceptorIt = std::find_if(
        cfg.ships.ships.begin(), cfg.ships.ships.end(),
        [](const ShipDef& s) { return s.id == "interceptor"; });
    REQUIRE(interceptorIt != cfg.ships.ships.end());
    REQUIRE_FALSE(interceptorIt->defaultModules.empty());
    REQUIRE(interceptorIt->defaultModules[0].moduleId == "laser_cannon");

    const auto salvageShipIt = std::find_if(
        cfg.ships.ships.begin(), cfg.ships.ships.end(),
        [](const ShipDef& s) { return s.id == "salvage_ship"; });
    REQUIRE(salvageShipIt != cfg.ships.ships.end());
    REQUIRE(salvageShipIt->defaultModules.empty());

    // modules.toml — optional per-module tooltip (REQ-MOD-UI-MODULE-TOOLTIP):
    // armor_plate defines one; salvager leaves it unset.
    const auto armorPlateIt = std::find_if(
        cfg.modules.modules.begin(), cfg.modules.modules.end(),
        [](const ModuleDef& m) { return m.id == "armor_plate"; });
    REQUIRE(armorPlateIt != cfg.modules.modules.end());
    REQUIRE(armorPlateIt->tooltip.has_value());
    REQUIRE(*armorPlateIt->tooltip == "Adds a large flat bonus to hit points.");

    const auto salvagerModuleIt = std::find_if(
        cfg.modules.modules.begin(), cfg.modules.modules.end(),
        [](const ModuleDef& m) { return m.id == "salvager"; });
    REQUIRE(salvagerModuleIt != cfg.modules.modules.end());
    REQUIRE_FALSE(salvagerModuleIt->tooltip.has_value());

    // stations.toml
    REQUIRE(cfg.stations.playerStation.level == 5);
    REQUIRE(cfg.stations.playerStation.hpFormula.evaluate(5.0) == Approx(500.0));  // 300 + 40*5
    REQUIRE(cfg.stations.enemyStation.hpFormula.evaluate(0.0) == Approx(300.0));   // 300 + 150*0
}

TEST_CASE("Loading a non-existent file throws", "[config]")
{
    REQUIRE_THROWS(ConfigLoader::loadWorld("does/not/exist.toml"));
}

TEST_CASE("Malformed TOML is rejected with a file-identifying message", "[config]")
{
    TempConfigDir dir;
    writeFile(dir.path() / "world.toml", "this is = not [ valid toml\n");

    try
    {
        ConfigLoader::loadWorld((dir.path() / "world.toml").string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        const std::string msg = e.what();
        REQUIRE(msg.find("world.toml") != std::string::npos);
    }
}

TEST_CASE("Missing field in world.toml is rejected with the field path", "[config]")
{
    TempConfigDir dir;
    writeFile(dir.path() / "world.toml", R"(
[world]
height_tiles = 60
refund_percentage = 75
deconstruction_time_seconds = 0.1
debris_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10
belt_speed_mps = 20
starting_building_blocks = 100
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0

[regions]
asteroid_width_tiles = 40
player_buffer_width_tiles = 10
contest_zone_width_tiles = 30
# enemy_buffer_width_tiles intentionally missing

[expansion]
columns_per_expansion_tiles = 10
cost_building_blocks_formula = "400 * 2^x"

[push]
push_expand_columns_tiles = 20
scaling_factor = 1.2

[waves]
threat_rate_formula = "1*x - 30"
ship_level_formula = "1 + x / 120"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10
)");

    try
    {
        ConfigLoader::loadWorld((dir.path() / "world.toml").string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        const std::string msg = e.what();
        REQUIRE(msg.find("enemy_buffer_width_tiles") != std::string::npos);
    }
}

TEST_CASE("Malformed formula in world.toml is rejected with field identification", "[config]")
{
    TempConfigDir dir;
    writeFile(dir.path() / "world.toml", R"(
[world]
height_tiles = 60
refund_percentage = 75
deconstruction_time_seconds = 0.1
debris_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10
belt_speed_mps = 20
starting_building_blocks = 100
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0

[regions]
asteroid_width_tiles = 40
player_buffer_width_tiles = 10
contest_zone_width_tiles = 30
enemy_buffer_width_tiles = 15

[expansion]
columns_per_expansion_tiles = 10
cost_building_blocks_formula = "400 * 2^x"

[push]
push_expand_columns_tiles = 20
boss_advance_seconds = 60

[waves]
threat_rate_formula = "1 +"
ship_level_formula = "1 + x / 10"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10
)");

    try
    {
        ConfigLoader::loadWorld((dir.path() / "world.toml").string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        const std::string msg = e.what();
        REQUIRE(msg.find("threat_rate_formula") != std::string::npos);
        REQUIRE(msg.find("formula") != std::string::npos);
    }
}

TEST_CASE("Inverted wave gap range is rejected", "[config]")
{
    TempConfigDir dir;
    writeFile(dir.path() / "world.toml", R"(
[world]
height_tiles = 60
refund_percentage = 75
deconstruction_time_seconds = 0.1
debris_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10
belt_speed_mps = 20

[regions]
asteroid_width_tiles = 40
player_buffer_width_tiles = 10
contest_zone_width_tiles = 30
enemy_buffer_width_tiles = 15

[expansion]
columns_per_expansion_tiles = 10
cost_building_blocks_formula = "400 * 2^x"

[push]
push_expand_columns_tiles = 20
scaling_factor = 1.2

[waves]
threat_rate_formula = "1*x - 30"
ship_level_formula = "1 + x / 120"
gap_min_seconds = 45
gap_max_seconds = 15
spawn_duration_seconds = 10
)");

    REQUIRE_THROWS_AS(
        ConfigLoader::loadWorld((dir.path() / "world.toml").string()),
        std::runtime_error);
}

TEST_CASE("Unknown building id in buildings.toml is rejected with the id in the message", "[config]")
{
    TempConfigDir dir;
    writeFile(dir.path() / "buildings.toml", R"(
[[building]]
id = "fictional_machine"
cost = 10
player_placeable = true
construction_time_seconds = 5
surface_mask = ["AA"]
)");

    try
    {
        ConfigLoader::loadBuildings((dir.path() / "buildings.toml").string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        const std::string msg = e.what();
        REQUIRE(msg.find("fictional_machine") != std::string::npos);
    }
}

TEST_CASE("Recipe referencing an unknown building is rejected", "[config]")
{
    TempConfigDir dir;
    writeFile(dir.path() / "recipes.toml", R"(
[[recipe]]
id = "bogus"
building = "imaginary_factory"
inputs = []
outputs = [{item = "foo", amount = 1}]
duration_seconds = 1.0
)");

    REQUIRE_THROWS_AS(
        ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()),
        std::runtime_error);
}

// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------

namespace
{

// Copies every regular file from CONFIG_DIR into dir, giving tests a full,
// valid config set they can then perturb before calling loadFromDirectory.
void copyConfigInto(const std::filesystem::path& dir)
{
    for (const std::filesystem::directory_entry& entry :
         std::filesystem::directory_iterator(std::string(CONFIG_DIR)))
    {
        if (entry.is_regular_file())
        {
            std::filesystem::copy_file(entry.path(), dir / entry.path().filename());
        }
    }
}

} // namespace

TEST_CASE("loadUnlocks parses id, station_level, requires, and grants", "[config]")
{
    TempConfigDir dir;
    writeFile(dir.path() / "unlocks.toml", R"(
[[unlock]]
id = "base"
station_level = 0
buildings = ["salvage_bay"]

[[unlock]]
id = "gated"
station_level = 3
requires = ["base"]
ships = ["cruiser"]
modules = ["railgun_m"]
recipes = ["shortcut_steel_plate"]
)");

    const UnlocksConfig cfg = ConfigLoader::loadUnlocks((dir.path() / "unlocks.toml").string());
    REQUIRE(cfg.groups.size() == 2);
    CHECK(cfg.groups[0].id == "base");
    CHECK(cfg.groups[0].stationLevel == 0);
    CHECK(cfg.groups[0].buildings == std::vector<std::string>{"salvage_bay"});
    CHECK(cfg.groups[1].requiredGroupIds == std::vector<std::string>{"base"});
    CHECK(cfg.groups[1].ships == std::vector<std::string>{"cruiser"});
    CHECK(cfg.groups[1].modules == std::vector<std::string>{"railgun_m"});
    CHECK(cfg.groups[1].recipes == std::vector<std::string>{"shortcut_steel_plate"});
}

TEST_CASE("validateUnlocks rejects a grant that names no definition", "[config]")
{
    TempConfigDir dir;
    copyConfigInto(dir.path());

    std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
    out << "\n[[unlock]]\nid = \"bogus\"\nstation_level = 0\nships = [\"___nope___\"]\n";
    out.close();

    try
    {
        ConfigLoader::loadFromDirectory(dir.path().string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        REQUIRE(std::string(e.what()).find("___nope___") != std::string::npos);
    }
}

TEST_CASE("validateUnlocks rejects an id granted by two groups", "[config]")
{
    TempConfigDir dir;
    copyConfigInto(dir.path());

    // repair_ship is already granted by the test config's unlocks.toml.
    std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
    out << "\n[[unlock]]\nid = \"dup\"\nstation_level = 0\nships = [\"repair_ship\"]\n";
    out.close();

    try
    {
        ConfigLoader::loadFromDirectory(dir.path().string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        REQUIRE(std::string(e.what()).find("already granted") != std::string::npos);
    }
}

TEST_CASE("validateUnlocks rejects a non-assembler recipe grant", "[config]")
{
    TempConfigDir dir;
    copyConfigInto(dir.path());

    // mine_iron_ore is a miner recipe, not an assembler recipe.
    std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
    out << "\n[[unlock]]\nid = \"badrecipe\"\nstation_level = 0\nrecipes = [\"mine_iron_ore\"]\n";
    out.close();

    try
    {
        ConfigLoader::loadFromDirectory(dir.path().string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        REQUIRE(std::string(e.what()).find("mine_iron_ore") != std::string::npos);
    }
}

TEST_CASE("validateUnlocks rejects requires naming an unknown group", "[config]")
{
    TempConfigDir dir;
    copyConfigInto(dir.path());

    std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
    out << "\n[[unlock]]\nid = \"needsmissing\"\nstation_level = 0\n"
           "requires = [\"___missing___\"]\nships = [\"interceptor\"]\n";
    out.close();

    try
    {
        ConfigLoader::loadFromDirectory(dir.path().string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        REQUIRE(std::string(e.what()).find("___missing___") != std::string::npos);
    }
}

TEST_CASE("validateUnlocks rejects a group that grants nothing", "[config]")
{
    TempConfigDir dir;
    copyConfigInto(dir.path());

    std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
    out << "\n[[unlock]]\nid = \"empty\"\nstation_level = 0\n";
    out.close();

    try
    {
        ConfigLoader::loadFromDirectory(dir.path().string());
        FAIL("Expected exception");
    }
    catch (const std::runtime_error& e)
    {
        REQUIRE(std::string(e.what()).find("grants no items") != std::string::npos);
    }
}

TEST_CASE("validateUnlocks accepts a well-formed unlock group", "[config]")
{
    TempConfigDir dir;
    copyConfigInto(dir.path());

    // salvage_ship starts unlocked (not granted elsewhere); repair_ship is an
    // existing group id, so this new group is valid.
    std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
    out << "\n[[unlock]]\nid = \"extra\"\nstation_level = 2\n"
           "requires = [\"repair_ship\"]\nships = [\"salvage_ship\"]\n";
    out.close();

    REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));
}

