module ActiveShipping

  # FedEx carrier implementation.
  #
  # FedEx module by Jimmy Baker (http://github.com/jimmyebaker)
  # Documentation can be found here: http://images.fedex.com/us/developer/product/WebServices/MyWebHelp/PropDevGuide.pdf
  class FedEx < Carrier
    self.retry_safe = true

    cattr_reader :name
    @@name = "FedEx"

    TEST_URL = 'https://gatewaybeta.fedex.com:443/xml'
    LIVE_URL = 'https://gateway.fedex.com:443/xml'

    CARRIER_CODES = {
      "fedex_ground" => "FDXG",
      "fedex_express" => "FDXE"
    }

    DELIVERY_ADDRESS_NODE_NAMES = %w(DestinationAddress ActualDeliveryAddress)
    SHIPPER_ADDRESS_NODE_NAMES  = %w(ShipperAddress)

    SERVICE_TYPES = {
      "PRIORITY_OVERNIGHT" => "FedEx Priority Overnight",
      "PRIORITY_OVERNIGHT_SATURDAY_DELIVERY" => "FedEx Priority Overnight Saturday Delivery",
      "FEDEX_2_DAY" => "FedEx 2 Day",
      "FEDEX_2_DAY_SATURDAY_DELIVERY" => "FedEx 2 Day Saturday Delivery",
      "STANDARD_OVERNIGHT" => "FedEx Standard Overnight",
      "FIRST_OVERNIGHT" => "FedEx First Overnight",
      "FIRST_OVERNIGHT_SATURDAY_DELIVERY" => "FedEx First Overnight Saturday Delivery",
      "FEDEX_EXPRESS_SAVER" => "FedEx Express Saver",
      "FEDEX_1_DAY_FREIGHT" => "FedEx 1 Day Freight",
      "FEDEX_1_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 1 Day Freight Saturday Delivery",
      "FEDEX_2_DAY_FREIGHT" => "FedEx 2 Day Freight",
      "FEDEX_2_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 2 Day Freight Saturday Delivery",
      "FEDEX_3_DAY_FREIGHT" => "FedEx 3 Day Freight",
      "FEDEX_3_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 3 Day Freight Saturday Delivery",
      "INTERNATIONAL_PRIORITY" => "FedEx International Priority",
      "INTERNATIONAL_PRIORITY_SATURDAY_DELIVERY" => "FedEx International Priority Saturday Delivery",
      "INTERNATIONAL_ECONOMY" => "FedEx International Economy",
      "INTERNATIONAL_FIRST" => "FedEx International First",
      "INTERNATIONAL_PRIORITY_FREIGHT" => "FedEx International Priority Freight",
      "INTERNATIONAL_ECONOMY_FREIGHT" => "FedEx International Economy Freight",
      "GROUND_HOME_DELIVERY" => "FedEx Ground Home Delivery",
      "FEDEX_GROUND" => "FedEx Ground",
      "INTERNATIONAL_GROUND" => "FedEx International Ground",
      "SMART_POST" => "FedEx SmartPost",
      "FEDEX_FREIGHT_PRIORITY" => "FedEx Freight Priority",
      "FEDEX_FREIGHT_ECONOMY" => "FedEx Freight Economy"
    }

    PACKAGE_TYPES = {
      "fedex_envelope" => "FEDEX_ENVELOPE",
      "fedex_pak" => "FEDEX_PAK",
      "fedex_box" => "FEDEX_BOX",
      "fedex_tube" => "FEDEX_TUBE",
      "fedex_10_kg_box" => "FEDEX_10KG_BOX",
      "fedex_25_kg_box" => "FEDEX_25KG_BOX",
      "your_packaging" => "YOUR_PACKAGING"
    }

    DROPOFF_TYPES = {
      'regular_pickup' => 'REGULAR_PICKUP',
      'request_courier' => 'REQUEST_COURIER',
      'dropbox' => 'DROP_BOX',
      'business_service_center' => 'BUSINESS_SERVICE_CENTER',
      'station' => 'STATION'
    }

    PAYMENT_TYPES = {
      'sender' => 'SENDER',
      'recipient' => 'RECIPIENT',
      'third_party' => 'THIRDPARTY',
      'collect' => 'COLLECT'
    }

    PACKAGE_IDENTIFIER_TYPES = {
      'tracking_number' => 'TRACKING_NUMBER_OR_DOORTAG',
      'door_tag' => 'TRACKING_NUMBER_OR_DOORTAG',
      'rma' => 'RMA',
      'ground_shipment_id' => 'GROUND_SHIPMENT_ID',
      'ground_invoice_number' => 'GROUND_INVOICE_NUMBER',
      'ground_customer_reference' => 'GROUND_CUSTOMER_REFERENCE',
      'ground_po' => 'GROUND_PO',
      'express_reference' => 'EXPRESS_REFERENCE',
      'express_mps_master' => 'EXPRESS_MPS_MASTER',
      'shipper_reference' => 'SHIPPER_REFERENCE',
    }

    TRANSIT_TIMES = %w(UNKNOWN ONE_DAY TWO_DAYS THREE_DAYS FOUR_DAYS FIVE_DAYS SIX_DAYS SEVEN_DAYS EIGHT_DAYS NINE_DAYS TEN_DAYS ELEVEN_DAYS TWELVE_DAYS THIRTEEN_DAYS FOURTEEN_DAYS FIFTEEN_DAYS SIXTEEN_DAYS SEVENTEEN_DAYS EIGHTEEN_DAYS)

    # FedEx tracking codes as described in the FedEx Tracking Service WSDL Guide
    # All delays also have been marked as exceptions
    TRACKING_STATUS_CODES = HashWithIndifferentAccess.new(
      'AA' => :at_airport,
      'AD' => :at_delivery,
      'AF' => :at_fedex_facility,
      'AR' => :at_fedex_facility,
      'AP' => :at_pickup,
      'CA' => :canceled,
      'CH' => :location_changed,
      'DE' => :exception,
      'DL' => :delivered,
      'DP' => :departed_fedex_location,
      'DR' => :vehicle_furnished_not_used,
      'DS' => :vehicle_dispatched,
      'DY' => :exception,
      'EA' => :exception,
      'ED' => :enroute_to_delivery,
      'EO' => :enroute_to_origin_airport,
      'EP' => :enroute_to_pickup,
      'FD' => :at_fedex_destination,
      'HL' => :held_at_location,
      'IT' => :in_transit,
      'LO' => :left_origin,
      'OC' => :order_created,
      'OD' => :out_for_delivery,
      'PF' => :plane_in_flight,
      'PL' => :plane_landed,
      'PU' => :picked_up,
      'RS' => :return_to_shipper,
      'SE' => :exception,
      'SF' => :at_sort_facility,
      'SP' => :split_status,
      'TR' => :transfer
    )

    def self.service_name_for_code(service_code)
      SERVICE_TYPES[service_code] || "FedEx #{service_code.titleize.sub(/Fedex /, '')}"
    end

    def requirements
      [:key, :password, :account, :login]
    end

    def find_rates(origin, destination, packages, options = {})
      options = @options.update(options)
      packages = Array(packages)

      rate_request = build_rate_request(origin, destination, packages, options)

      xml = commit(save_request(rate_request), (options[:test] || false))

      parse_rate_response(origin, destination, packages, xml, options)
    end

    def find_tracking_info(tracking_number, options = {})
      options = @options.update(options)

      tracking_request = build_tracking_request(tracking_number, options)
      xml = commit(save_request(tracking_request), (options[:test] || false))
      parse_tracking_response(xml, options)
    end

    protected

    def build_rate_request(origin, destination, packages, options = {})
      imperial = %w(US LR MM).include?(origin.country_code(:alpha2))

      xml_builder = Nokogiri::XML::Builder.new do |xml|
        xml.RateRequest(xmlns: 'http://fedex.com/ws/rate/v13') do
          build_request_header(xml)
          build_version_node(xml, 'crs', 13, 0 ,0)

          # Returns delivery dates
          xml.ReturnTransitAndCommit(true)

          # Returns saturday delivery shipping options when available
          xml.VariableOptions('SATURDAY_DELIVERY')

          xml.RequestedShipment do
            xml.ShipTimestamp(ship_timestamp(options[:turn_around_time]).iso8601(0))

            freight = has_freight?(options)

            unless freight
              # fedex api wants this up here otherwise request returns an error
              xml.DropoffType(options[:dropoff_type] || 'REGULAR_PICKUP')
              xml.PackagingType(options[:packaging_type] || 'YOUR_PACKAGING')
            end

            build_location_node(xml, 'Shipper', options[:shipper] || origin)
            build_location_node(xml, 'Recipient', destination)
            if options[:shipper] && options[:shipper] != origin
              build_location_node(xml, 'Origin', origin)
            end

            if freight
              freight_options = options[:freight]
              build_shipping_charges_payment_node(xml, freight_options)
              build_freight_shipment_detail_node(xml, freight_options, packages, imperial)
              build_rate_request_types_node(xml)
            else
              xml.SmartPostDetail do
                xml.Indicia(options[:smart_post_indicia] || 'PARCEL_SELECT')
                xml.HubId(options[:smart_post_hub_id] || 5902) # default to LA
              end

              build_rate_request_types_node(xml)
              xml.PackageCount(packages.size)
              build_packages_nodes(xml, packages, imperial)
            end
          end
        end
      end
      xml_builder.to_xml
    end

    def build_packages_nodes(xml, packages, imperial)
      packages.map do |pkg|
        xml.RequestedPackageLineItems do
          xml.GroupPackageCount(1)
          build_package_weight_node(xml, pkg, imperial)
          build_package_dimensions_node(xml, pkg, imperial)
        end
      end
    end

    def build_shipping_charges_payment_node(xml, freight_options)
      xml.ShippingChargesPayment do
        xml.PaymentType(freight_options[:payment_type])
        xml.Payor do
          xml.ResponsibleParty do
            # TODO: case of different freight account numbers?
            xml.AccountNumber(freight_options[:account])
          end
        end
      end
    end

    def build_freight_shipment_detail_node(xml, freight_options, packages, imperial)
      xml.FreightShipmentDetail do
        # TODO: case of different freight account numbers?
        xml.FedExFreightAccountNumber(freight_options[:account])
        build_location_node(xml, 'FedExFreightBillingContactAndAddress', freight_options[:billing_location])
        xml.Role(freight_options[:role])

        packages.each do |pkg|
          xml.LineItems do
            xml.FreightClass(freight_options[:freight_class])
            xml.Packaging(freight_options[:packaging])
            build_package_weight_node(xml, pkg, imperial)
            build_package_dimensions_node(xml, pkg, imperial)
          end
        end
      end
    end

    def has_freight?(options)
      options[:freight] && options[:freight].present?
    end

    def build_package_weight_node(xml, pkg, imperial)
      xml.Weight do
        xml.Units(imperial ? 'LB' : 'KG')
        xml.Value([((imperial ? pkg.lbs : pkg.kgs).to_f * 1000).round / 1000.0, 0.1].max)
      end
    end

    def build_package_dimensions_node(xml, pkg, imperial)
      xml.Dimensions do
        [:length, :width, :height].each do |axis|
          value = ((imperial ? pkg.inches(axis) : pkg.cm(axis)).to_f * 1000).round / 1000.0 # 3 decimals
          xml.public_send(axis.to_s.capitalize, value.ceil)
        end
        xml.Units(imperial ? 'IN' : 'CM')
      end
    end

    def build_rate_request_types_node(xml, type = 'ACCOUNT')
      xml.RateRequestTypes(type)
    end

    def build_tracking_request(tracking_number, options = {})
      xml_builder = Nokogiri::XML::Builder.new do |xml|
        xml.TrackRequest(xmlns: 'http://fedex.com/ws/track/v7') do
          build_request_header(xml)
          build_version_node(xml, 'trck', 7, 0, 0)

          xml.SelectionDetails do
            xml.PackageIdentifier do
              xml.Type(PACKAGE_IDENTIFIER_TYPES[options[:package_identifier_type] || 'tracking_number'])
              xml.Value(tracking_number)
            end

            xml.ShipDateRangeBegin(options[:ship_date_range_begin])         if options[:ship_date_range_begin]
            xml.ShipDateRangeEnd(options[:ship_date_range_end])             if options[:ship_date_range_end]
            xml.TrackingNumberUniqueIdentifier(options[:unique_identifier]) if options[:unique_identifier]
          end

          xml.ProcessingOptions('INCLUDE_DETAILED_SCANS')
        end
      end
      xml_builder.to_xml
    end

    def build_request_header(xml)
      xml.WebAuthenticationDetail do
        xml.UserCredential do
          xml.Key(@options[:key])
          xml.Password(@options[:password])
        end
      end

      xml.ClientDetail do
        xml.AccountNumber(@options[:account])
        xml.MeterNumber(@options[:login])
      end

      xml.TransactionDetail do
        xml.CustomerTransactionId(@options[:transaction_id] || 'ActiveShipping') # TODO: Need to do something better with this...
      end
    end

    def build_version_node(xml, service_id, major, intermediate, minor)
      xml.Version do
        xml.ServiceId(service_id)
        xml.Major(major)
        xml.Intermediate(intermediate)
        xml.Minor(minor)
      end
    end

    def build_location_node(xml, name, location)
      xml.public_send(name) do
        xml.Address do
          xml.StreetLines(location.address1) if location.address1
          xml.StreetLines(location.address2) if location.address2
          xml.City(location.city) if location.city
          xml.PostalCode(location.postal_code)
          xml.CountryCode(location.country_code(:alpha2))
          xml.Residential(true) unless location.commercial?
        end
      end
    end

    def parse_rate_response(origin, destination, packages, response, options)
      xml = build_document(response, 'RateReply')

      success = response_success?(xml)
      message = response_message(xml)

      rate_estimates = xml.root.css('> RateReplyDetails').map do |rated_shipment|
        service_code = rated_shipment.at('ServiceType').text
        is_saturday_delivery = rated_shipment.at('AppliedOptions').try(:text) == 'SATURDAY_DELIVERY'
        service_type = is_saturday_delivery ? "#{service_code}_SATURDAY_DELIVERY" : service_code

        transit_time = rated_shipment.at('TransitTime').text if service_code == "FEDEX_GROUND"
        max_transit_time = rated_shipment.at('MaximumTransitTime').try(:text) if service_code == "FEDEX_GROUND"

        delivery_timestamp = rated_shipment.at('DeliveryTimestamp').try(:text)

        delivery_range = delivery_range_from(transit_time, max_transit_time, delivery_timestamp, options)

        currency = rated_shipment.at('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').text
        RateEstimate.new(origin, destination, @@name,
             self.class.service_name_for_code(service_type),
             :service_code => service_code,
             :total_price => rated_shipment.at('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').text.to_f,
             :currency => currency,
             :packages => packages,
             :delivery_range => delivery_range)
      end

      if rate_estimates.empty?
        success = false
        message = "No shipping rates could be found for the destination address" if message.blank?
      end

      RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request, :log_xml => options[:log_xml])
    end

    def delivery_range_from(transit_time, max_transit_time, delivery_timestamp, options)
      delivery_range = [delivery_timestamp, delivery_timestamp]

      # if there's no delivery timestamp but we do have a transit time, use it
      if delivery_timestamp.blank? && transit_time.present?
        transit_range  = parse_transit_times([transit_time, max_transit_time.presence || transit_time])
        delivery_range = transit_range.map { |days| business_days_from(ship_date(options[:turn_around_time]), days) }
      end

      delivery_range
    end

    def business_days_from(date, days)
      future_date = date
      count       = 0

      while count < days
        future_date += 1.day
        count += 1 if business_day?(future_date)
      end

      future_date
    end

    def business_day?(date)
      (1..5).include?(date.wday)
    end

    def parse_tracking_response(response, options)
      xml = build_document(response, 'TrackReply')

      success = response_success?(xml)
      message = response_message(xml)

      if success
        origin = nil
        delivery_signature = nil
        shipment_events = []

        all_tracking_details = xml.root.xpath('CompletedTrackDetails/TrackDetails')
        tracking_details = case all_tracking_details.length
          when 1
            all_tracking_details.first
          when 0
            raise ActiveShipping::Error, "The response did not contain tracking details"
          else
            all_unique_identifiers = xml.root.xpath('CompletedTrackDetails/TrackDetails/TrackingNumberUniqueIdentifier').map(&:text)
            raise ActiveShipping::Error, "Multiple matches were found. Specify a unqiue identifier: #{all_unique_identifiers.join(', ')}"
        end


        first_notification = tracking_details.at('Notification')
        if first_notification.at('Severity').text == 'ERROR'
          case first_notification.at('Code').text
          when '9040'
            raise ActiveShipping::ShipmentNotFound, first_notification.at('Message').text
          else
            raise ActiveShipping::ResponseContentError, first_notification.at('Message').text
          end
        end

        tracking_number = tracking_details.at('TrackingNumber').text
        status_detail = tracking_details.at('StatusDetail')
        if status_detail.nil?
          raise ActiveShipping::Error, "Tracking response does not contain status information"
        end

        status_code = status_detail.at('Code').text
        status_description = (status_detail.at('AncillaryDetails/ReasonDescription') || status_detail.at('Description')).text
        status = TRACKING_STATUS_CODES[status_code]

        if status_code == 'DL' && tracking_details.at('AvailableImages').try(:text) == 'SIGNATURE_PROOF_OF_DELIVERY'
          delivery_signature = tracking_details.at('DeliverySignatureName').text
        end

        if origin_node = tracking_details.at('OriginLocationAddress')
          origin = Location.new(
                :country =>     origin_node.at('CountryCode').text,
                :province =>    origin_node.at('StateOrProvinceCode').text,
                :city =>        origin_node.at('City').text
          )
        end

        destination = extract_address(tracking_details, DELIVERY_ADDRESS_NODE_NAMES)
        shipper_address = extract_address(tracking_details, SHIPPER_ADDRESS_NODE_NAMES)

        ship_time = extract_timestamp(tracking_details, 'ShipTimestamp')
        actual_delivery_time = extract_timestamp(tracking_details, 'ActualDeliveryTimestamp')
        scheduled_delivery_time = extract_timestamp(tracking_details, 'EstimatedDeliveryTimestamp')

        tracking_details.xpath('Events').each do |event|
          address  = event.at('Address')
          next if address.nil? || address.at('CountryCode').nil?

          city     = address.at('City').try(:text)
          state    = address.at('StateOrProvinceCode').try(:text)
          zip_code = address.at('PostalCode').try(:text)
          country  = address.at('CountryCode').try(:text)

          location = Location.new(:city => city, :state => state, :postal_code => zip_code, :country => country)
          description = event.at('EventDescription').text

          time          = Time.parse(event.at('Timestamp').text)
          zoneless_time = time.utc

          shipment_events << ShipmentEvent.new(description, zoneless_time, location)
        end
        shipment_events = shipment_events.sort_by(&:time)

      end

      TrackingResponse.new(success, message, Hash.from_xml(response),
                           :carrier => @@name,
                           :xml => response,
                           :request => last_request,
                           :status => status,
                           :status_code => status_code,
                           :status_description => status_description,
                           :ship_time => ship_time,
                           :scheduled_delivery_date => scheduled_delivery_time,
                           :actual_delivery_date => actual_delivery_time,
                           :delivery_signature => delivery_signature,
                           :shipment_events => shipment_events,
                           :shipper_address => (shipper_address.nil? || shipper_address.unknown?) ? nil : shipper_address,
                           :origin => origin,
                           :destination => destination,
                           :tracking_number => tracking_number
      )
    end

    def ship_timestamp(delay_in_hours)
      delay_in_hours ||= 0
      Time.now + delay_in_hours.hours
    end

    def ship_date(delay_in_hours)
      delay_in_hours ||= 0
      (Time.now + delay_in_hours.hours).to_date
    end

    def response_success?(document)
      highest_severity = document.root.at('HighestSeverity')
      return false if highest_severity.nil?
      %w(SUCCESS WARNING NOTE).include?(highest_severity.text)
    end

    def response_message(document)
      notifications = document.root.at('Notifications')
      return "" if notifications.nil?

      "#{notifications.at('Severity').text} - #{notifications.at('Code').text}: #{notifications.at('Message').text}"
    end

    def commit(request, test = false)
      ssl_post(test ? TEST_URL : LIVE_URL, request.gsub("\n", ''))
    end

    def parse_transit_times(times)
      results = []
      times.each do |day_count|
        days = TRANSIT_TIMES.index(day_count.to_s.chomp)
        results << days.to_i
      end
      results
    end

    def extract_address(document, possible_node_names)
      node = nil
      args = {}
      possible_node_names.each do |name|
        node = document.at(name)
        break if node
      end

      if node
        args[:country] =
          node.at('CountryCode').try(:text) ||
          ActiveUtils::Country.new(:alpha2 => 'ZZ', :name => 'Unknown or Invalid Territory', :alpha3 => 'ZZZ', :numeric => '999')

        args[:province] = node.at('StateOrProvinceCode').try(:text) || 'unknown'
        args[:city] = node.at('City').try(:text) || 'unknown'
      end

      Location.new(args)
    end

    def extract_timestamp(document, node_name)
      if timestamp_node = document.at(node_name)
        if timestamp_node.text =~ /\A(\d{4}-\d{2}-\d{2})T00:00:00\Z/
          Date.parse($1)
        else
          Time.parse(timestamp_node.text)
        end
      end
    end

    def build_document(xml, expected_root_tag)
      document = Nokogiri.XML(xml) { |config| config.strict }
      document.remove_namespaces!
      if document.root.nil? || document.root.name != expected_root_tag
        raise ActiveShipping::ResponseContentError.new(StandardError.new('Invalid document'), xml)
      end
      document
    rescue Nokogiri::XML::SyntaxError => e
      raise ActiveShipping::ResponseContentError.new(e, xml)
    end
  end
end