import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
import { RRule, RRuleSet } from "rrule";
import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts";

const VERSION = "0.3.31";
const CACHE_KEY = "icalendar:lastSync";

console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);

/**
 * Mapping of verbose RRULE object keys to standard iCalendar shortened keys.
 */
const RRULE_KEY_MAP: Record<string, string> = {
  "frequency": "FREQ",
  "until": "UNTIL",
  "count": "COUNT",
  "interval": "INTERVAL",
  "bysecond": "BYSECOND",
  "byminute": "BYMINUTE",
  "byhour": "BYHOUR",
  "byday": "BYDAY",
  "bymonthday": "BYMONTHDAY",
  "byyearday": "BYYEARDAY",
  "byweekno": "BYWEEKNO",
  "bymonth": "BYMONTH",
  "bysetpos": "BYSETPOS",
  "wkst": "WKST",
  "freq": "FREQ", // Just in case
};

/**
 * Formats an RRULE value for the string representation.
 * Specifically handles Date objects and nested date objects from ts-ics.
 */
function formatRRuleValue(v: any): string {
  if (v instanceof Date) {
    return v.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
  }
  if (typeof v === "object" && v !== null && v.date instanceof Date) {
    return v.date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
  }
  return String(v);
}

// ============================================================================
// Utility Functions
// ============================================================================

/**
 * Creates a SHA-256 hash of a string (hex encoded)
 */
async function sha256Hash(str: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(str);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}

export function localDateString(date: Date): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + "T" + pad(date.getHours()) + ":" + pad(date.getMinutes()) + ":" + pad(date.getSeconds());
}

/**
 * Recursively converts all Date objects and ISO date strings to strings
 * Handles nested objects like {date: Date, local: {date: Date, timezone: string}}
 */
function convertDatesToStrings<T>(obj: T): any {
  if (obj === null || obj === undefined) {
    return obj;
  }

  if (obj instanceof Date) {
    return localDateString(obj);
  }
  
  if (typeof obj === 'object' && 'date' in obj && (obj as any).date instanceof Date) {
      return localDateString((obj as any).date);
  }

  if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
    try {
        return localDateString(new Date(obj));
    } catch {
        return obj;
    }
  }

  if (Array.isArray(obj)) {
    return obj.map(item => convertDatesToStrings(item));
  }

  if (typeof obj === 'object') {
    const result: any = {};
    for (const key in obj) {
      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        result[key] = convertDatesToStrings((obj as any)[key]);
      }
    }
    return result;
  }

  return obj;
}

// ============================================================================
// Configuration Functions
// ============================================================================

async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
  try {
    const rawConfig = await config.get("icalendar", { sources: [] }) as any;
    console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
    
    let sources = rawConfig.sources || [];
    const syncWindowDays = rawConfig.syncWindowDays || 365;

    if (sources && typeof sources === "object" && !Array.isArray(sources)) {
        const sourceArray = [];
        for (const key in sources) {
            if (sources[key] && typeof sources[key].url === "string") {
                sourceArray.push(sources[key]);
            }
        }
        sources = sourceArray;
    }

    return { sources, syncWindowDays };
  } catch (e) {
    console.error("[iCalendar] Error in getSources:", e);
    return { sources: [], syncWindowDays: 365 };
  }
}

// ============================================================================
// Calendar Fetching & Parsing
// ============================================================================

/**
 * Resolves the event start as a UTC Date object using DST-aware resolution.
 */
export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
  const obj = icsEvent.start;
  if (!obj) return null;

  // 1. Extract the wall-clock local datetime string
  let wallClock: string | null = null;
  if (obj.local?.date) {
    const d = obj.local.date;
    wallClock = d instanceof Date ? d.toISOString() : String(d);
  } else if (obj.date) {
    const d = obj.date;
    wallClock = d instanceof Date ? d.toISOString() : String(d);
  }

  if (!wallClock) return null;

  // Strip any trailing Z — this is treated as wall-clock local time
  wallClock = wallClock.replace(/Z$/, "");

  // 2. Resolve IANA timezone
  const rawTz = obj.local?.timezone || (obj as any).timezone || "UTC";
  const ianaName = resolveIanaName(rawTz);

  if (!ianaName) {
    console.warn(`[iCalendar] Unknown timezone: "${rawTz}" - falling back to UTC for event "${icsEvent.summary}"`);
    const utcDate = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
    if (isNaN(utcDate.getTime())) return null;
    return utcDate;
  }

  // 3. Parse the wall-clock time as a UTC instant (no offset yet)
  const wallClockAsUtc = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
  if (isNaN(wallClockAsUtc.getTime())) return null;

  // 4. Get the DST-aware offset for this IANA zone at this instant
  const offsetMs = getUtcOffsetMs(ianaName, wallClockAsUtc);
  
  // 5. Convert: UTC = wall-clock - offset
  return new Date(wallClockAsUtc.getTime() - offsetMs);
}

/**
 * Expands recurring events into individual occurrences.
 */
export function expandRecurrences(icsEvent: any, windowDays = 365): any[] {
  const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule;
  if (!rruleStr) return [icsEvent];

  try {
    const set = new RRuleSet();
    
    let cleanRule = "";
    if (typeof rruleStr === "string") {
        cleanRule = rruleStr.replace(/^RRULE:/i, "");
    } else if (typeof rruleStr === "object" && rruleStr !== null) {
        // Handle object rrule (e.g. from ts-ics) by converting back to string
        cleanRule = Object.entries(rruleStr)
            .map(([k, v]) => {
                const standardKey = RRULE_KEY_MAP[k.toLowerCase()] || k.toUpperCase();
                return `${standardKey}=${formatRRuleValue(v)}`;
            })
            .join(";");
    } else {
        console.warn(`[iCalendar] Invalid rrule type (${typeof rruleStr}) for event "${icsEvent.summary || "Untitled"}". Treating as non-recurring.`);
        return [icsEvent];
    }
    
    // We need to provide DTSTART if it's not in the string
    
    // We need to provide DTSTART if it's not in the string
    const dtstart = new Date(icsEvent.start.includes("Z") ? icsEvent.start : icsEvent.start + "Z");
    if (isNaN(dtstart.getTime())) {
        console.error(`[iCalendar] Invalid start date for recurrence: ${icsEvent.start}`);
        return [icsEvent];
    }

    const ruleOptions = RRule.parseString(cleanRule);
    ruleOptions.dtstart = dtstart;
    
    set.rrule(new RRule(ruleOptions));

    // Handle EXDATE
    for (const exdate of (icsEvent.exdate || [])) {
        set.exdate(new Date(exdate.includes("Z") ? exdate : exdate + "Z"));
    }

    const now = new Date();
    // Start our visible window 7 days ago to catch recent past events
    const filterStart = new Date(now.getTime() - 7 * 86400000);
    const windowEnd = new Date(now.getTime() + windowDays * 86400000);
    
    // Expand from the event's actual start date to ensure all recurrences are calculated correctly
    // but only take occurrences between (now - 7 days) and (now + windowDays)
    const occurrences = set.between(dtstart, windowEnd, true);
    
    return occurrences
        .filter(occurrenceDate => occurrenceDate >= filterStart)
        .map(occurrenceDate => {
            const localIso = localDateString(occurrenceDate);
            return {
                ...icsEvent,
                start: localIso,
                recurrent: true,
                rrule: undefined,
            };
        });
  } catch (err) {
    console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err);
    return [icsEvent];
  }
}

async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> {
  console.log(`[iCalendar] Fetching from: ${source.url}`);
  try {
    const response = await fetch(source.url);
    if (!response.ok) {
      console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status} ${response.statusText}`);
      return [];
    }
    const text = await response.text();
    const calendar = convertIcsCalendar(undefined, text);
    if (!calendar || !calendar.events) {
      return [];
    }
    
    const events: any[] = [];
    for (const icsEvent of calendar.events) {
      if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;

      const finalDate = await resolveEventStart(icsEvent);
      if (!finalDate) continue;
      
      const localIso = localDateString(finalDate);
      const baseEvent = {
        ...icsEvent,
        name: icsEvent.summary || "Untitled Event",
        start: localIso,
        tag: "ical-event",
        sourceName: source.name
      };

      const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
      if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) {
          baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`;
      }

      const expanded = expandRecurrences(baseEvent, windowDays);
      for (const occurrence of expanded) {
          const uniqueKey = `${occurrence.start}${occurrence.uid || occurrence.summary || ''}`;
          occurrence.ref = await sha256Hash(uniqueKey);
          events.push(convertDatesToStrings(occurrence));
      }
    }
    return events;
  } catch (err) {
    console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err);
    return [];
  }
}

export async function syncCalendars() {
  try {
    const { sources, syncWindowDays } = await getSources();
    if (sources.length === 0) return;
    
    await editor.flashNotification("Syncing calendars...", "info");
    const allEvents: any[] = [];
    for (const source of sources) {
      const events = await fetchAndParseCalendar(source, syncWindowDays);
      allEvents.push(...events);
    }
    await index.indexObjects("$icalendar", allEvents);
    await editor.flashNotification(`Synced ${allEvents.length} events`, "info");
  } catch (err) {
    console.error("[iCalendar] syncCalendars failed:", err);
  }
}

export async function forceSync() {
  await clientStore.del(CACHE_KEY);
  await syncCalendars();
}

export async function clearCache() {
  if (!await editor.confirm("Clear all calendar events?")) return;
  const pageKeys = await datastore.query({ prefix: ["ridx", "$icalendar"] });
  const allKeys: any[] = [];
  for (const { key } of pageKeys) {
    allKeys.push(key);
    allKeys.push(["idx", ...key.slice(2), "$icalendar"]);
  }
  if (allKeys.length > 0) await datastore.batchDel(allKeys);
  await clientStore.del(CACHE_KEY);
  await editor.flashNotification("Calendar index cleared", "info");
}

export async function showVersion() {
  await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
}
