import {
  BadRequestException,
  Inject,
  Injectable,
  Logger,
  NotFoundException,
} from "@nestjs/common";
import { DataSource, QueryFailedError } from "typeorm";
import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers";
import {
  ConfirmPaymentRequest,
  InitiatePaymentRequest,
  PaymentIntentSnapshot,
  PaymentReferenceType,
  PaymentService,
  ProviderMethod,
  ProviderPaymentStatus,
  ProviderStatus,
} from "@edr/types";
import {
  PAYMENT_PROVIDER_MAP,
  PaymentProviderMap,
} from "../providers/providers.module";
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
import { buildOutboxRow } from "../outbox/payment-event.factory";
import {
  PaymentIntent,
  TERMINAL_INTENT_STATUSES,
} from "./entities/payment-intent.entity";
import { IntentsRepository } from "./intents.repository";

const PG_UNIQUE_VIOLATION = "23505";
/** Don't hit the provider again if the intent was refreshed this recently. */
const REFRESH_MIN_AGE_MS = 5_000;

/** Result of a provider signal (webhook or status query) applied to the state machine. */
export interface ProviderResultInput {
  status: ProviderPaymentStatus;
  providerTxnId?: string;
  paidAt?: Date;
  confirmedAmountMinor?: number;
  failureCode?: string;
  failureMessage?: string;
}

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

  constructor(
    private readonly intentsRepository: IntentsRepository,
    // DataSource is used only for the finalize transaction (intent update + outbox insert
    // must commit atomically); routine access still goes through the custom repository.
    private readonly dataSource: DataSource,
    @Inject(PAYMENT_PROVIDER_MAP)
    private readonly providers: PaymentProviderMap,
    private readonly cacBankProvider: CacBankProvider,
  ) {}

  /* ------------------------------------------------------------------ initiate */

  async initiate(
    request: InitiatePaymentRequest,
  ): Promise<PaymentIntentSnapshot> {

    if (request.idempotencyKey) {
      const byKey = await this.intentsRepository.findByIdempotencyKey(
        request.service,
        request.idempotencyKey,
      );
      if (byKey) return this.toSnapshot(byKey);
    }

    const existing = await this.intentsRepository.findActiveByReference(
      request.service,
      request.referenceType,
      request.referenceId,
    );
    if (existing) {
      const reusable = await this.reuseOrRetire(existing);
      if (reusable) return this.toSnapshot(reusable);
    }

    const provider = this.providers.get(request.provider);
    if (!provider) {
      throw new BadRequestException(
        `Unsupported payment provider: ${request.provider}`,
      );
    }

    if (
      request.provider === ProviderMethod.CAC_BANK &&
      !request.payerAccount?.trim()
    ) {
      throw new BadRequestException(
        "payerAccount (customer mobile number) is required for CAC_BANK",
      );
    }

    const merchantOrderId = createMerchantOrderId();
    const result = await provider.initiate({
      merchantOrderId,
      orderRef: request.orderRef ?? request.referenceId,
      amountMinor: request.amountMinor,
      currency: request.currency,
      platform: request.platform,
      payerAccount: request.payerAccount,
      returnUrl: request.returnUrl,
      redirectUrl: request.returnUrl,
      failureUrl: request.failureUrl,
    });

    try {
      const intent = await this.intentsRepository.create({
        service: request.service,
        referenceType: request.referenceType,
        referenceId: request.referenceId,
        merchantOrderId,
        provider: request.provider,
        providerOrderId: result.providerOrderId,
        amountMinor: request.amountMinor,
        currency: request.currency,
        status: ProviderPaymentStatus.REQUIRES_ACTION,
        clientAction: result.clientAction,
        idempotencyKey: request.idempotencyKey ?? null,
        expiresAt: result.expiresAt,
        rawInitiation: result.rawInitiation,
      });
      this.logger.log(
        `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
      );
      return this.toSnapshot(intent);
    } catch (err) {
      if (
        err instanceof QueryFailedError &&
        (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
      ) {
        const winner = await this.intentsRepository.findActiveByReference(
          request.service,
          request.referenceType,
          request.referenceId,
        );
        if (winner) return this.toSnapshot(winner);
      }
      throw err;
    }
  }

  /* ------------------------------------------------------------------ confirm (OTP providers) */

  async confirm(
    intentId: string,
    request: ConfirmPaymentRequest,
  ): Promise<PaymentIntentSnapshot> {
    const intent = await this.intentsRepository.findById(intentId);
    if (!intent) throw new NotFoundException("PaymentIntent not found");

    if (intent.provider !== ProviderMethod.CAC_BANK) {
      throw new BadRequestException(
        `Confirm is not supported for provider: ${intent.provider}`,
      );
    }

    if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) {
      throw new BadRequestException(
        `Intent is not awaiting confirmation (status=${intent.status})`,
      );
    }

    if (!intent.providerOrderId) {
      throw new BadRequestException("Intent has no provider order id");
    }

    const confirmResult = await this.cacBankProvider.confirmPayment(
      intent.providerOrderId,
      request.otp,
    );

    if (confirmResult.reference) {
      await this.intentsRepository.update(intent.id, {
        rawInitiation: {
          ...(intent.rawInitiation ?? {}),
          reference: confirmResult.reference,
          confirmResponse: confirmResult.rawResponse,
        },
      });
    }

    if (confirmResult.status === "SUCCEEDED") {
      await this.applyProviderResult(intent.id, {
        status: ProviderPaymentStatus.SUCCEEDED,
        providerTxnId: confirmResult.providerTxnId,
        paidAt: new Date(),
      });
    } else {
      await this.applyProviderResult(intent.id, {
        status: ProviderPaymentStatus.FAILED,
        failureCode: confirmResult.failureCode,
        failureMessage: confirmResult.failureMessage,
      });
    }

    const updated = await this.intentsRepository.findById(intent.id);
    if (!updated) throw new NotFoundException("PaymentIntent not found");
    return this.toSnapshot(updated);
  }

  /**
   * Decide whether an existing active intent can be returned as-is. An expired
   * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
   * so a fresh provider session can be opened.
   */
  private async reuseOrRetire(
    intent: PaymentIntent,
  ): Promise<PaymentIntent | null> {
    const expired =
      intent.status === ProviderPaymentStatus.REQUIRES_ACTION &&
      intent.expiresAt != null &&
      intent.expiresAt.getTime() < Date.now();
    if (!expired) return intent;

    await this.intentsRepository.update(intent.id, {
      status: ProviderPaymentStatus.CANCELLED,
      failureCode: "EXPIRED",
      failureMessage: "Provider session expired before the payer acted",
    });
    return null;
  }

  /* ------------------------------------------------------------------ lookups */

  async getIntent(id: string): Promise<PaymentIntentSnapshot> {
    const intent = await this.intentsRepository.findById(id);
    if (!intent) throw new NotFoundException("PaymentIntent not found");
    return this.toSnapshot(await this.refreshIfStale(intent));
  }

  async getIntentByReference(
    service: PaymentService,
    referenceType: PaymentReferenceType,
    referenceId: string,
  ): Promise<PaymentIntentSnapshot> {
    const intent = await this.intentsRepository.findActiveByReference(
      service,
      referenceType,
      referenceId,
    );
    if (!intent) throw new NotFoundException("PaymentIntent not found");
    return this.toSnapshot(await this.refreshIfStale(intent));
  }

  /**
   * Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the
   * provider for the truth and run the answer through the state machine. The browser
   * redirect never confirms payment — this query (or a webhook) does.
   */
  private async refreshIfStale(intent: PaymentIntent): Promise<PaymentIntent> {
    const refreshable =
      intent.status === ProviderPaymentStatus.REQUIRES_ACTION ||
      intent.status === ProviderPaymentStatus.PROCESSING;
    const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS;
    const provider = this.providers.get(intent.provider);
    if (!refreshable || !stale || !provider) return intent;

    try {
      const status = await this.queryProviderStatus(intent);
      await this.applyProviderResult(
        intent.id,
        this.fromProviderStatus(status),
      );
      return (await this.intentsRepository.findById(intent.id)) ?? intent;
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      this.logger.warn(
        `queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
      );
      return intent;
    }
  }

  private async queryProviderStatus(
    intent: PaymentIntent,
  ): Promise<ProviderStatus> {
    const provider = this.providers.get(intent.provider);
    if (!provider) {
      throw new Error(`Unknown provider: ${intent.provider}`);
    }

    if (intent.provider === ProviderMethod.CAC_BANK) {
      const reference = (intent.rawInitiation as { reference?: string })
        ?.reference;
      return this.cacBankProvider.queryStatus(
        intent.merchantOrderId,
        reference,
      );
    }

    return provider.queryStatus(intent.merchantOrderId);
  }

  fromProviderStatus(status: ProviderStatus): ProviderResultInput {
    return {
      status: status.status,
      providerTxnId: status.providerTxnId,
      failureCode: status.failureCode,
      failureMessage: status.failureMessage,
    };
  }

  /* ------------------------------------------------------------------ state machine */

  /**
   * Advance the intent state machine with a verified provider signal. Terminal states are
   * absorbing; a terminal transition writes the notification_outbox row IN THE SAME
   * TRANSACTION as the intent update (transactional outbox — architecture.md §8).
   */
  async applyProviderResult(
    intentId: string,
    result: ProviderResultInput,
  ): Promise<{ alreadyTerminal: boolean }> {
    return this.dataSource.transaction(async (manager) => {
      const intent = await manager
        .getRepository(PaymentIntent)
        .createQueryBuilder("intent")
        .setLock("pessimistic_write")
        .where("intent.id = :intentId", { intentId })
        .getOne();
      if (!intent) throw new NotFoundException("PaymentIntent not found");

      if (
        (TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes(
          intent.status,
        )
      ) {
        return { alreadyTerminal: true };
      }

      if (result.status === ProviderPaymentStatus.SUCCEEDED) {
        const paidAt = result.paidAt ?? new Date();
        intent.status = ProviderPaymentStatus.SUCCEEDED;
        intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
        intent.paidAt = paidAt;
        intent.confirmedAmountMinor =
          result.confirmedAmountMinor ?? intent.confirmedAmountMinor;
        intent.failureCode = null;
        intent.failureMessage = null;
        await manager.save(intent);
        await manager.getRepository(NotificationOutbox).save(
          buildOutboxRow(intent, {
            eventType: "payment.succeeded",
            providerTxnId: intent.providerTxnId ?? undefined,
            paidAt,
          }),
        );
        if (
          result.confirmedAmountMinor != null &&
          result.confirmedAmountMinor !== intent.amountMinor
        ) {
          this.logger.error(
            `intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`,
          );
        }
        this.logger.log(
          `intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`,
        );
        return { alreadyTerminal: false };
      }

      if (
        result.status === ProviderPaymentStatus.FAILED ||
        result.status === ProviderPaymentStatus.CANCELLED
      ) {
        intent.status = result.status;
        intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
        intent.failureCode = result.failureCode ?? null;
        intent.failureMessage = result.failureMessage ?? null;
        await manager.save(intent);
        await manager.getRepository(NotificationOutbox).save(
          buildOutboxRow(intent, {
            eventType: "payment.failed",
            failureCode: result.failureCode,
            failureMessage: result.failureMessage,
          }),
        );
        this.logger.log(
          `intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`,
        );
        return { alreadyTerminal: false };
      }

      // Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse.
      if (
        result.status === ProviderPaymentStatus.PROCESSING &&
        intent.status === ProviderPaymentStatus.REQUIRES_ACTION
      ) {
        intent.status = ProviderPaymentStatus.PROCESSING;
      }
      intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
      await manager.save(intent);
      return { alreadyTerminal: false };
    });
  }

  /** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */
  async expireIntent(intentId: string): Promise<void> {
    await this.applyProviderResult(intentId, {
      status: ProviderPaymentStatus.CANCELLED,
      failureCode: "EXPIRED",
      failureMessage: "Payment session expired before completion",
    });
  }

  /* ------------------------------------------------------------------ mapping */

  toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot {
    return {
      intentId: intent.id,
      service: intent.service,
      referenceType: intent.referenceType,
      referenceId: intent.referenceId,
      merchantOrderId: intent.merchantOrderId,
      provider: intent.provider,
      status: intent.status,
      amountMinor: intent.amountMinor,
      currency: intent.currency,
      clientAction: intent.clientAction ?? undefined,
      providerTxnId: intent.providerTxnId ?? undefined,
      paidAt: intent.paidAt?.toISOString(),
      failureCode: intent.failureCode ?? undefined,
      failureMessage: intent.failureMessage ?? undefined,
      expiresAt: intent.expiresAt?.toISOString(),
    };
  }
}
