import {
  Injectable,
  Logger,
  OnModuleDestroy,
  OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Worker } from 'bullmq';
import { AtlasRuntimeClient, RuntimeSubject } from '../atlas-runtime.client';
import type {
  AutomationChannel,
  FlowEdge,
  FlowNode,
  MessageChannel,
} from '../engine/types';
import { resolveVariables } from '../engine/resolve-variables';
import { MessageProviderRegistry } from '../providers/message-providers';
import {
  AUTOMATION_QUEUE_NAME,
  type AutomationJobData,
} from './automation-job-data';
import { AutomationQueueService } from './automation-queue.service';
import { redisConnection } from './redis-connection';

type NodeExecutionResult = {
  nextNodeKey?: string;
  stop?: boolean;
  failed?: boolean;
  waitDelayMs?: number;
};

const MESSAGE_CHANNELS = new Set<MessageChannel>(['EMAIL', 'WHATSAPP', 'PUSH']);
// Chaves de node.config que o Atlas realmente lê em meta para canais de
// mensagem (EMAIL/WHATSAPP/PUSH). Apenas trackEmail é consumido (opt-out de
// rastreio de e-mail); o restante do config (body/subject/channel/...) vai
// pelos campos dedicados e não deve vazar para meta.
const MESSAGE_META_ALLOWLIST = ['trackEmail'] as const;
const TERMINAL_RUN_STATUSES = new Set([
  'SUCCESS',
  'FAILED',
  'CANCELLED',
  'SKIPPED',
]);
const MAX_JOB_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 60_000;

@Injectable()
export class AutomationProcessor implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(AutomationProcessor.name);
  private worker?: Worker<AutomationJobData>;

  constructor(
    private readonly configService: ConfigService,
    private readonly atlasRuntime: AtlasRuntimeClient,
    private readonly queueService: AutomationQueueService,
    private readonly providers: MessageProviderRegistry,
  ) {}

  onModuleInit() {
    const redisUrl = this.configService.get<string>('REDIS_URL');
    if (!redisUrl) {
      this.logger.warn(
        'automation.worker.degraded REDIS_URL ausente; worker não iniciado',
      );
      return;
    }

    this.worker = new Worker<AutomationJobData>(
      AUTOMATION_QUEUE_NAME,
      (job) =>
        this.processJob({
          ...job.data,
          attempt: job.attemptsMade + 1,
        }),
      {
        connection: redisConnection(redisUrl),
        prefix:
          this.configService.get<string>('AUTOMATION_QUEUE_PREFIX') ||
          'planfi:automations',
        concurrency: 5,
      },
    );

    this.logger.log(
      `automation.worker.ready queue=${AUTOMATION_QUEUE_NAME} prefix=${
        this.configService.get<string>('AUTOMATION_QUEUE_PREFIX') ||
        'planfi:automations'
      }`,
    );

    this.worker.on('error', (error) => {
      this.logger.error(`automation.worker.error message=${error.message}`);
    });

    this.worker.on('failed', (job, error) => {
      this.logger.error(
        `automation.job.failed id=${job?.id ?? 'unknown'} message=${error.message}`,
      );
    });
  }

  async onModuleDestroy() {
    await this.worker?.close();
  }

  async processJob(data: AutomationJobData) {
    // Timer durável de Espera: não roda o engine — só pede ao Atlas para retomar o
    // run (o Atlas é o runtime; o BFF é apenas o cronômetro). O Atlas torna o
    // /resume idempotente, então um disparo duplicado é seguro.
    if (data.kind === 'resume-atlas') {
      await this.atlasRuntime.resumeRun(data.runId);
      return;
    }

    const runStatus = await this.atlasRuntime.getRunStatus(data.runId);
    if (TERMINAL_RUN_STATUSES.has(runStatus.status)) {
      this.logger.debug(
        `automation.job.skip_terminal runId=${data.runId} status=${runStatus.status}`,
      );
      return;
    }

    const definition = await this.atlasRuntime.getDefinition(data.automationId);
    const node = definition.nodes.find((item) => item.nodeKey === data.nodeKey);
    if (!node) {
      await this.reportError(
        data,
        null,
        'NODE_NOT_FOUND',
        `Bloco ${data.nodeKey} não encontrado.`,
      );
      return;
    }

    const outgoing = definition.edges
      .filter((edge) => edge.sourceNodeKey === node.nodeKey)
      .sort((a, b) => a.edgeKey.localeCompare(b.edgeKey));

    const startedAt = new Date().toISOString();
    const stepBase = {
      runId: data.runId,
      automationId: data.automationId,
      nodeKey: node.nodeKey,
      attempts: data.attempt,
      input: {
        payloadKeys: Object.keys(data.payload ?? {}),
        nodeConfig: node.config,
      },
      metadata: { label: node.label, type: node.type },
      startedAt,
    };

    await this.atlasRuntime.reportRuntimeEvent({
      runId: data.runId,
      runStatus: 'RUNNING',
      step: { ...stepBase, status: 'RUNNING' },
    });

    const result = (await this.executeNode(data, node, outgoing)) ?? {
      failed: true as const,
    };
    if (result.nextNodeKey) {
      await this.queueService.enqueue(
        {
          ...data,
          nodeKey: result.nextNodeKey,
          attempt: 1,
        },
        result.waitDelayMs ?? 0,
      );
      return;
    }

    if (!result.failed && !result.stop) {
      await this.atlasRuntime.reportRuntimeEvent({
        runId: data.runId,
        runStatus: 'SUCCESS',
        finishedAt: new Date().toISOString(),
      });
    }
  }

  private async executeNode(
    data: AutomationJobData,
    node: FlowNode,
    outgoing: FlowEdge[],
  ): Promise<NodeExecutionResult> {
    switch (node.type) {
      case 'TRIGGER':
        await this.finishStepAndLog(data, node, {
          stepStatus: 'SUCCESS',
          runStatus: 'RUNNING',
          event: 'trigger.matched',
          logStatus: 'SUCCESS',
          payloadSummary: summarizePayload(data.payload),
        });
        return { nextNodeKey: firstEdge(outgoing)?.targetNodeKey };

      case 'WAIT': {
        const delayMs = calculateDelayMs(node.config);
        if (data.dryRun) {
          await this.finishStepAndLog(data, node, {
            stepStatus: 'SUCCESS',
            runStatus: 'RUNNING',
            event: 'wait.skipped',
            logStatus: 'WAITING',
            payloadSummary: {
              duration: node.config.duration ?? null,
              unit: node.config.unit ?? null,
              waitType: node.config.waitType ?? null,
              delayMs: 0,
            },
          });
          return {
            nextNodeKey: firstEdge(outgoing)?.targetNodeKey,
            waitDelayMs: 0,
          };
        }

        await this.finishStepAndLog(data, node, {
          stepStatus: 'WAITING',
          runStatus: 'WAITING',
          event: 'wait.scheduled',
          logStatus: 'WAITING',
          payloadSummary: {
            duration: node.config.duration ?? null,
            unit: node.config.unit ?? null,
            waitType: node.config.waitType ?? null,
            delayMs,
          },
        });
        return {
          nextNodeKey: firstEdge(outgoing)?.targetNodeKey,
          waitDelayMs: delayMs,
        };
      }

      case 'ACTION':
        return this.executeAction(data, node, outgoing);

      case 'CONDITION': {
        const result = evaluateCondition(node.config, data.payload);
        await this.finishStepAndLog(data, node, {
          stepStatus: 'SUCCESS',
          runStatus: 'RUNNING',
          event: 'condition.evaluated',
          logStatus: 'SUCCESS',
          payloadSummary: {
            result,
            field: node.config.field ?? null,
            operator: node.config.operator ?? null,
          },
        });
        const edge =
          outgoing.find((item) => item.condition === String(result)) ??
          firstEdge(outgoing);
        return { nextNodeKey: edge?.targetNodeKey };
      }

      case 'BRANCH': {
        const edge = firstEdge(outgoing);
        await this.finishStepAndLog(data, node, {
          stepStatus: 'SUCCESS',
          runStatus: 'RUNNING',
          event: 'branch.selected',
          logStatus: 'SUCCESS',
          payloadSummary: {
            edgeKey: edge?.edgeKey ?? null,
            condition: edge?.condition ?? null,
          },
        });
        return { nextNodeKey: edge?.targetNodeKey };
      }

      case 'END':
        await this.finishStepAndLog(data, node, {
          stepStatus: 'SUCCESS',
          runStatus: 'SUCCESS',
          event: 'flow.ended',
          logStatus: 'SUCCESS',
          payloadSummary: { outcome: node.config.outcome ?? 'finished' },
          finishedRun: true,
        });
        return { stop: true };

      default:
        await this.reportError(
          data,
          node,
          'UNKNOWN_NODE_TYPE',
          `Tipo de bloco desconhecido: ${String(node.type)}`,
        );
        return { failed: true };
    }
  }

  private async executeAction(
    data: AutomationJobData,
    node: FlowNode,
    outgoing: FlowEdge[],
  ): Promise<NodeExecutionResult> {
    const channel = resolveChannel(node);
    const isMessage = isMessageChannel(channel);

    if (!isMessage) {
      await this.finishStepAndLog(data, node, {
        stepStatus: 'SUCCESS',
        runStatus: 'RUNNING',
        event: 'action.executed',
        logStatus: 'SUCCESS',
        payloadSummary: { providerStatus: 'internal_noop' },
      });
      return { nextNodeKey: firstEdge(outgoing)?.targetNodeKey };
    }

    const subject = data.subject as RuntimeSubject;
    const optedOut = await this.atlasRuntime.checkOptOut({
      contactId: subject.contactId ?? null,
      userId: subject.userId ?? null,
      channel,
    });
    if (optedOut) {
      await this.finishStepAndLog(data, node, {
        stepStatus: 'SKIPPED',
        runStatus: 'RUNNING',
        event: 'message.ignored',
        logStatus: 'IGNORED',
        channel,
        payloadSummary: { reason: 'opt_out' },
      });
      return { nextNodeKey: firstEdge(outgoing)?.targetNodeKey };
    }

    const bodyTemplate =
      stringConfig(node.config.body) ??
      stringConfig(node.config.message) ??
      stringConfig(node.config.template) ??
      '';
    const subjectTemplate = stringConfig(node.config.subject);
    const variableContext = {
      ...(data.payload ?? {}),
      contactId: subject.contactId,
      userId: subject.userId,
    };
    const body = resolveVariables(bodyTemplate, variableContext);
    const subjectText = subjectTemplate
      ? resolveVariables(subjectTemplate, variableContext)
      : null;
    const unresolved = [...body.unresolved, ...(subjectText?.unresolved ?? [])];

    if (unresolved.length > 0) {
      const unique = Array.from(new Set(unresolved));
      await this.finishStepAndLog(data, node, {
        stepStatus: 'FAILED',
        runStatus: 'FAILED',
        event: 'message.error',
        logStatus: 'ERROR',
        channel,
        errorCode: 'UNRESOLVED_VARIABLE',
        errorMessage: `Variáveis sem valor: ${unique.join(', ')}.`,
        payloadSummary: { unresolved: unique },
        finishedRun: true,
      });
      return { failed: true };
    }

    const recipient = destinationFor(subject, data.payload ?? {}, channel);
    if (recipient === null && !data.dryRun) {
      await this.finishStepAndLog(data, node, {
        stepStatus: 'FAILED',
        runStatus: 'FAILED',
        event: 'message.error',
        logStatus: 'ERROR',
        channel,
        errorCode: 'MISSING_RECIPIENT',
        errorMessage: 'Destinatário não encontrado para o envio.',
        payloadSummary: { channel },
        finishedRun: true,
      });
      return { failed: true };
    }

    const providerResult = await this.sendProviderMessage({
      channel,
      data,
      subject,
      to: recipient ?? 'dry-run',
      subjectText: subjectText?.text,
      body: body.text,
      node,
      nodeConfig: node.config,
    });
    const willRetry =
      !providerResult.success &&
      providerResult.retryable &&
      data.attempt < MAX_JOB_ATTEMPTS;
    const nextRetryAt = willRetry
      ? new Date(Date.now() + retryDelayMs(data.attempt)).toISOString()
      : undefined;

    await this.finishStepAndLog(data, node, {
      stepStatus: providerResult.success
        ? 'SUCCESS'
        : willRetry
          ? 'WAITING'
          : 'FAILED',
      runStatus: providerResult.success
        ? 'RUNNING'
        : willRetry
          ? 'WAITING'
          : 'FAILED',
      event: providerResult.success ? 'message.sent' : 'message.error',
      logStatus: providerResult.success
        ? 'SUCCESS'
        : willRetry
          ? 'RETRY'
          : 'ERROR',
      channel,
      providerResponse: providerResult,
      requestId: providerResult.providerMessageId,
      errorCode: providerResult.errorCode,
      errorMessage: providerResult.errorMessage,
      nextRetryAt,
      payloadSummary: {
        providerStatus: providerResult.status,
        retryable: providerResult.retryable,
      },
      finishedRun: !providerResult.success && !willRetry,
    });

    if (!providerResult.success) {
      if (providerResult.retryable)
        throw new Error(
          providerResult.errorMessage ?? 'Provider retryable error',
        );
      return { failed: true };
    }
    return { nextNodeKey: firstEdge(outgoing)?.targetNodeKey };
  }

  private async sendProviderMessage(input: {
    channel: MessageChannel;
    data: AutomationJobData;
    subject: RuntimeSubject;
    to: string;
    subjectText?: string;
    body: string;
    node: FlowNode;
    nodeConfig: Record<string, unknown>;
  }) {
    try {
      return await this.providers.getProvider(input.channel).send({
        to: input.to,
        subject: input.subjectText,
        body: input.body,
        meta: {
          ...pickMeta(input.nodeConfig, MESSAGE_META_ALLOWLIST),
          emailTracking:
            input.channel === 'EMAIL'
              ? {
                  sourceType: 'AUTOMATION',
                  sourceId: input.data.automationId,
                  sourceLabel: input.node.label,
                  automationId: input.data.automationId,
                  automationRunId: input.data.runId,
                  contactId: stringConfig(input.subject.contactId),
                  userId: stringConfig(input.subject.userId),
                  nodeKey: input.node.nodeKey,
                  metadata: {
                    versionId: input.data.versionId,
                    blockLabel: input.node.label,
                    blockType: input.node.type,
                    attempt: input.data.attempt,
                  },
                }
              : undefined,
        },
        dryRun: input.data.dryRun,
      });
    } catch (error) {
      return {
        success: false,
        status: 'failed',
        errorCode: 'ATLAS_PROVIDER_DELEGATION_FAILED',
        errorMessage:
          error instanceof Error
            ? error.message
            : 'Falha ao delegar envio para o Atlas.',
        retryable: true,
      };
    }
  }

  private async finishStepAndLog(
    data: AutomationJobData,
    node: FlowNode,
    input: {
      stepStatus: 'SUCCESS' | 'FAILED' | 'SKIPPED' | 'WAITING' | 'CANCELLED';
      runStatus: 'RUNNING' | 'WAITING' | 'SUCCESS' | 'FAILED';
      event: string;
      logStatus:
        | 'SUCCESS'
        | 'ERROR'
        | 'RETRY'
        | 'IGNORED'
        | 'WAITING'
        | 'PROCESSING';
      channel?: AutomationChannel;
      payloadSummary?: Record<string, unknown>;
      providerResponse?: unknown;
      requestId?: string;
      errorCode?: string;
      errorMessage?: string;
      nextRetryAt?: string;
      finishedRun?: boolean;
    },
  ) {
    const now = new Date().toISOString();
    const subject = data.subject as RuntimeSubject;
    await this.atlasRuntime.reportRuntimeEvent({
      runId: data.runId,
      runStatus: input.runStatus,
      finishedAt: input.finishedRun ? now : null,
      step: {
        runId: data.runId,
        automationId: data.automationId,
        nodeKey: node.nodeKey,
        status: input.stepStatus,
        attempts: data.attempt,
        output: input.payloadSummary,
        error: input.errorCode ?? null,
        metadata: { label: node.label, type: node.type },
        finishedAt: now,
      },
      log: {
        automationId: data.automationId,
        versionId: data.versionId,
        runId: data.runId,
        contactId: subject.contactId ?? null,
        userId: subject.userId ?? null,
        nodeKey: node.nodeKey,
        blockLabel: node.label,
        channel: input.channel ?? null,
        event: input.event,
        status: input.logStatus,
        errorCode: input.errorCode ?? null,
        errorMessage: input.errorMessage ?? null,
        providerResponse: input.providerResponse ?? null,
        payloadSummary: input.payloadSummary ?? null,
        attempts: data.attempt,
        nextRetryAt: input.nextRetryAt ?? null,
        requestId:
          input.requestId ?? `${data.runId}:${node.nodeKey}:${data.attempt}`,
        dryRun: data.dryRun,
      },
    });
  }

  private async reportError(
    data: AutomationJobData,
    node: FlowNode | null,
    errorCode: string,
    errorMessage: string,
  ) {
    await this.atlasRuntime.reportRuntimeEvent({
      runId: data.runId,
      runStatus: 'FAILED',
      finishedAt: new Date().toISOString(),
      log: {
        automationId: data.automationId,
        versionId: data.versionId,
        runId: data.runId,
        nodeKey: node?.nodeKey ?? data.nodeKey,
        blockLabel: node?.label ?? data.nodeKey,
        event: 'runtime.error',
        status: 'ERROR',
        errorCode,
        errorMessage,
        payloadSummary: {},
        attempts: data.attempt,
        requestId: `${data.runId}:${data.nodeKey}:${data.attempt}`,
        dryRun: data.dryRun,
      },
    });
  }
}

function firstEdge(edges: FlowEdge[]) {
  return edges[0];
}

function calculateDelayMs(config: Record<string, unknown>) {
  const duration =
    typeof config.duration === 'number'
      ? config.duration
      : Number(config.duration ?? 0);
  const unit = stringConfig(config.unit) ?? 'minutes';
  const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
  const multipliers: Record<string, number> = {
    minute: 60_000,
    minutes: 60_000,
    hour: 3_600_000,
    hours: 3_600_000,
    day: 86_400_000,
    days: 86_400_000,
  };
  return safeDuration * (multipliers[unit] ?? 60_000);
}

function retryDelayMs(attempt: number) {
  return RETRY_BASE_DELAY_MS * 2 ** Math.max(attempt - 1, 0);
}

function resolveChannel(node: FlowNode): AutomationChannel {
  if (isAutomationChannel(node.config.channel)) return node.config.channel;
  if (stringConfig(node.config.url)) return 'WEBHOOK';
  if (stringConfig(node.config.title)) return 'TASK';
  return 'INTERNAL';
}

function isAutomationChannel(value: unknown): value is AutomationChannel {
  return (
    typeof value === 'string' &&
    ['EMAIL', 'WHATSAPP', 'PUSH', 'TASK', 'WEBHOOK', 'INTERNAL'].includes(value)
  );
}

function isMessageChannel(
  channel: AutomationChannel,
): channel is MessageChannel {
  return MESSAGE_CHANNELS.has(channel as MessageChannel);
}

function stringConfig(value: unknown): string | undefined {
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function pickMeta(
  config: Record<string, unknown>,
  keys: readonly string[],
): Record<string, unknown> {
  const picked: Record<string, unknown> = {};
  for (const key of keys) {
    if (Object.prototype.hasOwnProperty.call(config, key)) {
      picked[key] = config[key];
    }
  }
  return picked;
}

function destinationFor(
  subject: RuntimeSubject,
  payload: Record<string, unknown>,
  channel: MessageChannel,
): string | null {
  if (subject.to) return subject.to;
  if (channel === 'EMAIL') return stringConfig(payload.email) ?? null;
  if (channel === 'WHATSAPP') return stringConfig(payload.telefone) ?? null;
  return subject.to ?? subject.userId ?? subject.contactId ?? null;
}

function summarizePayload(payload: Record<string, unknown>) {
  return Object.fromEntries(
    Object.entries(payload)
      .slice(0, 12)
      .map(([key, value]) => [
        key,
        typeof value === 'string' ||
        typeof value === 'number' ||
        typeof value === 'boolean'
          ? value
          : '[complex]',
      ]),
  );
}

function evaluateCondition(
  config: Record<string, unknown>,
  payload: Record<string, unknown>,
) {
  const field = stringConfig(config.field) ?? stringConfig(config.event);
  const operator = stringConfig(config.operator) ?? 'equals';
  const actual = field ? payload[field] : undefined;
  const expected = config.value;

  switch (operator) {
    case 'not_equals':
    case '!=':
      return actual !== expected;
    case 'contains':
      return stringifyComparable(actual).includes(
        stringifyComparable(expected),
      );
    case 'exists':
      return actual !== null && actual !== undefined && actual !== '';
    case 'not_exists':
      return actual === null || actual === undefined || actual === '';
    case 'greater_than':
    case '>':
      return Number(actual) > Number(expected);
    case 'less_than':
    case '<':
      return Number(actual) < Number(expected);
    case 'equals':
    case '==':
    default:
      return actual === expected;
  }
}

function stringifyComparable(value: unknown): string {
  if (value === null || value === undefined) return '';
  if (typeof value === 'string') return value;
  if (
    typeof value === 'number' ||
    typeof value === 'boolean' ||
    typeof value === 'bigint'
  ) {
    return String(value);
  }
  if (value instanceof Date) return value.toISOString();

  return JSON.stringify(value) ?? '';
}
