# -*- coding: utf-8 -*-

# Copyright (c) 2025, Brandon Nielsen
# All rights reserved.
#
# This software may be modified and distributed under the terms
# of the BSD license.  See the LICENSE file for details.

import datetime
from collections import namedtuple
from functools import partial

from aniso8601.builders import (
    BaseTimeBuilder,
    DateTuple,
    Limit,
    TupleBuilder,
    cast,
    range_check,
)
from aniso8601.exceptions import (
    DayOutOfBoundsError,
    HoursOutOfBoundsError,
    MinutesOutOfBoundsError,
    MonthOutOfBoundsError,
    SecondsOutOfBoundsError,
    WeekOutOfBoundsError,
    YearOutOfBoundsError,
)
from aniso8601.utcoffset import UTCOffset

DAYS_PER_YEAR = 365
DAYS_PER_MONTH = 30
DAYS_PER_WEEK = 7

HOURS_PER_DAY = 24

MINUTES_PER_HOUR = 60
MINUTES_PER_DAY = MINUTES_PER_HOUR * HOURS_PER_DAY

SECONDS_PER_MINUTE = 60
SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE

MICROSECONDS_PER_SECOND = int(1e6)

MICROSECONDS_PER_MINUTE = 60 * MICROSECONDS_PER_SECOND
MICROSECONDS_PER_HOUR = 60 * MICROSECONDS_PER_MINUTE
MICROSECONDS_PER_DAY = 24 * MICROSECONDS_PER_HOUR
MICROSECONDS_PER_WEEK = 7 * MICROSECONDS_PER_DAY
MICROSECONDS_PER_MONTH = DAYS_PER_MONTH * MICROSECONDS_PER_DAY
MICROSECONDS_PER_YEAR = DAYS_PER_YEAR * MICROSECONDS_PER_DAY

TIMEDELTA_MAX_DAYS = datetime.timedelta.max.days

FractionalComponent = namedtuple(
    "FractionalComponent", ["principal", "microsecondremainder"]
)


def year_range_check(valuestr, limit):
    YYYYstr = valuestr

    # Truncated dates, like '19', refer to 1900-1999 inclusive,
    # we simply parse to 1900
    if len(valuestr) < 4:
        # Shift 0s in from the left to form complete year
        YYYYstr = valuestr.ljust(4, "0")

    return range_check(YYYYstr, limit)


def fractional_range_check(conversion, valuestr, limit):
    if valuestr is None:
        return None

    if "." in valuestr:
        castfunc = partial(_cast_to_fractional_component, conversion)
    else:
        castfunc = int

    value = cast(valuestr, castfunc, thrownmessage=limit.casterrorstring)

    if isinstance(value, FractionalComponent):
        tocheck = float(valuestr)
    else:
        tocheck = int(valuestr)

    if limit.min is not None and tocheck < limit.min:
        raise limit.rangeexception(limit.rangeerrorstring)

    if limit.max is not None and tocheck > limit.max:
        raise limit.rangeexception(limit.rangeerrorstring)

    return value


def _cast_to_fractional_component(conversion, floatstr):
    # Splits a string with a decimal point into an int, and
    # int representing the floating point remainder as a number
    # of microseconds, determined by multiplying by conversion
    intpart, floatpart = floatstr.split(".")

    intvalue = int(intpart)
    preconvertedvalue = int(floatpart)

    convertedvalue = (preconvertedvalue * conversion) // (10 ** len(floatpart))

    return FractionalComponent(intvalue, convertedvalue)


class PythonTimeBuilder(BaseTimeBuilder):
    # 0000 (1 BC) is not representable as a Python date
    DATE_YYYY_LIMIT = Limit(
        "Invalid year string.",
        datetime.MINYEAR,
        datetime.MAXYEAR,
        YearOutOfBoundsError,
        "Year must be between {0}..{1}.".format(datetime.MINYEAR, datetime.MAXYEAR),
        year_range_check,
    )
    TIME_HH_LIMIT = Limit(
        "Invalid hour string.",
        0,
        24,
        HoursOutOfBoundsError,
        "Hour must be between 0..24 with 24 representing midnight.",
        partial(fractional_range_check, MICROSECONDS_PER_HOUR),
    )
    TIME_MM_LIMIT = Limit(
        "Invalid minute string.",
        0,
        59,
        MinutesOutOfBoundsError,
        "Minute must be between 0..59.",
        partial(fractional_range_check, MICROSECONDS_PER_MINUTE),
    )
    TIME_SS_LIMIT = Limit(
        "Invalid second string.",
        0,
        60,
        SecondsOutOfBoundsError,
        "Second must be between 0..60 with 60 representing a leap second.",
        partial(fractional_range_check, MICROSECONDS_PER_SECOND),
    )
    DURATION_PNY_LIMIT = Limit(
        "Invalid year duration string.",
        None,
        None,
        YearOutOfBoundsError,
        None,
        partial(fractional_range_check, MICROSECONDS_PER_YEAR),
    )
    DURATION_PNM_LIMIT = Limit(
        "Invalid month duration string.",
        None,
        None,
        MonthOutOfBoundsError,
        None,
        partial(fractional_range_check, MICROSECONDS_PER_MONTH),
    )
    DURATION_PNW_LIMIT = Limit(
        "Invalid week duration string.",
        None,
        None,
        WeekOutOfBoundsError,
        None,
        partial(fractional_range_check, MICROSECONDS_PER_WEEK),
    )
    DURATION_PND_LIMIT = Limit(
        "Invalid day duration string.",
        None,
        None,
        DayOutOfBoundsError,
        None,
        partial(fractional_range_check, MICROSECONDS_PER_DAY),
    )
    DURATION_TNH_LIMIT = Limit(
        "Invalid hour duration string.",
        None,
        None,
        HoursOutOfBoundsError,
        None,
        partial(fractional_range_check, MICROSECONDS_PER_HOUR),
    )
    DURATION_TNM_LIMIT = Limit(
        "Invalid minute duration string.",
        None,
        None,
        MinutesOutOfBoundsError,
        None,
        partial(fractional_range_check, MICROSECONDS_PER_MINUTE),
    )
    DURATION_TNS_LIMIT = Limit(
        "Invalid second duration string.",
        None,
        None,
        SecondsOutOfBoundsError,
        None,
        partial(fractional_range_check, MICROSECONDS_PER_SECOND),
    )

    DATE_RANGE_DICT = BaseTimeBuilder.DATE_RANGE_DICT
    DATE_RANGE_DICT["YYYY"] = DATE_YYYY_LIMIT

    TIME_RANGE_DICT = {"hh": TIME_HH_LIMIT, "mm": TIME_MM_LIMIT, "ss": TIME_SS_LIMIT}

    DURATION_RANGE_DICT = {
        "PnY": DURATION_PNY_LIMIT,
        "PnM": DURATION_PNM_LIMIT,
        "PnW": DURATION_PNW_LIMIT,
        "PnD": DURATION_PND_LIMIT,
        "TnH": DURATION_TNH_LIMIT,
        "TnM": DURATION_TNM_LIMIT,
        "TnS": DURATION_TNS_LIMIT,
    }

    @classmethod
    def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None):
        YYYY, MM, DD, Www, D, DDD = cls.range_check_date(YYYY, MM, DD, Www, D, DDD)

        if MM is None:
            MM = 1

        if DD is None:
            DD = 1

        if DDD is not None:
            return PythonTimeBuilder._build_ordinal_date(YYYY, DDD)

        if Www is not None:
            return PythonTimeBuilder._build_week_date(YYYY, Www, isoday=D)

        return datetime.date(YYYY, MM, DD)

    @classmethod
    def build_time(cls, hh=None, mm=None, ss=None, tz=None):
        # Builds a time from the given parts, handling fractional arguments
        # where necessary
        hours = 0
        minutes = 0
        seconds = 0
        microseconds = 0

        hh, mm, ss, tz = cls.range_check_time(hh, mm, ss, tz)

        if isinstance(hh, FractionalComponent):
            hours = hh.principal
            microseconds = hh.microsecondremainder
        elif hh is not None:
            hours = hh

        if isinstance(mm, FractionalComponent):
            minutes = mm.principal
            microseconds = mm.microsecondremainder
        elif mm is not None:
            minutes = mm

        if isinstance(ss, FractionalComponent):
            seconds = ss.principal
            microseconds = ss.microsecondremainder
        elif ss is not None:
            seconds = ss

        (
            hours,
            minutes,
            seconds,
            microseconds,
        ) = PythonTimeBuilder._distribute_microseconds(
            microseconds,
            (hours, minutes, seconds),
            (MICROSECONDS_PER_HOUR, MICROSECONDS_PER_MINUTE, MICROSECONDS_PER_SECOND),
        )

        # Move midnight into range
        if hours == 24:
            hours = 0

        # Datetimes don't handle fractional components, so we use a timedelta
        if tz is not None:
            return (
                datetime.datetime(
                    1, 1, 1, hour=hours, minute=minutes, tzinfo=cls._build_object(tz)
                )
                + datetime.timedelta(seconds=seconds, microseconds=microseconds)
            ).timetz()

        return (
            datetime.datetime(1, 1, 1, hour=hours, minute=minutes)
            + datetime.timedelta(seconds=seconds, microseconds=microseconds)
        ).time()

    @classmethod
    def build_datetime(cls, date, time):
        return datetime.datetime.combine(
            cls._build_object(date), cls._build_object(time)
        )

    @classmethod
    def build_duration(
        cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None
    ):
        # PnY and PnM will be distributed to PnD, microsecond remainder to TnS
        PnY, PnM, PnW, PnD, TnH, TnM, TnS = cls.range_check_duration(
            PnY, PnM, PnW, PnD, TnH, TnM, TnS
        )

        seconds = TnS.principal
        microseconds = TnS.microsecondremainder

        return datetime.timedelta(
            days=PnD,
            seconds=seconds,
            microseconds=microseconds,
            minutes=TnM,
            hours=TnH,
            weeks=PnW,
        )

    @classmethod
    def build_interval(cls, start=None, end=None, duration=None):
        start, end, duration = cls.range_check_interval(start, end, duration)

        if start is not None and end is not None:
            # <start>/<end>
            startobject = cls._build_object(start)
            endobject = cls._build_object(end)

            return (startobject, endobject)

        durationobject = cls._build_object(duration)

        # Determine if datetime promotion is required
        datetimerequired = (
            duration.TnH is not None
            or duration.TnM is not None
            or duration.TnS is not None
            or durationobject.seconds != 0
            or durationobject.microseconds != 0
        )

        if end is not None:
            # <duration>/<end>
            endobject = cls._build_object(end)

            # Range check
            if isinstance(end, DateTuple) and datetimerequired is True:
                # <end> is a date, and <duration> requires datetime resolution
                return (
                    endobject,
                    cls.build_datetime(end, TupleBuilder.build_time()) - durationobject,
                )

            return (endobject, endobject - durationobject)

        # <start>/<duration>
        startobject = cls._build_object(start)

        # Range check
        if isinstance(start, DateTuple) and datetimerequired is True:
            # <start> is a date, and <duration> requires datetime resolution
            return (
                startobject,
                cls.build_datetime(start, TupleBuilder.build_time()) + durationobject,
            )

        return (startobject, startobject + durationobject)

    @classmethod
    def build_repeating_interval(cls, R=None, Rnn=None, interval=None):
        startobject = None
        endobject = None

        R, Rnn, interval = cls.range_check_repeating_interval(R, Rnn, interval)

        if interval.start is not None:
            startobject = cls._build_object(interval.start)

        if interval.end is not None:
            endobject = cls._build_object(interval.end)

        if interval.duration is not None:
            durationobject = cls._build_object(interval.duration)
        else:
            durationobject = endobject - startobject

        if R is True:
            if startobject is not None:
                return cls._date_generator_unbounded(startobject, durationobject)

            return cls._date_generator_unbounded(endobject, -durationobject)

        iterations = int(Rnn)

        if startobject is not None:
            return cls._date_generator(startobject, durationobject, iterations)

        return cls._date_generator(endobject, -durationobject, iterations)

    @classmethod
    def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""):
        negative, Z, hh, mm, name = cls.range_check_timezone(negative, Z, hh, mm, name)

        if Z is True:
            # Z -> UTC
            return UTCOffset(name="UTC", minutes=0)

        tzhour = int(hh)

        if mm is not None:
            tzminute = int(mm)
        else:
            tzminute = 0

        if negative is True:
            return UTCOffset(name=name, minutes=-(tzhour * 60 + tzminute))

        return UTCOffset(name=name, minutes=tzhour * 60 + tzminute)

    @classmethod
    def range_check_duration(
        cls,
        PnY=None,
        PnM=None,
        PnW=None,
        PnD=None,
        TnH=None,
        TnM=None,
        TnS=None,
        rangedict=None,
    ):
        years = 0
        months = 0
        days = 0
        weeks = 0
        hours = 0
        minutes = 0
        seconds = 0
        microseconds = 0

        PnY, PnM, PnW, PnD, TnH, TnM, TnS = BaseTimeBuilder.range_check_duration(
            PnY, PnM, PnW, PnD, TnH, TnM, TnS, rangedict=cls.DURATION_RANGE_DICT
        )

        if PnY is not None:
            if isinstance(PnY, FractionalComponent):
                years = PnY.principal
                microseconds = PnY.microsecondremainder
            else:
                years = PnY

            if years * DAYS_PER_YEAR > TIMEDELTA_MAX_DAYS:
                raise YearOutOfBoundsError("Duration exceeds maximum timedelta size.")

        if PnM is not None:
            if isinstance(PnM, FractionalComponent):
                months = PnM.principal
                microseconds = PnM.microsecondremainder
            else:
                months = PnM

            if months * DAYS_PER_MONTH > TIMEDELTA_MAX_DAYS:
                raise MonthOutOfBoundsError("Duration exceeds maximum timedelta size.")

        if PnW is not None:
            if isinstance(PnW, FractionalComponent):
                weeks = PnW.principal
                microseconds = PnW.microsecondremainder
            else:
                weeks = PnW

            if weeks * DAYS_PER_WEEK > TIMEDELTA_MAX_DAYS:
                raise WeekOutOfBoundsError("Duration exceeds maximum timedelta size.")

        if PnD is not None:
            if isinstance(PnD, FractionalComponent):
                days = PnD.principal
                microseconds = PnD.microsecondremainder
            else:
                days = PnD

            if days > TIMEDELTA_MAX_DAYS:
                raise DayOutOfBoundsError("Duration exceeds maximum timedelta size.")

        if TnH is not None:
            if isinstance(TnH, FractionalComponent):
                hours = TnH.principal
                microseconds = TnH.microsecondremainder
            else:
                hours = TnH

            if hours // HOURS_PER_DAY > TIMEDELTA_MAX_DAYS:
                raise HoursOutOfBoundsError("Duration exceeds maximum timedelta size.")

        if TnM is not None:
            if isinstance(TnM, FractionalComponent):
                minutes = TnM.principal
                microseconds = TnM.microsecondremainder
            else:
                minutes = TnM

            if minutes // MINUTES_PER_DAY > TIMEDELTA_MAX_DAYS:
                raise MinutesOutOfBoundsError(
                    "Duration exceeds maximum timedelta size."
                )

        if TnS is not None:
            if isinstance(TnS, FractionalComponent):
                seconds = TnS.principal
                microseconds = TnS.microsecondremainder
            else:
                seconds = TnS

            if seconds // SECONDS_PER_DAY > TIMEDELTA_MAX_DAYS:
                raise SecondsOutOfBoundsError(
                    "Duration exceeds maximum timedelta size."
                )

        (
            years,
            months,
            weeks,
            days,
            hours,
            minutes,
            seconds,
            microseconds,
        ) = PythonTimeBuilder._distribute_microseconds(
            microseconds,
            (years, months, weeks, days, hours, minutes, seconds),
            (
                MICROSECONDS_PER_YEAR,
                MICROSECONDS_PER_MONTH,
                MICROSECONDS_PER_WEEK,
                MICROSECONDS_PER_DAY,
                MICROSECONDS_PER_HOUR,
                MICROSECONDS_PER_MINUTE,
                MICROSECONDS_PER_SECOND,
            ),
        )

        # Note that weeks can be handled without conversion to days
        totaldays = years * DAYS_PER_YEAR + months * DAYS_PER_MONTH + days

        # Check against timedelta limits
        if (
            totaldays
            + weeks * DAYS_PER_WEEK
            + hours // HOURS_PER_DAY
            + minutes // MINUTES_PER_DAY
            + seconds // SECONDS_PER_DAY
            > TIMEDELTA_MAX_DAYS
        ):
            raise DayOutOfBoundsError("Duration exceeds maximum timedelta size.")

        return (
            None,
            None,
            weeks,
            totaldays,
            hours,
            minutes,
            FractionalComponent(seconds, microseconds),
        )

    @classmethod
    def range_check_interval(cls, start=None, end=None, duration=None):
        # Handles concise format, range checks any potential durations
        if start is not None and end is not None:
            # <start>/<end>
            # Handle concise format
            if cls._is_interval_end_concise(end) is True:
                end = cls._combine_concise_interval_tuples(start, end)

            return (start, end, duration)

        durationobject = cls._build_object(duration)

        if end is not None:
            # <duration>/<end>
            endobject = cls._build_object(end)

            # Range check
            if isinstance(end, DateTuple):
                enddatetime = cls.build_datetime(end, TupleBuilder.build_time())

                if enddatetime - datetime.datetime.min < durationobject:
                    raise YearOutOfBoundsError("Interval end less than minimium date.")
            else:
                mindatetime = datetime.datetime.min

                if end.time.tz is not None:
                    mindatetime = mindatetime.replace(tzinfo=endobject.tzinfo)

                if endobject - mindatetime < durationobject:
                    raise YearOutOfBoundsError("Interval end less than minimium date.")
        else:
            # <start>/<duration>
            startobject = cls._build_object(start)

            # Range check
            if type(start) is DateTuple:
                startdatetime = cls.build_datetime(start, TupleBuilder.build_time())

                if datetime.datetime.max - startdatetime < durationobject:
                    raise YearOutOfBoundsError(
                        "Interval end greater than maximum date."
                    )
            else:
                maxdatetime = datetime.datetime.max

                if start.time.tz is not None:
                    maxdatetime = maxdatetime.replace(tzinfo=startobject.tzinfo)

                if maxdatetime - startobject < durationobject:
                    raise YearOutOfBoundsError(
                        "Interval end greater than maximum date."
                    )

        return (start, end, duration)

    @staticmethod
    def _build_week_date(isoyear, isoweek, isoday=None):
        if isoday is None:
            return PythonTimeBuilder._iso_year_start(isoyear) + datetime.timedelta(
                weeks=isoweek - 1
            )

        return PythonTimeBuilder._iso_year_start(isoyear) + datetime.timedelta(
            weeks=isoweek - 1, days=isoday - 1
        )

    @staticmethod
    def _build_ordinal_date(isoyear, isoday):
        # Day of year to a date
        # https://stackoverflow.com/questions/2427555/python-question-year-and-day-of-year-to-date
        builtdate = datetime.date(isoyear, 1, 1) + datetime.timedelta(days=isoday - 1)

        return builtdate

    @staticmethod
    def _iso_year_start(isoyear):
        # Given an ISO year, returns the equivalent of the start of the year
        # on the Gregorian calendar (which is used by Python)
        # Stolen from:
        # http://stackoverflow.com/questions/304256/whats-the-best-way-to-find-the-inverse-of-datetime-isocalendar

        # Determine the location of the 4th of January, the first week of
        # the ISO year is the week containing the 4th of January
        # http://en.wikipedia.org/wiki/ISO_week_date
        fourth_jan = datetime.date(isoyear, 1, 4)

        # Note the conversion from ISO day (1 - 7) and Python day (0 - 6)
        delta = datetime.timedelta(days=fourth_jan.isoweekday() - 1)

        # Return the start of the year
        return fourth_jan - delta

    @staticmethod
    def _date_generator(startdate, timedelta, iterations):
        currentdate = startdate
        currentiteration = 0

        while currentiteration < iterations:
            yield currentdate

            # Update the values
            currentdate += timedelta
            currentiteration += 1

    @staticmethod
    def _date_generator_unbounded(startdate, timedelta):
        currentdate = startdate

        while True:
            yield currentdate

            # Update the value
            currentdate += timedelta

    @staticmethod
    def _distribute_microseconds(todistribute, recipients, reductions):
        # Given a number of microseconds as int, a tuple of ints length n
        # to distribute to, and a tuple of ints length n to divide todistribute
        # by (from largest to smallest), returns a tuple of length n + 1, with
        # todistribute divided across recipients using the reductions, with
        # the final remainder returned as the final tuple member
        results = []

        remainder = todistribute

        for index, reduction in enumerate(reductions):
            additional, remainder = divmod(remainder, reduction)

            results.append(recipients[index] + additional)

        # Always return the remaining microseconds
        results.append(remainder)

        return tuple(results)
