from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field
import time
from datetime import datetime, timezone
from collections import defaultdict, deque
import numpy as np

from ..core.base import BaseProcessor, ProcessingContext, ProcessingResult, ConfigProtocol
from ..utils import (
    filter_by_confidence,
    apply_category_mapping,
    count_objects_in_zones,
    calculate_counting_summary,
    match_results_structure,
    bbox_smoothing,
    BBoxSmoothingConfig,
    BBoxSmoothingTracker
)
from ..core.config import BaseConfig, AlertConfig
from ..utils.geometry_utils import get_bbox_center, point_in_polygon, get_bbox_bottom25_center

class RunningDetector:
    """
    Velocity-based running detection from tracked person detections.
    Uses height-normalized velocity for scale-invariant detection.
    """
    
    DEFAULT_CONFIG = {
        'fps': 30,
        'min_track_age': 3,
        'velocity_units': ['pixel_per_frame', 'height_norm'],
        'primary_unit': 'height_norm',
        'smoother': 'ewma',
        'ewma_alpha': 0.3,
        'ma_window': 5,
        'threshold_method': 'static',
        'static_thresholds': {'height_norm': 0.05, 'pixel_per_frame': 8.0},
        'trajectory_buffer_size': 60,
    }
    
    def __init__(self, config: Optional[Dict] = None):
        self.config = {**self.DEFAULT_CONFIG, **(config or {})}
        self.trajectories: Dict[int, deque] = {}
        self.velocity_history: Dict[int, deque] = {}
        self.smoothed_state: Dict[int, Dict[str, float]] = {}
        self.track_ages: Dict[int, int] = {}
        self.frame_count = 0
    
    def update(self, detections: List[Dict], frame_id: Optional[int] = None) -> List[Dict]:
        """Process detections and add velocity/running info."""
        if frame_id is not None:
            self.frame_count = frame_id
        else:
            self.frame_count += 1
        
        for det in detections:
            track_id = det.get('track_id')
            bbox = det.get('bounding_box')
            if track_id is None or bbox is None:
                continue
            
            centroid, height = self._extract_features(bbox)
            self._update_trajectory(track_id, centroid, height)
            velocity = self._compute_velocity(track_id)
            smoothed = self._smooth_velocity(track_id, velocity)
            is_running, details = self._classify_running(track_id, smoothed)
            
            det['velocity'] = velocity
            det['smoothed_velocity'] = smoothed
            det['is_running'] = is_running
            det['running_details'] = details
        
        return detections
    
    def _extract_features(self, bbox: Dict) -> Tuple[Tuple[float, float], float]:
        """Extract bottom-25% centroid and bbox height."""
        if 'xmin' in bbox:
            x1, y1, x2, y2 = bbox['xmin'], bbox['ymin'], bbox['xmax'], bbox['ymax']
        elif 'x1' in bbox:
            x1, y1, x2, y2 = bbox['x1'], bbox['y1'], bbox['x2'], bbox['y2']
        else:
            vals = list(bbox.values())
            if len(vals) >= 4:
                x1, y1, x2, y2 = vals[0], vals[1], vals[2], vals[3]
            else:
                return (0, 0), 1
        height = max(y2 - y1, 1)
        cx = (x1 + x2) / 2
        cy = y2 - 0.25 * height
        return (cx, cy), height
    
    def _update_trajectory(self, track_id: int, centroid: Tuple[float, float], height: float):
        if track_id not in self.trajectories:
            self.trajectories[track_id] = deque(maxlen=self.config['trajectory_buffer_size'])
            self.track_ages[track_id] = 0
        self.trajectories[track_id].append((centroid, height))
        self.track_ages[track_id] += 1
    
    def _compute_velocity(self, track_id: int) -> Dict[str, float]:
        traj = self.trajectories.get(track_id)
        if not traj or len(traj) < 2:
            return {}
        (c1, h1), (c2, h2) = traj[-2], traj[-1]
        dx, dy = c2[0] - c1[0], c2[1] - c1[1]
        pixel_disp = np.sqrt(dx*dx + dy*dy)
        height = (h1 + h2) / 2
        
        velocity = {}
        if 'pixel_per_frame' in self.config['velocity_units']:
            velocity['pixel_per_frame'] = pixel_disp
        if 'height_norm' in self.config['velocity_units']:
            velocity['height_norm'] = pixel_disp / height if height > 0 else 0
        
        if track_id not in self.velocity_history:
            self.velocity_history[track_id] = deque(maxlen=self.config['trajectory_buffer_size'])
        self.velocity_history[track_id].append(velocity)
        return velocity
    
    def _smooth_velocity(self, track_id: int, velocity: Dict[str, float]) -> float:
        primary_unit = self.config['primary_unit']
        if not velocity or primary_unit not in velocity:
            return 0.0
        raw_value = velocity[primary_unit]
        smoother = self.config['smoother']
        
        if track_id not in self.smoothed_state:
            self.smoothed_state[track_id] = {}
        
        if smoother == 'none':
            return raw_value
        elif smoother == 'ewma':
            alpha = self.config['ewma_alpha']
            prev = self.smoothed_state[track_id].get(primary_unit, raw_value)
            smoothed = alpha * raw_value + (1 - alpha) * prev
            self.smoothed_state[track_id][primary_unit] = smoothed
            return smoothed
        elif smoother == 'ma':
            history = self.velocity_history.get(track_id, deque())
            window = self.config['ma_window']
            recent = [v.get(primary_unit, 0) for v in list(history)[-window:]]
            return np.mean(recent) if recent else raw_value
        return raw_value
    
    def _classify_running(self, track_id: int, smoothed_velocity: float) -> Tuple[bool, Dict]:
        details = {'smoothed_velocity': smoothed_velocity, 'track_age': self.track_ages.get(track_id, 0)}
        if self.track_ages.get(track_id, 0) < self.config['min_track_age']:
            details['suppressed'] = True
            return False, details
        
        primary_unit = self.config['primary_unit']
        threshold = self.config['static_thresholds'].get(primary_unit, 0.05)
        details['threshold_value'] = threshold
        is_running = smoothed_velocity > threshold
        return is_running, details
    
    def reset(self):
        self.trajectories.clear()
        self.velocity_history.clear()
        self.smoothed_state.clear()
        self.track_ages.clear()
        self.frame_count = 0
    
    def cleanup_lost_tracks(self, active_track_ids: List[int]):
        active_set = set(active_track_ids)
        for store in [self.trajectories, self.velocity_history, self.smoothed_state, self.track_ages]:
            dead_ids = [tid for tid in store if tid not in active_set]
            for tid in dead_ids:
                del store[tid]


@dataclass
class RunningConfirmationConfig:
    """Configuration for running detection confirmation layer."""
    window_size: int = 10
    min_positives_for_run: int = 5 
    track_eviction_frames: int = 60
    enabled: bool = True


class RunningConfirmationLayer:
    """
    Per-track temporal confirmation for running detection.
    Suppresses flickering FPs by requiring persistent running classification.
    """
    
    def __init__(self, config: Optional[RunningConfirmationConfig] = None):
        self.config = config or RunningConfirmationConfig()
        self._track_windows: Dict[Any, deque] = defaultdict(
            lambda: deque(maxlen=self.config.window_size)
        )
        self._track_last_seen: Dict[Any, int] = {}
        self._frame_count: int = 0
        self._stats = {"total_running_raw": 0, "confirmed_running": 0, "suppressed_running": 0}
    
    def update(self, detections: List[Dict]) -> List[Dict]:
        """
        Apply confirmation logic to running detections.
        
        Detections with is_running=True are evaluated against the persistence window.
        Only detections meeting min_positives_for_run threshold are confirmed.
        """
        if not self.config.enabled:
            return detections
        
        self._frame_count += 1
        output = []
        seen_track_ids = set()
        
        for det in detections:
            track_id = det.get("track_id")
            is_running_raw = det.get("is_running", False)
            
            if track_id is None:
                output.append(det)
                continue
            
            seen_track_ids.add(track_id)
            self._track_last_seen[track_id] = self._frame_count
            
            # Record running state in window
            self._track_windows[track_id].append(is_running_raw)
            
            if is_running_raw:
                self._stats["total_running_raw"] += 1
            
            # Evaluate confirmation
            is_confirmed = self._evaluate_track(track_id)
            
            # Update detection with confirmed status
            det_out = det.copy()
            det_out["is_running_confirmed"] = is_confirmed
            
            if is_running_raw and is_confirmed:
                self._stats["confirmed_running"] += 1
            elif is_running_raw and not is_confirmed:
                self._stats["suppressed_running"] += 1
            
            output.append(det_out)
        
        # Periodic eviction
        if self._frame_count % 30 == 0:
            self._evict_stale_tracks()
        
        return output
    
    def _evaluate_track(self, track_id: Any) -> bool:
        """Check if track meets persistence threshold for running confirmation."""
        window = self._track_windows.get(track_id)
        if window is None or len(window) == 0:
            return False
        
        running_count = sum(1 for is_run in window if is_run)
        return running_count >= self.config.min_positives_for_run
    
    def _evict_stale_tracks(self):
        eviction_threshold = self._frame_count - self.config.track_eviction_frames
        stale_ids = [tid for tid, last_seen in self._track_last_seen.items() if last_seen < eviction_threshold]
        for tid in stale_ids:
            self._track_windows.pop(tid, None)
            self._track_last_seen.pop(tid, None)
    
    def get_stats(self) -> Dict[str, Any]:
        return {**self._stats, "active_tracks": len(self._track_windows), "frame_count": self._frame_count}
    
    def reset(self):
        self._track_windows.clear()
        self._track_last_seen.clear()
        self._frame_count = 0
        self._stats = {"total_running_raw": 0, "confirmed_running": 0, "suppressed_running": 0}

@dataclass
class RunningDetectionConfig(BaseConfig):
    """Configuration for running detection use case."""
    enable_smoothing: bool = True
    smoothing_algorithm: str = "observability"
    smoothing_window_size: int = 20
    smoothing_cooldown_frames: int = 5
    smoothing_confidence_range_factor: float = 0.5
    confidence_threshold: float = 0.6
    
    enable_class_aggregation: bool = False
    class_aggregation_window_size: int = 30
    
    zone_config: Optional[Dict[str, List[List[float]]]] = None
    
    # Categories: Input is 'person' (class 0), mapped to 'running' for pipeline
    # Final output contains only confirmed running detections with category='running'
    usecase_categories: List[str] = field(default_factory=lambda: ['running'])
    target_categories: List[str] = field(default_factory=lambda: ['running'])
    
    alert_config: AlertConfig = field(
        default_factory=lambda: AlertConfig(count_thresholds={"running": 1})
    )
    
    index_to_category: Optional[Dict[int, str]] = field(
        default_factory=lambda: {0: "running"}
    )
    
    running_detector_config: Dict = field(default_factory=lambda: {
        'fps': 30,
        'min_track_age': 3,
        'primary_unit': 'height_norm',
        'smoother': 'ewma',
        'ewma_alpha': 0.3,
        'static_thresholds': {'height_norm': 0.05, 'pixel_per_frame': 8.0},
    })
    
    min_positives_for_run: int = 5  # Frames of persistent running to confirm
    confirmation_window_size: int = 10


class RunningDetectionUseCase(BaseProcessor):
    CATEGORY_DISPLAY = {"running": "People Running"}
    
    def __init__(self):
        super().__init__("running_detection")
        self.category = "security"
        self.CASE_TYPE: Optional[str] = 'running_detection'
        self.CASE_VERSION: Optional[str] = '1.0'
        self.target_categories = ['running']
        
        # Core components 
        self.smoothing_tracker = None
        self.tracker = None
        self.running_detector = None
        self.running_confirmator = None
        
        # Tracking state
        self._total_frame_counter = 0
        self._global_frame_offset = 0
        self._tracking_start_time = None
        self._track_aliases: Dict[Any, Any] = {}
        self._canonical_tracks: Dict[Any, Dict[str, Any]] = {}
        self._track_merge_iou_threshold: float = 0.05
        self._track_merge_time_window: float = 7.0
        self._ascending_alert_list: List[int] = []
        self.current_incident_end_timestamp: str = "N/A"
        self.start_timer = None
        
        # Count tracking
        self._per_category_total_track_ids = {cat: set() for cat in self.target_categories}
        self._current_frame_track_ids = {cat: set() for cat in self.target_categories}
        self._new_track_ids_this_frame = {cat: set() for cat in self.target_categories}
        self._previous_frame_track_ids = {cat: set() for cat in self.target_categories}
        self._tracked_in_zones = set()
        self._total_count_list = []
        
        # Zone tracking
        self._zone_current_track_ids = {}
        self._zone_total_track_ids = {}
        self._zone_current_counts = {}
        self._zone_total_counts = {}

    def process(self, data: Any, config: ConfigProtocol, context: Optional[ProcessingContext] = None,
                stream_info: Optional[Dict[str, Any]] = None) -> ProcessingResult:
        processing_start = time.time()
        
        if not isinstance(config, RunningDetectionConfig):
            return self.create_error_result(
                f"Invalid config type: expected RunningDetectionConfig, got {type(config).__name__}",
                usecase=self.name, category=self.category, context=context
            )
        
        if context is None:
            context = ProcessingContext()
        
        has_zones = bool(config.zone_config and config.zone_config.get('zones'))
        
        data = self._normalize_yolo_results(data, config.index_to_category)
        
        input_format = match_results_structure(data)
        context.input_format = input_format
        context.confidence_threshold = config.confidence_threshold
        
        if config.confidence_threshold is not None:
            processed_data = filter_by_confidence(data, config.confidence_threshold)
        else:
            processed_data = data
        
        if config.index_to_category:
            processed_data = apply_category_mapping(processed_data, config.index_to_category)
        
        # NOTE : At this point all detections have category='running' (mapped from person)
        # We filter to only actual running detections after velocity analysis
        
        # Bbox smoothing 
        if config.enable_smoothing:
            if self.smoothing_tracker is None:
                smoothing_config = BBoxSmoothingConfig(
                    smoothing_algorithm=config.smoothing_algorithm,
                    window_size=config.smoothing_window_size,
                    cooldown_frames=config.smoothing_cooldown_frames,
                    confidence_threshold=config.confidence_threshold,
                    confidence_range_factor=config.smoothing_confidence_range_factor,
                    enable_smoothing=True
                )
                self.smoothing_tracker = BBoxSmoothingTracker(smoothing_config)
            processed_data = bbox_smoothing(processed_data, self.smoothing_tracker.config, self.smoothing_tracker)
        
        try:
            from ..advanced_tracker import AdvancedTracker
            from ..advanced_tracker.config import TrackerConfig
            if self.tracker is None:
                tracker_config = TrackerConfig(
                    enable_class_aggregation=config.enable_class_aggregation,
                    class_aggregation_window_size=config.class_aggregation_window_size
                )
                tracker_namespace = None
                if stream_info and stream_info.get('stream_key'):
                    tracker_namespace = str(hash(stream_info['stream_key']) % 1000000)
                self.tracker = AdvancedTracker(tracker_config, namespace=tracker_namespace)
                self.tracker.restore_state()
                self.logger.info(f"Initialized AdvancedTracker for Running Detection (namespace={tracker_namespace})")
            processed_data = self.tracker.update(processed_data)
        except Exception as e:
            self.logger.warning(f"AdvancedTracker failed: {e}")
        
        if self.running_detector is None:
            detector_config = config.running_detector_config.copy()
            if stream_info:
                fps = stream_info.get("input_settings", {}).get("original_fps", 30)
                detector_config['fps'] = fps
            self.running_detector = RunningDetector(detector_config)
            self.logger.info(f"Initialized RunningDetector with config: {detector_config}")
        
        # Running Detection based on trajectory analysis of tracked detections
        processed_data = self.running_detector.update(processed_data, self._total_frame_counter)
        
        if self.running_confirmator is None:
            confirm_config = RunningConfirmationConfig(
                window_size=config.confirmation_window_size,
                min_positives_for_run=config.min_positives_for_run,
                enabled=True
            )
            self.running_confirmator = RunningConfirmationLayer(confirm_config)
            self.logger.info(f"Initialized RunningConfirmationLayer (min_positives={config.min_positives_for_run})")
        
        processed_data = self.running_confirmator.update(processed_data)
        
        # Only keep detections where is_running_confirmed=True
        running_detections = [
            det for det in processed_data 
            if det.get('is_running_confirmed', False)
        ]
        
        # Log confirmation stats periodically
        if self._total_frame_counter % 100 == 0:
            stats = self.running_confirmator.get_stats()
            self.logger.debug(f"Running confirmation stats: {stats}")
        
        self._update_tracking_state(running_detections, has_zones=has_zones)
        self._total_frame_counter += 1
        
        # Get frame number
        frame_number = None
        if stream_info:
            input_settings = stream_info.get("input_settings", {})
            start_frame = input_settings.get("start_frame")
            end_frame = input_settings.get("end_frame")
            if start_frame is not None and end_frame is not None and start_frame == end_frame:
                frame_number = start_frame
        
        general_counting_summary = calculate_counting_summary(data)
        counting_summary = self._count_categories(running_detections, config)
        total_counts = self.get_total_counts()
        counting_summary['total_counts'] = total_counts
        counting_summary['categories'] = {}
        for detection in running_detections:
            category = detection.get("category", "running")
            counting_summary["categories"][category] = counting_summary["categories"].get(category, 0) + 1
        
        # Zone analysis
        zone_analysis = {}
        if has_zones:
            zone_analysis = count_objects_in_zones(running_detections, config.zone_config['zones'], stream_info)
            if zone_analysis:
                enhanced_zone_analysis = self._update_zone_tracking(zone_analysis, running_detections, config)
                for zone_name, enhanced_data in enhanced_zone_analysis.items():
                    zone_analysis[zone_name] = enhanced_data
                per_category_count = {cat: len(self._current_frame_track_ids.get(cat, set())) for cat in self.target_categories}
                counting_summary['per_category_count'] = {k: v for k, v in per_category_count.items() if v > 0}
                counting_summary['total_count'] = sum(per_category_count.values())
        
        alerts = self._check_alerts(counting_summary, frame_number, config)
        
        # Generate outputs
        incidents_list = self._generate_incidents(counting_summary, zone_analysis, alerts, config, frame_number, stream_info)
        tracking_stats_list = self._generate_tracking_stats(counting_summary, zone_analysis, alerts, config, frame_number, stream_info)
        business_analytics_list = self._generate_business_analytics(counting_summary, zone_analysis, alerts, config, stream_info, is_empty=True)
        summary_list = self._generate_summary(counting_summary, zone_analysis, incidents_list, tracking_stats_list, business_analytics_list, alerts)
        
        incidents = incidents_list[0] if incidents_list else {}
        tracking_stats = tracking_stats_list[0] if tracking_stats_list else {}
        business_analytics = business_analytics_list[0] if business_analytics_list else {}
        summary = summary_list[0] if summary_list else {}
        
        agg_summary = {
            str(frame_number): {
                "incidents": incidents,
                "tracking_stats": tracking_stats,
                "business_analytics": business_analytics,
                "alerts": alerts,
                "zone_analysis": zone_analysis,
                "human_text": summary
            }
        }
        
        context.mark_completed()
        result = self.create_result(
            data={"agg_summary": agg_summary},
            usecase=self.name,
            category=self.category,
            context=context
        )
        
        proc_time = time.time() - processing_start
        print(f"latency_ms: {proc_time * 1000:.1f} | fps: {1/proc_time:.1f} | frame: {self._total_frame_counter} | running: {len(running_detections)}")
        
        return result

    def _normalize_yolo_results(self, data: Any, index_to_category: Optional[Dict[int, str]] = None) -> Any:
        """Normalize YOLO outputs to internal schema."""
        def to_bbox_dict(d: Dict[str, Any]) -> Dict[str, Any]:
            if "bounding_box" in d and isinstance(d["bounding_box"], dict):
                return d["bounding_box"]
            if "bbox" in d:
                bbox = d["bbox"]
                if isinstance(bbox, dict):
                    return bbox
                if isinstance(bbox, (list, tuple)) and len(bbox) >= 4:
                    return {"x1": bbox[0], "y1": bbox[1], "x2": bbox[2], "y2": bbox[3]}
            if "xyxy" in d and isinstance(d["xyxy"], (list, tuple)) and len(d["xyxy"]) >= 4:
                return {"x1": d["xyxy"][0], "y1": d["xyxy"][1], "x2": d["xyxy"][2], "y2": d["xyxy"][3]}
            return {}

        def resolve_category(d: Dict[str, Any]) -> Tuple[str, Optional[int]]:
            raw_cls = d.get("category", d.get("category_id", d.get("class", d.get("cls"))))
            if isinstance(raw_cls, int):
                if index_to_category and raw_cls in index_to_category:
                    return index_to_category[raw_cls], raw_cls
                return str(raw_cls), raw_cls
            if isinstance(raw_cls, str):
                return raw_cls, None
            return "unknown", None

        def normalize_det(det: Dict[str, Any]) -> Dict[str, Any]:
            category_name, category_id = resolve_category(det)
            confidence = det.get("confidence", det.get("conf", det.get("score", 0.0)))
            bbox = to_bbox_dict(det)
            normalized = {"category": category_name, "confidence": confidence, "bounding_box": bbox}
            if category_id is not None:
                normalized["category_id"] = category_id
            for key in ("track_id", "frame_id", "masks", "segmentation"):
                if key in det:
                    normalized[key] = det[key]
            return normalized

        if isinstance(data, list):
            return [normalize_det(d) if isinstance(d, dict) else d for d in data]
        if isinstance(data, dict):
            return {k: ([normalize_det(d) for d in v] if isinstance(v, list) else v) for k, v in data.items()}
        return data

    def _update_tracking_state(self, detections: list, has_zones: bool = False):
        """Update tracking state with confirmed running detections."""
        if not hasattr(self, "_per_category_total_track_ids"):
            self._per_category_total_track_ids = {cat: set() for cat in self.target_categories}
        if not hasattr(self, "_previous_frame_track_ids"):
            self._previous_frame_track_ids = {cat: set() for cat in self.target_categories}

        self._current_frame_track_ids = {cat: set() for cat in self.target_categories}

        for det in detections:
            cat = det.get("category")
            raw_track_id = det.get("track_id")
            if cat not in self.target_categories or raw_track_id is None:
                continue
            bbox = det.get("bounding_box", det.get("bbox"))
            canonical_id = self._merge_or_register_track(raw_track_id, bbox)
            det["track_id"] = canonical_id
            self._current_frame_track_ids.setdefault(cat, set()).add(canonical_id)

        self._new_track_ids_this_frame = {
            cat: (self._current_frame_track_ids.get(cat, set()) - self._per_category_total_track_ids.get(cat, set()))
            for cat in self.target_categories
        }

        for cat, ids in self._current_frame_track_ids.items():
            self._per_category_total_track_ids.setdefault(cat, set()).update(ids)

        self._previous_frame_track_ids = {cat: set(ids) for cat, ids in self._current_frame_track_ids.items()}

    def _update_zone_tracking(self, zone_analysis: Dict, detections: List[Dict], config) -> Dict:
        """Update zone tracking with current frame data."""
        if not zone_analysis or not config.zone_config or not config.zone_config.get('zones'):
            return {}
        
        enhanced = {}
        zones = config.zone_config['zones']
        track_to_cat = {det.get('track_id'): det.get('category') for det in detections if det.get('track_id')}
        current_frame_zone_tracks = {z: set() for z in zones}
        
        for zone_name in zones:
            if zone_name not in self._zone_current_track_ids:
                self._zone_current_track_ids[zone_name] = set()
            if zone_name not in self._zone_total_track_ids:
                self._zone_total_track_ids[zone_name] = set()
        
        for det in detections:
            track_id = det.get("track_id")
            bbox = det.get("bounding_box", det.get("bbox"))
            if not track_id or not bbox:
                continue
            center = get_bbox_bottom25_center(bbox)
            for zone_name, polygon in zones.items():
                pts = [(p[0], p[1]) for p in polygon]
                if point_in_polygon(center, pts):
                    current_frame_zone_tracks[zone_name].add(track_id)
                    cat = track_to_cat.get(track_id)
                    if cat:
                        self._current_frame_track_ids.setdefault(cat, set()).add(track_id)
                        if track_id not in self._tracked_in_zones:
                            self._tracked_in_zones.add(track_id)
        
        for zone_name, counts in zone_analysis.items():
            current = current_frame_zone_tracks.get(zone_name, set())
            self._zone_current_track_ids[zone_name] = current
            self._zone_total_track_ids[zone_name].update(current)
            self._zone_current_counts[zone_name] = len(current)
            self._zone_total_counts[zone_name] = len(self._zone_total_track_ids[zone_name])
            enhanced[zone_name] = {
                "current_count": self._zone_current_counts[zone_name],
                "total_count": self._zone_total_counts[zone_name],
                "current_track_ids": list(current),
                "total_track_ids": list(self._zone_total_track_ids[zone_name]),
                "original_counts": counts
            }
        return enhanced

    def _count_categories(self, detections: list, config) -> dict:
        counts = {}
        for det in detections:
            cat = det.get('category', 'running')
            counts[cat] = counts.get(cat, 0) + 1
        return {
            "total_count": sum(counts.values()),
            "per_category_count": counts,
            "detections": [
                {
                    "bounding_box": det.get("bounding_box"),
                    "category": det.get("category"),
                    "confidence": det.get("confidence"),
                    "track_id": det.get("track_id"),
                    "frame_id": det.get("frame_id"),
                    "velocity": det.get("velocity", {}),
                    "smoothed_velocity": det.get("smoothed_velocity", 0),
                }
                for det in detections
            ]
        }

    def get_total_counts(self):
        return {cat: len(ids) for cat, ids in getattr(self, '_per_category_total_track_ids', {}).items()}

    def get_new_counts_this_frame(self) -> Dict[str, int]:
        return {cat: len(ids) for cat, ids in getattr(self, '_new_track_ids_this_frame', {}).items()}

    def _check_alerts(self, summary: dict, frame_number: Any, config) -> List[Dict]:
        """Check alert thresholds."""
        frame_key = str(frame_number) if frame_number is not None else "current_frame"
        alerts = []
        total = summary.get("total_count", 0)
        per_cat = summary.get("per_category_count", {})
        
        if not config.alert_config:
            return alerts
        
        thresholds = getattr(config.alert_config, 'count_thresholds', {})
        for category, threshold in thresholds.items():
            if category == "all" and total >= threshold:
                alerts.append({"alert_id": f"alert_all_{frame_key}", "category": "all", "count": total, "threshold": threshold})
            elif category in per_cat and per_cat[category] >= threshold:
                alerts.append({"alert_id": f"alert_{category}_{frame_key}", "category": category, "count": per_cat[category], "threshold": threshold})
        return alerts

    def _generate_incidents(self, counting_summary, zone_analysis, alerts, config, frame_number, stream_info) -> List[Dict]:
        """Generate incident reports."""
        total = counting_summary.get("total_count", 0)
        if total > 0:
            return [{"type": self.CASE_TYPE, "count": total, "frame": frame_number}]
        return [{}]

    def _generate_tracking_stats(self, counting_summary, zone_analysis, alerts, config, frame_number, stream_info) -> List[Dict]:
        """Generate tracking statistics with confirmed running detections."""
        total_counts_dict = counting_summary.get("total_counts", {})
        per_cat = counting_summary.get("per_category_count", {})
        new_counts = self.get_new_counts_this_frame()
        
        total_counts = [{"category": cat, "count": cnt} for cat, cnt in total_counts_dict.items() if cnt > 0]
        current_counts = [{"category": cat, "count": cnt} for cat, cnt in per_cat.items()]
        current_new_counts = [{"category": cat, "count": cnt} for cat, cnt in new_counts.items()]
        
        # Detections for overlay - only confirmed running
        detections = []
        for det in counting_summary.get("detections", []):
            bbox = det.get("bounding_box", {})
            category = det.get("category", "running")
            det_obj = self.create_detection_object(category, bbox)
            detections.append(det_obj)
        
        current_ts = self._get_current_timestamp_str(stream_info, precision=False)
        start_ts = self._get_start_timestamp_str(stream_info, precision=False)
        
        human_lines = [f"CURRENT FRAME @ {current_ts}:"]
        run_count = per_cat.get('running', 0)
        human_lines.append(f"\t- Running Detected: {run_count}")
        human_lines.append(f"TOTAL SINCE {start_ts}:")
        human_lines.append(f"\t- Total Running Events: {total_counts_dict.get('running', 0)}")
        
        tracking_stat = self.create_tracking_stats(
            total_counts=total_counts,
            current_counts=current_counts,
            detections=detections,
            human_text="\n".join(human_lines),
            camera_info=self.get_camera_info_from_stream(stream_info),
            alerts=alerts,
            alert_settings=[],
            reset_settings=[{"interval_type": "daily", "reset_time": {"value": 9, "time_unit": "hour"}}],
            start_time=self._get_current_timestamp_str(stream_info, precision=True),
            reset_time=self._get_start_timestamp_str(stream_info, precision=True)
        )
        tracking_stat['target_categories'] = self.target_categories
        tracking_stat['current_new_counts'] = current_new_counts
        return [tracking_stat]

    def _generate_business_analytics(self, *args, is_empty=False, **kwargs) -> List[Dict]:
        return [] if is_empty else []

    def _generate_summary(self, summary, zone_analysis, incidents, tracking_stats, business_analytics, alerts) -> List[str]:
        lines = [f"Application: {self.CASE_TYPE} v{self.CASE_VERSION}"]
        if tracking_stats:
            lines.append(tracking_stats[0].get('human_text', ''))
        return ["\n".join(lines)]

    # Timestamp helpers (preserved from base)
    def _format_timestamp(self, ts: Any) -> str:
        if isinstance(ts, (int, float)):
            return datetime.fromtimestamp(ts, timezone.utc).strftime('%Y:%m:%d %H:%M:%S')
        if isinstance(ts, str):
            ts = ts.replace(' UTC', '').strip()
            if '.' in ts:
                ts = ts.split('.')[0]
            parts = ts.split('-')
            if len(parts) >= 4:
                return f"{parts[0]}:{parts[1]}:{parts[2]} {'-'.join(parts[3:])}"
        return str(ts)

    def _get_current_timestamp_str(self, stream_info, precision=False, frame_id=None) -> str:
        if not stream_info:
            return "00:00:00.00"
        st = stream_info.get("input_settings", {}).get("stream_time", "NA")
        return self._format_timestamp(st) if st != "NA" else datetime.now(timezone.utc).strftime('%Y:%m:%d %H:%M:%S')

    def _get_start_timestamp_str(self, stream_info, precision=False) -> str:
        if self.start_timer:
            return self._format_timestamp(self.start_timer)
        if stream_info:
            st = stream_info.get("input_settings", {}).get("stream_time")
            if st and st != "NA":
                self.start_timer = st
                return self._format_timestamp(st)
        self.start_timer = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
        return self._format_timestamp(self.start_timer)

    def _merge_or_register_track(self, raw_id: Any, bbox: Any) -> Any:
        if raw_id is None or bbox is None:
            return raw_id
        now = time.time()
        if raw_id in self._track_aliases:
            cid = self._track_aliases[raw_id]
            if cid in self._canonical_tracks:
                self._canonical_tracks[cid].update({"last_bbox": bbox, "last_update": now})
                self._canonical_tracks[cid]["raw_ids"].add(raw_id)
            return cid
        for cid, info in self._canonical_tracks.items():
            if now - info["last_update"] > self._track_merge_time_window:
                continue
            if self._compute_iou(bbox, info["last_bbox"]) >= self._track_merge_iou_threshold:
                self._track_aliases[raw_id] = cid
                info.update({"last_bbox": bbox, "last_update": now})
                info["raw_ids"].add(raw_id)
                return cid
        self._track_aliases[raw_id] = raw_id
        self._canonical_tracks[raw_id] = {"last_bbox": bbox, "last_update": now, "raw_ids": {raw_id}}
        return raw_id

    def _compute_iou(self, b1: Any, b2: Any) -> float:
        def to_list(b):
            if isinstance(b, list):
                return b[:4]
            if isinstance(b, dict):
                if "xmin" in b:
                    return [b["xmin"], b["ymin"], b["xmax"], b["ymax"]]
                if "x1" in b:
                    return [b["x1"], b["y1"], b["x2"], b["y2"]]
            return []
        l1, l2 = to_list(b1), to_list(b2)
        if len(l1) < 4 or len(l2) < 4:
            return 0.0
        xi = max(l1[0], l2[0])
        yi = max(l1[1], l2[1])
        xa = min(l1[2], l2[2])
        ya = min(l1[3], l2[3])
        inter = max(0, xa - xi) * max(0, ya - yi)
        a1 = (l1[2] - l1[0]) * (l1[3] - l1[1])
        a2 = (l2[2] - l2[0]) * (l2[3] - l2[1])
        return inter / (a1 + a2 - inter) if (a1 + a2 - inter) > 0 else 0.0