import { Injectable, Logger } from '@nestjs/common'
import type {
  BookingEvent,
  BusyOptions,
  CalendarGateway,
  CalendarRef,
  HostCalendarAccess,
  Interval,
} from './calendar-gateway.types.js'

// JMAP-for-Calendars implementation against Stalwart (verified in Phase 0 — see
// reference_stalwart_calendar_jmap). Per-host auth is HTTP Basic with the host's
// app password. Free/busy uses Principal/getAvailability (server expands
// recurrences and returns UTC intervals). Bookings are written via
// CalendarEvent/set; the attendee is added with scheduleAgent:"client" so the
// server never sends an iMIP invite (dezky sends its own branded email), and the
// attendee is ALSO folded into the title/description because Stalwart's Community
// build does not persist the participants block (Phase 0 finding) — that keeps
// the booking legible to the host on their own calendar regardless.

const CORE = 'urn:ietf:params:jmap:core'
const CALENDARS = 'urn:ietf:params:jmap:calendars'
const PRINCIPALS = 'urn:ietf:params:jmap:principals'
const AVAILABILITY = 'urn:ietf:params:jmap:principals:availability'

interface JmapSession {
  apiUrl: string
  primaryAccounts: Record<string, string>
}

type MethodCall = [string, Record<string, unknown>, string]
type MethodResponse = [string, Record<string, any>, string]

@Injectable()
export class JmapCalendarGateway implements CalendarGateway {
  private readonly logger = new Logger(JmapCalendarGateway.name)

  private authHeader(access: HostCalendarAccess): string {
    return `Basic ${Buffer.from(`${access.email}:${access.secret}`).toString('base64')}`
  }

  // Method-call endpoint, derived from the session URL so we always hit the
  // internal Stalwart hostname (the session object's apiUrl may advertise the
  // public Traefik FQDN, which Node's fetch can't reach with the mkcert cert).
  private apiUrl(access: HostCalendarAccess): string {
    return access.jmapSessionUrl.replace(/\/\.well-known\/jmap$/, '/jmap')
  }

  private async session(access: HostCalendarAccess): Promise<JmapSession> {
    const res = await fetch(access.jmapSessionUrl, { headers: { Authorization: this.authHeader(access) } })
    if (!res.ok) throw new Error(`JMAP session ${res.status} for ${access.email}`)
    return (await res.json()) as JmapSession
  }

  private async call(access: HostCalendarAccess, using: string[], methodCalls: MethodCall[]): Promise<MethodResponse[]> {
    const res = await fetch(this.apiUrl(access), {
      method: 'POST',
      headers: { Authorization: this.authHeader(access), 'Content-Type': 'application/json' },
      body: JSON.stringify({ using, methodCalls }),
    })
    const text = await res.text()
    if (!res.ok) throw new Error(`JMAP ${res.status}: ${text.slice(0, 200)}`)
    const json = JSON.parse(text) as { methodResponses?: MethodResponse[] }
    if (!json.methodResponses) throw new Error(`JMAP error: ${text.slice(0, 200)}`)
    return json.methodResponses
  }

  private calAccountId(session: JmapSession): string {
    const id = session.primaryAccounts?.[CALENDARS]
    if (!id) throw new Error('Stalwart account has no calendar capability')
    return id
  }

  async validateCredential(access: HostCalendarAccess): Promise<boolean> {
    try {
      await this.session(access)
      return true
    } catch (err) {
      this.logger.warn(`Credential validation failed for ${access.email}: ${(err as Error).message}`)
      return false
    }
  }

  async listCalendars(access: HostCalendarAccess): Promise<CalendarRef[]> {
    const session = await this.session(access)
    const accountId = this.calAccountId(session)
    const resp = await this.call(access, [CORE, CALENDARS], [['Calendar/get', { accountId }, 'c']])
    const list = (resp.find((r) => r[0] === 'Calendar/get')?.[1]?.list ?? []) as Array<{ id: string; name: string }>
    return list.map((c) => ({ id: c.id, name: c.name }))
  }

  async getBusyIntervals(
    access: HostCalendarAccess,
    fromUtc: Date,
    toUtc: Date,
    options?: BusyOptions,
  ): Promise<Interval[]> {
    const session = await this.session(access)
    const accountId = this.calAccountId(session)
    const resp = await this.call(
      access,
      [CORE, PRINCIPALS, AVAILABILITY],
      [
        [
          'Principal/getAvailability',
          { accountId, id: accountId, utcStart: fromUtc.toISOString(), utcEnd: toUtc.toISOString() },
          'a',
        ],
      ],
    )
    const out = resp.find((r) => r[0] === 'Principal/getAvailability')
    if (!out) {
      const err = resp.find((r) => r[0] === 'error')?.[1]
      throw new Error(`getAvailability failed: ${JSON.stringify(err)}`)
    }
    const list = (out[1]?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: string }>
    // Drop free/tentative-cancelled markers; count confirmed + tentative as busy.
    const busy = list
      .filter((b) => b.busyStatus !== 'free')
      .map((b) => ({ startUtc: new Date(b.utcStart), endUtc: new Date(b.utcEnd) }))

    if (!options?.ignoreAllDayEvents) return busy

    // Best-effort all-day exclusion. Principal/getAvailability collapses every
    // event to a plain UTC interval with no all-day flag (Phase 0 finding —
    // reference_stalwart_calendar_jmap), so we cannot tell an all-day event from
    // a same-length timed event by its busy interval alone. To honour the flag we
    // separately query CalendarEvent over the window and read each event's
    // showWithoutTime / date-only `start` to recognise all-day events, then drop
    // any busy interval that an all-day event covers.
    //
    // This is intentionally fail-soft: if the (occasionally flaky) CalendarEvent
    // query errors, we keep ALL busy intervals (fail toward over-blocking, never
    // toward a double-book) rather than propagating and breaking slot computation.
    try {
      const allDay = await this.allDayIntervals(access, accountId, fromUtc, toUtc)
      if (allDay.length === 0) return busy
      return busy.filter((b) => !allDay.some((ad) => coversInterval(ad, b)))
    } catch (err) {
      this.logger.warn(
        `ignoreAllDayEvents: all-day detection failed for ${access.email}, keeping all busy intervals: ${(err as Error).message}`,
      )
      return busy
    }
  }

  // Returns the UTC spans of all-day events in [fromUtc, toUtc). An all-day event
  // in JSCalendar is signalled by showWithoutTime:true and/or a date-only `start`
  // ("YYYY-MM-DD") with no `timeZone`; multi-day all-day events carry a P..D
  // duration. We expand each to [start-of-day, start-of-day + duration) in the
  // event's local zone (falling back to UTC), which is enough to overlap-match
  // the busy intervals getAvailability produced for the same events.
  private async allDayIntervals(
    access: HostCalendarAccess,
    accountId: string,
    fromUtc: Date,
    toUtc: Date,
  ): Promise<Interval[]> {
    const resp = await this.call(
      access,
      [CORE, CALENDARS],
      [
        [
          'CalendarEvent/query',
          {
            accountId,
            filter: { before: toUtc.toISOString(), after: fromUtc.toISOString() },
          },
          'q',
        ],
        [
          'CalendarEvent/get',
          {
            accountId,
            '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
            properties: ['start', 'duration', 'timeZone', 'showWithoutTime', 'recurrenceOverrides'],
          },
          'g',
        ],
      ],
    )
    const got = resp.find((r) => r[0] === 'CalendarEvent/get')
    if (!got) return []
    const events = (got[1]?.list ?? []) as Array<{
      start?: string
      duration?: string
      timeZone?: string
      showWithoutTime?: boolean
    }>
    const out: Interval[] = []
    for (const ev of events) {
      if (!isAllDay(ev)) continue
      const span = allDaySpan(ev)
      if (span) out.push(span)
    }
    return out
  }

  async createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }> {
    const session = await this.session(access)
    const accountId = this.calAccountId(session)
    const calendarId = access.defaultCalendarId ?? (await this.firstCalendarId(access, accountId))

    const startLocal = toJsCalLocal(event.startUtc, event.hostTimezone)
    const durationIso = isoDuration(event.endUtc.getTime() - event.startUtc.getTime())
    const descriptionParts = [
      `Booked by: ${event.attendeeName} <${event.attendeeEmail}>`,
      event.location ? `Location: ${event.location}` : '',
      event.description ?? '',
    ].filter(Boolean)

    const jsEvent: Record<string, unknown> = {
      '@type': 'Event',
      uid: event.uid,
      calendarIds: { [calendarId]: true },
      title: `${event.title} — ${event.attendeeName}`,
      description: descriptionParts.join('\n'),
      start: startLocal,
      timeZone: event.hostTimezone,
      duration: durationIso,
      status: 'confirmed',
      ...(event.location ? { locations: { '1': { '@type': 'Location', name: event.location } } } : {}),
      // Defensive: even though Community does not persist participants or send
      // iMIP, mark the attendee scheduleAgent:client so a future server that DOES
      // auto-send still won't double-send (dezky owns attendee notification).
      replyTo: { imip: `mailto:${event.hostEmail}` },
      participants: {
        host: { '@type': 'Participant', email: event.hostEmail, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
        attendee: {
          '@type': 'Participant',
          name: event.attendeeName,
          email: event.attendeeEmail,
          roles: { attendee: true },
          participationStatus: 'accepted',
          scheduleAgent: 'client',
        },
      },
    }

    const resp = await this.call(access, [CORE, CALENDARS], [['CalendarEvent/set', { accountId, create: { e: jsEvent } }, 's']])
    const result = resp.find((r) => r[0] === 'CalendarEvent/set')?.[1]
    const id = result?.created?.e?.id
    if (!id) throw new Error(`CalendarEvent create failed: ${JSON.stringify(result?.notCreated ?? resp)}`)
    return { uid: event.uid, id }
  }

  async deleteEvent(access: HostCalendarAccess, eventId: string): Promise<void> {
    const session = await this.session(access)
    const accountId = this.calAccountId(session)
    const resp = await this.call(access, [CORE, CALENDARS], [['CalendarEvent/set', { accountId, destroy: [eventId] }, 'd']])
    const result = resp.find((r) => r[0] === 'CalendarEvent/set')?.[1]
    if ((result?.destroyed as string[] | undefined)?.includes(eventId)) return
    const notDestroyed = result?.notDestroyed?.[eventId]
    if (notDestroyed && notDestroyed.type !== 'notFound') {
      throw new Error(`CalendarEvent delete failed (id=${eventId}): ${JSON.stringify(notDestroyed)}`)
    }
  }

  private async firstCalendarId(access: HostCalendarAccess, accountId: string): Promise<string> {
    const resp = await this.call(access, [CORE, CALENDARS], [['Calendar/get', { accountId }, 'c']])
    const id = (resp.find((r) => r[0] === 'Calendar/get')?.[1]?.list ?? [])[0]?.id
    if (!id) throw new Error('Host has no writable calendar')
    return id
  }
}

// Render a UTC instant as a JSCalendar floating local date-time string in `tz`
// ("YYYY-MM-DDTHH:mm:ss", no offset — the offset is carried by `timeZone`).
function toJsCalLocal(utc: Date, tz: string): string {
  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone: tz,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
    hour12: false,
  }).formatToParts(utc)
  const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '00'
  // en-CA gives hour "24" at midnight in some runtimes; normalise to "00".
  const hour = get('hour') === '24' ? '00' : get('hour')
  return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`
}

// True if a JSCalendar event represents an all-day event: either the explicit
// showWithoutTime flag, or a date-only `start` ("YYYY-MM-DD" with no time part).
function isAllDay(ev: { start?: string; showWithoutTime?: boolean }): boolean {
  if (ev.showWithoutTime === true) return true
  return typeof ev.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(ev.start)
}

// Convert an all-day event to a concrete UTC interval. `start` is a local date
// (date-only) or local date-time; `duration` defaults to one day. We anchor the
// span at local midnight in the event's zone (or UTC if absent) so it overlaps
// the busy interval getAvailability emitted for the same event.
function allDaySpan(ev: { start?: string; duration?: string; timeZone?: string }): Interval | null {
  if (!ev.start) return null
  const datePart = ev.start.slice(0, 10)
  if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null
  const startUtc = localMidnightToUtc(datePart, ev.timeZone)
  if (!startUtc) return null
  const days = Math.max(1, durationToDays(ev.duration))
  const endUtc = new Date(startUtc.getTime() + days * 86_400_000)
  return { startUtc, endUtc }
}

// Interpret "YYYY-MM-DD" at 00:00 in `tz` (default UTC) as a UTC instant. We
// derive the zone offset for that date via Intl and subtract it from the naive
// UTC midnight.
function localMidnightToUtc(date: string, tz?: string): Date | null {
  const naive = new Date(`${date}T00:00:00Z`)
  if (Number.isNaN(naive.getTime())) return null
  if (!tz) return naive
  try {
    const offsetMs = zoneOffsetMs(naive, tz)
    return new Date(naive.getTime() - offsetMs)
  } catch {
    return naive
  }
}

// Offset (ms) of `tz` from UTC at instant `at` (positive east of UTC).
function zoneOffsetMs(at: Date, tz: string): number {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: tz,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
    hour12: false,
  }).formatToParts(at)
  const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0')
  const hour = get('hour') === 24 ? 0 : get('hour')
  const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), hour, get('minute'), get('second'))
  return asUtc - at.getTime()
}

// Whole days in an ISO-8601 duration like "P2D" / "P1W". All-day events use
// day/week granularity; sub-day parts (PT..) round up to at least one day.
function durationToDays(duration?: string): number {
  if (!duration) return 1
  const weeks = Number(/(\d+)W/.exec(duration)?.[1] ?? 0)
  const days = Number(/(\d+)D/.exec(duration)?.[1] ?? 0)
  const total = weeks * 7 + days
  return total > 0 ? total : 1
}

// True if all-day interval `ad` fully covers busy interval `b` (so `b` is the
// busy span of an all-day event and should be dropped when ignoring all-day).
function coversInterval(ad: Interval, b: Interval): boolean {
  return ad.startUtc.getTime() <= b.startUtc.getTime() && ad.endUtc.getTime() >= b.endUtc.getTime()
}

// Milliseconds → ISO-8601 duration (whole minutes are enough for bookings).
function isoDuration(ms: number): string {
  const totalMinutes = Math.round(ms / 60000)
  const h = Math.floor(totalMinutes / 60)
  const m = totalMinutes % 60
  return `PT${h ? `${h}H` : ''}${m || !h ? `${m}M` : ''}`
}
