text: import clone from 'component-clone';
  import find from 'component-find';
  import intersection from 'intersect';
  import isEmpty from 'lodash.isempty';
  import Promise from 'promise';
  import uniq from 'array-unique';
  import uid from '../../../util/uid';
  import errors from '../../errors';
  import { Pricing } from '../';
  import PricingPromise from '../promise';
  import SubscriptionPricing from '../subscription';
  import EmbeddedSubscriptionPricing from '../subscription/embedded';
  import Calculations from './calculations';
  import Attachment from './attachment';
  
  const debug = require('debug')('recurly:pricing:checkout-pricing');
  
  /**
   * CheckoutPricing
   *
   * Constructs a pricing model for a checkout purchase. Call methods
   * on an instance to add or remove items from the pricing model, and
   * listen for changes to the price.
   *
   * This class allows you to provide basic details about a checkout purchase,
   * such as plan code, item code, and address, and receive detailed pricing
   * information about the overall purchase, including item costs, tax estimates,
   * complex discounting, and currency changes.
   *
   * Example
   *
   * ```
   * const checkoutPricing = recurly.Pricing.Checkout();
   * checkoutPricing.on('change', price => console.log(price));
   * checkoutPricing
   *   .currency('USD')
   *   .subscription(recurly.Pricing.Subscription().plan('my-plan').done())
   *   .adjustment({ itemCode: 'my-item-code', quantity: 2 })
   *   .address({ country: 'US', postalCode: '94110' })
   *   .coupon('my-coupon-code')
   *   .done();
   * ```
   *
   * @class
   */
  
  export default class CheckoutPricing extends Pricing {
    constructor (...args) {
      super(...args);
      this.debug = debug;
      this.recurly.report('pricing:checkout:create');
    }
  
    reset () {
      super.reset();
      this.items.subscriptions = [];
      this.items.adjustments = [];
    }
  
    get Calculations () {
      return Calculations;
    }
  
    get PRICING_METHODS () {
      return super.PRICING_METHODS.concat([
        'address',
        'adjustment',
        'coupon',
        'currency',
        'giftCard',
        'shippingAddress',
        'subscription',
        'tax'
      ]);
    }
  
    get validAdjustments () {
      return this.items.adjustments.filter(adj => adj.currency === this.items.currency);
    }
  
    get validSubscriptions () {
      return this.items.subscriptions.filter(sub => sub.isValid);
    }
  
    // Currency codes common to all subscriptions
    get subscriptionCurrencies () {
      return intersection(this.validSubscriptions.map(s => Object.keys(s.items.plan.price)));
    }
  
    // Plan codes of all subscriptions
    get subscriptionPlanCodes () {
      return uniq(this.validSubscriptions.map(s => s.items.plan.code));
    }
  
    findSubscriptionById (id) {
      const embeddedSubscription = find(this.items.subscriptions, esub => esub.id === id);
      if (embeddedSubscription) return embeddedSubscription.subscription;
    }
  
    /**
     * Attachment factory
     *
     * @param {HTMLElement} container - element on which to attach
     */
    attach (container) {
      if (this.attachment) this.attachment.detach();
      this.attachment = new Attachment(this, container);
      this.attachment.once('ready', () => this.emit('attached'));
      return this.attachment;
    }
  
    /**
     * Updates currency code. Will reject if the subscription plans do not support the given code
     *
     * @param {String} code Currency code
     * @return {PricingPromise}
     * @public
     */
    currency (code) {
      return new PricingPromise((resolve, reject) => {
        if (this.items.currency === code) return resolve(this.items.currency);
        if (!this.subscriptionsAllowCurrency(code)) {
          return this.error(errors('invalid-currency', {
            currency: code,
            allowed: this.subscriptionCurrencies
          }), reject, 'currency');
        }
  
        this.items.currency = code;
  
        // Propagate currency changes to each subscription
        Promise.all(this.validSubscriptions.map(sub => {
          debug('checkout currency has changed. Updating subscription', sub);
          return sub.currency(code).reprice();
        }))
          // Eject gift cards on currency change
          .then(() => this.giftCard(null))
          .then(() => this.resolveAndEmit('set.currency', code, resolve));
      }, this);
    }
  
    /**
     * Adds a subscription
     *
     * - Ignores address and tax info from subscriptions
     * - Removes subscription discounts
     *
     * @param {SubscriptionPricing} subscription
     * @return {PricingPromise}
     * @public
     */
    subscription (subscription) {
      return new PricingPromise((resolve, reject) => {
        if (!(subscription instanceof SubscriptionPricing)) {
          return this.error(errors('invalid-option', {
            name: 'subscription',
            expect: 'a {recurly.Pricing.Subscription}'
          }), reject, 'subscription');
        }
  
        // Do nothing if we already have this subscription
        if (this.items.subscriptions.some(esub => esub.subscription === subscription)) {
          return resolve(subscription);
        }
  
        const plan = subscription.items.plan;
  
        // If the subscription currencies do not match the checkout currency, attempt to resolve it.
        // If it cannot be resolved, reject the subscription
        if (this.items.currency && plan) {
          const planCurrencies = Object.keys(plan.price);
          if (!~planCurrencies.indexOf(this.currencyCode)) {
            try {
              this.resolveCurrency(planCurrencies);
            } catch (e) {
              return this.error(errors('invalid-subscription-currency', {
                checkoutCurrency: this.currencyCode,
                checkoutSupportedCurrencies: this.subscriptionCurrencies,
                subscriptionPlanCurrencies: planCurrencies
              }), reject, 'currency');
            }
          }
        }
  
        if (!this.items.currency) this.currency(subscription.items.currency);
  
        const emitSubscriptionSetter = name => {
          return item => this.emit(`set.${name}`, { subscription: { id: subscription.id }, [name]: item });
        };
  
        const addSubscription = () => {
          subscription.on('change:external', () => this.reprice());
          subscription.on('set.plan', emitSubscriptionSetter('plan'));
          subscription.on('set.addon', emitSubscriptionSetter('addon'));
          this.items.subscriptions.push(new EmbeddedSubscriptionPricing(subscription, this));
          this.resolveAndEmit('set.subscription', subscription, resolve, { copy: false });
        };
  
        // Removes any subscription coupons and gift cards
        if (subscription.isValid) {
          subscription.coupon(null).giftcard(null).then(addSubscription);
        } else {
          addSubscription();
        }
      }, this);
    }
  
    /**
     * @typedef {Object} CheckoutPricing~Adjustment
     *
     * @property {String}  [itemCode] item code reference. If provided,
     *                                the amount and tax properties will be populated
     *                                from the given item. an itemCode may not be used to
     *                                modify an adjustment in-place.
     * @property {Number}  [amount] in unit price (1.0 for USD, etc)
     * @property {Number}  [quantity=1] number of units
     * @property {String}  [id=uid()] unique identifier. Use this value to modify an
     *                                adjustment in-place
     * @property {String}  [currency=this.items.currency] currency code
     * @property {Boolean} [taxExempt=false] whether this adjustment is tax exempt
     * @property {String}  [taxCode] taxation code
     */
  
    /**
     * Adds a one-time charge or credit a.k.a. adjustment
     *
     * @param {CheckoutPricing~Adjustment} adjustment
     * @return {PricingPromise}
     * @public
     */
    adjustment ({ itemCode = null, amount, quantity, id = uid(), currency, taxExempt, taxCode }) {
      const { recurly } = this;
      return new PricingPromise((resolve, reject) => {
        let existingAdjustment = find(this.items.adjustments, a => a.id === id);
        let validateAmount = true;
  
        if (itemCode || (existingAdjustment && typeof amount === 'undefined')) {
          validateAmount = false;
        }
  
        if (validateAmount) {
          amount = Number(amount);
          if (!isFinite(amount)) {
            let err = { name: 'amount', expect: 'a finite Number' };
            return this.error(errors('invalid-option', err), reject, 'adjustment');
          }
        }
  
        if (existingAdjustment && existingAdjustment.itemCode && !itemCode) {
          itemCode = existingAdjustment.itemCode;
        }
  
        new Promise((resolve, reject) => {
          if (!itemCode) return resolve();
          return recurly.item({ itemCode }).then(item => {
            const expectedCurrency = currency
                                  || (existingAdjustment && existingAdjustment.currency)
                                  || this.items.currency;
            if (typeof amount === 'undefined') {
              const itemPrice = find(item.currencies, c => c.currency_code === expectedCurrency);
              if (itemPrice) {
                amount = itemPrice.unit_amount;
              } else {
                return reject(errors('invalid-item-currency', { itemCode, currency: expectedCurrency }));
              }
            }
            taxCode = item.tax_code;
            taxExempt = item.tax_exempt;
            resolve();
          }, reject);
        })
          .then(() => {
            // New adjustment defaults
            if (!existingAdjustment) {
              currency = currency || this.items.currency;
              quantity = coerceAdjustmentQuantity(quantity);
              taxExempt = !!taxExempt;
            }
  
            return { amount, quantity, id, currency, taxExempt, taxCode, itemCode };
          })
          .then(adjustment => {
            if (existingAdjustment) {
              if (typeof amount === 'undefined') delete adjustment.amount;
              if (typeof currency === 'undefined') delete adjustment.currency;
              if (typeof quantity === 'undefined') {
                delete adjustment.quantity;
              } else {
                quantity = coerceAdjustmentQuantity(quantity);
              }
  
              if (existingAdjustment.itemCode || typeof taxCode === 'undefined') {
                delete adjustment.taxCode;
              }
  
              if (existingAdjustment.itemCode || typeof taxExempt === 'undefined') {
                delete  adjustment.taxExempt;
              }
  
              adjustment = Object.assign(existingAdjustment, adjustment);
            } else {
              this.items.adjustments.push(adjustment);
            }
  
            this.resolveAndEmit('set.adjustment', adjustment, resolve);
          })
          .catch(err => {
            this.error(err, reject, 'adjustment');
          });
      }, this);
    }
  
    /**
     * Updates coupon. Manages a single coupon on the item set, unsetting any exisitng
     * coupon
     *
     * `set.coupon` is emitted when a valid coupon is set
     * `unset.coupon` is emitted when an existing valid coupon is removed
     * `error.coupon` is emitted when the requested coupon is invalid
     *
     * @param {String} couponCode
     * @return {PricingPromise}
     * @public
     */
    coupon (couponCode) {
      if (~this.couponCodes.indexOf(couponCode)) {
        return new PricingPromise(resolve => resolve(clone(this.items.coupon)), this);
      }
  
      return new PricingPromise(resolve => resolve(), this)
        .then(() => {
          if (!this.items.coupon) return;
          const priorCoupon = clone(this.items.coupon);
          debug('unset.coupon');
          return this.remove({ coupon: priorCoupon.code })
            .then(() => {
              this.emit('unset.coupon', priorCoupon);
            });
        })
        .then(() => new PricingPromise((resolve, reject) => {
          // A blank coupon is handled as ok
          if (!couponCode) return resolve();
  
          this.recurly.coupon({ plans: this.subscriptionPlanCodes, coupon: couponCode }, (err, coupon) => {
            if (err) {
              return this.error(err, reject, 'coupon');
            } else {
              this.items.coupon = coupon;
              this.resolveAndEmit('set.coupon', coupon, resolve);
            }
          });
        }));
    }
  
    /**
     * Updates address
     *
     * @param {Object} address
     * @param {String} address.country
     * @param {String} address.postalCode
     * @param {String} address.vatNumber
     * @return {PricingPromise}
     * @public
     */
    address (address) {
      return new PricingPromise(this.itemUpdateFactory('address', address), this);
    }
  
    /**
     * Updates shipping address
     *
     * @param {Object} address
     * @param {String} address.country
     * @param {String} address.postalCode
     * @param {String} address.vatNumber
     * @return {PricingPromise}
     * @public
     */
    shippingAddress (address) {
      return new PricingPromise(this.itemUpdateFactory('shippingAddress', address), this);
    }
  
    /**
     * Updates tax info
     *
     * @param {Object} tax
     * @param {String} tax.vatNumber
     * @param {Object} [tax.amounts] specific tax amounts. Overrides automated tax rate calculations
     * @param {Object} [tax.amounts.now] specific tax to apply on the immediate charge
     * @param {Object} [tax.amounts.next=0] specific tax to apply on the next billing cycle
     * @public
     */
    tax (tax) {
      this.guardTaxSignature(tax);
      return new PricingPromise(this.itemUpdateFactory('tax', tax), this);
    }
  
    /**
     * Updates gift card
     *
     * @param {String} code Gift card code
     * @return {PricingPromise} [description]
     * @public
     */
    giftCard (code) {
      const unset = () => {
        if (!this.items.giftCard) return;
        debug('unset.giftCard');
        delete this.items.giftCard;
        this.emit('unset.giftCard');
      };
  
      return new PricingPromise((resolve, reject) => {
        if (!code) {
          unset();
          return resolve();
        }
  
        this.recurly.giftCard({ code }, (err, giftCard) => {
          if (err) {
            return this.error(err, reject, 'giftCard');
          } else {
            unset();
            if (this.items.currency !== giftCard.currency){
              return this.error(errors('gift-card-currency-mismatch'), reject, 'giftCard');
            } else {
              this.items.giftCard = giftCard;
              this.resolveAndEmit('set.giftCard', giftCard, resolve);
            }
          }
        });
      }, this);
    }
  
    /**
     * Adds handlers to remove subscriptions, adjustments, and coupons
     *
     * @param {Object} options
     * @param {Object} options[item] Must contain an id property. The key name
     *                               corresponds to the item type to be removed (ex: 'subscription')
     * @public
     */
    remove (options) {
      return new PricingPromise((resolve, reject) => {
        // Expects SubscriptionPricing or EmbeddedSubscriptionPricing
        if (options.subscription) {
          this.removeFromSet('subscriptions', options.subscription);
        } else if (options.adjustment) {
          this.removeFromSet('adjustments', options.adjustment);
        } else {
          if (options.coupon) {
            // Remove the coupon from embedded subscriptions
            Promise.all(this.validSubscriptions.map(sub => sub.coupon().reprice({ internal: true })))
              .then(() => super.remove(options).then(resolve, reject));
          } else {
            super.remove(options).then(resolve, reject);
          }
        }
      });
    }
  
    /**
     * Removes a set item (subscription or adjustment)
     *
     * @param  {String} name         item name (ex: 'subscription', 'adjustment')
     * @param  {Object} itemToRemove item to remove. Must have an id property
     * @private
     */
    removeFromSet (name, itemToRemove) {
      this.items[name] = this.items[name].filter(item => item.id !== itemToRemove.id);
    }
  
    /**
     * Checks whether the current subscriptions will allow the given currency code
     *
     * @param  {String} code Currency code
     * @return {Boolean}
     * @private
     */
    subscriptionsAllowCurrency (code) {
      if (isEmpty(this.items.subscriptions)) return true;
      return !!~this.subscriptionCurrencies.indexOf(code);
    }
  
    /**
     * Attempts to set a common currency from amongst the
     * current subscriptions and the provided codes.
     *
     * @param {Array} code Array of {String} currency codes
     * @param {Object} [options]
     * @param {Boolean} [options.commit=true] whether to commit a currency change
     * @throws {Error} If a common currency cannot be found
     * @private
     */
    resolveCurrency (codes, { commit = true } = {}) {
      let commonCurrencies;
  
      // If we have subscriptions already, check that we have common currencies. Otherwise, we may proceed
      if (isEmpty(this.validSubscriptions)) {
        commonCurrencies = codes;
      } else {
        commonCurrencies = intersection(this.subscriptionCurrencies, codes);
      }
  
      if (isEmpty(commonCurrencies)) throw new Error('unresolvable');
      if (commit) return this.currency(commonCurrencies[0]);
    }
  
    /**
     * Binds important events to the EventDispatcher
     *
     * @protected
     */
    bindReporting () {
      super.bindReporting('pricing:checkout');
      const report = (...args) => this.recurly.report(...args);
      this.on('attached', () => report('pricing:checkout:attached'));
      this.on('set.subscription', () => report('pricing:checkout:set:subscription'));
      this.on('change:external', price => report('pricing:checkout:change', {
        price: {
          couponCodes: this.couponCodes,
          currency: this.currencyCode,
          discount: price.now.discount,
          giftCard: price.now.giftCard,
          items: price.now.items.map(item => ({
            type: item.type,
            amount: item.amount,
            quantity: item.quantity,
          })),
          taxes: price.now.taxes,
          total: price.now.total,
          totalNext: price.next.total
        }
      }));
    }
  }
  
  function coerceAdjustmentQuantity (quantity) {
    quantity = parseInt(quantity, 10);
    if (isNaN(quantity)) quantity = 1;
    return quantity;
  }
  