import type { Prisma } from "@prisma/client";

import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";

export type UpdateArguments = {
  where: FindManyArgs["where"];
  data: Prisma.SelectedCalendarUpdateManyArgs["data"];
};
export type FindManyArgs = {
  // https://github.com/microsoft/TypeScript/issues/55217 It crashes atoms build with this if we become too generic here. Seems like a TS bug with complex prisma types.
  where?: {
    userId?:
      | number
      | {
          in: number[];
        };
    credentialId?: number | null;
    integration?: string;
    externalId?: string;
    eventTypeId?: number | null;
    googleChannelId?:
      | string
      | null
      | {
          not: null;
        };
  };
  orderBy?: {
    userId?: "asc" | "desc";
  };
  select?: Prisma.SelectedCalendarSelect;
};

const ensureUserLevelWhere = {
  eventTypeId: null,
};

export class SelectedCalendarRepository {
  private static async findConflicting(data: {
    userId: number;
    integration: string;
    externalId: string;
    eventTypeId?: number | null;
  }) {
    // Because userId_integration_externalId_eventTypeId is a unique constraint but with eventTypeId being nullable,
    // it allows for multiple entries with the same (userId, integration, externalId) with eventTypeId=null in DB.
    // We restrict it at app level by ensuring that an entry doesn't exist already before creating a new one
    return await SelectedCalendarRepository.findFirst({
      where: {
        userId: data.userId,
        integration: data.integration,
        externalId: data.externalId,
        eventTypeId: data.eventTypeId || null,
      },
    });
  }

  static async findById(id: string) {
    return await prisma.selectedCalendar.findUnique({ where: { id } });
  }

  static async create(data: Prisma.SelectedCalendarUncheckedCreateInput) {
    const conflictingCalendar = await SelectedCalendarRepository.findConflicting(data);

    if (conflictingCalendar) {
      throw new Error("Selected calendar already exists");
    }

    return await prisma.selectedCalendar.create({
      data: {
        ...data,
      },
    });
  }

  static async upsert(data: Prisma.SelectedCalendarUncheckedCreateInput) {
    // userId_integration_externalId_eventTypeId is a unique constraint but with eventTypeId being nullable
    // So, this unique constraint can't be used in upsert. Prisma doesn't allow that, So, we do create and update separately
    const conflictingCalendar = await SelectedCalendarRepository.findConflicting(data);

    if (conflictingCalendar) {
      return await prisma.selectedCalendar.update({
        where: {
          id: conflictingCalendar.id,
        },
        data,
      });
    }

    return await prisma.selectedCalendar.create({
      data,
    });
  }

  static async delete({ where }: { where: Prisma.SelectedCalendarUncheckedCreateInput }) {
    const calendarsToDelete = await SelectedCalendarRepository.findMany({ where });

    if (calendarsToDelete.length === 0) {
      // Same behaviour as Prisma.delete as it throws error if no record is found
      throw new Error("SelectedCalendar not found");
    }

    if (calendarsToDelete.length > 1) {
      throw new Error("Multiple SelectedCalendar records found to delete. deleteMany should be used instead");
    }

    // We can't use delete because unique constraint on userId_integration_externalId_eventTypeId has eventTypeId as nullable
    return await prisma.selectedCalendar.delete({
      where: {
        id: calendarsToDelete[0].id,
      },
    });
  }

  static async createIfNotExists(data: Prisma.SelectedCalendarUncheckedCreateInput) {
    const conflictingCalendar = await SelectedCalendarRepository.findConflicting(data);

    if (conflictingCalendar) return conflictingCalendar;
    return await prisma.selectedCalendar.create({
      data,
    });
  }

  /** Retrieve calendars that need to be watched */
  static async getNextBatchToWatch(limit = 100) {
    // Get selected calendars from users that belong to a team that has calendar cache enabled
    const oneDayInMS = 24 * 60 * 60 * 1000;
    const tomorrowTimestamp = String(new Date().getTime() + oneDayInMS);
    const nextBatch = await prisma.selectedCalendar.findMany({
      take: limit,
      where: {
        user: {
          teams: {
            some: {
              team: {
                features: {
                  some: {
                    featureId: "calendar-cache",
                  },
                },
              },
            },
          },
        },
        // RN we only support google calendar subscriptions for now
        integration: "google_calendar",
        // We skip retrying calendars that have errored
        error: null,
        OR: [
          // Either is a calendar pending to be watched
          { googleChannelExpiration: null },
          // Or is a calendar that is about to expire
          { googleChannelExpiration: { lt: tomorrowTimestamp } },
        ],
      },
    });
    return nextBatch;
  }
  /**
   * Retrieve calendars that are being watched but shouldn't be anymore
   * e.g. when a user disables calendar cache for the organization
   */
  static async getNextBatchToUnwatch(limit = 100) {
    const where: Prisma.SelectedCalendarWhereInput = {
      // RN we only support google calendar subscriptions for now
      integration: "google_calendar",
      googleChannelExpiration: { not: null },
      user: {
        teams: {
          every: {
            team: {
              features: {
                none: {
                  featureId: "calendar-cache",
                },
              },
            },
          },
        },
      },
    };
    // If calendar cache is disabled globally, we skip team features and unwatch all subscriptions
    const nextBatch = await prisma.selectedCalendar.findMany({
      take: limit,
      where,
    });
    return nextBatch;
  }

  static async findMany({ where, select, orderBy }: FindManyArgs) {
    return await prisma.selectedCalendar.findMany({ where, select, orderBy });
  }

  static async findUniqueOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
    const calendars = await prisma.selectedCalendar.findMany({ where });
    if (calendars.length === 0) {
      throw new Error("SelectedCalendar not found");
    }
    return calendars[0];
  }

  static async findFirstByGoogleChannelId(googleChannelId: string) {
    return await prisma.selectedCalendar.findFirst({
      where: {
        googleChannelId,
      },
      select: {
        credential: {
          select: {
            ...credentialForCalendarServiceSelect,
            selectedCalendars: {
              orderBy: {
                externalId: "asc",
              },
            },
          },
        },
      },
    });
  }

  static async findFirst({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
    return await prisma.selectedCalendar.findFirst({
      where,
    });
  }

  static async findFirstOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
    const calendar = await SelectedCalendarRepository.findFirst({ where });
    if (!calendar) {
      throw new Error("SelectedCalendar not found");
    }
    return calendar;
  }

  static async update(args: UpdateArguments) {
    const calendarsToUpdate = await SelectedCalendarRepository.findMany({ where: args.where });

    if (calendarsToUpdate.length === 0) {
      throw new Error("SelectedCalendar not found");
    }

    if (calendarsToUpdate.length > 1) {
      throw new Error("Multiple SelectedCalendar records found to update. updateMany should be used instead");
    }

    const calendarToUpdate = calendarsToUpdate[0];

    return await prisma.selectedCalendar.update({
      where: {
        id: calendarToUpdate.id,
      },
      data: args.data,
    });
  }

  /**
   * Following methods ensure that they operate on user level calendars only
   */
  static async findUserLevelUniqueOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
    const calendars = await SelectedCalendarRepository.findUniqueOrThrow({
      where: {
        ...where,
        ...ensureUserLevelWhere,
      },
    });

    if (!calendars) {
      throw new Error("SelectedCalendar not found");
    }
    return calendars;
  }

  static async findManyUserLevel(args: FindManyArgs) {
    return SelectedCalendarRepository.findMany({
      ...args,
      where: {
        ...args.where,
        ...ensureUserLevelWhere,
      },
    });
  }

  static async updateUserLevel(args: UpdateArguments) {
    return SelectedCalendarRepository.update({
      where: {
        ...args.where,
        ...ensureUserLevelWhere,
      },
      data: args.data,
    });
  }

  static async deleteUserLevel({ where }: { where: Prisma.SelectedCalendarUncheckedCreateInput }) {
    return await SelectedCalendarRepository.delete({
      where: {
        ...where,
        ...ensureUserLevelWhere,
      },
    });
  }

  static async upsertManyForEventTypeIds({
    data,
    eventTypeIds,
  }: {
    data: Prisma.SelectedCalendarUncheckedCreateInput;
    eventTypeIds: SelectedCalendarEventTypeIds;
  }) {
    const userId = data.userId;
    return await Promise.all(
      eventTypeIds.map((eventTypeId) =>
        SelectedCalendarRepository.upsert({
          ...data,
          eventTypeId,
          userId,
          integration: data.integration,
          externalId: data.externalId,
          credentialId: data.credentialId,
        })
      )
    );
  }
  static async updateById(id: string, data: Prisma.SelectedCalendarUpdateInput) {
    return await prisma.selectedCalendar.update({
      where: { id },
      data,
    });
  }
}
