"""
Module: batch_processor.py
Description: Provides a utility for processing operations in batches, 
             with error handling, logging, and configurable parameters.
"""

import logging
import time
from typing import Callable, List, Any, Optional

# Configure logging (can be further customized in a central logging config)
logging.basicConfig(
    level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)


class BatchProcessor:
    """
    A class for processing operations in batches.

    Attributes:
        batch_size (int): The number of items to process in each batch.
        retry_attempts (int): The number of times to retry a failed operation.
        retry_delay (float): The delay (in seconds) between retry attempts.
        processor_function (Callable): The function to apply to each item.
    """

    def __init__(
        self,
        batch_size: int = 100,
        retry_attempts: int = 3,
        retry_delay: float = 1.0,
        processor_function: Optional[Callable[[Any], Any]] = None,
    ):
        """
        Initializes the BatchProcessor with configurable parameters.

        Args:
            batch_size (int): The number of items to process in each batch.
            retry_attempts (int): The number of times to retry a failed operation.
            retry_delay (float): The delay (in seconds) between retry attempts.
            processor_function (Callable[[Any], Any], optional): The function to apply to each item. Defaults to None.
        """
        self.batch_size = batch_size
        self.retry_attempts = retry_attempts
        self.retry_delay = retry_delay
        self.processor_function = processor_function

    def set_processor_function(self, processor_function: Callable[[Any], Any]) -> None:
        """
        Sets the processor function.

        Args:
            processor_function (Callable[[Any], Any]): The function to apply to each item.
        """
        self.processor_function = processor_function

    def process_batch(self, data: List[Any]) -> List[Any]:
        """
        Processes a list of data items in batches, applying the configured 
        processor function to each item.

        Args:
            data (List[Any]): The list of data items to process.

        Returns:
            List[Any]: A list of results after processing each item.  Returns an empty list if the
                       processor function is not set.

        Raises:
            ValueError: If the data argument is not a list.
            RuntimeError: If the processor function is not set before calling this method.
        """
        if not isinstance(data, list):
            raise ValueError("Data must be a list.")

        if not self.processor_function:
            logging.error("Processor function not set.  Cannot process batch.")
            return []

        results: List[Any] = []
        for i in range(0, len(data), self.batch_size):
            batch = data[i : i + self.batch_size]
            for item in batch:
                try:
                    result = self._process_item_with_retry(item)
                    results.append(result)
                except Exception as e:
                    logging.error(f"Failed to process item {item}: {e}")
                    results.append(None)  # Or handle failures differently

        return results

    def _process_item_with_retry(self, item: Any) -> Any:
        """
        Processes a single item with retry logic.

        Args:
            item (Any): The item to process.

        Returns:
            Any: The result of processing the item.

        Raises:
            Exception: If processing fails after all retry attempts.
        """
        for attempt in range(self.retry_attempts):
            try:
                result = self.processor_function(item)
                return result
            except Exception as e:
                logging.warning(
                    f"Attempt {attempt + 1} failed for item {item}: {e}"
                )
                if attempt < self.retry_attempts - 1:
                    time.sleep(self.retry_delay)
                else:
                    raise  # Re-raise the exception after all retries
        raise Exception("Should not reach here")  # pragma: no cover - defensive

# Example Usage (can be moved to a separate example/test file)
if __name__ == "__main__":

    def example_processor(item: int) -> int:
        """
        An example processor function that squares a number.
        Raises an exception if the number is negative.
        """
        if item < 0:
            raise ValueError("Cannot square a negative number")
        return item * item

    # Create a BatchProcessor instance
    processor = BatchProcessor(batch_size=5, retry_attempts=2, retry_delay=0.5)
    processor.set_processor_function(example_processor)

    # Example data
    data = list(range(-2, 12))  # Include a negative number to test error handling

    # Process the data
    try:
        results = processor.process_batch(data)
        print("Results:", results)
    except ValueError as e:
        print(f"ValueError during batch processing: {e}") # Handle specific exceptions raised by processor function

    except Exception as e:
        print(f"An error occurred: {e}")