import { DateTime } from 'luxon';
import { AvailabilityRepository } from '../models/repositories/AvailabilityRepository';
import { UserRepository } from '../models/repositories/UserRepository';
import { EventTypeRepository } from '../models/repositories/EventTypeRepository';
import { BookingRepository } from '../models/repositories/BookingRepository';
import { BusyBlockRepository } from '../models/repositories/BusyBlockRepository';
import { BusyCacheService } from './BusyCacheService';
import { AppError } from '../utils/AppError';
import {
  WeeklyRule,
  TimeInterval,
  parseTimeOnDate,
  eachDateInRange,
  getDayOfWeek,
  subtractBusyFromInterval,
  intervalsOverlap,
  formatDisplayIso,
  isValidTimezone,
} from '../utils/timezone';
import { SlotResult } from '../views/SlotView';
import { AvailabilitySchedule } from '../models/entities/AvailabilitySchedule.entity';
import { EventType } from '../models/entities/EventType.entity';
import { TimezoneService } from './TimezoneService';
import { generateUuid } from '../utils/uuid';

export class AvailabilityService {
  constructor(
    private availabilityRepo = new AvailabilityRepository(),
    private userRepo = new UserRepository(),
    private eventTypeRepo = new EventTypeRepository(),
    private bookingRepo = new BookingRepository(),
    private busyBlockRepo = new BusyBlockRepository(),
    private busyCache = new BusyCacheService(),
    private timezoneService = new TimezoneService(),
  ) {}

  async getScheduleForEventType(userId: string, eventTypeId: string) {
    await this.assertEventTypeOwnership(userId, eventTypeId);

    const user = await this.userRepo.findById(userId);
    if (!user) throw new AppError('not_found', 404, 'User not found.');

    let schedule = await this.availabilityRepo.findByEventTypeIdForUser(userId, eventTypeId);
    if (!schedule) {
      schedule = await this.availabilityRepo.createSchedule({
        id: generateUuid(),
        userId,
        eventTypeId,
        minNoticeHours: 4,
        maxAdvanceDays: 60,
        timezone: user.timezone,
        weeklyRules: [],
      });
    }

    return { schedule, user };
  }

  async updateScheduleForEventType(
    userId: string,
    eventTypeId: string,
    data: {
      timezone?: string;
      minNoticeHours?: number;
      maxAdvanceDays?: number;
      weeklyRules?: WeeklyRule[];
      dateOverrides?: Array<{
        date: string;
        available: boolean;
        intervals?: TimeInterval[];
      }>;
    },
  ) {
    const { schedule } = await this.getScheduleForEventType(userId, eventTypeId);

    if (data.timezone !== undefined) {
      await this.timezoneService.assertActiveTimezone(data.timezone);
      schedule.timezone = data.timezone;
    }
    if (data.minNoticeHours !== undefined) schedule.minNoticeHours = data.minNoticeHours;
    if (data.maxAdvanceDays !== undefined) schedule.maxAdvanceDays = data.maxAdvanceDays;
    if (data.weeklyRules !== undefined) {
      this.assertValidWeeklyRules(data.weeklyRules);
      schedule.weeklyRules = this.sortWeeklyRules(data.weeklyRules);
    }

    await this.availabilityRepo.saveSchedule(schedule);

    if (data.dateOverrides !== undefined) {
      await this.availabilityRepo.replaceOverrides(
        schedule.id,
        data.dateOverrides.map((o) => ({
          id: generateUuid(),
          scheduleId: schedule.id,
          overrideDate: o.date,
          available: o.available,
          intervals: o.intervals ?? null,
        })),
      );
    }

    // Do not clear external calendar busy cache — availability edits are unrelated
    return this.getScheduleForEventType(userId, eventTypeId);
  }

  async copyScheduleFromEventType(
    userId: string,
    targetEventTypeId: string,
    sourceEventTypeId: string,
  ) {
    if (targetEventTypeId === sourceEventTypeId) {
      throw new AppError('validation_error', 400, 'Cannot copy availability from the same event type.');
    }

    await this.assertEventTypeOwnership(userId, targetEventTypeId);
    await this.assertEventTypeOwnership(userId, sourceEventTypeId);

    const source = await this.availabilityRepo.findByEventTypeIdForUser(userId, sourceEventTypeId);
    if (!source) {
      throw new AppError('not_found', 404, 'Source availability schedule not found.');
    }

    const { schedule: target } = await this.getScheduleForEventType(userId, targetEventTypeId);

    target.timezone = source.timezone;
    target.minNoticeHours = source.minNoticeHours;
    target.maxAdvanceDays = source.maxAdvanceDays;
    target.weeklyRules = JSON.parse(JSON.stringify(source.weeklyRules ?? []));

    await this.availabilityRepo.saveSchedule(target);

    const sourceOverrides = source.dateOverrides ?? [];
    await this.availabilityRepo.replaceOverrides(
      target.id,
      sourceOverrides.map((o) => ({
        id: generateUuid(),
        scheduleId: target.id,
        overrideDate: o.overrideDate,
        available: o.available,
        intervals: o.intervals ?? null,
      })),
    );

    return this.getScheduleForEventType(userId, targetEventTypeId);
  }

  async createDefaultSchedule(userId: string, eventTypeId: string, timezone: string) {
    return this.availabilityRepo.createSchedule({
      id: generateUuid(),
      userId,
      eventTypeId,
      minNoticeHours: 4,
      maxAdvanceDays: 60,
      timezone,
      weeklyRules: [],
    });
  }

  async getAvailableSlots(params: {
    username: string;
    eventSlug: string;
    from: string;
    to: string;
    guestTimezone: string;
  }): Promise<SlotResult[]> {
    if (!isValidTimezone(params.guestTimezone)) {
      throw new AppError('validation_error', 400, 'Invalid timezone.');
    }

    const days = eachDateInRange(params.from, params.to);
    if (days.length > 31) {
      throw new AppError('validation_error', 400, 'Date range cannot exceed 31 days.');
    }

    const eventType = await this.eventTypeRepo.findPublicByUsernameAndSlug(
      params.username,
      params.eventSlug,
    );
    if (!eventType) throw new AppError('not_found', 404, 'Event not found.');

    const user = await this.userRepo.findById(eventType.userId);
    if (!user) throw new AppError('not_found', 404, 'Host not found.');

    const schedule = await this.availabilityRepo.findByEventTypeId(eventType.id);
    if (!schedule || schedule.weeklyRules.length === 0) return [];

    const hostTimezone = schedule.timezone ?? user.timezone;
    return this.computeSlots(
      user.id,
      hostTimezone,
      schedule,
      eventType,
      params.from,
      params.to,
      params.guestTimezone,
    );
  }

  async getAvailableSlotsForUser(params: {
    userId: string;
    eventTypeId: string;
    from: string;
    to: string;
    guestTimezone: string;
  }): Promise<SlotResult[]> {
    if (!isValidTimezone(params.guestTimezone)) {
      throw new AppError('validation_error', 400, 'Invalid timezone.');
    }

    const days = eachDateInRange(params.from, params.to);
    if (days.length > 31) {
      throw new AppError('validation_error', 400, 'Date range cannot exceed 31 days.');
    }

    const eventType = await this.assertEventTypeOwnership(params.userId, params.eventTypeId);
    const user = await this.userRepo.findById(params.userId);
    if (!user) throw new AppError('not_found', 404, 'User not found.');

    const schedule = await this.availabilityRepo.findByEventTypeIdForUser(params.userId, eventType.id);
    if (!schedule || schedule.weeklyRules.length === 0) return [];

    const hostTimezone = schedule.timezone ?? user.timezone;
    return this.computeSlots(
      user.id,
      hostTimezone,
      schedule,
      eventType,
      params.from,
      params.to,
      params.guestTimezone,
    );
  }

  async findAlternativesAfter(params: {
    userId: string;
    eventTypeId: string;
    after: Date;
    guestTimezone: string;
    limit?: number;
  }): Promise<SlotResult[]> {
    return this.findOpenTimesAfter(params);
  }

  /**
   * Next bookable starts after `after`. Uses weekly hours when configured;
   * otherwise duration-stepped times that are not already booked / busy.
   */
  async findOpenTimesAfter(params: {
    userId: string;
    eventTypeId: string;
    after: Date;
    guestTimezone: string;
    limit?: number;
  }): Promise<SlotResult[]> {
    const limit = params.limit ?? 8;
    const after = DateTime.fromJSDate(params.after, { zone: 'utc' });
    const from = after.toISODate();
    const to = after.plus({ days: 7 }).toISODate();
    if (!from || !to) return [];

    const weeklySlots = await this.getAvailableSlotsForUser({
      userId: params.userId,
      eventTypeId: params.eventTypeId,
      from,
      to,
      guestTimezone: params.guestTimezone,
    });
    const fromWeekly = weeklySlots
      .filter((slot) => slot.startTime.getTime() > params.after.getTime())
      .slice(0, limit);
    if (fromWeekly.length > 0) return fromWeekly;

    return this.computeFallbackOpenSlots(params, from, to, limit);
  }

  private async computeFallbackOpenSlots(
    params: {
      userId: string;
      eventTypeId: string;
      after: Date;
      guestTimezone: string;
    },
    from: string,
    to: string,
    limit: number,
  ): Promise<SlotResult[]> {
    const eventType = await this.assertEventTypeOwnership(params.userId, params.eventTypeId);
    const zone = isValidTimezone(params.guestTimezone) ? params.guestTimezone : 'UTC';
    const duration = eventType.durationMinutes;
    const afterMs = params.after.getTime();
    const results: SlotResult[] = [];

    const rangeStart = DateTime.fromISO(from).toJSDate();
    const rangeEnd = DateTime.fromISO(to).plus({ days: 1 }).toJSDate();
    const bookings = await this.bookingRepo.findConfirmedInRange(params.userId, rangeStart, rangeEnd);
    const bookingBlocks = bookings.map((b) => ({
      start: new Date(b.startTime.getTime() - eventType.bufferBeforeMinutes * 60000),
      end: new Date(b.endTime.getTime() + eventType.bufferAfterMinutes * 60000),
    }));
    const externalBusy = await this.loadExternalBusy(params.userId, from, to);
    const allBusy = [...bookingBlocks, ...externalBusy];

    const dates = eachDateInRange(from, to);
    for (const date of dates) {
      let cursor = DateTime.fromISO(date, { zone }).set({ hour: 8, minute: 0, second: 0, millisecond: 0 });
      const dayEnd = DateTime.fromISO(date, { zone }).set({ hour: 20, minute: 0, second: 0, millisecond: 0 });
      while (cursor.plus({ minutes: duration }) <= dayEnd && results.length < limit) {
        const startDate = cursor.toUTC().toJSDate();
        const endDate = cursor.plus({ minutes: duration }).toUTC().toJSDate();
        const bufferStart = new Date(startDate.getTime() - eventType.bufferBeforeMinutes * 60000);
        const bufferEnd = new Date(endDate.getTime() + eventType.bufferAfterMinutes * 60000);
        if (startDate.getTime() > afterMs) {
          const blocked = allBusy.some((block) =>
            intervalsOverlap(bufferStart, bufferEnd, block.start, block.end),
          );
          if (!blocked) {
            results.push({
              startTime: startDate,
              endTime: endDate,
              displayStart: formatDisplayIso(startDate, zone),
              displayEnd: formatDisplayIso(endDate, zone),
            });
          }
        }
        cursor = cursor.plus({ minutes: duration });
      }
      if (results.length >= limit) break;
    }

    return results;
  }

  async isSlotBusy(userId: string, start: Date, end: Date, eventType: EventType): Promise<boolean> {
    const bufferStart = new Date(start.getTime() - eventType.bufferBeforeMinutes * 60000);
    const bufferEnd = new Date(end.getTime() + eventType.bufferAfterMinutes * 60000);

    const bookings = await this.bookingRepo.findConfirmedInRange(userId, bufferStart, bufferEnd);
    for (const b of bookings) {
      const bStart = new Date(b.startTime.getTime() - eventType.bufferBeforeMinutes * 60000);
      const bEnd = new Date(b.endTime.getTime() + eventType.bufferAfterMinutes * 60000);
      if (intervalsOverlap(bufferStart, bufferEnd, bStart, bEnd)) return true;
    }

    const from = start.toISOString().slice(0, 10);
    const to = end.toISOString().slice(0, 10);
    const busyBlocks = await this.loadExternalBusy(userId, from, to);
    for (const block of busyBlocks) {
      if (intervalsOverlap(bufferStart, bufferEnd, block.start, block.end)) return true;
    }

    return false;
  }

  /**
   * True when [start, end] falls inside the host's configured weekly hours / date override
   * and within min-notice / max-advance. If no weekly rules are set, returns true
   * (busy / overlap checks still apply separately).
   */
  async isWithinAvailableHours(
    userId: string,
    start: Date,
    end: Date,
    eventType: EventType,
  ): Promise<boolean> {
    const user = await this.userRepo.findById(userId);
    if (!user) return false;

    const schedule = await this.availabilityRepo.findByEventTypeIdForUser(userId, eventType.id);
    if (!schedule || schedule.weeklyRules.length === 0) {
      return true;
    }

    const startUtc = DateTime.fromJSDate(start, { zone: 'utc' });
    const now = DateTime.utc();
    if (startUtc < now.plus({ hours: schedule.minNoticeHours })) return false;
    if (startUtc > now.plus({ days: schedule.maxAdvanceDays })) return false;

    const hostTimezone = schedule.timezone ?? user.timezone;
    const startLocal = startUtc.setZone(hostTimezone);
    const endLocal = DateTime.fromJSDate(end, { zone: 'utc' }).setZone(hostTimezone);
    const date = startLocal.toISODate();
    if (!date) return false;

    const intervals = this.getWorkingIntervals(date, hostTimezone, schedule);
    return intervals.some(
      (interval) => startLocal >= interval.start && endLocal <= interval.end,
    );
  }

  /** Redis first; fall back to MySQL busy_blocks and warm the cache. */
  private async loadExternalBusy(
    userId: string,
    from: string,
    to: string,
  ): Promise<Array<{ start: Date; end: Date }>> {
    const cached = await this.busyCache.getBusyBlocks(userId, from, to);
    if (cached.length > 0) return cached;

    const fromDate = DateTime.fromISO(from).startOf('day').toJSDate();
    const toDate = DateTime.fromISO(to).endOf('day').toJSDate();
    const fromDb = await this.busyBlockRepo.findInRange(userId, fromDate, toDate);
    if (fromDb.length === 0) return [];

    const byDate = new Map<string, Array<{ start: Date; end: Date }>>();
    for (const block of fromDb) {
      const date = block.start.toISOString().slice(0, 10);
      if (!byDate.has(date)) byDate.set(date, []);
      byDate.get(date)!.push(block);
    }
    for (const [date, blocks] of byDate) {
      await this.busyCache.setBusyBlocks(userId, date, blocks);
    }
    return fromDb;
  }

  private async assertEventTypeOwnership(userId: string, eventTypeId: string) {
    const eventType = await this.eventTypeRepo.findById(eventTypeId);
    if (!eventType || eventType.userId !== userId) {
      throw new AppError('not_found', 404, 'Event type not found.');
    }
    return eventType;
  }

  private async computeSlots(
    userId: string,
    hostTimezone: string,
    schedule: AvailabilitySchedule,
    eventType: EventType,
    from: string,
    to: string,
    guestTimezone: string,
  ): Promise<SlotResult[]> {
    const now = DateTime.utc();
    const minNotice = now.plus({ hours: schedule.minNoticeHours });
    const maxAdvance = now.plus({ days: schedule.maxAdvanceDays });

    const bookings = await this.bookingRepo.findConfirmedInRange(
      userId,
      DateTime.fromISO(from).toJSDate(),
      DateTime.fromISO(to).plus({ days: 1 }).toJSDate(),
    );

    const bookingBlocks = bookings.map((b) => ({
      start: new Date(b.startTime.getTime() - eventType.bufferBeforeMinutes * 60000),
      end: new Date(b.endTime.getTime() + eventType.bufferAfterMinutes * 60000),
    }));

    const externalBusy = await this.loadExternalBusy(userId, from, to);
    const allBusy = [...bookingBlocks, ...externalBusy];

    const slots: SlotResult[] = [];
    const dates = eachDateInRange(from, to);

    for (const date of dates) {
      const intervals = this.getWorkingIntervals(date, hostTimezone, schedule);
      for (const interval of intervals) {
        const freeSlots = subtractBusyFromInterval(interval.start, interval.end, allBusy);
        for (const free of freeSlots) {
          let cursor = free.start;
          while (cursor.plus({ minutes: eventType.durationMinutes }) <= free.end) {
            const slotStart = cursor;
            const slotEnd = cursor.plus({ minutes: eventType.durationMinutes });

            const slotStartUtc = slotStart.toUTC();
            if (slotStartUtc >= minNotice && slotStartUtc <= maxAdvance) {
              const startDate = slotStart.toUTC().toJSDate();
              const endDate = slotEnd.toUTC().toJSDate();
              slots.push({
                startTime: startDate,
                endTime: endDate,
                displayStart: formatDisplayIso(startDate, guestTimezone),
                displayEnd: formatDisplayIso(endDate, guestTimezone),
              });
            }

            cursor = cursor.plus({ minutes: eventType.durationMinutes });
          }
        }
      }
    }

    return slots;
  }

  private getWorkingIntervals(
    date: string,
    timezone: string,
    schedule: AvailabilitySchedule,
  ): Array<{ start: DateTime; end: DateTime }> {
    const override = (schedule.dateOverrides ?? []).find((o) => o.overrideDate === date);

    if (override) {
      if (!override.available) return [];
      return (override.intervals ?? []).map((i) => ({
        start: parseTimeOnDate(date, i.startTime, timezone),
        end: parseTimeOnDate(date, i.endTime, timezone),
      }));
    }

    const dow = getDayOfWeek(date, timezone);
    const rules = schedule.weeklyRules.filter((r) => r.dayOfWeek === dow);
    return rules.map((r) => ({
      start: parseTimeOnDate(date, r.startTime, timezone),
      end: parseTimeOnDate(date, r.endTime, timezone),
    }));
  }

  private sortWeeklyRules(rules: WeeklyRule[]): WeeklyRule[] {
    return [...rules].sort((a, b) => {
      if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
      return this.timeToMinutes(a.startTime) - this.timeToMinutes(b.startTime);
    });
  }

  private assertValidWeeklyRules(rules: WeeklyRule[]): void {
    const byDay = new Map<number, WeeklyRule[]>();
    for (const rule of rules) {
      const start = this.timeToMinutes(rule.startTime);
      const end = this.timeToMinutes(rule.endTime);
      if (!(end > start)) {
        throw new AppError(
          'validation_error',
          400,
          'End time must be later than start time on every availability slot.',
        );
      }
      if (!byDay.has(rule.dayOfWeek)) byDay.set(rule.dayOfWeek, []);
      byDay.get(rule.dayOfWeek)!.push(rule);
    }

    for (const [, dayRules] of byDay) {
      if (dayRules.length > 5) {
        throw new AppError('validation_error', 400, 'Maximum 5 time slots per day.');
      }
      const sorted = this.sortWeeklyRules(dayRules);
      for (let i = 0; i < sorted.length; i++) {
        for (let j = i + 1; j < sorted.length; j++) {
          const aStart = this.timeToMinutes(sorted[i].startTime);
          const aEnd = this.timeToMinutes(sorted[i].endTime);
          const bStart = this.timeToMinutes(sorted[j].startTime);
          const bEnd = this.timeToMinutes(sorted[j].endTime);
          if (aStart === bStart && aEnd === bEnd) {
            throw new AppError('validation_error', 400, 'Duplicate availability slots are not allowed.');
          }
          if (aStart < bEnd && aEnd > bStart) {
            throw new AppError(
              'validation_error',
              400,
              'Overlapping availability slots on the same day are not allowed.',
            );
          }
        }
      }
    }
  }

  private timeToMinutes(time: string): number {
    const [h, m] = time.split(':').map(Number);
    return h * 60 + m;
  }
}
