import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
  PaymentAttempt,
  PaymentAttemptStatus,
  PaymentIntent,
  PaymentIntentStatus,
  PaymentMethod,
  Prisma,
  Provider,
} from "@prisma/client";
import { InvoiceService } from "../billing/invoice.service";
import { newId } from "../common/ids/id.util";
import { PrismaService } from "../prisma/prisma.service";
import { ProviderRouterService } from "../providers/provider-router.service";
import { WebhookEventService } from "../webhook-events/webhook-event.service";
import { WEBHOOK_EVENT_TYPES } from "../webhook-events/webhook-events.constants";
import type { ConfirmPaymentIntentDto } from "./dto/confirm-payment-intent.dto";
import type { CreatePaymentIntentDto } from "./dto/create-payment-intent.dto";

/** Monedas sin decimales (ISO 4217 — subset relevante). */
const ZERO_DECIMAL_CURRENCIES = new Set(["PYG", "CLP", "JPY", "KRW", "VND"]);

@Injectable()
export class PaymentIntentService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly router: ProviderRouterService,
    private readonly webhookEvents: WebhookEventService,
    private readonly config: ConfigService,
    private readonly invoices: InvoiceService,
  ) {}

  async create(merchantId: string, dto: CreatePaymentIntentDto, idempotencyKey?: string) {
    if (dto.customerId) {
      const customer = await this.prisma.customer.findFirst({
        where: { id: dto.customerId, merchantId },
        select: { id: true },
      });
      if (!customer) throw new BadRequestException("customerId does not belong to this merchant");
    }

    if (idempotencyKey) {
      const existing = await this.prisma.paymentIntent.findUnique({
        where: { merchantId_idempotencyKey: { merchantId, idempotencyKey } },
      });
      if (existing) return this.toDto(existing);
    }

    const amount = this.toDecimal(dto.amount, dto.currency);

    try {
      const row = await this.prisma.paymentIntent.create({
        data: {
          id: newId("paymentIntent"),
          merchantId,
          customerId: dto.customerId ?? null,
          externalReference: dto.externalReference ?? null,
          amount,
          currency: dto.currency,
          country: dto.country,
          description: dto.description ?? null,
          requestedProvider: dto.requestedProvider ?? null,
          platformId: dto.platformId ?? null,
          status: PaymentIntentStatus.created,
          metadata: (dto.metadata as Prisma.InputJsonValue) ?? Prisma.JsonNull,
          idempotencyKey: idempotencyKey ?? null,
          expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
        },
      });
      const dto2 = this.toDto(row);
      await this.webhookEvents.emit({
        merchantId,
        eventType: WEBHOOK_EVENT_TYPES.paymentIntentCreated,
        data: this.eventData(dto2),
        paymentIntentId: row.id,
      });
      return dto2;
    } catch (err) {
      if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
        throw new ConflictException("Idempotency-Key already used for a different intent");
      }
      throw err;
    }
  }

  async confirm(merchantId: string, intentId: string, dto: ConfirmPaymentIntentDto) {
    const intent = await this.prisma.paymentIntent.findFirst({
      where: { id: intentId, merchantId },
      include: { customer: true, checkoutSession: true },
    });
    if (!intent) throw new NotFoundException("Payment intent not found");
    if (
      intent.status !== PaymentIntentStatus.created &&
      intent.status !== PaymentIntentStatus.pending
    ) {
      throw new BadRequestException(`Cannot confirm intent in status ${intent.status}`);
    }

    const route = dto.providerConfigId
      ? await this.router.getById(merchantId, dto.providerConfigId)
      : await this.router.route({
          merchantId,
          country: intent.country,
          currency: intent.currency,
          requestedProvider: intent.requestedProvider,
        });

    // Link de pago nativo: no requiere platformId ni resolución de método.
    const preferLink = dto.paymentLink === true;
    // Medio de pago elegido: el del confirm tiene prioridad sobre el guardado en
    // el intent. Se valida contra el catálogo del provider ruteado.
    const platformId = preferLink ? undefined : (dto.platformId ?? intent.platformId ?? undefined);
    const resolvedMethod = preferLink
      ? undefined
      : await this.resolvePlatformMethod(route.config.provider, platformId);

    const result = await route.adapter.createPayment({
      intentId: intent.id,
      amount: intent.amount.toString(),
      currency: intent.currency,
      country: intent.country,
      description: intent.description,
      externalReference: intent.externalReference,
      customer: intent.customer
        ? {
            email: intent.customer.email,
            name: intent.customer.name,
            docType: intent.customer.docType,
            docNumber: intent.customer.docNumber,
            phone: intent.customer.phone,
            country: intent.customer.country,
          }
        : undefined,
      returnUrl: dto.returnUrl ?? intent.checkoutSession?.returnUrl ?? undefined,
      cancelUrl: dto.cancelUrl ?? intent.checkoutSession?.cancelUrl ?? undefined,
      notificationUrl: this.buildNotificationUrl(route.config.provider, route.config.id),
      platformId,
      method: resolvedMethod,
      preferLink,
    });

    const attempt = await this.prisma.paymentAttempt.create({
      data: {
        id: newId("paymentAttempt"),
        intentId: intent.id,
        provider: route.config.provider,
        providerPaymentId: result.providerPaymentId,
        method: result.method,
        platformId: platformId ?? null,
        status: this.mapAttemptStatus(result.status),
        responseCode: result.responseCode ?? null,
        responseMessage: result.responseMessage ?? null,
        rawRequestJson: result.rawRequest as Prisma.InputJsonValue,
        rawResponseJson: result.rawResponse as Prisma.InputJsonValue,
        approvedAt: result.status === "approved" ? new Date() : null,
      },
    });

    const nextIntentStatus = this.mapIntentStatusFromAttempt(result.status);
    const updatedIntent = await this.prisma.paymentIntent.update({
      where: { id: intent.id },
      data: { status: nextIntentStatus },
    });

    const intentDto = this.toDto(updatedIntent);
    await this.emitForStatus(merchantId, updatedIntent, intentDto);

    return {
      intent: intentDto,
      attempt: this.toAttemptDto(attempt, result.redirectUrl ?? null, result.qrData ?? null),
    };
  }

  async findOne(merchantId: string, id: string) {
    const row = await this.prisma.paymentIntent.findFirst({ where: { id, merchantId } });
    if (!row) throw new NotFoundException("Payment intent not found");
    return this.toDto(row);
  }

  /**
   * Resincroniza el intent consultando el estado al provider. Útil cuando el
   * webhook del provider hacia el gateway no llegó (entorno dev, túneles caídos,
   * etc.). No falla si no hay attempt todavía — devuelve el intent tal cual.
   *
   * Comportamiento:
   *   - Toma el último attempt del intent.
   *   - Llama adapter.getPayment(providerPaymentId).
   *   - Actualiza attempt + intent si el status cambió.
   *   - Emite el webhook outbound correspondiente (mantiene el mismo contrato
   *     que el webhook automático para que los merchants no vean diferencia).
   */
  async syncFromProvider(merchantId: string, intentId: string) {
    const intent = await this.prisma.paymentIntent.findFirst({
      where: { id: intentId, merchantId },
    });
    if (!intent) throw new NotFoundException("Payment intent not found");

    const attempt = await this.prisma.paymentAttempt.findFirst({
      where: { intentId: intent.id },
      orderBy: { createdAt: "desc" },
    });
    if (!attempt) return this.toDto(intent);

    if (!attempt.providerPaymentId) return this.toDto(intent);

    const route = await this.router.route({
      merchantId,
      country: intent.country,
      currency: intent.currency,
      requestedProvider: attempt.provider,
    });

    const result = await route.adapter.getPayment(attempt.providerPaymentId);
    const nextAttemptStatus = this.mapAttemptStatus(result.status);
    if (nextAttemptStatus !== attempt.status) {
      await this.prisma.paymentAttempt.update({
        where: { id: attempt.id },
        data: {
          status: nextAttemptStatus,
          responseCode: result.responseCode ?? attempt.responseCode,
          responseMessage: result.responseMessage ?? attempt.responseMessage,
          rawResponseJson: result.rawResponse as Prisma.InputJsonValue,
          approvedAt: result.status === "approved" ? new Date() : attempt.approvedAt,
        },
      });
    }

    const nextIntentStatus = this.mapIntentStatusFromAttempt(result.status);
    if (nextIntentStatus === intent.status) return this.toDto(intent);

    const updated = await this.prisma.paymentIntent.update({
      where: { id: intent.id },
      data: { status: nextIntentStatus },
    });
    const dto = this.toDto(updated);
    await this.emitForStatus(merchantId, updated, dto);
    return dto;
  }

  async list(
    merchantId: string,
    opts: { limit?: number; cursor?: string; status?: PaymentIntentStatus } = {},
  ) {
    const take = Math.min(Math.max(opts.limit ?? 20, 1), 100);
    const rows = await this.prisma.paymentIntent.findMany({
      where: { merchantId, ...(opts.status && { status: opts.status }) },
      take: take + 1,
      ...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
      orderBy: { createdAt: "desc" },
    });
    const hasMore = rows.length > take;
    const data = hasMore ? rows.slice(0, take) : rows;
    return {
      data: data.map((r) => this.toDto(r)),
      nextCursor: hasMore ? data[data.length - 1].id : null,
    };
  }

  private buildNotificationUrl(provider: string, providerConfigId: string): string {
    const base = this.config.get<string>("API_BASE_URL") ?? "http://localhost:3010";
    return `${base.replace(/\/$/, "")}/webhooks/in/${provider}/${providerConfigId}`;
  }

  private toDecimal(amount: number, currency: string): Prisma.Decimal {
    if (ZERO_DECIMAL_CURRENCIES.has(currency)) {
      return new Prisma.Decimal(amount);
    }
    return new Prisma.Decimal(amount).dividedBy(100);
  }

  private mapAttemptStatus(
    s: "pending" | "processing" | "approved" | "rejected" | "expired" | "error",
  ): PaymentAttemptStatus {
    switch (s) {
      case "approved":
        return PaymentAttemptStatus.approved;
      case "rejected":
      case "expired":
        return PaymentAttemptStatus.rejected;
      case "error":
        return PaymentAttemptStatus.error;
      case "processing":
        return PaymentAttemptStatus.processing;
      default:
        return PaymentAttemptStatus.pending;
    }
  }

  private mapIntentStatusFromAttempt(
    s: "pending" | "processing" | "approved" | "rejected" | "expired" | "error",
  ): PaymentIntentStatus {
    switch (s) {
      case "approved":
        return PaymentIntentStatus.approved;
      case "rejected":
        return PaymentIntentStatus.rejected;
      case "expired":
        return PaymentIntentStatus.expired;
      case "error":
        return PaymentIntentStatus.rejected;
      case "processing":
        return PaymentIntentStatus.processing;
      default:
        return PaymentIntentStatus.pending;
    }
  }

  private async emitForStatus(
    merchantId: string,
    row: PaymentIntent,
    dto: ReturnType<PaymentIntentService["toDto"]>,
  ) {
    const eventType =
      row.status === PaymentIntentStatus.approved
        ? WEBHOOK_EVENT_TYPES.paymentIntentSucceeded
        : row.status === PaymentIntentStatus.rejected
          ? WEBHOOK_EVENT_TYPES.paymentIntentFailed
          : row.status === PaymentIntentStatus.processing
            ? WEBHOOK_EVENT_TYPES.paymentIntentProcessing
            : row.status === PaymentIntentStatus.expired
              ? WEBHOOK_EVENT_TYPES.paymentIntentExpired
              : null;
    if (!eventType) return;
    await this.webhookEvents.emit({
      merchantId,
      eventType,
      data: this.eventData(dto),
      paymentIntentId: row.id,
    });
    if (row.status === PaymentIntentStatus.approved) {
      await this.invoices.onIntentApproved(row.id, row.metadata);
    }
  }

  private eventData(dto: ReturnType<PaymentIntentService["toDto"]>) {
    return {
      payment_intent_id: dto.id,
      merchant_id: dto.merchantId,
      external_reference: dto.externalReference,
      amount: dto.amount,
      currency: dto.currency,
      country: dto.country,
      status: dto.status,
    };
  }

  /**
   * Resuelve el `platformId` (código del provider) contra el catálogo
   * `provider_payment_method` y devuelve el método interno mapeado. Valida que el
   * medio exista y esté activo para ese provider. Si no se envía platformId,
   * devuelve `undefined` (el provider elige en su checkout, o su adapter falla si
   * lo exige, ej. Dpago).
   */
  private async resolvePlatformMethod(
    provider: Provider,
    platformId: string | undefined,
  ): Promise<PaymentMethod | undefined> {
    if (!platformId) return undefined;
    const entry = await this.prisma.providerPaymentMethod.findFirst({
      where: { provider, externalId: platformId, active: true },
      select: { method: true },
    });
    if (!entry) {
      throw new BadRequestException(
        `platformId '${platformId}' no es un medio de pago válido/activo para ${provider}`,
      );
    }
    return entry.method;
  }

  private toAttemptDto(
    attempt: PaymentAttempt,
    redirectUrl: string | null,
    qr: unknown | null = null,
  ) {
    return {
      id: attempt.id,
      intentId: attempt.intentId,
      provider: attempt.provider,
      providerPaymentId: attempt.providerPaymentId,
      method: attempt.method,
      platformId: attempt.platformId,
      status: attempt.status,
      responseCode: attempt.responseCode,
      responseMessage: attempt.responseMessage,
      redirectUrl,
      qr,
      createdAt: attempt.createdAt,
    };
  }

  private toDto(row: PaymentIntent) {
    return {
      id: row.id,
      merchantId: row.merchantId,
      customerId: row.customerId,
      externalReference: row.externalReference,
      amount: row.amount.toString(),
      currency: row.currency,
      country: row.country,
      description: row.description,
      requestedProvider: row.requestedProvider,
      platformId: row.platformId,
      status: row.status,
      metadata: row.metadata as Record<string, unknown> | null,
      expiresAt: row.expiresAt,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
    };
  }
}
