from abc import abstractmethod, ABC

import numpy as np
from openpilot.sunnypilot.models.helpers import get_active_bundle
from openpilot.sunnypilot.models.runners.constants import NumpyDict, ShapeDict, Model, SliceDict, SEND_RAW_PRED
from openpilot.system.hardware.hw import Paths
import pickle

CUSTOM_MODEL_PATH = Paths.model_root()


class ModelData:
  """
  Stores metadata and configuration for a specific machine learning model.

  This class loads model metadata (like input shapes and output slices)
  from a pickle file associated with a model instance.

  :param model: The machine learning model object containing metadata.
  """
  def __init__(self, model: Model):
    self.model = model
    self.metadata = model.metadata
    self.input_shapes: ShapeDict = {}
    self.output_slices: SliceDict = {}
    if self.metadata:
      self._load_metadata()

  def _load_metadata(self) -> None:
    """Loads input shapes and output slices from the model's metadata pickle file."""
    metadata_path = f"{CUSTOM_MODEL_PATH}/{self.metadata.fileName}"
    with open(metadata_path, 'rb') as f:
      model_metadata = pickle.load(f)
    self.input_shapes = model_metadata.get('input_shapes', {})
    self.output_slices = model_metadata.get('output_slices', {})


class ModularRunner(ABC):
  """
  Represents a modular runner for handling and slicing model outputs.

  This abstract base class is designed to provide an interface for modular
  parsing and processing of model outputs. Classes inheriting from it must
  implement the specified abstract methods, defining how model outputs
  should be handled and stored. The primary goal is to enable structured
  parsing of outputs through a dictionary-based method mapping.

  :ivar parser_method_dict: Mapping dictionary containing parser methods
      for handling specific types of outputs.
  :type parser_method_dict: dict
  """

  @property
  @abstractmethod
  def parser_method_dict(self) -> dict:
    pass

  @parser_method_dict.setter
  @abstractmethod
  def parser_method_dict(self, value: dict) -> None:
    pass

  @abstractmethod
  def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
    pass


class ModelRunner(ModularRunner):
  """
  Abstract base class for managing and executing machine learning models.

  Provides a common interface for loading models, preparing inputs, running
  inference, and slicing/parsing outputs based on model metadata. Derived
  classes implement the specifics of input preparation and model execution
  for different frameworks (e.g., Tinygrad, ONNX).
  """

  def __init__(self):
    """Initializes the model runner, loading the active model bundle."""
    self.is_20hz: bool | None = None
    self.is_20hz_3d: bool | None = None
    self.models: dict[int, ModelData] = {}
    self._model_data: ModelData | None = None  # Active model data for current operation
    self._parser_method_dict: dict = {}
    self.inputs: dict = {}
    self._parser = None
    self._load_models()
    self._constants = None

  @property
  def constants(self):
    return self._constants

  @property
  def parser_method_dict(self) -> dict:
    """Returns the dictionary mapping model types to their respective parsing methods."""
    return self._parser_method_dict

  @parser_method_dict.setter
  def parser_method_dict(self, value: dict) -> None:
    """Sets the dictionary mapping model types to their respective parsing methods."""
    self._parser_method_dict = value

  def _load_models(self) -> None:
    """Loads the active model bundle configuration and sets up ModelData."""
    bundle = get_active_bundle()
    if not bundle:
      raise ValueError("No active model bundle found, why are we being executed?")

    self.models = {model.type.raw: ModelData(model) for model in bundle.models}
    self.is_20hz = bundle.is20hz
    self.is_20hz_3d = False

  @property
  def input_shapes(self) -> ShapeDict:
    """Returns the input shapes for the currently active model."""
    if self._model_data:
      return self._model_data.input_shapes
    raise ValueError("Model data is not available. Ensure the model is loaded correctly.")

  @property
  def output_slices(self) -> SliceDict:
    """Returns the output slices for the currently active model."""
    if self._model_data:
      return self._model_data.output_slices
    raise ValueError("Model data is not available. Ensure the model is loaded correctly.")

  @property
  def vision_input_names(self) -> list[str]:
    """Returns the list of vision input names from the input shapes."""
    if self._model_data:
      return list(self._model_data.input_shapes.keys())
    raise ValueError("Model data is not available. Ensure the model is loaded correctly.")

  @abstractmethod
  def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict:
    """
    Abstract method to prepare inputs for model inference.

    :param numpy_inputs: Dictionary of numpy arrays for non-image inputs.
    :return: Dictionary of prepared inputs ready for the model.
    """
    raise NotImplementedError

  @abstractmethod
  def _run_model(self) -> NumpyDict:
    """
    Abstract method to execute model inference with prepared inputs.

    :return: Dictionary containing the model's raw output arrays.
    """
    raise NotImplementedError

  def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
    """
    Slices the raw model output array based on the output_slices metadata.

    :param model_outputs: The raw numpy array output from the model.
    :return: A dictionary where keys are output names and values are sliced numpy arrays.
    """
    if not self._model_data:
      raise ValueError("Model data is not available. Ensure the model is loaded correctly.")
    sliced_outputs = {k: model_outputs[np.newaxis, v] for k, v in self._model_data.output_slices.items()}
    if SEND_RAW_PRED:
      sliced_outputs['raw_pred'] = model_outputs.copy()  # Optionally include the full raw output
    return sliced_outputs

  def run_model(self) -> NumpyDict:
    """
    Executes the model inference pipeline: runs the model and parses outputs.

    :return: Dictionary containing the final parsed model outputs.
    """
    return self._run_model()  # Parsing is handled within specific runner implementations
