# acmenra_cv/tracker/tracker.py

import logging
import numpy as np
from datetime import datetime
from typing import List, Dict

from acmenra_cv.inference.backend import Backend
from acmenra_cv.tracker.timed_point import TimedPoint
from acmenra_cv.tracker.tracked_object import TrackedObject
from acmenra_cv.tracker.timed_point_queue import TimedPointQueue


logger = logging.getLogger(__name__)


class Tracker:
    """
    Multi-object tracking system that works with any Backend implementation.

    Provides persistent object IDs, configurable trajectory history, and
    backend-agnostic tracking. The Tracker does NOT perform inference -
    it receives Results from a Backend and manages track state.

    Key features:
    - Backend-agnostic: works with YOLO, OpenVINO, custom models, etc.
    - Persistent tracking IDs across frames
    - Configurable trajectory history length via `max_length`
    - Clean separation of concerns (tracking vs inference)
    ...

    Attributes:
        _id (int): Unique identifier for this tracker worker.
        _backend (Backend): Inference backend providing detection results.
        _max_length (int): Maximum trajectory history length per object.
        _objects (Dict[int, TrackedObject]): Active track state dictionary.

    Note:
        - Tracker requires Backend to return instances with valid IDs
        - All spatial coordinates use normalized space [0.0, 1.0]
        - Tracking state is maintained internally via _objects dictionary
        - Thread-safety: Not inherently thread-safe; use external locks for concurrent access
    """

    def __init__(self,
                 id: int,
                 backend: Backend,
                 max_length: int = 30) -> None:  # tested (good)
        """
        Initializes the Tracker with unique ID and backend configuration.

        Args:
            id (int): Unique identifier for this tracker worker. Must be >= 0.
            backend (Backend): Inference backend providing detection results.
            max_length (int, optional): Max trajectory history length. Defaults to 30.

        Raises:
            TypeError: If any parameter has incorrect type.
            ValueError: If id is negative or max_length is negative.

        Note:
            - ID is mandatory for multi-worker orchestration and log aggregation
            - Backend must be a valid Backend implementation
            - max_length affects memory usage and trajectory smoothness
        """
        self.id = id
        self.backend = backend
        self.max_length = max_length
        self._objects = {}

    def __str__(self) -> str:
        """
        Returns a human-readable string representation of the tracker.

        Returns:
            str: Concise summary with ID, backend type, and active tracks count.
        """
        return (f"Tracker(id={self.id!r}, "
                f"backend={self.backend.__class__.__name__}, "
                f"tracks={len(self._objects)})")

    def __repr__(self) -> str:
        """
        Returns a detailed string representation for debugging and reproduction.

        Returns:
            str: Exact representation including all configuration parameters.
        """
        return (f"Tracker(id={self.id!r}, "
                f"backend={self.backend!r}, "
                f"max_length={self.max_length!r}, "
                f"tracks={len(self._objects)!r})")

    @property
    def id(self) -> int:
        """
        int: Gets the unique identifier for this tracker instance.

        Returns:
            int: Non-negative integer ID assigned during initialization.

        Note:
            - Used to distinguish between multiple parallel tracking workers
            - Required for multi-worker orchestration and centralized logging
            - ID remains stable across runtime configuration changes
        """
        return self._id

    @id.setter
    def id(self, id: int) -> None:
        """
        Sets the tracker identifier with validation.

        Args:
            id (int): Unique non-negative integer identifier.

        Raises:
            TypeError: If id is not an integer or is a boolean.
            ValueError: If id is negative.

        Note:
            - Boolean values are explicitly rejected (bool is subclass of int)
            - Negative IDs are invalid for worker orchestration systems
        """
        if not isinstance(id, int) or isinstance(id, bool):
            raise TypeError(f"'id' must be '<int>', but got {type(id).__name__}")
        if id < 0:
            raise ValueError(f"'id' must be >= 0, but got '{id}'")
        self._id = id

    @property
    def backend(self) -> Backend:
        """
        Backend: Gets the inference backend used for detection.

        Returns:
            Backend: Backend instance providing detection results.

        Note:
            - Backend can be YOLOBackend, OpenVINOBackend, or custom implementation
            - Changing backend does not affect existing tracks
            - New backend must return instances with valid IDs for tracking
        """
        return self._backend

    @backend.setter
    def backend(self, backend: Backend) -> None:
        """
        Sets the inference backend with type validation.

        Args:
            backend (Backend): New backend instance for detection.

        Raises:
            TypeError: If backend is not a Backend instance.

        Note:
            - Existing tracks remain valid after backend change
            - New backend must be compatible with tracking requirements
        """
        if not isinstance(backend, Backend):
            raise TypeError(f"'backend' must be '<Backend>', but got {type(backend).__name__}")
        self._backend = backend

    @property
    def max_length(self) -> int:
        """
        int: Gets the maximum trajectory history length per tracked object.

        Returns:
            int: Maximum number of historical points retained per object.

        Note:
            - Specifies how many historical positions are stored per tracked object
            - Affects trajectory visualization smoothness and memory usage
            - Higher values create smoother trajectories but consume more memory
            - Lower values may cause jittery visualization but reduce resource usage
        """
        return self._max_length

    @max_length.setter
    def max_length(self, max_length: int) -> None:
        """
        Sets the maximum trajectory length with validation.

        Args:
            max_length (int): New maximum trajectory length. Must be non-negative.

        Raises:
            TypeError: If max_length is not an integer.
            ValueError: If max_length is negative.

        Note:
            - This change affects only NEW trajectories; existing ones maintain current length
            - No state refresh is needed as this is a pure tracking parameter
        """
        if not isinstance(max_length, int) or isinstance(max_length, bool):
            raise TypeError(f"'max_length' must be 'int', but got {type(max_length).__name__}")
        if max_length < 0:
            raise ValueError(f"'max_length' must be >= 0")
        self._max_length = max_length

    @property
    def objects(self) -> Dict[int, TrackedObject]:
        """
        Dict[int, TrackedObject]: Gets the dictionary of currently tracked objects.

        Returns:
            Dict[int, TrackedObject]: Mapping of track IDs to TrackedObject instances.

        Note:
            - Keys are track IDs (integers assigned by backend)
            - Values are TrackedObject instances with full trajectory history
            - Dictionary is updated every frame during track() calls
            - Stale tracks are automatically removed based on track_timeout
        """
        return self._objects

    def track(self, frame: np.ndarray, enable_tracking: bool = False) -> List[TrackedObject]: # TODO needs tests
        """
        Processes a video frame and returns tracking data for all detected objects.

        This method is the main entry point for frame processing, handling:
        1. Frame validation and preparation
        2. Object detection via backend.predict()
        3. Tracking state management (ID assignment, trajectory updates)
        4. Result aggregation for downstream processing

        Args:
            frame (np.ndarray): Input video frame in BGR format `(H, W, 3)`.
            enable_tracking (bool): Whether to use backend's built-in tracking.
                If `False`, uses detection-only mode (no ID persistence).

        Returns:
            List[TrackedObject]: All detected objects with complete tracking data:
                - `id`: Tracking ID from backend
                - `instance`: `Instance` with full detection data
                - `trajectory`: `TimedPointQueue` with historical positions

        Raises:
            TypeError: If frame is not a valid numpy array.
            ValueError: If frame is empty or has invalid dimensions.

        Note:
            - This method modifies the tracker's internal state by updating
              trajectory histories and maintaining object references between frames
            - Objects with `None` IDs are skipped (not tracked)
            - Thread-safety: Not inherently thread-safe; use external locks for concurrent access
        """
        if frame is None or not isinstance(frame, np.ndarray):
            raise TypeError(f"Frame must be a valid numpy array, got {type(frame)}!")
        if frame.size == 0:
            raise ValueError(f"Frame is empty!")
        if len(frame.shape) != 3 or frame.shape[2] != 3:
            raise ValueError(f"Frame must have shape (H, W, 3), got {frame.shape}")

        objects = []
        track_time = datetime.now()
        for obj in self.backend.predict(frame=frame, track=enable_tracking):
            if obj.id is None:
                continue

            if obj.id not in self._objects:
                self._objects[obj.id] = TrackedObject(id=obj.id,
                                                      instance=obj,
                                                      trajectory=TimedPointQueue(max_length=self.max_length))
            self._objects[obj.id].instance = obj
            self._objects[obj.id].trajectory.enqueue(TimedPoint(x=obj.box.center.X,
                                                                y=obj.box.center.Y,
                                                                z=obj.box.center.Z,
                                                                timestamp=track_time))
            objects.append(self._objects[obj.id])
        return objects

    def remove(self, object_id: int) -> None:
        """
        Removes a tracked object by ID from internal state.

        Args:
            object_id (int): Track ID to remove.

        Note:
            - Used for manual cleanup of specific tracks
            - Does not raise error if ID doesn't exist (idempotent)
            - Can be called for stale tracks or when objects leave the scene
        """
        if object_id in self._objects:
            del self._objects[object_id]

    def clear(self) -> None:
        """
        Removes all tracked objects from internal state.

        Note:
            - Useful for resetting tracker state between video sequences
            - Does not affect backend configuration
        """
        self._objects.clear()