"""
3D metrics calculation module.
"""
import numpy as np
from collections import defaultdict

from ..class_config import CLASSES_3D as _CLASSES_3D


def normalize_angle(angle):
    """
    Normalize angle to [-π, π].
    
    Args:
        angle: float, angle in radians
        
    Returns:
        float, normalized angle
    """
    while angle > np.pi:
        angle -= 2 * np.pi
    while angle < -np.pi:
        angle += 2 * np.pi
    return angle


class Metrics3D:
    """Calculate 3D detection metrics (lateral/longitudinal errors, heading error)."""
    
    # 3D class IDs — sourced from eval_tools/class_config.py
    CLASSES_3D = _CLASSES_3D  # vehicle, bus, truck, tanker, unknown, pedestrian, bicycle, motorcyclist, tricycle
    VEHICLE_LARGE_CLASS_NAME = 'vehicle_large'
    VEHICLE_SMALL_CLASS_NAME = 'vehicle_small'
    VEHICLE_SIZE_CLASS_NAMES = [VEHICLE_LARGE_CLASS_NAME, VEHICLE_SMALL_CLASS_NAME]
    
    # Face mapping for vehicle class
    FACE_MAPPING = {
        'front': [18, 19, 20],  # Indices in GT values for front face center
        'back': [26, 27, 28],
        'rear': [26, 27, 28],   # Alias for back (some models use 'rear')
        'tail': [26, 27, 28],   # Alias for back (some models use 'tail')
        'left': [34, 35, 36],
        'right': [42, 43, 44]
    }
    
    def __init__(self, distance_ranges=None, lateral_distance_ranges=None, heading_tolerance='strict',
                 coord_system='camera', vehicle_size_split=None):
        """Initialize 3D metrics calculator.
        
        Args:
            distance_ranges: list of [min, max] longitudinal distance ranges in meters (z-axis).
                           e.g., [[0, 30], [30, 60], [60, 100]]
                           If None, only overall statistics are computed.
            lateral_distance_ranges: list of [min, max] lateral distance ranges in meters (x-axis).
                           e.g., [[-10, -5], [-5, 0], [0, 5], [5, 10]]
                           If None, no lateral distance grouping is used.
            heading_tolerance: str, heading error calculation mode
                           'strict': Standard calculation (default)
                           'relaxed': Consider 180° symmetry (for symmetric objects)
                           'both': Calculate both strict and relaxed metrics
            vehicle_size_split: dict, optional vehicle GT size split config:
                           {
                               'enabled': True,
                               'large_length_threshold': 6.0,
                               'large_height_threshold': 3.0,
                           }
        """
        if coord_system not in ('camera', 'ego'):
            raise ValueError(f"Unsupported coord_system: {coord_system}")
        self.coord_system = coord_system
        vehicle_size_split = vehicle_size_split or {}
        self.vehicle_size_split_enabled = vehicle_size_split.get('enabled', True)
        self.vehicle_large_length_threshold = float(
            vehicle_size_split.get('large_length_threshold', 6.0)
        )
        self.vehicle_large_height_threshold = float(
            vehicle_size_split.get('large_height_threshold', 3.0)
        )

        # Store errors for each class and distance range
        # Format: {class_id: {range_key: {'lateral': [...], 'longitudinal': [...], 'heading': [...], 'heading_relaxed': [...], 'heading_reversal': [...]}}}
        self.errors = defaultdict(lambda: defaultdict(lambda: {
            'lateral': [],
            'longitudinal': [],
            'longitudinal_relative': [],
            'heading': [],
            'heading_relaxed': [],
            'heading_reversal': []
        }))
        
        # Distance ranges configuration
        self.distance_ranges = distance_ranges
        self.lateral_distance_ranges = lateral_distance_ranges
        
        # Heading tolerance mode
        self.heading_tolerance = heading_tolerance
        
        # Build range keys
        self.range_keys = ['overall']
        if self.distance_ranges:
            self.range_keys.extend([f"long_{r[0]}-{r[1]}m" for r in self.distance_ranges])
        if self.lateral_distance_ranges:
            self.range_keys.extend([f"lat_{r[0]}-{r[1]}m" for r in self.lateral_distance_ranges])

    def _get_lateral_axis(self):
        return 0 if self.coord_system == 'camera' else 1

    def _get_longitudinal_axis(self):
        return 2 if self.coord_system == 'camera' else 0

    def _get_vehicle_size_class_name(self, gt):
        """Classify vehicle GT as large/small by GT 3D box dimensions."""
        dimensions = (gt.get('3d_info') or {}).get('dimensions')
        if dimensions is None or len(dimensions) < 2:
            return None

        length = float(dimensions[0])
        height = float(dimensions[1])
        if length > self.vehicle_large_length_threshold and height > self.vehicle_large_height_threshold:
            return self.VEHICLE_LARGE_CLASS_NAME
        return self.VEHICLE_SMALL_CLASS_NAME

    def _get_storage_class_keys(self, gt, class_id):
        """Return all metric buckets that should receive this sample."""
        class_keys = [class_id]
        if class_id == 0 and self.vehicle_size_split_enabled:
            vehicle_size_class = self._get_vehicle_size_class_name(gt)
            if vehicle_size_class is not None:
                class_keys.append(vehicle_size_class)
        return class_keys

    def _append_error_sample(self, class_key, range_key, lateral_error, longitudinal_error,
                             longitudinal_relative_error, heading_error_strict,
                             heading_error_relaxed, is_reversal):
        self.errors[class_key][range_key]['lateral'].append(lateral_error)
        self.errors[class_key][range_key]['longitudinal'].append(longitudinal_error)
        self.errors[class_key][range_key]['longitudinal_relative'].append(longitudinal_relative_error)
        self.errors[class_key][range_key]['heading'].append(heading_error_strict)
        self.errors[class_key][range_key]['heading_relaxed'].append(heading_error_relaxed)
        self.errors[class_key][range_key]['heading_reversal'].append(1 if is_reversal else 0)
    
    def add_sample(self, gt, det, class_id):
        """
        Add a matched GT-detection pair for 3D metric calculation.
        
        Args:
            gt: dict, ground truth with 3d_info
            det: dict, detection with 3d_info
            class_id: int, class ID
        """
        if class_id not in self.CLASSES_3D:
            return
        
        if gt['3d_info'] is None or det['3d_info'] is None:
            return
        
        # Get GT 3D center based on class type
        if class_id == 0:  # Vehicle class
            # Use face-specific center
            gt_center = self._get_vehicle_face_center(gt['3d_info'], det['3d_info']['face_type'])
        else:
            # Non-vehicle: use overall 3D box center
            gt_center = gt['3d_info']['center']
        
        # Get detection 3D center
        det_center = det['3d_info']['center']
        
        lateral_axis = self._get_lateral_axis()
        longitudinal_axis = self._get_longitudinal_axis()

        # Calculate lateral / longitudinal error in the configured coordinate system.
        lateral_error = abs(det_center[lateral_axis] - gt_center[lateral_axis])
        longitudinal_error = abs(det_center[longitudinal_axis] - gt_center[longitudinal_axis])
        
        # Calculate longitudinal relative error (relative to GT depth).
        # Use whole-object center z as the reference depth so that face centers
        # with near-zero or negative z (e.g. side faces at close range) don't
        # produce astronomically large relative errors.
        gt_whole_longitudinal = gt['3d_info']['center'][longitudinal_axis]
        gt_depth = max(abs(gt_whole_longitudinal), 1e-6)
        longitudinal_relative_error = longitudinal_error / gt_depth
        
        # Calculate heading error
        gt_rot = gt['3d_info']['rotation']
        det_rot = det['3d_info']['rotation']
        heading_error_strict = abs(normalize_angle(det_rot - gt_rot))
        
        # Calculate relaxed heading error (considering 180° symmetry)
        heading_error_relaxed = heading_error_strict
        is_reversal = False
        if heading_error_strict > np.pi - 0.2:  # Close to 180° (within ~170°)
            heading_error_relaxed = np.pi - heading_error_strict
            is_reversal = True
        
        # Get distance for range classification.
        # Always use whole-object center z so that samples with negative/near-zero
        # face center z (side faces at close range) still fall into the correct
        # longitudinal bucket instead of being silently dropped from all segments
        # while remaining in 'overall' with enormous relative errors.
        longitudinal_distance = gt['3d_info']['center'][longitudinal_axis]
        lateral_distance = gt_center[lateral_axis]
        
        class_keys = self._get_storage_class_keys(gt, class_id)

        # Store errors in overall
        for class_key in class_keys:
            self._append_error_sample(
                class_key,
                'overall',
                lateral_error,
                longitudinal_error,
                longitudinal_relative_error,
                heading_error_strict,
                heading_error_relaxed,
                is_reversal,
            )
        
        # Store errors by longitudinal distance range if configured
        if self.distance_ranges:
            range_key = self._get_longitudinal_distance_range(longitudinal_distance)
            if range_key:
                for class_key in class_keys:
                    self._append_error_sample(
                        class_key,
                        range_key,
                        lateral_error,
                        longitudinal_error,
                        longitudinal_relative_error,
                        heading_error_strict,
                        heading_error_relaxed,
                        is_reversal,
                    )
        
        # Store errors by lateral distance range if configured
        if self.lateral_distance_ranges:
            range_key = self._get_lateral_distance_range(lateral_distance)
            if range_key:
                for class_key in class_keys:
                    self._append_error_sample(
                        class_key,
                        range_key,
                        lateral_error,
                        longitudinal_error,
                        longitudinal_relative_error,
                        heading_error_strict,
                        heading_error_relaxed,
                        is_reversal,
                    )
    
    def _get_vehicle_face_center(self, gt_3d_info, face_type):
        """
        Get vehicle face center from GT based on predicted face type.
        
        Args:
            gt_3d_info: dict, GT 3D info with faces
            face_type: str, predicted face type (front/back/rear/tail/left/right)
            
        Returns:
            list, [x3d, y3d, z3d] of the specified face center
        """
        if gt_3d_info['faces'] is None:
            # Fallback to overall center if no face info
            return gt_3d_info['center']
        
        # Normalize face_type: rear/tail -> back
        normalized_face_type = face_type.lower()
        if normalized_face_type in ['rear', 'tail']:
            normalized_face_type = 'back'
        
        face_data = gt_3d_info['faces'].get(normalized_face_type)
        # face_data[7] is is_visible_from_camera; when 0 the x3d/y3d/z3d are set to
        # the sentinel -1.0, which must not be used as a real coordinate.
        if face_data is None or (len(face_data) >= 8 and face_data[7] == 0) or face_data[2] <= 0:
            # Fallback to overall center if face not found or not visible
            return gt_3d_info['center']
        
        # Extract x3d, y3d, z3d from face data (first 3 elements)
        return face_data[:3]
    
    def _get_longitudinal_distance_range(self, distance):
        """Get longitudinal distance range key for a given distance.
        
        Args:
            distance: float, longitudinal distance in meters (z-axis)
            
        Returns:
            str, range key (e.g., 'long_0-30m') or None if not in any range
        """
        if not self.distance_ranges:
            return None
        
        for range_min, range_max in self.distance_ranges:
            if range_min <= distance < range_max:
                return f"long_{range_min}-{range_max}m"
        
        return None
    
    def _get_lateral_distance_range(self, distance):
        """Get lateral distance range key for a given distance.
        
        Args:
            distance: float, lateral distance in meters (x-axis)
            
        Returns:
            str, range key (e.g., 'lat_-10--5m') or None if not in any range
        """
        if not self.lateral_distance_ranges:
            return None
        
        for range_min, range_max in self.lateral_distance_ranges:
            if range_min <= distance < range_max:
                return f"lat_{range_min}-{range_max}m"
        
        return None
    
    def compute_statistics(self, class_id, range_key='overall'):
        """
        Compute statistical metrics for a class and distance range.
        
        Args:
            class_id: int | str, class ID or synthetic class key
            range_key: str, distance range key (e.g., 'overall', '0-30m')
            
        Returns:
            dict with mean, median, std, 90th percentile for each error type
        """
        if class_id not in self.errors or range_key not in self.errors[class_id] or \
           len(self.errors[class_id][range_key]['lateral']) == 0:
            result = {
                'lateral_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
                'longitudinal_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
                'longitudinal_relative_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
                'heading_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
                'num_samples': 0
            }
            if self.heading_tolerance in ['relaxed', 'both']:
                result['heading_error_relaxed'] = {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0}
                result['reversal_count'] = 0
                result['reversal_percentage'] = 0.0
            return result
        
        lateral = np.array(self.errors[class_id][range_key]['lateral'])
        longitudinal = np.array(self.errors[class_id][range_key]['longitudinal'])
        longitudinal_relative = np.array(self.errors[class_id][range_key]['longitudinal_relative'])
        heading = np.array(self.errors[class_id][range_key]['heading'])
        heading_relaxed = np.array(self.errors[class_id][range_key]['heading_relaxed'])
        heading_reversal = np.array(self.errors[class_id][range_key]['heading_reversal'])
        
        def stats(arr):
            return {
                'mean': float(np.mean(arr)),
                'median': float(np.median(arr)),
                'std': float(np.std(arr)),
                'percentile_90': float(np.percentile(arr, 90))
            }
        
        result = {
            'lateral_error': stats(lateral),
            'longitudinal_error': stats(longitudinal),
            'longitudinal_relative_error': stats(longitudinal_relative),
            'num_samples': len(lateral)
        }
        
        # Add heading error based on tolerance mode
        if self.heading_tolerance == 'strict':
            result['heading_error'] = stats(heading)
        elif self.heading_tolerance == 'relaxed':
            result['heading_error'] = stats(heading_relaxed)
            result['reversal_count'] = int(np.sum(heading_reversal))
            result['reversal_percentage'] = float(np.sum(heading_reversal) / len(heading_reversal) * 100)
        elif self.heading_tolerance == 'both':
            result['heading_error'] = stats(heading)
            result['heading_error_relaxed'] = stats(heading_relaxed)
            result['reversal_count'] = int(np.sum(heading_reversal))
            result['reversal_percentage'] = float(np.sum(heading_reversal) / len(heading_reversal) * 100)
        
        return result
    
    def get_summary(self):
        """
        Get complete 3D evaluation summary.
        
        Returns:
            dict with per-class 3D metrics, including distance ranges if configured
        """
        from .parser import GroundTruthParser
        
        summary = {}

        summary_class_keys = list(self.CLASSES_3D)
        if self.vehicle_size_split_enabled:
            summary_class_keys.extend(self.VEHICLE_SIZE_CLASS_NAMES)

        for class_id in summary_class_keys:
            if isinstance(class_id, str):
                class_name = class_id
            else:
                class_name = GroundTruthParser.CLASS_NAMES.get(class_id, f"class_{class_id}")
            
            # Compute statistics for each distance range
            class_summary = {}
            for range_key in self.range_keys:
                stats = self.compute_statistics(class_id, range_key)
                if stats['num_samples'] > 0 or range_key == 'overall':
                    class_summary[range_key] = stats
            
            if class_summary:
                summary[class_name] = class_summary
        
        return summary
