// Single entry-point for writing the platform audit trail. Every mutation in
// the API layer threads through `record()` so the operator's /audit timeline
// reflects reality. The service is intentionally minimal — no UPDATE/DELETE
// methods exist, and `list` is read-only.
//
// Phase 3 (tamper-evidence) wiring: each write now allocates an atomic
// sequence number, chains its hash to the previous event, and marks the row
// as `chained: true`. Pre-Phase-3 events stay in the collection with
// `chained: false` and no seq/hash; the verify endpoint only walks the
// chained set.
//
// What's still NOT here:
//   - Cold-storage archival (Phase 4)
//   - GDPR right-to-erasure (delete events for tenant X)

import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type { FilterQuery, Model, Types } from 'mongoose'
import { AuditCounter, type AuditCounterDocument } from '../schemas/audit-counter.schema.js'
import {
  AuditEvent,
  type AuditEventDocument,
  type AuditOutcome,
  type AuditResourceType,
  type AuditSource,
} from '../schemas/audit-event.schema.js'
import { eventToCanonical, hashCanonical, GENESIS_HASH } from './canonical.js'

export interface AuditActor {
  userId?: Types.ObjectId | string
  email?: string
  ip?: string
}

// What every caller hands to record(). Required: action + outcome. Everything
// else is contextual.
export interface AuditRecordInput {
  action: string
  outcome?: AuditOutcome
  resourceType?: AuditResourceType
  resourceId?: string
  resourceName?: string
  tenantSlug?: string
  partnerSlug?: string
  source?: AuditSource
  metadata?: Record<string, unknown>
  // Dedup key for ingest workers (Authentik pk, OCIS event id, …). Internally
  // recorded events leave this unset.
  externalId?: string
  // When the event happened at the source. Ingest workers pass this; internal
  // mutations let AuditService default to now().
  at?: Date
}

export interface AuditListFilters {
  since?: Date
  until?: Date
  before?: Date
  action?: string  // exact or prefix (handled below)
  tenantSlug?: string
  partnerSlug?: string
  actorEmail?: string
  outcome?: AuditOutcome
  q?: string
  limit?: number
}

const DEFAULT_LIMIT = 100
const MAX_LIMIT = 500
const SEQ_COUNTER_ID = 'audit_seq'

@Injectable()
export class AuditService {
  private readonly logger = new Logger(AuditService.name)
  // Serializes writes within this process. seq allocation + lastHash lookup
  // need to be atomic relative to each other so two concurrent writers don't
  // both observe the same "previous" event and chain off it — that produces
  // two events with the same prevHash but different seq, which the verifier
  // flags as a chain break. A Mongo transaction would handle this cleanly
  // but standalone Mongo (our dev setup) doesn't support transactions. For a
  // single-instance deployment a per-process queue is enough.
  //
  // Multi-instance caveat: this mutex doesn't cross processes. When we scale
  // platform-api to >1 replica (k3s phase), we need either a Mongo replica
  // set + real transaction here, OR a distributed lock (Redis SETNX or a
  // Mongo lock-doc with findOneAndUpdate on a "writing" key).
  private writeLock: Promise<unknown> = Promise.resolve()

  constructor(
    @InjectModel(AuditEvent.name) private readonly model: Model<AuditEventDocument>,
    @InjectModel(AuditCounter.name) private readonly counter: Model<AuditCounterDocument>,
  ) {}

  // Best-effort. We deliberately swallow write failures rather than failing
  // the operator's actual request — losing an audit entry is bad, but failing
  // the underlying mutation (which already succeeded) is worse. Write failures
  // are surfaced via the logger so they show up in container logs.
  async record(input: AuditRecordInput, actor?: AuditActor): Promise<void> {
    // Chain to the previous write before kicking off our own. .catch() here so
    // a single failed write doesn't deadlock the queue for everyone behind it.
    const run = this.writeLock.then(() => this.recordChained(input, actor).catch(() => {}))
    this.writeLock = run
    return run as Promise<void>
  }

  private async recordChained(input: AuditRecordInput, actor?: AuditActor): Promise<void> {
    try {
      const seq = await this.nextSeq()
      const prevHash = await this.lastHash()

      // Build the doc fields, then derive the canonical form + hash from them.
      // Doing this in two passes guarantees the hash matches exactly what the
      // verifier will recompute from the persisted row.
      const baseDoc = {
        at: input.at ?? new Date(),
        actorType: (actor?.userId || actor?.email ? 'user' : 'system') as 'user' | 'system',
        actorId: actor?.userId,
        actorEmail: actor?.email,
        actorIp: actor?.ip,
        action: input.action,
        outcome: input.outcome ?? 'success',
        resourceType: input.resourceType,
        resourceId: input.resourceId,
        resourceName: input.resourceName,
        tenantSlug: input.tenantSlug,
        partnerSlug: input.partnerSlug,
        source: input.source ?? 'platform-api',
        metadata: input.metadata,
        externalId: input.externalId,
        seq,
        prevHash,
        chained: true,
      }
      const canonical = eventToCanonical(baseDoc)
      const hash = hashCanonical(canonical)

      await this.model.create({ ...baseDoc, hash })
    } catch (err) {
      // Duplicate-key on (source, externalId) is expected when ingest workers
      // re-poll an overlapping window — quietly ignore so the worker doesn't
      // log noise every cycle. Everything else is logged.
      const isDup = (err as { code?: number })?.code === 11000
      if (!isDup) {
        this.logger.error(
          `audit write failed for action=${input.action} resource=${input.resourceId}: ${
            err instanceof Error ? err.message : String(err)
          }`,
        )
      }
    }
  }

  async list(filters: AuditListFilters): Promise<AuditEventDocument[]> {
    const q: FilterQuery<AuditEventDocument> = {}

    if (filters.since || filters.until || filters.before) {
      const at: Record<string, Date> = {}
      if (filters.since) at.$gte = filters.since
      if (filters.until) at.$lte = filters.until
      // `before` overrides `until` if both somehow get passed; the timeline UI
      // uses `before` for paging through older history.
      if (filters.before) at.$lt = filters.before
      q.at = at
    }

    if (filters.action) {
      // Allow prefix match: 'tenant' → everything under 'tenant.*'. If the
      // caller passes a full dotted verb we still match it exactly.
      q.action = filters.action.includes('.')
        ? filters.action
        : { $regex: `^${escapeRegex(filters.action)}(\\.|$)` }
    }
    if (filters.tenantSlug) q.tenantSlug = filters.tenantSlug
    if (filters.partnerSlug) q.partnerSlug = filters.partnerSlug
    if (filters.actorEmail) q.actorEmail = filters.actorEmail
    if (filters.outcome) q.outcome = filters.outcome

    if (filters.q) {
      // Free-text across the human-displayed fields. Case-insensitive contains.
      const re = new RegExp(escapeRegex(filters.q), 'i')
      q.$or = [
        { action: re },
        { resourceName: re },
        { actorEmail: re },
        { tenantSlug: re },
      ]
    }

    const limit = clamp(filters.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT)
    return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
  }

  // Partner-scoped activity feed. Returns events where partnerSlug matches
  // OR tenantSlug is one of the partner's tenants. Distinct shape from
  // list() because the OR-across-many-slugs query doesn't fit the
  // single-tenantSlug filter the operator UI uses.
  async listForPartner(opts: {
    partnerSlug: string
    tenantSlugs: string[]
    limit?: number
    before?: Date
  }): Promise<AuditEventDocument[]> {
    const q: FilterQuery<AuditEventDocument> = {
      $or: [
        { partnerSlug: opts.partnerSlug },
        ...(opts.tenantSlugs.length > 0 ? [{ tenantSlug: { $in: opts.tenantSlugs } }] : []),
      ],
    }
    if (opts.before) q.at = { $lt: opts.before }
    const limit = clamp(opts.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT)
    return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
  }

  // ── Chain integrity helpers ────────────────────────────────────────────

  // Atomic monotonic counter. findOneAndUpdate with $inc and upsert returns
  // the post-increment value — each caller gets a distinct seq even under
  // concurrent inserts.
  private async nextSeq(): Promise<number> {
    const res = await this.counter
      .findOneAndUpdate(
        { _id: SEQ_COUNTER_ID },
        { $inc: { n: 1 } },
        { upsert: true, new: true },
      )
      .exec()
    return res!.n
  }

  // The previous chained event's hash, by seq desc. Returns GENESIS for the
  // first chained event.
  async lastHash(): Promise<string> {
    const last = await this.model
      .findOne({ chained: true }, { hash: 1 })
      .sort({ seq: -1 })
      .exec()
    return last?.hash ?? GENESIS_HASH
  }

  // Used by the checkpoint scheduler to know the current chain head.
  async headChainState(): Promise<{ headSeq: number; headHash: string } | null> {
    const last = await this.model
      .findOne({ chained: true }, { seq: 1, hash: 1 })
      .sort({ seq: -1 })
      .exec()
    if (!last || last.seq == null || !last.hash) return null
    return { headSeq: last.seq, headHash: last.hash }
  }

  // Count of chained events at or below `seq`. Useful for the verify endpoint
  // to know how many events it walks per checkpoint segment.
  async countChainedThrough(seq: number): Promise<number> {
    return this.model.countDocuments({ chained: true, seq: { $lte: seq } }).exec()
  }

  // Walk the chain in seq order for the verifier. Stream-y signature so we
  // don't load everything into memory for large chains.
  chainCursor(fromSeq: number, toSeq: number) {
    return this.model
      .find({ chained: true, seq: { $gte: fromSeq, $lte: toSeq } })
      .sort({ seq: 1 })
      .cursor()
  }
}

function escapeRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function clamp(n: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, n))
}
