#!/usr/bin/env ruby -w
# encoding: UTF-8
#
# = FitMessageRecord.rb -- Fit4Ruby - FIT file processing library for Ruby
#
# Copyright (c) 2015, 2016, 2017, 2018 by Chris Schlaeger <cs@taskjuggler.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#

require 'fit4ruby/Activity'
require 'fit4ruby/Monitoring_B'
require 'fit4ruby/GlobalFitMessage'
require 'fit4ruby/Metrics'

module Fit4Ruby

  # The FIT file is a generic container for all kinds of data. This could be
  # activity data, config files, workout definitions, etc. All data is stored
  # in FIT message records. Also the information what kind of FIT file this is
  # is stored in such a record. When we start reading the file, we actually
  # don't know what kind of file it is until we find the right record to tell
  # us. Since we already need to have gathered some information at this point,
  # we use this utility class to store the read data until we know what Ruby
  # objec we need to use to store it for later consumption.
  class FitFileEntity

    attr_reader :top_level_record, :developer_fit_messages

    # Create a FitFileEntity.
    def initialize
      @top_level_record = nil
      @developer_fit_messages = GlobalFitMessageList.new
    end

    # Set what kind of FIT file we are dealing with.
    # @return The Ruby object that will hold the content of the FIT file. It's
    # a derivative of FitDataRecord.
    def set_type(type)
      if @top_level_record
        Log.fatal "FIT file type has already been set to " +
                  "#{@top_level_record.class}"
      end
      case type
      when 4, 'activity'
        @top_level_record = Activity.new
        @type = 'activity'
      when 32, 'monitoring_b'
        @top_level_record = Monitoring_B.new
        @type = 'monitoring_b'
      when 44, 'metrics'
        @top_level_record = Metrics.new
        @type = 'metrics'
      else
        Log.error "Unsupported FIT file type #{type}"
        return nil
      end

      @top_level_record
    end

    # Add a new data record to the top-level object.
    def new_fit_data_record(type)
      return nil unless @top_level_record

      # We already have a record for the top-level type. Just return it.
      return @top_level_record if type == @type

      # For all other types, we need to create a new record inside the
      # top-level record.
      @top_level_record.new_fit_data_record(type)
    end

    # Check the consistency of the top-level object.
    def check
      return false unless @top_level_record
      @top_level_record.check
    end

    # Write the top-level object into a IO stream.
    def write(io, id_mapper)
      return unless @top_level_record
      @top_level_record.write(io, id_mapper)
    end

  end

end


#!/usr/bin/env ruby -w
# encoding: UTF-8
#
# = Activity.rb -- Fit4Ruby - FIT file processing library for Ruby
#
# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#

require 'fit4ruby/FitDataRecord'
require 'fit4ruby/FileId'
require 'fit4ruby/FieldDescription'
require 'fit4ruby/DeveloperDataId'
require 'fit4ruby/EPO_Data'
require 'fit4ruby/FileCreator'
require 'fit4ruby/DeviceInfo'
require 'fit4ruby/SensorSettings'
require 'fit4ruby/DataSources'
require 'fit4ruby/UserData'
require 'fit4ruby/UserProfile'
require 'fit4ruby/PhysiologicalMetrics'
require 'fit4ruby/Session'
require 'fit4ruby/Lap'
require 'fit4ruby/Record'
require 'fit4ruby/HRV'
require 'fit4ruby/HeartRateZones'
require 'fit4ruby/Event'
require 'fit4ruby/PersonalRecords'

module Fit4Ruby

  # This is the most important class of this library. It holds references to
  # all other data structures. Each of the objects it references are direct
  # equivalents of the message record structures used in the FIT file.
  class Activity < FitDataRecord

    attr_accessor :file_id, :field_descriptions, :developer_data_ids, :epo_data,
                  :file_creator, :device_infos, :sensor_settings, :data_sources,
                  :user_data, :user_profiles, :physiological_metrics,
                  :sessions, :laps, :records, :hrv,
                  :heart_rate_zones, :events, :personal_records

    # Create a new Activity object.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    def initialize(field_values = {})
      super('activity')
      @meta_field_units['total_gps_distance'] = 'm'
      @num_sessions = 0

      @file_id = FileId.new
      @field_descriptions = []
      @developer_data_ids = []
      @epo_data = nil
      @file_creator = FileCreator.new
      @device_infos = []
      @sensor_settings = []
      @data_sources = []
      @user_data = []
      @user_profiles = []
      @physiological_metrics = []
      @events = []
      @sessions = []
      @laps = []
      @records = []
      @hrv = []
      @heart_rate_zones = []
      @personal_records = []

      @cur_session_laps = []
      @cur_lap_records = []

      @lap_counter = 1

      set_field_values(field_values)
    end

    # Perform some basic logical checks on the object and all references sub
    # objects. Any errors will be reported via the Log object.
    def check
      unless @timestamp && @timestamp >= Time.parse('1990-01-01T00:00:00+00:00')
        Log.fatal "Activity has no valid timestamp"
      end
      unless @total_timer_time
        Log.fatal "Activity has no valid total_timer_time"
      end
      unless @device_infos.length > 0
        Log.fatal "Activity must have at least one device_info section"
      end
      @device_infos.each.with_index { |d, index| d.check(index) }
      @sensor_settings.each.with_index { |s, index| s.check(index) }
      unless @num_sessions == @sessions.count
        Log.fatal "Activity record requires #{@num_sessions}, but "
                  "#{@sessions.length} session records were found in the "
                  "FIT file."
      end

      # Records must have consecutively growing timestamps and distances.
      ts = Time.parse('1989-12-31')
      distance = nil
      invalid_records = []
      @records.each_with_index do |r, idx|
        Log.fatal "Record has no timestamp" unless r.timestamp
        if r.timestamp < ts
          Log.fatal "Record has earlier timestamp than previous record"
        end
        if r.distance
          if distance && r.distance < distance
            # Normally this should be a fatal error as the FIT file is clearly
            # broken. Unfortunately, the Skiing/Boarding app in the Fenix3
            # produces such broken FIT files. So we just warn about this
            # problem and discard the earlier records.
            Log.error "Record #{r.timestamp} has smaller distance " +
                      "(#{r.distance}) than an earlier record (#{distance})."
            # Index of the list record to be discarded.
            (idx - 1).downto(0) do |i|
              if (ri = @records[i]).distance > r.distance
                # This is just an approximation. It looks like the app adds
                # records to the FIT file for runs that it meant to discard.
                # Maybe the two successive time start events are a better
                # criteria. But this workaround works for now.
                invalid_records << ri
              else
                # All broken records have been found.
                break
              end
            end
          end
          distance = r.distance
        end
        ts = r.timestamp
      end
      unless invalid_records.empty?
        # Delete all the broken records from the @records Array.
        Log.warn "Discarding #{invalid_records.length} earlier records"
        @records.delete_if { |r| invalid_records.include?(r) }
      end

      # Laps must have a consecutively growing message index.
      @laps.each.with_index do |lap, index|
        lap.check(index)
        # If we have heart rate zone records, there should be one for each
        # lap
        @heart_rate_zones[index].check(index) if @heart_rate_zones[index]
      end
      @sessions.each { |s| s.check(self) }
    end

    # Convenience method that aggregates all the distances from the included
    # sessions.
    def total_distance
      d = 0.0
      @sessions.each { |s| d += s.total_distance }
      d
    end

    # Total distance convered by this activity purely computed by the GPS
    # coordinates. This may differ from the distance computed by the device as
    # it can be based on a purely calibrated footpod.
    def total_gps_distance
      timer_stops = []
      # Generate a list of all timestamps where the timer was stopped.
      @events.each do |e|
        if e.event == 'timer' && e.event_type == 'stop_all'
          timer_stops << e.timestamp
        end
      end

      # The first record of a FIT file can already have a distance associated
      # with it. The GPS location of the first record is not where the start
      # button was pressed. This introduces a slight inaccurcy when computing
      # the total distance purely on the GPS coordinates found in the records.
      d = 0.0
      last_lat = last_long = nil
      last_timestamp = nil

      # Iterate over all the records and accumlate the distances between the
      # neiboring coordinates.
      @records.each do |r|
        if (lat = r.position_lat) && (long = r.position_long)
          if last_lat && last_long
            distance = Fit4Ruby::GeoMath.distance(last_lat, last_long,
                                                  lat, long)
            d += distance
          end
          if last_timestamp
            speed = distance / (r.timestamp - last_timestamp)
          end
          if timer_stops[0] == r.timestamp
            # If a stop event was found for this record timestamp we clear the
            # last_* values so that the distance covered while being stopped
            # is not added to the total.
            last_lat = last_long = nil
            last_timestamp = nil
            timer_stops.shift
          else
            last_lat = lat
            last_long = long
            last_timestamp = r.timestamp
          end
        end
      end
      d
    end

    # Call this method to update the aggregated data fields stored in Lap and
    # Session objects.
    def aggregate
      @laps.each { |l| l.aggregate }
      @sessions.each { |s| s.aggregate }
    end

    # Convenience method that averages the speed over all sessions.
    def avg_speed
      speed = 0.0
      @sessions.each { |s| speed += s.avg_speed }
      speed / @sessions.length
    end

    # Return the heart rate when the activity recording was last stopped.
    def ending_hr
      @records.empty? ? nil : @records[-1].heart_rate
    end

    # Return the measured recovery heart rate.
    def recovery_hr
      @events.each do |e|
        return e.recovery_hr if e.event == 'recovery_hr'
      end

      nil
    end

    # Returns the remaining recovery time at the start of the activity.
    # @return remaining recovery time in seconds.
    def recovery_info
      @events.each do |e|
        return e.recovery_info if e.event == 'recovery_info'
      end

      nil
    end

    # Returns the predicted recovery time needed after this activity.
    # @return recovery time in seconds.
    def recovery_time
      @events.each do |e|
        return e.recovery_time if e.event == 'recovery_time'
      end

      nil
    end

    # Returns the computed VO2max value. This value is computed by the device
    # based on multiple previous activities.
    def vo2max
      # First check the event log for a vo2max reporting event.
      @events.each do |e|
        return e.vo2max if e.event == 'vo2max'
      end
      # Then check the user_data entries for a metmax entry. METmax * 3.5
      # is same value as VO2max.
      @user_data.each do |u|
        return u.metmax * 3.5 if u.metmax
      end

      nil
    end

    # Returns the sport type of this activity.
    def sport
      @sessions[0].sport
    end

    # Returns the sport subtype of this activity.
    def sub_sport
      @sessions[0].sub_sport
    end

    # Write the Activity data to a file.
    # @param io [IO] File reference
    # @param id_mapper [FitMessageIdMapper] Maps global FIT record types to
    #        local ones.
    def write(io, id_mapper)
      @file_id.write(io, id_mapper)
      @file_creator.write(io, id_mapper)

      (@field_descriptions + @developer_data_ids +
       @device_infos + @sensor_settings +
       @data_sources + @user_profiles +
       @physiological_metrics + @events +
       @sessions + @laps + @records + @heart_rate_zones +
       @personal_records).sort.each do |s|
        s.write(io, id_mapper)
      end
      super
    end

    # Add a new FileId to the Activity. It will replace any previously added
    # FileId object.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [FileId]
    def new_file_id(field_values = {})
      new_fit_data_record('file_id', field_values)
    end

    # Add a new FieldDescription to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [FieldDescription]
    def new_field_description(field_values = {})
      new_fit_data_record('field_description', field_values)
    end

    # Add a new DeveloperDataId to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [DeveloperDataId]
    def new_developer_data_id(field_values = {})
      new_fit_data_record('developer_data_id', field_values)
    end

    # Add a new FileCreator to the Activity. It will replace any previously
    # added FileCreator object.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [FileCreator]
    def new_file_creator(field_values = {})
      new_fit_data_record('file_creator', field_values)
    end

    # Add a new DeviceInfo to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [DeviceInfo]
    def new_device_info(field_values = {})
      new_fit_data_record('device_info', field_values)
    end

    # Add a new SourceData to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [SourceData]
    def new_data_sources(field_values = {})
      new_fit_data_record('data_sources', field_values)
    end

    # Add a new UserData to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [UserData]
    def new_user_data(field_values = {})
      new_fit_data_record('user_data', field_values)
    end

    # Add a new UserProfile to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [UserProfile]
    def new_user_profile(field_values = {})
      new_fit_data_record('user_profile', field_values)
    end

    # Add a new PhysiologicalMetrics to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [PhysiologicalMetrics]
    def new_physiological_metrics(field_values = {})
      new_fit_data_record('physiological_metrics', field_values)
    end

    # Add a new Event to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [Event]
    def new_event(field_values = {})
      new_fit_data_record('event', field_values)
    end

    # Add a new Session to the Activity. All previously added Lap objects are
    # associated with this Session unless they have been associated with
    # another Session before. If there are any Record objects that have not
    # yet been associated with a Lap, a new lap will be created and the
    # Record objects will be associated with this Lap. The Lap will be
    # associated with the newly created Session.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [Session]
    def new_session(field_values = {})
      new_fit_data_record('session', field_values)
    end

    # Add a new Lap to the Activity. All previoulsy added Record objects are
    # associated with this Lap unless they have been associated with another
    # Lap before.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [Lap]
    def new_lap(field_values = {})
      new_fit_data_record('lap', field_values)
    end

    # Add a new HeartRateZones record to the Activity.
    # @param field_values [Heash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [HeartRateZones]
    def new_heart_rate_zones(field_values = {})
      new_fit_data_record('heart_rate_zones', field_values)
    end

    # Add a new PersonalRecord to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [PersonalRecord]
    def new_personal_record(field_values = {})
      new_fit_data_record('personal_record', field_values)
    end

    # Add a new Record to the Activity.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return [Record]
    def new_record(field_values = {})
      new_fit_data_record('record', field_values)
    end

    # Check if the current Activity is equal to the passed Activity.
    # @param a [Activity] Activity to compare this Activity with.
    # @return [TrueClass/FalseClass] true if both Activities are equal,
    # otherwise false.
    def ==(a)
      super(a) && @file_id == a.file_id &&
        @file_creator == a.file_creator &&
        @field_descriptions == a.field_descriptions &&
        @developer_data_ids == a.developer_data_ids &&
        @device_infos == a.device_infos &&
        @sensor_settings == a.sensor_settings &&
        @data_sources == a.data_sources &&
        @physiological_metrics == a.physiological_metrics &&
        @user_profiles == a.user_profiles &&
        @heart_rate_zones == a.heart_rate_zones &&
        @events == a.events &&
        @sessions == a.sessions && personal_records == a.personal_records
    end

    # Create a new FitDataRecord.
    # @param record_type [String] Type that identifies the FitDataRecord
    #        derived class to create.
    # @param field_values [Hash] A Hash that provides initial values for
    #        certain fields of the FitDataRecord.
    # @return FitDataRecord
    def new_fit_data_record(record_type, field_values = {})
      case record_type
      when 'file_id'
        @file_id = (record = FileId.new(field_values))
      when 'field_description'
        @field_descriptions << (record = FieldDescription.new(field_values))
      when 'developer_data_id'
        @developer_data_ids << (record = DeveloperDataId.new(field_values))
      when 'epo_data'
        @epo_data = (record = EPO_Data.new(field_values))
      when 'file_creator'
        @file_creator = (record = FileCreator.new(field_values))
      when 'device_info'
        @device_infos << (record = DeviceInfo.new(field_values))
      when 'sensor_settings'
        @sensor_settings << (record = SensorSettings.new(field_values))
      when 'data_sources'
        @data_sources << (record = DataSources.new(field_values))
      when 'user_data'
        @user_data << (record = UserData.new(field_values))
      when 'user_profile'
        @user_profiles << (record = UserProfile.new(field_values))
      when 'physiological_metrics'
        @physiological_metrics <<
          (record = PhysiologicalMetrics.new(field_values))
      when 'event'
        @events << (record = Event.new(field_values))
      when 'session'
        unless @cur_lap_records.empty?
          # Copy selected fields from section to lap.
          lap_field_values = {}
          [ :timestamp, :sport ].each do |f|
            lap_field_values[f] = field_values[f] if field_values.include?(f)
          end
          # Ensure that all previous records have been assigned to a lap.
          record = create_new_lap(lap_field_values)
        end
        @num_sessions += 1
        @sessions << (record = Session.new(@cur_session_laps, @lap_counter,
                                           field_values))
        @cur_session_laps = []
      when 'lap'
        record = create_new_lap(field_values)
      when 'record'
        @cur_lap_records << (record = Record.new(field_values))
        @records << record
      when 'hrv'
        @hrv << (record = HRV.new(field_values))
      when 'heart_rate_zones'
        @heart_rate_zones << (record = HeartRateZones.new(field_values))
      when 'personal_records'
        @personal_records << (record = PersonalRecords.new(field_values))
      else
        record = nil
      end

      record
    end

    private

    def create_new_lap(field_values)
      lap = Lap.new(@cur_lap_records, @laps.last, field_values)
      lap.message_index = @lap_counter - 1
      @lap_counter += 1
      @cur_session_laps << lap
      @laps << lap
      @cur_lap_records = []

      lap
    end

  end

end


#!/usr/bin/env ruby -w
# encoding: UTF-8
#
# = Session.rb -- Fit4Ruby - FIT file processing library for Ruby
#
# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#

require 'fit4ruby/Converters'
require 'fit4ruby/FitDataRecord'
require 'fit4ruby/RecordAggregator'
require 'fit4ruby/GeoMath'

module Fit4Ruby

  # The Session objects correspond to the session FIT messages. They hold
  # accumlated data for a set of Lap objects.
  class Session < FitDataRecord

    include RecordAggregator

    attr_reader :laps, :records

    # Create a new Session object.
    # @param laps [Array of Laps] Laps to associate with the Session.
    # @param first_lap_index [Fixnum] Index of the first Lap in this Session.
    # @param field_values [Hash] Hash that provides initial values for certain
    #        fields.
    def initialize(laps, first_lap_index, field_values)
      super('session')
      @meta_field_units['avg_stride_length'] = 'm'
      @laps = laps
      @records = []
      @laps.each { |lap| @records += lap.records }
      @first_lap_index = first_lap_index
      @num_laps = @laps.length

      if @records.first
        # Or to the timestamp of the first record.
        @start_time = @records.first.timestamp
        if @records.last
          @total_elapsed_time = @records.last.timestamp - @start_time
        end
      end

      set_field_values(field_values)
    end

    # Perform some basic consistency and logical checks on the object. Errors
    # are reported via the Log object.
    def check(activity)
      unless @first_lap_index
        Log.fatal 'first_lap_index is not set'
      end
      unless @num_laps
        Log.fatal 'num_laps is not set'
      end
      @first_lap_index.upto(@first_lap_index - @num_laps) do |i|
        if (lap = activity.lap[i])
          @laps << lap
        else
          Log.fatal "Session references lap #{i} which is not contained in "
                    "the FIT file."
        end
      end
    end

    # Return true if the session contains geographical location data.
    def has_geo_data?
      @swc_long && @swc_lat && @nec_long && nec_lat
    end

    # Compute the average stride length for this Session.
    def avg_stride_length
      return nil unless @total_strides

      @total_distance / (@total_strides * 2.0)
    end

  end

end


#!/usr/bin/env ruby -w
# encoding: UTF-8
#
# = GlobalFitMessage.rb -- Fit4Ruby - FIT file processing library for Ruby
#
# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#

require 'fit4ruby/GlobalFitDictList'
require 'fit4ruby/Converters'
require 'fit4ruby/FitDefinitionField'

module Fit4Ruby

  # The GlobalFitMessage stores an abstract description of a particular
  # FitMessage. It holds information like the name, the global ID number and
  # the data fields of the message.
  class GlobalFitMessage

    attr_reader :name, :number, :fields_by_name, :fields_by_number

    # The Field objects describe the name, type and optional attributes of a
    # FitMessage definition field. It also provides methods to convert field
    # values into various formats.
    class Field

      include Converters

      attr_reader :type, :name, :opts

      def initialize(type, name, opts = {})
        @type = type
        @name = name
        @opts = opts
      end

      def to_machine(value)
        return nil if value.nil?

        if @opts.include?(:dict) &&
           (dict = GlobalFitDictionaries[@opts[:dict]])
          return dict.name(value) || "Undocumented value #{value}"
        end

        case @opts[:type]
        when 'coordinate'
          value *= 180.0 / 2147483648
        when 'date_time'
          value = fit_time_to_time(value)
        end
        value /= @opts[:scale].to_f if @opts[:scale]
        value -= @opts[:offset] if @opts[:offset]
        value
      end

      def to_human(value)
        return nil if value.nil?

        if @opts.include?(:dict) &&
           (dict = GlobalFitDictionaries[@opts[:dict]])
          return [ dict.name(value) || "Undocumented value #{value}", nil ]
        end

        value /= @opts[:scale].to_f if @opts[:scale]
        value -= @opts[:offset] if @opts[:offset]

        case @opts[:type]
        when 'coordinate'
          value *= 180.0 / 2147483648
        when 'date_time'
          value = fit_time_to_time(value).strftime("%Y-%m-%d %H:%M:%S")
        when 'duration'
          value = secsToDHMS(value)
        when 'activity_intensity'
          # Activity monitoring data contains a byte value that consists of 5
          # bit for the activity type and 3 bit for the intensity. Activty 0x8
          # is resting. Instead if value + unit we return activity type +
          # intensity here.
          return [ value & 0x1F, (value >> 5) & 0x7 ]
        end
        [ value, @opts[:unit] ]
      end

      def fit_to_native(value)
        return nil if value == FitDefinitionFieldBase.undefined_value(@type)

        if @opts.include?(:dict) && (dict = GlobalFitDictionaries[@opts[:dict]])
          return dict.name(value) || "Undocumented value #{value}"
        end

        value /= @opts[:scale].to_f if @opts[:scale]
        value -= @opts[:offset] if @opts[:offset]

        case @opts[:type]
        when 'coordinate'
          value *= 180.0 / 2147483648
        when 'date_time'
          value = fit_time_to_time(value)
        end

        value
      end

      def native_to_fit(value)
        return FitDefinitionFieldBase.undefined_value(@type) if value.nil?

        if @opts.include?(:dict) && (dict = GlobalFitDictionaries[@opts[:dict]])
          unless (dv = dict.value_by_name(value))
            Log.error "Unknown value '#{value}' assigned to field #{@name}"
            return FitDefinitionFieldBase.undefined_value(@type)
          else
            return dv
          end
        end

        value += @opts[:offset] if @opts[:offset]
        value = (value * @opts[:scale].to_f).to_i if @opts[:scale]

        case @opts[:type]
        when 'coordinate'
          value = (value * 2147483648.0 / 180.0).to_i
        when 'date_time'
          value = time_to_fit_time(value)
        end
        if value.is_a?(Float) && @opts[:scale].nil?
          Log.error "Field #{@name} must not be a Float value"
        end

        value
      end

      def to_s(value)
        return "[no value]" if value.nil?

        human_readable = to_human(value)
        "#{human_readable[0]}" +
          "#{ human_readable[1] ? " #{human_readable[1]}" : ''}"
      end

    end

    # A GlobalFitMessage may have Field entries that are dependent on the
    # value of another Field. These alternative fields all depend on the value
    # of a specific other Field of the GlobalFitMessage and their presense is
    # mutually exclusive. An AltField object models such a group of Field
    # objects.
    class AltField

      attr_reader :fields, :ref_field

      # Create a new AltField object.
      # @param message [GlobalFitMessage] reference to the GlobalFitMessage
      #        this field belongs to.
      # @param ref_field [String] The name of the field that is used to select
      #        the alternative.
      def initialize(message, ref_field, &block)
        @message = message
        @ref_field = ref_field
        @fields = {}

        instance_eval(&block) if block_given?
      end

      def field(ref_value, type, name, opts = {})
        field = Field.new(type, name, opts)
        if ref_value.respond_to?('each')
          ref_value.each do |rv|
            @fields[rv] = field
          end
        else
          @fields[ref_value] = field
        end
        @message.register_field_by_name(field, name)
      end

      # Select the alternative field based on the actual field values of the
      # FitMessageRecord.
      def select(field_values_by_name)
        unless (value = field_values_by_name[@ref_field])
          Log.fatal "The selection field #{@ref_field} for the alternative " +
                    "field is undefined in global message #{@message.name}: " +
                    field_values_by_name.inspect
        end
        @fields.each do |ref_value, field|
          return field if ref_value == value
        end
        return @fields[:default] if @fields[:default]

        Log.fatal "The selector value #{value} for the alternative field " +
                  "is not supported in global message #{@message.name}."
      end

    end

    # Create a new GlobalFitMessage definition.
    # @param name [String] name of the FIT message
    # @param number [Fixnum] global message number
    def initialize(name, number)
      @name = name
      @number = number
      # Field names must be unique. A name always matches a single Field.
      @fields_by_name = {}
      # Field numbers are not unique. A group of alternative fields shares the
      # same number and is stored as an AltField. Otherwise as Field.
      @fields_by_number = {}
    end

    # Two GlobalFitMessage objects are considered equal if they have the same
    # number, name and list of named fields.
    def ==(m)
      @number == m.number && @name == m.name &&
        @fields_by_name.keys.sort == m.fields_by_name.keys.sort
    end

    # Define a new Field for this message definition.
    def field(number, type, name, opts = {})
      field = Field.new(type, name, opts)
      register_field_by_name(field, name)
      register_field_by_number(field, number)
    end

    # Define a new set of Field alternatives for this message definition.
    def alt_field(number, ref_field, &block)
      unless @fields_by_name.include?(ref_field)
        raise "Unknown ref_field: #{ref_field}"
      end

      field = AltField.new(self, ref_field, &block)
      register_field_by_number(field, number)
    end

    def register_field_by_name(field, name)
      if @fields_by_name.include?(name)
        raise "Field '#{name}' has already been defined"
      end

      @fields_by_name[name] = field
    end

    def register_field_by_number(field, number)
      if @fields_by_name.include?(number)
        raise "Field #{number} has already been defined"
      end

      @fields_by_number[number] = field
    end

    def construct(field_values_by_name)
      gfm = GlobalFitMessage.new(@name, @number)

      @fields_by_number.each do |number, field|
        if field.is_a?(AltField)
          # For alternative fields, we need to look at the value of the
          # selector field and pick the corresponding Field.
          field = field.select(field_values_by_name)
        end
        gfm.field(number, field.type, field.name, field.opts)
      end

      gfm
    end

    def write(io, local_message_type, global_fit_message = self)
      header = FitRecordHeader.new
      header.normal = 0
      header.message_type = 1
      header.local_message_type = local_message_type
      header.write(io)

      definition = FitDefinition.new
      definition.global_message_number = @number
      definition.setup(global_fit_message)
      definition.write(io)
    end

  end

  class GlobalFitMessageList

    def initialize(&block)
      @current_message = nil
      @messages = {}
      instance_eval(&block) if block_given?
    end

    def message(number, name)
      if @messages.include?(number)
        raise "Message #{number} has already been defined"
      end
      @messages[number] = @current_message = GlobalFitMessage.new(name, number)
    end

    def each
      @messages.each_value do |message|
        yield(message)
      end
    end

    def write(io)
      # The FIT file supports more than 16 global message types. But only 16
      # can be declared as local message types at any point of time. If there
      # are more then 16 messages, the current subset must be declared before
      # it's being used in the data records of the file.
      # Since we currently have 16 or less message types, we just write them
      # all upfront before the data records.
      if @messages.length > 16
        raise 'Cannot support more than 16 message types!'
      end

      local_message_type = 0
      @messages.each do |number, message|
        message.write(io, local_message_type)
      end
    end

    def find_by_name(name)
      @messages.values.find { |m| m.name == name }
    end

    def field(number, type, name, opts = {})
      unless @current_message
        raise "You must define a message first"
      end
      @current_message.field(number, type, name, opts)
    end

    def alt_field(number, ref_field, &block)
      unless @current_message
        raise "You must define a message first"
      end
      @current_message.alt_field(number, ref_field, &block)
    end

    def [](number)
      @messages[number]
    end

  end

end


module MailForm
  module Delivery
    extend ActiveSupport::Concern

    included do
      class_attribute :mail_attributes
      self.mail_attributes = []

      class_attribute :mail_captcha
      self.mail_captcha = []

      class_attribute :mail_attachments
      self.mail_attachments = []

      class_attribute :mail_appendable
      self.mail_appendable = []

      # not_spam? simply returns false, but when used as a callback, we have to throw(:abort)
      # to halt the callback chain, beginning with Rails 5.1.
      if ActionPack.respond_to?(:version) && ActionPack.version >= Gem::Version.new('5.1')
        before_deliver :check_not_spam
        after_deliver  :deliver!
      elsif respond_to?(:before_deliver) && respond_to?(:after_deliver)
        before_deliver :not_spam?
        after_deliver  :deliver!
      else # For ActiveRecord compatibility
        before_create :not_spam?
        after_create  :deliver!
        alias :deliver :save
      end

      attr_accessor :request
    end

    module ClassMethods
      # Declare your form attributes. All attributes declared here will be appended
      # to the e-mail, except the ones captcha is true.
      #
      # == Options
      #
      # * :validate - A hook to validates_*_of. When true is given, validates the
      #       presence of the attribute. When a regexp, validates format. When array,
      #       validates the inclusion of the attribute in the array.
      #
      #       Whenever :validate is given, the presence is automatically checked. Give
      #       :allow_blank => true to override.
      #
      #       Finally, when :validate is a symbol, the method given as symbol will be
      #       called. Then you can add validations as you do in ActiveRecord (errors.add).
      #
      # * <tt>:attachment</tt> - When given, expects a file to be sent and attaches
      #   it to the e-mail. Don't forget to set your form to multitype.
      #
      # * <tt>:captcha</tt> - When true, validates the attributes must be blank
      #   This is a simple way to avoid spam
      #
      # == Examples
      #
      #   class ContactForm < MailForm
      #     attributes :name,  :validate => true
      #     attributes :email, :validate => /^([^@]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
      #     attributes :type,  :validate => ["General", "Interface bug"]
      #     attributes :message
      #     attributes :screenshot, :attachment => true, :validate => :interface_bug?
      #     attributes :nickname, :captcha => true
      #
      #     def interface_bug?
      #       if type == 'Interface bug' && screenshot.nil?
      #         self.errors.add(:screenshot, "can't be blank when you are reporting an interface bug")
      #       end
      #     end
      #   end
      #
      def attribute(*accessors)
        options = accessors.extract_options!

        # TODO: make this not depend on column_names
        columns_methods = self.respond_to?(:column_names) ? column_names.map(&:to_sym) : []
        attr_accessor(*(accessors - instance_methods.map(&:to_sym) - columns_methods))

        if options[:attachment]
          self.mail_attachments += accessors
        elsif options[:captcha]
          self.mail_captcha += accessors
        else
          self.mail_attributes += accessors
        end

        validation = options.delete(:validate)
        return unless validation

        accessors.each do |accessor|
          case validation
          when Symbol, Class
            validate validation
            break
          when Regexp
            validates_format_of accessor, :with => validation, :allow_blank => true
          when Array
            validates_inclusion_of accessor, :in => validation, :allow_blank => true
          when Range
            validates_length_of accessor, :within => validation, :allow_blank => true
          end

          validates_presence_of accessor unless options[:allow_blank] == true
        end
      end
      alias :attributes :attribute

      # Values from request object to be appended to the contact form.
      # Whenever used, you have to send the request object when initializing the object:
      #
      #   @contact_form = ContactForm.new(params[:contact_form], request)
      #
      # You can get the values to be appended from the AbstractRequest
      # documentation (http://api.rubyonrails.org/classes/ActionController/AbstractRequest.html)
      #
      # == Examples
      #
      #   class ContactForm < MailForm
      #     append :remote_ip, :user_agent, :session, :cookies
      #   end
      #
      def append(*values)
        self.mail_appendable += values
      end
    end

    # In development, raises an error if the captcha field is not blank. This is
    # is good to remember that the field should be hidden with CSS and shown only
    # to robots.
    #
    # In test and in production, it returns true if all captcha fields are blank,
    # returns false otherwise.
    #
    def spam?
      self.class.mail_captcha.each do |field|
        next if send(field).blank?

        if defined?(Rails) && Rails.env.development?
          raise ScriptError, "The captcha field #{field} was supposed to be blank"
        else
          return true
        end
      end

      false
    end

    def not_spam?
      !spam?
    end

    def check_not_spam
      throw(:abort) if spam?
    end

    # Deliver the resource without running any validation.
    def deliver!
      mailer = MailForm::Notifier.contact(self)
      if mailer.respond_to?(:deliver_now)
        mailer.deliver_now
      else
        mailer.deliver
      end
    end

    # Returns a hash of attributes, according to the attributes existent in
    # self.class.mail_attributes.
    def mail_form_attributes
      self.class.mail_attributes.each_with_object({}) do |attr, hash|
        hash[attr.to_s] = send(attr)
      end
    end
  end
end


require 'digest'
require 'fileutils'
require 'json'
require 'open-uri'
require 'securerandom'
require 'socket'
require 'stringio'
require 'tmpdir'
require 'zip'
require 'erb'
require 'yaml'
require 'retriable'

module SolrWrapper
  class Instance
    attr_reader :config

    ##
    # @param [Hash] options
    # @option options [String] :url
    # @option options [String] :instance_dir Directory to store the solr index files
    # @option options [String] :version Solr version to download and install
    # @option options [String] :port port to run Solr on
    # @option options [Boolean] :cloud Run solr in cloud mode
    # @option options [String] :version_file Local path to store the currently installed version
    # @option options [String] :download_dir Local directory to store the downloaded Solr zip and its checksum file in (overridden by :solr_zip_path)
    # @option options [String] :solr_zip_path Local path for storing the downloaded Solr zip file
    # @option options [Boolean] :validate Should solr_wrapper download a new checksum and (re-)validate the zip file? (default: trueF)
    # @option options [String] :checksum Path/URL to checksum
    # @option options [String] :solr_xml Path to Solr configuration
    # @option options [String] :extra_lib_dir Path to directory containing extra libraries to copy into instance_dir/lib
    # @option options [Boolean] :verbose return verbose info when running solr commands
    # @option options [Boolean] :ignore_checksum
    # @option options [Hash] :solr_options
    # @option options [Hash] :env
    # @option options [String] :config
    def initialize(options = {})
      @config = Settings.new(Configuration.new(options))
    end

    def host
      config.host
    end

    def port
      config.port
    end

    def url
      config.url
    end

    def instance_dir
      config.instance_dir
    end

    def version
      config.version
    end

    def wrap(&_block)
      extract_and_configure
      start
      yield self
    ensure
      stop
    end

    ##
    # Start Solr and wait for it to become available
    def start
      extract_and_configure
      if config.managed?
        exec('start', p: port, c: config.cloud)

        # Wait for solr to start
        unless status
          sleep config.poll_interval
        end

        after_start
      end
    end

    ##
    # Stop Solr and wait for it to finish exiting
    def stop
      if config.managed? && started?
        exec('stop', p: port)
        wait
      end
    end

    ##
    # Stop Solr and wait for it to finish exiting
    def restart
      if config.managed? && started?
        exec('restart', p: port, c: config.cloud)
      end
    end

    ##
    # Check the status of a managed Solr service
    def status
      return true unless config.managed?

      out = exec('status').read
      out =~ /running on port #{port}/
    rescue
      false
    end

    def pid
      return unless config.managed?

      @pid ||= begin
        out = exec('status').read
        out.match(/process (?<pid>\d+) running on port #{port}/) do |m|
          m[:pid].to_i
        end
      end
    rescue
      nil
    end

    ##
    # Is Solr running?
    def started?
      !!status
    end

    def wait
      while (Process.getpgid(pid) rescue status)
        sleep config.poll_interval
      end
    end

    ##
    # Create a new collection in solr
    # @param [Hash] options
    # @option options [String] :name
    # @option options [String] :dir
    def create(options = {})
      options[:name] ||= SecureRandom.hex

      create_options = { p: port }
      create_options[:c] = options[:name] if options[:name]
      create_options[:n] = options[:config_name] if options[:config_name]
      create_options[:d] = options[:dir] if options[:dir]

      Retriable.retriable do
        raise "Not started yet" unless started?
      end

      # short-circuit if we're using persisted data with an existing core/collection
      return if options[:persist] && create_options[:c] && client.exists?(create_options[:c])

      exec("create", create_options)

      options[:name]
    end

    ##
    # Update the collection configuration in zookeeper
    # @param [Hash] options
    # @option options [String] :config_name
    # @option options [String] :dir
    def upconfig(options = {})
      options[:name] ||= SecureRandom.hex
      options[:zkhost] ||= zkhost

      upconfig_options = { upconfig: true, n: options[:name] }
      upconfig_options[:d] = options[:dir] if options[:dir]
      upconfig_options[:z] = options[:zkhost] if options[:zkhost]

      exec 'zk', upconfig_options

      options[:name]
    end

    ##
    # Copy the collection configuration from zookeeper to a local directory
    # @param [Hash] options
    # @option options [String] :config_name
    # @option options [String] :dir
    def downconfig(options = {})
      options[:name] ||= SecureRandom.hex
      options[:zkhost] ||= zkhost

      downconfig_options = { downconfig: true, n: options[:name] }
      downconfig_options[:d] = options[:dir] if options[:dir]
      downconfig_options[:z] = options[:zkhost] if options[:zkhost]

      exec 'zk', downconfig_options

      options[:name]
    end

    ##
    # Create a new collection in solr
    # @param [String] name collection name
    def delete(name, _options = {})
      exec("delete", c: name, p: port)
    end

    ##
    # Create a new collection, run the block, and then clean up the collection
    # @param [Hash] options
    # @option options [String] :name
    # @option options [String] :dir
    def with_collection(options = {})
      options = config.collection_options.merge(options)
      return yield if options.empty?

      name = create(options)
      begin
        yield name
      ensure
        delete name unless options[:persist]
      end
    end

    ##
    # Clean up any files solr_wrapper may have downloaded
    def clean!
      stop
      remove_instance_dir!
      FileUtils.remove_entry(config.download_dir, true) if File.exist?(config.download_dir)
      FileUtils.remove_entry(config.tmp_save_dir, true) if File.exist? config.tmp_save_dir
      checksum_validator.clean!
      FileUtils.remove_entry(config.version_file) if File.exist? config.version_file
    end

    ##
    # Clean up any files in the Solr instance dir
    def remove_instance_dir!
      FileUtils.remove_entry(instance_dir, true) if File.exist? instance_dir
    end

    def configure
      raise_error_unless_extracted
      FileUtils.cp config.solr_xml, File.join(config.instance_dir, 'server', 'solr', 'solr.xml') if config.solr_xml
      FileUtils.cp_r File.join(config.extra_lib_dir, '.'), File.join(config.instance_dir, 'server', 'solr', 'lib') if config.extra_lib_dir
    end

    def extract_and_configure
      extract.tap { configure }
    end

    # rubocop:disable Lint/RescueException

    # extract a copy of solr to instance_dir
    # Does noting if solr already exists at instance_dir
    # @return [String] instance_dir Directory where solr has been installed
    def extract
      return config.instance_dir if extracted?

      zip_path = download

      begin
        Zip::File.open(zip_path) do |zip_file|
          # Handle entries one by one
          zip_file.each do |entry|
            dest_file = File.join(config.tmp_save_dir, entry.name)
            FileUtils.remove_entry(dest_file, true)
            entry.extract(dest_file)
          end
        end

      rescue Exception => e
        abort "Unable to unzip #{zip_path} into #{config.tmp_save_dir}: #{e.message}"
      end

      begin
        FileUtils.remove_dir(config.instance_dir, true)
        FileUtils.cp_r File.join(config.tmp_save_dir, File.basename(config.download_url, ".zip")), config.instance_dir
        self.extracted_version = config.version
        FileUtils.chmod 0755, config.solr_binary
      rescue Exception => e
        abort "Unable to copy #{config.tmp_save_dir} to #{config.instance_dir}: #{e.message}"
      end

      config.instance_dir
    ensure
      FileUtils.remove_entry config.tmp_save_dir if File.exist? config.tmp_save_dir
    end
    # rubocop:enable Lint/RescueException

    protected

    def extracted?
      File.exist?(config.solr_binary) && extracted_version == config.version
    end

    def download
      unless File.exist?(config.solr_zip_path) && checksum_validator.validate?(config.solr_zip_path)
        Downloader.fetch_with_progressbar config.download_url, config.solr_zip_path
        checksum_validator.validate! config.solr_zip_path
      end
      config.solr_zip_path
    end

    ##
    # Run a bin/solr command
    # @param [String] cmd command to run
    # @param [Hash] options key-value pairs to transform into command line arguments
    # @return [StringIO] an IO object for the executed shell command
    # @see https://github.com/apache/lucene-solr/blob/trunk/solr/bin/solr
    # If you want to pass a boolean flag, include it in the +options+ hash with its value set to +true+
    # the key will be converted into a boolean flag for you.
    # @example start solr in cloud mode on port 8983
    #   exec('start', {p: '8983', c: true})
    def exec(cmd, options = {})
      stringio = StringIO.new
      # JRuby uses Popen4
      command_runner = IO.respond_to?(:popen4) ? Popen4Runner : PopenRunner
      runner = command_runner.new(cmd, options, config)
      exit_status = runner.run(stringio)

      if exit_status != 0 && cmd != 'status'
        raise "Failed to execute solr #{cmd}: #{stringio.read}. Further information may be available in #{instance_dir}/server/logs"
      end

      stringio
    end

    private

    def checksum_validator
      @checksum_validator ||= ChecksumValidator.new(config)
    end

    def after_start
      create_configsets if config.cloud
    end

    def create_configsets
      config.configsets.each do |configset|
        upconfig(configset)
      end
    end

    def extracted_version
      File.read(config.version_file).strip if File.exist? config.version_file
    end

    def extracted_version=(version)
      File.open(config.version_file, "w") do |f|
        f.puts version
      end
    end

    def zkhost
      "#{config.zookeeper_host}:#{config.zookeeper_port}" if config.cloud
    end

    def client
      SolrWrapper::Client.new(url)
    end

    def raise_error_unless_extracted
      raise RuntimeError, "there is no solr instance at #{config.instance_dir}.  Run SolrWrapper.extract first." unless extracted?
    end
  end
end