#!/usr/bin/env python
import re
from datetime import datetime, timedelta, timezone
from unittest import TestCase

import icalendar
import pytest
import vobject

from caldav.lib import vcal
from caldav.lib.python_utilities import to_normal_str, to_wire
from caldav.lib.vcal import create_ical

utc = timezone.utc

# example from https://datatracker.ietf.org/doc/html/rfc5545
ev = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:19970901T130000Z-123403@example.com
DTSTAMP:19970901T130000Z
DTSTART;VALUE=DATE:19971102
SUMMARY:Our Blissful Anniversary
TRANSP:TRANSPARENT
CLASS:CONFIDENTIAL
CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR"""


class TestVcal(TestCase):
    def assertSameICal(self, ical1, ical2, ignore_uid=False):
        """helper method"""

        def normalize(s, ignore_uid):
            ## TODO: This may break in case of line wrappings
            ## We're explicitly not using any libraries for this, as it may skew the test
            s = to_wire(s).replace(b"\r\n", b"\n").strip().split(b"\n")
            s.sort()
            if ignore_uid:
                s = [x for x in s if not x.startswith(b"UID:")]
            ## ref https://github.com/python-caldav/caldav/issues/380
            for i in range(0, len(s)):
                ## We cut the value itself, just asserting the attribute is present
                if s[i].startswith(b"DTSTAMP"):
                    s[i] = s[i][:9]
            return b"\n".join(s)

        self.assertEqual(normalize(ical1, ignore_uid), normalize(ical2, ignore_uid))
        return ical2

    def verifyICal(self, ical, allow_reordering=False):
        """
        Does a best effort on verifying that the ical is correct, by
        pushing it through the vobject and icalendar library
        """
        vobj = vobject.readOne(to_normal_str(ical))
        icalobj = icalendar.Calendar.from_ical(ical)
        self.assertSameICal(icalobj.to_ical(), ical)
        self.assertSameICal(vobj.serialize(), ical)
        return icalobj.to_ical()

    ## TODO: create a test_fix, should be fairly simple - for each
    ## "fix" that's done in the code, make up some broken ical data
    ## that demonstrates the brokenness we're dealing with (preferably
    ## real-world examples). Then ...
    # for bical in broken_ical:
    #    verifyICal(vcal.fix(bical))

    def test_create_ical(self):
        def create_and_validate(**args):
            return self.verifyICal(create_ical(**args))

        ## First, a fully valid ical_fragment should go through as is
        self.assertSameICal(create_and_validate(ical_fragment=ev), ev)

        ## One may add stuff to a fully valid ical_fragment
        self.assertSameICal(
            create_and_validate(ical_fragment=ev, priority=3), ev + "\nPRIORITY:3\n"
        )

        ## binary string or unicode string ... shouldn't matter
        self.assertSameICal(
            create_and_validate(ical_fragment=ev.encode("utf-8"), priority=3),
            ev + "\nPRIORITY:3\n",
        )

        ## The returned ical_fragment should always contain BEGIN:VCALENDAR and END:VCALENDAR
        ical_fragment = ev.replace("BEGIN:VCALENDAR", "").replace("END:VCALENDAR", "")
        self.assertSameICal(create_and_validate(ical_fragment=ical_fragment), ev)

        ## Create something with a dtstart and verify that we get it back in the ical
        some_ical0 = create_and_validate(
            summary="gobledok",
            dtstart=datetime(2032, 10, 10, 10, 10, 10, tzinfo=utc),
            duration=timedelta(hours=5),
        )
        some_ical1 = create_and_validate(
            summary=b"gobledok",
            dtstart=datetime(2032, 10, 10, 10, 10, 10, tzinfo=utc),
            duration=timedelta(hours=5),
        )
        assert re.search(b"DTSTART(;VALUE=DATE-TIME)?:20321010T101010Z", some_ical0)
        self.assertSameICal(some_ical0, some_ical1, ignore_uid=True)

        ## Verify that ical_fragment works as intended
        some_ical = create_and_validate(
            summary="gobledok",
            ical_fragment="PRIORITY:3",
            dtstart=datetime(2032, 10, 10, 10, 10, 10, tzinfo=utc),
            duration=timedelta(hours=5),
        )
        assert re.search(b"DTSTART(;VALUE=DATE-TIME)?:20321010T101010Z", some_ical)
        assert some_ical.count(b"PRIORITY") == 1

        some_ical = create_and_validate(
            summary="gobledok",
            ical_fragment=b"PRIORITY:3",
            dtstart=datetime(2032, 10, 10, 10, 10, 10, tzinfo=utc),
            duration=timedelta(hours=5),
        )
        assert re.search(b"DTSTART(;VALUE=DATE-TIME)?:20321010T101010Z", some_ical)

        some_ical = create_and_validate(
            summary=b"gobledok",
            ical_fragment="",
            dtstart=datetime(2032, 10, 10, 10, 10, 10, tzinfo=utc),
            duration=timedelta(hours=5),
        )
        assert re.search(b"DTSTART(;VALUE=DATE-TIME)?:20321010T101010Z", some_ical)

    def test_vcal_fixups(self):
        """
        There is an obscure function lib.vcal that attempts to fix up
        known ical standard breaches from various calendar servers.
        """
        non_broken_ical = [
            """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//python-caldav//caldav//en_DK
BEGIN:VEVENT
SUMMARY:Foo bar blah
 blub
DTSTART:20230313T084500Z
DURATION:PT30M
DTSTAMP:20230312T215907Z
UID:1c9bba3e-c121-11ed-bf96-982cbcdd642c
CATEGORIES:oslo
END:VEVENT
END:VCALENDAR
""",
        ]
        broken_ical = [
            ## This first one contains duplicated DTSTAMP in the event data
            """BEGIN:VCALENDAR
X-EXPANDED:True
X-MASTER-DTSTART:20200517T060000Z
X-MASTER-RRULE:FREQ=YEARLY
BEGIN:VEVENT
DTSTAMP:20210205T101751Z
UID:20200516T060000Z-123401@example.com
DTSTAMP:20200516T060000Z
SUMMARY:Do the needful
DTSTART:20210517T060000Z
DTEND:20210517T230000Z
RECURRENCE-ID:20210517T060000Z
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20210205T101751Z
UID:20200516T060000Z-123401@example.com
DTSTAMP:20200516T060000Z
SUMMARY:Do the needful
DTSTART:20220517T060000Z
DTEND:20220517T230000Z
RECURRENCE-ID:20220517T060000Z
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20210205T101751Z
UID:20200516T060000Z-123401@example.com
DTSTAMP:20200516T060000Z
SUMMARY:Do the needful
DTSTART:20230517T060000Z
DTEND:20230517T230000Z
RECURRENCE-ID:20230517T060000Z
END:VEVENT
END:VCALENDAR""",  ## Next one contains DTEND and DURATION.
            """BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTAMP:20210205T101751Z
UID:20200516T060000Z-123401@example.com
SUMMARY:Do the needful
DTSTART:20210517T060000Z
DURATION:PT15M
DTEND:20210517T230000Z
END:VEVENT
END:VCALENDAR""",  ## Same, but real example:
            """BEGIN:VCALENDAR
PRODID:Zimbra-Calendar-Provider
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Brussels
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETTO:+0100
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTH=10;BYDAY=-1SU
TZNAME:CET
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETTO:+0200
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTH=3;BYDAY=-1SU
TZNAME:CEST
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:e42481ad-aabf-43c1-bbc0-04754373678d
RRULE:FREQ=WEEKLY;UNTIL=20221222T225959Z;BYDAY=WE
SUMMARY:Competence Lunch
DESCRIPTION: *removed*
ATTENDEE;CN=Tobias Brox;PARTSTAT=TENTATIVE:mailto:tobias@redpill-linpro.com
PRIORITY:9
ORGANIZER;CN=Someone:mailto:noreply@redpill-linpro.com
DTSTART;TZID="Europe/Brussels":20220817T110000
DTEND;TZID="Europe/Brussels":20220817T113000
DURATION:PT30M
STATUS:CONFIRMED
CLASS:PUBLIC
TRANSP:OPAQUE
LAST-MODIFIED:20220916T120601Z
DTSTAMP:20220906T125002Z
SEQUENCE:1
EXDATE;TZID="Europe/Brussels":20221005T110000
EXDATE;TZID="Europe/Brussels":20221116T110000
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER;RELATED=START:-PT15M
DESCRIPTION:Reminder
END:VALARM
END:VEVENT
END:VCALENDAR""",  ## next has a completed date.  It should be a timestamp according to the RFC
            """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:20070313T123432Z-456553@example.com
DTSTAMP:20070313T123432Z
COMPLETED;VALUE=DATE:20070501
SUMMARY:Submit Quebec Income Tax Return for 2006
CLASS:CONFIDENTIAL
CATEGORIES:FAMILY,FINANCE
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR""",  ## Again, but implicit instead of explicit date
            """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:20070313T123432Z-456553@example.com
DTSTAMP:20070313T123432Z
COMPLETED:20070501
SUMMARY:Submit Quebec Income Tax Return for 2006
CLASS:CONFIDENTIAL
CATEGORIES:FAMILY,FINANCE
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR""",
        ]  ## todo: add more broken ical here

        for ical in broken_ical:
            ## This should raise error
            # with pytest.raises(vobject.base.ValidateError):
            with pytest.raises(Exception):
                vobject.readOne(ical).serialize()
            ## This should not raise error
            vobject.readOne(vcal.fix(ical)).serialize()

        for ical in non_broken_ical:
            assert vcal.fix(ical) == ical

    def test_missing_dtstamp_fix(self) -> None:
        """
        Test that missing DTSTAMP is added by the fix function.
        DTSTAMP is mandatory according to RFC5545 but doesn't cause
        vobject to fail, so it needs its own test (issue #504).
        """
        # Event without DTSTAMP
        double_event_without_dtstamp = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FOSDEM//Example//EN
BEGIN:VEVENT
UID:missing-dtstamp-test@example.com
DTSTART:20250101T100000Z
DTEND:20250101T110000Z
SUMMARY:Event without DTSTAMP
RRULE:FREQ=YEARLY
END:VEVENT
BEGIN:VEVENT
UID:missing-dtstamp-test@example.com
DTSTART:20260101T100000Z
DTEND:20260101T110000Z
SUMMARY:Event without DateTimeSTAMP 2026
RECURRENCE-ID:20260101T100000Z
END:VEVENT
END:VCALENDAR"""

        # Todo without DTSTAMP
        todo_without_dtstamp = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FOSDEM//Example//EN
BEGIN:VTODO
UID:missing-dtstamp-todo@example.com
SUMMARY:Todo without DTSTAMP
DUE:20250101T120000Z
END:VTODO
END:VCALENDAR"""

        # Journal without DTSTAMP
        journal_without_dtstamp = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FOSDEM//Example//EN
BEGIN:VJOURNAL
UID:missing-dtstamp-journal@example.com
SUMMARY:Journal without DTSTAMP
DTSTART:20250101T100000Z
END:VJOURNAL
END:VCALENDAR"""

        # Test each component type
        for ical in [
            double_event_without_dtstamp,
            todo_without_dtstamp,
            journal_without_dtstamp,
        ]:
            # Verify the original doesn't have DTSTAMP
            assert "DTSTAMP:" not in ical

            # Apply the fix
            fixed = vcal.fix(ical)

            # Verify DTSTAMP was added
            assert "DTSTAMP:" in fixed, f"DTSTAMP should be added to:\n{ical}"

            # Verify it matches the expected format (YYYYMMDDTHHMMSSZ)
            dtstamp_match = re.search(r"DTSTAMP:(\d{8}T\d{6}Z)", fixed)
            assert dtstamp_match is not None, f"DTSTAMP should be in correct format in:\n{fixed}"

            if ical.count("BEGIN:VEVENT") == 2:
                assert fixed.count("DTSTAMP:") == 2
            else:
                assert fixed.count("DTSTAMP:") == 1

            # Verify the fixed ical is valid
            self.verifyICal(fixed)
