import { getTenantDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import {
  type SelectAgent,
  type SelectChannel,
  type SelectSlotTemplate,
  type SelectAppointment,
  type SelectAgentAbsence,
} from "../db/tenant-schema";

import { eq, and, between, sql, or, inArray } from "drizzle-orm";
import logger from "$lib/logger";
import { z } from "zod";
import { ValidationError } from "../utils/errors";
import { isValidTimeZone, toLocalTime, toLocalTimeIgnoringDst } from "../utils/timezone";

const scheduleRequestSchema = z.object({
  startDate: z.string().datetime({ offset: true }), // ISO date string with timezone
  endDate: z.string().datetime({ offset: true }), // ISO date string with timezone
  tenantId: z.string().uuid({ message: "Invalid tenant ID format" }),
  timeZone: z
    .string()
    .refine((tz) => isValidTimeZone(tz), { message: "Invalid IANA timezone format" })
    .default("UTC"),
  channelId: z.string().uuid({ message: "Invalid channel ID format" }).optional(),
  agentId: z.string().uuid({ message: "Invalid agent ID format" }).optional(),
  staffUserId: z.string().uuid({ message: "Invalid staff user ID format" }).optional(),
});

export type ScheduleRequest = z.infer<typeof scheduleRequestSchema>;

export interface TimeSlot {
  from: string; // UTC ISO timestamp
  to: string; // UTC ISO timestamp
  duration: number; // minutes
  availableAgents: SelectAgent[];
}

export interface AppointmentWithKeyShare extends SelectAppointment {
  staffKeyShare?: string;
}

export interface DaySchedule {
  date: string; // YYYY-MM-DD format
  channels: {
    [channelId: string]: {
      channel: SelectChannel;
      appointments: AppointmentWithKeyShare[];
      availableSlots: TimeSlot[];
    };
  };
}

export interface ScheduleResult {
  period: {
    startDate: string;
    endDate: string;
  };
  schedule: DaySchedule[];
}

export class ScheduleService {
  #db: Awaited<ReturnType<typeof getTenantDb>> | null = null;

  private constructor(public readonly tenantId: string) {}

  /**
   * Create a schedule service for a specific tenant
   * @param tenantId The ID of the tenant
   * @returns new ScheduleService instance
   */
  static async forTenant(tenantId: string) {
    const log = logger.setContext("ScheduleService");
    log.debug("Creating schedule service for tenant", { tenantId });

    try {
      const service = new ScheduleService(tenantId);
      service.#db = await getTenantDb(tenantId);

      log.debug("Schedule service created successfully", { tenantId });
      return service;
    } catch (error) {
      log.error("Failed to create schedule service", { tenantId, error: String(error) });
      throw error;
    }
  }

  /**
   * Generate calendar view for a specific time period
   * @param request Schedule request with date range
   * @returns Schedule result with available slots and appointments
   */
  async getSchedule(request: ScheduleRequest): Promise<ScheduleResult> {
    const log = logger.setContext("ScheduleService");

    const validation = scheduleRequestSchema.safeParse(request);
    if (!validation.success) {
      throw new ValidationError("Invalid schedule request");
    }

    log.debug("Generating schedule", {
      tenantId: this.tenantId,
      startDate: request.startDate,
      endDate: request.endDate,
    });

    try {
      const db = await this.getDb();

      // 1. Get all channels for the tenant
      let channels = await db
        .select()
        .from(tenantSchema.channel)
        .where(
          and(eq(tenantSchema.channel.pause, false), eq(tenantSchema.channel.archived, false)),
        ); // Only active channels
      if (request.channelId) {
        channels = channels.filter((channel) => channel.id === request.channelId);
      }

      // 2. Get all slot templates associated with channels
      const slotTemplates = await db
        .select({
          slotTemplate: tenantSchema.slotTemplate,
          channelId: tenantSchema.channelSlotTemplate.channelId,
        })
        .from(tenantSchema.slotTemplate)
        .innerJoin(
          tenantSchema.channelSlotTemplate,
          eq(tenantSchema.slotTemplate.id, tenantSchema.channelSlotTemplate.slotTemplateId),
        );

      // 3. Get appointments in the date range
      const appointments = await db
        .select()
        .from(tenantSchema.appointment)
        .where(
          and(
            between(
              tenantSchema.appointment.appointmentDate,
              new Date(request.startDate),
              new Date(request.endDate),
            ),
            or(
              eq(tenantSchema.appointment.status, "NEW"),
              eq(tenantSchema.appointment.status, "CONFIRMED"),
              eq(tenantSchema.appointment.status, "HELD"),
            ),
          ),
        );

      // 3a. If staffUserId is provided, get staffKeyShares for all appointment tunnels
      let staffKeyShares: Record<string, string> = {};
      if (request.staffUserId && appointments.length > 0) {
        const tunnelIds = [...new Set(appointments.map((apt) => apt.tunnelId))];
        const keyShares = await db
          .select()
          .from(tenantSchema.clientTunnelStaffKeyShare)
          .where(
            and(
              eq(tenantSchema.clientTunnelStaffKeyShare.userId, request.staffUserId),
              inArray(tenantSchema.clientTunnelStaffKeyShare.tunnelId, tunnelIds),
            ),
          );

        // Create a map of tunnelId -> encryptedTunnelKey
        staffKeyShares = keyShares.reduce(
          (acc, share) => {
            acc[share.tunnelId] = share.encryptedTunnelKey;
            return acc;
          },
          {} as Record<string, string>,
        );
      }

      // 4. Get agent absences in the date range
      const absences = await db
        .select()
        .from(tenantSchema.agentAbsence)
        .where(
          or(
            // Absence starts within period
            and(
              sql`${tenantSchema.agentAbsence.startDate} >= ${request.startDate}`,
              sql`${tenantSchema.agentAbsence.startDate} <= ${request.endDate}`,
            ),
            // Absence ends within period
            and(
              sql`${tenantSchema.agentAbsence.endDate} >= ${request.startDate}`,
              sql`${tenantSchema.agentAbsence.endDate} <= ${request.endDate}`,
            ),
            // Absence spans entire period
            and(
              sql`${tenantSchema.agentAbsence.startDate} <= ${request.startDate}`,
              sql`${tenantSchema.agentAbsence.endDate} >= ${request.endDate}`,
            ),
          ),
        );

      // 5. Get channel-agent assignments
      let channelAgents = await db
        .select({
          channelId: tenantSchema.channelAgent.channelId,
          agent: tenantSchema.agent,
        })
        .from(tenantSchema.channelAgent)
        .innerJoin(
          tenantSchema.agent,
          eq(tenantSchema.channelAgent.agentId, tenantSchema.agent.id),
        );
      if (request.agentId) {
        channelAgents = channelAgents.filter((ca) => ca.agent.id === request.agentId);
      }

      // 6. Generate daily schedules
      const schedule = await this.generateDailySchedules({
        startDate: new Date(request.startDate),
        endDate: new Date(request.endDate),
        channels,
        slotTemplates,
        appointments,
        absences,
        channelAgents,
        staffKeyShares,
        timeZone: request.timeZone,
      });

      log.debug("Schedule generated successfully", {
        tenantId: this.tenantId,
        daysGenerated: schedule.length,
      });

      return {
        period: {
          startDate: request.startDate,
          endDate: request.endDate,
        },
        schedule,
      };
    } catch (error) {
      log.error("Failed to generate schedule", {
        tenantId: this.tenantId,
        error: String(error),
      });
      throw error;
    }
  }

  /**
   * Generate daily schedules for the given period and data
   */
  private async generateDailySchedules({
    startDate,
    endDate,
    channels,
    slotTemplates,
    appointments,
    absences,
    channelAgents,
    staffKeyShares,
    timeZone,
  }: {
    startDate: Date;
    endDate: Date;
    channels: SelectChannel[];
    slotTemplates: { slotTemplate: SelectSlotTemplate; channelId: string }[];
    appointments: SelectAppointment[];
    absences: SelectAgentAbsence[];
    channelAgents: { channelId: string; agent: SelectAgent }[];
    staffKeyShares: Record<string, string>;
    timeZone: string;
  }): Promise<DaySchedule[]> {
    const dailySchedules: DaySchedule[] = [];

    // Iterate through each day in the period
    const currentDate = new Date(startDate);
    currentDate.setUTCHours(0, 0, 0, 0);

    const normalizedEndDate = new Date(endDate);
    normalizedEndDate.setUTCHours(23, 59, 59, 999);

    while (currentDate <= normalizedEndDate) {
      const dateString = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD
      const weekday = currentDate.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
      const weekdayBit = weekday === 0 ? 64 : Math.pow(2, weekday - 1); // Convert to bitmask
      const dayAppointments = appointments.filter((appointment) =>
        new Date(appointment.appointmentDate).toISOString().startsWith(dateString),
      );

      const daySchedule: DaySchedule = {
        date: dateString,
        channels: {},
      };

      // Process each channel
      for (const channel of channels) {
        // Get appointments for this channel on this day
        const dayChannelAppointments = dayAppointments
          .filter((appointment) => appointment.channelId === channel.id)
          .map((appointment) => ({
            ...appointment,
            staffKeyShare: staffKeyShares[appointment.tunnelId],
          }));

        // Get slot templates for this channel that apply to this weekday
        const channelSlotTemplates = slotTemplates
          .filter(
            (st) =>
              st.channelId === channel.id &&
              st.slotTemplate.weekdays !== null &&
              (st.slotTemplate.weekdays & weekdayBit) !== 0,
          )
          .map((st) => st.slotTemplate);

        // Get agents assigned to this channel
        const channelAgentsList = channelAgents
          .filter((ca) => ca.channelId === channel.id)
          .map((ca) => ca.agent);

        // Generate available slots for this channel
        const availableSlots = this.generateAvailableSlots({
          date: currentDate,
          slotTemplates: channelSlotTemplates,
          appointments: dayAppointments,
          agents: channelAgentsList,
          absences,
          timeZone,
        });

        daySchedule.channels[channel.id] = {
          channel,
          appointments: dayChannelAppointments,
          availableSlots,
        };
      }

      dailySchedules.push(daySchedule);

      // Move to next day
      currentDate.setUTCDate(currentDate.getUTCDate() + 1);
    }

    return dailySchedules;
  }

  /**
   * Generate available time slots for a specific day and channel
   */
  private generateAvailableSlots({
    date,
    slotTemplates,
    appointments,
    agents,
    absences,
    timeZone,
  }: {
    date: Date;
    slotTemplates: SelectSlotTemplate[];
    appointments: SelectAppointment[];
    agents: SelectAgent[];
    absences: SelectAgentAbsence[];
    timeZone: string;
  }): TimeSlot[] {
    const availableSlots: TimeSlot[] = [];

    for (const template of slotTemplates) {
      // Parse template times
      const [fromHour, fromMinute] = template.from.split(":").map(Number);
      const [toHour, toMinute] = template.to.split(":").map(Number);

      // Generate slots based on duration
      const slotDuration = template.duration;
      let currentTime = fromHour * 60 + fromMinute; // minutes from midnight
      const endTime = toHour * 60 + toMinute;

      while (currentTime + slotDuration <= endTime) {
        const slotStartHour = Math.floor(currentTime / 60);
        const slotStartMinute = currentTime % 60;
        const slotEndTime = currentTime + slotDuration;
        const slotEndHour = Math.floor(slotEndTime / 60);
        const slotEndMinute = slotEndTime % 60;

        const slotStartDateTime = new Date(
          Date.UTC(
            date.getUTCFullYear(),
            date.getUTCMonth(),
            date.getUTCDate(),
            slotStartHour,
            slotStartMinute,
            0,
            0,
          ),
        );
        const slotEndDateTime = new Date(
          Date.UTC(
            date.getUTCFullYear(),
            date.getUTCMonth(),
            date.getUTCDate(),
            slotEndHour,
            slotEndMinute,
            0,
            0,
          ),
        );

        const availableAgents = agents.filter((agent) => {
          if (
            this.isAgentAbsent(agent.id, slotStartDateTime, slotEndDateTime, absences, timeZone)
          ) {
            return false;
          }

          return !this.hasAgentAppointmentConflict(
            agent.id,
            slotStartDateTime,
            slotEndDateTime,
            appointments,
            timeZone,
          );
        });

        // Only include slot if there are available agents
        if (availableAgents.length > 0) {
          availableSlots.push({
            from: slotStartDateTime.toISOString(),
            to: slotEndDateTime.toISOString(),
            duration: slotDuration,
            availableAgents,
          });
        }

        currentTime += slotDuration;
      }
    }

    return availableSlots;
  }

  /**
   * Check if an agent is absent during a specific time slot
   */
  private isAgentAbsent(
    agentId: string,
    slotStartDateTime: Date,
    slotEndDateTime: Date,
    absences: SelectAgentAbsence[],
    timeZone: string,
  ): boolean {
    return absences.some((absence) => {
      if (absence.agentId !== agentId) return false;

      const absenceStart = toLocalTime(new Date(absence.startDate), timeZone);
      const absenceEnd = toLocalTime(new Date(absence.endDate), timeZone);
      const slotStart = toLocalTimeIgnoringDst(slotStartDateTime, timeZone);
      const slotEnd = toLocalTimeIgnoringDst(slotEndDateTime, timeZone);

      // For time-specific absences, check if the time slot overlaps
      const slotStartsDuringAbsence = slotStart >= absenceStart && slotStart < absenceEnd;
      const slotEndsDuringAbsence = slotEnd > absenceStart && slotEnd <= absenceEnd;
      const slotCoversEntireAbsence = slotStart <= absenceStart && slotEnd >= absenceEnd;
      return slotStartsDuringAbsence || slotEndsDuringAbsence || slotCoversEntireAbsence;
    });
  }

  /**
   * Check if an agent already has an appointment that overlaps with a specific slot
   */
  private hasAgentAppointmentConflict(
    agentId: string,
    slotStartDateTime: Date,
    slotEndDateTime: Date,
    appointments: SelectAppointment[],
    timeZone: string,
  ): boolean {
    return appointments.some((appointment) => {
      if (appointment.agentId !== agentId) return false;

      const appointmentStart = new Date(appointment.appointmentDate);
      const appointmentDuration = Number.isFinite(appointment.duration) ? appointment.duration : 0;
      const appointmentEnd = new Date(appointmentStart.getTime() + appointmentDuration * 60 * 1000);
      const slotStart = toLocalTimeIgnoringDst(slotStartDateTime, timeZone);
      const slotEnd = toLocalTimeIgnoringDst(slotEndDateTime, timeZone);

      return (
        slotStart < toLocalTime(appointmentEnd, timeZone) &&
        slotEnd > toLocalTime(appointmentStart, timeZone)
      );
    });
  }

  /**
   * Get the tenant's database connection (cached)
   */
  private async getDb() {
    if (!this.#db) {
      this.#db = await getTenantDb(this.tenantId);
    }
    return this.#db;
  }
}
