// Copyright 2019 The Cockroach Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License.

package withstack

import (
	"errors"
	"fmt"
	"go/build"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/cockroachdb/errors/errbase"
	"github.com/getsentry/sentry-go"
	pkgErr "github.com/pkg/errors"
)

// ReportableStackTrace aliases the type of the same name in the sentry
// package. This is used by the 'report' error package.
type ReportableStackTrace = sentry.Stacktrace

// GetReportableStackTrace extracts a stack trace embedded in the
// given error in the format suitable for Sentry reporting.
//
// This supports:
// - errors generated by github.com/pkg/errors (either generated
//   locally or after transfer through the network),
// - errors generated with WithStack() in this package,
// - any other error that implements a StackTrace() method
//   returning a StackTrace from github.com/pkg/errors.
//
// Note: Sentry wants the oldest call frame first, so
// the entries are reversed in the result.
func GetReportableStackTrace(err error) *ReportableStackTrace {
	// If we have a stack trace in the style of github.com/pkg/errors
	// (either from there or our own withStack), use it.
	if st, ok := err.(errbase.StackTraceProvider); ok {
		return convertPkgStack(st.StackTrace())
	}

	// If we have flattened a github.com/pkg/errors-style stack
	// trace to a string, it will happen in the error's safe details
	// and we need to parse it.
	if sd, ok := err.(errbase.SafeDetailer); ok {
		details := sd.SafeDetails()
		if len(details) > 0 {
			switch errbase.GetTypeKey(err) {
			case pkgFundamental, pkgWithStackName, ourWithStackName:
				return parsePrintedStack(details[0])
			}
		}
	}

	// No conversion available - no stack trace.
	return nil
}

type frame = sentry.Frame

// convertPkgStack converts a StackTrace from github.com/pkg/errors
// to a Stacktrace in github.com/getsentry/sentry-go.
func convertPkgStack(st errbase.StackTrace) *ReportableStackTrace {
	// If there are no frames, the entire stacktrace is nil.
	if len(st) == 0 {
		return nil
	}

	// Note: the stack trace logic changed between go 1.11 and 1.12.
	// Trying to analyze the frame PCs point-wise will cause
	// the output to change between the go versions.
	return parsePrintedStack(fmt.Sprintf("%+v", st))
}

// trimPath is a copy of the same function in package sentry-go.
func trimPath(filename string) string {
	for _, prefix := range trimPaths {
		if trimmed := strings.TrimPrefix(filename, prefix); len(trimmed) < len(filename) {
			return trimmed
		}
	}
	return filename
}

var trimPaths []string

// init is a copy of the same function in package sentry-go.
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)
	}
}

// functionName is an adapted copy of the same function in package sentry-go.
func functionName(fnName string) (pack string, name string) {
	name = fnName
	// 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
}

// parsePrintedStack reverse-engineers a reportable stack trace from
// the result of printing a github.com/pkg/errors stack trace with format %+v.
func parsePrintedStack(st string) *ReportableStackTrace {
	// A printed stack trace looks like a repetition of either:
	// "unknown"
	// or
	// <result of fn.Name()>
	// <tab><file>:<linenum>
	// It's also likely to contain a heading newline character(s).
	var frames []frame
	lines := strings.Split(strings.TrimSpace(st), "\n")
	for i := 0; i < len(lines); i++ {
		nextI, file, line, fnName := parsePrintedStackEntry(lines, i)
		i = nextI

		// Compose the frame.
		frame := frame{
			AbsPath:  file,
			Filename: trimPath(file),
			Lineno:   line,
			InApp:    true,
			Module:   "unknown",
			Function: fnName,
		}
		if fnName != "unknown" {
			// Extract the function/module details.
			frame.Module, frame.Function = functionName(fnName)
		}
		frames = append(frames, frame)
	}

	if frames == nil {
		return nil
	}

	// 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 &ReportableStackTrace{Frames: frames}
}

// parsePrintedStackEntry extracts the stack entry information
// in lines at position i. It returns the new value of i if more than
// one line was read.
func parsePrintedStackEntry(
	lines []string, i int,
) (newI int, file string, line int, fnName string) {
	// The function name is on the first line.
	fnName = lines[i]

	// The file:line pair may be on the line after that.
	if i < len(lines)-1 && strings.HasPrefix(lines[i+1], "\t") {
		fileLine := strings.TrimSpace(lines[i+1])
		// Separate file path and line number.
		lineSep := strings.LastIndexByte(fileLine, ':')
		if lineSep == -1 {
			file = fileLine
		} else {
			file = fileLine[:lineSep]
			lineStr := fileLine[lineSep+1:]
			line, _ = strconv.Atoi(lineStr)
		}
		i++
	}
	return i, file, line, fnName
}

var pkgFundamental errbase.TypeKey
var pkgWithStackName errbase.TypeKey
var ourWithStackName errbase.TypeKey

func init() {
	err := errors.New("")
	pkgFundamental = errbase.GetTypeKey(pkgErr.New(""))
	pkgWithStackName = errbase.GetTypeKey(pkgErr.WithStack(err))
	ourWithStackName = errbase.GetTypeKey(WithStack(err))
}
