"""
Clustering service for map locations to improve performance and user experience.
"""

import math
from collections import defaultdict
from dataclasses import dataclass
from typing import Any

from .data_structures import (
    ClusterData,
    GeoBounds,
    LocationType,
    UnifiedLocation,
)


@dataclass
class ClusterPoint:
    """Internal representation of a point for clustering."""

    location: UnifiedLocation
    x: float  # Projected x coordinate
    y: float  # Projected y coordinate


class ClusteringService:
    """
    Handles location clustering for map display using a simple grid-based approach
    with zoom-level dependent clustering radius.
    """

    # Clustering configuration
    DEFAULT_RADIUS = 40  # pixels
    MIN_POINTS_TO_CLUSTER = 2
    MAX_ZOOM_FOR_CLUSTERING = 15
    MIN_ZOOM_FOR_CLUSTERING = 3

    # Zoom level configurations
    ZOOM_CONFIGS = {
        3: {"radius": 80, "min_points": 5},  # World level
        4: {"radius": 70, "min_points": 4},  # Continent level
        5: {"radius": 60, "min_points": 3},  # Country level
        6: {"radius": 50, "min_points": 3},  # Large region level
        7: {"radius": 45, "min_points": 2},  # Region level
        8: {"radius": 40, "min_points": 2},  # State level
        9: {"radius": 35, "min_points": 2},  # Metro area level
        10: {"radius": 30, "min_points": 2},  # City level
        11: {"radius": 25, "min_points": 2},  # District level
        12: {"radius": 20, "min_points": 2},  # Neighborhood level
        13: {"radius": 15, "min_points": 2},  # Block level
        14: {"radius": 10, "min_points": 2},  # Street level
        15: {"radius": 5, "min_points": 2},  # Building level
    }

    def __init__(self):
        self.cluster_id_counter = 0

    def should_cluster(self, zoom_level: int, point_count: int) -> bool:
        """Determine if clustering should be applied based on zoom level and point count."""
        if zoom_level > self.MAX_ZOOM_FOR_CLUSTERING:
            return False
        if zoom_level < self.MIN_ZOOM_FOR_CLUSTERING:
            return True

        config = self.ZOOM_CONFIGS.get(zoom_level, {"min_points": self.MIN_POINTS_TO_CLUSTER})
        return point_count >= config["min_points"]

    def cluster_locations(
        self,
        locations: list[UnifiedLocation],
        zoom_level: int,
        bounds: GeoBounds | None = None,
    ) -> tuple[list[UnifiedLocation], list[ClusterData]]:
        """
        Cluster locations based on zoom level and density.
        Returns (unclustered_locations, clusters).
        """
        if not locations or not self.should_cluster(zoom_level, len(locations)):
            return locations, []

        # Convert locations to projected coordinates for clustering
        cluster_points = self._project_locations(locations, bounds)

        # Get clustering configuration for zoom level
        config = self.ZOOM_CONFIGS.get(
            zoom_level,
            {
                "radius": self.DEFAULT_RADIUS,
                "min_points": self.MIN_POINTS_TO_CLUSTER,
            },
        )

        # Perform clustering
        clustered_groups = self._cluster_points(cluster_points, config["radius"], config["min_points"])

        # Separate individual locations from clusters
        unclustered_locations = []
        clusters = []

        for group in clustered_groups:
            if len(group) < config["min_points"]:
                # Add individual locations
                unclustered_locations.extend([cp.location for cp in group])
            else:
                # Create cluster
                cluster = self._create_cluster(group)
                clusters.append(cluster)

        return unclustered_locations, clusters

    def _project_locations(
        self,
        locations: list[UnifiedLocation],
        bounds: GeoBounds | None = None,
    ) -> list[ClusterPoint]:
        """Convert lat/lng coordinates to projected x/y for clustering calculations."""
        cluster_points = []

        # Use bounds or calculate from locations
        if not bounds:
            lats = [loc.latitude for loc in locations]
            lngs = [loc.longitude for loc in locations]
            bounds = GeoBounds(
                north=max(lats),
                south=min(lats),
                east=max(lngs),
                west=min(lngs),
            )

        # Simple equirectangular projection (good enough for clustering)
        center_lat = (bounds.north + bounds.south) / 2
        lat_scale = 111320  # meters per degree latitude
        lng_scale = 111320 * math.cos(math.radians(center_lat))  # meters per degree longitude

        for location in locations:
            # Convert to meters relative to bounds center
            x = (location.longitude - (bounds.west + bounds.east) / 2) * lng_scale
            y = (location.latitude - (bounds.north + bounds.south) / 2) * lat_scale

            cluster_points.append(ClusterPoint(location=location, x=x, y=y))

        return cluster_points

    def _cluster_points(
        self, points: list[ClusterPoint], radius_pixels: int, min_points: int
    ) -> list[list[ClusterPoint]]:
        """
        Cluster points using a simple distance-based approach.
        Radius is in pixels, converted to meters based on zoom level.
        """
        # Convert pixel radius to meters (rough approximation)
        # At zoom level 10, 1 pixel ≈ 150 meters
        radius_meters = radius_pixels * 150

        clustered = [False] * len(points)
        clusters = []

        for i, point in enumerate(points):
            if clustered[i]:
                continue

            # Find all points within radius
            cluster_group = [point]
            clustered[i] = True

            for j, other_point in enumerate(points):
                if i == j or clustered[j]:
                    continue

                distance = self._calculate_distance(point, other_point)
                if distance <= radius_meters:
                    cluster_group.append(other_point)
                    clustered[j] = True

            clusters.append(cluster_group)

        return clusters

    def _calculate_distance(self, point1: ClusterPoint, point2: ClusterPoint) -> float:
        """Calculate Euclidean distance between two projected points in meters."""
        dx = point1.x - point2.x
        dy = point1.y - point2.y
        return math.sqrt(dx * dx + dy * dy)

    def _create_cluster(self, cluster_points: list[ClusterPoint]) -> ClusterData:
        """Create a ClusterData object from a group of points."""
        locations = [cp.location for cp in cluster_points]

        # Calculate cluster center (average position)
        avg_lat = sum(loc.latitude for loc in locations) / len(locations)
        avg_lng = sum(loc.longitude for loc in locations) / len(locations)

        # Calculate cluster bounds
        lats = [loc.latitude for loc in locations]
        lngs = [loc.longitude for loc in locations]
        cluster_bounds = GeoBounds(north=max(lats), south=min(lats), east=max(lngs), west=min(lngs))

        # Collect location types in cluster
        types = {loc.type for loc in locations}

        # Select representative location (highest weight)
        representative = self._select_representative_location(locations)

        # Generate cluster ID
        self.cluster_id_counter += 1
        cluster_id = f"cluster_{self.cluster_id_counter}"

        return ClusterData(
            id=cluster_id,
            coordinates=[avg_lat, avg_lng],
            count=len(locations),
            types=types,
            bounds=cluster_bounds,
            representative_location=representative,
        )

    def _select_representative_location(self, locations: list[UnifiedLocation]) -> UnifiedLocation | None:
        """Select the most representative location for a cluster."""
        if not locations:
            return None

        # Prioritize by: 1) Parks over rides/companies, 2) Higher weight, 3)
        # Better rating
        parks = [loc for loc in locations if loc.type == LocationType.PARK]
        if parks:
            return max(
                parks,
                key=lambda x: (
                    x.cluster_weight,
                    x.metadata.get("rating", 0) or 0,
                ),
            )

        rides = [loc for loc in locations if loc.type == LocationType.RIDE]
        if rides:
            return max(
                rides,
                key=lambda x: (
                    x.cluster_weight,
                    x.metadata.get("rating", 0) or 0,
                ),
            )

        companies = [loc for loc in locations if loc.type == LocationType.COMPANY]
        if companies:
            return max(companies, key=lambda x: x.cluster_weight)

        # Fall back to highest weight location
        return max(locations, key=lambda x: x.cluster_weight)

    def get_cluster_breakdown(self, clusters: list[ClusterData]) -> dict[str, Any]:
        """Get statistics about clustering results."""
        if not clusters:
            return {
                "total_clusters": 0,
                "total_points_clustered": 0,
                "average_cluster_size": 0,
                "type_distribution": {},
                "category_distribution": {},
            }

        total_points = sum(cluster.count for cluster in clusters)
        type_counts = defaultdict(int)
        category_counts = defaultdict(int)

        for cluster in clusters:
            for location_type in cluster.types:
                type_counts[location_type.value] += cluster.count

            if cluster.representative_location:
                category_counts[cluster.representative_location.cluster_category] += 1

        return {
            "total_clusters": len(clusters),
            "total_points_clustered": total_points,
            "average_cluster_size": total_points / len(clusters),
            "largest_cluster_size": max(cluster.count for cluster in clusters),
            "smallest_cluster_size": min(cluster.count for cluster in clusters),
            "type_distribution": dict(type_counts),
            "category_distribution": dict(category_counts),
        }

    def expand_cluster(self, cluster: ClusterData, zoom_level: int) -> list[UnifiedLocation]:
        """
        Expand a cluster to show individual locations (for drill-down functionality).
        This would typically require re-querying the database with the cluster bounds.
        """
        # This is a placeholder - in practice, this would re-query the database
        # with the cluster bounds and higher detail level
        return []


class SmartClusteringRules:
    """
    Advanced clustering rules that consider location types and importance.
    """

    @staticmethod
    def should_cluster_together(loc1: UnifiedLocation, loc2: UnifiedLocation) -> bool:
        """Determine if two locations should be clustered together."""

        # Same park rides should cluster together more readily
        if loc1.type == LocationType.RIDE and loc2.type == LocationType.RIDE:
            park1_id = loc1.metadata.get("park_id")
            park2_id = loc2.metadata.get("park_id")
            if park1_id and park2_id and park1_id == park2_id:
                return True

        # Major parks should resist clustering unless very close
        if loc1.cluster_category == "major_park" or loc2.cluster_category == "major_park":
            return False

        # Similar types cluster more readily
        if loc1.type == loc2.type:  # noqa: SIM103
            return True

        # Different types can cluster but with higher threshold
        return False

    @staticmethod
    def calculate_cluster_priority(
        locations: list[UnifiedLocation],
    ) -> UnifiedLocation:
        """Select the representative location for a cluster based on priority rules."""
        # Prioritize by: 1) Parks over rides, 2) Higher weight, 3) Better
        # rating
        parks = [loc for loc in locations if loc.type == LocationType.PARK]
        if parks:
            return max(
                parks,
                key=lambda x: (
                    x.cluster_weight,
                    x.metadata.get("rating", 0) or 0,
                    x.metadata.get("ride_count", 0) or 0,
                ),
            )

        rides = [loc for loc in locations if loc.type == LocationType.RIDE]
        if rides:
            return max(
                rides,
                key=lambda x: (
                    x.cluster_weight,
                    x.metadata.get("rating", 0) or 0,
                ),
            )

        # Fall back to highest weight
        return max(locations, key=lambda x: x.cluster_weight)
