"""
Calendar Objects Resources, as defined in the RFC 4791.

There are three subclasses Todo, Journal and Event.  Those mirrors objects stored on the server.  The word ``CalendarObjectResource`` is long, complicated and may be hard to understand.  When you read the word "event" in any documentation, issue discussions, etc, then most likely it should be read as "a CalendarObjectResource, like an event, a task or a journal".  Do not make the mistake of going directly to the Event-class if you want to contribute code for handling "events" - consider that the same code probably will be appicable to Joural and Todo events, if so, CalendarObjectResource is the right class!  Clear as mud?

FreeBusy is also defined as a Calendar Object Resource in the RFC, and it is a bit different .  Perhaps there should be another class layer between CalendarObjectResource and Todo/Event/Journal to indicate that the three latter are closely related, while FreeBusy is something different.

Alarms and Time zone objects does not have any class as for now.  Those are typically subcomponents of an event/task/journal component.

Users of the library should not need to construct any of those objects.  To add new content to the calendar, use ``calendar.add_event``, ``calendar.add_todo`` or ``calendar.add_journal``.  Those methods will return a CalendarObjectResource.  To update an existing object, use ``event.save()``.
"""

import logging
import re
import sys
import uuid
import warnings
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, ClassVar, Optional
from urllib.parse import ParseResult, SplitResult

import icalendar
from dateutil.rrule import rrulestr
from icalendar import vCalAddress, vText

if TYPE_CHECKING:
    from icalendar import vCalAddress

    from .davclient import DAVClient

from collections.abc import Callable, Container
from typing import Literal

if sys.version_info < (3, 11):
    from typing_extensions import Self
else:
    from typing import Self

from contextlib import contextmanager

from .datastate import DataState, IcalendarState, NoDataState, RawDataState, VobjectState
from .davobject import DAVObject
from .elements import cdav, dav
from .lib import error, vcal
from .lib.error import errmsg
from .lib.python_utilities import to_normal_str, to_unicode, to_wire
from .lib.url import URL
from .operations.calendarobject_ops import _quote_uid

log = logging.getLogger("caldav")


class CalendarObjectResource(DAVObject):
    """Ref RFC 4791, section 4.1, a "Calendar Object Resource" can be an
    event, a todo-item, a journal entry, or a free/busy entry

    As per the RFC, a CalendarObjectResource can at most contain one
    calendar component, with the exception of recurrence components.
    Meaning that event.data typically contains one VCALENDAR with one
    VEVENT and possibly one VTIMEZONE.

    In the case of expanded calendar date searches, each recurrence
    will (by default) wrapped in a distinct CalendarObjectResource
    object.  This is a deviation from the definition given in the RFC.
    """

    ## There is also STARTTOFINISH, STARTTOSTART and FINISHTOFINISH in RFC9253,
    ## those do not seem to have any reverse
    ## (FINISHTOSTART and STARTTOFINISH may seem like reverse relations, but
    ## as I read the RFC, FINISHTOSTART seems like the reverse of DEPENDS-ON)
    ## (STARTTOSTART and FINISHTOFINISH may also seem like symmetric relations,
    ## meaning they are their own reverse, but as I read the RFC they are
    ## asymmetric)
    RELTYPE_REVERSE_MAP: ClassVar = {
        "PARENT": "CHILD",
        "CHILD": "PARENT",
        "SIBLING": "SIBLING",
        ## this is how Tobias Brox inteprets RFC9253:
        "DEPENDS-ON": "FINISHTOSTART",
        "FINISHTOSTART": "DEPENDENT",
        ## next/first is a special case, linked list
        ## it needs special handling when length of list<>2
        # "NEXT": "FIRST",
        # "FIRST": "NEXT",
    }

    _ENDPARAM = None

    _vobject_instance = None
    _icalendar_instance = None
    _data = None

    # New state management (issue #613)
    _state: DataState | None = None
    _borrowed: bool = False

    @property
    def id(self) -> str | None:
        """Returns the UID of the calendar object.

        Extracts the UID from the calendar data using cheap accessors
        that avoid unnecessary parsing (issue #515, #613).
        Falls back to direct icalendar parsing if the cheap accessor fails.
        Does not trigger a load from the server.
        """
        uid = self._get_uid_cheap()
        if uid is None and self._icalendar_instance:
            # Fallback: look in icalendar instance directly (without triggering load)
            for comp in self._icalendar_instance.subcomponents:
                if comp.name in ("VEVENT", "VTODO", "VJOURNAL") and "UID" in comp:
                    uid = str(comp["UID"])
                    break
        return uid

    @id.setter
    def id(self, value: str | None) -> None:
        """Setter exists for compatibility with parent class __init__.

        The actual UID is stored in the calendar data, not separately.
        Setting this is a no-op - modify the icalendar data directly.
        """
        pass

    def __init__(
        self,
        client: Optional["DAVClient"] = None,
        url: str | ParseResult | SplitResult | URL | None = None,
        data: Any | None = None,
        parent: Any | None = None,
        id: Any | None = None,
        props: Any | None = None,
    ) -> None:
        """
        CalendarObjectResource has an additional parameter for its constructor:
         * data = "...", vCal data for the event
        """
        super().__init__(client=client, url=url, parent=parent, id=id, props=props)
        if data is not None:
            self.data = data
            if id and self._get_component_type_cheap():
                old_id = self.icalendar_component.pop("UID", None)
                self.icalendar_component.add("UID", id)
                # Clear raw data and update state to use the modified icalendar instance
                self._data = None
                self._state = IcalendarState(self._icalendar_instance)

    def set_end(self, end, move_dtstart=False):
        """The RFC specifies that a VEVENT/VTODO cannot have both
        dtend/due and duration, so when setting dtend/due, the duration
        field must be evicted

        WARNING: this method is likely to be deprecated and parts of
        it moved to the icalendar library.  If you decide to use it,
        please put caldav<4.0 in the requirements.
        """
        i = self.icalendar_component
        ## TODO: are those lines useful for anything?
        if hasattr(end, "tzinfo") and not end.tzinfo:
            end = end.astimezone(timezone.utc)
        duration = self.get_duration()
        i.pop("DURATION", None)
        i.pop(self._ENDPARAM, None)

        if move_dtstart and duration and "DTSTART" in i:
            i.pop("DTSTART")
            i.add("DTSTART", end - duration)

        i.add(self._ENDPARAM, end)

    def add_organizer(self) -> None:
        """
        goes via self.client, finds the principal, figures out the right attendee-format and adds an
        organizer line to the event
        """
        if self.client is None:
            raise ValueError("Unexpected value None for self.client")

        principal = self.client.principal()
        ## TODO: remove Organizer-field, if exists
        ## TODO: what if walk returns more than one vevent?
        self.icalendar_component.add("organizer", principal.get_vcal_address())

    def split_expanded(self) -> list[Self]:
        """This was used internally for processing search results.
        Library users probably don't need to care about this one.

        The logic is now handled directly in the search method.

        This method is probably used by nobody and nothing, but
        it can't be removed easily as it's exposed as part of the
        public API
        """

        warnings.warn(
            "obj.split_expanded is likely to be removed in a future version of caldav.  Feel free to protest if you need it",
            DeprecationWarning,
            stacklevel=2,
        )

        i = self.icalendar_instance.subcomponents
        tz_ = [x for x in i if isinstance(x, icalendar.Timezone)]
        ntz = [x for x in i if not isinstance(x, icalendar.Timezone)]
        if len(ntz) == 1:
            return [self]
        if tz_:
            error.assert_(len(tz_) == 1)
        ret = []
        for ical_obj in ntz:
            obj = self.copy(keep_uid=True)
            obj.icalendar_instance.subcomponents = []
            if tz_:
                obj.icalendar_instance.subcomponents.append(tz_[0])
            obj.icalendar_instance.subcomponents.append(ical_obj)
            ret.append(obj)
        return ret

    def expand_rrule(self, start: datetime, end: datetime, include_completed: bool = True) -> None:
        """This method will transform the calendar content of the
        event and expand the calendar data from a "master copy" with
        RRULE set and into a "recurrence set" with RECURRENCE-ID set
        and no RRULE set.  The main usage is for client-side expansion
        in case the calendar server does not support server-side
        expansion.  If doing a `self.load`, the calendar
        content will be replaced with the "master copy".

        :param event: Event
        :param start: datetime
        :param end: datetime

        """
        ## TODO: this has been *copied* over to the icalendar-searcher package.
        ## This code was previously used internally by the search.
        ## By now it's probably dead code, used by nothing and nobody.
        ## Since it's exposed as part of the API, I cannot delete it, but I can
        ## deprecate it.
        warnings.warn(
            "obj.expand_rrule is likely to be removed in a future version of caldav.  Feel free to protest if you need it",
            DeprecationWarning,
            stacklevel=2,
        )

        import recurring_ical_events

        recurrings = recurring_ical_events.of(
            self.icalendar_instance, components=["VJOURNAL", "VTODO", "VEVENT"]
        ).between(start, end)

        recurrence_properties = {"exdate", "exrule", "rdate", "rrule"}

        error.assert_(
            not any(x for x in recurrings if not recurrence_properties.isdisjoint(set(x.keys())))
        )

        calendar = self.icalendar_instance
        calendar.subcomponents = []
        for occurrence in recurrings:
            ## Ignore completed task recurrences
            if (
                not include_completed
                and occurrence.name == "VTODO"
                and occurrence.get("STATUS") in ("COMPLETED", "CANCELLED")
            ):
                continue
            ## TODO: If there are no reports of missing RECURRENCE-ID until 2027,
            ## the if-statement below may be deleted
            error.assert_("RECURRENCE-ID" in occurrence)
            if "RECURRENCE-ID" not in occurrence:
                occurrence.add("RECURRENCE-ID", occurrence.get("DTSTART").dt)
            calendar.add_component(occurrence)

    def set_relation(
        self, other, reltype=None, set_reverse=True
    ) -> None:  ## TODO: logic to find and set siblings?
        """
        Sets a relation between this object and another object (given by uid or object).
        """
        ##TODO: test coverage
        reltype = reltype.upper()
        if isinstance(other, CalendarObjectResource):
            if other.id:
                uid = other.id
            else:
                # Use cheap accessor to avoid format conversion (issue #613)
                uid = other._get_uid_cheap() or other.icalendar_component["uid"]
            other_obj = other
        else:
            uid = other
            other_obj = None  # Resolved below (possibly async)

        if self.is_async_client:
            return self._async_set_relation(uid, other_obj, reltype, set_reverse)

        if other_obj is None and set_reverse:
            other_obj = self.parent.get_object_by_uid(uid)
        if set_reverse:
            ## TODO: special handling of NEXT/FIRST.
            ## STARTTOFINISH does not have any equivalent "reverse".
            reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype]
            other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)

        self._add_relation_to_ical(uid, reltype)
        self.save()

    def _add_relation_to_ical(self, uid, reltype) -> None:
        """Add a RELATED-TO property to the icalendar component (no-op if already present)."""
        existing_relation = self.icalendar_component.get("related-to", None)
        existing_relations = (
            existing_relation if isinstance(existing_relation, list) else [existing_relation]
        )
        for rel in existing_relations:
            if rel == uid:
                return

        # without str(…), icalendar ignores properties
        #  because if type(uid) == vText
        #  then Component._encode does miss adding properties
        #  see https://github.com/collective/icalendar/issues/557
        #  workaround should be safe to remove if issue gets fixed
        self.icalendar_component.add(
            "related-to", str(uid), parameters={"RELTYPE": reltype}, encode=True
        )

    async def _async_set_relation(self, uid, other_obj, reltype, set_reverse) -> None:
        """Async implementation of set_relation() for async clients."""
        if other_obj is None and set_reverse:
            other_obj = await self.parent.get_object_by_uid(uid)
        if set_reverse:
            ## TODO: special handling of NEXT/FIRST.
            reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype]
            # set_relation() returns a coroutine when is_async_client, so await it
            await other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)

        self._add_relation_to_ical(uid, reltype)
        await self.save()

    ## TODO: this method is undertested in the caldav library.
    ## However, as this consolidated and eliminated quite some duplicated code in the
    ## plann project, it is extensively tested in plann.
    def _parse_relatives_from_ical(
        self,
        reltypes: "Container[str] | None",
        relfilter: "Callable[[Any], bool] | None",
    ) -> "defaultdict[str, set[str]]":
        """Extract RELATED-TO properties as a {reltype: {uid, ...}} dict (pure, no I/O)."""
        ret: defaultdict[str, set[str]] = defaultdict(set)
        relations = self.icalendar_component.get("RELATED-TO", [])
        if not isinstance(relations, list):
            relations = [relations]
        for rel in relations:
            if relfilter and not relfilter(rel):
                continue
            reltype = rel.params.get("RELTYPE", "PARENT")
            if reltypes and reltype not in reltypes:
                continue
            ret[reltype].add(str(rel))
        return ret

    def get_relatives(
        self,
        reltypes: Container[str] | None = None,
        relfilter: Callable[[Any], bool] | None = None,
        fetch_objects: bool = True,
        ignore_missing: bool = True,
    ) -> defaultdict[str, set[str]]:
        """
        By default, loads all objects pointed to by the RELATED-TO
        property and loads the related objects.

        It's possible to filter, either by passing a set or a list of
        acceptable relation types in reltypes, or by passing a lambda
        function in relfilter.

        TODO: Make it possible to  also check up reverse relationships

        TODO: this is partially overlapped by plann.lib._relships_by_type
        in the plann tool.  Should consolidate the code.

        TODO: should probably return some kind of object instead of a weird dict structure.
        (but due to backward compatibility requirement, such an object should behave like
        the current dict)
        """
        if self.is_async_client:
            return self._async_get_relatives(reltypes, relfilter, fetch_objects, ignore_missing)

        from .collection import Calendar  ## late import to avoid cycling imports

        ret = self._parse_relatives_from_ical(reltypes, relfilter)

        if fetch_objects:
            for reltype in ret:
                uids = ret[reltype]
                reltype_set: set = set()

                if self.parent is None:
                    raise ValueError("Unexpected value None for self.parent")

                if not isinstance(self.parent, Calendar):
                    raise ValueError("self.parent expected to be of type Calendar but it is not")

                for obj in uids:
                    try:
                        reltype_set.add(self.parent.get_object_by_uid(obj))
                    except error.NotFoundError:
                        if not ignore_missing:
                            raise

                ret[reltype] = reltype_set

        return ret

    async def _async_get_relatives(
        self,
        reltypes: "Container[str] | None",
        relfilter: "Callable[[Any], bool] | None",
        fetch_objects: bool,
        ignore_missing: bool,
    ) -> "defaultdict[str, set]":
        """Async implementation of get_relatives() for async clients."""
        from .collection import Calendar  ## late import to avoid cycling imports

        ret = self._parse_relatives_from_ical(reltypes, relfilter)

        if fetch_objects:
            if self.parent is None:
                raise ValueError("Unexpected value None for self.parent")
            if not isinstance(self.parent, Calendar):
                raise ValueError("self.parent expected to be of type Calendar but it is not")

            for reltype in ret:
                uids = ret[reltype]
                reltype_set: set = set()
                for obj in uids:
                    try:
                        reltype_set.add(await self.parent.get_object_by_uid(obj))
                    except error.NotFoundError:
                        if not ignore_missing:
                            raise
                ret[reltype] = reltype_set

        return ret

    def _set_reverse_relation(self, other, reltype):
        ## TODO: handle RFC9253 better!  Particularly next/first-lists
        reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype)
        if not reverse_reltype:
            logging.error("Reltype %s not supported in object uid %s" % (reltype, self.id))
            return
        other.set_relation(self, reverse_reltype, other)

    async def _async_set_reverse_relation(self, other, reltype):
        """Async version of _set_reverse_relation."""
        reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype)
        if not reverse_reltype:
            logging.error("Reltype %s not supported in object uid %s" % (reltype, self.id))
            return
        await other.set_relation(self, reverse_reltype, other)

    def _verify_reverse_relation(self, other, reltype) -> tuple:
        revreltype = self.RELTYPE_REVERSE_MAP[reltype]
        ## TODO: special case FIRST/NEXT needs special handling
        other_relations = other.get_relatives(fetch_objects=False, reltypes={revreltype})
        # Use cheap accessor to avoid format conversion (issue #613)
        my_uid = self._get_uid_cheap() or str(self.icalendar_component["uid"])
        if my_uid not in other_relations[revreltype]:
            ## I don't remember why we need to return a tuple
            ## but it's propagated through the "public" methods, so we'll
            ## have to leave it like this.
            return (other, revreltype)
        return False

    async def _async_verify_reverse_relation(self, other, reltype) -> tuple:
        """Async version of _verify_reverse_relation."""
        revreltype = self.RELTYPE_REVERSE_MAP[reltype]
        other_relations = await other.get_relatives(fetch_objects=False, reltypes={revreltype})
        my_uid = self._get_uid_cheap() or str(self.icalendar_component["uid"])
        if my_uid not in other_relations[revreltype]:
            return (other, revreltype)
        return False

    async def _async_handle_reverse_relations(
        self, verify: bool = False, fix: bool = False, pdb: bool = False
    ) -> list:
        """Async version of _handle_reverse_relations for async clients."""
        ret = []
        assert verify or fix
        relations = await self.get_relatives()
        for reltype in relations:
            for other in relations[reltype]:
                if verify:
                    foobar = await self._async_verify_reverse_relation(other, reltype)
                    if foobar:
                        ret.append(foobar)
                        if pdb:
                            breakpoint()
                        if fix:
                            await self._async_set_reverse_relation(other, reltype)
                elif fix:
                    await self._async_set_reverse_relation(other, reltype)
        return ret

    def _handle_reverse_relations(
        self, verify: bool = False, fix: bool = False, pdb: bool = False
    ) -> list:
        """
        Goes through all relations and verifies that the return relation is set
        if verify is set:
            Returns a list of objects missing a reverse.
            Use public method check_reverse_relations instead
        if verify and fix is set:
            Fixup all objects missing a reverse.
            Use public method fix_reverse_relations instead.
        If fix but not verify is set:
            Assume all reverse relations are missing.
            Used internally when creating new objects.
        """
        ret = []
        assert verify or fix
        relations = self.get_relatives()
        for reltype in relations:
            for other in relations[reltype]:
                if verify:
                    foobar = self._verify_reverse_relation(other, reltype)
                    if foobar:
                        ret.append(foobar)
                        if pdb:
                            breakpoint()
                        if fix:
                            self._set_reverse_relation(other, reltype)
                elif fix:
                    self._set_reverse_relation(other, reltype)
        return ret

    def check_reverse_relations(self, pdb: bool = False) -> list[tuple]:
        """
        Will verify that for all the objects we point at though
        the RELATED-TO property, the other object points back to us as
        well.

        Returns a list of tuples.  Each tuple contains an object that
        do not point back as expected, and the expected reltype
        """
        return self._handle_reverse_relations(verify=True, fix=False, pdb=pdb)

    def fix_reverse_relations(self, pdb: bool = False) -> list:
        """
        Will ensure that for all the objects we point at though
        the RELATED-TO property, the other object points back to us as
        well.

        Returns a list of tuples.  Each tuple contains an object that
        did not point back as expected, and the expected reltype
        """
        return self._handle_reverse_relations(verify=True, fix=True, pdb=pdb)

    def _get_icalendar_component(self, assert_one=False):
        """Returns the icalendar subcomponent - which should be an
        Event, Journal, Todo or FreeBusy from the icalendar class

        See also https://github.com/python-caldav/caldav/issues/232
        """
        self.load(only_if_unloaded=True)
        if not self.icalendar_instance:
            return None
        ## PERFORMANCE TODO: no point creating a big list here
        ret = [
            x
            for x in self.icalendar_instance.subcomponents
            if not isinstance(x, icalendar.Timezone)
        ]
        error.assert_(len(ret) == 1 or not assert_one)
        for x in ret:
            for cl in (
                icalendar.Event,
                icalendar.Journal,
                icalendar.Todo,
                icalendar.FreeBusy,
            ):
                if isinstance(x, cl):
                    return x
        error.assert_(False)

    def _set_icalendar_component(self, value) -> None:
        s = self.icalendar_instance.subcomponents
        i = [i for i in range(0, len(s)) if not isinstance(s[i], icalendar.Timezone)]
        if len(i) == 1:
            self.icalendar_instance.subcomponents[i[0]] = value
        else:
            my_instance = icalendar.Calendar()
            my_instance.add("prodid", self.icalendar_instance["prodid"])
            my_instance.add("version", self.icalendar_instance["version"])
            my_instance.add_component(value)
            self.icalendar_instance = my_instance

    icalendar_component = property(
        _get_icalendar_component,
        _set_icalendar_component,
        doc="icalendar component - this is the simplest way to access the event/task - it will give you the first component that isn't a timezone component.  For recurrence sets, the master component will be returned.  For any non-recurring event/task/journal, there should be only one calendar component in the object.  For results from an expanded search, there should be only one calendar component in the object",
    )

    component = icalendar_component

    def get_due(self):
        """
        A VTODO may have due or duration set.  Return or calculate due.

        WARNING: this method is likely to be deprecated and moved to
        the icalendar library.  If you decide to use it, please put
        caldav<3.0 in the requirements.
        """
        i = self.icalendar_component
        if "DUE" in i:
            return i["DUE"].dt
        elif "DTEND" in i:
            return i["DTEND"].dt
        elif "DURATION" in i and "DTSTART" in i:
            return i["DTSTART"].dt + i["DURATION"].dt
        else:
            return None

    get_dtend = get_due

    def add_attendee(self, attendee, no_default_parameters: bool = False, **parameters) -> None:
        """
        For the current (event/todo/journal), add an attendee.

        The attendee can be any of the following:
        * A principal
        * An email address prepended with "mailto:"
        * An email address without the "mailto:"-prefix
        * A two-item tuple containing a common name and an email address
        * (not supported, but planned: an ical text line starting with the word "ATTENDEE")

        Any number of attendee parameters can be given, those will be used
        as defaults unless no_default_parameters is set to True:

        partstat=NEEDS-ACTION
        cutype=UNKNOWN (unless a principal object is given)
        rsvp=TRUE
        role=REQ-PARTICIPANT
        schedule-agent is not set
        """
        from .collection import Principal  ## late import to avoid cycling imports

        if isinstance(attendee, Principal):
            attendee_obj = attendee.get_vcal_address()
        elif isinstance(attendee, vCalAddress):
            attendee_obj = attendee
        elif isinstance(attendee, tuple):
            if attendee[1].startswith("mailto:"):
                attendee_obj = vCalAddress(attendee[1])
            else:
                attendee_obj = vCalAddress("mailto:" + attendee[1])
            attendee_obj.params["cn"] = vText(attendee[0])
        elif isinstance(attendee, str):
            if attendee.startswith("ATTENDEE"):
                raise NotImplementedError(
                    "do we need to support this anyway?  Should be trivial, but can't figure out how to do it with the icalendar.Event/vCalAddress objects right now"
                )
            elif attendee.startswith("mailto:"):
                attendee_obj = vCalAddress(attendee)
            elif "@" in attendee and ":" not in attendee and ";" not in attendee:
                attendee_obj = vCalAddress("mailto:" + attendee)
        else:
            error.assert_(False)
            attendee_obj = vCalAddress()

        ## TODO: if possible, check that the attendee exists
        ## TODO: check that the attendee will not be duplicated in the event.
        if not no_default_parameters:
            ## Sensible defaults:
            attendee_obj.params["partstat"] = "NEEDS-ACTION"
            if "cutype" not in attendee_obj.params:
                attendee_obj.params["cutype"] = "UNKNOWN"
            attendee_obj.params["rsvp"] = "TRUE"
            attendee_obj.params["role"] = "REQ-PARTICIPANT"
        params = {}
        for key in parameters:
            new_key = key.replace("_", "-")
            if parameters[key] is True:
                params[new_key] = "TRUE"
            else:
                params[new_key] = parameters[key]
        attendee_obj.params.update(params)
        ievent = self.icalendar_component
        ievent.add("attendee", attendee_obj)

    def is_invite_request(self) -> bool:
        """
        Returns True if this object is a request, see
        :rfc:`2446#section-3.2.2`.
        """
        self.load(only_if_unloaded=True)
        return self.icalendar_instance.get("method", None) == "REQUEST"

    def is_invite_reply(self) -> bool:
        """
        Returns True if the object is a reply, see
        :rfc:`2446#section-3.2.3`.
        """
        self.load(only_if_unloaded=True)
        return self.icalendar_instance.get("method", None) == "REPLY"

    def accept_invite(self, calendar: Optional["Calendar"] = None) -> None:
        """
        Accepts an invite - to be used on an invite object.
        """
        self._reply_to_invite_request("ACCEPTED", calendar)

    def decline_invite(self, calendar: Optional["Calendar"] = None) -> None:
        """
        Declines an invite - to be used on an invite object.
        """
        self._reply_to_invite_request("DECLINED", calendar)

    def tentatively_accept_invite(self, calendar: Any | None = None) -> None:
        """
        Tentatively accept an invite - to be used on an invite object.
        """
        self._reply_to_invite_request("TENTATIVE", calendar)

    ## TODO: DELEGATED is also a valid option, and for vtodos the
    ## partstat can also be set to COMPLETED and IN-PROGRESS.

    def _reply_to_invite_request(self, partstat, calendar) -> None:
        if self.is_async_client:
            raise NotImplementedError(
                "accept_invite/decline_invite/tentatively_accept_invite are not yet supported "
                "for async clients"
            )
        error.assert_(self.is_invite_request())
        if not calendar:
            calendar = self.client.principal().get_calendars()[0]
        ## we need to modify the icalendar code, update our own participant status
        self.icalendar_instance.pop("METHOD")
        self.change_attendee_status(partstat=partstat)
        self.get_property(cdav.ScheduleTag(), use_cached=True)
        try:
            calendar.add_event(self.data)
        except Exception:
            ## TODO - TODO - TODO
            ## RFC6638 does not seem to be very clear (or
            ## perhaps I should read it more thoroughly) neither on
            ## how to handle conflicts, nor if the reply should be
            ## posted to the "outbox", saved back to the same url or
            ## sent to a calendar.
            self.load()
            self.get_property(cdav.ScheduleTag(), use_cached=False)
            outbox = self.client.principal().schedule_outbox()
            if calendar.url != outbox.url:
                self._reply_to_invite_request(partstat, calendar=outbox)
            else:
                self.save()

    def copy(self, keep_uid: bool = False, new_parent: Any | None = None) -> Self:
        """
        Events, todos etc can be copied within the same calendar, to another
        calendar or even to another caldav server
        """
        obj = self.__class__(
            parent=new_parent or self.parent,
            data=self.data,
            id=self.id if keep_uid else str(uuid.uuid4()),
        )
        if new_parent or not keep_uid:
            obj.url = obj._generate_url()
        else:
            obj.url = self.url
        return obj

    ## TODO: move get-logics to a load_by_get method.
    ## The load method should deal with "server quirks".
    def load(self, only_if_unloaded: bool = False) -> Self:
        """
        (Re)load the object from the caldav server.

        For sync clients, loads and returns self.
        For async clients, returns a coroutine that must be awaited.

        Example (sync):
            obj.load()

        Example (async):
            await obj.load()
        """
        # Check if already loaded BEFORE delegating to async
        # This avoids returning a coroutine when no work is needed
        if only_if_unloaded and self.is_loaded():
            return self

        # Dual-mode support: async clients return a coroutine
        if self.is_async_client:
            return self._async_load(only_if_unloaded=only_if_unloaded)

        if self.url is None:
            raise ValueError("Unexpected value None for self.url")
        if self.client is None:
            raise ValueError("Unexpected value None for self.client")

        try:
            r = self.client.request(str(self.url))
            if r.status and r.status == 404:
                raise error.NotFoundError(errmsg(r))
            self.data = r.raw  # type: ignore
        except error.NotFoundError:
            # Only attempt fallbacks if the object was previously loaded
            # (has a UID), indicating the server may have changed the URL.
            # Without a UID, the 404 is definitive.
            uid = self.id
            if uid:
                # Fallback 1: try multiget (REPORT may work even when GET fails)
                try:
                    return self.load_by_multiget()
                except Exception:
                    pass
                # Fallback 2: re-fetch by UID (server may have changed the URL)
                if self.parent and hasattr(self.parent, "get_object_by_uid"):
                    try:
                        obj = self.parent.get_object_by_uid(uid)
                        if obj:
                            self.url = obj.url
                            self.data = obj.data
                            if hasattr(obj, "props"):
                                self.props.update(obj.props)
                            return self
                    except error.NotFoundError:
                        pass
            raise
        except Exception:
            return self.load_by_multiget()

        if "Etag" in r.headers:
            self.props[dav.GetEtag.tag] = r.headers["Etag"]
        if "Schedule-Tag" in r.headers:
            self.props[cdav.ScheduleTag.tag] = r.headers["Schedule-Tag"]
        return self

    async def _async_load(self, only_if_unloaded: bool = False) -> Self:
        """Async implementation of load."""
        if only_if_unloaded and self.is_loaded():
            return self

        if self.url is None:
            raise ValueError("Unexpected value None for self.url")
        if self.client is None:
            raise ValueError("Unexpected value None for self.client")

        try:
            r = await self.client.request(str(self.url))
            if r.status and r.status == 404:
                raise error.NotFoundError(errmsg(r))
            self.data = r.raw  # type: ignore
        except error.NotFoundError:
            uid = self.id
            if uid:
                # Fallback 1: try multiget (REPORT may work even when GET fails)
                try:
                    return await self._async_load_by_multiget()
                except Exception:
                    pass
                # Fallback 2: re-fetch by UID (server may have changed the URL)
                if self.parent and hasattr(self.parent, "get_object_by_uid"):
                    try:
                        obj = await self.parent.get_object_by_uid(uid)
                        if obj:
                            self.url = obj.url
                            self.data = obj.data
                            if hasattr(obj, "props"):
                                self.props.update(obj.props)
                            return self
                    except error.NotFoundError:
                        pass
            raise
        except Exception:
            return await self._async_load_by_multiget()

        if "Etag" in r.headers:
            self.props[dav.GetEtag.tag] = r.headers["Etag"]
        if "Schedule-Tag" in r.headers:
            self.props[cdav.ScheduleTag.tag] = r.headers["Schedule-Tag"]
        return self

    async def _async_load_by_multiget(self) -> Self:
        """Async implementation of load_by_multiget."""
        error.assert_(self.url)
        items = await self.parent._async_multiget(event_urls=[self.url], raise_notfound=True)
        if not items:
            raise error.NotFoundError(self.url)
        _url, self.data = items[0]
        error.assert_(self.data)
        error.assert_(len(items) == 1)
        return self

    def load_by_multiget(self) -> Self:
        """
        Some servers do not accept a GET, but we can still do a REPORT
        with a multiget query
        """
        error.assert_(self.url)
        mydata = self.parent._multiget(event_urls=[self.url], raise_notfound=True)
        url_data = next(mydata, None)
        if url_data is None:
            ## We shouldn't come here.  Something is wrong.
            ## TODO: research it
            ## As of 2025-05-20, this code section is used by
            ## TestForServerECloud::testCreateOverwriteDeleteEvent
            raise error.NotFoundError(self.url)
        url, self.data = url_data
        error.assert_(self.data)
        error.assert_(next(mydata, None) is None)
        return self

    ## TODO: self.id should either always be available or never
    ## TODO: run this logic on load, to ensure `self.id` is set after loading
    def _find_id_path(self, id=None, path=None) -> None:
        """
        With CalDAV, every object has a URL.  With icalendar, every object
        should have a UID.  This UID may or may not be copied into self.id.

        This method will:

        0) if ID is given, assume that as the UID, and set it in the object
        1) if UID is given in the object, assume that as the ID
        2) if ID is not given, but the path is given, generate the ID from the
           path
        3) If neither ID nor path is given, use the uuid method to generate an
           ID (TODO: recommendation in the RFC is to concat some timestamp, serial or
           random number and a domain)
        4) if no path is given, generate the URL from the ID
        """
        i = self._get_icalendar_component(assert_one=False)
        if not id and getattr(self, "id", None):
            id = self.id
        if not id:
            id = i.pop("UID", None)
            if id:
                id = str(id)
        if not path and getattr(self, "path", None):
            path = self.path
        if id is None and path is not None and str(path).endswith(".ics"):
            ## TODO: do we ever get here?  Perhaps this if is completely moot?
            id = re.search("(/|^)([^/]*).ics", str(path)).group(2)
        if id is None:
            id = str(uuid.uuid4())

        i.pop("UID", None)
        i.add("UID", id)

        for x in self.icalendar_instance.subcomponents:
            if not isinstance(x, icalendar.Timezone):
                error.assert_(x.get("UID", None) == self.id)

        if path is None:
            path = self._generate_url()
        else:
            path = self.parent.url.join(path)

        self.url = URL.objectify(path)

    def _put(self, retry_on_failure=True):
        ## SECURITY TODO: we should probably have a check here to verify that no such object exists already
        r = self.client.put(self.url, self.data, {"Content-Type": 'text/calendar; charset="utf-8"'})
        if r.status == 302:
            path = [x[1] for x in r.headers if x[0] == "location"][0]
        elif r.status not in (204, 201):
            if retry_on_failure:
                try:
                    import vobject
                except ImportError:
                    retry_on_failure = False
            if retry_on_failure:
                ## Accessing vobject_instance may "clean" the object.
                ## See https://github.com/python-caldav/caldav/issues/43
                self.get_vobject_instance()
                return self._put(False)
            else:
                raise error.PutError(errmsg(r))

    async def _async_put(self, retry_on_failure=True):
        """Async version of _put for async clients."""
        r = await self.client.put(
            str(self.url),
            str(self.data),
            {"Content-Type": 'text/calendar; charset="utf-8"'},
        )
        if r.status == 302:
            path = [x[1] for x in r.headers if x[0] == "location"][0]
            self.url = URL.objectify(path)
        elif r.status not in (204, 201):
            if retry_on_failure:
                try:
                    import vobject
                except ImportError:
                    retry_on_failure = False
            if retry_on_failure:
                self.get_vobject_instance()
                return await self._async_put(False)
            else:
                raise error.PutError(errmsg(r))

    def _create(self, id=None, path=None, retry_on_failure=True) -> None:
        ## TODO: Find a better method name
        self._find_id_path(id=id, path=path)
        self._put()

    async def _async_create(self, id=None, path=None) -> None:
        """Async version of _create for async clients."""
        self._find_id_path(id=id, path=path)
        await self._async_put()

    def _generate_url(self):
        ## See https://github.com/python-caldav/caldav/issues/143 for the rationale behind double-quoting slashes
        ## TODO: should try to wrap my head around issues that arises when id contains weird characters.  maybe it's
        ## better to generate a new uuid here, particularly if id is in some unexpected format.
        url = self.parent.url.join(_quote_uid(self.id) + ".ics")
        assert " " not in str(url)
        return url

    def change_attendee_status(self, attendee: Any | None = None, **kwargs) -> None:
        """
        Updates the attendee-line according to the arguments received
        """
        from .collection import Principal  ## late import to avoid cycling imports

        if not attendee:
            if self.client is None:
                raise ValueError("Unexpected value None for self.client")

            attendee = self.client.principal()

        cnt = 0

        if isinstance(attendee, Principal):
            attendee_emails = attendee.calendar_user_address_set()
            for addr in attendee_emails:
                try:
                    self.change_attendee_status(addr, **kwargs)
                    ## TODO: can probably just return now
                    cnt += 1
                except error.NotFoundError:
                    pass
            if not cnt:
                raise error.NotFoundError("Principal %s is not invited to event" % str(attendee))
            error.assert_(cnt == 1)
            return

        ical_obj = self.icalendar_component
        attendee_lines = ical_obj["attendee"]
        if isinstance(attendee_lines, str):
            attendee_lines = [attendee_lines]

        def strip_mailto(x):
            return str(x).lower().replace("mailto:", "")

        for attendee_line in attendee_lines:
            if strip_mailto(attendee_line) == strip_mailto(attendee):
                attendee_line.params.update(kwargs)
                cnt += 1
        if not cnt:
            raise error.NotFoundError("Participant %s not found in attendee list")
        error.assert_(cnt == 1)

    def save(
        self,
        no_overwrite: bool = False,
        no_create: bool = False,
        obj_type: str | None = None,
        increase_seqno: bool = True,
        if_schedule_tag_match: bool = False,
        only_this_recurrence: bool = True,
        all_recurrences: bool = False,
    ) -> Self:
        """Save the object, can be used for creation and update.

        no_overwrite and no_create will check if the object exists.
        Those two are mutually exclusive.  Some servers don't support
        searching for an object uid without explicitly specifying what
        kind of object it should be, hence obj_type can be passed.
        obj_type is only used in conjunction with no_overwrite and
        no_create.

        is_schedule_tag_match is currently ignored. (TODO - fix or remove)

        The SEQUENCE should be increased when saving a new version of
        the object.  If this behaviour is unwanted, then
        increase_seqno should be set to False.  Also, if SEQUENCE is
        not set, then this will be ignored.

        The behaviour when saving a single recurrence object to the
        server is as far as I can understand not defined in the RFCs,
        but all servers I've tested against will overwrite the full
        event with the recurrence instance (effectively deleting the
        recurrence rule).  That's almost for sure not what the caller
        intended.  only_this_recurrence and all_recurrences only
        applies when trying to save a recurrence object.  They are by
        nature mutually exclusive, but since only_this_recurrence is
        True by default, it will be ignored if all_recurrences is set.

        If you want to sent the recurrence as it is to the server,
        you should set both all_recurrences and only_this_recurrence
        to False.

        Returns:
         * self

        """
        # Early return if there's no data (no-op case)
        if not self.is_loaded():
            return self

        # Delegate to async version for async clients (all logic that calls parent
        # collection methods must be async-aware to avoid getting unawaited coroutines)
        if self.is_async_client:
            return self._async_save(
                no_overwrite=no_overwrite,
                no_create=no_create,
                obj_type=obj_type,
                increase_seqno=increase_seqno,
                if_schedule_tag_match=if_schedule_tag_match,
                only_this_recurrence=only_this_recurrence,
                all_recurrences=all_recurrences,
            )

        # Helper function to get the full object by UID
        def get_self():
            from caldav.lib import error

            uid = self.id or self.icalendar_component.get("uid")
            if uid and self.parent:
                try:
                    if not obj_type:
                        _obj_type = self.__class__.__name__.lower()
                    else:
                        _obj_type = obj_type
                    if _obj_type:
                        method_name = f"get_{_obj_type}_by_uid"
                        if hasattr(self.parent, method_name):
                            return getattr(self.parent, method_name)(uid)
                    if hasattr(self.parent, "get_object_by_uid"):
                        return self.parent.get_object_by_uid(uid)
                except error.NotFoundError:
                    return None
            return None

        if no_overwrite or no_create:
            uid = self.id or self.icalendar_component.get("uid")
            existing = get_self()
            self._validate_save_constraints(existing, uid, no_overwrite, no_create)

        if (
            only_this_recurrence or all_recurrences
        ) and "RECURRENCE-ID" in self.icalendar_component:
            from caldav.lib import error

            obj = get_self()
            if obj is None:
                raise error.NotFoundError("Could not find parent recurring event")
            self._incorporate_recurrence_into_parent(obj, only_this_recurrence, all_recurrences)
            return obj.save(increase_seqno=increase_seqno)

        self._maybe_increment_sequence(increase_seqno)
        path = self.url.path if self.url else None
        self._create(id=self.id, path=path)
        return self

    def _validate_save_constraints(self, existing, uid, no_overwrite, no_create):
        """Raise ConsistencyError if no_overwrite/no_create constraints are violated."""
        from caldav.lib import error

        if not uid and no_create:
            raise error.ConsistencyError("no_create flag was set, but no ID given")
        if no_overwrite and existing:
            raise error.ConsistencyError("no_overwrite flag was set, but object already exists")
        if no_create and not existing:
            raise error.ConsistencyError("no_create flag was set, but object does not exist")

    def _incorporate_recurrence_into_parent(self, obj, only_this_recurrence, all_recurrences):
        """Mutate obj's icalendar_instance to include/update self (a recurrence instance).

        When saving a single recurrence instance we need to merge it into the
        full recurring event rather than overwrite it on the server.  This method
        performs that pure icalendar manipulation so it can be shared between the
        sync and async save paths.
        """
        import icalendar

        from caldav.lib import error

        ici = obj.icalendar_instance

        if all_recurrences:
            occ = obj.icalendar_component
            ncc = self.icalendar_component.copy()
            for prop in ["exdate", "exrule", "rdate", "rrule"]:
                if prop in occ:
                    ncc[prop] = occ[prop]

            # dtstart_diff = how much we've moved the time
            dtstart_diff = ncc.start.astimezone() - ncc["recurrence-id"].dt.astimezone()
            new_duration = ncc.duration
            ncc.pop("dtstart")
            ncc.add("dtstart", occ.start + dtstart_diff)
            for ep in ("duration", "dtend"):
                if ep in ncc:
                    ncc.pop(ep)
            ncc.add("dtend", ncc.start + new_duration)
            ncc.pop("recurrence-id")
            s = ici.subcomponents

            # Replace the "root" subcomponent
            comp_idxes = [i for i in range(len(s)) if not isinstance(s[i], icalendar.Timezone)]
            s[comp_idxes[0]] = ncc

            # The recurrence-ids of all objects has to be recalculated
            if dtstart_diff:
                for i in comp_idxes[1:]:
                    rid = s[i].pop("recurrence-id")
                    s[i].add("recurrence-id", rid.dt + dtstart_diff)

        elif only_this_recurrence:
            existing_idx = [
                i
                for i in range(len(ici.subcomponents))
                if ici.subcomponents[i].get("recurrence-id")
                == self.icalendar_component["recurrence-id"]
            ]
            error.assert_(len(existing_idx) <= 1)
            if existing_idx:
                ici.subcomponents[existing_idx[0]] = self.icalendar_component
            else:
                ici.add_component(self.icalendar_component)

    def _maybe_increment_sequence(self, increase_seqno):
        """Increment SEQUENCE number if present and increase_seqno is True."""
        if increase_seqno and "SEQUENCE" in self.icalendar_component:
            seqno = self.icalendar_component.pop("SEQUENCE", None)
            if seqno is not None:
                self.icalendar_component.add("SEQUENCE", seqno + 1)

    async def _async_save(
        self,
        no_overwrite: bool = False,
        no_create: bool = False,
        obj_type: str | None = None,
        increase_seqno: bool = True,
        if_schedule_tag_match: bool = False,
        only_this_recurrence: bool = True,
        all_recurrences: bool = False,
    ) -> Self:
        """Async implementation of save() for async clients."""
        from caldav.lib import error

        async def get_self():
            uid = self.id or self.icalendar_component.get("uid")
            if uid and self.parent:
                try:
                    _obj_type = obj_type or self.__class__.__name__.lower()
                    if _obj_type:
                        method_name = f"get_{_obj_type}_by_uid"
                        if hasattr(self.parent, method_name):
                            return await getattr(self.parent, method_name)(uid)
                    if hasattr(self.parent, "get_object_by_uid"):
                        return await self.parent.get_object_by_uid(uid)
                except error.NotFoundError:
                    return None
            return None

        if no_overwrite or no_create:
            uid = self.id or self.icalendar_component.get("uid")
            existing = await get_self()
            self._validate_save_constraints(existing, uid, no_overwrite, no_create)

        if (
            only_this_recurrence or all_recurrences
        ) and "RECURRENCE-ID" in self.icalendar_component:
            obj = await get_self()
            if obj is None:
                raise error.NotFoundError("Could not find parent recurring event")
            self._incorporate_recurrence_into_parent(obj, only_this_recurrence, all_recurrences)
            return await obj.save(increase_seqno=increase_seqno)

        self._maybe_increment_sequence(increase_seqno)
        path = self.url.path if self.url else None
        await self._async_create(id=self.id, path=path)
        return self

    def is_loaded(self):
        """Returns True if there exists data in the object.  An
        object is considered not to be loaded if it contains no data
        but just the URL.

        Optimized to use cheap accessors (issue #613).
        """
        # Use the state pattern to check for data without side effects
        if not self._has_data():
            return False
        # Check if there's an actual component (not just empty VCALENDAR)
        return self._get_component_type_cheap() is not None

    def has_component(self) -> bool:
        """
        Returns True if there exists a VEVENT, VTODO or VJOURNAL in the data.
        Returns False if it's only a VFREEBUSY, VTIMEZONE or unknown components.

        Used internally after search to remove empty search results (sometimes Google return such)
        """
        if not self._has_data():
            return False
        return self._get_component_type_cheap() is not None

    def __str__(self) -> str:
        return "%s: %s" % (self.__class__.__name__, self.url)

    ## implementation of the properties self.data,
    ## self.vobject_instance and self.icalendar_instance follows.  The
    ## rule is that only one of them can be set at any time, this
    ## since vobject_instance and icalendar_instance are mutable,
    ## and any modification to those instances should apply
    def _set_data(self, data):
        ## The __init__ takes a data attribute, and it should be allowable to
        ## set it to a vobject object or an icalendar object, hence we should
        ## do type checking on the data (TODO: but should probably use
        ## isinstance rather than this kind of logic
        if type(data).__module__.startswith("vobject"):
            self._set_vobject_instance(data)
            return self

        if type(data).__module__.startswith("icalendar"):
            self._set_icalendar_instance(data)
            return self

        self._data = vcal.fix(data)
        self._vobject_instance = None
        self._icalendar_instance = None
        return self

    def _get_data(self):
        if self._data:
            return to_normal_str(self._data)
        elif self._vobject_instance:
            return to_normal_str(self._vobject_instance.serialize())
        elif self._icalendar_instance:
            return to_normal_str(self._icalendar_instance.to_ical())
        return None

    def _get_wire_data(self):
        if self._data:
            return to_wire(self._data)
        elif self._vobject_instance:
            return to_wire(self._vobject_instance.serialize())
        elif self._icalendar_instance:
            return to_wire(self._icalendar_instance.to_ical())
        return None

    data: Any = property(
        _get_data, _set_data, doc="vCal representation of the object as normal string"
    )
    wire_data = property(
        _get_wire_data,
        _set_data,
        doc="vCal representation of the object in wire format (UTF-8, CRLN)",
    )

    def _set_vobject_instance(self, inst: "vobject.base.Component"):
        self._vobject_instance = inst
        self._data = None
        self._icalendar_instance = None
        # Keep _state in sync with _vobject_instance
        self._state = VobjectState(inst)
        return self

    def _get_vobject_instance(self) -> Optional["vobject.base.Component"]:
        try:
            import vobject
        except ImportError:
            logging.critical(
                "A vobject instance has been requested, but the vobject library is not installed (vobject is no longer an official dependency in 2.0)"
            )
            return None
        if not self._vobject_instance:
            if self._get_data() is None:
                return None
            try:
                self._set_vobject_instance(
                    vobject.readOne(to_unicode(self._get_data()))  # type: ignore
                )
            except:
                log.critical(
                    "Something went wrong while loading icalendar data into the vobject class.  ical url: "
                    + str(self.url)
                )
                raise
        return self._vobject_instance

    ## event.instance has always yielded a vobject, but will probably yield an icalendar_instance
    ## in version 3.0!
    def _get_deprecated_vobject_instance(self) -> Optional["vobject.base.Component"]:
        warnings.warn(
            "use event.vobject_instance or event.icalendar_instance",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._get_vobject_instance()

    def _set_deprecated_vobject_instance(self, inst: "vobject.base.Component"):
        warnings.warn(
            "use event.vobject_instance or event.icalendar_instance",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._set_vobject_instance(inst)

    vobject_instance: "vobject.base.VBase" = property(
        _get_vobject_instance,
        _set_vobject_instance,
        doc="vobject instance of the object",
    )

    instance: "vobject.base.VBase" = property(
        _get_deprecated_vobject_instance,
        _set_deprecated_vobject_instance,
        doc="vobject instance of the object (DEPRECATED!  This will yield an icalendar instance in caldav 3.0)",
    )

    def _set_icalendar_instance(self, inst):
        if not isinstance(inst, icalendar.Calendar):
            ## assume inst is an Event, Journal or Todo.
            ## TODO: perhaps a bit better sanity checking here?
            try:  ## DEPRECATION TODO: remove this try/except the future
                ## icalendar 7.x behaviour (not released yet as of 2025-09
                cal = icalendar.Calendar.new()
            except:
                cal = icalendar.Calendar()
                cal.add("prodid", "-//python-caldav//caldav//en_DK")
                cal.add("version", "2.0")
            cal.add_component(inst)
            inst = cal
        self._icalendar_instance = inst
        self._data = None
        self._vobject_instance = None
        # Keep _state in sync with _icalendar_instance
        self._state = IcalendarState(inst)
        return self

    def _get_icalendar_instance(self):
        if not self._icalendar_instance:
            if not self.data:
                return None
            self.icalendar_instance = icalendar.Calendar.from_ical(to_unicode(self.data))
        return self._icalendar_instance

    icalendar_instance: Any = property(
        _get_icalendar_instance,
        _set_icalendar_instance,
        doc="icalendar instance of the object",
    )

    ## ===================================================================
    ## New API for safe data access (issue #613)
    ## ===================================================================

    def _ensure_state(self) -> DataState:
        """Ensure we have a DataState object, migrating from legacy attributes if needed."""
        if self._state is not None:
            return self._state

        # Migrate from legacy attributes
        if self._icalendar_instance is not None:
            self._state = IcalendarState(self._icalendar_instance)
        elif self._vobject_instance is not None:
            self._state = VobjectState(self._vobject_instance)
        elif self._data is not None:
            self._state = RawDataState(to_normal_str(self._data))
        else:
            self._state = NoDataState()

        return self._state

    def get_data(self) -> str:
        """Get raw iCalendar data as string.

        This is always safe to call and returns the current data without
        side effects. If the current representation is a parsed object,
        it will be serialized.

        Returns:
            The iCalendar data as a string, or empty string if no data.
        """
        return self._ensure_state().get_data()

    def get_icalendar_instance(self) -> icalendar.Calendar:
        """Get a COPY of the icalendar object for read-only access.

        This is safe for inspection - modifications to the returned object
        will NOT be saved. For editing, use edit_icalendar_instance().

        Returns:
            A copy of the icalendar.Calendar object.
        """
        return self._ensure_state().get_icalendar_copy()

    def get_icalendar_component(self) -> "icalendar.Component":
        """Get a COPY of the inner icalendar component (VEVENT/VTODO/VJOURNAL) for read-only access.

        This is safe for inspection - modifications to the returned object
        will NOT be saved. For editing, use edit_icalendar_component().

        For recurring events with multiple components, returns the first
        non-timezone component (the master RRULE component).  Use
        ``search(..., expand=True)`` to get individual expanded occurrences.

        Returns:
            A copy of the first non-timezone subcomponent.
        """
        import copy

        return copy.deepcopy(self.icalendar_component)

    def get_vobject_instance(self) -> "vobject.base.Component":
        """Get a COPY of the vobject object for read-only access.

        This is safe for inspection - modifications to the returned object
        will NOT be saved. For editing, use edit_vobject_instance().

        Returns:
            A copy of the vobject component.
        """
        return self._ensure_state().get_vobject_copy()

    @contextmanager
    def edit_icalendar_instance(self):
        """Context manager to borrow the icalendar object for editing.

        Usage::

            with event.edit_icalendar_instance() as cal:
                cal.subcomponents[0]['SUMMARY'] = 'New Summary'
            event.save()

        While inside the context, the icalendar object is the authoritative
        source. Accessing other representations (vobject) while borrowed
        will raise RuntimeError.

        Yields:
            The authoritative icalendar.Calendar object.

        Raises:
            RuntimeError: If another representation is currently borrowed.
        """
        if self._borrowed:
            raise RuntimeError(
                "Cannot borrow icalendar - another representation is already borrowed. "
                "Complete the current edit before starting another."
            )

        state = self._ensure_state()

        # Switch to icalendar state if not already
        if not isinstance(state, IcalendarState):
            cal = state.get_icalendar_copy()
            self._state = IcalendarState(cal)
            # Clear legacy attributes
            self._data = None
            self._vobject_instance = None
            self._icalendar_instance = cal

        self._borrowed = True
        try:
            yield self._state.get_authoritative_icalendar()
        finally:
            self._borrowed = False

    @contextmanager
    def edit_icalendar_component(self):
        """Context manager to borrow the inner icalendar component for editing.

        Like :meth:`edit_icalendar_instance` but yields the first
        ``VEVENT`` / ``VTODO`` / ``VJOURNAL`` subcomponent directly,
        rather than the ``VCALENDAR`` wrapper.  This is convenient when
        you only need to modify a single property on the component itself.

        Usage::

            with event.edit_icalendar_component() as comp:
                comp['SUMMARY'] = 'New Summary'
            event.save()

        Yields:
            The first non-``VTIMEZONE`` subcomponent of the icalendar object.

        Raises:
            RuntimeError: If another representation is currently borrowed.
            StopIteration: If the calendar contains no non-timezone components.
        """
        with self.edit_icalendar_instance() as cal:
            component = next(c for c in cal.subcomponents if c.name != "VTIMEZONE")
            yield component

    @contextmanager
    def edit_vobject_instance(self):
        """Context manager to borrow the vobject object for editing.

        Usage::

            with event.edit_vobject_instance() as vobj:
                vobj.vevent.summary.value = 'New Summary'
            event.save()

        While inside the context, the vobject object is the authoritative
        source. Accessing other representations (icalendar) while borrowed
        will raise RuntimeError.

        Yields:
            The authoritative vobject component.

        Raises:
            RuntimeError: If another representation is currently borrowed.
        """
        if self._borrowed:
            raise RuntimeError(
                "Cannot borrow vobject - another representation is already borrowed. "
                "Complete the current edit before starting another."
            )

        state = self._ensure_state()

        # Switch to vobject state if not already
        if not isinstance(state, VobjectState):
            vobj = state.get_vobject_copy()
            self._state = VobjectState(vobj)
            # Clear legacy attributes
            self._data = None
            self._icalendar_instance = None
            self._vobject_instance = vobj

        self._borrowed = True
        try:
            yield self._state.get_authoritative_vobject()
        finally:
            self._borrowed = False

    # --- Internal cheap accessors (no state changes) ---

    def _get_uid_cheap(self) -> str | None:
        """Get UID without triggering format conversions.

        This is for internal use where we just need to peek at the UID
        without needing to modify anything.
        """
        return self._ensure_state().get_uid()

    def _get_component_type_cheap(self) -> str | None:
        """Get component type (VEVENT/VTODO/VJOURNAL) without parsing.

        This is for internal use to quickly determine the type.
        """
        return self._ensure_state().get_component_type()

    def _has_data(self) -> bool:
        """Check if we have any data without triggering conversions."""
        return self._ensure_state().has_data()

    ## ===================================================================
    ## End of new API (issue #613)
    ## ===================================================================

    def get_duration(self) -> timedelta:
        """According to the RFC, either DURATION or DUE should be set
        for a task, but never both - implicitly meaning that DURATION
        is the difference between DTSTART and DUE (personally I
        believe that's stupid.  If a task takes five minutes to
        complete - say, fill in some simple form that should be
        delivered before midnight at new years eve, then it feels
        natural for me to define "duration" as five minutes, DTSTART
        to "some days before new years eve" and DUE to 20xx-01-01
        00:00:00 - but I digress.

        This method will return DURATION if set, otherwise the
        difference between DUE and DTSTART (if both of them are set).

        TODO: should be fixed for Event class as well (only difference
        is that DTEND is used rather than DUE) and possibly also for
        Journal (defaults to one day, probably?)

        WARNING: this method is likely to be deprecated and moved to
        the icalendar library.  If you decide to use it, please put
        caldav<3.0 in the requirements.
        """
        i = self.icalendar_component
        return self._get_duration(i)

    def _get_duration(self, i):
        if "DURATION" in i:
            return i["DURATION"].dt
        elif "DTSTART" in i and self._ENDPARAM in i:
            end = i[self._ENDPARAM].dt
            start = i["DTSTART"].dt
            ## We do have a problem here if one is a date and the other is a
            ## datetime.  This is NOT explicitly defined as a technical
            ## breach in the RFC, so we need to work around it.
            if isinstance(end, datetime) != isinstance(start, datetime):
                start = datetime(start.year, start.month, start.day)
                end = datetime(end.year, end.month, end.day)
            return end - start
        elif "DTSTART" in i and not isinstance(i["DTSTART"], datetime):
            return timedelta(days=1)
        else:
            return timedelta(0)


class Event(CalendarObjectResource):
    """
    The `Event` object is used to represent an event (VEVENT).

    As of 2020-12 it adds very little to the inheritated class.  (I have
    frequently asked myself if we need those subclasses ... perhaps
    not)
    """

    set_dtend = CalendarObjectResource.set_end
    _ENDPARAM = "DTEND"


class Journal(CalendarObjectResource):
    """
    The `Journal` object is used to represent a journal entry (VJOURNAL).

    As of 2020-12 it adds nothing to the inheritated class.  (I have
    frequently asked myself if we need those subclasses ... perhaps
    not)
    """

    pass


class FreeBusy(CalendarObjectResource):
    """
    The `FreeBusy` object is used to represent a freebusy response from
    the server.  __init__ is overridden, as a FreeBusy response has no
    URL or ID.  The inheritated methods .save and .load is moot and
    will probably throw errors (perhaps the class hierarchy should be
    rethought, to prevent the FreeBusy from inheritating moot methods)

    Update: With RFC6638 a freebusy object can have a URL and an ID.
    """

    def __init__(
        self,
        parent,
        data,
        url: str | ParseResult | SplitResult | URL | None = None,
        id: Any | None = None,
    ) -> None:
        CalendarObjectResource.__init__(
            self, client=parent.client, url=url, data=data, parent=parent, id=id
        )


class Todo(CalendarObjectResource):
    """The `Todo` object is used to represent a todo item (VTODO).  A
    Todo-object can be completed.

    There is some extra logic here - arguably none of it belongs to
    the caldav library, and should be moved either to the icalendar
    library or to the plann library (plann is a cli-tool, should
    probably be split up into one library for advanced calendaring
    operations and the cli-tool as separate packages)
    """

    _ENDPARAM = "DUE"

    def _next(self, ts=None, i=None, dtstart=None, rrule=None, by=None, no_count=True):
        """Special logic to fint the next DTSTART of a recurring
        just-completed task.

        If any BY*-parameters are present, assume the task should have
        fixed deadlines and preserve information from the previous
        dtstart.  If no BY*-parameters are present, assume the
        frequency is meant to be the interval between the tasks.

        Examples:

        1) Garbage collection happens every week on a Tuesday, but
        never earlier than 09 in the morning.  Hence, it may be
        important to take out the thrash Monday evenings or Tuesday
        morning.  DTSTART of the original task is set to Tuesday
        2022-11-01T08:50, DUE to 09:00.

        1A) Task is completed 07:50 on the 1st of November.  Next
        DTSTART should be Tuesday the 7th of November at 08:50.

        1B) Task is completed 09:15 on the 1st of November (which is
        probably OK, since they usually don't come before 09:30).
        Next DTSTART should be Tuesday the 7th of November at 08:50.

        1C) Task is completed at the 5th of November.  We've lost the
        DUE, but the calendar has no idea weather the DUE was a very
        hard due or not - and anyway, probably we'd like to do it
        again on Tuesday, so next DTSTART should be Tuesday the 7th of
        November at 08:50.

        1D) Task is completed at the 7th of November at 07:50.  Next
        DTSTART should be one hour later.  Now, this is very silly,
        but an algorithm cannot do guesswork on weather it's silly or
        not.  If DTSTART would be set to the earliest possible time
        one could start thinking on this task (like, Monday evening),
        then we would get Tue the 14th of November, which does make
        sense.  Unfortunately the icalendar standard does not specify
        what should be used for DTSTART and DURATION/DUE.

        1E) Task is completed on the 7th of November at 08:55.  This
        efficiently means we've lost the 1st of November recurrence
        but have done the 7th of November recurrence instead, so next
        timestamp will be the 14th of November.

        2) Floors at home should be cleaned like once a week, but
        there is no fixed deadline for it.  For some people it may
        make sense to have a routine doing it i.e. every Tuesday, but
        this is not a strict requirement.  If it wasn't done one
        Tuesday, it's probably even more important to do it Wednesday.
        If the floor was cleaned on a Saturday, it probably doesn't
        make sense cleaning it again on Tuesday, but it probably
        shouldn't wait until next Tuesday.  Rrule is set to
        FREQ=WEEKLY, but without any BYDAY.  The original VTODO is set
        up with DTSTART 16:00 on Tuesday the 1st of November and DUE
        17:00.  After 17:00 there will be dinner, so best to get it
        done before that.

        2A) Floor cleaning was finished 14:30.  The next recurrence
        has DTSTART set to 13:30 (and DUE set to 14:30).  The idea
        here is that since the floor starts accumulating dirt right
        after 14:30, obviously it is overdue at 16:00 Tuesday the 7th.

        2B) Floor cleaning was procrastinated with one day and
        finished Wednesday at 14:30.  Next instance will be Wednesday
        in a week, at 14:30.

        2C) Floor cleaning was procrastinated with two weeks and
        finished Tuesday the 14th at 14:30. Next instance will be
        Tuesday the 21st at 14:30.

        While scenario 2 is the most trivial to implement, it may not
        be the correct understanding of the RFC, and it may be tricky
        to get the RECURRENCE-ID set correctly.

        """
        if not i:
            i = self.icalendar_component
        if not rrule:
            rrule = i["RRULE"]
        if not dtstart:
            if by is True or (by is None and any(x for x in rrule if x.startswith("BY"))):
                if "DTSTART" in i:
                    dtstart = i["DTSTART"].dt
                else:
                    dtstart = ts or datetime.now()
            else:
                dtstart = ts or datetime.now() - self._get_duration(i)
        ## dtstart should be compared to the completion timestamp, which
        ## is set in UTC in the complete() method.  However, dtstart
        ## may be a naïve or a floating timestamp
        ## (TODO: what if it's a date?)
        ## (TODO: we need test code for those corner cases!)
        if hasattr(dtstart, "astimezone"):
            dtstart = dtstart.astimezone(timezone.utc)
        if not ts:
            ts = dtstart
        ## Counting is taken care of other places
        if no_count and "COUNT" in rrule:
            rrule = rrule.copy()
            rrule.pop("COUNT")
        rrule = rrulestr(rrule.to_ical().decode("utf-8"), dtstart=dtstart)
        return rrule.after(ts)

    def _reduce_count(self, i=None) -> bool:
        if not i:
            i = self.icalendar_component
        if "COUNT" in i["RRULE"]:
            if i["RRULE"]["COUNT"][0] == 1:
                return False
            i["RRULE"]["COUNT"][0] -= 1
        return True

    def _complete_recurring_safe(self, completion_timestamp):
        """This mode will create a new independent task which is
        marked as completed, and modify the existing recurring task.
        It is probably the most safe way to handle the completion of a
        recurrence of a recurring task, though the link between the
        completed task and the original task is lost.
        """
        ## If count is one, then it is not really recurring
        if not self._reduce_count():
            return self.complete(handle_rrule=False)
        next_dtstart = self._next(completion_timestamp)
        if not next_dtstart:
            return self.complete(handle_rrule=False)

        completed = self.copy()
        completed.url = self.parent.url.join(completed.id + ".ics")
        completed.icalendar_component.pop("RRULE")
        completed.save()
        completed.complete()

        duration = self.get_duration()
        i = self.icalendar_component
        i.pop("DTSTART", None)
        i.add("DTSTART", next_dtstart)
        self.set_duration(duration, movable_attr="DUE")

        self.save()

    def _complete_recurring_thisandfuture(self, completion_timestamp) -> None:
        """The RFC is not much helpful, a lot of guesswork is needed
        to consider what the "right thing" to do wrg of a completion of
        recurring tasks is ... but this is my shot at it.

        1) The original, with rrule, will be kept as it is.  The rrule
        string is fetched from the first subcomponent of the
        icalendar.

        2) If there are multiple recurrence instances in subcomponents
        and the last one is marked with RANGE=THISANDFUTURE, then
        select this one.  If it has the rrule property set, use this
        rrule rather than the original one.  Drop the RANGE parameter.
        Calculate the next RECURRENCE-ID from the DTSTART of this
        object.  Mark task as completed.  Increase SEQUENCE.

        3) Create a new recurrence instance with RANGE=THISANDFUTURE,
        without RRULE set (Ref
        https://github.com/Kozea/Radicale/issues/1264).  Set the
        RECURRENCE-ID to the one calculated in #2.  Calculate the
        DTSTART based on rrule and completion timestamp/date.
        """
        recurrences = self.icalendar_instance.subcomponents
        orig = recurrences[0]
        if "STATUS" not in orig:
            orig["STATUS"] = "NEEDS-ACTION"

        if len(recurrences) == 1:
            ## We copy the original one
            just_completed = orig.copy()
            just_completed.pop("RRULE")
            just_completed.add("RECURRENCE-ID", orig.get("DTSTART", completion_timestamp))
            seqno = just_completed.pop("SEQUENCE", 0)
            just_completed.add("SEQUENCE", seqno + 1)
            recurrences.append(just_completed)

        prev = recurrences[-1]
        rrule = prev.get("RRULE", orig["RRULE"])
        thisandfuture = prev.copy()
        seqno = thisandfuture.pop("SEQUENCE", 0)
        thisandfuture.add("SEQUENCE", seqno + 1)

        ## If we have multiple recurrences, assume the last one is a THISANDFUTURE.
        ## (Otherwise, the data is coming from another client ...)
        ## The RANGE parameter needs to be removed
        if len(recurrences) > 2:
            if prev["RECURRENCE-ID"].params.get("RANGE", None) == "THISANDFUTURE":
                prev["RECURRENCE-ID"].params.pop("RANGE")
            else:
                raise NotImplementedError(
                    "multiple instances found, but last one is not of type THISANDFUTURE, possibly this has been created by some incompatible client, but we should deal with it"
                )
        self._complete_ical(prev, completion_timestamp)

        thisandfuture.pop("RECURRENCE-ID", None)
        thisandfuture.add("RECURRENCE-ID", self._next(i=prev, rrule=rrule))
        thisandfuture["RECURRENCE-ID"].params["RANGE"] = "THISANDFUTURE"
        rrule2 = thisandfuture.pop("RRULE", None)

        ## Counting logic
        if rrule2 is not None:
            count = rrule2.get("COUNT", None)
            if count is not None and count[0] in (0, 1):
                for i in recurrences:
                    self._complete_ical(i, completion_timestamp=completion_timestamp)
            thisandfuture.add("RRULE", rrule2)
        else:
            count = rrule.get("COUNT", None)
            if count is not None and count[0] <= len(
                [x for x in recurrences if not self.is_pending(x)]
            ):
                self._complete_ical(recurrences[0], completion_timestamp=completion_timestamp)
                self.save(increase_seqno=False)
                return

        rrule = rrule2 or rrule

        duration = self._get_duration(i=prev)
        thisandfuture.pop("DTSTART", None)
        thisandfuture.pop("DUE", None)
        next_dtstart = self._next(i=prev, rrule=rrule, ts=completion_timestamp)
        thisandfuture.add("DTSTART", next_dtstart)
        self._set_duration(i=thisandfuture, duration=duration, movable_attr="DUE")
        self.icalendar_instance.subcomponents.append(thisandfuture)
        self.save(increase_seqno=False)

    def complete(
        self,
        completion_timestamp: datetime | None = None,
        handle_rrule: bool = False,
        rrule_mode: Literal["safe", "this_and_future"] = "safe",
    ) -> None:
        """Marks the task as completed.

        Parameters
        ----------
        completion_timestamp : datetime
            Defaults to ``datetime.now()``.
        handle_rrule : Bool
            If set to True, the library will try to be smart if
            the task is recurring.  The default is False, for backward
            compatibility.  I may consider making this one mandatory.
        rrule_mode : str
            The RFC leaves a lot of room for interpretation on how
            to handle recurring tasks, and what works on one server may break at
            another.  The following modes are accepted:
            * this_and_future - see doc for _complete_recurring_thisandfuture for details
            * safe - see doc for _complete_recurring_safe for details
        """
        if not completion_timestamp:
            completion_timestamp = datetime.now(timezone.utc)

        if self.is_async_client:
            return self._async_complete(completion_timestamp, handle_rrule, rrule_mode)

        if "RRULE" in self.icalendar_component and handle_rrule:
            return getattr(self, "_complete_recurring_%s" % rrule_mode)(completion_timestamp)
        self._complete_ical(completion_timestamp=completion_timestamp)
        self.save()

    async def _async_complete(
        self,
        completion_timestamp: datetime,
        handle_rrule: bool = False,
        rrule_mode: str = "safe",
    ) -> None:
        """Async implementation of complete()."""
        if "RRULE" in self.icalendar_component and handle_rrule:
            # _complete_recurring_* methods are sync-only for now; they internally
            # call self.save() which would return an unawaited coroutine in async mode.
            # This is a known limitation - handle_rrule is not yet async-safe.
            raise NotImplementedError("handle_rrule=True is not yet supported for async clients")
        self._complete_ical(completion_timestamp=completion_timestamp)
        await self.save()

    def _complete_ical(self, i=None, completion_timestamp=None) -> None:
        if i is None:
            i = self.icalendar_component
        assert self.is_pending(i)
        status = i.pop("STATUS", None)
        i.add("STATUS", "COMPLETED")
        i.add("COMPLETED", completion_timestamp)

    def is_pending(self, i=None) -> bool | None:
        if i is None:
            i = self.icalendar_component
        if i.get("COMPLETED", None) is not None:
            return False
        if i.get("STATUS", "NEEDS-ACTION") in ("NEEDS-ACTION", "IN-PROCESS"):
            return True
        if i.get("STATUS", "NEEDS-ACTION") in ("CANCELLED", "COMPLETED"):
            return False
        ## input data does not conform to the RFC
        raise AssertionError

    def uncomplete(self) -> None:
        """Undo completion - marks a completed task as not completed"""
        ### TODO: needs test code for code coverage!
        ## (it has been tested through the calendar-cli test code)
        if "status" in self.icalendar_component:
            self.icalendar_component.pop("status")
        self.icalendar_component.add("status", "NEEDS-ACTION")
        if "completed" in self.icalendar_component:
            self.icalendar_component.pop("completed")
        if self.is_async_client:
            return self._async_uncomplete()
        self.save()

    async def _async_uncomplete(self) -> None:
        """Async implementation of uncomplete() for async clients."""
        await self.save()

    ## TODO: should be moved up to the base class
    def set_duration(self, duration, movable_attr="DTSTART"):
        """
        If DTSTART and DUE/DTEND is already set, one of them should be moved.  Which one?  I believe that for EVENTS, the DTSTART should remain constant and DTEND should be moved, but for a task, I think the due date may be a hard deadline, hence by default we'll move DTSTART.

        TODO: can this be written in a better/shorter way?

        WARNING: this method may be deprecated and moved to
        the icalendar library at some point in the future.
        """
        i = self.icalendar_component
        return self._set_duration(i, duration, movable_attr)

    def _set_duration(self, i, duration, movable_attr="DTSTART") -> None:
        if ("DUE" in i or "DURATION" in i) and "DTSTART" in i:
            i.pop(movable_attr, None)
            if movable_attr == "DUE":
                i.pop("DURATION", None)
            if movable_attr == "DTSTART":
                i.add("DTSTART", i["DUE"].dt - duration)
            elif movable_attr == "DUE":
                i.add("DUE", i["DTSTART"].dt + duration)
        elif "DUE" in i:
            i.add("DTSTART", i["DUE"].dt - duration)
        elif "DTSTART" in i:
            i.add("DUE", i["DTSTART"].dt + duration)
        else:
            if "DURATION" in i:
                i.pop("DURATION")
            i.add("DURATION", duration)

    def set_due(self, due, move_dtstart=False, check_dependent=False):
        """The RFC specifies that a VTODO cannot have both due and
        duration, so when setting due, the duration field must be
        evicted

        check_dependent=True will raise some error if there exists a
        parent calendar component (through RELATED-TO), and the parents
        due or dtend is before the new dtend).

        WARNING: this method may become deprecated and parts of
        it moved to the icalendar library at some point in the future.

        WARNING: the check_dependent-logic may be rewritten to support
        RFC9253 in 3.x
        """
        i = self.icalendar_component
        if hasattr(due, "tzinfo") and not due.tzinfo:
            due = due.astimezone(timezone.utc)
        if check_dependent:
            parents = self.get_relatives({"PARENT"})
            for parent in parents["PARENT"]:
                pend = parent.get_dtend()
                ## Make sure both timestamps aren't "naive":
                if hasattr(pend, "tzinfo") and not pend.tzinfo:
                    pend = pend.astimezone(timezone.utc)
                ## pend and due may be date and datetime, then they cannot be compared directly
                if pend and pend.strftime("%s") < due.strftime("%s"):
                    if check_dependent == "return":
                        return parent
                    raise error.ConsistencyError(
                        "parent object has due/end %s, cannot procrastinate child object without first procrastinating parent object"
                    )
        CalendarObjectResource.set_end(self, due, move_dtstart)

    set_end = set_due
