#!/usr/bin/env python3
"""
Local fixture harness for the calendar plugin requirements.

Commands:
  python3 fixture/server.py init
  python3 fixture/server.py reset
  python3 fixture/server.py seed
  python3 fixture/server.py run --host 127.0.0.1 --port 8080
"""

from __future__ import annotations

import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import smtplib
import sqlite3
import ssl
import textwrap
import time
import calendar as pycalendar
from collections import deque
from email.message import EmailMessage
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, unquote, urlparse


ROOT = Path(__file__).resolve().parent
DB_PATH = ROOT / "fixture.db"
DEFAULT_TIMEZONE = "Europe/London"
SHARED_CALENDAR_ID = "public"
API_BASE = "/wp-json/calendar/v1"
PBKDF2_ITERATIONS = 210_000
VERIFY_TOKEN_TTL_SECONDS = 24 * 60 * 60
RESET_TOKEN_TTL_SECONDS = 30 * 60
_RATE_LIMIT_STATE: Dict[str, List[float]] = {}


def fixture_trace_log_path() -> Path:
    configured = (os.getenv("FIXTURE_HTTP_TRACE_LOG") or "").strip()
    if configured:
        p = Path(configured)
        return p if p.is_absolute() else (ROOT / p)
    return ROOT / "http_trace.log"


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def hash_password(value: str) -> str:
    # Stronger hash format for fixture: pbkdf2_sha256$iterations$salt_hex$digest_hex
    salt = secrets.token_bytes(16)
    digest = hashlib.pbkdf2_hmac("sha256", value.encode("utf-8"), salt, PBKDF2_ITERATIONS)
    return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"


def verify_password(value: str, stored_hash: str) -> bool:
    if not stored_hash:
        return False
    if stored_hash.startswith("pbkdf2_sha256$"):
        parts = stored_hash.split("$")
        if len(parts) != 4:
            return False
        try:
            iterations = int(parts[1])
            salt = bytes.fromhex(parts[2])
            expected = bytes.fromhex(parts[3])
        except ValueError:
            return False
        actual = hashlib.pbkdf2_hmac("sha256", value.encode("utf-8"), salt, iterations)
        return hmac.compare_digest(actual, expected)
    # Backward-compatible legacy support for old unsalted SHA-256 hashes.
    if re.fullmatch(r"[0-9a-f]{64}", stored_hash):
        legacy = hashlib.sha256(value.encode("utf-8")).hexdigest()
        return hmac.compare_digest(legacy, stored_hash)
    return False


def _rate_limited(scope: str, key: str, limit: int, window_seconds: int) -> bool:
    now = time.monotonic()
    bucket = f"{scope}:{key}"
    hits = _RATE_LIMIT_STATE.get(bucket, [])
    cutoff = now - window_seconds
    hits = [t for t in hits if t >= cutoff]
    blocked = len(hits) >= limit
    hits.append(now)
    _RATE_LIMIT_STATE[bucket] = hits[-max(limit * 2, 16) :]
    return blocked


def mk_etag(seed: str) -> str:
    digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
    return f'"{digest}"'


def parse_basic_auth(header_value: Optional[str]) -> Optional[Tuple[str, str]]:
    if not header_value or not header_value.startswith("Basic "):
        return None
    encoded = header_value[6:].strip()
    try:
        raw = base64.b64decode(encoded).decode("utf-8")
    except Exception:
        return None
    if ":" not in raw:
        return None
    username, password = raw.split(":", 1)
    return username, password


def _read_env_file(path: Path) -> Dict[str, str]:
    out: Dict[str, str] = {}
    if not path.exists():
        return out
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        out[key.strip()] = value.strip().strip('"').strip("'")
    return out


def _smtp_settings() -> Dict[str, str]:
    env = _read_env_file(ROOT.parent / "credentials" / ".env")
    merged = dict(env)
    merged.update({k: v for k, v in os.environ.items() if k.startswith("SMTP_")})
    return merged


def send_fixture_email(to_email: str, subject: str, body_text: str) -> Tuple[bool, str]:
    cfg = _smtp_settings()
    host = (cfg.get("SMTP_HOST") or "").strip()
    port = int((cfg.get("SMTP_PORT") or "587").strip())
    username = (cfg.get("SMTP_USERNAME") or "").strip()
    password = cfg.get("SMTP_PASSWORD") or ""
    from_email = (cfg.get("SMTP_FROM") or "").strip()
    use_tls = (cfg.get("SMTP_USE_TLS") or "true").strip().lower() in {"1", "true", "yes", "on"}
    if not host or not from_email or not to_email:
        return False, "smtp_not_configured"
    msg = EmailMessage()
    msg["From"] = from_email
    msg["To"] = to_email
    msg["Subject"] = subject
    msg.set_content(body_text)
    try:
        with smtplib.SMTP(host, port, timeout=10) as server:
            if use_tls:
                server.starttls(context=ssl.create_default_context())
            if username:
                server.login(username, password)
            server.send_message(msg)
        return True, "sent"
    except Exception as exc:  # pragma: no cover - environment dependent
        return False, f"smtp_error:{exc.__class__.__name__}:{exc}"


def ensure_db() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def create_schema(conn: sqlite3.Connection) -> None:
    cur = conn.cursor()
    cur.executescript(
        """
        CREATE TABLE IF NOT EXISTS events (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          uid TEXT NOT NULL UNIQUE,
          title TEXT NOT NULL,
          description TEXT NOT NULL DEFAULT '',
          location TEXT NOT NULL DEFAULT '',
          category TEXT NOT NULL DEFAULT '',
          all_day_event INTEGER NOT NULL DEFAULT 0,
          start_datetime TEXT NOT NULL,
          end_datetime TEXT NOT NULL,
          repeat_type TEXT NOT NULL DEFAULT 'none',
          repeat_interval INTEGER NOT NULL DEFAULT 1,
          repeat_nth_mode TEXT NOT NULL DEFAULT '',
          repeat_nth_day INTEGER NULL,
          repeat_nth_pos INTEGER NULL,
          repeat_nth_weekday INTEGER NULL,
          repeat_range_mode TEXT NOT NULL DEFAULT 'none',
          repeat_count INTEGER NULL,
          repeat_until TEXT NULL,
          timezone TEXT NOT NULL DEFAULT 'Europe/London',
          calendar_id TEXT NOT NULL DEFAULT 'public',
          etag TEXT NOT NULL,
          sync_version INTEGER NOT NULL DEFAULT 1,
          last_modified_by_user_id INTEGER NULL,
          created_at TEXT NOT NULL,
          updated_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS recurrence_exceptions (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          event_id INTEGER NOT NULL,
          occurrence_key TEXT NOT NULL,
          exception_type TEXT NOT NULL,
          override_payload TEXT NULL,
          created_at TEXT NOT NULL,
          updated_at TEXT NOT NULL,
          UNIQUE (event_id, occurrence_key)
        );

        CREATE TABLE IF NOT EXISTS caldav_users (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          email TEXT NOT NULL UNIQUE,
          password_hash TEXT NOT NULL,
          email_verified_at TEXT NULL,
          account_status TEXT NOT NULL,
          access_level TEXT NOT NULL,
          request_state TEXT NOT NULL DEFAULT 'none',
          created_at TEXT NOT NULL,
          updated_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS user_tokens (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id INTEGER NOT NULL,
          token_type TEXT NOT NULL,
          token_hash TEXT NOT NULL,
          expires_at TEXT NOT NULL,
          used_at TEXT NULL,
          created_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS audit_log (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          actor_type TEXT NOT NULL,
          actor_id TEXT NOT NULL,
          action TEXT NOT NULL,
          target_type TEXT NOT NULL,
          target_id TEXT NOT NULL,
          result TEXT NOT NULL,
          context_json TEXT NOT NULL,
          created_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS plugin_settings (
          key TEXT PRIMARY KEY,
          value TEXT NOT NULL,
          updated_at TEXT NOT NULL
        );
        """
    )
    cols = {r["name"] for r in conn.execute("PRAGMA table_info(events)").fetchall()}
    if "caldav_resource" not in cols:
        conn.execute("ALTER TABLE events ADD COLUMN caldav_resource TEXT NULL")
    conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_events_caldav_resource ON events(caldav_resource)")
    conn.execute("UPDATE events SET caldav_resource = id || '.ics' WHERE caldav_resource IS NULL")
    conn.commit()


def drop_all(conn: sqlite3.Connection) -> None:
    cur = conn.cursor()
    cur.executescript(
        """
        DROP TABLE IF EXISTS audit_log;
        DROP TABLE IF EXISTS user_tokens;
        DROP TABLE IF EXISTS caldav_users;
        DROP TABLE IF EXISTS recurrence_exceptions;
        DROP TABLE IF EXISTS events;
        DROP TABLE IF EXISTS plugin_settings;
        """
    )
    conn.commit()


def log_audit(
    conn: sqlite3.Connection,
    actor_type: str,
    actor_id: str,
    action: str,
    target_type: str,
    target_id: str,
    result: str,
    context: Dict[str, Any],
) -> None:
    conn.execute(
        """
        INSERT INTO audit_log (actor_type, actor_id, action, target_type, target_id, result, context_json, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            actor_type,
            actor_id,
            action,
            target_type,
            target_id,
            result,
            json.dumps(context, sort_keys=True),
            utc_now_iso(),
        ),
    )
    conn.commit()


def seed_data(conn: sqlite3.Connection) -> None:
    now = utc_now_iso()
    users = [
        ("rw_user@example.test", "rwpass123456", now, "active", "write", "approved"),
        ("adrians@chezstephens.org.uk", "brillig1", now, "active", "write", "approved"),
        ("pending_user@example.test", "pending123456", now, "pending_approval", "write", "requested"),
    ]
    for email, pw, verified, status, level, request_state in users:
        conn.execute(
            """
            INSERT OR IGNORE INTO caldav_users
              (email, password_hash, email_verified_at, account_status, access_level, request_state, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (email, hash_password(pw), verified, status, level, request_state, now, now),
        )

    fixture_events: List[Dict[str, Any]] = [
        {
            "id": 1,
            "uid": "fixture-ce-001@calendar-wp-plugin",
            "title": "Board Meeting",
            "description": "Quarterly board review.",
            "location": "Room A",
            "category": "Governance",
            "all_day_event": 0,
            "start_datetime": "2026-04-01T10:00:00+01:00",
            "end_datetime": "2026-04-01T11:30:00+01:00",
            "repeat_type": "none",
            "repeat_interval": 1,
            "repeat_range_mode": "none",
            "repeat_count": None,
            "repeat_until": None,
        },
        {
            "id": 2,
            "uid": "fixture-ce-002@calendar-wp-plugin",
            "title": "Office Closed",
            "description": "Public holiday closure.",
            "location": "HQ",
            "category": "Operations",
            "all_day_event": 1,
            "start_datetime": "2026-05-04",
            "end_datetime": "2026-05-05",
            "repeat_type": "none",
            "repeat_interval": 1,
            "repeat_range_mode": "none",
            "repeat_count": None,
            "repeat_until": None,
        },
        {
            "id": 3,
            "uid": "fixture-ce-003@calendar-wp-plugin",
            "title": "Daily Standup",
            "description": "15 minute sync.",
            "location": "Online",
            "category": "Team",
            "all_day_event": 0,
            "start_datetime": "2026-04-06T09:00:00+01:00",
            "end_datetime": "2026-04-06T09:15:00+01:00",
            "repeat_type": "daily",
            "repeat_interval": 1,
            "repeat_range_mode": "count",
            "repeat_count": 10,
            "repeat_until": None,
        },
        {
            "id": 4,
            "uid": "fixture-ce-004@calendar-wp-plugin",
            "title": "Community Lunch",
            "description": "Weekly community lunch.",
            "location": "Cafeteria",
            "category": "Community",
            "all_day_event": 0,
            "start_datetime": "2026-04-08T12:30:00+01:00",
            "end_datetime": "2026-04-08T13:30:00+01:00",
            "repeat_type": "weekly",
            "repeat_interval": 1,
            "repeat_range_mode": "no_end",
            "repeat_count": None,
            "repeat_until": None,
        },
        {
            "id": 5,
            "uid": "fixture-ce-005@calendar-wp-plugin",
            "title": "Finance Close",
            "description": "Month-end close process.",
            "location": "Finance Office",
            "category": "Finance",
            "all_day_event": 0,
            "start_datetime": "2026-04-30T17:00:00+01:00",
            "end_datetime": "2026-04-30T18:00:00+01:00",
            "repeat_type": "monthly",
            "repeat_interval": 1,
            "repeat_range_mode": "until",
            "repeat_count": None,
            "repeat_until": "2026-08-31",
        },
        {
            "id": 6,
            "uid": "fixture-ce-006@calendar-wp-plugin",
            "title": "Annual Conference",
            "description": "Annual community conference.",
            "location": "Main Hall",
            "category": "Events",
            "all_day_event": 0,
            "start_datetime": "2026-06-15T10:00:00+01:00",
            "end_datetime": "2026-06-15T17:00:00+01:00",
            "repeat_type": "yearly",
            "repeat_interval": 1,
            "repeat_range_mode": "count",
            "repeat_count": 3,
            "repeat_until": None,
        },
        {
            "id": 7,
            "uid": "fixture-ce-007@calendar-wp-plugin",
            "title": "Fortnightly Coaching",
            "description": "Coaching check-in.",
            "location": "Online",
            "category": "Training",
            "all_day_event": 0,
            "start_datetime": "2026-04-07T15:00:00+01:00",
            "end_datetime": "2026-04-07T16:00:00+01:00",
            "repeat_type": "custom",
            "repeat_interval": 2,
            "repeat_range_mode": "until",
            "repeat_count": None,
            "repeat_until": "2026-07-31",
        },
        {
            "id": 8,
            "uid": "fixture-ce-008@calendar-wp-plugin",
            "title": "DST Validation Event",
            "description": "Validates DST transition rendering.",
            "location": "Lab",
            "category": "QA",
            "all_day_event": 0,
            "start_datetime": "2026-10-25T00:30:00+01:00",
            "end_datetime": "2026-10-25T02:30:00+00:00",
            "repeat_type": "none",
            "repeat_interval": 1,
            "repeat_range_mode": "none",
            "repeat_count": None,
            "repeat_until": None,
        },
        {
            "id": 9,
            "uid": "fixture-ce-009@calendar-wp-plugin",
            "title": "Leap Day Marker",
            "description": "Leap day recurrence behavior.",
            "location": "Calendar",
            "category": "QA",
            "all_day_event": 0,
            "start_datetime": "2028-02-29T09:00:00+00:00",
            "end_datetime": "2028-02-29T10:00:00+00:00",
            "repeat_type": "yearly",
            "repeat_interval": 1,
            "repeat_range_mode": "count",
            "repeat_count": 3,
            "repeat_until": None,
        },
        {
            "id": 10,
            "uid": "fixture-ce-010@calendar-wp-plugin",
            "title": "Therapy Session",
            "description": "Used for single-occurrence delete exception tests.",
            "location": "Clinic",
            "category": "Health",
            "all_day_event": 0,
            "start_datetime": "2026-04-03T14:00:00+01:00",
            "end_datetime": "2026-04-03T15:00:00+01:00",
            "repeat_type": "weekly",
            "repeat_interval": 1,
            "repeat_range_mode": "count",
            "repeat_count": 8,
            "repeat_until": None,
        },
    ]

    for event in fixture_events:
        etag = mk_etag(f"{event['uid']}:{now}:1")
        conn.execute(
            """
            INSERT OR REPLACE INTO events
              (id, uid, title, description, location, category, all_day_event, start_datetime, end_datetime,
               repeat_type, repeat_interval, repeat_nth_mode, repeat_nth_day, repeat_nth_pos, repeat_nth_weekday,
               repeat_range_mode, repeat_count, repeat_until, timezone,
               calendar_id, caldav_resource, etag, sync_version, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                event["id"],
                event["uid"],
                event["title"],
                event["description"],
                event["location"],
                event["category"],
                event["all_day_event"],
                event["start_datetime"],
                event["end_datetime"],
                event["repeat_type"],
                event["repeat_interval"],
                event.get("repeat_nth_mode", ""),
                event.get("repeat_nth_day"),
                event.get("repeat_nth_pos"),
                event.get("repeat_nth_weekday"),
                event["repeat_range_mode"],
                event["repeat_count"],
                event["repeat_until"],
                DEFAULT_TIMEZONE,
                SHARED_CALENDAR_ID,
                f"{event['id']}.ics",
                etag,
                1,
                now,
                now,
            ),
        )

    conn.execute("DELETE FROM recurrence_exceptions")
    conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("presentation_name", "Calendar", now))
    conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("url_slug", "", now))
    conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("ics_access_mode", "public_read", now))
    conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("caldav_calendar_name", "Public Calendar", now))
    conn.commit()


def event_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
    return {
        "id": row["id"],
        "uid": row["uid"],
        "title": row["title"],
        "description": row["description"],
        "location": row["location"],
        "category": row["category"],
        "all_day_event": bool(row["all_day_event"]),
        "start_datetime": row["start_datetime"],
        "end_datetime": row["end_datetime"],
        "repeat_type": row["repeat_type"],
        "repeat_interval": row["repeat_interval"],
        "repeat_nth_mode": row["repeat_nth_mode"],
        "repeat_nth_day": row["repeat_nth_day"],
        "repeat_nth_pos": row["repeat_nth_pos"],
        "repeat_nth_weekday": row["repeat_nth_weekday"],
        "repeat_range_mode": row["repeat_range_mode"],
        "repeat_count": row["repeat_count"],
        "repeat_until": row["repeat_until"],
        "timezone": row["timezone"],
        "calendar_id": row["calendar_id"],
        "etag": row["etag"],
        "sync_version": row["sync_version"],
        "updated_at": row["updated_at"],
    }


def get_setting(conn: sqlite3.Connection, key: str, default_value: str) -> str:
    row = conn.execute("SELECT value FROM plugin_settings WHERE key = ?", (key,)).fetchone()
    if not row:
        return default_value
    return row["value"]


def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
    conn.execute(
        "INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)",
        (key, value, utc_now_iso()),
    )
    conn.commit()


def _parse_dt_maybe_date(value: str) -> datetime:
    if "T" in value:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    return datetime.fromisoformat(f"{value}T00:00:00+00:00")


def _canonical_occurrence_key(value: str) -> Optional[str]:
    raw = (value or "").strip()
    if not raw:
        return None
    try:
        dt = _parse_dt_maybe_date(raw)
    except ValueError:
        return None
    return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat()


def _normalize_occurrence_key_for_event(value: str, event_start_iso: str) -> Optional[str]:
    raw = (value or "").strip()
    if not raw:
        return None
    if "T" in raw:
        return _canonical_occurrence_key(raw)
    # Allow date-only delete keys by inheriting time+offset from event start.
    try:
        base = _parse_dt_maybe_date(event_start_iso)
        d = date.fromisoformat(raw)
    except ValueError:
        return None
    candidate = base.replace(year=d.year, month=d.month, day=d.day)
    return candidate.astimezone(timezone.utc).replace(microsecond=0).isoformat()


def _normalize_monthly_anchor(
    start_iso: str,
    end_iso: str,
    repeat_type: str,
    repeat_nth_mode: str,
    repeat_nth_day: Optional[int],
    repeat_nth_pos: Optional[int],
    repeat_nth_weekday: Optional[int],
) -> Tuple[str, str]:
    if repeat_type != "monthly":
        return start_iso, end_iso
    try:
        start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
        end_dt = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
    except ValueError:
        return start_iso, end_iso
    duration = end_dt - start_dt
    adjusted_start = start_dt
    if repeat_nth_mode == "day_of_month" and repeat_nth_day:
        day = max(1, min(int(repeat_nth_day), pycalendar.monthrange(start_dt.year, start_dt.month)[1]))
        adjusted_start = start_dt.replace(day=day)
    elif repeat_nth_mode == "weekday_of_month" and repeat_nth_pos and repeat_nth_weekday is not None:
        nth_day = _nth_weekday_of_month(start_dt.year, start_dt.month, int(repeat_nth_weekday), int(repeat_nth_pos))
        if nth_day is not None:
            adjusted_start = start_dt.replace(day=nth_day)
    adjusted_end = adjusted_start + duration
    return adjusted_start.isoformat(), adjusted_end.isoformat()


def _add_months(dt: datetime, months: int) -> datetime:
    year = dt.year + (dt.month - 1 + months) // 12
    month = (dt.month - 1 + months) % 12 + 1
    day = min(dt.day, pycalendar.monthrange(year, month)[1])
    return dt.replace(year=year, month=month, day=day)


def _add_years(dt: datetime, years: int) -> datetime:
    try:
        return dt.replace(year=dt.year + years)
    except ValueError:
        # Leap-day fallback
        return dt.replace(month=2, day=28, year=dt.year + years)


def _nth_weekday_of_month(year: int, month: int, weekday: int, pos: int) -> Optional[int]:
    # weekday: 0=Sunday..6=Saturday, pos: 1..5 or -1 (last)
    if not (0 <= weekday <= 6 and (1 <= pos <= 5 or pos == -1)):
        return None
    py_weekday = (weekday - 1) % 7  # python: Monday=0..Sunday=6
    if pos == -1:
        days_in_month = pycalendar.monthrange(year, month)[1]
        for day in range(days_in_month, 0, -1):
            if date(year, month, day).weekday() == py_weekday:
                return day
        return None
    day = 1
    hits = 0
    days_in_month = pycalendar.monthrange(year, month)[1]
    while day <= days_in_month:
        dt = date(year, month, day)
        if dt.weekday() == py_weekday:
            hits += 1
            if hits == pos:
                return day
        day += 1
    return None


def _window_for_view(view: str, anchor: date) -> Tuple[datetime, datetime]:
    if view == "day":
        start = datetime.fromisoformat(f"{anchor.isoformat()}T00:00:00+00:00")
        return start, start + timedelta(days=1)
    if view == "week":
        monday = anchor - timedelta(days=anchor.weekday())
        start = datetime.fromisoformat(f"{monday.isoformat()}T00:00:00+00:00")
        return start, start + timedelta(days=7)
    if view == "month":
        first = anchor.replace(day=1)
        start = datetime.fromisoformat(f"{first.isoformat()}T00:00:00+00:00")
        end = _add_months(start, 1)
        return start, end
    if view == "year":
        first = date(anchor.year, 1, 1)
        start = datetime.fromisoformat(f"{first.isoformat()}T00:00:00+00:00")
        end = _add_years(start, 1)
        return start, end
    start = datetime.fromisoformat(f"{anchor.isoformat()}T00:00:00+00:00")
    return start - timedelta(days=30), start + timedelta(days=90)


def _occurrence_overlap(s1: datetime, e1: datetime, s2: datetime, e2: datetime) -> bool:
    return s1 < e2 and e1 > s2


def make_uid(seed: str) -> str:
    digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:20]
    return f"{digest}@calendar-wp-plugin"


def create_ics_event(event: Dict[str, Any], exdates: List[str]) -> str:
    lines = [
        "BEGIN:VEVENT",
        f"UID:{event['uid']}",
        f"DTSTAMP:{_to_ics_ts(utc_now_iso())}",
        f"SUMMARY:{_ics_escape(event['title'])}",
    ]
    if event.get("description"):
        lines.append(f"DESCRIPTION:{_ics_escape(event['description'])}")
    if event.get("location"):
        lines.append(f"LOCATION:{_ics_escape(event['location'])}")
    if event.get("category"):
        lines.append(f"CATEGORIES:{_ics_escape(event['category'])}")

    if event.get("all_day_event"):
        lines.append(f"DTSTART;VALUE=DATE:{_to_ics_date(event['start_datetime'])}")
        lines.append(f"DTEND;VALUE=DATE:{_to_ics_date(event['end_datetime'])}")
    else:
        lines.append(f"DTSTART;TZID={DEFAULT_TIMEZONE}:{_to_ics_local(event['start_datetime'])}")
        lines.append(f"DTEND;TZID={DEFAULT_TIMEZONE}:{_to_ics_local(event['end_datetime'])}")

    rrule = _build_rrule(event)
    if rrule:
        lines.append(f"RRULE:{rrule}")
    if exdates:
        start_tz = None
        try:
            start_tz = datetime.fromisoformat(event["start_datetime"].replace("Z", "+00:00")).tzinfo
        except ValueError:
            start_tz = None
        normalized_exdates = []
        for x in exdates:
            try:
                dt = datetime.fromisoformat(x.replace("Z", "+00:00"))
                if start_tz is not None and dt.tzinfo is not None:
                    dt = dt.astimezone(start_tz)
                normalized_exdates.append(dt.strftime("%Y%m%dT%H%M%S"))
            except ValueError:
                continue
        joined = ",".join(normalized_exdates)
        if joined:
            lines.append(f"EXDATE;TZID={DEFAULT_TIMEZONE}:{joined}")

    lines.append("END:VEVENT")
    return "\r\n".join(lines)


def _build_rrule(event: Dict[str, Any]) -> Optional[str]:
    freq_map = {
        "daily": "DAILY",
        "weekly": "WEEKLY",
        "monthly": "MONTHLY",
        "yearly": "YEARLY",
        "custom": "WEEKLY",  # First-pass simplification for fixture.
    }
    repeat_type = event.get("repeat_type")
    if repeat_type not in freq_map or repeat_type == "none":
        return None
    parts = [f"FREQ={freq_map[repeat_type]}"]
    interval = int(event.get("repeat_interval") or 1)
    if interval > 1:
        parts.append(f"INTERVAL={interval}")
    if repeat_type == "monthly":
        nth_mode = event.get("repeat_nth_mode") or ""
        if nth_mode == "day_of_month" and event.get("repeat_nth_day"):
            parts.append(f"BYMONTHDAY={int(event['repeat_nth_day'])}")
        elif nth_mode == "weekday_of_month" and event.get("repeat_nth_pos") and event.get("repeat_nth_weekday") is not None:
            weekday_map = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
            pos = int(event["repeat_nth_pos"])
            wd = int(event["repeat_nth_weekday"])
            if 0 <= wd <= 6 and (1 <= pos <= 5 or pos == -1):
                parts.append(f"BYDAY={weekday_map[wd]}")
                parts.append(f"BYSETPOS={pos}")
    mode = event.get("repeat_range_mode")
    if mode == "count" and event.get("repeat_count"):
        parts.append(f"COUNT={int(event['repeat_count'])}")
    elif mode == "until" and event.get("repeat_until"):
        # date-only UNTIL for first pass
        until = event["repeat_until"].replace("-", "")
        parts.append(f"UNTIL={until}T235959")
    return ";".join(parts)


def _to_ics_ts(iso_ts: str) -> str:
    dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00")).astimezone(timezone.utc)
    return dt.strftime("%Y%m%dT%H%M%SZ")


def _to_ics_local(iso_ts: str) -> str:
    dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
    return dt.strftime("%Y%m%dT%H%M%S")


def _to_ics_date(iso_ts: str) -> str:
    if "T" in iso_ts:
        dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
        return dt.strftime("%Y%m%d")
    return iso_ts.replace("-", "")


def _ics_escape(value: str) -> str:
    return (
        value.replace("\\", "\\\\")
        .replace(";", r"\;")
        .replace(",", r"\,")
        .replace("\n", r"\n")
    )


@dataclass
class AuthContext:
    actor_type: str
    actor_id: str
    role: str


class _TraceWriter:
    def __init__(self, handler: "FixtureHandler", raw):
        self._handler = handler
        self._raw = raw

    def write(self, data):
        self._handler._trace_capture_response_bytes(data)
        return self._raw.write(data)

    def flush(self):
        return self._raw.flush()

    def __getattr__(self, name):
        return getattr(self._raw, name)


class FixtureHandler(BaseHTTPRequestHandler):
    server_version = "CalendarFixture/0.1"
    _trace_body_cap = 4096
    _trace_body_preview_cap = 1200

    def setup(self) -> None:
        super().setup()
        self.wfile = _TraceWriter(self, self.wfile)
        self._trace_started = False
        self._trace_request_body = b""
        self._trace_response_status: Optional[int] = None
        self._trace_response_headers: List[Tuple[str, str]] = []
        self._trace_response_body = bytearray()
        self._cached_request_body: Optional[bytes] = None

    def _trace_start(self) -> None:
        self._trace_started = True
        self._trace_request_body = b""
        self._trace_response_status = None
        self._trace_response_headers = []
        self._trace_response_body = bytearray()
        self._cached_request_body = None

    def _trace_capture_response_bytes(self, data: Any) -> None:
        if not self._trace_started:
            return
        raw = data.encode("utf-8", errors="replace") if isinstance(data, str) else bytes(data)
        remaining = self._trace_body_cap - len(self._trace_response_body)
        if remaining > 0:
            self._trace_response_body.extend(raw[:remaining])

    def send_response(self, code: int, message: Optional[str] = None) -> None:
        if getattr(self, "_trace_started", False):
            self._trace_response_status = int(code)
        super().send_response(code, message)

    def send_header(self, keyword: str, value: str) -> None:
        if getattr(self, "_trace_started", False):
            self._trace_response_headers.append((keyword, value))
        super().send_header(keyword, value)

    def _trace_log_path(self) -> Path:
        return fixture_trace_log_path()

    def _redact_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
        out: Dict[str, str] = {}
        for k, v in headers.items():
            if k.lower() in {"authorization", "cookie", "set-cookie"}:
                out[k] = "***"
            else:
                out[k] = v
        return out

    def _sanitize_json_for_log(self, value: Any) -> Any:
        if isinstance(value, dict):
            out: Dict[str, Any] = {}
            for k, v in value.items():
                if str(k).lower() in {"password", "new_password", "token"}:
                    out[k] = "***"
                else:
                    out[k] = self._sanitize_json_for_log(v)
            return out
        if isinstance(value, list):
            return [self._sanitize_json_for_log(v) for v in value]
        return value

    def _decode_body_for_log(self, body: bytes, content_type: str) -> str:
        if not body:
            return ""
        ct = (content_type or "").lower()
        raw = body[: self._trace_body_cap]
        text = raw.decode("utf-8", errors="replace")
        if "application/json" in ct:
            try:
                compact = json.dumps(self._sanitize_json_for_log(json.loads(text)), ensure_ascii=True)
                if len(compact) > self._trace_body_preview_cap:
                    return compact[: self._trace_body_preview_cap] + " ...(truncated)"
                return compact
            except json.JSONDecodeError:
                return text[: self._trace_body_preview_cap]
        if "text/html" in ct:
            return f"[omitted html payload: {len(body)} bytes]"
        if "application/xml" in ct or "text/xml" in ct:
            return text[: self._trace_body_preview_cap] + (" ...(truncated)" if len(text) > self._trace_body_preview_cap else "")
        if "text/calendar" in ct:
            return text[: self._trace_body_preview_cap] + (" ...(truncated)" if len(text) > self._trace_body_preview_cap else "")
        if ct.startswith("text/"):
            return text[: self._trace_body_preview_cap] + (" ...(truncated)" if len(text) > self._trace_body_preview_cap else "")
        return f"[omitted non-text payload: {len(body)} bytes, content-type={content_type or 'unknown'}]"

    def _strip_http_response_preamble(self, raw: bytes) -> bytes:
        if not raw.startswith(b"HTTP/"):
            return raw
        marker = b"\r\n\r\n"
        idx = raw.find(marker)
        if idx == -1:
            return raw
        return raw[idx + len(marker) :]

    def _read_trace_entries(self, limit: int = 20) -> List[Dict[str, Any]]:
        path = self._trace_log_path()
        if not path.exists():
            return []
        out: deque[Dict[str, Any]] = deque(maxlen=max(1, min(limit, 200)))
        with path.open("r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    out.append(json.loads(line))
                except json.JSONDecodeError:
                    continue
        return list(out)

    def _trace_finish(self) -> None:
        if not self._trace_started:
            return
        req_headers = self._redact_headers({k: v for (k, v) in self.headers.items()})
        resp_headers: Dict[str, str] = {}
        for k, v in self._trace_response_headers:
            resp_headers[k] = "***" if k.lower() == "set-cookie" else v
        req_ct = req_headers.get("Content-Type", "")
        resp_ct = resp_headers.get("Content-Type", "")
        response_raw = bytes(self._trace_response_body)
        response_body_raw = self._strip_http_response_preamble(response_raw)
        entry = {
            "ts": utc_now_iso(),
            "client": self.client_address[0] if self.client_address else "",
            "method": self.command,
            "path": self.path,
            "request": {
                "headers": req_headers,
                "body": self._decode_body_for_log(self._trace_request_body, req_ct),
                "body_bytes": len(self._trace_request_body),
                "body_truncated": len(self._trace_request_body) > self._trace_body_cap,
            },
            "response": {
                "status": self._trace_response_status,
                "headers": resp_headers,
                "body": self._decode_body_for_log(response_body_raw, resp_ct),
                "body_bytes": len(response_body_raw),
                "body_truncated": len(self._trace_response_body) >= self._trace_body_cap,
            },
        }
        path = self._trace_log_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry, ensure_ascii=True) + "\n")
        self._trace_started = False

    def _read_request_body_bytes(self) -> bytes:
        if self._cached_request_body is not None:
            return self._cached_request_body
        length = int(self.headers.get("Content-Length", "0") or "0")
        if length <= 0:
            data = b""
        else:
            data = self.rfile.read(length)
        self._cached_request_body = data
        self._trace_request_body = data[: self._trace_body_cap + 1]
        return data

    def _slug_value(self) -> str:
        conn = ensure_db()
        try:
            raw = get_setting(conn, "url_slug", "").strip()
        finally:
            conn.close()
        raw = raw.strip("/")
        return raw

    def _slug_prefix(self) -> str:
        slug = self._slug_value()
        return f"/{slug}" if slug else ""

    def _strip_slug(self, path: str) -> Optional[str]:
        prefix = self._slug_prefix()
        if not prefix:
            return path
        if path == prefix:
            return "/"
        if path.startswith(prefix + "/"):
            return path[len(prefix) :]
        return None

    def _with_slug(self, path: str) -> str:
        prefix = self._slug_prefix()
        return f"{prefix}{path}" if prefix else path

    def _request_query(self) -> Dict[str, List[str]]:
        return parse_qs(urlparse(self.path).query)

    def _json_body(self) -> Dict[str, Any]:
        data = self._read_request_body_bytes()
        if not data:
            return {}
        return json.loads(data.decode("utf-8"))

    def _write_json(self, status: int, payload: Dict[str, Any]) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _write_text(self, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
        body = text.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _not_found(self) -> None:
        self._write_json(404, {"error": {"code": "not_found", "message": "Route not found"}})

    def _authenticate_wp_admin(self) -> Optional[AuthContext]:
        # Fixture-only auth: header X-WP-User or query ?as=admin|editor.
        wp_user = self.headers.get("X-WP-User", "")
        if not wp_user:
            wp_user = (self._request_query().get("as") or [""])[0]
        if wp_user == "admin":
            return AuthContext("wp_user", "admin", "wp_admin")
        if wp_user == "editor":
            return AuthContext("wp_user", "editor", "wp_editor")
        return None

    def _authenticate_caldav_user(self) -> Optional[AuthContext]:
        creds = parse_basic_auth(self.headers.get("Authorization"))
        if not creds:
            return None
        email, password = creds
        conn = ensure_db()
        row = conn.execute(
            """
            SELECT id, email, password_hash, account_status, email_verified_at
            FROM caldav_users WHERE email = ?
            """,
            (email,),
        ).fetchone()
        if not row:
            conn.close()
            return None
        if not verify_password(password, row["password_hash"]):
            conn.close()
            return None
        if row["account_status"] != "active" or not row["email_verified_at"]:
            conn.close()
            return None
        # Transparent legacy-hash upgrade on successful login.
        if re.fullmatch(r"[0-9a-f]{64}", row["password_hash"]):
            conn.execute(
                "UPDATE caldav_users SET password_hash = ?, updated_at = ? WHERE id = ?",
                (hash_password(password), utc_now_iso(), int(row["id"])),
            )
            conn.commit()
        conn.close()
        return AuthContext("caldav_user", str(row["id"]), "caldav_write")

    def _require_api_admin(self) -> Optional[AuthContext]:
        auth = self._authenticate_wp_admin()
        if not auth:
            self._write_json(401, {"error": {"code": "authentication_error", "message": "Admin authentication required"}})
            return None
        return auth

    def _require_event_read_auth(self) -> Optional[AuthContext]:
        wp = self._authenticate_wp_admin()
        if wp:
            return wp
        caldav = self._authenticate_caldav_user()
        if caldav:
            return caldav
        self._write_json(401, {"error": {"code": "authentication_error", "message": "Login required"}})
        return None

    def _require_event_write_auth(self) -> Optional[AuthContext]:
        wp = self._authenticate_wp_admin()
        if wp and wp.role in {"wp_admin", "wp_editor"}:
            return wp
        caldav = self._authenticate_caldav_user()
        if caldav and caldav.role == "caldav_write":
            return caldav
        if wp or caldav:
            self._write_json(403, {"error": {"code": "authorization_error", "message": "write capability required"}})
            return None
        self._write_json(401, {"error": {"code": "authentication_error", "message": "Login required"}})
        return None

    def _validate_event_payload(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
        title = (payload.get("title") or "").strip()
        start = payload.get("start_datetime")
        end = payload.get("end_datetime")
        if not title:
            self._write_json(422, {"error": {"code": "validation_error", "message": "title is required"}})
            return None
        if not start or not end:
            self._write_json(422, {"error": {"code": "validation_error", "message": "start_datetime and end_datetime are required"}})
            return None
        try:
            ds = datetime.fromisoformat(start.replace("Z", "+00:00"))
            de = datetime.fromisoformat(end.replace("Z", "+00:00"))
        except ValueError:
            self._write_json(422, {"error": {"code": "validation_error", "message": "invalid datetime format"}})
            return None
        if de < ds:
            self._write_json(422, {"error": {"code": "validation_error", "message": "end_datetime must be >= start_datetime"}})
            return None
        normalized = {
            "title": title,
            "description": payload.get("description", ""),
            "location": payload.get("location", ""),
            "category": payload.get("category", ""),
            "all_day_event": 1 if payload.get("all_day_event") else 0,
            "start_datetime": start,
            "end_datetime": end,
            "repeat_type": payload.get("repeat_type", "none"),
            "repeat_interval": int(payload.get("repeat_interval", 1)),
            "repeat_nth_mode": payload.get("repeat_nth_mode", "") or "",
            "repeat_nth_day": payload.get("repeat_nth_day"),
            "repeat_nth_pos": payload.get("repeat_nth_pos"),
            "repeat_nth_weekday": payload.get("repeat_nth_weekday"),
            "repeat_range_mode": payload.get("repeat_range_mode", "none"),
            "repeat_count": payload.get("repeat_count"),
            "repeat_until": payload.get("repeat_until"),
            "timezone": payload.get("timezone", DEFAULT_TIMEZONE),
        }
        normalized_start, normalized_end = _normalize_monthly_anchor(
            normalized["start_datetime"],
            normalized["end_datetime"],
            str(normalized["repeat_type"] or "none"),
            str(normalized["repeat_nth_mode"] or ""),
            int(normalized["repeat_nth_day"]) if normalized["repeat_nth_day"] not in (None, "") else None,
            int(normalized["repeat_nth_pos"]) if normalized["repeat_nth_pos"] not in (None, "") else None,
            int(normalized["repeat_nth_weekday"]) if normalized["repeat_nth_weekday"] not in (None, "") else None,
        )
        normalized["start_datetime"] = normalized_start
        normalized["end_datetime"] = normalized_end
        return normalized

    def do_OPTIONS(self) -> None:
        self._trace_start()
        try:
            path = self._strip_slug(urlparse(self.path).path)
            if path is None:
                self.send_response(404)
                self.end_headers()
                return
            if path.startswith("/caldav"):
                self.send_response(200)
                self.send_header("DAV", "1, 2, calendar-access")
                self.send_header("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE")
                self.end_headers()
                return
            self.send_response(200)
            self.send_header("Allow", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
            self.end_headers()
        finally:
            self._trace_finish()

    def do_GET(self) -> None:
        self._trace_start()
        try:
            parsed = urlparse(self.path)
            path = self._strip_slug(parsed.path)
            if path is None:
                self._not_found()
                return

            if path == "/":
                prefix = self._slug_prefix()
                self._write_text(
                    200,
                    "calendar fixture is running\n"
                    f"slug prefix: {prefix or '(none)'}\n"
                    f"admin: {self._with_slug('/admin.php')}?as=admin\n"
                    f"public calendar: {self._with_slug('/calendar')}\n"
                    f"api: {self._with_slug('/wp-json/calendar/v1/events')}\n"
                    f"ics: {self._with_slug('/calendar.ics')}\n"
                    f"caldav: {self._with_slug('/caldav/')}\n",
                )
                return

            if path in {"/admin.php", "/admin.php/"}:
                self._handle_admin_page(parsed)
                return

            if path.startswith("/wp-admin/admin.php"):
                self._handle_admin_page(parsed)
                return

            if path == "/calendar.ics":
                self._handle_ics_get()
                return

            if path in {"/calendar", "/calendar/"}:
                self._handle_public_calendar_page()
                return

            if path in {"/calendar-sidebar", "/calendar-sidebar/"}:
                self._handle_sidebar_shortcode_page()
                return

            if path.startswith(API_BASE):
                self._handle_api_get(path, parsed)
                return

            if path.startswith("/caldav/"):
                self._handle_caldav_get(path)
                return

            self._not_found()
        finally:
            self._trace_finish()

    def _base_page(self, title: str, body_html: str) -> str:
        return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{_html_escape(title)}</title>
  <style>
    body {{ font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #f7f7f5; color: #1f2937; }}
    header {{ background: #0f766e; color: #fff; padding: 14px 18px; }}
    header a {{ color: #fff; margin-right: 10px; }}
    main {{ max-width: 1100px; margin: 18px auto; padding: 0 12px; }}
    .card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 12px; }}
    input, select, textarea, button {{ font: inherit; }}
    button {{ padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; cursor: pointer; }}
    button:disabled {{ opacity: 0.45; cursor: not-allowed; }}
    table {{ width: 100%; border-collapse: collapse; }}
    th, td {{ border-bottom: 1px solid #e5e7eb; padding: 8px; text-align: left; vertical-align: top; }}
    .row {{ display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }}
    .muted {{ color: #6b7280; font-size: 0.9rem; }}
    .ok {{ color: #065f46; }}
    .err {{ color: #991b1b; }}
    .grid3 {{ display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:8px; }}
    .grid4 {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:8px; }}
    .field {{ position: relative; margin: 14px 0; }}
    .field > label {{ position:absolute; top:-0.62rem; left:0.6rem; font-size:0.72rem; line-height:1; background:#fff; padding:0 0.25rem; color:#4b5563; letter-spacing:0.01em; }}
    .field > input, .field > select, .field > textarea {{
      width: 100%; box-sizing: border-box; padding: 0.78rem 0.65rem 0.5rem 0.65rem;
      border: 1px solid #cbd5e1; border-radius: 8px; background: #fff;
    }}
    .field-with-suffix > input {{ padding-right: 5.5rem; }}
    .input-suffix {{
      position: absolute; right: 0.7rem; top: 56%;
      transform: translateY(-50%); color: #374151; font-size: 0.84rem; pointer-events: none;
    }}
    .toolbar {{ display:flex; gap:8px; flex-wrap: wrap; align-items: end; }}
    #topControls {{ align-items: center; }}
    .topbar {{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap; }}
    .topbar-right {{ margin-left:auto; text-align:right; min-width: 220px; }}
    .topbar-right a {{ display:block; margin-bottom:4px; }}
    .toggle-wrap {{ display:flex; align-items:center; padding: 0 2px; min-height: 38px; }}
    .toggle-wrap label {{ position: static; font-size: 0.9rem; background: transparent; padding: 0; }}
    .modal {{ position: fixed; inset: 0; background: rgba(15,23,42,0.45); display: none; align-items: center; justify-content: center; z-index: 1000; }}
    .modal.open {{ display:flex; }}
    .modal-card {{ width: min(900px, 92vw); max-height: 92vh; overflow: auto; background: #fff; border-radius: 10px; padding: 12px; }}
    .week-grid {{ display:grid; grid-template-columns: 56px repeat(7, 1fr); gap:8px; }}
    .time-scale {{ border:1px solid #e5e7eb; border-radius:8px; background:#fff; overflow:hidden; }}
    .time-scale .week-head {{ text-align:center; }}
    .time-scale .time-lane {{ font-size:0.68rem; color:#6b7280; }}
    .time-label {{ position:absolute; left:4px; transform: translateY(-50%); }}
    .week-day {{ border:1px solid #e5e7eb; border-radius:8px; background:#fff; overflow:hidden; }}
    .week-head {{ padding:6px; border-bottom:1px solid #e5e7eb; font-size:0.85rem; font-weight:600; }}
    .all-day-list {{ min-height:24px; padding:4px; border-bottom:1px dashed #e5e7eb; font-size:0.75rem; }}
    .time-lane {{ position:relative; height: 960px; background:
      repeating-linear-gradient(to bottom, #f8fafc 0, #f8fafc 39px, #eef2f7 39px, #eef2f7 40px); }}
    .week-event {{ position:absolute; background:#dcfce7; border:1px solid #86efac; border-radius:6px; font-size:0.72rem; padding:2px 4px; overflow:hidden; cursor:pointer; box-shadow: 0 1px 2px rgba(0,0,0,0.08); }}
    .week-event:hover {{ filter: brightness(0.97); transform: translateY(-1px); }}
    .month-grid {{ display:grid; grid-template-columns: repeat(7, minmax(0,1fr)); gap:8px; }}
    .month-dow-grid {{ display:grid; grid-template-columns: repeat(7, minmax(0,1fr)); gap:8px; margin-bottom: 6px; }}
    .month-dow {{ font-size: 0.78rem; font-weight: 700; color: #4b5563; text-align: center; }}
    .month-cell {{ min-height:130px; border:1px solid #e5e7eb; border-radius:8px; background:#fff; padding:5px; }}
    .month-cell.blank {{ background:#f3f4f6; }}
    .day-num {{ font-size:0.82rem; font-weight:700; }}
    .mini-evt {{ font-size:0.72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid var(--accent, #34d399); background: color-mix(in srgb, var(--accent, #34d399) 12%, white); padding:2px 4px; margin-top:3px; cursor:pointer; border-radius:4px; }}
    .mini-evt:hover {{ filter: brightness(0.97); }}
    .year-grid {{ display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:10px; }}
    .year-month {{ border:1px solid #e5e7eb; border-radius:8px; padding:6px; background:#fff; }}
    .year-month h4 {{ margin: 3px 0 6px; font-size:0.9rem; }}
    .year-days {{ display:grid; grid-template-columns: repeat(7, 1fr); gap:3px; }}
    .year-day {{ font-size:0.7rem; border:1px solid #edf2f7; border-radius:4px; min-height:22px; text-align:center; line-height:22px; }}
    .year-day.has-event {{ font-weight: 700; background: #ecfeff; border-color: #67e8f9; }}
    .legend {{ display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }}
    .legend .chip {{ border:1px solid #e5e7eb; border-left: 4px solid var(--accent,#34d399); border-radius: 6px; padding: 3px 8px; font-size: 0.75rem; background:#fff; }}
    #occurrenceGrid .year-grid {{ grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 10px; align-items: start; }}
    #occurrenceGrid .month-grid {{ gap: 4px; }}
    #occurrenceGrid .month-cell {{ min-height: 24px; padding: 3px; }}
    #occurrenceGrid .day-num {{ font-size: 0.76rem; line-height: 1.1; text-align: center; }}
    #occurrenceGrid .month-cell.active {{ background: #ecfeff; border-color: #67e8f9; }}
    #loginModal .field > label {{
      position: static;
      display: inline-block;
      margin: 0 0 0.2rem;
      font-size: 0.8rem;
      background: transparent;
      padding: 0;
      color: #4b5563;
    }}
    #loginModal .field > input {{
      padding: 0.6rem 0.65rem;
    }}
  </style>
</head>
<body>
{body_html}
</body>
</html>"""

    def _handle_public_calendar_page(self) -> None:
        page_html = """
<header><strong>Calendar Fixture</strong> <span class="muted">Public Calendar UI</span></header>
<main>
  <div class="card">
    <div class="topbar">
      <div class="toolbar" id="topControls">
      <div class="field"><label>View</label>
        <select id="view">
          <option value="list">List</option>
          <option value="day">Day</option>
          <option value="week">Week</option>
          <option value="month" selected>Month</option>
          <option value="year">Year</option>
        </select>
      </div>
      <div class="field"><label>Date</label><input id="anchor" type="date" /></div>
      <div id="futureOnlyWrap" class="toggle-wrap"><label><input id="futureOnly" type="checkbox" checked /> Future Only</label></div>
      <button id="prevBtn" title="Previous">&#8592;</button>
      <button id="todayBtn" title="Today">&#128197;</button>
      <button id="nextBtn" title="Next">&#8594;</button>
      <button id="newEventBtn">Create Event</button>
      <button id="loginBtn">Login</button>
      </div>
      <div class="topbar-right">
        <a href="__ICS__" target="_blank">ICS Link</a>
        <a href="__CALDAV__" target="_blank">CalDAV</a>
        <div id="status" class="muted"></div>
        <div id="authState" class="muted"></div>
        <button id="logoutBtn" style="display:none">Logout</button>
      </div>
    </div>
  </div>
  <div id="events"></div>
  <div id="loginModal" class="modal">
    <div class="modal-card">
      <h3>Account Login</h3>
      <div id="loginFeedback" class="muted"></div>
      <div class="row">
        <div class="field"><label>Email</label><input id="loginEmail" type="email" /></div>
        <div class="field"><label>Password</label><input id="loginPassword" type="password" /></div>
      </div>
      <div class="toolbar">
        <button id="doLoginBtn" disabled>Login</button>
        <button id="closeLoginBtn">Close</button>
      </div>
      <div class="card">
        <h4>Register</h4>
        <div class="field"><label>Email</label><input id="regEmail" type="email" /></div>
        <div class="field"><label>Password (8+ chars)</label><input id="regPw" type="password" /></div>
        <div class="toolbar"><button id="regBtn" disabled>Register</button></div>
        <div class="field"><label>Verify Token</label><input id="verifyToken" /></div>
        <div class="toolbar"><button id="verifyBtn" disabled>Verify Email</button></div>
      </div>
      <div class="card">
        <h4>Password Recovery</h4>
        <div class="field"><label>Email</label><input id="forgotEmail" type="email" /></div>
        <button id="forgotBtn" disabled>Send Reset Token</button>
        <div class="field"><label>Reset Token</label><input id="resetToken" /></div>
        <div class="field"><label>New Password</label><input id="resetPw" type="password" /></div>
        <button id="resetBtn" disabled>Reset Password</button>
      </div>
    </div>
  </div>
  <div id="detailModal" class="modal">
    <div class="modal-card">
      <h3>Event Details</h3>
      <div class="field"><label>Title</label><input id="detailTitle" readonly /></div>
      <div class="field"><label>Category</label><input id="detailCategory" readonly /></div>
      <div class="field"><label>Description</label><textarea id="detailDescription" rows="5" readonly></textarea></div>
      <div class="toolbar"><button id="closeDetailBtn">Close</button></div>
    </div>
  </div>
  <div id="modal" class="modal">
    <div class="modal-card">
      <h3 id="modalTitle">Event</h3>
      <input id="eventId" type="hidden" />
      <div class="field"><label>Title</label><input id="title" /></div>
      <div class="row">
        <div class="field"><label>Location</label><input id="location" /></div>
        <div class="field"><label>Category</label><input id="category" /></div>
      </div>
      <div class="field"><label>Description</label><textarea id="description" rows="3"></textarea></div>
      <div class="toggle-wrap"><label><input id="allDay" type="checkbox" /> All Day Event</label></div>
      <div class="grid4">
        <div class="field"><label>Start Date</label><input id="startDate" type="date" /></div>
        <div class="field" id="startTimeWrap"><label>Start Time</label><input id="startTime" type="time" /></div>
        <div class="field"><label>End Date</label><input id="endDate" type="date" /></div>
        <div class="field" id="endTimeWrap"><label>End Time</label><input id="endTime" type="time" /></div>
      </div>
      <div class="row">
        <div class="field"><label>Repeat Type</label>
          <select id="repeatType">
            <option value="none">none</option>
            <option value="daily">daily</option>
            <option value="weekly">weekly</option>
            <option value="monthly">monthly</option>
            <option value="yearly">yearly</option>
            <option value="custom">custom</option>
          </select>
        </div>
        <div class="field field-with-suffix" id="repeatIntervalWrap"><label>Every</label><input id="repeatInterval" type="number" min="1" value="1" /><span id="repeatIntervalText" class="input-suffix"></span></div>
      </div>
      <div class="row" id="modeUntilRow">
        <div class="field"><label>Mode</label>
          <select id="rangeMode">
            <option value="none">none</option>
            <option value="no_end">no_end</option>
            <option value="count">count</option>
            <option value="until">until</option>
          </select>
        </div>
        <div class="field" id="repeatUntilWrap"><label>Repeat Until</label><input id="repeatUntil" type="date" /></div>
      </div>
      <div class="row" id="rangeWrap">
        <div class="field" id="repeatCountWrap"><label>Repeat Count</label><input id="repeatCount" type="number" min="1" /></div>
      </div>
      <div class="row" id="monthlyNthWrap">
        <div class="field"><label>Monthly Pattern</label>
          <select id="monthlyNthMode">
            <option value="">from start date</option>
            <option value="day_of_month">nth day of month</option>
            <option value="weekday_of_month">nth weekday of month</option>
          </select>
        </div>
        <div class="field" id="nthDayWrap"><label>Day Number</label><input id="nthDay" type="number" min="1" max="31" /></div>
      </div>
      <div class="row" id="nthWeekdayWrap">
        <div class="field"><label>Nth</label><select id="nthPos"><option value="1">1st</option><option value="2">2nd</option><option value="3">3rd</option><option value="4">4th</option><option value="5">5th</option><option value="-1">last</option></select></div>
        <div class="field"><label>Weekday</label><select id="nthWeekday"><option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option><option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option></select></div>
      </div>
      <div class="toolbar">
        <button id="saveCreateBtn">Create</button>
        <button id="saveUpdateBtn">Update</button>
        <button id="saveDeleteBtn">Delete</button>
        <button id="toggleSingleOccurrenceBtn">Delete a Single Occurrence</button>
        <button id="closeModalBtn">Close</button>
      </div>
      <div class="card" id="singleOccurrencePanel" style="display:none">
        <h4>Delete Single Occurrence (Exception)</h4>
        <div class="toolbar">
          <button id="occPrevBtn">&#8592;</button>
          <button id="occNextBtn">&#8594;</button>
        </div>
        <div id="occurrenceGrid" class="month-grid"></div>
        <input id="occKey" type="hidden" />
        <div class="field"><label>Occurrence</label><input id="occKeyDisplay" readonly placeholder="Select an occurrence from the calendar above" /></div>
        <button id="deleteOccurrenceBtn">Delete Occurrence</button>
      </div>
    </div>
  </div>
</main>
<script>
const statusEl = document.getElementById('status');
const authStateEl = document.getElementById('authState');
const loginFeedbackEl = document.getElementById('loginFeedback');
const eventsEl = document.getElementById('events');
const viewEl = document.getElementById('view');
const anchorEl = document.getElementById('anchor');
const futureOnlyWrapEl = document.getElementById('futureOnlyWrap');
const futureOnlyEl = document.getElementById('futureOnly');
const modalEl = document.getElementById('modal');
const loginModalEl = document.getElementById('loginModal');
const detailModalEl = document.getElementById('detailModal');
const loginBtnEl = document.getElementById('loginBtn');
const logoutBtnEl = document.getElementById('logoutBtn');
const newEventBtnEl = document.getElementById('newEventBtn');
const ROOT = __ROOT__;
let session = { email:'', password:'', user:null, canWrite:false };
viewEl.value = 'month';
function pad2(n){ return String(n).padStart(2,'0'); }
function ymdLocal(d){ return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; }
function parseYmd(s){ const p=(s||'').split('-'); if(p.length!==3) return new Date(); return new Date(Number(p[0]), Number(p[1])-1, Number(p[2])); }
anchorEl.value = ymdLocal(new Date());
function rootPath(path){ return ROOT + path; }
function apiUrl(path) { return rootPath(path); }
function authHeaders(){ return session.email && session.password ? { Authorization: 'Basic ' + btoa(session.email + ':' + session.password) } : {}; }
function isEmail(v){ return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test((v||'').trim()); }
function showLoginFeedback(msg, ok){
  loginFeedbackEl.textContent = msg || '';
  loginFeedbackEl.className = ok ? 'ok' : (msg ? 'err' : 'muted');
}
function updateAuthButtonStates(){
  const loginEmail = document.getElementById('loginEmail').value || '';
  const loginPw = document.getElementById('loginPassword').value || '';
  const regEmail = document.getElementById('regEmail').value || '';
  const regPw = document.getElementById('regPw').value || '';
  const verifyToken = document.getElementById('verifyToken').value || '';
  const forgotEmail = document.getElementById('forgotEmail').value || '';
  const resetToken = document.getElementById('resetToken').value || '';
  const resetPw = document.getElementById('resetPw').value || '';
  document.getElementById('doLoginBtn').disabled = !(isEmail(loginEmail) && loginPw.length >= 8);
  document.getElementById('regBtn').disabled = !(isEmail(regEmail) && regPw.length >= 8);
  document.getElementById('verifyBtn').disabled = !verifyToken.trim();
  document.getElementById('forgotBtn').disabled = !isEmail(forgotEmail);
  document.getElementById('resetBtn').disabled = !(resetToken.trim() && resetPw.length >= 8);
}
function refreshAuthUi(){
  const logged = !!session.user;
  loginBtnEl.style.display = logged ? 'none' : '';
  logoutBtnEl.style.display = logged ? '' : 'none';
  newEventBtnEl.style.display = session.canWrite ? '' : 'none';
  authStateEl.textContent = logged
    ? `Logged in as ${session.user.email}`
    : 'Public mode: read-only';
  updateEventButtons();
}
function splitIsoToInputs(iso) { if (!iso) return {d:'',t:''}; if (!iso.includes('T')) return {d:iso.slice(0,10),t:''}; return {d:iso.slice(0,10),t:iso.slice(11,16)}; }
function toIso(d,t,allDay){ if(!d) return ''; if(allDay) return d; return `${d}T${(t||'00:00')}:00+01:00`; }
function formatOccurrenceKey(iso){
  if (!iso) return '';
  const dt = new Date(iso);
  if (Number.isNaN(dt.getTime())) return iso;
  return dt.toLocaleString('en-GB', { year:'numeric', month:'short', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function applyAllDayVisibility(){
  const allDay = !!document.getElementById('allDay').checked;
  document.getElementById('startTimeWrap').style.display = allDay ? 'none' : '';
  document.getElementById('endTimeWrap').style.display = allDay ? 'none' : '';
}
let occCursor = new Date();
function everyText(){
  const n = Number(document.getElementById('repeatInterval').value || 1);
  const rt = document.getElementById('repeatType').value;
  const unit = rt === 'daily' ? 'day' : rt === 'weekly' ? 'week' : rt === 'monthly' ? 'month' : rt === 'yearly' ? 'year' : rt === 'custom' ? 'week' : '';
  document.getElementById('repeatIntervalText').textContent = unit ? `${unit}${n===1?'':'s'}` : '';
}
function applyRecurrenceVisibility(){ const rt=document.getElementById('repeatType').value; const rm=document.getElementById('rangeMode').value; const show=rt!=='none';
  document.getElementById('repeatIntervalWrap').style.display=show?'':'none';
  document.getElementById('modeUntilRow').style.display=show?'':'none';
  document.getElementById('rangeWrap').style.display=(show&&rm==='count')?'':'none';
  document.getElementById('repeatCountWrap').style.display=(show&&rm==='count')?'':'none';
  document.getElementById('repeatUntilWrap').style.display=(show&&rm==='until')?'':'none';
  document.getElementById('monthlyNthWrap').style.display=(rt==='monthly')?'':'none';
  const m = document.getElementById('monthlyNthMode').value;
  document.getElementById('nthDayWrap').style.display=(rt==='monthly' && m==='day_of_month')?'':'none';
  document.getElementById('nthWeekdayWrap').style.display=(rt==='monthly' && m==='weekday_of_month')?'':'none';
  document.getElementById('toggleSingleOccurrenceBtn').style.display=show?'':'none';
  if (!show) document.getElementById('singleOccurrencePanel').style.display='none';
  everyText();
  updateEventButtons();
}
function openModal(mode,evt){ if(!evt) evt={}; const sd=splitIsoToInputs(evt.start_datetime), ed=splitIsoToInputs(evt.end_datetime);
  if (!session.canWrite) return;
  document.getElementById('modalTitle').textContent=mode==='create'?'Create Event':'Edit Event';
  document.getElementById('eventId').value=evt.id||''; document.getElementById('title').value=evt.title||''; document.getElementById('location').value=evt.location||'';
  document.getElementById('category').value=evt.category||''; document.getElementById('description').value=evt.description||'';
  document.getElementById('allDay').checked=!!evt.all_day_event; document.getElementById('startDate').value=sd.d||''; document.getElementById('startTime').value=sd.t||'';
  document.getElementById('endDate').value=ed.d||''; document.getElementById('endTime').value=ed.t||''; document.getElementById('repeatType').value=evt.repeat_type||'none';
  document.getElementById('repeatInterval').value=evt.repeat_interval||1; document.getElementById('rangeMode').value=evt.repeat_range_mode||'none';
  document.getElementById('repeatCount').value=evt.repeat_count||''; document.getElementById('repeatUntil').value=evt.repeat_until||'';
  document.getElementById('monthlyNthMode').value=evt.repeat_nth_mode||'';
  document.getElementById('nthDay').value=evt.repeat_nth_day||'';
  document.getElementById('nthPos').value=evt.repeat_nth_pos||'1';
  document.getElementById('nthWeekday').value=evt.repeat_nth_weekday===null||evt.repeat_nth_weekday===undefined?'0':String(evt.repeat_nth_weekday);
  document.getElementById('singleOccurrencePanel').style.display='none';
  document.getElementById('occKey').value = '';
  document.getElementById('occKeyDisplay').value = '';
  occCursor = new Date((evt.start_datetime||new Date().toISOString()).slice(0,10) + 'T00:00:00');
  document.getElementById('toggleSingleOccurrenceBtn').style.display = mode==='edit' ? '' : 'none';
  document.getElementById('saveCreateBtn').style.display=mode==='create'?'':'none'; document.getElementById('saveUpdateBtn').style.display=mode==='edit'?'':'none';
  document.getElementById('saveDeleteBtn').style.display=mode==='edit'?'':'none'; applyRecurrenceVisibility(); modalEl.classList.add('open');
  applyAllDayVisibility();
  updateEventButtons();
}
function closeModal(){ modalEl.classList.remove('open'); }
function openDetail(item){
  document.getElementById('detailTitle').value = item?.title || '';
  document.getElementById('detailCategory').value = item?.category || '';
  document.getElementById('detailDescription').value = item?.description || '';
  detailModalEl.classList.add('open');
}
function closeDetail(){ detailModalEl.classList.remove('open'); }
function updateEventButtons(){
  const titleOk = !!(document.getElementById('title').value || '').trim();
  const startDate = (document.getElementById('startDate').value || '').trim();
  const endDateRaw = (document.getElementById('endDate').value || '').trim();
  const endDate = endDateRaw || startDate;
  const allDay = !!document.getElementById('allDay').checked;
  const startTime = (document.getElementById('startTime').value || '').trim();
  const endTime = (document.getElementById('endTime').value || '').trim();
  let validDateTime = false;
  if (startDate && endDate && endDate >= startDate){
    if (allDay){
      validDateTime = true;
    } else if (startTime && endTime){
      validDateTime = endDate > startDate || endTime >= startTime;
    }
  }
  const hasCore = titleOk && validDateTime;
  const hasId = !!(document.getElementById('eventId').value || '').trim();
  document.getElementById('saveCreateBtn').disabled = !(session.canWrite && hasCore && !hasId);
  document.getElementById('saveUpdateBtn').disabled = !(session.canWrite && hasCore && hasId);
  document.getElementById('saveDeleteBtn').disabled = !(session.canWrite && hasId);
  document.getElementById('toggleSingleOccurrenceBtn').disabled = !(session.canWrite && hasId && document.getElementById('repeatType').value !== 'none');
  document.getElementById('deleteOccurrenceBtn').disabled = !(session.canWrite && hasId && (document.getElementById('occKey').value || '').trim());
}
function eventPayload(){ const ad=document.getElementById('allDay').checked; return {
  title:document.getElementById('title').value, location:document.getElementById('location').value, category:document.getElementById('category').value,
  description:document.getElementById('description').value, all_day_event:ad,
  start_datetime:toIso(document.getElementById('startDate').value,document.getElementById('startTime').value,ad),
  end_datetime:toIso((document.getElementById('endDate').value || document.getElementById('startDate').value),document.getElementById('endTime').value,ad),
  repeat_type:document.getElementById('repeatType').value, repeat_interval:Number(document.getElementById('repeatInterval').value||1),
  repeat_nth_mode:document.getElementById('monthlyNthMode').value,
  repeat_nth_day:document.getElementById('nthDay').value?Number(document.getElementById('nthDay').value):null,
  repeat_nth_pos:document.getElementById('nthPos').value?Number(document.getElementById('nthPos').value):null,
  repeat_nth_weekday:document.getElementById('nthWeekday').value === '' ? null : Number(document.getElementById('nthWeekday').value),
  repeat_range_mode:document.getElementById('rangeMode').value, repeat_count:document.getElementById('repeatCount').value?Number(document.getElementById('repeatCount').value):null,
  repeat_until:document.getElementById('repeatUntil').value||null
};}
async function loadOccurrenceChooser(){
  const id = document.getElementById('eventId').value;
  if (!id) return;
  const from = new Date(occCursor.getFullYear(), occCursor.getMonth(), 1);
  const fromS = ymdLocal(from);
  const r = await fetch(apiUrl('/wp-json/calendar/v1/events/' + id + '/occurrences?from=' + fromS + '&months=3'), {headers:authHeaders()});
  const p = await r.json();
  const list = p.data || [];
  const dayToKey = new Map();
  list.forEach(x => {
    const d = (x.occurrence_start || '').slice(0,10);
    if (d && !dayToKey.has(d)) dayToKey.set(d, x.occurrence_start);
  });
  const cells = [];
  for(let m=0;m<3;m++){
    const d = new Date(from.getFullYear(), from.getMonth()+m, 1);
    const y=d.getFullYear(), mo=d.getMonth();
    const first=new Date(y,mo,1), off=(first.getDay()+6)%7, dim=new Date(y,mo+1,0).getDate();
    let monthCells=''; for(let i=0;i<off;i++) monthCells += '<div class="month-cell blank"></div>';
    for(let day=1; day<=dim; day++){
      const dt=new Date(y,mo,day); const k=ymdLocal(dt); const has=dayToKey.has(k);
      monthCells += `<div class="month-cell ${has?'active':'blank'}"><div class="day-num" data-occ-date="${k}" style="cursor:${has?'pointer':'default'}">${day}</div></div>`;
    }
    cells.push(`<div class="year-month"><h4>${d.toLocaleString('en-GB',{month:'short',year:'numeric'})}</h4><div class="month-grid">${monthCells}</div></div>`);
  }
  document.getElementById('occurrenceGrid').innerHTML = `<div class="year-grid">${cells.join('')}</div>`;
  document.querySelectorAll('[data-occ-date]').forEach(el=>el.addEventListener('click',()=>{
    const k=el.getAttribute('data-occ-date'); if(!dayToKey.has(k)) return;
    const iso = dayToKey.get(k) || '';
    document.getElementById('occKey').value = iso;
    document.getElementById('occKeyDisplay').value = formatOccurrenceKey(iso);
    updateEventButtons();
  }));
}
function startMinutes(iso){ if(!iso||!iso.includes('T')) return 0; return Number(iso.slice(11,13))*60+Number(iso.slice(14,16)); }
function categoryColor(category){
  const base = (category || 'general').toLowerCase();
  let h = 0; for (let i = 0; i < base.length; i++) h = (h * 31 + base.charCodeAt(i)) % 360;
  return `hsl(${h} 70% 45%)`;
}
function eventChip(e, cls='mini-evt'){ return `<div class="${cls}" data-edit="${e.event_id}" style="--accent:${categoryColor(e.category)}">${e.title}</div>`; }
function collectLegend(items){
  const map = new Map();
  items.forEach(e => { const c = e.category || 'Uncategorised'; if (!map.has(c)) map.set(c, categoryColor(c)); });
  return `<div class="legend">${Array.from(map.entries()).map(([k,v]) => `<div class="chip" style="--accent:${v}">${k}</div>`).join('')}</div>`;
}
function renderList(items){
  const fmt = (e) => {
    const sd = (e.occurrence_start||'').slice(0,10);
    const st = e.all_day_event ? 'All-day' : (e.occurrence_start||'').slice(11,16);
    const et = e.all_day_event ? 'All-day' : (e.occurrence_end||'').slice(11,16);
    const desc = (e.description || '').trim() || e.title || '';
    return `${sd} ${st}–${et} ${desc}`;
  };
  eventsEl.innerHTML=(items.map(e=>`<div class="card">${fmt(e)}</div>`).join('')||'<div class="card">No events in this window.</div>');
}
function layoutTimedEvents(dayItems){
  const events = dayItems.slice().sort((a,b)=>a.occurrence_start.localeCompare(b.occurrence_start));
  let i = 0; const out = [];
  while (i < events.length){
    let clusterEnd = startMinutes(events[i].occurrence_end);
    const cluster = [events[i]];
    let j = i + 1;
    while (j < events.length && startMinutes(events[j].occurrence_start) < clusterEnd){
      cluster.push(events[j]); clusterEnd = Math.max(clusterEnd, startMinutes(events[j].occurrence_end)); j++;
    }
    const active = []; let maxCols = 0;
    const placed = cluster.map(e => {
      const start = startMinutes(e.occurrence_start), end = startMinutes(e.occurrence_end);
      for (let k = active.length - 1; k >= 0; k--) if (active[k].end <= start) active.splice(k,1);
      const used = new Set(active.map(a => a.col));
      let col = 0; while (used.has(col)) col++;
      active.push({ end, col }); maxCols = Math.max(maxCols, col + 1);
      return { e, start, end, col };
    });
    placed.forEach(p => out.push({ ...p, span: maxCols }));
    i = j;
  }
  return out;
}
function renderWeek(items,anchor){
  const d=new Date(anchor+'T00:00:00'); const diff=(d.getDay()+6)%7; d.setDate(d.getDate()-diff);
  const days=Array.from({length:7},(_,i)=>new Date(d.getFullYear(),d.getMonth(),d.getDate()+i)); const by=Object.fromEntries(days.map(x=>[ymdLocal(x),[]]));
  items.forEach(e=>{const k=(e.occurrence_start||'').slice(0,10); if(by[k]) by[k].push(e);});
  const scaleLabels = Array.from({length:24}, (_,h)=>`<div class="time-label" style="top:${h*40}px">${String(h).padStart(2,'0')}:00</div>`).join('');
  const cols=days.map(dt=>{
    const k=ymdLocal(dt); const day=(by[k]||[]).sort((a,b)=>a.occurrence_start.localeCompare(b.occurrence_start));
    const ad=day.filter(x=>x.all_day_event), td=day.filter(x=>!x.all_day_event);
    const blocks=layoutTimedEvents(td).map(p=>{
      const top=Math.max(0,p.start/1.5), h=Math.max(24,(p.end-p.start)/1.5);
      const widthPct=100/p.span; const left=`calc(${p.col*widthPct}% + 2px)`; const width=`calc(${widthPct}% - 4px)`;
      const st = (p.e.occurrence_start||'').slice(11,16);
      return `<div class="week-event" data-edit="${p.e.event_id}" style="top:${top}px;height:${h}px;left:${left};width:${width};--accent:${categoryColor(p.e.category)};border-color:var(--accent);background:color-mix(in srgb, var(--accent) 18%, white);">${st} ${p.e.title}</div>`;
    }).join('');
    return `<div class="week-day"><div class="week-head">${dt.toDateString().slice(0,10)}</div><div class="all-day-list">${ad.map(e=>eventChip(e)).join('')}</div><div class="time-lane">${blocks}</div></div>`;
  }).join('');
  const scaleCol = `<div class="time-scale"><div class="week-head">Time</div><div class="time-lane">${scaleLabels}</div></div>`;
  eventsEl.innerHTML=`<div class="week-grid">${scaleCol}${cols}</div>${collectLegend(items)}`;
}
function renderMonth(items,anchor){
  const b=new Date(anchor+'T00:00:00'); const y=b.getFullYear(),m=b.getMonth(); const first=new Date(y,m,1); const off=(first.getDay()+6)%7; const dim=new Date(y,m+1,0).getDate();
  const by={}; items.forEach(e=>{const k=(e.occurrence_start||'').slice(0,10); (by[k]=by[k]||[]).push(e);});
  const dow = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
  const headers = dow.map(d=>`<div class="month-dow">${d}</div>`).join('');
  let cells=''; for(let i=0;i<off;i++) cells+='<div class="month-cell blank"></div>';
  for(let d=1; d<=dim; d++){
    const dt=new Date(y,m,d), k=ymdLocal(dt);
    const evs=(by[k]||[]).sort((a,b)=>a.occurrence_start.localeCompare(b.occurrence_start));
    cells+=`<div class="month-cell"><div class="day-num">${d}</div>${evs.map(e=>{ const t=e.all_day_event?'All-day':(e.occurrence_start||'').slice(11,16); return eventChip({...e, title:`${t} ${e.title}`}); }).join('')}</div>`;
  }
  eventsEl.innerHTML=`<div class="month-dow-grid">${headers}</div><div class="month-grid">${cells}</div>${collectLegend(items)}`;
}
function renderYear(items,anchor){
  const y=new Date(anchor+'T00:00:00').getFullYear(); const set=new Set(items.map(e=>(e.occurrence_start||'').slice(0,10))); const n=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const blocks=Array.from({length:12},(_,m)=>{const f=new Date(y,m,1),off=(f.getDay()+6)%7,dim=new Date(y,m+1,0).getDate(); let cells=''; for(let i=0;i<off;i++) cells+='<div class="year-day"></div>';
    for(let d=1; d<=dim; d++){ const k=ymdLocal(new Date(y,m,d)); cells+=`<div class="${set.has(k)?'year-day has-event':'year-day'}" data-year-date="${k}">${d}</div>`; }
    return `<div class="year-month"><h4>${n[m]}</h4><div class="year-days">${cells}</div></div>`; }).join('');
  eventsEl.innerHTML=`<div class="year-grid">${blocks}</div>`;
}
function startOfViewPeriod(d, view){
  const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  if (view === 'week'){ const diff = (x.getDay()+6)%7; x.setDate(x.getDate()-diff); return x; }
  if (view === 'month'){ return new Date(x.getFullYear(), x.getMonth(), 1); }
  if (view === 'year'){ return new Date(x.getFullYear(), 0, 1); }
  return x;
}
async function loadEvents(){ statusEl.textContent='Loading...'; const qs=new URLSearchParams({view:viewEl.value,date:anchorEl.value}); const resp=await fetch(rootPath('/wp-json/calendar/v1/public/events?'+qs.toString())); const payload=await resp.json();
  if(!resp.ok){ statusEl.textContent=payload?.error?.message||'Error'; statusEl.className='err'; return; } let items=payload.data||[];
  futureOnlyWrapEl.style.display = viewEl.value === 'list' ? '' : 'none';
  if(viewEl.value==='list' && futureOnlyEl.checked){ const td=ymdLocal(new Date()); items=items.filter(i=>(i.occurrence_start||'').slice(0,10)>=td); }
  statusEl.textContent=`Loaded ${items.length} item(s)`; statusEl.className='ok';
  if(viewEl.value==='week') renderWeek(items,anchorEl.value); else if(viewEl.value==='month') renderMonth(items,anchorEl.value); else if(viewEl.value==='year') renderYear(items,anchorEl.value); else renderList(items);
  eventsEl.querySelectorAll('[data-edit]').forEach(el=>el.addEventListener('click',async()=>{
    const id=el.getAttribute('data-edit');
    if (session.canWrite) {
      const r=await fetch(apiUrl('/wp-json/calendar/v1/events/'+id),{headers:authHeaders()}); const p=await r.json(); if(p?.data) openModal('edit',p.data);
      return;
    }
    const hit = items.find(i => String(i.event_id) === String(id));
    if (hit) openDetail(hit);
  }));
  eventsEl.querySelectorAll('[data-year-date]').forEach(el=>el.addEventListener('click',()=>{ viewEl.value='week'; anchorEl.value=el.getAttribute('data-year-date'); loadEvents(); }));
}
async function apiWrite(path,method,bodyObj){ const resp=await fetch(apiUrl(path),{method,headers:{'Content-Type':'application/json',...authHeaders()},body:bodyObj?JSON.stringify(bodyObj):undefined}); statusEl.textContent=`${method} ${path} -> ${resp.status}`; statusEl.className=resp.ok?'ok':'err'; return resp; }
newEventBtnEl.addEventListener('click',()=>openModal('create',{}));
document.getElementById('closeModalBtn').addEventListener('click',closeModal);
document.getElementById('saveCreateBtn').addEventListener('click',async()=>{await apiWrite('/wp-json/calendar/v1/events','POST',eventPayload()); closeModal(); loadEvents();});
document.getElementById('saveUpdateBtn').addEventListener('click',async()=>{const id=document.getElementById('eventId').value; if(!id) return; await apiWrite('/wp-json/calendar/v1/events/'+id,'PATCH',eventPayload()); closeModal(); loadEvents();});
document.getElementById('saveDeleteBtn').addEventListener('click',async()=>{const id=document.getElementById('eventId').value; if(!id) return; await apiWrite('/wp-json/calendar/v1/events/'+id,'DELETE'); closeModal(); loadEvents();});
document.getElementById('toggleSingleOccurrenceBtn').addEventListener('click',async()=>{ const p=document.getElementById('singleOccurrencePanel'); p.style.display = p.style.display==='none'?'':'none'; if(p.style.display!=='none') await loadOccurrenceChooser(); });
document.getElementById('occPrevBtn').addEventListener('click',async()=>{ occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth()-1, 1); await loadOccurrenceChooser(); });
document.getElementById('occNextBtn').addEventListener('click',async()=>{ occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth()+1, 1); await loadOccurrenceChooser(); });
document.getElementById('deleteOccurrenceBtn').addEventListener('click',async()=>{const id=document.getElementById('eventId').value; const key=encodeURIComponent(document.getElementById('occKey').value); if(!id||!key) return; await apiWrite('/wp-json/calendar/v1/events/'+id+'/occurrences/'+key,'DELETE'); await loadOccurrenceChooser(); loadEvents();});
document.getElementById('repeatType').addEventListener('change',applyRecurrenceVisibility);
document.getElementById('rangeMode').addEventListener('change',applyRecurrenceVisibility);
document.getElementById('monthlyNthMode').addEventListener('change',applyRecurrenceVisibility);
document.getElementById('repeatInterval').addEventListener('input',everyText);
document.getElementById('allDay').addEventListener('change', ()=>{{ applyAllDayVisibility(); updateEventButtons(); }});
['title','startDate','endDate','startTime','endTime','repeatType','occKey'].forEach(id=>document.getElementById(id).addEventListener('input',updateEventButtons));
function shiftAnchor(dir){
  const d = parseYmd(anchorEl.value);
  const v = viewEl.value;
  const s = startOfViewPeriod(d, v);
  if (v === 'day' || v === 'list') s.setDate(s.getDate() + dir);
  else if (v === 'week') s.setDate(s.getDate() + (7*dir));
  else if (v === 'month') s.setMonth(s.getMonth() + dir);
  else if (v === 'year') s.setFullYear(s.getFullYear() + dir);
  anchorEl.value = ymdLocal(startOfViewPeriod(s, v));
}
document.getElementById('prevBtn').addEventListener('click',()=>{ shiftAnchor(-1); loadEvents(); });
document.getElementById('nextBtn').addEventListener('click',()=>{ shiftAnchor(1); loadEvents(); });
document.getElementById('todayBtn').addEventListener('click',()=>{ const t = startOfViewPeriod(new Date(), viewEl.value); anchorEl.value = ymdLocal(t); loadEvents(); });
loginBtnEl.addEventListener('click', ()=> loginModalEl.classList.add('open'));
document.getElementById('closeLoginBtn').addEventListener('click', ()=>{ loginModalEl.classList.remove('open'); showLoginFeedback('', true); });
document.getElementById('closeDetailBtn').addEventListener('click', closeDetail);
logoutBtnEl.addEventListener('click', ()=>{ session = { email:'', password:'', user:null, canWrite:false }; refreshAuthUi(); closeModal(); loadEvents(); });
document.getElementById('doLoginBtn').addEventListener('click', async ()=>{
  const email = (document.getElementById('loginEmail').value || '').trim().toLowerCase();
  const password = document.getElementById('loginPassword').value || '';
  const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/me'), { headers: { Authorization: 'Basic ' + btoa(email + ':' + password) } });
  const payload = await resp.json();
  if (!resp.ok || !payload?.data){
    const msg = 'Login failure';
    statusEl.textContent = msg; statusEl.className='err';
    showLoginFeedback(msg, false);
    return;
  }
  session.email = email;
  session.password = password;
  session.user = payload.data;
  session.canWrite = payload.data.account_status === 'active';
  refreshAuthUi();
  showLoginFeedback('Login succeeded.', true);
  loginModalEl.classList.remove('open');
  loadEvents();
});
document.getElementById('regBtn').addEventListener('click', async ()=>{
  const body = { email: (document.getElementById('regEmail').value || '').trim().toLowerCase(), password: document.getElementById('regPw').value || '' };
  const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/register'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
  const payload = await resp.json();
  const msg = payload?.error?.message || 'Registered. Check your email for a verification link or token.';
  statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
  showLoginFeedback(msg, resp.ok);
});
document.getElementById('verifyBtn').addEventListener('click', async ()=>{
  const body = { token: document.getElementById('verifyToken').value || '' };
  const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/verify'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
  const payload = await resp.json();
  const msg = resp.ok
    ? 'An admin will review your request and notify you if approved.'
    : (payload?.error?.message || 'Verify failed');
  statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
  showLoginFeedback(msg, resp.ok);
});
document.getElementById('forgotBtn').addEventListener('click', async ()=>{
  const body = { email: (document.getElementById('forgotEmail').value || '').trim().toLowerCase() };
  const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/forgot-password'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
  const payload = await resp.json();
  const msg = payload?.error?.message || 'If the account exists, a reset token has been sent by email.';
  statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
  showLoginFeedback(msg, resp.ok);
});
document.getElementById('resetBtn').addEventListener('click', async ()=>{
  const body = { token: document.getElementById('resetToken').value || '', new_password: document.getElementById('resetPw').value || '' };
  const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/reset-password'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
  const payload = await resp.json();
  const msg = payload?.error?.message || 'Password reset complete';
  statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
  showLoginFeedback(msg, resp.ok);
});
viewEl.addEventListener('change',()=>{ const d = parseYmd(anchorEl.value); const s = startOfViewPeriod(d, viewEl.value); anchorEl.value = ymdLocal(s); loadEvents(); });
anchorEl.addEventListener('change',()=>{ const d = parseYmd(anchorEl.value); const s = startOfViewPeriod(d, viewEl.value); anchorEl.value = ymdLocal(s); loadEvents(); });
futureOnlyEl.addEventListener('change',loadEvents);
['loginEmail','loginPassword','regEmail','regPw','verifyToken','forgotEmail','resetToken','resetPw'].forEach(id=>{ const el=document.getElementById(id); el.addEventListener('input', updateAuthButtonStates); });
updateAuthButtonStates();
showLoginFeedback('', true);
refreshAuthUi();
applyAllDayVisibility();
updateEventButtons();
loadEvents();
</script>
"""
        page_html = page_html.replace("__ICS__", self._with_slug("/calendar.ics"))
        page_html = page_html.replace("__CALDAV__", self._with_slug("/caldav/"))
        page_html = page_html.replace("__ROOT__", json.dumps(self._slug_prefix()))
        html = self._base_page("Calendar", page_html)
        self._write_text(200, html, "text/html; charset=utf-8")

    def _handle_sidebar_shortcode_page(self) -> None:
        page_html = """
<header><strong>Calendar Fixture</strong> <span class="muted">Sidebar Shortcode Preview</span></header>
<main>
  <div class="card">
    <div class="muted">Shortcode: <code>[calendar_sidebar_upcoming]</code></div>
    <div class="muted">Window: next 14 days</div>
  </div>
  <div id="sidebarList"></div>
</main>
<script>
const ROOT = __ROOT__;
function rootPath(path){ return ROOT + path; }
function fmt(e){
  const sd = (e.occurrence_start||'').slice(0,10);
  const st = e.all_day_event ? 'All-day' : (e.occurrence_start||'').slice(11,16);
  const et = e.all_day_event ? 'All-day' : (e.occurrence_end||'').slice(11,16);
  const desc = (e.description || '').trim() || e.title || '';
  return `${sd} ${st}–${et} ${desc}`;
}
async function load(){
  const el = document.getElementById('sidebarList');
  const resp = await fetch(rootPath('/wp-json/calendar/v1/public/sidebar-events'));
  const payload = await resp.json();
  if(!resp.ok){
    el.innerHTML = `<div class="card err">${payload?.error?.message || 'Error'}</div>`;
    return;
  }
  const items = payload?.data || [];
  el.innerHTML = items.length
    ? items.map(e => `<div class="card">${fmt(e)}</div>`).join('')
    : '<div class="card">No events in this window.</div>';
}
load();
</script>
"""
        page_html = page_html.replace("__ROOT__", json.dumps(self._slug_prefix()))
        html = self._base_page("Calendar Sidebar Shortcode", page_html)
        self._write_text(200, html, "text/html; charset=utf-8")

    def do_POST(self) -> None:
        self._trace_start()
        try:
            path = self._strip_slug(urlparse(self.path).path)
            if path is None:
                self._not_found()
                return
            if path.startswith(API_BASE):
                self._handle_api_post(path)
                return
            self._not_found()
        finally:
            self._trace_finish()

    def do_PUT(self) -> None:
        self._trace_start()
        try:
            path = self._strip_slug(urlparse(self.path).path)
            if path is None:
                self._not_found()
                return
            if path.startswith(API_BASE):
                self._handle_api_put(path)
                return
            if path.startswith("/caldav/"):
                self._handle_caldav_put(path)
                return
            self._not_found()
        finally:
            self._trace_finish()

    def do_PATCH(self) -> None:
        self._trace_start()
        try:
            path = self._strip_slug(urlparse(self.path).path)
            if path is None:
                self._not_found()
                return
            if path.startswith(API_BASE):
                self._handle_api_patch(path)
                return
            self._not_found()
        finally:
            self._trace_finish()

    def do_DELETE(self) -> None:
        self._trace_start()
        try:
            path = self._strip_slug(urlparse(self.path).path)
            if path is None:
                self._not_found()
                return
            if path.startswith(API_BASE):
                self._handle_api_delete(path)
                return
            if path.startswith("/caldav/"):
                self._handle_caldav_delete(path)
                return
            self._not_found()
        finally:
            self._trace_finish()

    def do_PROPFIND(self) -> None:
        self._trace_start()
        try:
            self._read_request_body_bytes()
            path = self._strip_slug(urlparse(self.path).path)
            if path is None:
                self._not_found()
                return
            if not path.startswith("/caldav/"):
                self._not_found()
                return
            auth = self._authenticate_caldav_user()
            if not auth:
                self.send_response(401)
                self.send_header("WWW-Authenticate", 'Basic realm="calendar-caldav-fixture"')
                self.end_headers()
                return
            prefix = self._slug_prefix()
            caldav_root = f"{prefix}/caldav/"
            principals_root = f"{prefix}/caldav/principals/"
            principal_href = f"{prefix}/caldav/principals/user/"
            calendars_root = f"{prefix}/caldav/calendars/"
            public_calendar = f"{prefix}/caldav/calendars/public/"
            conn = ensure_db()
            caldav_calendar_name = get_setting(conn, "caldav_calendar_name", "Public Calendar")
            conn.close()
            def response_block(href: str, prop_xml: str) -> str:
                return textwrap.dedent(
                    f"""\
                    <D:response>
                      <D:href>{href}</D:href>
                      <D:propstat>
                        <D:prop>
                          {prop_xml}
                        </D:prop>
                        <D:status>HTTP/1.1 200 OK</D:status>
                      </D:propstat>
                    </D:response>
                    """
                ).strip()

            root_props = (
                "<D:resourcetype><D:collection/></D:resourcetype>"
                f"<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>"
            )
            principal_props = (
                "<D:resourcetype><D:collection/><D:principal/></D:resourcetype>"
                f"<C:calendar-home-set><D:href>{calendars_root}</D:href></C:calendar-home-set>"
            )
            calendar_home_props = "<D:resourcetype><D:collection/></D:resourcetype>"
            public_calendar_props = (
                "<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"
                f"<D:displayname>{_xml_escape(caldav_calendar_name)}</D:displayname>"
                "<C:supported-calendar-component-set>"
                "<C:comp name=\"VEVENT\"/>"
                "</C:supported-calendar-component-set>"
            )

            responses: List[str] = []
            canonical = path if path.endswith("/") else f"{path}/"
            if canonical in {"/caldav/", "/caldav"}:
                responses.append(response_block(caldav_root, root_props))
                if self.headers.get("Depth", "0") != "0":
                    responses.append(response_block(principal_href, principal_props))
                    responses.append(response_block(calendars_root, calendar_home_props))
                    responses.append(response_block(public_calendar, public_calendar_props))
            elif canonical == "/caldav/principals/":
                responses.append(response_block(principals_root, "<D:resourcetype><D:collection/></D:resourcetype>"))
                if self.headers.get("Depth", "0") != "0":
                    responses.append(response_block(principal_href, principal_props))
            elif canonical == "/caldav/principals/user/":
                responses.append(response_block(principal_href, principal_props))
            elif canonical == "/caldav/calendars/":
                responses.append(response_block(calendars_root, calendar_home_props))
                if self.headers.get("Depth", "0") != "0":
                    responses.append(response_block(public_calendar, public_calendar_props))
            elif canonical == "/caldav/calendars/public/":
                responses.append(response_block(public_calendar, public_calendar_props))
                if self.headers.get("Depth", "0") != "0":
                    conn = ensure_db()
                    rows = conn.execute(
                        "SELECT id, etag, caldav_resource AS resource FROM events WHERE calendar_id = ? AND caldav_resource IS NOT NULL ORDER BY id ASC",
                        (SHARED_CALENDAR_ID,),
                    ).fetchall()
                    conn.close()
                    for row in rows:
                        href = f"{prefix}/caldav/calendars/public/{row['resource']}"
                        item_props = (
                            "<D:resourcetype/>"
                            "<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>"
                            f"<D:getetag>{row['etag']}</D:getetag>"
                        )
                        responses.append(response_block(href, item_props))
            else:
                self._not_found()
                return

            body = (
                '<?xml version="1.0" encoding="utf-8" ?>\n'
                '<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
                + "\n".join(responses)
                + "\n</D:multistatus>\n"
            )
            self.send_response(207)
            self.send_header("Content-Type", "application/xml; charset=utf-8")
            self.send_header("Content-Length", str(len(body.encode("utf-8"))))
            self.end_headers()
            self.wfile.write(body.encode("utf-8"))
        finally:
            self._trace_finish()

    def do_REPORT(self) -> None:
        self._trace_start()
        try:
            request_body = self._read_request_body_bytes().decode("utf-8", errors="replace")
            path = self._strip_slug(urlparse(self.path).path)
            if path is None:
                self._not_found()
                return
            if not path.startswith("/caldav/"):
                self._not_found()
                return
            auth = self._authenticate_caldav_user()
            if not auth:
                self.send_response(401)
                self.send_header("WWW-Authenticate", 'Basic realm="calendar-caldav-fixture"')
                self.end_headers()
                return
            conn = ensure_db()
            all_rows = conn.execute(
                "SELECT * FROM events WHERE calendar_id = ? AND caldav_resource IS NOT NULL ORDER BY id ASC", (SHARED_CALENDAR_ID,)
            ).fetchall()
            conn.close()
            rows = list(all_rows)
            requested_hrefs = re.findall(r"<D:href>\s*([^<]+)\s*</D:href>", request_body, re.IGNORECASE)
            if "calendar-multiget" in request_body.lower() and requested_hrefs:
                wanted_resources: set[str] = set()
                for href in requested_hrefs:
                    href_path = urlparse(href.strip()).path
                    stripped = self._strip_slug(href_path)
                    if stripped is None:
                        stripped = href_path
                    filename = _caldav_filename_from_path(stripped)
                    if filename:
                        wanted_resources.add(filename)
                if wanted_resources:
                    rows = [r for r in all_rows if str(r["caldav_resource"]) in wanted_resources]
                else:
                    rows = []
            prefix = self._slug_prefix()
            responses = []
            for row in rows:
                evt = event_to_dict(row)
                href = f"{prefix}/caldav/calendars/public/{row['caldav_resource']}"
                ics = self._event_as_ics(evt)
                responses.append(
                    textwrap.dedent(
                        f"""\
                        <D:response>
                          <D:href>{href}</D:href>
                          <D:propstat>
                            <D:prop>
                              <D:getetag>{row["etag"]}</D:getetag>
                              <C:calendar-data>{_xml_escape(ics)}</C:calendar-data>
                            </D:prop>
                            <D:status>HTTP/1.1 200 OK</D:status>
                          </D:propstat>
                        </D:response>
                        """
                    ).strip()
                )
            body = (
                '<?xml version="1.0" encoding="utf-8" ?>\n'
                '<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
                + "\n".join(responses)
                + "\n</D:multistatus>\n"
            )
            self.send_response(207)
            self.send_header("Content-Type", "application/xml; charset=utf-8")
            self.send_header("Content-Length", str(len(body.encode("utf-8"))))
            self.end_headers()
            self.wfile.write(body.encode("utf-8"))
        finally:
            self._trace_finish()

    def _handle_admin_page(self, parsed) -> None:
        auth = self._authenticate_wp_admin()
        if not auth:
            html = self._base_page(
                "Admin Access",
                f"""
<header><strong>Calendar Fixture Admin</strong></header>
<main>
  <div class="card">
    <div class="err">Admin auth required (use <code>?as=admin</code> or <code>?as=editor</code> in fixture).</div>
    <h3>Useful Links</h3>
    <ul>
      <li><a href="{self._with_slug('/admin.php')}?as=admin">Admin Index</a></li>
      <li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-users')}&as=admin">Users</a></li>
      <li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-setup')}&as=admin">Setup</a></li>
      <li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-diagnostics')}&as=admin">Diagnostics</a></li>
      <li><a href="{self._with_slug('/calendar')}">Public Calendar</a></li>
    </ul>
  </div>
</main>
""",
            )
            self._write_text(401, html, "text/html; charset=utf-8")
            return
        query = parse_qs(parsed.query)
        page_values = query.get("page") or []
        page = page_values[0] if page_values else ""
        as_user = (query.get("as") or ["admin"])[0]
        allowed = {"calendar-users", "calendar-setup", "calendar-diagnostics"}
        if page and page not in allowed:
            self._write_text(404, "unknown admin page")
            return
        if page in {"calendar-users", "calendar-setup", "calendar-diagnostics"} and auth.role != "wp_admin":
            self._write_text(403, "forbidden")
            return
        nav = f"""
<header>
  <strong>Calendar Fixture Admin</strong> &nbsp;
  <a href="{self._with_slug('/wp-admin/admin.php?page=calendar-users')}&as={as_user}">Users</a>
  <a href="{self._with_slug('/wp-admin/admin.php?page=calendar-setup')}&as={as_user}">Setup</a>
  <a href="{self._with_slug('/wp-admin/admin.php?page=calendar-diagnostics')}&as={as_user}">Diagnostics</a>
  <a href="{self._with_slug('/calendar')}">Public Calendar</a>
</header>
<main>
  <div class="card"><div class="muted">Authenticated as: {auth.actor_id} ({auth.role})</div></div>
"""
        if not page:
            body = nav + f"""
  <div class="card">
    <h3>Admin Pages</h3>
    <ul>
      <li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-users')}&as={as_user}">Users</a></li>
      <li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-setup')}&as={as_user}">Setup</a></li>
      <li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-diagnostics')}&as={as_user}">Diagnostics</a></li>
    </ul>
  </div>
</main>
"""
            html = self._base_page("Admin Index", body)
        if page == "calendar-users":
            body = nav + self._admin_users_body(as_user) + "</main>"
            html = self._base_page("Users", body)
        elif page == "calendar-setup":
            body = nav + self._admin_setup_body(as_user) + "</main>"
            html = self._base_page("Setup", body)
        elif page == "calendar-diagnostics":
            body = nav + self._admin_diagnostics_body(as_user) + "</main>"
            html = self._base_page("Diagnostics", body)
        self._write_text(200, html, "text/html; charset=utf-8")

    def _admin_edit_body(self, as_user: str) -> str:
        return f"""
  <div class="card">
    <h3>Events</h3>
    <div class="toolbar">
      <div class="field"><label>From Date</label><input id="fromDate" type="date" /></div>
      <div class="field"><label>Future Only</label><select id="futureOnly"><option value="0">No</option><option value="1">Yes</option></select></div>
      <button id="newEventBtn">Create Event</button>
      <button id="reloadBtn">Reload List</button>
    </div>
  </div>
  <div class="card">
    <h3>Events</h3>
    <table><thead><tr><th>Title</th><th>Start</th><th>Repeat</th><th>Actions</th></tr></thead><tbody id="eventsBody"></tbody></table>
  </div>
  <div id="modal" class="modal">
    <div class="modal-card">
      <h3 id="modalTitle">Event</h3>
      <input id="eventId" type="hidden" />
      <div class="field"><label>Title</label><input id="title" /></div>
      <div class="row">
        <div class="field"><label>Location</label><input id="location" /></div>
        <div class="field"><label>Category</label><input id="category" /></div>
      </div>
      <div class="field"><label>Description</label><textarea id="description" rows="3"></textarea></div>
      <div class="toggle-wrap"><label><input id="allDay" type="checkbox" /> All Day Event</label></div>
      <div class="grid4">
        <div class="field"><label>Start Date</label><input id="startDate" type="date" /></div>
        <div class="field" id="startTimeWrap"><label>Start Time</label><input id="startTime" type="time" /></div>
        <div class="field"><label>End Date</label><input id="endDate" type="date" /></div>
        <div class="field" id="endTimeWrap"><label>End Time</label><input id="endTime" type="time" /></div>
      </div>
      <div class="row">
        <div class="field"><label>Repeat Type</label>
          <select id="repeatType">
            <option value="none">none</option>
            <option value="daily">daily</option>
            <option value="weekly">weekly</option>
            <option value="monthly">monthly</option>
            <option value="yearly">yearly</option>
            <option value="custom">custom</option>
          </select>
        </div>
        <div class="field field-with-suffix" id="repeatIntervalWrap"><label>Every</label><input id="repeatInterval" type="number" min="1" value="1" /><span id="repeatIntervalText" class="input-suffix"></span></div>
      </div>
      <div class="row" id="modeUntilRow">
        <div class="field"><label>Range Mode</label>
          <select id="rangeMode">
            <option value="none">none</option>
            <option value="no_end">no_end</option>
            <option value="count">count</option>
            <option value="until">until</option>
          </select>
        </div>
        <div class="field" id="repeatUntilWrap"><label>Repeat Until</label><input id="repeatUntil" type="date" /></div>
      </div>
      <div class="row" id="rangeWrap">
        <div class="field" id="repeatCountWrap"><label>Repeat Count</label><input id="repeatCount" type="number" min="1" /></div>
      </div>
      <div class="row" id="monthlyNthWrap">
        <div class="field"><label>Monthly Pattern</label>
          <select id="monthlyNthMode">
            <option value="">from start date</option>
            <option value="day_of_month">nth day of month</option>
            <option value="weekday_of_month">nth weekday of month</option>
          </select>
        </div>
        <div class="field" id="nthDayWrap"><label>Day Number</label><input id="nthDay" type="number" min="1" max="31" /></div>
      </div>
      <div class="row" id="nthWeekdayWrap">
        <div class="field"><label>Nth</label><select id="nthPos"><option value="1">1st</option><option value="2">2nd</option><option value="3">3rd</option><option value="4">4th</option><option value="5">5th</option><option value="-1">last</option></select></div>
        <div class="field"><label>Weekday</label><select id="nthWeekday"><option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option><option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option></select></div>
      </div>
      <div class="toolbar">
        <button id="createBtn">Create</button>
        <button id="updateBtn">Update</button>
        <button id="deleteBtn">Delete</button>
        <button id="toggleSingleOccurrenceBtn">Delete a Single Occurrence</button>
        <button id="closeModalBtn">Close</button>
      </div>
      <div class="card" id="singleOccurrencePanel" style="display:none">
        <h4>Delete Single Occurrence (Exception)</h4>
        <div class="toolbar">
          <button id="occPrevBtn">&#8592;</button>
          <button id="occNextBtn">&#8594;</button>
        </div>
        <div id="occurrenceGrid" class="month-grid"></div>
        <input id="occKey" type="hidden" />
        <div class="field"><label>Occurrence</label><input id="occKeyDisplay" readonly placeholder="Select an occurrence from the calendar above" /></div>
        <button id="deleteOccurrenceBtn">Delete Occurrence</button>
      </div>
    </div>
  </div>
  <pre id="out" class="card"></pre>
  <script>
    const AS = {json.dumps(as_user)};
    const ROOT = {json.dumps(self._slug_prefix())};
    const out = document.getElementById('out');
    const eventsBody = document.getElementById('eventsBody');
    const modalEl = document.getElementById('modal');
    let occCursor = new Date();
    document.getElementById('fromDate').value = new Date().toISOString().slice(0,10);
    function apiUrl(path) {{
      const full = ROOT + path;
      const glue = full.includes('?') ? '&' : '?';
      return full + glue + 'as=' + encodeURIComponent(AS);
    }}
    async function api(path, opts={{}}) {{
      const resp = await fetch(apiUrl(path), {{
        headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
        ...opts
      }});
      let payload = null;
      const text = await resp.text();
      try {{ payload = text ? JSON.parse(text) : {{}}; }} catch {{ payload = {{ raw: text }}; }}
      out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
      return {{ resp, payload }};
    }}
    function splitIsoToInputs(iso) {{
      if (!iso) return {{ d: '', t: '' }};
      if (!iso.includes('T')) return {{ d: iso.slice(0,10), t: '' }};
      return {{ d: iso.slice(0,10), t: iso.slice(11,16) }};
    }}
    function toIso(d,t,allDay) {{
      if (!d) return '';
      if (allDay) return d;
      return `${{d}}T${{(t||'00:00')}}:00+01:00`;
    }}
    function formatOccurrenceKey(iso) {{
      if (!iso) return '';
      const dt = new Date(iso);
      if (Number.isNaN(dt.getTime())) return iso;
      return dt.toLocaleString('en-GB', {{ year:'numeric', month:'short', day:'2-digit', hour:'2-digit', minute:'2-digit' }});
    }}
    function applyAllDayVisibility() {{
      const allDay = !!document.getElementById('allDay').checked;
      document.getElementById('startTimeWrap').style.display = allDay ? 'none' : '';
      document.getElementById('endTimeWrap').style.display = allDay ? 'none' : '';
    }}
    function repeatUnit(rt) {{
      if (rt === 'daily') return 'day';
      if (rt === 'weekly' || rt === 'custom') return 'week';
      if (rt === 'monthly') return 'month';
      if (rt === 'yearly') return 'year';
      return '';
    }}
    function repeatLabel(evt) {{
      if (!evt || evt.repeat_type === 'none') return 'none';
      const n = Number(evt.repeat_interval || 1);
      const unit = repeatUnit(evt.repeat_type);
      return unit ? `${{n}} ${{unit}}${{n===1?'':'s'}}` : evt.repeat_type;
    }}
    function everyText() {{
      const n = Number(document.getElementById('repeatInterval').value || 1);
      const rt = document.getElementById('repeatType').value;
      const unit = repeatUnit(rt);
      document.getElementById('repeatIntervalText').textContent = unit ? `${{unit}}${{n===1?'':'s'}}` : '';
    }}
    function applyRecurrenceVisibility() {{
      const rt = document.getElementById('repeatType').value;
      const rm = document.getElementById('rangeMode').value;
      const showRecurring = rt !== 'none';
      document.getElementById('repeatIntervalWrap').style.display = showRecurring ? '' : 'none';
      document.getElementById('modeUntilRow').style.display = showRecurring ? '' : 'none';
      document.getElementById('rangeWrap').style.display = (showRecurring && rm === 'count') ? '' : 'none';
      document.getElementById('repeatCountWrap').style.display = (showRecurring && rm === 'count') ? '' : 'none';
      document.getElementById('repeatUntilWrap').style.display = (showRecurring && rm === 'until') ? '' : 'none';
      document.getElementById('monthlyNthWrap').style.display = (rt === 'monthly') ? '' : 'none';
      const mm = document.getElementById('monthlyNthMode').value;
      document.getElementById('nthDayWrap').style.display = (rt === 'monthly' && mm === 'day_of_month') ? '' : 'none';
      document.getElementById('nthWeekdayWrap').style.display = (rt === 'monthly' && mm === 'weekday_of_month') ? '' : 'none';
      document.getElementById('toggleSingleOccurrenceBtn').style.display = showRecurring ? '' : 'none';
      if (!showRecurring) document.getElementById('singleOccurrencePanel').style.display = 'none';
      everyText();
    }}
    async function loadOccurrenceChooser() {{
      const id = document.getElementById('eventId').value;
      if (!id) return;
      const from = new Date(occCursor.getFullYear(), occCursor.getMonth(), 1);
      const fromS = from.toISOString().slice(0,10);
      const {{ payload }} = await api('/wp-json/calendar/v1/events/' + id + '/occurrences?from=' + fromS + '&months=3');
      const list = payload?.data || [];
      const dayToKey = new Map();
      list.forEach(x => {{
        const d = (x.occurrence_start || '').slice(0,10);
        if (d && !dayToKey.has(d)) dayToKey.set(d, x.occurrence_start);
      }});
      const cells = [];
      for (let m = 0; m < 3; m++) {{
        const d = new Date(from.getFullYear(), from.getMonth() + m, 1);
        const y = d.getFullYear(), mo = d.getMonth();
        const first = new Date(y, mo, 1), off = (first.getDay() + 6) % 7, dim = new Date(y, mo + 1, 0).getDate();
        let monthCells = '';
        for (let i = 0; i < off; i++) monthCells += '<div class="month-cell blank"></div>';
        for (let day = 1; day <= dim; day++) {{
          const dt = new Date(y, mo, day);
          const k = dt.toISOString().slice(0,10);
          const has = dayToKey.has(k);
          monthCells += `<div class="month-cell ${{has?'active':'blank'}}"><div class="day-num" data-occ-date="${{k}}" style="cursor:${{has?'pointer':'default'}}">${{day}}</div></div>`;
        }}
        cells.push(`<div class="year-month"><h4>${{d.toLocaleString('en-GB',{{month:'short',year:'numeric'}})}}</h4><div class="month-grid">${{monthCells}}</div></div>`);
      }}
      document.getElementById('occurrenceGrid').innerHTML = `<div class="year-grid">${{cells.join('')}}</div>`;
      document.querySelectorAll('[data-occ-date]').forEach(el => el.addEventListener('click', () => {{
        const k = el.getAttribute('data-occ-date');
        if (!dayToKey.has(k)) return;
        const iso = dayToKey.get(k) || '';
        document.getElementById('occKey').value = iso;
        document.getElementById('occKeyDisplay').value = formatOccurrenceKey(iso);
        updateEventButtons();
      }}));
    }}
    function openModal(mode, evt) {{
      if (!evt) evt = {{}};
      const sd = splitIsoToInputs(evt.start_datetime);
      const ed = splitIsoToInputs(evt.end_datetime);
      document.getElementById('modalTitle').textContent = mode === 'create' ? 'Create Event' : 'Edit Event';
      document.getElementById('eventId').value = evt.id || '';
      document.getElementById('title').value = evt.title || '';
      document.getElementById('location').value = evt.location || '';
      document.getElementById('category').value = evt.category || '';
      document.getElementById('description').value = evt.description || '';
      document.getElementById('allDay').checked = !!evt.all_day_event;
      document.getElementById('startDate').value = sd.d || '';
      document.getElementById('startTime').value = sd.t || '';
      document.getElementById('endDate').value = ed.d || '';
      document.getElementById('endTime').value = ed.t || '';
      document.getElementById('repeatType').value = evt.repeat_type || 'none';
      document.getElementById('repeatInterval').value = evt.repeat_interval || 1;
      document.getElementById('rangeMode').value = evt.repeat_range_mode || 'none';
      document.getElementById('repeatCount').value = evt.repeat_count || '';
      document.getElementById('repeatUntil').value = evt.repeat_until || '';
      document.getElementById('monthlyNthMode').value = evt.repeat_nth_mode || '';
      document.getElementById('nthDay').value = evt.repeat_nth_day || '';
      document.getElementById('nthPos').value = evt.repeat_nth_pos || '1';
      document.getElementById('nthWeekday').value = evt.repeat_nth_weekday === null || evt.repeat_nth_weekday === undefined ? '0' : String(evt.repeat_nth_weekday);
      document.getElementById('singleOccurrencePanel').style.display = 'none';
      document.getElementById('occKey').value = '';
      document.getElementById('occKeyDisplay').value = '';
      occCursor = new Date((evt.start_datetime || new Date().toISOString()).slice(0,10) + 'T00:00:00');
      document.getElementById('createBtn').style.display = mode === 'create' ? '' : 'none';
      document.getElementById('updateBtn').style.display = mode === 'edit' ? '' : 'none';
      document.getElementById('deleteBtn').style.display = mode === 'edit' ? '' : 'none';
      applyRecurrenceVisibility();
      applyAllDayVisibility();
      updateEventButtons();
      modalEl.classList.add('open');
    }}
    function closeModal() {{ modalEl.classList.remove('open'); }}
    function validateEventForm() {{
      const title = (document.getElementById('title').value || '').trim();
      const startDate = (document.getElementById('startDate').value || '').trim();
      const endDateInput = (document.getElementById('endDate').value || '').trim();
      const endDate = endDateInput || startDate;
      const allDay = !!document.getElementById('allDay').checked;
      const startTime = (document.getElementById('startTime').value || '').trim();
      const endTime = (document.getElementById('endTime').value || '').trim();
      if (!title || !startDate || !endDate) return false;
      if (endDate < startDate) return false;
      if (allDay) return true;
      if (!startTime || !endTime) return false;
      if (endDate === startDate && endTime < startTime) return false;
      return true;
    }}
    function updateEventButtons() {{
      const hasId = !!(document.getElementById('eventId').value || '').trim();
      const valid = validateEventForm();
      document.getElementById('createBtn').disabled = !(valid && !hasId);
      document.getElementById('updateBtn').disabled = !(valid && hasId);
      document.getElementById('deleteBtn').disabled = !hasId;
      document.getElementById('toggleSingleOccurrenceBtn').disabled = !(hasId && document.getElementById('repeatType').value !== 'none');
      document.getElementById('deleteOccurrenceBtn').disabled = !(hasId && (document.getElementById('occKey').value || '').trim());
    }}
    function getFormPayload() {{
      const allDay = document.getElementById('allDay').checked;
      return {{
        title: document.getElementById('title').value,
        location: document.getElementById('location').value,
        category: document.getElementById('category').value,
        description: document.getElementById('description').value,
        all_day_event: allDay,
        start_datetime: toIso(document.getElementById('startDate').value, document.getElementById('startTime').value, allDay),
        end_datetime: toIso((document.getElementById('endDate').value || document.getElementById('startDate').value), document.getElementById('endTime').value, allDay),
        repeat_type: document.getElementById('repeatType').value,
        repeat_interval: Number(document.getElementById('repeatInterval').value || 1),
        repeat_nth_mode: document.getElementById('monthlyNthMode').value,
        repeat_nth_day: document.getElementById('nthDay').value ? Number(document.getElementById('nthDay').value) : null,
        repeat_nth_pos: document.getElementById('nthPos').value ? Number(document.getElementById('nthPos').value) : null,
        repeat_nth_weekday: document.getElementById('nthWeekday').value === '' ? null : Number(document.getElementById('nthWeekday').value),
        repeat_range_mode: document.getElementById('rangeMode').value,
        repeat_count: document.getElementById('repeatCount').value ? Number(document.getElementById('repeatCount').value) : null,
        repeat_until: document.getElementById('repeatUntil').value || null
      }};
    }}
    async function loadEvents() {{
      const {{ payload }} = await api('/wp-json/calendar/v1/events');
      let events = payload?.data || [];
      const from = document.getElementById('fromDate').value;
      if (from) events = events.filter(e => (e.start_datetime || '').slice(0,10) >= from);
      if (document.getElementById('futureOnly').value === '1') {{
        const today = new Date().toISOString().slice(0,10);
        events = events.filter(e => (e.start_datetime || '').slice(0,10) >= today);
      }}
      eventsBody.innerHTML = events.map(evt => `
        <tr>
          <td>${{evt.title}}</td>
          <td>${{evt.start_datetime}}</td>
          <td>${{repeatLabel(evt)}}</td>
          <td><button data-id="${{evt.id}}">Edit</button></td>
        </tr>
      `).join('');
      eventsBody.querySelectorAll('button[data-id]').forEach(btn => {{
        btn.addEventListener('click', () => {{
          const evt = events.find(e => String(e.id) === btn.dataset.id);
          if (evt) openModal('edit', evt);
        }});
      }});
    }}
    document.getElementById('newEventBtn').onclick = () => openModal('create', {{}});
    document.getElementById('closeModalBtn').onclick = closeModal;
    document.getElementById('createBtn').onclick = () => {{
      if (!validateEventForm()) return;
      return api('/wp-json/calendar/v1/events', {{ method:'POST', body: JSON.stringify(getFormPayload()) }}).then(() => {{ closeModal(); return loadEvents(); }});
    }};
    document.getElementById('updateBtn').onclick = () => {{
      const id = document.getElementById('eventId').value;
      if (!id) return;
      if (!validateEventForm()) return;
      return api('/wp-json/calendar/v1/events/' + id, {{ method:'PATCH', body: JSON.stringify(getFormPayload()) }}).then(() => {{ closeModal(); return loadEvents(); }});
    }};
    document.getElementById('deleteBtn').onclick = () => {{
      const id = document.getElementById('eventId').value;
      if (!id) return;
      return api('/wp-json/calendar/v1/events/' + id, {{ method:'DELETE' }}).then(() => {{ closeModal(); return loadEvents(); }});
    }};
    document.getElementById('toggleSingleOccurrenceBtn').onclick = async () => {{
      const p = document.getElementById('singleOccurrencePanel');
      p.style.display = p.style.display === 'none' ? '' : 'none';
      if (p.style.display !== 'none') await loadOccurrenceChooser();
    }};
    document.getElementById('occPrevBtn').onclick = async () => {{
      occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth() - 1, 1);
      await loadOccurrenceChooser();
    }};
    document.getElementById('occNextBtn').onclick = async () => {{
      occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth() + 1, 1);
      await loadOccurrenceChooser();
    }};
    document.getElementById('deleteOccurrenceBtn').onclick = async () => {{
      const id = document.getElementById('eventId').value;
      const key = encodeURIComponent(document.getElementById('occKey').value);
      if (!id || !key) return;
      await api('/wp-json/calendar/v1/events/' + id + '/occurrences/' + key, {{ method:'DELETE' }});
      await loadOccurrenceChooser();
      await loadEvents();
    }};
    document.getElementById('repeatType').onchange = applyRecurrenceVisibility;
    document.getElementById('rangeMode').onchange = applyRecurrenceVisibility;
    document.getElementById('monthlyNthMode').onchange = applyRecurrenceVisibility;
    document.getElementById('repeatInterval').oninput = everyText;
    document.getElementById('allDay').onchange = () => {{ applyAllDayVisibility(); updateEventButtons(); }};
    ['title','startDate','endDate','startTime','endTime','repeatType','occKey'].forEach(id => {{
      document.getElementById(id).addEventListener('input', updateEventButtons);
    }});
    document.getElementById('reloadBtn').onclick = loadEvents;
    document.getElementById('fromDate').onchange = loadEvents;
    document.getElementById('futureOnly').onchange = loadEvents;
    applyAllDayVisibility();
    updateEventButtons();
    loadEvents();
  </script>
"""

    def _admin_users_body(self, as_user: str) -> str:
        return f"""
  <div class="card">
    <h3>Users</h3>
    <button id="reloadUsers">Reload</button>
    <table>
      <thead><tr><th>ID</th><th>Email</th><th>Email Verified</th><th>Status</th><th>Action</th></tr></thead>
      <tbody id="usersBody"></tbody>
    </table>
  </div>
  <pre id="out" class="card"></pre>
  <script>
    const AS = {json.dumps(as_user)};
    const ROOT = {json.dumps(self._slug_prefix())};
    const out = document.getElementById('out');
    const usersBody = document.getElementById('usersBody');
    function apiUrl(path) {{
      const full = ROOT + path;
      const glue = full.includes('?') ? '&' : '?';
      return full + glue + 'as=' + encodeURIComponent(AS);
    }}
    async function api(path, opts={{}}) {{
      const resp = await fetch(apiUrl(path), {{
        headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
        ...opts
      }});
      let payload = null;
      const raw = await resp.text();
      if (raw) {{
        try {{ payload = JSON.parse(raw); }} catch (_err) {{ payload = {{ raw }}; }}
      }}
      out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
      return payload;
    }}
    async function loadUsers() {{
      const payload = await api('/wp-json/calendar/v1/admin/users');
      const users = payload.data || [];
      usersBody.innerHTML = users.map(u => `
      <tr>
        <td>${{u.id}}</td>
        <td>${{u.email}}</td>
        <td>${{u.email_verified_at ? 'yes' : 'no'}}</td>
        <td><select data-kind="status" data-id="${{u.id}}">
          ${{['pending_approval','active'].map(v=>`<option ${{u.account_status===v?'selected':''}}>${{v}}</option>`).join('')}}
        </select></td>
        <td><button data-save="${{u.id}}">Save</button> <button data-del="${{u.id}}">Remove</button></td>
      </tr>`).join('');
      usersBody.querySelectorAll('button[data-save]').forEach(btn => {{
        btn.addEventListener('click', async () => {{
          const id = btn.dataset.save;
          const status = usersBody.querySelector(`select[data-kind="status"][data-id="${{id}}"]`).value;
          await api('/wp-json/calendar/v1/admin/users/' + id, {{
            method:'PATCH',
            body: JSON.stringify({{ account_status: status }})
          }});
          await loadUsers();
        }});
      }});
      usersBody.querySelectorAll('button[data-del]').forEach(btn => {{
        btn.addEventListener('click', async () => {{
          const id = btn.dataset.del;
          await api('/wp-json/calendar/v1/admin/users/' + id, {{ method:'DELETE' }});
          await loadUsers();
        }});
      }});
    }}
    document.getElementById('reloadUsers').onclick = loadUsers;
    loadUsers();
  </script>
"""

    def _admin_setup_body(self, as_user: str) -> str:
        return f"""
  <div class="card">
    <h3>Setup</h3>
    <div class="field"><label>Presentation Name</label><input id="presentationName" placeholder="presentation name" /></div>
    <div class="field"><label>CalDAV Calendar Name</label><input id="caldavCalendarName" placeholder="Team Calendar" /></div>
    <div class="field"><label>URL Slug (prefix for all routes)</label><input id="urlSlug" placeholder="calendar" /></div>
    <div class="field"><label>ICS Access Mode</label><select id="icsMode">
      <option value="public_read">public_read</option>
      <option value="authenticated_read">authenticated_read</option>
    </select></div>
    <button id="saveSetup">Save Setup</button>
    <button id="reloadSetup">Reload</button>
  </div>
  <div class="card">
    <h3>Shortcode Semantics</h3>
    <p><code>[calendar]</code> renders the full interactive calendar page (views, filters, login, and event edit for approved users).</p>
    <p><code>[calendar_sidebar_upcoming]</code> renders a compact upcoming-events list for sidebar/widget use, showing occurrences for the next 14 days.</p>
    <p>If <code>URL Slug</code> is set, all plugin routes are prefixed with that slug (for example: <code>/my-slug/calendar</code>, <code>/my-slug/calendar-sidebar</code>, <code>/my-slug/calendar.ics</code>, <code>/my-slug/caldav/</code>).</p>
  </div>
  <pre id="out" class="card"></pre>
  <script>
    const AS = {json.dumps(as_user)};
    const ROOT = {json.dumps(self._slug_prefix())};
    const out = document.getElementById('out');
    function apiUrl(path) {{
      const full = ROOT + path;
      const glue = full.includes('?') ? '&' : '?';
      return full + glue + 'as=' + encodeURIComponent(AS);
    }}
    async function api(path, opts={{}}) {{
      const resp = await fetch(apiUrl(path), {{
        headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
        ...opts
      }});
      const payload = await resp.json();
      out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
      return payload;
    }}
    async function loadSetup() {{
      const payload = await api('/wp-json/calendar/v1/admin/setup');
      document.getElementById('presentationName').value = payload?.data?.presentation_name || 'Calendar';
      document.getElementById('caldavCalendarName').value = payload?.data?.caldav_calendar_name || 'Public Calendar';
      document.getElementById('urlSlug').value = payload?.data?.url_slug || '';
      document.getElementById('icsMode').value = payload?.data?.ics_access_mode || 'public_read';
    }}
    document.getElementById('saveSetup').onclick = async () => {{
      await api('/wp-json/calendar/v1/admin/setup', {{
        method:'PATCH',
        body: JSON.stringify({{
          presentation_name: document.getElementById('presentationName').value,
          caldav_calendar_name: document.getElementById('caldavCalendarName').value,
          url_slug: document.getElementById('urlSlug').value,
          ics_access_mode: document.getElementById('icsMode').value
        }})
      }});
      await loadSetup();
    }};
    document.getElementById('reloadSetup').onclick = loadSetup;
    loadSetup();
  </script>
"""

    def _admin_diagnostics_body(self, as_user: str) -> str:
        return f"""
  <div class="card">
    <h3>Diagnostics</h3>
    <p class="muted">Shows the most recent HTTP trace events (requests/responses) from the fixture log.</p>
    <button id="reloadDiag">Reload</button>
    <table>
      <thead><tr><th>Timestamp</th><th>Method</th><th>Path</th><th>Status</th><th>Request</th><th>Response</th></tr></thead>
      <tbody id="diagBody"></tbody>
    </table>
  </div>
  <pre id="out" class="card"></pre>
  <script>
    const AS = {json.dumps(as_user)};
    const ROOT = {json.dumps(self._slug_prefix())};
    const out = document.getElementById('out');
    const diagBody = document.getElementById('diagBody');
    function esc(v) {{
      const s = String(v == null ? '' : v);
      return s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
    }}
    function apiUrl(path) {{
      const full = ROOT + path;
      const glue = full.includes('?') ? '&' : '?';
      return full + glue + 'as=' + encodeURIComponent(AS);
    }}
    async function api(path, opts={{}}) {{
      const resp = await fetch(apiUrl(path), {{
        headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
        ...opts
      }});
      const text = await resp.text();
      let payload = null;
      try {{ payload = text ? JSON.parse(text) : {{}}; }} catch (_err) {{ payload = {{ raw: text }}; }}
      out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
      return payload;
    }}
    async function loadDiagnostics() {{
      const payload = await api('/wp-json/calendar/v1/admin/diagnostics?limit=20');
      const rows = payload?.data || [];
      diagBody.innerHTML = rows.map(r => `
      <tr>
        <td>${{esc(r.ts || '')}}</td>
        <td>${{esc(r.method || '')}}</td>
        <td><code>${{esc(r.path || '')}}</code></td>
        <td>${{esc(r.response?.status || '')}}</td>
        <td><code>${{esc(r.request?.body || '')}}</code></td>
        <td><code>${{esc(r.response?.body || '')}}</code></td>
      </tr>`).join('');
    }}
    document.getElementById('reloadDiag').onclick = loadDiagnostics;
    loadDiagnostics();
  </script>
"""

    def _handle_api_get(self, path: str, parsed) -> None:
        if path == f"{API_BASE}/public/events":
            query = parse_qs(parsed.query)
            view = (query.get("view") or ["month"])[0]
            date_str = (query.get("date") or [date.today().isoformat()])[0]
            try:
                anchor = date.fromisoformat(date_str)
            except ValueError:
                self._write_json(422, {"error": {"code": "validation_error", "message": "invalid date"}})
                return
            window_start, window_end = _window_for_view(view, anchor)
            conn = ensure_db()
            rows = conn.execute("SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)).fetchall()
            ex_rows = conn.execute(
                "SELECT event_id, occurrence_key FROM recurrence_exceptions WHERE exception_type = 'deleted_occurrence'"
            ).fetchall()
            conn.close()
            ex_map: Dict[int, set] = {}
            for ex in ex_rows:
                ex_map.setdefault(ex["event_id"], set()).add(ex["occurrence_key"])
            occurrences: List[Dict[str, Any]] = []
            for row in rows:
                evt = event_to_dict(row)
                occurrences.extend(self._expand_event(evt, window_start, window_end, ex_map.get(evt["id"], set())))
            occurrences.sort(key=lambda x: x["occurrence_start"])
            self._write_json(200, {"data": occurrences, "meta": {"count": len(occurrences), "view": view}})
            return

        if path == f"{API_BASE}/public/sidebar-events":
            anchor = date.today()
            window_start = datetime.fromisoformat(f"{anchor.isoformat()}T00:00:00+00:00")
            window_end = window_start + timedelta(days=14)
            conn = ensure_db()
            rows = conn.execute("SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)).fetchall()
            ex_rows = conn.execute(
                "SELECT event_id, occurrence_key FROM recurrence_exceptions WHERE exception_type = 'deleted_occurrence'"
            ).fetchall()
            conn.close()
            ex_map: Dict[int, set] = {}
            for ex in ex_rows:
                ex_map.setdefault(ex["event_id"], set()).add(ex["occurrence_key"])
            occurrences: List[Dict[str, Any]] = []
            for row in rows:
                evt = event_to_dict(row)
                occurrences.extend(self._expand_event(evt, window_start, window_end, ex_map.get(evt["id"], set())))
            occurrences.sort(key=lambda x: x["occurrence_start"])
            self._write_json(200, {"data": occurrences, "meta": {"count": len(occurrences), "window_days": 14}})
            return

        if path == f"{API_BASE}/events":
            auth = self._require_event_read_auth()
            if not auth:
                return
            conn = ensure_db()
            rows = conn.execute(
                "SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)
            ).fetchall()
            conn.close()
            items = [event_to_dict(r) for r in rows]
            self._write_json(200, {"data": items, "meta": {"count": len(items)}})
            return

        m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)", path)
        if m:
            auth = self._require_event_read_auth()
            if not auth:
                return
            event_id = int(m.group(1))
            conn = ensure_db()
            row = conn.execute(
                "SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)
            ).fetchone()
            conn.close()
            if not row:
                self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
                return
            self._write_json(200, {"data": event_to_dict(row)})
            return

        if path == f"{API_BASE}/admin/users":
            auth = self._require_api_admin()
            if not auth:
                return
            if auth.role != "wp_admin":
                self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
                return
            conn = ensure_db()
            rows = conn.execute(
                "SELECT id, email, email_verified_at, account_status, updated_at FROM caldav_users ORDER BY id ASC"
            ).fetchall()
            conn.close()
            self._write_json(200, {"data": [dict(r) for r in rows]})
            return

        m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)/occurrences", path)
        if m:
            auth = self._require_event_read_auth()
            if not auth:
                return
            event_id = int(m.group(1))
            query = parse_qs(parsed.query)
            from_s = (query.get("from") or [date.today().isoformat()])[0]
            months = int((query.get("months") or ["3"])[0])
            try:
                from_d = date.fromisoformat(from_s)
            except ValueError:
                self._write_json(422, {"error": {"code": "validation_error", "message": "invalid from date"}})
                return
            start = datetime.fromisoformat(f"{from_d.isoformat()}T00:00:00+00:00")
            end = _add_months(start, max(1, min(months, 12)))
            conn = ensure_db()
            row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
            if not row:
                conn.close()
                self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
                return
            ex_rows = conn.execute(
                "SELECT occurrence_key FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence'",
                (event_id,),
            ).fetchall()
            conn.close()
            deleted = {r["occurrence_key"] for r in ex_rows}
            occ = self._expand_event(event_to_dict(row), start, end, deleted)
            self._write_json(200, {"data": occ})
            return

        if path == f"{API_BASE}/admin/setup":
            auth = self._require_api_admin()
            if not auth:
                return
            if auth.role != "wp_admin":
                self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
                return
            conn = ensure_db()
            payload = {
                "presentation_name": get_setting(conn, "presentation_name", "Calendar"),
                "caldav_calendar_name": get_setting(conn, "caldav_calendar_name", "Public Calendar"),
                "url_slug": get_setting(conn, "url_slug", ""),
                "ics_access_mode": get_setting(conn, "ics_access_mode", "public_read"),
            }
            conn.close()
            self._write_json(200, {"data": payload})
            return

        if path == f"{API_BASE}/admin/diagnostics":
            auth = self._require_api_admin()
            if not auth:
                return
            if auth.role != "wp_admin":
                self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
                return
            query = parse_qs(parsed.query)
            try:
                limit = int((query.get("limit") or ["20"])[0])
            except ValueError:
                self._write_json(422, {"error": {"code": "validation_error", "message": "invalid limit"}})
                return
            entries = self._read_trace_entries(limit=max(1, min(limit, 200)))
            self._write_json(200, {"data": entries, "meta": {"count": len(entries)}})
            return

        if path == f"{API_BASE}/users/me":
            creds = parse_basic_auth(self.headers.get("Authorization"))
            email_hint = (creds[0].strip().lower() if creds else "unknown")
            if _rate_limited("user_login", f"{self.client_address[0]}:{email_hint}", limit=20, window_seconds=300):
                self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many login attempts. Try again later."}})
                return
            auth = self._authenticate_caldav_user()
            if not auth:
                self._write_json(401, {"error": {"code": "authentication_error", "message": "Login failure"}})
                return
            conn = ensure_db()
            row = conn.execute(
                "SELECT id, email, account_status, email_verified_at FROM caldav_users WHERE id = ?",
                (int(auth.actor_id),),
            ).fetchone()
            conn.close()
            self._write_json(200, {"data": dict(row)})
            return

        self._not_found()

    def _handle_api_post(self, path: str) -> None:
        if path == f"{API_BASE}/events":
            auth = self._require_event_write_auth()
            if not auth:
                return
            payload = self._json_body()
            normalized = self._validate_event_payload(payload)
            if normalized is None:
                return
            now = utc_now_iso()
            uid = payload.get("uid") or make_uid(f"{normalized['title']}:{normalized['start_datetime']}:{now}")
            etag = mk_etag(f"{uid}:{now}:1")
            conn = ensure_db()
            cur = conn.execute(
                """
                INSERT INTO events
                  (uid, title, description, location, category, all_day_event, start_datetime, end_datetime,
                   repeat_type, repeat_interval, repeat_nth_mode, repeat_nth_day, repeat_nth_pos, repeat_nth_weekday,
                   repeat_range_mode, repeat_count, repeat_until, timezone,
                   calendar_id, caldav_resource, etag, sync_version, created_at, updated_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    uid,
                    normalized["title"],
                    normalized["description"],
                    normalized["location"],
                    normalized["category"],
                    normalized["all_day_event"],
                    normalized["start_datetime"],
                    normalized["end_datetime"],
                    normalized["repeat_type"],
                    normalized["repeat_interval"],
                    normalized["repeat_nth_mode"],
                    normalized["repeat_nth_day"],
                    normalized["repeat_nth_pos"],
                    normalized["repeat_nth_weekday"],
                    normalized["repeat_range_mode"],
                    normalized["repeat_count"],
                    normalized["repeat_until"],
                    normalized["timezone"],
                    SHARED_CALENDAR_ID,
                    None,
                    etag,
                    1,
                    now,
                    now,
                ),
            )
            event_id = cur.lastrowid
            conn.execute("UPDATE events SET caldav_resource = ? WHERE id = ?", (f"{event_id}.ics", event_id))
            conn.commit()
            row = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
            log_audit(conn, auth.actor_type, auth.actor_id, "event.create", "event", str(event_id), "success", {})
            conn.close()
            self._write_json(201, {"data": event_to_dict(row)})
            return

        if path == f"{API_BASE}/users/register":
            if _rate_limited("user_register", self.client_address[0], limit=8, window_seconds=300):
                self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many registration attempts. Try again later."}})
                return
            payload = self._json_body()
            email = (payload.get("email") or "").strip().lower()
            password = payload.get("password") or ""
            if not email or len(password) < 8:
                self._write_json(422, {"error": {"code": "validation_error", "message": "email and strong password required"}})
                return
            now = utc_now_iso()
            conn = ensure_db()
            try:
                cur = conn.execute(
                    """
                    INSERT INTO caldav_users
                      (email, password_hash, email_verified_at, account_status, access_level, request_state, created_at, updated_at)
                    VALUES (?, ?, NULL, 'pending_approval', 'write', 'requested', ?, ?)
                    """,
                    (email, hash_password(password), now, now),
                )
            except sqlite3.IntegrityError:
                conn.close()
                self._write_json(409, {"error": {"code": "conflict_error", "message": "user already exists"}})
                return
            user_id = cur.lastrowid
            token_plain = secrets.token_urlsafe(24)
            conn.execute(
                """
                INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at, used_at, created_at)
                VALUES (?, 'verify_email', ?, ?, NULL, ?)
                """,
                (user_id, hash_password(token_plain), (datetime.now(timezone.utc) + timedelta(seconds=VERIFY_TOKEN_TTL_SECONDS)).replace(microsecond=0).isoformat(), now),
            )
            conn.commit()
            conn.close()
            sent, send_info = send_fixture_email(
                email,
                "Calendar account verification",
                f"Your verification token is: {token_plain}\nUse this token in the calendar login dialog to verify your email.",
            )
            self._write_json(
                201,
                {
                    "data": {
                        "user_id": user_id,
                        "status": "pending_approval",
                        "email_status": send_info,
                        "email_sent": sent,
                    }
                },
            )
            return

        if path == f"{API_BASE}/users/verify":
            if _rate_limited("user_verify", self.client_address[0], limit=20, window_seconds=300):
                self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many verification attempts. Try again later."}})
                return
            payload = self._json_body()
            token = payload.get("token") or ""
            conn = ensure_db()
            now = utc_now_iso()
            candidates = conn.execute(
                """
                SELECT id, user_id, token_hash, expires_at FROM user_tokens
                WHERE token_type = 'verify_email' AND used_at IS NULL
                ORDER BY id DESC
                """
            ).fetchall()
            row = None
            for cand in candidates:
                if cand["expires_at"] < now:
                    continue
                if verify_password(token, cand["token_hash"]):
                    row = cand
                    break
            if not row:
                conn.close()
                self._write_json(422, {"error": {"code": "validation_error", "message": "invalid token"}})
                return
            conn.execute("UPDATE user_tokens SET used_at = ? WHERE id = ?", (now, row["id"]))
            conn.execute(
                "UPDATE caldav_users SET email_verified_at = ?, account_status = 'pending_approval', request_state = 'requested', updated_at = ? WHERE id = ?",
                (now, now, row["user_id"]),
            )
            conn.commit()
            requester = conn.execute("SELECT email FROM caldav_users WHERE id = ?", (row["user_id"],)).fetchone()
            conn.close()
            cfg = _smtp_settings()
            admin_to = (cfg.get("SMTP_ADMIN_TO") or "").strip()
            sent = False
            send_info = "admin_email_not_configured"
            if admin_to:
                req_email = requester["email"] if requester else f"user_id={row['user_id']}"
                host = (self.headers.get("Host") or "127.0.0.1:8080").strip()
                proto = (self.headers.get("X-Forwarded-Proto") or "http").strip().lower()
                scheme = "https" if proto == "https" else "http"
                approval_path = self._with_slug("/wp-admin/admin.php?page=calendar-users") + "&as=admin"
                approval_url = f"{scheme}://{host}{approval_path}"
                sent, send_info = send_fixture_email(
                    admin_to,
                    "Calendar registration pending approval",
                    f"User {req_email} (id {row['user_id']}) verified email and is awaiting approval.\nApprove here: {approval_url}",
                )
            self._write_json(200, {"data": {"status": "pending_approval", "email_status": send_info, "email_sent": sent}})
            return

        if path == f"{API_BASE}/users/forgot-password":
            if _rate_limited("user_forgot", self.client_address[0], limit=12, window_seconds=300):
                self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many password reset requests. Try again later."}})
                return
            payload = self._json_body()
            email = (payload.get("email") or "").strip().lower()
            conn = ensure_db()
            row = conn.execute("SELECT id FROM caldav_users WHERE email = ?", (email,)).fetchone()
            token_plain = None
            if row:
                now = utc_now_iso()
                token_plain = secrets.token_urlsafe(24)
                conn.execute(
                    """
                    INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at, used_at, created_at)
                    VALUES (?, 'reset_password', ?, ?, NULL, ?)
                    """,
                    (row["id"], hash_password(token_plain), (datetime.now(timezone.utc) + timedelta(seconds=RESET_TOKEN_TTL_SECONDS)).replace(microsecond=0).isoformat(), now),
                )
                conn.commit()
            conn.close()
            sent = False
            send_info = "no_matching_user"
            if token_plain:
                sent, send_info = send_fixture_email(
                    email,
                    "Calendar password reset",
                    f"Your password reset token is: {token_plain}\nUse this token in the calendar login dialog to set a new password.",
                )
            self._write_json(200, {"data": {"status": "ok", "email_status": send_info, "email_sent": sent}})
            return

        if path == f"{API_BASE}/users/reset-password":
            if _rate_limited("user_reset", self.client_address[0], limit=20, window_seconds=300):
                self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many reset attempts. Try again later."}})
                return
            payload = self._json_body()
            token = payload.get("token") or ""
            new_password = payload.get("new_password") or ""
            if len(new_password) < 8:
                self._write_json(422, {"error": {"code": "validation_error", "message": "weak password"}})
                return
            conn = ensure_db()
            now = utc_now_iso()
            candidates = conn.execute(
                """
                SELECT id, user_id, token_hash, expires_at FROM user_tokens
                WHERE token_type = 'reset_password' AND used_at IS NULL
                ORDER BY id DESC
                """
            ).fetchall()
            row = None
            for cand in candidates:
                if cand["expires_at"] < now:
                    continue
                if verify_password(token, cand["token_hash"]):
                    row = cand
                    break
            if not row:
                conn.close()
                self._write_json(422, {"error": {"code": "validation_error", "message": "invalid token"}})
                return
            conn.execute("UPDATE user_tokens SET used_at = ? WHERE id = ?", (now, row["id"]))
            conn.execute("UPDATE caldav_users SET password_hash = ?, updated_at = ? WHERE id = ?", (hash_password(new_password), now, row["user_id"]))
            conn.commit()
            conn.close()
            self._write_json(200, {"data": {"status": "password_reset"}})
            return

        self._not_found()

    def _handle_api_put(self, path: str) -> None:
        self._handle_api_patch(path)

    def _handle_api_patch(self, path: str) -> None:
        m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)", path)
        if m:
            auth = self._require_event_write_auth()
            if not auth:
                return
            event_id = int(m.group(1))
            payload = self._json_body()
            normalized = self._validate_event_payload(payload)
            if normalized is None:
                return
            conn = ensure_db()
            row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
            if not row:
                conn.close()
                self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
                return
            if_match = self.headers.get("If-Match")
            if if_match and if_match != row["etag"]:
                conn.close()
                self._write_json(412, {"error": {"code": "precondition_failed", "message": "etag mismatch"}})
                return
            now = utc_now_iso()
            sync_version = int(row["sync_version"]) + 1
            etag = mk_etag(f"{row['uid']}:{now}:{sync_version}")
            conn.execute(
                """
                UPDATE events
                SET title = ?, description = ?, location = ?, category = ?, all_day_event = ?,
                    start_datetime = ?, end_datetime = ?, repeat_type = ?, repeat_interval = ?,
                    repeat_nth_mode = ?, repeat_nth_day = ?, repeat_nth_pos = ?, repeat_nth_weekday = ?,
                    repeat_range_mode = ?, repeat_count = ?, repeat_until = ?, timezone = ?,
                    etag = ?, sync_version = ?, updated_at = ?
                WHERE id = ?
                """,
                (
                    normalized["title"],
                    normalized["description"],
                    normalized["location"],
                    normalized["category"],
                    normalized["all_day_event"],
                    normalized["start_datetime"],
                    normalized["end_datetime"],
                    normalized["repeat_type"],
                    normalized["repeat_interval"],
                    normalized["repeat_nth_mode"],
                    normalized["repeat_nth_day"],
                    normalized["repeat_nth_pos"],
                    normalized["repeat_nth_weekday"],
                    normalized["repeat_range_mode"],
                    normalized["repeat_count"],
                    normalized["repeat_until"],
                    normalized["timezone"],
                    etag,
                    sync_version,
                    now,
                    event_id,
                ),
            )
            conn.commit()
            updated = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
            log_audit(conn, auth.actor_type, auth.actor_id, "event.update", "event", str(event_id), "success", {})
            conn.close()
            self._write_json(200, {"data": event_to_dict(updated)})
            return

        m = re.fullmatch(rf"{re.escape(API_BASE)}/admin/users/(\d+)", path)
        if m:
            auth = self._require_api_admin()
            if not auth:
                return
            if auth.role != "wp_admin":
                self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
                return
            user_id = int(m.group(1))
            payload = self._json_body()
            account_status = payload.get("account_status")
            conn = ensure_db()
            row = conn.execute("SELECT * FROM caldav_users WHERE id = ?", (user_id,)).fetchone()
            if not row:
                conn.close()
                self._write_json(404, {"error": {"code": "not_found", "message": "user not found"}})
                return
            updates = []
            params: List[Any] = []
            if account_status in {"pending_approval", "active"}:
                if account_status == "active" and not row["email_verified_at"]:
                    conn.close()
                    self._write_json(422, {"error": {"code": "validation_error", "message": "email must be verified before approval"}})
                    return
                updates.append("account_status = ?")
                params.append(account_status)
                updates.append("request_state = ?")
                params.append("approved" if account_status == "active" else "requested")
            if not updates:
                conn.close()
                self._write_json(422, {"error": {"code": "validation_error", "message": "valid account_status required"}})
                return
            if account_status == "active":
                updates.append("access_level = ?")
                params.append("write")
            updates.append("updated_at = ?")
            params.append(utc_now_iso())
            params.append(user_id)
            conn.execute(f"UPDATE caldav_users SET {', '.join(updates)} WHERE id = ?", params)
            conn.commit()
            updated = conn.execute(
                "SELECT id, email, email_verified_at, account_status, updated_at FROM caldav_users WHERE id = ?",
                (user_id,),
            ).fetchone()
            log_audit(conn, auth.actor_type, auth.actor_id, "user.update", "caldav_user", str(user_id), "success", payload)
            conn.close()
            self._write_json(200, {"data": dict(updated)})
            return

        if path == f"{API_BASE}/admin/setup":
            auth = self._require_api_admin()
            if not auth:
                return
            if auth.role != "wp_admin":
                self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
                return
            payload = self._json_body()
            presentation_name = (payload.get("presentation_name") or "").strip()
            caldav_calendar_name = (payload.get("caldav_calendar_name") or "").strip()
            raw_slug = (payload.get("url_slug") or "").strip().strip("/")
            ics_access_mode = payload.get("ics_access_mode")
            if not presentation_name:
                self._write_json(422, {"error": {"code": "validation_error", "message": "presentation_name required"}})
                return
            if not caldav_calendar_name:
                self._write_json(422, {"error": {"code": "validation_error", "message": "caldav_calendar_name required"}})
                return
            if raw_slug and not re.fullmatch(r"[a-z0-9-]+", raw_slug):
                self._write_json(422, {"error": {"code": "validation_error", "message": "url_slug must match [a-z0-9-]+"}})
                return
            if ics_access_mode not in {"public_read", "authenticated_read"}:
                self._write_json(422, {"error": {"code": "validation_error", "message": "invalid ics_access_mode"}})
                return
            conn = ensure_db()
            set_setting(conn, "presentation_name", presentation_name)
            set_setting(conn, "caldav_calendar_name", caldav_calendar_name)
            set_setting(conn, "url_slug", raw_slug)
            set_setting(conn, "ics_access_mode", ics_access_mode)
            log_audit(conn, auth.actor_type, auth.actor_id, "setup.update", "plugin_settings", "global", "success", payload)
            conn.close()
            self._write_json(200, {"data": {"presentation_name": presentation_name, "caldav_calendar_name": caldav_calendar_name, "url_slug": raw_slug, "ics_access_mode": ics_access_mode}})
            return

        self._not_found()

    def _handle_api_delete(self, path: str) -> None:
        m = re.fullmatch(rf"{re.escape(API_BASE)}/admin/users/(\d+)$", path)
        if m:
            auth = self._require_api_admin()
            if not auth:
                return
            if auth.role != "wp_admin":
                self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
                return
            user_id = int(m.group(1))
            conn = ensure_db()
            row = conn.execute("SELECT id FROM caldav_users WHERE id = ?", (user_id,)).fetchone()
            if not row:
                conn.close()
                self._write_json(404, {"error": {"code": "not_found", "message": "user not found"}})
                return
            conn.execute("DELETE FROM user_tokens WHERE user_id = ?", (user_id,))
            conn.execute("DELETE FROM caldav_users WHERE id = ?", (user_id,))
            conn.commit()
            log_audit(conn, auth.actor_type, auth.actor_id, "user.delete", "caldav_user", str(user_id), "success", {})
            conn.close()
            self.send_response(204)
            self.end_headers()
            return

        m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)$", path)
        if m:
            auth = self._require_event_write_auth()
            if not auth:
                return
            event_id = int(m.group(1))
            conn = ensure_db()
            row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
            if not row:
                conn.close()
                self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
                return
            conn.execute("DELETE FROM recurrence_exceptions WHERE event_id = ?", (event_id,))
            conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
            conn.commit()
            log_audit(conn, auth.actor_type, auth.actor_id, "event.delete", "event", str(event_id), "success", {})
            conn.close()
            self.send_response(204)
            self.end_headers()
            return

        m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)/occurrences/(.+)$", path)
        if m:
            auth = self._require_event_write_auth()
            if not auth:
                return
            event_id = int(m.group(1))
            occurrence_key = unquote(m.group(2))
            conn = ensure_db()
            row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
            if not row:
                conn.close()
                self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
                return
            if row["repeat_type"] == "none":
                conn.close()
                self._write_json(422, {"error": {"code": "validation_error", "message": "event is not recurring"}})
                return
            normalized_occurrence_key = _normalize_occurrence_key_for_event(occurrence_key, row["start_datetime"])
            if not normalized_occurrence_key:
                conn.close()
                self._write_json(422, {"error": {"code": "validation_error", "message": "invalid occurrence key"}})
                return
            now = utc_now_iso()
            try:
                conn.execute(
                    """
                    INSERT INTO recurrence_exceptions (event_id, occurrence_key, exception_type, override_payload, created_at, updated_at)
                    VALUES (?, ?, 'deleted_occurrence', NULL, ?, ?)
                    """,
                    (event_id, normalized_occurrence_key, now, now),
                )
                sync_version = int(row["sync_version"]) + 1
                etag = mk_etag(f"{row['uid']}:{now}:{sync_version}")
                conn.execute("UPDATE events SET etag = ?, sync_version = ?, updated_at = ? WHERE id = ?", (etag, sync_version, now, event_id))
                conn.commit()
                log_audit(
                    conn,
                    auth.actor_type,
                    auth.actor_id,
                    "event.occurrence.delete",
                    "event",
                    str(event_id),
                    "success",
                    {"occurrence_key": normalized_occurrence_key},
                )
            except sqlite3.IntegrityError:
                conn.close()
                # Idempotent behavior: deleting an already-excepted occurrence is still success.
                self.send_response(204)
                self.end_headers()
                return
            conn.close()
            self.send_response(204)
            self.end_headers()
            return

        self._not_found()

    def _expand_event(
        self,
        evt: Dict[str, Any],
        window_start: datetime,
        window_end: datetime,
        deleted_occurrence_keys: set,
    ) -> List[Dict[str, Any]]:
        deleted_canonical = set()
        for key in deleted_occurrence_keys:
            deleted_canonical.add(str(key))
            canonical = _canonical_occurrence_key(str(key))
            if canonical:
                deleted_canonical.add(canonical)

        def is_deleted(occ_start: datetime) -> bool:
            raw = occ_start.isoformat()
            if raw in deleted_canonical:
                return True
            canonical = _canonical_occurrence_key(raw)
            return canonical in deleted_canonical if canonical else False

        start = _parse_dt_maybe_date(evt["start_datetime"])
        end = _parse_dt_maybe_date(evt["end_datetime"])
        duration = end - start
        repeat_type = evt.get("repeat_type", "none")
        interval = max(1, int(evt.get("repeat_interval") or 1))
        repeat_mode = evt.get("repeat_range_mode") or "none"
        max_count: Optional[int] = None
        until_dt: Optional[datetime] = None
        if repeat_mode == "count" and evt.get("repeat_count"):
            max_count = int(evt["repeat_count"])
        if repeat_mode == "until" and evt.get("repeat_until"):
            try:
                until_dt = _parse_dt_maybe_date(f"{evt['repeat_until']}T23:59:59+00:00")
            except ValueError:
                until_dt = None

        def make_occurrence(occ_start: datetime) -> Dict[str, Any]:
            occ_end = occ_start + duration
            return {
                "event_id": evt["id"],
                "uid": evt["uid"],
                "title": evt["title"],
                "description": evt["description"],
                "location": evt["location"],
                "category": evt["category"],
                "all_day_event": bool(evt.get("all_day_event")),
                "occurrence_start": occ_start.isoformat(),
                "occurrence_end": occ_end.isoformat(),
                "repeat_type": repeat_type,
            }

        occurrences: List[Dict[str, Any]] = []
        if repeat_type == "none":
            if _occurrence_overlap(start, end, window_start, window_end):
                if not is_deleted(start):
                    occurrences.append(make_occurrence(start))
            return occurrences

        current = start
        produced = 0
        for _ in range(0, 512):
            if max_count is not None and produced >= max_count:
                break
            if until_dt is not None and current > until_dt:
                break
            occ_end = current + duration
            if _occurrence_overlap(current, occ_end, window_start, window_end):
                if not is_deleted(current):
                    occurrences.append(make_occurrence(current))
            if current > window_end + timedelta(days=400):
                break
            produced += 1
            if repeat_type == "daily":
                current = current + timedelta(days=interval)
            elif repeat_type in {"weekly", "custom"}:
                current = current + timedelta(weeks=interval)
            elif repeat_type == "monthly":
                nth_mode = evt.get("repeat_nth_mode") or ""
                if nth_mode == "day_of_month" and evt.get("repeat_nth_day"):
                    next_month = _add_months(current, interval)
                    day = int(evt["repeat_nth_day"])
                    day = max(1, min(day, pycalendar.monthrange(next_month.year, next_month.month)[1]))
                    current = next_month.replace(day=day)
                elif nth_mode == "weekday_of_month" and evt.get("repeat_nth_pos") and evt.get("repeat_nth_weekday") is not None:
                    next_month = _add_months(current, interval)
                    pos = int(evt["repeat_nth_pos"])
                    wd = int(evt["repeat_nth_weekday"])
                    nth_day = _nth_weekday_of_month(next_month.year, next_month.month, wd, pos)
                    if nth_day is None:
                        current = next_month
                    else:
                        current = next_month.replace(day=nth_day)
                else:
                    current = _add_months(current, interval)
            elif repeat_type == "yearly":
                current = _add_years(current, interval)
            else:
                break
        return occurrences

    def _handle_ics_get(self) -> None:
        conn = ensure_db()
        ics_mode = get_setting(conn, "ics_access_mode", "public_read")
        if ics_mode == "authenticated_read":
            auth = self._authenticate_caldav_user() or self._authenticate_wp_admin()
            if not auth:
                conn.close()
                self.send_response(401)
                self.send_header("WWW-Authenticate", 'Basic realm="calendar-ics-fixture"')
                self.end_headers()
                return
        rows = conn.execute(
            "SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)
        ).fetchall()
        payloads = []
        for row in rows:
            evt = event_to_dict(row)
            ex_rows = conn.execute(
                "SELECT occurrence_key FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence' ORDER BY occurrence_key",
                (row["id"],),
            ).fetchall()
            exdates = [r["occurrence_key"] for r in ex_rows]
            payloads.append(create_ics_event(evt, exdates))
        conn.close()
        body = (
            "BEGIN:VCALENDAR\r\n"
            "VERSION:2.0\r\n"
            "PRODID:-//Calendar WP Plugin Fixture//EN\r\n"
            "CALSCALE:GREGORIAN\r\n"
            f"X-WR-TIMEZONE:{DEFAULT_TIMEZONE}\r\n"
            + ("\r\n".join(payloads) + "\r\n" if payloads else "")
            + "END:VCALENDAR\r\n"
        )
        encoded = body.encode("utf-8")
        etag = mk_etag(body)
        self.send_response(200)
        self.send_header("Content-Type", "text/calendar; charset=utf-8")
        self.send_header("ETag", etag)
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def _event_as_ics(self, evt: Dict[str, Any]) -> str:
        conn = ensure_db()
        ex_rows = conn.execute(
            "SELECT occurrence_key FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence' ORDER BY occurrence_key",
            (evt["id"],),
        ).fetchall()
        conn.close()
        exdates = [r["occurrence_key"] for r in ex_rows]
        event_ics = create_ics_event(evt, exdates)
        return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Calendar WP Plugin Fixture//EN\r\n" + event_ics + "\r\nEND:VCALENDAR\r\n"

    def _require_caldav_auth(self, write: bool = False) -> Optional[AuthContext]:
        auth = self._authenticate_caldav_user()
        if not auth:
            self.send_response(401)
            self.send_header("WWW-Authenticate", 'Basic realm="calendar-caldav-fixture"')
            self.end_headers()
            return None
        if write and auth.role != "caldav_write":
            self.send_response(403)
            self.end_headers()
            return None
        return auth

    def _handle_caldav_get(self, path: str) -> None:
        auth = self._require_caldav_auth(write=False)
        if not auth:
            return

        if path in {"/caldav/", "/caldav/calendars/", "/caldav/calendars/public/"}:
            self._write_text(200, "caldav shared public calendar\n")
            return

        filename = _caldav_filename_from_path(path)
        if not filename:
            self._not_found()
            return
        conn = ensure_db()
        row = _caldav_lookup_event(conn, filename)
        conn.close()
        if not row:
            self._not_found()
            return
        evt = event_to_dict(row)
        body = self._event_as_ics(evt).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/calendar; charset=utf-8")
        self.send_header("ETag", row["etag"])
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _handle_caldav_put(self, path: str) -> None:
        auth = self._require_caldav_auth(write=True)
        if not auth:
            return
        filename = _caldav_filename_from_path(path)
        if not filename:
            self._not_found()
            return
        raw = self._read_request_body_bytes().decode("utf-8", errors="replace")
        parsed = self._parse_ics_payload(raw)
        if parsed is None:
            self.send_response(415)
            self.end_headers()
            return
        conn = ensure_db()
        row = _caldav_lookup_event(conn, filename)
        now = utc_now_iso()
        status = 200
        existing_by_uid = None
        if row is None and parsed.get("uid"):
            existing_by_uid = conn.execute(
                "SELECT * FROM events WHERE calendar_id = ? AND uid = ?",
                (SHARED_CALENDAR_ID, parsed["uid"]),
            ).fetchone()
        if row is None:
            if existing_by_uid is not None:
                row = existing_by_uid
            else:
                uid = parsed["uid"] or make_uid(f"caldav:{filename}:{now}")
                etag = mk_etag(f"{uid}:{now}:1")
                conn.execute(
                    """
                    INSERT INTO events
                      (uid, title, description, location, category, all_day_event, start_datetime, end_datetime,
                       repeat_type, repeat_interval, repeat_nth_mode, repeat_nth_day, repeat_nth_pos, repeat_nth_weekday,
                       repeat_range_mode, repeat_count, repeat_until, timezone,
                       calendar_id, caldav_resource, etag, sync_version, last_modified_by_user_id, created_at, updated_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                    (
                        uid,
                        parsed["summary"],
                        parsed["description"],
                        parsed["location"],
                        "",
                        0,
                        parsed["start_datetime"],
                        parsed["end_datetime"],
                        parsed["repeat_type"],
                        parsed["repeat_interval"],
                        parsed["repeat_nth_mode"],
                        parsed["repeat_nth_day"],
                        parsed["repeat_nth_pos"],
                        parsed["repeat_nth_weekday"],
                        parsed["repeat_range_mode"],
                        parsed["repeat_count"],
                        parsed["repeat_until"],
                        DEFAULT_TIMEZONE,
                        SHARED_CALENDAR_ID,
                        filename,
                        etag,
                        1,
                        int(auth.actor_id),
                        now,
                        now,
                    ),
                )
                status = 201
                conn.commit()
                row = conn.execute("SELECT * FROM events WHERE id = last_insert_rowid()").fetchone()
                if row is not None and parsed.get("exdates_specified"):
                    _sync_deleted_occurrences(
                        conn,
                        int(row["id"]),
                        list(parsed.get("exdates") or []),
                        True,
                        now,
                    )
                    conn.commit()
        if row is not None and status != 201:
            if_match = self.headers.get("If-Match")
            if if_match and if_match != row["etag"]:
                conn.close()
                self.send_response(412)
                self.end_headers()
                return
            sync_version = int(row["sync_version"]) + 1
            etag = mk_etag(f"{row['uid']}:{now}:{sync_version}")
            conn.execute(
                """
                UPDATE events SET title = ?, description = ?, location = ?, start_datetime = ?, end_datetime = ?,
                  repeat_type = ?, repeat_interval = ?, repeat_nth_mode = ?, repeat_nth_day = ?, repeat_nth_pos = ?, repeat_nth_weekday = ?,
                  repeat_range_mode = ?, repeat_count = ?, repeat_until = ?,
                  caldav_resource = ?, etag = ?, sync_version = ?, last_modified_by_user_id = ?, updated_at = ?
                WHERE id = ?
                """,
                (
                    parsed["summary"],
                    parsed["description"],
                    parsed["location"],
                    parsed["start_datetime"],
                    parsed["end_datetime"],
                    parsed["repeat_type"],
                    parsed["repeat_interval"],
                    parsed["repeat_nth_mode"],
                    parsed["repeat_nth_day"],
                    parsed["repeat_nth_pos"],
                    parsed["repeat_nth_weekday"],
                    parsed["repeat_range_mode"],
                    parsed["repeat_count"],
                    parsed["repeat_until"],
                    filename,
                    etag,
                    sync_version,
                    int(auth.actor_id),
                    now,
                    int(row["id"]),
                ),
            )
            if parsed.get("exdates_specified"):
                _sync_deleted_occurrences(
                    conn,
                    int(row["id"]),
                    list(parsed.get("exdates") or []),
                    True,
                    now,
                )
            conn.commit()
        updated = conn.execute("SELECT id, etag FROM events WHERE id = ?", (int(row["id"]),)).fetchone()
        log_audit(conn, auth.actor_type, auth.actor_id, "caldav.put", "event", str(updated["id"]), "success", {"resource": filename})
        conn.close()
        self.send_response(status)
        self.send_header("ETag", updated["etag"])
        self.end_headers()
        return

    def _handle_caldav_delete(self, path: str) -> None:
        auth = self._require_caldav_auth(write=True)
        if not auth:
            return
        filename = _caldav_filename_from_path(path)
        if not filename:
            self._not_found()
            return
        conn = ensure_db()
        row = _caldav_lookup_event(conn, filename)
        if not row:
            conn.close()
            self._not_found()
            return
        event_id = int(row["id"])
        conn.execute("DELETE FROM recurrence_exceptions WHERE event_id = ?", (event_id,))
        conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
        conn.commit()
        log_audit(conn, auth.actor_type, auth.actor_id, "caldav.delete", "event", str(event_id), "success", {"resource": filename})
        conn.close()
        self.send_response(204)
        self.end_headers()

    def _parse_ics_payload(self, raw: str) -> Optional[Dict[str, Any]]:
        vevents = _extract_vevents(raw)
        vevent = _select_master_vevent(vevents)
        if not vevent:
            return None
        summary = _extract_line(vevent, "SUMMARY:") or "Untitled"
        description = _extract_line(vevent, "DESCRIPTION:") or ""
        location = _extract_line(vevent, "LOCATION:") or ""
        uid = _extract_line(vevent, "UID:")
        dtstart = _extract_dt(vevent, "DTSTART")
        dtend = _extract_dt(vevent, "DTEND")
        if not dtstart or not dtend:
            return None
        rrule = _extract_line(vevent, "RRULE:")
        exdates = _extract_exdates(vevent)
        cancelled_occurrence_exdates = _extract_cancelled_recurrence_ids(vevents)
        if cancelled_occurrence_exdates:
            dedup = set(exdates)
            for ex in cancelled_occurrence_exdates:
                if ex not in dedup:
                    exdates.append(ex)
                    dedup.add(ex)
        repeat_type = "none"
        repeat_interval = 1
        repeat_nth_mode = ""
        repeat_nth_day = None
        repeat_nth_pos = None
        repeat_nth_weekday = None
        repeat_range_mode = "none"
        repeat_count = None
        repeat_until = None
        if rrule:
            parts = {p.split("=", 1)[0]: p.split("=", 1)[1] for p in rrule.split(";") if "=" in p}
            freq = parts.get("FREQ", "")
            freq_map = {"DAILY": "daily", "WEEKLY": "weekly", "MONTHLY": "monthly", "YEARLY": "yearly"}
            repeat_type = freq_map.get(freq, "custom")
            if "INTERVAL" in parts:
                try:
                    repeat_interval = max(1, int(parts["INTERVAL"]))
                except ValueError:
                    repeat_interval = 1
            if repeat_type == "monthly":
                bymonthday = parts.get("BYMONTHDAY")
                byday = parts.get("BYDAY", "")
                bysetpos = parts.get("BYSETPOS")
                if bymonthday:
                    raw_day = bymonthday.split(",")[0].strip()
                    try:
                        parsed_day = int(raw_day)
                    except ValueError:
                        parsed_day = None
                    if parsed_day is not None and 1 <= parsed_day <= 31:
                        repeat_nth_mode = "day_of_month"
                        repeat_nth_day = parsed_day
                elif byday:
                    day_map = {"SU": 0, "MO": 1, "TU": 2, "WE": 3, "TH": 4, "FR": 5, "SA": 6}
                    token = byday.split(",")[0].strip().upper()
                    m = re.fullmatch(r"([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)", token)
                    if m:
                        wd = day_map[m.group(2)]
                        pos_raw = m.group(1)
                        if not pos_raw and bysetpos:
                            pos_raw = bysetpos.split(",")[0].strip()
                        try:
                            pos = int(pos_raw) if pos_raw else None
                        except (TypeError, ValueError):
                            pos = None
                        if pos is not None and (1 <= pos <= 5 or pos == -1):
                            repeat_nth_mode = "weekday_of_month"
                            repeat_nth_pos = pos
                            repeat_nth_weekday = wd
            if "COUNT" in parts:
                repeat_range_mode = "count"
                repeat_count = int(parts["COUNT"])
            elif "UNTIL" in parts:
                repeat_range_mode = "until"
                until_raw = parts["UNTIL"].strip()
                if len(until_raw) >= 8:
                    repeat_until = f"{until_raw[0:4]}-{until_raw[4:6]}-{until_raw[6:8]}"
            else:
                repeat_range_mode = "no_end"
        dtstart, dtend = _normalize_monthly_anchor(
            dtstart,
            dtend,
            repeat_type,
            repeat_nth_mode,
            repeat_nth_day,
            repeat_nth_pos,
            repeat_nth_weekday,
        )
        return {
            "uid": uid,
            "summary": summary,
            "description": description,
            "location": location,
            "start_datetime": dtstart,
            "end_datetime": dtend,
            "repeat_type": repeat_type,
            "repeat_interval": repeat_interval,
            "repeat_nth_mode": repeat_nth_mode,
            "repeat_nth_day": repeat_nth_day,
            "repeat_nth_pos": repeat_nth_pos,
            "repeat_nth_weekday": repeat_nth_weekday,
            "repeat_range_mode": repeat_range_mode,
            "repeat_count": repeat_count,
            "repeat_until": repeat_until,
            "exdates": exdates,
            "exdates_specified": bool(_extract_prop_values(vevent, "EXDATE") or cancelled_occurrence_exdates),
        }

    def log_message(self, fmt: str, *args: Any) -> None:
        # Keep output concise for fixture runs.
        print(f"[fixture] {self.address_string()} - {fmt % args}")


def _extract_line(raw: str, prefix: str) -> Optional[str]:
    for line in raw.splitlines():
        if line.startswith(prefix):
            return line[len(prefix) :].strip()
    return None


def _extract_prop_values(raw: str, name: str) -> List[str]:
    out: List[str] = []
    pattern = re.compile(rf"^{re.escape(name)}(?:;[^:]+)?:([^\r\n]+)$", re.IGNORECASE)
    for line in raw.splitlines():
        m = pattern.match(line.strip())
        if m:
            out.append(m.group(1).strip())
    return out


def _extract_first_vevent(raw: str) -> Optional[str]:
    vevents = _extract_vevents(raw)
    return vevents[0] if vevents else None


def _extract_vevents(raw: str) -> List[str]:
    out: List[str] = []
    for m in re.finditer(r"BEGIN:VEVENT\r?\n(.*?)\r?\nEND:VEVENT", raw, re.DOTALL):
        out.append("BEGIN:VEVENT\n" + m.group(1) + "\nEND:VEVENT")
    return out


def _is_cancelled_vevent(vevent: str) -> bool:
    status = (_extract_line(vevent, "STATUS:") or "").strip().upper()
    return status == "CANCELLED"


def _select_master_vevent(vevents: List[str]) -> str:
    if not vevents:
        return ""
    # Prefer the true series master, not detached/cancelled overrides.
    for v in vevents:
        if not _extract_prop_values(v, "RECURRENCE-ID") and not _is_cancelled_vevent(v):
            return v
    for v in vevents:
        if not _extract_prop_values(v, "RECURRENCE-ID"):
            return v
    for v in vevents:
        if _extract_line(v, "RRULE:") and not _is_cancelled_vevent(v):
            return v
    for v in vevents:
        if not _is_cancelled_vevent(v):
            return v
    return vevents[0]


def _parse_ics_token_to_iso(value: str) -> Optional[str]:
    token = (value or "").strip()
    if not token:
        return None
    # DATE value
    if "T" not in token:
        if re.fullmatch(r"\d{8}", token):
            return f"{token[0:4]}-{token[4:6]}-{token[6:8]}T00:00:00+00:00"
        return None
    # DATE-TIME value
    if token.endswith("Z"):
        core = token[:-1]
        try:
            dt = datetime.strptime(core, "%Y%m%dT%H%M%S").replace(tzinfo=timezone.utc)
            return dt.isoformat()
        except ValueError:
            return None
    try:
        dt = datetime.strptime(token[:15], "%Y%m%dT%H%M%S")
        return dt.strftime("%Y-%m-%dT%H:%M:%S+01:00")
    except ValueError:
        return None


def _extract_exdates(raw: str) -> List[str]:
    values = _extract_prop_values(raw, "EXDATE")
    out: List[str] = []
    for value in values:
        for token in value.split(","):
            iso = _parse_ics_token_to_iso(token)
            if iso:
                out.append(iso)
    return out


def _extract_cancelled_recurrence_ids(vevents: List[str]) -> List[str]:
    out: List[str] = []
    for vevent in vevents:
        rec_values = _extract_prop_values(vevent, "RECURRENCE-ID")
        if not rec_values:
            continue
        if not _is_cancelled_vevent(vevent):
            continue
        for value in rec_values:
            for token in value.split(","):
                iso = _parse_ics_token_to_iso(token)
                if iso:
                    out.append(iso)
    return out


def _extract_dt(raw: str, field: str) -> Optional[str]:
    pattern = re.compile(rf"^{field}(?:;[^:]+)?:([0-9TzZ]+)$")
    for line in raw.splitlines():
        m = pattern.match(line.strip())
        if m:
            value = m.group(1)
            if "T" in value:
                # Interpret as local naive fixture time and inject +01:00 offset for test determinism.
                try:
                    dt = datetime.strptime(value[:15], "%Y%m%dT%H%M%S")
                    return dt.strftime("%Y-%m-%dT%H:%M:%S+01:00")
                except ValueError:
                    return None
    return None


def _sync_deleted_occurrences(
    conn: sqlite3.Connection,
    event_id: int,
    exdates: List[str],
    replace: bool,
    now: str,
) -> None:
    if replace:
        conn.execute(
            "DELETE FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence'",
            (event_id,),
        )
    if not exdates:
        return
    for ex in exdates:
        canonical = _canonical_occurrence_key(ex)
        if not canonical:
            continue
        conn.execute(
            """
            INSERT OR IGNORE INTO recurrence_exceptions
              (event_id, occurrence_key, exception_type, override_payload, created_at, updated_at)
            VALUES (?, ?, 'deleted_occurrence', NULL, ?, ?)
            """,
            (event_id, canonical, now, now),
        )


def _xml_escape(value: str) -> str:
    return (
        value.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
        .replace("'", "&apos;")
    )


def _html_escape(value: str) -> str:
    return _xml_escape(value)


def _caldav_filename_from_path(path: str) -> Optional[str]:
    m = re.fullmatch(r"/caldav/calendars/public/([^/]+\.ics)", path)
    if not m:
        return None
    return unquote(m.group(1))


def _caldav_lookup_event(conn: sqlite3.Connection, filename: str) -> Optional[sqlite3.Row]:
    return conn.execute(
        "SELECT * FROM events WHERE calendar_id = ? AND caldav_resource = ?",
        (SHARED_CALENDAR_ID, filename),
    ).fetchone()


def cmd_init(_: argparse.Namespace) -> int:
    conn = ensure_db()
    create_schema(conn)
    conn.close()
    print(f"initialized fixture db at {DB_PATH}")
    return 0


def cmd_reset(_: argparse.Namespace) -> int:
    conn = ensure_db()
    drop_all(conn)
    create_schema(conn)
    conn.close()
    print(f"reset fixture db at {DB_PATH}")
    return 0


def cmd_seed(_: argparse.Namespace) -> int:
    conn = ensure_db()
    create_schema(conn)
    seed_data(conn)
    conn.close()
    print("seeded fixture data")
    return 0


def cmd_run(args: argparse.Namespace) -> int:
    conn = ensure_db()
    create_schema(conn)
    conn.close()
    server = ThreadingHTTPServer((args.host, args.port), FixtureHandler)
    trace_log = fixture_trace_log_path()
    trace_log.parent.mkdir(parents=True, exist_ok=True)
    print(f"fixture server listening at http://{args.host}:{args.port}")
    print(f"http trace log: {trace_log}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Calendar plugin local fixture server")
    sub = parser.add_subparsers(dest="command", required=True)

    p_init = sub.add_parser("init", help="initialize fixture db schema")
    p_init.set_defaults(func=cmd_init)

    p_reset = sub.add_parser("reset", help="reset fixture db schema")
    p_reset.set_defaults(func=cmd_reset)

    p_seed = sub.add_parser("seed", help="seed fixture db data")
    p_seed.set_defaults(func=cmd_seed)

    p_run = sub.add_parser("run", help="run fixture server")
    p_run.add_argument("--host", default="127.0.0.1")
    p_run.add_argument("--port", type=int, default=8080)
    p_run.set_defaults(func=cmd_run)

    return parser


def main(argv: Optional[List[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
