// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Some code from the runtime/debug package of the Go standard library.

package raven

import (
	"bytes"
	"go/build"
	"io/ioutil"
	"path/filepath"
	"runtime"
	"strings"
	"sync"

	"github.com/pkg/errors"
)

// https://docs.getsentry.com/hosted/clientdev/interfaces/#failure-interfaces
type Stacktrace struct {
	// Required
	Frames []*StacktraceFrame `json:"frames"`
}

func (s *Stacktrace) Class() string { return "stacktrace" }

func (s *Stacktrace) Culprit() string {
	for i := len(s.Frames) - 1; i >= 0; i-- {
		frame := s.Frames[i]
		if frame.InApp == true && frame.Module != "" && frame.Function != "" {
			return frame.Module + "." + frame.Function
		}
	}
	return ""
}

type StacktraceFrame struct {
	// At least one required
	Filename string `json:"filename,omitempty"`
	Function string `json:"function,omitempty"`
	Module   string `json:"module,omitempty"`

	// Optional
	Lineno       int      `json:"lineno,omitempty"`
	Colno        int      `json:"colno,omitempty"`
	AbsolutePath string   `json:"abs_path,omitempty"`
	ContextLine  string   `json:"context_line,omitempty"`
	PreContext   []string `json:"pre_context,omitempty"`
	PostContext  []string `json:"post_context,omitempty"`
	InApp        bool     `json:"in_app"`
}

// Try to get stacktrace from err as an interface of github.com/pkg/errors, or else NewStacktrace()
func GetOrNewStacktrace(err error, skip int, context int, appPackagePrefixes []string) *Stacktrace {
	stacktracer, errHasStacktrace := err.(interface {
		StackTrace() errors.StackTrace
	})
	if errHasStacktrace {
		var frames []*StacktraceFrame
		for _, f := range stacktracer.StackTrace() {
			pc := uintptr(f) - 1
			fn := runtime.FuncForPC(pc)
			var file string
			var line int
			if fn != nil {
				file, line = fn.FileLine(pc)
			} else {
				file = "unknown"
			}
			frame := NewStacktraceFrame(pc, file, line, context, appPackagePrefixes)
			if frame != nil {
				frames = append([]*StacktraceFrame{frame}, frames...)
			}
		}
		return &Stacktrace{Frames: frames}
	} else {
		return NewStacktrace(skip+1, context, appPackagePrefixes)
	}
}

// Intialize and populate a new stacktrace, skipping skip frames.
//
// context is the number of surrounding lines that should be included for context.
// Setting context to 3 would try to get seven lines. Setting context to -1 returns
// one line with no surrounding context, and 0 returns no context.
//
// appPackagePrefixes is a list of prefixes used to check whether a package should
// be considered "in app".
func NewStacktrace(skip int, context int, appPackagePrefixes []string) *Stacktrace {
	var frames []*StacktraceFrame
	for i := 1 + skip; ; i++ {
		pc, file, line, ok := runtime.Caller(i)
		if !ok {
			break
		}
		frame := NewStacktraceFrame(pc, file, line, context, appPackagePrefixes)
		if frame != nil {
			frames = append(frames, frame)
		}
	}
	// If there are no frames, the entire stacktrace is nil
	if len(frames) == 0 {
		return nil
	}
	// Optimize the path where there's only 1 frame
	if len(frames) == 1 {
		return &Stacktrace{frames}
	}
	// 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 &Stacktrace{frames}
}

// Build a single frame using data returned from runtime.Caller.
//
// context is the number of surrounding lines that should be included for context.
// Setting context to 3 would try to get seven lines. Setting context to -1 returns
// one line with no surrounding context, and 0 returns no context.
//
// appPackagePrefixes is a list of prefixes used to check whether a package should
// be considered "in app".
func NewStacktraceFrame(pc uintptr, file string, line, context int, appPackagePrefixes []string) *StacktraceFrame {
	frame := &StacktraceFrame{AbsolutePath: file, Filename: trimPath(file), Lineno: line, InApp: false}
	frame.Module, frame.Function = functionName(pc)

	// `runtime.goexit` is effectively a placeholder that comes from
	// runtime/asm_amd64.s and is meaningless.
	if frame.Module == "runtime" && frame.Function == "goexit" {
		return nil
	}

	if frame.Module == "main" {
		frame.InApp = true
	} else {
		for _, prefix := range appPackagePrefixes {
			if strings.HasPrefix(frame.Module, prefix) && !strings.Contains(frame.Module, "vendor") && !strings.Contains(frame.Module, "third_party") {
				frame.InApp = true
			}
		}
	}

	if context > 0 {
		contextLines, lineIdx := fileContext(file, line, context)
		if len(contextLines) > 0 {
			for i, line := range contextLines {
				switch {
				case i < lineIdx:
					frame.PreContext = append(frame.PreContext, string(line))
				case i == lineIdx:
					frame.ContextLine = string(line)
				default:
					frame.PostContext = append(frame.PostContext, string(line))
				}
			}
		}
	} else if context == -1 {
		contextLine, _ := fileContext(file, line, 0)
		if len(contextLine) > 0 {
			frame.ContextLine = string(contextLine[0])
		}
	}
	return frame
}

// Retrieve the name of the package and function containing the PC.
func functionName(pc uintptr) (pack string, name string) {
	fn := runtime.FuncForPC(pc)
	if fn == nil {
		return
	}
	name = fn.Name()
	// We get this:
	//	runtime/debug.*T·ptrmethod
	// and want this:
	//  pack = runtime/debug
	//	name = *T.ptrmethod
	if idx := strings.LastIndex(name, "."); idx != -1 {
		pack = name[:idx]
		name = name[idx+1:]
	}
	name = strings.Replace(name, "·", ".", -1)
	return
}

var fileCacheLock sync.Mutex
var fileCache = make(map[string][][]byte)

func fileContext(filename string, line, context int) ([][]byte, int) {
	fileCacheLock.Lock()
	defer fileCacheLock.Unlock()
	lines, ok := fileCache[filename]
	if !ok {
		data, err := ioutil.ReadFile(filename)
		if err != nil {
			// cache errors as nil slice: code below handles it correctly
			// otherwise when missing the source or running as a different user, we try
			// reading the file on each error which is unnecessary
			fileCache[filename] = nil
			return nil, 0
		}
		lines = bytes.Split(data, []byte{'\n'})
		fileCache[filename] = lines
	}

	if lines == nil {
		// cached error from ReadFile: return no lines
		return nil, 0
	}

	line-- // stack trace lines are 1-indexed
	start := line - context
	var idx int
	if start < 0 {
		start = 0
		idx = line
	} else {
		idx = context
	}
	end := line + context + 1
	if line >= len(lines) {
		return nil, 0
	}
	if end > len(lines) {
		end = len(lines)
	}
	return lines[start:end], idx
}

var trimPaths []string

// Try to trim the GOROOT or GOPATH prefix off of a filename
func trimPath(filename string) string {
	for _, prefix := range trimPaths {
		if trimmed := strings.TrimPrefix(filename, prefix); len(trimmed) < len(filename) {
			return trimmed
		}
	}
	return filename
}

func init() {
	// Collect all source directories, and make sure they
	// end in a trailing "separator"
	for _, prefix := range build.Default.SrcDirs() {
		if prefix[len(prefix)-1] != filepath.Separator {
			prefix += string(filepath.Separator)
		}
		trimPaths = append(trimPaths, prefix)
	}
}
