
"""
Calendar-based time window filtering for frist package.

Provides calendar window filtering functionality for Chronoobjects).
"""



import functools
from typing import Any, Callable

import datetime as dt
from typing import TYPE_CHECKING

from ._constants import WEEKDAY_INDEX
from ._cal_policy import CalendarPolicy

if TYPE_CHECKING:  # pragma: no cover
    pass




def normalize_weekday(day_spec: str) -> int:
    """Normalize various day-of-week specifications to Python weekday numbers.

    Args:
        day_spec: Day specification as a string

    Returns:
        int: Python weekday number (0=Monday, 1=Tuesday, ..., 6=Sunday)

    Accepts:
        - Full names: 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'
        - 3-letter abbrev: 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'
        - 2-letter abbrev: 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'
        - Pandas style: 'w-mon', 'w-tue', etc.
        - All case insensitive

    Examples:
        normalize_weekday('monday') -> 0
        normalize_weekday('MON') -> 0
        normalize_weekday('w-sun') -> 6
        normalize_weekday('thu') -> 3
    """
    day_spec = str(day_spec).lower().strip()

    # Remove pandas-style prefix
    if day_spec.startswith("w-"):
        day_spec = day_spec[2:]

    if day_spec in WEEKDAY_INDEX:
        return WEEKDAY_INDEX[day_spec]

    # Generate helpful error message
    valid_examples = [
        "Full: 'monday', 'sunday'",
        "3-letter: 'mon', 'sun', 'tue', 'wed', 'thu', 'fri', 'sat'",
        "2-letter: 'mo', 'su', 'tu', 'we', 'th', 'fr', 'sa'",
        "Pandas: 'w-mon', 'w-sun'",
    ]
    raise ValueError(
        f"Invalid day specification: '{day_spec}'. Valid formats:\n"
        + "\n".join(f"  • {ex}" for ex in valid_examples)
    )




def verify_start_end(func: Callable[..., Any]) -> Callable[..., Any]:
    """
    Decorator for calendar window methods to validate input ranges.

    Ensures that the 'start' argument is less than or equal to 'end'.
    If 'end' is None, it is set to 'start' (single-value window).
    If 'start' > 'end', raises ValueError with the function name and values.

    Usage:
        @verify_start_end
        def in_days(self, start=0, end=None):
            ...

    Exception message format:
        '<function>: start (<start>) must not be greater than end (<end>)'
    """
    @functools.wraps(func)
    def wrapper(self: Any, start: int = 0, end: int | None = None, *args: Any, **kwargs: Any) -> bool:
        if end is None:
            end = start
        if start > end:
            func_name = getattr(func, "__name__", repr(func))
            raise ValueError(f"{func_name}: start ({start}) must not be greater than end ({end})")
        return func(self, start, end, *args, **kwargs)
    return wrapper



class Cal:
    """Calendar window filtering functionality for direct datetime/timestamp inputs."""

    def __init__(
        self,
        target_dt: dt.datetime | float | int,
        ref_dt: dt.datetime | float | int,
        cal_policy: CalendarPolicy | None = None,
    ) -> None:
        # Convert to datetime if needed
        if isinstance(target_dt, (float, int)):
            self._target_dt = dt.datetime.fromtimestamp(target_dt)
        elif isinstance(target_dt, dt.datetime): # type: ignore # Explicit type check for runtime safety
            self._target_dt = target_dt
        else:
            raise TypeError("target_dt must be datetime, float, or int")

        if isinstance(ref_dt, (float, int)):
            self._ref_dt = dt.datetime.fromtimestamp(ref_dt)
        elif isinstance(ref_dt, dt.datetime): # type: ignore # Explicit type check for runtime safety
            self._ref_dt = ref_dt
        else:
            raise TypeError("ref_dt must be datetime, float, or int")

        if cal_policy is None:
            self.cal_policy = CalendarPolicy()
        else:
            self.cal_policy = cal_policy


    @property
    def target_dt(self) -> dt.datetime:
        """Get the target datetime."""
        return self._target_dt
    
    @property
    def ref_dt(self) -> dt.datetime:
        """Get the reference datetime."""
        return self._ref_dt
    
    @property
    def holiday(self) -> bool:
        """Return True if target_dt is a holiday according to calendar policy."""
        return self.cal_policy.is_holiday(self.target_dt)


    @property
    def fiscal_year(self) -> int:
        """Return the fiscal year for target_dt based on CalendarPolicy."""
        return Cal.get_fiscal_year(self.target_dt, self.cal_policy.fiscal_year_start_month)

    @property
    def fiscal_quarter(self) -> int:
        """Return the fiscal quarter for target_dt based on CalendarPolicy."""
        return Cal.get_fiscal_quarter(self.target_dt, self.cal_policy.fiscal_year_start_month)


    @verify_start_end
    def in_minutes(self, start: int = 0, end: int = 0) -> bool:
        """
        True if timestamp falls within the minute window(s) from start to end.

        Uses a half-open interval: start_minute <= target_time < end_minute.

        Args:
            start: Minutes from now to start range (negative = past, 0 = current minute, positive = future)
            end: Minutes from now to end range (defaults to start for single minute)

        Examples:
            chrono.cal.in_minutes(0)          # This minute (now)
            chrono.cal.in_minutes(-5)         # 5 minutes ago only
            chrono.cal.in_minutes(-10, -5)    # From 10 minutes ago through 5 minutes ago
            chrono.cal.in_minutes(-30, 0)     # Last 30 minutes through now
        """

        target_time = self.target_dt

        # Calculate the time window boundaries
        start_time = self.ref_dt + dt.timedelta(minutes=start)
        start_minute = start_time.replace(second=0, microsecond=0)

        end_time = self.ref_dt + dt.timedelta(minutes=end)
        end_minute = end_time.replace(second=0, microsecond=0) + dt.timedelta(minutes=1)

        return start_minute <= target_time < end_minute

    @verify_start_end
    def in_hours(self, start: int = 0, end: int = 0) -> bool:
        """
        True if timestamp falls within the hour window(s) from start to end.

        Uses a half-open interval: start_hour <= target_time < end_hour.

        Args:
            start: Hours from now to start range (negative = past, 0 = current hour, positive = future)
            end: Hours from now to end range (defaults to start for single hour)

        Examples:
            chrono.cal.in_hours(0)          # This hour (now)
            chrono.cal.in_hours(-2)         # 2 hours ago only
            chrono.cal.in_hours(-6, -1)     # From 6 hours ago through 1 hour ago
            chrono.cal.in_hours(-24, 0)     # Last 24 hours through now
        """

        target_time = self.target_dt

        # Calculate the time window boundaries
        start_time = self.ref_dt + dt.timedelta(hours=start)
        start_hour = start_time.replace(minute=0, second=0, microsecond=0)

        end_time = self.ref_dt + dt.timedelta(hours=end)
        end_hour = end_time.replace(minute=0, second=0, microsecond=0) + dt.timedelta(hours=1)

        return start_hour <= target_time < end_hour

    @verify_start_end
    def in_days(self, start: int = 0, end: int = 0) -> bool:
        """True if timestamp falls within the day window(s) from start to end.

        Args:
            start: Days from reference to start range (negative = past, 0 = today, positive = future)
            end: Days from reference to end range (defaults to start for single day)

        Examples:
            cal.in_days(0)          # Today only
            cal.in_days(-1)         # Yesterday only
            cal.in_days(-7, -1)     # From 7 days ago through yesterday
            cal.in_days(-30, 0)     # Last 30 days through today
        """

        target_date = self.target_dt.date()

        # Calculate the date range boundaries
        start_date = (self.ref_dt + dt.timedelta(days=start)).date()
        end_date = (self.ref_dt + dt.timedelta(days=end)).date()

        return start_date <= target_date <= end_date


    @verify_start_end
    def in_workdays(self, start: int = 0, end: int = 0) -> bool:
        """
        True if target_dt falls within the working day window(s) from start to end,
        counting only working days as defined by CalendarPolicy (workdays, holidays).

        The window is defined by moving exactly `start` and `end` working days from ref_dt,
        skipping non-workdays and holidays. The check is inclusive: start_workday <= target_dt <= end_workday.

        Args:
            start: Working days from reference to start range (negative = past, 0 = today, positive = future)
            end: Working days from reference to end range (defaults to start for single working day)

        Examples:
            cal.in_workdays(0)          # Today only, if today is a working day
            cal.in_workdays(-1)         # Previous working day only
            cal.in_workdays(-5, 5)      # From 5 working days ago through 5 working days ahead
        """
        ref_date = self.ref_dt.date()
        target_date = self.target_dt.date()

        def move_workdays(date: dt.date, n: int) -> dt.date:
            """Move n working days from date, skipping non-workdays and holidays."""
            step = 1 if n > 0 else -1
            count = 0
            current = date
            while count < abs(n):
                current += dt.timedelta(days=step)
                if self.cal_policy.is_workday(current) and not self.cal_policy.is_holiday(current):
                    count += 1
            return current

        start_workday = move_workdays(ref_date, start)
        end_workday = move_workdays(ref_date, end)


        # Target must be a workday (per CalendarPolicy)
        is_workday = self.cal_policy.is_workday(target_date) and not self.cal_policy.is_holiday(target_date)
        return is_workday and (start_workday <= target_date <= end_workday)


    @verify_start_end
    def in_months(self, start: int = 0, end: int = 0) -> bool:
        """True if timestamp falls within the month window(s) from start to end.

        Args:
            start: Months from now to start range (negative = past, 0 = this month, positive = future)
            end: Months from now to end range (defaults to start for single month)

        Examples:
            chrono.cal.in_months(0)          # This month
            chrono.cal.in_months(-1)         # Last month only
            chrono.cal.in_months(-6, -1)     # From 6 months ago through last month
            chrono.cal.in_months(-12, 0)     # Last 12 months through this month
        """

        target_time = self.target_dt
        base_year = self.ref_dt.year
        base_month = self.ref_dt.month

        # Calculate the start month (earliest)
        start_month = base_month + start
        start_year = base_year
        while start_month <= 0:
            start_month += 12
            start_year -= 1
        while start_month > 12:
            start_month -= 12
            start_year += 1

        # Calculate the end month (latest)
        end_month = base_month + end
        end_year = base_year
        while end_month <= 0:
            end_month += 12
            end_year -= 1
        while end_month > 12:
            end_month -= 12
            end_year += 1

        # Convert months to a comparable format (year * 12 + month)
        file_month_index = target_time.year * 12 + target_time.month
        start_month_index = start_year * 12 + start_month
        end_month_index = end_year * 12 + end_month

        return start_month_index <= file_month_index <= end_month_index

    @verify_start_end
    def in_quarters(self, start: int = 0, end: int = 0) -> bool:
        """
        True if timestamp falls within the quarter window(s) from start to end.

        Uses a half-open interval: start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1).

        Args:
            start: Quarters from now to start range (negative = past, 0 = this quarter, positive = future)
            end: Quarters from now to end range (defaults to start for single quarter)

        Examples:
            chrono.cal.in_quarters(0)          # This quarter (Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec)
            chrono.cal.in_quarters(-1)         # Last quarter
            chrono.cal.in_quarters(-4, -1)     # From 4 quarters ago through last quarter
            chrono.cal.in_quarters(-8, 0)      # Last 8 quarters through this quarter
        """

        target_time = self.target_dt
        base_time = self.ref_dt

        # Get current quarter (1-4) and year
        current_quarter = ((base_time.month - 1) // 3) + 1
        current_year = base_time.year

        def normalize_quarter_year(offset: int) -> tuple[int, int]:
            total_quarters = (current_year * 4 + current_quarter + offset - 1)
            year = total_quarters // 4
            quarter = (total_quarters % 4) + 1
            return year, quarter

        start_year, start_quarter = normalize_quarter_year(start)
        end_year, end_quarter = normalize_quarter_year(end)

        # Get target's quarter
        target_quarter = ((target_time.month - 1) // 3) + 1
        target_year = target_time.year

        # Use tuple comparison for (year, quarter)
        target_tuple = (target_year, target_quarter)
        start_tuple = (start_year, start_quarter)
        end_tuple = (end_year, end_quarter)

        # Check if target falls within the quarter range: start <= target < end
        return start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1)

    @verify_start_end
    def in_years(self, start: int = 0, end: int = 0) -> bool:
        """True if timestamp falls within the year window(s) from start to end.

        Args:
            start: Years from now to start range (negative = past, 0 = this year, positive = future)
            end: Years from now to end range (defaults to start for single year)

        Examples:
            chrono.cal.in_years(0)          # This year
            chrono.cal.in_years(-1)         # Last year only
            chrono.cal.in_years(-5, -1)     # From 5 years ago through last year
            chrono.cal.in_years(-10, 0)     # Last 10 years through this year
        """

        target_year = self.target_dt.year
        base_year = self.ref_dt.year

        # Calculate year range boundaries
        start_year = base_year + start
        end_year = base_year + end

        return start_year <= target_year <= end_year
    
    @verify_start_end
    def in_weeks(
        self, start: int = 0, end: int = 0, week_start: str = "monday"
    ) -> bool:
        """True if timestamp falls within the week window(s) from start to end.

        Args:
            start: Weeks from now to start range (negative = past, 0 = current week, positive = future)
            end: Weeks from now to end range (defaults to start for single week)
            week_start: Week start day (default: 'monday' for ISO weeks)
                - 'monday'/'mon'/'mo' (ISO 8601 default)
                - 'sunday'/'sun'/'su' (US convention)
                - Supports full names, abbreviations, pandas style ('w-mon')
                - Case insensitive

        Examples:
            chrono.cal.in_weeks(0)                     # This week (Monday start)
            chrono.cal.in_weeks(-1, week_start='sun')  # Last week (Sunday start)
            chrono.cal.in_weeks(-4, 0)                 # Last 4 weeks through this week
            chrono.cal.in_weeks(-2, -1, 'sunday')      # 2-1 weeks ago (Sunday weeks)
        """

        week_start_day = normalize_weekday(week_start)

        target_date = self.target_dt.date()
        base_date = self.ref_dt.date()

        # Calculate the start of the current week based on week_start_day
        days_since_week_start = (base_date.weekday() - week_start_day) % 7
        current_week_start = base_date - dt.timedelta(days=days_since_week_start)

        # Calculate week boundaries
        start_week_start = current_week_start + dt.timedelta(weeks=start)
        end_week_start = current_week_start + dt.timedelta(weeks=end)
        end_week_end = end_week_start + dt.timedelta(
            days=6
        )  # End of week (6 days after start)

        return start_week_start <= target_date <= end_week_end


    @verify_start_end
    def in_fiscal_quarters(self, start: int = 0, end: int = 0) -> bool:
        """
        True if timestamp falls within the fiscal quarter window(s) from start to end.

        Uses a half-open interval: start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1).

        Args:
            start: Fiscal quarters from now to start range (negative = past, 0 = this fiscal quarter, positive = future)
            end: Fiscal quarters from now to end range (defaults to start for single fiscal quarter)

        Examples:
            chrono.cal.in_fiscal_quarters(0)          # This fiscal quarter
            chrono.cal.in_fiscal_quarters(-1)         # Last fiscal quarter
            chrono.cal.in_fiscal_quarters(-4, -1)     # From 4 fiscal quarters ago through last fiscal quarter
            chrono.cal.in_fiscal_quarters(-8, 0)      # Last 8 fiscal quarters through this fiscal quarter
        """
        fy_start_month = self.cal_policy.fiscal_year_start_month
        base_time = self.ref_dt
        fy = Cal.get_fiscal_year(base_time, fy_start_month)
        fq = Cal.get_fiscal_quarter(base_time, fy_start_month)

        def normalize_fiscal_quarter_year(offset: int) -> tuple[int, int]:
            total_quarters = (fy * 4 + fq + offset - 1)
            year = total_quarters // 4
            quarter = (total_quarters % 4) + 1
            return year, quarter

        start_year, start_quarter = normalize_fiscal_quarter_year(start)
        end_year, end_quarter = normalize_fiscal_quarter_year(end)

        target_fy = Cal.get_fiscal_year(self.target_dt, fy_start_month)
        target_fq = Cal.get_fiscal_quarter(self.target_dt, fy_start_month)

        target_tuple = (target_fy, target_fq)
        start_tuple = (start_year, start_quarter)
        end_tuple = (end_year, end_quarter)

        return start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1)


    @verify_start_end
    def in_fiscal_years(self, start: int = 0, end: int = 0) -> bool:
        """
        True if timestamp falls within the fiscal year window(s) from start to end.

        Uses a half-open interval: start_year <= target_year < end_year + 1.

        Args:
            start: Fiscal years from now to start range (negative = past, 0 = this fiscal year, positive = future)
            end: Fiscal years from now to end range (defaults to start for single fiscal year)

        Examples:
            chrono.cal.in_fiscal_years(0)          # This fiscal year
            chrono.cal.in_fiscal_years(-1)         # Last fiscal year
            chrono.cal.in_fiscal_years(-5, -1)     # From 5 fiscal years ago through last fiscal year
            chrono.cal.in_fiscal_years(-10, 0)     # Last 10 fiscal years through this fiscal year
        """
        fy_start_month = self.cal_policy.fiscal_year_start_month
        base_time = self.ref_dt
        fy = Cal.get_fiscal_year(base_time, fy_start_month)
        start_year = fy + start
        end_year = fy + end

        target_fy = Cal.get_fiscal_year(self.target_dt, fy_start_month)

        return start_year <= target_fy < end_year + 1
    
    @staticmethod
    def get_fiscal_year(dt: dt.datetime, fy_start_month: int) -> int:
        """Return the fiscal year for a given datetime and fiscal year start month."""
        return dt.year if dt.month >= fy_start_month else dt.year - 1

    @staticmethod
    def get_fiscal_quarter(dt: dt.datetime, fy_start_month: int) -> int:
        """Return the fiscal quarter for a given datetime and fiscal year start month."""
        offset = (dt.month - fy_start_month) % 12 if dt.month >= fy_start_month else (dt.month + 12 - fy_start_month) % 12
        return (offset // 3) + 1

    @staticmethod
    def count_working_days(start: dt.date, end: dt.date, holidays: set[str]) -> int:
        """
        Count working days between start and end dates (inclusive).
        Uses Monday-Friday as workdays and provided holidays set.
        Args:
            start: Start date (inclusive)
            end: End date (inclusive)
            holidays: Set of holiday date strings (YYYY-MM-DD)
        Returns:
            int: Number of working days
        """
        workdays = {0, 1, 2, 3, 4}  # Monday=0 ... Friday=4
        count = 0
        current = start
        while current <= end:
            weekday = current.weekday()
            date_str = current.strftime('%Y-%m-%d')
            if weekday in workdays and date_str not in holidays:
                count += 1
            current += dt.timedelta(days=1)
        return count