require 'base64'
require 'json'
require 'securerandom'

module Honeycomb
  class << self
    # Start a new trace. Calling {.span} will automatically start a new trace if
    # one is not already active, so you do not need to call this explicitly
    # unless you want to specify the trace id or parent span id (e.g. to
    # propagate a trace id received from upstream).
    def trace(trace_id: nil, parent_span_id: nil, context: {}, **extra_context)
      context = context.merge(extra_context)

      with_trace trace_id: trace_id, parent_span_id: parent_span_id, context: context do
        yield
      end
    end

    # Continue a trace from a serialized trace context, e.g. propagated from
    # another process.
    def trace_from_encoded_context(encoded_context = nil, additional_context: {})
      trace_context = decode_trace_context(encoded_context) || {}
      trace_id = trace_context[:trace_id]
      parent_span_id = trace_context[:parent_span_id]
      context = trace_context[:context] || {}

      trace(trace_id: trace_id, parent_span_id: parent_span_id, context: context.merge(additional_context)) do
        yield
      end
    end

    # Start a new span, and send it at the end of the supplied code block. This
    # will start a new trace if one is not already active.
    def span(name = nil, type: 'app', fields: {}, **extra_fields)
      fields = fields.merge(extra_fields)

      start = nil

      event = client.event
      span_for_existing_event(event, name: name, type: type) do |span_id, trace_id|
        fields.each do |field, value|
          event.add_field "app.#{field}", value
        end

        start = Time.now

        yield span_id, trace_id, event
      end
    rescue Exception => e
      if event
        # TODO what should the prefix be?
        event.add_field 'app.error', e.class.name
        event.add_field 'app.error_detail', e.message
      end
      raise
    ensure
      if start && event
        finish = Time.now
        duration = finish - start
        event.add_field 'duration_ms', duration * 1000
        event.send
      end
    end

    # Start a new span, and annotate an existing {Libhoney::Event} with its
    # tracing fields. Most users should call {.span} instead, since it has
    # simpler semantics (e.g. it will time the execution of the code block for
    # you, record any exceptions that were thrown, and send the event at the end
    # of the code block). This method is mainly useful if you are writing a
    # library instrumentation which needs to also work independently of the
    # Beeline, and which therefore needs to implement those semantics itself; or
    # which needs custom error handling, e.g. adding custom fields in case of
    # error.
    def span_for_existing_event(event, name:, type:)
      with_trace do |trace_id, context|
        with_span do |parent_span_id, span_id|
          event.add_field 'trace.trace_id', trace_id
          event.add_field 'trace.parent_id', parent_span_id if parent_span_id
          event.add_field 'trace.span_id', span_id
          event.add_field 'name', name if name
          event.add_field 'type', type

          context.each do |field, value|
            event.add_field "app.#{field}", value
          end

          yield span_id, trace_id
        end
      end
    end

    # Add a trace field, which will get added to all spans sent after this call.
    def add_trace_field(name, value)
      self.active_trace_context[name] = value
      # TODO right now this will only add the field to all spans *started* after
      # this call, which unfortunately excludes the actual active span when the
      # call was made. One way to fix this is to change .span_for_existing_event
      # to add fields from .active_trace_context _after_ the yield (in a
      # begin/ensure block) instead of before.
    end

    def decode_trace_context(encoded_context)
      return nil unless encoded_context
      version, payload = encoded_context.split(';', 2)
      case version
      when '1'
        decode_payload_v1(payload)
      else
        warn "#{self}.decode_trace_context: unrecognized trace context version #{version.inspect}"
        nil
      end
    end

    def encode_trace_context_v1(trace_id, parent_span_id, context)
      version = 1

      encoded_payload = encode_payload_v1(
        trace_id: trace_id,
        parent_id: parent_span_id,
        context: context,
      )

      "#{version};#{encoded_payload}"
    end
    alias encode_trace_context encode_trace_context_v1

    def active_trace_id
      Thread.current[:honeycomb_trace_id]
    end
    def active_trace_id=(trace_id)
      Thread.current[:honeycomb_trace_id] = trace_id
    end

    def active_parent_span_id
      Thread.current[:honeycomb_parent_span_id]
    end
    def active_parent_span_id=(parent_span_id)
      Thread.current[:honeycomb_parent_span_id] = parent_span_id
    end

    def active_trace_context
      Thread.current[:honeycomb_trace_context]
    end
    def active_trace_context=(trace_context)
      Thread.current[:honeycomb_trace_context] = trace_context
    end

    private
    def with_trace(trace_id: nil, parent_span_id: nil, context: nil)
      if self.active_trace_id
        if trace_id
          warn "#{self}.with_trace called while another trace is already active; ignoring supplied trace_id and preserving existing one"
        end
        yield self.active_trace_id, self.active_trace_context
      else
        begin
          trace_id, context = start_trace!(trace_id: trace_id, parent_span_id: parent_span_id, context: context)

          yield trace_id, context
        ensure
          finish_trace!
        end
      end
    end

    def start_trace!(trace_id: nil, parent_span_id: nil, context: nil)
      raise "#{self}.start_trace! called while another trace is already active" if self.active_trace_id

      trace_id ||= SecureRandom.uuid
      self.active_trace_id = trace_id

      self.active_parent_span_id = parent_span_id if parent_span_id

      context ||= {}
      self.active_trace_context = context

      [trace_id, context]
    end

    def finish_trace!
      self.active_trace_id = nil
      self.active_parent_span_id = nil
      self.active_trace_context = nil
    end

    def with_span
      parent_span_id, span_id = start_span!

      yield parent_span_id, span_id
    ensure
      finish_span!(parent_span_id)
    end

    def start_span!
      span_id = SecureRandom.uuid

      parent_span_id = self.active_parent_span_id
      self.active_parent_span_id = span_id

      return parent_span_id, span_id
    end

    def finish_span!(parent_span_id)
      self.active_parent_span_id = parent_span_id
    end

    def decode_payload_v1(encoded_payload)
      trace_id, parent_span_id, context = nil

      encoded_payload.split(',').each do |entry|
        k, v = entry.split('=', 2)
        case k
        when 'trace_id'
          trace_id = v
        when 'parent_id'
          parent_span_id = v
        when 'context'
          context = decode_payload_context_v1(v)
        else
          debug "#{self}.decode_payload_v1: unrecognized payload key #{k.inspect}"
        end
      end

      if trace_id.nil?
        warn "#{self}.decode_payload_v1: no trace_id in context"
        return nil
      elsif parent_span_id.nil?
        warn "#{self}.decode_payload_v1: no parent_id in context"
        return nil
      end

      payload = {
        trace_id: trace_id,
        parent_span_id: parent_span_id,
      }
      payload[:context] = context if context
      payload
    rescue StandardError => e
      warn "#{self}.decode_payload_v1: encountered #{e.class} decoding payload: #{e}"
      nil
    end

    def decode_payload_context_v1(encoded_payload_context)
      return {} if encoded_payload_context.empty?
      json = Base64.decode64(encoded_payload_context)
      JSON.parse(json)
    end

    def encode_payload_v1(payload_parts)
      payload_parts.map do |k, v|
        encoded_part = encode_payload_part_v1(k, v)
        encoded_part ? "#{k}=#{encoded_part}" : nil
      end
        .compact # strip out parts that failed to encode
        .join(',')
    end

    def encode_payload_part_v1(param, value)
      case param
      when :trace_id, :parent_id
        encode_payload_id_v1(value)
      when :context
        encode_payload_context_v1(value)
      end
    end

    def encode_payload_id_v1(id)
      case id
      when nil
        nil
      when String, Symbol
        id = id.to_s
        if id.include? ','
          raise ArgumentError, "can't include ','"
        end
        id
      when Numeric
        id.to_s
      else
        raise ArgumentError, "invalid type #{id.class}"
      end
    end

    def encode_payload_context_v1(context)
      case context
      when nil
        nil
      when Hash
        Base64.urlsafe_encode64(JSON.generate(context)).strip
      else
        raise ArgumentError, "invalid type #{context.class}"
      end
    end
  end
end
