// Package time implements a time delay analyzer using linear
// regression heuristics inspired from ZAP to discover time
// based issues.
//
// The approach is the one used in ZAP for timing based checks.
// Advantages of this approach are many compared to the old approach of
// heuristics of sleep time.
//
// NOTE: This algorithm has been heavily modified after being introduced
// in nuclei. Now the logic has sever bug fixes and improvements and
// has been evolving to be more stable.
//
// As we are building a statistical model, we can predict if the delay
// is random or not very quickly. Also, the payloads are alternated to send
// a very high sleep and a very low sleep. This way the comparison is
// faster to eliminate negative cases. Only legitimate cases are sent for
// more verification.
//
// For more details on the algorithm, follow the links below:
// - https://groups.google.com/g/zaproxy-develop/c/KGSkNHlLtqk
// - https://github.com/zaproxy/zap-extensions/pull/5053
//
// This file has been implemented from its original version. It was originally licensed under the Apache License 2.0 (see LICENSE file for details).
// The original algorithm is implemented in ZAP Active Scanner.
package time

import (
	"errors"
	"fmt"
	"math"
	"strings"
)

type timeDelayRequestSender func(delay int) (float64, error)

// requestsSentMetadata is used to store the delay requested
// and delay received for each request
type requestsSentMetadata struct {
	delay         int
	delayReceived float64
}

// checkTimingDependency checks the timing dependency for a given request
//
// It alternates and sends first a high request, then a low request. Each time
// it checks if the delay of the application can be predictably controlled.
func checkTimingDependency(
	requestsLimit int,
	highSleepTimeSeconds int,
	correlationErrorRange float64,
	slopeErrorRange float64,
	baselineDelay float64,
	requestSender timeDelayRequestSender,
) (bool, string, error) {
	if requestsLimit < 2 {
		return false, "", errors.New("requests limit should be at least 2")
	}

	regression := newSimpleLinearRegression()
	requestsLeft := requestsLimit

	var requestsSent []requestsSentMetadata
	for requestsLeft > 0 {
		isCorrelationPossible, delayRecieved, err := sendRequestAndTestConfidence(regression, highSleepTimeSeconds, requestSender, baselineDelay)
		if err != nil {
			return false, "", err
		}
		if !isCorrelationPossible {
			return false, "", nil
		}
		// Check the delay is greater than baseline by seconds requested
		if delayRecieved < baselineDelay+float64(highSleepTimeSeconds)*0.8 {
			return false, "", nil
		}
		requestsSent = append(requestsSent, requestsSentMetadata{
			delay:         highSleepTimeSeconds,
			delayReceived: delayRecieved,
		})

		isCorrelationPossibleSecond, delayRecievedSecond, err := sendRequestAndTestConfidence(regression, int(DefaultLowSleepTimeSeconds), requestSender, baselineDelay)
		if err != nil {
			return false, "", err
		}
		if !isCorrelationPossibleSecond {
			return false, "", nil
		}
		if delayRecievedSecond < baselineDelay+float64(DefaultLowSleepTimeSeconds)*0.8 {
			return false, "", nil
		}
		requestsLeft = requestsLeft - 2

		requestsSent = append(requestsSent, requestsSentMetadata{
			delay:         int(DefaultLowSleepTimeSeconds),
			delayReceived: delayRecievedSecond,
		})
	}

	result := regression.IsWithinConfidence(correlationErrorRange, 1.0, slopeErrorRange)
	if result {
		var resultReason strings.Builder
		resultReason.WriteString(fmt.Sprintf(
			"[time_delay] made %d requests (baseline: %.2fs) successfully, with a regression slope of %.2f and correlation %.2f",
			requestsLimit,
			baselineDelay,
			regression.slope,
			regression.correlation,
		))
		for _, request := range requestsSent {
			resultReason.WriteString(fmt.Sprintf("\n - delay: %ds, delayReceived: %fs", request.delay, request.delayReceived))
		}
		return result, resultReason.String(), nil
	}
	return result, "", nil
}

// sendRequestAndTestConfidence sends a request and tests the confidence of delay
func sendRequestAndTestConfidence(
	regression *simpleLinearRegression,
	delay int,
	requestSender timeDelayRequestSender,
	baselineDelay float64,
) (bool, float64, error) {
	delayReceived, err := requestSender(delay)
	if err != nil {
		return false, 0, err
	}

	if delayReceived < float64(delay) {
		return false, 0, nil
	}

	regression.AddPoint(float64(delay), delayReceived-baselineDelay)

	if !regression.IsWithinConfidence(0.3, 1.0, 0.5) {
		return false, delayReceived, nil
	}
	return true, delayReceived, nil
}

type simpleLinearRegression struct {
	count float64

	sumX  float64
	sumY  float64
	sumXX float64
	sumYY float64
	sumXY float64

	slope       float64
	intercept   float64
	correlation float64
}

func newSimpleLinearRegression() *simpleLinearRegression {
	return &simpleLinearRegression{
		// Start everything at zero until we have data
		slope:       0.0,
		intercept:   0.0,
		correlation: 0.0,
	}
}

func (o *simpleLinearRegression) AddPoint(x, y float64) {
	o.count += 1
	o.sumX += x
	o.sumY += y
	o.sumXX += x * x
	o.sumYY += y * y
	o.sumXY += x * y

	// Need at least two points for meaningful calculation
	if o.count < 2 {
		return
	}

	n := o.count
	meanX := o.sumX / n
	meanY := o.sumY / n

	// Compute sample variances and covariance
	varX := (o.sumXX - n*meanX*meanX) / (n - 1)
	varY := (o.sumYY - n*meanY*meanY) / (n - 1)
	covXY := (o.sumXY - n*meanX*meanY) / (n - 1)

	// If varX is zero, slope cannot be computed meaningfully.
	// This would mean all X are the same, so handle that edge case.
	if varX == 0 {
		o.slope = 0.0
		o.intercept = meanY // Just the mean
		o.correlation = 0.0 // No correlation since all X are identical
		return
	}

	o.slope = covXY / varX
	o.intercept = meanY - o.slope*meanX

	// If varX or varY are zero, we cannot compute correlation properly.
	if varX > 0 && varY > 0 {
		o.correlation = covXY / (math.Sqrt(varX) * math.Sqrt(varY))
	} else {
		o.correlation = 0.0
	}
}

func (o *simpleLinearRegression) Predict(x float64) float64 {
	return o.slope*x + o.intercept
}

func (o *simpleLinearRegression) IsWithinConfidence(correlationErrorRange float64, expectedSlope float64, slopeErrorRange float64) bool {
	if o.count < 2 {
		return true
	}
	// Check if slope is within error range of expected slope
	// Also consider cases where slope is approximately 2x of expected slope
	// as this can happen with time-based responses
	slopeDiff := math.Abs(expectedSlope - o.slope)
	slope2xDiff := math.Abs(expectedSlope*2 - o.slope)
	if slopeDiff > slopeErrorRange && slope2xDiff > slopeErrorRange {
		return false
	}
	return o.correlation > 1.0-correlationErrorRange
}
