import {
  HttpException,
  Injectable,
  Logger,
  ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { AtlasRuntimeClient, RuntimeSubject } from './atlas-runtime.client';
import { IngestAutomationEventDto } from './dto/ingest-automation-event.dto';
import { ScheduleWaitDto } from './dto/schedule-wait.dto';
import {
  AutomationEvent,
  AutomationEventDocument,
} from './schemas/automation-event.schema';
import { AutomationQueueService } from './queue/automation-queue.service';

type IngestResult = {
  accepted: true;
  duplicate: boolean;
  matched: number;
  enqueued: number;
  degraded: boolean;
};

@Injectable()
export class AutomationsService {
  private readonly logger = new Logger(AutomationsService.name);

  constructor(
    @InjectModel(AutomationEvent.name)
    private readonly eventModel: Model<AutomationEventDocument>,
    private readonly atlasRuntime: AtlasRuntimeClient,
    private readonly queueService: AutomationQueueService,
    private readonly configService: ConfigService,
  ) {}

  /**
   * Agenda o timer durável de uma Espera: enfileira um job ATRASADO que, ao vencer,
   * pede ao Atlas para retomar o run (kind 'resume-atlas' — não roda o engine).
   * É o "cronômetro" que o Atlas delega quando uma execução pausa numa Espera.
   */
  async scheduleWait(
    dto: ScheduleWaitDto,
  ): Promise<{ accepted: true; degraded: boolean }> {
    const queued = await this.queueService.enqueue(
      {
        kind: 'resume-atlas',
        runId: dto.runId,
        // Campos do engine não se aplicam ao resume; sentinelas não-vazios para o
        // schema do Mongo (automationId/nodeKey são required).
        automationId: dto.runId,
        versionId: null,
        nodeKey: dto.waitKey ?? 'resume-atlas',
        subject: {},
        payload: {},
        dryRun: false,
        attempt: 1,
        // waitSeq garante jobId único por entrada de Espera: idealmente é o id do
        // wait-log no Atlas (determinístico → POST duplicado deduplicado no BullMQ);
        // quando ausente, o port gera um UUID (fallback — nunca colide, perde dedup).
        waitSeq: dto.waitSeq,
      },
      dto.delayMs,
    );
    this.logger.log(
      `automation.wait.scheduled runId=${dto.runId} delayMs=${dto.delayMs} degraded=${queued.degraded}`,
    );
    return { accepted: true, degraded: queued.degraded };
  }

  async recordAndIngest(dto: IngestAutomationEventDto): Promise<IngestResult> {
    const payload = dto.payload ?? {};
    // Compartilhado entre as duas chamadas de ingest() no caminho de duplicate-key:
    // garante que o mesmo runId (createRun é idempotente por automationId+sourceEventId)
    // não seja enfileirado duas vezes mesmo após removeOnComplete evictar o job.
    const enqueuedRunIds = new Set<string>();

    try {
      const event = await this.eventModel.create({
        eventName: dto.eventName,
        source: dto.source,
        occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : undefined,
        subjectType: dto.subjectType,
        subjectId: dto.subjectId,
        contactId: dto.contactId,
        userId: dto.userId,
        payload,
        idempotencyKey: dto.idempotencyKey,
      });

      const result = await this.ingest(
        {
          eventName: event.eventName,
          idempotencyKey: event.idempotencyKey,
          subject: {
            subjectType: event.subjectType,
            subjectId: event.subjectId,
            contactId: event.contactId,
            userId: event.userId,
          },
          payload: event.payload ?? {},
        },
        enqueuedRunIds,
      );

      await this.eventModel.updateOne(
        { idempotencyKey: dto.idempotencyKey },
        { $set: { processedAt: new Date() } },
      );

      const response = { accepted: true as const, duplicate: false, ...result };
      await this.safeReportIngestionEvent(dto, {
        status: this.statusForResult(response),
        matched: response.matched,
        enqueued: response.enqueued,
        degraded: response.degraded,
        duplicate: false,
      });
      return response;
    } catch (error: any) {
      if (this.isDuplicateKeyError(error)) {
        this.logger.debug(
          `automation.event.duplicate key=${dto.idempotencyKey}`,
        );
        const existing = await this.eventModel.findOne({
          idempotencyKey: dto.idempotencyKey,
        });
        if (existing && !existing.processedAt) {
          const result = await this.ingest(
            {
              eventName: existing.eventName,
              idempotencyKey: existing.idempotencyKey,
              subject: {
                subjectType: existing.subjectType,
                subjectId: existing.subjectId,
                contactId: existing.contactId,
                userId: existing.userId,
              },
              payload: existing.payload ?? {},
            },
            enqueuedRunIds,
          );
          await this.eventModel.updateOne(
            { idempotencyKey: dto.idempotencyKey },
            { $set: { processedAt: new Date() } },
          );
          const response = {
            accepted: true as const,
            duplicate: true,
            ...result,
          };
          await this.safeReportIngestionEvent(dto, {
            status: this.statusForResult(response),
            matched: response.matched,
            enqueued: response.enqueued,
            degraded: response.degraded,
            duplicate: true,
          });
          return response;
        }
        await this.safeReportIngestionEvent(dto, {
          status: 'DUPLICATE',
          matched: 0,
          enqueued: 0,
          degraded: false,
          duplicate: true,
        });
        return {
          accepted: true,
          duplicate: true,
          matched: 0,
          enqueued: 0,
          degraded: false,
        };
      }
      if (this.isEventStoreUnavailable(error)) {
        const message = error instanceof Error ? error.message : String(error);
        await this.safeReportIngestionEvent(dto, {
          status: 'FAILED',
          matched: 0,
          enqueued: 0,
          degraded: true,
          duplicate: false,
          errorCode: 'AUTOMATION_EVENT_STORE_UNAVAILABLE',
          errorMessage: message,
          retryable: true,
        });
        throw new ServiceUnavailableException({
          accepted: false,
          stage: 'event_store',
          errorCode: 'AUTOMATION_EVENT_STORE_UNAVAILABLE',
          message:
            'MongoDB indisponível; evento não foi persistido nem enfileirado.',
          detail: message,
          retryable: true,
        });
      }
      if (this.isAtlasRuntimeAuthorizationError(error)) {
        const message = this.errorMessage(error);
        await this.safeReportIngestionEvent(dto, {
          status: 'FAILED',
          matched: 0,
          enqueued: 0,
          degraded: true,
          duplicate: false,
          errorCode: 'BFF_TO_ATLAS_AUTHORIZATION_FAILED',
          errorMessage: message,
          retryable: false,
        });
        throw new ServiceUnavailableException({
          accepted: false,
          stage: 'atlas_runtime_authorization',
          errorCode: 'BFF_TO_ATLAS_AUTHORIZATION_FAILED',
          message:
            'BFF não autorizado pelo Atlas runtime. Verifique BFF_TO_ATLAS_SERVICE_KEY e permissões da service credential owner=bff.',
          detail: message,
          retryable: false,
        });
      }
      throw error;
    }
  }

  async ingest(
    input: {
      eventName: string;
      idempotencyKey: string;
      subject: RuntimeSubject;
      payload: Record<string, unknown>;
    },
    // Conjunto de runIds já enfileirados em chamadas anteriores de ingest() para o
    // mesmo evento (usado na re-tentativa de duplicate-key para evitar enfileiramento
    // duplicado quando createRun retorna o mesmo runId idempotente).
    enqueuedRunIds: Set<string> = new Set(),
  ): Promise<Omit<IngestResult, 'accepted' | 'duplicate'>> {
    const automations = await this.atlasRuntime.findPublishedByTrigger(
      input.eventName,
    );
    let enqueued = 0;
    let degraded = false;

    for (const automation of automations) {
      try {
        const run = await this.atlasRuntime.createRun({
          automationId: automation.id,
          versionId: automation.currentVersionId,
          sourceEventId: input.idempotencyKey,
          subject: input.subject,
          payload: input.payload,
          dryRun: this.effectiveDryRun(),
        });

        const runId = String(run.id);

        // Guard BFF-local: createRun é idempotente (Atlas upsert por automationId +
        // sourceEventId) — na re-tentativa retorna o MESMO runId. Se já enfileiramos
        // esse runId nesta sessão de ingest, pular para evitar job duplicado no BullMQ
        // após o removeOnComplete evictar o job original.
        if (enqueuedRunIds.has(runId)) {
          this.logger.debug(
            `automation.ingest.skip_reenqueue runId=${runId} automationId=${automation.id} idempotencyKey=${input.idempotencyKey}`,
          );
          continue;
        }

        const queueResult = await this.queueService.enqueue({
          runId,
          automationId: automation.id,
          versionId: automation.currentVersionId,
          nodeKey: automation.triggerNodeKey ?? 'trigger_1',
          subject: input.subject as Record<string, unknown>,
          payload: input.payload,
          dryRun: this.effectiveDryRun(),
          attempt: 1,
        });

        enqueuedRunIds.add(runId);

        if (queueResult.degraded) {
          await this.reportQueueDegraded({
            runId,
            automationId: automation.id,
            versionId: automation.currentVersionId,
            subject: input.subject,
            dryRun: this.effectiveDryRun(),
          });
        }
        enqueued += queueResult.degraded ? 0 : 1;
        degraded ||= queueResult.degraded;
      } catch (error) {
        // Isola a falha de uma automação para não abortar o processamento das demais.
        this.logger.error(
          `automation.ingest.iteration_error automationId=${automation.id} idempotencyKey=${input.idempotencyKey} error=${error instanceof Error ? error.message : String(error)}`,
        );
        degraded = true;
      }
    }

    return { matched: automations.length, enqueued, degraded };
  }

  private effectiveDryRun(): boolean {
    return (
      !isFalseFlag(this.configService.get('AUTOMATION_DRY_RUN')) ||
      !isTrueFlag(this.configService.get('AUTOMATION_SEND_ENABLED'))
    );
  }

  private async reportQueueDegraded(input: {
    runId: string;
    automationId: string;
    versionId: string | null;
    subject: RuntimeSubject;
    dryRun: boolean;
  }) {
    await this.atlasRuntime.reportRuntimeEvent({
      runId: input.runId,
      runStatus: 'FAILED',
      finishedAt: new Date().toISOString(),
      log: {
        automationId: input.automationId,
        versionId: input.versionId,
        runId: input.runId,
        contactId: input.subject.contactId ?? null,
        userId: input.subject.userId ?? null,
        nodeKey: null,
        blockLabel: null,
        channel: null,
        event: 'queue.degraded',
        status: 'ERROR',
        errorCode: 'QUEUE_DEGRADED',
        errorMessage:
          'Fila de automações indisponível; execução não enfileirada.',
        payloadSummary: { degraded: true },
        attempts: 1,
        requestId: `${input.runId}:queue:degraded`,
        dryRun: input.dryRun,
      },
    });
  }

  private async safeReportIngestionEvent(
    dto: IngestAutomationEventDto,
    input: {
      status:
        | 'RECEIVED'
        | 'MATCHED'
        | 'ENQUEUED'
        | 'DUPLICATE'
        | 'FAILED'
        | 'DEGRADED';
      matched: number;
      enqueued: number;
      degraded: boolean;
      duplicate: boolean;
      errorCode?: string;
      errorMessage?: string;
      retryable?: boolean;
    },
  ) {
    try {
      await this.atlasRuntime.reportIngestionEvent({
        eventName: dto.eventName,
        source: dto.source,
        subjectType: dto.subjectType ?? null,
        subjectId: dto.subjectId ?? null,
        contactId: dto.contactId ?? null,
        userId: dto.userId ?? null,
        payload: dto.payload ?? {},
        idempotencyKey: dto.idempotencyKey,
        status: input.status,
        matched: input.matched,
        enqueued: input.enqueued,
        degraded: input.degraded,
        duplicate: input.duplicate,
        errorCode: input.errorCode ?? null,
        errorMessage: input.errorMessage ?? null,
        retryable: input.retryable ?? false,
      });
    } catch (error) {
      this.logger.warn(
        `automation.ingestion.report_failed key=${dto.idempotencyKey} message=${
          error instanceof Error ? error.message : String(error)
        }`,
      );
    }
  }

  private statusForResult(result: {
    matched: number;
    enqueued: number;
    degraded: boolean;
  }): 'RECEIVED' | 'MATCHED' | 'ENQUEUED' | 'DEGRADED' {
    if (result.degraded) return 'DEGRADED';
    if (result.enqueued > 0) return 'ENQUEUED';
    if (result.matched > 0) return 'MATCHED';
    return 'RECEIVED';
  }

  private isDuplicateKeyError(error: unknown): boolean {
    if (!isRecord(error)) return false;

    if (error.code === 11000) return true;
    if (!Array.isArray(error.writeErrors)) return false;

    return error.writeErrors.some(
      (item: unknown) => isRecord(item) && item.code === 11000,
    );
  }

  private isEventStoreUnavailable(error: unknown): boolean {
    if (!isRecord(error)) return false;
    const name = typeof error.name === 'string' ? error.name : '';
    const message = typeof error.message === 'string' ? error.message : '';
    return (
      name.includes('MongooseServerSelectionError') ||
      name.includes('MongoNetworkError') ||
      name.includes('MongoServerSelectionError') ||
      message.includes('ECONNREFUSED') ||
      message.includes('ENOTFOUND') ||
      message.includes('buffering timed out') ||
      message.includes('Topology is closed') ||
      message.includes('MongoDB')
    );
  }

  private isAtlasRuntimeAuthorizationError(error: unknown): boolean {
    const status = this.errorStatus(error);
    if (status === 401 || status === 403) return true;

    const message = this.errorMessage(error);
    return (
      message.includes('BFF_TO_ATLAS_SERVICE_KEY') ||
      message.includes('Service key negada') ||
      message.includes('Service key denied') ||
      message.includes('scope_missing') ||
      message.includes('route_denied') ||
      message.includes('method_denied')
    );
  }

  private errorStatus(error: unknown): number | null {
    if (error instanceof HttpException) return error.getStatus();
    if (!isRecord(error)) return null;

    if (typeof error.status === 'number') return error.status;
    if (typeof error.statusCode === 'number') return error.statusCode;

    const response = error.response;
    if (isRecord(response)) {
      if (typeof response.status === 'number') return response.status;
      if (typeof response.statusCode === 'number') return response.statusCode;
    }

    return null;
  }

  private errorMessage(error: unknown): string {
    if (error instanceof HttpException) {
      const response = error.getResponse();
      if (typeof response === 'string') return response;
      if (isRecord(response)) {
        const message = response.message;
        if (typeof message === 'string') return message;
        if (Array.isArray(message)) return message.join(', ');
      }
    }

    if (error instanceof Error) return error.message;
    if (isRecord(error)) {
      const message = error.message;
      if (typeof message === 'string') return message;
    }

    return String(error);
  }
}

function isTrueFlag(value: unknown): boolean {
  return value === true || value === 'true';
}

function isFalseFlag(value: unknown): boolean {
  return value === false || value === 'false';
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}
