import { EstadoCita, Role } from '@prisma/client';
import prisma from '../config/db';
import { listFallbackAppointments } from './appointment.store';
import { readDataFile, writeDataFile } from './local-store';
import { findFallbackUserById, listFallbackUsers } from './settings.store';

export type DoctorWeeklyScheduleEntry = {
  id: string;
  doctorUserId: string;
  dayOfWeek: number;
  isWorkingDay: boolean;
  startTime: string | null;
  endTime: string | null;
  slotDurationMinutes: number;
  breakStartTime: string | null;
  breakEndTime: string | null;
  createdAt: string;
  updatedAt: string;
};

export type DoctorScheduleOverrideEntry = {
  id: string;
  doctorUserId: string;
  dateKey: string;
  isWorkingDay: boolean;
  startTime: string | null;
  endTime: string | null;
  slotDurationMinutes: number | null;
  breakStartTime: string | null;
  breakEndTime: string | null;
  reason: string | null;
  createdAt: string;
  updatedAt: string;
};

export type DoctorAvailabilitySlot = {
  startAt: string;
  endAt: string;
  label: string;
};

export type AvailabilityDoctorSummary = {
  id: string;
  name: string;
  email: string;
  role: Role;
  hasWorkingSchedule: boolean;
};

export type AvailabilitySourceMode = 'database' | 'local-fallback';

type StoredDoctorSchedule = DoctorWeeklyScheduleEntry;
type StoredDoctorScheduleOverride = DoctorScheduleOverrideEntry;

type DoctorScheduleInput = {
  dayOfWeek: number;
  isWorkingDay: boolean;
  startTime?: string | null;
  endTime?: string | null;
  slotDurationMinutes?: number | null;
  breakStartTime?: string | null;
  breakEndTime?: string | null;
};

type DoctorScheduleOverrideInput = {
  dateKey: string;
  isWorkingDay: boolean;
  startTime?: string | null;
  endTime?: string | null;
  slotDurationMinutes?: number | null;
  breakStartTime?: string | null;
  breakEndTime?: string | null;
  reason?: string | null;
};

type AvailabilityOptions = {
  excludeAppointmentId?: string | null;
};

type OccupiedRange = {
  startAt: Date;
  endAt: Date;
};

const scheduleFileName = 'doctor-schedules.json';
const scheduleOverridesFileName = 'doctor-schedule-overrides.json';
const workingRoles = [Role.DENTIST, Role.ADMIN] as const;
const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/;
const limaUtcOffset = '-05:00';
const isWorkingRole = (role: Role) => role === Role.DENTIST || role === Role.ADMIN;

export class DoctorScheduleNotFoundError extends Error {
  constructor() {
    super('No se encontro un doctor activo para configurar el horario.');
  }
}

export class InvalidDoctorScheduleError extends Error {
  constructor(message: string) {
    super(message);
  }
}

export class DoctorAvailabilityConflictError extends Error {
  constructor(message: string) {
    super(message);
  }
}

export class DoctorScheduleOverrideAlreadyExistsError extends Error {
  constructor() {
    super('Ya existe una excepcion configurada para esa fecha.');
  }
}

export class DoctorScheduleOverrideNotFoundError extends Error {
  constructor() {
    super('No se encontro la excepcion solicitada para ese doctor.');
  }
}

const defaultDoctorScheduleTemplate = (doctorUserId: string): DoctorWeeklyScheduleEntry[] => {
  const now = new Date().toISOString();

  return [
    {
      id: `${doctorUserId}-0`,
      doctorUserId,
      dayOfWeek: 0,
      isWorkingDay: false,
      startTime: null,
      endTime: null,
      slotDurationMinutes: 30,
      breakStartTime: null,
      breakEndTime: null,
      createdAt: now,
      updatedAt: now,
    },
    {
      id: `${doctorUserId}-1`,
      doctorUserId,
      dayOfWeek: 1,
      isWorkingDay: true,
      startTime: '09:00',
      endTime: '19:00',
      slotDurationMinutes: 30,
      breakStartTime: '13:00',
      breakEndTime: '15:00',
      createdAt: now,
      updatedAt: now,
    },
    {
      id: `${doctorUserId}-2`,
      doctorUserId,
      dayOfWeek: 2,
      isWorkingDay: true,
      startTime: '09:00',
      endTime: '19:00',
      slotDurationMinutes: 30,
      breakStartTime: '13:00',
      breakEndTime: '15:00',
      createdAt: now,
      updatedAt: now,
    },
    {
      id: `${doctorUserId}-3`,
      doctorUserId,
      dayOfWeek: 3,
      isWorkingDay: true,
      startTime: '09:00',
      endTime: '19:00',
      slotDurationMinutes: 30,
      breakStartTime: '13:00',
      breakEndTime: '15:00',
      createdAt: now,
      updatedAt: now,
    },
    {
      id: `${doctorUserId}-4`,
      doctorUserId,
      dayOfWeek: 4,
      isWorkingDay: true,
      startTime: '09:00',
      endTime: '19:00',
      slotDurationMinutes: 30,
      breakStartTime: '13:00',
      breakEndTime: '15:00',
      createdAt: now,
      updatedAt: now,
    },
    {
      id: `${doctorUserId}-5`,
      doctorUserId,
      dayOfWeek: 5,
      isWorkingDay: true,
      startTime: '09:00',
      endTime: '19:00',
      slotDurationMinutes: 30,
      breakStartTime: '13:00',
      breakEndTime: '15:00',
      createdAt: now,
      updatedAt: now,
    },
    {
      id: `${doctorUserId}-6`,
      doctorUserId,
      dayOfWeek: 6,
      isWorkingDay: true,
      startTime: '09:00',
      endTime: '13:00',
      slotDurationMinutes: 30,
      breakStartTime: null,
      breakEndTime: null,
      createdAt: now,
      updatedAt: now,
    },
  ];
};

const defaultSchedules = () => [] as StoredDoctorSchedule[];
const defaultOverrides = () => [] as StoredDoctorScheduleOverride[];

const normalizeNullableText = (value: string | null | undefined) => {
  if (typeof value !== 'string') {
    return null;
  }

  const trimmedValue = value.trim();
  return trimmedValue ? trimmedValue : null;
};

const normalizeTime = (value: string | null | undefined) => {
  const normalizedValue = normalizeNullableText(value);
  return normalizedValue && timePattern.test(normalizedValue) ? normalizedValue : null;
};

const normalizeDateKey = (value: string) => (/^\d{4}-\d{2}-\d{2}$/.test(value.trim()) ? value.trim() : null);

const timeToMinutes = (value: string) => {
  const [hoursText, minutesText] = value.split(':');
  return Number(hoursText) * 60 + Number(minutesText);
};

const buildDateAtTime = (dateKey: string, time: string) => new Date(`${dateKey}T${time}:00${limaUtcOffset}`);

const getDateKeyFromDate = (value: Date) => {
  const formatter = new Intl.DateTimeFormat('en-CA', {
    timeZone: 'America/Lima',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  });

  const parts = formatter.formatToParts(value);
  const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
  const month = parts.find((part) => part.type === 'month')?.value ?? '00';
  const day = parts.find((part) => part.type === 'day')?.value ?? '00';
  return `${year}-${month}-${day}`;
};

const getDayOfWeekFromDateKey = (dateKey: string) => new Date(`${dateKey}T00:00:00${limaUtcOffset}`).getUTCDay();

const formatSlotLabel = (startAt: Date) =>
  new Intl.DateTimeFormat('es-PE', {
    timeZone: 'America/Lima',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  }).format(startAt);

const intervalsOverlap = (leftStart: Date, leftEnd: Date, rightStart: Date, rightEnd: Date) =>
  leftStart.getTime() < rightEnd.getTime() && rightStart.getTime() < leftEnd.getTime();

const normalizeStoredSchedule = (entry: Partial<StoredDoctorSchedule>): StoredDoctorSchedule => ({
  id: entry.id ?? `${entry.doctorUserId ?? 'doctor'}-${entry.dayOfWeek ?? 0}`,
  doctorUserId: entry.doctorUserId ?? '',
  dayOfWeek: typeof entry.dayOfWeek === 'number' ? entry.dayOfWeek : 0,
  isWorkingDay: entry.isWorkingDay ?? false,
  startTime: normalizeTime(entry.startTime),
  endTime: normalizeTime(entry.endTime),
  slotDurationMinutes:
    typeof entry.slotDurationMinutes === 'number' && Number.isFinite(entry.slotDurationMinutes)
      ? entry.slotDurationMinutes
      : 30,
  breakStartTime: normalizeTime(entry.breakStartTime),
  breakEndTime: normalizeTime(entry.breakEndTime),
  createdAt: entry.createdAt ?? new Date().toISOString(),
  updatedAt: entry.updatedAt ?? new Date().toISOString(),
});

const normalizeStoredOverride = (entry: Partial<StoredDoctorScheduleOverride>): StoredDoctorScheduleOverride => ({
  id: entry.id ?? `${entry.doctorUserId ?? 'doctor'}-${entry.dateKey ?? '1970-01-01'}`,
  doctorUserId: entry.doctorUserId ?? '',
  dateKey: typeof entry.dateKey === 'string' ? entry.dateKey : '1970-01-01',
  isWorkingDay: entry.isWorkingDay ?? false,
  startTime: normalizeTime(entry.startTime),
  endTime: normalizeTime(entry.endTime),
  slotDurationMinutes:
    typeof entry.slotDurationMinutes === 'number' && Number.isFinite(entry.slotDurationMinutes)
      ? entry.slotDurationMinutes
      : null,
  breakStartTime: normalizeTime(entry.breakStartTime),
  breakEndTime: normalizeTime(entry.breakEndTime),
  reason: normalizeNullableText(entry.reason),
  createdAt: entry.createdAt ?? new Date().toISOString(),
  updatedAt: entry.updatedAt ?? new Date().toISOString(),
});

const readFallbackSchedules = async () => {
  const schedules = await readDataFile<Array<Partial<StoredDoctorSchedule>>>(scheduleFileName, defaultSchedules);
  return schedules.map(normalizeStoredSchedule);
};

const writeFallbackSchedules = async (schedules: StoredDoctorSchedule[]) => writeDataFile(scheduleFileName, schedules);

const readFallbackOverrides = async () => {
  const overrides = await readDataFile<Array<Partial<StoredDoctorScheduleOverride>>>(
    scheduleOverridesFileName,
    defaultOverrides,
  );

  return overrides.map(normalizeStoredOverride);
};

const writeFallbackOverrides = async (overrides: StoredDoctorScheduleOverride[]) =>
  writeDataFile(scheduleOverridesFileName, overrides);

const assertValidScheduleWindow = (input: {
  isWorkingDay: boolean;
  startTime: string | null;
  endTime: string | null;
  slotDurationMinutes: number;
  breakStartTime: string | null;
  breakEndTime: string | null;
}) => {
  if (!input.isWorkingDay) {
    return;
  }

  if (!input.startTime || !input.endTime) {
    throw new InvalidDoctorScheduleError('Debes definir hora de inicio y fin para un dia laborable.');
  }

  if (!timePattern.test(input.startTime) || !timePattern.test(input.endTime)) {
    throw new InvalidDoctorScheduleError('Las horas deben tener formato HH:mm.');
  }

  const startMinutes = timeToMinutes(input.startTime);
  const endMinutes = timeToMinutes(input.endTime);

  if (startMinutes >= endMinutes) {
    throw new InvalidDoctorScheduleError('La hora de inicio debe ser menor que la hora de fin.');
  }

  if (!Number.isInteger(input.slotDurationMinutes) || input.slotDurationMinutes < 5 || input.slotDurationMinutes > 240) {
    throw new InvalidDoctorScheduleError('La duracion del turno debe estar entre 5 y 240 minutos.');
  }

  if (input.breakStartTime || input.breakEndTime) {
    if (!input.breakStartTime || !input.breakEndTime) {
      throw new InvalidDoctorScheduleError('Debes completar inicio y fin del descanso.');
    }

    const breakStartMinutes = timeToMinutes(input.breakStartTime);
    const breakEndMinutes = timeToMinutes(input.breakEndTime);

    if (breakStartMinutes >= breakEndMinutes) {
      throw new InvalidDoctorScheduleError('La hora de inicio del descanso debe ser menor que la hora de fin.');
    }

    if (breakStartMinutes < startMinutes || breakEndMinutes > endMinutes) {
      throw new InvalidDoctorScheduleError('El descanso debe estar dentro del horario laboral.');
    }
  }
};

const ensureWorkingDoctorRecord = async (doctorUserId: string) => {
  const doctor = await prisma.user.findFirst({
    where: {
      id: doctorUserId,
      active: true,
      role: { in: [...workingRoles] },
    },
  });

  if (!doctor) {
    throw new DoctorScheduleNotFoundError();
  }

  return doctor;
};

const ensureFallbackDoctorRecord = async (doctorUserId: string) => {
  const doctor = await findFallbackUserById(doctorUserId);

  if (!doctor || !doctor.active || !isWorkingRole(doctor.role)) {
    throw new DoctorScheduleNotFoundError();
  }

  return doctor;
};

const ensureDatabaseDoctorSchedules = async (doctorUserId: string) => {
  await ensureWorkingDoctorRecord(doctorUserId);

  const existingSchedules = await prisma.doctorSchedule.findMany({
    where: { doctorUserId },
    orderBy: { dayOfWeek: 'asc' },
  });

  if (existingSchedules.length !== 7) {
    const existingByDay = new Map(existingSchedules.map((entry) => [entry.dayOfWeek, entry]));

    for (const fallbackEntry of defaultDoctorScheduleTemplate(doctorUserId)) {
      if (existingByDay.has(fallbackEntry.dayOfWeek)) {
        continue;
      }

      await prisma.doctorSchedule.create({
        data: {
          doctorUserId,
          dayOfWeek: fallbackEntry.dayOfWeek,
          isWorkingDay: fallbackEntry.isWorkingDay,
          startTime: fallbackEntry.startTime,
          endTime: fallbackEntry.endTime,
          slotDurationMinutes: fallbackEntry.slotDurationMinutes,
          breakStartTime: fallbackEntry.breakStartTime,
          breakEndTime: fallbackEntry.breakEndTime,
        },
      });
    }
  }

  const completedSchedules = await prisma.doctorSchedule.findMany({
    where: { doctorUserId },
    orderBy: { dayOfWeek: 'asc' },
  });

  return completedSchedules.map((entry) => ({
    id: entry.id,
    doctorUserId: entry.doctorUserId,
    dayOfWeek: entry.dayOfWeek,
    isWorkingDay: entry.isWorkingDay,
    startTime: entry.startTime,
    endTime: entry.endTime,
    slotDurationMinutes: entry.slotDurationMinutes,
    breakStartTime: entry.breakStartTime,
    breakEndTime: entry.breakEndTime,
    createdAt: entry.createdAt.toISOString(),
    updatedAt: entry.updatedAt.toISOString(),
  }));
};

const ensureFallbackDoctorSchedules = async (doctorUserId: string) => {
  await ensureFallbackDoctorRecord(doctorUserId);

  const schedules = await readFallbackSchedules();
  const existingSchedules = schedules.filter((entry) => entry.doctorUserId === doctorUserId);

  if (existingSchedules.length === 7) {
    return existingSchedules.sort((left, right) => left.dayOfWeek - right.dayOfWeek);
  }

  const defaults = defaultDoctorScheduleTemplate(doctorUserId);
  const existingByDay = new Map(existingSchedules.map((entry) => [entry.dayOfWeek, entry]));
  const mergedSchedules = [
    ...schedules.filter((entry) => entry.doctorUserId !== doctorUserId),
    ...defaults.map((fallbackEntry) => existingByDay.get(fallbackEntry.dayOfWeek) ?? fallbackEntry),
  ];

  await writeFallbackSchedules(mergedSchedules);

  return mergedSchedules
    .filter((entry) => entry.doctorUserId === doctorUserId)
    .sort((left, right) => left.dayOfWeek - right.dayOfWeek);
};

const buildScheduleEntryFromInput = (
  doctorUserId: string,
  currentEntry: DoctorWeeklyScheduleEntry,
  input: DoctorScheduleInput,
): DoctorWeeklyScheduleEntry => {
  const nextEntry: DoctorWeeklyScheduleEntry = {
    id: currentEntry.id,
    doctorUserId,
    dayOfWeek: currentEntry.dayOfWeek,
    isWorkingDay: input.isWorkingDay,
    startTime: input.isWorkingDay ? normalizeTime(input.startTime) : null,
    endTime: input.isWorkingDay ? normalizeTime(input.endTime) : null,
    slotDurationMinutes:
      typeof input.slotDurationMinutes === 'number' && Number.isFinite(input.slotDurationMinutes)
        ? input.slotDurationMinutes
        : currentEntry.slotDurationMinutes,
    breakStartTime: input.isWorkingDay ? normalizeTime(input.breakStartTime) : null,
    breakEndTime: input.isWorkingDay ? normalizeTime(input.breakEndTime) : null,
    createdAt: currentEntry.createdAt,
    updatedAt: new Date().toISOString(),
  };

  assertValidScheduleWindow(nextEntry);
  return nextEntry;
};

const buildOverrideEntry = (
  doctorUserId: string,
  input: DoctorScheduleOverrideInput,
  baseSchedule: DoctorWeeklyScheduleEntry,
  currentOverride?: DoctorScheduleOverrideEntry | null,
): DoctorScheduleOverrideEntry => {
  const normalizedDateKey = normalizeDateKey(input.dateKey);

  if (!normalizedDateKey) {
    throw new InvalidDoctorScheduleError('La fecha de la excepcion debe tener formato YYYY-MM-DD.');
  }

  const isWorkingDay = input.isWorkingDay;
  const now = new Date().toISOString();
  const nextEntry: DoctorScheduleOverrideEntry = {
    id: currentOverride?.id ?? `${doctorUserId}-${normalizedDateKey}`,
    doctorUserId,
    dateKey: normalizedDateKey,
    isWorkingDay,
    startTime: isWorkingDay ? normalizeTime(input.startTime ?? currentOverride?.startTime ?? baseSchedule.startTime) : null,
    endTime: isWorkingDay ? normalizeTime(input.endTime ?? currentOverride?.endTime ?? baseSchedule.endTime) : null,
    slotDurationMinutes: isWorkingDay
      ? typeof input.slotDurationMinutes === 'number' && Number.isFinite(input.slotDurationMinutes)
        ? input.slotDurationMinutes
        : currentOverride?.slotDurationMinutes ?? baseSchedule.slotDurationMinutes
      : null,
    breakStartTime: isWorkingDay
      ? normalizeTime(input.breakStartTime ?? currentOverride?.breakStartTime ?? baseSchedule.breakStartTime)
      : null,
    breakEndTime: isWorkingDay
      ? normalizeTime(input.breakEndTime ?? currentOverride?.breakEndTime ?? baseSchedule.breakEndTime)
      : null,
    reason: normalizeNullableText(input.reason ?? currentOverride?.reason),
    createdAt: currentOverride?.createdAt ?? now,
    updatedAt: now,
  };

  assertValidScheduleWindow({
    isWorkingDay: nextEntry.isWorkingDay,
    startTime: nextEntry.startTime,
    endTime: nextEntry.endTime,
    slotDurationMinutes: nextEntry.slotDurationMinutes ?? baseSchedule.slotDurationMinutes,
    breakStartTime: nextEntry.breakStartTime,
    breakEndTime: nextEntry.breakEndTime,
  });

  return nextEntry;
};

const getDatabaseOccupiedRanges = async (
  doctorUserId: string,
  dateKey: string,
  slotDurationMinutes: number,
  options?: AvailabilityOptions,
) => {
  const dayStart = buildDateAtTime(dateKey, '00:00');
  const dayEnd = buildDateAtTime(dateKey, '23:59');
  const appointments = await prisma.cita.findMany({
    where: {
      dentistaId: doctorUserId,
      fecha: {
        gte: dayStart,
        lte: dayEnd,
      },
      estado: {
        notIn: [EstadoCita.CANCELADA, EstadoCita.NO_ASISTIO],
      },
      ...(options?.excludeAppointmentId ? { id: { not: options.excludeAppointmentId } } : {}),
    },
    orderBy: { fecha: 'asc' },
  });

  return appointments.map((appointment) => ({
    startAt: appointment.fecha,
    endAt: new Date(appointment.fecha.getTime() + slotDurationMinutes * 60 * 1000),
  }));
};

const getFallbackOccupiedRanges = async (
  doctorUserId: string,
  dateKey: string,
  slotDurationMinutes: number,
  options?: AvailabilityOptions,
) => {
  const appointments = await listFallbackAppointments();

  return appointments
    .filter(
      (appointment) =>
        appointment.dentistaId === doctorUserId &&
        appointment.estado !== EstadoCita.CANCELADA &&
        appointment.estado !== EstadoCita.NO_ASISTIO &&
        getDateKeyFromDate(new Date(appointment.fecha)) === dateKey &&
        appointment.id !== options?.excludeAppointmentId,
    )
    .map((appointment) => {
      const startAt = new Date(appointment.fecha);
      return {
        startAt,
        endAt: new Date(startAt.getTime() + slotDurationMinutes * 60 * 1000),
      };
    });
};

const buildSlotsFromSchedule = (
  dateKey: string,
  schedule: {
    isWorkingDay: boolean;
    startTime: string | null;
    endTime: string | null;
    slotDurationMinutes: number;
    breakStartTime: string | null;
    breakEndTime: string | null;
  },
  occupiedRanges: OccupiedRange[],
) => {
  if (!schedule.isWorkingDay || !schedule.startTime || !schedule.endTime) {
    return [] as DoctorAvailabilitySlot[];
  }

  const slots: DoctorAvailabilitySlot[] = [];
  const slotDurationMs = schedule.slotDurationMinutes * 60 * 1000;
  let cursor = buildDateAtTime(dateKey, schedule.startTime);
  const workdayEnd = buildDateAtTime(dateKey, schedule.endTime);
  const breakStart = schedule.breakStartTime ? buildDateAtTime(dateKey, schedule.breakStartTime) : null;
  const breakEnd = schedule.breakEndTime ? buildDateAtTime(dateKey, schedule.breakEndTime) : null;

  while (cursor.getTime() + slotDurationMs <= workdayEnd.getTime()) {
    const slotStart = new Date(cursor);
    const slotEnd = new Date(slotStart.getTime() + slotDurationMs);

    const overlapsBreak =
      breakStart && breakEnd ? intervalsOverlap(slotStart, slotEnd, breakStart, breakEnd) : false;
    const overlapsAppointment = occupiedRanges.some((range) =>
      intervalsOverlap(slotStart, slotEnd, range.startAt, range.endAt),
    );

    if (!overlapsBreak && !overlapsAppointment) {
      slots.push({
        startAt: slotStart.toISOString(),
        endAt: slotEnd.toISOString(),
        label: formatSlotLabel(slotStart),
      });
    }

    cursor = new Date(cursor.getTime() + slotDurationMs);
  }

  return slots;
};

const resolveEffectiveScheduleForDate = (
  weeklySchedule: DoctorWeeklyScheduleEntry[],
  overrides: DoctorScheduleOverrideEntry[],
  dateKey: string,
) => {
  const dayOfWeek = getDayOfWeekFromDateKey(dateKey);
  const baseSchedule = weeklySchedule.find((entry) => entry.dayOfWeek === dayOfWeek);

  if (!baseSchedule) {
    throw new InvalidDoctorScheduleError('No existe horario base para el doctor en ese dia.');
  }

  const override = overrides.find((entry) => entry.dateKey === dateKey) ?? null;

  if (!override) {
    return {
      ...baseSchedule,
      source: 'base' as const,
    };
  }

  return {
    id: override.id,
    doctorUserId: override.doctorUserId,
    dayOfWeek,
    isWorkingDay: override.isWorkingDay,
    startTime: override.startTime,
    endTime: override.endTime,
    slotDurationMinutes: override.slotDurationMinutes ?? baseSchedule.slotDurationMinutes,
    breakStartTime: override.breakStartTime,
    breakEndTime: override.breakEndTime,
    createdAt: override.createdAt,
    updatedAt: override.updatedAt,
    source: 'override' as const,
  };
};

const serializeDatabaseOverride = (entry: {
  id: string;
  doctorUserId: string;
  dateKey: string;
  isWorkingDay: boolean;
  startTime: string | null;
  endTime: string | null;
  slotDurationMinutes: number | null;
  breakStartTime: string | null;
  breakEndTime: string | null;
  reason: string | null;
  createdAt: Date;
  updatedAt: Date;
}): DoctorScheduleOverrideEntry => ({
  id: entry.id,
  doctorUserId: entry.doctorUserId,
  dateKey: entry.dateKey,
  isWorkingDay: entry.isWorkingDay,
  startTime: entry.startTime,
  endTime: entry.endTime,
  slotDurationMinutes: entry.slotDurationMinutes,
  breakStartTime: entry.breakStartTime,
  breakEndTime: entry.breakEndTime,
  reason: entry.reason,
  createdAt: entry.createdAt.toISOString(),
  updatedAt: entry.updatedAt.toISOString(),
});

const ensureValidDateRange = (dateFrom?: string | null, dateTo?: string | null) => {
  const normalizedDateFrom = dateFrom ? normalizeDateKey(dateFrom) : null;
  const normalizedDateTo = dateTo ? normalizeDateKey(dateTo) : null;

  if (dateFrom && !normalizedDateFrom) {
    throw new InvalidDoctorScheduleError('La fecha inicial debe tener formato YYYY-MM-DD.');
  }

  if (dateTo && !normalizedDateTo) {
    throw new InvalidDoctorScheduleError('La fecha final debe tener formato YYYY-MM-DD.');
  }

  if (normalizedDateFrom && normalizedDateTo && normalizedDateFrom > normalizedDateTo) {
    throw new InvalidDoctorScheduleError('La fecha inicial no puede ser mayor que la fecha final.');
  }

  return {
    dateFrom: normalizedDateFrom,
    dateTo: normalizedDateTo,
  };
};

const filterOverridesByDateRange = (
  overrides: DoctorScheduleOverrideEntry[],
  dateFrom?: string | null,
  dateTo?: string | null,
) =>
  overrides
    .filter((entry) => (!dateFrom || entry.dateKey >= dateFrom) && (!dateTo || entry.dateKey <= dateTo))
    .sort((left, right) => left.dateKey.localeCompare(right.dateKey));

export const listAvailabilityDoctors = async (): Promise<{
  sourceMode: AvailabilitySourceMode;
  doctors: AvailabilityDoctorSummary[];
}> => {
  try {
    const doctors = await prisma.user.findMany({
      where: {
        active: true,
        role: { in: [...workingRoles] },
      },
      orderBy: { name: 'asc' },
    });

    const summaries = await Promise.all(
      doctors.map(async (doctor) => {
        const schedule = await ensureDatabaseDoctorSchedules(doctor.id);

        return {
          id: doctor.id,
          name: doctor.name,
          email: doctor.email,
          role: doctor.role,
          hasWorkingSchedule: schedule.some((entry) => entry.isWorkingDay),
        };
      }),
    );

    return {
      sourceMode: 'database',
      doctors: summaries,
    };
  } catch (error) {
    console.warn('Database unavailable for doctor availability listing, using local fallback store.', error);

    const doctors = (await listFallbackUsers())
      .filter((doctor) => doctor.active && isWorkingRole(doctor.role))
      .sort((left, right) => left.name.localeCompare(right.name, 'es'));

    const summaries = await Promise.all(
      doctors.map(async (doctor) => {
        const schedule = await ensureFallbackDoctorSchedules(doctor.id);

        return {
          id: doctor.id,
          name: doctor.name,
          email: doctor.email,
          role: doctor.role,
          hasWorkingSchedule: schedule.some((entry) => entry.isWorkingDay),
        };
      }),
    );

    return {
      sourceMode: 'local-fallback',
      doctors: summaries,
    };
  }
};

export const getDoctorWeeklySchedule = async (
  doctorUserId: string,
): Promise<{
  sourceMode: AvailabilitySourceMode;
  schedule: DoctorWeeklyScheduleEntry[];
}> => {
  try {
    const schedule = await ensureDatabaseDoctorSchedules(doctorUserId);
    return {
      sourceMode: 'database',
      schedule,
    };
  } catch (error) {
    console.warn('Database unavailable for doctor schedule lookup, using local fallback store.', error);
    const schedule = await ensureFallbackDoctorSchedules(doctorUserId);
    return {
      sourceMode: 'local-fallback',
      schedule,
    };
  }
};

export const updateDoctorWeeklySchedule = async (
  doctorUserId: string,
  entries: DoctorScheduleInput[],
): Promise<{
  sourceMode: AvailabilitySourceMode;
  schedule: DoctorWeeklyScheduleEntry[];
}> => {
  if (!Array.isArray(entries) || entries.length === 0) {
    throw new InvalidDoctorScheduleError('Debes enviar al menos un dia para actualizar el horario.');
  }

  const entriesByDay = new Map<number, DoctorScheduleInput>();

  for (const entry of entries) {
    if (!Number.isInteger(entry.dayOfWeek) || entry.dayOfWeek < 0 || entry.dayOfWeek > 6) {
      throw new InvalidDoctorScheduleError('Cada dia del horario debe estar entre 0 y 6.');
    }

    if (entriesByDay.has(entry.dayOfWeek)) {
      throw new InvalidDoctorScheduleError('No puedes repetir dias al actualizar el horario.');
    }

    entriesByDay.set(entry.dayOfWeek, entry);
  }

  try {
    const currentSchedule = await ensureDatabaseDoctorSchedules(doctorUserId);
    const nextSchedule = currentSchedule.map((entry) => {
      const requestedEntry = entriesByDay.get(entry.dayOfWeek);
      return requestedEntry ? buildScheduleEntryFromInput(doctorUserId, entry, requestedEntry) : entry;
    });

    for (const entry of nextSchedule) {
      if (!entriesByDay.has(entry.dayOfWeek)) {
        continue;
      }

      await prisma.doctorSchedule.update({
        where: {
          doctorUserId_dayOfWeek: {
            doctorUserId,
            dayOfWeek: entry.dayOfWeek,
          },
        },
        data: {
          isWorkingDay: entry.isWorkingDay,
          startTime: entry.startTime,
          endTime: entry.endTime,
          slotDurationMinutes: entry.slotDurationMinutes,
          breakStartTime: entry.breakStartTime,
          breakEndTime: entry.breakEndTime,
        },
      });
    }

    return {
      sourceMode: 'database',
      schedule: nextSchedule.sort((left, right) => left.dayOfWeek - right.dayOfWeek),
    };
  } catch (error) {
    if (error instanceof DoctorScheduleNotFoundError || error instanceof InvalidDoctorScheduleError) {
      throw error;
    }

    console.warn('Database unavailable while updating doctor schedule, using local fallback store.', error);

    const currentSchedule = await ensureFallbackDoctorSchedules(doctorUserId);
    const nextSchedule = currentSchedule.map((entry) => {
      const requestedEntry = entriesByDay.get(entry.dayOfWeek);
      return requestedEntry ? buildScheduleEntryFromInput(doctorUserId, entry, requestedEntry) : entry;
    });
    const schedules = await readFallbackSchedules();
    const mergedSchedules = [
      ...schedules.filter((entry) => entry.doctorUserId !== doctorUserId),
      ...nextSchedule,
    ];

    await writeFallbackSchedules(mergedSchedules);

    return {
      sourceMode: 'local-fallback',
      schedule: nextSchedule.sort((left, right) => left.dayOfWeek - right.dayOfWeek),
    };
  }
};

export const listDoctorScheduleOverrides = async (
  doctorUserId: string,
  options?: {
    dateFrom?: string | null;
    dateTo?: string | null;
  },
): Promise<{
  sourceMode: AvailabilitySourceMode;
  overrides: DoctorScheduleOverrideEntry[];
}> => {
  const { dateFrom, dateTo } = ensureValidDateRange(options?.dateFrom, options?.dateTo);

  try {
    await ensureDatabaseDoctorSchedules(doctorUserId);
    const overrides = await prisma.doctorScheduleOverride.findMany({
      where: {
        doctorUserId,
        ...(dateFrom || dateTo
          ? {
              dateKey: {
                ...(dateFrom ? { gte: dateFrom } : {}),
                ...(dateTo ? { lte: dateTo } : {}),
              },
            }
          : {}),
      },
      orderBy: { dateKey: 'asc' },
    });

    return {
      sourceMode: 'database',
      overrides: overrides.map(serializeDatabaseOverride),
    };
  } catch (error) {
    if (error instanceof DoctorScheduleNotFoundError || error instanceof InvalidDoctorScheduleError) {
      throw error;
    }

    console.warn('Database unavailable for schedule overrides, using local fallback store.', error);
    await ensureFallbackDoctorSchedules(doctorUserId);
    const overrides = await readFallbackOverrides();

    return {
      sourceMode: 'local-fallback',
      overrides: filterOverridesByDateRange(
        overrides.filter((entry) => entry.doctorUserId === doctorUserId),
        dateFrom,
        dateTo,
      ),
    };
  }
};

export const createDoctorScheduleOverride = async (
  doctorUserId: string,
  input: DoctorScheduleOverrideInput,
): Promise<{
  sourceMode: AvailabilitySourceMode;
  override: DoctorScheduleOverrideEntry;
}> => {
  const normalizedDateKey = normalizeDateKey(input.dateKey);

  if (!normalizedDateKey) {
    throw new InvalidDoctorScheduleError('La fecha de la excepcion debe tener formato YYYY-MM-DD.');
  }

  try {
    const weeklySchedule = await ensureDatabaseDoctorSchedules(doctorUserId);
    const existingOverride = await prisma.doctorScheduleOverride.findUnique({
      where: {
        doctorUserId_dateKey: {
          doctorUserId,
          dateKey: normalizedDateKey,
        },
      },
    });

    if (existingOverride) {
      throw new DoctorScheduleOverrideAlreadyExistsError();
    }

    const baseSchedule = weeklySchedule.find(
      (entry) => entry.dayOfWeek === getDayOfWeekFromDateKey(normalizedDateKey),
    );

    if (!baseSchedule) {
      throw new InvalidDoctorScheduleError('No existe horario base para crear la excepcion.');
    }

    const nextOverride = buildOverrideEntry(doctorUserId, { ...input, dateKey: normalizedDateKey }, baseSchedule);
    const createdOverride = await prisma.doctorScheduleOverride.create({
      data: {
        doctorUserId,
        dateKey: nextOverride.dateKey,
        isWorkingDay: nextOverride.isWorkingDay,
        startTime: nextOverride.startTime,
        endTime: nextOverride.endTime,
        slotDurationMinutes: nextOverride.slotDurationMinutes,
        breakStartTime: nextOverride.breakStartTime,
        breakEndTime: nextOverride.breakEndTime,
        reason: nextOverride.reason,
      },
    });

    return {
      sourceMode: 'database',
      override: serializeDatabaseOverride(createdOverride),
    };
  } catch (error) {
    if (
      error instanceof DoctorScheduleOverrideAlreadyExistsError ||
      error instanceof DoctorScheduleNotFoundError ||
      error instanceof InvalidDoctorScheduleError
    ) {
      throw error;
    }

    console.warn('Database unavailable while creating schedule override, using local fallback store.', error);

    const weeklySchedule = await ensureFallbackDoctorSchedules(doctorUserId);
    const overrides = await readFallbackOverrides();
    const existingOverride = overrides.find(
      (entry) => entry.doctorUserId === doctorUserId && entry.dateKey === normalizedDateKey,
    );

    if (existingOverride) {
      throw new DoctorScheduleOverrideAlreadyExistsError();
    }

    const baseSchedule = weeklySchedule.find(
      (entry) => entry.dayOfWeek === getDayOfWeekFromDateKey(normalizedDateKey),
    );

    if (!baseSchedule) {
      throw new InvalidDoctorScheduleError('No existe horario base para crear la excepcion.');
    }

    const nextOverride = buildOverrideEntry(doctorUserId, { ...input, dateKey: normalizedDateKey }, baseSchedule);
    const nextOverrides = [...overrides, nextOverride].sort((left, right) => left.dateKey.localeCompare(right.dateKey));
    await writeFallbackOverrides(nextOverrides);

    return {
      sourceMode: 'local-fallback',
      override: nextOverride,
    };
  }
};

export const updateDoctorScheduleOverride = async (
  doctorUserId: string,
  dateKey: string,
  input: Omit<DoctorScheduleOverrideInput, 'dateKey'>,
): Promise<{
  sourceMode: AvailabilitySourceMode;
  override: DoctorScheduleOverrideEntry;
}> => {
  const normalizedDateKey = normalizeDateKey(dateKey);

  if (!normalizedDateKey) {
    throw new InvalidDoctorScheduleError('La fecha de la excepcion debe tener formato YYYY-MM-DD.');
  }

  try {
    const weeklySchedule = await ensureDatabaseDoctorSchedules(doctorUserId);
    const currentOverride = await prisma.doctorScheduleOverride.findUnique({
      where: {
        doctorUserId_dateKey: {
          doctorUserId,
          dateKey: normalizedDateKey,
        },
      },
    });

    if (!currentOverride) {
      throw new DoctorScheduleOverrideNotFoundError();
    }

    const baseSchedule = weeklySchedule.find(
      (entry) => entry.dayOfWeek === getDayOfWeekFromDateKey(normalizedDateKey),
    );

    if (!baseSchedule) {
      throw new InvalidDoctorScheduleError('No existe horario base para actualizar la excepcion.');
    }

    const nextOverride = buildOverrideEntry(
      doctorUserId,
      { ...input, dateKey: normalizedDateKey },
      baseSchedule,
      serializeDatabaseOverride(currentOverride),
    );
    const updatedOverride = await prisma.doctorScheduleOverride.update({
      where: {
        doctorUserId_dateKey: {
          doctorUserId,
          dateKey: normalizedDateKey,
        },
      },
      data: {
        isWorkingDay: nextOverride.isWorkingDay,
        startTime: nextOverride.startTime,
        endTime: nextOverride.endTime,
        slotDurationMinutes: nextOverride.slotDurationMinutes,
        breakStartTime: nextOverride.breakStartTime,
        breakEndTime: nextOverride.breakEndTime,
        reason: nextOverride.reason,
      },
    });

    return {
      sourceMode: 'database',
      override: serializeDatabaseOverride(updatedOverride),
    };
  } catch (error) {
    if (
      error instanceof DoctorScheduleOverrideNotFoundError ||
      error instanceof DoctorScheduleNotFoundError ||
      error instanceof InvalidDoctorScheduleError
    ) {
      throw error;
    }

    console.warn('Database unavailable while updating schedule override, using local fallback store.', error);

    const weeklySchedule = await ensureFallbackDoctorSchedules(doctorUserId);
    const overrides = await readFallbackOverrides();
    const currentOverride = overrides.find(
      (entry) => entry.doctorUserId === doctorUserId && entry.dateKey === normalizedDateKey,
    );

    if (!currentOverride) {
      throw new DoctorScheduleOverrideNotFoundError();
    }

    const baseSchedule = weeklySchedule.find(
      (entry) => entry.dayOfWeek === getDayOfWeekFromDateKey(normalizedDateKey),
    );

    if (!baseSchedule) {
      throw new InvalidDoctorScheduleError('No existe horario base para actualizar la excepcion.');
    }

    const nextOverride = buildOverrideEntry(
      doctorUserId,
      { ...input, dateKey: normalizedDateKey },
      baseSchedule,
      currentOverride,
    );
    const nextOverrides = overrides.map((entry) =>
      entry.doctorUserId === doctorUserId && entry.dateKey === normalizedDateKey ? nextOverride : entry,
    );
    await writeFallbackOverrides(nextOverrides);

    return {
      sourceMode: 'local-fallback',
      override: nextOverride,
    };
  }
};

export const deleteDoctorScheduleOverride = async (
  doctorUserId: string,
  dateKey: string,
): Promise<{
  sourceMode: AvailabilitySourceMode;
}> => {
  const normalizedDateKey = normalizeDateKey(dateKey);

  if (!normalizedDateKey) {
    throw new InvalidDoctorScheduleError('La fecha de la excepcion debe tener formato YYYY-MM-DD.');
  }

  try {
    await ensureDatabaseDoctorSchedules(doctorUserId);
    const deleted = await prisma.doctorScheduleOverride.deleteMany({
      where: {
        doctorUserId,
        dateKey: normalizedDateKey,
      },
    });

    if (deleted.count === 0) {
      throw new DoctorScheduleOverrideNotFoundError();
    }

    return {
      sourceMode: 'database',
    };
  } catch (error) {
    if (
      error instanceof DoctorScheduleOverrideNotFoundError ||
      error instanceof DoctorScheduleNotFoundError ||
      error instanceof InvalidDoctorScheduleError
    ) {
      throw error;
    }

    console.warn('Database unavailable while deleting schedule override, using local fallback store.', error);

    await ensureFallbackDoctorSchedules(doctorUserId);
    const overrides = await readFallbackOverrides();
    const exists = overrides.some((entry) => entry.doctorUserId === doctorUserId && entry.dateKey === normalizedDateKey);

    if (!exists) {
      throw new DoctorScheduleOverrideNotFoundError();
    }

    const nextOverrides = overrides.filter(
      (entry) => !(entry.doctorUserId === doctorUserId && entry.dateKey === normalizedDateKey),
    );
    await writeFallbackOverrides(nextOverrides);

    return {
      sourceMode: 'local-fallback',
    };
  }
};

export const listDoctorAvailableSlots = async (
  doctorUserId: string,
  dateKey: string,
  options?: AvailabilityOptions,
): Promise<{
  sourceMode: AvailabilitySourceMode;
  dateKey: string;
  effectiveSchedule: ReturnType<typeof resolveEffectiveScheduleForDate>;
  slots: DoctorAvailabilitySlot[];
}> => {
  const normalizedDateKey = normalizeDateKey(dateKey);

  if (!normalizedDateKey) {
    throw new InvalidDoctorScheduleError('La fecha consultada debe tener formato YYYY-MM-DD.');
  }

  try {
    const weeklySchedule = await ensureDatabaseDoctorSchedules(doctorUserId);
    const overrides = (
      await prisma.doctorScheduleOverride.findMany({
        where: {
          doctorUserId,
          dateKey: normalizedDateKey,
        },
      })
    ).map(serializeDatabaseOverride);
    const effectiveSchedule = resolveEffectiveScheduleForDate(weeklySchedule, overrides, normalizedDateKey);
    const occupiedRanges = await getDatabaseOccupiedRanges(
      doctorUserId,
      normalizedDateKey,
      effectiveSchedule.slotDurationMinutes,
      options,
    );

    return {
      sourceMode: 'database',
      dateKey: normalizedDateKey,
      effectiveSchedule,
      slots: buildSlotsFromSchedule(normalizedDateKey, effectiveSchedule, occupiedRanges),
    };
  } catch (error) {
    if (error instanceof DoctorScheduleNotFoundError || error instanceof InvalidDoctorScheduleError) {
      throw error;
    }

    console.warn('Database unavailable while listing availability slots, using local fallback store.', error);

    const weeklySchedule = await ensureFallbackDoctorSchedules(doctorUserId);
    const overrides = (await readFallbackOverrides()).filter(
      (entry) => entry.doctorUserId === doctorUserId && entry.dateKey === normalizedDateKey,
    );
    const effectiveSchedule = resolveEffectiveScheduleForDate(weeklySchedule, overrides, normalizedDateKey);
    const occupiedRanges = await getFallbackOccupiedRanges(
      doctorUserId,
      normalizedDateKey,
      effectiveSchedule.slotDurationMinutes,
      options,
    );

    return {
      sourceMode: 'local-fallback',
      dateKey: normalizedDateKey,
      effectiveSchedule,
      slots: buildSlotsFromSchedule(normalizedDateKey, effectiveSchedule, occupiedRanges),
    };
  }
};

export const assertDoctorSlotAvailability = async (
  doctorUserId: string,
  appointmentStart: Date | string,
  options?: AvailabilityOptions,
) => {
  const requestedStart = appointmentStart instanceof Date ? new Date(appointmentStart) : new Date(appointmentStart);

  if (Number.isNaN(requestedStart.getTime())) {
    throw new InvalidDoctorScheduleError('La fecha de la cita no es valida.');
  }

  const dateKey = getDateKeyFromDate(requestedStart);
  const { effectiveSchedule, slots } = await listDoctorAvailableSlots(doctorUserId, dateKey, options);

  if (!effectiveSchedule.isWorkingDay) {
    throw new DoctorAvailabilityConflictError('El odontologo no labora en la fecha seleccionada.');
  }

  const hasMatchingSlot = slots.some((slot) => new Date(slot.startAt).getTime() === requestedStart.getTime());

  if (!hasMatchingSlot) {
    const formattedDate = new Intl.DateTimeFormat('es-PE', {
      timeZone: 'America/Lima',
      day: '2-digit',
      month: '2-digit',
      year: 'numeric',
    }).format(requestedStart);

    throw new DoctorAvailabilityConflictError(
      `El odontologo no tiene disponibilidad el ${formattedDate} a las ${formatSlotLabel(requestedStart)}.`,
    );
  }
};
