<?php

declare(strict_types=1);

namespace CalendarPlugin;

use CalendarPlugin\Contracts\AuthAdapterInterface;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use CalendarPlugin\Contracts\HttpAdapterInterface;
use CalendarPlugin\Contracts\OptionsAdapterInterface;
use CalendarPlugin\Domain\CalDavService;
use CalendarPlugin\Domain\EventService;
use CalendarPlugin\Domain\IcsService;
use CalendarPlugin\Domain\SettingsService;
use CalendarPlugin\Domain\UserService;
use CalendarPlugin\Infrastructure\WordPress\MigrationManager;
use CalendarPlugin\Infrastructure\WordPress\WordPressAuthAdapter;
use CalendarPlugin\Infrastructure\WordPress\WordPressDatabaseAdapter;
use CalendarPlugin\Infrastructure\WordPress\WordPressHttpAdapter;
use CalendarPlugin\Infrastructure\WordPress\WordPressOptionsAdapter;

final class Plugin
{
    private const API_NAMESPACE = 'calendar/v1';
    private const SESSION_COOKIE = 'calendar_session';
    private const SESSION_TTL_SECONDS = 2592000;

    private readonly SettingsService $settingsService;
    private readonly EventService $eventService;
    private readonly IcsService $icsService;
    private readonly CalDavService $calDavService;
    private readonly UserService $userService;
    private readonly string $tableStem;

    public function __construct(
        private readonly DatabaseAdapterInterface $db,
        private readonly OptionsAdapterInterface $options,
        private readonly AuthAdapterInterface $auth,
        private readonly HttpAdapterInterface $http
    ) {
        $this->tableStem = self::resolveTableStem();
        $this->settingsService = new SettingsService($this->options);
        $this->eventService = new EventService($this->db, $this->tableStem);
        $this->icsService = new IcsService();
        $this->calDavService = new CalDavService($this->eventService, $this->icsService);
        $this->userService = new UserService($this->db, $this->tableStem);
    }

    public static function boot(string $pluginFile): void
    {
        global $wpdb;

        $db = new WordPressDatabaseAdapter($wpdb);
        $options = new WordPressOptionsAdapter();
        $auth = new WordPressAuthAdapter();
        $http = new WordPressHttpAdapter();

        register_activation_hook($pluginFile, [self::class, 'activate']);
        register_deactivation_hook($pluginFile, [self::class, 'deactivate']);

        $plugin = new self($db, $options, $auth, $http);
        $plugin->register();
    }

    public static function activate(): void
    {
        try {
            global $wpdb;
            $db = new WordPressDatabaseAdapter($wpdb);
            $tableStem = self::resolveTableStem();
            $migration = new MigrationManager($db, $tableStem);
            $migration->assertActivationSafe();
            $migration->migrate();
        } catch (\Throwable $e) {
            if (function_exists('wp_die')) {
                wp_die(
                    esc_html($e->getMessage()),
                    'Calendar Plugin Activation Blocked',
                    ['response' => 500, 'back_link' => true]
                );
            }
            throw $e;
        }
    }

    public static function deactivate(): void
    {
        // Intentionally no destructive behavior on deactivate.
    }

    public function register(): void
    {
        $this->http->addAction('init', [$this, 'ensureSchemaCurrent']);
        $this->http->addAction('init', [$this, 'onInit']);
        $this->http->addAction('rest_api_init', [$this, 'registerRoutes']);
        $this->http->addAction('admin_menu', [$this, 'registerAdminMenu']);
        $this->http->addAction('admin_post_calendar_plugin_download_diagnostics', [$this, 'handleDiagnosticsDownload']);
        $this->http->addAction('template_redirect', [$this, 'maybeServeSpecialEndpoints']);
    }

    public function onInit(): void
    {
        $this->http->addShortcode('calendar', [$this, 'renderCalendarShortcode']);
        $this->http->addShortcode('calendar_sidebar_upcoming', [$this, 'renderSidebarShortcode']);
    }

    public function ensureSchemaCurrent(): void
    {
        $version = trim((string) get_option('calendar_plugin_schema_version', ''));
        if ($version === '3') {
            return;
        }
        try {
            self::activate();
        } catch (\Throwable) {
            // Avoid hard-failing page loads; diagnostics/setup will still surface issues.
        }
    }

    public function registerRoutes(): void
    {
        $this->registerHealthRoute();
        $this->registerSettingsRoutes();
        $this->registerEventRoutes();
        $this->registerPublicRoutes();
        $this->registerUserRoutes();
        $this->registerIcsRoutes();
        $this->registerCalDavRoutes();
    }

    public function renderCalendarShortcode(): string
    {
        $caldavRoot = $this->caldavRootPath();
        $icsUrl = $this->icsPath();
        $ns = self::API_NAMESPACE;
        $caldavRootEsc = esc_html($caldavRoot);
        $icsUrlEsc = esc_html($icsUrl);
        $nsEsc = esc_html($ns);
        $todayEsc = esc_html(gmdate('Y-m-d'));

        return strtr(<<<'HTML'
<div id="cp-shell" class="calendar-plugin-shell" data-mode="full" style="border:1px solid #ddd;padding:12px;border-radius:8px;background:#f8fafc;">
  <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
    <div style="display:flex;gap:8px;align-items:center;">
      <span id="cp-auth-state">Not logged in</span>
      <button id="cp-open-login-btn" type="button">Login</button>
      <button id="cp-logout-btn" type="button" style="display:none;">Logout</button>
      <a href="__CALDAV_ROOT__">CalDAV</a>
      <a href="__ICS_URL__">ICS</a>
    </div>
  </div>

  <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px;">
    <button id="cp-prev-btn" type="button">&#x2190;</button>
    <button id="cp-today-btn" type="button">Today</button>
    <button id="cp-next-btn" type="button">&#x2192;</button>
    <select id="cp-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>
    <input id="cp-date" type="date" value="__TODAY__" />
    <label id="cp-future-wrap" style="display:flex;gap:6px;align-items:center;"><input id="cp-future-only" type="checkbox" checked /> Future only</label>
    <button id="cp-refresh-btn" type="button">Refresh</button>
    <button id="cp-create-btn" type="button" style="display:none;">Create Event</button>
    <div style="margin-left:auto;min-width:190px;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;">
      <label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Theme</label>
      <select id="cp-theme" title="Theme" style="width:100%;border:0;outline:none;background:#fff;">
        <option value="editorial">Editorial</option>
        <option value="paper">Warm Paper</option>
        <option value="dashboard">Modern Dashboard</option>
        <option value="garden">Seasonal Garden</option>
        <option value="contrast">High Contrast</option>
      </select>
    </div>
  </div>

  <p id="cp-status" style="margin:10px 0 8px 0;"></p>
  <h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
  <div id="cp-view-panel"></div>
  <ul id="cp-public-list" style="margin-top:8px;list-style:none;padding-left:0;"></ul>

  <div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;align-items:center;justify-content:center;padding:12px;box-sizing:border-box;">
    <div style="width:min(560px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:8px;padding:12px;">
      <h3 style="margin:0 0 8px 0;">Account Login</h3>
      <p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
        <input id="cp-login-email" type="email" placeholder="Email" />
        <input id="cp-login-password" type="password" placeholder="Password (8+ chars)" />
      </div>
      <div style="display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:8px;">
        <input id="cp-verify-token" type="text" placeholder="Verification token" />
        <button id="cp-verify-btn" type="button">Verify Email</button>
      </div>
      <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;">
        <button id="cp-login-btn" type="button">Login</button>
        <button id="cp-register-btn" type="button">Register</button>
        <button id="cp-reset-request-btn" type="button">Reset Password</button>
        <button id="cp-close-login-btn" type="button">Close</button>
      </div>
    </div>
  </div>

  <div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
    <div style="max-width:900px;margin:3vh auto;background:#fff;border-radius:8px;padding:12px;max-height:94vh;overflow:auto;">
      <h3 id="cp-editor-title" style="margin:0 0 8px 0;">Create Event</h3>
      <input id="cp-event-id" type="hidden" />
      <div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
        <div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-title" type="text" style="width:100%;border:0;outline:none;" /></div>
        <div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><select id="cp-visibility" style="width:100%;border:0;outline:none;background:#fff;"><option value="public">Public</option><option value="private">Private</option></select></div>
        <div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-category" type="text" style="width:100%;border:0;outline:none;" /></div>
        <div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-location" type="text" style="width:100%;border:0;outline:none;" /></div>
        <div style="grid-column:1 / span 4;"><label style="display:inline-flex;align-items:center;gap:6px;"><input id="cp-all-day" type="checkbox" /> All Day Event</label></div>
        <div style="grid-column:1 / span 1;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start Date</label><input id="cp-start-date" type="date" style="width:100%;border:0;outline:none;" /></div>
        <div style="grid-column:2 / span 1;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">End Date</label><input id="cp-end-date" type="date" style="width:100%;border:0;outline:none;" /></div>
        <div id="cp-start-time-wrap" style="grid-column:3 / span 1;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start Time</label><input id="cp-start-time" type="time" style="width:100%;border:0;outline:none;" /></div>
        <div id="cp-end-time-wrap" style="grid-column:4 / span 1;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">End Time</label><input id="cp-end-time" type="time" style="width:100%;border:0;outline:none;" /></div>
        <div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Repeat</label><select id="cp-repeat-type" style="width:100%;border:0;outline:none;"><option value="none">No repeat</option><option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option><option value="yearly">Yearly</option></select></div>
      </div>
      <div id="cp-repeat-extra" style="display:none;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;margin-top:8px;">
        <div style="position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Every</label><div style="display:flex;align-items:center;gap:6px;"><input id="cp-repeat-interval" type="number" min="1" value="1" style="width:90px;border:0;outline:none;" /><span id="cp-repeat-unit" style="color:#555;font-size:13px;"></span></div></div>
        <div id="cp-monthly-nth-wrap" style="display:none;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Monthly Pattern</label><select id="cp-monthly-nth-mode" style="width:100%;border:0;outline:none;"><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 id="cp-nth-day-wrap" style="display:none;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Day Number</label><input id="cp-nth-day" type="number" min="1" max="31" style="width:100%;border:0;outline:none;" /></div>
        <div id="cp-nth-weekday-wrap" style="display:none;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Nth</label><select id="cp-nth-pos" style="width:100%;border:0;outline:none;"><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 id="cp-nth-weekday2-wrap" style="display:none;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Weekday</label><select id="cp-nth-weekday" style="width:100%;border:0;outline:none;"><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 style="position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Repeat Until Mode</label><select id="cp-range-mode" style="width:100%;border:0;outline:none;"><option value="none">No end</option><option value="count">Count</option><option value="until">Until</option></select></div>
        <div style="position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Count</label><input id="cp-repeat-count" type="number" min="1" style="width:100%;border:0;outline:none;" /></div>
        <div style="position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Repeat Until Date</label><input id="cp-repeat-until" type="date" style="width:100%;border:0;outline:none;" /></div>
      </div>
      <div id="cp-occurrence-panel" style="display:none;margin-top:8px;border:1px solid #c7c7c7;border-radius:6px;padding:8px;background:#fff;">
        <div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
          <strong>Recurrence Preview</strong>
          <button id="cp-occ-prev-btn" type="button">&#8592;</button>
          <button id="cp-occ-next-btn" type="button">&#8594;</button>
        </div>
        <p id="cp-occurrence-hint" style="margin:0 0 6px 0;color:#666;"></p>
        <div id="cp-occurrence-grid" style="display:grid;grid-template-columns:repeat(3,minmax(200px,1fr));gap:8px;"></div>
        <input id="cp-occurrence-key-iso" type="hidden" />
        <div style="margin-top:6px;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Occurrence</label><input id="cp-occurrence-key" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
        <div style="margin-top:8px;"><button id="cp-delete-occ-confirm-btn" type="button" disabled>Delete Occurrence</button></div>
      </div>
      <div style="margin-top:8px;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Description</label><textarea id="cp-description" style="width:100%;min-height:60px;border:0;outline:none;resize:vertical;"></textarea></div>
      <p id="cp-editor-status" style="margin:8px 0 0 0;"></p>
      <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;">
        <button id="cp-editor-create-btn" type="button">Create</button>
        <button id="cp-update-btn" type="button" disabled>Update</button>
        <button id="cp-delete-btn" type="button" disabled>Delete</button>
        <button id="cp-editor-close-btn" type="button">Close</button>
      </div>
    </div>
  </div>

  <div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
    <div style="max-width:900px;margin:4vh auto;background:#fff;border-radius:8px;padding:12px;max-height:90vh;overflow:auto;">
      <h3 style="margin:0 0 8px 0;">Event Details</h3>
      <div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
        <div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-details-title" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
        <div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><input id="cp-details-visibility" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
        <div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-details-category" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
        <div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-details-location" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
        <div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start</label><input id="cp-details-start" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
        <div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">End</label><input id="cp-details-end" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
      </div>
      <div style="margin-top:8px;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Description</label><textarea id="cp-details-description" readonly style="width:100%;min-height:60px;border:0;outline:none;resize:vertical;background:#fff;"></textarea></div>
      <div style="display:flex;justify-content:flex-end;margin-top:8px;">
        <button id="cp-details-close-btn" type="button">Cancel</button>
      </div>
    </div>
  </div>

  <script>(function(){
    const ns="__NS__";
    const api=(p,o)=>fetch((window.location.origin||"")+("/wp-json/"+ns+p),o||{});
    const s=id=>document.getElementById(id);
    let auth=null;
    let me=null;
    let lastItems=[];

    const setStatus=(m,e)=>{const x=s("cp-status"); x.textContent=m||""; x.style.color=e?"#a00":"#060";};
    const setAuthStatus=(m,e)=>{const x=s("cp-auth-status"); x.textContent=m||""; x.style.color=e?"#a00":"#060";};
    const setEditorStatus=(m,e)=>{const x=s("cp-editor-status"); x.textContent=m||""; x.style.color=e?"#a00":"#060";};
    const authHeaders=()=>auth?{Authorization:"Basic "+btoa(auth.email+":"+auth.password)}:{};
    const ymd=(iso)=>String(iso||"").slice(0,10);
    const hm=(iso)=>String(iso||"").slice(11,16);
    const esc=(v)=>{const d=document.createElement("div"); d.textContent=v==null?"":String(v); return d.innerHTML;};
    const localYmd=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate());};
    const dmy=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return p(d.getDate())+"/"+p(d.getMonth()+1)+"/"+d.getFullYear();};
    const longDate=(iso)=>{
      if(!iso){return "";}
      const d=new Date(iso);
      if(Number.isNaN(d.getTime())){return ymd(iso);}
      return d.toLocaleDateString("en-GB",{day:"numeric",month:"long",year:"numeric"});
    };
    const timeValue=(iso)=>{
      if(!iso){return "";}
      const d=new Date(iso);
      if(Number.isNaN(d.getTime())){return "";}
      let h=d.getHours();
      const m=d.getMinutes();
      const mer=h>=12?"pm":"am";
      h=h%12;
      if(h===0){h=12;}
      if(m===0){return `${h}${mer}`;}
      return `${h}.${String(m).padStart(2,"0")}${mer}`;
    };
    const timeRange=(startIso,endIso)=>{
      const s=new Date(startIso);
      const e=new Date(endIso);
      if(Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())){return "";}
      const sm=s.getHours()>=12?"pm":"am";
      const em=e.getHours()>=12?"pm":"am";
      let sv=timeValue(startIso);
      const ev=timeValue(endIso);
      if(sm===em){sv=sv.replace(/(am|pm)$/,"");}
      return `${sv}–${ev}`;
    };
    const itemId=(it)=>String(it.event_id||it.id||"");
    const futureWrap=()=>s("cp-future-wrap");
    const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
    const themeKey="calendar_plugin_theme";
    const themes={
      editorial:{shellBg:"#f8fafc",border:"#cbd5e1",headBg:"#eff6ff",cellBg:"#ffffff",mutedBg:"#f1f5f9",text:"#0f172a",mutedText:"#475569",accent:"#2563eb"},
      paper:{shellBg:"#fdf8f0",border:"#d6c3a7",headBg:"#faefe1",cellBg:"#fffdf8",mutedBg:"#f6efe4",text:"#3b2f2f",mutedText:"#6b5b4b",accent:"#b45309"},
      dashboard:{shellBg:"#f6f8fb",border:"#d1d9e6",headBg:"#e6f4f1",cellBg:"#ffffff",mutedBg:"#edf2f7",text:"#111827",mutedText:"#4b5563",accent:"#0f766e"},
      garden:{shellBg:"#f5fbf7",border:"#c8decf",headBg:"#e7f5ea",cellBg:"#ffffff",mutedBg:"#ecf6ef",text:"#1f3b2d",mutedText:"#4f6b5f",accent:"#3f7d20"},
      contrast:{shellBg:"#ffffff",border:"#0f172a",headBg:"#e2e8f0",cellBg:"#ffffff",mutedBg:"#f8fafc",text:"#0f172a",mutedText:"#334155",accent:"#ea580c"},
    };
    let currentThemeName="editorial";
    const theme=()=>themes[currentThemeName]||themes.editorial;
    let occCursor=new Date();
    let selectedOccurrenceDate="";
    let pendingDeletedOccurrenceKeys=[];

    const applyTheme=()=>{
      const t=theme();
      const shell=s("cp-shell");
      shell.style.background=t.shellBg;
      shell.style.borderColor=t.border;
      shell.style.color=t.text;
      s("cp-status").style.color=t.mutedText;
    };

    const setAuthUi=()=>{
      s("cp-auth-state").textContent=me?("Logged in as "+(me.email||"")):"Not logged in";
      s("cp-open-login-btn").style.display=me?"none":"inline-block";
      s("cp-logout-btn").style.display=me?"inline-block":"none";
      s("cp-create-btn").style.display=me?"inline-block":"none";
    };

    const setDateInput=(d)=>{s("cp-date").value=(new Date(d.getTime()-d.getTimezoneOffset()*60000)).toISOString().slice(0,10);};
    const currentDate=()=>new Date((s("cp-date").value||new Date().toISOString().slice(0,10))+"T00:00:00");
    const stepDate=(dir)=>{const d=currentDate(); const v=s("cp-view").value; if(v==="day"){d.setDate(d.getDate()+dir);} else if(v==="week"){d.setDate(d.getDate()+dir*7);} else if(v==="month"){d.setMonth(d.getMonth()+dir);} else if(v==="year"){d.setFullYear(d.getFullYear()+dir);} else {d.setDate(d.getDate()+dir);} setDateInput(d); loadPublic();};

    const toLocalParts=(iso)=>{
      const d=new Date(String(iso||""));
      if(Number.isNaN(d.getTime())){return {date:"",time:""};}
      const p=(n)=>String(n).padStart(2,"0");
      return {date:d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate()),time:p(d.getHours())+":"+p(d.getMinutes())};
    };
    const friendlyDateTime=(iso)=>{
      const d=new Date(String(iso||""));
      if(Number.isNaN(d.getTime())){return "";}
      const p=(n)=>String(n).padStart(2,"0");
      return dmy(d)+" "+p(d.getHours())+":"+p(d.getMinutes());
    };
    const detailsDateTime=(iso,allDay)=>{
      if(!iso){return "";}
      if(allDay){return String(iso).slice(0,10);}
      return friendlyDateTime(iso);
    };
    const repeatUnitLabel=()=>{
      const rt=s("cp-repeat-type").value;
      const n=Math.max(1,parseInt(s("cp-repeat-interval").value||"1",10)||1);
      const unit=rt==="daily"?"day":(rt==="weekly"?"week":(rt==="monthly"?"month":(rt==="yearly"?"year":"")));
      if(unit===""){return "";}
      return n===1?unit:(unit+"s");
    };

    const applyAllDayVisibility=()=>{
      const allDay=!!s("cp-all-day").checked;
      s("cp-start-time-wrap").style.display=allDay?"none":"block";
      s("cp-end-time-wrap").style.display=allDay?"none":"block";
    };

    const syncRepeatUi=()=>{
      const rt=s("cp-repeat-type").value;
      const rm=s("cp-range-mode").value;
      const isRepeat=rt!=="none";
      const hasId=!!s("cp-event-id").value.trim();
      s("cp-repeat-extra").style.display=isRepeat?"grid":"none";
      s("cp-monthly-nth-wrap").style.display=rt==="monthly"?"block":"none";
      const nthMode=s("cp-monthly-nth-mode").value;
      const showNthDay=rt==="monthly" && nthMode==="day_of_month";
      const showNthWeekday=rt==="monthly" && nthMode==="weekday_of_month";
      s("cp-nth-day-wrap").style.display=showNthDay?"block":"none";
      s("cp-nth-weekday-wrap").style.display=showNthWeekday?"block":"none";
      s("cp-nth-weekday2-wrap").style.display=showNthWeekday?"block":"none";
      s("cp-repeat-count").parentElement.style.display=(isRepeat && rm==="count")?"block":"none";
      s("cp-repeat-until").parentElement.style.display=(isRepeat && rm==="until")?"block":"none";
      s("cp-occurrence-panel").style.display=isRepeat?"block":"none";
      s("cp-repeat-unit").textContent=isRepeat?repeatUnitLabel():"";
      s("cp-occurrence-hint").textContent=isRepeat
        ? (hasId ? "Select an occurrence to delete it from the recurrence." : "Preview shows upcoming generated occurrences.")
        : "";
      if(!isRepeat){
        s("cp-occurrence-grid").innerHTML="";
        s("cp-occurrence-key-iso").value="";
        s("cp-occurrence-key").value="";
        selectedOccurrenceDate="";
      }
    };

    const updateEditorButtons=()=>{
      const titleOk=!!s("cp-title").value.trim();
      const startDate=s("cp-start-date").value.trim();
      const endDateRaw=s("cp-end-date").value.trim();
      const endDate=endDateRaw||startDate;
      const allDay=!!s("cp-all-day").checked;
      const startTime=s("cp-start-time").value.trim();
      const endTime=s("cp-end-time").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=!!s("cp-event-id").value.trim();
      const hasOcc=!!s("cp-occurrence-key-iso").value.trim();
      s("cp-editor-create-btn").disabled=!me || !hasCore || hasId;
      s("cp-update-btn").disabled=!me || !hasCore || !hasId;
      s("cp-delete-btn").disabled=!me || !hasId;
      s("cp-delete-occ-confirm-btn").disabled=!me || s("cp-repeat-type").value==="none" || !hasOcc;
    };

    const clearEditor=()=>{
      s("cp-event-id").value="";
      ["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value="");
      s("cp-visibility").value="public";
      s("cp-occurrence-key-iso").value="";
      s("cp-all-day").checked=false;
      s("cp-repeat-type").value="none";
      s("cp-repeat-interval").value="1";
      s("cp-range-mode").value="none";
      s("cp-repeat-count").value="";
      s("cp-repeat-until").value="";
      s("cp-monthly-nth-mode").value="";
      s("cp-nth-day").value="";
      s("cp-nth-pos").value="1";
      s("cp-nth-weekday").value="0";
      s("cp-start-date").value="";
      s("cp-end-date").value="";
      s("cp-start-time").value="";
      s("cp-end-time").value="";
      selectedOccurrenceDate="";
      pendingDeletedOccurrenceKeys=[];
      s("cp-occurrence-panel").style.display="none";
      s("cp-editor-title").textContent="Create Event";
      occCursor=new Date();
      setEditorStatus("",false);
      applyAllDayVisibility();
      syncRepeatUi();
      updateEditorButtons();
    };

    const openEditorCreate=()=>{
      if(!me){setStatus("Login required",true); return;}
      clearEditor();
      s("cp-editor-modal").style.display="block";
    };

    const openEditorCreateAt=(dateYmd)=>{
      if(!me){setStatus("Login required",true); return;}
      clearEditor();
      s("cp-start-date").value=dateYmd||"";
      s("cp-end-date").value=dateYmd||"";
      updateEditorButtons();
      s("cp-editor-modal").style.display="block";
    };

    const openDetailsForItem=(it)=>{
      s("cp-details-title").value=it.title||"";
      s("cp-details-visibility").value=(it.visibility||"public")==="private"?"Private":"Public";
      s("cp-details-category").value=it.category||"";
      s("cp-details-location").value=it.location||"";
      s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
      s("cp-details-end").value=detailsDateTime(it.occurrence_end||it.end_datetime,!!it.all_day_event);
      s("cp-details-description").value=it.description||"";
      s("cp-details-modal").style.display="block";
    };

    const openEditorForItem=async(it)=>{
      if(!me){
        openDetailsForItem(it);
        return;
      }
      let source=it;
      const id=itemId(it);
      if(id){
        try{
          const r=await api("/events/"+id,{headers:authHeaders()});
          const p=await r.json();
          if(r.ok && p && p.data){source=Object.assign({},it,p.data);}
        }catch(_e){}
      }
      clearEditor();
      s("cp-editor-title").textContent="Edit Event";
      s("cp-event-id").value=itemId(source);
      s("cp-title").value=source.title||"";
      s("cp-visibility").value=source.visibility==="private"?"private":"public";
      s("cp-description").value=source.description||"";
      s("cp-location").value=source.location||"";
      s("cp-category").value=source.category||"";
      s("cp-repeat-type").value=source.repeat_type||"none";
      s("cp-repeat-interval").value=source.repeat_interval||1;
      s("cp-range-mode").value=source.repeat_range_mode||"none";
      s("cp-repeat-count").value=source.repeat_count||"";
      s("cp-repeat-until").value=source.repeat_until||"";
      s("cp-monthly-nth-mode").value=source.repeat_nth_mode||"";
      s("cp-nth-day").value=source.repeat_nth_day||"";
      s("cp-nth-pos").value=source.repeat_nth_pos||"1";
      s("cp-nth-weekday").value=(source.repeat_nth_weekday===null||source.repeat_nth_weekday===undefined)?"0":String(source.repeat_nth_weekday);
      s("cp-occurrence-key-iso").value=(it.occurrence_start||"");
      s("cp-occurrence-key").value=friendlyDateTime(it.occurrence_start||"");
      pendingDeletedOccurrenceKeys=[];
      const st=toLocalParts(source.start_datetime||it.occurrence_start);
      const en=toLocalParts(source.end_datetime||it.occurrence_end);
      s("cp-all-day").checked=!!source.all_day_event;
      s("cp-start-date").value=st.date;
      s("cp-start-time").value=st.time;
      s("cp-end-date").value=en.date;
      s("cp-end-time").value=en.time;
      occCursor=new Date((source.start_datetime||new Date().toISOString()).slice(0,10)+"T00:00:00");
      selectedOccurrenceDate="";
      applyAllDayVisibility();
      syncRepeatUi();
      updateEditorButtons();
      if(s("cp-repeat-type").value!=="none"){loadOccurrenceChooser();}
      s("cp-editor-modal").style.display="block";
    };

    const formPayload=()=>{
      const title=s("cp-title").value.trim();
      const startDate=s("cp-start-date").value;
      const endDateRaw=s("cp-end-date").value;
      const allDay=!!s("cp-all-day").checked;
      const startTime=s("cp-start-time").value;
      const endTime=s("cp-end-time").value;
      const endDate=endDateRaw||startDate;
      if(title===""){return {ok:false,message:"Title is required"};}
      if(!startDate){return {ok:false,message:"Start date is required"};}
      if(!allDay && !startTime){return {ok:false,message:"Start time is required"};}
      if(!allDay && !endTime){return {ok:false,message:"End time is required"};}
      const startIso=allDay?(startDate+"T00:00:00+00:00"):new Date(startDate+"T"+startTime).toISOString();
      const endIso=allDay?(endDate+"T00:00:00+00:00"):new Date(endDate+"T"+endTime).toISOString();
      if(Number.isNaN(new Date(startIso).getTime()) || Number.isNaN(new Date(endIso).getTime())){return {ok:false,message:"Invalid date/time values"};}
      if(new Date(endIso).getTime()<new Date(startIso).getTime()){return {ok:false,message:"End must be at or after start"};}
      const repeatType=s("cp-repeat-type").value;
      const repeatRangeMode=repeatType==="none" ? "none" : s("cp-range-mode").value;
      const nthMode=s("cp-monthly-nth-mode").value;
      return {
        ok:true,
        payload:{
          title:title,
          visibility:s("cp-visibility").value==="private"?"private":"public",
          description:s("cp-description").value,
          location:s("cp-location").value,
          category:s("cp-category").value,
          all_day_event:allDay,
          start_datetime:startIso,
          end_datetime:endIso,
          repeat_type:repeatType,
          repeat_interval:repeatType==="none" ? 1 : parseInt(s("cp-repeat-interval").value||"1",10),
          repeat_nth_mode:repeatType==="monthly" ? nthMode : "",
          repeat_nth_day:(repeatType==="monthly" && nthMode==="day_of_month" && s("cp-nth-day").value)?parseInt(s("cp-nth-day").value,10):null,
          repeat_nth_pos:(repeatType==="monthly" && nthMode==="weekday_of_month" && s("cp-nth-pos").value)?parseInt(s("cp-nth-pos").value,10):null,
          repeat_nth_weekday:(repeatType==="monthly" && nthMode==="weekday_of_month")?parseInt(s("cp-nth-weekday").value||"0",10):null,
          repeat_range_mode:repeatRangeMode,
          repeat_count:(repeatType!=="none" && repeatRangeMode==="count" && s("cp-repeat-count").value)?parseInt(s("cp-repeat-count").value,10):null,
          repeat_until:(repeatType!=="none" && repeatRangeMode==="until") ? (s("cp-repeat-until").value||null) : null,
          deleted_occurrence_keys:pendingDeletedOccurrenceKeys.slice()
        }
      };
    };

    const loadOccurrenceChooser=async()=>{
      const id=s("cp-event-id").value;
      const from=new Date(occCursor.getFullYear(),occCursor.getMonth(),1);
      const fromS=localYmd(from);
      let r;
      if(id){
        r=await api("/events/"+id+"/occurrences?from="+encodeURIComponent(fromS)+"&months=3",{headers:authHeaders()});
      } else {
        const built=formPayload();
        if(!built.ok){
          s("cp-occurrence-grid").innerHTML="";
          s("cp-occurrence-key-iso").value="";
          s("cp-occurrence-key").value="";
          updateEditorButtons();
          return;
        }
        r=await api("/events/preview-occurrences",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:built.payload,from:fromS,months:3})});
      }
      const p=await r.json();
      if(!r.ok){setEditorStatus((p.error&&p.error.message)||"Failed to load occurrences",true); return;}
      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);}});
      if(selectedOccurrenceDate && !dayToKey.has(selectedOccurrenceDate)){
        selectedOccurrenceDate="";
      }
      if(selectedOccurrenceDate && dayToKey.has(selectedOccurrenceDate)){
        const iso=dayToKey.get(selectedOccurrenceDate)||"";
        s("cp-occurrence-key-iso").value=iso;
        s("cp-occurrence-key").value=friendlyDateTime(iso);
      } else {
        s("cp-occurrence-key-iso").value="";
        s("cp-occurrence-key").value="";
      }
      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++){
          const weekend=(i%7)>=5;
          monthCells+=`<div style="border:1px solid #eee;border-radius:4px;min-height:22px;background:${weekend?'#fef2f2':'#f5f5f5'};"></div>`;
        }
        for(let day=1; day<=dim; day++){
          const dt=new Date(y,mo,day); const k=localYmd(dt); const has=dayToKey.has(k);
          const selected=(k===selectedOccurrenceDate);
          const weekend=((off+day-1)%7)>=5;
          const baseBg=weekend?'#fff1f2':'#f5f5f5';
          monthCells+=`<div style="border:1px solid ${selected?'#2563eb':(has?'#67e8f9':'#eee')};border-radius:4px;min-height:22px;padding:2px;text-align:center;background:${selected?'#dbeafe':(has?'#ecfeff':baseBg)};"><div data-occ-date="${k}" style="cursor:${has?'pointer':'default'};font-weight:${selected?700:400};">${day}</div></div>`;
        }
        cells.push(`<div style="border:1px solid #ddd;padding:6px;border-radius:6px;"><h4 style="margin:2px 0 6px 0;">${d.toLocaleString('en-GB',{month:'short',year:'numeric'})}</h4><div style="display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:3px;">${monthCells}</div></div>`);
      }
      s("cp-occurrence-grid").innerHTML=cells.join("");
      Array.from(s("cp-occurrence-grid").querySelectorAll("[data-occ-date]")).forEach(el=>el.addEventListener("click",()=>{
        const k=el.getAttribute("data-occ-date")||"";
        if(!dayToKey.has(k)){return;}
        if(selectedOccurrenceDate===k){
          selectedOccurrenceDate="";
          s("cp-occurrence-key-iso").value="";
          s("cp-occurrence-key").value="";
        } else {
          selectedOccurrenceDate=k;
          const iso=dayToKey.get(k)||"";
          s("cp-occurrence-key-iso").value=iso;
          s("cp-occurrence-key").value=friendlyDateTime(iso);
        }
        loadOccurrenceChooser();
      }));
      updateEditorButtons();
    };

    const rowsForDay=(items)=>{
      const t=theme();
      return items.map(it=>`<div data-cp-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
    };

    const bindClicks=()=>{
      Array.from(document.querySelectorAll("#cp-view-panel [data-cp-id], #cp-public-list [data-cp-id]")).forEach(el=>{
        el.addEventListener("click",async()=>{
          const id=el.getAttribute("data-cp-id")||"";
          const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
          if(it){await openEditorForItem(it);}
        });
      });
    };
    const bindCreateClicks=()=>{
      Array.from(document.querySelectorAll("#cp-view-panel [data-cp-create-date]")).forEach(el=>{
        el.addEventListener("click",(ev)=>{
          if(ev.target && ev.target.closest("[data-cp-id]")){return;}
          const dateYmd=el.getAttribute("data-cp-create-date")||"";
          if(dateYmd){openEditorCreateAt(dateYmd);}
        });
      });
    };

    const renderView=(items)=>{
      lastItems=items||[];
      const view=s("cp-view").value;
      const panel=s("cp-view-panel");
      const ul=s("cp-public-list");
      const title=s("cp-events-title");
      ul.innerHTML="";
      if(view==="list"){
        const todayLocal=dmy(new Date());
        const t=theme();
        title.textContent=s("cp-future-only").checked?`Events [from ${todayLocal}]`:"Events";
        panel.innerHTML="";
        (items||[]).slice(0,300).forEach(it=>{
          const li=document.createElement("li");
          li.setAttribute("data-cp-id", itemId(it));
          li.style.cursor="pointer";
          li.style.background=t.cellBg;
          li.style.border=`1px solid ${t.border}`;
          li.style.margin="4px 0";
          li.style.padding="6px 8px";
          li.style.borderRadius="4px";
          const dateLabel=longDate(it.occurrence_start||it.start_datetime);
          const timeLabel=it.all_day_event?"All day":timeRange(it.occurrence_start,it.occurrence_end);
          const title=it.title||"";
          const desc=String(it.description||"").trim();
          const headline=[dateLabel,timeLabel,title].filter(Boolean).join(", ");
          li.innerHTML=`<div>${esc(headline)}</div>${(desc!=="" && desc!==title)?`<div style="color:${t.mutedText};margin-top:2px;">${esc(desc)}</div>`:""}`;
          ul.appendChild(li);
        });
        bindClicks();
        return;
      }

      const map={};
      (items||[]).forEach(it=>{const k=ymd(it.occurrence_start); (map[k]=map[k]||[]).push(it);});
      const anchor=currentDate();
      const dow=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];

      if(view==="day"){
        const k=s("cp-date").value;
        title.textContent=`Events for ${dmy(anchor)}`;
        const day=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
        panel.innerHTML=`<div><strong>${esc(k)}</strong></div><div>${rowsForDay(day)}</div>`;
        bindClicks();
        return;
      }

      if(view==="week"){
        const t=theme();
        const wd=anchor.getDay();
        const start=new Date(anchor);
        start.setDate(anchor.getDate()-wd);
        const end=new Date(start); end.setDate(start.getDate()+6);
        title.textContent=`Events for ${dmy(start)}–${dmy(end)}`;
        let cells="";
        for(let i=0;i<7;i++){
          const d=new Date(start); d.setDate(start.getDate()+i);
          const k=localYmd(d);
          const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
          cells+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
        }
        panel.innerHTML=`<table style="width:100%;border-collapse:collapse;"><tr><th style="width:65px;border:1px solid ${t.border};background:${t.headBg};">Time</th>${dow.map(n=>`<th style="border:1px solid ${t.border};background:${t.headBg};">${n}</th>`).join("")}</tr><tr><td style="border:1px solid ${t.border};vertical-align:top;padding:4px;background:${t.mutedBg};color:${t.mutedText};">00:00<br>06:00<br>12:00<br>18:00</td>${cells}</tr></table>`;
        bindClicks();
        bindCreateClicks();
        return;
      }

      if(view==="month"){
        const t=theme();
        const first=new Date(anchor.getFullYear(),anchor.getMonth(),1);
        const start=new Date(first); start.setDate(first.getDate()-first.getDay());
        title.textContent=`Events for ${monthNames[anchor.getMonth()]}`;
        const rowParts=[];
        for(let r=0;r<6;r++){
          let tds="";
          let rowHasInMonth=false;
          for(let c=0;c<7;c++){
            const i=r*7+c;
            const d=new Date(start); d.setDate(start.getDate()+i);
            const k=localYmd(d);
            const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
            const inMonth=d.getMonth()===anchor.getMonth();
            if(inMonth){rowHasInMonth=true;}
            tds+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
          }
          rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
        }
        const rows=rowParts.filter(r=>r.has).map(r=>r.html).join("");
        panel.innerHTML=`<table style="width:100%;border-collapse:collapse;"><tr>${dow.map(n=>`<th style="border:1px solid ${t.border};background:${t.headBg};">${n}</th>`).join("")}</tr>${rows}</table>`;
        bindClicks();
        bindCreateClicks();
        return;
      }

      if(view==="year"){
        const t=theme();
        title.textContent=`Events for ${anchor.getFullYear()}`;
        let out='<div style="display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:8px;">';
        const mon=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        const has=(k)=>Boolean((map[k]||[]).length);
        for(let m=0;m<12;m++){
          const first=new Date(anchor.getFullYear(),m,1);
          const start=new Date(first); start.setDate(first.getDate()-first.getDay());
          let g="";
          for(let i=0;i<35;i++){
            const d=new Date(start); d.setDate(start.getDate()+i);
            const k=localYmd(d);
            const same=d.getMonth()===m;
            g+=`<div data-cp-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
          }
          out+=`<div style="border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${mon[m]}</strong></div><div style="display:grid;grid-template-columns:repeat(7,1fr);gap:2px;">${g}</div></div>`;
        }
        out+='</div>';
        panel.innerHTML=out;
        Array.from(panel.querySelectorAll("[data-cp-day]")).forEach(el=>{
          el.addEventListener("click",()=>{
            s("cp-date").value=el.getAttribute("data-cp-day")||s("cp-date").value;
            s("cp-view").value="week";
            loadPublic();
          });
        });
        return;
      }
    };

    const loadPublic=async()=>{
      const params={view:s("cp-view").value,date:s("cp-date").value};
      if(s("cp-view").value==="list" && s("cp-future-only").checked){params.future_only="1";}
      const qs=new URLSearchParams(params);
      const r=await api("/public/events?"+qs.toString());
      const p=await r.json();
      if(!r.ok){setStatus((p.error&&p.error.message)||"Failed to load events",true); return;}
      renderView(p.data||[]);
      futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
    };

    s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="flex";};
    s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
    s("cp-logout-btn").onclick=async()=>{
      await api("/users/logout",{method:"POST"});
      auth=null;
      me=null;
      setAuthUi();
      setStatus("Logged out",false);
    };
    s("cp-create-btn").onclick=openEditorCreate;
    s("cp-editor-close-btn").onclick=()=>{s("cp-editor-modal").style.display="none"; setEditorStatus("",false);};
    s("cp-details-close-btn").onclick=()=>{s("cp-details-modal").style.display="none";};

    s("cp-login-btn").onclick=async()=>{
      const email=s("cp-login-email").value.trim();
      const password=s("cp-login-password").value;
      const r=await api("/users/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email,password})});
      const p=await r.json();
      if(!r.ok){setAuthStatus("Login failure",true);return;}
      auth=null;
      me=(p.data||{}).user||{email};
      setAuthUi();
      s("cp-auth-modal").style.display="none";
      setStatus("Logged in",false);
    };

    s("cp-register-btn").onclick=async()=>{
      const email=s("cp-login-email").value.trim();
      const password=s("cp-login-password").value;
      const r=await api("/users/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email,password})});
      const p=await r.json();
      if(!r.ok){setAuthStatus((p.error&&p.error.message)||"Registration failed",true);return;}
      setAuthStatus("Registration submitted. Check your email for verification.",false);
    };

    s("cp-reset-request-btn").onclick=async()=>{
      const email=s("cp-login-email").value.trim();
      const r=await api("/users/password/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email})});
      const p=await r.json();
      if(!r.ok){setAuthStatus((p.error&&p.error.message)||"Reset request failed",true);return;}
      setAuthStatus((p.data&&p.data.message)||"If account exists, reset email sent.",false);
    };

    s("cp-verify-btn").onclick=async()=>{
      const token=s("cp-verify-token").value.trim();
      if(!token){setAuthStatus("Verification token is required",true);return;}
      const r=await api("/users/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token})});
      const p=await r.json();
      if(!r.ok){setAuthStatus((p.error&&p.error.message)||"Verification failed",true);return;}
      setAuthStatus((p.data&&p.data.message)||"Email verified. An admin will review your request and notify you if approved.",false);
    };

    s("cp-editor-create-btn").onclick=async()=>{
      if(!me){setEditorStatus("Login required",true);return;}
      const built=formPayload();
      if(!built.ok){setEditorStatus(built.message,true); return;}
      const r=await api("/events",{method:"POST",headers:{"Content-Type":"application/json",...authHeaders()},body:JSON.stringify(built.payload)});
      const p=await r.json();
      if(!r.ok){setEditorStatus((p.error&&p.error.message)||"Create failed",true);return;}
      s("cp-editor-modal").style.display="none";
      setEditorStatus("",false);
      setStatus("Event created.",false);
      loadPublic();
    };

    s("cp-update-btn").onclick=async()=>{
      if(!me){setEditorStatus("Login required",true);return;}
      const id=s("cp-event-id").value;
      if(!id){return;}
      const built=formPayload();
      if(!built.ok){setEditorStatus(built.message,true); return;}
      const r=await api("/events/"+id,{method:"PATCH",headers:{"Content-Type":"application/json",...authHeaders()},body:JSON.stringify(built.payload)});
      const p=await r.json();
      if(!r.ok){setEditorStatus((p.error&&p.error.message)||"Update failed",true);return;}
      s("cp-editor-modal").style.display="none";
      setEditorStatus("",false);
      setStatus("Event updated.",false);
      loadPublic();
    };

    s("cp-delete-btn").onclick=async()=>{
      if(!me){setEditorStatus("Login required",true);return;}
      const id=s("cp-event-id").value;
      if(!id){return;}
      const r=await api("/events/"+id,{method:"DELETE",headers:authHeaders()});
      if(!r.ok){const p=await r.json(); setEditorStatus((p.error&&p.error.message)||"Delete failed",true);return;}
      s("cp-editor-modal").style.display="none";
      setStatus("Event deleted.",false);
      loadPublic();
    };

    s("cp-delete-occ-confirm-btn").onclick=async()=>{
      if(!me){setEditorStatus("Login required",true);return;}
      const id=s("cp-event-id").value;
      const key=s("cp-occurrence-key-iso").value.trim();
      if(!key){setEditorStatus("Select an occurrence date first",true);return;}
      if(id){
        const r=await api("/events/"+id+"/occurrences/"+encodeURIComponent(key),{method:"DELETE",headers:authHeaders()});
        if(!r.ok){const p=await r.json(); setEditorStatus((p.error&&p.error.message)||"Delete occurrence failed",true);return;}
        setStatus("Occurrence deleted.",false);
      } else {
        if(!pendingDeletedOccurrenceKeys.includes(key)){
          pendingDeletedOccurrenceKeys.push(key);
        }
        setEditorStatus("Occurrence removed from preview and will be excluded when saved.",false);
      }
      s("cp-occurrence-key-iso").value="";
      s("cp-occurrence-key").value="";
      selectedOccurrenceDate="";
      await loadOccurrenceChooser();
      if(id){loadPublic();}
    };

    s("cp-refresh-btn").onclick=loadPublic;
    const maybeRefreshOccurrencePreview=()=>{
      if(s("cp-repeat-type").value!=="none"){loadOccurrenceChooser();}
    };
    s("cp-repeat-type").onchange=()=>{syncRepeatUi(); maybeRefreshOccurrencePreview(); updateEditorButtons();};
    s("cp-range-mode").onchange=()=>{syncRepeatUi(); maybeRefreshOccurrencePreview(); updateEditorButtons();};
    s("cp-monthly-nth-mode").onchange=()=>{syncRepeatUi(); maybeRefreshOccurrencePreview(); updateEditorButtons();};
    s("cp-view").onchange=loadPublic;
    s("cp-date").onchange=loadPublic;
    s("cp-future-only").onchange=loadPublic;
    s("cp-today-btn").onclick=()=>{setDateInput(new Date()); loadPublic();};
    s("cp-prev-btn").onclick=()=>stepDate(-1);
    s("cp-next-btn").onclick=()=>stepDate(1);
    s("cp-occ-prev-btn").onclick=async()=>{occCursor=new Date(occCursor.getFullYear(),occCursor.getMonth()-1,1); await loadOccurrenceChooser();};
    s("cp-occ-next-btn").onclick=async()=>{occCursor=new Date(occCursor.getFullYear(),occCursor.getMonth()+1,1); await loadOccurrenceChooser();};
    s("cp-theme").onchange=()=>{
      currentThemeName=s("cp-theme").value||"editorial";
      try{localStorage.setItem(themeKey,currentThemeName);}catch(_e){}
      applyTheme();
      renderView(lastItems);
    };
    s("cp-all-day").onchange=()=>{applyAllDayVisibility(); updateEditorButtons();};
    s("cp-repeat-interval").addEventListener("input",()=>{s("cp-repeat-unit").textContent=repeatUnitLabel();});
    s("cp-repeat-interval").addEventListener("change",()=>{s("cp-repeat-unit").textContent=repeatUnitLabel();});
    ["cp-title","cp-start-date","cp-end-date","cp-start-time","cp-end-time","cp-repeat-interval","cp-repeat-count","cp-repeat-until","cp-nth-day","cp-nth-pos","cp-nth-weekday"].forEach(id=>{
      s(id).addEventListener("input",updateEditorButtons);
      s(id).addEventListener("input",maybeRefreshOccurrencePreview);
      s(id).addEventListener("change",updateEditorButtons);
      s(id).addEventListener("change",maybeRefreshOccurrencePreview);
    });

    const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
    if(tokenFromUrl){
      s("cp-verify-token").value=tokenFromUrl;
      s("cp-auth-modal").style.display="flex";
      setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
    }

    const loadCurrentSession=async()=>{
      try{
        const r=await api("/users/me");
        const p=await r.json();
        if(r.ok){
          me=(p.data||null);
          auth=null;
        } else {
          me=null;
        }
      }catch(_e){
        me=null;
      }
      setAuthUi();
      updateEditorButtons();
    };

    try{
      const savedTheme=localStorage.getItem(themeKey)||"editorial";
      if(themes[savedTheme]){currentThemeName=savedTheme;}
    }catch(_e){}
    s("cp-theme").value=currentThemeName;
    applyTheme();
    loadCurrentSession().finally(loadPublic);
  })();</script>
</div>
HTML
        , [
            '__CALDAV_ROOT__' => $caldavRootEsc,
            '__ICS_URL__' => $icsUrlEsc,
            '__TODAY__' => $todayEsc,
            '__NS__' => $nsEsc,
        ]);
    }

    public function renderSidebarShortcode(): string
    {
        $items = $this->eventService->listSidebarUpcoming(14);
        if (!$items) {
            return '<div class="calendar-plugin-shell" data-mode="sidebar"><p>No upcoming events.</p></div>';
        }

        $rows = [];
        $sidebarTz = new \DateTimeZone('Europe/London');
        foreach ($items as $item) {
            $start = (string) ($item['occurrence_start'] ?? '');
            $end = (string) ($item['occurrence_end'] ?? '');
            $startDt = $this->toSidebarDateTime($start, $sidebarTz);
            $endDt = $this->toSidebarDateTime($end, $sidebarTz);
            $dateLabel = $startDt !== null ? $startDt->format('j F Y') : substr($start, 0, 10);
            $timeLabel = '';
            if ($startDt !== null && $endDt !== null) {
                $startTime = $startDt->format('H:i');
                $endTime = $endDt->format('H:i');
                if ($startTime !== '00:00' || $endTime !== '00:00') {
                    $timeLabel = $this->formatSidebarTimeRange($startDt, $endDt);
                }
            }
            $title = trim((string) ($item['title'] ?? ''));
            $description = trim((string) ($item['description'] ?? ''));
            $headline = $dateLabel;
            if ($timeLabel !== '') {
                $headline .= ', ' . $timeLabel;
            }
            $headline .= ', ' . ($title !== '' ? $title : $description);
            $rows[] = sprintf(
                '<p style="margin:0 0 10px 0;">%s%s</p>',
                esc_html($headline),
                $description !== '' ? '<br>' . esc_html($description) : ''
            );
        }

        return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
    }

    private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
    {
        if ($value === '') {
            return null;
        }
        try {
            return (new \DateTimeImmutable($value))->setTimezone($timezone);
        } catch (\Throwable) {
            return null;
        }
    }

    private function formatSidebarTimeRange(\DateTimeImmutable $startDt, \DateTimeImmutable $endDt): string
    {
        $startMeridiem = strtolower($startDt->format('a'));
        $endMeridiem = strtolower($endDt->format('a'));
        $startLabel = $this->formatSidebarTimeValue($startDt);
        $endLabel = $this->formatSidebarTimeValue($endDt);
        if ($startMeridiem === $endMeridiem) {
            $startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
            return $startLabel . '–' . $endLabel;
        }
        return $startLabel . '–' . $endLabel;
    }

    private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
    {
        $hour = (int) $dt->format('G');
        $minute = (int) $dt->format('i');
        $meridiem = strtolower($dt->format('a'));
        $hour12 = $hour % 12;
        if ($hour12 === 0) {
            $hour12 = 12;
        }
        if ($minute === 0) {
            return $hour12 . $meridiem;
        }
        return $hour12 . '.' . str_pad((string) $minute, 2, '0', STR_PAD_LEFT) . $meridiem;
    }

    public function registerAdminMenu(): void
    {
        if (!function_exists('add_menu_page')) {
            return;
        }

        add_menu_page(
            'Calendar Plugin',
            'Calendar Plugin',
            'manage_options',
            'calendar-plugin',
            [$this, 'renderAdminSetupPage']
        );

        add_submenu_page(
            'calendar-plugin',
            'Users',
            'Users',
            'manage_options',
            'calendar-plugin-users',
            [$this, 'renderAdminUsersPage']
        );

        add_submenu_page(
            'calendar-plugin',
            'Setup',
            'Setup',
            'manage_options',
            'calendar-plugin-setup',
            [$this, 'renderAdminSetupPage']
        );

        add_submenu_page(
            'calendar-plugin',
            'Diagnostics',
            'Diagnostics',
            'manage_options',
            'calendar-plugin-diagnostics',
            [$this, 'renderAdminDiagnosticsPage']
        );

        if (function_exists('remove_submenu_page')) {
            remove_submenu_page('calendar-plugin', 'calendar-plugin');
        }
    }

    public function renderAdminSetupPage(): void
    {
        $settings = $this->settingsService->getAll();
        $currentStem = trim((string) get_option('calendar_plugin_table_stem', $this->tableStem), '_');
        if ($currentStem === '') {
            $currentStem = $this->tableStem;
        }
        $dbPrefix = (string) $this->db->getPrefix();
        $currentTablePrefix = $dbPrefix . $currentStem;
        $message = '';
        $error = '';

        if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
            $nonce = (string) ($_POST['_calendar_setup_nonce'] ?? '');
            $nonceSupported = function_exists('wp_nonce_field') && function_exists('wp_verify_nonce');
            if ($nonceSupported && ($nonce === '' || !$this->auth->verifyNonce($nonce, 'calendar_setup_update'))) {
                $error = 'Invalid setup form submission.';
            } else {
                $action = (string) ($_POST['setup_action'] ?? 'save_settings');
                if ($action === 'delete_all_events') {
                    $deleted = $this->eventService->deleteAllEventsData();
                    $message = 'Deleted ' . $deleted . ' events and all recurrence exceptions.';
                } elseif ($action === 'seed_events') {
                    try {
                        $seeded = $this->eventService->seedDefaultEvents();
                        $message = 'Seeded ' . $seeded . ' default events.';
                    } catch (\Throwable $e) {
                        $error = 'Seed failed: ' . $e->getMessage();
                    }
                } else {
                    $payload = [
                        'caldav_calendar_name' => (string) ($_POST['caldav_calendar_name'] ?? ''),
                        'url_slug' => (string) ($_POST['url_slug'] ?? ''),
                        'verification_page_path' => (string) ($_POST['verification_page_path'] ?? ''),
                        'ics_access_mode' => (string) ($_POST['ics_access_mode'] ?? ''),
                        'diagnostics_enabled' => isset($_POST['diagnostics_enabled']) ? '1' : '0',
                        'uninstall_cleanup_mode' => (string) ($_POST['uninstall_cleanup_mode'] ?? 'keep'),
                    ];
                    $settings = $this->settingsService->update($payload);
                    $requestedPrefixRaw = strtolower(trim((string) ($_POST['table_prefix'] ?? $currentTablePrefix)));
                    $requestedPrefix = preg_replace('/[^a-z0-9_]/', '', $requestedPrefixRaw) ?: $currentTablePrefix;
                    $requestedPrefix = trim($requestedPrefix, '_');
                    if ($requestedPrefix === '') {
                        $requestedPrefix = $currentTablePrefix;
                    }
                    if ($requestedPrefix !== $currentTablePrefix) {
                        $prefixResult = $this->updatePluginTablePrefix($currentStem, $requestedPrefix);
                        if ($prefixResult['ok']) {
                            $currentStem = (string) $prefixResult['stem'];
                            $currentTablePrefix = $dbPrefix . $currentStem;
                            $message = 'Settings saved. Table prefix updated to ' . $currentTablePrefix . '.';
                        } else {
                            $error = (string) $prefixResult['message'];
                        }
                    } else {
                        $message = 'Settings saved.';
                    }
                }
            }
        }

        $nonceField = '';
        if (function_exists('wp_nonce_field')) {
            ob_start();
            wp_nonce_field('calendar_setup_update', '_calendar_setup_nonce');
            $nonceField = (string) ob_get_clean();
        } else {
            $nonceField = '<input type="hidden" name="_calendar_setup_nonce" value="" />';
        }

        $saveNote = '';
        if ($error !== '') {
            $saveNote .= '<p style="color:#a00;"><strong>' . esc_html($error) . '</strong></p>';
        }
        if ($message !== '') {
            $saveNote .= '<p style="color:#060;"><strong>' . esc_html($message) . '</strong></p>';
        }

        $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
        $icsModePublicSel = $icsMode === 'public_read' ? 'selected' : '';
        $icsModeAuthSel = $icsMode === 'authenticated_read' ? 'selected' : '';
        $diagnosticsChecked = ((string) ($settings['diagnostics_enabled'] ?? '1')) === '1' ? 'checked' : '';
        $cleanupMode = (string) ($settings['uninstall_cleanup_mode'] ?? 'keep');
        $cleanupKeepSel = $cleanupMode === 'keep' ? 'selected' : '';
        $cleanupRemoveSel = $cleanupMode === 'remove' ? 'selected' : '';

        $form = '<form method="post" action="" style="max-width:840px;">'
            . $nonceField
            . '<table class="form-table" role="presentation"><tbody>'
            . '<tr><th scope="row"><label for="cp-setup-calendar-name">CalDAV/ICS Calendar Name</label></th><td><input id="cp-setup-calendar-name" name="caldav_calendar_name" type="text" class="regular-text" value="' . esc_attr((string) ($settings['caldav_calendar_name'] ?? '')) . '" /></td></tr>'
            . '<tr><th scope="row"><label for="cp-setup-url-slug">URL Slug Prefix</label></th><td><input id="cp-setup-url-slug" name="url_slug" type="text" class="regular-text" value="' . esc_attr((string) ($settings['url_slug'] ?? '')) . '" /><p class="description">Optional prefix applied to plugin URLs, e.g. <code>events</code> gives <code>/events/calendar.ics</code>.</p></td></tr>'
            . '<tr><th scope="row"><label for="cp-setup-verification-page">Verification Page Path</label></th><td><input id="cp-setup-verification-page" name="verification_page_path" type="text" class="regular-text" value="' . esc_attr((string) ($settings['verification_page_path'] ?? '/calendar')) . '" /><p class="description">Path used in verification emails, e.g. <code>/calendar</code>.</p></td></tr>'
            . '<tr><th scope="row"><label for="cp-setup-table-prefix">Plugin Table Prefix</label></th><td><input id="cp-setup-table-prefix" name="table_prefix" type="text" class="regular-text" value="' . esc_attr($currentTablePrefix) . '" /><p class="description">Recommended initial prefix is <code>wp_cs_calendar</code>. Changing this renames plugin tables while preserving data.</p></td></tr>'
            . '<tr><th scope="row"><label for="cp-setup-ics-mode">ICS Access Mode</label></th><td><select id="cp-setup-ics-mode" name="ics_access_mode"><option value="public_read" ' . $icsModePublicSel . '>Public Read</option><option value="authenticated_read" ' . $icsModeAuthSel . '>Authenticated Read</option></select></td></tr>'
            . '<tr><th scope="row"><label for="cp-setup-diagnostics-enabled">Diagnostics Enabled</label></th><td><label><input id="cp-setup-diagnostics-enabled" name="diagnostics_enabled" type="checkbox" value="1" ' . $diagnosticsChecked . ' /> Collect diagnostics data and enable diagnostic downloads.</label></td></tr>'
            . '<tr><th scope="row"><label for="cp-setup-uninstall-cleanup">Uninstall Cleanup</label></th><td><select id="cp-setup-uninstall-cleanup" name="uninstall_cleanup_mode"><option value="keep" ' . $cleanupKeepSel . '>Keep plugin tables/data on uninstall</option><option value="remove" ' . $cleanupRemoveSel . '>Remove plugin tables/data on uninstall</option></select><p class="description">Controls whether plugin-owned tables are dropped when the plugin is removed from WordPress.</p></td></tr>'
            . '</tbody></table>'
            . '<p><button type="submit" class="button button-primary" name="setup_action" value="save_settings">Save Settings</button> '
            . '<button type="submit" class="button button-secondary" name="setup_action" value="seed_events">Seed Events</button> '
            . '<button type="submit" class="button button-secondary" name="setup_action" value="delete_all_events" onclick="return confirm(\'Delete ALL event data?\');">Delete All Events Data</button></p>'
            . '</form>';

        echo $this->adminPageShell(
            'Setup',
            $saveNote
            . '<p>Configure presentation and endpoint behavior below or via API: <code>/wp-json/' . self::API_NAMESPACE . '/settings</code>.</p>'
            . $form
            . '<ul>'
            . '<li>Calendar shortcode: <code>[calendar]</code></li>'
            . '<li>Sidebar shortcode: <code>[calendar_sidebar_upcoming]</code> (next 14 days list)</li>'
            . '<li>ICS endpoint: <code>' . esc_html($this->icsPath()) . '</code></li>'
            . '<li>CalDAV root: <code>' . esc_html($this->caldavRootPath()) . '</code></li>'
            . '</ul>'
        );
    }

    public function renderAdminUsersPage(): void
    {
        $message = '';
        $error = '';

        if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
            $nonce = (string) ($_POST['_calendar_users_nonce'] ?? '');
            $nonceSupported = function_exists('wp_nonce_field') && function_exists('wp_verify_nonce');
            if ($nonceSupported && ($nonce === '' || !$this->auth->verifyNonce($nonce, 'calendar_users_action'))) {
                $error = 'Invalid users action submission.';
            } else {
                $action = (string) ($_POST['users_action'] ?? '');
                $userId = (int) ($_POST['user_id'] ?? 0);
                if ($userId <= 0) {
                    $error = 'Invalid user selection.';
                } elseif ($action === 'approve') {
                    $user = $this->userService->approveUser($userId);
                    if ($user === null) {
                        $error = 'User not found.';
                    } else {
                        $message = 'User approved.';
                    }
                } elseif ($action === 'remove') {
                    $ok = $this->userService->removeUser($userId);
                    if (!$ok) {
                        $error = 'User not found.';
                    } else {
                        $message = 'User removed.';
                    }
                } else {
                    $error = 'Unsupported users action.';
                }
            }
        }

        $nonceField = '';
        if (function_exists('wp_nonce_field')) {
            ob_start();
            wp_nonce_field('calendar_users_action', '_calendar_users_nonce');
            $nonceField = (string) ob_get_clean();
        } else {
            $nonceField = '<input type="hidden" name="_calendar_users_nonce" value="" />';
        }

        $users = $this->userService->listUsers();
        $rows = '';
        foreach ($users as $u) {
            $userId = (int) ($u['id'] ?? 0);
            $status = (string) ($u['account_status'] ?? '');
            $isApproved = $status === 'active';
            $isVerified = !empty((string) ($u['email_verified_at'] ?? ''));
            $approveButton = $isApproved
                ? '<button type="button" class="button" disabled>Approved</button>'
                : ($isVerified
                    ? '<button type="submit" name="users_action" value="approve" class="button button-primary">Approve</button>'
                    : '<button type="button" class="button" disabled title="User must verify email first">Awaiting Verification</button>');
            $removeButton = '<button type="submit" name="users_action" value="remove" class="button button-secondary" onclick="return confirm(\'Remove this user?\');">Remove</button>';
            $actions = '<form method="post" action="" style="display:flex;gap:6px;align-items:center;">'
                . $nonceField
                . '<input type="hidden" name="user_id" value="' . $userId . '" />'
                . $approveButton
                . $removeButton
                . '</form>';
            $rows .= '<tr>'
                . '<td>' . $userId . '</td>'
                . '<td>' . esc_html((string) $u['email']) . '</td>'
                . '<td>' . esc_html($status) . '</td>'
                . '<td>' . esc_html((string) ($u['email_verified_at'] ?? '')) . '</td>'
                . '<td>' . $actions . '</td>'
                . '</tr>';
        }
        $feedback = '';
        if ($error !== '') {
            $feedback .= '<p style="color:#a00;"><strong>' . esc_html($error) . '</strong></p>';
        }
        if ($message !== '') {
            $feedback .= '<p style="color:#060;"><strong>' . esc_html($message) . '</strong></p>';
        }

        $body = $feedback
            . '<p>Approve pending users or remove defunct users.</p>'
            . '<table class="widefat"><thead><tr><th>ID</th><th>Email</th><th>Status</th><th>Email Verified</th><th>Actions</th></tr></thead><tbody>'
            . ($rows !== '' ? $rows : '<tr><td colspan="5">No users</td></tr>')
            . '</tbody></table>';
        echo $this->adminPageShell('Users', $body);
    }

    public function renderAdminDiagnosticsPage(): void
    {
        $enabled = $this->isDiagnosticsEnabled();
        $diag = $this->collectDiagnosticsSnapshot($enabled);
        $downloadUrl = admin_url('admin-post.php?action=calendar_plugin_download_diagnostics&_wpnonce=' . wp_create_nonce('calendar_diagnostics_download'));
        $downloadButton = $enabled
            ? '<p><a class="button button-secondary" href="' . esc_url((string) $downloadUrl) . '">Download Diagnostics</a></p>'
            : '<p><button class="button button-secondary" disabled>Download Diagnostics</button> <em>Enable diagnostics in Setup to collect and download diagnostics.</em></p>';
        echo $this->adminPageShell(
            'Diagnostics',
            $downloadButton
            . '<p>Runtime diagnostics snapshot:</p><pre style="white-space:pre-wrap;">' . esc_html(json_encode($diag, JSON_PRETTY_PRINT)) . '</pre>'
        );
    }

    public function handleDiagnosticsDownload(): void
    {
        if (!$this->auth->currentUserCan('manage_options')) {
            wp_die('Not authorized.', 'Forbidden', ['response' => 403]);
        }
        $nonce = (string) ($_GET['_wpnonce'] ?? '');
        if (!$this->auth->verifyNonce($nonce, 'calendar_diagnostics_download')) {
            wp_die('Invalid diagnostics download request.');
        }
        if (!$this->isDiagnosticsEnabled()) {
            wp_die('Diagnostics are disabled in Setup.');
        }

        $diag = $this->collectDiagnosticsSnapshot(true);
        nocache_headers();
        header('Content-Type: application/json; charset=utf-8');
        header('Content-Disposition: attachment; filename="calendar-diagnostics-' . gmdate('Ymd-His') . '.json"');
        header('X-Content-Type-Options: nosniff');
        echo json_encode($diag, JSON_PRETTY_PRINT);
        exit;
    }

    private function isDiagnosticsEnabled(): bool
    {
        return ((string) ($this->settingsService->get('diagnostics_enabled', '1') ?? '1')) === '1';
    }

    public function maybeServeSpecialEndpoints(): void
    {
        $uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
        $path = parse_url($uri, PHP_URL_PATH);
        if (!is_string($path)) {
            return;
        }

        if ($path === $this->icsPath()) {
            $this->serveIcsResponse();
            exit;
        }

        if ($path === '/.well-known/caldav') {
            wp_redirect($this->caldavRootPath(), 301);
            exit;
        }

        if ($path === rtrim($this->caldavRootPath(), '/') . '/') {
            $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
            if ($method === 'OPTIONS') {
                header('Allow: OPTIONS, PROPFIND, REPORT');
                header('DAV: 1, calendar-access');
                http_response_code(200);
                exit;
            }
        }

        if (str_starts_with($path, rtrim($this->caldavRootPath(), '/'))) {
            $this->serveCalDavPath($path, strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
            exit;
        }
    }

    private function registerHealthRoute(): void
    {
        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/health',
            [
                'methods' => 'GET',
                'permission_callback' => '__return_true',
                'callback' => function (): array {
                    return [
                        'status' => 'ok',
                        'plugin' => 'calendar-plugin',
                        'version' => '1.0.1',
                        'db_prefix' => $this->db->getPrefix(),
                    ];
                },
            ]
        );
    }

    private function registerSettingsRoutes(): void
    {
        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/settings',
            [
                'methods' => 'GET',
                'permission_callback' => fn(): bool => $this->auth->currentUserCan('manage_options'),
                'callback' => fn(): array => ['data' => $this->settingsService->getAll()],
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/settings',
            [
                'methods' => 'PATCH',
                'permission_callback' => fn(): bool => $this->auth->currentUserCan('manage_options'),
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    return ['data' => $this->settingsService->update($payload)];
                },
            ]
        );
    }

    private function registerEventRoutes(): void
    {
        $canWrite = fn($request = null): bool => $this->canWriteCalendar($request);

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events',
            [
                'methods' => 'GET',
                'permission_callback' => $canWrite,
                'callback' => fn(): array => ['data' => $this->eventService->listEvents()],
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events',
            [
                'methods' => 'POST',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    try {
                        $deletedKeys = isset($payload['deleted_occurrence_keys']) && is_array($payload['deleted_occurrence_keys'])
                            ? array_values($payload['deleted_occurrence_keys'])
                            : [];
                        unset($payload['deleted_occurrence_keys']);
                        $created = $this->eventService->createEvent($payload);
                        if ($deletedKeys !== []) {
                            $this->eventService->syncDeletedOccurrenceKeys((int) ($created['id'] ?? 0), $deletedKeys, true);
                            $created = $this->eventService->getEvent((int) ($created['id'] ?? 0)) ?: $created;
                        }
                        return ['data' => $created];
                    } catch (\InvalidArgumentException $e) {
                        return $this->error('validation_error', $e->getMessage(), 422);
                    } catch (\RuntimeException $e) {
                        return $this->error('internal_error', $e->getMessage(), 500);
                    }
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events/(?P<id>\d+)',
            [
                'methods' => 'GET',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $id = (int) $request->get_param('id');
                    $event = $this->eventService->getEvent($id);
                    if (!$event) {
                        return $this->error('not_found', 'event not found', 404);
                    }
                    return ['data' => $event];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events/(?P<id>\d+)',
            [
                'methods' => 'PATCH',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $id = (int) $request->get_param('id');
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    try {
                        $deletedKeysProvided = isset($payload['deleted_occurrence_keys']) && is_array($payload['deleted_occurrence_keys']);
                        $deletedKeys = $deletedKeysProvided ? array_values((array) $payload['deleted_occurrence_keys']) : [];
                        unset($payload['deleted_occurrence_keys']);
                        $event = $this->eventService->updateEvent($id, $payload);
                    } catch (\InvalidArgumentException $e) {
                        return $this->error('validation_error', $e->getMessage(), 422);
                    }
                    if (!$event) {
                        return $this->error('not_found', 'event not found', 404);
                    }
                    if ($deletedKeysProvided) {
                        $this->eventService->syncDeletedOccurrenceKeys($id, $deletedKeys, true);
                        $event = $this->eventService->getEvent($id) ?: $event;
                    }
                    return ['data' => $event];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events/(?P<id>\d+)',
            [
                'methods' => 'DELETE',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $id = (int) $request->get_param('id');
                    $ok = $this->eventService->deleteEvent($id);
                    if (!$ok) {
                        return $this->error('not_found', 'event not found', 404);
                    }
                    return ['data' => ['deleted' => true]];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events/(?P<id>\d+)/occurrences/(?P<occurrence_key>[^/]+)',
            [
                'methods' => 'DELETE',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $id = (int) $request->get_param('id');
                    $key = urldecode((string) $request->get_param('occurrence_key'));
                    $ok = $this->eventService->deleteOccurrence($id, $key);
                    if (!$ok) {
                        return $this->error('validation_error', 'invalid occurrence or event', 422);
                    }
                    return ['data' => ['deleted' => true]];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events/(?P<id>\d+)/occurrences',
            [
                'methods' => 'GET',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $id = (int) $request->get_param('id');
                    $from = (string) ($request->get_param('from') ?: gmdate('Y-m-d'));
                    $months = (int) ($request->get_param('months') ?: 3);
                    $items = $this->eventService->listEventOccurrences($id, $from, $months);
                    if ($items === null) {
                        return $this->error('not_found', 'event not found', 404);
                    }
                    return ['data' => $items, 'meta' => ['count' => count($items), 'from' => $from, 'months' => max(1, min($months, 24))]];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/events/preview-occurrences',
            [
                'methods' => 'POST',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null || !is_array($payload)) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    $event = isset($payload['event']) && is_array($payload['event']) ? (array) $payload['event'] : [];
                    $from = (string) ($payload['from'] ?? gmdate('Y-m-d'));
                    $months = (int) ($payload['months'] ?? 3);
                    try {
                        $items = $this->eventService->previewOccurrences($event, $from, $months);
                    } catch (\InvalidArgumentException $e) {
                        return $this->error('validation_error', $e->getMessage(), 422);
                    }
                    return ['data' => $items, 'meta' => ['count' => count($items), 'from' => $from, 'months' => max(1, min($months, 24))]];
                },
            ]
        );
    }

    private function registerPublicRoutes(): void
    {
        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/public/events',
            [
                'methods' => 'GET',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array {
                    $view = (string) ($request->get_param('view') ?: 'month');
                    $date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
                    $futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
                    $futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
                    $includePrivateDetails = $this->canWriteCalendar($request);
                    $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails);
                    return [
                        'data' => $items,
                        'meta' => [
                            'count' => count($items),
                            'view' => $view,
                            'future_only' => $futureOnly,
                            'redacted_private' => !$includePrivateDetails,
                        ],
                    ];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/public/sidebar-events',
            [
                'methods' => 'GET',
                'permission_callback' => '__return_true',
                'callback' => function (): array {
                    $items = $this->eventService->listSidebarUpcoming(14, true);
                    return [
                        'data' => $items,
                        'meta' => ['count' => count($items), 'window_days' => 14],
                    ];
                },
            ]
        );
    }

    private function registerUserRoutes(): void
    {
        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/users/register',
            [
                'methods' => 'POST',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    $result = $this->userService->register(
                        (string) ($payload['email'] ?? ''),
                        (string) ($payload['password'] ?? '')
                    );
                    if (isset($result['error'])) {
                        return $this->unwrapServiceResult($result);
                    }

                    $debugTokens = $this->allowDebugTokens() && !empty($payload['debug_tokens']);
                    $verifyToken = (string) ($result['verify_token'] ?? '');
                    $email = (string) ($result['user']['email'] ?? '');
                    if ($verifyToken !== '' && $email !== '') {
                        $this->sendUserEmailVerification($email, $verifyToken);
                    }

                    if (!$debugTokens) {
                        unset($result['verify_token']);
                    }
                    return ['data' => $result];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/users/verify',
            [
                'methods' => 'POST',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    $result = $this->userService->verifyEmail((string) ($payload['token'] ?? ''));
                    if (!isset($result['error'])) {
                        $verifiedEmail = (string) ($result['user']['email'] ?? '');
                        if ($verifiedEmail !== '') {
                            $this->sendAdminApprovalRequestEmail($verifiedEmail);
                        }
                    }
                    return $this->unwrapServiceResult($result);
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/users/login',
            [
                'methods' => 'POST',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    $result = $this->userService->login(
                        (string) ($payload['email'] ?? ''),
                        (string) ($payload['password'] ?? '')
                    );
                    if (isset($result['error'])) {
                        return $this->unwrapServiceResult($result);
                    }
                    $sessionToken = $this->userService->issueSessionToken((int) ($result['user']['id'] ?? 0), self::SESSION_TTL_SECONDS);
                    if ($sessionToken !== '') {
                        $this->setCalendarSessionCookie($sessionToken);
                    }
                    return ['data' => $result];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/users/logout',
            [
                'methods' => 'POST',
                'permission_callback' => '__return_true',
                'callback' => function (): array {
                    $token = $this->readCalendarSessionTokenFromRequest(null);
                    if ($token !== '') {
                        $this->userService->revokeSessionToken($token);
                    }
                    $this->clearCalendarSessionCookie();
                    return ['data' => ['logged_out' => true]];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/users/me',
            [
                'methods' => 'GET',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $user = $this->resolveCalDavUserForRequest($request);
                    if ($user === null) {
                        return $this->error('auth_required', 'Login failure', 401);
                    }
                    return ['data' => $user];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/users/password/request',
            [
                'methods' => 'POST',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    $result = $this->userService->requestPasswordReset((string) ($payload['email'] ?? ''));
                    if (isset($result['error'])) {
                        return $this->unwrapServiceResult($result);
                    }

                    $debugTokens = $this->allowDebugTokens() && !empty($payload['debug_tokens']);
                    $email = (string) ($payload['email'] ?? '');
                    $token = (string) ($result['reset_token'] ?? '');
                    if ($email !== '' && $token !== '') {
                        $this->sendPasswordResetEmail($email, $token);
                    }
                    if (!$debugTokens) {
                        unset($result['reset_token']);
                    }
                    return ['data' => $result];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/users/password/reset',
            [
                'methods' => 'POST',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null) {
                        return $this->error('validation_error', 'invalid JSON payload', 422);
                    }
                    $result = $this->userService->resetPassword(
                        (string) ($payload['token'] ?? ''),
                        (string) ($payload['new_password'] ?? '')
                    );
                    return $this->unwrapServiceResult($result);
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/admin/users',
            [
                'methods' => 'GET',
                'permission_callback' => fn(): bool => $this->auth->currentUserCan('manage_options'),
                'callback' => fn(): array => ['data' => $this->userService->listUsers()],
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/admin/users/(?P<id>\d+)/approve',
            [
                'methods' => 'PATCH',
                'permission_callback' => fn(): bool => $this->auth->currentUserCan('manage_options'),
                'callback' => function ($request): array|\WP_Error {
                    $id = (int) $request->get_param('id');
                    $user = $this->userService->approveUser($id);
                    if (!$user) {
                        return $this->error('not_found', 'user not found', 404);
                    }
                    return ['data' => $user];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/admin/users/(?P<id>\d+)',
            [
                'methods' => 'DELETE',
                'permission_callback' => fn(): bool => $this->auth->currentUserCan('manage_options'),
                'callback' => function ($request): array|\WP_Error {
                    $id = (int) $request->get_param('id');
                    $ok = $this->userService->removeUser($id);
                    if (!$ok) {
                        return $this->error('not_found', 'user not found', 404);
                    }
                    return ['data' => ['deleted' => true]];
                },
            ]
        );
    }

    private function registerIcsRoutes(): void
    {
        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/public/ics',
            [
                'methods' => 'GET',
                'permission_callback' => '__return_true',
                'callback' => function ($request): array|\WP_Error {
                    $settings = $this->settingsService->getAll();
                    $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
                    $includePrivateDetails = $this->canWriteCalendar($request);
                    if ($icsMode === 'authenticated_read' && !$includePrivateDetails) {
                        return $this->error('auth_required', 'authentication required', 401);
                    }
                    $calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
                    $ics = $this->icsService->buildCalendar(
                        $this->eventService->listEvents(),
                        fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
                        $calendarName,
                        !$includePrivateDetails
                    );
                    return ['data' => $ics];
                },
            ]
        );
    }

    private function registerCalDavRoutes(): void
    {
        $canRead = fn($request = null): bool => $this->resolveCalDavUserForRequest($request) !== null;
        $canWrite = fn($request = null): bool => $this->canWriteCalendar($request);

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/caldav/resources',
            [
                'methods' => 'GET',
                'permission_callback' => $canRead,
                'callback' => fn(): array => ['data' => $this->calDavService->listResources()],
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/caldav/object/(?P<resource>[^/]+\.ics)',
            [
                'methods' => 'GET',
                'permission_callback' => $canRead,
                'callback' => function ($request): array|\WP_Error {
                    $resource = urldecode((string) $request->get_param('resource'));
                    $obj = $this->calDavService->getObject($resource);
                    if ($obj === null) {
                        return $this->error('not_found', 'resource not found', 404);
                    }
                    return ['data' => $obj];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/caldav/object/(?P<resource>[^/]+\.ics)',
            [
                'methods' => 'PUT',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $resource = urldecode((string) $request->get_param('resource'));
                    $payload = $this->jsonPayload($request);
                    if ($payload === null || !isset($payload['ics']) || !is_string($payload['ics'])) {
                        return $this->error('validation_error', 'ics payload is required', 422);
                    }

                    $ifMatch = $this->requestHeader($request, 'if-match');
                    $ifNoneMatch = $this->requestHeader($request, 'if-none-match');
                    $result = $this->calDavService->putObject(
                        $resource,
                        $payload['ics'],
                        $ifMatch !== '' ? $ifMatch : null,
                        $ifNoneMatch !== '' ? $ifNoneMatch : null,
                        (int) (($this->resolveCalDavUserForRequest($request)['id'] ?? 0))
                    );
                    if (isset($result['error'])) {
                        $error = (array) $result['error'];
                        return $this->error(
                            (string) ($error['code'] ?? 'caldav_error'),
                            (string) ($error['message'] ?? 'caldav operation failed'),
                            (int) ($error['status'] ?? 500)
                        );
                    }

                    return ['data' => $result];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/caldav/object/(?P<resource>[^/]+\.ics)',
            [
                'methods' => 'DELETE',
                'permission_callback' => $canWrite,
                'callback' => function ($request): array|\WP_Error {
                    $resource = urldecode((string) $request->get_param('resource'));
                    $result = $this->calDavService->deleteObject($resource);
                    if (isset($result['error'])) {
                        $error = (array) $result['error'];
                        return $this->error(
                            (string) ($error['code'] ?? 'caldav_error'),
                            (string) ($error['message'] ?? 'caldav operation failed'),
                            (int) ($error['status'] ?? 500)
                        );
                    }
                    return ['data' => $result];
                },
            ]
        );

        $this->http->registerRestRoute(
            self::API_NAMESPACE,
            '/caldav/multiget',
            [
                'methods' => 'POST',
                'permission_callback' => $canRead,
                'callback' => function ($request): array|\WP_Error {
                    $payload = $this->jsonPayload($request);
                    if ($payload === null || !isset($payload['resources']) || !is_array($payload['resources'])) {
                        return $this->error('validation_error', 'resources array is required', 422);
                    }
                    return ['data' => $this->calDavService->multiget($payload['resources'])];
                },
            ]
        );
    }

    private function serveIcsResponse(): void
    {
        $settings = $this->settingsService->getAll();
        $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
        $includePrivateDetails = $this->canWriteCalendar(null);
        if (
            $icsMode === 'authenticated_read'
            && !$includePrivateDetails
        ) {
            http_response_code(401);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['error' => ['code' => 'auth_required', 'message' => 'authentication required']]);
            return;
        }

        $calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
        $ics = $this->icsService->buildCalendar(
            $this->eventService->listEvents(),
            fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
            $calendarName,
            !$includePrivateDetails
        );
        $etag = '"' . substr(sha1($ics), 0, 16) . '"';
        $lastModified = gmdate('D, d M Y H:i:s') . ' GMT';

        http_response_code(200);
        header('Content-Type: text/calendar; charset=utf-8');
        header('ETag: ' . $etag);
        header('Last-Modified: ' . $lastModified);
        header('Cache-Control: public, max-age=120');
        echo $ics;
    }

    private function serveCalDavPath(string $path, string $method): void
    {
        $userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
        $caldavUser = $this->resolveCalDavUserForRequest(null);
        $this->caldavTrace('request', [
            'method' => $method,
            'path' => $path,
            'user_agent' => $userAgent,
            'authorized' => $caldavUser !== null,
        ]);
        if ($caldavUser === null) {
            http_response_code(401);
            header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
            header('Content-Type: application/xml; charset=utf-8');
            echo '<error><message>auth required</message></error>';
            $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 401, 'reason' => 'auth_required']);
            return;
        }

        $root = rtrim($this->caldavRootPath(), '/');
        $principalCollection = $root . '/principals/';
        $principal = $principalCollection . (int) ($caldavUser['id'] ?? 0) . '/';
        $calendarsRoot = $root . '/calendars/';
        $collection = $root . '/calendars/public/';
        $resourcePrefix = $collection;

        if ($method === 'HEAD') {
            if (
                $path === $root
                || $path === $root . '/'
                || $path === $principalCollection
                || $path === rtrim($principalCollection, '/')
                || $path === $principal
                || $path === rtrim($principal, '/')
                || $path === $calendarsRoot
                || $path === rtrim($calendarsRoot, '/')
                || $path === $collection
                || $path === rtrim($collection, '/')
            ) {
                header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
                header('DAV: 1, calendar-access');
                http_response_code(200);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_head']);
                return;
            }
            if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
                $resource = basename($path);
                $obj = $this->calDavService->getObject($resource);
                if ($obj === null) {
                    http_response_code(404);
                    $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource]);
                    return;
                }
                header('Content-Type: text/calendar; charset=utf-8');
                header('ETag: ' . (string) ($obj['etag'] ?? ''));
                http_response_code(200);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource]);
                return;
            }
        }

        if ($method === 'GET') {
            if (
                $path === $root
                || $path === $root . '/'
                || $path === $principalCollection
                || $path === rtrim($principalCollection, '/')
                || $path === $principal
                || $path === rtrim($principal, '/')
                || $path === $calendarsRoot
                || $path === rtrim($calendarsRoot, '/')
                || $path === $collection
                || $path === rtrim($collection, '/')
            ) {
                header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
                header('DAV: 1, calendar-access');
                http_response_code(200);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_get']);
                return;
            }
        }

        if ($method === 'OPTIONS') {
            header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
            header('DAV: 1, calendar-access');
            http_response_code(200);
            $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'options']);
            return;
        }

        if ($method === 'PROPFIND') {
            header('Content-Type: application/xml; charset=utf-8');
            http_response_code(207);
            if ($path === $root || $path === $root . '/') {
                echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_root']);
                return;
            }
            if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
                echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal_collection']);
                return;
            }
            if ($path === $principal || $path === rtrim($principal, '/')) {
                echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal']);
                return;
            }
            if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
                echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_calendars_root']);
                return;
            }
            if ($path === rtrim($collection, '/')) {
                $path = $collection;
            }
            if ($path === $collection) {
                echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_collection']);
                return;
            }
            if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
                $resource = urldecode(basename($path));
                $obj = $this->calDavService->getObject($resource);
                if ($obj === null) {
                    http_response_code(404);
                    echo '<error><message>not found</message></error>';
                    $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'propfind_object']);
                    return;
                }
                echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'resource' => $resource, 'kind' => 'propfind_object']);
                return;
            }
            http_response_code(404);
            echo '<error><message>not found</message></error>';
            $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'kind' => 'propfind_not_found']);
            return;
        }

        if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
            $body = (string) file_get_contents('php://input');
            $reportType = $this->caldavReportType($body);
            header('Content-Type: application/xml; charset=utf-8');
            http_response_code(207);
            echo $this->caldavReportXml($collection, $body, $this->caldavSyncToken());
            $this->caldavTrace('response', [
                'method' => $method,
                'path' => $path,
                'status' => 207,
                'kind' => 'report',
                'report_type' => $reportType,
                'body_bytes' => strlen($body),
            ]);
            return;
        }

        if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
            $resource = urldecode(basename($path));
            if ($method === 'GET') {
                $obj = $this->calDavService->getObject($resource);
                if ($obj === null) {
                    http_response_code(404);
                    $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'object_get']);
                    return;
                }
                header('Content-Type: text/calendar; charset=utf-8');
                header('ETag: ' . (string) ($obj['etag'] ?? ''));
                http_response_code(200);
                echo (string) ($obj['ics'] ?? '');
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource, 'kind' => 'object_get']);
                return;
            }
            if ($method === 'PUT') {
                $raw = (string) file_get_contents('php://input');
                $ifMatch = trim((string) ($_SERVER['HTTP_IF_MATCH'] ?? ''));
                $ifNoneMatch = trim((string) ($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
                $result = $this->calDavService->putObject(
                    $resource,
                    $raw,
                    $ifMatch !== '' ? $ifMatch : null,
                    $ifNoneMatch !== '' ? $ifNoneMatch : null,
                    (int) ($caldavUser['id'] ?? 0)
                );
                if (isset($result['error'])) {
                    $error = (array) $result['error'];
                    http_response_code((int) ($error['status'] ?? 500));
                    header('Content-Type: application/xml; charset=utf-8');
                    echo '<error><message>' . esc_html((string) ($error['message'] ?? 'error')) . '</message></error>';
                    $this->caldavTrace('response', [
                        'method' => $method,
                        'path' => $path,
                        'status' => (int) ($error['status'] ?? 500),
                        'resource' => $resource,
                        'kind' => 'object_put',
                        'body_bytes' => strlen($raw),
                    ]);
                    return;
                }
                $status = (int) ($result['status'] ?? 204);
                $event = (array) ($result['event'] ?? []);
                if (!empty($event['etag'])) {
                    header('ETag: ' . (string) $event['etag']);
                }
                http_response_code($status);
                $this->caldavTrace('response', [
                    'method' => $method,
                    'path' => $path,
                    'status' => $status,
                    'resource' => $resource,
                    'kind' => 'object_put',
                    'body_bytes' => strlen($raw),
                ]);
                return;
            }
            if ($method === 'DELETE') {
                $result = $this->calDavService->deleteObject($resource);
                if (isset($result['error'])) {
                    $error = (array) $result['error'];
                    http_response_code((int) ($error['status'] ?? 500));
                    $this->caldavTrace('response', [
                        'method' => $method,
                        'path' => $path,
                        'status' => (int) ($error['status'] ?? 500),
                        'resource' => $resource,
                        'kind' => 'object_delete',
                    ]);
                    return;
                }
                http_response_code(204);
                $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 204, 'resource' => $resource, 'kind' => 'object_delete']);
                return;
            }
        }

        http_response_code(405);
        header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
        $this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 405, 'reason' => 'method_not_allowed']);
    }

    private function caldavPropfindRootXml(string $root, string $principal, string $calendarsRoot, string $collection): string
    {
        return '<?xml version="1.0" encoding="utf-8"?>'
            . '<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
            . '<D:response><D:href>' . htmlspecialchars($root . '/', ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:collection/></D:resourcetype>'
            . '<D:displayname>CalDAV Root</D:displayname>'
            . '<D:current-user-principal><D:href>' . htmlspecialchars($principal, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href></D:current-user-principal>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '<D:response><D:href>' . htmlspecialchars($calendarsRoot, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:collection/></D:resourcetype>'
            . '<D:displayname>Calendar Home Set</D:displayname>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '<D:response><D:href>' . htmlspecialchars($collection, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>'
            . '<D:displayname>' . htmlspecialchars((string) $this->settingsService->get('caldav_calendar_name', 'Public Calendar'), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:displayname>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '</D:multistatus>';
    }

    private function caldavPropfindPrincipalCollectionXml(string $principalCollection, string $principal): string
    {
        return '<?xml version="1.0" encoding="utf-8"?>'
            . '<D:multistatus xmlns:D="DAV:">'
            . '<D:response><D:href>' . htmlspecialchars($principalCollection, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:collection/></D:resourcetype>'
            . '<D:displayname>Principals</D:displayname>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '<D:response><D:href>' . htmlspecialchars($principal, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:principal/></D:resourcetype>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '</D:multistatus>';
    }

    private function caldavPropfindPrincipalXml(string $principal, string $calendarsRoot): string
    {
        return '<?xml version="1.0" encoding="utf-8"?>'
            . '<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
            . '<D:response><D:href>' . htmlspecialchars($principal, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:principal/></D:resourcetype>'
            . '<D:principal-URL><D:href>' . htmlspecialchars($principal, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href></D:principal-URL>'
            . '<C:calendar-home-set><D:href>' . htmlspecialchars($calendarsRoot, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href></C:calendar-home-set>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '</D:multistatus>';
    }

    private function caldavPropfindCalendarsRootXml(string $calendarsRoot, string $collection): string
    {
        return '<?xml version="1.0" encoding="utf-8"?>'
            . '<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">'
            . '<D:response><D:href>' . htmlspecialchars($calendarsRoot, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:collection/></D:resourcetype>'
            . '<D:displayname>Calendar Home Set</D:displayname>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '<D:response><D:href>' . htmlspecialchars($collection, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>'
            . '<D:displayname>' . htmlspecialchars((string) $this->settingsService->get('caldav_calendar_name', 'Public Calendar'), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:displayname>'
            . '<CS:getctag>' . htmlspecialchars(sha1($this->caldavSyncToken()), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</CS:getctag>'
            . '<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>'
            . $this->caldavSupportedReportSetXml()
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '</D:multistatus>';
    }

    private function caldavPropfindCollectionXml(string $collection, string $syncToken, bool $includeMembers = false): string
    {
        $xml = '<?xml version="1.0" encoding="utf-8"?>'
            . '<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">'
            . '<D:response><D:href>' . htmlspecialchars($collection, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>'
            . '<D:displayname>' . htmlspecialchars((string) $this->settingsService->get('caldav_calendar_name', 'Public Calendar'), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:displayname>'
            . '<D:getctag>' . htmlspecialchars(sha1($syncToken), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getctag>'
            . '<CS:getctag>' . htmlspecialchars(sha1($syncToken), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</CS:getctag>'
            . '<D:sync-token>' . htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:sync-token>'
            . '<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>'
            . $this->caldavSupportedReportSetXml()
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>';

        if ($includeMembers) {
            $resources = $this->calDavService->listResources();
            foreach ($resources as $r) {
                $resource = (string) ($r['resource'] ?? '');
                if ($resource === '') {
                    continue;
                }
                $etag = (string) ($r['etag'] ?? '');
                $href = $collection . $resource;
                $xml .= '<D:response><D:href>' . htmlspecialchars($href, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
                    . '<D:resourcetype/>'
                    . '<D:getetag>' . htmlspecialchars($etag, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
                    . '<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>'
                    . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>';
            }
        }

        return $xml . '</D:multistatus>';
    }

    private function caldavPropfindObjectXml(string $href, string $etag): string
    {
        return '<?xml version="1.0" encoding="utf-8"?>'
            . '<D:multistatus xmlns:D="DAV:">'
            . '<D:response><D:href>' . htmlspecialchars($href, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>'
            . '<D:resourcetype/>'
            . '<D:getetag>' . htmlspecialchars($etag, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
            . '<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>'
            . '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>'
            . '</D:multistatus>';
    }

    private function caldavSupportedReportSetXml(): string
    {
        return '<D:supported-report-set>'
            . '<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>'
            . '<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>'
            . '<D:supported-report><D:report><D:sync-collection/></D:report></D:supported-report>'
            . '</D:supported-report-set>';
    }

    private function caldavReportXml(string $collection, string $xmlBody, string $syncToken): string
    {
        $bodyLower = strtolower($xmlBody);
        $resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
        $items = [];
        $reportType = $this->caldavReportType($xmlBody);
        $includeDeleted = false;
        $deletedCount = 0;
        $clientSyncToken = '';

        if (str_contains($bodyLower, 'sync-collection')) {
            $clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
            // If client token is already current, no changes should be emitted.
            if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) {
                $items = [];
            } else {
                // Emit current objects for initial/out-of-date tokens.
                $items = $this->calDavService->multiget($resources);
                // For incremental syncs, include a bounded set of deleted hrefs so clients can remove local copies.
                if ($clientSyncToken !== '') {
                    $includeDeleted = true;
                    // Keep incremental deletes bounded to avoid overwhelming strict clients.
                    $deletedResources = $this->calDavService->listDeletedResources(20);
                    $deletedCount = count($deletedResources);
                    foreach ($deletedResources as $deletedResource) {
                        $items[] = ['resource' => $deletedResource, 'status' => 404];
                    }
                }
            }
        } elseif (str_contains($bodyLower, 'calendar-query')) {
            $items = $this->calDavService->multiget($resources);
            if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
                $start = $this->icalToIso($s[1]);
                $end = $this->icalToIso($e[1]);
                $items = array_values(array_filter($items, function (array $it) use ($start, $end): bool {
                    if ((int) ($it['status'] ?? 404) !== 200) {
                        return false;
                    }
                    $ics = (string) ($it['ics'] ?? '');
                    $dt = $this->extractFirstDtStartIso($ics);
                    if ($dt === null) {
                        return false;
                    }
                    return $dt >= $start && $dt <= $end;
                }));
            }
        } else {
            preg_match_all('#<[^>]*href[^>]*>([^<]+)</[^>]*href>#i', $xmlBody, $matches);
            $requested = array_values(array_filter(array_map(static function (string $href): string {
                $trimmed = trim($href);
                $path = parse_url($trimmed, PHP_URL_PATH);
                $candidate = is_string($path) && $path !== '' ? $path : $trimmed;
                return urldecode(basename($candidate));
            }, (array) ($matches[1] ?? []))));
            $items = $requested ? $this->calDavService->multiget($requested) : $this->calDavService->multiget($resources);
        }

        $responses = '';
        $okCount = 0;
        $notFoundCount = 0;
        foreach ($items as $item) {
            $status = (int) ($item['status'] ?? 404);
            $resource = (string) ($item['resource'] ?? '');
            $responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href>';
            if ($status === 200) {
                $okCount++;
                $responses .= '<D:propstat><D:prop>';
                $responses .= '<D:getetag>' . htmlspecialchars((string) ($item['etag'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
                    . '<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">' . htmlspecialchars((string) ($item['ics'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</C:calendar-data>';
                $responses .= '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>';
            } else {
                $notFoundCount++;
                // For sync-collection deletions, clients expect bare status (no propstat block).
                if ($reportType === 'sync-collection') {
                    $responses .= '<D:status>HTTP/1.1 404 Not Found</D:status>';
                } else {
                    $responses .= '<D:propstat><D:prop></D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>';
                }
            }
            $responses .= '</D:response>';
        }
        $this->caldavTrace('report', [
            'report_type' => $reportType,
            'client_sync_token_present' => $clientSyncToken !== '',
            'client_sync_token_matches' => $clientSyncToken !== '' && $clientSyncToken === $syncToken,
            'include_deleted' => $includeDeleted,
            'deleted_included_count' => $deletedCount,
            'response_ok_count' => $okCount,
            'response_not_found_count' => $notFoundCount,
        ]);
        return '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:"><D:sync-token>'
            . htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
            . '</D:sync-token>'
            . $responses
            . '</D:multistatus>';
    }

    private function caldavSyncToken(): string
    {
        $rows = $this->calDavService->listResources();
        $seed = '';
        foreach ($rows as $row) {
            $seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
        }
        foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) {
            $seed .= 'deleted:' . $deletedResource . ';';
        }
        return 'urn:calendar-plugin:sync:' . sha1($seed);
    }

    private function icalToIso(string $value): string
    {
        $value = trim($value);
        if (preg_match('/^\\d{8}T\\d{6}Z$/', $value)) {
            $dt = \DateTimeImmutable::createFromFormat('Ymd\\THis\\Z', $value, new \DateTimeZone('UTC'));
            if ($dt instanceof \DateTimeImmutable) {
                return $dt->setTimezone(new \DateTimeZone('Europe/London'))->format('c');
            }
        }
        if (preg_match('/^\\d{8}T\\d{6}$/', $value)) {
            $dt = \DateTimeImmutable::createFromFormat('Ymd\\THis', $value, new \DateTimeZone('Europe/London'));
            if ($dt instanceof \DateTimeImmutable) {
                return $dt->format('c');
            }
        }
        return '1970-01-01T00:00:00+00:00';
    }

    private function extractFirstDtStartIso(string $ics): ?string
    {
        if (!preg_match('/^DTSTART(?:;[^:]+)?:([0-9TzZ]+)$/mi', $ics, $m)) {
            return null;
        }
        return $this->icalToIso((string) $m[1]);
    }

    private function extractSyncCollectionToken(string $xmlBody): string
    {
        if (!preg_match('/<[^>]*sync-token[^>]*>(.*?)<\\/[^>]*sync-token>/is', $xmlBody, $m)) {
            return '';
        }
        $token = trim(html_entity_decode((string) $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
        return $token;
    }

    private function caldavReportType(string $xmlBody): string
    {
        $bodyLower = strtolower($xmlBody);
        if (str_contains($bodyLower, 'sync-collection')) {
            return 'sync-collection';
        }
        if (str_contains($bodyLower, 'calendar-query')) {
            return 'calendar-query';
        }
        if (str_contains($bodyLower, 'calendar-multiget')) {
            return 'calendar-multiget';
        }
        return 'other';
    }

    private function caldavTrace(string $event, array $context = []): void
    {
        if (!$this->isDiagnosticsEnabled()) {
            return;
        }
        $payload = [
            'event' => $event,
            'at' => gmdate('c'),
            'context' => $context,
        ];
        error_log('[calendar-plugin][caldav] ' . wp_json_encode($payload));
    }

    private function unwrapServiceResult(array $result): array|\WP_Error
    {
        if (isset($result['error']) && is_array($result['error'])) {
            $error = (array) $result['error'];
            return $this->error(
                (string) ($error['code'] ?? 'service_error'),
                (string) ($error['message'] ?? 'request failed'),
                (int) ($error['status'] ?? 500)
            );
        }
        return ['data' => $result];
    }

    private function collectDiagnosticsSnapshot(bool $enabled): array
    {
        $diag = [
            'diagnostics_enabled' => $enabled,
            'generated_at_utc' => gmdate('c'),
            'db_prefix' => $this->db->getPrefix(),
            'table_stem' => $this->tableStem,
            'ics_path' => $this->icsPath(),
            'caldav_root' => $this->caldavRootPath(),
            'current_user_id' => $this->auth->currentUserId(),
            'current_user_email' => $this->auth->currentUserEmail(),
        ];
        if (!$enabled) {
            $diag['note'] = 'Diagnostics collection is disabled in Setup.';
            return $diag;
        }

        $auditTable = $this->db->getPrefix() . trim($this->tableStem, '_') . '_audit_log';
        $sql = $this->db->prepare(
            "SELECT actor, action, target, result, created_at, context_json FROM {$auditTable} ORDER BY id DESC LIMIT %d",
            200
        );
        $rows = $this->db->getResults($sql);
        $diag['audit_log_recent'] = array_map(
            static function (object $row): array {
                return [
                    'actor' => (string) ($row->actor ?? ''),
                    'action' => (string) ($row->action ?? ''),
                    'target' => (string) ($row->target ?? ''),
                    'result' => (string) ($row->result ?? ''),
                    'created_at' => (string) ($row->created_at ?? ''),
                    'context_json' => (string) ($row->context_json ?? ''),
                ];
            },
            $rows
        );
        $diag['audit_log_count'] = count($diag['audit_log_recent']);
        return $diag;
    }

    private function updatePluginTablePrefix(string $currentStem, string $requestedPrefix): array
    {
        $dbPrefix = (string) $this->db->getPrefix();
        if (!str_starts_with($requestedPrefix, $dbPrefix)) {
            return ['ok' => false, 'message' => 'Table prefix must start with WordPress DB prefix: ' . $dbPrefix];
        }
        $newStem = trim(substr($requestedPrefix, strlen($dbPrefix)), '_');
        if ($newStem === '') {
            return ['ok' => false, 'message' => 'Invalid table prefix.'];
        }
        if ($newStem === $currentStem) {
            return ['ok' => true, 'stem' => $currentStem];
        }

        $suffixes = ['events', 'recurrence_exceptions', 'users', 'user_tokens', 'audit_log'];
        foreach ($suffixes as $suffix) {
            $from = $dbPrefix . $currentStem . '_' . $suffix;
            $to = $dbPrefix . $newStem . '_' . $suffix;
            $existsFrom = $this->db->getResults($this->db->prepare('SHOW TABLES LIKE %s', $from));
            if (count($existsFrom) === 0) {
                continue;
            }
            $existsTo = $this->db->getResults($this->db->prepare('SHOW TABLES LIKE %s', $to));
            if (count($existsTo) > 0) {
                return ['ok' => false, 'message' => 'Target table already exists: ' . $to];
            }
            $this->db->query(sprintf('RENAME TABLE `%s` TO `%s`', $from, $to));
        }
        update_option('calendar_plugin_table_stem', $newStem);
        return ['ok' => true, 'stem' => $newStem];
    }

    private function adminPageShell(string $title, string $bodyHtml): string
    {
        $nav = '<div style="width:220px;"><h3 style="margin-top:0;">Calendar Plugin</h3><ul>'
            . '<li><a href="' . esc_html((string) admin_url('admin.php?page=calendar-plugin-setup')) . '">Setup</a></li>'
            . '<li><a href="' . esc_html((string) admin_url('admin.php?page=calendar-plugin-users')) . '">Users</a></li>'
            . '<li><a href="' . esc_html((string) admin_url('admin.php?page=calendar-plugin-diagnostics')) . '">Diagnostics</a></li>'
            . '</ul></div>';
        return '<div class="wrap"><h1>' . esc_html($title) . '</h1>'
            . '<div style="display:flex;gap:24px;align-items:flex-start;">' . $nav
            . '<div style="flex:1;">' . $bodyHtml . '</div></div></div>';
    }

    private function slugPrefix(): string
    {
        $slug = trim((string) ($this->settingsService->get('url_slug', '') ?? ''), '/');
        return $slug === '' ? '' : ('/' . $slug);
    }

    private function icsPath(): string
    {
        return $this->slugPrefix() . '/calendar.ics';
    }

    private function caldavRootPath(): string
    {
        return $this->slugPrefix() . '/caldav/';
    }

    private function canWriteCalendar(mixed $request = null): bool
    {
        if ($this->auth->currentUserCan('edit_posts')) {
            return true;
        }
        return $this->resolveCalDavUserForRequest($request) !== null;
    }

    private static function resolveTableStem(): string
    {
        $stem = 'cs_calendar';
        $saved = '';
        if (function_exists('get_option')) {
            $saved = (string) get_option('calendar_plugin_table_stem', '');
            if ($saved !== '') {
                $stem = $saved;
            }
        }
        if (defined('CALENDAR_PLUGIN_TABLE_STEM')) {
            $stem = (string) CALENDAR_PLUGIN_TABLE_STEM;
        }
        if (function_exists('apply_filters')) {
            /** @var mixed $filtered */
            $filtered = apply_filters('calendar_plugin_table_stem', $stem);
            if (is_string($filtered)) {
                $stem = $filtered;
            }
        }
        $stem = strtolower(trim($stem));
        $stem = preg_replace('/[^a-z0-9_]/', '', $stem) ?: 'calendar';
        $stem = trim($stem, '_') ?: 'calendar';
        if ($saved === '') {
            $detected = self::detectExistingStem();
            if ($detected !== '') {
                return $detected;
            }
        }
        return $stem;
    }

    private static function detectExistingStem(): string
    {
        global $wpdb;
        if (!isset($wpdb) || !is_object($wpdb) || !property_exists($wpdb, 'prefix')) {
            return '';
        }
        $prefix = (string) $wpdb->prefix;
        $candidates = ['cs_calendar', 'calendar'];
        foreach ($candidates as $candidate) {
            $table = $prefix . $candidate . '_events';
            $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
            if (is_string($exists) && $exists !== '') {
                return $candidate;
            }
        }
        return '';
    }

    private function resolveCalDavUserForRequest(mixed $request = null): ?array
    {
        if ($this->auth->currentUserId() > 0) {
            return [
                'id' => $this->auth->currentUserId(),
                'email' => $this->auth->currentUserEmail(),
                'source' => 'wp',
            ];
        }

        $sessionToken = $this->readCalendarSessionTokenFromRequest($request);
        if ($sessionToken !== '') {
            $sessionUser = $this->userService->authenticateSessionToken($sessionToken);
            if ($sessionUser !== null) {
                $sessionUser['source'] = 'calendar_session';
                return $sessionUser;
            }
        }

        [$email, $password] = $this->readBasicAuthCredentialsFromRequest($request);
        if ($email === '' || $password === '') {
            return null;
        }
        $user = $this->userService->authenticateActiveUserCredentials($email, $password);
        if (!$user) {
            return null;
        }
        $user['source'] = 'calendar_user';
        return $user;
    }

    private function readBasicAuthCredentialsFromRequest(mixed $request = null): array
    {
        $header = '';
        if (is_object($request) && method_exists($request, 'get_header')) {
            $header = (string) $request->get_header('authorization');
        }
        if ($header === '') {
            $header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? '');
        }
        if ($header === '' && isset($_SERVER['PHP_AUTH_USER'])) {
            return [(string) ($_SERVER['PHP_AUTH_USER'] ?? ''), (string) ($_SERVER['PHP_AUTH_PW'] ?? '')];
        }
        if ($header === '' || !str_starts_with(strtolower($header), 'basic ')) {
            return ['', ''];
        }
        $decoded = base64_decode(substr($header, 6), true);
        if (!is_string($decoded) || !str_contains($decoded, ':')) {
            return ['', ''];
        }
        [$u, $p] = explode(':', $decoded, 2);
        return [(string) $u, (string) $p];
    }

    private function readCalendarSessionTokenFromRequest(mixed $request = null): string
    {
        $name = self::SESSION_COOKIE;
        if (isset($_COOKIE[$name]) && is_string($_COOKIE[$name])) {
            return trim((string) $_COOKIE[$name]);
        }

        $cookieHeader = '';
        if (is_object($request) && method_exists($request, 'get_header')) {
            $cookieHeader = (string) $request->get_header('cookie');
        }
        if ($cookieHeader === '') {
            $cookieHeader = (string) ($_SERVER['HTTP_COOKIE'] ?? '');
        }
        if ($cookieHeader === '') {
            return '';
        }

        foreach (explode(';', $cookieHeader) as $pair) {
            $parts = explode('=', trim($pair), 2);
            if (count($parts) !== 2) {
                continue;
            }
            if (trim((string) $parts[0]) !== $name) {
                continue;
            }
            return trim((string) $parts[1]);
        }

        return '';
    }

    private function setCalendarSessionCookie(string $token): void
    {
        if ($token === '') {
            return;
        }
        $secure = $this->isHttpsRequest();
        setcookie(self::SESSION_COOKIE, $token, [
            'expires' => time() + self::SESSION_TTL_SECONDS,
            'path' => '/',
            'secure' => $secure,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    }

    private function clearCalendarSessionCookie(): void
    {
        $secure = $this->isHttpsRequest();
        setcookie(self::SESSION_COOKIE, '', [
            'expires' => time() - 3600,
            'path' => '/',
            'secure' => $secure,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    }

    private function isHttpsRequest(): bool
    {
        if (!empty($_SERVER['HTTPS']) && (string) $_SERVER['HTTPS'] !== 'off') {
            return true;
        }
        $forwardedProto = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
        if ($forwardedProto !== '') {
            foreach (explode(',', $forwardedProto) as $proto) {
                if (trim($proto) === 'https') {
                    return true;
                }
            }
        }
        if (function_exists('home_url')) {
            $home = (string) home_url('/');
            if (str_starts_with(strtolower($home), 'https://')) {
                return true;
            }
        }
        return false;
    }

    private function allowDebugTokens(): bool
    {
        return defined('CALENDAR_PLUGIN_ALLOW_DEBUG_TOKENS') && CALENDAR_PLUGIN_ALLOW_DEBUG_TOKENS === true;
    }

    private function sendUserEmailVerification(string $email, string $token): void
    {
        if (!function_exists('wp_mail')) {
            return;
        }
        $verifyUrl = (string) home_url($this->verificationPagePath() . '?calendar_verify_token=' . rawurlencode($token));
        @wp_mail(
            $email,
            'Verify your calendar account',
            "Please verify your calendar account using this link:\n\n{$verifyUrl}\n\nVerification token (for copy/paste):\n{$token}\n"
        );
    }

    private function verificationPagePath(): string
    {
        $path = trim((string) ($this->settingsService->get('verification_page_path', '/calendar') ?? '/calendar'));
        if ($path === '') {
            $path = '/calendar';
        }
        if (!str_starts_with($path, '/')) {
            $path = '/' . $path;
        }
        return '/' . trim($path, '/');
    }

    private function sendPasswordResetEmail(string $email, string $token): void
    {
        if (!function_exists('wp_mail')) {
            return;
        }
        $resetUrl = (string) home_url('/?calendar_reset_token=' . rawurlencode($token));
        @wp_mail(
            $email,
            'Calendar password reset',
            "A password reset was requested for your calendar account.\n\nReset link:\n{$resetUrl}\n"
        );
    }

    private function sendAdminApprovalRequestEmail(string $email): void
    {
        if (!function_exists('wp_mail') || !function_exists('get_option')) {
            return;
        }
        $adminEmail = (string) get_option('admin_email', '');
        if ($adminEmail === '') {
            return;
        }
        $url = (string) admin_url('admin.php?page=calendar-plugin-users');
        @wp_mail(
            $adminEmail,
            'Calendar user pending approval',
            "A new user is pending approval: {$email}\nReview: {$url}\n"
        );
    }

    private function jsonPayload($request): ?array
    {
        if (!is_object($request) || !method_exists($request, 'get_json_params')) {
            return null;
        }
        $payload = $request->get_json_params();
        return is_array($payload) ? $payload : [];
    }

    private function requestHeader(object $request, string $name): string
    {
        if (method_exists($request, 'get_header')) {
            $value = $request->get_header($name);
            return is_string($value) ? trim($value) : '';
        }
        return '';
    }

    private function error(string $code, string $message, int $status): \WP_Error
    {
        return new \WP_Error($code, $message, ['status' => $status]);
    }
}
