# gpx.py
#
# author: deng
# date: 20251127

from typing import IO, Optional

import gpxpy
import numpy as np
from geopy.distance import geodesic


class GPXProcessor:
    """Handle GPX file parsing and route analysis."""

    def __init__(self, gpx_file: IO) -> None:
        """Initialize GPX processor with uploaded file.

        Args:
            gpx_file: File-like object containing GPX data
        """
        self.gpx_file = gpx_file
        self.gpx = None
        self.time = []
        self.points = []
        self.elevations = []  # unit: m
        self.distances = []  # unit: km
        self.waypoints = []  # list of (lat, lon, name, elevation)

    def _get_sample_rate(self) -> float:
        """Get the median time interval between points.

        Returns:
            float: Median sample time interval in seconds
        """
        if len(self.time) < 2:
            return 0
        diff = np.diff(self.time)
        median_sample_time = np.median(diff).total_seconds()
        return median_sample_time

    def validate_and_parse(self) -> bool:
        """Validate and parse the GPX file.

        Returns:
            bool: True if valid GPX file, False otherwise

        Raises:
            Exception: If file cannot be parsed
        """
        try:
            gpx_data = self.gpx_file.read()
            if isinstance(gpx_data, bytes):
                gpx_data = gpx_data.decode('utf-8')

            self.gpx = gpxpy.parse(gpx_data)

            # Extract waypoints
            for waypoint in self.gpx.waypoints:
                self.waypoints.append(
                    {
                        'lat': round(waypoint.latitude, 6),
                        'lon': round(waypoint.longitude, 6),
                        'name': waypoint.name if waypoint.name else '航點',
                        'elevation': round(waypoint.elevation, 1) if waypoint.elevation else None,
                    }
                )

            # Extract points from all tracks and segments
            for track in self.gpx.tracks:
                for segment in track.segments:
                    for point in segment.points:
                        self.time.append(point.time)
                        self.points.append((round(point.latitude, 6), round(point.longitude, 6)))
                        self.elevations.append(round(point.elevation, 1) if point.elevation else 0)

            return len(self.points) > 0

        except Exception as e:
            raise Exception(f'GPX 檔案解析失敗: {str(e)}')

    def calculate_distance(self) -> float:
        """Calculate total distance of the route.

        Returns:
            float: Total distance in kilometers
        """
        if len(self.points) < 2:
            return 0.0

        total_distance = 0.0
        self.distances = [0.0]  # Start at 0 km

        for i in range(1, len(self.points)):
            segment_distance = geodesic(self.points[i - 1], self.points[i]).kilometers
            total_distance += segment_distance
            self.distances.append(total_distance)

        return total_distance

    def calculate_elevation_gain_loss(self, threshold: float = 1.0) -> tuple[float, float]:
        """Calculate total elevation gain and loss with noise filtering.

        GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can
        accumulate to create inflated elevation gain/loss values. This method uses
        a threshold to filter out GPS noise and downsampling to reduce computation.

        Args:
            threshold: Minimum elevation change in meters to be counted (default: 1.0)
                      This filters out GPS noise while preserving real elevation changes.

        Returns:
            tuple: (elevation_gain, elevation_loss) in meters
        """
        window_size = int(120 / self._get_sample_rate())
        if len(self.elevations) <= window_size:
            return 0.0, 0.0

        gain = 0.0
        loss = 0.0

        for i in range(window_size, len(self.elevations), window_size):
            diff = self.elevations[i] - self.elevations[i - window_size]

            if diff >= threshold:
                gain += diff
            elif diff <= -threshold:
                loss += abs(diff)

        return gain, loss

    def get_min_max_elevation(self) -> tuple[float, float]:
        """Get minimum and maximum elevation.

        Returns:
            tuple: (min_elevation, max_elevation) in meters
        """
        if not self.elevations:
            return 0.0, 0.0

        return min(self.elevations), max(self.elevations)

    def get_start_end_points(self) -> tuple[Optional[tuple[float, float, float]], Optional[tuple[float, float, float]]]:
        """Get start and end point coordinates with elevation.

        Returns:
            tuple: ((start_lat, start_lon, start_elevation), (end_lat, end_lon, end_elevation))
        """
        if len(self.points) < 2:
            return None, None

        return (self.points[0][0], self.points[0][1], self.elevations[0]), (self.points[-1][0], self.points[-1][1], self.elevations[-1])

    def get_all_points(self) -> list[tuple[float, float]]:
        """Get all route points.

        Returns:
            list: List of (latitude, longitude) tuples
        """
        return self.points

    def get_elevation_profile_data(self) -> tuple[list[float], list[float]]:
        """Get data for elevation profile visualization.

        Returns:
            tuple: (distances, elevations) where distances is cumulative km
        """
        return self.distances, self.elevations

    def get_gradients(self) -> list[float]:
        """Calculate gradient (slope) for each segment.

        Returns:
            list: List of gradients in percentage
        """
        if len(self.points) < 2:
            return []

        gradients = []
        for i in range(1, len(self.points)):
            distance_m = geodesic(self.points[i - 1], self.points[i]).meters
            elevation_diff = self.elevations[i] - self.elevations[i - 1]

            if distance_m > 0:
                gradient = (elevation_diff / distance_m) * 100
            else:
                gradient = 0

            gradients.append(gradient)

        return gradients

    def calculate_naismith_time(self, horizontal_speed: float = 5, vertical_speed: float = 600) -> int:
        """Calculate estimated hiking time using Naismith's Rule.

        Naismith's Rule:
        - Horizontal speed: 1 hour per 5 km of horizontal distance
        - Vertical speed: 1 hour per 600 meters of ascent

        Returns:
            int: Estimated time in minutes
        """
        if len(self.points) < 2:
            return 0

        # Get total distance and elevation gain
        total_distance = self.calculate_distance() if not self.distances else self.distances[-1]
        elevation_gain, _ = self.calculate_elevation_gain_loss()

        # Naismith's Rule calculation
        time_for_distance = total_distance / horizontal_speed
        time_for_ascent = elevation_gain / vertical_speed

        total_time = int((time_for_distance + time_for_ascent) * 60)

        return total_time
