import type Stripe from 'stripe'
import { describe, expect, it, vi } from 'vitest'
import { InvalidAmountError, RefundFailedError, WebhookVerificationError } from './errors.js'
import { idempotencyKey } from './index.js'
import {
  classifyStripeEvent,
  stripe,
  type StripeChargeRequest,
  type StripeRefundRequest,
} from './stripe.js'

function makeMockStripe() {
  const paymentIntents = { create: vi.fn(), retrieve: vi.fn() }
  const refunds = { create: vi.fn() }
  const webhooks = { constructEvent: vi.fn() }
  const client = { paymentIntents, refunds, webhooks } as unknown as Stripe
  return { client, paymentIntents, refunds, webhooks }
}

describe('stripe adapter', () => {
  it('charge maps req→PI params with idempotencyKey in options, Connect split from the TYPED stripe extension, and metadata.chargeKey', async () => {
    const { client, paymentIntents } = makeMockStripe()
    paymentIntents.create.mockResolvedValue({
      id: 'pi_test',
      status: 'requires_payment_method',
      client_secret: 'pi_test_secret',
      amount_received: 0,
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
      authorizeConnectSplit: vi.fn().mockResolvedValue(true),
    })

    const result = await provider.charge({
      chargeKey: 'ord-stripe-1',
      amount: 5000,
      currency: 'ils',
      // Money-routing inputs ride the TYPED extension — never metadata.
      stripe: { connectDestination: 'acct_vendor', applicationFeeAmount: 500 },
      metadata: { extra: 'x' },
    } as StripeChargeRequest)

    expect(paymentIntents.create).toHaveBeenCalledOnce()
    const [params, options] = paymentIntents.create.mock.calls[0]!
    expect(params).toMatchObject({
      amount: 5000,
      currency: 'ils',
      application_fee_amount: 500,
      transfer_data: { destination: 'acct_vendor' },
      metadata: expect.objectContaining({ chargeKey: 'ord-stripe-1', extra: 'x' }),
    })
    expect(params).not.toHaveProperty('idempotencyKey')
    expect(options).toEqual({ idempotencyKey: 'ord-stripe-1' })

    expect(result).toEqual({
      kind: 'requires_client_action',
      chargeKey: 'ord-stripe-1',
      providerRef: 'pi_test',
      clientSecret: 'pi_test_secret',
    })
  })

  it('SECURITY: rejects a Connect split unless host authority approves the complete tuple', async () => {
    const { client, paymentIntents } = makeMockStripe()
    const authorizeConnectSplit = vi.fn().mockResolvedValue(false)
    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
      authorizeConnectSplit,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-unauthorized-split',
        amount: 5000,
        currency: 'ils',
        stripe: { connectDestination: 'acct_attacker', applicationFeeAmount: 1 },
      } as StripeChargeRequest),
    ).rejects.toThrow(/not authorized/)
    expect(authorizeConnectSplit).toHaveBeenCalledWith('ord-unauthorized-split', {
      connectDestination: 'acct_attacker',
      applicationFeeAmount: 1,
    })
    expect(paymentIntents.create).not.toHaveBeenCalled()
  })

  it('SECURITY: a caller-supplied metadata.destination NEVER routes money (funds-redirect footgun closed)', async () => {
    const { client, paymentIntents } = makeMockStripe()
    paymentIntents.create.mockResolvedValue({
      id: 'pi_attack',
      status: 'requires_payment_method',
      client_secret: 'pi_attack_secret',
      amount_received: 0,
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    // An attacker who can influence metadata sets destination/connectDestination
    // hoping to redirect the Connect payout. With NO typed stripe.connectDestination,
    // the charge MUST be a plain non-split charge — the metadata values are inert.
    await provider.charge({
      chargeKey: 'ord-attack',
      amount: 9000,
      currency: 'ils',
      metadata: {
        destination: 'acct_attacker',
        connectDestination: 'acct_attacker',
        applicationFeeAmount: '8999',
      },
    })

    const [params] = paymentIntents.create.mock.calls[0]!
    // The funds-redirect proof: NO transfer_data, NO application_fee_amount from metadata.
    expect(params).not.toHaveProperty('transfer_data')
    expect(params).not.toHaveProperty('application_fee_amount')
    // metadata still round-trips as inert tags (chargeKey + the attacker's keys carry no power).
    expect(params.metadata).toMatchObject({ chargeKey: 'ord-attack', destination: 'acct_attacker' })
  })

  it('SECURITY: caller-supplied metadata.chargeKey cannot override the round-tripping chargeKey', async () => {
    // metadata is caller-influenced scalar data. If it can shadow the real
    // chargeKey, the webhook settlement/refund recovers the SHADOWED key and posts
    // the money against a different charge (settlement misattribution + wrong-order
    // dispatch). The typed req.chargeKey must always win the merge.
    const { client, paymentIntents } = makeMockStripe()
    paymentIntents.create.mockResolvedValue({
      id: 'pi_shadow',
      status: 'requires_payment_method',
      client_secret: 'pi_shadow_secret',
      amount_received: 0,
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    await provider.charge({
      chargeKey: 'ord-real',
      amount: 1000,
      currency: 'ils',
      metadata: { chargeKey: 'ord-victim' },
    })

    const [params] = paymentIntents.create.mock.calls[0]!
    expect(params.metadata.chargeKey).toBe('ord-real')
  })

  it('rejects a typed-but-empty connectDestination rather than silently dropping the split', async () => {
    const { client, paymentIntents } = makeMockStripe()
    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-empty-dest',
        amount: 1000,
        currency: 'ils',
        stripe: { connectDestination: '   ' },
      } as StripeChargeRequest),
    ).rejects.toThrow(/connectDestination/)
    expect(paymentIntents.create).not.toHaveBeenCalled()
  })

  it('rejects a malformed applicationFeeAmount rather than silently dropping the platform fee', async () => {
    // A silently-dropped fee = the FULL amount transfers to the vendor and the
    // platform commission vanishes (same silent-money-loss class as the
    // destination footgun). The fee must fail LOUD, never default to zero.
    const { client, paymentIntents } = makeMockStripe()
    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    for (const badFee of [-1, 12.5, 'lots']) {
      await expect(
        provider.charge({
          chargeKey: 'ord-bad-fee',
          amount: 1000,
          currency: 'ils',
          stripe: { connectDestination: 'acct_vendor', applicationFeeAmount: badFee as never },
        } as StripeChargeRequest),
      ).rejects.toThrow(/applicationFeeAmount/)
    }
    expect(paymentIntents.create).not.toHaveBeenCalled()
  })

  it('rejects an applicationFeeAmount exceeding the charge amount (negative vendor net)', async () => {
    const { client, paymentIntents } = makeMockStripe()
    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-fee-gt-amount',
        amount: 1000,
        currency: 'ils',
        stripe: { connectDestination: 'acct_vendor', applicationFeeAmount: 1001 },
      } as StripeChargeRequest),
    ).rejects.toThrow(/exceeds the charge amount/)
    await expect(
      provider.charge({
        chargeKey: 'ord-fee-gt-amount',
        amount: 1000,
        currency: 'ils',
        stripe: { connectDestination: 'acct_vendor', applicationFeeAmount: 1001 },
      } as StripeChargeRequest),
    ).rejects.toThrow(InvalidAmountError)
    expect(paymentIntents.create).not.toHaveBeenCalled()
  })

  it('passes currency through without an ils default', async () => {
    const { client, paymentIntents } = makeMockStripe()
    paymentIntents.create.mockResolvedValue({
      id: 'pi_usd',
      status: 'requires_payment_method',
      client_secret: 'pi_usd_secret',
      amount_received: 0,
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    await provider.charge({
      chargeKey: 'ord-usd',
      amount: 1000,
      currency: 'usd',
    })

    const [params] = paymentIntents.create.mock.calls[0]!
    expect(params.currency).toBe('usd')
    expect(params.currency).not.toBe('ils')
  })

  it('returns settled when PaymentIntent already succeeded', async () => {
    const { client, paymentIntents } = makeMockStripe()
    paymentIntents.create.mockResolvedValue({
      id: 'pi_settled',
      status: 'succeeded',
      client_secret: 'pi_settled_secret',
      amount_received: 4200,
      currency: 'eur',
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    const result = await provider.charge({
      chargeKey: 'ord-stripe-2',
      amount: 4200,
      currency: 'eur',
    })

    expect(result).toEqual({
      kind: 'settled',
      chargeKey: 'ord-stripe-2',
      providerRef: 'pi_settled',
      amount: 4200,
      currency: 'EUR',
    })
  })

  it('parseWebhook rejects tampered bodies with WebhookVerificationError', async () => {
    const { client, webhooks } = makeMockStripe()
    webhooks.constructEvent.mockImplementation(() => {
      throw new Error('bad signature')
    })

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    await expect(
      provider.parseWebhook('{"tampered":true}', new Headers({ 'stripe-signature': 'sig' })),
    ).rejects.toBeInstanceOf(WebhookVerificationError)
  })

  it('classifies payment_intent.succeeded to settlement with chargeKey and eventId=event.id', async () => {
    const { client } = makeMockStripe()
    const event = {
      id: 'evt_settle_1',
      type: 'payment_intent.succeeded',
      data: {
        object: {
          id: 'pi_abc',
          amount_received: 3100,
          currency: 'usd',
          metadata: { chargeKey: 'ord-roundtrip' },
        },
      },
    } as unknown as Stripe.Event

    await expect(classifyStripeEvent(event, client)).resolves.toEqual({
      eventId: 'evt_settle_1',
      kind: 'settlement',
      chargeKey: 'ord-roundtrip',
      providerRef: 'pi_abc',
      amount: 3100,
      currency: 'USD',
    })
  })

  it('classifies payment_intent.succeeded without chargeKey metadata as other', async () => {
    const { client } = makeMockStripe()
    const event = {
      id: 'evt_settle_no_key',
      type: 'payment_intent.succeeded',
      data: {
        object: {
          id: 'pi_foreign',
          amount_received: 500,
          metadata: {},
        },
      },
    } as unknown as Stripe.Event

    const classified = await classifyStripeEvent(event, client)
    expect(classified).toMatchObject({ eventId: 'evt_settle_no_key', kind: 'other' })
  })

  it('converges sync refund result and refund.updated webhook on the re_ ledger key', async () => {
    const { client, paymentIntents, refunds } = makeMockStripe()
    const chargeKey = 'ord-converge'
    const refundId = 're_converge'
    const paymentIntentId = 'pi_converge'

    paymentIntents.retrieve.mockResolvedValue({
      id: paymentIntentId,
      currency: 'usd',
      metadata: { chargeKey },
    } as never)

    refunds.create.mockResolvedValue({
      id: refundId,
      status: 'succeeded',
      amount: 900,
      currency: 'usd',
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
      resolvePaymentIntentId: vi.fn().mockResolvedValue(paymentIntentId),
    })

    const hostRefundKey = idempotencyKey(['refund', chargeKey, 'host-r1'])
    const syncResult = await provider.refund({
      refundKey: hostRefundKey,
      chargeKey,
      refundId: 'r1',
      amount: 900,
      paymentIntentId,
    } as StripeRefundRequest)

    const expectedLedgerKey = idempotencyKey(['refund', chargeKey, refundId])
    expect(syncResult).toEqual({
      kind: 'refunded',
      refundKey: expectedLedgerKey,
      chargeKey,
      providerRef: refundId,
      amount: 900,
      currency: 'USD',
    })

    const event = {
      id: 'evt_refund_converge',
      type: 'refund.updated',
      data: {
        object: {
          id: refundId,
          status: 'succeeded',
          amount: 900,
          currency: 'usd',
          payment_intent: paymentIntentId,
        },
      },
    } as unknown as Stripe.Event

    const webhookEvent = await classifyStripeEvent(event, client)
    expect(webhookEvent).toEqual({
      eventId: 'evt_refund_converge',
      kind: 'refund',
      refundKey: expectedLedgerKey,
      chargeKey,
      providerChargeId: paymentIntentId,
      providerRef: refundId,
      amount: 900,
      currency: 'USD',
    })
    expect(paymentIntents.retrieve).toHaveBeenCalledWith(paymentIntentId)
  })

  it('produces distinct refundKeys for two partial refunds of one charge', async () => {
    const { client, paymentIntents } = makeMockStripe()
    const chargeKey = 'ord-partial'
    const paymentIntentId = 'pi_partial'

    paymentIntents.retrieve.mockResolvedValue({
      id: paymentIntentId,
      currency: 'usd',
      metadata: { chargeKey },
    } as never)

    const eventA = {
      id: 'evt_refund_a',
      type: 'refund.updated',
      data: {
        object: {
          id: 're_A',
          status: 'succeeded',
          amount: 300,
          currency: 'usd',
          payment_intent: paymentIntentId,
        },
      },
    } as unknown as Stripe.Event

    const eventB = {
      id: 'evt_refund_b',
      type: 'refund.updated',
      data: {
        object: {
          id: 're_B',
          status: 'succeeded',
          amount: 200,
          currency: 'usd',
          payment_intent: paymentIntentId,
        },
      },
    } as unknown as Stripe.Event

    const classifiedA = await classifyStripeEvent(eventA, client)
    const classifiedB = await classifyStripeEvent(eventB, client)

    expect(classifiedA).toMatchObject({
      kind: 'refund',
      refundKey: idempotencyKey(['refund', chargeKey, 're_A']),
    })
    expect(classifiedB).toMatchObject({
      kind: 'refund',
      refundKey: idempotencyKey(['refund', chargeKey, 're_B']),
    })
    expect(
      (classifiedA as { refundKey: string }).refundKey,
    ).not.toBe((classifiedB as { refundKey: string }).refundKey)
  })

  it('classifies a non-succeeded refund event as other', async () => {
    const { client } = makeMockStripe()
    const event = {
      id: 'evt_refund_pending',
      type: 'refund.created',
      data: {
        object: {
          id: 're_pending',
          status: 'pending',
          amount: 500,
          payment_intent: 'pi_x',
        },
      },
    } as unknown as Stripe.Event

    const classified = await classifyStripeEvent(event, client)
    expect(classified).toMatchObject({ eventId: 'evt_refund_pending', kind: 'other' })
  })

  it('lets paymentIntents.retrieve failures throw a non-WebhookVerificationError', async () => {
    const { client, paymentIntents, webhooks } = makeMockStripe()
    paymentIntents.retrieve.mockRejectedValue(new Error('network timeout'))
    webhooks.constructEvent.mockReturnValue({
      id: 'evt_refund_retrieve_fail',
      type: 'refund.updated',
      data: {
        object: {
          id: 're_fail',
          status: 'succeeded',
          amount: 100,
          payment_intent: 'pi_fail',
        },
      },
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    await expect(
      provider.parseWebhook('{}', new Headers({ 'stripe-signature': 'sig' })),
    ).rejects.toSatisfy(
      (err: unknown) => err instanceof Error && !(err instanceof WebhookVerificationError),
    )
  })

  it('refund passes reverse_transfer, refund_application_fee, and idempotency key in options', async () => {
    const { client, refunds } = makeMockStripe()
    refunds.create.mockResolvedValue({
      id: 're_test',
      status: 'succeeded',
      amount: 700,
      currency: 'usd',
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
      resolvePaymentIntentId: vi.fn().mockResolvedValue('pi_ord_3'),
    })

    const hostRefundKey = idempotencyKey(['refund', 'ord-3', 'r1'])
    const req: StripeRefundRequest = {
      refundKey: hostRefundKey,
      chargeKey: 'ord-3',
      refundId: 'r1',
      amount: 700,
      paymentIntentId: 'pi_ord_3',
    }

    const result = await provider.refund(req)

    expect(refunds.create).toHaveBeenCalledOnce()
    const [params, options] = refunds.create.mock.calls[0]!
    expect(params).toMatchObject({
      payment_intent: 'pi_ord_3',
      amount: 700,
      reverse_transfer: true,
      refund_application_fee: true,
    })
    expect(params).not.toHaveProperty('idempotencyKey')
    expect(options).toEqual({ idempotencyKey: hostRefundKey })
    expect(result).toEqual({
      kind: 'refunded',
      refundKey: idempotencyKey(['refund', 'ord-3', 're_test']),
      chargeKey: 'ord-3',
      providerRef: 're_test',
      amount: 700,
      currency: 'USD',
    })
  })

  it('refund throws loudly on a failed refund — never reports it refunded', async () => {
    // A refund whose create-time status is 'failed'/'canceled' moved NO money.
    // Reporting kind:'refunded' would drive refundCharge → confirmRefund → a
    // reversing ledger post for a refund that never happened (ledger understates
    // received money). The sync path must mirror the webhook path, which posts
    // only on status === 'succeeded'.
    const { client, refunds } = makeMockStripe()
    refunds.create.mockResolvedValue({
      id: 're_failed',
      status: 'failed',
      amount: 700,
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
      resolvePaymentIntentId: vi.fn().mockResolvedValue('pi_fail'),
    })

    await expect(
      provider.refund({
        refundKey: 'rk',
        chargeKey: 'ord-fail',
        refundId: 'r1',
        amount: 700,
        paymentIntentId: 'pi_fail',
      } as StripeRefundRequest),
    ).rejects.toThrow(/failed/)
    await expect(
      provider.refund({
        refundKey: 'rk',
        chargeKey: 'ord-fail',
        refundId: 'r1',
        amount: 700,
        paymentIntentId: 'pi_fail',
      } as StripeRefundRequest),
    ).rejects.toThrow(RefundFailedError)
  })

  it('refund maps requires_action to pending (the refund.updated webhook confirms it)', async () => {
    const { client, refunds } = makeMockStripe()
    refunds.create.mockResolvedValue({
      id: 're_ra',
      status: 'requires_action',
      amount: 300,
    } as never)

    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
      resolvePaymentIntentId: vi.fn().mockResolvedValue('pi_ra'),
    })

    await expect(
      provider.refund({
        refundKey: 'rk',
        chargeKey: 'ord-ra',
        refundId: 'r1',
        amount: 300,
        paymentIntentId: 'pi_ra',
      } as StripeRefundRequest),
    ).resolves.toEqual({ kind: 'pending' })
  })

  it('SECURITY: refund requires an authority resolver even when caller supplies a paymentIntentId', async () => {
    const { client, refunds } = makeMockStripe()
    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })

    await expect(
      provider.refund({
        refundKey: 'rk',
        chargeKey: 'ord-x',
        refundId: 'r1',
        amount: 100,
        paymentIntentId: 'pi_attacker',
      } as StripeRefundRequest),
    ).rejects.toThrow('resolvePaymentIntentId')
    expect(refunds.create).not.toHaveBeenCalled()
  })

  it('SECURITY: refund rejects a caller PaymentIntent that differs from host authority', async () => {
    const { client, refunds } = makeMockStripe()
    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
      resolvePaymentIntentId: vi.fn().mockResolvedValue('pi_authorized'),
    })

    await expect(
      provider.refund({
        refundKey: 'rk',
        chargeKey: 'ord-bound',
        refundId: 'r1',
        amount: 100,
        paymentIntentId: 'pi_attacker',
      } as StripeRefundRequest),
    ).rejects.toThrow(/does not match/)
    expect(refunds.create).not.toHaveBeenCalled()
  })

  it('exposes provider metadata', () => {
    const { client } = makeMockStripe()
    const provider = stripe({
      secretKey: 'sk_test',
      webhookSecret: 'whsec_test',
      stripe: client,
    })
    expect(provider.provider).toBe('stripe')
    expect(provider.emitsInvoiceOnCharge).toBe(false)
  })
})
