# KEKS -- Python KEKS implementation
# Copyright (C) 2024-2025 Anton Rudenko <rudenko.ad@phystech.edu>
#               2024-2025 Sergey Matveev <stargrave@stargrave.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program.  If not, see <http://www.gnu.org/licenses/>.

from datetime import datetime
from unittest import TestCase

from hypothesis import given
from hypothesis.strategies import datetimes

from keks import _byte
from keks import DecodeError
from keks import dumps
from keks import Leapsecs
from keks import loads
from keks import NotEnoughData
from keks import Raw
from keks import TAI64Base
from tests.strategies import junk_st


TagTAI64 = 0x18
TagTAI64N = 0x19
TagTAI64NA = 0x1A

# This is output of: curl http://cr.yp.to/libtai/leapsecs.dat | xxd -c 8 -p
DJB_Leapsecs = (
    "4000000004B2580A",
    "4000000005A4EC0B",
    "4000000007861F8C",
    "400000000967530D",
    "400000000B48868E",
    "400000000D2B0B8F",
    "400000000F0C3F10",
    "4000000010ED7291",
    "4000000012CEA612",
    "40000000159FCA93",
    "400000001780FE14",
    "4000000019623195",
    "400000001D25EA16",
    "4000000021DAE517",
    "40000000259E9D98",
    "40000000277FD119",
    "400000002A50F59A",
    "400000002C32291B",
    "400000002E135C9C",
    "4000000030E7241D",
    "4000000033B8489E",
    "40000000368C101F",
    "4000000043B71BA0",
    "40000000495C07A1",
    "400000004FEF9322",
    "4000000055932DA3",
    "40000000586846A4",
)


class TestTAI64(TestCase):
    @given(datetimes(), junk_st)
    def test_symmetric(self, dt: datetime, junk: bytes) -> None:
        dt = dt.replace(microsecond=0)
        encoded = dumps(dt)
        decoded, tail = loads(encoded + junk)
        self.assertEqual(dt, decoded)
        self.assertSequenceEqual(tail, junk)

    def test_throws_when_not_enough_data(self) -> None:
        with self.assertRaises(NotEnoughData) as err:
            loads(b"\x18" + b"\x00" * (8-1))
        self.assertEqual(err.exception.n, 1)

    def test_large_number_of_secs(self) -> None:
        decoded, tail = loads(bytes.fromhex("187000000065195F65"))
        self.assertEqual(decoded, Raw(
            _byte(0x18) + bytes.fromhex("7000000065195F65")),
        )
        self.assertSequenceEqual(tail, b"")

    def test_throws_when_msb_is_set(self) -> None:
        with self.assertRaises(DecodeError) as err:
            loads(bytes.fromhex("188000000065195F65"))
        self.assertEqual(str(err.exception), "reserved TAI64 value is in use")

    def test_leapsecUTCAllow_is_passed_through_containers(self) -> None:
        leaped = _byte(TagTAI64) + (Leapsecs[8] + TAI64Base).to_bytes(8, "big")
        raw, _ = loads(leaped)
        self.assertIsInstance(raw, Raw)
        decoded, _ = loads(leaped, leapsecUTCAllow=True)
        self.assertIsInstance(decoded, datetime)
        decoded, _ = loads(dumps([raw]), leapsecUTCAllow=True)
        self.assertIsInstance(decoded[0], datetime)
        decoded, _ = loads(dumps({"try that": [raw]}), leapsecUTCAllow=True)
        self.assertIsInstance(decoded["try that"][0], datetime)


class TestTAI64N(TestCase):
    @given(datetimes(), junk_st)
    def test_symmetric_tai64n(self, dt: datetime, junk: bytes) -> None:
        encoded = dumps(dt)
        decoded, tail = loads(encoded + junk)
        self.assertEqual(dt, decoded)
        self.assertSequenceEqual(tail, junk)

    def test_throws_when_not_enough_data(self) -> None:
        with self.assertRaises(NotEnoughData) as err:
            loads(b"\x19" + b"\x00" * (12-2))
        self.assertEqual(err.exception.n, 2)

    def test_nanoseconds_not_convertible_to_microseconds(self) -> None:
        decoded, tail = loads(bytes.fromhex("194000000065195F65075BCA01"))
        self.assertEqual(
            decoded,
            Raw(_byte(0x19) + bytes.fromhex("4000000065195F65075BCA01")),
        )
        self.assertSequenceEqual(tail, b"")

    def test_throws_when_too_many_nanosecs(self) -> None:
        with self.assertRaises(DecodeError) as err:
            loads(
                bytes.fromhex("194000000065195F65") +
                (999999999 + 1).to_bytes(4, "big")
            )
        self.assertEqual(str(err.exception), "too many nanoseconds")

    def test_zero_nanoseconds(self) -> None:
        with self.assertRaises(DecodeError) as err:
            loads(
                bytes.fromhex("194000000000000000") +
                bytes.fromhex("00000000")
            )
        self.assertEqual(str(err.exception), "non-minimal TAI64N")

    def test_large_number_of_secs(self) -> None:
        decoded, tail = loads(
            bytes.fromhex("197000000065195F65") +
            bytes.fromhex("00010000")
        )
        self.assertEqual(decoded, Raw(
            _byte(0x19) +
            bytes.fromhex("7000000065195F65") +
            bytes.fromhex("00010000"),
        ))
        self.assertSequenceEqual(tail, b"")

    def test_throws_when_msb_is_set(self) -> None:
        with self.assertRaises(DecodeError) as err:
            loads(
                bytes.fromhex("198000000065195F65") +
                bytes.fromhex("00010000")
            )
        self.assertEqual(str(err.exception), "reserved TAI64 value is in use")


class TestTAI64NA(TestCase):
    @given(junk_st)
    def test_decode(self, junk: bytes) -> None:
        encoded = bytes.fromhex("1A4000000065195F65075BCA00075BCA00") + junk
        expected = Raw(_byte(0x1A) + bytes.fromhex("4000000065195F65075BCA00075BCA00"))
        decoded, tail = loads(encoded)
        self.assertEqual(decoded, expected)
        self.assertSequenceEqual(tail, junk)

    def test_throws_when_not_enough_data(self) -> None:
        with self.assertRaises(NotEnoughData) as err:
            loads(b"\x1a" + b"\x00" * (16-3))
        self.assertEqual(err.exception.n, 3)

    def test_throws_when_too_many_attosecs(self) -> None:
        with self.assertRaises(DecodeError) as err:
            loads(bytes.fromhex("1A4000000065195F65075BCA00A75BCA00"))
        self.assertEqual(str(err.exception), "too many attoseconds")

    def test_zero_attoseconds(self) -> None:
        with self.assertRaises(DecodeError) as err:
            loads(
                bytes.fromhex("1A4000000000000000") +
                bytes.fromhex("00000000") +
                bytes.fromhex("00000000")
            )
        self.assertEqual(str(err.exception), "non-minimal TAI64NA")

    def test_zero_nanoseconds_ok_for_tai64na(self) -> None:
        loads(
            bytes.fromhex("1A4000000000000000") +
            bytes.fromhex("00000000") +
            bytes.fromhex("00000001")
        )

    def test_large_number_of_secs(self) -> None:
        decoded, tail = loads(
            bytes.fromhex("1A7000000065195F65") +
            bytes.fromhex("00010000") +
            bytes.fromhex("00010000")
        )
        self.assertEqual(decoded, Raw(
            _byte(0x1A) +
            bytes.fromhex("7000000065195F65") +
            2 * bytes.fromhex("00010000"),
        ))
        self.assertSequenceEqual(tail, b"")

    def test_throws_when_msb_is_set(self) -> None:
        with self.assertRaises(DecodeError) as err:
            loads(
                bytes.fromhex("1A8000000065195F65") +
                bytes.fromhex("00010000") +
                bytes.fromhex("00010000")
            )
        self.assertEqual(str(err.exception), "reserved TAI64 value is in use")


class TestVectors(TestCase):
    def test_19700101(self) -> None:
        decoded, _ = loads(bytes.fromhex("18400000000000000A"))
        self.assertEqual(decoded, datetime(1970, 1, 1, 0, 0))

    def test_19920602(self) -> None:
        decoded, _ = loads(bytes.fromhex("18400000002A2B2C2D"))
        self.assertEqual(decoded, datetime(1992, 6, 2, 8, 6, 43))

    def test_19971003(self) -> None:
        decoded, _ = loads(bytes.fromhex("184000000034353637"))
        self.assertEqual(decoded, datetime(1997, 10, 3, 18, 14, 48))

    def test_20161231(self) -> None:
        decoded, _ = loads(bytes.fromhex("1840000000586846A3"))
        self.assertEqual(decoded, datetime(2016, 12, 31, 23, 59, 59))

    def test_20170101(self) -> None:
        decoded, _ = loads(bytes.fromhex("1840000000586846A5"))
        self.assertEqual(decoded, datetime(2017, 1, 1, 0, 0))

    def test_20241120(self) -> None:
        decoded, _ = loads(bytes.fromhex("1940000000673DD3E136F11FE0"))
        self.assertEqual(decoded, datetime(2024, 11, 20, 12, 19, 8, 921772))


class TestLeapsecs(TestCase):
    def test_match_DJB(self) -> None:
        """Check that our keks.Leapsecs is equally calculated to DJB's one
        """
        for our, their in zip(Leapsecs, DJB_Leapsecs):
            self.assertEqual(
                our,
                int.from_bytes(bytes.fromhex(their), "big") - TAI64Base,
            )

    def test_decode(self) -> None:
        for leapsec in DJB_Leapsecs:
            decoded, tail = loads(
                v=b"\x18" + bytes.fromhex(leapsec),
                leapsecUTCAllow=True,
            )
            self.assertIsInstance(decoded, datetime)
            decoded, tail = loads(
                v=b"\x18" + bytes.fromhex(leapsec),
                leapsecUTCAllow=False,
            )
            self.assertIsInstance(decoded, Raw)
            self.assertSequenceEqual(decoded.v[1:], bytes.fromhex(leapsec))
