package io.github.js0ny.ilp_coursework.service;

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 io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;

import org.springframework.stereotype.Service;

import java.util.List;

/**
 * Class that handles calculations about Coordinates
 *
 * @see LngLat
 * @see Region
 */
@Service
public class GpsCalculationService {

    /**
     * Given step size
     *
     * @see #nextPosition(LngLat, double)
     */
    private static final double STEP = 0.00015;

    /**
     * Given threshold to judge if two points are close to each other
     *
     * @see #isCloseTo(LngLat, LngLat)
     */
    private static final double CLOSE_THRESHOLD = 0.00015;

    /**
     * Calculate the Euclidean distance between {@code position1} and {@code position2}, which are
     * coordinates defined as {@link LngLat}
     *
     * @param position1 The coordinate of the first position
     * @param position2 The coordinate of the second position
     * @return The Euclidean distance between {@code position1} and {@code position2}
     * @see io.github.js0ny.ilp_coursework.controller.ApiController#getDistance(DistanceRequest)
     */
    public double calculateDistance(LngLat position1, LngLat position2) {
        double lngDistance = position2.lng() - position1.lng();
        double latDistance = position2.lat() - position1.lat();
        // Euclidean: \sqrt{a^2 + b^2}
        return Math.sqrt(lngDistance * lngDistance + latDistance * latDistance);
    }

    public double calculateSteps(LngLat position1, LngLat position2) {
        double distance = calculateDistance(position1, position2);
        return distance / STEP;
    }

    /**
     * Check if {@code position1} and {@code position2} are close to each other, the threshold is <
     * 0.00015
     *
     * <p>Note that = 0.00015 will be counted as not close to and will return {@code false}
     *
     * @param position1 The coordinate of the first position
     * @param position2 The coordinate of the second position
     * @return {@code true} if {@code position1} and {@code position2} are close to each other
     * @see #CLOSE_THRESHOLD
     * @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsCloseTo(DistanceRequest)
     */
    public boolean isCloseTo(LngLat position1, LngLat position2) {
        double distance = calculateDistance(position1, position2);
        return distance < CLOSE_THRESHOLD;
    }

    /**
     * Returns the next position moved from {@code start} in the direction with {@code angle}, with
     * step size 0.00015
     *
     * @param start The coordinate of the original start point.
     * @param angle The direction to be moved in angle.
     * @return The next position moved from {@code start}
     * @see #STEP
     * @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest)
     */
    public LngLat nextPosition(LngLat start, Angle angle) {
        double rad = angle.toRadians();
        double newLng = Math.cos(rad) * STEP + start.lng();
        double newLat = Math.sin(rad) * STEP + start.lat();
        return new LngLat(newLng, newLat);
    }

    /**
     * Used to check if the given {@code position} is inside the {@code region}, on edge and vertex
     * is considered as inside.
     *
     * @param position The coordinate of the position.
     * @param region A {@link Region} that contains name and a list of {@code LngLatDto}
     * @return {@code true} if {@code position} is inside the {@code region}.
     * @throws IllegalArgumentException If {@code region} is not closed
     * @see
     *     io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest)
     * @see Region#isClosed()
     */
    public boolean checkIsInRegion(LngLat position, Region region) throws IllegalArgumentException {
        if (!region.isClosed()) {
            // call method from RegionDto to check if not closed
            throw new IllegalArgumentException("Region is not closed.");
        }
        return rayCasting(position, region.vertices());
    }

    /**
     * Helper function to {@code checkIsInRegion}, use of ray-casting algorithm to check if inside
     * the polygon
     *
     * @param point The point to check
     * @param polygon The region that forms a polygon to check if {@code point} sits inside.
     * @return If the {@code point} sits inside the {@code polygon} then return {@code true}
     * @see #isPointOnEdge(LngLat, LngLat, LngLat)
     * @see #checkIsInRegion(LngLat, Region)
     */
    private boolean rayCasting(LngLat point, List<LngLat> polygon) {
        int intersections = 0;
        int n = polygon.size();
        for (int i = 0; i < n; ++i) {
            LngLat a = polygon.get(i);
            LngLat b = polygon.get((i + 1) % n); // Next vertex

            if (isPointOnEdge(point, a, b)) {
                return true;
            }

            // Ensure that `a` is norther than `b`, in order to easy classification
            if (a.lat() > b.lat()) {
                LngLat temp = a;
                a = b;
                b = temp;
            }

            // The point is not between a and b in latitude mean, skip this loop
            if (point.lat() < a.lat() || point.lat() >= b.lat()) {
                continue;
            }

            // Skip the case of horizontal edge, already handled in `isPointOnEdge`:w
            if (a.lat() == b.lat()) {
                continue;
            }

            double xIntersection =
                    a.lng() + ((point.lat() - a.lat()) * (b.lng() - a.lng())) / (b.lat() - a.lat());

            if (xIntersection > point.lng()) {
                ++intersections;
            }
        }
        // If intersections are odd, ray-casting returns true, which the point sits
        // inside the polygon;
        // If intersections are even, the point does not sit inside the polygon.
        return intersections % 2 == 1;
    }

    /**
     * Helper function from {@code rayCasting} that used to simply calculation <br>
     * Used to check if point {@code p} is on the edge formed by {@code a} and {@code b}
     *
     * @param p point to be checked on the edge
     * @param a point that forms the edge
     * @param b point that forms the edge
     * @return {@code true} if {@code p} is on {@code ab}
     * @see #rayCasting(LngLat, List)
     */
    private boolean isPointOnEdge(LngLat p, LngLat a, LngLat b) {
        // Cross product: (p - a) × (b - a)
        double crossProduct =
                (p.lng() - a.lng()) * (b.lat() - a.lat())
                        - (p.lat() - a.lat()) * (b.lng() - a.lng());
        if (Math.abs(crossProduct) > 1e-9) {
            return false;
        }

        boolean isWithinLng =
                p.lng() >= Math.min(a.lng(), b.lng()) && p.lng() <= Math.max(a.lng(), b.lng());
        boolean isWithinLat =
                p.lat() >= Math.min(a.lat(), b.lat()) && p.lat() <= Math.max(a.lat(), b.lat());

        return isWithinLng && isWithinLat;
    }
}
