import { vi, expect } from 'vitest'
import sinon from 'sinon'
import recurly from 'recurly'
import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs'

const {
  PaymentProviderSubscription,
  PaymentProviderSubscriptionChange,
  PaymentProviderSubscriptionChangeRequest,
  PaymentProviderSubscriptionUpdateRequest,
  PaymentProviderSubscriptionAddOn,
  PaymentProviderSubscriptionAddOnUpdate,
  PaymentProviderAccount,
  PaymentProviderCoupon,
} = PaymentProviderEntities
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'

describe('RecurlyClient', function () {
  beforeEach(async function (ctx) {
    ctx.settings = {
      apis: {
        recurly: {
          apiKey: 'nonsense',
          privateKey: 'private_nonsense',
          subdomain: 'test',
        },
      },
      plans: [],
      features: [],
    }

    ctx.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
    ctx.subscriptionChange = { id: 'subscription-change-123' }
    ctx.recurlyAccount = new recurly.Account()
    Object.assign(ctx.recurlyAccount, {
      code: ctx.user._id,
      email: ctx.user.email,
    })

    ctx.subscriptionAddOn = {
      code: 'addon-code',
      name: 'My Add-On',
      quantity: 1,
      unitPrice: 2,
      preTaxTotal: 2,
    }

    ctx.subscription = new PaymentProviderSubscription({
      id: 'subscription-id',
      userId: 'user-id',
      currency: 'EUR',
      planCode: 'plan-code',
      planName: 'plan-name',
      planPrice: 13,
      addOns: [ctx.subscriptionAddOn],
      subtotal: 15,
      taxRate: 0.1,
      taxAmount: 1.5,
      total: 16.5,
      periodStart: new Date(),
      periodEnd: new Date(),
      collectionMethod: 'automatic',
      netTerms: 0,
      poNumber: '',
      termsAndConditions: '',
    })

    ctx.recurlySubscription = {
      uuid: ctx.subscription.id,
      account: {
        code: ctx.subscription.userId,
      },
      plan: {
        code: ctx.subscription.planCode,
        name: ctx.subscription.planName,
      },
      addOns: [
        {
          addOn: {
            code: ctx.subscriptionAddOn.code,
            name: ctx.subscriptionAddOn.name,
          },
          quantity: ctx.subscriptionAddOn.quantity,
          unitAmount: ctx.subscriptionAddOn.unitPrice,
        },
      ],
      unitAmount: ctx.subscription.planPrice,
      subtotal: ctx.subscription.subtotal,
      taxInfo: { rate: ctx.subscription.taxRate },
      tax: ctx.subscription.taxAmount,
      total: ctx.subscription.total,
      currency: ctx.subscription.currency,
      currentPeriodStartedAt: ctx.subscription.periodStart,
      currentPeriodEndsAt: ctx.subscription.periodEnd,
      collectionMethod: ctx.subscription.collectionMethod,
      netTerms: ctx.subscription.netTerms,
      poNumber: ctx.subscription.poNumber,
      termsAndConditions: ctx.subscription.termsAndConditions,
    }

    ctx.recurlySubscriptionChange = new recurly.SubscriptionChange()
    Object.assign(ctx.recurlySubscriptionChange, ctx.subscriptionChange)

    ctx.UserGetter = {
      promises: {
        getUser: sinon.stub().callsFake(userId => {
          if (userId === ctx.user._id) {
            return ctx.user
          }
        }),
      },
    }

    let client
    ctx.client = client = {
      getAccount: sinon.stub(),
      getBillingInfo: sinon.stub(),
      listAccountSubscriptions: sinon.stub(),
      listActiveCouponRedemptions: sinon.stub(),
      previewSubscriptionChange: sinon.stub(),
      listSubscriptionInvoices: sinon.stub(),
    }
    ctx.recurly = {
      errors: recurly.errors,
      Client: function () {
        return client
      },
    }
    ctx.Errors = {
      MissingBillingInfoError: class extends Error {},
      SubtotalLimitExceededError: class extends Error {},
    }

    vi.doMock('@overleaf/settings', () => ({
      default: ctx.settings,
    }))

    vi.doMock('recurly', () => ({
      default: ctx.recurly,
    }))

    vi.doMock('@overleaf/logger', () => ({
      default: {
        err: sinon.stub(),
        error: sinon.stub(),
        warn: sinon.stub(),
        log: sinon.stub(),
        debug: sinon.stub(),
      },
    }))

    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
      default: ctx.UserGetter,
    }))

    vi.doMock(
      '../../../../app/src/Features/Subscription/Errors',
      () => ctx.Errors
    )

    vi.doMock('../../../../app/src/models/Subscription', () => ({}))

    ctx.RecurlyClient = (await import(MODULE_PATH)).default
  })

  describe('initializing recurly client with undefined API key parameter', function () {
    it('should create a client without error', function () {
      let testClient
      expect(() => {
        testClient = new recurly.Client(undefined)
      }).to.not.throw()
      expect(testClient).to.be.instanceOf(recurly.Client)
    })
  })

  describe('getAccountForUserId', function () {
    it('should return an Account if one exists', async function (ctx) {
      ctx.client.getAccount = sinon.stub().resolves(ctx.recurlyAccount)
      const account = await ctx.RecurlyClient.promises.getAccountForUserId(
        ctx.user._id
      )
      const expectedAccount = new PaymentProviderAccount({
        code: ctx.user._id,
        email: ctx.user.email,
        hasPastDueInvoice: false,
      })
      expect(account).to.deep.equal(expectedAccount)
    })

    it('should return null if no account found', async function (ctx) {
      ctx.client.getAccount = sinon
        .stub()
        .throws(new recurly.errors.NotFoundError())
      const account =
        await ctx.RecurlyClient.promises.getAccountForUserId('nonsense')
      expect(account).to.equal(null)
    })

    it('should re-throw caught errors', async function (ctx) {
      ctx.client.getAccount = sinon.stub().throws()
      await expect(
        ctx.RecurlyClient.promises.getAccountForUserId(ctx.user._id)
      ).to.eventually.be.rejectedWith(Error)
    })
  })

  describe('createAccountForUserId', function () {
    it('should return the Account as created by recurly', async function (ctx) {
      ctx.client.createAccount = sinon.stub().resolves(ctx.recurlyAccount)
      const result = await ctx.RecurlyClient.promises.createAccountForUserId(
        ctx.user._id
      )
      expect(result).to.has.property('code', ctx.user._id)
    })

    it('should throw any API errors', async function (ctx) {
      ctx.client.createAccount = sinon.stub().throws()
      await expect(
        ctx.RecurlyClient.promises.createAccountForUserId(ctx.user._id)
      ).to.eventually.be.rejectedWith(Error)
    })
  })

  describe('getActiveCouponsForUserId', function () {
    it('should return an empty array if no coupons returned', async function (ctx) {
      ctx.client.listActiveCouponRedemptions.returns({
        each: async function* () {},
      })
      const coupons =
        await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
      expect(coupons).to.deep.equal([])
    })

    it('should return a coupons returned by recurly', async function (ctx) {
      const recurlyCoupon = {
        coupon: {
          code: 'coupon-code',
          name: 'Coupon Name',
          hostedPageDescription: 'hosted page description',
          invoiceDescription: 'invoice description',
        },
      }
      ctx.client.listActiveCouponRedemptions.returns({
        each: async function* () {
          yield recurlyCoupon
        },
      })
      const coupons =
        await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
      const expectedCoupons = [
        new PaymentProviderCoupon({
          code: 'coupon-code',
          name: 'Coupon Name',
          description: 'hosted page description',
        }),
      ]
      expect(coupons).to.deep.equal(expectedCoupons)
    })

    it('should not throw for Recurly not found error', async function (ctx) {
      ctx.client.listActiveCouponRedemptions = sinon
        .stub()
        .throws(new recurly.errors.NotFoundError())
      const coupons =
        await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
      expect(coupons).to.deep.equal([])
    })

    it('should throw any other API errors', async function (ctx) {
      ctx.client.listActiveCouponRedemptions = sinon.stub().throws()
      await expect(
        ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
      ).to.eventually.be.rejectedWith(Error)
    })
  })

  describe('getCustomerManagementLink', function () {
    it('should throw if recurly token is not returned', async function (ctx) {
      ctx.client.getAccount.resolves({})
      await expect(
        ctx.RecurlyClient.promises.getCustomerManagementLink(
          '12345',
          'account-management',
          'en-US'
        )
      ).to.be.rejectedWith('recurly account does not have hosted login token')
    })

    it('should generate the correct account management url', async function (ctx) {
      ctx.client.getAccount.resolves({
        hostedLoginToken: '987654321',
      })
      const result = await ctx.RecurlyClient.promises.getCustomerManagementLink(
        '12345',
        'account-management',
        'en-US'
      )

      expect(result).to.equal('https://test.recurly.com/account/987654321')
    })

    it('should generate the correct billing details url', async function (ctx) {
      ctx.client.getAccount.resolves({
        hostedLoginToken: '987654321',
      })
      const result = await ctx.RecurlyClient.promises.getCustomerManagementLink(
        '12345',
        'billing-details',
        'en-US'
      )

      expect(result).to.equal(
        'https://test.recurly.com/account/billing_info/edit?ht=987654321'
      )
    })
  })

  describe('getSubscription', function () {
    it('should return the subscription found by recurly', async function (ctx) {
      ctx.client.getSubscription = sinon
        .stub()
        .withArgs('uuid-subscription-id')
        .resolves(ctx.recurlySubscription)
      const subscription = await ctx.RecurlyClient.promises.getSubscription(
        ctx.subscription.id
      )
      expect(subscription).to.deep.equal(ctx.subscription)
    })

    it('should throw any API errors', async function (ctx) {
      ctx.client.getSubscription = sinon.stub().throws()
      await expect(
        ctx.RecurlyClient.promises.getSubscription(ctx.user._id)
      ).to.eventually.be.rejectedWith(Error)
    })
  })

  describe('getSubscriptionForUser', function () {
    it("should return null if the account doesn't exist", async function (ctx) {
      ctx.client.listAccountSubscriptions.returns({
        // eslint-disable-next-line require-yield
        each: async function* () {
          throw new recurly.errors.NotFoundError('account not found')
        },
      })
      const subscription =
        await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
      expect(subscription).to.be.null
    })

    it("should return null if the account doesn't have subscriptions", async function (ctx) {
      ctx.client.listAccountSubscriptions.returns({
        each: async function* () {},
      })
      const subscription =
        await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
      expect(subscription).to.be.null
    })

    it('should return the subscription if the account has one subscription', async function (ctx) {
      const recurlySubscription = ctx.recurlySubscription
      ctx.client.listAccountSubscriptions.returns({
        each: async function* () {
          yield recurlySubscription
        },
      })
      const subscription =
        await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
      expect(subscription).to.deep.equal(ctx.subscription)
    })

    it('should throw an error if the account has more than one subscription', async function (ctx) {
      const recurlySubscription = ctx.recurlySubscription
      ctx.client.listAccountSubscriptions.returns({
        each: async function* () {
          yield recurlySubscription
          yield { another: 'subscription' }
        },
      })
      await expect(
        ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
      ).to.be.rejected
    })
  })

  describe('applySubscriptionChangeRequest', function () {
    beforeEach(function (ctx) {
      ctx.client.createSubscriptionChange = sinon
        .stub()
        .resolves(ctx.recurlySubscriptionChange)
    })

    it('handles plan changes', async function (ctx) {
      await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
        new PaymentProviderSubscriptionChangeRequest({
          subscription: ctx.subscription,
          timeframe: 'now',
          planCode: 'new-plan',
        })
      )
      expect(ctx.client.createSubscriptionChange).to.be.calledWith(
        'uuid-subscription-id',
        { timeframe: 'now', planCode: 'new-plan' }
      )
    })

    it('handles add-on changes', async function (ctx) {
      await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
        new PaymentProviderSubscriptionChangeRequest({
          subscription: ctx.subscription,
          timeframe: 'now',
          addOnUpdates: [
            new PaymentProviderSubscriptionAddOnUpdate({
              code: 'new-add-on',
              quantity: 2,
              unitPrice: 8.99,
            }),
          ],
        })
      )
      expect(ctx.client.createSubscriptionChange).to.be.calledWith(
        'uuid-subscription-id',
        {
          timeframe: 'now',
          addOns: [{ code: 'new-add-on', quantity: 2, unitAmount: 8.99 }],
        }
      )
    })

    it('should throw any API errors', async function (ctx) {
      ctx.client.createSubscriptionChange = sinon.stub().throws()
      await expect(
        ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
          subscription: ctx.subscription,
        })
      ).to.eventually.be.rejectedWith(Error)
    })

    it('should throw SubtotalLimitExceededError', async function (ctx) {
      class ValidationError extends recurly.errors.ValidationError {
        constructor() {
          super()
          this.params = [{ param: 'subtotal_amount_in_cents' }]
        }
      }
      ctx.client.createSubscriptionChange = sinon
        .stub()
        .throws(new ValidationError())
      await expect(
        ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
          subscription: ctx.subscription,
        })
      ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError)
    })

    it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) {
      ctx.client.createSubscriptionChange = sinon.stub().throws(new Error())
      await expect(
        ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
          subscription: ctx.subscription,
        })
      ).to.be.rejectedWith(Error)
    })

    describe('when subscription has a pending add-on removal', function () {
      beforeEach(function (ctx) {
        // Create subscription with an add-on that has a pending removal scheduled
        ctx.subscriptionWithAddOn = new PaymentProviderSubscription({
          id: 'subscription-id',
          userId: 'user-id',
          currency: 'EUR',
          planCode: 'collaborator',
          planName: 'Collaborator Plan',
          planPrice: 13,
          addOns: [
            new PaymentProviderSubscriptionAddOn({
              code: 'assistant',
              name: 'AI Assistant',
              quantity: 1,
              unitPrice: 5,
            }),
          ],
          subtotal: 18,
          taxRate: 0.1,
          taxAmount: 1.8,
          total: 19.8,
          periodStart: new Date(),
          periodEnd: new Date(),
          collectionMethod: 'automatic',
          service: 'recurly',
          state: 'active',
        })
        ctx.subscriptionWithAddOn.pendingChange =
          new PaymentProviderSubscriptionChange({
            subscription: ctx.subscriptionWithAddOn,
            nextPlanCode: 'collaborator',
            nextPlanName: 'Collaborator Plan',
            nextPlanPrice: 13,
            nextAddOns: [], // add-on is being removed
          })

        ctx.client.getSubscription = sinon.stub().resolves({
          uuid: 'subscription-id',
          account: { code: 'user-id' },
          plan: { code: 'professional', name: 'Professional Plan' },
          addOns: [
            {
              addOn: { code: 'assistant', name: 'AI Assistant' },
              quantity: 1,
              unitAmount: 5,
            },
          ],
          unitAmount: 20,
          subtotal: 25,
          taxInfo: { rate: 0.1 },
          tax: 2.5,
          total: 27.5,
          currency: 'EUR',
          currentPeriodStartedAt: new Date(),
          currentPeriodEndsAt: new Date(),
          collectionMethod: 'automatic',
          netTerms: 0,
          poNumber: '',
          termsAndConditions: '',
        })
      })

      it('re-applies pending add-on removal after immediate plan upgrade', async function (ctx) {
        await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
          new PaymentProviderSubscriptionChangeRequest({
            subscription: ctx.subscriptionWithAddOn,
            timeframe: 'now',
            planCode: 'professional',
          })
        )

        // immediate change
        expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
          'uuid-subscription-id',
          { timeframe: 'now', planCode: 'professional' }
        )

        // re-apply pending change: only add-on removal, NOT the plan downgrade
        // because the immediate change was a plan change, the user's new plan choice supersedes
        expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
          'uuid-subscription-id',
          {
            timeframe: 'term_end',
            addOns: [],
          }
        )

        expect(ctx.client.createSubscriptionChange.callCount).to.equal(2)
      })

      it('does not re-apply pending change if plan and add-ons are the same', async function (ctx) {
        ctx.subscriptionWithAddOn.pendingChange =
          new PaymentProviderSubscriptionChange({
            subscription: ctx.subscriptionWithAddOn,
            nextPlanCode: 'professional', // pending change to professional
            nextPlanName: 'Professional Plan',
            nextPlanPrice: 20,
            nextAddOns: [
              new PaymentProviderSubscriptionAddOn({
                code: 'assistant',
                name: 'AI Assistant',
                quantity: 1,
                unitPrice: 5,
              }),
            ],
          })

        await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
          new PaymentProviderSubscriptionChangeRequest({
            subscription: ctx.subscriptionWithAddOn,
            timeframe: 'now',
            planCode: 'professional',
          })
        )

        expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
      })

      it('does not re-apply anything if no pending change existed', async function (ctx) {
        ctx.subscriptionWithAddOn.pendingChange = null

        await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
          new PaymentProviderSubscriptionChangeRequest({
            subscription: ctx.subscriptionWithAddOn,
            timeframe: 'now',
            planCode: 'professional',
          })
        )

        expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
      })

      it('does not re-apply when timeframe is term_end', async function (ctx) {
        await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
          new PaymentProviderSubscriptionChangeRequest({
            subscription: ctx.subscriptionWithAddOn,
            timeframe: 'term_end',
            planCode: 'professional',
          })
        )

        expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
      })

      it('re-applies pending plan downgrade while preserving newly purchased add-on', async function (ctx) {
        const subscriptionWithPendingDowngrade =
          new PaymentProviderSubscription({
            id: 'subscription-id',
            userId: 'user-id',
            currency: 'EUR',
            planCode: 'professional',
            planName: 'Professional Plan',
            planPrice: 20,
            addOns: [], // NO add-ons originally
            subtotal: 20,
            taxRate: 0.1,
            taxAmount: 2,
            total: 22,
            periodStart: new Date(),
            periodEnd: new Date(),
            collectionMethod: 'automatic',
            service: 'recurly',
            state: 'active',
          })
        subscriptionWithPendingDowngrade.pendingChange =
          new PaymentProviderSubscriptionChange({
            subscription: subscriptionWithPendingDowngrade,
            nextPlanCode: 'collaborator',
            nextPlanName: 'Collaborator Plan',
            nextPlanPrice: 13,
            nextAddOns: [], // NO add-ons in pending change
          })

        // mock the subscription fetch after immediate update - now has add-on
        ctx.client.getSubscription = sinon.stub().resolves({
          uuid: 'subscription-id',
          account: { code: 'user-id' },
          plan: { code: 'professional', name: 'Professional Plan' },
          addOns: [
            {
              addOn: { code: 'assistant', name: 'AI Assistant' },
              quantity: 1,
              unitAmount: 5,
            },
          ],
          unitAmount: 20,
          subtotal: 25,
          taxInfo: { rate: 0.1 },
          tax: 2.5,
          total: 27.5,
          currency: 'EUR',
          currentPeriodStartedAt: new Date(),
          currentPeriodEndsAt: new Date(),
          collectionMethod: 'automatic',
          netTerms: 0,
          poNumber: '',
          termsAndConditions: '',
        })

        await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
          new PaymentProviderSubscriptionChangeRequest({
            subscription: subscriptionWithPendingDowngrade,
            timeframe: 'now',
            addOnUpdates: [
              new PaymentProviderSubscriptionAddOnUpdate({
                code: 'assistant',
                quantity: 1,
                unitPrice: 5,
              }),
            ],
          })
        )

        expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
          'uuid-subscription-id',
          {
            timeframe: 'now',
            addOns: [{ code: 'assistant', quantity: 1, unitAmount: 5 }],
          }
        )

        expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
          'uuid-subscription-id',
          {
            timeframe: 'term_end',
            planCode: 'collaborator',
            addOns: [{ code: 'assistant', quantity: 1, unitAmount: 5 }], // add-on preserved!
          }
        )

        expect(ctx.client.createSubscriptionChange.callCount).to.equal(2)
      })
    })
  })

  describe('updateSubscriptionDetails', function () {
    beforeEach(function (ctx) {
      ctx.client.updateSubscription = sinon
        .stub()
        .resolves({ id: ctx.subscription.id })
    })

    it('handles subscription update', async function (ctx) {
      await ctx.RecurlyClient.promises.updateSubscriptionDetails(
        new PaymentProviderSubscriptionUpdateRequest({
          subscription: ctx.subscription,
          poNumber: '012345',
          termsAndConditions: 'T&C',
        })
      )
      expect(ctx.client.updateSubscription).to.be.calledWith(
        'uuid-subscription-id',
        { poNumber: '012345', termsAndConditions: 'T&C' }
      )
    })

    it('should throw any API errors', async function (ctx) {
      ctx.client.updateSubscription = sinon.stub().throws()
      await expect(
        ctx.RecurlyClient.promises.updateSubscriptionDetails({
          subscription: ctx.subscription,
        })
      ).to.eventually.be.rejectedWith(Error)
    })
  })

  describe('removeSubscriptionChange', function () {
    beforeEach(function (ctx) {
      ctx.client.removeSubscriptionChange = sinon.stub().resolves()
    })

    it('should attempt to remove a pending subscription change', async function (ctx) {
      ctx.RecurlyClient.promises.removeSubscriptionChange(
        ctx.subscription.id,
        {}
      )
      expect(ctx.client.removeSubscriptionChange).to.be.calledWith(
        ctx.subscription.id
      )
    })

    it('should throw any API errors', async function (ctx) {
      ctx.client.removeSubscriptionChange = sinon.stub().throws()
      await expect(
        ctx.RecurlyClient.promises.removeSubscriptionChange(
          ctx.subscription.id,
          {}
        )
      ).to.eventually.be.rejectedWith(Error)
    })

    describe('removeSubscriptionChangeByUuid', function () {
      it('should attempt to remove a pending subscription change', async function (ctx) {
        ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid(
          ctx.subscription.uuid,
          {}
        )
        expect(ctx.client.removeSubscriptionChange).to.be.calledWith(
          'uuid-' + ctx.subscription.uuid
        )
      })

      it('should throw any API errors', async function (ctx) {
        ctx.client.removeSubscriptionChange = sinon.stub().throws()
        await expect(
          ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid(
            ctx.subscription.id,
            {}
          )
        ).to.eventually.be.rejectedWith(Error)
      })
    })
  })

  describe('reactivateSubscriptionByUuid', function () {
    it('should attempt to reactivate the subscription', async function (ctx) {
      ctx.client.reactivateSubscription = sinon
        .stub()
        .resolves(ctx.recurlySubscription)
      const subscription =
        await ctx.RecurlyClient.promises.reactivateSubscriptionByUuid(
          ctx.subscription.uuid
        )
      expect(subscription).to.deep.equal(ctx.recurlySubscription)
      expect(ctx.client.reactivateSubscription).to.be.calledWith(
        'uuid-' + ctx.subscription.uuid
      )
    })
  })

  describe('cancelSubscriptionByUuid', function () {
    it('should attempt to cancel the subscription', async function (ctx) {
      ctx.client.cancelSubscription = sinon
        .stub()
        .resolves(ctx.recurlySubscription)
      const subscription =
        await ctx.RecurlyClient.promises.cancelSubscriptionByUuid(
          ctx.subscription.uuid
        )
      expect(subscription).to.deep.equal(ctx.recurlySubscription)
      expect(ctx.client.cancelSubscription).to.be.calledWith(
        'uuid-' + ctx.subscription.uuid
      )
    })

    it('should terminate subscription when cancellation fails due to being in last cycle of paused term', async function (ctx) {
      const validationError = new recurly.errors.ValidationError()
      validationError.message =
        'Cannot cancel a paused subscription in the last cycle of the term'

      ctx.client.cancelSubscription = sinon.stub().throws(validationError)
      ctx.client.terminateSubscription = sinon
        .stub()
        .resolves(ctx.recurlySubscription)

      const subscription =
        await ctx.RecurlyClient.promises.cancelSubscriptionByUuid(
          ctx.subscription.uuid
        )

      expect(ctx.client.cancelSubscription).to.be.calledWith(
        'uuid-' + ctx.subscription.uuid
      )
      expect(ctx.client.terminateSubscription).to.be.calledWith(
        'uuid-' + ctx.subscription.uuid
      )
      expect(subscription).to.deep.equal(ctx.recurlySubscription)
    })
  })

  describe('pauseSubscriptionByUuid', function () {
    it('should attempt to pause the subscription', async function (ctx) {
      ctx.client.pauseSubscription = sinon
        .stub()
        .resolves(ctx.recurlySubscription)
      const subscription =
        await ctx.RecurlyClient.promises.pauseSubscriptionByUuid(
          ctx.subscription.uuid,
          3
        )
      expect(subscription).to.deep.equal(ctx.recurlySubscription)
      expect(ctx.client.pauseSubscription).to.be.calledWith(
        'uuid-' + ctx.subscription.uuid,
        { remainingPauseCycles: 3 }
      )
    })
  })

  describe('previewSubscriptionChange', function () {
    describe('compute immediate charge', function () {
      it('only has charge invoice', async function (ctx) {
        ctx.client.previewSubscriptionChange.resolves({
          plan: { code: 'test_code', name: 'test name' },
          unitAmount: 0,
          invoiceCollection: {
            chargeInvoice: {
              subtotal: 100,
              tax: 20,
              total: 120,
            },
          },
        })
        const { immediateCharge } =
          await ctx.RecurlyClient.promises.previewSubscriptionChange(
            new PaymentProviderSubscriptionChangeRequest({
              subscription: ctx.subscription,
              timeframe: 'now',
              planCode: 'new-plan',
            })
          )
        expect(immediateCharge.subtotal).to.be.equal(100)
        expect(immediateCharge.tax).to.be.equal(20)
        expect(immediateCharge.total).to.be.equal(120)
      })

      it('credit invoice with imprecise float number', async function (ctx) {
        ctx.client.previewSubscriptionChange.resolves({
          plan: { code: 'test_code', name: 'test name' },
          unitAmount: 0,
          invoiceCollection: {
            chargeInvoice: {
              subtotal: 100.3,
              tax: 20.3,
              total: 120.3,
            },
            creditInvoices: [
              {
                subtotal: -20.1,
                tax: -4.1,
                total: -24.1,
              },
            ],
          },
        })
        const { immediateCharge } =
          await ctx.RecurlyClient.promises.previewSubscriptionChange(
            new PaymentProviderSubscriptionChangeRequest({
              subscription: ctx.subscription,
              timeframe: 'now',
              planCode: 'new-plan',
            })
          )
        expect(immediateCharge.subtotal).to.be.equal(80.2)
        expect(immediateCharge.tax).to.be.equal(16.2)
        expect(immediateCharge.total).to.be.equal(96.2)
      })
    })

    it('should throw SubtotalLimitExceededError', async function (ctx) {
      class ValidationError extends recurly.errors.ValidationError {
        constructor() {
          super()
          this.params = [{ param: 'subtotal_amount_in_cents' }]
        }
      }
      ctx.client.previewSubscriptionChange = sinon
        .stub()
        .throws(new ValidationError())
      await expect(
        ctx.RecurlyClient.promises.previewSubscriptionChange(
          new PaymentProviderSubscriptionChangeRequest({
            subscription: ctx.subscription,
            timeframe: 'now',
            planCode: 'new-plan',
          })
        )
      ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError)
    })

    it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) {
      ctx.client.previewSubscriptionChange = sinon.stub().throws(new Error())
      await expect(
        ctx.RecurlyClient.promises.previewSubscriptionChange(
          new PaymentProviderSubscriptionChangeRequest({
            subscription: ctx.subscription,
            timeframe: 'now',
            planCode: 'new-plan',
          })
        )
      ).to.be.rejectedWith(Error)
    })
  })

  describe('getPaymentMethod', function () {
    it('should throw MissingBillingInfoError', async function (ctx) {
      ctx.client.getBillingInfo = sinon
        .stub()
        .throws(new recurly.errors.NotFoundError())
      await expect(
        ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id)
      ).to.be.rejectedWith(ctx.Errors.MissingBillingInfoError)
    })

    it('should rethrow errors different than MissingBillingInfoError', async function (ctx) {
      ctx.client.getBillingInfo = sinon.stub().throws(new Error())
      await expect(
        ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id)
      ).to.be.rejectedWith(Error)
    })
  })

  describe('terminateSubscriptionByUuid', function () {
    it('should attempt to terminate the subscription', async function (ctx) {
      ctx.client.terminateSubscription = sinon
        .stub()
        .resolves(ctx.recurlySubscription)
      const subscription =
        await ctx.RecurlyClient.promises.terminateSubscriptionByUuid(
          ctx.subscription.uuid
        )
      expect(subscription).to.deep.equal(ctx.recurlySubscription)
      expect(ctx.client.terminateSubscription).to.be.calledWith(
        'uuid-' + ctx.subscription.uuid
      )
    })
  })
})
