package custom_detectors

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"maps"
	"net/http"
	"regexp"
	"slices"
	"strings"

	"golang.org/x/sync/errgroup"

	"github.com/trufflesecurity/trufflehog/v3/pkg/common"
	"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
	"github.com/trufflesecurity/trufflehog/v3/pkg/pb/custom_detectorspb"
	"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

// The maximum number of matches from one chunk. This const is used when
// permutating each regex match to protect the scanner from doing too much work
// for poorly defined regexps.
const maxTotalMatches = 100

// CustomRegexWebhook is a CustomRegex with webhook validation that is
// guaranteed to be valid (assuming the data is not changed after
// initialization).
type CustomRegexWebhook struct {
	*custom_detectorspb.CustomRegex
}

// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*CustomRegexWebhook)(nil)
var _ detectors.CustomFalsePositiveChecker = (*CustomRegexWebhook)(nil)
var _ detectors.MaxSecretSizeProvider = (*CustomRegexWebhook)(nil)

// NewWebhookCustomRegex initializes and validates a CustomRegexWebhook. An
// unexported type is intentionally returned here to ensure the values have
// been validated.
func NewWebhookCustomRegex(pb *custom_detectorspb.CustomRegex) (*CustomRegexWebhook, error) {
	// TODO: Return all validation errors.
	if err := ValidateKeywords(pb.Keywords); err != nil {
		return nil, err
	}
	if err := ValidateRegex(pb.Regex); err != nil {
		return nil, err
	}
	if err := ValidateRegexSlice(pb.ExcludeRegexesCapture); err != nil {
		return nil, err
	}
	if err := ValidateRegexSlice(pb.ExcludeRegexesMatch); err != nil {
		return nil, err
	}
	if err := ValidatePrimaryRegexName(pb.PrimaryRegexName, pb.Regex); err != nil {
		return nil, err
	}

	for _, verify := range pb.Verify {
		if err := ValidateVerifyEndpoint(verify.Endpoint, verify.Unsafe); err != nil {
			return nil, err
		}
		if err := ValidateVerifyHeaders(verify.Headers); err != nil {
			return nil, err
		}
		if err := ValidateVerifyRanges(verify.SuccessRanges); err != nil {
			return nil, err
		}
		if err := ValidateVerifyRanges(verify.RotatedRanges); err != nil {
			return nil, err
		}
	}

	// Ensure primary regex name is set.
	ensurePrimaryRegexNameSet(pb)

	// TODO: Copy only necessary data out of pb.
	return &CustomRegexWebhook{pb}, nil
}

var httpClient = common.SaneHttpClient()

func (c *CustomRegexWebhook) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
	dataStr := string(data)
	regexMatches := make(map[string][][]string, len(c.GetRegex()))

	// Compile exclude regexes targeting the capture group
	excludeRegexesCapture := make([]*regexp.Regexp, 0, len(c.GetExcludeRegexesCapture()))
	for _, exclude := range c.GetExcludeRegexesCapture() {
		regex, err := regexp.Compile(exclude)
		if err != nil {
			// This will only happen if the regex is invalid.
			return nil, err
		}
		excludeRegexesCapture = append(excludeRegexesCapture, regex)
	}

	// Compile exclude regexes targeting the entire match
	excludeRegexes := make([]*regexp.Regexp, 0, len(c.GetExcludeRegexesMatch()))
	for _, exclude := range c.GetExcludeRegexesMatch() {
		regex, err := regexp.Compile(exclude)
		if err != nil {
			// This will only happen if the regex is invalid.
			return nil, err
		}
		excludeRegexes = append(excludeRegexes, regex)
	}

	// Find all submatches for each regex.
	for name, regex := range c.GetRegex() {
		regex, err := regexp.Compile(regex)
		if err != nil {
			// This will only happen if the regex is invalid.
			return nil, err
		}
		regexMatches[name] = regex.FindAllStringSubmatch(dataStr, -1)
	}

	// Permutate each individual match.
	// {
	//    "foo": [["match1"]]
	//    "bar": [["match2"], ["match3"]]
	// }
	// becomes
	// [
	//    {"foo": ["match1"], "bar": ["match2"]},
	//    {"foo": ["match1"], "bar": ["match3"]},
	// ]
	matches := permutateMatches(regexMatches)

	g := new(errgroup.Group)

	// Create result object and test for verification.
	resultsCh := make(chan detectors.Result, maxTotalMatches)

MatchLoop:
	for _, match := range matches {
		for key, values := range match {
			// attempt to use capture group
			secret := values[0]
			if len(values) > 1 {
				secret = values[1]
			}

			// check entropy
			entropy := c.GetEntropy()
			if entropy > 0.0 && detectors.StringShannonEntropy(secret) < float64(entropy) {
				continue MatchLoop
			}

			// check for exclude words
			for _, excludeWord := range c.GetExcludeWords() {
				if strings.Contains(strings.ToLower(secret), excludeWord) {
					continue MatchLoop
				}
			}

			// exclude checks
			for _, excludeMatch := range excludeRegexes {
				if excludeMatch.MatchString(values[0]) {
					continue MatchLoop
				}
			}

			// exclude secret (capture group), or if no capture group is set,
			// check against entire match.
			for _, excludeSecret := range excludeRegexesCapture {
				if excludeSecret.MatchString(secret) {
					continue MatchLoop
				}
			}

			if validations := c.GetValidations(); validations != nil {
				validationRules := []struct {
					enabled   bool
					validator func(string) bool
				}{
					{validations[key].GetContainsDigit(), ContainsDigit},
					{validations[key].GetContainsLowercase(), ContainsLowercase},
					{validations[key].GetContainsUppercase(), ContainsUppercase},
					{validations[key].GetContainsSpecialChar(), ContainsSpecialChar},
				}

				for _, rule := range validationRules {
					if rule.enabled && !rule.validator(secret) {
						// skip this match if a validation rule is enabled but missing from the secret
						continue MatchLoop
					}
				}
			}

		}

		g.Go(func() error {
			return c.createResults(ctx, match, verify, resultsCh)
		})
	}

	// Ignore any errors and collect as many of the results as we can.
	_ = g.Wait()
	close(resultsCh)

	for result := range resultsCh {
		if result.ExtraData != nil {
			result.ExtraData["name"] = c.GetName()
		}

		results = append(results, result)
	}

	return results, nil
}

func (c *CustomRegexWebhook) IsFalsePositive(_ detectors.Result) (bool, string) {
	return false, ""
}

// custom max size for custom detector
func (c *CustomRegexWebhook) MaxSecretSize() int64 {
	return 1000
}

func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string][]string, verify bool, results chan<- detectors.Result) error {
	if common.IsDone(ctx) {
		// TODO: Log we're possibly leaving out results.
		return ctx.Err()
	}

	result := detectors.Result{
		DetectorType: detector_typepb.DetectorType_CustomRegex,
		DetectorName: c.GetName(),
		ExtraData:    map[string]string{},
	}

	var raw string
	for _, key := range slices.Sorted(maps.Keys(match)) {
		values := match[key]
		// values[0] contains the entire regex match.
		secret := values[0]
		fullMatch := values[0]
		if len(values) > 1 {
			secret = values[1]
		}
		raw += secret

		// We set the full regex match as the primary secret value.
		// Reasoning:
		// The engine calculates the line number using the match. When a primary secret is set, it uses that value instead of the raw secret.
		// While the secret match itself is sufficient to calculate the line number, the same group match could appear elsewhere in the data.
		// To avoid ambiguity, we store the full regex match as the primary secret value.
		// This primary secret value is used only for identifying the exact line number and is not used anywhere else.

		// Example:
		// Full regex match: secret = ABC123
		// Secret (raw): ABC123

		// In this case, the primary secret value stores the full string `secret = ABC123`,
		// allowing the engine to pinpoint the exact location and avoid matching redundant occurrences of `ABC123` in the data.
		if c.PrimaryRegexName == key {
			result.SetPrimarySecretValue(fullMatch)
		}
	}

	result.Raw = []byte(raw)

	if !verify {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case results <- result:
			return nil
		}
	}
	// Verify via webhook.
	jsonBody, err := json.Marshal(map[string]map[string][]string{
		c.GetName(): match,
	})
	if err != nil {
		// This should never happen, but if it does, return nil to not
		// disrupt other verification.
		return nil
	}

	var (
		definitive     bool
		rangesInEffect bool
	)

	// Try each config until we get a definitive answer.
	for _, verifyConfig := range c.GetVerify() {
		if common.IsDone(ctx) {
			return ctx.Err()
		}
		req, err := http.NewRequestWithContext(ctx, "POST", verifyConfig.GetEndpoint(), bytes.NewReader(jsonBody))
		if err != nil {
			continue
		}
		for _, header := range verifyConfig.GetHeaders() {
			key, value, found := strings.Cut(header, ":")
			if !found {
				continue
			}
			req.Header.Add(key, strings.TrimLeft(value, "\t\n\v\f\r "))
		}
		if req.Header.Get("Content-Type") == "" {
			req.Header.Set("Content-Type", "application/json")
		}
		resp, err := httpClient.Do(req)
		if err != nil {
			continue
		}
		defer func() {
			_, _ = io.Copy(io.Discard, resp.Body)
			_ = resp.Body.Close()
		}()

		successRanges := verifyConfig.GetSuccessRanges()
		rotatedRanges := verifyConfig.GetRotatedRanges()

		if len(successRanges) == 0 && len(rotatedRanges) == 0 {
			// Backward compat: no ranges configured, use legacy behavior.
			if resp.StatusCode == http.StatusOK {
				result.Verified = true
				definitive = true
				storeResponseBody(resp, result.ExtraData)
				break
			}
			// Legacy non-200 is a meaningful response (verifier said "no");
			// mark definitive so a prior ranged verifier with rangesInEffect
			// does not cause a spurious verification error.
			definitive = true
			continue
		}

		rangesInEffect = true
		bothConfigured := len(successRanges) > 0 && len(rotatedRanges) > 0

		if StatusCodeMatchesRanges(resp.StatusCode, successRanges) {
			result.Verified = true
			definitive = true
			storeResponseBody(resp, result.ExtraData)
			break
		}

		if StatusCodeMatchesRanges(resp.StatusCode, rotatedRanges) {
			definitive = true
			break
		}

		// Status matched neither configured range.
		if !bothConfigured {
			// Only one side was configured: the non-matching response is
			// treated as the opposite state.
			//   successRanges only -> non-match means rotated
			//   rotatedRanges only -> non-match means live
			definitive = true
			if len(rotatedRanges) > 0 {
				result.Verified = true
				storeResponseBody(resp, result.ExtraData)
			}
			break
		}

		// Both configured but neither matched -- try the next verifier.
	}

	if rangesInEffect && !definitive {
		result.SetVerificationError(errors.New("verification response status code did not match any configured successRanges or rotatedRanges"))
	}

	select {
	case <-ctx.Done():
		return ctx.Err()
	case results <- result:
		return nil
	}
}

const maxResponseLen = 200

func storeResponseBody(resp *http.Response, extraData map[string]string) {
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return
	}
	responseStr := string(body)
	if len(responseStr) > maxResponseLen {
		responseStr = responseStr[:maxResponseLen]
	}
	extraData["response"] = responseStr
}

func (c *CustomRegexWebhook) Keywords() []string {
	return c.GetKeywords()
}

// productIndices produces a permutation of indices for each length. Example:
// productIndices(3, 2) -> [[0 0] [1 0] [2 0] [0 1] [1 1] [2 1]]. It returns
// a slice of length no larger than maxTotalMatches.
func productIndices(lengths ...int) [][]int {
	count := 1
	for _, l := range lengths {
		count *= l
	}
	if count == 0 {
		return nil
	}
	if count > maxTotalMatches {
		count = maxTotalMatches
	}

	results := make([][]int, count)
	for i := 0; i < count; i++ {
		j := 1
		result := make([]int, 0, len(lengths))
		for _, l := range lengths {
			result = append(result, (i/j)%l)
			j *= l
		}
		results[i] = result
	}
	return results
}

// permutateMatches converts the list of all regex matches into all possible
// permutations selecting one from each named entry in the map. For example:
// {"foo": [matchA, matchB], "bar": [matchC]} becomes
//
// [{"foo": matchA, "bar": matchC}, {"foo": matchB, "bar": matchC}]
func permutateMatches(regexMatches map[string][][]string) []map[string][]string {
	// Get a consistent order for names and their matching lengths.
	// The lengths are used in calculating the permutation so order matters.
	names := make([]string, 0, len(regexMatches))
	lengths := make([]int, 0, len(regexMatches))
	for key, value := range regexMatches {
		names = append(names, key)
		lengths = append(lengths, len(value))
	}

	// Permutate all the indices for each match. For example, if "foo" has
	// [matchA, matchB] and "bar" has [matchC], we will get indices [0 0] [1 0].
	permutationIndices := productIndices(lengths...)

	// Build {"foo": matchA, "bar": matchC} and {"foo": matchB, "bar": matchC}
	// from the indices.
	var matches []map[string][]string
	for _, permutation := range permutationIndices {
		candidate := make(map[string][]string, len(permutationIndices))
		for i, name := range names {
			candidate[name] = regexMatches[name][permutation[i]]
		}
		matches = append(matches, candidate)
	}

	return matches
}

func (c *CustomRegexWebhook) Type() detector_typepb.DetectorType {
	return detector_typepb.DetectorType_CustomRegex
}

const defaultDescription = "This is a user-defined detector with no description provided."

func (c *CustomRegexWebhook) Description() string {
	if c.GetDescription() == "" {
		return defaultDescription
	}
	return c.GetDescription()
}

// ensurePrimaryRegexNameSet sets the PrimaryRegexName field to the
// first regex name in sorted order if it is not already set.
// We're sorting to ensure deterministic behavior.
func ensurePrimaryRegexNameSet(pb *custom_detectorspb.CustomRegex) {
	if pb.PrimaryRegexName == "" {
		for _, name := range slices.Sorted(maps.Keys(pb.Regex)) {
			pb.PrimaryRegexName = name
			return
		}
	}
}
