/**
 * Calendar sidebar — Thunderbird Lightning "Events and Tasks" pane.
 *
 * Right-docked vertical panel showing:
 *   - Day-grouped upcoming events (Today / Tomorrow / Friday Apr 24 / …)
 *   - Tasks list (no due date — just a checkable to-do list)
 *
 * Toggled by the View menu's "Calendar sidebar" checkbox. Reads from the
 * primary account's Google Calendar / Tasks via the existing OAuth token.
 * Sidebar and the full-screen calendar modal (calendar.ts) read the SAME
 * underlying data — two views onto one source.
 *
 * All storage goes through the service-side two-way cache (calendar_events
 * and tasks tables); this file does not use localStorage for data.
 */

import {
    getPrimaryAccount,
    getCalendarEvents, getCalendars, createCalendarEvent,
    getTasks, createTask, updateTask, deleteTask,
    reauthGoogleScopes,
    getSettings, saveSettings,
} from "../lib/api-client.js";
import { showContextMenu } from "./context-menu.js";

const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
const SHOW_DONE_PREF = "mailx-task-show-done";
const HORIZON_DAYS_PREF = "mailx-cal-horizon-days";
const HORIZON_DEFAULT_DAYS = 30;

interface CalEvent {
    id: string;
    title: string;
    start: number;        // ms since epoch
    end: number;
    allDay?: boolean;
    location?: string;
    notes?: string;
    source?: "local" | "google";
    recurringEventId?: string | null;
    htmlLink?: string | null;
    isHoliday?: boolean;
    /** Google calendar id this row was fetched from. Distinguishes
     *  holiday sources (en.usa#holiday vs en.jewish#holiday) so the
     *  sidebar can color them differently. */
    calendarId?: string;
}

/** Cap holiday rows at this lookahead window regardless of the user's
 *  overall horizon. Holidays mostly clutter a list 2 weeks out; the
 *  user can always extend by opening Google Calendar directly.
 *
 *  Recurring events deliberately DON'T get this cap any more — a weekly
 *  meeting whose next occurrence is on day 16 would otherwise vanish
 *  from a 30-day-horizon view, which surprised users. Recurring follows
 *  the overall horizon. */
const HOLIDAY_HORIZON_MS = 14 * 86400_000;

let viewYear = new Date().getFullYear();
let viewMonth = new Date().getMonth();
let viewDay = new Date().getDate();
let lastEvents: CalEvent[] = [];
/** Overdue tasks (due before today, not completed) — pinned to the top of
 *  the calendar list. Cached so renderEvents() can be re-run on a calendar
 *  visibility toggle without re-fetching tasks. */
let lastOverdueTasks: Array<{ uuid: string; title: string; dueMs?: number }> = [];

// ── Per-calendar metadata ─────────────────────────────────────────────
// The user curates which calendars exist by selecting them in Google
// Calendar; mailx enumerates them via getCalendars() and renders one
// checkbox + icon per calendar. Holiday calendars are no longer hard-coded.

type CalKind = "personal" | "usHoliday" | "jewishHoliday" | "birthday" | "otherHoliday" | "other";

interface CalInfo {
    id: string;
    name: string;
    color: string;
    primary: boolean;
}

/** Selected Google calendars, by id. The primary calendar is also keyed
 *  under the literal "primary" so legacy/stale rows (whose `calendar_id`
 *  predates per-calendar tagging) still resolve. */
const calById = new Map<string, CalInfo>();
let calendarList: CalInfo[] = [];
/** Calendar IDs the user has hidden in mailx (still selected in Google —
 *  hidden is a display filter, not a fetch filter). Persisted in
 *  settings.calendar.hiddenCalendars. */
let hiddenCalendars = new Set<string>();

/** Classify a calendar by its Google id. Birthdays live on Google's
 *  auto `addressbook#contacts` calendar; holiday calendars carry
 *  `#holiday@group.v.calendar.google.com`. The personal calendar is the
 *  `primary` one. Everything else is a generic named calendar. */
function calendarKind(id: string, primary: boolean): CalKind {
    if (primary) return "personal";
    const c = (id || "").toLowerCase();
    if (c.includes("addressbook#contacts")) return "birthday";
    if (c.includes("#holiday@") || c.includes("holiday@group")) {
        if (c.includes("usa")) return "usHoliday";
        if (c.includes("judaism") || c.includes("jewish")) return "jewishHoliday";
        return "otherHoliday";
    }
    return "other";
}

/** Icon markup for a calendar: blue dot = personal, themed emoji for
 *  holiday/birthday calendars, monogram badge (first letter in a circle
 *  tinted with the Google calendar color) for any other named calendar. */
function calIconHtml(info: CalInfo): string {
    const kind = calendarKind(info.id, info.primary);
    const t = escapeHtml(info.name);
    if (kind === "personal")      return `<span class="cal-ico cal-ico-dot" title="${t}"></span>`;
    if (kind === "usHoliday")     return `<span class="cal-ico cal-ico-emoji" title="${t}">🇺🇸</span>`;
    if (kind === "jewishHoliday") return `<span class="cal-ico cal-ico-emoji" title="${t}">✡️</span>`;
    if (kind === "birthday")      return `<span class="cal-ico cal-ico-emoji" title="${t}">🎂</span>`;
    if (kind === "otherHoliday")  return `<span class="cal-ico cal-ico-emoji" title="${t}">✦</span>`;
    const letter = escapeHtml((info.name.trim()[0] || "?").toUpperCase());
    const color = info.color || "#7a7a7a";
    return `<span class="cal-ico cal-ico-mono" style="background:${escapeHtml(color)}" title="${t}">${letter}</span>`;
}

/** Resolve an event's `calendarId` to a CalInfo. Falls back to a
 *  synthesised entry (pattern-based kind, generic monogram) when the id
 *  isn't in the enumerated list — e.g. a stale row, or before
 *  getCalendars() has resolved. */
function calInfoFor(calendarId: string | undefined): CalInfo {
    const id = calendarId || "primary";
    const found = calById.get(id);
    if (found) return found;
    return { id, name: (id.split("@")[0] || id), color: "", primary: id === "primary" };
}

function isCalHidden(calendarId: string | undefined): boolean {
    return hiddenCalendars.has(calInfoFor(calendarId).id);
}

async function loadHiddenCalendars(): Promise<void> {
    try {
        const s: any = await getSettings();
        const arr = s?.calendar?.hiddenCalendars;
        hiddenCalendars = new Set(Array.isArray(arr) ? arr : []);
    } catch { hiddenCalendars = new Set(); }
}

async function saveHiddenCalendars(): Promise<void> {
    try {
        const s: any = await getSettings();
        s.calendar = { ...(s.calendar || {}), hiddenCalendars: [...hiddenCalendars] };
        await saveSettings(s);
    } catch (e: any) { console.error("[cal] save hiddenCalendars failed:", e); }
}

/** Pull the user's selected Google calendars and render the per-calendar
 *  checkbox list. Each row toggles `hiddenCalendars` (a display filter —
 *  instant, no Google round-trip, no re-fetch). Called on init and on
 *  manual refresh; cheap enough but not run on every nav click. */
async function renderCalendarList(): Promise<void> {
    const host = document.getElementById("cal-side-calendars");
    let list: CalInfo[] = [];
    try {
        list = await getCalendars() as CalInfo[];
    } catch { /* offline / no calendar scope — leave the list empty */ }
    calendarList = list;
    calById.clear();
    for (const c of list) {
        calById.set(c.id, c);
        if (c.primary) calById.set("primary", c);
    }
    if (host) {
        if (list.length === 0) {
            host.innerHTML = "";
        } else {
            // Personal first, then alphabetical.
            const sorted = [...list].sort((a, b) =>
                (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
            host.innerHTML = sorted.map(c => `<label class="cal-side-cal-row" title="${escapeHtml(c.name)}">
                <input type="checkbox" class="cal-side-cal-check" data-cal-id="${escapeHtml(c.id)}" ${hiddenCalendars.has(c.id) ? "" : "checked"}>
                ${calIconHtml(c)}
                <span class="cal-side-cal-name">${escapeHtml(c.name)}</span>
            </label>`).join("");
            host.querySelectorAll<HTMLInputElement>(".cal-side-cal-check").forEach(cb => {
                cb.addEventListener("change", async () => {
                    const id = cb.dataset.calId || "";
                    if (cb.checked) hiddenCalendars.delete(id);
                    else hiddenCalendars.add(id);
                    await saveHiddenCalendars();
                    renderEvents(lastEvents);   // re-filter instantly
                });
            });
        }
    }
    // Repaint events so rows pick up proper icons now the list is known.
    if (lastEvents.length > 0) renderEvents(lastEvents);
}

// Set of selected task UUIDs. Persists across renderTasks() calls so the
// user keeps their selection when a re-render happens (e.g., after a
// `tasksUpdated` event from the service). Clicking a row with no modifier
// replaces the set; ctrl/cmd-click toggles a single row; shift-click is
// not implemented yet (would need an "anchor" to compute the range).
const selectedTaskUuids = new Set<string>();

/** Read-only snapshot of selected task UUIDs — used by app.ts to route the
 *  global Delete key / trash-button click to tasks when any are selected. */
export function getSelectedTaskUuids(): string[] {
    return [...selectedTaskUuids];
}

/** Delete every selected task and clear the selection. Called from app.ts
 *  when the user hits Delete / Ctrl+D / the trash button while tasks are
 *  selected — same semantics as the per-row × button, just batch. */
export async function deleteSelectedTasks(): Promise<void> {
    const uuids = [...selectedTaskUuids];
    if (uuids.length === 0) return;
    selectedTaskUuids.clear();
    for (const uuid of uuids) {
        try { await deleteTask(uuid); } catch { /* ignore individual failures */ }
    }
    await renderTasks();
}

function getHorizonDays(): number {
    try {
        const v = localStorage.getItem(HORIZON_DAYS_PREF);
        const n = v ? parseInt(v, 10) : NaN;
        if (Number.isFinite(n) && n > 0 && n <= 365) return n;
    } catch { /* */ }
    return HORIZON_DEFAULT_DAYS;
}

function getShowRecurring(): boolean {
    try { return localStorage.getItem(SHOW_RECURRING_PREF) !== "false"; } catch { return true; }
}

/** Fetch events from the local two-way cache; service returns local rows
 *  immediately and kicks a background refresh from Google. Next render
 *  (view-nav or user action) picks up the refreshed rows. No localStorage
 *  — everything lives in the service-side DB so phone / desktop share
 *  the same events. */
async function fetchUpcoming(from: Date): Promise<CalEvent[]> {
    const horizon = from.getTime() + getHorizonDays() * 86400_000;
    const rows = await getCalendarEvents(from.getTime(), horizon);
    const showRecurring = getShowRecurring();
    const filtered = showRecurring ? rows : rows.filter((r: any) => !r.recurringEventId);
    return filtered.map((r: any) => ({
        id: r.uuid,
        title: r.title,
        start: r.startMs,
        end: r.endMs,
        allDay: !!r.allDay,
        location: r.location,
        notes: r.notes,
        source: r.providerId ? "google" : "local",
        recurringEventId: r.recurringEventId,
        htmlLink: r.htmlLink,
        isHoliday: !!r.isHoliday,
        calendarId: r.calendarId,
    }));
}

function formatDayHeader(d: Date, today: Date, tomorrow: Date): string {
    const sameDay = (a: Date, b: Date) =>
        a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
    const label = d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
    if (sameDay(d, today)) return `${label} (today)`;
    if (sameDay(d, tomorrow)) return `${label} (tomorrow)`;
    return label;
}

function formatTime(e: CalEvent): string {
    if (e.allDay) return "all day";
    return new Date(e.start).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
}

function escapeHtml(s: string): string {
    return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]!));
}

function renderHead(): void {
    const dateEl = document.getElementById("cal-side-date");
    if (!dateEl) return;
    const d = new Date(viewYear, viewMonth, viewDay);
    dateEl.innerHTML = `<strong>${d.getDate()}</strong> ${d.toLocaleDateString(undefined, { weekday: "short" })} <span class="cal-side-date-month">${d.toLocaleDateString(undefined, { month: "short", year: "numeric" })}</span>`;
}

/** Markup for one overdue-task row (pinned at the top of the calendar). */
function overdueTaskRowHtml(t: { uuid: string; title: string; dueMs?: number }): string {
    let dueLabel = "";
    if (t.dueMs) {
        const d = new Date(t.dueMs);
        const sameYear = d.getFullYear() === new Date().getFullYear();
        dueLabel = sameYear ? `${d.getMonth() + 1}/${d.getDate()}` : d.toISOString().slice(0, 10);
    }
    return `<div class="cal-side-task cal-side-task-overdue" data-uuid="${escapeHtml(t.uuid)}">
        <input type="checkbox" class="cal-side-overdue-check" title="Mark done">
        <span class="cal-side-task-title" title="${escapeHtml(t.title)}">${escapeHtml(t.title)}</span>
        <span class="cal-side-task-due overdue">${escapeHtml(dueLabel)}</span>
    </div>`;
}

function renderEvents(events: CalEvent[]): void {
    const body = document.getElementById("cal-side-body");
    if (!body) return;
    // Drop events from calendars the user has hidden in mailx.
    events = events.filter(e => !isCalHidden(e.calendarId));
    const overdue = lastOverdueTasks;
    if (events.length === 0 && overdue.length === 0) {
        body.innerHTML = `<div class="cal-side-empty">No upcoming events. Click + New event to add one.</div>`;
        return;
    }
    const today = new Date(); today.setHours(0, 0, 0, 0);
    const tomorrow = new Date(today.getTime() + 86400_000);

    // Daily-recurring detection: group expanded instances by recurringEventId,
    // pull out series that fire (roughly) every day at the same time of day,
    // and render them once at the top instead of cluttering each per-day
    // section. "Daily" = same recurringEventId + same H:MM + appears on at
    // least 80% of the days in the rendered horizon (allows for the
    // occasional skipped day without losing the grouping).
    const horizonDays = getHorizonDays();
    const dailyThreshold = Math.max(2, Math.floor(horizonDays * 0.8));
    const byRecurId = new Map<string, CalEvent[]>();
    for (const e of events) {
        if (!e.recurringEventId) continue;
        let arr = byRecurId.get(e.recurringEventId);
        if (!arr) { arr = []; byRecurId.set(e.recurringEventId, arr); }
        arr.push(e);
    }
    const dailyKeys = new Set<string>();
    const dailyHeads: CalEvent[] = [];
    for (const [recId, instances] of byRecurId) {
        if (instances.length < dailyThreshold) continue;
        // Same time-of-day across all instances?
        const hm = (e: CalEvent) => {
            if (e.allDay) return "all";
            const d = new Date(e.start);
            return `${d.getHours()}:${d.getMinutes()}`;
        };
        const firstHm = hm(instances[0]);
        const allSameTime = instances.every(e => hm(e) === firstHm);
        if (!allSameTime) continue;
        dailyKeys.add(recId);
        // Use the earliest instance as the "head" entry (its htmlLink is
        // identical across instances of the same series).
        dailyHeads.push(instances.reduce((a, b) => a.start < b.start ? a : b));
    }

    let html = "";

    // Overdue tasks pinned to the very top — a persistent "behind on these"
    // block. Marking one done (the checkbox) completes it in Google and it
    // drops off on the next render.
    if (overdue.length > 0) {
        html += `<div class="cal-side-day cal-side-day-overdue">Overdue tasks</div>`;
        for (const t of overdue) html += overdueTaskRowHtml(t);
    }

    if (dailyHeads.length > 0) {
        html += `<div class="cal-side-day cal-side-day-daily">Daily</div>`;
        for (const e of dailyHeads) {
            const link = e.htmlLink || "";
            html += `<div class="cal-side-event" data-id="${e.id}" data-link="${escapeHtml(link)}" ${link ? 'title="Click to open in Google Calendar"' : ""}>
                ${calIconHtml(calInfoFor(e.calendarId))}
                <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
                <span class="cal-side-event-title" title="${escapeHtml(e.title)}">${escapeHtml(e.title)}<span class="cal-side-event-recur" title="Daily">↻</span></span>
            </div>`;
        }
    }

    let lastDayKey = "";
    const nowMs = Date.now();
    const holidayCutoff = nowMs + HOLIDAY_HORIZON_MS;
    for (const e of events) {
        // Filter out daily-grouped instances — they're shown once at top.
        if (e.recurringEventId && dailyKeys.has(e.recurringEventId)) continue;
        const info = calInfoFor(e.calendarId);
        const kind = calendarKind(info.id, info.primary);
        const isHolidayKind = kind === "usHoliday" || kind === "jewishHoliday" || kind === "otherHoliday";
        // Holiday calendars get the 2-week cap — they otherwise clutter a
        // long horizon. Birthdays and recurring events follow the user's
        // overall horizon.
        if (isHolidayKind && e.start > holidayCutoff) continue;
        const d = new Date(e.start);
        const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
        if (dayKey !== lastDayKey) {
            html += `<div class="cal-side-day">${escapeHtml(formatDayHeader(d, today, tomorrow))}</div>`;
            lastDayKey = dayKey;
        }
        const recurMark = e.recurringEventId ? `<span class="cal-side-event-recur" title="Recurring event">↻</span>` : "";
        const link = e.htmlLink || "";
        const recurAttr = e.recurringEventId ? ' data-recurring="1"' : "";
        // Holiday + birthday calendars render as a single full-width row
        // (all-day, no useful clock time) led by the calendar's icon.
        // Regular events keep the icon/time/title columns and the
        // click-to-open-in-Google deep link.
        if (isHolidayKind || kind === "birthday") {
            html += `<div class="cal-side-event" data-holiday="1" data-holiday-kind="${kind}" data-id="${e.id}">
                <span class="cal-side-event-title cal-side-event-holiday-title" title="${escapeHtml(e.title)}">${calIconHtml(info)} ${escapeHtml(e.title)}</span>
            </div>`;
        } else {
            const titleAttr = link ? 'title="Click to open in Google Calendar"' : "";
            html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} data-link="${escapeHtml(link)}" ${titleAttr}>
                ${calIconHtml(info)}
                <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
                <span class="cal-side-event-title" title="${escapeHtml(e.title)}">${escapeHtml(e.title)}${recurMark}</span>
            </div>`;
        }
    }
    body.innerHTML = html;

    // Overdue-task checkboxes — mark the task completed in Google (non-
    // destructive; it survives in the Completed section) and re-render.
    body.querySelectorAll<HTMLElement>(".cal-side-task-overdue").forEach(row => {
        const uuid = row.dataset.uuid!;
        row.querySelector<HTMLInputElement>(".cal-side-overdue-check")?.addEventListener("change", async () => {
            await updateTask(uuid, { completedMs: Date.now() });
            lastOverdueTasks = lastOverdueTasks.filter(t => t.uuid !== uuid);
            renderEvents(lastEvents);
            renderTasks();
        });
    });

    // Click-to-open — interim per user 2026-04-23: route to Google Calendar's
    // web UI via openExternal until we build an in-app event editor.
    // Right-click gives a context menu with "View in browser" explicit.
    // Item 17: on Android, openCalendarEvent redirects to the native Calendar
    // app via ACTION_VIEW; desktop still falls through to the browser.
    const openInCalendar = (url: string): void => {
        const api = (window as any).mailxapi;
        if (api?.openCalendarEvent) { api.openCalendarEvent(url); return; }
        if (api?.openExternal) api.openExternal(url);
        else window.open(url, "_blank");
    };
    const openInBrowser = (url: string): void => {
        const api = (window as any).mailxapi;
        if (api?.openExternal) api.openExternal(url);
        else window.open(url, "_blank");
    };
    body.querySelectorAll<HTMLElement>(".cal-side-event").forEach(el => {
        el.addEventListener("click", () => {
            const link = el.dataset.link;
            if (link) openInCalendar(link);
        });
        el.addEventListener("contextmenu", (e) => {
            e.preventDefault();
            const link = el.dataset.link;
            const items = [];
            if (link) items.push({
                label: "View in browser",
                action: () => openInBrowser(link),
            });
            items.push({
                label: "Open Google Calendar",
                action: () => openInBrowser("https://calendar.google.com/"),
            });
            if (items.length > 0) showContextMenu(e.clientX, e.clientY, items);
        });
    });
}

async function renderTasks(prefetched?: any[]): Promise<void> {
    const cb = document.getElementById("cal-side-show-done") as HTMLInputElement | null;
    const showDone = cb?.checked ?? false;
    // Reuse the list refresh() already fetched (avoids a duplicate getTasks
    // IPC + Google pull); fall back to fetching when called standalone.
    const tasks = prefetched ?? await getTasks(showDone);
    const host = document.getElementById("cal-side-tasks");
    if (!host) return;
    if (tasks.length === 0) {
        host.innerHTML = `<div class="cal-side-empty">No tasks.</div>`;
        return;
    }
    let html = "<div class='cal-side-task-head'>Title</div>";
    const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
    for (const t of tasks) {
        const done = !!t.completedMs;
        // Due date column: short M/D if same year, else YYYY-MM-DD; overdue
        // (date < today, not completed) gets a distinct color via the
        // .overdue class. No due date → empty span keeps the grid aligned.
        let dueHtml = "";
        if (t.dueMs) {
            const d = new Date(t.dueMs);
            const sameYear = d.getFullYear() === startOfToday.getFullYear();
            const label = sameYear
                ? `${d.getMonth() + 1}/${d.getDate()}`
                : d.toISOString().slice(0, 10);
            const overdue = !done && d < startOfToday;
            dueHtml = `<span class="cal-side-task-due${overdue ? " overdue" : ""}" title="${d.toLocaleDateString()}">${label}</span>`;
        } else {
            dueHtml = `<span class="cal-side-task-due"></span>`;
        }
        const sel = selectedTaskUuids.has(t.uuid) ? " selected" : "";
        html += `<div class="cal-side-task${sel}" data-uuid="${t.uuid}">
            <input type="checkbox" ${done ? "checked" : ""} class="cal-side-task-check">
            <span class="cal-side-task-title${done ? " done" : ""}" title="${escapeHtml(t.title)}">${escapeHtml(t.title)}</span>
            ${dueHtml}
            <button class="cal-side-task-delete" title="Delete task" aria-label="Delete task">×</button>
        </div>`;
    }
    // Drop selection entries for tasks that no longer exist (deleted /
    // synced-out from another device) so the set doesn't grow unbounded.
    const liveUuids = new Set(tasks.map(t => t.uuid));
    for (const uuid of selectedTaskUuids) {
        if (!liveUuids.has(uuid)) selectedTaskUuids.delete(uuid);
    }
    host.innerHTML = html;
    const openInBrowser = (url: string): void => {
        const api = (window as any).mailxapi;
        if (api?.openExternal) api.openExternal(url);
        else window.open(url, "_blank");
    };
    host.querySelectorAll<HTMLElement>(".cal-side-task").forEach(row => {
        const uuid = row.dataset.uuid!;
        row.querySelector<HTMLInputElement>(".cal-side-task-check")?.addEventListener("change", async (e) => {
            const checked = (e.target as HTMLInputElement).checked;
            await updateTask(uuid, { completedMs: checked ? Date.now() : null });
            renderTasks();
        });
        row.querySelector<HTMLButtonElement>(".cal-side-task-delete")?.addEventListener("click", async (e) => {
            e.stopPropagation();    // don't also trigger the row's selection click
            await deleteTask(uuid);
            selectedTaskUuids.delete(uuid);
            renderTasks();
        });
        // Click on the row body selects it. Plain click replaces the set;
        // ctrl/cmd toggles. Clicks that originated on the checkbox or the
        // × button were stopped above, so they don't bubble to here.
        row.addEventListener("click", (e) => {
            const t = e.target as HTMLElement;
            if (t.closest(".cal-side-task-check") || t.closest(".cal-side-task-delete")) return;
            if (e.ctrlKey || e.metaKey) {
                if (selectedTaskUuids.has(uuid)) selectedTaskUuids.delete(uuid);
                else selectedTaskUuids.add(uuid);
            } else {
                selectedTaskUuids.clear();
                selectedTaskUuids.add(uuid);
            }
            // Re-render to update selected styling. Cheap (DOM is small).
            renderTasks();
        });
        row.addEventListener("contextmenu", (e) => {
            e.preventDefault();
            // Google Tasks doesn't have a per-task web URL; open the list view.
            showContextMenu(e.clientX, e.clientY, [
                { label: "View in Google Tasks", action: () => openInBrowser("https://tasks.google.com/") },
                { label: "", action: () => {}, separator: true },
                { label: "Delete task", action: async () => { await deleteTask(uuid); renderTasks(); } },
            ]);
        });
    });
}

async function refresh(): Promise<void> {
    renderHead();
    const from = new Date(viewYear, viewMonth, viewDay);
    // A throw here (IPC reject, daemon not ready) must NOT leave the
    // sidebar stuck on its initial "Loading…" placeholder — that was the
    // "calendar shows nothing" symptom. Render an explicit state either
    // way: events on success, an error line on failure.
    // Tasks are fetched ONCE here and handed to renderTasks() so the events
    // pane and the task pane don't each fire their own getTasks() — that
    // doubled the Google Tasks API call rate and helped trip the 429 storm.
    let prefetchedTasks: any[] | undefined;
    try {
        const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
        const showDone = (document.getElementById("cal-side-show-done") as HTMLInputElement | null)?.checked ?? false;
        const [events, tasks] = await Promise.all([
            fetchUpcoming(from),
            getTasks(showDone).catch(() => [] as any[]),
        ]);
        lastEvents = events;
        prefetchedTasks = tasks as any[];
        // Overdue tasks (due before today, not completed) pin to the top of
        // the calendar list.
        lastOverdueTasks = prefetchedTasks
            .filter(t => t.dueMs && !t.completedMs && t.dueMs < startOfToday.getTime())
            .map(t => ({ uuid: t.uuid, title: t.title, dueMs: t.dueMs }));
        renderEvents(lastEvents);
    } catch (e: any) {
        const body = document.getElementById("cal-side-body");
        if (body) body.innerHTML = `<div class="cal-side-empty cal-side-quota-error">Couldn't load calendar: ${escapeHtml(e?.message || String(e))}</div>`;
    }
    renderTasks(prefetchedTasks);
}

/** Open the natural-language new-event modal. The user types a free-form
 *  description ("Lunch with John tomorrow at noon at Joe's"); we send it
 *  to aiTransform with action=extractEvent, get back structured fields
 *  (title/start/end/location/notes), and open Google Calendar's
 *  create-event URL with those fields pre-filled. The user reviews / edits
 *  / saves in Google's UI — that path keeps mailx out of the
 *  event-editor business and gets all the niceties (recurrence, guests,
 *  conferencing) for free. If AI is disabled or extraction fails, the raw
 *  text becomes the title and Google's own quick-add parser takes over. */
function openNewEventDialog(): void {
    const backdrop = document.createElement("div");
    backdrop.className = "mailx-modal-backdrop";
    backdrop.style.cssText = "position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;";

    const panel = document.createElement("div");
    panel.style.cssText = "background:var(--color-bg, #fff);color:var(--color-text, #000);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);width:min(560px,90vw);max-height:80vh;display:flex;flex-direction:column;padding:18px;gap:10px;";

    const heading = document.createElement("div");
    heading.style.cssText = "font-weight:600;font-size:1.05em;";
    heading.textContent = "New event";

    const hint = document.createElement("div");
    hint.style.cssText = "font-size:0.9em;color:var(--color-text-muted);";
    hint.textContent = "Describe the event in plain English — AI extracts the details and opens Google Calendar to confirm.";

    const ta = document.createElement("textarea");
    ta.style.cssText = "width:100%;min-height:120px;font:inherit;padding:8px;border-radius:4px;border:1px solid var(--color-border, #ccc);box-sizing:border-box;resize:vertical;";
    ta.placeholder = "Lunch with John tomorrow at noon at Joe's Pizza";

    const status = document.createElement("div");
    status.style.cssText = "min-height:1.2em;font-size:0.85em;color:var(--color-text-muted);";

    const btnRow = document.createElement("div");
    btnRow.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";

    const cancelBtn = document.createElement("button");
    cancelBtn.textContent = "Cancel";
    cancelBtn.style.cssText = "padding:6px 14px;";

    const createBtn = document.createElement("button");
    createBtn.textContent = "Create";
    createBtn.style.cssText = "padding:6px 14px;font-weight:500;";

    btnRow.append(cancelBtn, createBtn);
    panel.append(heading, hint, ta, status, btnRow);
    backdrop.append(panel);
    document.body.append(backdrop);
    setTimeout(() => ta.focus(), 0);

    const close = () => backdrop.remove();
    cancelBtn.addEventListener("click", close);
    backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
    document.addEventListener("keydown", function escClose(e) {
        if (e.key === "Escape") { close(); document.removeEventListener("keydown", escClose); }
    });

    // Submit on Ctrl/Cmd+Enter as well as button click.
    ta.addEventListener("keydown", (e) => {
        if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); createBtn.click(); }
    });

    // AI failure does NOT silently fall back — the whole point of this
    // dialog is the AI parse (same interface as `gcal add -clip`). On
    // failure the dialog stays open showing WHY, and the button flips to an
    // explicit "Create as text" so raw-title mode is a user choice, never a
    // surprise ("danielle 12:30" was landing in Google Calendar as a text
    // title with no hint the AI leg had been skipped — Bob 2026-07-20).
    let asTextMode = false;
    createBtn.addEventListener("click", async () => {
        const text = ta.value.trim();
        if (!text) { ta.focus(); return; }
        createBtn.disabled = true;
        cancelBtn.disabled = true;
        status.textContent = "Parsing…";

        let events: any[] = [];
        if (!asTextMode) {
            try {
                const { aiTransform } = await import("../lib/api-client.js");
                const r = await aiTransform({
                    action: "extractEvent",
                    text,
                    nowISO: new Date().toISOString(),
                });
                events = r?.events || (r?.event ? [r.event] : []);
                if (events.length === 0) {
                    status.textContent = `AI parse failed${r?.reason ? `: ${r.reason}` : ""}.`;
                }
            } catch (e: any) {
                status.textContent = `AI call failed: ${e.message}`;
            }
            if (events.length === 0) {
                createBtn.textContent = "Create as text";
                createBtn.disabled = false;
                cancelBtn.disabled = false;
                asTextMode = true;
                return;
            }
        }

        // Build Google Calendar's create-event URL(s) — one per extracted
        // event (the text may describe several). Always opens in browser
        // for the user to confirm/edit/save. We never write to the local
        // store from this path — the round-trip back via Google Calendar
        // sync keeps state consistent and avoids duplicate events.
        const api = (window as any).mailxapi;
        const targets = events.length ? events : [null];
        for (const ev of targets) {
            const url = buildGoogleEventUrl(ev, text);
            if (api?.openExternal) api.openExternal(url);
            else window.open(url, "_blank");
        }
        close();
    });
}

/** Compose Google Calendar's "render?action=TEMPLATE" URL with extracted
 *  fields. When `event` is null we still produce a useful URL — the raw
 *  description goes into `text=` (becomes the title) and Google's UI lets
 *  the user fill in dates manually. Times are emitted in compact form
 *  without `Z` so Google interprets them in the user's calendar timezone. */
function buildGoogleEventUrl(event: any, fallbackText: string): string {
    const base = "https://calendar.google.com/calendar/render?action=TEMPLATE";
    const params: string[] = [];
    const fmt = (iso: string): string => {
        // ISO "2026-05-06T12:00:00" → "20260506T120000". Google accepts
        // local-naive times in this form.
        const m = iso.match(/(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/);
        if (!m) return "";
        const [, y, mo, d, hh, mm, ss] = m;
        const date = `${y}${mo}${d}`;
        if (!hh) return date; // all-day form: dates=YYYYMMDD/YYYYMMDD
        return `${date}T${hh}${mm}${ss || "00"}`;
    };

    if (event?.title) params.push(`text=${encodeURIComponent(event.title)}`);
    else params.push(`text=${encodeURIComponent(fallbackText.slice(0, 200))}`);

    if (event?.startISO) {
        const startFmt = fmt(event.startISO);
        // For all-day events Google wants `dates=YYYYMMDD/YYYYMMDD` where
        // the end date is exclusive (next day). For timed events it's
        // `YYYYMMDDTHHMMSS/YYYYMMDDTHHMMSS`. Default end = +1h timed,
        // +1 day all-day.
        if (event.allDay) {
            const startDate = new Date(event.startISO + (event.startISO.includes("T") ? "" : "T00:00:00"));
            const endDate = new Date(startDate.getTime() + 86400_000);
            const endFmt = `${endDate.getFullYear()}${String(endDate.getMonth() + 1).padStart(2, "0")}${String(endDate.getDate()).padStart(2, "0")}`;
            params.push(`dates=${startFmt}/${endFmt}`);
        } else {
            const endFmt = event.endISO
                ? fmt(event.endISO)
                : fmt(new Date(new Date(event.startISO).getTime() + 3600_000).toISOString().slice(0, 19));
            params.push(`dates=${startFmt}/${endFmt}`);
        }
    }
    if (event?.location) params.push(`location=${encodeURIComponent(event.location)}`);
    if (event?.notes) params.push(`details=${encodeURIComponent(event.notes)}`);
    // Times above are wall-clock in the event's own zone (an invite that says
    // "9am Kuala Lumpur" stays 9am); ctz tells Google which zone that is.
    if (event?.timeZone) params.push(`ctz=${encodeURIComponent(event.timeZone)}`);
    return `${base}&${params.join("&")}`;
}

/** Show the sidebar (called from the View menu toggle). Idempotent. */
export async function showCalendarSidebar(): Promise<void> {
    const el = document.getElementById("calendar-sidebar");
    if (!el) return;
    el.hidden = false;
    document.body.classList.add("calendar-sidebar-on");
    try { localStorage.setItem(SIDEBAR_PREF, "true"); } catch { /* */ }
    await loadHiddenCalendars();
    void renderCalendarList();   // async — repaints events when it resolves
    await refresh();
}

export function hideCalendarSidebar(): void {
    const el = document.getElementById("calendar-sidebar");
    if (!el) return;
    el.hidden = true;
    document.body.classList.remove("calendar-sidebar-on");
    try { localStorage.setItem(SIDEBAR_PREF, "false"); } catch { /* */ }
}

export function isCalendarSidebarOn(): boolean {
    // Default is ON — user-reported 2026-04-23 that the sidebar should be
    // visible by default, not hidden. An explicit "false" in localStorage
    // still hides it (the user's stored preference wins).
    try {
        const v = localStorage.getItem(SIDEBAR_PREF);
        return v === null ? true : v !== "false";
    } catch { return true; }
}

/** Wire one-time event handlers + restore from localStorage. Safe to call
 *  multiple times — handlers are idempotent because the elements are stable. */
export function initCalendarSidebar(): void {
    const wireOnce = (id: string, fn: () => void) => {
        const el = document.getElementById(id);
        if (!el || (el as any).__wired) return;
        (el as any).__wired = true;
        el.addEventListener("click", fn);
    };
    wireOnce("cal-side-prev", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
    wireOnce("cal-side-next", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
    wireOnce("cal-side-today", () => { const t = new Date(); viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate(); refresh(); });
    wireOnce("cal-side-new", () => { openNewEventDialog(); });
    wireOnce("cal-side-new-task", async () => {
        const title = prompt("Task title:");
        if (!title) return;
        await createTask({ title });
        renderTasks();
    });
    // Manual refresh — getTasks on the service side already fires a Google
    // pull under the hood on every call, but the visible feedback (spinning
    // glyph for ~600 ms, disabled while in flight) makes it explicit that
    // the user asked for a fresh pull, not a cached redraw.
    wireOnce("cal-side-refresh-tasks", async () => {
        const btn = document.getElementById("cal-side-refresh-tasks") as HTMLButtonElement | null;
        if (btn?.classList.contains("cal-side-refreshing")) return;
        btn?.classList.add("cal-side-refreshing");
        (btn as HTMLButtonElement).disabled = true;
        try {
            // renderTasks() calls getTasks() on the service which triggers
            // the async Google pull; the subsequent tasksUpdated event
            // re-renders with the merged result.
            await renderTasks();
        } finally {
            setTimeout(() => {
                btn?.classList.remove("cal-side-refreshing");
                if (btn) btn.disabled = false;
            }, 600);
        }
    });
    // Manual events refresh — same pattern as tasks. fetchUpcoming hits the
    // service which kicks a Google pull under the hood.
    wireOnce("cal-side-refresh-events", async () => {
        const btn = document.getElementById("cal-side-refresh-events") as HTMLButtonElement | null;
        if (btn?.classList.contains("cal-side-refreshing")) return;
        btn?.classList.add("cal-side-refreshing");
        if (btn) btn.disabled = true;
        try {
            // Also re-enumerate calendars — the user may have selected /
            // deselected one in Google since the sidebar opened.
            await Promise.all([renderCalendarList(), refresh()]);
        } finally {
            setTimeout(() => {
                btn?.classList.remove("cal-side-refreshing");
                if (btn) btn.disabled = false;
            }, 600);
        }
    });
    const showDoneCb = document.getElementById("cal-side-show-done") as HTMLInputElement | null;
    if (showDoneCb && !(showDoneCb as any).__wired) {
        (showDoneCb as any).__wired = true;
        // Sticky: restore prior state and persist on change. Default off.
        try { showDoneCb.checked = localStorage.getItem(SHOW_DONE_PREF) === "true"; } catch { /* */ }
        showDoneCb.addEventListener("change", () => {
            try { localStorage.setItem(SHOW_DONE_PREF, String(showDoneCb.checked)); } catch { /* */ }
            renderTasks();
        });
    }
    // Recurring-events filter toggle — hides expanded recurring-series
    // instances when unchecked. Default on so new users see everything.
    const recurCb = document.getElementById("cal-side-show-recurring") as HTMLInputElement | null;
    if (recurCb && !(recurCb as any).__wired) {
        (recurCb as any).__wired = true;
        recurCb.checked = getShowRecurring();
        recurCb.addEventListener("change", () => {
            try { localStorage.setItem(SHOW_RECURRING_PREF, String(recurCb.checked)); } catch { /* */ }
            refresh();
        });
    }
    // Per-calendar checkboxes are generated dynamically from the user's
    // selected Google calendars — see renderCalendarList(), driven from
    // showCalendarSidebar() + the manual refresh button. No hard-coded
    // holiday toggles any more.
    // Horizon input — how many days ahead to list events. Bounded 1..365.
    const horizonInput = document.getElementById("cal-side-horizon") as HTMLInputElement | null;
    if (horizonInput && !(horizonInput as any).__wired) {
        (horizonInput as any).__wired = true;
        horizonInput.value = String(getHorizonDays());
        const commit = () => {
            const n = parseInt(horizonInput.value, 10);
            const clamped = Number.isFinite(n) && n > 0 ? Math.min(365, Math.max(1, n)) : HORIZON_DEFAULT_DAYS;
            horizonInput.value = String(clamped);
            try { localStorage.setItem(HORIZON_DAYS_PREF, String(clamped)); } catch { /* */ }
            refresh();
        };
        horizonInput.addEventListener("change", commit);
        horizonInput.addEventListener("blur", commit);
    }
    // Subscribe to service events — when a background Google pull finishes
    // and upserted/reconciled rows, re-render without waiting for a nav
    // click. Uses the mailxapi.onEvent bus. Idempotent flag keeps multiple
    // initCalendarSidebar calls from dup-subscribing.
    if (!(window as any).__mailxCalEventsWired) {
        const api = (window as any).mailxapi;
        if (api?.onEvent) {
            (window as any).__mailxCalEventsWired = true;
            api.onEvent((event: any) => {
                if (event?.type === "calendarUpdated") refresh();
                else if (event?.type === "tasksUpdated") renderTasks();
                else if (event?.type === "quotaError") {
                    // Surface a non-clickable banner — daily quota will reset
                    // on its own, no user action helps. Idempotent so the
                    // banner doesn't flash on repeat poll attempts.
                    const host = event.feature === "tasks"
                        ? document.getElementById("cal-side-tasks")
                        : document.getElementById("cal-side-body");
                    if (host && !host.querySelector(".cal-side-quota-error")) {
                        const msg = event.message || `Google ${event.feature} quota exceeded — try again later.`;
                        host.innerHTML = `<div class="cal-side-empty cal-side-quota-error">${escapeHtml(msg)}</div>`;
                    }
                }
                else if (event?.type === "authScopeError") {
                    // Surface a visible hint right in the affected pane so the
                    // user doesn't stare at an empty list wondering why. Only
                    // writes to the matching pane; other panes keep rendering.
                    //
                    // Idempotent: if the banner is already shown for this
                    // feature (class `.cal-side-auth-error` present), don't
                    // re-write the DOM — stops the "flashing on and off
                    // continually" effect when the service re-emits on every
                    // 5-min poll or sidebar-nav click.
                    const host = event.feature === "tasks"
                        ? document.getElementById("cal-side-tasks")
                        : document.getElementById("cal-side-body");
                    if (host && !host.querySelector(".cal-side-auth-error")) {
                        const msg = event.message || "Google access needs re-consent.";
                        host.innerHTML = `<div class="cal-side-empty cal-side-auth-error">
                            <div style="margin-bottom:0.6em">${escapeHtml(msg)}</div>
                            <button type="button" class="cal-side-reauth-btn" style="padding:0.3em 0.8em;border-radius:4px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer;font-size:0.9em">Re-authenticate Now</button>
                        </div>`;
                        const btn = host.querySelector<HTMLButtonElement>(".cal-side-reauth-btn");
                        btn?.addEventListener("click", async () => {
                            btn.disabled = true;
                            btn.textContent = "Opening browser…";
                            try {
                                await reauthGoogleScopes();
                                btn.textContent = "Consent opened — complete it in the browser";
                            } catch (err: any) {
                                btn.disabled = false;
                                btn.textContent = `Failed: ${err?.message || err}. Click to retry.`;
                            }
                        });
                    }
                }
            });
        }
    }
}
