import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
  ServiceUnavailableException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { randomBytes, randomUUID } from 'node:crypto'
import { Model, Types } from 'mongoose'
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
import { EventTypeDocument } from '../../schemas/event-type.schema.js'
import { HostDocument } from '../../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockDocument } from '../../schemas/slot-lock.schema.js'
import { confirmationEmail, cancellationEmail } from '../email/booking-templates.js'
import { buildBookingIcs } from '../email/ics.js'
import { JmapMailer } from '../email/jmap-mailer.service.js'
import { SlotService } from '../slots/slot.service.js'
import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'

const HOLD_MS = 10 * 60 * 1000

// Tenant identity needed for branding the calendar event + email.
export interface BookingTenantRef {
  _id: Types.ObjectId
  slug: string
  name: string
  brandColor?: string
}

export interface BookingContext {
  tenant: BookingTenantRef
  host: HostDocument
  eventType: EventTypeDocument
}

export interface ConfirmBookingInput {
  startUtc: Date
  attendeeName: string
  attendeeEmail: string
  attendeeTimezone: string
  attendeeNotes?: string
  holdId?: string
}

@Injectable()
export class BookingsService {
  private readonly logger = new Logger(BookingsService.name)
  private readonly bookingPublicUrl: string
  private readonly meetBaseUrl: string

  constructor(
    @InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
    @InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
    private readonly slots: SlotService,
    private readonly provisioner: CredentialProvisioner,
    private readonly gateway: JmapCalendarGateway,
    private readonly mailer: JmapMailer,
    config: ConfigService,
  ) {
    this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
    this.meetBaseUrl = (config.get<string>('MEET_PUBLIC_URL') ?? 'https://meet.dezky.local').replace(/\/$/, '')
  }

  // ── Holds (optional reservation during checkout) ───────────────────────────
  async hold(ctx: BookingContext, startUtc: Date): Promise<{ holdId: string; expiresAt: Date }> {
    const endUtc = new Date(startUtc.getTime() + ctx.eventType.durationMinutes * 60_000)
    const holdId = randomBytes(18).toString('hex')
    const expiresAt = new Date(Date.now() + HOLD_MS)
    try {
      await this.lockModel.create({
        tenantId: ctx.tenant._id,
        hostId: ctx.host._id,
        startUtc,
        endUtc,
        expiresAt,
        holdToken: holdId,
        bookingId: null,
      })
    } catch (err: any) {
      if (err?.code === 11000) throw new ConflictException('That time is currently being booked by someone else.')
      throw err
    }
    return { holdId, expiresAt }
  }

  // ── Confirm ────────────────────────────────────────────────────────────────
  async confirm(ctx: BookingContext, input: ConfirmBookingInput): Promise<BookingDocument> {
    return this.createConfirmedBooking(ctx, input, {})
  }

  private async createConfirmedBooking(
    ctx: BookingContext,
    input: ConfirmBookingInput,
    opts: { rescheduledFromBookingId?: Types.ObjectId },
  ): Promise<BookingDocument> {
    const { host, eventType, tenant } = ctx
    if (!host.isActive || !eventType.isActive) throw new BadRequestException('This booking page is not available.')

    const now = new Date()
    const startUtc = input.startUtc
    const endUtc = new Date(startUtc.getTime() + eventType.durationMinutes * 60_000)

    // (a) Validate the time is genuinely offered AND free against live free/busy
    // (this performs the §8.2 live re-check via the calendar gateway).
    const offered = await this.slots.availableSlots(host, eventType, startUtc, endUtc, now)
    if (!offered.some((s) => s.startUtc.getTime() === startUtc.getTime())) {
      throw new ConflictException('That time is no longer available.')
    }

    // (b) Persist a pending booking so we have an id to attach to the lock.
    const calendarEventUid = randomUUID()
    const manageToken = randomBytes(24).toString('hex')
    const location = this.resolveLocation(ctx)
    const booking = await this.bookingModel.create({
      tenantId: tenant._id,
      eventTypeId: eventType._id,
      hostId: host._id,
      status: 'pending',
      startUtc,
      endUtc,
      attendeeName: input.attendeeName,
      attendeeEmail: input.attendeeEmail,
      attendeeTimezone: input.attendeeTimezone,
      attendeeNotes: input.attendeeNotes,
      calendarEventUid,
      manageToken,
      locationType: location.type,
      locationUrl: location.url,
      rescheduledFromBookingId: opts.rescheduledFromBookingId ?? null,
      reminderState: 'none',
    })

    // (c) §8.2 layer 1 — atomic slot claim. Claim our own hold by token if given;
    // otherwise insert a fresh unique lock (dup-key => slot already taken).
    let claimed = false
    if (input.holdId) {
      const upd = await this.lockModel
        .findOneAndUpdate(
          { hostId: host._id, startUtc, holdToken: input.holdId, bookingId: null },
          { $set: { bookingId: booking._id, expiresAt: null, endUtc } },
        )
        .exec()
      claimed = !!upd
    }
    if (!claimed) {
      try {
        await this.lockModel.create({
          tenantId: tenant._id,
          hostId: host._id,
          startUtc,
          endUtc,
          expiresAt: null,
          bookingId: booking._id,
          holdToken: null,
        })
      } catch (err: any) {
        await this.bookingModel.deleteOne({ _id: booking._id }).exec()
        if (err?.code === 11000) throw new ConflictException('That time was just taken.')
        throw err
      }
    }

    // (d) Write to the host's Stalwart calendar; promote to confirmed on success.
    let access: HostCalendarAccess
    try {
      access = await this.provisioner.resolveAccess(host)
      const { id } = await this.gateway.createEvent(access, {
        uid: calendarEventUid,
        title: eventType.title,
        description: input.attendeeNotes,
        startUtc,
        endUtc,
        hostTimezone: host.timezone,
        location: location.url,
        hostEmail: host.email,
        attendeeName: input.attendeeName,
        attendeeEmail: input.attendeeEmail,
      })
      booking.calendarEventId = id
      booking.status = 'confirmed'
      await booking.save()
    } catch (err) {
      // Compensate: never leave a confirmed-looking booking with no calendar event.
      await this.lockModel.deleteOne({ hostId: host._id, startUtc, bookingId: booking._id }).exec()
      await this.bookingModel.deleteOne({ _id: booking._id }).exec()
      this.logger.error(`Calendar write failed for ${host.email}: ${(err as Error).message}`)
      throw new ServiceUnavailableException('Could not complete the booking on the calendar — please try again.')
    }

    // (e) Branded confirmation email — best-effort (booking already valid).
    this.sendEmail(ctx, booking, access, 'confirmation').catch((e) =>
      this.logger.warn(`Confirmation email failed for ${booking.attendeeEmail}: ${e.message}`),
    )

    return booking
  }

  // ── Manage / cancel / reschedule ───────────────────────────────────────────
  async getByManageToken(token: string): Promise<BookingDocument> {
    const booking = await this.bookingModel.findOne({ manageToken: token }).exec()
    if (!booking) throw new NotFoundException('Booking not found')
    return booking
  }

  // Tenant-scoped lookup for the admin surface (cancel from the bookings list).
  async getForTenant(tenantId: Types.ObjectId, id: string): Promise<BookingDocument> {
    const booking = await this.bookingModel.findOne({ _id: id, tenantId }).exec()
    if (!booking) throw new NotFoundException('Booking not found')
    return booking
  }

  async cancel(token: string, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
    return this.performCancel(await this.getByManageToken(token), reason, ctx)
  }

  // Admin cancel by booking doc (already tenant-checked by the caller).
  async cancelResolved(booking: BookingDocument, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
    return this.performCancel(booking, reason, ctx)
  }

  private async performCancel(booking: BookingDocument, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
    if (booking.status === 'cancelled') return booking

    const access = await this.provisioner.resolveAccess(ctx.host)
    if (booking.calendarEventId) {
      await this.gateway.deleteEvent(access, booking.calendarEventId).catch((e) =>
        this.logger.warn(`Calendar delete failed for booking ${booking._id}: ${e.message}`),
      )
    }
    booking.status = 'cancelled'
    booking.cancelledAt = new Date()
    booking.cancellationReason = reason
    await booking.save()
    await this.lockModel.deleteOne({ hostId: booking.hostId, startUtc: booking.startUtc, bookingId: booking._id }).exec()

    this.sendEmail(ctx, booking, access, 'cancellation').catch((e) =>
      this.logger.warn(`Cancellation email failed: ${e.message}`),
    )
    return booking
  }

  async reschedule(token: string, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
    return this.performReschedule(await this.getByManageToken(token), newStartUtc, ctx)
  }

  // Admin reschedule by booking doc (already tenant-checked by the caller).
  async rescheduleResolved(old: BookingDocument, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
    return this.performReschedule(old, newStartUtc, ctx)
  }

  private async performReschedule(old: BookingDocument, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
    if (old.status === 'cancelled') throw new BadRequestException('This booking was cancelled and cannot be rescheduled.')

    // Create the replacement booking first (validates + claims the new slot).
    const fresh = await this.createConfirmedBooking(
      ctx,
      {
        startUtc: newStartUtc,
        attendeeName: old.attendeeName,
        attendeeEmail: old.attendeeEmail,
        attendeeTimezone: old.attendeeTimezone,
        attendeeNotes: old.attendeeNotes,
      },
      { rescheduledFromBookingId: old._id },
    )

    // Tear down the old one (delete its calendar event, mark rescheduled).
    const access = await this.provisioner.resolveAccess(ctx.host)
    if (old.calendarEventId) {
      await this.gateway.deleteEvent(access, old.calendarEventId).catch((e) =>
        this.logger.warn(`Old event delete failed during reschedule: ${e.message}`),
      )
    }
    old.status = 'rescheduled'
    await old.save()
    await this.lockModel.deleteOne({ hostId: old.hostId, startUtc: old.startUtc, bookingId: old._id }).exec()
    return fresh
  }

  // ── Admin listing ──────────────────────────────────────────────────────────
  listForTenant(tenantId: Types.ObjectId, hostId?: Types.ObjectId): Promise<BookingDocument[]> {
    const filter: Record<string, unknown> = { tenantId }
    if (hostId) filter.hostId = hostId
    return this.bookingModel.find(filter).sort({ startUtc: -1 }).limit(500).exec()
  }

  // ── Helpers ────────────────────────────────────────────────────────────────
  private resolveLocation(ctx: BookingContext): { type: BookingDocument['locationType']; url?: string } {
    const et = ctx.eventType
    if (et.locationType === 'jitsi') {
      return { type: 'jitsi', url: `${this.meetBaseUrl}/${ctx.tenant.slug}-${randomBytes(6).toString('hex')}` }
    }
    return { type: et.locationType, url: et.locationDetails }
  }

  private async sendEmail(
    ctx: BookingContext,
    booking: BookingDocument,
    access: HostCalendarAccess,
    kind: 'confirmation' | 'cancellation',
  ): Promise<void> {
    const emailCtx = {
      brandName: ctx.tenant.name,
      brandColor: ctx.tenant.brandColor,
      eventTitle: ctx.eventType.title,
      hostName: ctx.host.displayName,
      attendeeName: booking.attendeeName,
      startUtc: booking.startUtc,
      endUtc: booking.endUtc,
      attendeeTimezone: booking.attendeeTimezone,
      location: booking.locationUrl,
      manageUrl: `${this.bookingPublicUrl}/manage/${booking.manageToken}`,
    }
    const rendered = kind === 'confirmation' ? confirmationEmail(emailCtx) : cancellationEmail(emailCtx)
    const ics = buildBookingIcs({
      uid: booking.calendarEventUid,
      start: booking.startUtc,
      end: booking.endUtc,
      summary: `${ctx.eventType.title} with ${ctx.host.displayName}`,
      description: booking.attendeeNotes,
      location: booking.locationUrl,
      organizerName: ctx.host.displayName,
      organizerEmail: ctx.host.email,
      attendeeName: booking.attendeeName,
      attendeeEmail: booking.attendeeEmail,
    })
    await this.mailer.send(access, {
      to: booking.attendeeEmail,
      toName: booking.attendeeName,
      subject: rendered.subject,
      text: rendered.text,
      html: rendered.html,
      ics,
      icsFilename: 'invite.ics',
    })
  }
}
