// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.

package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"regexp"
	"strings"
	"sync"

	raven "github.com/getsentry/raven-go"
	"github.com/maruel/panicparse/v2/stack"
	"github.com/syncthing/syncthing/lib/build"
)

const reportServer = "https://crash.syncthing.net/report/"

var loader = newGithubSourceCodeLoader()

func init() {
	raven.SetSourceCodeLoader(loader)
}

var (
	clients    = make(map[string]*raven.Client)
	clientsMut sync.Mutex
)

type sentryService struct {
	dsn   string
	inbox chan sentryRequest
}

type sentryRequest struct {
	reportID string
	userID   string
	data     []byte
}

func (s *sentryService) Serve(ctx context.Context) {
	for {
		select {
		case req := <-s.inbox:
			pkt, err := parseCrashReport(req.reportID, req.data)
			if err != nil {
				log.Println("Failed to parse crash report:", err)
				continue
			}
			if err := sendReport(s.dsn, pkt, req.userID); err != nil {
				log.Println("Failed to send crash report:", err)
			}

		case <-ctx.Done():
			return
		}
	}
}

func (s *sentryService) Send(reportID, userID string, data []byte) bool {
	select {
	case s.inbox <- sentryRequest{reportID, userID, data}:
		return true
	default:
		return false
	}
}

func sendReport(dsn string, pkt *raven.Packet, userID string) error {
	pkt.Interfaces = append(pkt.Interfaces, &raven.User{ID: userID})

	clientsMut.Lock()
	defer clientsMut.Unlock()

	cli, ok := clients[dsn]
	if !ok {
		var err error
		cli, err = raven.New(dsn)
		if err != nil {
			return err
		}
		clients[dsn] = cli
	}

	// The client sets release and such on the packet before sending, in the
	// misguided idea that it knows this better than than the packet we give
	// it. So we copy the values from the packet to the client first...
	cli.SetRelease(pkt.Release)
	cli.SetEnvironment(pkt.Environment)

	defer cli.Wait()
	_, errC := cli.Capture(pkt, nil)
	return <-errC
}

func parseCrashReport(path string, report []byte) (*raven.Packet, error) {
	parts := bytes.SplitN(report, []byte("\n"), 2)
	if len(parts) != 2 {
		return nil, errors.New("no first line")
	}

	version, err := build.ParseVersion(string(parts[0]))
	if err != nil {
		return nil, err
	}
	report = parts[1]

	foundPanic := false
	var subjectLine []byte
	for {
		parts = bytes.SplitN(report, []byte("\n"), 2)
		if len(parts) != 2 {
			return nil, errors.New("no panic line found")
		}

		line := parts[0]
		report = parts[1]

		if foundPanic {
			// The previous line was our "Panic at ..." header. We are now
			// at the beginning of the real panic trace and this is our
			// subject line.
			subjectLine = line
			break
		} else if bytes.HasPrefix(line, []byte("Panic at")) {
			foundPanic = true
		}
	}

	r := bytes.NewReader(report)
	ctx, _, err := stack.ScanSnapshot(r, io.Discard, stack.DefaultOpts())
	if err != nil && err != io.EOF {
		return nil, err
	}
	if ctx == nil || len(ctx.Goroutines) == 0 {
		return nil, errors.New("no goroutines found")
	}

	// Lock the source code loader to the version we are processing here.
	if version.Commit != "" {
		// We have a commit hash, so we know exactly which source to use
		loader.LockWithVersion(version.Commit)
	} else if strings.HasPrefix(version.Tag, "v") {
		// Lets hope the tag is close enough
		loader.LockWithVersion(version.Tag)
	} else {
		// Last resort
		loader.LockWithVersion("main")
	}
	defer loader.Unlock()

	var trace raven.Stacktrace
	for _, gr := range ctx.Goroutines {
		if gr.First {
			trace.Frames = make([]*raven.StacktraceFrame, len(gr.Stack.Calls))
			for i, sc := range gr.Stack.Calls {
				trace.Frames[len(trace.Frames)-1-i] = raven.NewStacktraceFrame(0, sc.Func.Name, sc.RemoteSrcPath, sc.Line, 3, nil)
			}
			break
		}
	}

	pkt := packet(version, "crash")
	pkt.Message = string(subjectLine)
	pkt.Extra = raven.Extra{
		"url": reportServer + path,
	}
	pkt.Interfaces = []raven.Interface{&trace}
	pkt.Fingerprint = crashReportFingerprint(pkt.Message)

	return pkt, nil
}

var (
	indexRe          = regexp.MustCompile(`\[[-:0-9]+\]`)
	sizeRe           = regexp.MustCompile(`(length|capacity) [0-9]+`)
	ldbPosRe         = regexp.MustCompile(`(\(pos=)([0-9]+)\)`)
	ldbChecksumRe    = regexp.MustCompile(`(want=0x)([a-z0-9]+)( got=0x)([a-z0-9]+)`)
	ldbFileRe        = regexp.MustCompile(`(\[file=)([0-9]+)(\.ldb\])`)
	ldbInternalKeyRe = regexp.MustCompile(`(internal key ")[^"]+(", len=)[0-9]+`)
	ldbPathRe        = regexp.MustCompile(`(open|write|read) .+[\\/].+[\\/]index[^\\/]+[\\/][^\\/]+: `)
)

func sanitizeMessageLDB(message string) string {
	message = ldbPosRe.ReplaceAllString(message, "${1}x)")
	message = ldbFileRe.ReplaceAllString(message, "${1}x${3}")
	message = ldbChecksumRe.ReplaceAllString(message, "${1}X${3}X")
	message = ldbInternalKeyRe.ReplaceAllString(message, "${1}x${2}x")
	message = ldbPathRe.ReplaceAllString(message, "$1 x: ")
	return message
}

func crashReportFingerprint(message string) []string {
	// Do not fingerprint on the stack in case of db corruption or fatal
	// db io error - where it occurs doesn't matter.
	orig := message
	message = sanitizeMessageLDB(message)
	if message != orig {
		return []string{message}
	}

	message = indexRe.ReplaceAllString(message, "[x]")
	message = sizeRe.ReplaceAllString(message, "$1 x")

	// {{ default }} is what sentry uses as a fingerprint by default. While
	// never specified, the docs point at this being some hash derived from the
	// stack trace. Here we include the filtered panic message on top of that.
	// https://docs.sentry.io/platforms/go/data-management/event-grouping/sdk-fingerprinting/#basic-example
	return []string{"{{ default }}", message}
}

func packet(version build.VersionParts, reportType string) *raven.Packet {
	pkt := &raven.Packet{
		Platform:    "go",
		Release:     version.Tag,
		Environment: version.Environment(),
		Tags: raven.Tags{
			raven.Tag{Key: "version", Value: version.Version},
			raven.Tag{Key: "tag", Value: version.Tag},
			raven.Tag{Key: "codename", Value: version.Codename},
			raven.Tag{Key: "runtime", Value: version.Runtime},
			raven.Tag{Key: "goos", Value: version.GOOS},
			raven.Tag{Key: "goarch", Value: version.GOARCH},
			raven.Tag{Key: "builder", Value: version.Builder},
			raven.Tag{Key: "report_type", Value: reportType},
		},
	}
	if version.Commit != "" {
		pkt.Tags = append(pkt.Tags, raven.Tag{Key: "commit", Value: version.Commit})
	}
	for _, tag := range version.Extra {
		pkt.Tags = append(pkt.Tags, raven.Tag{Key: tag, Value: "1"})
	}
	return pkt
}
