import { describe, expect, it, vi } from 'vitest'
import { InvalidAmountError, WebhookVerificationError } from './errors.js'
import { idempotencyKey } from './index.js'
import {
  isSumitRefundPartialFailureError,
  majorDecimalToMinorUnits,
  sumit,
  SumitRefundPartialFailureError,
  type SumitChargeExtras,
  type SumitChargeRequest,
  type SumitRefundRequest,
} from './sumit.js'

describe('sumit adapter', () => {
  it('charge posts multivendor body with ExternalIdentifier=chargeKey and invoice flags → settled', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    fetchImpl.mockResolvedValue(
      new Response(
        JSON.stringify({
          Status: 0,
          Data: {
            Vendors: [
              {
                CompanyID: 11,
                Payment: { PaymentID: 'pay_vendor', Amount: 80 },
                DocumentID: 1,
                DocumentDownloadURL: 'https://sumit.example/v.pdf',
              },
              {
                CompanyID: 99,
                Payment: { PaymentID: 'pay_platform', Amount: 20 },
                DocumentID: 2,
                DocumentDownloadURL: 'https://sumit.example/p.pdf',
              },
            ],
          },
        }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ),
    )

    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    const req: SumitChargeRequest = {
      chargeKey: 'ord-sumit-1',
      amount: 10000,
      currency: 'ils',
      sumit: {
        singleUseToken: 'tok_abc',
        customerName: 'Ada',
        customerEmail: 'ada@example.com',
        platformAmountMinor: 2000,
        vendorItems: [
          {
            companyId: 11,
            apiKey: 'vendor-key',
            itemName: 'Ticket',
            amountMinor: 8000,
          },
        ],
      },
    }
    const result = await provider.charge(req)

    expect(fetchImpl).toHaveBeenCalledOnce()
    const [url, init] = fetchImpl.mock.calls[0]!
    expect(url).toContain('/billing/payments/multivendorcharge/')
    const body = JSON.parse(String(init?.body))
    expect(body.ExternalIdentifier).toBe('ord-sumit-1')
    expect(body.SendDocumentByEmail).toBe(true)
    expect(body.DocumentType).toBe(1)
    expect(body.VATIncluded).toBe(true)
    expect(body.SingleUseToken).toBe('tok_abc')
    expect(body.Items).toHaveLength(2)

    expect(result).toEqual({
      kind: 'settled',
      chargeKey: 'ord-sumit-1',
      providerRef: 'pay_platform',
      documentUrls: ['https://sumit.example/v.pdf', 'https://sumit.example/p.pdf'],
      amount: 10000,
      currency: 'ILS',
    })
  })

  it('converts decimal major-unit API amounts to exact integer minor-units without float drift', () => {
    expect(majorDecimalToMinorUnits('19.99')).toBe(1999)
    expect(majorDecimalToMinorUnits(19.99)).toBe(1999)
    expect(majorDecimalToMinorUnits('0.05')).toBe(5)
    expect(majorDecimalToMinorUnits('-2.50')).toBe(-250)
  })

  it('carries a fraction that rounds to 100 into the next whole unit (no digit blowup)', () => {
    // 19.996 → frac rounds to 100. Without the carry the formatter emitted
    // "19.100", which re-parses as 19.10 → 1910 minor units instead of 2000 —
    // a silent ~4.5% money corruption. The carry makes it 20.00 → 2000.
    expect(majorDecimalToMinorUnits(19.996)).toBe(2000)
    expect(majorDecimalToMinorUnits(-19.996)).toBe(-2000)
  })

  it('rejects a major-amount STRING with non-zero sub-minor precision (never silently truncates)', () => {
    expect(() => majorDecimalToMinorUnits('19.996')).toThrow(/sub-minor/)
    expect(() => majorDecimalToMinorUnits('19.996')).toThrow(InvalidAmountError)
    // trailing zeros beyond 2 decimals are exact — still accepted
    expect(majorDecimalToMinorUnits('19.100')).toBe(1910)
  })

  it('normalizes a decimal charge response to integer minor-units in the settled result', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    fetchImpl.mockResolvedValue(
      new Response(
        JSON.stringify({
          Status: 0,
          Data: {
            Vendors: [
              {
                CompanyID: 11,
                Payment: { PaymentID: 'pay_v', Amount: 0 },
                DocumentID: 1,
                DocumentDownloadURL: 'https://sumit.example/v.pdf',
              },
              {
                CompanyID: 99,
                Payment: { PaymentID: 'pay_p', Amount: 19.99 },
                DocumentID: 2,
                DocumentDownloadURL: 'https://sumit.example/p.pdf',
              },
            ],
          },
        }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ),
    )

    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    const req: SumitChargeRequest = {
      chargeKey: 'ord-decimal',
      amount: 1999,
      currency: 'ils',
      sumit: {
        singleUseToken: 'tok',
        platformAmountMinor: 1999,
        vendorItems: [{ companyId: 11, apiKey: 'k', itemName: 'X', amountMinor: 0 }],
      },
    }
    const result = await provider.charge(req)

    expect(result.kind).toBe('settled')
    if (result.kind === 'settled') {
      expect(result.amount).toBe(1999)
    }
  })

  it('parseWebhook rejects wrong x-webhook-secret with timing-safe compare', async () => {
    const provider = sumit({
      platformCompanyId: 1,
      platformApiKey: 'k',
      webhookSecret: 'correct-secret',
    })

    await expect(
      provider.parseWebhook(
        JSON.stringify({ eventId: 'evt_1', type: 'subscription.cancelled' }),
        new Headers({ 'x-webhook-secret': 'wrong-secret' }),
      ),
    ).rejects.toBeInstanceOf(WebhookVerificationError)
  })

  it('charge throws on non-ILS currency without firing network', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-usd',
        amount: 1000,
        currency: 'usd',
        sumit: { singleUseToken: 'tok' },
      } as SumitChargeRequest),
    ).rejects.toThrow('Sumit settles ILS only, got usd')
    expect(fetchImpl).not.toHaveBeenCalled()
  })

  it('parseWebhook returns kind other carrying eventId from payload', async () => {
    const provider = sumit({
      platformCompanyId: 1,
      platformApiKey: 'k',
      webhookSecret: 'correct-secret',
    })

    const raw = JSON.stringify({
      eventId: 'sumit_evt_42',
      type: 'subscription.cancelled',
      subscriptionId: 'sub_1',
    })

    const event = await provider.parseWebhook(
      raw,
      new Headers({ 'x-webhook-secret': 'correct-secret' }),
    )

    expect(event).toEqual({
      eventId: 'sumit_evt_42',
      kind: 'other',
      raw: JSON.parse(raw),
    })
  })

  it('refund emits negative-amount legs with stable idempotency keys', async () => {
    const bodies: unknown[] = []
    const fetchImpl = vi.fn<typeof fetch>(async (_url, init) => {
      bodies.push(JSON.parse(String(init?.body)))
      const path = String(_url)
      if (path.includes('/charge/')) {
        return new Response(
          JSON.stringify({ Status: 0, Data: { PaymentID: `pay_${bodies.length}` } }),
          { status: 200, headers: { 'Content-Type': 'application/json' } },
        )
      }
      return new Response(JSON.stringify({ Status: 0, Data: {} }), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      })
    })

    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    const refundKey = idempotencyKey(['refund', 'ord-r', 'r1'])
    const req: SumitRefundRequest = {
      refundKey,
      chargeKey: 'ord-r',
      refundId: 'r1',
      amount: 1500,
      sumit: {
        vendorItems: [{ companyId: 11, apiKey: 'vk', amountMinor: 1000, documentId: 5 }],
        platformDocumentId: 9,
        platformAmountMinor: 500,
      },
    }

    const result = await provider.refund(req)

    const chargeBodies = bodies.filter(
      (b) => (b as { Item?: { Name?: string } }).Item?.Name === 'Refund',
    )
    expect(chargeBodies).toHaveLength(2)
    expect(chargeBodies[0]).toMatchObject({
      ExternalIdentifier: 'refund:vendor:ord-r:r1:11:5',
      UnitPrice: -10,
    })
    expect(chargeBodies[1]).toMatchObject({
      ExternalIdentifier: 'refund:platform:ord-r:r1',
      UnitPrice: -5,
    })

    expect(result).toEqual({
      kind: 'refunded',
      refundKey,
      chargeKey: 'ord-r',
      providerRef: 'pay_1',
      amount: 1500,
      currency: 'ILS',
    })
  })

  it('charge throws loudly when the typed sumit extras are absent', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    await expect(provider.charge({ chargeKey: 'ord-x', amount: 1000, currency: 'ils' })).rejects.toThrow(
      'Sumit charge requires sumit extras',
    )
    expect(fetchImpl).not.toHaveBeenCalled()
  })

  it('charge throws loudly when the single-use token is missing from extras', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-x',
        amount: 1000,
        currency: 'ils',
        sumit: { vendorItems: [] } as unknown as SumitChargeExtras,
      } as SumitChargeRequest),
    ).rejects.toThrow('Sumit charge requires sumit.singleUseToken')
    expect(fetchImpl).not.toHaveBeenCalled()
  })

  it('refund throws loudly when the typed sumit extras are absent', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    await expect(
      provider.refund({ refundKey: 'rk', chargeKey: 'ord-x', refundId: 'r1', amount: 500 }),
    ).rejects.toThrow('Sumit refund requires sumit extras')
    expect(fetchImpl).not.toHaveBeenCalled()
  })

  it('exposes provider metadata', () => {
    const provider = sumit({
      platformCompanyId: 1,
      platformApiKey: 'k',
      webhookSecret: 'wh',
    })
    expect(provider.provider).toBe('sumit')
    expect(provider.emitsInvoiceOnCharge).toBe(true)
  })

  it('rejects construction with an empty/undefined webhookSecret (fail-closed)', () => {
    expect(() =>
      sumit({ platformCompanyId: 1, platformApiKey: 'k', webhookSecret: '' }),
    ).toThrow('non-empty webhookSecret')
    expect(() =>
      sumit({ platformCompanyId: 1, platformApiKey: 'k' } as unknown as {
        platformCompanyId: number
        platformApiKey: string
        webhookSecret: string
      }),
    ).toThrow('non-empty webhookSecret')
  })

  it('charge rejects a negative platform leg (vendor split over-allocates the total)', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-neg',
        amount: 5000,
        currency: 'ils',
        sumit: {
          singleUseToken: 'tok',
          // vendor leg (6000) exceeds the charge total (5000) → platform leg = -1000
          vendorItems: [{ companyId: 11, apiKey: 'k', itemName: 'X', amountMinor: 6000 }],
        },
      } as SumitChargeRequest),
    ).rejects.toThrow('platform leg must be a non-negative integer')
    expect(fetchImpl).not.toHaveBeenCalled()
  })

  it('charge rejects a negative vendor leg (would credit out of the vendor account)', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-neg-vendor',
        amount: 5000,
        currency: 'ils',
        sumit: {
          singleUseToken: 'tok',
          // default platform leg absorbs the negative (5050) so Σ still matches —
          // only a per-leg guard stops the negative vendor UnitPrice (live credit)
          vendorItems: [{ companyId: 11, apiKey: 'k', itemName: 'X', amountMinor: -50 }],
        },
      } as SumitChargeRequest),
    ).rejects.toThrow('vendor leg must be a non-negative integer')
    await expect(
      provider.charge({
        chargeKey: 'ord-neg-vendor',
        amount: 5000,
        currency: 'ils',
        sumit: {
          singleUseToken: 'tok',
          vendorItems: [{ companyId: 11, apiKey: 'k', itemName: 'X', amountMinor: -50 }],
        },
      } as SumitChargeRequest),
    ).rejects.toThrow(InvalidAmountError)
    expect(fetchImpl).not.toHaveBeenCalled()
  })

  it('charge rejects legs that do not sum to the charge amount (order↔provider drift)', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    await expect(
      provider.charge({
        chargeKey: 'ord-mismatch',
        amount: 10000,
        currency: 'ils',
        sumit: {
          singleUseToken: 'tok',
          platformAmountMinor: 1000,
          vendorItems: [{ companyId: 11, apiKey: 'k', itemName: 'X', amountMinor: 8000 }],
        },
      } as SumitChargeRequest),
    ).rejects.toThrow('must sum to the charge amount')
    await expect(
      provider.charge({
        chargeKey: 'ord-mismatch',
        amount: 10000,
        currency: 'ils',
        sumit: {
          singleUseToken: 'tok',
          platformAmountMinor: 1000,
          vendorItems: [{ companyId: 11, apiKey: 'k', itemName: 'X', amountMinor: 8000 }],
        },
      } as SumitChargeRequest),
    ).rejects.toThrow(InvalidAmountError)
    expect(fetchImpl).not.toHaveBeenCalled()
  })

  it('charge selects the platform leg by CompanyID identity, not array position', async () => {
    const fetchImpl = vi.fn<typeof fetch>()
    // Platform (CompanyID 99) returned FIRST, vendor (11) second — order drifted
    // from the request order. Identity selection must still bind providerRef to platform.
    fetchImpl.mockResolvedValue(
      new Response(
        JSON.stringify({
          Status: 0,
          Data: {
            Vendors: [
              {
                CompanyID: 99,
                Payment: { PaymentID: 'pay_platform', Amount: 20 },
                DocumentID: 2,
                DocumentDownloadURL: 'https://sumit.example/p.pdf',
              },
              {
                CompanyID: 11,
                Payment: { PaymentID: 'pay_vendor', Amount: 80 },
                DocumentID: 1,
                DocumentDownloadURL: 'https://sumit.example/v.pdf',
              },
            ],
          },
        }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ),
    )

    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    const result = await provider.charge({
      chargeKey: 'ord-ident',
      amount: 10000,
      currency: 'ils',
      sumit: {
        singleUseToken: 'tok',
        platformAmountMinor: 2000,
        vendorItems: [{ companyId: 11, apiKey: 'vk', itemName: 'T', amountMinor: 8000 }],
      },
    } as SumitChargeRequest)

    expect(result.kind).toBe('settled')
    if (result.kind === 'settled') {
      expect(result.providerRef).toBe('pay_platform')
    }
  })

  it('refund surfaces a structured partial-failure error carrying the completed legs', async () => {
    let call = 0
    const fetchImpl = vi.fn<typeof fetch>(async (url) => {
      const path = String(url)
      if (path.includes('/charge/')) {
        call += 1
        // First (vendor) leg succeeds; second (platform) leg fails at the API.
        if (call === 1) {
          return new Response(
            JSON.stringify({ Status: 0, Data: { PaymentID: 'pay_vendor' } }),
            { status: 200, headers: { 'Content-Type': 'application/json' } },
          )
        }
        return new Response(JSON.stringify({ Status: 5, Message: 'declined' }), {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        })
      }
      return new Response(JSON.stringify({ Status: 0, Data: {} }), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      })
    })

    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    const req: SumitRefundRequest = {
      refundKey: idempotencyKey(['refund', 'ord-pf', 'r1']),
      chargeKey: 'ord-pf',
      refundId: 'r1',
      amount: 1500,
      sumit: {
        vendorItems: [{ companyId: 11, apiKey: 'vk', amountMinor: 1000, documentId: 5 }],
        platformDocumentId: 9,
        platformAmountMinor: 500,
      },
    }

    const err = await provider.refund(req).catch((e) => e)
    expect(isSumitRefundPartialFailureError(err)).toBe(true)
    expect(err).toBeInstanceOf(SumitRefundPartialFailureError)
    expect(err.chargeKey).toBe('ord-pf')
    expect(err.completedLegs).toEqual([
      {
        scope: 'vendor',
        companyId: 11,
        amountMinor: 1000,
        externalId: 'refund:vendor:ord-pf:r1:11:5',
        paymentId: 'pay_vendor',
      },
    ])
  })

  it('gives duplicate-vendor refund legs DISTINCT idempotency keys (resume disambiguation)', async () => {
    // Two refund legs for the SAME vendor (two documents) must not share an
    // ExternalIdentifier/leg key: on a partial failure between them, identical
    // keys make the completedLegs entry ambiguous — the resuming caller cannot
    // tell WHICH of the two legs completed and risks double-refunding one and
    // skipping the other. documentId is the per-leg discriminator.
    const bodies: Array<Record<string, unknown>> = []
    const fetchImpl = vi.fn<typeof fetch>(async (_url, init) => {
      bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>)
      return new Response(
        JSON.stringify({ Status: 0, Data: { PaymentID: `pay_${bodies.length}` } }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      )
    })

    const provider = sumit({
      platformCompanyId: 99,
      platformApiKey: 'plat-key',
      webhookSecret: 'wh',
      fetch: fetchImpl,
    })

    const req: SumitRefundRequest = {
      refundKey: idempotencyKey(['refund', 'ord-dup', 'r1']),
      chargeKey: 'ord-dup',
      refundId: 'r1',
      amount: 700,
      sumit: {
        vendorItems: [
          { companyId: 11, apiKey: 'vk', amountMinor: 400, documentId: 5 },
          { companyId: 11, apiKey: 'vk', amountMinor: 300, documentId: 6 },
        ],
        platformDocumentId: 9,
        platformAmountMinor: 0,
      },
    }

    await provider.refund(req)

    const legIds = bodies
      .filter((b) => (b as { Item?: { Name?: string } }).Item?.Name === 'Refund')
      .map((b) => b.ExternalIdentifier)
    expect(legIds).toHaveLength(2)
    expect(new Set(legIds).size).toBe(2)
  })
})
