# Xandikos
# Copyright (C) 2016-2017 Jelmer Vernooĳ <jelmer@jelmer.uk>, et al.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; version 3
# of the License or (at your option) any later version of
# the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA  02110-1301, USA.

"""Tests for xandikos.web."""

import asyncio
import os
import shutil
import tempfile
import unittest

from xandikos import caldav, webdav
from xandikos.icalendar import ICalendarFile
from xandikos.store.git import TreeGitStore
from xandikos.web import (
    CalendarCollection,
    ObjectResource,
    SingleUserFilesystemBackend,
    StoreBasedCollection,
    XandikosApp,
)

EXAMPLE_VCALENDAR1 = b"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
BEGIN:VTODO
CREATED:20150314T223512Z
DTSTAMP:20150527T221952Z
LAST-MODIFIED:20150314T223512Z
STATUS:NEEDS-ACTION
SUMMARY:do something
UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff
END:VTODO
END:VCALENDAR
"""


class CalendarCollectionTests(unittest.TestCase):
    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)

        self.store = TreeGitStore.create(os.path.join(self.tempdir, "c"))
        self.store.load_extra_file_handler(ICalendarFile)
        self.backend = SingleUserFilesystemBackend(self.tempdir)

        self.cal = CalendarCollection(self.backend, "c", self.store)

    def test_description(self):
        self.store.set_description("foo")
        self.assertEqual("foo", self.cal.get_calendar_description())

    def test_set_description(self):
        self.cal.set_calendar_description("My Calendar")
        self.assertEqual("My Calendar", self.cal.get_calendar_description())
        self.assertEqual("My Calendar", self.store.get_description())

    def test_color(self):
        self.assertRaises(KeyError, self.cal.get_calendar_color)
        self.cal.set_calendar_color("#aabbcc")
        self.assertEqual("#aabbcc", self.cal.get_calendar_color())

    def test_get_supported_calendar_components(self):
        self.assertEqual(
            ["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY", "VAVAILABILITY"],
            self.cal.get_supported_calendar_components(),
        )

    def test_calendar_query_vtodos(self):
        def create_fn(cls):
            f = cls(None)
            f.filter_subcomponent("VCALENDAR").filter_subcomponent("VTODO")
            return f

        self.assertEqual([], list(self.cal.calendar_query(create_fn)))
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        result = list(self.cal.calendar_query(create_fn))
        self.assertEqual(1, len(result))
        self.assertEqual("foo.ics", result[0][0])
        self.assertIs(self.store, result[0][1].store)
        self.assertEqual("foo.ics", result[0][1].name)
        self.assertEqual("text/calendar", result[0][1].content_type)

    def test_calendar_query_vtodo_by_uid(self):
        def create_fn(cls):
            f = cls(None)
            f.filter_subcomponent("VCALENDAR").filter_subcomponent(
                "VTODO"
            ).filter_property("UID").filter_text_match(
                "bdc22720-b9e1-42c9-89c2-a85405d8fbff"
            )
            return f

        self.assertEqual([], list(self.cal.calendar_query(create_fn)))
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        result = list(self.cal.calendar_query(create_fn))
        self.assertEqual(1, len(result))
        self.assertEqual("foo.ics", result[0][0])
        self.assertIs(self.store, result[0][1].store)
        self.assertEqual("foo.ics", result[0][1].name)
        self.assertEqual("text/calendar", result[0][1].content_type)

    def test_get_supported_calendar_data_types(self):
        self.assertEqual(
            [("text/calendar", "1.0"), ("text/calendar", "2.0")],
            self.cal.get_supported_calendar_data_types(),
        )

    def test_get_max_date_time(self):
        self.assertEqual("99991231T235959Z", self.cal.get_max_date_time())

    def test_get_min_date_time(self):
        self.assertEqual("00010101T000000Z", self.cal.get_min_date_time())

    def test_members(self):
        self.assertEqual([], list(self.cal.members()))
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        result = list(self.cal.members())
        self.assertEqual(1, len(result))
        self.assertEqual("foo.ics", result[0][0])
        self.assertIs(self.store, result[0][1].store)
        self.assertEqual("foo.ics", result[0][1].name)
        self.assertEqual("text/calendar", result[0][1].content_type)

    def test_get_member(self):
        self.assertRaises(KeyError, self.cal.get_member, "foo.ics")
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        result = self.cal.get_member("foo.ics")
        self.assertIs(self.store, result.store)
        self.assertEqual("foo.ics", result.name)
        self.assertEqual("text/calendar", result.content_type)

    def test_delete_member(self):
        self.assertRaises(KeyError, self.cal.get_member, "foo.ics")
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        self.cal.get_member("foo.ics")
        self.cal.delete_member("foo.ics")
        self.assertRaises(KeyError, self.cal.get_member, "foo.ics")

    def test_get_schedule_calendar_transparency(self):
        self.assertEqual(
            caldav.TRANSPARENCY_OPAQUE,
            self.cal.get_schedule_calendar_transparency(),
        )

    def test_git_refs(self):
        from wsgiref.util import setup_testing_defaults

        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        app = XandikosApp(self.backend, "user")

        default_branch = self.store.repo.refs.follow(b"HEAD")[0][-1]
        commit_hash = self.store.repo.refs[default_branch]

        environ = {
            "PATH_INFO": "/c/.git/info/refs",
            "REQUEST_METHOD": "GET",
            "QUERY_STRING": "",
        }
        setup_testing_defaults(environ)

        codes = []

        def start_response(code, _headers):
            codes.append(code)

        body = b"".join(app(environ, start_response))

        self.assertEqual(["200 OK"], codes)
        self.assertEqual(b"".join([commit_hash, b"\t", default_branch, b"\n"]), body)

    def test_calendar_availability_not_set(self):
        """Test getting availability when none is set raises KeyError."""
        with self.assertRaises(KeyError):
            self.cal.get_calendar_availability()

    def test_calendar_availability_set_and_get(self):
        """Test setting and getting calendar availability."""
        availability_data = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY-UNAVAILABLE
SUMMARY:Working hours
END:VAVAILABILITY
END:VCALENDAR"""

        # Set availability
        self.cal.set_calendar_availability(availability_data)

        # Get it back - it will be in normalized form
        retrieved = self.cal.get_calendar_availability()

        # Verify we can parse both and they represent the same data
        from icalendar.cal import Calendar as ICalendar

        original_cal = ICalendar.from_ical(availability_data)
        retrieved_cal = ICalendar.from_ical(retrieved)

        # Compare the normalized forms
        self.assertEqual(original_cal.to_ical(), retrieved_cal.to_ical())

    def test_calendar_availability_set_none_removes(self):
        """Test that setting availability to None removes it."""
        availability_data = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY
END:VAVAILABILITY
END:VCALENDAR"""

        # Set availability first
        self.cal.set_calendar_availability(availability_data)

        # Verify it was stored
        retrieved = self.cal.get_calendar_availability()
        self.assertIsNotNone(retrieved)

        # Remove it
        self.cal.set_calendar_availability(None)

        # Should raise KeyError now
        with self.assertRaises(KeyError):
            self.cal.get_calendar_availability()

    def test_calendar_availability_invalid_data(self):
        """Test that setting invalid iCalendar data raises InvalidFileContents."""
        from xandikos.store import InvalidFileContents

        invalid_data = "This is not valid iCalendar data"

        with self.assertRaises(InvalidFileContents) as cm:
            self.cal.set_calendar_availability(invalid_data)

        # Check the exception has the expected attributes
        self.assertEqual(cm.exception.content_type, "text/calendar")
        self.assertEqual(cm.exception.data, invalid_data)
        self.assertIsInstance(cm.exception.error, ValueError)

    def test_calendar_availability_remove_nonexistent(self):
        """Test that removing non-existent availability doesn't raise errors."""
        # Should not raise any exception
        self.cal.set_calendar_availability(None)

        # Still should raise KeyError when trying to get
        with self.assertRaises(KeyError):
            self.cal.get_calendar_availability()

    def test_calendar_availability_update(self):
        """Test updating existing availability data."""
        availability1 = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY-UNAVAILABLE
SUMMARY:Working hours v1
END:VAVAILABILITY
END:VCALENDAR"""

        availability2 = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T080000Z
DTEND:20240101T180000Z
BUSYTYPE:BUSY
SUMMARY:Working hours v2
BEGIN:AVAILABLE
DTSTART:20240101T120000Z
DTEND:20240101T130000Z
SUMMARY:Lunch break
END:AVAILABLE
END:VAVAILABILITY
END:VCALENDAR"""

        # Set first version
        self.cal.set_calendar_availability(availability1)

        # Verify first version was stored
        retrieved1 = self.cal.get_calendar_availability()
        from icalendar.cal import Calendar as ICalendar

        original1_cal = ICalendar.from_ical(availability1)
        retrieved1_cal = ICalendar.from_ical(retrieved1)
        self.assertEqual(original1_cal.to_ical(), retrieved1_cal.to_ical())

        # Update to second version
        self.cal.set_calendar_availability(availability2)

        # Verify second version was stored
        retrieved2 = self.cal.get_calendar_availability()
        original2_cal = ICalendar.from_ical(availability2)
        retrieved2_cal = ICalendar.from_ical(retrieved2)
        self.assertEqual(original2_cal.to_ical(), retrieved2_cal.to_ical())

    def test_render_html_default(self):
        """A text/html Accept renders HTML, not the ICS export."""
        body, _length, _etag, content_type, languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/",
                [("text/html", {})],
                ["en"],
            )
        )
        self.assertEqual("text/html; encoding=utf-8", content_type)
        self.assertEqual(["en-UK"], languages)

    def test_render_ics_via_accept(self):
        """A request with only text/calendar in Accept returns ICS."""
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        body, length, etag, content_type, languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/",
                [("text/calendar", {})],
                ["en"],
            )
        )
        self.assertEqual("text/calendar; charset=utf-8", content_type)
        self.assertIsNone(languages)
        ics = b"".join(body)
        self.assertEqual(length, len(ics))
        self.assertEqual(asyncio.run(self.cal.get_etag()), etag)
        from icalendar.cal import Calendar as ICalendar

        merged = ICalendar.from_ical(ics)
        self.assertEqual("VCALENDAR", merged.name)
        self.assertEqual("2.0", str(merged["VERSION"]))
        self.assertEqual("-//Jelmer Vernooĳ//Xandikos//EN", str(merged["PRODID"]))
        self.assertEqual(["VTODO"], [c.name for c in merged.subcomponents])
        self.assertEqual(
            "bdc22720-b9e1-42c9-89c2-a85405d8fbff",
            str(merged.subcomponents[0]["UID"]),
        )

    def test_render_browser_accept_returns_html(self):
        """Accept: */* (browser default) falls through to HTML."""
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        _body, _length, _etag, content_type, _languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/",
                [("text/html", {}), ("*/*", {"q": "0.8"})],
                ["en"],
            )
        )
        self.assertEqual("text/html; encoding=utf-8", content_type)

    def test_render_ics_via_export_query(self):
        """The ?export query forces ICS even from a browser-style Accept."""
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        body, _length, _etag, content_type, _languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/?export",
                [("text/html", {}), ("*/*", {"q": "0.8"})],
                ["en"],
            )
        )
        self.assertEqual("text/calendar; charset=utf-8", content_type)
        from icalendar.cal import Calendar as ICalendar

        merged = ICalendar.from_ical(b"".join(body))
        self.assertEqual(["VTODO"], [c.name for c in merged.subcomponents])

    def test_render_ics_merges_multiple_members(self):
        """Multiple ICS members are merged into a single VCALENDAR."""
        second = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//Test//Test//EN\r
BEGIN:VEVENT\r
UID:second-event@example.com\r
DTSTAMP:20260101T120000Z\r
DTSTART:20260601T100000Z\r
DTEND:20260601T110000Z\r
SUMMARY:Another event\r
END:VEVENT\r
END:VCALENDAR\r
"""
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        self.store.import_one("bar.ics", "text/calendar", [second])

        body, _length, _etag, _ct, _languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/?export",
                [("*/*", {})],
                ["en"],
            )
        )
        from icalendar.cal import Calendar as ICalendar

        merged = ICalendar.from_ical(b"".join(body))
        names = sorted(c.name for c in merged.subcomponents)
        self.assertEqual(["VEVENT", "VTODO"], names)
        uids = sorted(str(c["UID"]) for c in merged.subcomponents)
        self.assertEqual(
            [
                "bdc22720-b9e1-42c9-89c2-a85405d8fbff",
                "second-event@example.com",
            ],
            uids,
        )

    def test_render_ics_dedupes_vtimezones(self):
        """VTIMEZONE components with the same TZID appear once in the export."""
        tz_body = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//Test//Test//EN\r
BEGIN:VTIMEZONE\r
TZID:Europe/Amsterdam\r
BEGIN:STANDARD\r
DTSTART:19701025T030000\r
TZOFFSETFROM:+0200\r
TZOFFSETTO:+0100\r
END:STANDARD\r
END:VTIMEZONE\r
BEGIN:VEVENT\r
UID:tz-event-1@example.com\r
DTSTAMP:20260101T120000Z\r
DTSTART;TZID=Europe/Amsterdam:20260601T100000\r
DTEND;TZID=Europe/Amsterdam:20260601T110000\r
SUMMARY:TZ One\r
END:VEVENT\r
END:VCALENDAR\r
"""
        tz_body2 = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//Test//Test//EN\r
BEGIN:VTIMEZONE\r
TZID:Europe/Amsterdam\r
BEGIN:STANDARD\r
DTSTART:19701025T030000\r
TZOFFSETFROM:+0200\r
TZOFFSETTO:+0100\r
END:STANDARD\r
END:VTIMEZONE\r
BEGIN:VEVENT\r
UID:tz-event-2@example.com\r
DTSTAMP:20260101T120000Z\r
DTSTART;TZID=Europe/Amsterdam:20260602T100000\r
DTEND;TZID=Europe/Amsterdam:20260602T110000\r
SUMMARY:TZ Two\r
END:VEVENT\r
END:VCALENDAR\r
"""
        self.store.import_one("one.ics", "text/calendar", [tz_body])
        self.store.import_one("two.ics", "text/calendar", [tz_body2])

        body, _length, _etag, _ct, _languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/?export",
                [("*/*", {})],
                ["en"],
            )
        )
        from icalendar.cal import Calendar as ICalendar

        merged = ICalendar.from_ical(b"".join(body))
        vtimezones = [c for c in merged.subcomponents if c.name == "VTIMEZONE"]
        self.assertEqual(1, len(vtimezones))
        self.assertEqual("Europe/Amsterdam", str(vtimezones[0]["TZID"]))
        vevents = [c for c in merged.subcomponents if c.name == "VEVENT"]
        self.assertEqual(2, len(vevents))
        self.assertEqual(
            ["tz-event-1@example.com", "tz-event-2@example.com"],
            sorted(str(c["UID"]) for c in vevents),
        )

    def test_render_ics_includes_displayname_and_description(self):
        """The merged feed exposes the calendar name and description."""
        self.cal.set_displayname("Holidays")
        self.cal.set_calendar_description("Public holidays")
        self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1])

        body, _length, _etag, _ct, _languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/?export",
                [("*/*", {})],
                ["en"],
            )
        )
        from icalendar.cal import Calendar as ICalendar

        merged = ICalendar.from_ical(b"".join(body))
        self.assertEqual("Holidays", str(merged["X-WR-CALNAME"]))
        self.assertEqual("Public holidays", str(merged["X-WR-CALDESC"]))

    def test_render_ics_empty_collection(self):
        """An empty calendar still produces a valid VCALENDAR wrapper."""
        body, _length, _etag, content_type, _languages = asyncio.run(
            self.cal.render(
                "http://example.com/c/?export",
                [("*/*", {})],
                ["en"],
            )
        )
        self.assertEqual("text/calendar; charset=utf-8", content_type)
        from icalendar.cal import Calendar as ICalendar

        merged = ICalendar.from_ical(b"".join(body))
        self.assertEqual("VCALENDAR", merged.name)
        self.assertEqual([], list(merged.subcomponents))


class ObjectResourceSetBodyTests(unittest.TestCase):
    """Test ObjectResource.set_body error mapping."""

    def _make_resource(self, store):
        return ObjectResource(store, "foo.ics", "text/calendar", "etag")

    def test_set_body_maps_out_of_space(self):
        from xandikos.store import OutOfSpaceError

        class _Store:
            def import_one(self, *args, **kwargs):
                raise OutOfSpaceError()

        resource = self._make_resource(_Store())
        with self.assertRaises(webdav.InsufficientStorage):
            asyncio.run(resource.set_body([EXAMPLE_VCALENDAR1]))

    def test_set_body_maps_locked(self):
        from xandikos.store import LockedError

        class _Store:
            def import_one(self, *args, **kwargs):
                raise LockedError("foo.ics")

        resource = self._make_resource(_Store())
        with self.assertRaises(webdav.ResourceLocked):
            asyncio.run(resource.set_body([EXAMPLE_VCALENDAR1]))


class CalendarCollectionMigrationTests(unittest.TestCase):
    """Test migration from .xandikos file to .xandikos/ directory structure."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)

        self.store = TreeGitStore.create(os.path.join(self.tempdir, "c"))
        self.store.load_extra_file_handler(ICalendarFile)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.cal = CalendarCollection(self.backend, "c", self.store)

    def test_migration_with_existing_config_file(self):
        """Test migration from existing .xandikos file to .xandikos/ directory."""
        # Create an old-style .xandikos config file
        old_config_content = """[DEFAULT]
source = https://example.com/calendar.ics
color = #ff0000
displayname = Test Calendar
description = A test calendar
comment = This is a comment

[calendar]
order = 10
"""
        self.store.import_one(
            ".xandikos", "text/plain", [old_config_content.encode("utf-8")]
        )

        # Verify old config exists and directory doesn't
        self.assertNotIn(".xandikos", self.store.subdirectories())
        old_file = self.store.get_file(".xandikos", "text/plain")
        self.assertEqual(b"".join(old_file.content).decode("utf-8"), old_config_content)

        # Trigger migration by setting availability
        availability_data = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY
END:VAVAILABILITY
END:VCALENDAR"""

        self.cal.set_calendar_availability(availability_data)

        # Verify migration happened (directory exists on filesystem but filtered from subdirectories)
        xandikos_path = os.path.join(self.store.path, ".xandikos")
        self.assertTrue(os.path.isdir(xandikos_path))

        # Verify old file was removed
        with self.assertRaises(KeyError):
            self.store.get_file(".xandikos", "text/plain")

        # Verify new config file exists with same content
        new_config_file = self.store.get_file(".xandikos/config", "text/plain")
        new_config_content = b"".join(new_config_file.content).decode("utf-8")
        self.assertEqual(new_config_content, old_config_content)

        # Verify availability file was created
        availability_file = self.store.get_file(
            ".xandikos/availability.ics", "text/calendar"
        )
        self.assertIsNotNone(availability_file)

    def test_migration_without_existing_config_file(self):
        """Test migration when no .xandikos file exists."""
        # Verify no old config or directory exists
        with self.assertRaises(KeyError):
            self.store.get_file(".xandikos", "text/plain")
        self.assertNotIn(".xandikos", self.store.subdirectories())

        # Trigger migration by setting availability
        availability_data = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY-UNAVAILABLE
END:VAVAILABILITY
END:VCALENDAR"""

        self.cal.set_calendar_availability(availability_data)

        # Verify directory was created (exists on filesystem but filtered from subdirectories)
        xandikos_path = os.path.join(self.store.path, ".xandikos")
        self.assertTrue(os.path.isdir(xandikos_path))

        # Verify empty config file was created
        config_file = self.store.get_file(".xandikos/config", "text/plain")
        config_content = b"".join(config_file.content)
        self.assertEqual(config_content, b"")

        # Verify availability file was created
        availability_file = self.store.get_file(
            ".xandikos/availability.ics", "text/calendar"
        )
        self.assertIsNotNone(availability_file)

    def test_no_migration_when_directory_exists(self):
        """Test that migration doesn't happen when .xandikos/ directory already exists."""
        # Create .xandikos/ directory by creating a file in it first
        existing_config = "[DEFAULT]\nexisting = value"
        self.store.import_one(
            ".xandikos/config", "text/plain", [existing_config.encode("utf-8")]
        )

        # Verify directory exists on filesystem
        xandikos_path = os.path.join(self.store.path, ".xandikos")
        self.assertTrue(os.path.isdir(xandikos_path))
        existing_file = self.store.get_file(".xandikos/config", "text/plain")
        existing_content = b"".join(existing_file.content)

        # Trigger what would normally be migration
        availability_data = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY
END:VAVAILABILITY
END:VCALENDAR"""

        self.cal.set_calendar_availability(availability_data)

        # Verify existing config wasn't overwritten (migration should have been skipped)
        existing_file_after = self.store.get_file(".xandikos/config", "text/plain")
        existing_content_after = b"".join(existing_file_after.content)
        self.assertEqual(existing_content, existing_content_after)

        # Verify availability file was still created
        availability_file = self.store.get_file(
            ".xandikos/availability.ics", "text/calendar"
        )
        self.assertIsNotNone(availability_file)

    def test_migration_preserves_config_content_exactly(self):
        """Test that migration preserves config file content exactly."""
        # Create config with various edge cases
        complex_config = """# This is a comment
[DEFAULT]
source = https://example.com/cal.ics
color = #ff0000
displayname = Calendar with "quotes" and spaces
description = Multi-line
    description with
    indentation
comment = Contains = equals and [brackets]

[calendar]
order = 42

[section with spaces]
key with spaces = value with spaces
"""
        self.store.import_one(
            ".xandikos", "text/plain", [complex_config.encode("utf-8")]
        )

        # Trigger migration
        self.cal.set_calendar_availability("""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY
END:VAVAILABILITY
END:VCALENDAR""")

        # Verify exact content preservation
        migrated_config = self.store.get_file(".xandikos/config", "text/plain")
        migrated_content = b"".join(migrated_config.content).decode("utf-8")
        self.assertEqual(migrated_content, complex_config)

    def test_migration_handles_unicode_content(self):
        """Test that migration properly handles Unicode content in config files."""
        unicode_config = """[DEFAULT]
displayname = Календарь тест 📅
description = Français: café, naïve, résumé
comment = 中文测试 العربية हिन्दी
"""
        self.store.import_one(
            ".xandikos", "text/plain", [unicode_config.encode("utf-8")]
        )

        # Trigger migration
        self.cal.set_calendar_availability("""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY-TENTATIVE
END:VAVAILABILITY
END:VCALENDAR""")

        # Verify Unicode content preserved
        migrated_config = self.store.get_file(".xandikos/config", "text/plain")
        migrated_content = b"".join(migrated_config.content).decode("utf-8")
        self.assertEqual(migrated_content, unicode_config)

    def test_migration_with_empty_config_file(self):
        """Test migration with an empty .xandikos file."""
        # Create empty config file
        self.store.import_one(".xandikos", "text/plain", [b""])

        # Trigger migration
        self.cal.set_calendar_availability("""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY
END:VAVAILABILITY
END:VCALENDAR""")

        # Verify migration happened (directory exists on filesystem but filtered from subdirectories)
        xandikos_path = os.path.join(self.store.path, ".xandikos")
        self.assertTrue(os.path.isdir(xandikos_path))

        # Verify old file was removed
        with self.assertRaises(KeyError):
            self.store.get_file(".xandikos", "text/plain")

        # Verify new config file exists and is empty
        new_config = self.store.get_file(".xandikos/config", "text/plain")
        self.assertEqual(b"".join(new_config.content), b"")

    def test_multiple_migration_triggers_only_migrate_once(self):
        """Test that multiple operations only trigger migration once."""
        # Create old config
        old_config = "[DEFAULT]\ntest = value"
        self.store.import_one(".xandikos", "text/plain", [old_config.encode("utf-8")])

        # First operation triggers migration
        self.cal.set_calendar_availability("""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY
END:VAVAILABILITY
END:VCALENDAR""")

        # Verify migration happened (directory exists on filesystem but filtered from subdirectories)
        xandikos_path = os.path.join(self.store.path, ".xandikos")
        self.assertTrue(os.path.isdir(xandikos_path))
        with self.assertRaises(KeyError):
            self.store.get_file(".xandikos", "text/plain")

        # Get content of migrated config
        config_file = self.store.get_file(".xandikos/config", "text/plain")
        config_content = b"".join(config_file.content)

        # Second operation should not re-migrate - test with an update instead of removal
        self.cal.set_calendar_availability("""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T100000Z
DTEND:20240101T180000Z
BUSYTYPE:BUSY-UNAVAILABLE
END:VAVAILABILITY
END:VCALENDAR""")

        # Verify config file content wasn't changed (only availability should change)
        config_file_after = self.store.get_file(".xandikos/config", "text/plain")
        config_content_after = b"".join(config_file_after.content)
        self.assertEqual(config_content, config_content_after)

    def test_directory_filtering_hides_xandikos_from_listings(self):
        """Test that .xandikos/ directory is filtered out from collection listings."""
        # Create some regular files with different UIDs
        example_vcalendar2 = EXAMPLE_VCALENDAR1.replace(
            b"bdc22720-b9e1-42c9-89c2-a85405d8fbff",
            b"aaaaaa20-b9e1-42c9-89c2-a85405d8fbff",
        )
        self.store.import_one("event1.ics", "text/calendar", [EXAMPLE_VCALENDAR1])
        self.store.import_one("event2.ics", "text/calendar", [example_vcalendar2])

        # Trigger migration to create .xandikos/ directory
        self.cal.set_calendar_availability("""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VAVAILABILITY
DTSTART:20240101T090000Z
DTEND:20240101T170000Z
BUSYTYPE:BUSY
END:VAVAILABILITY
END:VCALENDAR""")

        # Verify .xandikos/ directory exists on filesystem but is filtered from subdirectories()
        xandikos_path = os.path.join(self.store.path, ".xandikos")
        self.assertTrue(os.path.isdir(xandikos_path))
        # But should be filtered out from subdirectories()
        self.assertNotIn(".xandikos", self.store.subdirectories())

        # But verify it's filtered out from collection member listings
        members = list(self.cal.members())
        member_names = [name for name, _ in members]

        # Should see regular files but not config files
        self.assertIn("event1.ics", member_names)
        self.assertIn("event2.ics", member_names)
        self.assertNotIn(".xandikos", member_names)
        self.assertNotIn(".xandikos/config", member_names)
        self.assertNotIn(".xandikos/availability.ics", member_names)

        # Also verify iter_with_etag filtering
        all_items = list(self.store.iter_with_etag())
        all_names = [name for name, _, _ in all_items]

        self.assertIn("event1.ics", all_names)
        self.assertIn("event2.ics", all_names)
        self.assertNotIn(".xandikos", all_names)
        self.assertNotIn(".xandikos/config", all_names)
        self.assertNotIn(".xandikos/availability.ics", all_names)


SCHEDULING_BASE = b"""\
BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//Test//EN\r
BEGIN:VEVENT\r
UID:meeting@example.com\r
DTSTAMP:20260101T120000Z\r
SEQUENCE:0\r
DTSTART:20260601T100000Z\r
DTEND:20260601T110000Z\r
SUMMARY:Sync\r
ORGANIZER:mailto:alice@example.com\r
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r
DESCRIPTION:original\r
END:VEVENT\r
END:VCALENDAR\r
"""


class ObjectResourceScheduleTagTests(unittest.TestCase):
    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)

        self.store = TreeGitStore.create(os.path.join(self.tempdir, "c"))
        self.store.load_extra_file_handler(ICalendarFile)

    def _put(self, name: str, body: bytes) -> str:
        _, etag = self.store.import_one(name, "text/calendar", [body])
        return etag

    def _resource(self, name: str, etag: str) -> ObjectResource:
        return ObjectResource(self.store, name, "text/calendar", etag)

    def test_schedule_tag_is_strong_etag_format(self):
        etag = self._put("event.ics", SCHEDULING_BASE)
        tag = asyncio.run(self._resource("event.ics", etag).get_schedule_tag())
        # Strong etag: surrounded by double quotes, content is hex.
        self.assertTrue(tag.startswith('"') and tag.endswith('"'))
        self.assertEqual(64, len(tag) - 2)  # sha256 hex

    def test_schedule_tag_stable_across_dtstamp_change(self):
        etag1 = self._put("event.ics", SCHEDULING_BASE)
        tag1 = asyncio.run(self._resource("event.ics", etag1).get_schedule_tag())

        rewritten = SCHEDULING_BASE.replace(
            b"DTSTAMP:20260101T120000Z", b"DTSTAMP:20260201T120000Z"
        )
        etag2 = self._put("event.ics", rewritten)
        tag2 = asyncio.run(self._resource("event.ics", etag2).get_schedule_tag())

        self.assertEqual(tag1, tag2)

    def test_schedule_tag_changes_on_attendee_partstat(self):
        etag1 = self._put("event.ics", SCHEDULING_BASE)
        tag1 = asyncio.run(self._resource("event.ics", etag1).get_schedule_tag())

        replied = SCHEDULING_BASE.replace(
            b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED"
        )
        etag2 = self._put("event.ics", replied)
        tag2 = asyncio.run(self._resource("event.ics", etag2).get_schedule_tag())

        self.assertNotEqual(tag1, tag2)

    def test_schedule_tag_changes_on_description_change(self):
        # DESCRIPTION is in SCHEDULING_PROPERTIES — RFC 6638 considers it
        # part of the iTIP payload, so the tag must move.
        etag1 = self._put("event.ics", SCHEDULING_BASE)
        tag1 = asyncio.run(self._resource("event.ics", etag1).get_schedule_tag())

        edited = SCHEDULING_BASE.replace(b"DESCRIPTION:original", b"DESCRIPTION:edited")
        etag2 = self._put("event.ics", edited)
        tag2 = asyncio.run(self._resource("event.ics", etag2).get_schedule_tag())

        self.assertNotEqual(tag1, tag2)

    def test_schedule_tag_rejects_non_calendar(self):
        async def run():
            resource = ObjectResource(self.store, "x", "text/plain", "etag")
            with self.assertRaises(KeyError):
                await resource.get_schedule_tag()

        asyncio.run(run())


class ScheduleOutboxLookupTests(unittest.TestCase):
    """Integration tests for ScheduleOutbox.get_attendee_busy_periods."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        # Set a known default email so the principal's
        # calendar-user-address-set resolves to a predictable address.
        self.backend = SingleUserFilesystemBackend(
            self.tempdir, default_email="user@example.com"
        )
        # Pass an absolute path so the principal is registered under the same
        # key the resource lookup uses.
        self.backend.create_principal("/user", create_defaults=True)

    def _put_event(self, body):
        cal = self.backend.get_resource("/user/calendars/calendar")
        cal.store.import_one("event.ics", "text/calendar", [body])

    def _outbox(self):
        # SingleUserFilesystemBackend does not autocreate the outbox in
        # principal defaults, so create one explicitly here.
        from xandikos.store import STORE_TYPE_SCHEDULE_OUTBOX

        outbox_path = "/user/outbox"
        if self.backend.get_resource(outbox_path) is None:
            outbox_resource = self.backend.create_collection(outbox_path)
            outbox_resource.store.set_type(STORE_TYPE_SCHEDULE_OUTBOX)
        return self.backend.get_resource(outbox_path)

    def test_returns_none_for_unknown_attendee(self):
        outbox = self._outbox()
        from datetime import datetime, timezone

        start = datetime(2026, 6, 1, tzinfo=timezone.utc)
        end = datetime(2026, 6, 2, tzinfo=timezone.utc)
        result = asyncio.run(
            outbox.get_attendee_busy_periods("mailto:stranger@example.com", start, end)
        )
        self.assertIsNone(result)

    def test_returns_periods_for_principal_address(self):
        from datetime import datetime, timezone

        self._put_event(
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:e1@example.com\r\n"
            b"DTSTAMP:20260101T000000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"SUMMARY:Standup\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        outbox = self._outbox()
        start = datetime(2026, 6, 1, tzinfo=timezone.utc)
        end = datetime(2026, 6, 2, tzinfo=timezone.utc)
        result = asyncio.run(
            outbox.get_attendee_busy_periods("mailto:user@example.com", start, end)
        )
        self.assertIsNotNone(result)
        ical = b",".join(p.to_ical() for p in result).decode()
        self.assertIn("20260601T100000Z/20260601T110000Z", ical)

    def test_multiday_event_clipped_to_query_window(self):
        # RFC 4791 §7.10: response periods must fall within the time-range.
        from datetime import datetime, timezone

        self._put_event(
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:conf@example.com\r\n"
            b"DTSTAMP:20260101T000000Z\r\n"
            b"DTSTART:20260601T000000Z\r\n"
            b"DTEND:20260605T000000Z\r\n"
            b"SUMMARY:Conference\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        outbox = self._outbox()
        start = datetime(2026, 6, 2, tzinfo=timezone.utc)
        end = datetime(2026, 6, 4, tzinfo=timezone.utc)
        result = asyncio.run(
            outbox.get_attendee_busy_periods("mailto:user@example.com", start, end)
        )
        self.assertEqual(1, len(result))
        ical = result[0].to_ical().decode()
        self.assertEqual("20260602T000000Z/20260604T000000Z", ical)

    def test_finds_principal_when_outbox_not_directly_under_principal(self):
        # If the outbox lives at e.g. /user/scheduling/outbox rather than
        # /user/outbox, the lookup must still walk up to find the principal.
        from datetime import datetime, timezone
        from xandikos.store import STORE_TYPE_SCHEDULE_OUTBOX

        self._put_event(
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:e@example.com\r\n"
            b"DTSTAMP:20260101T000000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"SUMMARY:S\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        self.backend.create_collection("/user/scheduling")
        nested = self.backend.create_collection("/user/scheduling/outbox")
        nested.store.set_type(STORE_TYPE_SCHEDULE_OUTBOX)
        outbox = self.backend.get_resource("/user/scheduling/outbox")

        start = datetime(2026, 6, 1, tzinfo=timezone.utc)
        end = datetime(2026, 6, 2, tzinfo=timezone.utc)
        result = asyncio.run(
            outbox.get_attendee_busy_periods("mailto:user@example.com", start, end)
        )
        self.assertEqual(1, len(result))
        self.assertIn("20260601T100000Z/20260601T110000Z", result[0].to_ical().decode())


class CalendarCollectionPreDeleteHookTests(unittest.TestCase):
    """End-to-end tests for CalendarCollection.pre_delete_hook."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)

        # Pin distinct addresses on each principal so we don't depend on
        # the EMAIL environment variable.
        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
        }

        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        backend = self.backend
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)
        del backend  # silence linter

    def _alice_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/alice/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _put_event(self, body: bytes, name: str = "event.ics") -> None:
        self._alice_calendar().store.import_one(name, "text/calendar", [body])

    def _bob_inbox_messages(self) -> list[bytes]:
        inbox = self.backend.get_resource("/bob/inbox")
        assert isinstance(inbox, StoreBasedCollection)
        store = inbox.store
        return [
            b"".join(store._get_raw(name, etag))
            for name, _ct, etag in store.iter_with_etag()
        ]

    def test_delivers_cancel_to_attendee_inbox(self):
        self._put_event(
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:meeting@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"SEQUENCE:1\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"SUMMARY:Sync\r\n"
            b"ORGANIZER:mailto:alice@example.com\r\n"
            b"ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )

        asyncio.run(self._alice_calendar().pre_delete_hook("event.ics"))

        messages = self._bob_inbox_messages()
        self.assertEqual(1, len(messages))
        body = messages[0]
        self.assertIn(b"METHOD:CANCEL", body)
        self.assertIn(b"STATUS:CANCELLED", body)
        # SEQUENCE bumped from 1 to 2.
        self.assertIn(b"SEQUENCE:2", body)
        self.assertIn(b"UID:meeting@example.com", body)

    def test_no_delivery_when_user_is_attendee_not_organiser(self):
        # Alice is invited to bob's event; deleting it should not cancel
        # on bob's behalf.
        self._put_event(
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:bobs-meeting@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:bob@example.com\r\n"
            b"ATTENDEE:mailto:alice@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )

        asyncio.run(self._alice_calendar().pre_delete_hook("event.ics"))

        self.assertEqual([], self._bob_inbox_messages())

    def test_no_delivery_when_event_has_no_attendees(self):
        self._put_event(
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:solo@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:alice@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )

        asyncio.run(self._alice_calendar().pre_delete_hook("solo.ics"))

        self.assertEqual([], self._bob_inbox_messages())

    def test_remote_attendee_is_skipped_silently(self):
        # An attendee whose address doesn't match any local principal
        # should not raise — delivery just returns False.
        self._put_event(
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:mixed@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:alice@example.com\r\n"
            b"ATTENDEE:mailto:bob@example.com\r\n"
            b"ATTENDEE:mailto:carol@elsewhere.example\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )

        asyncio.run(self._alice_calendar().pre_delete_hook("event.ics"))

        # Bob still got his copy; carol just gets dropped.
        messages = self._bob_inbox_messages()
        self.assertEqual(1, len(messages))
        self.assertIn(b"METHOD:CANCEL", messages[0])

    def test_missing_member_is_silent(self):
        # No event with this name exists; hook should return without error.
        asyncio.run(self._alice_calendar().pre_delete_hook("does-not-exist.ics"))
        self.assertEqual([], self._bob_inbox_messages())


class CalendarCollectionPrePutHookTests(unittest.TestCase):
    """End-to-end tests for CalendarCollection.pre_put_hook (organiser path)."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)
        self.backend.create_principal("/carol", create_defaults=True)

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
            "/carol": ["mailto:carol@example.com"],
        }

        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _alice_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/alice/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _store_event(self, body: bytes, name: str = "event.ics") -> None:
        """Seed an event directly via the store (bypassing the hook)."""
        self._alice_calendar().store.import_one(name, "text/calendar", [body])

    def _inbox_messages(self, principal: str) -> list[bytes]:
        inbox = self.backend.get_resource(f"{principal}/inbox")
        assert isinstance(inbox, StoreBasedCollection)
        store = inbox.store
        return [
            b"".join(store._get_raw(name, etag))
            for name, _ct, etag in store.iter_with_etag()
        ]

    def _put(self, body: bytes, name: str = "event.ics") -> None:
        asyncio.run(self._alice_calendar().pre_put_hook(name, [body], "text/calendar"))

    BASE_EVENT = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meeting@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE:mailto:bob@example.com\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_create_sends_request_to_attendee(self):
        self._put(self.BASE_EVENT)
        messages = self._inbox_messages("/bob")
        self.assertEqual(1, len(messages))
        body = messages[0]
        self.assertIn(b"METHOD:REQUEST", body)
        self.assertIn(b"UID:meeting@example.com", body)
        # SEQUENCE preserved (transport doesn't bump for REQUEST).
        self.assertIn(b"SEQUENCE:1", body)

    def test_irrelevant_change_skips_delivery(self):
        # First, store the original event without going through the hook.
        self._store_event(self.BASE_EVENT)
        # Now PUT a copy that only differs by DTSTAMP — schedule-tag-stable.
        rewritten = self.BASE_EVENT.replace(
            b"DTSTAMP:20260101T120000Z", b"DTSTAMP:20260201T120000Z"
        )
        self._put(rewritten)
        self.assertEqual([], self._inbox_messages("/bob"))

    def test_material_change_sends_request(self):
        self._store_event(self.BASE_EVENT)
        edited = self.BASE_EVENT.replace(b"SUMMARY:Sync", b"SUMMARY:Sync (rescheduled)")
        self._put(edited)
        messages = self._inbox_messages("/bob")
        self.assertEqual(1, len(messages))
        self.assertIn(b"METHOD:REQUEST", messages[0])
        self.assertIn(b"SUMMARY:Sync (rescheduled)", messages[0])

    def test_dropped_attendee_gets_cancel_remaining_get_request(self):
        # Seed an event with both bob and carol as attendees.
        with_both = self.BASE_EVENT.replace(
            b"ATTENDEE:mailto:bob@example.com\r\n",
            b"ATTENDEE:mailto:bob@example.com\r\nATTENDEE:mailto:carol@example.com\r\n",
        )
        self._store_event(with_both)
        # Now PUT a version with only bob.
        self._put(self.BASE_EVENT)

        bob = self._inbox_messages("/bob")
        carol = self._inbox_messages("/carol")
        self.assertEqual(1, len(bob))
        self.assertEqual(1, len(carol))
        self.assertIn(b"METHOD:REQUEST", bob[0])
        self.assertIn(b"METHOD:CANCEL", carol[0])
        self.assertIn(b"STATUS:CANCELLED", carol[0])

    def test_no_delivery_when_user_is_attendee_not_organiser(self):
        # Bob's event; alice is just an attendee storing an invitation copy.
        body = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:bobs@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:bob@example.com\r\n"
            b"ATTENDEE:mailto:alice@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        self._put(body)
        self.assertEqual([], self._inbox_messages("/bob"))

    def test_no_delivery_when_event_has_no_attendees(self):
        body = self.BASE_EVENT.replace(b"ATTENDEE:mailto:bob@example.com\r\n", b"")
        self._put(body)
        self.assertEqual([], self._inbox_messages("/bob"))

    def test_remote_attendee_silently_skipped(self):
        # carol@elsewhere isn't a local principal — delivery returns False
        # without raising; bob still gets his REQUEST.
        with_remote = self.BASE_EVENT.replace(
            b"ATTENDEE:mailto:bob@example.com\r\n",
            b"ATTENDEE:mailto:bob@example.com\r\n"
            b"ATTENDEE:mailto:dave@elsewhere.example\r\n",
        )
        self._put(with_remote)
        self.assertEqual(1, len(self._inbox_messages("/bob")))

    def test_non_calendar_content_type_skipped(self):
        # Should not raise even if body is total garbage; non-calendar PUTs
        # are out of scope for the hook.
        asyncio.run(
            self._alice_calendar().pre_put_hook(
                "junk.txt", [b"not a calendar"], "text/plain"
            )
        )
        self.assertEqual([], self._inbox_messages("/bob"))

    def test_unparseable_calendar_silently_returns(self):
        asyncio.run(
            self._alice_calendar().pre_put_hook(
                "broken.ics", [b"BEGIN:VCALENDAR\r\nnot ical"], "text/calendar"
            )
        )
        self.assertEqual([], self._inbox_messages("/bob"))


class CalendarCollectionPrePutHookAttendeeReplyTests(unittest.TestCase):
    """End-to-end tests for the attendee-REPLY half of CalendarCollection.pre_put_hook."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
        }

        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _bob_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/bob/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _store_event(self, body: bytes, name: str = "event.ics") -> None:
        self._bob_calendar().store.import_one(name, "text/calendar", [body])

    def _inbox_messages(self, principal: str) -> list[bytes]:
        inbox = self.backend.get_resource(f"{principal}/inbox")
        assert isinstance(inbox, StoreBasedCollection)
        store = inbox.store
        return [
            b"".join(store._get_raw(name, etag))
            for name, _ct, etag in store.iter_with_etag()
        ]

    def _put(self, body: bytes, name: str = "event.ics") -> None:
        asyncio.run(self._bob_calendar().pre_put_hook(name, [body], "text/calendar"))

    INVITATION = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meeting@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_partstat_change_sends_reply_to_organiser(self):
        self._store_event(self.INVITATION)
        accepted = self.INVITATION.replace(
            b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED"
        )
        self._put(accepted)
        messages = self._inbox_messages("/alice")
        self.assertEqual(1, len(messages))
        body = messages[0]
        self.assertIn(b"METHOD:REPLY", body)
        # ATTENDEE narrowed to bob, with his ACCEPTED PARTSTAT.
        self.assertIn(b"ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob@example.com", body)
        self.assertIn(b"ORGANIZER:mailto:alice@example.com", body)

    def test_no_reply_when_partstat_unchanged(self):
        self._store_event(self.INVITATION)
        # PUT the same content with only DTSTAMP shifted — irrelevant change,
        # signature stays equal, so the hook short-circuits.
        rewritten = self.INVITATION.replace(
            b"DTSTAMP:20260101T120000Z", b"DTSTAMP:20260201T120000Z"
        )
        self._put(rewritten)
        self.assertEqual([], self._inbox_messages("/alice"))

    def test_no_reply_on_first_import(self):
        # No prior event in bob's calendar. PUTting an invitation with bob
        # already PARTSTAT=ACCEPTED is a fresh import, not a reply — the
        # server has no evidence bob actually changed his mind.
        accepted = self.INVITATION.replace(
            b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED"
        )
        self._put(accepted)
        self.assertEqual([], self._inbox_messages("/alice"))

    def test_no_reply_when_event_is_self_organised(self):
        # Bob organises an event he's also attending — no organiser to reply to.
        body = self.INVITATION.replace(
            b"ORGANIZER:mailto:alice@example.com", b"ORGANIZER:mailto:bob@example.com"
        )
        self._store_event(body)
        accepted = body.replace(b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED")
        self._put(accepted)
        self.assertEqual([], self._inbox_messages("/alice"))

    def test_reply_skipped_for_remote_organiser(self):
        # Organiser address isn't a local principal — delivery returns False
        # silently. We don't have an assertion target other than "no crash"
        # since there's no inbox to inspect.
        body = self.INVITATION.replace(
            b"ORGANIZER:mailto:alice@example.com",
            b"ORGANIZER:mailto:remote@elsewhere.example",
        )
        self._store_event(body)
        accepted = body.replace(b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED")
        self._put(accepted)
        # Alice is unaffected.
        self.assertEqual([], self._inbox_messages("/alice"))


class ScheduleInboxDefaultCalendarTests(unittest.TestCase):
    """Tests for ScheduleInbox.get_schedule_default_calendar_url (RFC 6638 §9.2)."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)

    def _inbox(self, principal: str = "/alice"):
        from xandikos.web import ScheduleInbox

        inbox = self.backend.get_resource(f"{principal}/inbox")
        assert isinstance(inbox, ScheduleInbox)
        return inbox

    def test_returns_default_calendar_when_one_exists(self):
        self.backend.create_principal("/alice", create_defaults=True)
        url = self._inbox().get_schedule_default_calendar_url()
        self.assertEqual("/alice/calendars/calendar", url)

    def test_returns_none_when_no_calendars(self):
        # Create just the principal + inbox; no calendar collections.
        self.backend.create_principal("/alice")
        self.backend.create_collection("/alice/inbox")
        from xandikos.store import STORE_TYPE_SCHEDULE_INBOX

        inbox_resource = self.backend.get_resource("/alice/inbox")
        assert isinstance(inbox_resource, StoreBasedCollection)
        inbox_resource.store.set_type(STORE_TYPE_SCHEDULE_INBOX)
        # Refetch so the type-dispatch picks ScheduleInbox.
        self.assertIsNone(self._inbox().get_schedule_default_calendar_url())

    def test_returns_none_without_defaults(self):
        # No --defaults: the principal has an inbox but no nominated default.
        self.backend.create_principal("/alice")
        self.backend.create_collection("/alice/inbox")
        from xandikos.store import STORE_TYPE_CALENDAR, STORE_TYPE_SCHEDULE_INBOX

        inbox_resource = self.backend.get_resource("/alice/inbox")
        assert isinstance(inbox_resource, StoreBasedCollection)
        inbox_resource.store.set_type(STORE_TYPE_SCHEDULE_INBOX)
        # Even with calendars present, no default is picked unless
        # explicitly nominated.
        cal = self.backend.create_collection("/alice/calendars/work")
        cal.store.set_type(STORE_TYPE_CALENDAR)
        self.assertIsNone(self._inbox().get_schedule_default_calendar_url())

    def test_principal_override(self):
        from xandikos.store import STORE_TYPE_CALENDAR

        self.backend.create_principal("/alice", create_defaults=True)
        extra = self.backend.create_collection("/alice/calendars/work")
        extra.store.set_type(STORE_TYPE_CALENDAR)

        # Override the seeded default.
        self._inbox().set_schedule_default_calendar_url("/alice/calendars/work")

        self.assertEqual(
            "/alice/calendars/work",
            self._inbox().get_schedule_default_calendar_url(),
        )

    def test_setting_none_unsets_default(self):
        self.backend.create_principal("/alice", create_defaults=True)
        inbox = self._inbox()
        inbox.set_schedule_default_calendar_url(None)
        self.assertIsNone(self._inbox().get_schedule_default_calendar_url())

    def test_override_persists_across_inbox_lookups(self):
        self.backend.create_principal("/alice", create_defaults=True)
        self._inbox().set_schedule_default_calendar_url("/alice/calendars/other")
        # Refetch the inbox object; the override should still be visible.
        self.assertEqual(
            "/alice/calendars/other",
            self._inbox().get_schedule_default_calendar_url(),
        )


class InboxAutoProcessingTests(unittest.TestCase):
    """End-to-end tests for RFC 6638 §3.1 implicit-scheduling.

    Verifies that iTIP messages delivered to an inbox don't just sit
    there — they get auto-applied to the recipient's default calendar:
    REQUEST creates a tentative copy, CANCEL marks the existing one
    cancelled, REPLY updates the organiser's stored copy.
    """

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
        }
        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _alice_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/alice/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _bob_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/bob/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _calendar_events(self, principal: str) -> list[dict]:
        """Return parsed VEVENTs from the principal's default calendar.

        Each entry is a dict of the iTIP-relevant fields, suitable for
        exact ``assertEqual`` comparison without depending on the raw
        iCalendar serialization (DTSTAMP, line ordering, etc.).
        """
        from icalendar.cal import Calendar as ICalendar

        cal = self.backend.get_resource(f"{principal}/calendars/calendar")
        assert isinstance(cal, StoreBasedCollection)
        store = cal.store
        events: list[dict] = []
        for name, _ct, etag in store.iter_with_etag():
            raw = b"".join(store._get_raw(name, etag))
            parsed = ICalendar.from_ical(raw.decode("utf-8"))
            method = parsed.get("METHOD")
            for comp in parsed.subcomponents:
                if comp.name != "VEVENT":
                    continue
                attendees = comp.get("ATTENDEE", [])
                if not isinstance(attendees, list):
                    attendees = [attendees]
                events.append(
                    {
                        "uid": str(comp["UID"]),
                        "method": str(method) if method is not None else None,
                        "status": str(comp["STATUS"]) if "STATUS" in comp else None,
                        "summary": str(comp["SUMMARY"]) if "SUMMARY" in comp else None,
                        "organizer": str(comp["ORGANIZER"])
                        if "ORGANIZER" in comp
                        else None,
                        "attendees": {
                            str(a): str(a.params.get("PARTSTAT", "NEEDS-ACTION"))
                            for a in attendees
                        },
                    }
                )
        return events

    INVITATION = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meet@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_request_creates_tentative_copy_in_attendee_calendar(self):
        # Alice PUTs a new event with bob as attendee. Bob should now see
        # the event in his own calendar, not just his inbox.
        asyncio.run(
            self._alice_calendar().pre_put_hook(
                "event.ics", [self.INVITATION], "text/calendar"
            )
        )
        self.assertEqual(
            [
                {
                    "uid": "meet@example.com",
                    # Stored calendar objects don't carry METHOD — that's an
                    # iTIP transport property, not stored alongside events
                    # (RFC 5545 §3.4).
                    "method": None,
                    "status": None,
                    "summary": "Sync",
                    "organizer": "mailto:alice@example.com",
                    "attendees": {"mailto:bob@example.com": "NEEDS-ACTION"},
                }
            ],
            self._calendar_events("/bob"),
        )

    def test_request_resend_preserves_attendee_partstat(self):
        # Bob has accepted alice's invitation in his calendar.
        accepted = self.INVITATION.replace(
            b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED"
        )
        self._bob_calendar().store.import_one("event.ics", "text/calendar", [accepted])
        # Alice now resends a REQUEST (typical: she edits the event).
        edited = self.INVITATION.replace(b"SUMMARY:Sync", b"SUMMARY:Sync (rev)")
        asyncio.run(
            self._alice_calendar().pre_put_hook("event.ics", [edited], "text/calendar")
        )
        # Bob's calendar copy now has the updated SUMMARY, but his
        # PARTSTAT is preserved.
        self.assertEqual(
            [
                {
                    "uid": "meet@example.com",
                    "method": None,
                    "status": None,
                    "summary": "Sync (rev)",
                    "organizer": "mailto:alice@example.com",
                    "attendees": {"mailto:bob@example.com": "ACCEPTED"},
                }
            ],
            self._calendar_events("/bob"),
        )

    def test_cancel_marks_attendee_copy_cancelled(self):
        # Bob has accepted alice's invitation.
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        # Alice deletes the event in her calendar — sends CANCEL.
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        asyncio.run(self._alice_calendar().pre_delete_hook("event.ics"))

        # The event is still there but marked cancelled; bob's client
        # decides whether to hide or surface it.
        self.assertEqual(
            [
                {
                    "uid": "meet@example.com",
                    "method": None,
                    "status": "CANCELLED",
                    "summary": "Sync",
                    "organizer": "mailto:alice@example.com",
                    "attendees": {"mailto:bob@example.com": "NEEDS-ACTION"},
                }
            ],
            self._calendar_events("/bob"),
        )

    def test_reply_updates_organiser_calendar_partstat(self):
        # Alice has the original event in her calendar.
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        # Bob has his copy too, will accept it.
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        accepted = self.INVITATION.replace(
            b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED"
        )
        # Bob PUTs the accepted version: triggers REPLY → alice's inbox →
        # auto-processing should update bob's PARTSTAT in alice's stored copy.
        asyncio.run(
            self._bob_calendar().pre_put_hook("event.ics", [accepted], "text/calendar")
        )
        self.assertEqual(
            [
                {
                    "uid": "meet@example.com",
                    "method": None,
                    "status": None,
                    "summary": "Sync",
                    "organizer": "mailto:alice@example.com",
                    "attendees": {"mailto:bob@example.com": "ACCEPTED"},
                }
            ],
            self._calendar_events("/alice"),
        )

    def test_cancel_with_no_existing_copy_is_noop(self):
        # Bob never had alice's event — CANCEL arrives anyway. Nothing
        # to cancel locally; inbox copy still records what happened.
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        # Alice deletes; CANCEL goes to bob.
        asyncio.run(self._alice_calendar().pre_delete_hook("event.ics"))
        # Bob's calendar is empty (no auto-create on CANCEL).
        self.assertEqual([], self._calendar_events("/bob"))


class ScheduleStatusAnnotationTests(unittest.TestCase):
    """Tests for SCHEDULE-STATUS annotation on organiser PUTs (RFC 6638 §3.2)."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
        }
        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _alice_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/alice/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _attendee_statuses(self, body: bytes) -> dict[str, dict[str, str]]:
        """Return ``{attendee_address: {param_name: value}}`` for a stored event."""
        from icalendar.cal import Calendar as ICalendar

        parsed = ICalendar.from_ical(body.decode("utf-8"))
        out: dict[str, dict[str, str]] = {}
        for comp in parsed.subcomponents:
            if comp.name != "VEVENT":
                continue
            attendees = comp.get("ATTENDEE", [])
            if not isinstance(attendees, list):
                attendees = [attendees]
            for a in attendees:
                out[str(a)] = {k: str(v) for k, v in a.params.items()}
        return out

    EVENT_TWO_ATTENDEES = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meet@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE:mailto:bob@example.com\r\n"
        b"ATTENDEE:mailto:dave@elsewhere.example\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_organiser_put_returns_annotated_bytes(self):
        rewritten = asyncio.run(
            self._alice_calendar().pre_put_hook(
                "event.ics", [self.EVENT_TWO_ATTENDEES], "text/calendar"
            )
        )
        self.assertIsNotNone(rewritten)
        body = b"".join(rewritten)
        statuses = self._attendee_statuses(body)
        # Bob is local: 1.2 delivered; dave is remote: 3.7 invalid.
        self.assertEqual(
            "2.0;Success", statuses["mailto:bob@example.com"]["SCHEDULE-STATUS"]
        )
        self.assertEqual(
            "3.7;Invalid calendar user",
            statuses["mailto:dave@elsewhere.example"]["SCHEDULE-STATUS"],
        )

    def test_attendee_reply_path_does_not_annotate(self):
        # Bob is the user here; he's an ATTENDEE, not the ORGANIZER.
        # The hook delivers a REPLY but doesn't annotate bob's stored copy
        # (only the organiser annotates after sending REQUEST/CANCEL).
        bob_cal = self.backend.get_resource("/bob/calendars/calendar")
        assert isinstance(bob_cal, CalendarCollection)
        bob_cal.store.import_one(
            "event.ics", "text/calendar", [self.EVENT_TWO_ATTENDEES]
        )
        accepted = self.EVENT_TWO_ATTENDEES.replace(
            b"ATTENDEE:mailto:bob@example.com",
            b"ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob@example.com",
        )
        rewritten = asyncio.run(
            bob_cal.pre_put_hook("event.ics", [accepted], "text/calendar")
        )
        self.assertIsNone(rewritten)

    def test_irrelevant_change_returns_none(self):
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.EVENT_TWO_ATTENDEES]
        )
        # Same content with only DTSTAMP shifted — schedule-tag-stable.
        rewritten = self.EVENT_TWO_ATTENDEES.replace(
            b"DTSTAMP:20260101T120000Z", b"DTSTAMP:20260201T120000Z"
        )
        result = asyncio.run(
            self._alice_calendar().pre_put_hook(
                "event.ics", [rewritten], "text/calendar"
            )
        )
        self.assertIsNone(result)

    def test_no_attendees_returns_none(self):
        body = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:solo@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:alice@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        result = asyncio.run(
            self._alice_calendar().pre_put_hook("solo.ics", [body], "text/calendar")
        )
        self.assertIsNone(result)

    def test_other_attendee_params_preserved(self):
        # ATTENDEE often carries CN, CUTYPE, ROLE, RSVP — annotation must
        # not strip them.
        body = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:meet@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:alice@example.com\r\n"
            b"ATTENDEE;CN=Bob;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bob@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        rewritten = asyncio.run(
            self._alice_calendar().pre_put_hook("event.ics", [body], "text/calendar")
        )
        self.assertIsNotNone(rewritten)
        statuses = self._attendee_statuses(b"".join(rewritten))
        self.assertEqual(
            {
                "CN": "Bob",
                "ROLE": "REQ-PARTICIPANT",
                "RSVP": "TRUE",
                "SCHEDULE-STATUS": "2.0;Success",
            },
            statuses["mailto:bob@example.com"],
        )


class FreeBusyTranspAndDeclinedTests(unittest.TestCase):
    """ScheduleOutbox.get_attendee_busy_periods honours TRANSP/DECLINED."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(
            self.tempdir, default_email="user@example.com"
        )
        self.backend.create_principal("/user", create_defaults=True)

    def _put_event(self, body, calendar_path="/user/calendars/calendar", name="e.ics"):
        cal = self.backend.get_resource(calendar_path)
        cal.store.import_one(name, "text/calendar", [body])

    def _outbox(self):
        from xandikos.store import STORE_TYPE_SCHEDULE_OUTBOX

        path = "/user/outbox"
        if self.backend.get_resource(path) is None:
            r = self.backend.create_collection(path)
            r.store.set_type(STORE_TYPE_SCHEDULE_OUTBOX)
        return self.backend.get_resource(path)

    def _query(self):
        from datetime import datetime, timezone

        return asyncio.run(
            self._outbox().get_attendee_busy_periods(
                "mailto:user@example.com",
                datetime(2026, 6, 1, tzinfo=timezone.utc),
                datetime(2026, 6, 2, tzinfo=timezone.utc),
            )
        )

    EVENT = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:e@example.com\r\n"
        b"DTSTAMP:20260101T000000Z\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:S\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_transparent_calendar_excluded(self):
        from xandikos.store import STORE_TYPE_CALENDAR
        from xandikos import caldav
        from xandikos.web import CalendarCollection

        # Create a second calendar that should report TRANSPARENT.
        # CalendarCollection currently hardcodes the value in
        # get_schedule_calendar_transparency; monkey-patch that to flip
        # it for one specific calendar.
        holidays = self.backend.create_collection("/user/calendars/holidays")
        holidays.store.set_type(STORE_TYPE_CALENDAR)

        original = CalendarCollection.get_schedule_calendar_transparency

        def transp(cal_self):
            if cal_self.relpath == "/user/calendars/holidays":
                return caldav.TRANSPARENCY_TRANSPARENT
            return caldav.TRANSPARENCY_OPAQUE

        CalendarCollection.get_schedule_calendar_transparency = transp
        self.addCleanup(
            setattr, CalendarCollection, "get_schedule_calendar_transparency", original
        )

        # Put the event ONLY in the holidays calendar.
        self._put_event(self.EVENT, calendar_path="/user/calendars/holidays")

        result = self._query()
        # Holidays-only event must not contribute busy time.
        self.assertEqual([], [p.to_ical().decode() for p in result])

    def test_opaque_calendar_still_contributes(self):
        # Same event but in the default OPAQUE calendar.
        self._put_event(self.EVENT)
        result = self._query()
        self.assertEqual(
            ["20260601T100000Z/20260601T110000Z"],
            [p.to_ical().decode() for p in result],
        )

    def test_declined_event_excluded(self):
        body = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:e@example.com\r\n"
            b"DTSTAMP:20260101T000000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:other@example.com\r\n"
            b"ATTENDEE;PARTSTAT=DECLINED:mailto:user@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        self._put_event(body)
        result = self._query()
        self.assertEqual([], [p.to_ical().decode() for p in result])

    def test_accepted_event_still_busy(self):
        body = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:e@example.com\r\n"
            b"DTSTAMP:20260101T000000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"ORGANIZER:mailto:other@example.com\r\n"
            b"ATTENDEE;PARTSTAT=ACCEPTED:mailto:user@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        self._put_event(body)
        result = self._query()
        self.assertEqual(
            ["20260601T100000Z/20260601T110000Z"],
            [p.to_ical().decode() for p in result],
        )


class PrincipalCalendarUserAddressSetTests(unittest.TestCase):
    """Tests for the per-principal calendar-user-address-set storage."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(
            self.tempdir, default_email="alice@env.example"
        )
        self.backend.create_principal("/alice")
        self.principal = self.backend.get_principal("/alice")

    def test_falls_back_to_default_email_when_no_config(self):
        self.assertEqual(
            ["mailto:alice@env.example"],
            self.principal.get_calendar_user_address_set(),
        )

    def test_returns_empty_when_no_config_and_no_default_email(self):
        self.backend.default_email = None
        self.assertEqual([], self.principal.get_calendar_user_address_set())

    def test_set_then_get_round_trips(self):
        addresses = [
            "mailto:alice@example.com",
            "mailto:a.lice@work.example",
            "/principals/alice/",
        ]
        self.principal.set_calendar_user_address_set(addresses)
        self.assertEqual(addresses, self.principal.get_calendar_user_address_set())

    def test_set_overrides_default_email(self):
        self.principal.set_calendar_user_address_set(["mailto:new@example.com"])
        self.assertEqual(
            ["mailto:new@example.com"],
            self.principal.get_calendar_user_address_set(),
        )

    def test_set_empty_restores_default_email_fallback(self):
        self.principal.set_calendar_user_address_set(["mailto:new@example.com"])
        self.principal.set_calendar_user_address_set([])
        self.assertEqual(
            ["mailto:alice@env.example"],
            self.principal.get_calendar_user_address_set(),
        )

    def test_addresses_persist_across_principal_lookups(self):
        # The Principal object is stateless above the file; refetching
        # it should still see the persisted addresses.
        self.principal.set_calendar_user_address_set(["mailto:p@example.com"])
        refetched = self.backend.get_principal("/alice")
        self.assertEqual(
            ["mailto:p@example.com"],
            refetched.get_calendar_user_address_set(),
        )

    def test_other_xandikos_config_keys_preserved_when_setting(self):
        # If the principal's .xandikos already has unrelated config in
        # other sections, set_calendar_user_address_set must not stomp it.
        import configparser

        config_path = os.path.join(self.tempdir, "alice", ".xandikos")
        cp = configparser.ConfigParser()
        cp.add_section("other")
        cp.set("other", "preserve", "yes")
        with open(config_path, "w") as f:
            cp.write(f)

        self.principal.set_calendar_user_address_set(["mailto:p@example.com"])

        cp2 = configparser.ConfigParser()
        cp2.read(config_path)
        self.assertEqual("yes", cp2.get("other", "preserve"))
        self.assertEqual("mailto:p@example.com", cp2.get("scheduling", "addresses"))


class AttendeeWriteRestrictionTests(unittest.TestCase):
    """RFC 6638 §3.1: an attendee's PUT may only change their own ATTENDEE."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
        }
        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _bob_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/bob/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    INVITATION = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meet@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
        b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:carol@example.com\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def _put(self, body):
        return asyncio.run(
            self._bob_calendar().pre_put_hook("event.ics", [body], "text/calendar")
        )

    def _seed(self):
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )

    def test_partstat_change_allowed(self):
        self._seed()
        accepted = self.INVITATION.replace(
            b"PARTSTAT=NEEDS-ACTION:mailto:bob@example.com",
            b"PARTSTAT=ACCEPTED:mailto:bob@example.com",
        )
        # Should not raise — the only diff is bob's own PARTSTAT.
        self._put(accepted)

    def test_dtstart_change_rejected(self):
        self._seed()
        moved = self.INVITATION.replace(
            b"DTSTART:20260601T100000Z", b"DTSTART:20260601T140000Z"
        )
        with self.assertRaises(webdav.PreconditionFailure) as ctx:
            self._put(moved)
        self.assertEqual(
            "{urn:ietf:params:xml:ns:caldav}attendee-allowed",
            ctx.exception.precondition,
        )

    def test_summary_change_rejected(self):
        self._seed()
        renamed = self.INVITATION.replace(b"SUMMARY:Sync", b"SUMMARY:Hijacked")
        with self.assertRaises(webdav.PreconditionFailure):
            self._put(renamed)

    def test_organiser_change_rejected(self):
        self._seed()
        usurped = self.INVITATION.replace(
            b"ORGANIZER:mailto:alice@example.com",
            b"ORGANIZER:mailto:bob@example.com",
        )
        with self.assertRaises(webdav.PreconditionFailure):
            self._put(usurped)

    def test_dropping_other_attendee_rejected(self):
        self._seed()
        without_carol = self.INVITATION.replace(
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:carol@example.com\r\n",
            b"",
        )
        with self.assertRaises(webdav.PreconditionFailure):
            self._put(without_carol)

    def test_changing_other_attendee_partstat_rejected(self):
        # Bob can update his own PARTSTAT but not carol's.
        self._seed()
        forged = self.INVITATION.replace(
            b"PARTSTAT=NEEDS-ACTION:mailto:carol@example.com",
            b"PARTSTAT=DECLINED:mailto:carol@example.com",
        )
        with self.assertRaises(webdav.PreconditionFailure):
            self._put(forged)

    def test_first_import_not_restricted(self):
        # No prior copy in bob's calendar — restriction only applies to
        # updates of an existing scheduling object.
        self._put(self.INVITATION)  # should not raise

    def test_self_organised_event_unrestricted(self):
        # Bob is the organiser of his own event. Restrictions only apply
        # when the user is an attendee, not the organiser.
        body = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:bobs@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"SUMMARY:Solo\r\n"
            b"ORGANIZER:mailto:bob@example.com\r\n"
            b"ATTENDEE:mailto:bob@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        self._bob_calendar().store.import_one("event.ics", "text/calendar", [body])
        moved = body.replace(b"DTSTART:20260601T100000Z", b"DTSTART:20260601T140000Z")
        # Should not raise — bob is the organiser of this one.
        asyncio.run(
            self._bob_calendar().pre_put_hook("event.ics", [moved], "text/calendar")
        )


class PrincipalCalendarUserTypeTests(unittest.TestCase):
    """Tests for per-principal calendar-user-type storage."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice")
        self.principal = self.backend.get_principal("/alice")

    def test_default_is_individual(self):
        from xandikos import scheduling

        self.assertEqual(
            scheduling.CALENDAR_USER_TYPE_INDIVIDUAL,
            self.principal.get_calendar_user_type(),
        )

    def test_set_then_get_round_trips(self):
        from xandikos import scheduling

        self.principal.set_calendar_user_type(scheduling.CALENDAR_USER_TYPE_RESOURCE)
        self.assertEqual(
            scheduling.CALENDAR_USER_TYPE_RESOURCE,
            self.principal.get_calendar_user_type(),
        )

    def test_set_none_restores_default(self):
        from xandikos import scheduling

        self.principal.set_calendar_user_type(scheduling.CALENDAR_USER_TYPE_GROUP)
        self.principal.set_calendar_user_type(None)
        self.assertEqual(
            scheduling.CALENDAR_USER_TYPE_INDIVIDUAL,
            self.principal.get_calendar_user_type(),
        )

    def test_invalid_cutype_rejected(self):
        with self.assertRaises(ValueError):
            self.principal.set_calendar_user_type("BANANA")

    def test_addresses_and_user_type_coexist(self):
        # Both keys live under [scheduling]; setting one shouldn't
        # clobber the other.
        from xandikos import scheduling

        self.principal.set_calendar_user_address_set(["mailto:a@example.com"])
        self.principal.set_calendar_user_type(scheduling.CALENDAR_USER_TYPE_ROOM)
        self.assertEqual(
            ["mailto:a@example.com"],
            self.principal.get_calendar_user_address_set(),
        )
        self.assertEqual(
            scheduling.CALENDAR_USER_TYPE_ROOM,
            self.principal.get_calendar_user_type(),
        )


class ScheduleForceSendTests(unittest.TestCase):
    """RFC 6638 §3.2.4: SCHEDULE-FORCE-SEND on PUT triggers re-delivery."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
        }
        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _alice_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/alice/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _bob_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/bob/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _inbox_event_methods(self, principal: str) -> list[str]:
        """Return the iTIP METHOD value of each message in *principal*'s inbox."""
        from icalendar.cal import Calendar as ICalendar

        inbox = self.backend.get_resource(f"{principal}/inbox")
        assert isinstance(inbox, StoreBasedCollection)
        store = inbox.store
        out: list[str] = []
        for name, _ct, etag in store.iter_with_etag():
            raw = b"".join(store._get_raw(name, etag))
            cal = ICalendar.from_ical(raw.decode("utf-8"))
            method = cal.get("METHOD")
            out.append(str(method) if method is not None else "")
        return out

    def _stored_force_send_params(self, rewritten: "list[bytes] | None") -> list[str]:
        """Return SCHEDULE-FORCE-SEND values present on attendees of rewritten output."""
        from icalendar.cal import Calendar as ICalendar

        if rewritten is None:
            return []
        cal = ICalendar.from_ical(b"".join(rewritten).decode("utf-8"))
        out: list[str] = []
        for comp in cal.subcomponents:
            if comp.name != "VEVENT":
                continue
            attendees = comp.get("ATTENDEE", [])
            if not isinstance(attendees, list):
                attendees = [attendees]
            for a in attendees:
                value = a.params.get("SCHEDULE-FORCE-SEND")
                if value is not None:
                    out.append(str(value))
        return out

    EVENT = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meet@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_organiser_force_send_request_re_delivers_unchanged_event(self):
        # Seed alice's stored event.
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.EVENT]
        )
        # Same event PUT with SCHEDULE-FORCE-SEND=REQUEST on bob — should
        # bypass the no-change short-circuit and deliver to bob.
        forced = self.EVENT.replace(
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com",
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION;SCHEDULE-FORCE-SEND=REQUEST:"
            b"mailto:bob@example.com",
        )
        rewritten = asyncio.run(
            self._alice_calendar().pre_put_hook("event.ics", [forced], "text/calendar")
        )
        self.assertEqual(["REQUEST"], self._inbox_event_methods("/bob"))
        # The stored event must not carry SCHEDULE-FORCE-SEND.
        self.assertEqual([], self._stored_force_send_params(rewritten))

    def test_no_force_send_unchanged_event_skips_delivery(self):
        # Sanity check: without FORCE-SEND, the same PUT short-circuits.
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.EVENT]
        )
        # PUT identical content — only DTSTAMP differs (sig-stable).
        rewritten = self.EVENT.replace(
            b"DTSTAMP:20260101T120000Z", b"DTSTAMP:20260202T120000Z"
        )
        result = asyncio.run(
            self._alice_calendar().pre_put_hook(
                "event.ics", [rewritten], "text/calendar"
            )
        )
        self.assertIsNone(result)
        self.assertEqual([], self._inbox_event_methods("/bob"))

    def test_attendee_force_send_reply_bypasses_partstat_gate(self):
        # Bob has the invitation already-accepted in his calendar.
        accepted = self.EVENT.replace(
            b"PARTSTAT=NEEDS-ACTION:mailto:bob@example.com",
            b"PARTSTAT=ACCEPTED:mailto:bob@example.com",
        )
        self._bob_calendar().store.import_one("event.ics", "text/calendar", [accepted])
        # Bob PUTs the same content with SCHEDULE-FORCE-SEND=REPLY on
        # his own ATTENDEE — the PARTSTAT didn't change, but the server
        # should still send a REPLY to alice.
        forced = accepted.replace(
            b"ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob@example.com",
            b"ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-FORCE-SEND=REPLY:"
            b"mailto:bob@example.com",
        )
        rewritten = asyncio.run(
            self._bob_calendar().pre_put_hook("event.ics", [forced], "text/calendar")
        )
        self.assertEqual(["REPLY"], self._inbox_event_methods("/alice"))
        # The stored event must not carry SCHEDULE-FORCE-SEND.
        self.assertEqual([], self._stored_force_send_params(rewritten))

    def test_force_send_param_stripped_on_first_put(self):
        # First-time PUT with FORCE-SEND — the REQUEST goes out anyway
        # (it's a new event), and the stored bytes must be clean.
        forced = self.EVENT.replace(
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com",
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION;SCHEDULE-FORCE-SEND=REQUEST:"
            b"mailto:bob@example.com",
        )
        rewritten = asyncio.run(
            self._alice_calendar().pre_put_hook("event.ics", [forced], "text/calendar")
        )
        self.assertEqual([], self._stored_force_send_params(rewritten))
        self.assertEqual(["REQUEST"], self._inbox_event_methods("/bob"))

    def test_unrecognised_force_send_value_silently_dropped(self):
        # Per RFC 6638 §3.2.4 the only legal values are REQUEST and REPLY.
        # An unknown value should not crash; the param is still stripped
        # but no forced delivery is triggered.
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.EVENT]
        )
        # Otherwise unchanged event with a bogus FORCE-SEND value.
        bogus = self.EVENT.replace(
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com",
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION;SCHEDULE-FORCE-SEND=BANANA:"
            b"mailto:bob@example.com",
        ).replace(b"DTSTAMP:20260101T120000Z", b"DTSTAMP:20260202T120000Z")
        rewritten = asyncio.run(
            self._alice_calendar().pre_put_hook("event.ics", [bogus], "text/calendar")
        )
        # Param stripped from stored output, no forced delivery.
        self.assertEqual([], self._stored_force_send_params(rewritten))
        self.assertEqual([], self._inbox_event_methods("/bob"))


class AttendeeDelegationTests(unittest.TestCase):
    """RFC 6638 §3.2.6: an attendee may add a delegate ATTENDEE on PUT.

    Bob has been invited to alice's event; he can't attend and
    delegates to dave by:
    - changing his own ATTENDEE to PARTSTAT=DELEGATED;DELEGATED-TO=…
    - adding an ATTENDEE for dave with DELEGATED-FROM=mailto:bob@…

    The server should:
    - allow the PUT (the new ATTENDEE is exempt from §3.1's
      attendee-write restriction)
    - send a REPLY to alice carrying bob's PARTSTAT=DELEGATED
    - send a REQUEST to dave so the meeting appears on his calendar
    - annotate SCHEDULE-STATUS on dave's ATTENDEE in bob's stored copy
    """

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)
        self.backend.create_principal("/dave", create_defaults=True)

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
            "/dave": ["mailto:dave@example.com"],
        }
        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _bob_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/bob/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _inbox_event_methods(self, principal: str) -> list[str]:
        from icalendar.cal import Calendar as ICalendar

        inbox = self.backend.get_resource(f"{principal}/inbox")
        assert isinstance(inbox, StoreBasedCollection)
        store = inbox.store
        out: list[str] = []
        for name, _ct, etag in store.iter_with_etag():
            raw = b"".join(store._get_raw(name, etag))
            cal = ICalendar.from_ical(raw.decode("utf-8"))
            method = cal.get("METHOD")
            out.append(str(method) if method is not None else "")
        return out

    def _stored_attendee_params(
        self, rewritten: "list[bytes] | None"
    ) -> dict[str, dict[str, str]]:
        """Return ``{address: {param: value}}`` for ATTENDEEs in *rewritten*."""
        from icalendar.cal import Calendar as ICalendar

        if rewritten is None:
            return {}
        cal = ICalendar.from_ical(b"".join(rewritten).decode("utf-8"))
        out: dict[str, dict[str, str]] = {}
        for comp in cal.subcomponents:
            if comp.name != "VEVENT":
                continue
            attendees = comp.get("ATTENDEE", [])
            if not isinstance(attendees, list):
                attendees = [attendees]
            for a in attendees:
                out[str(a)] = {k: str(v) for k, v in a.params.items()}
        return out

    INVITATION = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meet@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    DELEGATED = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meet@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b'ATTENDEE;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:dave@example.com":'
        b"mailto:bob@example.com\r\n"
        b'ATTENDEE;PARTSTAT=NEEDS-ACTION;DELEGATED-FROM="mailto:bob@example.com":'
        b"mailto:dave@example.com\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_delegation_delivers_request_to_delegate_and_reply_to_organiser(self):
        # Bob has alice's invitation in his calendar.
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        # Bob delegates to dave.
        rewritten = asyncio.run(
            self._bob_calendar().pre_put_hook(
                "event.ics", [self.DELEGATED], "text/calendar"
            )
        )
        # Both the organiser (REPLY) and the delegate (REQUEST) received iTIP.
        self.assertEqual(["REPLY"], self._inbox_event_methods("/alice"))
        self.assertEqual(["REQUEST"], self._inbox_event_methods("/dave"))
        # Bob's stored copy carries SCHEDULE-STATUS on dave's ATTENDEE.
        params = self._stored_attendee_params(rewritten)
        self.assertEqual(
            "2.0;Success",
            params["mailto:dave@example.com"]["SCHEDULE-STATUS"],
        )

    def test_delegation_to_remote_address_skipped_silently(self):
        # Bob delegates to a non-local address.
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        delegated_remote = self.DELEGATED.replace(
            b"mailto:dave@example.com", b"mailto:remote@elsewhere.example"
        )
        rewritten = asyncio.run(
            self._bob_calendar().pre_put_hook(
                "event.ics", [delegated_remote], "text/calendar"
            )
        )
        # Alice still gets the REPLY.
        self.assertEqual(["REPLY"], self._inbox_event_methods("/alice"))
        # The remote delegate has no inbox to receive into; SCHEDULE-STATUS
        # records 3.7.
        params = self._stored_attendee_params(rewritten)
        self.assertEqual(
            "3.7;Invalid calendar user",
            params["mailto:remote@elsewhere.example"]["SCHEDULE-STATUS"],
        )

    def test_attendee_cannot_smuggle_non_delegate_attendee_addition(self):
        # Bob tries to add eve as an ATTENDEE without DELEGATED-FROM —
        # that's an organiser-only change and should be refused.
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [self.INVITATION]
        )
        smuggled = self.INVITATION.replace(
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n",
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
            b"ATTENDEE:mailto:eve@example.com\r\n",
        )
        with self.assertRaises(webdav.PreconditionFailure) as ctx:
            asyncio.run(
                self._bob_calendar().pre_put_hook(
                    "event.ics", [smuggled], "text/calendar"
                )
            )
        self.assertEqual(
            "{urn:ietf:params:xml:ns:caldav}attendee-allowed",
            ctx.exception.precondition,
        )

    def test_delegation_first_put_delivers_to_delegate(self):
        # No prior version in bob's calendar; the §3.1 restriction
        # only applies on update. Even on first PUT, the delegate
        # receives a REQUEST.
        rewritten = asyncio.run(
            self._bob_calendar().pre_put_hook(
                "event.ics", [self.DELEGATED], "text/calendar"
            )
        )
        self.assertEqual(["REQUEST"], self._inbox_event_methods("/dave"))
        # And alice's REPLY only fires when there's a prior version to
        # compare PARTSTAT against. First PUT → no REPLY.
        self.assertEqual([], self._inbox_event_methods("/alice"))
        # Stored copy reflects the delivery.
        params = self._stored_attendee_params(rewritten)
        self.assertEqual(
            "2.0;Success",
            params["mailto:dave@example.com"]["SCHEDULE-STATUS"],
        )


class OutboundIMIPTests(unittest.TestCase):
    """Implicit scheduling delivers iTIP to remote attendees via iMIP.

    A non-local ATTENDEE used to be flagged 3.7;Invalid calendar user.
    With --imip-send configured, the dispatcher hands the iTIP to the
    transport and records 1.1;Sent (or 5.1 on transport failure)
    instead. The captured email carries METHOD: REQUEST/REPLY/CANCEL,
    Reply-To: of the originator, and Auto-Submitted: auto-generated to
    keep an inbound Sieve hook from looping.
    """

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/alice", create_defaults=True)
        self.backend.create_principal("/bob", create_defaults=True)

        from xandikos import imip_transport

        self.transport = imip_transport.CapturingTransport()
        self.backend.imip_transport = self.transport
        self.backend.imip_from = "calendar@server.example"

        self._addresses = {
            "/alice": ["mailto:alice@example.com"],
            "/bob": ["mailto:bob@example.com"],
        }
        from xandikos.web import Principal

        original = Principal.get_calendar_user_address_set
        addresses = self._addresses

        def fake_addresses(principal_self):
            return addresses.get(principal_self.relpath, [])

        Principal.get_calendar_user_address_set = fake_addresses
        self.addCleanup(setattr, Principal, "get_calendar_user_address_set", original)

    def _alice_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/alice/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _bob_calendar(self) -> CalendarCollection:
        cal = self.backend.get_resource("/bob/calendars/calendar")
        assert isinstance(cal, CalendarCollection)
        return cal

    def _stored_attendee_params(self, body: bytes) -> dict[str, dict[str, str]]:
        from icalendar.cal import Calendar as ICalendar

        parsed = ICalendar.from_ical(body.decode("utf-8"))
        out: dict[str, dict[str, str]] = {}
        for comp in parsed.subcomponents:
            if comp.name != "VEVENT":
                continue
            attendees = comp.get("ATTENDEE", [])
            if not isinstance(attendees, list):
                attendees = [attendees]
            for a in attendees:
                out[str(a)] = {k: str(v) for k, v in a.params.items()}
        return out

    EVENT_REMOTE_ATTENDEE = (
        b"BEGIN:VCALENDAR\r\n"
        b"VERSION:2.0\r\n"
        b"PRODID:-//Test//EN\r\n"
        b"BEGIN:VEVENT\r\n"
        b"UID:meet@example.com\r\n"
        b"DTSTAMP:20260101T120000Z\r\n"
        b"SEQUENCE:1\r\n"
        b"DTSTART:20260601T100000Z\r\n"
        b"DTEND:20260601T110000Z\r\n"
        b"SUMMARY:Sync\r\n"
        b"ORGANIZER:mailto:alice@example.com\r\n"
        b"ATTENDEE:mailto:carol@elsewhere.example\r\n"
        b"END:VEVENT\r\n"
        b"END:VCALENDAR\r\n"
    )

    def test_organiser_request_sent_to_remote_attendee(self):
        rewritten = asyncio.run(
            self._alice_calendar().pre_put_hook(
                "event.ics", [self.EVENT_REMOTE_ATTENDEE], "text/calendar"
            )
        )
        self.assertEqual(1, len(self.transport.sent))
        msg = self.transport.sent[0]
        self.assertEqual("calendar@server.example", msg["From"])
        self.assertEqual("carol@elsewhere.example", msg["To"])
        self.assertEqual("alice@example.com", msg["Reply-To"])
        self.assertEqual("auto-generated", msg["Auto-Submitted"])
        self.assertEqual("text/calendar", msg.get_content_type())
        self.assertEqual("REQUEST", msg.get_param("method", header="content-type"))

        params = self._stored_attendee_params(b"".join(rewritten))
        self.assertEqual(
            "1.1;Sent",
            params["mailto:carol@elsewhere.example"]["SCHEDULE-STATUS"],
        )

    def test_transport_failure_marks_schedule_status_5_1(self):
        from xandikos import imip_transport

        class FailingTransport:
            def send(self, message):
                raise imip_transport.IMIPTransportError("relay refused")

        self.backend.imip_transport = FailingTransport()
        with self.assertLogs("xandikos", level="WARNING"):
            rewritten = asyncio.run(
                self._alice_calendar().pre_put_hook(
                    "event.ics", [self.EVENT_REMOTE_ATTENDEE], "text/calendar"
                )
            )
        params = self._stored_attendee_params(b"".join(rewritten))
        self.assertEqual(
            "5.1;Service unavailable",
            params["mailto:carol@elsewhere.example"]["SCHEDULE-STATUS"],
        )

    def test_disabled_transport_falls_back_to_3_7(self):
        # NullTransport is what --imip-send=off configures; behaviour
        # matches the pre-iMIP code path.
        from xandikos import imip_transport

        self.backend.imip_transport = imip_transport.NullTransport()
        rewritten = asyncio.run(
            self._alice_calendar().pre_put_hook(
                "event.ics", [self.EVENT_REMOTE_ATTENDEE], "text/calendar"
            )
        )
        self.assertEqual(0, len(self.transport.sent))
        params = self._stored_attendee_params(b"".join(rewritten))
        self.assertEqual(
            "3.7;Invalid calendar user",
            params["mailto:carol@elsewhere.example"]["SCHEDULE-STATUS"],
        )

    def test_missing_smtp_from_logs_warning_and_marks_3_7(self):
        # Operator misconfiguration: --imip-send is on, but
        # --smtp-from was not set. The dispatcher refuses to send
        # without a server identity, logs a warning, and labels the
        # ATTENDEE 3.7 instead of silently sending mail with an empty
        # From: header.
        self.backend.imip_from = None
        with self.assertLogs("xandikos", level="WARNING") as logs:
            rewritten = asyncio.run(
                self._alice_calendar().pre_put_hook(
                    "event.ics", [self.EVENT_REMOTE_ATTENDEE], "text/calendar"
                )
            )
        self.assertEqual(0, len(self.transport.sent))
        params = self._stored_attendee_params(b"".join(rewritten))
        self.assertEqual(
            "3.7;Invalid calendar user",
            params["mailto:carol@elsewhere.example"]["SCHEDULE-STATUS"],
        )
        self.assertTrue(
            any("smtp-from" in line for line in logs.output),
            logs.output,
        )

    def test_organiser_delete_sends_cancel_to_remote_attendee(self):
        # Seed alice's calendar with the event, then delete it.
        self._alice_calendar().store.import_one(
            "event.ics", "text/calendar", [self.EVENT_REMOTE_ATTENDEE]
        )
        asyncio.run(self._alice_calendar().pre_delete_hook("event.ics"))
        self.assertEqual(1, len(self.transport.sent))
        msg = self.transport.sent[0]
        self.assertEqual("CANCEL", msg.get_param("method", header="content-type"))
        self.assertEqual("carol@elsewhere.example", msg["To"])

    def test_attendee_reply_sent_to_remote_organiser(self):
        # Bob is invited by an external organiser; bob accepts.
        invitation = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:meet@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"SEQUENCE:1\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"SUMMARY:Sync\r\n"
            b"ORGANIZER:mailto:carol@elsewhere.example\r\n"
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [invitation]
        )
        accepted = invitation.replace(b"PARTSTAT=NEEDS-ACTION", b"PARTSTAT=ACCEPTED")
        asyncio.run(
            self._bob_calendar().pre_put_hook("event.ics", [accepted], "text/calendar")
        )
        self.assertEqual(1, len(self.transport.sent))
        msg = self.transport.sent[0]
        self.assertEqual("REPLY", msg.get_param("method", header="content-type"))
        self.assertEqual("carol@elsewhere.example", msg["To"])
        self.assertEqual("bob@example.com", msg["Reply-To"])

    def test_delegation_to_remote_delegate(self):
        # Alice invites bob; bob delegates to a remote dave.
        invitation = (
            b"BEGIN:VCALENDAR\r\n"
            b"VERSION:2.0\r\n"
            b"PRODID:-//Test//EN\r\n"
            b"BEGIN:VEVENT\r\n"
            b"UID:meet@example.com\r\n"
            b"DTSTAMP:20260101T120000Z\r\n"
            b"SEQUENCE:1\r\n"
            b"DTSTART:20260601T100000Z\r\n"
            b"DTEND:20260601T110000Z\r\n"
            b"SUMMARY:Sync\r\n"
            b"ORGANIZER:mailto:alice@example.com\r\n"
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n"
            b"END:VEVENT\r\n"
            b"END:VCALENDAR\r\n"
        )
        self._bob_calendar().store.import_one(
            "event.ics", "text/calendar", [invitation]
        )
        delegated = invitation.replace(
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com\r\n",
            b"ATTENDEE;PARTSTAT=DELEGATED;"
            b'DELEGATED-TO="mailto:dave@elsewhere.example":mailto:bob@example.com\r\n'
            b"ATTENDEE;PARTSTAT=NEEDS-ACTION;"
            b'DELEGATED-FROM="mailto:bob@example.com":mailto:dave@elsewhere.example\r\n',
        )
        asyncio.run(
            self._bob_calendar().pre_put_hook("event.ics", [delegated], "text/calendar")
        )
        # Two messages on the wire: REQUEST to dave (the new delegate)
        # and REPLY to alice (the organiser, who is local — REPLY goes
        # to her inbox, not the transport). Only the REQUEST shows up
        # in the capturing transport because alice is local.
        recipients = [
            (m["To"], m.get_param("method", header="content-type"))
            for m in self.transport.sent
        ]
        self.assertEqual([("dave@elsewhere.example", "REQUEST")], recipients)


class PrincipalHomeSetTests(unittest.TestCase):
    """Per-principal calendar/addressbook-home-set overrides via .xandikos."""

    def setUp(self):
        super().setUp()
        self.tempdir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.tempdir)
        self.backend = SingleUserFilesystemBackend(self.tempdir)
        self.backend.create_principal("/user", create_defaults=False)
        self.principal = self.backend.get_resource("/user")

    def test_defaults_when_no_config(self):
        self.assertEqual(["calendars"], self.principal.get_calendar_home_set())
        self.assertEqual(["contacts"], self.principal.get_addressbook_home_set())

    def test_calendar_home_set_override(self):
        self.principal.set_calendar_home_set(["my-cals"])
        self.assertEqual(["my-cals"], self.principal.get_calendar_home_set())
        # addressbook is unaffected.
        self.assertEqual(["contacts"], self.principal.get_addressbook_home_set())

    def test_addressbook_home_set_override(self):
        self.principal.set_addressbook_home_set(["my-contacts"])
        self.assertEqual(["my-contacts"], self.principal.get_addressbook_home_set())
        self.assertEqual(["calendars"], self.principal.get_calendar_home_set())

    def test_multiple_homes(self):
        self.principal.set_calendar_home_set(["work-cal", "home-cal"])
        self.principal.set_addressbook_home_set(["work-ab", "home-ab"])
        self.assertEqual(
            ["work-cal", "home-cal"], self.principal.get_calendar_home_set()
        )
        self.assertEqual(
            ["work-ab", "home-ab"], self.principal.get_addressbook_home_set()
        )

    def test_override_persists_across_lookups(self):
        """Override survives backend re-reading the .xandikos file."""
        self.principal.set_calendar_home_set(["persistent"])
        # Re-resolve the principal via the backend to bypass any
        # in-memory caching.
        fresh = self.backend.get_resource("/user")
        self.assertEqual(["persistent"], fresh.get_calendar_home_set())

    def test_unset_restores_default(self):
        self.principal.set_calendar_home_set(["my-cals"])
        self.principal.set_calendar_home_set([])
        self.assertEqual(["calendars"], self.principal.get_calendar_home_set())

    def test_create_principal_honours_pre_seeded_override(self):
        """A pre-seeded .xandikos override drives the principal layout.

        The realistic configure-from-file path: an operator writes a
        ``.xandikos`` config under the principal directory before the
        server creates the principal. ``create_principal`` then
        creates home dirs at the configured paths instead of the
        defaults.
        """
        import configparser

        principal_dir = os.path.join(self.tempdir, "alice")
        os.makedirs(principal_dir)
        cp = configparser.ConfigParser()
        cp.add_section("principal")
        cp.set("principal", "calendar-home-set", "my-cals")
        cp.set("principal", "addressbook-home-set", "my-contacts")
        with open(os.path.join(principal_dir, ".xandikos"), "w") as f:
            cp.write(f)

        backend = SingleUserFilesystemBackend(self.tempdir)
        backend.create_principal("/alice", create_defaults=True)
        self.assertIsNotNone(backend.get_resource("/alice/my-cals/calendar"))
        self.assertIsNotNone(backend.get_resource("/alice/my-contacts/addressbook"))
        # Old default paths are not created.
        self.assertIsNone(backend.get_resource("/alice/calendars"))
        self.assertIsNone(backend.get_resource("/alice/contacts"))
