from datetime import datetime, timedelta, timezone
from operator import itemgetter
from typing import List, Optional, Tuple

import pytz


class TimeToCallCalculator:
    """
    What happens here:
    1. Org schedule is in org's timezone. So firstly we shift schedule to UTC timezone.
    2. Check if reservation time is on org's working hours. If not - return.
    3. Find closest to now intersection in org and call center open hours
        in seconds from week start.
        Whe should call no less then 45 min before org closes.
    4. If we don't have intersections or don't have org schedule
        - find closest call center open moment in seconds from week start.
    5. Convert calculated moment from week start to particular datetime.
    6. If this particular datetime is earlier than now,
        move this datetime forward by one week.
    """

    _CALL_CENTER_OPEN_HOURS = [
        (21600, 72000),  # Mon 6-20 UTC
        (108000, 158400),  # Tue 6-20 UTC
        (194400, 244800),  # Wed 6-20 UTC
        (280800, 331200),  # Thu 6-20 UTC
        (367200, 417600),  # Fri 6-20 UTC
        (453600, 504000),  # Sat 6-20 UTC
        (540000, 590400),  # San 6-20 UTC
    ]

    _SECONDS_IN_DAY: int = 86400
    _WEEK_END: int = 604800  # seconds
    _CALLING_RESERVE: int = 2700  # 45 minutes in seconds
    _VISITATION_RESERVE: int = 1800  # 30 minutes in seconds
    _org_open_hours: Optional[List[Tuple[int, int]]]
    _reservation_datetime: datetime
    _reservation_timezone: str

    def __init__(
        self,
        org_open_hours: Optional[List[Tuple[int, int]]],
        reservation_datetime: datetime,
        reservation_timezone: str,
    ):
        self._org_open_hours = org_open_hours
        self._reservation_datetime = reservation_datetime
        self._reservation_timezone = reservation_timezone
        self._utc_now = datetime.now(tz=timezone.utc)

    def calculate(self) -> Optional[datetime]:
        # calculations should be in one timezone
        if self._org_open_hours is not None:
            self._org_open_hours = self._shift_open_hours_to_utc_timezone()

        if not self._check_reservation_in_org_work_hours():
            return

        ntc_in_sec = self._find_nearest_time_to_call_in_sec_from_week_start()

        time_to_call = self._convert_seconds_from_week_start_to_closest_datetime(
            ntc_in_sec=ntc_in_sec
        )
        if (
            time_to_call + timedelta(seconds=self._CALLING_RESERVE)
            < self._reservation_datetime
        ):
            return time_to_call

    def _check_reservation_in_org_work_hours(self) -> bool:
        if self._org_open_hours is None:
            return True

        reservation_in_secs = self._calculate_seconds_from_weekstart(
            self._reservation_datetime
        )
        for start, end in self._org_open_hours:
            # visit should start no less then 30 min before closing
            if start <= reservation_in_secs <= end - self._VISITATION_RESERVE:
                return True

        return False

    def _shift_open_hours_to_utc_timezone(
        self,
    ) -> Optional[List[Tuple[int, int]]]:
        org_now = self._utc_now.astimezone(pytz.timezone(self._reservation_timezone))

        timediff = int(
            (org_now.replace(tzinfo=pytz.utc) - self._utc_now).total_seconds()
        )
        shifted_open_hours = [
            (start - timediff, end - timediff) for start, end in self._org_open_hours
        ]

        corrected_open_hours = []

        # shift cyclically so that there are no negative intervals
        for start, end in shifted_open_hours:
            if start >= 0 and end >= 0:
                corrected_open_hours.append((start, end))
            elif start < 0 and end >= 0:
                corrected_open_hours.append((0, end))
                corrected_open_hours.append((self._WEEK_END + start, self._WEEK_END))
            else:
                corrected_open_hours.append(
                    (self._WEEK_END + start, self._WEEK_END + end)
                )

        return sorted(corrected_open_hours, key=itemgetter(0))

    def _calculate_seconds_from_weekstart(self, moment: datetime) -> int:
        weekstart = moment.replace(
            hour=0, minute=0, second=0, microsecond=0
        ) - timedelta(days=moment.weekday())

        return int((moment - weekstart).total_seconds())

    def _find_nearest_time_to_call_in_sec_from_week_start(self) -> int:
        ntc_seconds = None

        now_in_sec = self._calculate_seconds_from_weekstart(self._utc_now)
        # if current moment gt end of call center schedule - begin from week start
        if now_in_sec >= self._CALL_CENTER_OPEN_HOURS[-1][1]:
            now_in_sec = 0

        if self._org_open_hours is not None:
            ntc_seconds = self._find_nearest_intersection_in_schedules(
                now_in_sec=now_in_sec
            )

        # if intersection was not found or org has no open hours
        # find closest call center working time
        if ntc_seconds is None:
            for cc_start, cc_end in self._CALL_CENTER_OPEN_HOURS:
                if cc_end > now_in_sec:
                    ntc_seconds = max(cc_start, now_in_sec)
                    break

        return ntc_seconds

    def _find_nearest_intersection_in_schedules(self, now_in_sec: int) -> Optional[int]:
        # if current moment gt end of org schedule - begin from week start
        if now_in_sec >= self._org_open_hours[-1][1]:
            now_in_sec = 0

        for org_start, org_end in self._org_open_hours:
            for cc_start, cc_end in self._CALL_CENTER_OPEN_HOURS:
                # need some time to call to org
                # so should call CALLING_RESERVE time before org closes
                if (
                    cc_end > now_in_sec
                    and org_end - self._CALLING_RESERVE > now_in_sec
                    and cc_start < org_end - self._CALLING_RESERVE
                    and org_start < cc_end
                ):
                    return max(org_start, cc_start, now_in_sec)

    def _convert_seconds_from_week_start_to_closest_datetime(
        self, ntc_in_sec: int
    ) -> datetime:
        target_day = self._utc_now + timedelta(
            days=((ntc_in_sec // self._SECONDS_IN_DAY - self._utc_now.weekday()) % 7)
        )

        target_time_sec = ntc_in_sec % self._SECONDS_IN_DAY
        target_hour = target_time_sec // (60 * 60)
        target_minutes = (target_time_sec // 60) % 60

        target_datetime = target_day.replace(hour=target_hour, minute=target_minutes)

        if target_datetime < self._utc_now:
            target_datetime = target_datetime + timedelta(days=7)

        return target_datetime
