// Polls Authentik's /events/events/ endpoint on an interval and writes each
// event into our audit log via AuditService.record(). Cursor state lives in
// the ingest_cursors collection so a platform-api restart resumes where it
// left off without re-recording (sparse unique index on
// AuditEvent { source, externalId } is the second line of defense against
// dupes).

import {
  Injectable,
  Logger,
  type OnApplicationBootstrap,
  type OnModuleDestroy,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import type { Model } from 'mongoose'
import { AuditService } from '../audit/audit.service.js'
import { AuthentikClient, type AuthentikEvent } from '../integrations/authentik.client.js'
import { Tenant, type TenantDocument } from '../schemas/tenant.schema.js'
import {
  IngestCursor,
  type IngestCursorDocument,
} from '../schemas/ingest-cursor.schema.js'
import { mapAuthentikAction } from './action-map.js'

const SOURCE = 'authentik' as const
const DEFAULT_INTERVAL_MS = 60_000
const MAX_PAGES_PER_TICK = 10 // safety cap — 100/page × 10 = 1000 events max per cycle

// Authentik bundles platform admin into specific groups; strip those when we
// derive a tenant slug from the user's group list (the same convention the
// portal /api/flags/evaluate proxy uses).
const ADMIN_GROUPS = new Set(['dezky-platform-admins', 'authentik Admins'])

@Injectable()
export class AuthentikIngest implements OnApplicationBootstrap, OnModuleDestroy {
  private readonly logger = new Logger(AuthentikIngest.name)
  private readonly intervalMs: number
  private readonly enabled: boolean
  private timer: NodeJS.Timeout | null = null
  private inFlight = false

  constructor(
    @InjectModel(IngestCursor.name) private readonly cursors: Model<IngestCursorDocument>,
    @InjectModel(Tenant.name) private readonly tenants: Model<TenantDocument>,
    private readonly audit: AuditService,
    private readonly authentik: AuthentikClient,
    config: ConfigService,
  ) {
    this.intervalMs = Number(config.get('AUDIT_INGEST_INTERVAL_MS') ?? DEFAULT_INTERVAL_MS)
    // Default ON in dev/prod. Set AUDIT_INGEST_ENABLED=false in test runs that
    // don't have a real Authentik to poll.
    this.enabled = config.get('AUDIT_INGEST_ENABLED') !== 'false'
  }

  onApplicationBootstrap(): void {
    if (!this.enabled) {
      this.logger.log('AUDIT_INGEST_ENABLED=false — skipping Authentik ingest')
      return
    }
    this.logger.log(`Authentik ingest starting (every ${this.intervalMs / 1000}s)`)
    // Fire once on boot so we don't have to wait a full interval for the
    // first pull, then schedule the recurring loop.
    void this.tick()
    this.timer = setInterval(() => void this.tick(), this.intervalMs)
  }

  onModuleDestroy(): void {
    if (this.timer) clearInterval(this.timer)
  }

  // One pull cycle. Reads the cursor, paginates forward through new events,
  // writes each to AuditEvent. We avoid overlapping runs with an in-flight
  // flag — Authentik can be slow when there are many events.
  private async tick(): Promise<void> {
    if (this.inFlight) {
      this.logger.warn('previous tick still running — skipping')
      return
    }
    this.inFlight = true
    try {
      const cursor = await this.cursors.findOne({ source: SOURCE }).exec()
      const since = cursor?.lastEventAt ?? new Date(Date.now() - 24 * 60 * 60 * 1000) // first run: last 24h

      let page = 1
      let recorded = 0
      let newest: { at: Date; id: string } | undefined

      while (page <= MAX_PAGES_PER_TICK) {
        const res = await this.authentik.listEvents(since, page).catch((err) => {
          this.logger.error(`Authentik listEvents page ${page} failed: ${err instanceof Error ? err.message : String(err)}`)
          return null
        })
        if (!res || !res.results.length) break

        for (const evt of res.results) {
          await this.recordOne(evt)
          recorded++
          const at = new Date(evt.created)
          if (!newest || at > newest.at) newest = { at, id: evt.pk }
        }

        if (res.pagination.next === 0) break // no next page
        page++
      }

      if (newest) {
        await this.cursors.findOneAndUpdate(
          { source: SOURCE },
          { $set: { lastEventAt: newest.at, lastEventId: newest.id } },
          { upsert: true },
        ).exec()
        // `recorded` counts attempts, not net new rows — the dedup index on
        // (source, externalId) silently rejects events Authentik returns in
        // the overlap of the cursor window. Real net-new = recorded minus the
        // duplicate-key suppressions in AuditService.record.
        this.logger.log(
          `Authentik ingest: ${recorded} event(s) processed, cursor → ${newest.at.toISOString()}`,
        )
      }
    } catch (err) {
      this.logger.error(`Authentik ingest tick failed: ${err instanceof Error ? err.message : String(err)}`)
    } finally {
      this.inFlight = false
    }
  }

  private async recordOne(evt: AuthentikEvent): Promise<void> {
    const mapped = mapAuthentikAction(evt.action)
    const userEmail = evt.user?.email || evt.user?.username
    const tenantSlug = await this.deriveTenantSlug(evt)
    const targetName = pickTargetName(evt)

    await this.audit.record({
      action: mapped.action,
      outcome: mapped.outcome,
      resourceType: mapped.resourceType,
      resourceName: targetName,
      tenantSlug,
      source: SOURCE,
      externalId: evt.pk,
      at: new Date(evt.created),
      metadata: {
        app: evt.app,
        context: evt.context,
        rawAction: evt.action,
      },
    }, {
      email: userEmail,
      ip: evt.client_ip,
    })
  }

  // Authentik doesn't tell us which tenant an event belongs to; we infer it
  // from the user's groups (same rule the portal uses to pick a tenant slug
  // for flag eval). If the actor has no recognizable tenant group, we leave
  // tenantSlug undefined — the event still lands in the global timeline,
  // just not bucketed.
  private async deriveTenantSlug(evt: AuthentikEvent): Promise<string | undefined> {
    const groups = (evt.context?.['user_groups'] as { name?: string }[] | undefined) ?? []
    const tenantGroupNames = groups
      .map((g) => g.name)
      .filter((n): n is string => typeof n === 'string' && !ADMIN_GROUPS.has(n))
    if (!tenantGroupNames.length) return undefined
    // We bias toward the first match that actually exists as a tenant in our
    // DB — Authentik groups can be arbitrary names. One query covers all
    // candidates.
    const match = await this.tenants
      .findOne({ slug: { $in: tenantGroupNames } }, { slug: 1 })
      .exec()
    return match?.slug
  }
}

// Pick a human label for the audit row's "target" column. Authentik puts the
// affected object in different shapes depending on the action; this favors
// the most useful display string per event type.
function pickTargetName(evt: AuthentikEvent): string | undefined {
  const ctx = evt.context ?? {}
  if (typeof ctx['username'] === 'string') return ctx['username'] as string
  if (typeof ctx['email'] === 'string') return ctx['email'] as string
  if (ctx['model'] && typeof ctx['model'] === 'object') {
    const m = ctx['model'] as { name?: string; app?: string }
    if (m.name) return `${m.app ? `${m.app}.` : ''}${m.name}`
  }
  return evt.user?.email || evt.user?.username
}
