import {
  BadRequestException,
  ConflictException,
  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;
  /** Raw provider status-query body, merged into the intent's audit payload when present. */
  rawResponse?: Record<string, unknown>;
}

@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) {
      // Payer switched method (e.g. Waafi → Telebirr) on an uncharged session: retire the
      // open intent and fall through to open a fresh one for the new provider. Only safe
      // while REQUIRES_ACTION — PROCESSING/SUCCEEDED intents may have money in flight, so
      // they keep the reuse path (the switch is silently refused until they resolve).
      const switchingProvider =
        existing.provider !== request.provider &&
        existing.status === ProviderPaymentStatus.REQUIRES_ACTION;
      if (switchingProvider) {
        await this.intentsRepository.update(existing.id, {
          status: ProviderPaymentStatus.CANCELLED,
          failureCode: "METHOD_CHANGED",
          failureMessage: `Payer switched from ${existing.provider} to ${request.provider}`,
        });
        this.logger.log(
          `intent ${existing.id} retired (METHOD_CHANGED ${existing.provider} → ${request.provider}) for ` +
            `${request.service}/${request.referenceType}/${request.referenceId}`,
        );
      } else if (
        existing.status === ProviderPaymentStatus.REQUIRES_ACTION
      ) {
        // Same provider, payer re-initiated while a session is open (back button,
        // abandoned checkout, second device). Verify at the provider first, then:
        // paid/processing sessions are adopted; an unpaid session that is still live
        // (unexpired, same amount) is REUSED — its hosted page stays payable until
        // expiresAt, so minting a fresh session would leave the old one concurrently
        // payable and invite a double charge. Only genuinely expired/changed sessions
        // are retired so a fresh one opens below.
        const settled = await this.verifyThenReuseOrRetire(existing, request);
        if (settled) return this.toSnapshot(settled);
      } else if (existing.provider !== request.provider) {
        // PROCESSING/SUCCEEDED on a DIFFERENT provider than requested: money may already be
        // in flight there. Must not silently hand back that other provider's clientAction
        // (e.g. its redirect URL) as if it belonged to the newly requested provider — the
        // caller has no way to tell the two apart (see InitiateResponseDto), so it would
        // blindly redirect the payer to the wrong gateway's checkout page.
        throw new ConflictException(
          `A ${existing.provider} payment is already ${existing.status.toLowerCase()} for this ` +
            `booking. Complete or wait for it to resolve before switching payment methods.`,
        );
      } else {
        // Same provider, already PROCESSING/SUCCEEDED: never reopen — return the existing
        // intent so the caller adopts its outcome.
        return this.toSnapshot(existing);
      }
    }

    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}`,
      );
    }

    // REQUIRES_ACTION is the normal awaiting-OTP state; PROCESSING is tolerated so an intent
    // that a poll/sweep nudged forward can still be confirmed. Terminal states are rejected.
    if (
      intent.status !== ProviderPaymentStatus.REQUIRES_ACTION &&
      intent.status !== ProviderPaymentStatus.PROCESSING
    ) {
      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(),
      });
      return this.snapshotOf(intent.id);
    }

    // Confirm did not clearly succeed. CAC has no callback and the confirm response can be
    // lost after the customer was charged, so before failing anything verify the source of
    // truth by paymentRequestId (GetPaymentByReferenceRequest keys on it).
    const verified = await this.cacBankProvider
      .queryStatus(intent.providerOrderId)
      .catch((err: unknown) => {
        this.logger.warn(
          `CAC verify after failed confirm errored for intent ${intent.id}: ${
            err instanceof Error ? err.message : String(err)
          }`,
        );
        return null;
      });

    if (verified?.status === ProviderPaymentStatus.SUCCEEDED) {
      await this.applyProviderResult(intent.id, {
        status: ProviderPaymentStatus.SUCCEEDED,
        providerTxnId: verified.providerTxnId,
        paidAt: new Date(),
      });
      return this.snapshotOf(intent.id);
    }

    // Genuinely not paid — almost always a wrong or expired OTP. Leave the intent in
    // REQUIRES_ACTION so the payer can re-enter the code, and do NOT emit payment.failed:
    // a mistyped OTP must not cancel the booking. The reconciliation sweep CANCELs the
    // intent once its OTP window (expiresAt) passes.
    throw new BadRequestException(
      confirmResult.failureMessage ??
        "OTP confirmation failed — please re-enter the code sent to your phone",
    );
  }

  private async snapshotOf(intentId: string): Promise<PaymentIntentSnapshot> {
    const updated = await this.intentsRepository.findById(intentId);
    if (!updated) throw new NotFoundException("PaymentIntent not found");
    return this.toSnapshot(updated);
  }

  /**
   * Re-initiate handler for an open REQUIRES_ACTION intent on the same provider.
   * Queries the provider first — the payer may have paid on the old session with
   * the webhook still in flight. Then:
   *
   * - Paid/processing: applied through the state machine and the intent is returned
   *   for the caller to adopt.
   * - Unpaid but still live (not expired, same amount/currency): the existing intent
   *   is REUSED and returned — the provider's hosted page remains payable until
   *   expiresAt, so opening a fresh session would leave two concurrently-payable
   *   sessions and invite a double charge (observed in prod: a superseded Telebirr
   *   session was paid after cancellation, orphaning the capture).
   * - Expired, or the requested amount/currency or platform (web↔mobile) changed:
   *   retired (CANCELLED, no notification — nothing was paid; a payment.failed here
   *   would wrongly fail the domain order mid-retry) and null is returned so the caller
   *   opens a fresh session with the correct amount/clientAction for the new platform.
   *
   * When the status query itself errors, the existing intent is reused unchanged:
   * superseding blind could leave two live sessions and a double charge.
   */
  private async verifyThenReuseOrRetire(
    intent: PaymentIntent,
    request: InitiatePaymentRequest,
  ): Promise<PaymentIntent | null> {
    let status: ProviderStatus;
    try {
      status = await this.queryProviderStatus(intent);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      this.logger.warn(
        `verify-before-reuse: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`,
      );
      return intent;
    }

    if (
      status.status === ProviderPaymentStatus.SUCCEEDED ||
      status.status === ProviderPaymentStatus.PROCESSING
    ) {
      await this.applyProviderResult(intent.id, this.fromProviderStatus(status));
      return (await this.intentsRepository.findById(intent.id)) ?? intent;
    }

    // Unpaid at the provider. Reuse the still-live session rather than superseding it.
    const expired =
      intent.expiresAt != null && intent.expiresAt.getTime() < Date.now();
    const chargeChanged =
      intent.amountMinor !== request.amountMinor ||
      intent.currency !== request.currency;
    // A web↔mobile switch needs a different clientAction shape (e.g. Telebirr:
    // REDIRECT for web vs LAUNCH_APP for the native app), so reusing the stored
    // session would hand the payer the wrong launch method and break the return.
    // Detect the stored session's platform from its clientAction and retire on a switch.
    const storedIsMobileLaunch = intent.clientAction?.type === "LAUNCH_APP";
    const requestedMobile = (request.platform ?? "web") === "mobile";
    const platformChanged = storedIsMobileLaunch !== requestedMobile;

    if (!expired && !chargeChanged && !platformChanged) {
      this.logger.log(
        `intent ${intent.id} reused (live ${intent.provider} session, unpaid, not expired) for ` +
          `${request.service}/${request.referenceType}/${request.referenceId}`,
      );
      return intent;
    }

    await this.intentsRepository.update(intent.id, {
      status: ProviderPaymentStatus.CANCELLED,
      failureCode: expired ? "EXPIRED" : "SUPERSEDED",
      failureMessage: expired
        ? "Provider session expired before the payer acted"
        : "Payer re-initiated with a changed amount or platform; previous session superseded",
    });
    this.logger.log(
      `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
    );
    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));
  }

  /**
   * Diagnostic lookup by domain reference (service + referenceType + referenceId). Returns the
   * stored intent AND a LIVE provider status query side by side — the reference-keyed twin of
   * {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without
   * knowing the merchant order id. Pure read (no state-machine mutation).
   *
   * - `db`: the active stored intent for the reference, or `null` when none exists.
   * - `provider`: the raw provider status response (queried using the intent's own provider), or
   *   `null` when there is no intent or the query fails.
   */
  async getDiagnosticByReference(
    service: PaymentService,
    referenceType: PaymentReferenceType,
    referenceId: string,
  ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
    const intent = await this.intentsRepository.findActiveByReference(
      service,
      referenceType,
      referenceId,
    );
    const providerStatus = intent
      ? await this.queryProviderForMerchantOrder(
          intent.merchantOrderId,
          intent,
          undefined,
        )
      : null;
    return { db: intent ?? null, provider: providerStatus };
  }

  /**
   * Diagnostic lookup by provider-facing merchant order id (PSG-/FRT-…). Returns the stored
   * intent AND a LIVE provider status query side by side, so the caller can compare what the
   * platform believes against what the provider currently reports. This is a pure read — it
   * does NOT mutate the intent (no state-machine transition, no outbox event).
   *
   * - `db`: the full stored intent row, or `null` when no intent has this merchant order id.
   * - `provider`: the raw provider status response. When there is a DB row its provider is
   *   used; when there is no DB row a `providerHint` must be supplied to know which provider
   *   to ask (the merchant-order prefix only identifies the service). `null` if the provider
   *   is unknown or the query fails.
   */
  async getByMerchantOrderId(
    merchantOrderId: string,
    providerHint?: ProviderMethod,
  ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
    const intent =
      await this.intentsRepository.findByMerchantOrderId(merchantOrderId);

    const providerStatus = await this.queryProviderForMerchantOrder(
      merchantOrderId,
      intent,
      providerHint,
    );

    return { db: intent ?? null, provider: providerStatus };
  }

  /** Best-effort live provider status for a merchant order id; never throws (returns null). */
  private async queryProviderForMerchantOrder(
    merchantOrderId: string,
    intent: PaymentIntent | null,
    providerHint?: ProviderMethod,
  ): Promise<ProviderStatus | null> {
    try {
      if (intent) {
        const provider = this.providers.get(intent.provider);
        if (!provider) return null;
        return await this.queryProviderStatus(intent);
      }
      // No DB row — fall back to the caller-supplied provider hint keyed on merchantOrderId.
      if (!providerHint) return null;
      const provider = this.providers.get(providerHint);
      if (!provider) return null;
      return await provider.queryStatus(merchantOrderId);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      this.logger.warn(
        `provider status query failed for merchantOrderId ${merchantOrderId}: ${message}`,
      );
      return null;
    }
  }

  /**
   * 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) {
      if (!intent.providerOrderId) {
        throw new Error(
          `CAC intent ${intent.id} has no providerOrderId to verify`,
        );
      }
      return this.cacBankProvider.queryStatus(intent.providerOrderId);
    }

    return provider.queryStatus(intent.merchantOrderId);
  }

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

  /* ------------------------------------------------------------------ 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 };
      }

      // Keep the audit payload current with the latest provider status body (surfaced as
      // `providerResponse` in the snapshot). Merged so the initiation keys are preserved.
      if (result.rawResponse) {
        intent.rawInitiation = {
          ...(intent.rawInitiation ?? {}),
          statusResponse: result.rawResponse,
        };
      }

      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(),
      providerResponse: intent.rawInitiation ?? undefined,
    };
  }
}
