"""
DateHelper - Comprehensive Date Operations Utility
Provides convenient methods for date calculations and common date operations
"""

from datetime import datetime, date, timedelta
from typing import Optional, Tuple
from calendar import monthrange
from enum import Enum


class Weekday(Enum):
    """Enum for days of the week"""
    MONDAY = 0
    TUESDAY = 1
    WEDNESDAY = 2
    THURSDAY = 3
    FRIDAY = 4
    SATURDAY = 5
    SUNDAY = 6


class DateHelper:
    """
    Comprehensive date operations helper class.
    
    Provides methods for:
    - Relative dates (yesterday, last week, last month, last year)
    - Date ranges (week, month, quarter, year)
    - Business day calculations
    - Date comparisons and formatting
    
    Example:
        dh = DateHelper()
        yesterday = dh.yesterday()
        last_week = dh.last_week_same_day()
        month_range = dh.current_month_range()
    """
    
    def __init__(self, reference_date: Optional[date | datetime] = None):
        """
        Initialize DateHelper with optional reference date.
        
        Args:
            reference_date: Reference date for calculations (defaults to today)
        
        Example:
            # Use today as reference
            dh = DateHelper()
            
            # Use specific date as reference
            dh = DateHelper(date(2025, 1, 15))
        """
        if reference_date is None:
            self._reference = datetime.now()
        elif isinstance(reference_date, date) and not isinstance(reference_date, datetime):
            self._reference = datetime.combine(reference_date, datetime.min.time())
        else:
            self._reference = reference_date
    
    @property
    def reference_date(self) -> datetime:
        """Get the current reference date"""
        return self._reference
    
    def set_reference_date(self, new_date: date | datetime) -> None:
        """
        Update the reference date.
        
        Args:
            new_date: New reference date
        """
        if isinstance(new_date, date) and not isinstance(new_date, datetime):
            self._reference = datetime.combine(new_date, datetime.min.time())
        else:
            self._reference = new_date
    
    # ==================== Basic Relative Dates ====================
    
    def today(self) -> date:
        """
        Get today's date.
        
        Returns:
            Today's date
        
        Example:
            today = dh.today()  # 2025-02-05
        """
        return self._reference.date()
    
    def yesterday(self) -> date:
        """
        Get yesterday's date.
        
        Returns:
            Yesterday's date
        
        Example:
            yesterday = dh.yesterday()  # 2025-02-04
        """
        return (self._reference - timedelta(days=1)).date()
    
    def tomorrow(self) -> date:
        """
        Get tomorrow's date.
        
        Returns:
            Tomorrow's date
        
        Example:
            tomorrow = dh.tomorrow()  # 2025-02-06
        """
        return (self._reference + timedelta(days=1)).date()
    
    def n_days_ago(self, n: int) -> date:
        """
        Get date N days ago.
        
        Args:
            n: Number of days to go back
        
        Returns:
            Date N days ago
        
        Example:
            week_ago = dh.n_days_ago(7)  # 7 days ago
            month_ago = dh.n_days_ago(30)  # 30 days ago
        """
        return (self._reference - timedelta(days=n)).date()
    
    def n_days_ahead(self, n: int) -> date:
        """
        Get date N days ahead.
        
        Args:
            n: Number of days to go forward
        
        Returns:
            Date N days ahead
        
        Example:
            next_week = dh.n_days_ahead(7)
        """
        return (self._reference + timedelta(days=n)).date()
    
    # ==================== Same Day in Previous Periods ====================
    
    def same_day_last_week(self) -> date:
        """
        Get the same day of the week last week (7 days ago).
        
        Returns:
            Date 7 days ago
        
        Example:
            # If today is Wednesday Feb 5
            last_wed = dh.same_day_last_week()  # Wednesday Jan 29
        """
        return (self._reference - timedelta(days=7)).date()
    
    def same_day_last_month(self) -> date:
        """
        Get the same day last month.
        Handles month-end edge cases intelligently.
        
        Returns:
            Same day last month (or last valid day if doesn't exist)
        
        Example:
            # If today is Jan 31
            dh.same_day_last_month()  # Dec 31
            
            # If today is Mar 31
            dh.same_day_last_month()  # Feb 28 (or 29 in leap year)
        """
        current_day = self._reference.day
        current_month = self._reference.month
        current_year = self._reference.year
        
        # Calculate last month
        last_month = current_month - 1
        last_year = current_year
        if last_month < 1:
            last_month = 12
            last_year -= 1
        
        # Get last day of last month
        last_day_of_last_month = monthrange(last_year, last_month)[1]
        
        # Use the minimum of current day and last day of last month
        day = min(current_day, last_day_of_last_month)
        
        return date(last_year, last_month, day)
    
    def same_day_last_year(self) -> date:
        """
        Get the same day last year.
        Handles Feb 29 in leap years intelligently.
        
        Returns:
            Same day last year (or Feb 28 if Feb 29 doesn't exist)
        
        Example:
            # If today is Feb 5, 2025
            dh.same_day_last_year()  # Feb 5, 2024
            
            # If today is Feb 29, 2024 (leap year)
            dh.same_day_last_year()  # Feb 28, 2023 (not leap year)
        """
        current_day = self._reference.day
        current_month = self._reference.month
        last_year = self._reference.year - 1
        
        # Handle Feb 29 in leap year
        if current_month == 2 and current_day == 29:
            # Check if last year is leap year
            if not self._is_leap_year(last_year):
                return date(last_year, 2, 28)
        
        return date(last_year, current_month, current_day)
    
    def same_day_next_month(self) -> date:
        """
        Get the same day next month.
        
        Returns:
            Same day next month
        
        Example:
            dh.same_day_next_month()
        """
        current_day = self._reference.day
        current_month = self._reference.month
        current_year = self._reference.year
        
        next_month = current_month + 1
        next_year = current_year
        if next_month > 12:
            next_month = 1
            next_year += 1
        
        last_day_of_next_month = monthrange(next_year, next_month)[1]
        day = min(current_day, last_day_of_next_month)
        
        return date(next_year, next_month, day)
    
    def same_day_next_year(self) -> date:
        """
        Get the same day next year.
        
        Returns:
            Same day next year
        
        Example:
            dh.same_day_next_year()
        """
        current_day = self._reference.day
        current_month = self._reference.month
        next_year = self._reference.year + 1
        
        if current_month == 2 and current_day == 29:
            if not self._is_leap_year(next_year):
                return date(next_year, 2, 28)
        
        return date(next_year, current_month, current_day)
    
    # ==================== Week Operations ====================
    
    def start_of_week(self, week_starts_on: Weekday = Weekday.MONDAY) -> date:
        """
        Get the first day of the current week.
        
        Args:
            week_starts_on: Day week starts on (default: Monday)
        
        Returns:
            First day of current week
        
        Example:
            monday = dh.start_of_week()  # Monday of current week
            sunday = dh.start_of_week(Weekday.SUNDAY)  # Sunday of current week
        """
        days_since_start = (self._reference.weekday() - week_starts_on.value) % 7
        return (self._reference - timedelta(days=days_since_start)).date()
    
    def end_of_week(self, week_starts_on: Weekday = Weekday.MONDAY) -> date:
        """
        Get the last day of the current week.
        
        Args:
            week_starts_on: Day week starts on (default: Monday)
        
        Returns:
            Last day of current week
        
        Example:
            sunday = dh.end_of_week()  # Sunday of current week
        """
        start = self.start_of_week(week_starts_on)
        return start + timedelta(days=6)
    
    def current_week_range(
        self, week_starts_on: Weekday = Weekday.MONDAY
    ) -> Tuple[date, date]:
        """
        Get the date range for the current week.
        
        Args:
            week_starts_on: Day week starts on (default: Monday)
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.current_week_range()  # (Monday, Sunday)
        """
        return self.start_of_week(week_starts_on), self.end_of_week(week_starts_on)
    
    def last_week_range(
        self, week_starts_on: Weekday = Weekday.MONDAY
    ) -> Tuple[date, date]:
        """
        Get the date range for last week.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.last_week_range()
        """
        last_week = self._reference - timedelta(days=7)
        helper = DateHelper(last_week)
        return helper.current_week_range(week_starts_on)
    
    # ==================== Month Operations ====================
    
    def start_of_month(self) -> date:
        """
        Get the first day of the current month.
        
        Returns:
            First day of current month
        
        Example:
            first = dh.start_of_month()  # Feb 1, 2025
        """
        return date(self._reference.year, self._reference.month, 1)
    
    def end_of_month(self) -> date:
        """
        Get the last day of the current month.
        
        Returns:
            Last day of current month
        
        Example:
            last = dh.end_of_month()  # Feb 28, 2025
        """
        last_day = monthrange(self._reference.year, self._reference.month)[1]
        return date(self._reference.year, self._reference.month, last_day)
    
    def current_month_range(self) -> Tuple[date, date]:
        """
        Get the date range for the current month.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.current_month_range()  # (Feb 1, Feb 28)
        """
        return self.start_of_month(), self.end_of_month()
    
    def last_month_range(self) -> Tuple[date, date]:
        """
        Get the date range for last month.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.last_month_range()  # (Jan 1, Jan 31)
        """
        last_month_date = self.same_day_last_month()
        helper = DateHelper(last_month_date)
        return helper.current_month_range()
    
    def next_month_range(self) -> Tuple[date, date]:
        """
        Get the date range for next month.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.next_month_range()
        """
        next_month_date = self.same_day_next_month()
        helper = DateHelper(next_month_date)
        return helper.current_month_range()
    
    # ==================== Quarter Operations ====================
    
    def current_quarter(self) -> int:
        """
        Get the current quarter number (1-4).
        
        Returns:
            Quarter number
        
        Example:
            q = dh.current_quarter()  # 1 (for Jan-Mar)
        """
        return (self._reference.month - 1) // 3 + 1
    
    def start_of_quarter(self) -> date:
        """
        Get the first day of the current quarter.
        
        Returns:
            First day of current quarter
        
        Example:
            start = dh.start_of_quarter()  # Jan 1 for Q1
        """
        quarter = self.current_quarter()
        first_month = (quarter - 1) * 3 + 1
        return date(self._reference.year, first_month, 1)
    
    def end_of_quarter(self) -> date:
        """
        Get the last day of the current quarter.
        
        Returns:
            Last day of current quarter
        
        Example:
            end = dh.end_of_quarter()  # Mar 31 for Q1
        """
        quarter = self.current_quarter()
        last_month = quarter * 3
        last_day = monthrange(self._reference.year, last_month)[1]
        return date(self._reference.year, last_month, last_day)
    
    def current_quarter_range(self) -> Tuple[date, date]:
        """
        Get the date range for the current quarter.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.current_quarter_range()  # (Jan 1, Mar 31) for Q1
        """
        return self.start_of_quarter(), self.end_of_quarter()
    
    def last_quarter_range(self) -> Tuple[date, date]:
        """
        Get the date range for last quarter.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.last_quarter_range()
        """
        current_q = self.current_quarter()
        if current_q == 1:
            # Last quarter is Q4 of previous year
            year = self._reference.year - 1
            return date(year, 10, 1), date(year, 12, 31)
        else:
            last_q = current_q - 1
            first_month = (last_q - 1) * 3 + 1
            last_month = last_q * 3
            last_day = monthrange(self._reference.year, last_month)[1]
            return (
                date(self._reference.year, first_month, 1),
                date(self._reference.year, last_month, last_day)
            )
    
    # ==================== Year Operations ====================
    
    def start_of_year(self) -> date:
        """
        Get the first day of the current year.
        
        Returns:
            First day of current year
        
        Example:
            start = dh.start_of_year()  # Jan 1, 2025
        """
        return date(self._reference.year, 1, 1)
    
    def end_of_year(self) -> date:
        """
        Get the last day of the current year.
        
        Returns:
            Last day of current year
        
        Example:
            end = dh.end_of_year()  # Dec 31, 2025
        """
        return date(self._reference.year, 12, 31)
    
    def current_year_range(self) -> Tuple[date, date]:
        """
        Get the date range for the current year.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.current_year_range()  # (Jan 1, Dec 31)
        """
        return self.start_of_year(), self.end_of_year()
    
    def last_year_range(self) -> Tuple[date, date]:
        """
        Get the date range for last year.
        
        Returns:
            Tuple of (start_date, end_date)
        
        Example:
            start, end = dh.last_year_range()  # (Jan 1 2024, Dec 31 2024)
        """
        last_year = self._reference.year - 1
        return date(last_year, 1, 1), date(last_year, 12, 31)
    
    # ==================== Business Days ====================
    
    def is_weekend(self, check_date: Optional[date] = None) -> bool:
        """
        Check if a date is a weekend (Saturday or Sunday).
        
        Args:
            check_date: Date to check (defaults to reference date)
        
        Returns:
            True if weekend
        
        Example:
            if dh.is_weekend():
                print("It's the weekend!")
        """
        if check_date is None:
            check_date = self._reference.date()
        return check_date.weekday() >= 5  # 5=Saturday, 6=Sunday
    
    def is_weekday(self, check_date: Optional[date] = None) -> bool:
        """
        Check if a date is a weekday (Monday-Friday).
        
        Args:
            check_date: Date to check (defaults to reference date)
        
        Returns:
            True if weekday
        
        Example:
            if dh.is_weekday():
                print("It's a work day!")
        """
        return not self.is_weekend(check_date)
    
    def next_business_day(self) -> date:
        """
        Get the next business day (skips weekends).
        
        Returns:
            Next business day
        
        Example:
            # If today is Friday
            next_bday = dh.next_business_day()  # Monday
        """
        next_day = self._reference + timedelta(days=1)
        while next_day.weekday() >= 5:  # Skip weekend
            next_day += timedelta(days=1)
        return next_day.date()
    
    def previous_business_day(self) -> date:
        """
        Get the previous business day (skips weekends).
        
        Returns:
            Previous business day
        
        Example:
            # If today is Monday
            prev_bday = dh.previous_business_day()  # Friday
        """
        prev_day = self._reference - timedelta(days=1)
        while prev_day.weekday() >= 5:  # Skip weekend
            prev_day -= timedelta(days=1)
        return prev_day.date()
    
    def add_business_days(self, n: int) -> date:
        """
        Add N business days (excluding weekends).
        
        Args:
            n: Number of business days to add
        
        Returns:
            Date after adding N business days
        
        Example:
            # Add 5 business days
            future = dh.add_business_days(5)
        """
        current = self._reference
        days_added = 0
        
        while days_added < n:
            current += timedelta(days=1)
            if current.weekday() < 5:  # Not weekend
                days_added += 1
        
        return current.date()
    
    # ==================== Utility Methods ====================
    
    def days_between(self, other_date: date | datetime) -> int:
        """
        Calculate number of days between reference date and another date.
        
        Args:
            other_date: Date to compare with
        
        Returns:
            Number of days (positive if other_date is in future)
        
        Example:
            days = dh.days_between(date(2025, 12, 31))
        """
        if isinstance(other_date, datetime):
            other_date = other_date.date()
        
        delta = other_date - self._reference.date()
        return delta.days
    
    def weeks_between(self, other_date: date | datetime) -> float:
        """
        Calculate number of weeks between dates.
        
        Args:
            other_date: Date to compare with
        
        Returns:
            Number of weeks (can be fractional)
        
        Example:
            weeks = dh.weeks_between(date(2025, 3, 1))
        """
        days = self.days_between(other_date)
        return days / 7
    
    def is_same_week(self, other_date: date | datetime) -> bool:
        """
        Check if another date is in the same week.
        
        Args:
            other_date: Date to compare
        
        Returns:
            True if in same week
        """
        if isinstance(other_date, datetime):
            other_date = other_date.date()
        
        ref_week = self._reference.isocalendar()[1]
        ref_year = self._reference.isocalendar()[0]
        other_week = other_date.isocalendar()[1]
        other_year = other_date.isocalendar()[0]
        
        return ref_week == other_week and ref_year == other_year
    
    def is_same_month(self, other_date: date | datetime) -> bool:
        """
        Check if another date is in the same month.
        
        Args:
            other_date: Date to compare
        
        Returns:
            True if in same month
        """
        if isinstance(other_date, datetime):
            other_date = other_date.date()
        
        return (
            self._reference.year == other_date.year
            and self._reference.month == other_date.month
        )
    
    def is_same_year(self, other_date: date | datetime) -> bool:
        """
        Check if another date is in the same year.
        
        Args:
            other_date: Date to compare
        
        Returns:
            True if in same year
        """
        if isinstance(other_date, datetime):
            other_date = other_date.date()
        
        return self._reference.year == other_date.year
    
    def format_date(self, format_string: str = "%Y-%m-%d") -> str:
        """
        Format the reference date as a string.
        
        Args:
            format_string: strftime format string
        
        Returns:
            Formatted date string
        
        Example:
            formatted = dh.format_date("%B %d, %Y")  # "February 05, 2025"
        """
        return self._reference.strftime(format_string)
    
    # ==================== Helper Methods ====================
    
    @staticmethod
    def _is_leap_year(year: int) -> bool:
        """Check if a year is a leap year"""
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
    
    def __repr__(self) -> str:
        """String representation"""
        return f"DateHelper(reference_date={self._reference.date()})"