#include "catch.hpp"

#include "WorldCamera.h"
#include "WorldConfig.h"

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// Slow 10 tiles/s, fast 50 tiles/s, and a 4-tile ramp band — so each ramp spans
// 2 tiles either side of a contest-zone boundary and the arithmetic stays exact.
static WorldScroll makeScroll(int rampBandWidth_tiles = 4)
{
    WorldScroll scroll;
    scroll.panSpeedSlow_tps       = 10.0;
    scroll.panSpeedFast_tps       = 50.0;
    scroll.panRampBandWidth_tiles = rampBandWidth_tiles;
    return scroll;
}

// The player buffer's right edge — the contest zone's left boundary — sits at 20.
static WorldRegions makeRegions()
{
    WorldRegions regions;
    regions.asteroidWidth_tiles     = 10;
    regions.playerBufferWidth_tiles = 20;
    regions.contestZoneWidth_tiles  = 60;
    regions.enemyBufferWidth_tiles  = 10;
    return regions;
}

static ScrollBounds makeBounds(float leftTiles = -100.0f, float rightTiles = 100.0f)
{
    return ScrollBounds{leftTiles, rightTiles};
}

// ---------------------------------------------------------------------------
// Pan speed ramp (REQ-UI-SCROLL-SPEED)
// ---------------------------------------------------------------------------

TEST_CASE("Pan speed is slow over the asteroid and player buffer", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    // Left of the rising ramp band (which starts at 20 - 2 = 18).
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(-50.0f, 80.0f) == Approx(10.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f,   80.0f) == Approx(10.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(18.0f,  80.0f) == Approx(10.0f));
}

TEST_CASE("Pan speed is fast across the middle of the contest zone", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    // Between the two ramp bands: past 20 + 2 and before 80 - 2.
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(22.0f, 80.0f) == Approx(50.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(78.0f, 80.0f) == Approx(50.0f));
}

TEST_CASE("Pan speed ramps linearly across each contest-zone boundary", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    // Rising band spans [18, 22]; the boundary itself is the midpoint.
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 80.0f) == Approx(30.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.0f, 80.0f) == Approx(20.0f));
    // Falling band spans [78, 82], mirrored.
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.0f, 80.0f) == Approx(30.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(81.0f, 80.0f) == Approx(20.0f));
}

TEST_CASE("Pan speed returns to slow past the enemy stations", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    REQUIRE(camera.getPanSpeedTilesPerSecondAt(82.0f,  80.0f) == Approx(10.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(200.0f, 80.0f) == Approx(10.0f));
}

TEST_CASE("The ramp follows the front line as it is pushed", "[camera]")
{
    // The right boundary is passed in per call, so a push that moves the enemy
    // stations moves the falling ramp with it (REQ-GW-PUSH-EXPAND).
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    // X = 90 is past the old boundary (slow) but well inside the pushed-back one.
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 80.0f)  == Approx(10.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 140.0f) == Approx(50.0f));
}

TEST_CASE("Overlapping ramp bands peak below the fast speed", "[camera]")
{
    // A contest zone narrower than the ramp band never reaches full speed: the two
    // ramps cross before either tops out, leaving a single peak where they meet.
    const WorldScroll  scroll  = makeScroll(/*rampBandWidth_tiles*/ 40);
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    // Bands are [0, 40] rising and [10, 50] falling; they cross at x = 25.
    const float peak = camera.getPanSpeedTilesPerSecondAt(25.0f, 30.0f);
    REQUIRE(peak > 10.0f);
    REQUIRE(peak < 50.0f);

    // And it really is the maximum — the neighbours on both sides are lower.
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 30.0f) < peak);
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(30.0f, 30.0f) < peak);
}

TEST_CASE("A zero-width ramp band steps straight from slow to fast", "[camera]")
{
    const WorldScroll  scroll  = makeScroll(/*rampBandWidth_tiles*/ 0);
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.99f, 80.0f) == Approx(10.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f,  80.0f) == Approx(50.0f));
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.01f, 80.0f) == Approx(10.0f));
}

// ---------------------------------------------------------------------------
// Panning
// ---------------------------------------------------------------------------

TEST_CASE("The camera starts centered on the world origin", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);
    REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}

TEST_CASE("Panning moves the view center at the local pan speed", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    WorldCamera        camera(scroll, regions);

    // Starting at 0, the local speed is the slow one: 10 tiles/s for 500 ms.
    REQUIRE(camera.advance(PanDirection::Right, 500, makeBounds()));
    REQUIRE(camera.getViewCenterXTiles() == Approx(5.0f));

    REQUIRE(camera.advance(PanDirection::Left, 500, makeBounds()));
    REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}

TEST_CASE("Not panning leaves the view center alone", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    WorldCamera        camera(scroll, regions);

    camera.advance(PanDirection::Right, 500, makeBounds());
    const float before = camera.getViewCenterXTiles();

    REQUIRE_FALSE(camera.advance(PanDirection::None, 500, makeBounds()));
    REQUIRE(camera.getViewCenterXTiles() == Approx(before));
}

// ---------------------------------------------------------------------------
// Clamping (REQ-GW-SCROLL-LIMIT)
// ---------------------------------------------------------------------------

TEST_CASE("Panning stops at the scroll bounds", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    WorldCamera        camera(scroll, regions);

    // Far more time than it takes to cross the bound.
    camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
    REQUIRE(camera.getViewCenterXTiles() == Approx(12.0f));

    camera.advance(PanDirection::Left, 100000, makeBounds(-5.0f, 12.0f));
    REQUIRE(camera.getViewCenterXTiles() == Approx(-5.0f));
}

TEST_CASE("Shrinking bounds pull the view in even without panning", "[camera]")
{
    // Bounds move as the game progresses, so clamping cannot wait for the player
    // to press a key — a view left outside them would show unreachable world.
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    WorldCamera        camera(scroll, regions);

    camera.advance(PanDirection::Right, 100000, makeBounds(-50.0f, 40.0f));
    REQUIRE(camera.getViewCenterXTiles() == Approx(40.0f));

    // The right bound comes in; the camera must follow it despite no pan input,
    // and must report that it moved.
    REQUIRE(camera.advance(PanDirection::None, 16, makeBounds(-50.0f, 25.0f)));
    REQUIRE(camera.getViewCenterXTiles() == Approx(25.0f));
}

TEST_CASE("Panning into a bound the view already sits on reports no movement",
          "[camera]")
{
    // The caller refreshes the box-select rectangle on a true return, so a camera
    // pinned against its limit must not keep claiming to have moved.
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    WorldCamera        camera(scroll, regions);

    camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
    REQUIRE_FALSE(camera.advance(PanDirection::Right, 16, makeBounds(-5.0f, 12.0f)));
}

TEST_CASE("Reset returns the view to the origin", "[camera]")
{
    const WorldScroll  scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    WorldCamera        camera(scroll, regions);

    camera.advance(PanDirection::Right, 2000, makeBounds());
    REQUIRE(camera.getViewCenterXTiles() != Approx(0.0f));

    camera.reset();
    REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}

TEST_CASE("The camera tracks config edited after construction", "[camera]")
{
    // The camera references the config rather than copying it, so a restart that
    // reloads world.toml in place (REQ-CFG-RELOAD) changes the pan speed without
    // the camera being rebuilt.
    WorldScroll        scroll  = makeScroll();
    const WorldRegions regions = makeRegions();
    const WorldCamera  camera(scroll, regions);

    REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
    scroll.panSpeedSlow_tps = 3.0;
    REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(3.0f));
}
