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, BookingStatus } from '../../schemas/booking.schema.js'
import { EventType, EventTypeDocument } from '../../schemas/event-type.schema.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockDocument } from '../../schemas/slot-lock.schema.js'
import { Tenant, TenantDocument } from '../../schemas/tenant.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'
import { buildMeetUrl, meetJwtEnabled, type MeetJwtConfig } from './meet-room.js'
import { WebhooksService, toWebhookBookingView } from '../webhooks/webhooks.service.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
  private readonly meetDomain: string
  private readonly meetJwt: MeetJwtConfig

  constructor(
    @InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
    @InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    @InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
    @InjectModel(EventType.name) private readonly eventTypeModel: Model<EventTypeDocument>,
    private readonly slots: SlotService,
    private readonly provisioner: CredentialProvisioner,
    private readonly gateway: JmapCalendarGateway,
    private readonly mailer: JmapMailer,
    private readonly webhooks: WebhooksService,
    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(/\/$/, '')
    // dezky Meet domain for JWT-authed rooms; defaults to the local meet host.
    this.meetDomain = config.get<string>('JITSI_DOMAIN') ?? 'meet.dezky.local'
    this.meetJwt = {
      jwtSecret: config.get<string>('JITSI_JWT_SECRET'),
      appId: config.get<string>('JITSI_APP_ID'),
      domain: this.meetDomain,
    }
  }

  // ── 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> {
    // Round-robin: choose the pooled host this booking lands on BEFORE anything is
    // written, then run the rest of the (single-host) flow against that host by
    // swapping ctx.host. Single-host event types skip this entirely and behave
    // exactly as before.
    if (ctx.eventType.assignment === 'round_robin') {
      const chosen = await this.selectRoundRobinHost(ctx, input.startUtc)
      ctx = { ...ctx, host: chosen }
    }

    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 = await 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.
    // On failure we DO NOT delete the booking/lock anymore: the booking stays
    // 'pending' with its SlotLock held so the retry worker (§8.2.4) can re-drive
    // the write. We still surface 503 to the synchronous caller so the attendee
    // is told to retry immediately — but the slot is now durably reserved and
    // will be promoted-and-emailed (or terminally released) in the background.
    //
    // Public-confirm behavior (DoD: never silently confirm without a calendar
    // event): the public endpoint keeps throwing ServiceUnavailable on a failed
    // first write rather than returning a 'pending' booking, so the UI never
    // tells the attendee "confirmed" before a calendar event exists. The pending
    // booking lives on for the background retry to finish the job.
    const written = await this.attemptCalendarWrite(ctx, booking)
    if (!written) {
      throw new ServiceUnavailableException('Could not complete the booking on the calendar — please try again.')
    }
    // Booking is now confirmed with a calendar event — emit the lifecycle event.
    // Fire-and-forget; webhook delivery must never block or break a booking.
    void this.webhooks.dispatch('booking.created', toWebhookBookingView(booking))
    return booking
  }

  /**
   * Round-robin host selection (§Phase 3). Returns the pooled host this booking
   * should be assigned to for the requested start time:
   *   1. Resolve the active pool (owner always included).
   *   2. Keep only hosts for whom the slot is genuinely free right now (live
   *      free/busy re-check via SlotService — the same authoritative check the
   *      single-host path uses, so we never assign to a host who is actually busy).
   *   3. Among the free hosts, pick the least-recently-booked (fewest/oldest
   *      bookings first) to spread load; ties fall back to the first in pool order.
   * Throws Conflict if no pooled host is free (the slot was taken since it was shown).
   */
  private async selectRoundRobinHost(ctx: BookingContext, startUtc: Date): Promise<HostDocument> {
    const { eventType } = ctx
    const pool = await this.slots.resolvePoolHosts(eventType, ctx.host)
    const endUtc = new Date(startUtc.getTime() + eventType.durationMinutes * 60_000)
    const now = new Date()

    // Determine which pooled hosts have this exact slot free (live re-check).
    const checks = await Promise.all(
      pool.map(async (host) => {
        try {
          const offered = await this.slots.availableSlots(host, eventType, startUtc, endUtc, now)
          return offered.some((s) => s.startUtc.getTime() === startUtc.getTime()) ? host : null
        } catch {
          // A host whose calendar is unreachable is treated as not-free here so we
          // never assign a booking we couldn't verify against that host's calendar.
          return null
        }
      }),
    )
    const free = checks.filter((h): h is HostDocument => h !== null)
    if (!free.length) throw new ConflictException('That time is no longer available.')

    // Least-recently-booked: rank free hosts by their most recent booking time
    // (a host with no bookings sorts first). Preserve pool order on ties.
    const lastBookedAt = new Map<string, number>()
    const recent = await this.bookingModel
      .find({
        hostId: { $in: free.map((h) => h._id) },
        status: { $in: ['confirmed', 'pending'] },
      })
      .select('hostId createdAt')
      .sort({ createdAt: -1 })
      .exec()
    for (const b of recent) {
      const key = b.hostId.toString()
      // createdAt is provided by the schema's timestamps:true.
      const ts = (b as unknown as { createdAt?: Date }).createdAt?.getTime() ?? 0
      if (!lastBookedAt.has(key)) lastBookedAt.set(key, ts)
    }
    const ranked = free
      .map((host, index) => ({ host, index, last: lastBookedAt.get(host._id.toString()) ?? 0 }))
      .sort((a, b) => a.last - b.last || a.index - b.index)
    return ranked[0]!.host
  }

  /**
   * Drives the Stalwart calendar write for a pending booking and, on success,
   * promotes it to 'confirmed' and fires the branded confirmation email.
   *
   * On failure it increments `calendarWriteAttempts`, records `lastCalendarError`
   * and leaves the booking 'pending' with its SlotLock intact for a later retry.
   * Returns true iff the booking is now confirmed. The `calendarEventUid` is
   * generated up-front and reused across attempts, so a retry after a partial
   * failure is idempotent (§9).
   */
  private async attemptCalendarWrite(ctx: BookingContext, booking: BookingDocument): Promise<boolean> {
    const { host, eventType } = ctx
    try {
      const access = await this.provisioner.resolveAccess(host)
      const { id } = await this.gateway.createEvent(access, {
        uid: booking.calendarEventUid,
        title: eventType.title,
        description: booking.attendeeNotes,
        startUtc: booking.startUtc,
        endUtc: booking.endUtc,
        hostTimezone: host.timezone,
        location: booking.locationUrl,
        hostEmail: host.email,
        attendeeName: booking.attendeeName,
        attendeeEmail: booking.attendeeEmail,
      })
      booking.calendarEventId = id
      booking.status = 'confirmed'
      await booking.save()

      // 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 true
    } catch (err) {
      booking.calendarWriteAttempts = (booking.calendarWriteAttempts ?? 0) + 1
      booking.lastCalendarError = (err as Error).message
      await booking.save().catch(() => undefined)
      this.logger.error(
        `Calendar write failed for ${host.email} (booking ${booking._id}, attempt ${booking.calendarWriteAttempts}): ${(err as Error).message}`,
      )
      return false
    }
  }

  // ── Retry queue (§8.2.4) ─────────────────────────────────────────────────────
  /**
   * Re-drive the calendar write for a single pending booking. On success the
   * booking is promoted to confirmed and the confirmation email is sent. Once
   * attempts reach `maxAttempts` the booking moves to the terminal
   * 'calendar_failed' state and its SlotLock is released so the slot frees up.
   * Returns the booking's resulting status. Invoked by the retry worker.
   */
  async retryPendingCalendarWrite(booking: BookingDocument, maxAttempts: number): Promise<BookingStatus> {
    const tenant = await this.tenantModel.findById(booking.tenantId).exec()
    const host = await this.hostModel.findById(booking.hostId).exec()
    const eventType = await this.eventTypeModel.findById(booking.eventTypeId).exec()
    if (!tenant || !host || !eventType) {
      this.logger.warn(`Retry skipped — missing tenant/host/eventType for booking ${booking._id}`)
      return booking.status
    }

    const ctx: BookingContext = {
      tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
      host,
      eventType,
    }

    const ok = await this.attemptCalendarWrite(ctx, booking)
    if (ok) return booking.status

    if (booking.calendarWriteAttempts >= maxAttempts) {
      booking.status = 'calendar_failed'
      await booking.save().catch(() => undefined)
      await this.lockModel
        .deleteOne({ hostId: booking.hostId, startUtc: booking.startUtc, bookingId: booking._id })
        .exec()
        .catch(() => undefined)
      this.logger.error(
        `Booking ${booking._id} reached max calendar-write attempts (${maxAttempts}); marked calendar_failed and released its slot lock. Last error: ${booking.lastCalendarError}`,
      )
    }
    return booking.status
  }

  // Pending bookings still eligible for a calendar-write retry (under the cap).
  findPendingForRetry(maxAttempts: number, limit = 100): Promise<BookingDocument[]> {
    return this.bookingModel
      .find({ status: 'pending', calendarWriteAttempts: { $lt: maxAttempts } })
      .sort({ createdAt: 1 })
      .limit(limit)
      .exec()
  }

  /**
   * Operator-triggered "retry now" from the admin bookings list. Works on a
   * still-'pending' booking (which already holds its slot lock) or a terminal
   * 'calendar_failed' one (whose lock was released on terminal failure — we
   * re-claim the slot atomically first and refuse if it was taken in the
   * meantime, so we never double-book). Resets the attempt budget and re-drives
   * the shared write path; on success the booking is confirmed and the attendee
   * emailed, otherwise it returns to 'pending' for the background worker.
   */
  async adminRetryCalendarWrite(tenantId: Types.ObjectId, bookingId: string): Promise<BookingDocument> {
    const booking = await this.getForTenant(tenantId, bookingId)
    if (booking.status === 'confirmed') return booking
    if (booking.status === 'cancelled' || booking.status === 'rescheduled') {
      throw new BadRequestException(`Cannot retry a ${booking.status} booking.`)
    }

    const tenant = await this.tenantModel.findById(booking.tenantId).exec()
    const host = await this.hostModel.findById(booking.hostId).exec()
    const eventType = await this.eventTypeModel.findById(booking.eventTypeId).exec()
    if (!tenant || !host || !eventType) {
      throw new NotFoundException('The host or event type for this booking no longer exists.')
    }
    const ctx: BookingContext = {
      tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
      host,
      eventType,
    }

    // A terminal failure released the slot lock — re-claim it before retrying so
    // we can't double-book a slot that was taken after this booking failed.
    if (booking.status === 'calendar_failed') {
      try {
        await this.lockModel.create({
          tenantId: booking.tenantId,
          hostId: booking.hostId,
          startUtc: booking.startUtc,
          endUtc: booking.endUtc,
          expiresAt: null,
          bookingId: booking._id,
          holdToken: null,
        })
      } catch (err: any) {
        if (err?.code === 11000) {
          throw new ConflictException('That time was taken after this booking failed — reschedule it to a free slot instead.')
        }
        throw err
      }
    }

    // Fresh attempt budget, then re-drive the shared write path.
    booking.calendarWriteAttempts = 0
    booking.lastCalendarError = undefined
    booking.status = 'pending'
    await booking.save()
    await this.attemptCalendarWrite(ctx, booking)
    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}`),
    )
    void this.webhooks.dispatch('booking.cancelled', toWebhookBookingView(booking))
    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()
    // The fresh booking already emitted 'booking.created'; emit 'booking.rescheduled'
    // for it too (its rescheduledFromBookingId points at the old booking).
    void this.webhooks.dispatch('booking.rescheduled', toWebhookBookingView(fresh))
    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 async resolveLocation(
    ctx: BookingContext,
  ): Promise<{ type: BookingDocument['locationType']; url?: string }> {
    const et = ctx.eventType
    if (et.locationType === 'jitsi') {
      // Room name is stable per booking (one room per attendee booking) and
      // doubles as the JWT `room` claim when JWT auth is enabled.
      const room = `${ctx.tenant.slug}-${randomBytes(6).toString('hex')}`
      // When JWT is configured we build the URL via the dezky Meet helper
      // (https://<domain>/<room>?jwt=…); otherwise keep the plain public URL.
      const url = meetJwtEnabled(this.meetJwt)
        ? await buildMeetUrl(this.meetJwt, room)
        : `${this.meetBaseUrl}/${room}`
      return { type: 'jitsi', url }
    }
    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',
    })
  }
}
