package be.seeseepuff.allowanceplanner;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

import java.time.Instant;
import java.util.List;
import java.util.Map;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class ApiTest {
    private static final String TEST_HISTORY_NAME = "Test History";

    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18")
            .withDatabaseName("allowance_planner_test")
            .withUsername("test")
            .withPassword("test");

    @LocalServerPort
    int port;

    @Autowired
    Flyway flyway;

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.flyway.clean-disabled", () -> "false");
    }

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
        RestAssured.basePath = "/api";
        RestAssured.config = RestAssured.config()
                .jsonConfig(io.restassured.config.JsonConfig.jsonConfig()
                        .numberReturnType(io.restassured.path.json.config.JsonPathConfig.NumberReturnType.DOUBLE));

        // Clean and re-migrate the database before each test
        flyway.clean();
        flyway.migrate();
    }

    // ---- User Tests ----

    @Test
    void getUsers() {
        given()
                .when()
                .get("/users")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[0].name", isA(String.class))
                .body("[1].name", isA(String.class));
    }

    @Test
    void getUser() {
        given()
                .when()
                .get("/user/1")
                .then()
                .statusCode(200)
                .body("name", is("Seeseemelk"))
                .body("id", is(1))
                .body("allowance", is(0.0d));
    }

    @Test
    void getUserUnknown() {
        given()
                .when()
                .get("/user/999")
                .then()
                .statusCode(404);
    }

    @Test
    void getUserBadId() {
        given()
                .when()
                .get("/user/bad-id")
                .then()
                .statusCode(400);
    }

    // ---- Allowance Tests ----

    @Test
    void getUserAllowanceWhenNoAllowancePresent() {
        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].id", is(0));
    }

    @Test
    void getUserAllowance() {
        createAllowance(1, TEST_HISTORY_NAME, 5000, 10);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[1].name", is(TEST_HISTORY_NAME))
                .body("[1].target", is(5000.0d))
                .body("[1].weight", is(10.0d))
                .body("[1].progress", is(0.0d))
                .body("[1]", not(hasKey("user_id")));
    }

    @Test
    void getUserAllowanceNoUser() {
        given()
                .when()
                .get("/user/999/allowance")
                .then()
                .statusCode(404);
    }

    @Test
    void getUserAllowanceBadId() {
        given()
                .when()
                .get("/user/bad-id/allowance")
                .then()
                .statusCode(400);
    }

    @Test
    void createUserAllowance() {
        int allowanceId = createAllowance(1, TEST_HISTORY_NAME, 5000, 10);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[1].id", is(allowanceId))
                .body("[1].name", is(TEST_HISTORY_NAME))
                .body("[1].target", is(5000.0d))
                .body("[1].weight", is(10.0d))
                .body("[1].progress", is(0.0d));
    }

    @Test
    void createUserAllowanceNoUser() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", TEST_HISTORY_NAME, "target", 5000, "weight", 10))
                .when()
                .post("/user/999/allowance")
                .then()
                .statusCode(404);
    }

    @Test
    void createUserAllowanceInvalidInput() {
        // Empty name
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "", "target", 5000, "weight", 10))
                .when()
                .post("/user/1/allowance")
                .then()
                .statusCode(400);

        // Missing name
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("target", 5000))
                .when()
                .post("/user/1/allowance")
                .then()
                .statusCode(400);
    }

    @Test
    void createUserAllowanceBadId() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", TEST_HISTORY_NAME, "target", 5000, "weight", 10))
                .when()
                .post("/user/bad-id/allowance")
                .then()
                .statusCode(400);
    }

    @Test
    void deleteUserAllowance() {
        int allowanceId = createAllowance(1, TEST_HISTORY_NAME, 1000, 5);

        given()
                .when()
                .delete("/user/1/allowance/" + allowanceId)
                .then()
                .statusCode(200)
                .body("message", is("History deleted successfully"));

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(1));
    }

    @Test
    void deleteUserRestAllowance() {
        given()
                .when()
                .delete("/user/1/allowance/0")
                .then()
                .statusCode(400);
    }

    @Test
    void deleteUserAllowanceNotFound() {
        given()
                .when()
                .delete("/user/1/allowance/999")
                .then()
                .statusCode(404)
                .body("error", is("History not found"));
    }

    @Test
    void deleteUserAllowanceInvalidId() {
        given()
                .when()
                .delete("/user/1/allowance/invalid-id")
                .then()
                .statusCode(400)
                .body("error", is("Invalid allowance ID"));
    }

    // ---- Task Tests ----

    @Test
    void createTask() {
        // Without assigned user
        int taskId = given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "Test Task", "reward", 100))
                .when()
                .post("/tasks")
                .then()
                .statusCode(201)
                .body("id", notNullValue())
                .extract().path("id");

        given().when().get("/tasks").then().statusCode(200).body("$.size()", is(1));

        given()
                .when()
                .get("/task/" + taskId)
                .then()
                .statusCode(200)
                .body("id", is(taskId))
                .body("name", is("Test Task"))
                .body("reward", is(100.0d))
                .body("assigned", nullValue());

        // With assigned user
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "Test Task Assigned", "reward", 200, "assigned", 1))
                .when()
                .post("/tasks")
                .then()
                .statusCode(201)
                .body("id", notNullValue());
    }

    @Test
    void deleteTask() {
        int taskId = createTestTask(100);

        given().when().delete("/task/" + taskId).then().statusCode(200);
        given().when().get("/task/" + taskId).then().statusCode(404);
    }

    @Test
    void deleteTaskNotFound() {
        given().when().delete("/task/1").then().statusCode(404);
    }

    @Test
    void createTaskNoName() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("reward", 100))
                .when()
                .post("/tasks")
                .then()
                .statusCode(400);
    }

    @Test
    void createTaskInvalidAssignedUser() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "Test Task Invalid User", "reward", 100, "assigned", 999))
                .when()
                .post("/tasks")
                .then()
                .statusCode(404)
                .body("error", is("User not found"));
    }

    @Test
    void createTaskInvalidRequestBody() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("reward", 5000))
                .when()
                .post("/tasks")
                .then()
                .statusCode(400);
    }

    @Test
    void getTaskWhenNoTasks() {
        given()
                .when()
                .get("/tasks")
                .then()
                .statusCode(200)
                .body("$.size()", is(0));
    }

    @Test
    void getTasksWhenTasks() {
        createTestTask(100);

        given()
                .when()
                .get("/tasks")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].name", is("Test Task"))
                .body("[0].reward", is(100.0d))
                .body("[0].assigned", nullValue());
    }

    @Test
    void getTask() {
        int taskId = createTestTask(100);

        given()
                .when()
                .get("/task/" + taskId)
                .then()
                .statusCode(200)
                .body("id", is(taskId))
                .body("name", is("Test Task"))
                .body("reward", is(100.0d))
                .body("assigned", nullValue());
    }

    @Test
    void getTaskInvalidId() {
        createTestTask(100);
        // Task ID won't be found since we use auto-increment and there's only one
        given().when().get("/task/99999").then().statusCode(404);
    }

    @Test
    void getTaskBadId() {
        createTestTask(100);
        given().when().get("/task/invalid").then().statusCode(400);
    }

    @Test
    void putTaskModifiesTask() {
        int taskId = createTestTask(100);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "Updated Task", "reward", 100))
                .when()
                .put("/task/" + taskId)
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/task/" + taskId)
                .then()
                .statusCode(200)
                .body("id", is(taskId))
                .body("name", is("Updated Task"))
                .body("reward", is(100.0d));
    }

    @Test
    void putTaskInvalidTaskId() {
        createTestTask(100);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "Updated Task"))
                .when()
                .put("/task/999")
                .then()
                .statusCode(404);
    }

    // ---- History Tests ----

    @Test
    void postHistory() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", 100, "description", "Add a 100"))
                .when()
                .post("/user/1/history")
                .then()
                .statusCode(200);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", 20, "description", "Lolol"))
                .when()
                .post("/user/1/history")
                .then()
                .statusCode(200);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", -10, "description", "Subtracting"))
                .when()
                .post("/user/1/history")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1")
                .then()
                .statusCode(200)
                .body("allowance", is(110.0d));
    }

    @Test
    void postHistoryInvalidUserId() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", 100, "description", "Good"))
                .when()
                .post("/user/999/history")
                .then()
                .statusCode(404);
    }

    @Test
    void postHistoryInvalidDescription() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", 100))
                .when()
                .post("/user/1/history")
                .then()
                .statusCode(400);
    }

    @Test
    void getHistory() {
        Instant before = Instant.now().minusSeconds(2);
        Instant after = Instant.now().plusSeconds(2);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", 100, "description", "Add 100"))
                .when()
                .post("/user/1/history")
                .then()
                .statusCode(200);
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", 20, "description", "Add 20"))
                .when()
                .post("/user/1/history")
                .then()
                .statusCode(200);
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("allowance", -10, "description", "Subtract 10"))
                .when()
                .post("/user/1/history")
                .then()
                .statusCode(200);

        // History is returned newest first (by ID desc)
        given()
                .when()
                .get("/user/1/history")
                .then()
                .statusCode(200)
                .body("$.size()", is(3))
                .body("[0].allowance", is(-10.0d))
                .body("[0].description", is("Subtract 10"))
                .body("[1].allowance", is(20.0d))
                .body("[1].description", is("Add 20"))
                .body("[2].allowance", is(100.0d))
                .body("[2].description", is("Add 100"));
    }

    // ---- Allowance By ID Tests ----

    @Test
    void getUserAllowanceById() {
        int allowanceId = createAllowanceWithColour(1, TEST_HISTORY_NAME, 5000, 10, "#FF5733");

        given()
                .when()
                .get("/user/1/allowance/" + allowanceId)
                .then()
                .statusCode(200)
                .body("id", is(allowanceId))
                .body("name", is(TEST_HISTORY_NAME))
                .body("target", is(5000.0d))
                .body("weight", is(10.0d))
                .body("progress", is(0.0d))
                .body("colour", is("#FF5733"));

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[1].id", is(allowanceId))
                .body("[1].colour", is("#FF5733"));
    }

    @Test
    void getUserByAllowanceIdInvalidAllowance() {
        given().when().get("/user/1/allowance/9999").then().statusCode(404);
    }

    @Test
    void getUserByAllowanceByIdInvalidUserId() {
        given().when().get("/user/999/allowance/1").then().statusCode(404);
    }

    @Test
    void getUserByAllowanceByIdBadUserId() {
        given().when().get("/user/bad/allowance/1").then().statusCode(400);
    }

    @Test
    void getUserByAllowanceByIdBadAllowanceId() {
        given().when().get("/user/1/allowance/bad").then().statusCode(400);
    }

    @Test
    void putAllowanceById() {
        int allowanceId = createAllowanceWithColour(1, TEST_HISTORY_NAME, 5000, 10, "#FF5733");

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "Updated Allowance", "target", 6000, "weight", 15, "colour", "#3357FF"))
                .when()
                .put("/user/1/allowance/" + allowanceId)
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance/" + allowanceId)
                .then()
                .statusCode(200)
                .body("id", is(allowanceId))
                .body("name", is("Updated Allowance"))
                .body("target", is(6000.0d))
                .body("weight", is(15.0d))
                .body("colour", is("#3357FF"));
    }

    // ---- Complete Task Tests ----

    @Test
    void completeTask() {
        int taskId = createTestTask(101);

        given().when().get("/tasks").then().statusCode(200).body("$.size()", is(1));

        // Update rest allowance weight
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "", "target", 0, "weight", 25, "colour", ""))
                .when()
                .put("/user/1/allowance/0")
                .then()
                .statusCode(200);

        // Create two allowance goals
        createAllowance(1, "Test Allowance 1", 100, 50);
        createAllowance(1, "Test Allowance 1", 10, 25);

        // Complete the task
        given().when().post("/task/" + taskId + "/complete").then().statusCode(200);

        // Verify task is completed
        given().when().get("/task/" + taskId).then().statusCode(404);

        // Verify allowances for user 1
        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(3))
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(30.34, 0.01))
                .body("[1].progress", closeTo(60.66, 0.01))
                .body("[2].progress", closeTo(10.0, 0.01));

        // Verify allowances for user 2
        given()
                .when()
                .get("/user/2/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(101.0, 0.01));

        // Verify history for both users
        for (int userId = 1; userId <= 2; userId++) {
            given()
                    .when()
                    .get("/user/" + userId + "/history")
                    .then()
                    .statusCode(200)
                    .body("$.size()", is(1))
                    .body("[0].allowance", closeTo(101.0, 0.01));
        }
    }

    @Test
    void completeTaskWithNoWeights() {
        int taskId = createTestTask(101);

        given().when().get("/tasks").then().statusCode(200).body("$.size()", is(1));

        // Ensure main allowance has no weight
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "", "target", 0, "weight", 0, "colour", ""))
                .when()
                .put("/user/1/allowance/0")
                .then()
                .statusCode(200);

        // Complete the task
        given().when().post("/task/" + taskId + "/complete").then().statusCode(200);

        // Verify task is completed
        given().when().get("/task/" + taskId).then().statusCode(404);

        // Verify allowances for user 1
        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(101.0, 0.01));

        // Verify allowances for user 2
        given()
                .when()
                .get("/user/2/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(101.0, 0.01));
    }

    @Test
    void completeTaskAllowanceWeightsSumTo0() {
        int taskId = createTestTask(101);

        given().when().get("/tasks").then().statusCode(200).body("$.size()", is(1));

        // Update rest allowance to 0 weight
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "", "target", 0, "weight", 0, "colour", ""))
                .when()
                .put("/user/1/allowance/0")
                .then()
                .statusCode(200);

        // Create allowance with 0 weight
        createAllowance(1, "Test Allowance 1", 1000, 0);

        // Complete the task
        given().when().post("/task/" + taskId + "/complete").then().statusCode(200);

        // Verify allowances for user 1
        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(101.0, 0.01))
                .body("[1].progress", closeTo(0.0, 0.01));
    }

    @Test
    void completeTaskInvalidId() {
        given().when().post("/task/999/complete").then().statusCode(404);
    }

    // ---- Complete Allowance Tests ----

    @Test
    void completeAllowance() {
        createTestTask(100);
        createAllowance(1, "Test Allowance 1", 100, 50);

        // Update base allowance to 0 weight
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "", "target", 0, "weight", 0, "colour", ""))
                .when()
                .put("/user/1/allowance/0")
                .then()
                .statusCode(200);

        // Complete the task
        given().when().post("/task/1/complete").then().statusCode(200);

        // Get the allowance ID (first named allowance)
        int allowanceId = given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .extract()
                .path("[1].id");

        // Complete allowance
        given().when().post("/user/1/allowance/" + allowanceId + "/complete").then().statusCode(200);

        // Verify allowance no longer exists
        given().when().get("/user/1/allowance/" + allowanceId).then().statusCode(404);

        // Verify history (newest first)
        given()
                .when()
                .get("/user/1/history")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[0].allowance", closeTo(-100.0, 0.01))
                .body("[0].description", is("Allowance completed: Test Allowance 1"))
                .body("[1].allowance", closeTo(100.0, 0.01))
                .body("[1].description", is("Task completed: Test Task"));
    }

    @Test
    void completeAllowanceInvalidUserId() {
        given().when().post("/user/999/allowance/1/complete").then().statusCode(404);
    }

    @Test
    void completeAllowanceInvalidAllowanceId() {
        given().when().post("/user/1/allowance/999/complete").then().statusCode(404);
    }

    // ---- Bulk Update Tests ----

    @Test
    void putBulkAllowance() {
        int id1 = createAllowance(1, "Test Allowance 1", 1000, 1);
        int id2 = createAllowance(1, "Test Allowance 2", 1000, 2);

        given()
                .contentType(ContentType.JSON)
                .body(List.of(
                        Map.of("id", id1, "weight", 5),
                        Map.of("id", 0, "weight", 99),
                        Map.of("id", id2, "weight", 10)))
                .when()
                .put("/user/1/allowance")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("$.size()", is(3))
                .body("[0].id", is(0))
                .body("[0].weight", closeTo(99.0, 0.01))
                .body("[1].id", is(id1))
                .body("[1].weight", closeTo(5.0, 0.01))
                .body("[2].id", is(id2))
                .body("[2].weight", closeTo(10.0, 0.01));
    }

    // ---- Add Allowance Amount Tests ----

    @Test
    void addAllowanceSimple() {
        int allowanceId = createAllowance(1, "Test Allowance 1", 1000, 1);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 10, "description", "Added to allowance 1"))
                .when()
                .post("/user/1/allowance/" + allowanceId + "/add")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("[1].id", is(allowanceId))
                .body("[1].progress", closeTo(10.0, 0.01));

        given()
                .when()
                .get("/user/1/history")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].allowance", closeTo(10.0, 0.01))
                .body("[0].description", is("Added to allowance 1"));
    }

    @Test
    void addAllowanceWithSpillage() {
        int id1 = createAllowance(1, "Test Allowance 1", 5, 1);
        int id2 = createAllowance(1, "Test Allowance 2", 5, 1);
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "", "target", 0, "weight", 1, "colour", ""))
                .when()
                .put("/user/1/allowance/0")
                .then()
                .statusCode(200);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 10, "description", "Added to allowance 1"))
                .when()
                .post("/user/1/allowance/" + id1 + "/add")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("[1].id", is(id1))
                .body("[1].progress", closeTo(5.0, 0.01))
                .body("[2].id", is(id2))
                .body("[2].progress", closeTo(2.5, 0.01))
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(2.5, 0.01));

        given()
                .when()
                .get("/user/1/history")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].allowance", closeTo(10.0, 0.01))
                .body("[0].description", is("Added to allowance 1"));
    }

    @Test
    void addAllowanceIdZero() {
        createAllowance(1, "Test Allowance 1", 1000, 1);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 10, "description", "Added to allowance 1"))
                .when()
                .post("/user/1/allowance/0/add")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(10.0, 0.01));

        given()
                .when()
                .get("/user/1/history")
                .then()
                .statusCode(200)
                .body("$.size()", is(1))
                .body("[0].allowance", closeTo(10.0, 0.01))
                .body("[0].description", is("Added to allowance 1"));
    }

    @Test
    void subtractAllowanceSimple() {
        int allowanceId = createAllowance(1, "Test Allowance 1", 1000, 1);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 10, "description", "Added to allowance 1"))
                .when()
                .post("/user/1/allowance/" + allowanceId + "/add")
                .then()
                .statusCode(200);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", -2.5, "description", "Added to allowance 1"))
                .when()
                .post("/user/1/allowance/" + allowanceId + "/add")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("[1].id", is(allowanceId))
                .body("[1].progress", closeTo(7.5, 0.01));

        given()
                .when()
                .get("/user/1/history")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[0].allowance", closeTo(-2.5, 0.01))
                .body("[0].description", is("Added to allowance 1"))
                .body("[1].allowance", closeTo(10.0, 0.01))
                .body("[1].description", is("Added to allowance 1"));
    }

    @Test
    void subtractAllowanceIdZero() {
        createAllowance(1, "Test Allowance 1", 1000, 1);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 10, "description", "Added to allowance 1"))
                .when()
                .post("/user/1/allowance/0/add")
                .then()
                .statusCode(200);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", -2.5, "description", "Added to allowance 1"))
                .when()
                .post("/user/1/allowance/0/add")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("[0].id", is(0))
                .body("[0].progress", closeTo(7.5, 0.01));

        given()
                .when()
                .get("/user/1/history")
                .then()
                .statusCode(200)
                .body("$.size()", is(2))
                .body("[0].allowance", closeTo(-2.5, 0.01))
                .body("[0].description", is("Added to allowance 1"))
                .body("[1].allowance", closeTo(10.0, 0.01))
                .body("[1].description", is("Added to allowance 1"));
    }

    // ---- Transfer Tests ----

    @Test
    void transferSuccessful() {
        int id1 = createAllowance(1, "From Allowance", 100, 1);
        int id2 = createAllowance(1, "To Allowance", 100, 1);

        // Add 30 to allowance 1
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 30, "description", "funds"))
                .when()
                .post("/user/1/allowance/" + id1 + "/add")
                .then()
                .statusCode(200);

        // Transfer 10 from 1 to 2
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("from", id1, "to", id2, "amount", 10))
                .when()
                .post("/transfer")
                .then()
                .statusCode(200)
                .body("message", is("Transfer successful"));

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("[1].progress", closeTo(20.0, 0.01))
                .body("[2].progress", closeTo(10.0, 0.01));
    }

    @Test
    void transferCapsAtTarget() {
        int id1 = createAllowance(1, "From Allowance", 100, 1);
        int id2 = createAllowance(1, "To Allowance", 5, 1);

        // Add 10 to allowance 1
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 10, "description", "funds"))
                .when()
                .post("/user/1/allowance/" + id1 + "/add")
                .then()
                .statusCode(200);

        // Transfer 10, but to only needs 5
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("from", id1, "to", id2, "amount", 10))
                .when()
                .post("/transfer")
                .then()
                .statusCode(200);

        given()
                .when()
                .get("/user/1/allowance")
                .then()
                .statusCode(200)
                .body("[1].progress", closeTo(5.0, 0.01))
                .body("[2].progress", closeTo(5.0, 0.01));
    }

    @Test
    void transferDifferentUsersFails() {
        int id1 = createAllowance(1, "User1 Allowance", 100, 1);

        // Create allowance for user 2
        int id2 = createAllowance(2, "User2 Allowance", 100, 1);

        // Add to user1 allowance
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("amount", 10, "description", "funds"))
                .when()
                .post("/user/1/allowance/" + id1 + "/add")
                .then()
                .statusCode(200);

        // Transfer between different users
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("from", id1, "to", id2, "amount", 5))
                .when()
                .post("/transfer")
                .then()
                .statusCode(400);
    }

    @Test
    void transferInsufficientFunds() {
        int id1 = createAllowance(1, "From Allowance", 100, 1);
        int id2 = createAllowance(1, "To Allowance", 100, 1);

        given()
                .contentType(ContentType.JSON)
                .body(Map.of("from", id1, "to", id2, "amount", 10))
                .when()
                .post("/transfer")
                .then()
                .statusCode(400)
                .body("error", containsStringIgnoringCase("insufficient"));
    }

    @Test
    void transferNotFound() {
        given()
                .contentType(ContentType.JSON)
                .body(Map.of("from", 999, "to", 1000, "amount", 1))
                .when()
                .post("/transfer")
                .then()
                .statusCode(404);
    }

    // ---- Helpers ----

    private int createTestTask(int reward) {
        return given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", "Test Task", "reward", reward))
                .when()
                .post("/tasks")
                .then()
                .statusCode(201)
                .extract()
                .path("id");
    }

    private int createAllowance(int userId, String name, double target, double weight) {
        return given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", name, "target", target, "weight", weight))
                .when()
                .post("/user/" + userId + "/allowance")
                .then()
                .statusCode(201)
                .extract()
                .path("id");
    }

    private int createAllowanceWithColour(int userId, String name, double target, double weight, String colour) {
        return given()
                .contentType(ContentType.JSON)
                .body(Map.of("name", name, "target", target, "weight", weight, "colour", colour))
                .when()
                .post("/user/" + userId + "/allowance")
                .then()
                .statusCode(201)
                .extract()
                .path("id");
    }
}
