import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Cron, CronExpression } from '@nestjs/schedule'
import { Model } from 'mongoose'
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
import { EventType, EventTypeDocument } from '../../schemas/event-type.schema.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { Tenant, TenantDocument } from '../../schemas/tenant.schema.js'
import { reminderEmail } from '../email/booking-templates.js'
import { buildBookingIcs } from '../email/ics.js'
import { JmapMailer } from '../email/jmap-mailer.service.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'

// Periodic worker that emails attendees a branded reminder ahead of confirmed
// bookings. Offsets (minutes before start) are configurable via
// SCHEDULING_REMINDER_OFFSETS (comma-separated, default "1440,60"). Idempotency
// is enforced per-offset on the Booking via `sentReminderOffsets`: the worker
// only emails an offset that is absent, then atomically appends it so concurrent
// or repeated runs never double-send. Email is best-effort — a send failure
// leaves the offset un-appended so the next run retries.
@Injectable()
export class BookingReminderWorker {
  private readonly logger = new Logger(BookingReminderWorker.name)
  private readonly offsets: number[]
  private readonly bookingPublicUrl: string

  constructor(
    @InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
    @InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
    @InjectModel(EventType.name) private readonly eventTypeModel: Model<EventTypeDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    private readonly provisioner: CredentialProvisioner,
    private readonly mailer: JmapMailer,
    config: ConfigService,
  ) {
    this.offsets = parseOffsets(config.get<string>('SCHEDULING_REMINDER_OFFSETS'))
    this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
  }

  @Cron(CronExpression.EVERY_5_MINUTES, { name: 'booking-reminders' })
  async run(): Promise<void> {
    if (this.offsets.length === 0) return
    const now = new Date()

    // For each offset, a reminder is "due" once we are within the offset window
    // of the start (start - offset <= now < start). We also clamp at `now` on the
    // lower bound so a worker that was down does not fire stale reminders for
    // bookings whose offset window opened long ago — but bookings still in the
    // future get caught up. The lower bound is the previous run window (5 min).
    for (const offset of this.offsets) {
      const offsetMs = offset * 60_000
      const windowEnd = new Date(now.getTime() + offsetMs)
      const candidates = await this.bookingModel
        .find({
          status: 'confirmed',
          startUtc: { $gt: now, $lte: windowEnd },
          sentReminderOffsets: { $ne: offset },
        })
        .limit(200)
        .exec()

      for (const booking of candidates) {
        await this.sendReminder(booking, offset).catch((e) =>
          this.logger.warn(`Reminder (offset ${offset}m) failed for booking ${booking._id}: ${e.message}`),
        )
      }
    }
  }

  private async sendReminder(booking: BookingDocument, offset: number): Promise<void> {
    // Atomically claim this offset first so a concurrent run cannot also send it.
    const claim = await this.bookingModel
      .findOneAndUpdate(
        { _id: booking._id, sentReminderOffsets: { $ne: offset } },
        { $addToSet: { sentReminderOffsets: offset } },
      )
      .exec()
    if (!claim) return // another run already claimed this offset

    try {
      const [host, eventType, tenant] = await Promise.all([
        this.hostModel.findById(booking.hostId).exec(),
        this.eventTypeModel.findById(booking.eventTypeId).exec(),
        this.tenantModel.findById(booking.tenantId).exec(),
      ])
      if (!host || !eventType || !tenant) {
        throw new Error('missing host/eventType/tenant for booking')
      }

      const access = await this.provisioner.resolveAccess(host)
      const rendered = reminderEmail({
        brandName: tenant.name,
        brandColor: tenant.brandColor,
        eventTitle: eventType.title,
        hostName: host.displayName,
        attendeeName: booking.attendeeName,
        startUtc: booking.startUtc,
        endUtc: booking.endUtc,
        attendeeTimezone: booking.attendeeTimezone,
        location: booking.locationUrl,
        manageUrl: `${this.bookingPublicUrl}/manage/${booking.manageToken}`,
        offsetMinutes: offset,
      })
      const ics = buildBookingIcs({
        uid: booking.calendarEventUid,
        start: booking.startUtc,
        end: booking.endUtc,
        summary: `${eventType.title} with ${host.displayName}`,
        description: booking.attendeeNotes,
        location: booking.locationUrl,
        organizerName: host.displayName,
        organizerEmail: 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',
      })
      this.logger.log(`Sent ${offset}m reminder for booking ${booking._id}`)
    } catch (err) {
      // Roll back the claim so the next run retries this offset.
      await this.bookingModel
        .updateOne({ _id: booking._id }, { $pull: { sentReminderOffsets: offset } })
        .exec()
        .catch(() => undefined)
      throw err
    }
  }
}

// "1440,60" -> [1440, 60]; ignores blanks and non-positive/NaN entries.
function parseOffsets(raw: string | undefined): number[] {
  const source = (raw ?? '1440,60').split(',')
  const seen = new Set<number>()
  const out: number[] = []
  for (const part of source) {
    const n = Number(part.trim())
    if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
      seen.add(n)
      out.push(n)
    }
  }
  return out
}
