package logrus_sentry

import (
	"encoding/json"
	"fmt"
	"runtime"
	"sync"
	"time"

	raven "github.com/getsentry/raven-go"
	"github.com/pkg/errors"
	"github.com/sirupsen/logrus"
)

var (
	severityMap = map[logrus.Level]raven.Severity{
		logrus.TraceLevel: raven.DEBUG,
		logrus.DebugLevel: raven.DEBUG,
		logrus.InfoLevel:  raven.INFO,
		logrus.WarnLevel:  raven.WARNING,
		logrus.ErrorLevel: raven.ERROR,
		logrus.FatalLevel: raven.FATAL,
		logrus.PanicLevel: raven.FATAL,
	}
)

// SentryHook delivers logs to a sentry server.
type SentryHook struct {
	// Timeout sets the time to wait for a delivery error from the sentry server.
	// If this is set to zero the server will not wait for any response and will
	// consider the message correctly sent.
	//
	// This is ignored for asynchronous hooks. If you want to set a timeout when
	// using an async hook (to bound the length of time that hook.Flush can take),
	// you probably want to create your own raven.Client and set
	// ravenClient.Transport.(*raven.HTTPTransport).Client.Timeout to set a
	// timeout on the underlying HTTP request instead.
	Timeout                 time.Duration
	StacktraceConfiguration StackTraceConfiguration

	client *raven.Client
	levels []logrus.Level

	serverName    string
	ignoreFields  map[string]struct{}
	extraFilters  map[string]func(interface{}) interface{}
	errorHandlers []func(entry *logrus.Entry, err error)

	asynchronous bool

	mu sync.RWMutex
	wg sync.WaitGroup
}

// The Stacktracer interface allows an error type to return a raven.Stacktrace.
type Stacktracer interface {
	GetStacktrace() *raven.Stacktrace
}

type causer interface {
	Cause() error
}

type pkgErrorStackTracer interface {
	StackTrace() errors.StackTrace
}

// StackTraceConfiguration allows for configuring stacktraces
type StackTraceConfiguration struct {
	// whether stacktraces should be enabled
	Enable bool
	// the level at which to start capturing stacktraces
	Level logrus.Level
	// how many stack frames to skip before stacktrace starts recording
	Skip int
	// the number of lines to include around a stack frame for context
	Context int
	// the prefixes that will be matched against the stack frame.
	// if the stack frame's package matches one of these prefixes
	// sentry will identify the stack frame as "in_app"
	InAppPrefixes []string
	// whether sending exception type should be enabled.
	SendExceptionType bool
	// whether the exception type and message should be switched.
	SwitchExceptionTypeAndMessage bool
	// whether to include a breadcrumb with the full error stack
	IncludeErrorBreadcrumb bool
}

// NewSentryHook creates a hook to be added to an instance of logger
// and initializes the raven client.
// This method sets the timeout to 100 milliseconds.
func NewSentryHook(DSN string, levels []logrus.Level) (*SentryHook, error) {
	client, err := raven.New(DSN)
	if err != nil {
		return nil, err
	}
	return NewWithClientSentryHook(client, levels)
}

// NewWithTagsSentryHook creates a hook with tags to be added to an instance
// of logger and initializes the raven client. This method sets the timeout to
// 100 milliseconds.
func NewWithTagsSentryHook(DSN string, tags map[string]string, levels []logrus.Level) (*SentryHook, error) {
	client, err := raven.NewWithTags(DSN, tags)
	if err != nil {
		return nil, err
	}
	return NewWithClientSentryHook(client, levels)
}

// NewWithClientSentryHook creates a hook using an initialized raven client.
// This method sets the timeout to 100 milliseconds.
func NewWithClientSentryHook(client *raven.Client, levels []logrus.Level) (*SentryHook, error) {
	return &SentryHook{
		Timeout: 100 * time.Millisecond,
		StacktraceConfiguration: StackTraceConfiguration{
			Enable:            false,
			Level:             logrus.ErrorLevel,
			Skip:              6,
			Context:           0,
			InAppPrefixes:     nil,
			SendExceptionType: true,
		},
		client:       client,
		levels:       levels,
		ignoreFields: make(map[string]struct{}),
		extraFilters: make(map[string]func(interface{}) interface{}),
	}, nil
}

// NewAsyncSentryHook creates a hook same as NewSentryHook, but in asynchronous
// mode.
func NewAsyncSentryHook(DSN string, levels []logrus.Level) (*SentryHook, error) {
	hook, err := NewSentryHook(DSN, levels)
	return setAsync(hook), err
}

// NewAsyncWithTagsSentryHook creates a hook same as NewWithTagsSentryHook, but
// in asynchronous mode.
func NewAsyncWithTagsSentryHook(DSN string, tags map[string]string, levels []logrus.Level) (*SentryHook, error) {
	hook, err := NewWithTagsSentryHook(DSN, tags, levels)
	return setAsync(hook), err
}

// NewAsyncWithClientSentryHook creates a hook same as NewWithClientSentryHook,
// but in asynchronous mode.
func NewAsyncWithClientSentryHook(client *raven.Client, levels []logrus.Level) (*SentryHook, error) {
	hook, err := NewWithClientSentryHook(client, levels)
	return setAsync(hook), err
}

func setAsync(hook *SentryHook) *SentryHook {
	if hook == nil {
		return nil
	}
	hook.asynchronous = true
	return hook
}

// Fire is called when an event should be sent to sentry
// Special fields that sentry uses to give more information to the server
// are extracted from entry.Data (if they are found)
// These fields are: error, logger, server_name, http_request, tags
func (hook *SentryHook) Fire(entry *logrus.Entry) error {
	hook.mu.RLock() // Allow multiple go routines to log simultaneously
	defer hook.mu.RUnlock()

	df := newDataField(entry.Data)

	err, hasError := df.getError()
	var crumbs *Breadcrumbs
	if hasError && hook.StacktraceConfiguration.IncludeErrorBreadcrumb {
		crumbs = &Breadcrumbs{
			Values: []Value{{
				Timestamp: int64(time.Now().Unix()),
				Type:      "error",
				Message:   fmt.Sprintf("%+v", err),
			}},
		}
	}

	packet := raven.NewPacketWithExtra(entry.Message, nil, crumbs)
	packet.Timestamp = raven.Timestamp(entry.Time)
	packet.Level = severityMap[entry.Level]
	packet.Platform = "go"

	// set special fields
	if hook.serverName != "" {
		packet.ServerName = hook.serverName
	}
	if logger, ok := df.getLogger(); ok {
		packet.Logger = logger
	}
	if serverName, ok := df.getServerName(); ok {
		packet.ServerName = serverName
	}
	if eventID, ok := df.getEventID(); ok {
		packet.EventID = eventID
	}
	if tags, ok := df.getTags(); ok {
		packet.Tags = tags
	}
	if fingerprint, ok := df.getFingerprint(); ok {
		packet.Fingerprint = fingerprint
	}
	if req, ok := df.getHTTPRequest(); ok {
		packet.Interfaces = append(packet.Interfaces, req)
	}
	if user, ok := df.getUser(); ok {
		packet.Interfaces = append(packet.Interfaces, user)
	}

	// set stacktrace data
	stConfig := &hook.StacktraceConfiguration
	if stConfig.Enable && entry.Level <= stConfig.Level {
		if err, ok := df.getError(); ok {
			var currentStacktrace *raven.Stacktrace
			currentStacktrace = hook.findStacktrace(err)
			if currentStacktrace == nil {
				currentStacktrace = raven.NewStacktrace(stConfig.Skip, stConfig.Context, stConfig.InAppPrefixes)
			}
			cause := errors.Cause(err)
			if cause == nil {
				cause = err
			}
			exc := raven.NewException(cause, currentStacktrace)
			if !stConfig.SendExceptionType {
				exc.Type = ""
			}
			if stConfig.SwitchExceptionTypeAndMessage {
				packet.Interfaces = append(packet.Interfaces, currentStacktrace)
				packet.Culprit = exc.Type + ": " + currentStacktrace.Culprit()
			} else {
				packet.Interfaces = append(packet.Interfaces, exc)
				packet.Culprit = err.Error()
			}
		} else {
			currentStacktrace := raven.NewStacktrace(stConfig.Skip, stConfig.Context, stConfig.InAppPrefixes)
			if currentStacktrace != nil {
				packet.Interfaces = append(packet.Interfaces, currentStacktrace)
			}
		}
	} else {
		// set the culprit even when the stack trace is disabled, as long as we have an error
		if err, ok := df.getError(); ok {
			packet.Culprit = err.Error()
		}
	}

	// set other fields
	dataExtra := hook.formatExtraData(df)
	if packet.Extra == nil {
		packet.Extra = dataExtra
	} else {
		for k, v := range dataExtra {
			packet.Extra[k] = v
		}
	}

	_, errCh := hook.client.Capture(packet, nil)

	switch {
	case hook.asynchronous:
		// Our use of hook.mu guarantees that we are following the WaitGroup rule of
		// not calling Add in parallel with Wait.
		hook.wg.Add(1)
		go func() {
			if err := <-errCh; err != nil {
				for _, handlerFn := range hook.errorHandlers {
					handlerFn(entry, err)
				}
			}
			hook.wg.Done()
		}()
		return nil
	case hook.Timeout == 0:
		return nil
	default:
		timeout := hook.Timeout
		timeoutCh := time.After(timeout)
		select {
		case err := <-errCh:
			for _, handlerFn := range hook.errorHandlers {
				handlerFn(entry, err)
			}
			return err
		case <-timeoutCh:
			return fmt.Errorf("no response from sentry server in %s", timeout)
		}
	}
}

// Flush waits for the log queue to empty. This function only does anything in
// asynchronous mode.
func (hook *SentryHook) Flush() {
	if !hook.asynchronous {
		return
	}
	hook.mu.Lock() // Claim exclusive access; any logging goroutines will block until the flush completes
	defer hook.mu.Unlock()

	hook.wg.Wait()
}

func (hook *SentryHook) findStacktrace(err error) *raven.Stacktrace {
	var stacktrace *raven.Stacktrace
	var stackErr errors.StackTrace
	for err != nil {
		// Find the earliest *raven.Stacktrace, or error.StackTrace
		if tracer, ok := err.(Stacktracer); ok {
			stacktrace = tracer.GetStacktrace()
			stackErr = nil
		} else if tracer, ok := err.(pkgErrorStackTracer); ok {
			stacktrace = nil
			stackErr = tracer.StackTrace()
		}
		if cause, ok := err.(causer); ok {
			err = cause.Cause()
		} else {
			break
		}
	}
	if stackErr != nil {
		stacktrace = hook.convertStackTrace(stackErr)
	}
	return stacktrace
}

// convertStackTrace converts an errors.StackTrace into a natively consumable
// *raven.Stacktrace
func (hook *SentryHook) convertStackTrace(st errors.StackTrace) *raven.Stacktrace {
	stConfig := &hook.StacktraceConfiguration
	stFrames := []errors.Frame(st)
	frames := make([]*raven.StacktraceFrame, 0, len(stFrames))
	for i := range stFrames {
		pc := uintptr(stFrames[i])
		fn := runtime.FuncForPC(pc)
		file, line := fn.FileLine(pc)
		frame := raven.NewStacktraceFrame(pc, fn.Name(), file, line, stConfig.Context, stConfig.InAppPrefixes)
		if frame != nil {
			frames = append(frames, frame)
		}
	}

	// Sentry wants the frames with the oldest first, so reverse them
	for i, j := 0, len(frames)-1; i < j; i, j = i+1, j-1 {
		frames[i], frames[j] = frames[j], frames[i]
	}
	return &raven.Stacktrace{Frames: frames}
}

// Levels returns the available logging levels.
func (hook *SentryHook) Levels() []logrus.Level {
	return hook.levels
}

// AddIgnore adds field name to ignore.
func (hook *SentryHook) AddIgnore(name string) {
	hook.ignoreFields[name] = struct{}{}
}

// AddExtraFilter adds a custom filter function.
func (hook *SentryHook) AddExtraFilter(name string, fn func(interface{}) interface{}) {
	hook.extraFilters[name] = fn
}

// AddErrorHandler adds a error handler function used when Sentry returns error.
func (hook *SentryHook) AddErrorHandler(fn func(entry *logrus.Entry, err error)) {
	hook.errorHandlers = append(hook.errorHandlers, fn)
}

func (hook *SentryHook) formatExtraData(df *dataField) (result map[string]interface{}) {
	// create a map for passing to Sentry's extra data
	result = make(map[string]interface{}, df.len())
	for k, v := range df.data {
		if df.isOmit(k) {
			continue // skip already used special fields
		}
		if _, ok := hook.ignoreFields[k]; ok {
			continue
		}

		if fn, ok := hook.extraFilters[k]; ok {
			v = fn(v) // apply custom filter
		} else {
			v = formatData(v) // use default formatter
		}
		result[k] = v
	}
	return result
}

// formatData returns value as a suitable format.
func formatData(value interface{}) (formatted interface{}) {
	switch value := value.(type) {
	case json.Marshaler:
		return value
	case error:
		return value.Error()
	case fmt.Stringer:
		return value.String()
	default:
		return value
	}
}

// utility classes for breadcrumb support
type Breadcrumbs struct {
	Values []Value `json:"values"`
}

type Value struct {
	Timestamp int64       `json:"timestamp"`
	Type      string      `json:"type"`
	Message   string      `json:"message"`
	Category  string      `json:"category"`
	Level     string      `json:"string"`
	Data      interface{} `json:"data"`
}

func (b *Breadcrumbs) Class() string {
	return "breadcrumbs"
}