import crypto from "crypto";
import { DAVClient } from "tsdav";
import ICAL from "ical.js";
import { createLogger } from "../logging/logger";
import { CaldavRepository } from "./interfaces/CaldavRepository";
import {
  CalendarEvent,
  CreateEventDTO,
  CaldavConfig,
  formatDateKey,
} from "@calchat/shared";
import { EventService } from "./EventService";

const logger = createLogger("CaldavService");

export class CaldavService {
  constructor(
    private caldavRepo: CaldavRepository,
    private eventService: EventService,
  ) {}

  /**
   * Login to CalDAV server and return client + first calendar.
   */
  async connect(userId: string) {
    const config = await this.caldavRepo.findByUserId(userId);
    if (config === null) {
      throw new Error(`Coudn't find config by user id ${userId}`);
    }
    const client = new DAVClient({
      serverUrl: config.serverUrl,
      credentials: {
        username: config.username,
        password: config.password,
      },
      authMethod: "Basic",
      defaultAccountType: "caldav",
    });

    try {
      await client.login();
    } catch (error) {
      throw new Error("Caldav login failed");
    }

    const calendars = await client.fetchCalendars();
    if (calendars.length === 0) {
      throw new Error("No calendars found on CalDAV server");
    }

    return { client, calendar: calendars[0] };
  }

  /**
   * Pull events from CalDAV server and sync with local database.
   * - Compares etags to skip unchanged events
   * - Creates new or updates existing events in the database
   * - Deletes local events that were removed on the CalDAV server
   *
   * @returns List of newly created or updated events
   */
  async pullEvents(userId: string): Promise<CalendarEvent[]> {
    const { client, calendar } = await this.connect(userId);
    const calendarEvents: CalendarEvent[] = [];
    const caldavEventUUIDs = new Set<string>();

    const events = await client.fetchCalendarObjects({ calendar });
    for (const event of events) {
      const etag = event.etag;
      const jcal = ICAL.parse(event.data);
      const comp = new ICAL.Component(jcal);
      // A CalendarObject (.ics file) can contain multiple VEVENTs (e.g.
      // recurring events with RECURRENCE-ID exceptions), but the etag belongs
      // to the whole file, not individual VEVENTs. We only need the first
      // VEVENT since we handle recurrence via RRULE/exceptionDates, not as
      // separate events.
      const vevent = comp.getFirstSubcomponent("vevent");
      if (!vevent) continue;

      const icalEvent = new ICAL.Event(vevent);
      caldavEventUUIDs.add(icalEvent.uid);

      const exceptionDates = vevent
        .getAllProperties("exdate")
        .flatMap((prop) => prop.getValues())
        .map((time: ICAL.Time) => formatDateKey(time.toJSDate()));

      const existingEvent = await this.eventService.findByCaldavUUID(
        userId,
        icalEvent.uid,
      );

      const didChange = existingEvent?.etag !== etag;

      if (existingEvent && !didChange) {
        continue;
      }

      const eventObject: CreateEventDTO = {
        caldavUUID: icalEvent.uid,
        etag,
        title: icalEvent.summary,
        description: icalEvent.description,
        startTime: icalEvent.startDate.toJSDate(),
        endTime: icalEvent.endDate.toJSDate(),
        recurrenceRule: vevent.getFirstPropertyValue("rrule")?.toString(),
        exceptionDates,
        caldavSyncStatus: "synced",
      };

      const calendarEvent = existingEvent
        ? await this.eventService.update(existingEvent.id, userId, eventObject)
        : await this.eventService.create(userId, eventObject);

      if (calendarEvent) {
        calendarEvents.push(calendarEvent);
      }
    }

    // delete all events, that got deleted remotely
    const localEvents = await this.eventService.getAll(userId);
    for (const localEvent of localEvents) {
      if (
        localEvent.caldavUUID &&
        !caldavEventUUIDs.has(localEvent.caldavUUID)
      ) {
        await this.eventService.delete(localEvent.id, userId);
      }
    }

    return calendarEvents;
  }

  /**
   * Push a single event to the CalDAV server.
   * Creates a new event if no caldavUUID exists, updates otherwise.
   */
  async pushEvent(userId: string, event: CalendarEvent): Promise<void> {
    const { client, calendar } = await this.connect(userId);

    try {
      if (event.caldavUUID) {
        await client.updateCalendarObject({
          calendarObject: {
            url: `${calendar.url}${event.caldavUUID}.ics`,
            data: this.toICalString(event.caldavUUID, event),
            etag: event.etag || "",
          },
        });
      } else {
        const uid = crypto.randomUUID();
        await client.createCalendarObject({
          calendar,
          filename: `${uid}.ics`,
          iCalString: this.toICalString(uid, event),
        });
        await this.eventService.update(event.id, userId, { caldavUUID: uid });
      }

      // Fetch updated etag from server
      const objects = await client.fetchCalendarObjects({ calendar });
      const caldavUUID =
        event.caldavUUID ||
        (await this.eventService.getById(event.id, userId))?.caldavUUID;
      const pushed = objects.find((o) => o.data?.includes(caldavUUID!));

      await this.eventService.update(event.id, userId, {
        etag: pushed?.etag || undefined,
        caldavSyncStatus: "synced",
      });
    } catch (error) {
      await this.eventService.update(event.id, userId, {
        caldavSyncStatus: "error",
      });
      throw error;
    }
  }

  /**
   * Build an iCalendar string from a CalendarEvent using ical.js.
   */
  private toICalString(uid: string, event: CalendarEvent): string {
    const vcalendar = new ICAL.Component("vcalendar");
    vcalendar.addPropertyWithValue("version", "2.0");
    vcalendar.addPropertyWithValue("prodid", "-//CalChat//EN");

    const vevent = new ICAL.Component("vevent");
    vevent.addPropertyWithValue("uid", uid);
    vevent.addPropertyWithValue("summary", event.title);
    vevent.addPropertyWithValue(
      "dtstart",
      ICAL.Time.fromJSDate(new Date(event.startTime)),
    );
    vevent.addPropertyWithValue(
      "dtend",
      ICAL.Time.fromJSDate(new Date(event.endTime)),
    );

    if (event.description) {
      vevent.addPropertyWithValue("description", event.description);
    }

    if (event.recurrenceRule) {
      // Strip RRULE: prefix if present — fromString expects only the value part,
      // and addPropertyWithValue("rrule", ...) adds the RRULE: prefix automatically.
      const rule = event.recurrenceRule.replace(/^RRULE:/i, "");
      vevent.addPropertyWithValue("rrule", ICAL.Recur.fromString(rule));
    }

    if (event.exceptionDates?.length) {
      for (const exdate of event.exceptionDates) {
        vevent.addPropertyWithValue("exdate", ICAL.Time.fromDateString(exdate));
      }
    }

    vcalendar.addSubcomponent(vevent);
    return vcalendar.toString();
  }

  async pushAll(userId: string): Promise<void> {
    const allEvents = await this.eventService.getAll(userId);
    for (const event of allEvents) {
      if (event.caldavSyncStatus !== "synced") {
        await this.pushEvent(userId, event);
      }
    }
  }

  async deleteEvent(userId: string, caldavUUID: string) {
    const { client, calendar } = await this.connect(userId);

    await client.deleteCalendarObject({
      calendarObject: {
        url: `${calendar.url}${caldavUUID}.ics`,
      },
    });
  }

  async findEventByCaldavUUID(userId: string, caldavUUID: string) {
    return this.eventService.findByCaldavUUID(userId, caldavUUID);
  }

  async getConfig(userId: string): Promise<CaldavConfig | null> {
    return this.caldavRepo.findByUserId(userId);
  }

  async saveConfig(config: CaldavConfig): Promise<CaldavConfig> {
    const savedConfig = await this.caldavRepo.createOrUpdate(config);
    try {
      await this.connect(savedConfig.userId);
    } catch (error) {
      await this.caldavRepo.deleteByUserId(savedConfig.userId);
      throw new Error("failed to connect");
    }
    return savedConfig;
  }

  async deleteConfig(userId: string) {
    return await this.caldavRepo.deleteByUserId(userId);
  }

  /**
   * Sync with CalDAV server if config exists. Silent no-op if no config.
   */
  async sync(userId: string): Promise<void> {
    const config = await this.getConfig(userId);
    if (!config) return;
    await this.pushAll(userId);
    await this.pullEvents(userId);
  }
}
