"""
PAMOLA.CORE - Privacy-Preserving AI Data Processors
Module:        DateTime Generalization Operation
Package:       pamola_core.anonymization.generalization
Version:       2.0.1
Status:        stable
Author:        PAMOLA Core Team
Created:       2025-01-15
Updated:       2025-01-15
License:       BSD 3-Clause

Description:
This module provides an operation for generalizing datetime fields to enhance privacy
while maintaining temporal utility. It implements various strategies:
1. Rounding: Reduce precision to year, quarter, month, week, day, or hour
2. Binning: Group into time intervals (hour ranges, day ranges, custom periods)
3. Component-based: Keep only specific datetime components
4. Relative: Express as relative to reference date

Key Features:
- Multiple generalization strategies for temporal data
- Robust handling of various datetime formats and timezones
- Support for incomplete dates and null values
- Memory-efficient processing with vectorized operations
- Comprehensive temporal information loss metrics
- Visualization of temporal distributions
- Integration with k-anonymity risk assessment

Framework:
Implementation follows the PAMOLA.CORE operation framework with standardized interfaces
for input/output, progress tracking, and result reporting.

Changelog:
2.0.1 - 2025-01-15 - Critical bug fixes and improvements:
      - Improved timezone handling for naive datetimes
      - Added privacy level validation
      - Enhanced error handling with specific exceptions
      - Added constants for magic numbers
      - Fixed binning edge cases
2.0.0 - 2025-01-15 - Complete rewrite with improvements
1.1.0 - 2025-06-16 - Bug fixes and metric improvements
1.0.0 - 2025-06-15 - Initial implementation
"""

from datetime import datetime, timedelta
from pathlib import Path
import time
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
import pandas as pd
from pamola_core.anonymization.base_anonymization_op import AnonymizationOperation
from pamola_core.anonymization.commons.categorical_config import NullStrategy
from pamola_core.errors.codes import ErrorCode
from pamola_core.errors.error_handler import ErrorHandler
from pamola_core.errors.exceptions import (
    DateTimeGeneralizationError,
    DateTimeParsingError,
    FieldValueError,
    InvalidStrategyError,
    ValidationError,
)
from pamola_core.anonymization.commons.validation_utils import (
    validate_datetime_field,
)
import dask.dataframe as dd
from pamola_core.anonymization.schemas.datetime_op_core_schema import (
    DateTimeGeneralizationConfig,
)
from pamola_core.common.constants import Constants
from pamola_core.common.helpers.data_helper import DataHelper
from pamola_core.utils.io import load_settings_operation
from pamola_core.utils.ops.op_cache import OperationCache
from pamola_core.utils.ops.op_data_source import DataSource
from pamola_core.utils.ops.op_data_writer import DataWriter
from pamola_core.utils.ops.op_registry import register
from pamola_core.utils.ops.op_result import OperationResult, OperationStatus
from pamola_core.utils.progress import HierarchicalProgressTracker
from pamola_core.utils.helpers import filter_used_kwargs


# Constants to replace magic numbers
class DateTimeConstants:
    """Constants for datetime operations."""

    HOURS_PER_DAY = 24
    MONTHS_PER_QUARTER = 3
    MONTHS_PER_YEAR = 12
    BUSINESS_HOURS_START = 6
    BUSINESS_HOURS_MORNING_END = 12
    BUSINESS_HOURS_AFTERNOON_END = 18
    DEFAULT_SAMPLE_SIZE = 100
    DEFAULT_BATCH_SIZE = 10000
    DAYS_PER_YEAR = 365
    DAYS_PER_MONTH = 30
    DAYS_PER_WEEK = 7
    MIN_PRIVACY_REDUCTION = 0.3  # Minimum 30% reduction in unique values


@register(version="1.0.0")
class DateTimeGeneralizationOperation(AnonymizationOperation):
    """
    Operation for generalizing datetime data.

    This operation generalizes datetime fields using strategies like rounding,
    binning, or component-based generalization to reduce precision and improve
    anonymity while preserving temporal patterns.
    """

    def __init__(
        self,
        field_name: str,
        strategy: str = "rounding",
        # Rounding parameters
        rounding_unit: str = "day",
        # Binning parameters
        bin_type: str = "day_range",
        interval_size: int = 7,
        reference_date: Optional[Union[str, datetime]] = None,
        custom_bins: Optional[List[Union[str, datetime]]] = None,
        # Component parameters
        keep_components: Optional[List[str]] = None,
        strftime_output_format: Optional[str] = None,
        timezone_handling: str = "preserve",
        default_timezone: str = "UTC",
        input_formats: Optional[List[str]] = None,
        min_privacy_threshold: float = DateTimeConstants.MIN_PRIVACY_REDUCTION,
        quasi_identifiers: Optional[List[str]] = None,
        **kwargs,
    ):
        """
        Initialize the DateTimeGeneralizationOperation.

        Parameters
        ----------
        field_name : str
            Name of the datetime field to generalize.
        strategy : str
            Generalization strategy: "rounding", "binning", "component", or "relative"
        rounding_unit : str
            Unit for rounding (e.g., 'day', 'hour').
        bin_type : str
            Type of binning ('day_range', 'month_range', etc.).
        interval_size : int
            Size of each binning interval.
        reference_date : Optional[Union[str, datetime]]
            Reference date for binning alignment.
        custom_bins : Optional[List[Union[str, datetime]]]
            User-defined bin boundaries.
        keep_components : Optional[List[str]]
            Components of datetime to keep (e.g., ['year', 'month']).
        strftime_output_format : Optional[str]
            Output format for datetime string conversion.
        timezone_handling : str
            How to handle timezones ('preserve', 'convert', etc.).
        default_timezone : str
            Default timezone if missing in input.
        input_formats : Optional[List[str]]
            Accepted input datetime formats.
        min_privacy_threshold : float
            Minimum privacy preservation threshold.
        quasi_identifiers : Optional[List[str]]
            List of quasi-identifier fields to consider.
        **kwargs :
            Additional keyword arguments passed to AnonymizationOperation.
        """

        # Default description if missing
        kwargs.setdefault(
            "description",
            f"Datetime generalization for '{field_name}' using {strategy} strategy",
        )

        # Build config for validation and schema
        config = DateTimeGeneralizationConfig(
            field_name=field_name,
            strategy=strategy,
            rounding_unit=rounding_unit,
            bin_type=bin_type,
            interval_size=interval_size,
            reference_date=reference_date,
            custom_bins=custom_bins,
            keep_components=keep_components,
            strftime_output_format=strftime_output_format,
            timezone_handling=timezone_handling,
            default_timezone=default_timezone,
            input_formats=input_formats,
            min_privacy_threshold=min_privacy_threshold,
            quasi_identifiers=quasi_identifiers,
            **kwargs,
        )

        # Pass config to parent class
        kwargs["config"] = config

        # Initialize parent AnonymizationOperation
        super().__init__(
            field_name=field_name,
            **kwargs,
        )

        # Save config attributes to self
        for k, v in config.to_dict().items():
            setattr(self, k, v)

        # Set default input formats if missing
        self.input_formats = self.input_formats or Constants.COMMON_DATE_FORMATS

        # Parse custom bins
        self.custom_bins = self._parse_custom_bins(custom_bins)

        # Parse reference date
        self.reference_date = self._parse_reference_date(reference_date)

        # Operation metadata
        self.operation_name = self.__class__.__name__

    def execute(
        self,
        data_source: DataSource,
        task_dir: Path,
        reporter: Any,
        progress_tracker: Optional[HierarchicalProgressTracker] = None,
        **kwargs,
    ) -> OperationResult:
        """
        Execute the operation with timing and error handling.

        Parameters
        -----------
        data_source : DataSource
            Source of data for the operation
        task_dir : Path
            Directory where task artifacts should be saved
        reporter : Any
            Reporter object for tracking progress and artifacts
        progress_tracker : Optional[HierarchicalProgressTracker]
            Progress tracker for the operation
        **kwargs : dict
            Additional parameters for the operation

        Returns
        --------
        OperationResult
            Results of the operation
        """
        try:
            # Start timing
            self.start_time = time.time()
            self.logger = kwargs.get("logger", self.logger)
            self.logger.info(
                f"Starting: {self.operation_name} operation at {self.start_time}"
            )

            # Initialize result object
            result = OperationResult(status=OperationStatus.PENDING)

            # Initialize dataframe
            df = None

            # Extract dataset name from kwargs (default to "main")
            dataset_name = kwargs.get("dataset_name", "main")

            # Generate single timestamp for all artifacts
            operation_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

            # Prepare directories for artifacts
            dirs = self._prepare_directories(task_dir)

            # Initialize operation cache
            self.operation_cache = OperationCache(
                cache_dir=dirs["cache"],
            )

            # Initialize error handler
            self.error_handler = ErrorHandler(
                logger=self.logger,
                operation_name=self.operation_name,
            )

            # Create DataWriter for consistent file operations
            writer = DataWriter(
                task_dir=task_dir, logger=self.logger, progress_tracker=progress_tracker
            )

            # Save configuration to task directory
            self.save_config(task_dir)

            self.logger.info(
                f"Visualization settings: theme={self.visualization_theme}, backend={self.visualization_backend}, strict={self.visualization_strict}, timeout={self.visualization_timeout}s"
            )

            # Load settings operation
            settings_operation = load_settings_operation(
                data_source, dataset_name, **kwargs
            )

            # Set up progress tracking with proper steps
            # Main steps: 1. Cache check, 2. Data Loading & Validation, 3. Prepare output field, 4. Processing, 5. Metrics, 6. Visualization, 7. Save output
            TOTAL_MAIN_STEPS = 6 + (
                1 if self.use_cache and not self.force_recalculation else 0
            )
            main_progress = progress_tracker
            current_steps = 0
            if main_progress:
                self.logger.info(
                    f"Setting up progress tracker with {TOTAL_MAIN_STEPS} main steps"
                )
                try:
                    main_progress.total = TOTAL_MAIN_STEPS
                    main_progress.update(
                        current_steps,
                        {
                            "step": "Starting date time generalization",
                            "field": self.field_name,
                        },
                    )
                except Exception as e:
                    self.logger.warning(f"Could not update progress tracker: {e}")

            # Step 1: Data Loading & Validation
            if main_progress:
                current_steps += 1
                main_progress.update(
                    current_steps, {"step": "Data Loading", "field": self.field_name}
                )

            # Validate and get dataframe
            try:
                self.logger.info(
                    f"Operation: {self.operation_name}, Load data and validate input parameters"
                )
                df = self._validate_and_get_dataframe(
                    data_source, dataset_name, **settings_operation
                )

                # Validate field is suitable for datetime operations
                is_valid = validate_datetime_field(
                    df,
                    self.field_name,
                    allow_null=(self.null_strategy != NullStrategy.ERROR.value),
                    logger_instance=self.logger,
                )

                if not is_valid:
                    raise FieldValueError(
                        self.field_name,
                        reason="Invalid datetime format",
                        examples=df[self.field_name].head(5).tolist(),
                    )
            except Exception as e:
                return self.error_handler.handle_error(
                    error=e,
                    error_code=ErrorCode.DATA_LOAD_FAILED,
                    context={"dataset": dataset_name, "operation": self.operation_name},
                    message_kwargs={"source": dataset_name, "reason": str(e)},
                )

            # Step 2: Check if we have a cached result
            if self.use_cache and not self.force_recalculation:
                if main_progress:
                    current_steps += 1
                    main_progress.update(
                        current_steps,
                        {"step": "Checking cache", "field": self.field_name},
                    )

                # Generate cache key based on operation parameters
                self.logger.info("Checking operation cache...")
                cache_result = self._check_cache(df, reporter)

                if cache_result:
                    self.logger.info(
                        f"Using cached result for {self.field_name} generalization"
                    )

                    # Update progress
                    if main_progress:
                        main_progress.update(
                            current_steps,
                            {"step": "Complete (cached)", "field": self.field_name},
                        )

                    # Report cache hit to reporter
                    if reporter:
                        reporter.add_operation(
                            f"Date time generalization of {self.field_name} (cached)",
                            details={"cached": True},
                        )

                    return cache_result

            # Step 3: Prepare output field
            if main_progress:
                current_steps += 1
                main_progress.update(
                    current_steps,
                    {"step": "Preparing output field", "field": self.field_name},
                )

            try:
                self.output_field_name = self._prepare_output_field(df)
                self.logger.info(f"Prepared output_field: '{self.output_field_name}'")
                self._report_operation_details(reporter, self.output_field_name)
            except Exception as e:
                return self.error_handler.handle_error(
                    error=e,
                    error_code=ErrorCode.PROCESSING_FAILED,
                    context={"step": "prepare_output_field", "field": self.field_name},
                    message_kwargs={
                        "field_name": self.field_name,
                        "operation": self.operation_name,
                        "reason": str(e),
                    },
                )

            # Step 4: Processing
            if main_progress:
                current_steps += 1
                main_progress.update(
                    current_steps, {"step": "Processing", "field": self.field_name}
                )

            try:
                # Copy original data for processing
                original_data = df[self.field_name].copy(deep=True)

                # Create child progress tracker for Chunk processing
                data_tracker = None
                if main_progress and hasattr(main_progress, "create_subtask"):
                    try:
                        data_tracker = main_progress.create_subtask(
                            total=3,
                            description="Processing dataframe",
                            unit="steps",
                        )
                    except Exception as e:
                        self.logger.debug(
                            f"Could not create child progress tracker: {e}"
                        )

                # Process the filtered data
                processed_df = self._process_data_with_config(
                    df=df, progress_tracker=data_tracker
                )

                # Get the anonymized data
                anonymized_data = processed_df[self.output_field_name]

                # Validate privacy level
                if not self._validate_privacy_level(
                    original=original_data, generalized=anonymized_data
                ):
                    self.logger.warning(
                        f"Generalization doesn't provide sufficient privacy. "
                        f"Unique values reduced by less than {self.min_privacy_threshold * 100}%"
                    )

                # Close child progress tracker
                if data_tracker:
                    try:
                        data_tracker.close()
                    except:
                        pass
            except Exception as e:
                return self.error_handler.handle_error(
                    error=e,
                    error_code=ErrorCode.PROCESSING_FAILED,
                    context={"step": "processing", "field": self.field_name},
                    message_kwargs={
                        "field_name": self.field_name,
                        "operation": self.operation_name,
                        "reason": str(e),
                    },
                )

            # Record end time after processing metrics
            self.end_time = time.time()
            if self.end_time and self.start_time:
                self.execution_time = self.end_time - self.start_time

            # Step 5: Metrics Calculation
            if main_progress:
                current_steps += 1
                main_progress.update(
                    current_steps,
                    {"step": "Metrics Calculation", "field": self.field_name},
                )

            # Initialize metrics in scope
            metrics = {}

            try:
                metrics = self._collect_all_metrics(original_data, anonymized_data)

                # Generate metrics file name
                metrics_file_name = (
                    f"{self.field_name}_{self.name}_metrics_{operation_timestamp}"
                )

                self._save_metrics(
                    metrics=metrics,
                    writer=writer,
                    result=result,
                    reporter=reporter,
                    progress_tracker=progress_tracker,
                    operation_timestamp=operation_timestamp,
                    file_name=metrics_file_name,
                )
            except Exception as e:
                error_message = f"Error calculating metrics: {str(e)}"
                self.logger.warning(error_message)
                # Continue execution - metrics failure is not critical

            # Step 6: Visualizations
            if main_progress:
                current_steps += 1
                main_progress.update(
                    current_steps,
                    {"step": "Generating Visualizations", "field": self.field_name},
                )

            # Generate visualizations if required
            # Initialize visualization paths dictionary
            if self.generate_visualization and self.visualization_backend is not None:
                try:
                    kwargs_encryption = {
                        "use_encryption": self.use_encryption,
                        "encryption_key": self.encryption_key,
                    }
                    self._handle_visualizations(
                        original_data=original_data,
                        anonymized_data=anonymized_data,
                        task_dir=task_dir,
                        result=result,
                        reporter=reporter,
                        progress_tracker=main_progress,
                        vis_theme=self.visualization_theme,
                        vis_backend=self.visualization_backend,
                        vis_strict=self.visualization_strict,
                        vis_timeout=self.visualization_timeout,
                        operation_timestamp=operation_timestamp,
                        **kwargs_encryption,
                    )
                except Exception as e:
                    error_message = f"Error generating visualizations: {str(e)}"
                    self.logger.warning(error_message)
                    # Continue execution - visualization failure is not critical
            else:
                self.logger.info(
                    "Skipping visualizations as generate_visualization is False or backend is not set"
                )

            # Step 7: Save Output Data
            if main_progress:
                current_steps += 1
                main_progress.update(
                    current_steps,
                    {"step": "Save Output Data", "field": self.field_name},
                )

            # Save output data if required
            if self.save_output:
                try:
                    safe_kwargs = filter_used_kwargs(kwargs, self._save_output_data)
                    self._save_output_data(
                        result_df=processed_df,
                        writer=writer,
                        result=result,
                        reporter=reporter,
                        progress_tracker=main_progress,
                        timestamp=operation_timestamp,
                        **safe_kwargs,
                    )
                except Exception as e:
                    return self.error_handler.handle_error(
                        error=e,
                        error_code=ErrorCode.ARTIFACT_WRITE_FAILED,
                        context={"step": "save_output", "field": self.field_name},
                        message_kwargs={
                            "path": str(task_dir / "output"),
                            "reason": str(e),
                        },
                    )

            # Cache the result if caching is enabled
            if self.use_cache:
                try:
                    self._save_to_cache(
                        original_data=original_data,
                        anonymized_data=anonymized_data,
                        result=result,
                        task_dir=task_dir,
                    )
                except Exception as e:
                    # Failure to cache is non-critical
                    self.logger.warning(f"Failed to cache results: {str(e)}")

            # Clean up memory AFTER all write operations are complete
            self.logger.info("Cleaning up memory after all file operations")
            self._cleanup_memory(
                processed_df=processed_df,
                original_data=original_data,
                anonymized_data=anonymized_data,
            )

            # Report completion
            if reporter:
                reporter.add_operation(
                    f"Anonymization of {self.field_name} completed",
                    details={
                        "records_processed": self.process_count,
                        "execution_time": self.execution_time,
                        "processed_df": len(processed_df),
                        "vulnerable_records_handled": metrics.get(
                            "vulnerable_records", 0
                        ),
                    },
                )

            # Set success status
            result.status = OperationStatus.SUCCESS
            result.execution_time = self.execution_time
            self.logger.info(
                f"Processing completed {self.operation_name} operation in {self.execution_time:.2f} seconds"
            )
            return result

        except Exception as e:
            self.logger.exception(f"Error in {self.operation_name}: {str(e)}")
            return self.error_handler.handle_error(
                error=e,
                error_code=ErrorCode.PROCESSING_FAILED,
                context={"operation": self.operation_name, "field": self.field_name},
                message_kwargs={
                    "field_name": self.field_name,
                    "operation": self.operation_name,
                    "reason": str(e),
                },
            )

    def _parse_reference_date(
        self, ref_date: Optional[Union[str, datetime]]
    ) -> Optional[pd.Timestamp]:
        """Parse reference date from various formats."""
        if ref_date is None:
            return None
        if isinstance(ref_date, datetime):
            return pd.Timestamp(ref_date)
        try:
            return pd.to_datetime(ref_date)
        except (ValidationError, ValueError, TypeError) as e:
            raise DateTimeParsingError(
                f"Invalid reference date format: {ref_date}"
            ) from e

    def _parse_custom_bins(
        self, bins: Optional[List[Union[str, datetime]]]
    ) -> Optional[List[pd.Timestamp]]:
        """Parse custom bin boundaries."""
        if bins is None:
            return None
        parsed_bins = []
        for i, bin_value in enumerate(bins):
            if isinstance(bin_value, datetime):
                parsed_bins.append(pd.Timestamp(bin_value))
            else:
                try:
                    parsed_bins.append(pd.to_datetime(bin_value))
                except (ValidationError, ValueError, TypeError) as e:
                    raise DateTimeParsingError(
                        f"Invalid bin value at index {i}: {bin_value}"
                    ) from e
        return sorted(parsed_bins) if parsed_bins else None

    def process_batch(self, batch: pd.DataFrame) -> pd.DataFrame:
        """
        Process a batch of datetime data.

        Parameters
        -----------
        batch : pd.DataFrame
            DataFrame batch to process

        Returns
        --------
        pd.DataFrame
            Processed DataFrame with generalized datetimes
        """
        # Read configuration strictly from self
        field_name = self.field_name
        output_field_name = self.output_field_name
        mode = self.mode
        strategy = self.strategy
        input_formats = self.input_formats

        # Extract field data
        field_data = batch[field_name]

        # Convert to datetime if needed
        if not pd.api.types.is_datetime64_any_dtype(field_data):
            field_data = DataHelper.convert_to_datetime(field_data, input_formats)

        # Handle timezone
        field_values = self._handle_timezone(field_data)

        # Apply generalization strategy
        if strategy == "rounding":
            generalized = self._apply_rounding(field_values)
        elif strategy == "binning":
            generalized = self._apply_binning(field_values)
        elif strategy == "component":
            generalized = self._apply_component(field_values)
        elif strategy == "relative":
            generalized = self._apply_relative(field_values)
        else:
            raise InvalidStrategyError(
                strategy=strategy,
                valid_strategies=["rounding", "binning", "component", "relative"],
                operation_type=self.operation_name,
            )

        # Update DataFrame
        if mode == "REPLACE":
            batch[field_name] = generalized
        else:  # ENRICH
            batch[output_field_name] = generalized

        return batch

    def process_batch_dask(self, ddf: dd.DataFrame) -> dd.DataFrame:
        """
        Process Dask DataFrame. Should be overridden by subclasses for optimal performance.

        Parameters
        -----------
        ddf : dd.DataFrame
            Dask DataFrame to process

        Returns
        --------
        dd.DataFrame
            Processed Dask DataFrame
        """

        # Default implementation: process each partition with process_batch
        def process_partition(partition):
            return self.process_batch(partition.copy(deep=True))

        return ddf.map_partitions(process_partition)

    def process_value(self, value):
        """
        Process a single datetime value.

        Parameters
        -----------
        value : Any
            DateTime value to process

        Returns
        --------
        Any
            Processed datetime value
        """
        # Load config from self
        strategy = getattr(self, "strategy", "rounding")

        # Ensure Timestamp
        if not isinstance(value, pd.Timestamp):
            value = pd.Timestamp(value)

        # Apply strategy
        if strategy == "rounding":
            return self._round_single_value(value)

        elif strategy == "binning":
            return self._bin_single_value(value)

        elif strategy == "component":
            return self._component_single_value(value)

        elif strategy == "relative":
            return self._relative_single_value(value)

        # fallback (should never hit)
        return value

    def _handle_timezone(self, series: pd.Series) -> pd.Series:
        """Handle timezone according to instance configuration."""

        if not pd.api.types.is_datetime64_any_dtype(series):
            return series

        # Extract timezone handling config
        handling = getattr(self, "timezone_handling", "preserve")
        default_tz = getattr(self, "default_timezone", "UTC")

        # If datetime is timezone-aware
        def is_aware(s):
            try:
                return s.dt.tz is not None
            except Exception:
                return False

        # Convert to UTC
        if handling == "utc":
            if is_aware(series):
                return series.dt.tz_convert("UTC")
            # naive → localize → convert
            return series.dt.tz_localize(default_tz).dt.tz_convert("UTC")

        # Remove timezone
        if handling == "remove":
            if is_aware(series):
                return series.dt.tz_localize(None)
            return series

        # Preserve (default)
        return series

    def _apply_rounding(self, series: pd.Series) -> pd.Series:
        """Apply datetime rounding using instance configuration."""
        if series.empty:
            return series

        # Extract rounding config
        unit = getattr(self, "rounding_unit", "year")
        fmt = getattr(self, "strftime_output_format", None)

        result = series.copy()
        mask = ~series.isna()

        if not mask.any():
            return result

        # Use vectorized rounding
        rounded = series.copy()

        if unit == "year":
            rounded[mask] = series[mask].dt.to_period("Y").dt.to_timestamp()
        elif unit == "quarter":
            rounded[mask] = series[mask].dt.to_period("Q").dt.to_timestamp()
        elif unit == "month":
            rounded[mask] = series[mask].dt.to_period("M").dt.to_timestamp()
        elif unit == "week":
            rounded[mask] = series[mask].dt.to_period("W").dt.to_timestamp()
        elif unit == "day":
            rounded[mask] = series[mask].dt.floor("D")
        elif unit == "hour":
            rounded[mask] = series[mask].dt.floor("H")
        else:
            # Unknown rounding_unit → return unchanged
            return result

        # Apply strftime format if configured
        if fmt:
            result = result.astype("object")
            result[mask] = rounded[mask].dt.strftime(fmt)
        else:
            result = rounded

        return result

    def _apply_binning(self, series: pd.Series) -> pd.Series:
        """Apply binning generalization using instance configuration."""

        result = pd.Series(index=series.index, dtype="object")
        mask = ~series.isna()

        if not mask.any():
            return result

        bin_type = getattr(self, "bin_type", "day_range")
        interval = getattr(self, "interval_size", 7)
        values = series[mask]

        # Hour range binning
        if bin_type == "hour_range":
            hours = values.dt.hour

            bin_edges = list(range(0, DateTimeConstants.HOURS_PER_DAY + 1, interval))
            if bin_edges[-1] != DateTimeConstants.HOURS_PER_DAY:
                bin_edges.append(DateTimeConstants.HOURS_PER_DAY)

            labels = [
                f"{i:02d}:00-{min(i + interval, DateTimeConstants.HOURS_PER_DAY):02d}:00"
                for i in bin_edges[:-1]
            ]

            result[mask] = pd.cut(
                hours, bins=bin_edges, labels=labels, include_lowest=True
            )
            return result

        # Day range binning
        if bin_type == "day_range":
            reference_date = getattr(self, "reference_date", None)
            ref_date = reference_date or values.min()

            if isinstance(ref_date, str):
                ref_date = pd.to_datetime(ref_date)

            days_diff = (values - ref_date).dt.days

            min_day = int(days_diff.min())
            max_day = int(days_diff.max())

            # Align bins
            start = min_day - (min_day % interval)
            end = max_day + (interval - (max_day % interval))
            bins = list(range(start, end + 1, interval))

            labels = [f"Day {i}-{i + interval - 1}" for i in bins[:-1]]

            result[mask] = pd.cut(
                days_diff, bins=bins, labels=labels, include_lowest=True
            )
            return result

        # Business period
        if bin_type == "business_period":
            hours = values.dt.hour
            c = DateTimeConstants

            conditions = [
                (hours >= c.BUSINESS_HOURS_START)
                & (hours < c.BUSINESS_HOURS_MORNING_END),
                (hours >= c.BUSINESS_HOURS_MORNING_END)
                & (hours < c.BUSINESS_HOURS_AFTERNOON_END),
                (hours >= c.BUSINESS_HOURS_AFTERNOON_END)
                | (hours < c.BUSINESS_HOURS_START),
            ]

            result[mask] = np.select(
                conditions, ["Morning", "Afternoon", "Night"], default="Unknown"
            )
            return result

        # Seasonal binning
        if bin_type == "seasonal":
            months = values.dt.month

            conditions = [
                months.isin([12, 1, 2]),
                months.isin([3, 4, 5]),
                months.isin([6, 7, 8]),
                months.isin([9, 10, 11]),
            ]

            result[mask] = np.select(
                conditions, ["Winter", "Spring", "Summer", "Fall"], default="Unknown"
            )
            return result

        # Custom bins
        if bin_type == "custom":
            bins = getattr(self, "custom_bins", None)

            if not bins or len(bins) < 2:
                raise DateTimeGeneralizationError(
                    "custom_bins must contain ≥ 2 datetime boundaries."
                )

            try:
                bins = sorted(pd.to_datetime(bins))
            except Exception as e:
                raise DateTimeGeneralizationError(f"Invalid custom_bins: {bins}") from e

            labels = [
                f"{bins[i].date()} to {bins[i+1].date()}" for i in range(len(bins) - 1)
            ]

            result[mask] = pd.cut(values, bins=bins, labels=labels, include_lowest=True)
            return result

        # -----------------------
        # Unknown type → no change
        # -----------------------
        return result

    def _apply_component(self, series: pd.Series) -> pd.Series:
        """Apply component-based generalization using instance configuration."""

        result = pd.Series(index=series.index, dtype="object")
        mask = ~series.isna()

        if not mask.any():
            return result

        components = getattr(self, "keep_components", None) or ["year", "month"]

        # Mapping internal component → strftime format
        fmt_map = {
            "year": "%Y",
            "month": "%m",
            "day": "%d",
            "hour": "%H",
            "minute": "%M",
            "weekday": "%A",
        }

        # Build format string dynamically
        fmt_parts = [fmt_map[c] for c in components if c in fmt_map]

        if not fmt_parts:
            # No components requested
            result[mask] = None
            return result

        fmt = "-".join(fmt_parts)

        result[mask] = series[mask].dt.strftime(fmt)
        return result

    def _apply_relative(self, series: pd.Series) -> pd.Series:
        """Apply relative date generalization."""
        result = pd.Series(index=series.index, dtype="object")
        non_null_mask = series.notna()

        if not non_null_mask.any():
            return result

        # Use provided reference_date if class has it, otherwise use now()
        reference_date = getattr(self, "reference_date", None)
        ref_date = reference_date or pd.Timestamp.now()

        # Ensure datetime & handle invalid values
        s = pd.to_datetime(series[non_null_mask], errors="coerce")
        valid_mask = s.notna()

        # invalid datetimes → Unknown
        result[non_null_mask & ~valid_mask] = "Unknown"

        if not valid_mask.any():
            return result

        # Compute day difference
        diff_days = (s[valid_mask] - ref_date).dt.days

        y = DateTimeConstants.DAYS_PER_YEAR
        m = DateTimeConstants.DAYS_PER_MONTH
        w = DateTimeConstants.DAYS_PER_WEEK

        conditions = [
            diff_days < -y,
            diff_days.between(-y, -m - 1),
            diff_days.between(-m, -w - 1),
            diff_days.between(-w, -1),
            diff_days == 0,
            diff_days.between(1, w),
            diff_days.between(w + 1, m),
            diff_days.between(m + 1, y),
            diff_days > y,
        ]

        choices = [
            "More than a year ago",
            "Months ago",
            "Weeks ago",
            "Days ago",
            "Same day",
            "Days ahead",
            "Weeks ahead",
            "Months ahead",
            "More than a year ahead",
        ]

        result[valid_mask] = np.select(conditions, choices, default="Unknown")

        return result

    def _round_single_value(self, value: pd.Timestamp) -> Union[pd.Timestamp, str]:
        """Round a single timestamp value according to self.rounding_unit."""
        if not isinstance(value, pd.Timestamp):
            return value

        unit = getattr(self, "rounding_unit", "year")
        fmt = getattr(self, "strftime_output_format", None)

        if unit == "year":
            rounded = value.replace(
                month=1, day=1, hour=0, minute=0, second=0, microsecond=0
            )

        elif unit == "quarter":
            quarter = (
                value.month - 1
            ) // DateTimeConstants.MONTHS_PER_QUARTER  # 0,1,2,3
            start_month = quarter * DateTimeConstants.MONTHS_PER_QUARTER + 1  # 1,4,7,10
            rounded = value.replace(
                month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0
            )

        elif unit == "month":
            rounded = value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)

        elif unit == "week":
            rounded = value - timedelta(days=value.weekday())
            rounded = rounded.replace(hour=0, minute=0, second=0, microsecond=0)

        elif unit == "day":
            rounded = value.replace(hour=0, minute=0, second=0, microsecond=0)

        elif unit == "hour":
            rounded = value.replace(minute=0, second=0, microsecond=0)

        else:
            rounded = value

        return rounded.strftime(fmt) if fmt else rounded

    def _bin_single_value(self, value: pd.Timestamp) -> str:
        """Bin a single timestamp value based on self.bin_type and self.interval_size."""
        if not isinstance(value, pd.Timestamp):
            return str(value)

        bin_type = getattr(self, "bin_type", "day_range")
        interval = getattr(self, "interval_size", 7)

        if bin_type == "hour_range":
            hour = value.hour
            start = (hour // interval) * interval
            end = min(start + interval, DateTimeConstants.HOURS_PER_DAY)
            return f"{start:02d}:00-{end:02d}:00"

        elif bin_type == "business_period":
            h = value.hour
            if (
                DateTimeConstants.BUSINESS_HOURS_START
                <= h
                < DateTimeConstants.BUSINESS_HOURS_MORNING_END
            ):
                return "Morning"
            elif (
                DateTimeConstants.BUSINESS_HOURS_MORNING_END
                <= h
                < DateTimeConstants.BUSINESS_HOURS_AFTERNOON_END
            ):
                return "Afternoon"
            else:
                return "Night"

        elif bin_type == "seasonal":
            m = value.month
            if m in (12, 1, 2):
                return "Winter"
            if m in (3, 4, 5):
                return "Spring"
            if m in (6, 7, 8):
                return "Summer"
            return "Fall"

        return str(value)

    def _component_single_value(self, value: pd.Timestamp) -> str:
        """Extract datetime components according to self.keep_components."""
        if not isinstance(value, pd.Timestamp):
            return str(value)

        comps = getattr(self, "keep_components", None) or ["year", "month"]
        fmt = getattr(self, "strftime_output_format", None)

        if fmt:
            return value.strftime(fmt)

        parts = []
        if "year" in comps:
            parts.append(str(value.year))
        if "month" in comps:
            parts.append(f"{value.month:02d}")
        if "day" in comps:
            parts.append(f"{value.day:02d}")
        if "hour" in comps:
            parts.append(f"{value.hour:02d}")
        if "minute" in comps:
            parts.append(f"{value.minute:02d}")
        if "weekday" in comps:
            parts.append(value.strftime("%A"))

        return "-".join(parts)

    def _relative_single_value(self, value: pd.Timestamp) -> str:
        """Convert single timestamp to relative description."""
        if not isinstance(value, pd.Timestamp):
            return "Unknown"

        ref = getattr(self, "reference_date", pd.Timestamp.now())
        diff = (value - ref).days

        y = DateTimeConstants.DAYS_PER_YEAR
        m = DateTimeConstants.DAYS_PER_MONTH
        w = DateTimeConstants.DAYS_PER_WEEK

        if diff < -y:
            return "More than a year ago"
        if -y <= diff < -m:
            return "Months ago"
        if -m <= diff < -w:
            return "Weeks ago"
        if -w <= diff < 0:
            return "Days ago"
        if diff == 0:
            return "Same day"
        if 0 < diff <= w:
            return "Days ahead"
        if w < diff <= m:
            return "Weeks ahead"
        if m < diff <= y:
            return "Months ahead"
        return "More than a year ahead"

    def _validate_date_range(self, series: pd.Series) -> Tuple[int, int]:
        """
        Validate dates are within pandas datetime range.

        Returns
        --------
        Tuple[int, int]
            (out_of_range_count, total_count)
        """
        min_date = pd.Timestamp.min
        max_date = pd.Timestamp.max

        out_of_range = ((series < min_date) | (series > max_date)).sum()
        total = len(series.dropna())

        if out_of_range > 0:
            self.logger.warning(
                f"{out_of_range}/{total} dates out of valid pandas range "
                f"[{min_date}, {max_date}]"
            )

        return out_of_range, total

    def _validate_privacy_level(
        self, original: pd.Series, generalized: pd.Series
    ) -> bool:
        """
        Validate that generalization provides sufficient privacy.

        Parameters
        -----------
        original : pd.Series
            Original datetime series
        generalized : pd.Series
            Generalized datetime series

        Returns
        --------
        bool
            True if privacy level is sufficient, False otherwise
        """
        original_unique = original.nunique()
        generalized_unique = generalized.nunique()

        if original_unique == 0:
            return True  # No data to protect

        # Calculate reduction ratio
        reduction_ratio = 1 - (generalized_unique / original_unique)

        self.logger.info(
            f"Privacy validation: {original_unique} unique values reduced to "
            f"{generalized_unique} ({reduction_ratio:.2%} reduction)"
        )

        return reduction_ratio >= self.min_privacy_threshold

    def _collect_specific_metrics(
        self, original_data: pd.Series, anonymized_data: pd.Series
    ) -> Dict[str, Any]:
        """Collect datetime-specific metrics."""
        metrics = {}

        # Calculate temporal granularity loss
        if pd.api.types.is_datetime64_any_dtype(original_data):
            # Validate date ranges
            out_of_range, total = self._validate_date_range(original_data)
            if out_of_range > 0:
                metrics["dates_out_of_range"] = int(out_of_range)
                metrics["out_of_range_ratio"] = (
                    float(out_of_range / total) if total > 0 else 0.0
                )

            # Convert anonymized back to datetime if possible for comparison
            try:
                if self.strategy == "rounding" and self.rounding_unit in [
                    "day",
                    "hour",
                ]:
                    if pd.api.types.is_datetime64_any_dtype(anonymized_data):
                        anon_dt = anonymized_data
                    else:
                        anon_dt = pd.to_datetime(anonymized_data, errors="coerce")

                    # Calculate average time difference
                    valid_mask = ~(original_data.isna() | anon_dt.isna())
                    if valid_mask.any():
                        time_diffs = abs(
                            original_data[valid_mask] - anon_dt[valid_mask]
                        )
                        avg_loss_hours = time_diffs.dt.total_seconds().mean() / 3600
                        metrics["avg_temporal_loss_hours"] = float(avg_loss_hours)

            except (ValidationError, ValueError, TypeError) as e:
                self.logger.warning(f"Could not calculate temporal loss: {e}")

        # Date range coverage
        orig_range = original_data.dropna()
        if len(orig_range) > 0 and pd.api.types.is_datetime64_any_dtype(orig_range):
            date_span = (orig_range.max() - orig_range.min()).days
            metrics["original_date_span_days"] = int(date_span)

        # Unique temporal patterns
        metrics["unique_patterns_before"] = int(original_data.nunique())
        metrics["unique_patterns_after"] = int(anonymized_data.nunique())

        # Privacy metrics
        if metrics["unique_patterns_before"] > 0:
            metrics["privacy_reduction_ratio"] = float(
                1 - metrics["unique_patterns_after"] / metrics["unique_patterns_before"]
            )

        # Strategy-specific metrics
        metrics["datetime_strategy"] = self.strategy
        if self.strategy == "rounding":
            metrics["rounding_unit"] = self.rounding_unit
        elif self.strategy == "binning":
            metrics["bin_type"] = self.bin_type
            metrics["interval_size"] = self.interval_size
            if self.bin_type == "custom" and self.custom_bins:
                metrics["custom_bin_count"] = len(self.custom_bins) - 1
        elif self.strategy == "component":
            metrics["keep_components"] = self.keep_components

        return metrics

    def _get_cache_parameters(self) -> Dict[str, Any]:
        """
        Get operation-specific parameters for cache key generation.

        Returns
        --------
        Dict[str, Any]
            Strategy-specific parameters for date time generalization
        """
        params = dict(
            strategy=self.strategy,
            rounding_unit=self.rounding_unit,
            bin_type=self.bin_type,
            interval_size=self.interval_size,
            reference_date=self.reference_date,
            custom_bins=self.custom_bins,
            keep_components=self.keep_components,
            strftime_output_format=self.strftime_output_format,
            timezone_handling=self.timezone_handling,
            default_timezone=self.default_timezone,
            input_formats=self.input_formats,
            min_privacy_threshold=self.min_privacy_threshold,
        )

        return params


def create_datetime_generalization_operation(
    field_name: str, **kwargs
) -> DateTimeGeneralizationOperation:
    """
    Create a datetime generalization operation.

    Parameters
    -----------
    field_name : str
        Field to generalize
    **kwargs : dict
        Additional parameters

    Returns
    --------
    DateTimeGeneralizationOperation
        Configured operation instance
    """
    return DateTimeGeneralizationOperation(field_name=field_name, **kwargs)
