package appsec

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	corazatypes "github.com/corazawaf/coraza/v3/types"
	apivalidation "github.com/crowdsecurity/crowdsec/pkg/appsec/api_validation"
	"github.com/expr-lang/expr"
	"github.com/expr-lang/expr/vm"
	"github.com/prometheus/client_golang/prometheus"
	log "github.com/sirupsen/logrus"
	"gopkg.in/yaml.v2"

	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
	"github.com/crowdsecurity/crowdsec/pkg/exprhelpers"
	"github.com/crowdsecurity/crowdsec/pkg/metrics"
	"github.com/crowdsecurity/crowdsec/pkg/pipeline"
)

type Hook struct {
	Filter     string      `yaml:"filter"`
	FilterExpr *vm.Program `yaml:"-"`

	OnSuccess string        `yaml:"on_success"`
	Apply     []string      `yaml:"apply"`
	ApplyExpr []*vm.Program `yaml:"-"`
}

type hookStage int

const (
	hookOnLoad hookStage = iota
	hookPreEval
	hookPostEval
	hookOnMatch
)

func (s hookStage) String() string {
	switch s {
	case hookOnLoad:
		return "on_load"
	case hookPreEval:
		return "pre_eval"
	case hookPostEval:
		return "post_eval"
	case hookOnMatch:
		return "on_match"
	default:
		return "unknown"
	}
}

// PhaseHooks bundles the three phase-scoped hook lists (pre_eval, post_eval,
// on_match) that run during request evaluation. OnLoad is excluded because it
// runs once at startup and is not phase-scoped.
type PhaseHooks struct {
	PreEval  []Hook
	PostEval []Hook
	OnMatch  []Hook
}

// get returns the hook list for a given stage, or nil for stages that are not
// phase-scoped (hookOnLoad or unknown).
func (p *PhaseHooks) get(stage hookStage) []Hook {
	switch stage {
	case hookPreEval:
		return p.PreEval
	case hookPostEval:
		return p.PostEval
	case hookOnMatch:
		return p.OnMatch
	default:
		return nil
	}
}

const (
	BanRemediation     = "ban"
	CaptchaRemediation = "captcha"
	AllowRemediation   = "allow"
)

const (
	// BodySizeActionDrop drops the request when the body exceeds the maximum size.
	BodySizeActionDrop = "drop"
	// BodySizeActionPartial reads the body up to the maximum size and processes it.
	BodySizeActionPartial = "partial"
	// BodySizeActionAllow processes the request without inspecting the body.
	BodySizeActionAllow = "allow"

	// DefaultMaxBodySize is the default maximum body size (10MB).
	DefaultMaxBodySize = int64(10 * 1024 * 1024)
)

type phase int

const (
	PhaseInBand phase = iota
	PhaseOutOfBand
)

func (h *Hook) Build(ctx context.Context, stage hookStage) error {
	env := map[string]any{}

	// Env builders for phase hooks dereference state (e.g. state.HookVars), so
	// we hand them a zero-value state with an initialized HookVars map for
	// compile-time expr setup. At runtime the real state is passed.
	placeholderState := &AppsecRequestState{HookVars: map[string]string{}}

	switch stage {
	case hookOnLoad:
		env = GetOnLoadEnv(&AppsecRuntimeConfig{})
	case hookPreEval:
		env = GetPreEvalEnv(ctx, &AppsecRuntimeConfig{}, placeholderState, &ParsedRequest{})
	case hookPostEval:
		env = GetPostEvalEnv(&AppsecRuntimeConfig{}, placeholderState, &ParsedRequest{})
	case hookOnMatch:
		env = GetOnMatchEnv(&AppsecRuntimeConfig{}, placeholderState, &ParsedRequest{}, pipeline.Event{})
	}

	opts := exprhelpers.GetExprOptions(env)
	if h.Filter != "" {
		program, err := expr.Compile(h.Filter, opts...) // FIXME: opts
		if err != nil {
			return fmt.Errorf("unable to compile filter %s : %w", h.Filter, err)
		}

		h.FilterExpr = program
	}

	for _, apply := range h.Apply {
		program, err := expr.Compile(apply, opts...)
		if err != nil {
			return fmt.Errorf("unable to compile apply %s : %w", apply, err)
		}

		h.ApplyExpr = append(h.ApplyExpr, program)
	}

	return nil
}

type AppsecTempResponse struct {
	InBandInterrupt         bool
	OutOfBandInterrupt      bool
	Action                  string // allow, deny, captcha, log
	UserHTTPResponseCode    int    // The response code to send to the user
	BouncerHTTPResponseCode int    // The response code to send to the remediation component
	SendEvent               bool   // do we send an internal event on rule match
	SendAlert               bool   // do we send an alert on rule match
}

type AppsecDropInfo struct {
	Reason       string
	Interruption *corazatypes.Interruption
}

type AppsecRequestState struct {
	Tx           ExtendedTransaction
	CurrentPhase phase
	Response     AppsecTempResponse

	InBandDrop    *AppsecDropInfo
	OutOfBandDrop *AppsecDropInfo

	PendingAction   *string
	PendingHTTPCode *int

	// HookVars is a per-request scratch space exposed to expr hooks as
	// `hook_vars`. Helpers (e.g. ValidateRequestWithSchema) publish string
	// values here so that later hook expressions — including the `apply`
	// block of the same hook — can read them. The map is allocated once in
	// NewRequestState, persists across in-band/out-of-band phases, and is
	// copied into pipeline.AppsecEvent.HookVars when an event is emitted.
	HookVars              map[string]string
	DisableBodyInspection bool
}

func (s *AppsecRequestState) ResetResponse(cfg *AppsecConfig) {
	if cfg == nil {
		s.Response = AppsecTempResponse{}
		return
	}

	s.Response = AppsecTempResponse{}
	s.Response.Action = cfg.DefaultPassAction
	s.Response.BouncerHTTPResponseCode = cfg.BouncerPassedHTTPCode
	s.Response.UserHTTPResponseCode = cfg.UserPassedHTTPCode
	s.Response.SendEvent = true
	s.Response.SendAlert = true
	s.PendingAction = nil
	s.PendingHTTPCode = nil
}

func (s *AppsecRequestState) DropInfo(request *ParsedRequest) *AppsecDropInfo {
	switch {
	case request != nil && request.IsInBand:
		return s.InBandDrop
	case request != nil && request.IsOutBand:
		return s.OutOfBandDrop
	default:
		return nil
	}
}

func (s *AppsecRequestState) ApplyPendingResponse() {
	if s.PendingAction != nil {
		s.Response.Action = *s.PendingAction
		s.PendingAction = nil
	}

	if s.PendingHTTPCode != nil {
		s.Response.UserHTTPResponseCode = *s.PendingHTTPCode
		s.PendingHTTPCode = nil
	}
}

type AppsecSubEngineOpts struct {
	DisableBodyInspection    bool `yaml:"disable_body_inspection"`
	RequestBodyInMemoryLimit *int `yaml:"request_body_in_memory_limit"`
}

// BodySettings controls how oversized request bodies are handled.
type BodySettings struct {
	// MaxSize is the maximum allowed body size in bytes. Defaults to DefaultMaxBodySize (10MB).
	MaxSize int64 `yaml:"max_body_size"`
	// Action controls what happens when a body exceeds MaxSize:
	// "drop" (default) - block the request, "partial" - inspect up to MaxSize bytes, "allow" - skip body inspection.
	Action string `yaml:"body_size_exceeded_action"`
}

// AppsecPhaseConfig holds configuration scoped to a specific phase (inband or outofband).
// Hooks defined here are automatically dispatched only during the corresponding phase.
type AppsecPhaseConfig struct {
	Rules             []string            `yaml:"rules"`
	OnMatch           []Hook              `yaml:"on_match"`
	PreEval           []Hook              `yaml:"pre_eval"`
	PostEval          []Hook              `yaml:"post_eval"`
	Options           AppsecSubEngineOpts `yaml:"options"`
	VariablesTracking []string            `yaml:"variables_tracking"`
}

// runtime version of AppsecConfig
type AppsecRuntimeConfig struct {
	Name           string
	OutOfBandRules []AppsecCollection

	InBandRules []AppsecCollection

	DefaultRemediation string
	RemediationByTag   map[string]string // Also used for ByName, as the name (for modsec rules) is a tag crowdsec-NAME
	RemediationById    map[int]string

	CompiledOnLoad []Hook     // runs once at startup, not phase-scoped
	CommonHooks    PhaseHooks // apply to both phases
	InBandHooks    PhaseHooks // only run during in-band
	OutOfBandHooks PhaseHooks // only run during out-of-band

	CompiledVariablesTracking []*regexp.Regexp
	Config                    *AppsecConfig
	// CorazaLogger              debuglog.Logger

	Logger *log.Entry

	// Set by on_load to ignore some rules on loading
	DisabledInBandRuleIds   []int
	DisabledInBandRulesTags []string // Also used for ByName, as the name (for modsec rules) is a tag crowdsec-NAME

	DisabledOutOfBandRuleIds   []int
	DisabledOutOfBandRulesTags []string // Also used for ByName, as the name (for modsec rules) is a tag crowdsec-NAME

	RequestValidator *apivalidation.RequestValidator
	DataDir          string
	// BodySettings controls how oversized request bodies are handled. Settable via on_load hooks.
	BodySettings BodySettings
}

type AppsecConfig struct {
	Name                   string   `yaml:"name"`
	OutOfBandRules         []string `yaml:"outofband_rules"`
	InBandRules            []string `yaml:"inband_rules"`
	DefaultRemediation     string   `yaml:"default_remediation"`
	DefaultPassAction      string   `yaml:"default_pass_action"`
	BouncerBlockedHTTPCode int      `yaml:"blocked_http_code"`      // returned to the bouncer
	BouncerPassedHTTPCode  int      `yaml:"passed_http_code"`       // returned to the bouncer
	UserBlockedHTTPCode    int      `yaml:"user_blocked_http_code"` // returned to the user
	UserPassedHTTPCode     int      `yaml:"user_passed_http_code"`  // returned to the user

	OnLoad            []Hook              `yaml:"on_load"`
	PreEval           []Hook              `yaml:"pre_eval"`
	PostEval          []Hook              `yaml:"post_eval"`
	OnMatch           []Hook              `yaml:"on_match"`
	VariablesTracking []string            `yaml:"variables_tracking"`
	InbandOptions     AppsecSubEngineOpts `yaml:"inband_options"`
	OutOfBandOptions  AppsecSubEngineOpts `yaml:"outofband_options"`

	InBand    *AppsecPhaseConfig `yaml:"inband"`
	OutOfBand *AppsecPhaseConfig `yaml:"outofband"`

	LogLevel *log.Level `yaml:"log_level"`
	Logger   *log.Entry `yaml:"-"`
}

func (w *AppsecRuntimeConfig) NewRequestState() AppsecRequestState {
	state := AppsecRequestState{
		HookVars: make(map[string]string),
	}
	state.ResetResponse(w.Config)
	return state
}

func (w *AppsecRuntimeConfig) ClearResponse(state *AppsecRequestState) {
	state.ResetResponse(w.Config)
}

func (w *AppsecRuntimeConfig) DropRequest(state *AppsecRequestState, request *ParsedRequest, reason string) error {
	reason = strings.TrimSpace(reason)
	if reason == "" {
		reason = "request dropped by drop helper"
	}

	interrupt := &corazatypes.Interruption{
		RuleID: 0,
		Action: "deny",
		Status: w.Config.UserBlockedHTTPCode,
		Data:   reason,
		Tags:   []string{"crowdsec:drop-request"},
	}

	switch {
	case request.IsInBand:
		if state.Tx.Tx == nil {
			return errors.New("inband transaction not initialized")
		}
		interrupt.Tags = append(interrupt.Tags, "crowdsec:drop-request:inband")
		state.InBandDrop = &AppsecDropInfo{Reason: reason, Interruption: interrupt}
		state.Response.InBandInterrupt = true
		state.Response.Action = w.DefaultRemediation
		state.Response.BouncerHTTPResponseCode = w.Config.BouncerBlockedHTTPCode
		state.Response.UserHTTPResponseCode = w.Config.UserBlockedHTTPCode
		state.Tx.Interrupt(interrupt)
		w.Logger.Debugf("drop request helper triggered for inband phase: %s", reason)
	case request.IsOutBand:
		if state.Tx.Tx == nil {
			return errors.New("outofband transaction not initialized")
		}
		interrupt.Tags = append(interrupt.Tags, "crowdsec:drop-request:outofband")
		state.OutOfBandDrop = &AppsecDropInfo{Reason: reason, Interruption: interrupt}
		state.Response.OutOfBandInterrupt = true
		state.Tx.Interrupt(interrupt)
		w.Logger.Debugf("drop request helper triggered for out-of-band phase: %s", reason)
	default:
		return errors.New("unable to determine request band for drop helper")
	}

	return nil
}

func (wc *AppsecConfig) SetUpLogger() {
	if wc.LogLevel == nil {
		lvl := wc.Logger.Logger.GetLevel()
		wc.LogLevel = &lvl
	}

	/* wc.Name is actually the datasource name.*/
	wc.Logger = wc.Logger.Dup().WithField("name", wc.Name)
	wc.Logger.Logger.SetLevel(*wc.LogLevel)
}

func (wc *AppsecConfig) LoadByPath(file string) error {
	wc.Logger.Debugf("loading config %s", file)

	yamlFile, err := os.ReadFile(file)
	if err != nil {
		return fmt.Errorf("unable to read file %s : %w", file, err)
	}

	// as  LoadByPath can be called several time, we append rules/hooks, but override other options
	var tmp AppsecConfig

	err = yaml.UnmarshalStrict(yamlFile, &tmp)
	if err != nil {
		return fmt.Errorf("unable to parse yaml file %s : %w", file, err)
	}

	// Normalize phase-scoped sections: merge rules, options, and variables_tracking
	// into flat fields. Hooks stay in the phase sections for Build() to compile separately.
	tmp.normalizePhaseScoped()

	if wc.Name == "" && tmp.Name != "" {
		wc.Name = tmp.Name
	}

	// We can append rules/hooks
	if tmp.OutOfBandRules != nil {
		wc.OutOfBandRules = append(wc.OutOfBandRules, tmp.OutOfBandRules...)
	}

	if tmp.InBandRules != nil {
		wc.InBandRules = append(wc.InBandRules, tmp.InBandRules...)
	}

	if tmp.OnLoad != nil {
		wc.OnLoad = append(wc.OnLoad, tmp.OnLoad...)
	}

	if tmp.PreEval != nil {
		wc.PreEval = append(wc.PreEval, tmp.PreEval...)
	}

	if tmp.PostEval != nil {
		wc.PostEval = append(wc.PostEval, tmp.PostEval...)
	}

	if tmp.OnMatch != nil {
		wc.OnMatch = append(wc.OnMatch, tmp.OnMatch...)
	}

	if tmp.VariablesTracking != nil {
		wc.VariablesTracking = append(wc.VariablesTracking, tmp.VariablesTracking...)
	}

	// Append phase-scoped hooks
	if tmp.InBand != nil {
		if wc.InBand == nil {
			wc.InBand = &AppsecPhaseConfig{}
		}

		wc.InBand.OnMatch = append(wc.InBand.OnMatch, tmp.InBand.OnMatch...)
		wc.InBand.PreEval = append(wc.InBand.PreEval, tmp.InBand.PreEval...)
		wc.InBand.PostEval = append(wc.InBand.PostEval, tmp.InBand.PostEval...)
	}

	if tmp.OutOfBand != nil {
		if wc.OutOfBand == nil {
			wc.OutOfBand = &AppsecPhaseConfig{}
		}

		wc.OutOfBand.OnMatch = append(wc.OutOfBand.OnMatch, tmp.OutOfBand.OnMatch...)
		wc.OutOfBand.PreEval = append(wc.OutOfBand.PreEval, tmp.OutOfBand.PreEval...)
		wc.OutOfBand.PostEval = append(wc.OutOfBand.PostEval, tmp.OutOfBand.PostEval...)
	}

	// override other options
	wc.LogLevel = tmp.LogLevel

	wc.DefaultRemediation = tmp.DefaultRemediation
	wc.DefaultPassAction = tmp.DefaultPassAction
	wc.BouncerBlockedHTTPCode = tmp.BouncerBlockedHTTPCode
	wc.BouncerPassedHTTPCode = tmp.BouncerPassedHTTPCode
	wc.UserBlockedHTTPCode = tmp.UserBlockedHTTPCode
	wc.UserPassedHTTPCode = tmp.UserPassedHTTPCode

	if tmp.InbandOptions.DisableBodyInspection {
		wc.InbandOptions.DisableBodyInspection = true
	}

	if tmp.InbandOptions.RequestBodyInMemoryLimit != nil {
		wc.InbandOptions.RequestBodyInMemoryLimit = tmp.InbandOptions.RequestBodyInMemoryLimit
	}

	if tmp.OutOfBandOptions.DisableBodyInspection {
		wc.OutOfBandOptions.DisableBodyInspection = true
	}

	if tmp.OutOfBandOptions.RequestBodyInMemoryLimit != nil {
		wc.OutOfBandOptions.RequestBodyInMemoryLimit = tmp.OutOfBandOptions.RequestBodyInMemoryLimit
	}

	return nil
}

// normalizePhaseScoped merges rules, options, and variables_tracking from
// phase-scoped sections (inband/outofband) into the flat top-level fields.
// Hooks are left in the phase sections for Build() to compile separately.
func (wc *AppsecConfig) normalizePhaseScoped() {
	if wc.InBand != nil {
		wc.InBandRules = append(wc.InBandRules, wc.InBand.Rules...)
		wc.InBand.Rules = nil

		if wc.InBand.Options.DisableBodyInspection {
			wc.InbandOptions.DisableBodyInspection = true
		}

		if wc.InBand.Options.RequestBodyInMemoryLimit != nil {
			wc.InbandOptions.RequestBodyInMemoryLimit = wc.InBand.Options.RequestBodyInMemoryLimit
		}

		wc.VariablesTracking = append(wc.VariablesTracking, wc.InBand.VariablesTracking...)
		wc.InBand.VariablesTracking = nil
	}

	if wc.OutOfBand != nil {
		wc.OutOfBandRules = append(wc.OutOfBandRules, wc.OutOfBand.Rules...)
		wc.OutOfBand.Rules = nil

		if wc.OutOfBand.Options.DisableBodyInspection {
			wc.OutOfBandOptions.DisableBodyInspection = true
		}

		if wc.OutOfBand.Options.RequestBodyInMemoryLimit != nil {
			wc.OutOfBandOptions.RequestBodyInMemoryLimit = wc.OutOfBand.Options.RequestBodyInMemoryLimit
		}

		wc.VariablesTracking = append(wc.VariablesTracking, wc.OutOfBand.VariablesTracking...)
		wc.OutOfBand.VariablesTracking = nil
	}
}

// buildHookList validates and compiles a list of hooks of the given stage.
func buildHookList(ctx context.Context, hooks []Hook, stage hookStage) ([]Hook, error) {
	var compiled []Hook

	for _, hook := range hooks {
		if hook.OnSuccess != "" && hook.OnSuccess != "continue" && hook.OnSuccess != "break" {
			return nil, fmt.Errorf("invalid 'on_success' for %s hook : %s", stage, hook.OnSuccess)
		}

		if err := hook.Build(ctx, stage); err != nil {
			return nil, fmt.Errorf("unable to build %s hook : %w", stage, err)
		}

		compiled = append(compiled, hook)
	}

	return compiled, nil
}

// buildPhaseHooks compiles pre_eval / post_eval / on_match hook lists into a
// PhaseHooks. phaseName is only used to wrap errors ("" for the shared section).
func buildPhaseHooks(ctx context.Context, phaseName string, pre, post, onMatch []Hook) (PhaseHooks, error) {
	var (
		out PhaseHooks
		err error
	)

	wrap := func(e error) error {
		if phaseName == "" || e == nil {
			return e
		}
		return fmt.Errorf("%s: %w", phaseName, e)
	}

	if out.PreEval, err = buildHookList(ctx, pre, hookPreEval); err != nil {
		return PhaseHooks{}, wrap(err)
	}

	if out.PostEval, err = buildHookList(ctx, post, hookPostEval); err != nil {
		return PhaseHooks{}, wrap(err)
	}

	if out.OnMatch, err = buildHookList(ctx, onMatch, hookOnMatch); err != nil {
		return PhaseHooks{}, wrap(err)
	}

	return out, nil
}

func (wc *AppsecConfig) Load(configName string, hub *cwhub.Hub) error {
	item := hub.GetItem(cwhub.APPSEC_CONFIGS, configName)

	if item != nil && item.State.IsInstalled() {
		wc.Logger.Infof("loading %s", item.State.LocalPath)

		err := wc.LoadByPath(item.State.LocalPath)
		if err != nil {
			return fmt.Errorf("unable to load appsec-config %s : %s", item.State.LocalPath, err)
		}

		return nil
	}

	return fmt.Errorf("no appsec-config found for %s", configName)
}

func (wc *AppsecConfig) Build(ctx context.Context, hub *cwhub.Hub) (*AppsecRuntimeConfig, error) {
	ret := &AppsecRuntimeConfig{Logger: wc.Logger.WithField("component", "appsec_runtime_config")}

	ret.RequestValidator = apivalidation.NewRequestValidator(wc.Logger.WithField("component", "api_validator"))

	if wc.BouncerBlockedHTTPCode == 0 {
		wc.BouncerBlockedHTTPCode = http.StatusForbidden
	}

	if wc.BouncerPassedHTTPCode == 0 {
		wc.BouncerPassedHTTPCode = http.StatusOK
	}

	if wc.UserBlockedHTTPCode == 0 {
		wc.UserBlockedHTTPCode = http.StatusForbidden
	}

	if wc.UserPassedHTTPCode == 0 {
		wc.UserPassedHTTPCode = http.StatusOK
	}

	if wc.DefaultPassAction == "" {
		wc.DefaultPassAction = AllowRemediation
	}

	if wc.DefaultRemediation == "" {
		wc.DefaultRemediation = BanRemediation
	}

	// set the defaults
	switch wc.DefaultRemediation {
	case BanRemediation, CaptchaRemediation, AllowRemediation:
		// those are the officially supported remediation(s)
	default:
		wc.Logger.Warningf("default '%s' remediation of %s is none of [%s,%s,%s] ensure bouncer compatbility!", wc.DefaultRemediation, wc.Name, BanRemediation, CaptchaRemediation, AllowRemediation)
	}

	ret.Name = wc.Name
	ret.Config = wc
	ret.DefaultRemediation = wc.DefaultRemediation
	ret.BodySettings = BodySettings{
		MaxSize: DefaultMaxBodySize,
		Action:  BodySizeActionDrop,
	}

	wc.Logger.Tracef("Loading config %+v", wc)
	// load rules
	for _, rule := range wc.OutOfBandRules {
		wc.Logger.Infof("loading outofband rule %s", rule)

		collections, err := LoadCollection(rule, wc.Logger.WithField("component", "appsec_collection_loader"), hub)
		if err != nil {
			return nil, fmt.Errorf("unable to load outofband rule %s : %s", rule, err)
		}

		ret.OutOfBandRules = append(ret.OutOfBandRules, collections...)
	}

	wc.Logger.Infof("Loaded %d outofband rules", len(ret.OutOfBandRules))

	for _, rule := range wc.InBandRules {
		wc.Logger.Infof("loading inband rule %s", rule)

		collections, err := LoadCollection(rule, wc.Logger.WithField("component", "appsec_collection_loader"), hub)
		if err != nil {
			return nil, fmt.Errorf("unable to load inband rule %s : %s", rule, err)
		}

		ret.InBandRules = append(ret.InBandRules, collections...)
	}

	wc.Logger.Infof("Loaded %d inband rules", len(ret.InBandRules))

	// load hooks
	var err error

	if ret.CompiledOnLoad, err = buildHookList(ctx, wc.OnLoad, hookOnLoad); err != nil {
		return nil, err
	}

	if ret.CommonHooks, err = buildPhaseHooks(ctx, "", wc.PreEval, wc.PostEval, wc.OnMatch); err != nil {
		return nil, err
	}

	if wc.InBand != nil {
		if ret.InBandHooks, err = buildPhaseHooks(ctx, "inband",
			wc.InBand.PreEval, wc.InBand.PostEval, wc.InBand.OnMatch); err != nil {
			return nil, err
		}
	}

	if wc.OutOfBand != nil {
		if ret.OutOfBandHooks, err = buildPhaseHooks(ctx, "outofband",
			wc.OutOfBand.PreEval, wc.OutOfBand.PostEval, wc.OutOfBand.OnMatch); err != nil {
			return nil, err
		}
	}

	// variable tracking
	for _, variable := range wc.VariablesTracking {
		compiledVariableRule, err := regexp.Compile(variable)
		if err != nil {
			return nil, fmt.Errorf("cannot compile variable regexp %s: %w", variable, err)
		}

		ret.CompiledVariablesTracking = append(ret.CompiledVariablesTracking, compiledVariableRule)
	}

	return ret, nil
}

// processHooks runs a list of compiled hooks with the given environment.
func (w *AppsecRuntimeConfig) processHooks(hooks []Hook, env map[string]interface{}, hookType string) error {
	has_match := false

	for _, rule := range hooks {
		if rule.FilterExpr != nil {
			output, err := exprhelpers.Run(rule.FilterExpr, env, w.Logger, w.Logger.Level >= log.DebugLevel)
			if err != nil {
				return fmt.Errorf("unable to run appsec %s filter %s : %w", hookType, rule.Filter, err)
			}

			switch t := output.(type) {
			case bool:
				if !t {
					w.Logger.Debugf("filter didnt match")
					continue
				}
			default:
				w.Logger.Errorf("Filter must return a boolean, can't filter")
				continue
			}

			has_match = true
		}

		for _, applyExpr := range rule.ApplyExpr {
			o, err := exprhelpers.Run(applyExpr, env, w.Logger, w.Logger.Level >= log.DebugLevel)
			if err != nil {
				w.Logger.Errorf("unable to apply appsec %s expr: %s", hookType, err)
				continue
			}

			switch t := o.(type) {
			case error:
				w.Logger.Errorf("unable to apply appsec %s expr: %s", hookType, t)
				continue
			default:
			}
		}

		if has_match && rule.OnSuccess == "break" {
			break
		}
	}

	return nil
}

func (w *AppsecRuntimeConfig) ProcessOnLoadRules() error {
	return w.processHooks(w.CompiledOnLoad, GetOnLoadEnv(w), "on_load")
}

// runPhaseHooks runs the common hooks for the given stage, then dispatches to
// the in-band or out-of-band phase hooks depending on the request band.
func (w *AppsecRuntimeConfig) runPhaseHooks(stage hookStage, env map[string]interface{}, request *ParsedRequest) error {
	label := stage.String()

	if err := w.processHooks(w.CommonHooks.get(stage), env, label); err != nil {
		return err
	}

	switch {
	case request.IsInBand:
		return w.processHooks(w.InBandHooks.get(stage), env, label+"[inband]")
	case request.IsOutBand:
		return w.processHooks(w.OutOfBandHooks.get(stage), env, label+"[outofband]")
	}

	return nil
}

func (w *AppsecRuntimeConfig) ProcessOnMatchRules(state *AppsecRequestState, request *ParsedRequest, evt pipeline.Event) error {
	return w.runPhaseHooks(hookOnMatch, GetOnMatchEnv(w, state, request, evt), request)
}

func (w *AppsecRuntimeConfig) ProcessPreEvalRules(ctx context.Context, state *AppsecRequestState, request *ParsedRequest) error {
	return w.runPhaseHooks(hookPreEval, GetPreEvalEnv(ctx, w, state, request), request)
}

func (w *AppsecRuntimeConfig) ProcessPostEvalRules(state *AppsecRequestState, request *ParsedRequest) error {
	return w.runPhaseHooks(hookPostEval, GetPostEvalEnv(w, state, request), request)
}

func (w *AppsecRuntimeConfig) RemoveInbandRuleByID(state *AppsecRequestState, id int) error {
	if state.CurrentPhase != PhaseInBand {
		w.Logger.Warnf("cannot remove inband rule %d when not in inband phase", id)
		return nil
	}

	w.Logger.Debugf("removing inband rule %d", id)
	return state.Tx.RemoveRuleByIDWithError(id)
}

func (w *AppsecRuntimeConfig) RemoveOutbandRuleByID(state *AppsecRequestState, id int) error {
	if state.CurrentPhase != PhaseOutOfBand {
		w.Logger.Warnf("cannot remove outband rule %d when not in outband phase", id)
		return nil
	}

	w.Logger.Debugf("removing outband rule %d", id)
	return state.Tx.RemoveRuleByIDWithError(id)
}

func (w *AppsecRuntimeConfig) RemoveInbandRuleByTag(state *AppsecRequestState, tag string) error {
	if state.CurrentPhase != PhaseInBand {
		w.Logger.Warnf("cannot remove inband rule with tag %s when not in inband phase", tag)
		return nil
	}

	w.Logger.Debugf("removing inband rule with tag %s", tag)
	return state.Tx.RemoveRuleByTagWithError(tag)
}

func (w *AppsecRuntimeConfig) RemoveOutbandRuleByTag(state *AppsecRequestState, tag string) error {
	if state.CurrentPhase != PhaseOutOfBand {
		w.Logger.Warnf("cannot remove outband rule with tag %s when not in outband phase", tag)
		return nil
	}

	w.Logger.Debugf("removing outband rule with tag %s", tag)
	return state.Tx.RemoveRuleByTagWithError(tag)
}

func (w *AppsecRuntimeConfig) RemoveInbandRuleByName(state *AppsecRequestState, name string) error {
	if state.CurrentPhase != PhaseInBand {
		w.Logger.Warnf("cannot remove inband rule with name %s when not in inband phase", name)
		return nil
	}
	tag := fmt.Sprintf("crowdsec-%s", name)
	w.Logger.Debugf("removing inband rule %s", tag)
	return w.RemoveInbandRuleByTag(state, tag)
}

func (w *AppsecRuntimeConfig) RemoveOutbandRuleByName(state *AppsecRequestState, name string) error {
	if state.CurrentPhase != PhaseOutOfBand {
		w.Logger.Warnf("cannot remove outband rule with name %s when not in outband phase", name)
		return nil
	}
	tag := fmt.Sprintf("crowdsec-%s", name)
	w.Logger.Debugf("removing outband rule %s", tag)
	return w.RemoveOutbandRuleByTag(state, tag)
}

func (w *AppsecRuntimeConfig) CancelEvent(state *AppsecRequestState) error {
	w.Logger.Debugf("canceling event")
	state.Response.SendEvent = false

	return nil
}

// Disable a rule at load time, meaning it will not run for any request
func (w *AppsecRuntimeConfig) DisableInBandRuleByID(id int) error {
	w.DisabledInBandRuleIds = append(w.DisabledInBandRuleIds, id)
	return nil
}

// Disable a rule at load time, meaning it will not run for any request
func (w *AppsecRuntimeConfig) DisableInBandRuleByName(name string) error {
	tagValue := fmt.Sprintf("crowdsec-%s", name)
	w.DisabledInBandRulesTags = append(w.DisabledInBandRulesTags, tagValue)
	return nil
}

// Disable a rule at load time, meaning it will not run for any request
func (w *AppsecRuntimeConfig) DisableInBandRuleByTag(tag string) error {
	w.DisabledInBandRulesTags = append(w.DisabledInBandRulesTags, tag)
	return nil
}

// Disable a rule at load time, meaning it will not run for any request
func (w *AppsecRuntimeConfig) DisableOutBandRuleByID(id int) error {
	w.DisabledOutOfBandRuleIds = append(w.DisabledOutOfBandRuleIds, id)
	return nil
}

// Disable a rule at load time, meaning it will not run for any request
func (w *AppsecRuntimeConfig) DisableOutBandRuleByName(name string) error {
	tagValue := fmt.Sprintf("crowdsec-%s", name)
	w.DisabledOutOfBandRulesTags = append(w.DisabledOutOfBandRulesTags, tagValue)
	return nil
}

// Disable a rule at load time, meaning it will not run for any request
func (w *AppsecRuntimeConfig) DisableOutBandRuleByTag(tag string) error {
	w.DisabledOutOfBandRulesTags = append(w.DisabledOutOfBandRulesTags, tag)
	return nil
}

func (w *AppsecRuntimeConfig) SendEvent(state *AppsecRequestState) error {
	w.Logger.Debugf("sending event")
	state.Response.SendEvent = true
	return nil
}

func (w *AppsecRuntimeConfig) SendAlert(state *AppsecRequestState) error {
	w.Logger.Debugf("sending alert")
	state.Response.SendAlert = true
	return nil
}

func (w *AppsecRuntimeConfig) CancelAlert(state *AppsecRequestState) error {
	w.Logger.Debugf("canceling alert")
	state.Response.SendAlert = false
	return nil
}

func (w *AppsecRuntimeConfig) SetActionByTag(tag string, action string) error {
	if w.RemediationByTag == nil {
		w.RemediationByTag = make(map[string]string)
	}
	w.Logger.Debugf("setting action of %s to %s", tag, action)
	w.RemediationByTag[tag] = action
	return nil
}

func (w *AppsecRuntimeConfig) SetActionByID(id int, action string) error {
	if w.RemediationById == nil {
		w.RemediationById = make(map[int]string)
	}
	w.Logger.Debugf("setting action of %d to %s", id, action)
	w.RemediationById[id] = action
	return nil
}

func (w *AppsecRuntimeConfig) SetActionByName(name string, action string) error {
	if w.RemediationByTag == nil {
		w.RemediationByTag = make(map[string]string)
	}
	tag := fmt.Sprintf("crowdsec-%s", name)
	w.Logger.Debugf("setting action of %s to %s", tag, action)
	w.RemediationByTag[tag] = action
	return nil
}

func (w *AppsecRuntimeConfig) SetAction(state *AppsecRequestState, action string) error {
	w.Logger.Debugf("setting action to %s", action)
	state.Response.Action = action
	return nil
}

func (w *AppsecRuntimeConfig) SetHTTPCode(state *AppsecRequestState, code int) error {
	w.Logger.Debugf("setting http code to %d", code)
	state.Response.UserHTTPResponseCode = code
	return nil
}

// SetMaxBodySize sets the maximum allowed body size in bytes. Intended for use in on_load hooks.
func (w *AppsecRuntimeConfig) SetMaxBodySize(size int64) error {
	if size <= 0 {
		return errors.New("max_body_size must be a positive integer")
	}

	w.Logger.Debugf("setting max body size to %d bytes", size)
	w.BodySettings.MaxSize = size

	return nil
}

// SetBodySizeExceededAction sets what happens when the body exceeds the maximum size.
// Valid values: "drop" (block request), "partial" (inspect up to max size), "allow" (skip body inspection).
// Intended for use in on_load hooks.
func (w *AppsecRuntimeConfig) SetBodySizeExceededAction(action string) error {
	switch action {
	case BodySizeActionDrop, BodySizeActionPartial, BodySizeActionAllow:
		w.Logger.Debugf("setting body size exceeded action to %q", action)
		w.BodySettings.Action = action

		return nil
	default:
		return fmt.Errorf("invalid body_size_exceeded_action %q (must be %s, %s, or %s)", action, BodySizeActionDrop, BodySizeActionPartial, BodySizeActionAllow)
	}
}

// DisableBodyInspection prevents Coraza from processing the request body for the current request.
// Intended for use in pre_eval hooks.
func (w *AppsecRuntimeConfig) DisableBodyInspection(state *AppsecRequestState) error {
	state.DisableBodyInspection = true
	w.Logger.Debugf("body inspection disabled for this request")

	return nil
}

type BodyResponse struct {
	Action     string `json:"action"`
	HTTPStatus int    `json:"http_status"`
}

func (w *AppsecRuntimeConfig) GenerateResponse(response AppsecTempResponse, logger *log.Entry) (int, BodyResponse) {
	var bouncerStatusCode int

	resp := BodyResponse{Action: response.Action}
	if response.Action == AllowRemediation {
		resp.HTTPStatus = w.Config.UserPassedHTTPCode
		bouncerStatusCode = w.Config.BouncerPassedHTTPCode
	} else { // ban, captcha and anything else
		resp.HTTPStatus = response.UserHTTPResponseCode
		if resp.HTTPStatus == 0 {
			resp.HTTPStatus = w.Config.UserBlockedHTTPCode
		}
		bouncerStatusCode = response.BouncerHTTPResponseCode
		if bouncerStatusCode == 0 {
			bouncerStatusCode = w.Config.BouncerBlockedHTTPCode
		}
	}

	return bouncerStatusCode, resp
}

const schemasSubDir = "schemas"

func (w *AppsecRuntimeConfig) loadAPISchema(ref, filename string, opts *apivalidation.SchemaOptions) error {
	if !filepath.IsLocal(filename) {
		return fmt.Errorf("schema filename %q must be relative to %s and stay within it", filename, schemasSubDir)
	}
	schemaPath := filepath.Join(w.DataDir, schemasSubDir, filename)
	w.Logger.Debugf("loading schema %s for ref %s", schemaPath, ref)
	schema, err := os.ReadFile(schemaPath)
	if err != nil {
		return fmt.Errorf("unable to read schema file %s : %w", schemaPath, err)
	}
	return w.RequestValidator.LoadSchema(ref, string(schema), opts)
}

func (w *AppsecRuntimeConfig) LoadAPISchemaWithName(ref string, filename string) error {
	return w.loadAPISchema(ref, filename, nil)
}

// LoadAPISchemaWithOptions behaves like LoadAPISchemaWithName but accepts a
// map of policy overrides. Supported keys:
//   - "on_route_not_found":             "drop" | "ignore"  (default: "drop")
//   - "on_method_not_allowed":          "drop" | "ignore"  (default: "drop")
//   - "on_unsupported_security_scheme": "drop" | "ignore"  (default: "drop")
func (w *AppsecRuntimeConfig) LoadAPISchemaWithOptions(ref string, filename string, opts map[string]any) error {
	schemaOpts, err := parseSchemaOptions(opts)
	if err != nil {
		return err
	}
	return w.loadAPISchema(ref, filename, schemaOpts)
}

// RegisterAPISchemaBodyDecoder allows a user's on_load hook to add a Content-Type
// to the set the API schema validator can decode. decoderName must be one of
// the stable built-in identifiers exported by the api_validation package
// ("json", "urlencoded", "multipart", "yaml", "csv", "plain", "file"). Note
// that the underlying kin-openapi decoder registry is process-global: today
// all appsec datasources in the same process share the same set of
// registered body decoders.
func (w *AppsecRuntimeConfig) RegisterAPISchemaBodyDecoder(contentType, decoderName string) error {
	return w.RequestValidator.RegisterBodyDecoder(contentType, decoderName)
}

func parseSchemaOptions(opts map[string]any) (*apivalidation.SchemaOptions, error) {
	out := &apivalidation.SchemaOptions{}
	for k, v := range opts {
		s, ok := v.(string)
		if !ok {
			return nil, fmt.Errorf("schema option %q must be a string, got %T", k, v)
		}
		switch k {
		case "on_route_not_found":
			out.OnRouteNotFound = apivalidation.Policy(s)
		case "on_method_not_allowed":
			out.OnMethodNotAllowed = apivalidation.Policy(s)
		case "on_unsupported_security_scheme":
			out.OnUnsupportedSecurityScheme = apivalidation.Policy(s)
		default:
			return nil, fmt.Errorf("unknown schema option %q", k)
		}
	}
	return out, nil
}

// validationErrorVarKeys lists the keys published into state.HookVars by
// ValidateRequestWithSchema. Keeping them centralized makes it easy to reset
// them all at the start of each validation call.
var validationErrorVarKeys = []string{
	"validation_error",
	"validation_error_reason",
	"validation_error_field",
	"validation_error_message",
	"validation_error_value",
	"validation_error_expected",
}

// ValidateRequestWithSchema validates r against the OpenAPI schema registered
// under ref. It returns true when the request is valid, false when it is not
// (or when no schema is registered for ref). On failure, structured error
// details are published into state.HookVars under the "validation_error*" keys
// so that subsequent hook expressions (typically the `apply` block of the same
// hook) can build a drop reason or enrich an event. Each call also increments
// the AppsecValidationOKCounter / AppsecValidationFailedCounter metric.
func (w *AppsecRuntimeConfig) ValidateRequestWithSchema(ctx context.Context, state *AppsecRequestState, request *ParsedRequest, ref string) bool {
	for _, k := range validationErrorVarKeys {
		delete(state.HookVars, k)
	}

	r := request.HTTPRequest.Clone(ctx)
	r.Body = io.NopCloser(bytes.NewReader(request.Body))

	err := w.RequestValidator.ValidateRequest(ctx, ref, r)
	if err == nil {
		metrics.AppsecValidationOKCounter.With(prometheus.Labels{
			"source":        request.RemoteAddrNormalized,
			"appsec_engine": request.AppsecEngine,
			"schema_ref":    ref,
		}).Inc()
		return true
	}

	var valErr *apivalidation.ValidationError
	if !errors.As(err, &valErr) {
		// Non-ValidationError paths cover things like "no schema loaded for
		// ref X". Log loudly and surface a synthetic entry so the hook can
		// still build a message.
		w.Logger.Errorf("request validation failed: %s", err)
		valErr = &apivalidation.ValidationError{
			Reason:  "internal",
			Message: err.Error(),
		}
	}

	state.HookVars["validation_error"] = valErr.Error()
	state.HookVars["validation_error_reason"] = valErr.Reason
	state.HookVars["validation_error_field"] = valErr.Field
	state.HookVars["validation_error_message"] = valErr.Message
	state.HookVars["validation_error_value"] = valErr.Value
	state.HookVars["validation_error_expected"] = valErr.Expected

	metrics.AppsecValidationFailedCounter.With(prometheus.Labels{
		"source":        request.RemoteAddrNormalized,
		"appsec_engine": request.AppsecEngine,
		"schema_ref":    ref,
		"reason":        valErr.Reason,
	}).Inc()

	return false
}
