#include "catch.hpp"
#include "FactoryQueries.h"

#include <cmath>
#include <random>

#include <QPoint>
#include <QSize>
#include <QVector2D>

#include "AdvanceBehavior.h"
#include "AiSystem.h"
#include "AttackBehavior.h"
#include "BehaviorKind.h"
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "ConstructionSystem.h"
#include "FactoryState.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "DeliverScrapBehavior.h"
#include "DynamicBodyComponent.h"
#include "DynamicBodySystem.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
#include "MovementIntentSystem.h"
#include "OrbitMath.h"
#include "PositionComponent.h"
#include "RallyBehavior.h"
#include "RepairBehavior.h"
#include "RepairSystem.h"
#include "RepairToolComponent.h"
#include "RetreatBehavior.h"
#include "CargoComponent.h"
#include "Rotation.h"
#include "SalvageScrapBehavior.h"
#include "SalvagerComponent.h"
#include "SalvagerSystem.h"
#include "DebrisSystem.h"
#include "SelectedBehaviorComponent.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipLayout.h"
#include "ShipSystem.h"
#include "Tick.h"
#include "TestConfig.h"

// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------

struct Fixture
{
    GameConfig     cfg;
    FactoryState state = makeFactoryState(cfg);
    BeltSystem     belts;
    std::mt19937   rng;
    EntityAdmin    admin;
    BuildingSystem buildings;
    ConstructionSystem construction;
    ShipSystem     ships;
    AiSystem             ai;
    SalvagerSystem       salvager;
    RepairSystem         repair;
    MovementIntentSystem movementIntent;
    DynamicBodySystem    dynamicBody;
    DebrisSystem          scraps;
    Tick           tick;
    std::vector<BeamFiredEvent> beamEvents;

    explicit Fixture()
        : cfg(loadTestConfig())
        , belts(cfg.world.beltSpeed_tps)
        , rng(42)
        , buildings(cfg)
        , construction(cfg)
        , ships(cfg, admin)
        , ai(cfg)
        , salvager(admin)
        , repair(admin)
        , scraps(admin)
        , tick(0)
    {
    }

    // Phase 1-3: clear intents, evaluate behaviors, select winners, execute.
    void decide()
    {
        ships.clearMovementIntents();
        ai.tick(admin, state);
    }

    // World mutation: collection/delivery and healing.
    void runModules()
    {
        beamEvents.clear();
        salvager.tick(tick, state, beamEvents);
        repair.tick(tick, beamEvents);
    }

    // Run one full behavior+movement tick (steps 7 and 10).
    void runBehaviorTick()
    {
        decide();
        runModules();
        movementIntent.tick(admin);
        dynamicBody.tick(admin);
        ++tick;
    }

    // One repair-system tick at the current sim time (advances the tick counter).
    // Starts cycles and applies any due (mid-beam-delayed) heals.
    void repairTick()
    {
        beamEvents.clear();
        repair.tick(tick, beamEvents);
        ++tick;
    }

    // Drive the repair system long enough for a started cycle's delayed heal to land.
    void runRepairHeal()
    {
        for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { repairTick(); }
    }

    // One salvage-system tick at the current sim time (advances the tick counter).
    void salvageTick()
    {
        beamEvents.clear();
        salvager.tick(tick, state, beamEvents);
        ++tick;
    }

    // Drive the salvage system long enough for a started cycle's delayed collection.
    void runSalvageCollect()
    {
        for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { salvageTick(); }
    }
};

static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId)
{
    PlacedModule pm;
    pm.moduleId = moduleId;
    pm.position = QPoint(1, 1);
    pm.rotation = Rotation::East;
    ShipLayoutConfig layout;
    layout.placedModules.push_back(pm);
    return layout;
}

static ShipLayoutConfig makeTwoModuleLayout(const std::string& moduleId)
{
    ShipLayoutConfig layout;
    PlacedModule pm1;
    pm1.moduleId = moduleId;
    pm1.position = QPoint(0, 0);
    pm1.rotation = Rotation::East;
    layout.placedModules.push_back(pm1);
    PlacedModule pm2;
    pm2.moduleId = moduleId;
    pm2.position = QPoint(0, 1);
    pm2.rotation = Rotation::East;
    layout.placedModules.push_back(pm2);
    return layout;
}

static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
{
    entt::entity result = entt::null;
    admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
        [&](entt::entity ce, const SalvagerComponent&, const ModuleOwnerComponent& o)
        {
            if (o.owner == ship && result == entt::null) { result = ce; }
        });
    return result;
}

static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship)
{
    entt::entity result = entt::null;
    admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
        [&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o)
        {
            if (o.owner == ship && result == entt::null) { result = ce; }
        });
    return result;
}

static std::vector<entt::entity> allRepairChildren(EntityAdmin& admin, entt::entity ship)
{
    std::vector<entt::entity> result;
    admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
        [&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o)
        {
            if (o.owner == ship) { result.push_back(ce); }
        });
    return result;
}

static const MovementIntentComponent& intent(EntityAdmin& a, entt::entity e)
{
    return a.get<MovementIntentComponent>(e);
}

static BehaviorKind winnerOf(EntityAdmin& a, entt::entity e)
{
    return a.get<SelectedBehaviorComponent>(e).winner;
}

static const HealthComponent& health(EntityAdmin& a, entt::entity e)
{
    return a.get<HealthComponent>(e);
}

static const PositionComponent& pos(EntityAdmin& a, entt::entity e)
{
    return a.get<PositionComponent>(e);
}

// ---------------------------------------------------------------------------
// clearMovementIntents
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to inactive",
          "[behavior]")
{
    Fixture f;
    const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));

    f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(10.0f, 0.0f)};
    f.ships.clearMovementIntents();

    REQUIRE_FALSE(intent(f.admin, e).active);
}

// ---------------------------------------------------------------------------
// tickMovement
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeed_tpt toward target",
          "[behavior]")
{
    Fixture f;
    const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));

    const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
    f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(100.0f, 0.0f)};
    f.movementIntent.tick(f.admin);
    f.dynamicBody.tick(f.admin);

    REQUIRE(pos(f.admin, e).value.x() == Approx(speed));
    REQUIRE(pos(f.admin, e).value.y() == Approx(0.0f));
}

TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot",
          "[behavior]")
{
    Fixture f;
    const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));

    const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
    const QVector2D target(speed * 0.5f, 0.0f);
    f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, target};
    f.movementIntent.tick(f.admin);
    f.dynamicBody.tick(f.admin);

    REQUIRE(pos(f.admin, e).value.x() == Approx(target.x()));
    REQUIRE(pos(f.admin, e).value.y() == Approx(target.y()));
}

// ---------------------------------------------------------------------------
// RetreatBehavior
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: healthy player ship does not retreat", "[behavior]")
{
    Fixture f;
    const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp;  // full HP

    f.decide();

    REQUIRE(winnerOf(f.admin, e) != BehaviorKind::Retreat);
}

TEST_CASE("BehaviorSystem: low-HP player ship retreats toward the rally point", "[behavior]")
{
    Fixture f;
    const QVector2D rallyPoint(-50.0f, 0.0f);
    f.ships.setRallyPoint(rallyPoint);
    const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp * 0.2f;  // below threshold

    f.decide();

    REQUIRE(winnerOf(f.admin, e) == BehaviorKind::Retreat);
    REQUIRE(intent(f.admin, e).active);
    REQUIRE(intent(f.admin, e).target.x() == Approx(rallyPoint.x()));
}

TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[behavior]")
{
    Fixture f;
    const QVector2D rallyPoint(-50.0f, 0.0f);
    f.ships.setRallyPoint(rallyPoint);
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
    f.admin.get<HealthComponent>(player).hp = f.admin.get<HealthComponent>(player).maxHp * 0.1f;

    f.decide();

    REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Retreat);
    REQUIRE(intent(f.admin, player).target.x() == Approx(rallyPoint.x()));
}

TEST_CASE("BehaviorSystem: enemy ships never retreat even at low HP", "[behavior]")
{
    Fixture f;
    const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f),
                                             /*isEnemy=*/true);
    f.admin.get<HealthComponent>(enemy).hp = f.admin.get<HealthComponent>(enemy).maxHp * 0.05f;

    f.decide();

    REQUIRE_FALSE(f.admin.hasAll<RetreatBehavior>(enemy));
    REQUIRE(winnerOf(f.admin, enemy) != BehaviorKind::Retreat);
}

// ---------------------------------------------------------------------------
// AttackBehavior — player ships
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
          "[behavior]")
{
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemy  = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
                                               /*isEnemy=*/true);

    f.decide();

    REQUIRE(f.admin.hasAll<AttackBehavior>(player));
    const AttackBehavior& attack = f.admin.get<AttackBehavior>(player);
    REQUIRE(attack.currentTarget.has_value());
    REQUIRE(*attack.currentTarget == enemy);
    REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack);
}

TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
          "[behavior]")
{
    Fixture f;
    const entt::entity e1 = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f));  // also player

    f.decide();

    REQUIRE(f.admin.hasAll<AttackBehavior>(e1));
    REQUIRE_FALSE(f.admin.get<AttackBehavior>(e1).currentTarget.has_value());
    REQUIRE(winnerOf(f.admin, e1) != BehaviorKind::Attack);
}

TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
          "[behavior]")
{
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    f.ships.spawn("interceptor", QVector2D(500.0f, 0.0f), /*isEnemy=*/true);

    f.decide();

    REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
}

// ---------------------------------------------------------------------------
// AttackBehavior — overclaim penalty & hysteresis
// ---------------------------------------------------------------------------

// Absent claims, the nearer enemy wins; once another ship claims that enemy, the
// overclaim penalty steers the deciding ship to the unclaimed, equidistant one.
TEST_CASE("BehaviorSystem: overclaim penalty steers a ship off a claimed target",
          "[behavior]")
{
    SECTION("no claim: the nearer enemy is chosen")
    {
        Fixture f;
        const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
        const entt::entity nearEnemy = f.ships.spawn("interceptor", QVector2D(8.0f, 0.0f),
                                                     /*isEnemy=*/true);
        f.ships.spawn("interceptor", QVector2D(0.0f, 12.0f), /*isEnemy=*/true);

        f.decide();

        REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == nearEnemy);
    }

    SECTION("enemyA already claimed: penalty redirects to the unclaimed enemyB")
    {
        const float d = 10.0f;
        Fixture f;
        const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
        const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
                                                  /*isEnemy=*/true);
        const entt::entity enemyB = f.ships.spawn("interceptor", QVector2D(0.0f, d),
                                                  /*isEnemy=*/true);

        // A second player ship already commits to enemyA, registering a claim, so
        // the penalised score of enemyA falls below the unclaimed equidistant enemyB.
        const entt::entity claimant = f.ships.spawn("interceptor", QVector2D(d, 1.0f));
        f.admin.get<AttackBehavior>(claimant).currentTarget = enemyA;

        f.decide();

        REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyB);
    }
}

// Hysteresis keeps a ship on its committed target when a fresh candidate is only
// marginally better — and self-exclusion means the ship's own claim never counts
// against the target it already holds.
TEST_CASE("BehaviorSystem: hysteresis keeps a ship on its own claimed target",
          "[behavior]")
{
    const float d = 10.0f;
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
                                              /*isEnemy=*/true);
    f.ships.spawn("interceptor", QVector2D(0.0f, d), /*isEnemy=*/true);

    // The ship already holds enemyA; enemyB is equidistant. Without self-exclusion
    // the ship's own claim would penalise enemyA and flip the choice.
    f.admin.get<AttackBehavior>(player).currentTarget = enemyA;

    f.decide();

    REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyA);
}

// When the held target becomes heavily overclaimed by others, its penalised score
// drops far enough that an equidistant unclaimed enemy beats the hysteresis margin
// and the ship switches.
TEST_CASE("BehaviorSystem: a ship switches off a heavily overclaimed target",
          "[behavior]")
{
    const float d = 10.0f;
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
                                              /*isEnemy=*/true);
    const entt::entity enemyB = f.ships.spawn("interceptor", QVector2D(0.0f, d),
                                              /*isEnemy=*/true);

    f.admin.get<AttackBehavior>(player).currentTarget = enemyA;

    // Five other ships also commit to enemyA, saturating the claim penalty (0.5).
    for (int i = 0; i < 5; ++i)
    {
        const entt::entity other =
            f.ships.spawn("interceptor", QVector2D(d, static_cast<float>(2 + i)));
        f.admin.get<AttackBehavior>(other).currentTarget = enemyA;
    }

    f.decide();

    REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyB);
}

// ---------------------------------------------------------------------------
// AttackBehavior — enemy ships
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
          "[behavior]")
{
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemy  = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
                                               /*isEnemy=*/true);

    f.decide();

    REQUIRE(f.admin.hasAll<AttackBehavior>(enemy));
    const AttackBehavior& attack = f.admin.get<AttackBehavior>(enemy);
    REQUIRE(attack.currentTarget.has_value());
    REQUIRE(*attack.currentTarget == player);
    REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Attack);
}

TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward",
          "[behavior]")
{
    Fixture f;
    const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(100.0f, 0.0f),
                                              /*isEnemy=*/true);

    f.decide();

    REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
    REQUIRE(intent(f.admin, enemy).active);
    REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
}

TEST_CASE("BehaviorSystem: advancing ship targets center between enemy defence stations",
          "[behavior]")
{
    Fixture f;

    // Two enemy defence stations far from the ship (out of sensor/attack range),
    // 1x1 footprint so each center is anchor + (0.5, 0.5).
    const std::vector<QPoint> body{QPoint(0, 0)};
    f.admin.spawnStation(QPoint(1000, 10), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);
    f.admin.spawnStation(QPoint(1000, 30), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);

    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f),
                                              /*isEnemy=*/false);
    // Player ships rally until departure; drop Rally so Advance is the fallback.
    f.ships.triggerRallyDeparture();

    f.decide();

    // Centers (1000.5, 10.5) and (1000.5, 30.5) -> midpoint (1000.5, 20.5).
    REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Advance);
    REQUIRE(intent(f.admin, player).active);
    REQUIRE(intent(f.admin, player).target.x() == Approx(1000.5f));
    REQUIRE(intent(f.admin, player).target.y() == Approx(20.5f));
}

TEST_CASE("BehaviorSystem: advancing ship falls back to enemy HQ, then off-world",
          "[behavior]")
{
    Fixture f;

    // Player HQ proxy (isEnemy=false) but no player defence stations.
    const QVector2D hqPos(5.0f, 7.0f);
    const entt::entity hq = f.admin.spawnHqProxy(hqPos, 100.0f, 100.0f);

    const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(1000.0f, 0.0f),
                                             /*isEnemy=*/true);

    f.decide();

    REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
    REQUIRE(intent(f.admin, enemy).active);
    REQUIRE(intent(f.admin, enemy).target.x() == Approx(hqPos.x()));
    REQUIRE(intent(f.admin, enemy).target.y() == Approx(hqPos.y()));

    // With the HQ gone too, the ship falls back to advancing off-world (leftward).
    f.admin.get<HealthComponent>(hq).hp = 0.0f;
    f.decide();

    REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
    REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
}

// ---------------------------------------------------------------------------
// RepairBehavior
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: repair ship orbits damaged friendly ship",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    const entt::entity friendly   = f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f));

    f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;

    f.decide();

    REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Repair);
    REQUIRE(intent(f.admin, repairShip).active);

    // Orbit at orbit_factor * max repair range (REQ-SHP-ORBIT): the intent carries
    // the target's center and the orbit radius; MovementIntentSystem turns these
    // into a point on the orbit circle when it steers.
    const float orbitRadius = f.admin.get<RepairBehavior>(repairShip).orbitRadius_tiles;
    REQUIRE(orbitRadius > 0.0f);
    REQUIRE(intent(f.admin, repairShip).target == pos(f.admin, friendly).value);
    REQUIRE(intent(f.admin, repairShip).orbitRadius_tiles == Approx(orbitRadius));
}

// ---------------------------------------------------------------------------
// StandbyBehavior (repair ships hold with the fleet when idle)
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: idle repair ship stands by with the fleet instead of charging the enemy",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    // A healthy ally (nothing to repair) and a far enemy station (no threat in range).
    const entt::entity ally = f.ships.spawn("interceptor", QVector2D(50.0f, 0.0f));
    const std::vector<QPoint> body{QPoint(0, 0)};
    f.admin.spawnStation(QPoint(1000, 0), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);

    f.decide();

    // With no damaged ally and no enemy in sensor range the repair ship neither
    // repairs nor retreats: it stands by (REQ-SHP-STANDBY), steering toward its
    // fleet (the ally) rather than charging the distant enemy station.
    REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Standby);
    REQUIRE(intent(f.admin, repairShip).active);
    REQUIRE(intent(f.admin, repairShip).target.x() == Approx(pos(f.admin, ally).value.x()));
    REQUIRE(intent(f.admin, repairShip).target.y() == Approx(pos(f.admin, ally).value.y()));
}

TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout);
    const entt::entity friendly   = f.ships.spawn("interceptor", QVector2D(1.0f, 0.0f));

    const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
    f.admin.get<HealthComponent>(friendly).hp = initialHp;

    f.decide();
    f.runRepairHeal();

    REQUIRE(health(f.admin, friendly).hp > initialHp);
}

TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout);
    const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(1.0f, 0.0f));

    f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp - 0.001f;

    f.decide();
    f.runRepairHeal();

    const HealthComponent& h = health(f.admin, friendly);
    REQUIRE(h.hp <= h.maxHp);
    REQUIRE(h.hp == Approx(h.maxHp));
}

// ---------------------------------------------------------------------------
// RepairSystem — per-module targeting
// ---------------------------------------------------------------------------

TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the executor",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    const entt::entity friendly   = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));

    const float initHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
    f.admin.get<HealthComponent>(friendly).hp = initHp;

    f.decide();
    f.runRepairHeal();

    const entt::entity rc = firstRepairChild(f.admin, repairShip);
    REQUIRE(f.admin.isValid(rc));
    REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
    REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == friendly);
    REQUIRE(health(f.admin, friendly).hp > initHp);
}

TEST_CASE("RepairSystem: tool falls back to in-range target when its target is out of repair range",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    // out of repair range (80) but in sensor range (200)
    const entt::entity outOfRange = f.ships.spawn("interceptor", QVector2D(90.0f, 0.0f));
    // within repair range
    const entt::entity fallback   = f.ships.spawn("interceptor", QVector2D(20.0f, 0.0f));

    const float outInitHp      = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
    const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp  * 0.5f;
    f.admin.get<HealthComponent>(outOfRange).hp = outInitHp;
    f.admin.get<HealthComponent>(fallback).hp   = fallbackInitHp;

    // Seed the tool with an out-of-range target; RepairSystem must reacquire.
    const entt::entity rc = firstRepairChild(f.admin, repairShip);
    f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;

    f.runRepairHeal();

    REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
    REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
    REQUIRE(health(f.admin, fallback).hp   > fallbackInitHp);
    REQUIRE(health(f.admin, outOfRange).hp == Approx(outInitHp));
}

TEST_CASE("RepairSystem: tool falls back when its target is fully healed",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    const entt::entity healed   = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
    const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(15.0f, 0.0f));

    f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
    const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
    f.admin.get<HealthComponent>(fallback).hp  = fallbackInitHp;

    const entt::entity rc = firstRepairChild(f.admin, repairShip);
    f.admin.get<RepairToolComponent>(rc).currentTarget = healed;

    f.runRepairHeal();

    REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
    REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
}

TEST_CASE("RepairSystem: tool falls back when its target is destroyed",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    const entt::entity gone     = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
    const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(15.0f, 0.0f));

    const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
    f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;

    const entt::entity rc = firstRepairChild(f.admin, repairShip);
    f.admin.get<RepairToolComponent>(rc).currentTarget = gone;
    f.ships.despawn(gone);

    f.runRepairHeal();

    REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
    REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
}

TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in range",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    // damaged but beyond repair range (80)
    const entt::entity outOfRange = f.ships.spawn("interceptor", QVector2D(150.0f, 0.0f));

    const float initHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
    f.admin.get<HealthComponent>(outOfRange).hp = initHp;

    const entt::entity rc = firstRepairChild(f.admin, repairShip);
    f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;

    f.runRepairHeal();

    REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
    REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp));
}

TEST_CASE("RepairSystem: two repair modules both heal the chosen target additively",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    const entt::entity targetA    = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));

    const float initHp = f.admin.get<HealthComponent>(targetA).maxHp * 0.5f;
    f.admin.get<HealthComponent>(targetA).hp = initHp;

    f.decide();
    f.runRepairHeal();

    // Both modules run one cycle and heal targetA — total increase is 2 * repairAmountHp.
    // repair_amount_hp_formula = "5 + x" at x=1 → 6 HP per cycle.
    const float repairAmountHp = 5.0f + 1.0f;
    REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * repairAmountHp));

    const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
    REQUIRE(children.size() == 2);
    for (const entt::entity child : children)
    {
        REQUIRE(f.admin.get<RepairToolComponent>(child).currentTarget == targetA);
    }
}

TEST_CASE("RepairSystem: two modules both fall back and heal the same target",
          "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    const entt::entity healed   = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
    const entt::entity targetB  = f.ships.spawn("interceptor", QVector2D(20.0f, 0.0f));

    f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
    const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f;
    f.admin.get<HealthComponent>(targetB).hp = initHp;

    // Seed both tools with the (fully-healed) target; they must reacquire targetB.
    for (const entt::entity child : allRepairChildren(f.admin, repairShip))
    {
        f.admin.get<RepairToolComponent>(child).currentTarget = healed;
    }

    f.runRepairHeal();

    const float repairAmountHp = 5.0f + 1.0f;
    REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * repairAmountHp));

    const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
    REQUIRE(children.size() == 2);
    for (const entt::entity child : children)
    {
        REQUIRE(f.admin.get<RepairToolComponent>(child).currentTarget == targetB);
    }
}

TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship",
          "[behavior]")
{
    Fixture f;

    // Bare child entity: RepairToolComponent + ModuleOwnerComponent, owner is a combat ship.
    const entt::entity ownerShip = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity moduleEntity = f.admin.createModuleEntity();
    RepairToolComponent rt;
    rt.repairAmountHp         = 1.0f;
    rt.repairIntervalTicks    = kTickRateHz;
    rt.cooldownTicksRemaining = 0;
    rt.range_tiles            = 10.0f;
    rt.currentTarget          = std::nullopt;
    f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
    f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});

    // Must not crash; no damaged friendly in range, so no target is set.
    f.runRepairHeal();

    REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
}

TEST_CASE("RepairSystem: repair tool does not repair an HQ", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);

    // A damaged, same-faction HQ in repair range — spawned as a station and tagged
    // as an HQ (as the balancing arena does). The HQ is not a repair target.
    const std::vector<QPoint> cells = {QPoint(2, 0), QPoint(3, 0), QPoint(2, 1), QPoint(3, 1)};
    const entt::entity hq = f.admin.spawnStation(QPoint(2, 0), QSize(2, 2), cells,
                                                 100.0f, 200.0f, false);
    f.admin.addComponent<HqProxyComponent>(hq);

    f.decide();
    f.runRepairHeal();

    REQUIRE(f.admin.get<HealthComponent>(hq).hp == Approx(100.0f));
    const entt::entity rc = firstRepairChild(f.admin, repairShip);
    REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
}

TEST_CASE("RepairSystem: repair tool still repairs a damaged defence station", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);

    // A damaged, same-faction defence station (no HQ tag) in repair range.
    const std::vector<QPoint> cells = {QPoint(2, 0), QPoint(3, 0), QPoint(2, 1), QPoint(3, 1)};
    const entt::entity station = f.admin.spawnStation(QPoint(2, 0), QSize(2, 2), cells,
                                                      100.0f, 200.0f, false);

    f.decide();
    f.runRepairHeal();

    REQUIRE(f.admin.get<HealthComponent>(station).hp > 100.0f);
    const entt::entity rc = firstRepairChild(f.admin, repairShip);
    REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == station);
}

// ---------------------------------------------------------------------------
// SalvageScrapBehavior / DeliverScrapBehavior
// ---------------------------------------------------------------------------

TEST_CASE("BehaviorSystem: salvage ship orbits nearest scrap", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);

    const QVector2D scrapPos(100.0f, 0.0f);
    f.scraps.spawn(scrapPos, 1, 100000);

    f.decide();

    REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::SalvageScrap);
    REQUIRE(intent(f.admin, ship).active);

    // Orbit at orbit_factor * max collection range (REQ-SHP-ORBIT): the intent
    // carries the scrap center and the orbit radius.
    const float orbitRadius = f.admin.get<SalvageScrapBehavior>(ship).orbitRadius_tiles;
    REQUIRE(orbitRadius > 0.0f);
    REQUIRE(intent(f.admin, ship).target == scrapPos);
    REQUIRE(intent(f.admin, ship).orbitRadius_tiles == Approx(orbitRadius));
}

TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship       = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                                   false, salvageLayout);
    const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);

    f.runSalvageCollect();

    const entt::entity sc = firstSalvageChild(f.admin, ship);
    REQUIRE(f.admin.isValid(sc));
    REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
    REQUIRE_FALSE(f.admin.isValid(scrapEntity));
}

TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]")
{
    Fixture f;

    const BuildingId bayId = f.buildings.place(f.state, BuildingType::SalvageBay,
                                              QPoint(-4, 0), Rotation::East, 0).value();
    Tick t = 0;
    for (int i = 0; i < 500; ++i)
    {
        f.construction.tick(f.state, f.belts, t++);
        if (findBuilding(f.state, bayId) != nullptr)
        {
            break;
        }
    }
    REQUIRE(findBuilding(f.state, bayId) != nullptr);

    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(5.0f, 0.0f),
                                             false, salvageLayout);
    {
        REQUIRE(f.admin.isValid(firstSalvageChild(f.admin, ship)));
        CargoComponent& cargo = f.admin.get<CargoComponent>(ship);
        cargo.current = cargo.maxCapacity;  // full cargo
    }

    f.decide();

    REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::DeliverScrap);
    REQUIRE(f.admin.get<DeliverScrapBehavior>(ship).deliveryBay == bayId);
    const MovementIntentComponent& i = intent(f.admin, ship);
    REQUIRE(i.active);
    REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
}

TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo", "[behavior]")
{
    Fixture f;

    const BuildingId bayId = f.buildings.place(f.state, BuildingType::SalvageBay,
                                              QPoint(-4, 0), Rotation::East, 0).value();
    Tick t = 0;
    for (int i = 0; i < 500; ++i)
    {
        f.construction.tick(f.state, f.belts, t++);
        if (findBuilding(f.state, bayId) != nullptr) { break; }
    }
    const Building* bay = findBuilding(f.state, bayId);
    REQUIRE(bay != nullptr);
    // Config-driven output-buffer capacity is applied on placement, onto the single
    // scrap buffer the bay holds (REQ-BLD-SALVAGE-BAY).
    REQUIRE(bay->outputBuffer.caps.at(ItemType{"scrap"}) == 20);

    const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width()  / 2.0f,
                              bay->anchor.y() + bay->footprint.height() / 2.0f);

    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", bayCenter, false, salvageLayout);
    f.admin.get<PositionComponent>(ship).value = bayCenter;
    CargoComponent& cargo = f.admin.get<CargoComponent>(ship);
    cargo.current = cargo.maxCapacity;  // full cargo
    const int before = cargo.current;
    REQUIRE(before > 0);
    f.admin.get<DeliverScrapBehavior>(ship).deliveryBay = bayId;

    f.salvageTick();

    // One unit handed over from cargo into the bay's output buffer.
    REQUIRE(f.admin.get<CargoComponent>(ship).current == before - 1);
    const Building* bayAfter = findBuilding(f.state, bayId);
    REQUIRE(bayAfter != nullptr);
    REQUIRE(bayAfter->outputBuffer.items.size() == 1);
    REQUIRE(bayAfter->outputBuffer.items.front().type.id == "scrap");
}

// ---------------------------------------------------------------------------
// Collection range (per-module)
// ---------------------------------------------------------------------------

static int totalSalvageCurrent(EntityAdmin& admin, entt::entity ship)
{
    return admin.hasAll<CargoComponent>(ship)
        ? admin.get<CargoComponent>(ship).current
        : 0;
}

TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection range",
          "[behavior]")
{
    // collection_range_m_formula = "50"; scrap at distance 55 must not be collected.
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);
    f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);

    f.runSalvageCollect();

    REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
}

TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
          "[behavior]")
{
    // collection_range_m_formula = "50"; scrap at distance 45 must be collected.
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);
    f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);

    f.runSalvageCollect();

    REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
}

// ---------------------------------------------------------------------------
// Collection rate (per-module cooldown)
// ---------------------------------------------------------------------------

TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);
    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);

    // Starting a collection cycle sets the cooldown immediately; the scrap is not
    // collected until mid-beam (REQ-SHP-SALVAGE), so cargo is still empty now.
    f.salvageTick();

    REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
    const SalvagerComponent& salvager =
        f.admin.get<SalvagerComponent>(firstSalvageChild(f.admin, ship));
    REQUIRE(salvager.cooldownTicksRemaining == salvager.collectionIntervalTicks);
}

TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);
    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);

    f.admin.get<SalvagerComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;

    f.runSalvageCollect();

    REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
}

TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);
    const entt::entity sc = firstSalvageChild(f.admin, ship);

    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
    f.runSalvageCollect();
    REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);

    // Shorten cooldown to 1 tick and place a second scrap.
    f.admin.get<SalvagerComponent>(sc).cooldownTicksRemaining = 1;
    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);

    // Once the cooldown expires the module starts another cycle and collects the
    // second scrap after the mid-beam delay.
    f.runSalvageCollect();

    REQUIRE(f.admin.get<CargoComponent>(ship).current == 2);
}

// ---------------------------------------------------------------------------
// Multiple salvage modules
// ---------------------------------------------------------------------------

TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tick", "[behavior]")
{
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);

    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);

    f.runSalvageCollect();

    REQUIRE(totalSalvageCurrent(f.admin, ship) == 2);
}

TEST_CASE("SalvagerSystem: second salvage module does not collect when first is on cooldown",
          "[behavior]")
{
    // One module on cooldown, one ready: only the ready module collects.
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);

    // Put the first salvage child on cooldown.
    entt::entity blocked = entt::null;
    f.admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
        [&](entt::entity ce, SalvagerComponent& s, const ModuleOwnerComponent& o)
        {
            if (o.owner == ship && blocked == entt::null)
            {
                s.cooldownTicksRemaining = 99;
                blocked = ce;
            }
        });

    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
    f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);

    f.runSalvageCollect();

    // Only one module was ready, so only one scrap is collected.
    REQUIRE(totalSalvageCurrent(f.admin, ship) == 1);
}

// ---------------------------------------------------------------------------
// Sensor range — spawn
// ---------------------------------------------------------------------------

TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]")
{
    Fixture f;
    const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    REQUIRE(f.admin.get<SensorRangeComponent>(e).value_tiles == Approx(200.0f));
}

// ---------------------------------------------------------------------------
// Sensor range — AttackBehavior
// ---------------------------------------------------------------------------

TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
{
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemy  = f.ships.spawn("interceptor", QVector2D(190.0f, 0.0f),
                                               /*isEnemy=*/true);

    f.decide();

    REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemy);
}

TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
{
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f), /*isEnemy=*/true);

    f.decide();

    REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
}

TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
{
    Fixture f;
    f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f),
                                              /*isEnemy=*/true);

    f.decide();

    REQUIRE_FALSE(f.admin.get<AttackBehavior>(enemy).currentTarget.has_value());
}

// ---------------------------------------------------------------------------
// Sensor range — RetreatBehavior (unarmed ships flee threats)
// ---------------------------------------------------------------------------

TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
{
    Fixture f;
    const QVector2D rallyPoint(-100.0f, 0.0f);
    f.ships.setRallyPoint(rallyPoint);
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    f.ships.spawn("interceptor", QVector2D(200.0f, 0.0f), /*isEnemy=*/true);

    f.decide();

    REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Retreat);
    REQUIRE(intent(f.admin, repairShip).target.x() == Approx(rallyPoint.x()));
}

TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    f.ships.spawn("interceptor", QVector2D(300.0f, 0.0f), /*isEnemy=*/true);

    f.decide();

    // Beyond sensor range the enemy is no threat, so the repair ship does not flee;
    // with nothing to repair it holds with the fleet (REQ-SHP-STANDBY) rather than
    // retreating or charging the enemy.
    REQUIRE(winnerOf(f.admin, repairShip) != BehaviorKind::Retreat);
    REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Standby);
}

TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]")
{
    Fixture f;
    const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
    const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
                                                   false, repairLayout);
    const entt::entity friendly   = f.ships.spawn("interceptor", QVector2D(300.0f, 0.0f));
    f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;

    f.decide();

    REQUIRE_FALSE(f.admin.get<RepairBehavior>(repairShip).currentTarget.has_value());
}

// ---------------------------------------------------------------------------
// Sensor range — SalvageScrapBehavior
// ---------------------------------------------------------------------------

TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
{
    Fixture f;
    const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
    const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
                                             false, salvageLayout);
    f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);

    f.decide();

    REQUIRE_FALSE(f.admin.get<SalvageScrapBehavior>(ship).debrisTarget.has_value());
    REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
}

// ---------------------------------------------------------------------------
// Orbit movement (REQ-SHP-ORBIT)
// ---------------------------------------------------------------------------

TEST_CASE("Orbit: combat ship's intent carries the target center and orbit radius",
          "[orbit]")
{
    Fixture f;
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemy  = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
                                               /*isEnemy=*/true);

    f.decide();

    REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack);
    const float orbitRadius = f.admin.get<AttackBehavior>(player).orbitRadius_tiles;
    REQUIRE(orbitRadius > 0.0f);
    // The intent carries the enemy center and the orbit radius; the orbit point is
    // resolved later by MovementIntentSystem.
    REQUIRE(intent(f.admin, player).target == pos(f.admin, enemy).value);
    REQUIRE(intent(f.admin, player).orbitRadius_tiles == Approx(orbitRadius));
}

TEST_CASE("Orbit: rally ship orbits the rally point at the configured rally radius",
          "[orbit]")
{
    Fixture f;
    const QVector2D rallyPoint(-50.0f, 0.0f);
    f.ships.setRallyPoint(rallyPoint);
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));

    f.decide();

    REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Rally);
    const float orbitRadius = f.admin.get<RallyBehavior>(player).orbitRadius_tiles;
    REQUIRE(orbitRadius == Approx(static_cast<float>(f.cfg.world.rallyOrbitRadius_tiles)));
    REQUIRE(intent(f.admin, player).target == rallyPoint);
    REQUIRE(intent(f.admin, player).orbitRadius_tiles == Approx(orbitRadius));
}

TEST_CASE("Orbit: combat ship settles near the orbit radius and circles a stationary target",
          "[orbit]")
{
    Fixture f;
    const QVector2D enemyPos(80.0f, 0.0f);
    const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
    const entt::entity enemy  = f.ships.spawn("interceptor", enemyPos, /*isEnemy=*/true);

    const float orbitRadius = f.admin.get<AttackBehavior>(player).orbitRadius_tiles;
    REQUIRE(orbitRadius > 0.0f);
    REQUIRE(orbitRadius < enemyPos.x());  // ship must close in to reach the orbit

    // Run many full ticks, pinning the enemy in place so it is a stationary target.
    float angleBefore = 0.0f;
    for (int i = 0; i < 1200; ++i)
    {
        f.admin.get<PositionComponent>(enemy).value = enemyPos;       // keep target fixed
        f.admin.get<DynamicBodyComponent>(enemy).velocity_tpt = QVector2D(0.0f, 0.0f);
        if (i == 800)
        {
            angleBefore = std::atan2(pos(f.admin, player).value.y() - enemyPos.y(),
                                     pos(f.admin, player).value.x() - enemyPos.x());
        }
        f.runBehaviorTick();
    }
    const float angleAfter = std::atan2(pos(f.admin, player).value.y() - enemyPos.y(),
                                        pos(f.admin, player).value.x() - enemyPos.x());

    // Settled close to the orbit radius (chasing a moving lead point oscillates a bit).
    const float dist = (pos(f.admin, player).value - enemyPos).length();
    REQUIRE(dist == Approx(orbitRadius).margin(0.35f * orbitRadius));
    // The ship is circling: its angular position around the target has moved.
    REQUIRE(std::abs(angleAfter - angleBefore) > 0.05f);
}

TEST_CASE("OrbitMath: sign mirrors the orbit destination across the radial",
          "[orbit]")
{
    const QVector2D center(0.0f, 0.0f);
    const QVector2D shipPos(5.0f, 0.0f);
    const float radius = 5.0f;

    const QVector2D ccw = OrbitMath::computeOrbitDestination(shipPos, center, radius, +1.0f);
    const QVector2D cw  = OrbitMath::computeOrbitDestination(shipPos, center, radius, -1.0f);

    // Both lie exactly on the orbit circle.
    REQUIRE((ccw - center).length() == Approx(radius));
    REQUIRE((cw - center).length() == Approx(radius));
    // Opposite tangential lead: CCW leads to +y, CW to -y; x components match.
    REQUIRE(ccw.y() > 0.0f);
    REQUIRE(cw.y() < 0.0f);
    REQUIRE(ccw.y() == Approx(-cw.y()));
    REQUIRE(ccw.x() == Approx(cw.x()));
}

TEST_CASE("OrbitMath: orbit sign follows the ship's tangential velocity",
          "[orbit]")
{
    const QVector2D center(0.0f, 0.0f);
    const QVector2D shipPos(5.0f, 0.0f);  // radial points +x

    // Tangential +y velocity is counter-clockwise around the center → +1.
    REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 1.0f)) == Approx(1.0f));
    // Tangential -y velocity is clockwise → -1.
    REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, -1.0f)) == Approx(-1.0f));
    // Radial velocity (toward/away from center) is ambiguous → fallback +1.
    REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(-1.0f, 0.0f)) == Approx(1.0f));
    // Zero velocity (e.g. freshly spawned) → fallback +1.
    REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 0.0f)) == Approx(1.0f));
}

TEST_CASE("OrbitMath: orbit sense uses velocity relative to a moving center",
          "[orbit]")
{
    const QVector2D center(0.0f, 0.0f);
    const QVector2D shipPos(5.0f, 0.0f);  // radial points +x

    // Two ships orbiting each other can translate in parallel: the ship and the
    // center share the same velocity, so relative velocity cancels → fallback +1
    // (both ships agree on the sign and break into a real mutual orbit).
    const QVector2D sharedVelocity(0.0f, 3.0f);
    REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, sharedVelocity, sharedVelocity)
            == Approx(1.0f));

    // A moving center's own motion is removed: the ship is stationary while the
    // center drifts +y, so relative motion is -y → clockwise → -1.
    REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 0.0f),
                                        QVector2D(0.0f, 3.0f)) == Approx(-1.0f));
}
