import * as Sentry from '@sentry/react';
import moment from 'moment-timezone';

import {
  addErrorMessage,
  addLoadingMessage,
  addSuccessMessage,
} from 'sentry/actionCreators/indicator';
import {fetchOrganizationDetails} from 'sentry/actionCreators/organization';
import {Client} from 'sentry/api';
import {t} from 'sentry/locale';
import {DataCategory} from 'sentry/types/core';
import type {Organization} from 'sentry/types/organization';
import {browserHistory} from 'sentry/utils/browserHistory';
import normalizeUrl from 'sentry/utils/url/normalizeUrl';

import {DEFAULT_TIER, MONTHLY, SUPPORTED_TIERS} from 'getsentry/constants';
import SubscriptionStore from 'getsentry/stores/subscriptionStore';
import type {
  DataCategories,
  EventBucket,
  OnDemandBudgets,
  Plan,
  PlanTier,
  PreviewData,
  Subscription,
} from 'getsentry/types';
import {getSlot} from 'getsentry/utils/billing';
import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
import trackMarketingEvent from 'getsentry/utils/trackMarketingEvent';
import type {CheckoutAPIData, CheckoutFormData} from 'getsentry/views/amCheckout/types';
import {
  normalizeOnDemandBudget,
  parseOnDemandBudgetsFromSubscription,
  trackOnDemandBudgetAnalytics,
} from 'getsentry/views/onDemandBudgets/utils';
import {bigNumFormatter} from 'getsentry/views/spendAllocations/utils';

const CURRENCY_LOCALE = 'en-US';

/**
 * Includes $, and cents in the price when needed.
 *
 * 100.01 => $100.01
 * 100.00 => $100
 * 100.30 => $100.30
 * -100 => -$100
 */
type DisplayPriceTypes = {
  cents: number;
  formatBigNum?: boolean;
};

// Intent details returned by CustomerSubscriptionEndpoint
// when there is an error and customer card actions are
// required.
export type IntentDetails = {
  paymentIntent: string;
  paymentSecret: string;
};

type APIDataProps = {
  formData: CheckoutFormData;
  isPreview?: boolean;
  onDemandBudget?: OnDemandBudgets;
  paymentIntent?: string;
  previewToken?: PreviewData['previewToken'];
  referrer?: string;
  shouldUpdateOnDemand?: boolean;
};

export function displayPrice({cents, formatBigNum = false}: DisplayPriceTypes): string {
  const dollars = cents / 100;
  const prefix = dollars >= 0 ? '$' : '-$';

  if (formatBigNum) {
    return prefix + bigNumFormatter(Math.abs(dollars));
  }
  const hasCents = dollars % 1 !== 0;
  if (hasCents) {
    return displayPriceWithCents({cents});
  }
  return prefix + Math.abs(dollars).toLocaleString(CURRENCY_LOCALE);
}

/**
 * Always include $ and cents in the price.
 *
 * $100.01 => $100.01
 * $100.00 => $100.00
 * $100.30 => $100.30
 * -100 => -$100.00
 */
export function displayPriceWithCents({
  cents,
  minimumFractionDigits,
  maximumFractionDigits,
}: {
  cents: number;
  maximumFractionDigits?: number;
  minimumFractionDigits?: number;
}): string {
  const dollars = cents / 100;
  const prefix = dollars >= 0 ? '$' : '-$';

  return (
    prefix +
    Math.abs(dollars).toLocaleString(CURRENCY_LOCALE, {
      minimumFractionDigits: minimumFractionDigits ?? 2,
      maximumFractionDigits: maximumFractionDigits ?? 2,
    })
  );
}

type UnitPriceProps = {
  cents: number;
  maxDigits?: number;
  minDigits?: number;
};

/**
 * Includes cents in the price when needed and excludes $ for separate formatting.
 * Note: Use `displayPrice` if prices can be negative (ex: credits)
 *
 * 100.01 => 100.01
 * 100.00 => 100
 * 100.30 => 100.30
 * -100 => -100
 */
export function formatPrice({cents}: {cents: number}): string {
  return displayPrice({cents}).replace('$', '');
}

/**
 * Format per unit price for events,
 * where errors and transactions default to 5 digits
 * and attachments should use 2 digits.
 */
export function displayUnitPrice({
  cents,
  minDigits = 5,
  maxDigits = 7,
}: UnitPriceProps): string {
  const dollars = cents / 100;

  return (
    '$' +
    dollars.toLocaleString(CURRENCY_LOCALE, {
      minimumFractionDigits: minDigits,
      maximumFractionDigits: maxDigits,
    })
  );
}

export function getBucket({
  buckets,
  events,
  price,
  shouldMinimize = false,
}: {
  buckets?: EventBucket[];
  events?: number;
  price?: number;
  shouldMinimize?: boolean; // the slot strategy when `events` does not exist in `buckets`
}): EventBucket {
  if (buckets) {
    const slot = getSlot(events, price, buckets, shouldMinimize);
    if (slot in buckets) {
      return buckets[slot]!;
    }
  }
  throw new Error('Invalid data category for plan');
}

type ReservedTotalProps = {
  plan: Plan;
  reserved: {
    [categoryKey in DataCategory]?: number;
  };
  amount?: number;
  creditCategory?: string;
  discountType?: string;
  maxDiscount?: number;
};

/**
 * Returns the total plan price (including prices for reserved categories) in cents.
 */
export function getReservedPriceCents({
  plan,
  reserved,
  amount,
  discountType,
  maxDiscount,
  creditCategory,
}: ReservedTotalProps): number {
  let reservedCents = plan.basePrice;

  if (amount && discountType && creditCategory) {
    reservedCents = getDiscountedPrice({
      basePrice: reservedCents,
      amount,
      discountType,
      creditCategory,
    });
  }

  Object.entries(reserved).forEach(
    ([category, quantity]) =>
      (reservedCents += getBucket({
        events: quantity,
        buckets: plan.planCategories[category as DataCategories],
      }).price)
  );

  if (amount && maxDiscount) {
    const discount = Math.min(maxDiscount, (reservedCents * amount) / 10000);
    reservedCents -= discount;
  }

  return reservedCents;
}

/**
 * Gets the price in cents per reserved category, and returns the
 * reserved total in dollars.
 */
export function getReservedTotal({
  plan,
  reserved,
  amount,
  discountType,
  maxDiscount,
  creditCategory,
}: ReservedTotalProps): string {
  return formatPrice({
    cents: getReservedPriceCents({
      plan,
      reserved,
      amount,
      discountType,
      maxDiscount,
      creditCategory,
    }),
  });
}

type DiscountedPriceProps = {
  amount: number;
  basePrice: number;
  creditCategory: string;
  discountType: string;
};

/**
 * Gets the price in cents after the discount is applied.
 */
export function getDiscountedPrice({
  basePrice,
  discountType,
  amount,
  creditCategory,
}: DiscountedPriceProps): number {
  let price = basePrice;
  if (discountType === 'percentPoints' && creditCategory === 'subscription') {
    const discount = (basePrice * amount) / 10000;
    price = basePrice - discount;
  } else if (discountType === 'amountCents') {
    price = basePrice - amount;
  }
  return price;
}

/**
 * Returns the short billing interval name.
 */
export function getShortInterval(billingInterval: string): string {
  return billingInterval === MONTHLY ? 'mo' : 'yr';
}

function getAttachmentsWithUnit(gigabytes: number): string {
  return `${gigabytes.toLocaleString()} GB`;
}

/**
 * Used by RangeSlider. As such, a value of zero is not equivalent to unlimited.
 */
export function getEventsWithUnit(
  events: number,
  dataType: string
): string | number | null {
  if (!events) {
    return null;
  }

  if (dataType === DataCategory.ATTACHMENTS) {
    return getAttachmentsWithUnit(events).replace(' ', '');
  }

  if (events >= 1_000_000_000) {
    return `${events / 1_000_000_000}B`;
  }
  if (events >= 1_000_000) {
    return `${events / 1_000_000}M`;
  }
  if (events >= 1_000) {
    return `${events / 1_000}K`;
  }

  return events;
}

function recordAnalytics(
  organization: Organization,
  subscription: Subscription,
  data: CheckoutAPIData,
  isMigratingPartnerAccount: boolean
) {
  trackMarketingEvent('Upgrade', {plan: data.plan});

  const currentData = {
    plan: data.plan,
    errors: data.reservedErrors,
    transactions: data.reservedTransactions,
    attachments: data.reservedAttachments,
    replays: data.reservedReplays,
    monitorSeats: data.reservedMonitorSeats,
    spans: data.reservedSpans,
    profileDuration: data.reservedProfileDuration,
    uptime: data.reservedUptime,
  };

  const previousData = {
    plan: subscription.plan,
    errors: subscription.categories.errors?.reserved || undefined,
    transactions: subscription.categories.transactions?.reserved || undefined,
    attachments: subscription.categories.attachments?.reserved || undefined,
    replays: subscription.categories.replays?.reserved || undefined,
    monitorSeats: subscription.categories.monitorSeats?.reserved || undefined,
    profileDuration: subscription.categories.profileDuration?.reserved || undefined,
    spans: subscription.categories.spans?.reserved || undefined,
    uptime: subscription.categories.uptime?.reserved || undefined,
  };

  trackGetsentryAnalytics('checkout.upgrade', {
    organization,
    subscription,
    previous_plan: previousData.plan,
    previous_errors: previousData.errors,
    previous_transactions: previousData.transactions,
    previous_attachments: previousData.attachments,
    previous_replays: previousData.replays,
    previous_monitorSeats: previousData.monitorSeats,
    previous_profileDuration: previousData.profileDuration,
    previous_spans: previousData.spans,
    previous_uptime: previousData.uptime,
    ...currentData,
  });

  let {onDemandBudget} = data;
  if (onDemandBudget) {
    onDemandBudget = normalizeOnDemandBudget(onDemandBudget);
    const previousOnDemandBudget = parseOnDemandBudgetsFromSubscription(subscription);

    trackOnDemandBudgetAnalytics(
      organization,
      previousOnDemandBudget,
      onDemandBudget,
      'checkout'
    );
  }

  if (
    currentData.transactions &&
    previousData.transactions &&
    currentData.transactions > previousData.transactions
  ) {
    trackGetsentryAnalytics('checkout.transactions_upgrade', {
      organization,
      subscription,
      plan: data.plan,
      previous_transactions: previousData.transactions,
      transactions: currentData.transactions,
    });
  }

  if (isMigratingPartnerAccount) {
    trackGetsentryAnalytics('partner_billing_migration.checkout.completed', {
      subscription,
      organization,
      applyNow: data.applyNow ?? false,
      daysLeft: moment(subscription.contractPeriodEnd).diff(moment(), 'days'),
      partner: subscription.partner?.partnership.id,
    });
  }
}

export function stripeHandleCardAction(
  intentDetails: IntentDetails,
  stripeInstance?: stripe.Stripe,
  onSuccess?: () => void,
  onError?: (errorMessage?: string) => void
) {
  if (!stripeInstance) {
    return;
  }
  // Use stripe client library to handle additional actions.
  // This allows us to complete 3DS and MFA during checkout.
  stripeInstance
    .handleCardAction(intentDetails.paymentSecret)
    .then((result: stripe.PaymentIntentResponse) => {
      if (result.error) {
        let message =
          'Your payment could not be authorized. Please try a different card, or try again later.';
        if (
          ['card_error', 'validation_error'].includes(result.error.type) &&
          result.error.message
        ) {
          message = result.error.message;
        }
        onError?.(message);
        return;
      }
      // With our intent confirmed we can complete checkout.
      onSuccess?.();
    });
}

/** @internal exported for tests only */
export function getCheckoutAPIData({
  formData,
  onDemandBudget,
  previewToken,
  paymentIntent,
  referrer,
  shouldUpdateOnDemand = true,
}: APIDataProps) {
  const formatReservedData = (value: number | null | undefined) => value ?? undefined;

  const reservedData = {
    reservedErrors: formatReservedData(formData.reserved.errors),
    reservedTransactions: formatReservedData(formData.reserved.transactions),
    reservedAttachments: formatReservedData(formData.reserved.attachments),
    reservedReplays: formatReservedData(formData.reserved.replays),
    reservedMonitorSeats: formatReservedData(formData.reserved.monitorSeats),
    reservedProfileDuration: formatReservedData(formData.reserved.profileDuration),
    reservedSpans: formatReservedData(formData.reserved.spans),
    reservedUptime: formatReservedData(formData.reserved.uptime),
  } satisfies Partial<
    // Enforce plural spelling against the enums in DataCategory
    Record<`reserved${Capitalize<DataCategory>}`, number | undefined>
  >;

  const onDemandMaxSpend = shouldUpdateOnDemand
    ? (formData.onDemandMaxSpend ?? 0)
    : undefined;

  let data: CheckoutAPIData = {
    ...reservedData,
    onDemandBudget,
    plan: formData.plan,
    onDemandMaxSpend,
    referrer: referrer || 'billing',
    ...(previewToken && {previewToken}),
    ...(paymentIntent && {paymentIntent}),
  };

  if (formData.applyNow) {
    data = {
      ...data,
      applyNow: true,
    };
  }
  return data;
}

export async function fetchPreviewData(
  organization: Organization,
  api: Client,
  formData: CheckoutFormData,
  onLoading: () => void,
  onSuccess: (previewData: PreviewData) => void,
  onError: (error: any) => void
) {
  onLoading?.();
  const data = getCheckoutAPIData({formData, isPreview: true});
  try {
    const previewData: PreviewData = await api.requestPromise(
      `/customers/${organization.slug}/subscription/preview/`,
      {
        method: 'GET',
        data,
      }
    );
    onSuccess?.(previewData);
  } catch (error) {
    onError?.(error);

    Sentry.withScope(scope => {
      scope.setExtras({data});
      Sentry.captureException(error);
    });
  }
}

export async function submitCheckout(
  organization: Organization,
  subscription: Subscription,
  previewData: PreviewData,
  formData: CheckoutFormData,
  api: Client,
  onFetchPreviewData: () => void,
  onHandleCardAction: (intentDetails: IntentDetails) => void,
  onSubmitting?: (b: boolean) => void,
  intentId?: string,
  referrer = 'billing',
  codecovReferrer = 'checkout',
  shouldUpdateOnDemand = true
) {
  const endpoint = `/customers/${organization.slug}/subscription/`;

  let {onDemandBudget} = formData;
  if (onDemandBudget) {
    onDemandBudget = normalizeOnDemandBudget(onDemandBudget);
  }

  // this is necessary for recording partner billing migration-specific analytics after
  // the migration is successful (during which the flag is flipped off)
  const isMigratingPartnerAccount = organization.features.includes(
    'partner-billing-migration'
  );

  const data = getCheckoutAPIData({
    formData,
    onDemandBudget,
    previewToken: previewData?.previewToken,
    paymentIntent: intentId,
    referrer,
    shouldUpdateOnDemand,
  });

  addLoadingMessage(t('Saving changes\u{2026}'));
  try {
    onSubmitting?.(true);

    await api.requestPromise(endpoint, {
      method: 'PUT',
      data,
    });

    addSuccessMessage(t('Success'));
    recordAnalytics(organization, subscription, data, isMigratingPartnerAccount);

    // refresh org and subscription state
    // useApi cancels open requests on unmount by default, so we create a new Client to ensure this
    // request doesn't get cancelled
    fetchOrganizationDetails(new Client(), organization.slug);
    SubscriptionStore.loadData(organization.slug);
    browserHistory.push(
      normalizeUrl(
        `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=${codecovReferrer}`
      )
    );
  } catch (error) {
    const body = error.responseJSON;

    if (body?.previewToken) {
      onSubmitting?.(false);
      addErrorMessage(t('Your preview expired, please review changes and submit again'));
      onFetchPreviewData?.();
    } else if (body?.paymentIntent && body?.paymentSecret && body?.detail) {
      // When an error response contains payment intent information
      // we can retry the payment using the client-side confirmation flow
      // in stripe.
      // We don't re-enable the button here as we don't want users clicking it
      // while there are UI transitions happening.
      addErrorMessage(body.detail);
      const intent: IntentDetails = {
        paymentIntent: body.paymentIntent,
        paymentSecret: body.paymentSecret,
      };
      onHandleCardAction?.(intent);
    } else {
      const msg =
        body?.detail || t('An unknown error occurred while saving your subscription');
      addErrorMessage(msg);
      onSubmitting?.(false);

      // Don't capture 402 errors as that status code is used for
      // customer credit card failures.
      if (error.status !== 402) {
        Sentry.withScope(scope => {
          scope.setExtras({data});
          Sentry.captureException(error);
        });
      }
    }
  }
}

export function getToggleTier(checkoutTier: PlanTier | undefined) {
  // cannot toggle from or to AM3
  if (checkoutTier === DEFAULT_TIER || !checkoutTier || SUPPORTED_TIERS.length === 0) {
    return null;
  }

  if (SUPPORTED_TIERS.length === 1) {
    return SUPPORTED_TIERS[0];
  }

  const tierIndex = SUPPORTED_TIERS.indexOf(checkoutTier);

  // can toggle between AM1 and AM2 for AM1 customers
  if (tierIndex === SUPPORTED_TIERS.length - 1) {
    return SUPPORTED_TIERS[tierIndex - 1];
  }

  return SUPPORTED_TIERS[tierIndex + 1];
}
