package internal

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/signal"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/onsi/ginkgo/v2/types"
)

var _SOURCE_CACHE = map[string][]string{}

type ProgressSignalRegistrar func(func()) context.CancelFunc

func RegisterForProgressSignal(handler func()) context.CancelFunc {
	signalChannel := make(chan os.Signal, 1)
	if len(PROGRESS_SIGNALS) > 0 {
		signal.Notify(signalChannel, PROGRESS_SIGNALS...)
	}
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		for {
			select {
			case <-signalChannel:
				handler()
			case <-ctx.Done():
				signal.Stop(signalChannel)
				return
			}
		}
	}()

	return cancel
}

type ProgressStepCursor struct {
	Text         string
	CodeLocation types.CodeLocation
	StartTime    time.Time
}

func NewProgressReport(isRunningInParallel bool, report types.SpecReport, currentNode Node, currentNodeStartTime time.Time, currentStep types.SpecEvent, gwOutput string, timelineLocation types.TimelineLocation, additionalReports []string, sourceRoots []string, includeAll bool) (types.ProgressReport, error) {
	pr := types.ProgressReport{
		ParallelProcess:         report.ParallelProcess,
		RunningInParallel:       isRunningInParallel,
		ContainerHierarchyTexts: report.ContainerHierarchyTexts,
		LeafNodeText:            report.LeafNodeText,
		LeafNodeLocation:        report.LeafNodeLocation,
		SpecStartTime:           report.StartTime,

		CurrentNodeType:      currentNode.NodeType,
		CurrentNodeText:      currentNode.Text,
		CurrentNodeLocation:  currentNode.CodeLocation,
		CurrentNodeStartTime: currentNodeStartTime,

		CurrentStepText:      currentStep.Message,
		CurrentStepLocation:  currentStep.CodeLocation,
		CurrentStepStartTime: currentStep.TimelineLocation.Time,

		AdditionalReports: additionalReports,

		CapturedGinkgoWriterOutput: gwOutput,
		TimelineLocation:           timelineLocation,
	}

	goroutines, err := extractRunningGoroutines()
	if err != nil {
		return pr, err
	}
	pr.Goroutines = goroutines

	// now we want to try to find goroutines of interest.  these will be goroutines that have any function calls with code in packagesOfInterest:
	packagesOfInterest := map[string]bool{}
	packageFromFilename := func(filename string) string {
		return filepath.Dir(filename)
	}
	addPackageFor := func(filename string) {
		if filename != "" {
			packagesOfInterest[packageFromFilename(filename)] = true
		}
	}
	isPackageOfInterest := func(filename string) bool {
		stackPackage := packageFromFilename(filename)
		for packageOfInterest := range packagesOfInterest {
			if strings.HasPrefix(stackPackage, packageOfInterest) {
				return true
			}
		}
		return false
	}
	for _, location := range report.ContainerHierarchyLocations {
		addPackageFor(location.FileName)
	}
	addPackageFor(report.LeafNodeLocation.FileName)
	addPackageFor(currentNode.CodeLocation.FileName)
	addPackageFor(currentStep.CodeLocation.FileName)

	//First, we find the SpecGoroutine - this will be the goroutine that includes `runNode`
	specGoRoutineIdx := -1
	runNodeFunctionCallIdx := -1
OUTER:
	for goroutineIdx, goroutine := range pr.Goroutines {
		for functionCallIdx, functionCall := range goroutine.Stack {
			if strings.Contains(functionCall.Function, "ginkgo/v2/internal.(*Suite).runNode.func") {
				specGoRoutineIdx = goroutineIdx
				runNodeFunctionCallIdx = functionCallIdx
				break OUTER
			}
		}
	}

	//Now, we find the first non-Ginkgo function call
	if specGoRoutineIdx > -1 {
		for runNodeFunctionCallIdx >= 0 {
			fn := goroutines[specGoRoutineIdx].Stack[runNodeFunctionCallIdx].Function
			file := goroutines[specGoRoutineIdx].Stack[runNodeFunctionCallIdx].Filename
			// these are all things that could potentially happen from within ginkgo
			if strings.Contains(fn, "ginkgo/v2/internal") || strings.Contains(fn, "reflect.Value") || strings.Contains(file, "ginkgo/table_dsl") || strings.Contains(file, "ginkgo/core_dsl") {
				runNodeFunctionCallIdx--
				continue
			}
			if strings.Contains(goroutines[specGoRoutineIdx].Stack[runNodeFunctionCallIdx].Function, "ginkgo/table_dsl") {

			}
			//found it!  lets add its package of interest
			addPackageFor(goroutines[specGoRoutineIdx].Stack[runNodeFunctionCallIdx].Filename)
			break
		}
	}

	ginkgoEntryPointIdx := -1
OUTER_GINKGO_ENTRY_POINT:
	for goroutineIdx, goroutine := range pr.Goroutines {
		for _, functionCall := range goroutine.Stack {
			if strings.Contains(functionCall.Function, "ginkgo/v2.RunSpecs") {
				ginkgoEntryPointIdx = goroutineIdx
				break OUTER_GINKGO_ENTRY_POINT
			}
		}
	}

	// Now we go through all goroutines and highlight any lines with packages in `packagesOfInterest`
	// Any goroutines with highlighted lines end up in the HighlightGoRoutines
	for goroutineIdx, goroutine := range pr.Goroutines {
		if goroutineIdx == ginkgoEntryPointIdx {
			continue
		}
		if goroutineIdx == specGoRoutineIdx {
			pr.Goroutines[goroutineIdx].IsSpecGoroutine = true
		}
		for functionCallIdx, functionCall := range goroutine.Stack {
			if isPackageOfInterest(functionCall.Filename) {
				goroutine.Stack[functionCallIdx].Highlight = true
				goroutine.Stack[functionCallIdx].Source, goroutine.Stack[functionCallIdx].SourceHighlight = fetchSource(functionCall.Filename, functionCall.Line, 2, sourceRoots)
			}
		}
	}

	if !includeAll {
		goroutines := []types.Goroutine{pr.SpecGoroutine()}
		goroutines = append(goroutines, pr.HighlightedGoroutines()...)
		pr.Goroutines = goroutines
	}

	return pr, nil
}

func extractRunningGoroutines() ([]types.Goroutine, error) {
	var stack []byte
	for size := 64 * 1024; ; size *= 2 {
		stack = make([]byte, size)
		if n := runtime.Stack(stack, true); n < size {
			stack = stack[:n]
			break
		}
	}
	r := bufio.NewReader(bytes.NewReader(stack))
	out := []types.Goroutine{}
	idx := -1
	for {
		line, err := r.ReadString('\n')
		if err == io.EOF {
			break
		}

		line = strings.TrimSuffix(line, "\n")

		//skip blank lines
		if line == "" {
			continue
		}

		//parse headers for new goroutine frames
		if strings.HasPrefix(line, "goroutine") {
			out = append(out, types.Goroutine{})
			idx = len(out) - 1

			line = strings.TrimPrefix(line, "goroutine ")
			line = strings.TrimSuffix(line, ":")
			fields := strings.SplitN(line, " ", 2)
			if len(fields) != 2 {
				return nil, types.GinkgoErrors.FailedToParseStackTrace(fmt.Sprintf("Invalid goroutine frame header: %s", line))
			}
			out[idx].ID, err = strconv.ParseUint(fields[0], 10, 64)
			if err != nil {
				return nil, types.GinkgoErrors.FailedToParseStackTrace(fmt.Sprintf("Invalid goroutine ID: %s", fields[1]))
			}

			out[idx].State = strings.TrimSuffix(strings.TrimPrefix(fields[1], "["), "]")
			continue
		}

		//if we are here we must be at a function call entry in the stack
		functionCall := types.FunctionCall{
			Function: strings.TrimPrefix(line, "created by "), // no need to track 'created by'
		}

		line, err = r.ReadString('\n')
		line = strings.TrimSuffix(line, "\n")
		if err == io.EOF {
			return nil, types.GinkgoErrors.FailedToParseStackTrace(fmt.Sprintf("Invalid function call: %s -- missing file name and line number", functionCall.Function))
		}
		line = strings.TrimLeft(line, " \t")
		delimiterIdx := strings.LastIndex(line, ":")
		if delimiterIdx == -1 {
			return nil, types.GinkgoErrors.FailedToParseStackTrace(fmt.Sprintf("Invalid filename and line number: %s", line))
		}
		functionCall.Filename = line[:delimiterIdx]
		line = strings.Split(line[delimiterIdx+1:], " ")[0]
		lineNumber, err := strconv.ParseInt(line, 10, 32)
		functionCall.Line = int(lineNumber)
		if err != nil {
			return nil, types.GinkgoErrors.FailedToParseStackTrace(fmt.Sprintf("Invalid function call line number: %s\n%s", line, err.Error()))
		}
		out[idx].Stack = append(out[idx].Stack, functionCall)
	}

	return out, nil
}

func fetchSource(filename string, lineNumber int, span int, configuredSourceRoots []string) ([]string, int) {
	if filename == "" {
		return []string{}, 0
	}

	var lines []string
	var ok bool
	if lines, ok = _SOURCE_CACHE[filename]; !ok {
		sourceRoots := []string{""}
		sourceRoots = append(sourceRoots, configuredSourceRoots...)
		var data []byte
		var err error
		var found bool
		for _, root := range sourceRoots {
			data, err = os.ReadFile(filepath.Join(root, filename))
			if err == nil {
				found = true
				break
			}
		}
		if !found {
			return []string{}, 0
		}
		lines = strings.Split(string(data), "\n")
		_SOURCE_CACHE[filename] = lines
	}

	startIndex := lineNumber - span - 1
	endIndex := startIndex + span + span + 1
	if startIndex < 0 {
		startIndex = 0
	}
	if endIndex > len(lines) {
		endIndex = len(lines)
	}
	highlightIndex := lineNumber - 1 - startIndex
	return lines[startIndex:endIndex], highlightIndex
}
