package accesslog

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/textproto"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/containous/alice"
	"github.com/rs/zerolog/log"
	"github.com/sirupsen/logrus"
	ptypes "github.com/traefik/paerser/types"
	"github.com/traefik/traefik/v3/pkg/middlewares/capture"
	"github.com/traefik/traefik/v3/pkg/middlewares/observability"
	"github.com/traefik/traefik/v3/pkg/observability/logs"
	otypes "github.com/traefik/traefik/v3/pkg/observability/types"
	traefiktls "github.com/traefik/traefik/v3/pkg/tls"
	"github.com/traefik/traefik/v3/pkg/types"
	"go.opentelemetry.io/contrib/bridges/otellogrus"
	"go.opentelemetry.io/otel/trace"
)

type key string

const (
	// DataTableKey is the key within the request context used to store the Log Data Table.
	DataTableKey key = "LogDataTable"

	// CommonFormat is the common logging format (CLF).
	CommonFormat string = "common"

	// GenericCLFFormat is the generic CLF format.
	GenericCLFFormat string = "genericCLF"

	// JSONFormat is the JSON logging format.
	JSONFormat string = "json"
)

type noopCloser struct {
	*os.File
}

func (n noopCloser) Write(p []byte) (int, error) {
	return n.File.Write(p)
}

func (n noopCloser) Close() error {
	// noop
	return nil
}

type handlerParams struct {
	ctx          context.Context
	logDataTable *LogData
}

// Handler will write each request and its response to the access log.
type Handler struct {
	config         *otypes.AccessLog
	logger         *logrus.Logger
	file           io.WriteCloser
	mu             sync.Mutex
	httpCodeRanges types.HTTPCodeRanges
	logHandlerChan chan handlerParams
	wg             sync.WaitGroup
}

// NewHandler creates a new Handler.
func NewHandler(ctx context.Context, config *otypes.AccessLog) (*Handler, error) {
	var file io.WriteCloser = noopCloser{os.Stdout}
	if len(config.FilePath) > 0 {
		f, err := openAccessLogFile(config.FilePath)
		if err != nil {
			return nil, fmt.Errorf("error opening access log file: %w", err)
		}
		file = f
	}
	logHandlerChan := make(chan handlerParams, config.BufferingSize)

	var formatter logrus.Formatter

	switch config.Format {
	case CommonFormat:
		formatter = new(CommonLogFormatter)
	case GenericCLFFormat:
		formatter = new(GenericCLFLogFormatter)
	case JSONFormat:
		formatter = new(logrus.JSONFormatter)
	default:
		log.Error().Msgf("Unsupported access log format: %q, defaulting to common format instead.", config.Format)
		formatter = new(CommonLogFormatter)
	}

	logger := &logrus.Logger{
		Out:       file,
		Formatter: formatter,
		Hooks:     make(logrus.LevelHooks),
		Level:     logrus.InfoLevel,
	}

	if config.OTLP != nil {
		otelLoggerProvider, err := config.OTLP.NewLoggerProvider(ctx)
		if err != nil {
			return nil, fmt.Errorf("setting up OpenTelemetry logger provider: %w", err)
		}

		logger.Hooks.Add(otellogrus.NewHook("traefik", otellogrus.WithLoggerProvider(otelLoggerProvider)))
		if !config.DualOutput {
			logger.Out = io.Discard
		}
	}

	// Transform header names to a canonical form, to be used as is without further transformations,
	// and transform field names to lower case, to enable case-insensitive lookup.
	if config.Fields != nil {
		if len(config.Fields.Names) > 0 {
			fields := map[string]string{}

			for h, v := range config.Fields.Names {
				fields[strings.ToLower(h)] = v
			}

			config.Fields.Names = fields
		}

		if config.Fields.Headers != nil && len(config.Fields.Headers.Names) > 0 {
			fields := map[string]string{}

			for h, v := range config.Fields.Headers.Names {
				fields[textproto.CanonicalMIMEHeaderKey(h)] = v
			}

			config.Fields.Headers.Names = fields
		}
	}

	logHandler := &Handler{
		config:         config,
		logger:         logger,
		file:           file,
		logHandlerChan: logHandlerChan,
	}

	if config.Filters != nil {
		if httpCodeRanges, err := types.NewHTTPCodeRanges(config.Filters.StatusCodes); err != nil {
			log.Error().Err(err).Msg("Failed to create new HTTP code ranges")
		} else {
			logHandler.httpCodeRanges = httpCodeRanges
		}
	}

	if config.BufferingSize > 0 {
		logHandler.wg.Go(func() {
			for handlerParams := range logHandler.logHandlerChan {
				logHandler.logTheRoundTrip(handlerParams.ctx, handlerParams.logDataTable)
			}
		})
	}

	return logHandler, nil
}

// AliceConstructor returns an alice.Constructor that wraps the Handler (conditionally) in a middleware chain.
func (h *Handler) AliceConstructor() alice.Constructor {
	return func(next http.Handler) (http.Handler, error) {
		return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
			if h == nil {
				next.ServeHTTP(rw, req)
				return
			}

			h.ServeHTTP(rw, req, next)
		}), nil
	}
}

func (h *Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request, next http.Handler) {
	if !observability.AccessLogsEnabled(req.Context()) {
		next.ServeHTTP(rw, req)

		return
	}

	now := time.Now().UTC()

	core := CoreLogData{
		StartUTC:   now,
		StartLocal: now.Local(),
	}

	logDataTable := &LogData{
		Core: core,
		Request: request{
			headers: req.Header,
		},
	}

	if metadata := observability.GetObservabilityMetadata(req.Context()); metadata != nil {
		if metadata.Ingress != nil {
			logDataTable.Core[KubernetesIngressNamespace] = metadata.Ingress.Namespace
			logDataTable.Core[KubernetesIngressName] = metadata.Ingress.IngressName
			logDataTable.Core[KubernetesServiceName] = metadata.Ingress.ServiceName
			logDataTable.Core[KubernetesServicePort] = metadata.Ingress.ServicePort
		}
	}

	if span := trace.SpanFromContext(req.Context()); span != nil {
		spanContext := span.SpanContext()
		if spanContext.HasTraceID() && spanContext.HasSpanID() {
			logDataTable.Core[TraceID] = spanContext.TraceID().String()
			logDataTable.Core[SpanID] = spanContext.SpanID().String()
			logDataTable.Core[OTelTraceID] = spanContext.TraceID().String()
			logDataTable.Core[OTelSpanID] = spanContext.SpanID().String()
		}
	}

	reqWithDataTable := req.WithContext(context.WithValue(req.Context(), DataTableKey, logDataTable))

	core[RequestCount] = nextRequestCount()
	if req.Host != "" {
		core[RequestAddr] = req.Host
		core[RequestHost], core[RequestPort] = silentSplitHostPort(req.Host)
	}

	queryParameters := ""
	if h.config.Fields.KeepQueryParameters() {
		queryParameters = req.URL.RawQuery
	}

	// copy the URL without the scheme, hostname etc
	urlCopy := &url.URL{
		Path:       req.URL.Path,
		RawPath:    req.URL.RawPath,
		RawQuery:   queryParameters,
		ForceQuery: req.URL.ForceQuery && h.config.Fields.KeepQueryParameters(),
		Fragment:   req.URL.Fragment,
	}
	urlCopyString := urlCopy.String()
	core[RequestMethod] = req.Method
	core[RequestPath] = urlCopyString
	core[RequestProtocol] = req.Proto

	core[RequestScheme] = "http"
	if req.TLS != nil {
		core[RequestScheme] = "https"
		core[TLSVersion] = traefiktls.GetVersion(req.TLS)
		core[TLSCipher] = traefiktls.GetCipherName(req.TLS)
		if len(req.TLS.PeerCertificates) > 0 && req.TLS.PeerCertificates[0] != nil {
			core[TLSClientSubject] = req.TLS.PeerCertificates[0].Subject.String()
		}
	}

	core[ClientAddr] = req.RemoteAddr
	core[ClientHost], core[ClientPort] = silentSplitHostPort(req.RemoteAddr)

	if forwardedFor := req.Header.Get("X-Forwarded-For"); forwardedFor != "" {
		core[ClientHost] = forwardedFor
	}

	ctx := req.Context()
	capt, err := capture.FromContext(ctx)
	if err != nil {
		log.Ctx(ctx).Error().Err(err).Str(logs.MiddlewareType, "AccessLogs").Msg("Could not get Capture")
		return
	}

	defer func() {
		logDataTable.DownstreamResponse = downstreamResponse{
			headers: rw.Header().Clone(),
		}

		logDataTable.DownstreamResponse.status = capt.StatusCode()
		logDataTable.DownstreamResponse.size = capt.ResponseSize()
		logDataTable.Request.size = capt.RequestSize()

		if _, ok := core[ClientUsername]; !ok {
			core[ClientUsername] = usernameIfPresent(reqWithDataTable.URL)
		}

		if h.config.BufferingSize > 0 {
			h.logHandlerChan <- handlerParams{
				ctx:          req.Context(),
				logDataTable: logDataTable,
			}
			return
		}

		h.logTheRoundTrip(req.Context(), logDataTable)
	}()

	next.ServeHTTP(rw, reqWithDataTable)
}

// Close closes the Logger (i.e. the file, drain logHandlerChan, etc).
func (h *Handler) Close() error {
	close(h.logHandlerChan)
	h.wg.Wait()
	return h.file.Close()
}

// Rotate closes and reopens the log file to allow for rotation by an external source.
func (h *Handler) Rotate() error {
	if h.config.FilePath == "" {
		return nil
	}

	if h.file != nil {
		defer func(f io.Closer) { _ = f.Close() }(h.file)
	}

	var err error
	h.file, err = os.OpenFile(h.config.FilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o664)
	if err != nil {
		return err
	}
	h.mu.Lock()
	defer h.mu.Unlock()
	h.logger.Out = h.file
	return nil
}

// Logging handler to log frontend name, backend name, and elapsed time.
func (h *Handler) logTheRoundTrip(ctx context.Context, logDataTable *LogData) {
	core := logDataTable.Core

	retryAttempts, ok := core[RetryAttempts].(int)
	if !ok {
		retryAttempts = 0
	}
	core[RetryAttempts] = retryAttempts
	core[RequestContentSize] = logDataTable.Request.size

	status := logDataTable.DownstreamResponse.status
	core[DownstreamStatus] = status

	// n.b. take care to perform time arithmetic using UTC to avoid errors at DST boundaries.
	totalDuration := time.Now().UTC().Sub(core[StartUTC].(time.Time))
	core[Duration] = totalDuration

	if !h.keepAccessLog(status, retryAttempts, totalDuration) {
		return
	}

	size := logDataTable.DownstreamResponse.size
	core[DownstreamContentSize] = size
	if original, ok := core[OriginContentSize]; ok {
		o64 := original.(int64)
		if size != o64 && size != 0 {
			core[GzipRatio] = float64(o64) / float64(size)
		}
	}

	core[Overhead] = totalDuration
	if origin, ok := core[OriginDuration]; ok {
		core[Overhead] = totalDuration - origin.(time.Duration)
	}

	fields := logrus.Fields{}

	for k, v := range logDataTable.Core {
		if h.config.Fields.Keep(strings.ToLower(k)) {
			fields[k] = v
		}
	}

	h.redactHeaders(logDataTable.Request.headers, fields, "request_")
	h.redactHeaders(logDataTable.OriginResponse, fields, "origin_")
	h.redactHeaders(logDataTable.DownstreamResponse.headers, fields, "downstream_")

	h.mu.Lock()
	defer h.mu.Unlock()

	entry := h.logger.WithContext(ctx).WithFields(fields)

	var message string
	if h.config.OTLP != nil {
		// If the logger is configured to use OpenTelemetry,
		// we compute the log body with the formatter.
		mBytes, err := h.logger.Formatter.Format(entry)
		if err != nil {
			message = fmt.Sprintf("Failed to format access log entry: %v", err)
		} else {
			message = string(mBytes)
		}
	}

	entry.Println(message)
}

func (h *Handler) redactHeaders(headers http.Header, fields logrus.Fields, prefix string) {
	for k := range headers {
		v := h.config.Fields.KeepHeader(k)
		switch v {
		case otypes.AccessLogKeep:
			fields[prefix+k] = strings.Join(headers.Values(k), ",")
		case otypes.AccessLogRedact:
			fields[prefix+k] = "REDACTED"
		}
	}
}

func (h *Handler) keepAccessLog(statusCode, retryAttempts int, duration time.Duration) bool {
	if h.config.Filters == nil {
		// no filters were specified
		return true
	}

	if len(h.httpCodeRanges) == 0 && !h.config.Filters.RetryAttempts && h.config.Filters.MinDuration == 0 {
		// empty filters were specified, e.g. by passing --accessLog.filters only (without other filter options)
		return true
	}

	if h.httpCodeRanges.Contains(statusCode) {
		return true
	}

	if h.config.Filters.RetryAttempts && retryAttempts > 0 {
		return true
	}

	if h.config.Filters.MinDuration > 0 && (ptypes.Duration(duration) > h.config.Filters.MinDuration) {
		return true
	}

	return false
}

// GetLogData gets the request context object that contains logging data.
// This creates data as the request passes through the middleware chain.
func GetLogData(req *http.Request) *LogData {
	if ld, ok := req.Context().Value(DataTableKey).(*LogData); ok {
		return ld
	}
	return nil
}

func openAccessLogFile(filePath string) (*os.File, error) {
	dir := filepath.Dir(filePath)

	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, fmt.Errorf("failed to create log path %s: %w", dir, err)
	}

	file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o664)
	if err != nil {
		return nil, fmt.Errorf("error opening file %s: %w", filePath, err)
	}

	return file, nil
}

func silentSplitHostPort(value string) (host, port string) {
	host, port, err := net.SplitHostPort(value)
	if err != nil {
		return value, "-"
	}
	return host, port
}

func usernameIfPresent(theURL *url.URL) string {
	if theURL.User != nil {
		if name := theURL.User.Username(); name != "" {
			return name
		}
	}
	return "-"
}

var requestCounter atomic.Uint64 // Request ID

func nextRequestCount() uint64 {
	return requestCounter.Add(1)
}
