import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import {
  PaymentProvider,
  ProviderInitiationInput,
  ProviderInitiationResult,
  ProviderStatus,
  ProviderPaymentStatus,
  ProviderMethod,
} from "@edr/types";
import { AxiosError, AxiosRequestConfig } from "axios";
import { firstValueFrom } from "rxjs";
import { CacBankAuth } from "./cac-bank.auth";
import {
  normalizeCacMobile,
  parseCacResponse,
  serializeCacRequest,
} from "./cac-bank.json";
import type {
  CacConfirmResult,
  CacGetPaymentByReferenceRequest,
  CacPaymentByReferenceResponse,
  CacPaymentConfirmRequest,
  CacPaymentConfirmResponse,
  CacPaymentInitiateRequest,
  CacPaymentInitiateResponse,
} from "./cac-bank.types";

/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */
const CAC_MIN_AMOUNT = 10;
const CAC_MAX_AMOUNT = 100_000;

@Injectable()
export class CacBankProvider implements PaymentProvider, OnModuleInit {
  readonly method = ProviderMethod.CAC_BANK;
  private readonly logger = new Logger(CacBankProvider.name);
  private auth: CacBankAuth | null = null;

  constructor(
    private readonly config: ConfigService,
    private readonly http: HttpService,
  ) {}

  /** Log the effective CAC Bank config once at startup (secrets masked) so misconfig is visible. */
  onModuleInit(): void {
    this.logger.log(
      `CAC Bank config resolved: ${JSON.stringify(this.effectiveConfig())}`,
    );
  }

  /** Snapshot of every resolved CAC env value; secret fields are masked, not printed raw. */
  private effectiveConfig(): Record<string, unknown> {
    return {
      CAC_BASE_URL: this.baseUrl || "(empty)",
      CAC_USERNAME: this.username || "(empty)",
      CAC_PASSWORD: this.password,
      CAC_APP_KEY: this.appKey,
      CAC_API_KEY: this.apiKey,
      CAC_COMPANY_SERVICES_ID: this.companyServicesId,
      CAC_CURRENCY: this.defaultCurrency,
      CAC_TOKEN_TTL_MS: this.tokenTtlMs,
      CAC_OTP_EXPIRY_MS: this.otpExpiryMs,
    };
  }
  

  async initiate(
    input: ProviderInitiationInput,
  ): Promise<ProviderInitiationResult> {
    if (!input.payerAccount) {
      throw new Error("CAC Bank requires payerAccount (customer mobile number)");
    }

    const customerMobile = normalizeCacMobile(input.payerAccount);
    const amount = this.toMajorAmount(input.amountMinor, input.currency);
    if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
      throw new Error(
        `CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` +
          `(${CAC_MIN_AMOUNT}–${CAC_MAX_AMOUNT} DJF)`,
      );
    }

    const requestBody: CacPaymentInitiateRequest = {
      app_key: this.appKey,
      api_key: this.apiKey,
      customer_mobile: customerMobile,
      currency: input.currency || this.defaultCurrency,
      desc: `${input.orderRef}`.slice(0, 500),
      vender_ref: input.merchantOrderId,
      amount,
      company_services_id: this.companyServicesId,
    };

    this.logger.log(
      `CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${customerMobile} ref=${input.merchantOrderId}`,
    );
    this.logger.debug(
      `CAC Bank initiate request body: ${JSON.stringify(this.sanitizeKeys(requestBody))}`,
    );
    this.logger.debug(
      `CAC Bank effective config: ${JSON.stringify(this.effectiveConfig())}`,
    );

    const response = await this.postJson<CacPaymentInitiateResponse>(
      "/paymentapi/PaymentInitiateRequest",
      requestBody,
    );

    if (response.paymentRequestId == null) {
      this.logger.error(
        `CAC Bank initiate rejected — sent currency=${requestBody.currency} amount=${requestBody.amount} mobile=${input.payerAccount} | full response: ${JSON.stringify(response)}`,
      );
      throw new Error(
        `CAC Bank initiate failed: ${JSON.stringify(response)}`,
      );
    }

    // Already an exact string (parseCacResponse keeps the 17-digit id lossless).
    const providerOrderId = response.paymentRequestId;
    const expiresAt = new Date(Date.now() + this.otpExpiryMs);

    return {
      providerOrderId,
      clientAction: {
        type: "COLLECT_OTP",
        providerOrderId,
        message: "Enter the OTP sent to your phone",
      },
      expiresAt,
      rawInitiation: {
        request: this.sanitizeKeys(requestBody),
        response,
        venderRef: input.merchantOrderId,
      },
    };
  }

  async confirmPayment(
    paymentRequestId: string,
    otp: string,
  ): Promise<CacConfirmResult> {
    const requestBody: CacPaymentConfirmRequest = {
      app_key: this.appKey,
      api_key: this.apiKey,
      // Kept as a string here; serializeCacRequest emits it as a raw JSON number so the
      // full 17-digit precision reaches the bank.
      payment_request_id: paymentRequestId,
      otp,
    };

    this.logger.log(
      `CAC Bank confirm → paymentRequestId=${paymentRequestId} otp=${"*".repeat(otp?.length ?? 0)}`,
    );

    try {
      const response = await this.postJson<CacPaymentConfirmResponse>(
        "/paymentapi/PaymentConfirmationRequest",
        requestBody,
      );

      if (response.confirmReference == null && !response.reference) {
        this.logger.warn(
          `CAC Bank confirm rejected — paymentRequestId=${paymentRequestId} | full response: ${JSON.stringify(response)}`,
        );
        return {
          status: "FAILED",
          failureCode: "CONFIRM_REJECTED",
          failureMessage: response.description ?? "Confirmation rejected",
          rawResponse: response as unknown as Record<string, unknown>,
        };
      }

      return {
        status: "SUCCEEDED",
        providerTxnId: String(
          response.confirmReference ?? response.reference,
        ),
        reference: response.reference,
        rawResponse: response as unknown as Record<string, unknown>,
      };
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return {
        status: "FAILED",
        failureCode: "CONFIRM_ERROR",
        failureMessage: message,
        rawResponse: {},
      };
    }
  }

  /**
   * Verify a payment via GetPaymentByReferenceRequest, keyed on the paymentRequestId. This
   * is CAC's callback replacement: the bank sends no webhook, but the id is known from
   * initiate and the lookup accepts it, so a lost/failed confirm can still be reconciled.
   * A settled payment carries a transactionNo; anything else means the OTP hasn't been
   * confirmed yet — that's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING.
   * Returning PROCESSING would let a poll/sweep advance the intent out of REQUIRES_ACTION
   * and block the confirm() call.
   */
  async queryStatus(paymentRequestId: string): Promise<ProviderStatus> {
    const requestBody: CacGetPaymentByReferenceRequest = {
      app_key: this.appKey,
      api_key: this.apiKey,
      reference: paymentRequestId,
    };

    try {
      const response = await this.postJson<CacPaymentByReferenceResponse>(
        "/paymentapi/GetPaymentByReferenceRequest",
        requestBody,
      );

      if (response.transactionNo != null && response.transactionDate) {
        return {
          status: ProviderPaymentStatus.SUCCEEDED,
          providerTxnId: String(response.transactionNo),
          rawResponse: response as unknown as Record<string, unknown>,
        };
      }

      return {
        status: ProviderPaymentStatus.REQUIRES_ACTION,
        rawResponse: response as unknown as Record<string, unknown>,
      };
    } catch (err) {
      if (err instanceof AxiosError && err.response?.status === 404) {
        return {
          status: ProviderPaymentStatus.REQUIRES_ACTION,
          rawResponse: { notFound: true, reference: paymentRequestId },
        };
      }
      throw err;
    }
  }

  private async postJson<T>(path: string, body: unknown): Promise<T> {
    const token = await this.getAuth().getAccessToken();
    const url = `${this.baseUrl}${path}`;
    // Serialize ourselves so numeric ids we carry as strings go on the wire unquoted.
    const payload = serializeCacRequest(body);
    const config: AxiosRequestConfig = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
      timeout: this.httpTimeoutMs,
      // Keep the raw response text — 17-digit ids would lose precision under axios's
      // default JSON.parse. We parse losslessly with parseCacResponse.
      transformResponse: [(data) => data],
    };

    const started = Date.now();
    try {
      const res = await firstValueFrom(
        this.http.post<string>(url, payload, config),
      );
      this.logger.debug(
        `CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
      );
      return parseCacResponse<T>(res.data);
    } catch (err) {
      if (err instanceof AxiosError && err.response?.status === 401) {
        this.getAuth().invalidate();
        const retryToken = await this.getAuth().getAccessToken();
        const retryConfig: AxiosRequestConfig = {
          ...config,
          headers: {
            ...config.headers,
            Authorization: `Bearer ${retryToken}`,
          },
        };
        const res = await firstValueFrom(
          this.http.post<string>(url, payload, retryConfig),
        );
        return parseCacResponse<T>(res.data);
      }

      if (err instanceof AxiosError) {
        this.logger.error(
          `CAC Bank POST ${path} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
        );
      } else {
        this.logger.error(
          `CAC Bank POST ${path} threw: ${err instanceof Error ? err.message : err}`,
        );
      }
      throw err;
    }
  }

  private getAuth(): CacBankAuth {
    if (!this.auth) {
      this.auth = new CacBankAuth(this.http, {
        baseUrl: this.baseUrl,
        username: this.username,
        password: this.password,
        tokenTtlMs: this.tokenTtlMs,
        httpTimeoutMs: this.httpTimeoutMs,
      });
    }
    return this.auth;
  }

  /** DJF has no fractional units — amountMinor is the major amount. */
  private toMajorAmount(amountMinor: number, currency: string): number {
    if (currency.toUpperCase() === "DJF") {
      return amountMinor;
    }
    return amountMinor / 100;
  }

  private sanitizeKeys(
    body: CacPaymentInitiateRequest | CacPaymentConfirmRequest,
  ): Record<string, unknown> {
    const { app_key: _appKey, api_key: _apiKey, ...rest } = body;
    return rest;
  }

  private get baseUrl(): string {
    return (this.config.get<string>("cac.baseUrl") ?? "").replace(/\/$/, "");
  }
  private get username(): string {
    return this.config.get<string>("cac.username") ?? "";
  }
  private get password(): string {
    return this.config.get<string>("cac.password") ?? "";
  }
  private get appKey(): string {
    return this.config.get<string>("cac.appKey") ?? "";
  }
  private get apiKey(): string {
    return this.config.get<string>("cac.apiKey") ?? "";
  }
  private get companyServicesId(): number {
    return this.config.get<number>("cac.companyServicesId") ?? 0;
  }
  private get defaultCurrency(): string {
    return this.config.get<string>("cac.currency") ?? "DJF";
  }
  private get tokenTtlMs(): number {
    return this.config.get<number>("cac.tokenTtlMs") ?? 23 * 60 * 60 * 1000;
  }
  private get otpExpiryMs(): number {
    return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
  }
  private get httpTimeoutMs(): number {
    return this.config.get<number>("cac.httpTimeoutMs") ?? 60_000;
  }
}
