package io.github.js0ny.ilp_coursework.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.within;

import io.github.js0ny.ilp_coursework.data.common.Angle;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.util.List;

public class GpsCalculationServiceTest {

    private static final double STEP = 0.00015;
    // private static final double CLOSE_THRESHOLD = STEP;
    private static final double PRECISION = 1e-9;

    private GpsCalculationService service;

    @BeforeEach
    void setUpService() {
        service = new GpsCalculationService();
    }

    @Nested
    @DisplayName("Test for calculateDistance(LngLatDto, LngLatDto) -> double")
    class CalculateDistanceTests {

        @Test
        @DisplayName("False: Given Example For Testing")
        void isCloseTo_shouldReturnFalse_givenExample() {
            var p1 = new LngLat(-3.192473, 55.946233);
            var p2 = new LngLat(-3.192473, 55.942617);
            double expected = 0.0036;
            double actual = service.calculateDistance(p1, p2);
            assertThat(actual).isCloseTo(expected, within(1e-4));
        }

        @Test
        @DisplayName("General Case: 3-4-5 Triangle")
        void calculateDistance_shouldReturnCorrectEuclideanDistance_forGeneralCase() {
            var p1 = new LngLat(0, 3.0);
            var p2 = new LngLat(4.0, 0);
            double expected = 5.0;
            double actual = service.calculateDistance(p1, p2);
            assertThat(actual).isCloseTo(expected, within(PRECISION));
        }

        @Test
        @DisplayName("Edge Case: Points are Identical")
        void calculateDistance_shouldReturnZero_whenPointsAreIdentical() {
            var p1 = new LngLat(12.85, 68.2119);
            double expected = 0.0;
            double actual = service.calculateDistance(p1, p1);
            assertThat(actual).isEqualTo(expected);
        }

        @Test
        @DisplayName("Edge Case: Longitudinal-only movement")
        void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLatitude() {
            var p1 = new LngLat(23.85, 83.2119);
            var p2 = new LngLat(33.85, 83.2119);
            double expected = 10.0;
            double actual = service.calculateDistance(p1, p2);
            assertThat(actual).isCloseTo(expected, within(PRECISION));
        }

        @Test
        @DisplayName("Edge Case: Latitude-only movement")
        void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLongitude() {
            var p1 = new LngLat(123.85, 68.2119);
            var p2 = new LngLat(123.85, 58.2119);
            double expected = 10.0;
            double actual = service.calculateDistance(p1, p2);
            assertThat(actual).isCloseTo(expected, within(PRECISION));
        }

        @Test
        @DisplayName("General Case: Calculate with negative Coordinates")
        void calculateDistance_shouldReturnCorrectEuclideanDistance_forNegativeCoordinates() {
            LngLat p1 = new LngLat(-1.0, -2.0);
            LngLat p2 = new LngLat(2.0, 2.0); // lngDiff = 3, latDiff = 4
            double expected = 5.0;
            double actual = service.calculateDistance(p1, p2);
            assertThat(actual).isCloseTo(expected, within(PRECISION));
        }
    }

    @Nested
    @DisplayName("Test for isCloseTo(LngLatDto, LngLatDto) -> boolean")
    class IsCloseToTests {

        @Test
        @DisplayName("False: Given Example For Testing")
        void isCloseTo_shouldReturnFalse_givenExample() {
            var p1 = new LngLat(-3.192473, 55.946233);
            var p2 = new LngLat(-3.192473, 55.942617);
            boolean expected = false;
            boolean actual = service.isCloseTo(p1, p2);
            assertThat(actual).isEqualTo(expected);
        }

        @Test
        @DisplayName("True: Two points are the same")
        void isCloseTo_shouldReturnTrue_whenPointsAreIdentical() {
            var p1 = new LngLat(15.86, 28.37);
            boolean expected = true;
            boolean actual = service.isCloseTo(p1, p1);
            assertThat(actual).isEqualTo(expected);
        }

        @Test
        @DisplayName("True: Two points are close to each other and near threshold")
        void isCloseTo_shouldReturnTrue_whenCloseAndSmallerThanThreshold() {
            var p1 = new LngLat(0.0, 0.0);
            var p2 = new LngLat(0.0, 0.00014);
            boolean expected = true;
            boolean actual = service.isCloseTo(p1, p2);
            assertThat(actual).isEqualTo(expected);
        }

        @Test
        @DisplayName("False: Distance nears the threshold")
        void isCloseTo_shouldReturnFalse_whenEqualsToThreshold() {
            var p1 = new LngLat(0.0, 0.0);
            var p2 = new LngLat(0.0, 0.00015);
            boolean expected = false;
            boolean actual = service.isCloseTo(p1, p2);
            assertThat(actual).isEqualTo(expected);
        }

        @Test
        @DisplayName("False: Distance larger to threshold")
        void isCloseTo_shouldReturnFalse_whenNotCloseAndLargerThanThreshold() {
            var p1 = new LngLat(0.0, 0.0);
            var p2 = new LngLat(0.0, 0.00016);
            boolean expected = false;
            boolean actual = service.isCloseTo(p1, p2);
            assertThat(actual).isEqualTo(expected);
        }
    }

    @Nested
    @DisplayName("Test for nextPosition(LngLatDto, double) -> LngLatDto")
    class NextPositionTests {

        @Test
        @DisplayName("General Case: nextPosition in East direction (0 degrees)")
        void nextPosition_shouldMoveEast_forAngleZero() {
            var start = new LngLat(0.0, 0.0);
            Angle angle = new Angle(0);
            // For 0 degrees, cos(0)=1, sin(0)=0. Move happens entirely on lng axis.
            var expected = new LngLat(STEP, 0.0);

            var actual = service.nextPosition(start, angle);

            assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
            assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
        }

        @Test
        @DisplayName("Cardinal Direction: nextPosition in North direction (90 degrees)")
        void nextPosition_shouldMoveNorth_forAngle90() {
            var start = new LngLat(0.0, 0.0);
            Angle angle = new Angle(90);
            // For 90 degrees, cos(90)=0, sin(90)=1. Move happens entirely on lat axis.
            var expected = new LngLat(0.0, STEP);

            var actual = service.nextPosition(start, angle);

            assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
            assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
        }

        @Test
        @DisplayName("Cardinal Direction: nextPosition in West direction (180 degrees)")
        void nextPosition_shouldMoveWest_forAngle180() {
            var start = new LngLat(0.0, 0.0);
            Angle angle = new Angle(180);
            // For 180 degrees, cos(180)=-1, sin(180)=0. Move happens entirely on negative
            // lng axis.
            var expected = new LngLat(-STEP, 0.0);

            var actual = service.nextPosition(start, angle);

            assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
            assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
        }

        @Test
        @DisplayName("Cardinal Direction: nextPosition in South direction (270 degrees)")
        void nextPosition_shouldMoveSouth_forAngle270() {
            var start = new LngLat(0.0, 0.0);
            Angle angle = new Angle(270);
            // For 270 degrees, cos(270)=0, sin(270)=-1. Move happens entirely on negative
            // lat axis.
            var expected = new LngLat(0.0, -STEP);

            var actual = service.nextPosition(start, angle);

            assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
            assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
        }

        @Test
        @DisplayName("Intercardinal Direction: nextPosition in Northeast direction (45 degrees)")
        void nextPosition_shouldMoveNortheast_forAngle45() {
            var start = new LngLat(0.0, 0.0);
            Angle angle = new Angle(45);
            // Δlng = step * cos(45°), Δlat = step * sin(45°)
            double expectedLng = STEP * Math.cos(angle.toRadians());
            double expectedLat = STEP * Math.sin(angle.toRadians());
            var expected = new LngLat(expectedLng, expectedLat);

            var actual = service.nextPosition(start, angle);

            assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
            assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
        }

        @Nested
        @DisplayName("Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean")
        class CheckIsInRegionTests {

            public static final Region RECTANGLE_REGION =
                    new Region(
                            "rectangle",
                            List.of(
                                    new LngLat(0.0, 0.0),
                                    new LngLat(2.0, 0.0),
                                    new LngLat(2.0, 2.0),
                                    new LngLat(0.0, 2.0),
                                    new LngLat(0.0, 0.0)));

            @Test
            @DisplayName("General Case: Given Example for Testing")
            void isInRegion_shouldReturnFalse_givenPolygonCentral() {
                var position = new LngLat(1.234, 1.222);
                var region =
                        new Region(
                                "central",
                                List.of(
                                        new LngLat(-3.192473, 55.946233),
                                        new LngLat(-3.192473, 55.942617),
                                        new LngLat(-3.184319, 55.942617),
                                        new LngLat(-3.184319, 55.946233),
                                        new LngLat(-3.192473, 55.946233)));
                boolean expected = false;
                boolean actual = service.checkIsInRegion(position, region);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("General Case: Simple Rectangle")
            void isInRegion_shouldReturnTrue_forSimpleRectangle() {
                var position = new LngLat(1.0, 1.0);
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("General Case: Simple Rectangle")
            void isInRegion_shouldReturnFalse_forSimpleRectangle() {
                var position = new LngLat(3.0, 1.0);
                boolean expected = false;
                boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("General Case: Simple Hexagon")
            void isInRegion_shouldReturnTrue_forSimpleHexagon() {
                var position = new LngLat(2.0, 2.0);
                var region =
                        new Region(
                                "hexagon",
                                List.of(
                                        new LngLat(1.0, 0.0),
                                        new LngLat(4.0, 0.0),
                                        new LngLat(5.0, 2.0),
                                        new LngLat(4.0, 4.0),
                                        new LngLat(1.0, 4.0),
                                        new LngLat(0.0, 2.0),
                                        new LngLat(1.0, 0.0)));
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, region);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("Edge Case: Small Triangle")
            void isInRegion_shouldReturnTrue_forSmallTriangle() {
                var position = new LngLat(0.00001, 0.00001);
                var region =
                        new Region(
                                "triangle",
                                List.of(
                                        new LngLat(0.0, 0.0),
                                        new LngLat(0.0001, 0.0),
                                        new LngLat(0.00005, 0.0001),
                                        new LngLat(0.0, 0.0)));
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, region);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("Edge Case: Point on Lower Edge of Rectangle")
            void isInRegion_shouldReturnTrue_whenPointOnLowerEdge() {
                var position = new LngLat(0.0, 1.0);
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("Edge Case: Point on Upper Edge of Rectangle")
            void isInRegion_shouldReturnTrue_whenPointOnUpperEdge() {
                var position = new LngLat(2.0, 1.0);
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("Edge Case: Point on Left Edge")
            void isInRegion_shouldReturnTrue_whenPointOnLeftEdge() {
                var position = new LngLat(0.0, 1.0);
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("Edge Case: Point on Lower Vertex")
            void isInRegion_shouldReturnTrue_whenPointOnLowerVertex() {
                var position = new LngLat(0.0, 0.0);
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("Edge Case: Point on Upper Vertex")
            void isInRegion_shouldReturnTrue_whenPointOnUpperVertex() {
                var position = new LngLat(2.0, 2.0);
                boolean expected = true;
                boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
                assertThat(actual).isEqualTo(expected);
            }

            @Test
            @DisplayName("Edge Case: Region not forming polygon")
            void isInRegion_shouldThrowExceptions_whenRegionNotFormingPolygon() {
                var position = new LngLat(2.0, 2.0);
                var region =
                        new Region(
                                "line",
                                List.of(
                                        new LngLat(0.0, 0.0),
                                        new LngLat(0.0001, 0.0),
                                        new LngLat(0.0, 0.0)));
                assertThatThrownBy(
                                () -> {
                                    service.checkIsInRegion(position, region);
                                })
                        .isInstanceOf(IllegalArgumentException.class)
                        .hasMessage("Region is not closed.");
            }

            @Test
            @DisplayName("Edge Case: Region is not closed")
            void isInRegion_shouldThrowExceptions_whenRegionNotClose() {
                var position = new LngLat(2.0, 2.0);
                var region =
                        new Region(
                                "rectangle",
                                List.of(
                                        new LngLat(0.0, 0.0),
                                        new LngLat(2.0, 0.0),
                                        new LngLat(2.0, 2.0),
                                        new LngLat(0.0, 2.0),
                                        new LngLat(0.0, -1.0)));
                assertThatThrownBy(
                                () -> {
                                    service.checkIsInRegion(position, region);
                                })
                        .isInstanceOf(IllegalArgumentException.class)
                        .hasMessage("Region is not closed.");
            }

            @Test
            @DisplayName("Edge Case: Vertex list is empty")
            void isInRegion_shouldThrowExceptions_whenListIsEmpty() {
                var position = new LngLat(2.0, 2.0);
                var region = new Region("rectangle", List.of());
                assertThatThrownBy(
                                () -> {
                                    service.checkIsInRegion(position, region);
                                })
                        .isInstanceOf(IllegalArgumentException.class)
                        .hasMessage("Region is not closed.");
            }
        }
    }
}
