#!/usr/bin/python3

# Author: Benjamin Drung <bdrung@ubuntu.com>

"""Test timezones using Python's zoneinfo module."""

import datetime
import os
import pathlib
import re
import sys
import typing
import unittest
import zoneinfo

ROOT_DIR = pathlib.Path(__file__).parent.parent.parent
HAS_LEGACY_TIMEZONES = "NO_LEGACY_TIMEZONES" not in os.environ


def read_backwards_links(backwards_file: pathlib.Path) -> dict[str, str]:
    """Read backwards compatibility links from the upstream backwards file."""
    backwards_links = {}
    for line in backwards_file.read_text(encoding="utf-8").splitlines():
        match = re.match(r"^Link\t(?P<target>\S+)\t+(?P<link_name>\S+)", line)
        if not match:
            continue
        backwards_links[match.group("link_name")] = match.group("target")
    return backwards_links


class TestZoneinfo(unittest.TestCase):
    """Test timezones using Python's zoneinfo module."""

    def _hours(self, delta: typing.Optional[datetime.timedelta]) -> int:
        assert delta is not None
        total_seconds = int(delta.total_seconds())
        self.assertEqual(total_seconds % 3600, 0)
        return total_seconds // 3600

    def test_available_timezones_count(self) -> None:
        """Test available_timezones() count to be reasonable."""
        zones = len(zoneinfo.available_timezones())
        expected_zones = 485
        if HAS_LEGACY_TIMEZONES:
            expected_zones = 597
        self.assertGreaterEqual(zones, expected_zones, "less zones than 2023c-8")
        self.assertLess(
            zones, round(expected_zones * 1.1), ">10% more zones than 2023c-8"
        )

    def test_daylight_saving_transition(self) -> None:
        """Test daylight saving time transition from Python documentation."""
        tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles")
        date = datetime.datetime(2020, 10, 31, 12, tzinfo=tzinfo)
        self.assertEqual(date.tzname(), "PDT")
        next_day = date + datetime.timedelta(days=1)
        self.assertEqual(next_day.tzname(), "PST")

    def _assert_equal_zones_at_date(
        self,
        date: datetime.datetime,
        timezone1: zoneinfo.ZoneInfo,
        timezone2: zoneinfo.ZoneInfo,
    ) -> None:
        date1 = date.replace(tzinfo=timezone1)
        date2 = date.replace(tzinfo=timezone2)
        self.assertEqual(date1 - date2, datetime.timedelta(seconds=0))
        self.assertEqual(date1.tzname(), date2.tzname())

    def _assert_equal_zones(
        self, timezone1: zoneinfo.ZoneInfo, timezone2: zoneinfo.ZoneInfo
    ) -> None:
        """Test timezones to be heuristically equal regardless of the name."""
        october_2020 = datetime.datetime(2020, 10, 31, 12)
        self._assert_equal_zones_at_date(october_2020, timezone1, timezone2)
        july_2021 = datetime.datetime(2021, 7, 3, 12)
        self._assert_equal_zones_at_date(july_2021, timezone1, timezone2)

    @unittest.skipIf(os.environ.get("PYTHONTZPATH"), "requires installed tzdata")
    def test_localtime(self) -> None:
        """Test 'localtime' timezone."""
        localtime = pathlib.Path("/etc/localtime")
        zone = str(localtime.resolve().relative_to("/usr/share/zoneinfo"))
        tzinfo = zoneinfo.ZoneInfo("localtime")
        self._assert_equal_zones(tzinfo, zoneinfo.ZoneInfo(zone))

    def _test_timezone(self, zone: str) -> None:
        """Test zone to load, have a name, and have a reasonable offset."""
        tzinfo = zoneinfo.ZoneInfo(zone)
        self.assertEqual(str(tzinfo), zone)
        date = datetime.datetime(2020, 10, 31, 12, tzinfo=tzinfo)

        tzname = date.tzname()
        assert tzname is not None
        self.assertGreaterEqual(len(tzname), 3, tzname)
        self.assertLessEqual(len(tzname), 5, tzname)

        utc_offset = date.utcoffset()
        assert utc_offset is not None
        self.assertEqual(int(utc_offset.total_seconds()) % 900, 0)
        self.assertLessEqual(utc_offset, datetime.timedelta(hours=14))

    def test_pre_1970_timestamps(self) -> None:
        """Test pre-1970 timestamps of Berlin and Oslo being different."""
        berlin = zoneinfo.ZoneInfo("Europe/Berlin")
        date = datetime.datetime(1960, 7, 1, tzinfo=berlin)
        self.assertEqual(self._hours(date.utcoffset()), 1)
        oslo = zoneinfo.ZoneInfo("Europe/Oslo")
        self.assertEqual(self._hours(date.replace(tzinfo=oslo).utcoffset()), 2)

    def test_post_1970_symlinks_consistency(self) -> None:
        """Test that post-1970 symlinks are consistent with pre-1970 timezones.

        Building tzdata with PACKRATDATA=backzone will result in separate
        time zones for time zones that differ only before 1970. These time
        zones should behave identical after 1970. Building tzdata without
        PACKRATDATA=backzone will result in one of the time zones become a
        symlink to the other time zone.
        """
        links = read_backwards_links(ROOT_DIR / "backward")
        for link_name, target in links.items():
            with self.subTest(f"{link_name} -> {target}"):
                try:
                    tz_link = zoneinfo.ZoneInfo(link_name)
                except zoneinfo.ZoneInfoNotFoundError:
                    if not HAS_LEGACY_TIMEZONES:
                        continue
                    raise
                tz_target = zoneinfo.ZoneInfo(target)
                now = datetime.datetime.now()
                self._assert_equal_zones_at_date(now, tz_link, tz_target)
                future = now + datetime.timedelta(days=30 * 6)
                self._assert_equal_zones_at_date(future, tz_link, tz_target)

    def test_timezones(self) -> None:
        """Test all zones to load, have a name, and have a reasonable offset."""
        for zone in zoneinfo.available_timezones():
            with self.subTest(zone=zone):
                self._test_timezone(zone)

    def test_2022g(self) -> None:
        """Test new zone America/Ciudad_Juarez from 2022g release."""
        tzinfo = zoneinfo.ZoneInfo("America/Ciudad_Juarez")
        date = datetime.datetime(2022, 12, 1, tzinfo=tzinfo)
        self.assertEqual(self._hours(date.utcoffset()), -7)

    def test_2023a(self) -> None:
        """Test Egypt uses DST again from 2023a release."""
        tzinfo = zoneinfo.ZoneInfo("Africa/Cairo")
        date = datetime.datetime(2023, 4, 28, 12, 0, tzinfo=tzinfo)
        self.assertEqual(self._hours(date.utcoffset()), 3)

    def test_2023c(self) -> None:
        """Test Lebanon's reverted DST delay from 2023c release."""
        tzinfo = zoneinfo.ZoneInfo("Asia/Beirut")
        date = datetime.datetime(2023, 4, 2, tzinfo=tzinfo)
        self.assertEqual(self._hours(date.utcoffset()), 3)

    def test_2023d(self) -> None:
        """Test 2023d release: Vostok being on +05 from 2023-12-18 on."""
        tzinfo = zoneinfo.ZoneInfo("Antarctica/Vostok")
        date = datetime.datetime(2023, 12, 19, tzinfo=tzinfo)
        self.assertEqual(self._hours(date.utcoffset()), 5)

    def test_2024a(self) -> None:
        """Test 2024a release: Kazakhstan being on +05 from 2024-03-01 on."""
        tzinfo = zoneinfo.ZoneInfo("Asia/Almaty")
        date = datetime.datetime(2024, 3, 2, tzinfo=tzinfo)
        self.assertEqual(self._hours(date.utcoffset()), 5)

def main() -> None:
    """Run unit tests in verbose mode."""
    argv = sys.argv.copy()
    argv.insert(1, "-v")
    unittest.main(argv=argv)


if __name__ == "__main__":
    main()
