require_relative 'globals'
require_relative 'tunes/tunes_client'

module Spaceship
  class Client
    def handle_two_step_or_factor(response)
      raise "2FA can only be performed in interactive mode" if ENV["SPACESHIP_ONLY_ALLOW_INTERACTIVE_2FA"] == "true" && ENV["FASTLANE_IS_INTERACTIVE"] == "false"
      # extract `x-apple-id-session-id` and `scnt` from response, to be used by `update_request_headers`
      @x_apple_id_session_id = response["x-apple-id-session-id"]
      @scnt = response["scnt"]

      # get authentication options
      r = request(:get) do |req|
        req.url("https://idmsa.apple.com/appleauth/auth")
        update_request_headers(req)
      end

      if r.body.kind_of?(Hash) && r.body["trustedDevices"].kind_of?(Array)
        handle_two_step(r)
      elsif r.body.kind_of?(Hash) && r.body["trustedPhoneNumbers"].kind_of?(Array) && r.body["trustedPhoneNumbers"].first.kind_of?(Hash)
        handle_two_factor(r)
      else
        raise "Although response from Apple indicated activated Two-step Verification or Two-factor Authentication, spaceship didn't know how to handle this response: #{r.body}"
      end
    end

    def handle_two_step(response)
      if response.body.fetch("securityCode", {})["tooManyCodesLock"].to_s.length > 0
        raise Tunes::Error.new, "Too many verification codes have been sent. Enter the last code you received, use one of your devices, or try again later."
      end

      puts("Two-step Verification (4 digits code) is enabled for account '#{self.user}'")
      puts("More information about Two-step Verification: https://support.apple.com/en-us/HT204152")
      puts("")

      puts("Please select a trusted device to verify your identity")
      available = response.body["trustedDevices"].collect do |current|
        "#{current['name']}\t#{current['modelName'] || 'SMS'}\t(#{current['id']})"
      end
      result = choose(*available)

      device_id = result.match(/.*\t.*\t\((.*)\)/)[1]
      handle_two_step_for_device(device_id)
    end

    # this is extracted into its own method so it can be called multiple times (see end)
    def handle_two_step_for_device(device_id)
      # Request token to device
      r = request(:put) do |req|
        req.url("https://idmsa.apple.com/appleauth/auth/verify/device/#{device_id}/securitycode")
        update_request_headers(req)
      end

      # we use `Spaceship::TunesClient.new.handle_itc_response`
      # since this might be from the Dev Portal, but for 2 step
      Spaceship::TunesClient.new.handle_itc_response(r.body)

      puts("Successfully requested notification")
      code = ask("Please enter the 4 digit code: ")
      puts("Requesting session...")

      # Send token to server to get a valid session
      r = request(:post) do |req|
        req.url("https://idmsa.apple.com/appleauth/auth/verify/phone/securitycode")
        req.headers['Content-Type'] = 'application/json'
        req.body = {
          "phoneNumber": {
            "id": device_id
          },
          "securityCode": {
            "code" => code.to_s
          },
          "mode": "sms"
        }.to_json
        update_request_headers(req)
      end

      begin
        Spaceship::TunesClient.new.handle_itc_response(r.body) # this will fail if the code is invalid
      rescue => ex
        # If the code was entered wrong
        # {
        #   "securityCode": {
        #     "code": "1234"
        #   },
        #   "securityCodeLocked": false,
        #   "recoveryKeyLocked": false,
        #   "recoveryKeySupported": true,
        #   "manageTrustedDevicesLinkName": "appleid.apple.com",
        #   "suppressResend": false,
        #   "authType": "hsa",
        #   "accountLocked": false,
        #   "validationErrors": [{
        #     "code": "-21669",
        #     "title": "Incorrect Verification Code",
        #     "message": "Incorrect verification code."
        #   }]
        # }
        if ex.to_s.include?("verification code") # to have a nicer output
          puts("Error: Incorrect verification code")
          return handle_two_step_for_device(device_id)
        end

        raise ex
      end

      store_session

      return true
    end

    def handle_two_factor(response, depth = 0)
      if depth == 0
        puts("Two-factor Authentication (6 digits code) is enabled for account '#{self.user}'")
        puts("More information about Two-factor Authentication: https://support.apple.com/en-us/HT204915")
        puts("")

        two_factor_url = "https://github.com/fastlane/fastlane/tree/master/spaceship#2-step-verification"
        puts("If you're running this in a non-interactive session (e.g. server or CI)")
        puts("check out #{two_factor_url}")
      end

      # "verification code" has already be pushed to devices

      security_code = response.body["securityCode"]
      # "securityCode": {
      # 	"length": 6,
      # 	"tooManyCodesSent": false,
      # 	"tooManyCodesValidated": false,
      # 	"securityCodeLocked": false
      # },
      code_length = security_code["length"]

      puts("")
      env_2fa_sms_default_phone_number = ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"]

      if env_2fa_sms_default_phone_number
        raise Tunes::Error.new, "Environment variable SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER is set, but empty." if env_2fa_sms_default_phone_number.empty?

        puts("Environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` is set, automatically requesting 2FA token via SMS to that number")
        puts("SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER = #{env_2fa_sms_default_phone_number}")
        puts("")

        phone_number = env_2fa_sms_default_phone_number
        phone_id = phone_id_from_number(response.body["trustedPhoneNumbers"], phone_number)
        push_mode = push_mode_from_number(response.body["trustedPhoneNumbers"], phone_number)
        # don't request sms if no trusted devices and env default is the only trusted number,
        # code was automatically sent
        should_request_code = !sms_automatically_sent(response)
        code_type = 'phone'
        body = request_two_factor_code_from_phone(phone_id, phone_number, code_length, push_mode, should_request_code)
      elsif sms_automatically_sent(response) # sms fallback, code was automatically sent
        fallback_number = response.body["trustedPhoneNumbers"].first
        phone_number = fallback_number["numberWithDialCode"]
        phone_id = fallback_number["id"]
        push_mode = fallback_number['pushMode']

        code_type = 'phone'
        body = request_two_factor_code_from_phone(phone_id, phone_number, code_length, push_mode, false)
      elsif sms_fallback(response) # sms fallback but code wasn't sent bec > 1 phone number
        code_type = 'phone'
        body = request_two_factor_code_from_phone_choose(response.body["trustedPhoneNumbers"], code_length)
      else
        puts("(Input `sms` to escape this prompt and select a trusted phone number to send the code as a text message)")
        puts("")
        puts("(You can also set the environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` to automate this)")
        puts("(Read more at: https://github.com/fastlane/fastlane/blob/master/spaceship/docs/Authentication.md#auto-select-sms-via-spaceship_2fa_sms_default_phone_number)")
        puts("")

        code = ask_for_2fa_code("Please enter the #{code_length} digit code:")
        code_type = 'trusteddevice'
        body = { "securityCode" => { "code" => code.to_s } }.to_json

        # User exited by entering `sms` and wants to choose phone number for SMS
        if code.casecmp?("sms")
          code_type = 'phone'
          body = request_two_factor_code_from_phone_choose(response.body["trustedPhoneNumbers"], code_length)
        end
      end

      puts("Requesting session...")

      # Send "verification code" back to server to get a valid session
      r = request(:post) do |req|
        req.url("https://idmsa.apple.com/appleauth/auth/verify/#{code_type}/securitycode")
        req.headers['Content-Type'] = 'application/json'
        req.body = body
        update_request_headers(req)
      end

      begin
        # we use `Spaceship::TunesClient.new.handle_itc_response`
        # since this might be from the Dev Portal, but for 2 factor
        Spaceship::TunesClient.new.handle_itc_response(r.body) # this will fail if the code is invalid
      rescue => ex
        # If the code was entered wrong
        # {
        #   "service_errors": [{
        #     "code": "-21669",
        #     "title": "Incorrect Verification Code",
        #     "message": "Incorrect verification code."
        #   }],
        #   "hasError": true
        # }

        if ex.to_s.include?("verification code") # to have a nicer output
          puts("Error: Incorrect verification code")
          depth += 1
          return handle_two_factor(response, depth)
        end

        raise ex
      end

      store_session

      return true
    end

    # For reference in case auth behavior changes:
    # The "noTrustedDevices" field is only present
    # in the response for `GET /appleauth/auth`

    # Account is not signed into any devices that can display a verification code
    def sms_fallback(response)
      response.body["noTrustedDevices"]
    end

    # see `sms_fallback` + account has only one trusted number for receiving an sms
    def sms_automatically_sent(response)
      (response.body["trustedPhoneNumbers"] || []).count == 1 && sms_fallback(response)
    end

    # extracted into its own method for testing
    def ask_for_2fa_code(text)
      ask(text)
    end

    # extracted into its own method for testing
    def choose_phone_number(opts)
      choose(*opts)
    end

    def phone_id_from_number(phone_numbers, phone_number)
      phone_numbers.each do |phone|
        return phone['id'] if match_phone_to_masked_phone(phone_number, phone['numberWithDialCode'])
      end

      # Handle case of phone_number not existing in phone_numbers because ENV var is wrong or matcher is broken
      raise Tunes::Error.new, %(
Could not find a matching phone number to #{phone_number} in #{phone_numbers}.
Make sure your environment variable is set to the correct phone number.
If it is, please open an issue at https://github.com/fastlane/fastlane/issues/new and include this output so we can fix our matcher. Thanks.
)
    end

    def push_mode_from_number(phone_numbers, phone_number)
      phone_numbers.each do |phone|
        return phone['pushMode'] if match_phone_to_masked_phone(phone_number, phone['numberWithDialCode'])
      end

      # If no pushMode was supplied, assume sms
      return "sms"
    end

    def match_phone_to_masked_phone(phone_number, masked_number)
      characters_to_remove_from_phone_numbers = ' \-()"'

      # start with e.g. +49 162 1234585 or +1-123-456-7866
      phone_number = phone_number.tr(characters_to_remove_from_phone_numbers, '')
      # cleaned: +491621234585 or +11234567866

      # rubocop:disable Style/AsciiComments
      # start with: +49 •••• •••••85 or +1 (•••) •••-••66
      number_with_dialcode_masked = masked_number.tr(characters_to_remove_from_phone_numbers, '')
      # cleaned: +49•••••••••85 or +1••••••••66
      # rubocop:enable Style/AsciiComments

      maskings_count = number_with_dialcode_masked.count('•') # => 9 or 8
      pattern = /^([0-9+]{2,4})([•]{#{maskings_count}})([0-9]{2})$/
      # following regex: range from maskings_count-2 because sometimes the masked number has 1 or 2 dots more than the actual number
      # e.g. https://github.com/fastlane/fastlane/issues/14969
      replacement = "\\1([0-9]{#{maskings_count - 2},#{maskings_count}})\\3"
      number_with_dialcode_regex_part = number_with_dialcode_masked.gsub(pattern, replacement)
      # => +49([0-9]{8,9})85 or +1([0-9]{7,8})66

      backslash = '\\'
      number_with_dialcode_regex_part = backslash + number_with_dialcode_regex_part
      number_with_dialcode_regex = /^#{number_with_dialcode_regex_part}$/
      # => /^\+49([0-9]{8})85$/ or /^\+1([0-9]{7,8})66$/

      return phone_number =~ number_with_dialcode_regex
      # +491621234585 matches /^\+49([0-9]{8})85$/
    end

    def phone_id_from_masked_number(phone_numbers, masked_number)
      phone_numbers.each do |phone|
        return phone['id'] if phone['numberWithDialCode'] == masked_number
      end
    end

    def push_mode_from_masked_number(phone_numbers, masked_number)
      phone_numbers.each do |phone|
        return phone['pushMode'] if phone['numberWithDialCode'] == masked_number
      end

      # If no pushMode was supplied, assume sms
      return "sms"
    end

    def request_two_factor_code_from_phone_choose(phone_numbers, code_length)
      puts("Please select a trusted phone number to send code to:")

      available = phone_numbers.collect do |current|
        current['numberWithDialCode']
      end
      chosen = choose_phone_number(available)
      phone_id = phone_id_from_masked_number(phone_numbers, chosen)
      push_mode = push_mode_from_masked_number(phone_numbers, chosen)

      request_two_factor_code_from_phone(phone_id, chosen, code_length, push_mode)
    end

    # this is used in two places: after choosing a phone number and when a phone number is set via ENV var
    def request_two_factor_code_from_phone(phone_id, phone_number, code_length, push_mode = "sms", should_request_code = true)
      if should_request_code
        # Request code
        r = request(:put) do |req|
          req.url("https://idmsa.apple.com/appleauth/auth/verify/phone")
          req.headers['Content-Type'] = 'application/json'
          req.body = { "phoneNumber" => { "id" => phone_id }, "mode" => push_mode }.to_json
          update_request_headers(req)
        end

        # we use `Spaceship::TunesClient.new.handle_itc_response`
        # since this might be from the Dev Portal, but for 2 step
        Spaceship::TunesClient.new.handle_itc_response(r.body)

        puts("Successfully requested text message to #{phone_number}")
      end

      code = ask_for_2fa_code("Please enter the #{code_length} digit code you received at #{phone_number}:")

      return { "securityCode" => { "code" => code.to_s }, "phoneNumber" => { "id" => phone_id }, "mode" => push_mode }.to_json
    end

    def store_session
      # If the request was successful, r.body is actually nil
      # The previous request will fail if the user isn't on a team
      # on App Store Connect, but it still works, so we're good

      # Tell iTC that we are trustworthy (obviously)
      # This will update our local cookies to something new
      # They probably have a longer time to live than the other poor cookies
      # Changed Keys
      # - myacinfo
      # - DES5c148586dfd451e55afb0175f62418f91
      # We actually only care about the DES value

      request(:get) do |req|
        req.url("https://idmsa.apple.com/appleauth/auth/2sv/trust")
        update_request_headers(req)
      end
      # This request will fail if the user isn't added to a team on iTC
      # However we don't really care, this request will still return the
      # correct DES... cookie

      self.store_cookie
    end

    # Responsible for setting all required header attributes for the requests
    # to succeed
    def update_request_headers(req)
      req.headers["X-Apple-Id-Session-Id"] = @x_apple_id_session_id
      req.headers["X-Apple-Widget-Key"] = self.itc_service_key
      req.headers["Accept"] = "application/json"
      req.headers["scnt"] = @scnt
    end
  end
end
