import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { Prisma, novasis_pay_cobro_status } from '@prisma/client';
import { randomUUID } from 'crypto';
import { PrismaService } from 'src/prisma/prisma.service';
import { CrearNovasisPayCobroDto } from './dto/crear-novasis-pay-cobro.dto';
import { ListarNovasisPayCobrosDto } from './dto/listar-novasis-pay-cobros.dto';
import { NovasisPayClient } from './novasis-pay.client';
import { NovasisPayConfigService } from './novasis-pay-config.service';

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly client: NovasisPayClient,
    private readonly config: NovasisPayConfigService,
  ) {}

  async create(
    empresaId: string,
    usuarioId: string | null,
    dto: CrearNovasisPayCobroDto,
  ) {
    // Falla rápido si la empresa no tiene config activa: evita la llamada HTTP
    // a la pasarela y produce un error más claro para la UI.
    const cfg = await this.config.requireActiveConfig(empresaId);

    const currency = dto.currency ?? this.defaultCurrencyForCountry(cfg.defaultCountry);
    const country = dto.country ?? cfg.defaultCountry;
    const modo = dto.modo ?? 'link';

    if (modo === 'directo' && !dto.platform_id) {
      throw new BadRequestException(
        'Para modo directo (ej. QR) es obligatorio platform_id (medio de pago del proveedor).',
      );
    }

    if (dto.external_reference) {
      const existing = await this.prisma.novasis_pay_cobro.findUnique({
        where: {
          empresa_id_external_reference: {
            empresa_id: empresaId,
            external_reference: dto.external_reference,
          },
        },
      });
      if (existing) {
        // Mismo external_reference → devolvemos el cobro existente (idempotencia).
        return this.toDto(existing);
      }
    }

    // Si vino info del comprador, asegurar que exista un Customer en el gateway
    // y obtener su id (cus_xxx). El checkout lo necesita para prellenar el form.
    const customerId = await this.ensureGatewayCustomer(
      empresaId,
      dto.comprador,
      country,
      cfg.merchantId,
    );

    let intent;
    try {
      intent = await this.client.createPaymentIntent(empresaId, {
        amount: dto.monto,
        currency,
        country,
        description: dto.descripcion,
        externalReference: dto.external_reference,
        requestedProvider: dto.requested_provider,
        customerId: customerId ?? undefined,
        platformId: dto.platform_id,
        // El gateway acepta Idempotency-Key separado del external_reference;
        // usamos uno generado por nosotros para sobrevivir retries de red.
        idempotencyKey: `cobro-${randomUUID()}`,
      });
    } catch (err) {
      this.logger.error(
        `Pasarela rechazó createPaymentIntent (empresa=${empresaId}): ${(err as Error).message}`,
      );
      throw err;
    }

    // Datos comunes del cobro (independientes de la modalidad).
    const baseData = {
      empresa_id: empresaId,
      monto: new Prisma.Decimal(dto.monto),
      currency,
      country,
      descripcion: dto.descripcion ?? null,
      comprador_nombre: dto.comprador?.nombre ?? null,
      comprador_email: dto.comprador?.email ?? null,
      comprador_documento: dto.comprador?.documento ?? null,
      comprador_telefono: dto.comprador?.telefono ?? null,
      external_reference: dto.external_reference ?? null,
      intent_id: intent.id,
      return_url: dto.return_url ?? null,
      cancel_url: dto.cancel_url ?? null,
      creado_por: usuarioId,
    };

    try {
      if (modo === 'directo') {
        // Confirmación en el acto: el gateway llama al provider y devuelve el
        // attempt con redirectUrl y/o qr. No hay checkout session hospedada.
        const confirm = await this.client.confirmPaymentIntent(empresaId, intent.id, {
          platformId: dto.platform_id,
          returnUrl: dto.return_url ?? undefined,
          cancelUrl: dto.cancel_url ?? undefined,
        });
        const row = await this.prisma.novasis_pay_cobro.create({
          data: {
            ...baseData,
            modo: 'directo',
            platform_id: dto.platform_id ?? null,
            session_id: null,
            hosted_url: null,
            public_token: null,
            redirect_url: confirm.attempt?.redirectUrl ?? null,
            qr_data: (confirm.attempt?.qr as Prisma.InputJsonValue) ?? Prisma.JsonNull,
            status: this.mapIntentStatus(confirm.intent?.status) ?? novasis_pay_cobro_status.pendiente,
          },
        });
        return this.toDto(row);
      }

      // modo 'link': si el provider ofrece link nativo (ej. Dpago /links), lo
      // usamos — aparece en el portal del provider y funciona en test. Si no
      // (ej. dLocal), checkout hospedado de Novasis (flujo original sin cambios).
      const effProvider = dto.requested_provider || cfg.defaultProvider || null;
      const usarLinkNativo = effProvider
        ? await this.providerSupportsNativeLink(empresaId, effProvider)
        : false;

      if (usarLinkNativo) {
        const confirm = await this.client.confirmPaymentIntent(empresaId, intent.id, {
          paymentLink: true,
          returnUrl: dto.return_url ?? undefined,
          cancelUrl: dto.cancel_url ?? undefined,
        });
        const row = await this.prisma.novasis_pay_cobro.create({
          data: {
            ...baseData,
            modo: 'link',
            session_id: null,
            hosted_url: null,
            public_token: null,
            redirect_url: confirm.attempt?.redirectUrl ?? null,
            status:
              this.mapIntentStatus(confirm.intent?.status) ?? novasis_pay_cobro_status.pendiente,
          },
        });
        return this.toDto(row);
      }

      // Checkout hospedado de Novasis (dLocal y providers sin link nativo).
      const session = await this.client.createCheckoutSession(empresaId, {
        intentId: intent.id,
        returnUrl: dto.return_url ?? undefined,
        cancelUrl: dto.cancel_url ?? undefined,
        // El checkout usa esto para decidir si dibuja el QR directo embebido
        // (nuestra UI) o redirige a la página del proveedor. Configurable por
        // empresa desde la config de Novasis Pay del ERP.
        // La identidad del comercio (logo/nombre) viaja acá también: el comprador
        // llega redirigido desde la tienda y tiene que reconocer a quién le paga.
        uiConfig: { qrEmbedded: cfg.qrEmbebido, ...(await this.identidadComercio(empresaId)) },
      });
      const row = await this.prisma.novasis_pay_cobro.create({
        data: {
          ...baseData,
          modo: 'link',
          session_id: session.id,
          hosted_url: session.url,
          public_token: session.publicToken,
          status: novasis_pay_cobro_status.pendiente,
          expires_at: session.expiresAt ? new Date(session.expiresAt) : null,
        },
      });
      return this.toDto(row);
    } catch (err) {
      if (
        err instanceof Prisma.PrismaClientKnownRequestError &&
        err.code === 'P2002'
      ) {
        throw new ConflictException(
          'Ya existe un cobro con esa referencia o intent_id para esta empresa',
        );
      }
      throw err;
    }
  }

  async list(empresaId: string, dto: ListarNovasisPayCobrosDto) {
    const take = Math.min(Math.max(dto.limit ?? 20, 1), 100);
    const page = Math.max(dto.page ?? 1, 1);
    const search = dto.search?.trim();
    const where: Prisma.novasis_pay_cobroWhereInput = {
      empresa_id: empresaId,
      ...(dto.status && { status: dto.status as novasis_pay_cobro_status }),
      ...(search && {
        OR: [
          { external_reference: { contains: search, mode: 'insensitive' } },
          { descripcion: { contains: search, mode: 'insensitive' } },
          { comprador_nombre: { contains: search, mode: 'insensitive' } },
          { comprador_email: { contains: search, mode: 'insensitive' } },
          { comprador_documento: { contains: search, mode: 'insensitive' } },
        ],
      }),
    };
    const [rows, total] = await this.prisma.$transaction([
      this.prisma.novasis_pay_cobro.findMany({
        where,
        take,
        skip: (page - 1) * take,
        orderBy: { created_at: 'desc' },
      }),
      this.prisma.novasis_pay_cobro.count({ where }),
    ]);
    return {
      data: rows.map((r) => this.toDto(r)),
      total,
      page,
      limit: take,
      totalPages: Math.max(Math.ceil(total / take), 1),
    };
  }

  async findOne(empresaId: string, id: string) {
    const row = await this.prisma.novasis_pay_cobro.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!row) throw new NotFoundException('Cobro no encontrado');
    return this.toDto(row);
  }

  /**
   * Sincroniza el status del cobro consultando el intent al gateway. Útil
   * cuando el webhook no llegó (demo sin túnel público o config sin webhook
   * endpoint). Idempotente: si ya está aprobado/rechazado/expirado, no toca.
   */
  async sync(empresaId: string, id: string) {
    const row = await this.prisma.novasis_pay_cobro.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!row) throw new NotFoundException('Cobro no encontrado');

    // Primero le pedimos al gateway que pull-ee el estado al provider (dLocal):
    // así cubrimos el caso típico en dev/demo donde el webhook del provider hacia
    // el gateway tampoco llega. Si falla el pull, caemos al simple GET (no perder
    // el sync local cuando el provider está caído).
    let intent;
    try {
      intent = await this.client.syncPaymentIntent(empresaId, row.intent_id);
    } catch (err) {
      this.logger.warn(
        `Sync contra provider falló (empresa=${empresaId}, intent=${row.intent_id}): ${(err as Error).message}. Fallback a GET.`,
      );
      try {
        intent = await this.client.getPaymentIntent(empresaId, row.intent_id);
      } catch (errGet) {
        // Gateway inalcanzable (el polling en tiempo real pega seguido): no rompemos
        // con un 502. Devolvemos el estado local sin tocar; el front sigue en "pendiente"
        // y reintentará, y el webhook queda como fuente de verdad al recuperar conexión.
        this.logger.warn(
          `Gateway Novasis Pay inalcanzable (empresa=${empresaId}, intent=${row.intent_id}): ${(errGet as Error).message}. Devuelvo estado local.`,
        );
        return this.toDto(row);
      }
    }
    const mapped = this.mapIntentStatus(intent.status);
    if (mapped && mapped !== row.status) {
      const updated = await this.prisma.novasis_pay_cobro.update({
        where: { id: row.id },
        data: { status: mapped },
      });
      return this.toDto(updated);
    }
    return this.toDto(row);
  }

  /**
   * ¿El provider ofrece link de pago nativo (ej. Dpago /links)? Se lee del
   * catálogo de capacidades del gateway. Ante cualquier falla, false (cae al
   * checkout hospedado — comportamiento seguro que no rompe dLocal).
   */
  private async providerSupportsNativeLink(
    empresaId: string,
    provider: string,
  ): Promise<boolean> {
    try {
      const res = (await this.client.getProviders(empresaId)) as {
        providers?: Array<{ id: string; supportsNativeLink?: boolean }>;
      };
      return !!res.providers?.find((p) => p.id === provider)?.supportsNativeLink;
    } catch {
      return false;
    }
  }

  /**
   * Identidad del comercio para el checkout hospedado. `empresas.logo` ya guarda
   * la URL pública completa (Spaces), así que se pasa tal cual. Si la empresa no
   * tiene logo se omite el campo y el checkout cae a las iniciales.
   */
  private async identidadComercio(empresaId: string): Promise<{ logoUrl?: string; merchantDisplayName?: string }> {
    try {
      const empresa = await this.prisma.empresas.findUnique({
        where: { id: empresaId },
        select: { logo: true, nombre_fantasia: true, razon_social: true },
      });
      if (!empresa) return {};
      const nombre = empresa.nombre_fantasia?.trim() || empresa.razon_social?.trim();
      return {
        ...(empresa.logo?.trim() ? { logoUrl: empresa.logo.trim() } : {}),
        ...(nombre ? { merchantDisplayName: nombre } : {}),
      };
    } catch (err) {
      // La identidad es decorativa: si falla, el cobro sigue igual.
      this.logger.warn(`No se pudo leer la identidad de la empresa ${empresaId}: ${(err as Error).message}`);
      return {};
    }
  }

  private mapIntentStatus(status: string): novasis_pay_cobro_status | null {
    switch (status) {
      case 'created':
      case 'pending':
        return novasis_pay_cobro_status.pendiente;
      case 'processing':
      case 'requires_action':
        return novasis_pay_cobro_status.procesando;
      case 'approved':
      case 'succeeded':
        return novasis_pay_cobro_status.aprobado;
      case 'failed':
      case 'rejected':
        return novasis_pay_cobro_status.rechazado;
      case 'expired':
        return novasis_pay_cobro_status.expirado;
      case 'cancelled':
      case 'canceled':
        return novasis_pay_cobro_status.cancelado;
      default:
        return null;
    }
  }

  /**
   * Garantiza que exista un Customer en el gateway para este comprador y
   * devuelve su id (cus_xxx). Estrategia:
   *
   * 1. Si no hay datos significativos del comprador (sin email ni doc), no
   *    creamos customer — el checkout pedirá los datos al cliente.
   * 2. Si ya tenemos un mapping local por email o doc, reutilizamos su id
   *    (evita pegarle al gateway en cobros repetidos al mismo cliente).
   * 3. Si no, llamamos POST /customers y persistimos el mapping para próximas.
   *
   * Si el gateway falla, no abortamos el cobro: logueamos y seguimos sin
   * customerId. El checkout va a pedir los datos manualmente, igual que antes.
   */
  private async ensureGatewayCustomer(
    empresaId: string,
    comprador: CrearNovasisPayCobroDto['comprador'],
    country: string,
    merchantId: string | null,
  ): Promise<string | null> {
    if (!comprador) return null;
    const email = comprador.email?.trim() || null;
    const docNumber = comprador.documento?.trim() || null;
    if (!email && !docNumber) return null;

    // El mapeo se busca scopeado por comercio (merchant_id): un cus_ creado bajo
    // otro comercio no sirve (el gateway lo rechaza con "does not belong to this
    // merchant"). Filas viejas con merchant_id NULL no matchean → se recrea.
    const existing = await this.prisma.novasis_pay_customer_ref.findFirst({
      where: {
        empresa_id: empresaId,
        merchant_id: merchantId,
        OR: [
          ...(email ? [{ email }] : []),
          ...(docNumber ? [{ doc_number: docNumber }] : []),
        ],
      },
    });
    if (existing) return existing.gateway_customer_id;

    const docType = docNumber ? this.inferDocType(docNumber, country) : undefined;

    try {
      const created = await this.client.createCustomer(empresaId, {
        email: email ?? undefined,
        name: comprador.nombre?.trim() || undefined,
        docType,
        docNumber: docNumber ?? undefined,
        phone: comprador.telefono?.trim() || undefined,
        country,
      });
      await this.persistCustomerMapping(empresaId, merchantId, created.id, {
        email,
        docNumber,
        docType: docType ?? null,
        name: comprador.nombre?.trim() || null,
        phone: comprador.telefono?.trim() || null,
        country,
      });
      return created.id;
    } catch (err) {
      // 409 del gateway: el customer ya existe (por email o doc únicos) pero
      // no tenemos mapping local. Lo buscamos por filtro y persistimos el
      // mapping para no volver a chocar.
      const message = (err as Error).message ?? '';
      if (message.includes('respondió 409')) {
        try {
          const found = await this.client.findCustomerByEmailOrDoc(empresaId, {
            email,
            docNumber,
          });
          if (found) {
            await this.persistCustomerMapping(empresaId, merchantId, found.id, {
              email,
              docNumber,
              docType: docType ?? null,
              name: comprador.nombre?.trim() || null,
              phone: comprador.telefono?.trim() || null,
              country,
            });
            return found.id;
          }
        } catch (lookupErr) {
          this.logger.warn(
            `Lookup tras 409 falló (empresa=${empresaId}): ${(lookupErr as Error).message}`,
          );
        }
      }
      // No bloqueamos el cobro: si el gateway rechaza al customer, dejamos
      // que el checkout pida los datos manualmente y seguimos sin customerId.
      this.logger.warn(
        `No se pudo crear customer en gateway (empresa=${empresaId}): ${message}`,
      );
      return null;
    }
  }

  private async persistCustomerMapping(
    empresaId: string,
    merchantId: string | null,
    gatewayCustomerId: string,
    data: {
      email: string | null;
      docNumber: string | null;
      docType: string | null;
      name: string | null;
      phone: string | null;
      country: string;
    },
  ) {
    try {
      await this.prisma.novasis_pay_customer_ref.create({
        data: {
          empresa_id: empresaId,
          merchant_id: merchantId,
          gateway_customer_id: gatewayCustomerId,
          email: data.email,
          doc_number: data.docNumber,
          doc_type: data.docType,
          name: data.name,
          phone: data.phone,
          country: data.country,
        },
      });
    } catch (err) {
      // Race condition o mapping previo: si ya existe, seguimos.
      if (
        err instanceof Prisma.PrismaClientKnownRequestError &&
        err.code === 'P2002'
      ) {
        return;
      }
      throw err;
    }
  }

  /**
   * Heurística simple para PY: documento con guión → RUC, si no → CI.
   * Para otros países usamos 'other' por ahora (el gateway lo acepta).
   * Cuando agreguemos AR/UY etc. refinamos.
   */
  private inferDocType(docNumber: string, country: string): string {
    if (country === 'PY') {
      return docNumber.includes('-') ? 'ruc' : 'ci';
    }
    return 'other';
  }

  /**
   * Para currencies habituales en la región. Si la empresa quiere otra,
   * la manda explícita en el DTO.
   */
  private defaultCurrencyForCountry(country: string): string {
    const map: Record<string, string> = {
      PY: 'PYG',
      AR: 'ARS',
      BR: 'BRL',
      CL: 'CLP',
      UY: 'UYU',
      US: 'USD',
    };
    const c = map[country.toUpperCase()];
    if (!c) {
      throw new BadRequestException(
        `No hay currency default para country=${country}; especificalo en el body`,
      );
    }
    return c;
  }

  private toDto(row: {
    id: string;
    empresa_id: string;
    monto: Prisma.Decimal;
    currency: string;
    country: string;
    descripcion: string | null;
    comprador_nombre: string | null;
    comprador_email: string | null;
    comprador_documento: string | null;
    comprador_telefono: string | null;
    external_reference: string | null;
    intent_id: string;
    session_id: string | null;
    hosted_url: string | null;
    public_token: string | null;
    modo?: string;
    platform_id?: string | null;
    redirect_url?: string | null;
    qr_data?: unknown;
    status: novasis_pay_cobro_status;
    return_url: string | null;
    cancel_url: string | null;
    expires_at?: Date | null;
    creado_por: string | null;
    created_at: Date;
    updated_at: Date;
  }) {
    return {
      id: row.id,
      empresa_id: row.empresa_id,
      monto: row.monto.toString(),
      currency: row.currency,
      country: row.country,
      descripcion: row.descripcion,
      comprador: row.comprador_nombre || row.comprador_email || row.comprador_documento || row.comprador_telefono
        ? {
            nombre: row.comprador_nombre,
            email: row.comprador_email,
            documento: row.comprador_documento,
            telefono: row.comprador_telefono,
          }
        : null,
      external_reference: row.external_reference,
      intent_id: row.intent_id,
      session_id: row.session_id,
      hosted_url: row.hosted_url,
      public_token: row.public_token,
      modo: row.modo ?? 'link',
      platform_id: row.platform_id ?? null,
      redirect_url: row.redirect_url ?? null,
      qr: row.qr_data ?? null,
      status: row.status,
      return_url: row.return_url,
      cancel_url: row.cancel_url,
      expires_at: row.expires_at ?? null,
      creado_por: row.creado_por,
      created_at: row.created_at,
      updated_at: row.updated_at,
    };
  }
}
