package listener

import (
	"context"
	"crypto/subtle"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"net"
	"net/http"
	"os"
	"os/user"
	"strconv"
	"time"

	"github.com/icinga/icinga-go-library/database"
	"github.com/icinga/icinga-go-library/logging"
	"github.com/icinga/icinga-go-library/notifications"
	baseEv "github.com/icinga/icinga-go-library/notifications/event"
	"github.com/icinga/icinga-go-library/notifications/source"
	"github.com/icinga/icinga-notifications/internal"
	"github.com/icinga/icinga-notifications/internal/config"
	"github.com/icinga/icinga-notifications/internal/daemon"
	"github.com/icinga/icinga-notifications/internal/event"
	"github.com/icinga/icinga-notifications/internal/incident"
	"github.com/icinga/icinga-notifications/internal/object"
	"github.com/jmoiron/sqlx"
	"go.uber.org/zap"
)

// responseWriter is a wrapper around [http.ResponseWriter] that captures the status code written to the response.
type responseWriter struct {
	http.ResponseWriter
	status int
}

// Unwrap returns the underlying [http.ResponseWriter] that is wrapped by this [responseWriter].
func (rw *responseWriter) Unwrap() http.ResponseWriter { return rw.ResponseWriter }

// WriteHeader captures the status code and writes it to the underlying [ResponseWriter].
func (rw *responseWriter) WriteHeader(status int) {
	rw.status = status
	rw.ResponseWriter.WriteHeader(status)
}

// peerUserLookupKey is the context key under which a [peerUserLookupFunc] is stored for Unix socket connections.
type peerUserLookupKey struct{}

// peerUserLookupFunc resolves the OS username of the peer process connected via a Unix domain socket.
type peerUserLookupFunc func() (string, error)

type Listener struct {
	db            *database.DB
	logger        *logging.Logger
	runtimeConfig *config.RuntimeConfig

	logs      *logging.Logging
	mux       http.ServeMux
	useSocket bool
}

func NewListener(db *database.DB, runtimeConfig *config.RuntimeConfig, logs *logging.Logging, useSocket bool) *Listener {
	l := &Listener{
		db:            db,
		logger:        logs.GetChildLogger("listener"),
		logs:          logs,
		runtimeConfig: runtimeConfig,
		useSocket:     useSocket,
	}

	debugMux := http.NewServeMux()
	debugMux.HandleFunc("/dump-config", l.DumpConfig)
	debugMux.HandleFunc("/dump-incidents", l.DumpIncidents)
	debugMux.HandleFunc("/dump-schedules", l.DumpSchedules)
	debugMux.HandleFunc("/dump-rules", l.DumpRules)

	l.mux.Handle("/debug/", http.StripPrefix("/debug", l.requireDebugAuth(debugMux)))
	l.mux.HandleFunc("/process-event", l.ProcessEvent)
	l.mux.HandleFunc("/incidents", l.IncidentsHandler)
	return l
}

// ServeHTTP implements the [http.Handler] interface for the Listener, allowing it to be used as an actual HTTP handler.
//
// It just sets a Server header and then delegates the request handling to the internal [http.ServeMux].
// The status code of the response is captured and logged together with other request information after
// the request has been handled.
func (l *Listener) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
	rw.Header().Set("Server", "icinga-notifications/"+internal.Version.Version)
	// Default to OK, so that if a handler just writes to the response without explicitly setting a status,
	// we still log the request as successful instead of logging it with a misleading 0 status code.
	crw := &responseWriter{ResponseWriter: rw, status: http.StatusOK}
	l.mux.ServeHTTP(crw, req)

	// We're not using a `defer` here because we don't actually care logging these info if the handler panics,
	// so we want to let the panic propagate instead of logging a potentially misleading request log with OK status.
	l.logger.Debugw("Handled request",
		zap.String("method", req.Method),
		zap.String("target_url", req.RequestURI),
		zap.String("remote_addr", req.RemoteAddr),
		zap.String("user_agent", req.UserAgent()),
		zap.String("status", http.StatusText(crw.status)))
}

// Run starts the HTTP listener and blocks until the server finishes.
//
// An error is returned in all cases except for a graceful shutdown triggered by the context being done within a
// hardcoded time limit.
func (l *Listener) Run(ctx context.Context) error {
	stdlogger, err := zap.NewStdLogAt(l.logger.Desugar(), zap.ErrorLevel)
	if err != nil {
		return err
	}

	server := &http.Server{
		Handler:     l,
		ReadTimeout: 10 * time.Second,
		IdleTimeout: 30 * time.Second,
		// Redirect the standard library's HTTP server error log to our logger with error level because these
		// errors are usually unexpected and indicate a problem with the server that should be investigated.
		ErrorLog: stdlogger,
	}

	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	var listeningFunc func() error
	if l.useSocket {
		listeningFunc, err = l.initSocketServer(server)
	} else {
		listeningFunc, err = l.initTcpServer(ctx, cancel, server)
	}
	if err != nil {
		return err
	}

	serverErr := make(chan error, 1)
	go func() {
		serverErr <- listeningFunc()
	}()

	select {
	case <-ctx.Done():
		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 3*time.Second)
		defer shutdownCancel()
		if err := server.Shutdown(shutdownCtx); err != nil {
			return err
		}
		return context.Cause(ctx)

	case err := <-serverErr:
		return err
	}
}

// initTcpServer configures the given HTTP(S) server for the configured TCP address and returns a function that
// starts serving. The caller is responsible for actually calling that function and for graceful shutdown.
func (l *Listener) initTcpServer(ctx context.Context, cancel context.CancelCauseFunc, server *http.Server) (func() error, error) {
	conf := daemon.Config().Listener
	tlsConfig, crlChecker, hasCrlChecker, err := conf.GetTlsConfig()
	if err != nil {
		return nil, err
	}

	if hasCrlChecker {
		go func() {
			err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger)
			if err != nil && ctx.Err() == nil {
				l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err))
				cancel(fmt.Errorf("CRL watcher exited: %w", err))
			}
		}()
	}

	var https string
	if conf.TLSOptions.Enable {
		https = "s"
	}
	l.logger.Infof("Starting listener on http%s://%s", https, conf.Addr)

	server.Addr = conf.Addr
	server.TLSConfig = tlsConfig

	if conf.TLSOptions.Enable {
		// We've already created the TLS config for the server, so we can pass empty strings
		// for certFile and keyFile, which makes ListenAndServeTLS use the TLS config directly
		// instead of trying to load certs from files.
		return func() error { return server.ListenAndServeTLS("", "") }, nil
	}

	return func() error { return server.ListenAndServe() }, nil
}

// initSocketServer configures the given HTTP server to listen on the configured Unix socket path and returns a
// function that starts serving. If a socket file already exists at the path, it is removed before binding.
// The caller is responsible for actually calling that function and for graceful shutdown.
func (l *Listener) initSocketServer(server *http.Server) (fn func() error, retErr error) {
	conf := daemon.Config().Listener
	mode := *conf.SocketMode
	if mode&0o006 > 0 {
		l.logger.Warnw("Unix socket is world-accessible; consider restricting socket_mode and using socket_group instead",
			zap.String("socket_mode", fmt.Sprintf("%04o", mode)),
		)
	}

	path := conf.Socket
	info, err := os.Stat(path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("cannot read socket path: %w", err)
	} else if err == nil {
		if info.Mode()&os.ModeSocket == 0 {
			return nil, fmt.Errorf("the configured socket path already exists and is not a socket: %q", path)
		}
		if err := os.Remove(path); err != nil {
			return nil, fmt.Errorf("cannot remove existing unix socket: %w", err)
		}
	}

	listener, err := net.Listen("unix", path)
	if err != nil {
		return nil, fmt.Errorf("cannot listen on unix socket %q: %w", path, err)
	}

	defer func() {
		if retErr != nil {
			_ = listener.Close()
		}
	}()

	if groupName := conf.SocketGroup; groupName != "" {
		group, err := user.LookupGroup(groupName)
		if err != nil {
			return nil, fmt.Errorf("cannot find group %q: %w", groupName, err)
		}

		gid, err := strconv.Atoi(group.Gid)
		if err != nil {
			return nil, fmt.Errorf("cannot parse GID for group %q: %w", groupName, err)
		}

		if gid != -1 {
			if err := os.Chown(path, -1, gid); err != nil {
				return nil, fmt.Errorf("cannot change ownership of unix socket %q: %w", path, err)
			}
		}
	}

	if err := os.Chmod(path, fs.FileMode(mode)); err != nil {
		return nil, fmt.Errorf("cannot set permissions on unix socket %q: %w", path, err)
	}

	server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
		unixConn, ok := c.(*net.UnixConn)
		if !ok {
			l.logger.Fatalw("expected *net.UnixConn", zap.String("connection_type", fmt.Sprintf("%T", c)))
		}

		rawConn, err := unixConn.SyscallConn()
		if err != nil {
			l.logger.Fatalw("Cannot extract RawConnection", zap.Error(err))
		}

		lookUpUser := func() (string, error) {
			uid, err := socketPeerUid(rawConn)
			if err != nil {
				return "", fmt.Errorf("cannot obtain peer credentials: %w", err)
			}

			u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10))
			if err != nil {
				return "", fmt.Errorf("cannot obtain user id: %w", err)
			}

			return u.Username, nil
		}

		return context.WithValue(ctx, peerUserLookupKey{}, peerUserLookupFunc(lookUpUser))
	}

	l.logger.Infof("Starting listener on unix socket %s", path)

	return func() error { return server.Serve(listener) }, nil
}

// sourceFromAuthOrAbort extracts a *config.Source from the request. For Unix socket connections, the source is looked
// up by the OS username of the connecting process via peer credentials. For TCP connections, HTTP Basic Auth is used.
// If no matching source is found, nil is returned and 401 is written to the response.
func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) (src *config.Source) {
	var errMsg string
	errFunc := func(httpCode int, sendAuthHeader bool) *config.Source {
		if sendAuthHeader {
			w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`)
		}
		l.logger.Debugw("Error in sourceFromAuthOrAbort", zap.String("error", errMsg))
		w.WriteHeader(httpCode)
		_, _ = fmt.Fprintln(w, errMsg)
		return nil
	}

	if l.useSocket {
		errMsg = "unix socket: "
		value := r.Context().Value(peerUserLookupKey{})
		if value == nil {
			errMsg += "no lookup function present in context"
			return errFunc(http.StatusUnauthorized, false)
		}

		lookup, ok := value.(peerUserLookupFunc)
		if !ok {
			errMsg += "lookup function has wrong method signature"
			return errFunc(http.StatusUnauthorized, false)
		}

		username, err := lookup()
		if err != nil {
			errMsg += err.Error()
			return errFunc(http.StatusUnauthorized, false)
		}

		src = l.runtimeConfig.GetSourceByUsername(username)
		if src == nil {
			errMsg += fmt.Sprintf("system user %q is not registered as a source username", username)
			return errFunc(http.StatusUnauthorized, false)
		}

		l.logger.Debugw(
			"Source is authenticated via socket connection",
			zap.String("source_name", src.Name),
			zap.String("username", username),
		)
	} else {
		authUser, authPass, authOk := r.BasicAuth()
		if r.TLS != nil && len(r.TLS.VerifiedChains) > 0 {
			// if VerifiedChains isn't empty, the request doesn't fall back to basic auth
			errMsg = "tls cert: "
			if authUser != "" || authPass != "" {
				errMsg += " both client certificate and basic auth provided"
				return errFunc(http.StatusBadRequest, false)
			}

			if len(r.TLS.VerifiedChains[0]) == 0 {
				errMsg += "no verified chain found"
				return errFunc(http.StatusUnauthorized, true)
			}

			clientCert := r.TLS.VerifiedChains[0][0]
			if clientCert == nil {
				errMsg += "no client certificate found"
				return errFunc(http.StatusUnauthorized, true)
			}

			src = l.runtimeConfig.GetSourceByClientCertSubject(clientCert.Subject.String())
			if src == nil {
				errMsg += "no matching source found"
				return errFunc(http.StatusUnauthorized, true)
			}

			l.logger.Debugw(
				"Source is authenticated via a TLS client certificate",
				zap.String("source_name", src.Name),
				zap.String("common_name", clientCert.Subject.CommonName),
			)
		} else {
			errMsg = "basic auth: "
			if !authOk {
				errMsg += "missing or malformed basic auth credentials"
				return errFunc(http.StatusUnauthorized, true)
			}

			src = l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger)
			if src == nil {
				errMsg += "expected valid icinga-notifications source basic auth credentials"
				return errFunc(http.StatusUnauthorized, true)
			}

			l.logger.Debugw(
				"Source is authenticated via HTTP Basic Auth",
				zap.String("source_name", src.Name),
				zap.String("username", authUser),
			)
		}
	}

	return src
}

// abort the current connection by sending the status code and an error both to the log and back to the client.
func (l *Listener) abort(w http.ResponseWriter, statusCode int, src *config.Source, format string, a ...any) {
	msg := format
	if len(a) > 0 {
		msg = fmt.Sprintf(format, a...)
	}

	logger := l.logger.With(zap.Int("status_code", statusCode), zap.String("message", msg))
	if src != nil {
		logger = logger.With(zap.String("source", src.Name))
	}

	http.Error(w, msg, statusCode)
	logger.Debugw("Request aborted")
}

func (l *Listener) ProcessEvent(w http.ResponseWriter, r *http.Request) {
	// abort the current connection by sending the status code and an error both to the log and back to the client.
	if r.Method != http.MethodPost {
		l.abort(w, http.StatusMethodNotAllowed, nil, "POST required")
		return
	}

	src := l.sourceFromAuthOrAbort(w, r)
	if src == nil {
		// Listener.sourceFromAuthOrAbort writes 401 response by itself; no abort() necessary.
		return
	}

	var innerEv baseEv.Event
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(&innerEv); err != nil {
		l.abort(w, http.StatusBadRequest, nil, "cannot parse JSON body: %v", err)
		return
	}

	ev := event.Event{
		Time:     time.Now(),
		SourceId: src.ID,
		Event:    innerEv,
	}
	ev.CompleteURL(daemon.Config().IcingaWeb2UrlParsed)

	if err := ev.Validate(); err != nil {
		l.abort(w, http.StatusBadRequest, src, "%v", err)
		return
	}

	l.logger.Debugw("Received event", zap.String("source", src.Name), zap.Object("event", &ev))

	// Submitting an event without the "incident" field won't cause any new event rules to be evaluated or
	// escalations to be triggered, but only updates the state of an existing incident without a severity change.
	if ev.OpenOrEscalate() {
		filterColumns, hasRulesWithoutFilter := l.runtimeConfig.GetRulesFilterColumnsForSource(src)
		missingRelations := ev.ExtractMissingRelations(filterColumns...)
		if len(missingRelations) > 0 && ShouldRejectRequestOnIncompleteRelations(r, hasRulesWithoutFilter) {
			l.sendMissingAttrsError(w, src, missingRelations)
			return
		}
	}

	// Enqueue the event restricted to the HTTP request context, and restrict it further to well-guessed ten seconds.
	//
	// Doing so reduces the chance that event queueing works, while the HTTP client disappears. Unfortunately, this
	// cannot be done in reverse - like with a callback, writing the HTTP response within the transaction -, as at this
	// moment it is unknown if the transaction will succeed. This approach binds the transaction to the request context
	// and has a minimum interval between COMMIT and writing back a response.
	//
	// Of course, the client might disappear without disconnecting. Unless we have some super aggressive TCP heartbeat,
	// this will fall through. But, for the moment, I am totally fine with this.
	//
	// Nevertheless, event.Event submissions should be unique, especially due to the Event.ID field. Thus, if a client
	// resubmits an identical event, its event.Queue.ID should be identical as well, resulting in a no-op.
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()

	err := event.Enqueue(ctx, l.db, &ev, object.ID(ev.SourceId, ev.Tags))
	if err != nil {
		l.logger.Errorw("Failed to enqueue event into event queue",
			zap.String("source", src.Name),
			zap.String("event_name", ev.Name),
			zap.Error(err))

		l.abort(w, http.StatusInternalServerError, src, "internal error: see server logs for details")
		return
	}

	l.logger.Debugw("Successfully enqueued event for future processing",
		zap.String("source", src.Name),
		zap.String("event_name", ev.Name))

	w.WriteHeader(http.StatusAccepted)
	_, _ = fmt.Fprintln(w, "event accepted for processing")
	_, _ = fmt.Fprintln(w)
}

// IncidentsHandler handles GET and POST requests to the /incidents endpoint.
//
// It performs authentication using HTTP Basic Auth and delegates the actual handling to [Listener.getIncidents]
// for GET requests and [Listener.modifyIncident] for POST requests. If the request method is neither GET nor POST,
// it responds with a 405 Method Not Allowed status.
func (l *Listener) IncidentsHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet && r.Method != http.MethodPost {
		l.abort(w, http.StatusMethodNotAllowed, nil, "GET or POST required")
		return
	}

	src := l.sourceFromAuthOrAbort(w, r)
	if src == nil {
		// Listener.sourceFromAuthOrAbort writes 401 response by itself; no abort() necessary.
		return
	}

	qs := r.URL.Query().Get("filter")
	if qs == "" {
		l.abort(w, http.StatusBadRequest, nil, "missing required filter query parameter")
		return
	}

	filter, err := ParseQueryFilter(qs)
	if err != nil {
		l.logger.Warnw("Error parsing filter", zap.String("qs", qs), zap.Error(err))
		l.abort(w, http.StatusBadRequest, src, "failed to parse provided query string: %v", err)
		return
	}

	onStreamErr := func(enc *json.Encoder, wroteHeader *bool, err error) {
		// The database query is bound to the HTTP request context, so if the client disconnects prematurely but
		// still normally closes the connection, the context will be canceled and the DB query will return that
		// error. In that case, there is no client to send a response to, so debug log it and be done with it.
		if errors.Is(err, context.Canceled) {
			l.logger.Debugw("Client disconnected prematurely", zap.String("source", src.Name), zap.Error(err))
			return
		}
		l.logger.Warnw("Error processing incident request", zap.String("source", src.Name), zap.Error(err))

		var code int
		var errState source.ErrorState
		if errors.Is(err, ErrFilterEval) {
			code = http.StatusBadRequest
			errState.Error = err.Error()
		} else {
			code = http.StatusInternalServerError
			errState.Error = "some incidents could not be modified due to an internal error, see server logs for details"
			if r.Method == http.MethodGet {
				errState.Error = "some incidents could not be retrieved due to an internal error, see server logs for details"
			}
		}

		if !*wroteHeader {
			*wroteHeader = true
			l.abort(w, code, src, "%s", errState.Error)
		} else if err := enc.Encode(&errState); err != nil {
			l.logger.Warnw("Error serializing error response", zap.String("source", src.Name), zap.Error(err))
		}
	}

	if r.Method == http.MethodGet {
		l.getIncidentsHandler(w, r, src, filter, onStreamErr)
	} else {
		l.modifyIncidentsHandler(w, r, src, filter, onStreamErr)
	}
}

// getIncidentsHandler handles GET requests to the /incidents endpoint.
//
// It retrieves the current incidents for the authenticated source, applies any filters provided in the query
// parameters, and streams the filtered incidents back to the client in NDJSON format.
//
// The filters are evaluated against the object tags of each incident, and only incidents that match the filters
// are included in the response.
func (l *Listener) getIncidentsHandler(w http.ResponseWriter, r *http.Request, src *config.Source, filter any, errF OnErrFunc) {
	opts := []StreamOpt[incident.Pair]{
		WithOnError[incident.Pair](errF),
		WithOnResult(func(pair incident.Pair) (any, error) {
			if match, err := EvaluateQueryFilter(filter, pair.Object.Tags); err != nil {
				return nil, err
			} else if match {
				return &source.Incident{
					IsMuted:    pair.Incident.IsMuted(),
					ObjectTags: pair.Object.Tags,
					Severity:   pair.Incident.Severity,
				}, nil
			}
			return nil, nil
		}),
	}

	pairCh, errCh := incident.YieldForSource(r.Context(), l.db, l.logs, l.runtimeConfig, src.ID)
	if err := StreamJsonResults(r.Context(), w, pairCh, errCh, opts...); err != nil {
		l.logger.Debugw("Error streaming get incidents response", zap.String("source", src.Name), zap.Error(err))
	}
}

// modifyIncidentsHandler handles POST requests to the /incidents endpoint.
//
// It retrieves the current incidents for the authenticated source, applies any filters provided in the query params,
// and modifies the filtered incidents based on the JSON body of the request. The JSON body can contain a "message"
// field to update the incident message and a "close" field to close the incident. If there is an error parsing the
// filters or evaluating them against the incidents, it responds with an appropriate HTTP status code and error message.
func (l *Listener) modifyIncidentsHandler(w http.ResponseWriter, r *http.Request, src *config.Source, filter any, errF OnErrFunc) {
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	var attrs source.ModifiableIncidentAttrs
	if err := dec.Decode(&attrs); err != nil {
		l.abort(w, http.StatusBadRequest, src, "cannot parse JSON body: %v", err)
		return
	}

	if err := attrs.Validate(); err != nil {
		l.abort(w, http.StatusBadRequest, src, "invalid request body: %v", err)
		return
	}

	opts := []StreamOpt[incident.Pair]{
		WithSendHeaderEarly[incident.Pair](),
		WithOnError[incident.Pair](errF),
		WithOnResult(func(pair incident.Pair) (any, error) {
			if match, err := EvaluateQueryFilter(filter, pair.Object.Tags); err != nil {
				return nil, err
			} else if match {
				err := l.db.ExecTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted},
					func(ctx context.Context, tx *sqlx.Tx) error {
						// This is a bit of a hack since we already have a fully restored incident, but this acquires
						// an exclusive lock on the incident row in the database to prevent concurrent modifications.
						if err := pair.Incident.RestoreState(ctx, tx, false); err != nil {
							return err
						}

						if attrs.Message.Valid {
							pair.Incident.Message = attrs.Message
						}
						if attrs.Close.Valid {
							if err := pair.Incident.Close(ctx, tx); err != nil {
								return err
							}
						}
						return pair.Incident.Sync(ctx, tx)
					})

				switch {
				case errors.Is(err, sql.ErrNoRows):
					// The incident was closed in the meantime, so we cannot modify it anymore!

				case err != nil:
					l.logger.Errorw("Failed to modify incident", zap.String("source", src.Name), zap.Error(err))
					return &source.ModifiedIncidentResp{
						ObjectTags: pair.Object.Tags,
						ErrorState: source.ErrorState{
							Error: "failed to modify incident, see server logs for details",
						},
					}, nil
				default:
					return &source.ModifiedIncidentResp{ObjectTags: pair.Object.Tags}, nil
				}
			}
			return nil, nil
		}),
	}

	pairCh, errCh := incident.YieldForSource(r.Context(), l.db, l.logs, l.runtimeConfig, src.ID)
	if err := StreamJsonResults(r.Context(), w, pairCh, errCh, opts...); err != nil {
		l.logger.Debugw("Error streaming modify incidents response", zap.String("source", src.Name), zap.Error(err))
	}
}

// requireDebugAuth is a middleware that checks if the valid debug password was provided. If there is no password
// configured or the supplied password is incorrect, it sends an error code and does not redirect the request.
func (l *Listener) requireDebugAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		expectedPassword := daemon.Config().Listener.DebugPassword
		if expectedPassword == "" {
			w.WriteHeader(http.StatusForbidden)
			_, _ = fmt.Fprintln(w, "config dump disabled, no debug-password set in config")

			return
		}

		_, providedPassword, _ := r.BasicAuth()
		if subtle.ConstantTimeCompare([]byte(expectedPassword), []byte(providedPassword)) != 1 {
			l.logger.Warnw("Unauthorized request", zap.String("url", r.RequestURI))

			w.Header().Set("WWW-Authenticate", `Basic realm="debug"`)
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = fmt.Fprintln(w, "please provide the debug-password as basic auth credentials (user is ignored)")
			return
		}

		next.ServeHTTP(w, r)
	})
}

// DumpConfig is used as /debug prefixed endpoint to dump the current live configuration of the daemon.
// The authorization has to be done beforehand.
func (l *Listener) DumpConfig(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		_, _ = fmt.Fprintln(w, "GET required")
		return
	}
	w.Header().Add("Content-Type", "application/json")

	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")

	l.runtimeConfig.RLock()
	defer l.runtimeConfig.RUnlock()
	_ = enc.Encode(&l.runtimeConfig.ConfigSet)
}

// DumpIncidents is used as /debug prefixed endpoint to dump all incidents. The authorization has to be done beforehand.
func (l *Listener) DumpIncidents(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		l.abort(w, http.StatusMethodNotAllowed, nil, "GET required")
		return
	}

	opts := []StreamOpt[incident.Pair]{
		WithOnError[incident.Pair](func(enc *json.Encoder, wroteHeader *bool, err error) {
			if errors.Is(err, context.Canceled) {
				l.logger.Debugw("Client disconnected while retrieving incidents from database", zap.Error(err))
				return
			}
			l.logger.Errorw("Failed to fetch current active incidents", zap.Error(err))
			if !*wroteHeader {
				*wroteHeader = true
				l.abort(w, http.StatusInternalServerError, nil, "cannot dump incidents due to an internal error, see server logs for details")
			}
			// It's a debugging endpoint, so don't care about sending a proper error response here.
		}),
		WithOnResult(func(pair incident.Pair) (any, error) { return pair, nil }),
	}
	pairCh, errCh := incident.Yield(r.Context(), l.db, l.logs, l.runtimeConfig)
	_ = StreamJsonResults(r.Context(), w, pairCh, errCh, opts...)
}

// DumpSchedules is used as /debug prefixed endpoint to dump all schedules. The authorization has to be done beforehand.
func (l *Listener) DumpSchedules(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		_, _ = fmt.Fprintln(w, "GET required")
		return
	}

	l.runtimeConfig.RLock()
	defer l.runtimeConfig.RUnlock()

	for _, schedule := range l.runtimeConfig.Schedules {
		_, _ = fmt.Fprintf(w, "[id=%d] %q:\n", schedule.ID, schedule.Name)

		// Iterate in 30 minute steps as this is the granularity Icinga Notifications Web allows in the configuration.
		// Truncation to seconds happens only for a more readable output.
		step := 30 * time.Minute
		start := time.Now().Truncate(time.Second)
		for t := start; t.Before(start.Add(48 * time.Hour)); t = t.Add(step) {
			_, _ = fmt.Fprintf(w, "\t%v: %v\n", t, schedule.GetContactsAt(t))
		}

		_, _ = fmt.Fprintln(w)
	}
}

// DumpRules is used as /debug prefixed endpoint to dump all rules. The authorization has to be done beforehand.
func (l *Listener) DumpRules(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		_, _ = fmt.Fprintln(w, "GET required")
		return
	}
	w.Header().Add("Content-Type", "application/json")

	l.runtimeConfig.RLock()
	defer l.runtimeConfig.RUnlock()

	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	_ = enc.Encode(l.runtimeConfig.Rules)
}

// sendMissingAttrsError sends a response with status code 422 Unprocessable Entity to the client.
func (l *Listener) sendMissingAttrsError(w http.ResponseWriter, src *config.Source, missingAttrs []string) {
	l.logger.Debugw(
		"Event is missing attributes required for rule evaluation",
		zap.String("source", src.Name),
		zap.Strings("missing_attributes", missingAttrs),
	)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusUnprocessableEntity)

	resp := map[string]any{
		"type":       "attribute negotiation",
		"attributes": missingAttrs,
	}

	if err := json.NewEncoder(w).Encode(resp); err != nil {
		l.logger.Errorw("Failed to send missing attributes required for rule evaluation", zap.Error(err))
		return
	}
}

// ShouldRejectRequestOnIncompleteRelations determines whether a request with incomplete relations should be rejected.
//
// This function always returns true if the client explicitly requested to reject such events by setting the
// [notifications.XIcingaRejectIfRelationsIncomplete] HTTP header. Otherwise, if there are rules without a filter,
// it returns false as such rules match unconditionally and thus don't necessarily require the missing relations.
//
// Note that this function assumes that it's guarded by a [baseEv.Event.OpenOrEscalate] check, since only
// events that open or escalate an incident are subject to relation completeness checks in the first place.
func ShouldRejectRequestOnIncompleteRelations(r *http.Request, hasRulesWithoutFilter bool) bool {
	if r.Header.Get(notifications.XIcingaRejectIfRelationsIncomplete) == "true" {
		return true
	}
	if hasRulesWithoutFilter {
		return false
	}
	return true
}
