package appsecacquisition

import (
	"context"
	"fmt"
	"io"
	"maps"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	log "github.com/sirupsen/logrus"
	"gopkg.in/tomb.v2"

	"github.com/corazawaf/coraza/v3"
	corazatypes "github.com/corazawaf/coraza/v3/types"

	"github.com/crowdsecurity/crowdsec/pkg/appsec"
	"github.com/crowdsecurity/crowdsec/pkg/appsec/allowlists"
	"github.com/crowdsecurity/crowdsec/pkg/metrics"
	"github.com/crowdsecurity/crowdsec/pkg/pipeline"
)

// that's the runtime structure of the Application security engine as seen from the acquis
type AppsecRunner struct {
	outChan                chan pipeline.Event
	inChan                 chan appsec.ParsedRequest
	UUID                   string
	AppsecRuntime          *appsec.AppsecRuntimeConfig //this holds the actual appsec runtime config, rules, remediations, hooks etc.
	AppsecInbandEngine     coraza.WAF
	AppsecOutbandEngine    coraza.WAF
	Labels                 map[string]string
	logger                 *log.Entry
	appsecAllowlistsClient *allowlists.AppsecAllowlist
}

// ruleIDDirective matches the generated id so dedup can ignore it: rules that
// differ only by id (same content, different position) are duplicates.
var ruleIDDirective = regexp.MustCompile(`"id:\d+,phase:`)

func (*AppsecRunner) MergeDedupRules(collections []appsec.AppsecCollection, logger *log.Entry) string {
	var rulesArr []string
	dedupRules := make(map[string]struct{})
	discarded := 0

	for _, collection := range collections {
		// Dedup *our* rules, ignoring the generated id.
		for _, rule := range collection.Rules {
			key := ruleIDDirective.ReplaceAllString(rule, `"id:,phase:`)
			if _, ok := dedupRules[key]; ok {
				discarded++
				logger.Debugf("Discarding duplicate rule : %s", rule)
				continue
			}
			rulesArr = append(rulesArr, rule)
			dedupRules[key] = struct{}{}
		}
		// Don't mess up with native modsec rules
		rulesArr = append(rulesArr, collection.NativeRules...)
	}
	if discarded > 0 {
		logger.Warningf("%d rules were discarded as they were duplicates", discarded)
	}

	return strings.Join(rulesArr, "\n")
}

func (r *AppsecRunner) Init(datadir string) error {
	var err error
	fs := os.DirFS(datadir)

	inBandLogger := r.logger.Dup().WithField("band", "inband")
	outBandLogger := r.logger.Dup().WithField("band", "outband")

	//While loading rules, we dedup rules based on their content, while keeping the order
	inBandRules := r.MergeDedupRules(r.AppsecRuntime.InBandRules, inBandLogger)
	outOfBandRules := r.MergeDedupRules(r.AppsecRuntime.OutOfBandRules, outBandLogger)

	//setting up inband engine
	inbandCfg := coraza.NewWAFConfig().WithDirectives(inBandRules).WithRootFS(fs).WithDebugLogger(appsec.NewCrzLogger(inBandLogger))
	if !r.AppsecRuntime.Config.InbandOptions.DisableBodyInspection {
		inbandCfg = inbandCfg.WithRequestBodyAccess()
	} else {
		log.Warningf("Disabling body inspection, Inband rules will not be able to match on body's content.")
	}
	if r.AppsecRuntime.Config.InbandOptions.RequestBodyInMemoryLimit != nil {
		inbandCfg = inbandCfg.WithRequestBodyInMemoryLimit(*r.AppsecRuntime.Config.InbandOptions.RequestBodyInMemoryLimit)
	}
	r.AppsecInbandEngine, err = coraza.NewWAF(inbandCfg)
	if err != nil {
		return fmt.Errorf("unable to initialize inband engine : %w", err)
	}

	//setting up outband engine
	outbandCfg := coraza.NewWAFConfig().WithDirectives(outOfBandRules).WithRootFS(fs).WithDebugLogger(appsec.NewCrzLogger(outBandLogger))
	if !r.AppsecRuntime.Config.OutOfBandOptions.DisableBodyInspection {
		outbandCfg = outbandCfg.WithRequestBodyAccess()
	} else {
		log.Warningf("Disabling body inspection, Out of band rules will not be able to match on body's content.")
	}
	if r.AppsecRuntime.Config.OutOfBandOptions.RequestBodyInMemoryLimit != nil {
		outbandCfg = outbandCfg.WithRequestBodyInMemoryLimit(*r.AppsecRuntime.Config.OutOfBandOptions.RequestBodyInMemoryLimit)
	}
	r.AppsecOutbandEngine, err = coraza.NewWAF(outbandCfg)
	if err != nil {
		return fmt.Errorf("unable to initialize outband engine : %w", err)
	}

	if r.AppsecRuntime.DisabledInBandRulesTags != nil {
		for _, tag := range r.AppsecRuntime.DisabledInBandRulesTags {
			r.AppsecInbandEngine.GetRuleGroup().DeleteByTag(tag)
		}
	}

	if r.AppsecRuntime.DisabledOutOfBandRulesTags != nil {
		for _, tag := range r.AppsecRuntime.DisabledOutOfBandRulesTags {
			r.AppsecOutbandEngine.GetRuleGroup().DeleteByTag(tag)
		}
	}

	if r.AppsecRuntime.DisabledInBandRuleIds != nil {
		for _, id := range r.AppsecRuntime.DisabledInBandRuleIds {
			r.AppsecInbandEngine.GetRuleGroup().DeleteByID(id)
		}
	}

	if r.AppsecRuntime.DisabledOutOfBandRuleIds != nil {
		for _, id := range r.AppsecRuntime.DisabledOutOfBandRuleIds {
			r.AppsecOutbandEngine.GetRuleGroup().DeleteByID(id)
		}
	}

	r.logger.Tracef("Loaded inband rules: %+v", r.AppsecInbandEngine.GetRuleGroup().GetRules())
	r.logger.Tracef("Loaded outband rules: %+v", r.AppsecOutbandEngine.GetRuleGroup().GetRules())

	// Materialize the fingerprint-dump dir under data_dir. Failure doesn't prevent startup.
	r.AppsecRuntime.FingerprintDumpDir = filepath.Join(datadir, "fingerprint_dumps")
	if err := os.MkdirAll(r.AppsecRuntime.FingerprintDumpDir, 0o700); err != nil {
		r.logger.Warnf("could not create fingerprint dump dir %q: %s; DumpFingerprint() will be a no-op", r.AppsecRuntime.FingerprintDumpDir, err)
		r.AppsecRuntime.FingerprintDumpDir = ""
	} else if err := os.Chmod(r.AppsecRuntime.FingerprintDumpDir, 0o700); err != nil {
		r.logger.Warnf("could not chmod fingerprint dump dir %q to 0700: %s", r.AppsecRuntime.FingerprintDumpDir, err)
	}

	return nil
}

func (r *AppsecRunner) processRequest(ctx context.Context, state *appsec.AppsecRequestState, request *appsec.ParsedRequest) error {
	var in *corazatypes.Interruption
	var err error

	if state.Tx.IsRuleEngineOff() {
		r.logger.Debugf("rule engine is off, skipping")
		return nil
	}

	defer func() {
		// We don't close the transaction here, as it would reset coraza internal state and break variable tracking.
		err := r.AppsecRuntime.ProcessPostEvalRules(ctx, state, request)
		if err != nil {
			r.logger.Errorf("unable to process PostEval rules: %s", err)
		}
	}()

	//pre eval (expr) rules
	err = r.AppsecRuntime.ProcessPreEvalRules(ctx, state, request)
	if err != nil {
		r.logger.Errorf("unable to process PreEval rules: %s", err)
		//FIXME: should we abort here ?
	}

	// User has requested valid challenge, but we did not find a valid cookie
	// Immediately return, everything has been set already
	if state.RequireChallenge {
		r.logger.Debug("serving challenge")
		return nil
	}

	if state.DropInfo(request) != nil {
		r.logger.Debug("drop helper triggered during pre_eval, skipping WAF evaluation")
		return nil
	}

	if request.BodySizeExceeded {
		// DisableBodyInspection in pre_eval also opts out of the size-exceeded drop:
		// the operator has explicitly accepted that this request's body will not be
		// processed, so there is nothing to protect the WAF from.
		if !state.DisableBodyInspection {
			r.logger.Warnf("request body exceeded maximum allowed size, dropping request")
			if err = r.AppsecRuntime.DropRequest(state, request, "request body exceeded maximum allowed size"); err != nil {
				r.logger.Errorf("unable to drop request: %s", err)
			}
			return nil
		}
		r.logger.Debugf("request body exceeded maximum allowed size but body inspection is disabled, allowing request")
	}

	defer func() {
		state.Tx.ProcessLogging()
		// We don't close the transaction here, as it would reset coraza internal state and break variable tracking.
	}()

	state.Tx.ProcessConnection(request.ClientIP, 0, "", 0)

	for k, v := range request.Args {
		for _, vv := range v {
			state.Tx.AddGetRequestArgument(k, vv)
		}
	}

	state.Tx.ProcessURI(request.URI, request.Method, request.Proto)

	for k, vr := range request.Headers {
		for _, v := range vr {
			state.Tx.AddRequestHeader(k, v)
		}
	}

	if request.ClientHost != "" {
		state.Tx.AddRequestHeader("Host", request.ClientHost)
		state.Tx.SetServerName(request.ClientHost)
	}

	if request.TransferEncoding != nil {
		state.Tx.AddRequestHeader("Transfer-Encoding", request.TransferEncoding[0])
	}

	in = state.Tx.ProcessRequestHeaders()

	if in != nil {
		r.logger.Infof("inband rules matched for headers : %s", in.Action)
		return nil
	}

	if state.DisableBodyInspection {
		r.logger.Debugf("body inspection is disabled for this request, skipping body write")
	} else {
		if request.BodyTruncated {
			r.logger.Warnf("request body was truncated to %d bytes (partial mode)", len(request.Body))
		}

		if len(request.Body) > 0 {
			in, _, err = state.Tx.WriteRequestBody(request.Body)
			if err != nil {
				r.logger.Warnf("unable to write request body: %s", err)
			} else if in != nil {
				return nil
			}
		}
	}

	in, err = state.Tx.ProcessRequestBody()
	if err != nil {
		r.logger.Warnf("unable to process request body: %s", err)
	}

	if in != nil {
		r.logger.Debugf("rules matched for body : %d", in.RuleID)
	}

	return nil
}

func (r *AppsecRunner) ProcessInBandRules(ctx context.Context, state *appsec.AppsecRequestState, request *appsec.ParsedRequest) error {
	tx := appsec.NewExtendedTransaction(r.AppsecInbandEngine, request.UUID)
	state.Tx = tx
	// Even if we have no inband rules, we might have pre-eval, post-eval or on_challenge hooks to process
	if len(r.AppsecRuntime.InBandRules) == 0 &&
		len(r.AppsecRuntime.CommonHooks.PreEval) == 0 &&
		len(r.AppsecRuntime.InBandHooks.PreEval) == 0 &&
		len(r.AppsecRuntime.CommonHooks.PostEval) == 0 &&
		len(r.AppsecRuntime.InBandHooks.PostEval) == 0 &&
		len(r.AppsecRuntime.CompiledOnChallenge) == 0 &&
		r.AppsecRuntime.ChallengeRuntime == nil {
		return nil
	}

	// on_challenge runs before any WAF work: it serves PoW infrastructure paths,
	// validates submissions, and populates state.Fingerprint from the cookie.
	if err := r.AppsecRuntime.ProcessOnChallengeRules(ctx, state, request); err != nil {
		r.logger.Errorf("unable to process OnChallenge rules: %s", err)
	}

	// Infrastructure paths (PoW worker, challenge submit) already set up the
	// full response — skip pre_eval, WAF evaluation and post_eval entirely.
	if state.RequireChallenge {
		r.logger.Debugf("challenge response set by on_challenge, skipping WAF evaluation")
		return nil
	}

	if state.DropInfo(request) != nil {
		r.logger.Debug("drop helper triggered during on_challenge, skipping WAF evaluation")
		return nil
	}

	err := r.processRequest(ctx, state, request)
	return err
}

func (r *AppsecRunner) ProcessOutOfBandRules(ctx context.Context, state *appsec.AppsecRequestState, request *appsec.ParsedRequest) error {
	tx := appsec.NewExtendedTransaction(r.AppsecOutbandEngine, request.UUID)
	state.Tx = tx
	if len(r.AppsecRuntime.OutOfBandRules) == 0 &&
		len(r.AppsecRuntime.CommonHooks.PreEval) == 0 &&
		len(r.AppsecRuntime.OutOfBandHooks.PreEval) == 0 &&
		len(r.AppsecRuntime.CommonHooks.PostEval) == 0 &&
		len(r.AppsecRuntime.OutOfBandHooks.PostEval) == 0 {
		return nil
	}
	err := r.processRequest(ctx, state, request)
	return err
}

func (r *AppsecRunner) handleInBandInterrupt(ctx context.Context, state *appsec.AppsecRequestState, request *appsec.ParsedRequest) {
	//create the associated event for crowdsec itself
	evt, err := appsec.EventFromRequest(request, r.Labels, state.Tx.ID())
	if err != nil {
		//let's not interrupt the pipeline for this
		r.logger.Errorf("unable to create event from request : %s", err)
	}
	r.AccumulateTxToEvent(&evt, state, request)

	interrupt := state.Tx.Interruption()
	dropInfo := state.InBandDrop

	if interrupt == nil && dropInfo == nil {
		return
	}

	if interrupt != nil {
		r.logger.Debugf("inband rules matched : %d", interrupt.RuleID)
	} else if dropInfo != nil {
		r.logger.Debugf("inband drop helper triggered: %s", dropInfo.Reason)
		interrupt = dropInfo.Interruption
	}

	state.Response.InBandInterrupt = true
	state.Response.BouncerHTTPResponseCode = r.AppsecRuntime.Config.BouncerBlockedHTTPCode
	state.Response.UserHTTPResponseCode = r.AppsecRuntime.Config.UserBlockedHTTPCode
	state.Response.Action = r.AppsecRuntime.DefaultRemediation
	state.ApplyPendingResponse()

	if _, ok := r.AppsecRuntime.RemediationById[interrupt.RuleID]; ok {
		state.Response.Action = r.AppsecRuntime.RemediationById[interrupt.RuleID]
	}

	for tag, remediation := range r.AppsecRuntime.RemediationByTag {
		if slices.Contains(interrupt.Tags, tag) {
			state.Response.Action = remediation
		}
	}

	if dropInfo != nil && dropInfo.Reason != "" {
		evt.Meta["appsec_drop_reason"] = dropInfo.Reason
	}

	err = r.AppsecRuntime.ProcessOnMatchRules(state, request, evt)
	if err != nil {
		r.logger.Errorf("unable to process OnMatch rules: %s", err)
		return
	}

	// Snapshot hook vars after on_match so any hook-published values are
	// captured onto the event and onto each matched rule (for alert context).
	copyHookVars(&evt, state)

	// Should the in band match trigger an overflow ?
	if state.Response.SendAlert {
		appsecOvlfw, err := AppsecEventGeneration(evt, request.HTTPRequest)
		if err != nil {
			r.logger.Errorf("unable to generate appsec event : %s", err)
			return
		}
		if appsecOvlfw != nil {
			r.outChan <- *appsecOvlfw
		}
	}
	// Should the in band match trigger an event ?
	if state.Response.SendEvent {
		r.outChan <- evt
	}
}

// copyHookVars snapshots the per-request HookVars onto the emitted event:
//   - evt.Appsec.HookVars gets a shallow copy (state keeps mutating during the
//     out-of-band phase, so the event must own its snapshot).
//   - Each MatchedRule in evt.Appsec.MatchedRules gets the same snapshot
//     under the "hook_vars" key, so alert-context expressions can access
//     match.hook_vars.<key> alongside evt.Appsec.HookVars.<key>.
func copyHookVars(evt *pipeline.Event, state *appsec.AppsecRequestState) {
	if len(state.HookVars) == 0 {
		return
	}
	snapshot := make(map[string]string, len(state.HookVars))
	maps.Copy(snapshot, state.HookVars)
	evt.Appsec.HookVars = snapshot
	for i := range evt.Appsec.MatchedRules {
		evt.Appsec.MatchedRules[i]["hook_vars"] = snapshot
	}
}

func (r *AppsecRunner) handleOutBandInterrupt(ctx context.Context, state *appsec.AppsecRequestState, request *appsec.ParsedRequest) {
	evt, err := appsec.EventFromRequest(request, r.Labels, state.Tx.ID())
	if err != nil {
		//let's not interrupt the pipeline for this
		r.logger.Errorf("unable to create event from request : %s", err)
	}
	r.AccumulateTxToEvent(&evt, state, request)
	interrupt := state.Tx.Interruption()
	dropInfo := state.OutOfBandDrop
	if interrupt == nil && dropInfo == nil {
		return
	}
	if interrupt == nil && dropInfo != nil {
		interrupt = dropInfo.Interruption
	}

	if dropInfo != nil {
		r.logger.Debugf("out-of-band drop helper triggered: %s", dropInfo.Reason)
	} else {
		r.logger.Debugf("outband rules matched : %d", interrupt.RuleID)
	}

	state.Response.OutOfBandInterrupt = true
	state.ApplyPendingResponse()

	if dropInfo != nil && dropInfo.Reason != "" {
		if evt.Meta == nil {
			evt.Meta = map[string]string{}
		}
		evt.Meta["appsec_drop_reason"] = dropInfo.Reason
	}

	err = r.AppsecRuntime.ProcessOnMatchRules(state, request, evt)
	if err != nil {
		r.logger.Errorf("unable to process OnMatch rules: %s", err)
		return
	}

	copyHookVars(&evt, state)

	// The alert needs to be sent first:
	// The event and the alert share the same internal map (parsed, meta, ...)
	// The event can be modified by the parsers, which might cause a concurrent map read/write
	// Should the match trigger an overflow ?
	if state.Response.SendAlert {
		appsecOvlfw, err := AppsecEventGeneration(evt, request.HTTPRequest)
		if err != nil {
			r.logger.Errorf("unable to generate appsec event : %s", err)
			return
		}
		if appsecOvlfw != nil {
			r.outChan <- *appsecOvlfw
		}
	}

	// Should the match trigger an event ?
	if state.Response.SendEvent {
		r.outChan <- evt
	}
}

func (r *AppsecRunner) handleRequest(ctx context.Context, request *appsec.ParsedRequest) {
	state := r.AppsecRuntime.NewRequestState()
	stateLogger := r.AppsecRuntime.Logger.WithField("request_uuid", request.UUID)
	r.AppsecRuntime.Logger = stateLogger
	logger := r.logger.WithField("request_uuid", request.UUID)
	logger.Debug("Request received in runner")
	r.AppsecRuntime.ClearResponse(&state)

	// Allowlisted IPs bypass every form of appsec processing (in-band rules,
	// hooks, challenge issuance, on_challenge cookie validation, out-of-band
	// rules). We send the default pass response straight back to the bouncer.
	if r.appsecAllowlistsClient != nil {
		if allowed, reason := r.appsecAllowlistsClient.IsAllowlisted(request.ClientIP); allowed {
			logger.Infof("%s is allowlisted by %s, skipping WAF processing", request.ClientIP, reason)
			// No Clone() needed (unlike the in-band send below): we return
			// immediately, so no out-of-band phase races this response.
			request.ResponseChannel <- state.Response
			return
		}
	}

	request.IsInBand = true
	request.IsOutBand = false

	//to measure the time spent in the Application Security Engine for InBand rules
	startInBandParsing := time.Now()
	startGlobalParsing := time.Now()

	state.CurrentPhase = appsec.PhaseInBand

	//inband appsec rules
	err := r.ProcessInBandRules(ctx, &state, request)
	if err != nil {
		logger.Errorf("unable to process InBand rules: %s", err)
		err = state.Tx.Close()
		if err != nil {
			logger.Errorf("unable to close inband transaction: %s", err)
		}
		return
	}

	// time spent to process in band rules
	inBandParsingElapsed := time.Since(startInBandParsing)
	metrics.AppsecInbandParsingHistogram.With(prometheus.Labels{"source": request.RemoteAddrNormalized, "appsec_engine": request.AppsecEngine}).Observe(inBandParsingElapsed.Seconds())

	if state.Tx.IsInterrupted() || state.InBandDrop != nil {
		r.handleInBandInterrupt(ctx, &state, request)
	}

	err = state.Tx.Close()
	if err != nil {
		r.logger.Errorf("unable to close inband transaction: %s", err)
	}

	// Clone as the out-of-band phase might mutate the response
	request.ResponseChannel <- state.Response.Clone()

	// A challenge was served, so the request never reaches the backend;
	if state.RequireChallenge {
		globalParsingElapsed := time.Since(startGlobalParsing)
		metrics.AppsecGlobalParsingHistogram.With(prometheus.Labels{"source": request.RemoteAddrNormalized, "appsec_engine": request.AppsecEngine}).Observe(globalParsingElapsed.Seconds())
		return
	}

	// Challenge remediation is intentionally a no-op for OOB matches: the inband
	// response has already been sent to the visitor at this point, so there is
	// nothing left to challenge. OOB matches still feed alerts/events.
	// (captcha gets the same treatment for the same reason.)

	//Now let's process the out of band rules

	request.IsInBand = false
	request.IsOutBand = true
	state.Response.SendAlert = false
	state.Response.SendEvent = true
	state.CurrentPhase = appsec.PhaseOutOfBand

	startOutOfBandParsing := time.Now()

	err = r.ProcessOutOfBandRules(ctx, &state, request)
	if err != nil {
		logger.Errorf("unable to process OutOfBand rules: %s", err)
		err = state.Tx.Close()
		if err != nil {
			logger.Errorf("unable to close outband transaction: %s", err)
		}
		return
	}

	outOfBandParsingElapsed := time.Since(startOutOfBandParsing)
	metrics.AppsecOutbandParsingHistogram.With(prometheus.Labels{"source": request.RemoteAddrNormalized, "appsec_engine": request.AppsecEngine}).Observe(outOfBandParsingElapsed.Seconds())
	if state.Tx.IsInterrupted() || state.OutOfBandDrop != nil {
		r.handleOutBandInterrupt(ctx, &state, request)
	}
	err = state.Tx.Close()
	if err != nil {
		r.logger.Errorf("unable to close outband transaction: %s", err)
	}
	// time spent to process inband AND out of band rules
	globalParsingElapsed := time.Since(startGlobalParsing)
	metrics.AppsecGlobalParsingHistogram.With(prometheus.Labels{"source": request.RemoteAddrNormalized, "appsec_engine": request.AppsecEngine}).Observe(globalParsingElapsed.Seconds())
}

// closeEngine releases the resources cached by a coraza engine. Compiled
// regexes and operators are memoized process-wide and only freed on Close, so
// skipping it leaks them for every engine we build across reloads.
func (r *AppsecRunner) closeEngine(band string, engine coraza.WAF) {
	// coraza.WAF doesn't expose Close, but the type NewWAF returns does.
	closer, ok := engine.(io.Closer)
	if !ok {
		return
	}

	if err := closer.Close(); err != nil {
		r.logger.Errorf("Error closing %s engine: %s", band, err)
	}
}

// Close releases both engines. Safe to call once the Run loop has stopped: the
// engines are only ever used from that goroutine.
func (r *AppsecRunner) Close() {
	r.closeEngine("inband", r.AppsecInbandEngine)
	r.closeEngine("outband", r.AppsecOutbandEngine)
}

func (r *AppsecRunner) Run(ctx context.Context, t *tomb.Tomb) error {
	defer r.Close()

	r.logger.Infof("Appsec Runner ready to process event")
	for {
		select {
		case <-t.Dying():
			r.logger.Infof("Appsec Runner is dying")
			return nil
		case request := <-r.inChan:
			r.handleRequest(ctx, &request)
		}
	}
}
