"""Allows to refine the tour by adding more landmarks and making the path easier to follow."""
import logging
from math import pi
import yaml
from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull

from ..structs.landmark import Landmark
from ..utils.get_time_distance import get_time
from ..utils.take_most_important import take_most_important
from .optimizer import Optimizer
from ..constants import OPTIMIZER_PARAMETERS_PATH



class Refiner :
    """
    Refines a tour by incorporating smaller landmarks along the path to enhance the experience.

    This class is designed to adjust an existing tour by considering additional, 
    smaller points of interest (landmarks) that may require minor detours but 
    improve the overall quality of the tour. It balances the efficiency of travel 
    with the added value of visiting these landmarks.
    """
    logger = logging.getLogger(__name__)

    detour_factor: float            # detour factor of straight line vs real distance in cities
    detour_corridor_width: float    # width of the corridor around the path
    average_walking_speed: float    # average walking speed of adult
    max_landmarks_refiner: int      # max number of landmarks to visit
    optimizer: Optimizer            # optimizer object

    def __init__(self, optimizer: Optimizer) :
        self.optimizer = optimizer

        # load parameters from file
        with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
            parameters = yaml.safe_load(f)
            self.detour_factor = parameters['detour_factor']
            self.detour_corridor_width = parameters['detour_corridor_width']
            self.average_walking_speed = parameters['average_walking_speed']
            self.max_landmarks_refiner = parameters['max_landmarks_refiner']


    def create_corridor(self, landmarks: list[Landmark], width: float) :
        """
        Create a corridor around the path connecting the landmarks.

        Args:
            landmarks (list[Landmark]) : the landmark path around which to create the corridor
            width (float) : width of the corridor in meters.

        Returns:
            Geometry: a buffered geometry object representing the corridor around the path.
        """

        corrected_width = (180*width)/(6371000*pi)

        path = self.create_linestring(landmarks)
        obj = buffer(path, corrected_width, join_style="mitre", cap_style="square", mitre_limit=2)

        return obj


    def create_linestring(self, tour: list[Landmark]) -> LineString :
        """
        Create a `LineString` object from a tour.

        Args:
            tour (list[Landmark]): An ordered sequence of landmarks that represents the visiting order.

        Returns:
            LineString: A `LineString` object representing the path through the landmarks.
        """

        points = []
        for landmark in tour :
            points.append(Point(landmark.location))

        return LineString(points)


    # Check if some coordinates are in area. Used for the corridor
    def is_in_area(self, area: Polygon, coordinates) -> bool :
        """
        Check if a given point is within a specified area.

        Args:
            area (Polygon): The polygon defining the area.
            coordinates (tuple[float, float]): The coordinates of the point to check.

        Returns:
            bool: True if the point is within the area, otherwise False.
        """
        point = Point(coordinates)
        return point.within(area)


    # Function to determine if two landmarks are close to each other
    def is_close_to(self, location1: tuple[float], location2: tuple[float]):
        """
        Determine if two locations are close to each other by rounding their coordinates to 3 decimal places.

        Args:
            location1 (tuple[float, float]): The coordinates of the first location.
            location2 (tuple[float, float]): The coordinates of the second location.

        Returns:
            bool: True if the locations are within 0.001 degrees of each other, otherwise False.
        """

        absx = abs(location1[0] - location2[0])
        absy = abs(location1[1] - location2[1])

        return absx < 0.001 and absy < 0.001
        #return (round(location1[0], 3), round(location1[1], 3)) == (round(location2[0], 3), round(location2[1], 3))


    def rearrange(self, tour: list[Landmark]) -> list[Landmark]:
        """
        Rearrange landmarks to group nearby visits together.

        This function reorders landmarks so that nearby landmarks are adjacent to each other in the list,
        while keeping 'start' and 'finish' landmarks in their original positions.

        Args:
            tour (list[Landmark]): Ordered list of landmarks to be rearranged.

        Returns:
            list[Landmark]: The rearranged list of landmarks with grouped nearby visits.
        """

        i = 1
        while i < len(tour):
            j = i+1
            while j < len(tour):
                if self.is_close_to(tour[i].location, tour[j].location) and tour[i].name not in ['start', 'finish'] and tour[j].name not in ['start', 'finish']:
                    # If they are not adjacent, move the j-th element to be adjacent to the i-th element
                    if j != i + 1:
                        tour.insert(i + 1, tour.pop(j))
                        break  # Move to the next i-th element after rearrangement
            j += 1
            i += 1

        return tour

    def integrate_landmarks(self, sub_list: list[Landmark], main_list: list[Landmark]) :
        """
        Inserts 'sub_list' of Landmarks inside the 'main_list' by leaving the ends untouched.
        
        Args: 
            sub_list    : the list of Landmarks to be inserted inside of the 'main_list'.
            main_list   : the original list with start and finish.

        Returns:
            the full list.
        """
        sub_list.append(main_list[-1])          # add finish back
        return main_list[:-1] + sub_list        # create full set of possible landmarks



    def find_shortest_path_through_all_landmarks(self, landmarks: list[Landmark]) -> tuple[list[Landmark], Polygon]:
        """
        Find the shortest path through all landmarks using a nearest neighbor heuristic.

        This function constructs a path that starts from the 'start' landmark, visits all other landmarks in the order
        of their proximity, and ends at the 'finish' landmark. It returns both the ordered list of landmarks and a 
        polygon representing the path.

        Args:
            landmarks (list[Landmark]): list of all landmarks including 'start' and 'finish'.

        Returns:
            tuple[list[Landmark], Polygon]: A tuple where the first element is the list of landmarks in the order they 
                                             should be visited, and the second element is a `Polygon` representing 
                                             the path connecting all landmarks.
        """

        # Step 1: Find 'start' and 'finish' landmarks
        start_idx = next(i for i, lm in enumerate(landmarks) if lm.type == 'start')
        finish_idx = next(i for i, lm in enumerate(landmarks) if lm.type == 'finish')

        start_landmark = landmarks[start_idx]
        finish_landmark = landmarks[finish_idx]


        # Step 2: Create a list of unvisited landmarks excluding 'start' and 'finish'
        unvisited_landmarks = [lm for i, lm in enumerate(landmarks) if i not in [start_idx, finish_idx]]

        # Step 3: Initialize the path with the 'start' landmark
        path = [start_landmark]
        coordinates = [landmarks[start_idx].location]

        current_landmark = start_landmark

        # Step 4: Use nearest neighbor heuristic to visit all landmarks
        while unvisited_landmarks:
            nearest_landmark = min(unvisited_landmarks, key=lambda lm: get_time(current_landmark.location, lm.location))
            path.append(nearest_landmark)
            coordinates.append(nearest_landmark.location)
            current_landmark = nearest_landmark
            unvisited_landmarks.remove(nearest_landmark)

        # Step 5: Finally add the 'finish' landmark to the path
        path.append(finish_landmark)
        coordinates.append(landmarks[finish_idx].location)

        path_poly = Polygon(coordinates)

        return path, path_poly


    # Returns a list of minor landmarks around the planned path to enhance experience
    def get_minor_landmarks(self, all_landmarks: list[Landmark], visited_landmarks: list[Landmark], width: float) -> list[Landmark] :
        """
        Identify landmarks within a specified corridor that have not been visited yet.

        This function creates a corridor around the path defined by visited landmarks and then finds landmarks that fall
        within this corridor. It returns a list of these landmarks, excluding those already visited, sorted by their importance.

        Args:
            all_landmarks (list[Landmark]): list of all available landmarks.
            visited_landmarks (list[Landmark]): list of landmarks that have already been visited.
            width (float): Width of the corridor around the visited landmarks.

        Returns:
            list[Landmark]: list of important landmarks within the corridor that have not been visited yet.
        """

        second_order_landmarks = []
        visited_names = []
        area = self.create_corridor(visited_landmarks, width)

        for visited in visited_landmarks :
            visited_names.append(visited.name)

        for landmark in all_landmarks :
            if self.is_in_area(area, landmark.location) and landmark.name not in visited_names:
                second_order_landmarks.append(landmark)

        return take_most_important(second_order_landmarks, int(self.max_landmarks_refiner*0.75))


    # Try fix the shortest path using shapely
    def fix_using_polygon(self, tour: list[Landmark])-> list[Landmark] :
        """
        Improve the tour path using geometric methods to ensure it follows a more optimal shape.

        This function creates a polygon from the given tour and attempts to refine it using a concave hull. It reorders
        the landmarks to fit within this refined polygon and adjusts the tour to ensure the 'start' landmark is at the
        beginning. It also checks if the final polygon is simple and rearranges the tour if necessary.

        Args:
            tour (list[Landmark]): list of landmarks representing the current tour path.

        Returns:
            list[Landmark]: Refined list of landmarks in the order of visit to produce a better tour path.
        """

        coords = []
        coords_dict = {}
        for landmark in tour :
            coords.append(landmark.location)
            if landmark.name != 'finish' :
                coords_dict[landmark.location] = landmark

        tour_poly = Polygon(coords)

        better_tour_poly = tour_poly.buffer(0)
        try :
            xs, ys = better_tour_poly.exterior.xy

            if len(xs) != len(tour) :
                better_tour_poly = concave_hull(MultiPoint(coords))  # Create concave hull with "core" of tour leaving out start and finish
                xs, ys = better_tour_poly.exterior.xy

        except Exception:
            better_tour_poly = concave_hull(MultiPoint(coords))  # Create concave hull with "core" of tour leaving out start and finish
            xs, ys = better_tour_poly.exterior.xy
            """ 
            FIXED : ERROR HERE : 
                Exception has occurred: AttributeError
                'LineString' object has no attribute 'exterior'
            """


        # reverse the xs and ys
        xs.reverse()
        ys.reverse()

        better_tour = []   # list of ordered visit
        name_index = {}     # Maps the name of a landmark to its index in the concave polygon

        # Loop through the polygon and generate the better (ordered) tour
        for i,x in enumerate(xs[:-1]) :
            y = ys[i]
            better_tour.append(coords_dict[tuple((x,y))])
            name_index[coords_dict[tuple((x,y))].name] = i


        # Scroll the list to have start in front again
        start_index = name_index['start']
        better_tour = better_tour[start_index:] + better_tour[:start_index]

        # Append the finish back and correct the time to reach
        better_tour.append(tour[-1])

        # Rearrange only if polygon still not simple
        if not better_tour_poly.is_simple :
            better_tour = self.rearrange(better_tour)

        return better_tour


    def refine_optimization(
            self,
            all_landmarks: list[Landmark],
            base_tour: list[Landmark],
            max_time: int,
            detour: int
        ) -> list[Landmark]:
        """
        This is the second stage of the optimization. It refines the initial tour path by considering additional minor landmarks and optimizes the path.

        This method evaluates the need for further optimization based on the initial tour. If a detour is required
        it adds minor landmarks around the initial predicted path and solves a new optimization problem to find a potentially better
        tour. It then links the new tour and adjusts it using a nearest neighbor heuristic and polygon-based methods to
        ensure a valid path. The final tour is chosen based on the shortest distance.

        Args:
            all_landmarks (list[Landmark]): The full list of landmarks available for the optimization.
            base_tour (list[Landmark]): The initial tour path to be refined.
            max_time (int): The maximum time available for the tour in minutes.
            detour (int): The maximum detour time allowed for the tour in minutes.
        Returns:
            list[Landmark]: The refined list of landmarks representing the optimized tour path.
        """

        # No need to refine if no detour is taken
        # if detour == 0:
        #     return base_tour

        minor_landmarks = self.get_minor_landmarks(all_landmarks, base_tour, self.detour_corridor_width)

        self.logger.debug(f"Using {len(minor_landmarks)} minor landmarks around the predicted path")

        # Full set of visitable landmarks.
        full_set = self.integrate_landmarks(minor_landmarks, base_tour)     # could probably be optimized with less overhead

        # Generate a new tour with the optimizer.
        new_tour = self.optimizer.solve_optimization(
            max_time = max_time + detour,
            landmarks = full_set,
            max_landmarks = self.max_landmarks_refiner
        )

        # If unsuccessful optimization, use the base_tour.
        if new_tour is None:
            self.logger.warning("Refiner failed: No solution found during second stage optimization.")
            new_tour = base_tour

        # If only one landmark, return it.
        if len(new_tour) < 4 :
            return new_tour

        # Find shortest path using the nearest neighbor heuristic.
        better_tour, better_poly = self.find_shortest_path_through_all_landmarks(new_tour)

        # Fix the tour using Polygons if the path looks weird.
        # Conditions : circular trip and invalid polygon.
        if base_tour[0].location == base_tour[-1].location and not better_poly.is_valid :
            self.logger.debug("Tours might be funky, attempting to correct with polygons")
            better_tour = self.fix_using_polygon(better_tour)

        return better_tour
