require "dat/science/result"

module Dat
  module Science

    # Public: Try things in code.
    class Experiment

      # Public: The name of this experiment.
      attr_reader :name

      # Public: Create a new experiment instance. `self` is yielded to an
      # optional `block` if it's provided.
      def initialize(name, &block)
        @candidate  = nil
        @cleaner    = lambda { |r| r }
        @comparator = lambda { |a, b| a == b }
        @context    = { :experiment => name }
        @control    = nil
        @name       = name

        yield self if block_given?
      end

      # Public: Declare the candidate behavior `block` for this experiment.
      # Returns `block`.
      def candidate(&block)
        @candidate = block if block
        @candidate
      end

      # Internal: Run the cleaner on a value.
      def clean(value)
        cleaner.call value
      end

      # Public: Declare a cleaner `block` to scrub the result before it's
      # published. `block` is called twice, once with the result of
      # the control behavior and once with the result of the candidate.
      # Exceptions during cleaning are treated as if they were raised
      # in a candidate or control behavior block: They're reported as part
      # of the result.
      #
      # Returns `block`.
      def cleaner(&block)
        @cleaner = block if block
        @cleaner
      end

      # Internal: Run the comparator on two values.
      def compare(a, b)
        comparator.call a, b
      end

      # Public: Declare a comparator `block`. Results are compared with
      # `==` by default.
      #
      # Returns `block`.
      def comparator(&block)
        @comparator = block if block
        @comparator
      end

      # Public: Add a Hash of `payload` data to be included when events are
      # published or returns the current context if `payload` is `nil`.
      def context(payload = nil)
        @context.merge! payload if payload
        @context
      end

      # Public: Declare the control behavior `block` for this experiment.
      # Returns `block`.
      def control(&block)
        @control = block if block
        @control
      end

      # Public: Run the control and candidate behaviors, timing each and
      # comparing the results. The run order is randomized. Returns the control
      # behavior's result.
      #
      # If the experiment is disabled or candidate behavior isn't provided the
      # control behavior's result will be returned immediately.
      def run
        return run_control unless candidate? && enabled?

        timestamp = Time.now

        if control_runs_first?
          control   = observe_control
          candidate = observe_candidate
        else
          candidate = observe_candidate
          control   = observe_control
        end

        payload = {
          :timestamp => timestamp,
          :candidate => candidate.payload,
          :control   => control.payload,
          :first     => control_runs_first? ? :control : :candidate
        }

        kind = evaluate control, candidate
        publish_with_context kind, payload

        raise control.exception if control.raised?

        control.value
      end

      # Public: Determine the outcome of an experiment run
      #
      # control - result from running control method
      # candidate - result from running candidate method
      #
      # Returns :match if control and candidate produced the same
      # result, :mismatch otherwise.
      def evaluate(control, candidate)
        control == candidate ? :match : :mismatch
      end

      protected

      # Internal: Does this experiment have candidate behavior?
      def candidate?
        !!candidate
      end

      # Internal: Should the control behavior run first?
      def control_runs_first?
        return @control_runs_first if defined? @control_runs_first
        @control_runs_first = rand(2) == 0
      end

      # Internal: Is this experiment enabled? More specifically, should
      # the candidate behavior be run and compared to the control
      # behavior? The default implementation returns `true`.
      def enabled?
        true
      end

      # Internal: Run `block`, measuring the duration and rescuing any
      # raised exceptions. Returns a Dat::Science::Result.
      def observe(&block)
        start = Time.now

        begin
          value = block.call
        rescue => ex
          raised = ex
        end

        duration = (Time.now - start) * 1000
        Science::Result.new self, value, duration, raised
      end


      # Internal. Returns a Dat::Science::Result for `candidate`.
      def observe_candidate
        observe { run_candidate }
      end

      # Internal. Returns a Dat::Science::Result for `control`.
      def observe_control
        observe { run_control }
      end

      # Internal: Broadcast an `event` String and `payload` Hash. The
      # default implementation is a no-op. Returns nothing.
      def publish(event, payload)
      end

      # Internal: Call `publish`, merging the `payload` with `context`.
      def publish_with_context(event, payload)
        publish event, context.merge(payload)
      end

      # Internal: Run the candidate behavior and return its result.
      def run_candidate
        candidate.call
      end

      # Internal: Run the control behavior and return its result.
      def run_control
        control.call
      end
    end
  end
end
