import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { AuthenticatedUser } from '../auth/interfaces/jwt-payload.interface';
import {
  AHA_SCREEN_EVENT_NAMES,
  sanitizeAuditMetadata,
} from './audit-event-metadata';
import { CreateAuditEventDto } from './dto/create-audit-event.dto';
import { CreateAhaRuleVersionDto } from './dto/create-aha-rule-version.dto';
import { QueryAhaProgressDto } from './dto/query-aha-progress.dto';
import {
  AhaMetricsQuery,
  AuditEventFilters,
} from './dto/query-audit-events.dto';
import { AuditEvent, AuditEventDocument } from './schemas/audit-event.schema';
import {
  AuditEventRollup,
  AuditEventRollupDocument,
} from './schemas/audit-event-rollup.schema';
import {
  AhaProgress,
  AhaProgressDocument,
} from './schemas/aha-progress.schema';
import {
  AhaRuleVersion,
  AhaRuleVersionDocument,
} from './schemas/aha-rule-version.schema';

const MAX_METADATA_BYTES = 10 * 1024;
const MAX_ERROR_METADATA_BYTES = 256 * 1024;
const ERROR_EVENT_NAMES = new Set(['api.request.failed', 'ui.crash']);
const ROLLUP_EXCLUDED_EVENT_NAMES = new Set(ERROR_EVENT_NAMES);
const NULL_CLIENT_UUID_ROLLUP_KEY = '__null__';
const DEFAULT_AHA_RULE_VERSION = 'v1';

type AhaRuleSnapshot = {
  version: string;
  label: string;
  description?: string;
  is_active: boolean;
  key_events: AhaKeyEventRuleSnapshot[];
  allowed_key_screens: string[];
  min_distinct_key_screens: number;
  min_return_days: number;
  created_by?: string;
};

type AhaKeyEventRuleSnapshot = {
  key: string;
  label: string;
  event_names: string[];
  min_count: number;
  required: boolean;
};

type CreateBatchResult = {
  inserted: number;
  duplicates: number;
};

type PersistedAuditEvent = Omit<
  CreateAuditEventDto,
  'advisor' | 'timestamp'
> & {
  timestamp: Date;
  metadata: Record<string, unknown>;
};

@Injectable()
export class AuditEventsService {
  constructor(
    @InjectModel(AuditEvent.name)
    private readonly auditEventModel: Model<AuditEventDocument>,
    @InjectModel(AuditEventRollup.name)
    private readonly auditEventRollupModel: Model<AuditEventRollupDocument>,
    @InjectModel(AhaProgress.name)
    private readonly ahaProgressModel: Model<AhaProgressDocument>,
    @InjectModel(AhaRuleVersion.name)
    private readonly ahaRuleVersionModel: Model<AhaRuleVersionDocument>,
  ) {}

  async createBatch(
    events: CreateAuditEventDto[],
    user?: AuthenticatedUser,
  ): Promise<CreateBatchResult> {
    const persistedEvents: PersistedAuditEvent[] = events.map((event) => {
      this.assertUnknownActorAllowed(event, user);

      const metadata = sanitizeAuditMetadata(event.event_name, event.metadata);

      this.assertMetadataSize(event.event_name, metadata);

      const actor = this.resolveActor(event, user);
      const { advisor: _ignoredAdvisor, ...eventWithoutAdvisor } = event;

      return {
        ...eventWithoutAdvisor,
        ...actor,
        timestamp: new Date(event.timestamp),
        metadata,
      };
    });
    const operations = persistedEvents.map((event) => ({
      insertOne: {
        document: event,
      },
    }));

    if (operations.length === 0) {
      return { inserted: 0, duplicates: 0 };
    }

    try {
      const result = await this.auditEventModel.bulkWrite(operations, {
        ordered: false,
      });
      await this.updateAhaProgressFromEvents(persistedEvents);

      return { inserted: result.insertedCount ?? 0, duplicates: 0 };
    } catch (error: any) {
      const duplicateCount = this.countDuplicateKeyErrors(error);

      if (duplicateCount === 0 || this.hasNonDuplicateWriteError(error)) {
        throw error;
      }

      const duplicateIndexes = new Set(
        this.getWriteErrors(error)
          .filter((writeError) => writeError?.code === 11000)
          .map((writeError) => writeError?.index)
          .filter((index) => Number.isInteger(index)),
      );
      const insertedEvents = persistedEvents.filter(
        (_event, index) => !duplicateIndexes.has(index),
      );

      await this.updateAhaProgressFromEvents(insertedEvents);

      return {
        inserted: this.getBulkInsertedCount(error),
        duplicates: duplicateCount,
      };
    }
  }

  async findAll(filters: AuditEventFilters = {}) {
    const query = this.buildQuery(filters);
    const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
    const offset = Math.max(filters.offset ?? 0, 0);

    const [events, total] = await Promise.all([
      this.auditEventModel
        .find(query)
        .sort({ timestamp: -1 })
        .skip(offset)
        .limit(limit)
        .lean()
        .exec(),
      this.auditEventModel.countDocuments(query).exec(),
    ]);

    return { events, total, limit, offset };
  }

  async findRollups(filters: AuditEventFilters = {}) {
    const query = this.buildRollupQuery(filters);
    const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
    const offset = Math.max(filters.offset ?? 0, 0);

    const [rollups, total] = await Promise.all([
      this.auditEventRollupModel
        .find(query)
        .sort({ last_seen_at: -1 })
        .skip(offset)
        .limit(limit)
        .lean()
        .exec(),
      this.auditEventRollupModel.countDocuments(query).exec(),
    ]);

    return { rollups, total, limit, offset };
  }

  async getAhaMetrics(query: AhaMetricsQuery) {
    const activeRule = await this.getActiveAhaRule();
    const ruleVersion = query.rule_version ?? activeRule.version;

    const [totals] = await this.ahaProgressModel.aggregate<{
      _id?: null;
      actors_started: number;
      key_events_completed: number;
      key_screens_completed: number;
      return_completed: number;
      aha_reached: number;
    }>([
      {
        $match: {
          actor_role: 'advisor',
          rule_version: ruleVersion,
          ...(query.actor_id ? { actor_id: query.actor_id } : {}),
        },
      },
      {
        $project: {
          started: this.buildSingleFieldProjection('journey_started_at', query),
          key_events_completed: this.buildSingleFieldProjection(
            'required_key_events_completed_at',
            query,
          ),
          key_screens_completed: this.buildSingleFieldProjection(
            'key_screens_completed_at',
            query,
          ),
          return_completed: this.buildSingleFieldProjection(
            'return_completed_at',
            query,
          ),
          aha_reached: this.buildSingleFieldProjection('aha_reached_at', query),
        },
      },
      {
        $group: {
          _id: null,
          actors_started: { $sum: '$started' },
          key_events_completed: { $sum: '$key_events_completed' },
          key_screens_completed: { $sum: '$key_screens_completed' },
          return_completed: { $sum: '$return_completed' },
          aha_reached: { $sum: '$aha_reached' },
        },
      },
    ]);

    const { _id: _ignoredAggregateId, ...normalizedTotals } = totals ?? {};

    return {
      rule_version: ruleVersion,
      rule:
        activeRule.version === ruleVersion
          ? activeRule
          : await this.getAhaRuleByVersion(ruleVersion),
      period: {
        ...(query.startDate ? { startDate: query.startDate } : {}),
        ...(query.endDate ? { endDate: query.endDate } : {}),
      },
      totals:
        Object.keys(normalizedTotals).length > 0
          ? normalizedTotals
          : {
              actors_started: 0,
              key_events_completed: 0,
              key_screens_completed: 0,
              return_completed: 0,
              aha_reached: 0,
            },
      key_events: await this.buildAhaKeyEventMetrics(ruleVersion, query),
    };
  }

  async getActiveAhaRule(): Promise<AhaRuleSnapshot> {
    const activeRule = await this.ahaRuleVersionModel
      .findOne({ is_active: true })
      .lean()
      .exec();

    return activeRule ?? this.buildDefaultAhaRule();
  }

  async listAhaRuleVersions(): Promise<AhaRuleSnapshot[]> {
    const versions = await this.ahaRuleVersionModel
      .find({})
      .sort({ createdAt: -1 })
      .lean()
      .exec();

    if (
      versions.some((version) => version.version === DEFAULT_AHA_RULE_VERSION)
    ) {
      return versions;
    }

    return [...versions, this.buildDefaultAhaRule()];
  }

  async createAhaRuleVersion(
    dto: CreateAhaRuleVersionDto,
    user?: AuthenticatedUser,
  ) {
    this.validateAhaRuleVersion(dto);

    await this.ahaRuleVersionModel.updateMany(
      { is_active: true },
      { $set: { is_active: false } },
    );

    return this.ahaRuleVersionModel.create({
      ...dto,
      is_active: true,
      created_by: user?.tokenData?.email ?? user?.tokenData?.uuid ?? user?.id,
    });
  }

  async listAhaProgress(query: QueryAhaProgressDto = {}) {
    const filters: Record<string, unknown> = {};
    if (query.actor_id) filters.actor_id = query.actor_id;
    if (query.actor_role) filters.actor_role = query.actor_role;
    if (query.rule_version) filters.rule_version = query.rule_version;
    if (query.aha_reached) {
      filters.aha_reached_at = { $exists: true, $ne: null };
    }

    const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
    const offset = Math.max(query.offset ?? 0, 0);

    const [items, total] = await Promise.all([
      this.ahaProgressModel
        .find(filters)
        .sort({ updatedAt: -1 })
        .skip(offset)
        .limit(limit)
        .lean()
        .exec(),
      this.ahaProgressModel.countDocuments(filters).exec(),
    ]);

    return { items, total, limit, offset };
  }

  async getAhaRuleSnippet() {
    const rule = await this.getActiveAhaRule();

    const keyEvents = rule.key_events ?? [];
    const requiredKeyEvents = keyEvents.filter((ke) => ke.required);
    const optionalKeyEvents = keyEvents.filter((ke) => !ke.required);
    const allowedScreens = rule.allowed_key_screens ?? [];

    const keyEventsSection =
      keyEvents.length === 0
        ? '  (nenhum evento-chave configurado)'
        : [
            ...requiredKeyEvents.map((ke) =>
              [
                `  [OBRIGATORIO] key="${ke.key}" label="${ke.label}"`,
                `    event_names: ${ke.event_names.join(', ')}`,
                `    min_count: ${ke.min_count} (o evento precisa ocorrer pelo menos ${ke.min_count} vez(es))`,
                `    REGRA: o AHA so e atingido se este evento ocorrer. Nao emitir este evento = AHA nunca acontece.`,
              ].join('\n'),
            ),
            ...optionalKeyEvents.map((ke) =>
              [
                `  [OPCIONAL] key="${ke.key}" label="${ke.label}"`,
                `    event_names: ${ke.event_names.join(', ')}`,
                `    min_count: ${ke.min_count}`,
                `    REGRA: rastreado no funil mas nao bloqueia o AHA.`,
              ].join('\n'),
            ),
          ].join('\n\n');

    const screensSection =
      allowedScreens.length === 0
        ? '  (nenhuma tela-chave configurada)'
        : allowedScreens.map((s) => `  - ${s}`).join('\n');

    const snippet = [
      `========================================`,
      `REGRA AHA ATIVA — versao: ${rule.version}`,
      `Label: ${rule.label}`,
      rule.description ? `Descricao: ${rule.description}` : null,
      `========================================`,
      ``,
      `O AHA Moment ocorre quando os 3 criterios abaixo sao satisfeitos simultaneamente:`,
      ``,
      `CRITERIO 1 — Eventos-chave obrigatorios (${requiredKeyEvents.length} obrigatorio(s), ${optionalKeyEvents.length} opcional(is)):`,
      keyEventsSection,
      ``,
      `CRITERIO 2 — Telas-chave distintas:`,
      `  O planejador precisa visualizar pelo menos ${rule.min_distinct_key_screens} tela(s)-chave diferentes com dados carregados.`,
      `  Telas validas (emitir o evento quando a tela carregar com dados):`,
      screensSection,
      `  IMPORTANTE: emitir o evento so quando os dados ja estiverem visiveis na tela, nao no mount do componente.`,
      ``,
      `CRITERIO 3 — Retorno:`,
      `  O planejador precisa voltar ao produto pelo menos ${rule.min_return_days} dia(s) depois do primeiro evento registrado.`,
      `  Qualquer evento de advisor (exceto erros) serve como evidencia de retorno.`,
      ``,
      `========================================`,
      `COMO EMITIR OS EVENTOS`,
      `========================================`,
      ``,
      `Use SEMPRE o hook existente "useTracking" (ou o equivalente no contexto atual).`,
      `NUNCA chame o endpoint de audit-events diretamente de um componente.`,
      ``,
      `Exemplo de chamada:`,
      `  const { track } = useTracking();`,
      `  track({ event_name: 'NOME_DO_EVENTO', metadata: { client_uuid: '...' } });`,
      ``,
      `========================================`,
      `INSTRUCOES PARA A IA — LEIA ANTES DE IMPLEMENTAR`,
      `========================================`,
      ``,
      `Antes de adicionar qualquer chamada de tracking, pergunte ao usuario:`,
      ``,
      `1. LOCALIZACAO: "Em qual arquivo/componente/funcao voce quer adicionar este evento?`,
      `   Me mostre o trecho de codigo ou descreva o fluxo para eu confirmar se o lugar esta correto."`,
      ``,
      `2. MOMENTO: "O evento deve ser emitido apos sucesso, no inicio da acao ou quando a tela carregar?`,
      `   Para eventos de tela-chave, so emitir quando os dados ja estiverem visiveis."`,
      ``,
      `3. METADATA: "Quais campos de metadata fazem sentido aqui? O client_uuid esta disponivel neste contexto?`,
      `   Outros campos relevantes (trigger, file_type, etc.) precisam ser passados?"`,
      ``,
      `4. DUPLICATAS: "Este evento pode ser emitido mais de uma vez no mesmo fluxo?`,
      `   Se sim, a contagem minima (min_count) da regra esta correta para este caso?"`,
      ``,
      `NAO implemente nada sem confirmar esses pontos com o usuario.`,
      `Se o lugar ou o momento parecer incorreto, AVISE antes de prosseguir.`,
    ]
      .filter((line) => line !== null)
      .join('\n');

    return {
      rule_version: rule.version,
      snippet,
    };
  }

  async previewRollups(filters: AuditEventFilters = {}) {
    const groups = await this.buildRollupGroups(filters);
    const rawCount = groups.reduce((total, group) => total + group.count, 0);
    const estimatedBytes = groups.reduce(
      (total, group) => total + (group.estimated_bytes ?? 0),
      0,
    );

    return {
      raw_count: rawCount,
      group_count: groups.length,
      duplicate_count: Math.max(rawCount - groups.length, 0),
      estimated_bytes: estimatedBytes,
      groups,
    };
  }

  async runRollups(filters: AuditEventFilters = {}) {
    const groups = await this.buildRollupGroups(filters);

    for (const group of groups) {
      await this.auditEventRollupModel.updateOne(
        { rollup_key: group.rollup_key },
        { $set: group },
        { upsert: true },
      );
    }

    return {
      upserted_count: groups.length,
      raw_count: groups.reduce((total, group) => total + group.count, 0),
    };
  }

  async pruneRolledUpEvents(filters: AuditEventFilters = {}) {
    const rollupQuery = this.buildPrunableRollupQuery(filters);
    if (!rollupQuery) {
      return {
        deleted_count: 0,
        rollup_count: 0,
      };
    }

    const rollups = await this.auditEventRollupModel
      .find(rollupQuery)
      .lean()
      .exec();

    if (rollups.length === 0) {
      return {
        deleted_count: 0,
        rollup_count: 0,
      };
    }

    let deletedCount = 0;
    for (const rollup of rollups) {
      const result = await this.auditEventModel
        .deleteMany(this.buildQueryForRollup(rollup))
        .exec();
      deletedCount += result.deletedCount ?? 0;
    }

    return {
      deleted_count: deletedCount,
      rollup_count: rollups.length,
    };
  }

  async deleteRollups(filters: AuditEventFilters = {}) {
    const result = await this.auditEventRollupModel
      .deleteMany(this.buildRollupQuery(filters))
      .exec();

    return {
      deleted_count: result.deletedCount ?? 0,
    };
  }

  async deleteSelectedRollups(rollupKeys: string[]) {
    if (!Array.isArray(rollupKeys) || rollupKeys.length === 0) {
      throw new BadRequestException('rollup_keys are required');
    }

    const result = await this.auditEventRollupModel
      .deleteMany({ rollup_key: { $in: rollupKeys } })
      .exec();

    return {
      deleted_count: result.deletedCount ?? 0,
    };
  }

  async deleteAuditEvents(filters: AuditEventFilters = {}) {
    const query = this.buildQuery(filters);

    if (Object.keys(query).length === 0) {
      throw new BadRequestException(
        'At least one audit event filter is required',
      );
    }

    const result = await this.auditEventModel.deleteMany(query).exec();

    return {
      deleted_count: result.deletedCount ?? 0,
    };
  }

  async deleteSelectedAuditEvents(eventIds: string[]) {
    if (!Array.isArray(eventIds) || eventIds.length === 0) {
      throw new BadRequestException('event_ids are required');
    }

    const result = await this.auditEventModel
      .deleteMany({ event_id: { $in: eventIds } })
      .exec();

    return {
      deleted_count: result.deletedCount ?? 0,
    };
  }

  private resolveActor(
    event: CreateAuditEventDto,
    user?: AuthenticatedUser,
  ): Pick<CreateAuditEventDto, 'actor_id' | 'actor_role'> {
    if (!user) {
      return {
        actor_id: event.actor_id,
        actor_role: event.actor_role,
      };
    }

    const actorId =
      user.tokenData?.uuid ||
      user.tokenData?.client_id ||
      (event.source === 'web' ? event.actor_id : undefined) ||
      user.tokenData?.subject ||
      user.id;

    return {
      actor_id: String(actorId),
      actor_role: user.type === 'client' ? 'client' : 'advisor',
    };
  }

  private assertUnknownActorAllowed(
    event: CreateAuditEventDto,
    user?: AuthenticatedUser,
  ): void {
    if (user || event.actor_role !== 'unknown') {
      return;
    }

    const isAllowedUnknownActorEvent =
      event.event_name === 'api.request.failed' &&
      event.source === 'api' &&
      event.actor_id === 'unknown';

    if (!isAllowedUnknownActorEvent) {
      throw new BadRequestException(
        'actor_role "unknown" is allowed only for api.request.failed events from api source',
      );
    }
  }

  private assertMetadataSize(
    eventName: string,
    metadata: Record<string, unknown>,
  ): void {
    const size = Buffer.byteLength(JSON.stringify(metadata), 'utf8');
    const maxSize = ERROR_EVENT_NAMES.has(eventName)
      ? MAX_ERROR_METADATA_BYTES
      : MAX_METADATA_BYTES;

    if (size > maxSize) {
      throw new BadRequestException(
        `Audit event metadata exceeds ${Math.round(maxSize / 1024)}KB`,
      );
    }
  }

  private async updateAhaProgressFromEvents(events: PersistedAuditEvent[]) {
    const advisorEvents = events
      .filter(
        (event) =>
          event.actor_role === 'advisor' &&
          !ERROR_EVENT_NAMES.has(event.event_name),
      )
      .sort(
        (left, right) => left.timestamp.getTime() - right.timestamp.getTime(),
      );

    if (advisorEvents.length === 0) {
      return;
    }

    const activeRule = await this.getActiveAhaRule();
    const allowedKeyScreens = new Set(activeRule.allowed_key_screens);
    const keyEventsByEventName = this.buildKeyEventsByEventName(activeRule);

    for (const event of advisorEvents) {
      const query = {
        actor_id: event.actor_id,
        actor_role: 'advisor' as const,
        rule_version: activeRule.version,
      };
      const existing = await this.ahaProgressModel.findOne(query).lean().exec();
      const isAhaRelevantEvent = this.isAhaRelevantEvent(
        event.event_name,
        allowedKeyScreens,
        keyEventsByEventName,
      );

      if (!existing && !isAhaRelevantEvent) {
        continue;
      }

      const journeyStartedAt = existing?.journey_started_at ?? event.timestamp;
      const keyEventMatches = keyEventsByEventName.get(event.event_name) ?? [];
      const keyEventsCounts = this.resolveNextKeyEventCounts(
        existing?.key_events_counts ?? {},
        keyEventMatches,
      );
      const keyEventsSeen = this.resolveNextKeyEventsSeen(
        existing?.key_events_seen ?? [],
        keyEventMatches,
      );
      const keyEventsCompleted = this.resolveNextKeyEventsCompleted(
        existing?.key_events_completed ?? [],
        keyEventsCounts,
        activeRule.key_events,
      );
      const keyEventsCompletedAt = this.resolveNextKeyEventsCompletedAt(
        existing?.key_events_completed_at ?? {},
        keyEventsCompleted,
        event.timestamp,
      );
      const requiredKeyEventsCompletedAt =
        existing?.required_key_events_completed_at ??
        (this.areRequiredKeyEventsCompleted(
          activeRule.key_events,
          keyEventsCompleted,
        )
          ? event.timestamp
          : undefined);
      const keyScreensSeen = this.resolveNextKeyScreensSeen(
        existing?.key_screens_seen ?? [],
        event.event_name,
        allowedKeyScreens,
      );
      const keyScreensSeenCount = keyScreensSeen.length;
      const keyScreensCompletedAt =
        existing?.key_screens_completed_at ??
        (keyScreensSeenCount >= activeRule.min_distinct_key_screens
          ? event.timestamp
          : undefined);
      const returnCompletedAt =
        existing?.return_completed_at ??
        (this.isReturnEventSatisfied(
          event,
          journeyStartedAt,
          activeRule.min_return_days,
        )
          ? event.timestamp
          : undefined);
      const lastEventAt =
        existing?.last_event_at &&
        new Date(existing.last_event_at).getTime() > event.timestamp.getTime()
          ? existing.last_event_at
          : event.timestamp;
      const ahaReachedAt =
        existing?.aha_reached_at ??
        this.resolveAhaReachedAt(
          requiredKeyEventsCompletedAt,
          keyScreensCompletedAt,
          returnCompletedAt,
        );

      await this.ahaProgressModel.updateOne(
        query,
        {
          $set: {
            journey_started_at: journeyStartedAt,
            key_events_seen: keyEventsSeen,
            key_events_counts: keyEventsCounts,
            key_events_completed: keyEventsCompleted,
            key_events_completed_at: keyEventsCompletedAt,
            ...(requiredKeyEventsCompletedAt
              ? {
                  required_key_events_completed_at:
                    requiredKeyEventsCompletedAt,
                }
              : {}),
            key_screens_seen: keyScreensSeen,
            key_screens_seen_count: keyScreensSeenCount,
            ...(keyScreensCompletedAt
              ? { key_screens_completed_at: keyScreensCompletedAt }
              : {}),
            ...(returnCompletedAt
              ? { return_completed_at: returnCompletedAt }
              : {}),
            last_event_at: lastEventAt,
            ...(ahaReachedAt ? { aha_reached_at: ahaReachedAt } : {}),
          },
          $setOnInsert: query,
        },
        { upsert: true },
      );
    }
  }

  private isAhaRelevantEvent(
    eventName: string,
    allowedKeyScreens: Set<string>,
    keyEventsByEventName: Map<string, AhaKeyEventRuleSnapshot[]>,
  ) {
    return (
      keyEventsByEventName.has(eventName) || allowedKeyScreens.has(eventName)
    );
  }

  private buildKeyEventsByEventName(rule: AhaRuleSnapshot) {
    const byEventName = new Map<string, AhaKeyEventRuleSnapshot[]>();
    for (const keyEvent of rule.key_events) {
      for (const eventName of keyEvent.event_names) {
        byEventName.set(eventName, [
          ...(byEventName.get(eventName) ?? []),
          keyEvent,
        ]);
      }
    }
    return byEventName;
  }

  private resolveNextKeyEventCounts(
    currentCounts: Record<string, number>,
    keyEventMatches: AhaKeyEventRuleSnapshot[],
  ) {
    return keyEventMatches.reduce(
      (counts, keyEvent) => ({
        ...counts,
        [keyEvent.key]: (counts[keyEvent.key] ?? 0) + 1,
      }),
      { ...currentCounts },
    );
  }

  private resolveNextKeyEventsSeen(
    currentSeen: string[],
    keyEventMatches: AhaKeyEventRuleSnapshot[],
  ) {
    return Array.from(
      new Set([
        ...currentSeen,
        ...keyEventMatches.map((keyEvent) => keyEvent.key),
      ]),
    );
  }

  private resolveNextKeyEventsCompleted(
    currentCompleted: string[],
    counts: Record<string, number>,
    keyEvents: AhaKeyEventRuleSnapshot[],
  ) {
    const completed = new Set(currentCompleted);
    for (const keyEvent of keyEvents) {
      if ((counts[keyEvent.key] ?? 0) >= keyEvent.min_count) {
        completed.add(keyEvent.key);
      }
    }
    return Array.from(completed);
  }

  private resolveNextKeyEventsCompletedAt(
    currentCompletedAt: Record<string, Date>,
    completedKeys: string[],
    timestamp: Date,
  ) {
    return completedKeys.reduce<Record<string, Date>>(
      (completedAt, key) => ({
        ...completedAt,
        [key]: completedAt[key] ?? timestamp,
      }),
      { ...currentCompletedAt },
    );
  }

  private areRequiredKeyEventsCompleted(
    keyEvents: AhaKeyEventRuleSnapshot[],
    completedKeys: string[],
  ) {
    const completed = new Set(completedKeys);
    return keyEvents
      .filter((keyEvent) => keyEvent.required)
      .every((keyEvent) => completed.has(keyEvent.key));
  }

  private resolveNextKeyScreensSeen(
    currentScreens: string[],
    eventName: string,
    allowedKeyScreens: Set<string>,
  ) {
    if (!allowedKeyScreens.has(eventName)) {
      return currentScreens;
    }

    return Array.from(new Set([...currentScreens, eventName]));
  }

  private isReturnEventSatisfied(
    event: PersistedAuditEvent,
    journeyStartedAt: Date,
    minReturnDays: number,
  ) {
    return (
      this.calculateDayDiff(journeyStartedAt, event.timestamp) >= minReturnDays
    );
  }

  private resolveAhaReachedAt(
    requiredKeyEventsCompletedAt?: Date,
    keyScreensCompletedAt?: Date,
    returnCompletedAt?: Date,
  ) {
    if (
      !requiredKeyEventsCompletedAt ||
      !keyScreensCompletedAt ||
      !returnCompletedAt
    ) {
      return undefined;
    }

    return new Date(
      Math.max(
        requiredKeyEventsCompletedAt.getTime(),
        keyScreensCompletedAt.getTime(),
        returnCompletedAt.getTime(),
      ),
    );
  }

  private async getAhaRuleByVersion(
    version: string,
  ): Promise<AhaRuleSnapshot | null> {
    if (version === DEFAULT_AHA_RULE_VERSION) {
      return this.buildDefaultAhaRule();
    }

    const rule = await this.ahaRuleVersionModel
      .findOne({ version })
      .lean()
      .exec();

    return rule ?? null;
  }

  private buildDefaultAhaRule(): AhaRuleSnapshot {
    return {
      version: DEFAULT_AHA_RULE_VERSION,
      label: 'Tracking V1',
      description: 'Regra padrao inicial do tracking',
      is_active: true,
      key_events: [
        {
          key: 'first_import',
          label: 'Primeira importacao',
          event_names: ['import.openfinance.succeeded', 'import.csv.succeeded'],
          min_count: 1,
          required: true,
        },
      ],
      allowed_key_screens: [...AHA_SCREEN_EVENT_NAMES],
      min_distinct_key_screens: 1,
      min_return_days: 1,
      created_by: 'system',
    };
  }

  private calculateDayDiff(start: Date, end: Date) {
    const millisecondsPerDay = 24 * 60 * 60 * 1000;
    return Math.floor((end.getTime() - start.getTime()) / millisecondsPerDay);
  }

  private validateAhaRuleVersion(dto: CreateAhaRuleVersionDto) {
    const seenKeys = new Set<string>();
    const keyPattern = /^[a-z][a-z0-9_]*$/;
    let hasRequiredKeyEvent = false;

    for (const keyEvent of dto.key_events ?? []) {
      if (!keyPattern.test(keyEvent.key)) {
        throw new BadRequestException('Invalid AHA key_event key');
      }
      if (seenKeys.has(keyEvent.key)) {
        throw new BadRequestException('Duplicate AHA key_event key');
      }
      seenKeys.add(keyEvent.key);
      hasRequiredKeyEvent ||= keyEvent.required;
    }

    if (!hasRequiredKeyEvent) {
      throw new BadRequestException(
        'At least one required AHA key_event is required',
      );
    }
  }

  private async buildAhaKeyEventMetrics(
    ruleVersion: string,
    query: AhaMetricsQuery,
  ) {
    const rule = await this.getAhaRuleByVersion(ruleVersion);
    if (!rule) {
      return [];
    }

    return Promise.all(
      rule.key_events.map(async (keyEvent) => {
        const [row] = await this.ahaProgressModel.aggregate<{
          _id: null;
          actors_seen: number;
          actors_completed: number;
          total_count: number;
        }>([
          {
            $match: {
              actor_role: 'advisor',
              rule_version: ruleVersion,
              ...(query.actor_id ? { actor_id: query.actor_id } : {}),
            },
          },
          {
            $project: {
              seen: {
                $in: [keyEvent.key, { $ifNull: ['$key_events_seen', []] }],
              },
              completed: {
                $in: [keyEvent.key, { $ifNull: ['$key_events_completed', []] }],
              },
              count: {
                $ifNull: [`$key_events_counts.${keyEvent.key}`, 0],
              },
            },
          },
          {
            $group: {
              _id: null,
              actors_seen: { $sum: { $cond: ['$seen', 1, 0] } },
              actors_completed: { $sum: { $cond: ['$completed', 1, 0] } },
              total_count: { $sum: '$count' },
            },
          },
        ]);

        return {
          ...keyEvent,
          actors_seen: row?.actors_seen ?? 0,
          actors_completed: row?.actors_completed ?? 0,
          total_count: row?.total_count ?? 0,
        };
      }),
    );
  }

  private async buildRollupGroups(filters: AuditEventFilters = {}) {
    const match = this.buildRollupMatch(filters);

    if (!match) {
      return [];
    }

    return this.auditEventModel.aggregate<{
      rollup_key: string;
      day: string;
      event_name: string;
      actor_id: string;
      actor_role: 'advisor' | 'client' | 'unknown';
      source: 'web' | 'api' | 'mobile';
      client_uuid?: string;
      count: number;
      first_seen_at: Date;
      last_seen_at: Date;
      rolled_up_until: Date;
      sample_session_ids: string[];
      sample_correlation_ids: string[];
      metadata: Record<string, unknown>;
      estimated_bytes?: number;
    }>([
      { $match: match },
      {
        $addFields: {
          rollup_day: {
            $dateToString: {
              format: '%Y-%m-%d',
              date: '$timestamp',
              timezone: 'America/Sao_Paulo',
            },
          },
          rollup_client_uuid: '$metadata.client_uuid',
        },
      },
      {
        $group: {
          _id: {
            day: '$rollup_day',
            event_name: '$event_name',
            actor_id: '$actor_id',
            actor_role: '$actor_role',
            source: '$source',
            client_uuid: '$rollup_client_uuid',
          },
          count: { $sum: 1 },
          first_seen_at: { $min: '$timestamp' },
          last_seen_at: { $max: '$timestamp' },
          sample_session_ids: { $addToSet: '$session_id' },
          sample_correlation_ids: { $addToSet: '$correlation_id' },
          metadata: { $first: '$metadata' },
          estimated_bytes: { $sum: { $bsonSize: '$$ROOT' } },
        },
      },
      {
        $match: {
          count: { $gt: 1 },
        },
      },
      {
        $project: {
          _id: 0,
          rollup_key: {
            $concat: [
              '$_id.day',
              '|',
              '$_id.event_name',
              '|',
              '$_id.actor_id',
              '|',
              '$_id.actor_role',
              '|',
              '$_id.source',
              '|',
              { $ifNull: ['$_id.client_uuid', NULL_CLIENT_UUID_ROLLUP_KEY] },
            ],
          },
          day: '$_id.day',
          event_name: '$_id.event_name',
          actor_id: '$_id.actor_id',
          actor_role: '$_id.actor_role',
          source: '$_id.source',
          client_uuid: '$_id.client_uuid',
          count: 1,
          first_seen_at: 1,
          last_seen_at: 1,
          rolled_up_until: '$last_seen_at',
          sample_session_ids: { $slice: ['$sample_session_ids', 5] },
          sample_correlation_ids: { $slice: ['$sample_correlation_ids', 5] },
          metadata: 1,
          estimated_bytes: 1,
        },
      },
      { $sort: { day: -1, count: -1 } },
    ]);
  }

  private buildQuery(filters: AuditEventFilters) {
    const query: Record<string, unknown> = {};

    if (filters.actor_id) query.actor_id = filters.actor_id;
    if (filters.actor_role) query.actor_role = filters.actor_role;
    if (filters.client_uuid !== undefined && filters.client_uuid !== null) {
      query['metadata.client_uuid'] = filters.client_uuid;
    }
    if (filters.event_name) query.event_name = filters.event_name;
    if (filters.session_id) query.session_id = filters.session_id;
    if (filters.correlation_id) query.correlation_id = filters.correlation_id;

    if (filters.startDate || filters.endDate) {
      query.timestamp = {};
      if (filters.startDate) {
        (query.timestamp as Record<string, Date>).$gte = filters.startDate;
      }
      if (filters.endDate) {
        (query.timestamp as Record<string, Date>).$lte = filters.endDate;
      }
    }

    return query;
  }

  private buildSingleFieldProjection(field: string, query: AhaMetricsQuery) {
    return {
      $cond: [this.buildFieldInPeriodExpression(field, query), 1, 0],
    };
  }

  private buildPresenceProjection(fields: string[], query: AhaMetricsQuery) {
    return {
      $cond: [
        {
          $or: fields.map((field) =>
            this.buildFieldInPeriodExpression(field, query),
          ),
        },
        1,
        0,
      ],
    };
  }

  private buildFieldInPeriodExpression(field: string, query: AhaMetricsQuery) {
    const fieldRef = `$${field}`;
    const normalizedFieldRef = { $ifNull: [fieldRef, null] };

    // Only journey_started_at anchors the actor's journey in the queried
    // period — the other milestone timestamps (key events/screens/return/
    // aha) happen downstream of it, so requiring each of *those* fields to
    // independently satisfy startDate/endDate zeroed out the totals for any
    // actor whose journey began before the window, even though the actor
    // (and their later milestones) are legitimately in scope. Gate the
    // period filter on journey_started_at only; every other field just
    // needs to be non-null (reached), matching how key_events counts
    // "ever reached" per actor with no per-field date gating.
    const periodFieldRef =
      field === 'journey_started_at' ? normalizedFieldRef : null;

    const clauses: Record<string, unknown>[] = [
      { $ne: [normalizedFieldRef, null] },
    ];

    if (periodFieldRef) {
      if (query.startDate) {
        clauses.push({ $gte: [periodFieldRef, query.startDate] });
      }

      if (query.endDate) {
        clauses.push({ $lte: [periodFieldRef, query.endDate] });
      }
    }

    return clauses.length === 1 ? clauses[0] : { $and: clauses };
  }

  private buildRollupMatch(filters: AuditEventFilters) {
    if (
      filters.event_name &&
      ROLLUP_EXCLUDED_EVENT_NAMES.has(filters.event_name)
    ) {
      return null;
    }

    const query = this.buildQuery(filters);

    if (!filters.event_name) {
      query.event_name = { $nin: Array.from(ROLLUP_EXCLUDED_EVENT_NAMES) };
    }

    return query;
  }

  private buildRollupQuery(filters: AuditEventFilters) {
    const query: Record<string, unknown> = {};

    if (filters.actor_id) query.actor_id = filters.actor_id;
    if (filters.actor_role) query.actor_role = filters.actor_role;
    if (filters.client_uuid !== undefined && filters.client_uuid !== null) {
      query.client_uuid = filters.client_uuid;
    }
    if (filters.event_name) query.event_name = filters.event_name;

    if (filters.startDate || filters.endDate) {
      query.day = {};
      if (filters.startDate) {
        (query.day as Record<string, string>).$gte = this.formatRollupDay(
          filters.startDate,
        );
      }
      if (filters.endDate) {
        (query.day as Record<string, string>).$lte = this.formatRollupDay(
          filters.endDate,
        );
      }
    }

    return query;
  }

  private buildPrunableRollupQuery(filters: AuditEventFilters) {
    if (
      filters.event_name &&
      ROLLUP_EXCLUDED_EVENT_NAMES.has(filters.event_name)
    ) {
      return null;
    }

    const query = this.buildRollupQuery(filters);
    query.count = { $gt: 1 };

    if (!filters.event_name) {
      query.event_name = { $nin: Array.from(ROLLUP_EXCLUDED_EVENT_NAMES) };
    }

    return query;
  }

  private buildQueryForRollup(rollup: {
    day: string;
    event_name: string;
    actor_id: string;
    actor_role: 'advisor' | 'client' | 'unknown';
    source: 'web' | 'api' | 'mobile';
    client_uuid?: string;
    rolled_up_until: Date;
  }) {
    const startOfDay = new Date(`${rollup.day}T00:00:00.000-03:00`);
    const endOfDay = new Date(`${rollup.day}T23:59:59.999-03:00`);
    const rolledUpUntil = new Date(rollup.rolled_up_until);

    if (Number.isNaN(rolledUpUntil.getTime())) {
      throw new BadRequestException('Rollup is missing a valid cutoff');
    }

    const deleteUntil =
      rolledUpUntil.getTime() < endOfDay.getTime() ? rolledUpUntil : endOfDay;
    const query: Record<string, unknown> = {
      event_name: rollup.event_name,
      actor_id: rollup.actor_id,
      actor_role: rollup.actor_role,
      source: rollup.source,
      timestamp: {
        $gte: startOfDay,
        $lte: deleteUntil,
      },
    };

    if (rollup.client_uuid !== undefined && rollup.client_uuid !== null) {
      query['metadata.client_uuid'] = rollup.client_uuid;
    } else {
      query.$or = [
        { 'metadata.client_uuid': { $exists: false } },
        { 'metadata.client_uuid': null },
      ];
    }

    return query;
  }

  private formatRollupDay(date: Date) {
    return new Intl.DateTimeFormat('en-CA', {
      timeZone: 'America/Sao_Paulo',
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
    }).format(date);
  }

  private countDuplicateKeyErrors(error: any): number {
    const writeErrors = this.getWriteErrors(error);

    if (writeErrors.length > 0) {
      return writeErrors.filter((writeError) => writeError?.code === 11000)
        .length;
    }

    return error?.code === 11000 ? 1 : 0;
  }

  private hasNonDuplicateWriteError(error: any): boolean {
    const writeErrors = this.getWriteErrors(error);

    return writeErrors.some((writeError) => writeError?.code !== 11000);
  }

  private getWriteErrors(error: any): any[] {
    if (Array.isArray(error?.writeErrors)) {
      return error.writeErrors;
    }

    if (typeof error?.result?.getWriteErrors === 'function') {
      return error.result.getWriteErrors();
    }

    if (typeof error?.getWriteErrors === 'function') {
      return error.getWriteErrors();
    }

    return [];
  }

  private getBulkInsertedCount(error: any): number {
    if (typeof error?.result?.insertedCount === 'number') {
      return error.result.insertedCount;
    }

    if (typeof error?.result?.nInserted === 'number') {
      return error.result.nInserted;
    }

    if (typeof error?.result?.getInsertedCount === 'function') {
      return error.result.getInsertedCount();
    }

    if (typeof error?.insertedCount === 'number') {
      return error.insertedCount;
    }

    return 0;
  }
}
