//
//  STPCardValidator.swift
//  Stripe
//
//  Created by Jack Flintermann on 7/15/15.
//  Copyright (c) 2015 Stripe, Inc. All rights reserved.
//

import Foundation

/// These fields indicate whether a card field represents a valid value, invalid
/// value, or incomplete value.
@objc public enum STPCardValidationState: Int {
  /// The field's contents are valid. For example, a valid, 16-digit card number.
  /// Note that valid values may not be complete. For example: a US Zip code can
  /// be 5 or 9 digits. A 5-digit code is Valid, but more text could be entered
  /// to transition to incomplete again. American Express CVC codes can be 3 or
  /// 4 digits and both will be treated as Valid.
  case valid
  /// The field's contents are invalid. For example, an expiration date
  /// of "13/42".
  case invalid
  /// The field's contents are not currently valid, but could be by typing
  /// additional characters. For example, a CVC of "1".
  case incomplete
}

/// This class contains static methods to validate card numbers, expiration dates,
/// and CVCs. For a list of test card numbers to use with this code,
/// see https://stripe.com/docs/testing
public class STPCardValidator: NSObject {
  /// Returns a copy of the passed string with all non-numeric characters removed.
  @objc(sanitizedNumericStringForString:)
  public class func sanitizedNumericString(for string: String)
    -> String
  {
    return stringByRemovingCharactersFromSet(string, CharacterSet.stp_invertedAsciiDigit)
  }

  /// Returns a copy of the passed string with all characters removed that do not exist within a postal code.
  @objc(sanitizedPostalStringForString:)
  public class func sanitizedPostalString(for string: String)
    -> String
  {
    let sanitizedString = stringByRemovingCharactersFromSet(
      string, CharacterSet.stp_invertedPostalCode)
    let sanitizedStringWithoutPunctuation = stringByRemovingCharactersFromSet(
      sanitizedString, CharacterSet(charactersIn: " -"))
    if sanitizedStringWithoutPunctuation == "" {
      // No postal codes begin with a space or -. If the user has only entered these characters, it was probably a typo.
      return ""
    }
    return sanitizedString
  }

  /// Whether or not the target string contains only numeric characters.
  @objc(stringIsNumeric:)
  public class func stringIsNumeric(_ string: String) -> Bool {
    return (string as NSString).rangeOfCharacter(from: CharacterSet.stp_invertedAsciiDigit).location
      == NSNotFound
  }

  /// Validates a card number, passed as a string. This will return
  /// STPCardValidationStateInvalid for numbers that are too short or long, contain
  /// invalid characters, do not pass Luhn validation, or (optionally) do not match
  /// a number format issued by a major card brand.
  /// - Parameters:
  ///   - cardNumber: The card number to validate. Ex. @"4242424242424242"
  ///   - validatingCardBrand: Whether or not to enforce that the number appears to
  /// be issued by a major card brand (or could be). For example, no issuing card
  /// network currently issues card numbers beginning with the digit 9; if an
  /// otherwise correct-length and luhn-valid card number beginning with 9
  /// (example: 9999999999999995) were passed to this method, it would return
  /// STPCardValidationStateInvalid if this parameter were YES and
  /// STPCardValidationStateValid if this parameter were NO. If unsure, you should
  /// use YES for this value.
  /// - Returns: STPCardValidationStateValid if the number is valid,
  /// STPCardValidationStateInvalid if the number is invalid, or
  /// STPCardValidationStateIncomplete if the number is a substring of a valid
  /// card (e.g. @"4242").
  @objc(validationStateForNumber:validatingCardBrand:)
  public class func validationState(
    forNumber cardNumber: String?,
    validatingCardBrand: Bool
  ) -> STPCardValidationState {
    guard let cardNumber = cardNumber else {
      return .incomplete
    }
    let sanitizedNumber = self.stringByRemovingSpaces(from: cardNumber)
    if sanitizedNumber.count == 0 {
      return .incomplete
    }
    if !self.stringIsNumeric(sanitizedNumber) {
      return .invalid
    }
    let binRange = STPBINRange.mostSpecificBINRange(forNumber: sanitizedNumber)
    if binRange.brand == .unknown && validatingCardBrand {
      return .invalid
    }
    if sanitizedNumber.count == binRange.length {
      let isValidLuhn = self.stringIsValidLuhn(sanitizedNumber)
      if isValidLuhn {
        if !binRange.isCardMetadata && STPBINRange.hasBINRanges(forPrefix: sanitizedNumber) {
          // log that we didn't get a match in the metadata response so fell back to a hard coded response
          STPAnalyticsClient.sharedClient.logCardMetadataMissingRange(
            with: STPPaymentConfiguration.shared)
        }
        return .valid
      } else {
        return .invalid
      }
    } else if sanitizedNumber.count > binRange.length {
      return .invalid
    } else {
      return .incomplete
    }
  }

  /// The card brand for a card number or substring thereof.
  /// - Parameter cardNumber: A card number, or partial card number. For
  /// example, @"4242", @"5555555555554444", or @"123".
  /// - Returns: The brand for that card number. The example parameters would
  /// return STPCardBrandVisa, STPCardBrandMasterCard, and
  /// STPCardBrandUnknown, respectively.
  @objc(brandForNumber:)
  public class func brand(forNumber cardNumber: String) -> STPCardBrand {
    let sanitizedNumber = self.sanitizedNumericString(for: cardNumber)
    let brands = self.possibleBrands(forNumber: sanitizedNumber)
    if brands.count == 1 {
      return brands.first!
    }
    return .unknown
  }

  /// The possible number lengths for cards associated with a card brand. For
  /// example, Discover card numbers contain 16 characters, while American Express
  /// cards contain 15 characters.
  /// - Parameter brand: The brand to return lengths for.
  /// - Returns: The set of possible lengths cards associated with that brand can be.
  @objc(lengthsForCardBrand:)
  public class func lengths(for brand: STPCardBrand) -> Set<UInt> {
    var set: Set<UInt> = []
    let binRanges = STPBINRange.binRanges(for: brand)
    for binRange in binRanges {
      _ = set.insert(binRange.length)
    }
    return set
  }

  /// The maximum possible length the number of a card associated with the specified
  /// brand could be.
  /// For example, Visa cards could be either 13 or 16 characters, so this method
  /// would return 16 for the that card brand.
  /// - Parameter brand: The brand to return the max length for.
  /// - Returns: The maximum length card numbers associated with that brand could be.
  @objc(maxLengthForCardBrand:)
  public class func maxLength(for brand: STPCardBrand) -> Int {
    var maxLength = -1
    for length in self.lengths(for: brand) {
      if length > maxLength {
        maxLength = Int(length)
      }
    }
    return maxLength
  }

  /// The length of the final grouping of digits to use when formatting a card number
  /// for display.
  /// For example, Visa cards display their final 4 numbers, e.g. "4242", while
  /// American Express cards display their final 5 digits, e.g. "10005".
  /// - Parameter brand: The brand to return the fragment length for.
  /// - Returns: The final fragment length card numbers associated with that brand use.
  @objc(fragmentLengthForCardBrand:)
  public class func fragmentLength(for brand: STPCardBrand) -> Int {
    return Int(self.cardNumberFormat(for: brand).last?.uintValue ?? 0)
  }

  /// Validates an expiration month, passed as an (optionally 0-padded) string.
  /// Example valid values are "3", "12", and "08". Example invalid values are "99",
  /// "a", and "00". Incomplete values include "0" and "1".
  /// - Parameter expirationMonth: A string representing a 2-digit expiration month for a
  /// payment card.
  /// - Returns: STPCardValidationStateValid if the month is valid,
  /// STPCardValidationStateInvalid if the month is invalid, or
  /// STPCardValidationStateIncomplete if the month is a substring of a valid
  /// month (e.g. @"0" or @"1").
  @objc(validationStateForExpirationMonth:)
  public class func validationState(forExpirationMonth expirationMonth: String)
    -> STPCardValidationState
  {

    let sanitizedExpiration = self.stringByRemovingSpaces(from: expirationMonth)

    if !self.stringIsNumeric(sanitizedExpiration) {
      return .invalid
    }

    switch sanitizedExpiration.count {
    case 0:
      return .incomplete
    case 1:
      return ((sanitizedExpiration == "0") || (sanitizedExpiration == "1")) ? .incomplete : .valid
    case 2:
      return (0 < Int(sanitizedExpiration) ?? 0 && Int(sanitizedExpiration) ?? 0 <= 12)
        ? .valid : .invalid
    default:
      return .invalid
    }
  }

  /// Validates an expiration year, passed as a string representing the final
  /// 2 digits of the year.
  /// This considers the period between the current year until 2099 as valid times.
  /// An example valid year value would be "16" (assuming the current year, as
  /// determined by NSDate.date, is 2015).
  /// Will return STPCardValidationStateInvalid for a month/year combination that
  /// is earlier than the current date (i.e. @"15" and @"04" in October 2015).
  /// Example invalid year values are "00", "a", and "13". Any 1-digit year string
  /// will return STPCardValidationStateIncomplete.
  /// - Parameters:
  ///   - expirationYear: A string representing a 2-digit expiration year for a
  /// payment card.
  ///   - expirationMonth: A string representing a valid 2-digit expiration month
  /// for a payment card. If the month is invalid
  /// (see `validationStateForExpirationMonth`), this will
  /// return STPCardValidationStateInvalid.
  /// - Returns: STPCardValidationStateValid if the year is valid,
  /// STPCardValidationStateInvalid if the year is invalid, or
  /// STPCardValidationStateIncomplete if the year is a substring of a valid
  /// year (e.g. @"1" or @"2").
  @objc(validationStateForExpirationYear:inMonth:)
  public class func validationState(
    forExpirationYear expirationYear: String,
    inMonth expirationMonth: String
  ) -> STPCardValidationState {
    return self.validationState(
      forExpirationYear: expirationYear,
      inMonth: expirationMonth,
      inCurrentYear: self.currentYear(),
      currentMonth: self.currentMonth())
  }

  /// The max CVC length for a card brand (for example, American Express CVCs are
  /// 4 digits, while all others are 3).
  /// - Parameter brand: The brand to return the max CVC length for.
  /// - Returns: The maximum length of CVC numbers for cards associated with that brand.
  @objc(maxCVCLengthForCardBrand:)
  public class func maxCVCLength(for brand: STPCardBrand) -> UInt {
    switch brand {
    case .amex, .unknown:
      return 4
    default:
      return 3
    }
  }

  /// Validates a card's CVC, passed as a numeric string, for the given card brand.
  /// - Parameters:
  ///   - cvc:   the CVC to validate
  ///   - brand: the card brand (can be determined from the card's number
  /// using `brandForNumber`)
  /// - Returns: Whether the CVC represents a valid CVC for that card brand. For
  /// example, would return STPCardValidationStateValid for @"123" and
  /// STPCardBrandVisa, STPCardValidationStateValid for @"1234" and
  /// STPCardBrandAmericanExpress, STPCardValidationStateIncomplete for @"12" and
  /// STPCardBrandVisa, and STPCardValidationStateInvalid for @"12345" and any brand.
  @objc(validationStateForCVC:cardBrand:)
  public class func validationState(forCVC cvc: String, cardBrand brand: STPCardBrand)
    -> STPCardValidationState
  {

    if !self.stringIsNumeric(cvc) {
      return .invalid
    }

    let sanitizedCvc = self.sanitizedNumericString(for: cvc)

    let minLength = self.minCVCLength()
    let maxLength = self.maxCVCLength(for: brand)
    if sanitizedCvc.count < minLength {
      return .incomplete
    } else if sanitizedCvc.count > maxLength {
      return .invalid
    } else {
      return .valid
    }
  }

  /// Validates the given card details.
  /// - Parameter card: The card details to validate.
  /// - Returns: STPCardValidationStateValid if all fields are valid,
  /// STPCardValidationStateInvalid if any field is invalid, or
  /// STPCardValidationStateIncomplete if all fields are either incomplete or valid.
  @objc(validationStateForCard:)
  public class func validationState(forCard card: STPCardParams) -> STPCardValidationState {
    return self.validationState(
      forCard: card,
      inCurrentYear: self.currentYear(),
      currentMonth: self.currentMonth())
  }

  class func stringByRemovingSpaces(from string: String) -> String {
    let set = CharacterSet.whitespaces
    return stringByRemovingCharactersFromSet(string, set)
  }

  static func stringByRemovingCharactersFromSet(_ string: String, _ cs: CharacterSet) -> String {
    let filtered = string.unicodeScalars.filter { !cs.contains($0) }
    return String(String.UnicodeScalarView(filtered))
  }

  class func validationState(
    forExpirationYear expirationYear: String, inMonth expirationMonth: String,
    inCurrentYear currentYear: Int, currentMonth: Int
  ) -> STPCardValidationState {

    let moddedYear = currentYear % 100

    if !self.stringIsNumeric(expirationMonth) || !self.stringIsNumeric(expirationYear) {
      return .invalid
    }

    let sanitizedMonth = self.sanitizedNumericString(for: expirationMonth)
    let sanitizedYear = self.sanitizedNumericString(for: expirationYear)

    switch sanitizedYear.count {
    case 0, 1:
      return .incomplete
    case 2:
      if self.validationState(forExpirationMonth: sanitizedMonth) == .invalid {
        return .invalid
      } else {
        if Int(sanitizedYear) ?? 0 == moddedYear {
          return Int(sanitizedMonth) ?? 0 >= currentMonth ? .valid : .invalid
        } else {
          return Int(sanitizedYear) ?? 0 > moddedYear ? .valid : .invalid
        }
      }
    default:
      return .invalid
    }
  }

  class func validationState(
    forCard card: STPCardParams, inCurrentYear currentYear: Int, currentMonth: Int
  ) -> STPCardValidationState {
    let numberValidation = self.validationState(
      forNumber: card.number ?? "", validatingCardBrand: true)
    let expMonthString = String(format: "%02lu", UInt(card.expMonth))
    let expMonthValidation = self.validationState(forExpirationMonth: expMonthString)
    let expYearString = String(format: "%02lu", UInt(card.expYear) % 100)
    let expYearValidation = self.validationState(
      forExpirationYear: expYearString,
      inMonth: expMonthString,
      inCurrentYear: currentYear,
      currentMonth: currentMonth)
    let brand = self.brand(forNumber: card.number ?? "")
    let cvcValidation = self.validationState(forCVC: card.cvc ?? "", cardBrand: brand)

    let states = [
      NSNumber(value: numberValidation.rawValue),
      NSNumber(value: expMonthValidation.rawValue),
      NSNumber(value: expYearValidation.rawValue),
      NSNumber(value: cvcValidation.rawValue),
    ]
    var incomplete = false
    for boxedState in states {
      let state = STPCardValidationState(rawValue: boxedState.intValue)
      if state == .invalid {
        return state!
      } else if state == .incomplete {
        incomplete = true
      }
    }
    return incomplete ? .incomplete : .valid
  }

  class func minCVCLength() -> Int {
    return 3
  }

  class func possibleBrands(forNumber cardNumber: String) -> Set<STPCardBrand> {
    let binRanges = STPBINRange.binRanges(forNumber: cardNumber)
    var brands = binRanges.map { $0.brand }
    brands.removeAll { $0 == .unknown }
    return Set(brands)
  }

  class func currentYear() -> Int {
    let calendar = Calendar(identifier: .gregorian)
    return (calendar.component(.year, from: Date())) % 100
  }

  class func currentMonth() -> Int {
    let calendar = Calendar(identifier: .gregorian)
    return calendar.component(.month, from: Date())
  }
}

extension STPCardValidator {
  class func cardNumberFormat(for brand: STPCardBrand) -> [NSNumber] {
    switch brand {
    case .amex:
      return [NSNumber(value: 4), NSNumber(value: 6), NSNumber(value: 5)]
    default:
      return [NSNumber(value: 4), NSNumber(value: 4), NSNumber(value: 4), NSNumber(value: 4)]
    }
  }

  class func cardNumberFormat(forCardNumber cardNumber: String) -> [NSNumber] {
    let binRange = STPBINRange.mostSpecificBINRange(forNumber: cardNumber)
    if binRange.brand == .dinersClub && binRange.length == 14 {
      return [NSNumber(value: 4), NSNumber(value: 6), NSNumber(value: 4)]
    }

    return self.cardNumberFormat(for: binRange.brand)
  }

  class func stringIsValidLuhn(_ number: String) -> Bool {
    var odd = true
    var sum = 0
    var digits : [String] = []

    for i in 0..<number.count {
      digits.append((number as NSString).substring(with: NSRange(location: i, length: 1)))
    }

    for digitStr in digits.reversed() {
      var digit = Int(digitStr) ?? 0
      odd = !odd
      if odd {
        digit *= 2
      }
      if digit > 9 {
        digit -= 9
      }
      sum += digit
    }

    return sum % 10 == 0
  }
}
