#!/usr/bin/python3

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

"""Test timezones using pytz module."""

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

import pytz


def _is_tzif_file(fpath):
    with open(fpath, "rb") as tz_file:
        return tz_file.read(4) == b"TZif"


@functools.lru_cache(maxsize=1)
def available_timezones():
    """Return a set of available timezones.

    Search in PYTZ_TZDATADIR if set or in the system location.
    """
    available = set()
    tz_root = os.environ.get("PYTZ_TZDATADIR", "/usr/share/zoneinfo")
    for root, dirnames, files in os.walk(tz_root):
        if root == tz_root:
            # right/ and posix/ are special directories and shouldn't be
            # included in the output of available zones
            if "right" in dirnames:
                dirnames.remove("right")
            if "posix" in dirnames:
                dirnames.remove("posix")

        for file in files:
            fpath = os.path.join(root, file)
            key = os.path.relpath(fpath, start=tz_root)
            if _is_tzif_file(fpath):
                available.add(key)

    return available

ROOT_DIR = pathlib.Path(__file__).parent.parent.parent


def read_backwards_links(backwards_file: pathlib.Path):
    """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


def read_link(link: pathlib.Path) -> pathlib.Path:
    """Return the absolute path to which the symbolic link points."""
    destination = link.parent / os.readlink(link)
    return pathlib.Path(os.path.normpath(destination))


class TestZoneinfo(unittest.TestCase):
    """Test timezones using pytz 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(available_timezones())
        self.assertGreaterEqual(zones, 597, "less zones than 2022g-2")
        self.assertLess(zones, round(597 * 1.1), ">10% more zones than 2022g-2")

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

    def _assert_equal_zones(self, timezone1, timezone2) -> 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)

    def test_systemv_timezones(self) -> None:
        """Test that the old SystemV timezones are still available."""
        systemv = [t for t in available_timezones() if t.startswith("SystemV/")]
        self.assertIn("SystemV/MST7", systemv)
        self.assertEqual(len(systemv), 13, f"SystemV timezones: {systemv}")

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

        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 = pytz.timezone("Europe/Berlin")
        date = datetime.datetime(1960, 7, 1)
        self.assertEqual(self._hours(berlin.localize(date).utcoffset()), 1)
        oslo = pytz.timezone("Europe/Oslo")
        self.assertEqual(self._hours(oslo.localize(date).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}"):
                tz_link = pytz.timezone(link_name)
                tz_target = pytz.timezone(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 assert_not_symlink_to_symlink(self, timezone_path: pathlib.Path) -> None:
        """Assert that the timezone is not a symlink to another symlink."""
        if not timezone_path.is_symlink():
            return
        destination = read_link(timezone_path)
        if not destination.is_symlink():
            return
        self.fail(
            f"Symlink to symlink found: {timezone_path} -> {destination}"
            f" -> {read_link(destination)}"
        )

    def test_no_symlinks_to_symlinks(self) -> None:
        """Check that no timezone is a symlink to another symlink."""
        tzpath = os.environ.get("PYTZ_TZDATADIR", "/usr/share/zoneinfo")
        for timezone in sorted(available_timezones()):
            if timezone == "localtime":
                continue
            with self.subTest(timezone):
                timezone_path = pathlib.Path(tzpath) / timezone
                self.assert_not_symlink_to_symlink(timezone_path)

    @unittest.skip("Needs https://launchpad.net/bugs/207604")
    def test_timezones(self) -> None:
        """Test all zones to load, have a name, and have a reasonable offset."""
        for zone in available_timezones():
            with self.subTest(zone=zone):
                self._test_timezone(zone)

    @unittest.skip("Needs https://launchpad.net/bugs/207604")
    def test_2022g(self) -> None:
        """Test new zone America/Ciudad_Juarez from 2022g release."""
        timezone = pytz.timezone("America/Ciudad_Juarez")
        date = timezone.localize(datetime.datetime(2022, 12, 1))
        self.assertEqual(self._hours(date.utcoffset()), -7)

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

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

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

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

    def test_2024b(self) -> None:
        """Test 2024b release: Azores did not observe DST from 1977 to 1981."""
        timezone = pytz.timezone("Atlantic/Azores")
        date = timezone.localize(datetime.datetime(1980, 7, 3))
        self.assertEqual(self._hours(date.utcoffset()), -1)

    def test_2025a(self) -> None:
        """Test 2025a release: Paraguary stopped using DST from 2024-10-15 on."""
        timezone = pytz.timezone("America/Asuncion")
        date = timezone.localize(datetime.datetime(2025, 3, 23))
        self.assertEqual(self._hours(date.utcoffset()), -3)

    @unittest.skip("Needs https://launchpad.net/bugs/207604")
    def test_2025b(self) -> None:
        """Test 2025b release: Coyhaique stopped using DST from 2025-03-20 on."""
        timezone = pytz.timezone("America/Coyhaique")
        date = timezone.localize(datetime.datetime(2025, 3, 21))
        self.assertEqual(self._hours(date.utcoffset()), 0)

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()
