import { $inject, Alepha } from "alepha";
import { $job } from "alepha/api/jobs";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { $repository } from "alepha/orm";
import {
  type PaymentIntentEntity,
  paymentIntents,
} from "../entities/paymentIntents.ts";
import { type RefundEntity, refunds } from "../entities/refunds.ts";
import { PaymentError } from "../errors/PaymentError.ts";
import {
  PaymentProvider,
  type WebhookEvent,
} from "../providers/PaymentProvider.ts";

export class PaymentService {
  protected readonly alepha = $inject(Alepha);
  protected readonly log = $logger();
  protected readonly dateTime = $inject(DateTimeProvider);
  protected readonly provider = $inject(PaymentProvider);
  protected readonly intentRepo = $repository(paymentIntents);
  protected readonly refundRepo = $repository(refunds);

  /**
   * Expires stale payment intents that have been in "processing" status
   * for more than 30 minutes. Runs every 5 minutes — shares the CF wrangler
   * trigger with the jobs sweep so no extra binding is consumed.
   */
  protected readonly expireStaleIntents = $job({
    name: "api:payments:expireStaleIntents",
    cron: "*/5 * * * *",
    handler: async () => {
      const cutoff = this.dateTime.now().subtract(30, "minutes").toISOString();

      const stale = await this.intentRepo.findMany({
        where: { status: { eq: "processing" }, createdAt: { lt: cutoff } },
      });

      for (const intent of stale) {
        if (intent.providerRef) {
          try {
            await this.provider.expireSession(intent.providerRef);
          } catch (error) {
            this.log.warn(
              `Failed to expire session for intent ${intent.id}`,
              error,
            );
          }
        }
        await this.intentRepo.updateById(intent.id, { status: "expired" });
        this.log.info(`Expired stale intent ${intent.id}`);
      }
    },
  });

  /**
   * Create a new payment intent in "created" status.
   */
  public async createIntent(
    amount: number,
    currency: string,
    metadata?: unknown,
    options?: { paymentMethodId?: string; userId?: string },
  ): Promise<PaymentIntentEntity> {
    return await this.intentRepo.create({
      amount,
      currency: currency.toLowerCase(),
      status: "created",
      metadata: metadata as any,
      paymentMethodId: options?.paymentMethodId,
      userId: options?.userId,
    });
  }

  /**
   * Create a checkout session with the payment provider and
   * transition the intent to "processing".
   */
  public async createSession(
    intentId: string,
    returnUrl: string,
    authorize?: boolean,
    userId?: string,
    options?: {
      stripeAccount?: string;
      applicationFeeAmount?: number;
      /**
       * Pre-fills the payer's email on the PSP checkout page. Useful when
       * the session runs on a sub-account (Stripe connected account) where
       * no customer object exists — without it the hosted checkout makes
       * the payer retype an address the platform already knows.
       */
      customerEmail?: string;
    },
  ): Promise<{ url: string; intentId: string }> {
    const intent = await this.getIntent(intentId);
    this.assertStatus(intent, "created", "createSession");

    // Verify intent ownership if userId is provided
    if (userId && intent.userId && intent.userId !== userId) {
      throw new PaymentError("Payment intent does not belong to this user");
    }

    // Associate intent with user if not already set
    if (userId && !intent.userId) {
      await this.intentRepo.updateById(intent.id, { userId });
    }

    const result = await this.provider.createSession(intent, {
      returnUrl,
      authorize,
      stripeAccount: options?.stripeAccount,
      applicationFeeAmount: options?.applicationFeeAmount,
      customerEmail: options?.customerEmail,
    });

    await this.intentRepo.updateById(intent.id, {
      status: "processing",
      providerRef: result.providerRef,
    });

    return { url: result.url, intentId: intent.id };
  }

  /**
   * Handle an incoming webhook from the payment provider.
   */
  public async handleWebhook(request: Request): Promise<void> {
    const event = await this.provider.parseWebhook(request);
    await this.handleParsedWebhook(event);
  }

  /**
   * Resolve an already-parsed webhook event to its stored intent and apply
   * it. Split from `handleWebhook` so callers that parse with a DIFFERENT
   * verification path (e.g. a connected-accounts endpoint that must first
   * route the event to a tenant) can reuse the matching + transition logic.
   */
  public async handleParsedWebhook(event: WebhookEvent): Promise<void> {
    let intents = await this.intentRepo.findMany({
      where: { providerRef: { eq: event.providerRef } },
      limit: 1,
    });

    // Session events reference two PSP objects (session + PaymentIntent);
    // the stored ref is whichever existed at creation time — try the other.
    if (intents.length === 0 && event.providerRefAlt) {
      intents = await this.intentRepo.findMany({
        where: { providerRef: { eq: event.providerRefAlt } },
        limit: 1,
      });
    }

    if (intents.length === 0) {
      this.log.warn(`Webhook for unknown providerRef: ${event.providerRef}`);
      return;
    }

    const intent = intents[0];

    // Session events put the session id in `providerRef` and the (lazily
    // created) PaymentIntent in `providerRefAlt`. When the stored ref is
    // still the session id, upgrade it to the PI as soon as an event
    // reveals it — refunds can only target the PI, never the session.
    if (
      event.providerRefAlt &&
      intent.providerRef === event.providerRef &&
      event.providerRefAlt !== event.providerRef
    ) {
      await this.intentRepo.updateById(intent.id, {
        providerRef: event.providerRefAlt,
      });
    }

    await this.handleWebhookEvent(intent.id, event.status, event.raw);
  }

  /**
   * Process a webhook event by updating the intent status and emitting
   * the corresponding payment event.
   */
  /**
   * Valid status transitions from webhook events.
   * Only these transitions are allowed — all others are silently ignored.
   */
  protected static readonly VALID_WEBHOOK_TRANSITIONS: Record<
    string,
    string[]
  > = {
    processing: ["authorized", "captured", "failed"],
    authorized: ["captured", "failed"],
  };

  public async handleWebhookEvent(
    intentId: string,
    status: string,
    raw?: unknown,
  ): Promise<void> {
    const intent = await this.getIntent(intentId);

    const eventMap = {
      authorized: "payments:authorized",
      captured: "payments:captured",
      failed: "payments:failed",
    } as const;

    type WebhookStatus = keyof typeof eventMap;
    if (!(status in eventMap)) {
      this.log.warn(`Unknown webhook status: ${status}`);
      return;
    }

    const webhookStatus = status as WebhookStatus;

    // Validate status transition
    const allowed = PaymentService.VALID_WEBHOOK_TRANSITIONS[intent.status];
    if (!allowed?.includes(webhookStatus)) {
      this.log.warn(
        `Ignoring webhook: cannot transition ${intent.status} → ${webhookStatus}`,
        { intentId: intent.id },
      );
      return;
    }

    await this.intentRepo.updateById(intent.id, {
      status: webhookStatus,
      providerRaw: raw as any,
    });

    await this.alepha.events.emit(eventMap[webhookStatus], {
      intentId: intent.id,
      amount: intent.amount,
      currency: intent.currency,
      metadata: intent.metadata,
    });
  }

  /**
   * Capture a previously authorized payment. Optionally specify a different
   * amount for partial capture.
   */
  public async capture(
    intentId: string,
    finalAmount?: number,
  ): Promise<PaymentIntentEntity> {
    const intent = await this.getIntent(intentId);
    this.assertStatus(intent, "authorized", "capture");

    const amount = finalAmount ?? intent.amount;
    if (amount > intent.amount) {
      throw new PaymentError(
        `Capture amount ${amount} exceeds authorized amount ${intent.amount}`,
      );
    }

    if (intent.providerRef) {
      await this.provider.capturePayment(intent.providerRef, amount);
    }

    const updated = await this.intentRepo.updateById(intent.id, {
      status: "captured",
      amount,
    });

    await this.alepha.events.emit("payments:captured", {
      intentId: intent.id,
      amount,
      currency: intent.currency,
      metadata: intent.metadata,
    });

    return updated;
  }

  /**
   * Void a previously authorized payment before capture.
   */
  public async void(intentId: string): Promise<PaymentIntentEntity> {
    const intent = await this.getIntent(intentId);
    this.assertStatus(intent, "authorized", "void");

    if (intent.providerRef) {
      await this.provider.voidPayment(intent.providerRef);
    }

    const updated = await this.intentRepo.updateById(intent.id, {
      status: "voided",
    });

    await this.alepha.events.emit("payments:voided", {
      intentId: intent.id,
      amount: intent.amount,
      currency: intent.currency,
      metadata: intent.metadata,
    });

    return updated;
  }

  /**
   * Refund a captured payment (partial or full).
   */
  public async refund(
    intentId: string,
    amount: number,
    reason?: string,
    options: {
      /**
       * PSP sub-account holding the charge (Stripe connected account) —
       * required to refund payments that were created with the same option,
       * e.g. direct charges on a club's connected account.
       */
      stripeAccount?: string;
    } = {},
  ): Promise<RefundEntity> {
    const intent = await this.getIntent(intentId);

    // Allow refunds from both "captured" and "partially_refunded" states
    if (
      intent.status !== "captured" &&
      intent.status !== "partially_refunded"
    ) {
      throw new PaymentError(
        `Cannot refund: intent ${intent.id} is '${intent.status}', expected 'captured' or 'partially_refunded'`,
      );
    }

    // Validate refund amount against remaining refundable amount
    const existingRefunds = await this.refundRepo.findMany({
      where: { intentId: { eq: intent.id } },
    });
    const totalRefunded = existingRefunds.reduce((sum, r) => sum + r.amount, 0);
    const remaining = intent.amount - totalRefunded;

    if (amount > remaining) {
      throw new PaymentError(
        `Refund amount ${amount} exceeds remaining refundable amount ${remaining}`,
      );
    }

    let refundProviderRef: string | undefined;
    if (intent.providerRef) {
      const result = await this.provider.refundPayment(
        intent.providerRef,
        amount,
        options,
      );
      refundProviderRef = result.providerRef;
    }

    const refund = await this.refundRepo.create({
      intentId: intent.id,
      organizationId: intent.organizationId,
      amount,
      currency: intent.currency,
      status: "completed",
      reason,
      providerRef: refundProviderRef,
    });

    // Set status based on whether fully or partially refunded
    const newTotalRefunded = totalRefunded + amount;
    const newStatus =
      newTotalRefunded >= intent.amount ? "refunded" : "partially_refunded";
    await this.intentRepo.updateById(intent.id, {
      status: newStatus,
    });

    await this.alepha.events.emit("payments:refunded", {
      intentId: intent.id,
      refundId: refund.id,
      amount,
      currency: intent.currency,
      metadata: intent.metadata,
    });

    return refund;
  }

  /**
   * Record a cash or offline payment directly as captured,
   * bypassing the checkout flow.
   */
  public async recordCashPayment(
    amount: number,
    currency: string,
    metadata?: unknown,
  ): Promise<PaymentIntentEntity> {
    const intent = await this.intentRepo.create({
      amount,
      currency: currency.toLowerCase(),
      status: "captured",
      metadata: metadata as any,
    });

    await this.alepha.events.emit("payments:captured", {
      intentId: intent.id,
      amount,
      currency,
      metadata,
    });

    return intent;
  }

  /**
   * Cancel a payment intent that has not yet entered processing.
   */
  public async cancel(intentId: string): Promise<PaymentIntentEntity> {
    const intent = await this.getIntent(intentId);
    this.assertStatus(intent, "created", "cancel");

    const cancelled = await this.intentRepo.updateById(intent.id, {
      status: "cancelled",
    });

    await this.alepha.events.emit("payments:cancelled", {
      intentId: intent.id,
      amount: intent.amount,
      currency: intent.currency,
      metadata: intent.metadata,
    });

    return cancelled;
  }

  /**
   * Get a payment intent by ID. Throws NotFoundError if not found.
   */
  public async getIntent(intentId: string): Promise<PaymentIntentEntity> {
    return await this.intentRepo.getById(intentId);
  }

  /**
   * Find payment intents with optional filters and pagination.
   */
  public async findIntents(query: {
    status?: string;
    userId?: string;
    sort?: string;
    size?: number;
    page?: number;
  }) {
    const where = this.intentRepo.createQueryWhere();
    if (query.status)
      where.status = { eq: query.status as PaymentIntentEntity["status"] };
    if (query.userId) where.userId = { eq: query.userId };
    return await this.intentRepo.paginate(query, { where }, { count: true });
  }

  protected assertStatus(
    intent: PaymentIntentEntity,
    expected: PaymentIntentEntity["status"],
    operation: string,
  ): void {
    if (intent.status !== expected) {
      throw new PaymentError(
        `Cannot ${operation}: intent ${intent.id} is '${intent.status}', expected '${expected}'`,
      );
    }
  }
}
