<commit_before>module ActiveShipping
  class CanadaPostPWS < Carrier
    TRACKING_STATUS_CODES = HashWithIndifferentAccess.new(
      'Item in transit' => :in_transit,
      'Item out for delivery' => :out_for_delivery,
      'Shipment picked up by Canada Post' => :picked_up,
      'Delivered' => :delivered,
      'Delivered to community mailbox or parcel locker' => :delivered,
      'Delivered to building superintendent or security agent' => :delivered,
      'Electronic information submitted by shipper' => :registered,
    )
    
    cattr_reader :name
    @@name = "Canada Post PWS"

    SHIPPING_SERVICES = {
      "DOM.RP"        => "Regular Parcel",
      "DOM.EP"        => "Expedited Parcel",
      "DOM.XP"        => "Xpresspost",
      "DOM.XP.CERT"   => "Xpresspost Certified",
      "DOM.PC"        => "Priority",
      "DOM.LIB"       => "Library Books",

      "USA.EP"        => "Expedited Parcel USA",
      "USA.PW.ENV"    => "Priority Worldwide Envelope USA",
      "USA.PW.PAK"    => "Priority Worldwide pak USA",
      "USA.PW.PARCEL" => "Priority Worldwide Parcel USA",
      "USA.SP.AIR"    => "Small Packet USA Air",
      "USA.SP.SURF"   => "Small Packet USA Surface",
      "USA.XP"        => "Xpresspost USA",

      "INT.XP"        => "Xpresspost International",
      "INT.IP.AIR"    => "International Parcel Air",
      "INT.IP.SURF"   => "International Parcel Surface",
      "INT.PW.ENV"    => "Priority Worldwide Envelope Int'l",
      "INT.PW.PAK"    => "Priority Worldwide pak Int'l",
      "INT.PW.PARCEL" => "Priority Worldwide parcel Int'l",
      "INT.SP.AIR"    => "Small Packet International Air",
      "INT.SP.SURF"   => "Small Packet International Surface"
    }

    ENDPOINT = "https://soa-gw.canadapost.ca/"    # production

    SHIPMENT_MIMETYPE = "application/vnd.cpc.ncshipment+xml"
    RATE_MIMETYPE = "application/vnd.cpc.ship.rate+xml"
    TRACK_MIMETYPE = "application/vnd.cpc.track+xml"
    REGISTER_MIMETYPE = "application/vnd.cpc.registration+xml"

    LANGUAGE = {
      'en' => 'en-CA',
      'fr' => 'fr-CA'
    }

    SHIPPING_OPTIONS = [:d2po, :d2po_office_id, :cov, :cov_amount, :cod, :cod_amount, :cod_includes_shipping,
                        :cod_method_of_payment, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad,
                        :rase, :rts, :aban]

    RATES_OPTIONS = [:cov, :cov_amount, :cod, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad]

    MAX_WEIGHT = 30 # kg

    attr_accessor :language, :endpoint, :logger, :platform_id, :customer_number

    def initialize(options = {})
      @language = LANGUAGE[options[:language]] || LANGUAGE['en']
      @endpoint = options[:endpoint] || ENDPOINT
      @platform_id = options[:platform_id]
      @customer_number = options[:customer_number]
      super(options)
    end

    def requirements
      [:api_key, :secret]
    end

    def self.default_location
      {
        :country     => 'CA',
        :province    => 'ON',
        :city        => 'Ottawa',
        :address1    => '61A York St',
        :postal_code => 'K1N5T2'
      }
    end

    def find_rates(origin, destination, line_items = [], options = {}, package = nil, services = [])
      url = endpoint + "rs/ship/price"
      request  = build_rates_request(origin, destination, line_items, options, package, services)
      response = ssl_post(url, request, headers(options, RATE_MIMETYPE, RATE_MIMETYPE))
      parse_rates_response(response, origin, destination, !!options[:exclude_tax])
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def find_tracking_info(pin, options = {})
      response = ssl_get(tracking_url(pin), headers(options, TRACK_MIMETYPE))
      parse_tracking_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      if e.response
        error_response(e.response.body, CPPWSTrackingResponse)
      else
        CPPWSTrackingResponse.new(false, e.message, {}, :carrier => @@name)
      end
    rescue InvalidPinFormatError
      CPPWSTrackingResponse.new(false, "Invalid Pin Format", {}, :carrier => @@name)
    end

    # line_items should be a list of PackageItem's
    def create_shipment(origin, destination, package, line_items = [], options = {})
      request_body = build_shipment_request(origin, destination, package, line_items, options)
      response = ssl_post(create_shipment_url(options), request_body, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
      parse_shipment_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSShippingResponse)
    rescue MissingCustomerNumberError
      CPPWSShippingResponse.new(false, "Missing Customer Number", {}, :carrier => @@name)
    end

    def retrieve_shipment(shipping_id, options = {})
      response = ssl_post(shipment_url(shipping_id, options), nil, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
      parse_shipment_response(response)
    end

    def find_shipment_receipt(shipping_id, options = {})
      response = ssl_get(shipment_receipt_url(shipping_id, options), headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
      parse_shipment_receipt_response(response)
    end

    def retrieve_shipping_label(shipping_response, options = {})
      raise MissingShippingNumberError unless shipping_response && shipping_response.shipping_id
      ssl_get(shipping_response.label_url, headers(options, "application/pdf"))
    end

    def register_merchant(options = {})
      url = endpoint + "ot/token"
      response = ssl_post(url, nil, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE).merge("Content-Length" => "0"))
      parse_register_token_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRegisterResponse)
    end

    def retrieve_merchant_details(options = {})
      raise MissingTokenIdError unless token_id = options[:token_id]
      url = endpoint + "ot/token/#{token_id}"
      response = ssl_get(url, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE))
      parse_merchant_details_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSMerchantDetailsResponse)
    rescue Exception => e
      raise ResponseError.new(e.message)
    end

    def find_services(country = nil, options = {})
      response = ssl_get(services_url(country), headers(options, RATE_MIMETYPE))
      parse_services_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def find_service_options(service_code, country, options = {})
      response = ssl_get(services_url(country, service_code), headers(options, RATE_MIMETYPE))
      parse_service_options_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def find_option_details(option_code, options = {})
      url = endpoint + "rs/ship/option/#{option_code}"
      response = ssl_get(url, headers(options, RATE_MIMETYPE))
      parse_option_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def maximum_weight
      Mass.new(MAX_WEIGHT, :kilograms)
    end

    def maximum_address_field_length
      # https://www.canadapost.ca/cpo/mc/business/productsservices/developers/services/shippingmanifest/createshipment.jsf
      44
    end

    # service discovery

    def parse_services_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!
      service_nodes = doc.xpath('services/service')
      service_nodes.inject({}) do |result, node|
        service_code = node.at("service-code").text
        service_name = node.at("service-name").text
        service_link = node.at("link").attributes['href'].value
        service_link_media_type = node.at("link").attributes['media-type'].value
        result[service_code] = {
          :name => service_name,
          :link => service_link,
          :link_media_type => service_link_media_type
        }
        result
      end
    end

    def parse_service_options_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!

      service_code = doc.root.at("service-code").text
      service_name = doc.root.at("service-name").text

      option_nodes = doc.root.xpath('options/option')
      options = option_nodes.map do |node|
        option = {
          :code => node.at("option-code").text,
          :name => node.at("option-name").text,
          :required => node.at("mandatory").text != "false",
          :qualifier_required => node.at("qualifier-required").text != "false",
        }
        option[:qualifier_max] = node.at("qualifier-max").text.to_i if node.at("qualifier-max")
        option
      end

      restrictions_node = doc.root.at('restrictions')
      dimensions_node = restrictions_node.at('dimensional-restrictions')
      restrictions = {
        :min_weight => restrictions_node.at("weight-restriction").attributes['min'].value.to_i,
        :max_weight => restrictions_node.at("weight-restriction").attributes['max'].value.to_i,
        :min_length => dimensions_node.at("length").attributes['min'].value.to_f,
        :max_length => dimensions_node.at("length").attributes['max'].value.to_f,
        :min_height => dimensions_node.at("height").attributes['min'].value.to_f,
        :max_height => dimensions_node.at("height").attributes['max'].value.to_f,
        :min_width => dimensions_node.at("width").attributes['min'].value.to_f,
        :max_width => dimensions_node.at("width").attributes['max'].value.to_f
      }

      {
        :service_code => service_code,
        :service_name => service_name,
        :options => options,
        :restrictions => restrictions
      }
    end

    def parse_option_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!

      conflicts = doc.root.xpath('conflicting-options/option-code').map(&:text)
      prereqs = doc.root.xpath('prerequisite-options/option-code').map(&:text)
      option = {
        :code => doc.root.at('option-code').text,
        :name => doc.root.at('option-name').text,
        :class => doc.root.at('option-class').text,
        :prints_on_label => doc.root.at('prints-on-label').text != "false",
        :qualifier_required => doc.root.at('qualifier-required').text != "false",
      }
      option[:conflicting_options] = conflicts if conflicts
      option[:prerequisite_options] = prereqs if prereqs

      option[:qualifier_max] = doc.root.at("qualifier-max").text.to_i if doc.root.at("qualifier-max")
      option
    end

    # rating

    def build_rates_request(origin, destination, line_items = [], options = {}, package = nil, services = [])
      line_items = Array(line_items)

      builder = Nokogiri::XML::Builder.new do |xml|
        xml.public_send('mailing-scenario', :xmlns => "http://www.canadapost.ca/ws/ship/rate") do
          customer_number_node(xml, options)
          contract_id_node(xml, options)
          quote_type_node(xml, options)
          expected_mailing_date_node(xml, shipping_date(options)) if options[:shipping_date]
          shipping_options_node(xml, RATES_OPTIONS, options)
          parcel_node(xml, line_items, package)
          origin_node(xml, origin)
          destination_node(xml, destination)
          services_node(xml, services) unless services.blank?
        end
      end
      builder.to_xml
    end

    def parse_rates_response(response, origin, destination, exclude_tax)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!
      raise ActiveShipping::ResponseError, "No Quotes" unless doc.at('price-quotes')

      rates = doc.root.xpath('price-quote').map do |node|
        service_name  = node.at("service-name").text
        service_code  = node.at("service-code").text
        total_price   = price_from_node(node, exclude_tax)
        expected_date = expected_date_from_node(node)
        options = {
          :service_code   => service_code,
          :total_price    => total_price,
          :currency       => 'CAD',
          :delivery_range => [expected_date, expected_date]
        }
        RateEstimate.new(origin, destination, @@name, service_name, options)
      end
      CPPWSRateResponse.new(true, "", {}, :rates => rates)
    end

    def price_from_node(node, exclude_tax)
      price = node.at('price-details/due').text
      return price unless exclude_tax
      children = node.at('price-details/taxes').children
      tax_total_cents = children.sum { |node| node.elem? ? Package.cents_from(node.text) : 0 }
      Package.cents_from(price) - tax_total_cents
    end

    # tracking

    def parse_tracking_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!
      raise ActiveShipping::ResponseError, "No Tracking" unless doc.at('tracking-detail')

      events = doc.root.xpath('significant-events/occurrence')

      shipment_events  = build_tracking_events(events)
      change_date      = doc.root.at('changed-expected-date').text
      expected_date    = doc.root.at('expected-delivery-date').text
      dest_postal_code = doc.root.at('destination-postal-id').text
      destination      = Location.new(:postal_code => dest_postal_code)
      origin           = Location.new(origin_hash_for(doc.root))
      status           = TRACKING_STATUS_CODES[shipment_events.first.message]
      options = {
        :carrier                 => @@name,
        :service_name            => doc.root.at('service-name').text,
        :expected_date           => expected_date.blank? ? nil : Date.parse(expected_date),
        :changed_date            => change_date.blank? ? nil : Date.parse(change_date),
        :change_reason           => doc.root.at('changed-expected-delivery-reason').text.strip,
        :destination_postal_code => doc.root.at('destination-postal-id').text,
        :shipment_events         => shipment_events,
        :tracking_number         => doc.root.at('pin').text,
        :origin                  => origin,
        :destination             => destination,
        :status                  => status,
        :customer_number         => doc.root.at('mailed-by-customer-number').text
      }

      CPPWSTrackingResponse.new(true, "", {}, options)
    end

    def build_tracking_events(events)
      events.map do |event|
        date      = event.at('event-date').text
        time      = event.at('event-time').text
        zone      = event.at('event-time-zone').text
        timestamp = DateTime.parse("#{date} #{time} #{zone}")
        time      = Time.utc(timestamp.utc.year, timestamp.utc.month, timestamp.utc.day, timestamp.utc.hour, timestamp.utc.min, timestamp.utc.sec)
        name   = event.at('event-description').text
        message   = event.at('event-description').text
        location  = [event.at('event-retail-name'), event.at('event-site'), event.at('event-province')].
                      reject { |e| e.nil? || e.text.empty? }.join(", ")<commit_msg>canada post<commit_after>module ActiveShipping
  class CanadaPostPWS < Carrier
    TRACKING_STATUS_CODES = HashWithIndifferentAccess.new(
      'Item in transit' => :in_transit,
      'Item out for delivery' => :out_for_delivery,
      'Shipment picked up by Canada Post' => :picked_up,
      'Delivered' => :delivered,
      'Delivered to community mailbox or parcel locker' => :delivered,
      'Delivered to building superintendent or security agent' => :delivered,
      'Electronic information submitted by shipper' => :registered,
    )
    
    cattr_reader :name
    @@name = "Canada Post PWS"

    SHIPPING_SERVICES = {
      "DOM.RP"        => "Regular Parcel",
      "DOM.EP"        => "Expedited Parcel",
      "DOM.XP"        => "Xpresspost",
      "DOM.XP.CERT"   => "Xpresspost Certified",
      "DOM.PC"        => "Priority",
      "DOM.LIB"       => "Library Books",

      "USA.EP"        => "Expedited Parcel USA",
      "USA.PW.ENV"    => "Priority Worldwide Envelope USA",
      "USA.PW.PAK"    => "Priority Worldwide pak USA",
      "USA.PW.PARCEL" => "Priority Worldwide Parcel USA",
      "USA.SP.AIR"    => "Small Packet USA Air",
      "USA.SP.SURF"   => "Small Packet USA Surface",
      "USA.XP"        => "Xpresspost USA",

      "INT.XP"        => "Xpresspost International",
      "INT.IP.AIR"    => "International Parcel Air",
      "INT.IP.SURF"   => "International Parcel Surface",
      "INT.PW.ENV"    => "Priority Worldwide Envelope Int'l",
      "INT.PW.PAK"    => "Priority Worldwide pak Int'l",
      "INT.PW.PARCEL" => "Priority Worldwide parcel Int'l",
      "INT.SP.AIR"    => "Small Packet International Air",
      "INT.SP.SURF"   => "Small Packet International Surface"
    }

    ENDPOINT = "https://soa-gw.canadapost.ca/"    # production

    SHIPMENT_MIMETYPE = "application/vnd.cpc.ncshipment+xml"
    RATE_MIMETYPE = "application/vnd.cpc.ship.rate+xml"
    TRACK_MIMETYPE = "application/vnd.cpc.track+xml"
    REGISTER_MIMETYPE = "application/vnd.cpc.registration+xml"

    LANGUAGE = {
      'en' => 'en-CA',
      'fr' => 'fr-CA'
    }

    SHIPPING_OPTIONS = [:d2po, :d2po_office_id, :cov, :cov_amount, :cod, :cod_amount, :cod_includes_shipping,
                        :cod_method_of_payment, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad,
                        :rase, :rts, :aban]

    RATES_OPTIONS = [:cov, :cov_amount, :cod, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad]

    MAX_WEIGHT = 30 # kg

    attr_accessor :language, :endpoint, :logger, :platform_id, :customer_number

    def initialize(options = {})
      @language = LANGUAGE[options[:language]] || LANGUAGE['en']
      @endpoint = options[:endpoint] || ENDPOINT
      @platform_id = options[:platform_id]
      @customer_number = options[:customer_number]
      super(options)
    end

    def requirements
      [:api_key, :secret]
    end

    def self.default_location
      {
        :country     => 'CA',
        :province    => 'ON',
        :city        => 'Ottawa',
        :address1    => '61A York St',
        :postal_code => 'K1N5T2'
      }
    end

    def find_rates(origin, destination, line_items = [], options = {}, package = nil, services = [])
      url = endpoint + "rs/ship/price"
      request  = build_rates_request(origin, destination, line_items, options, package, services)
      response = ssl_post(url, request, headers(options, RATE_MIMETYPE, RATE_MIMETYPE))
      parse_rates_response(response, origin, destination, !!options[:exclude_tax])
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def find_tracking_info(pin, options = {})
      response = ssl_get(tracking_url(pin), headers(options, TRACK_MIMETYPE))
      parse_tracking_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      if e.response
        error_response(e.response.body, CPPWSTrackingResponse)
      else
        CPPWSTrackingResponse.new(false, e.message, {}, :carrier => @@name)
      end
    rescue InvalidPinFormatError
      CPPWSTrackingResponse.new(false, "Invalid Pin Format", {}, :carrier => @@name)
    end

    # line_items should be a list of PackageItem's
    def create_shipment(origin, destination, package, line_items = [], options = {})
      request_body = build_shipment_request(origin, destination, package, line_items, options)
      response = ssl_post(create_shipment_url(options), request_body, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
      parse_shipment_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSShippingResponse)
    rescue MissingCustomerNumberError
      CPPWSShippingResponse.new(false, "Missing Customer Number", {}, :carrier => @@name)
    end

    def retrieve_shipment(shipping_id, options = {})
      response = ssl_post(shipment_url(shipping_id, options), nil, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
      parse_shipment_response(response)
    end

    def find_shipment_receipt(shipping_id, options = {})
      response = ssl_get(shipment_receipt_url(shipping_id, options), headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
      parse_shipment_receipt_response(response)
    end

    def retrieve_shipping_label(shipping_response, options = {})
      raise MissingShippingNumberError unless shipping_response && shipping_response.shipping_id
      ssl_get(shipping_response.label_url, headers(options, "application/pdf"))
    end

    def register_merchant(options = {})
      url = endpoint + "ot/token"
      response = ssl_post(url, nil, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE).merge("Content-Length" => "0"))
      parse_register_token_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRegisterResponse)
    end

    def retrieve_merchant_details(options = {})
      raise MissingTokenIdError unless token_id = options[:token_id]
      url = endpoint + "ot/token/#{token_id}"
      response = ssl_get(url, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE))
      parse_merchant_details_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSMerchantDetailsResponse)
    rescue Exception => e
      raise ResponseError.new(e.message)
    end

    def find_services(country = nil, options = {})
      response = ssl_get(services_url(country), headers(options, RATE_MIMETYPE))
      parse_services_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def find_service_options(service_code, country, options = {})
      response = ssl_get(services_url(country, service_code), headers(options, RATE_MIMETYPE))
      parse_service_options_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def find_option_details(option_code, options = {})
      url = endpoint + "rs/ship/option/#{option_code}"
      response = ssl_get(url, headers(options, RATE_MIMETYPE))
      parse_option_response(response)
    rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
      error_response(e.response.body, CPPWSRateResponse)
    end

    def maximum_weight
      Mass.new(MAX_WEIGHT, :kilograms)
    end

    def maximum_address_field_length
      # https://www.canadapost.ca/cpo/mc/business/productsservices/developers/services/shippingmanifest/createshipment.jsf
      44
    end

    # service discovery

    def parse_services_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!
      service_nodes = doc.xpath('services/service')
      service_nodes.inject({}) do |result, node|
        service_code = node.at("service-code").text
        service_name = node.at("service-name").text
        service_link = node.at("link").attributes['href'].value
        service_link_media_type = node.at("link").attributes['media-type'].value
        result[service_code] = {
          :name => service_name,
          :link => service_link,
          :link_media_type => service_link_media_type
        }
        result
      end
    end

    def parse_service_options_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!

      service_code = doc.root.at("service-code").text
      service_name = doc.root.at("service-name").text

      option_nodes = doc.root.xpath('options/option')
      options = option_nodes.map do |node|
        option = {
          :code => node.at("option-code").text,
          :name => node.at("option-name").text,
          :required => node.at("mandatory").text != "false",
          :qualifier_required => node.at("qualifier-required").text != "false",
        }
        option[:qualifier_max] = node.at("qualifier-max").text.to_i if node.at("qualifier-max")
        option
      end

      restrictions_node = doc.root.at('restrictions')
      dimensions_node = restrictions_node.at('dimensional-restrictions')
      restrictions = {
        :min_weight => restrictions_node.at("weight-restriction").attributes['min'].value.to_i,
        :max_weight => restrictions_node.at("weight-restriction").attributes['max'].value.to_i,
        :min_length => dimensions_node.at("length").attributes['min'].value.to_f,
        :max_length => dimensions_node.at("length").attributes['max'].value.to_f,
        :min_height => dimensions_node.at("height").attributes['min'].value.to_f,
        :max_height => dimensions_node.at("height").attributes['max'].value.to_f,
        :min_width => dimensions_node.at("width").attributes['min'].value.to_f,
        :max_width => dimensions_node.at("width").attributes['max'].value.to_f
      }

      {
        :service_code => service_code,
        :service_name => service_name,
        :options => options,
        :restrictions => restrictions
      }
    end

    def parse_option_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!

      conflicts = doc.root.xpath('conflicting-options/option-code').map(&:text)
      prereqs = doc.root.xpath('prerequisite-options/option-code').map(&:text)
      option = {
        :code => doc.root.at('option-code').text,
        :name => doc.root.at('option-name').text,
        :class => doc.root.at('option-class').text,
        :prints_on_label => doc.root.at('prints-on-label').text != "false",
        :qualifier_required => doc.root.at('qualifier-required').text != "false",
      }
      option[:conflicting_options] = conflicts if conflicts
      option[:prerequisite_options] = prereqs if prereqs

      option[:qualifier_max] = doc.root.at("qualifier-max").text.to_i if doc.root.at("qualifier-max")
      option
    end

    # rating

    def build_rates_request(origin, destination, line_items = [], options = {}, package = nil, services = [])
      line_items = Array(line_items)

      builder = Nokogiri::XML::Builder.new do |xml|
        xml.public_send('mailing-scenario', :xmlns => "http://www.canadapost.ca/ws/ship/rate") do
          customer_number_node(xml, options)
          contract_id_node(xml, options)
          quote_type_node(xml, options)
          expected_mailing_date_node(xml, shipping_date(options)) if options[:shipping_date]
          shipping_options_node(xml, RATES_OPTIONS, options)
          parcel_node(xml, line_items, package)
          origin_node(xml, origin)
          destination_node(xml, destination)
          services_node(xml, services) unless services.blank?
        end
      end
      builder.to_xml
    end

    def parse_rates_response(response, origin, destination, exclude_tax)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!
      raise ActiveShipping::ResponseError, "No Quotes" unless doc.at('price-quotes')

      rates = doc.root.xpath('price-quote').map do |node|
        service_name  = node.at("service-name").text
        service_code  = node.at("service-code").text
        total_price   = price_from_node(node, exclude_tax)
        expected_date = expected_date_from_node(node)
        options = {
          :service_code   => service_code,
          :total_price    => total_price,
          :currency       => 'CAD',
          :delivery_range => [expected_date, expected_date]
        }
        RateEstimate.new(origin, destination, @@name, service_name, options)
      end
      CPPWSRateResponse.new(true, "", {}, :rates => rates)
    end

    def price_from_node(node, exclude_tax)
      price = node.at('price-details/due').text
      return price unless exclude_tax
      children = node.at('price-details/taxes').children
      tax_total_cents = children.sum { |node| node.elem? ? Package.cents_from(node.text) : 0 }
      Package.cents_from(price) - tax_total_cents
    end

    # tracking

    def parse_tracking_response(response)
      doc = Nokogiri.XML(response)
      doc.remove_namespaces!
      raise ActiveShipping::ResponseError, "No Tracking" unless doc.at('tracking-detail')

      events = doc.root.xpath('significant-events/occurrence')

      shipment_events  = build_tracking_events(events)
      change_date      = doc.root.at('changed-expected-date').text
      expected_date    = doc.root.at('expected-delivery-date').text
      dest_postal_code = doc.root.at('destination-postal-id').text
      destination      = Location.new(:postal_code => dest_postal_code)
      origin           = Location.new(origin_hash_for(doc.root))
      status           = TRACKING_STATUS_CODES[shipment_events.first.message]
      options = {
        :carrier                 => @@name,
        :service_name            => doc.root.at('service-name').text,
        :expected_date           => expected_date.blank? ? nil : Date.parse(expected_date),
        :changed_date            => change_date.blank? ? nil : Date.parse(change_date),
        :change_reason           => doc.root.at('changed-expected-delivery-reason').text.strip,
        :destination_postal_code => doc.root.at('destination-postal-id').text,
        :shipment_events         => shipment_events,
        :tracking_number         => doc.root.at('pin').text,
        :origin                  => origin,
        :destination             => destination,
        :status                  => status,
        :customer_number         => doc.root.at('mailed-by-customer-number').text
      }

      if status == :delivered
        options[:actual_delivery_date] = shipment_events.first.time
      end

      CPPWSTrackingResponse.new(true, "", {}, options)
    end

    def build_tracking_events(events)
      events.map do |event|
        date      = event.at('event-date').text
        time      = event.at('event-time').text
        zone      = event.at('event-time-zone').text
        timestamp = DateTime.parse("#{date} #{time} #{zone}")
        time      = Time.utc(timestamp.utc.year, timestamp.utc.month, timestamp.utc.day, timestamp.utc.hour, timestamp.utc.min, timestamp.utc.sec)
        name   = event.at('event-description').text
        message   = event.at('event-description').text
        location  = [event.at('event-retail-name'), event.at('event-site'), event.at('event-province')].
                      reject { |e| e.nil? || e.text.empty? }.join(", ")
{'fraction_non_alphanumeric': 0.1263605939500276, 'fraction_numerical': 0.0014296390161484226, 'mean_word_length': 3.055606799314798, 'pattern_counts': {'":': 0, '<': 5, '<?xml version=': 0, '>': 185, 'https://': 4, 'lorem ipsum': 0, 'www.': 4, 'xml': 34}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '7051404', 'n_tokens_mistral': 9672, 'n_tokens_neox': 9454, 'n_words': 1641}