// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package plog

import (
	"context"
	"encoding/base64"
	"fmt"
	"io"
	"os"
	"slices"
	"strings"
	"time"

	"github.com/go-logr/logr"
	"github.com/go-logr/zapr"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/util/duration"
	"k8s.io/apimachinery/pkg/util/rand"
	"k8s.io/klog/v2"
	"k8s.io/klog/v2/textlogger"
)

func newLogr(ctx context.Context, encoding string, klogLevel klog.Level) (logr.Logger, func(), error) {
	overrides, hasOverrides := ctx.Value(testOverridesContextKey).(*testOverrides)

	if encoding == "text" {
		var w io.Writer = os.Stderr
		flush := func() { _ = os.Stderr.Sync() }

		var textloggerOptions []textlogger.ConfigOption

		if hasOverrides {
			if overrides.w != nil {
				w = newSink(overrides.w) // make sure the value is safe for concurrent use
				flush = func() {}
			}
			textloggerOptions = append(textloggerOptions, textlogger.FixedTime(overrides.fakeClock.Now()))
		}

		textloggerOptions = append(textloggerOptions,
			textlogger.Verbosity(int(klogLevel)),
			textlogger.Output(w))

		return textlogger.NewLogger(textlogger.NewConfig(textloggerOptions...)), flush, nil
	}

	path := "stderr" // this is how zap refers to os.Stderr
	f := func(config *zap.Config) {
		if encoding == "console" {
			config.EncoderConfig.LevelKey = zapcore.OmitKey
			config.EncoderConfig.EncodeCaller = zapcore.ShortCallerEncoder
			config.EncoderConfig.EncodeTime = humanTimeEncoder
			config.EncoderConfig.EncodeDuration = humanDurationEncoder
		}
	}
	var opts []zap.Option

	if hasOverrides {
		if overrides.w != nil {
			// use a per invocation random string as the key into the global map
			testKey := "/" + base64.RawURLEncoding.EncodeToString([]byte(rand.String(32)))

			// tell zap to use our custom sink registry to find the writer
			path = "pinniped://" + testKey

			// the registry may be called multiple times so make sure the value is safe for concurrent use
			sink := newSink(overrides.w)

			// store the test's buffer where we can find it globally
			actual, loaded := sinkMap.LoadOrStore(testKey, sink)
			require.False(overrides.t, loaded)
			require.Equal(overrides.t, sink, actual)

			defer func() {
				// delete buffer from the global map to prevent a memory leak
				value, loaded := sinkMap.LoadAndDelete(testKey)
				require.True(overrides.t, loaded)
				require.Equal(overrides.t, sink, value)
			}()
		}
		if overrides.f != nil {
			f = overrides.f
		}
		opts = slices.Concat(opts, overrides.opts)
	}

	// when using the trace or all log levels, an error log will contain the full stack.
	// this is too noisy for regular use because things like leader election conflicts
	// result in transient errors and we do not want all of that noise in the logs.
	// this check is performed dynamically on the global log level.
	return newZapr(globalLevel, LevelTrace, encoding, path, f, opts...)
}

func newZapr(level zap.AtomicLevel, addStack zapcore.LevelEnabler, encoding, path string, f func(config *zap.Config), opts ...zap.Option) (logr.Logger, func(), error) {
	opts = slices.Concat([]zap.Option{zap.AddStacktrace(addStack)}, opts)

	config := zap.Config{
		Level:             level,
		Development:       false,
		DisableCaller:     false,
		DisableStacktrace: true, // handled via the AddStacktrace call above
		Sampling:          nil,  // keep all logs for now
		Encoding:          encoding,
		EncoderConfig: zapcore.EncoderConfig{
			MessageKey:     "message",
			LevelKey:       "level",
			TimeKey:        "timestamp",
			NameKey:        "logger",
			CallerKey:      "caller",
			FunctionKey:    zapcore.OmitKey, // included in caller
			StacktraceKey:  "stacktrace",
			SkipLineEnding: false,
			LineEnding:     zapcore.DefaultLineEnding,
			EncodeLevel:    levelEncoder,
			// human-readable and machine parsable with microsecond precision (same as klog, kube audit event, etc)
			EncodeTime:          zapcore.TimeEncoderOfLayout(metav1.RFC3339Micro),
			EncodeDuration:      zapcore.StringDurationEncoder,
			EncodeCaller:        callerEncoder,
			EncodeName:          nil,
			NewReflectedEncoder: nil,
			ConsoleSeparator:    "  ",
		},
		OutputPaths:      []string{path},
		ErrorOutputPaths: []string{path},
		InitialFields:    nil,
	}

	f(&config)

	log, err := config.Build(opts...)
	if err != nil {
		return logr.Logger{}, nil, fmt.Errorf("failed to build zap logger: %w", err)
	}

	return zapr.NewLogger(log), func() { _ = log.Sync() }, nil
}

func levelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
	plogLevel := zapLevelToPlogLevel(l)

	if len(plogLevel) == 0 {
		return // this tells zap that it should handle encoding the level itself because we do not know the mapping
	}

	enc.AppendString(string(plogLevel))
}

func zapLevelToPlogLevel(l zapcore.Level) LogLevel {
	if l > 0 {
		// best effort mapping, the zap levels do not really translate to klog
		// but this is correct for "error" level which is all we need for logr
		return LogLevel(l.String())
	}

	// klog levels are inverted when zap handles them
	switch {
	case -l >= klogLevelAll:
		return LevelAll
	case -l >= KlogLevelTrace:
		return LevelTrace
	case -l >= KlogLevelDebug:
		return LevelDebug
	case -l >= KlogLevelInfo:
		return LevelInfo
	default:
		return "" // warning is handled via a custom key since klog level 0 is ambiguous
	}
}

func callerEncoder(caller zapcore.EntryCaller, enc zapcore.PrimitiveArrayEncoder) {
	enc.AppendString(caller.String() + funcEncoder(caller))
}

func funcEncoder(caller zapcore.EntryCaller) string {
	funcName := caller.Function
	if idx := strings.LastIndexByte(funcName, '/'); idx != -1 {
		funcName = funcName[idx+1:] // keep everything after the last /
	}
	return "$" + funcName
}

func humanDurationEncoder(d time.Duration, enc zapcore.PrimitiveArrayEncoder) {
	enc.AppendString(duration.HumanDuration(d))
}

func humanTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
	enc.AppendString(t.Local().Format(time.RFC1123))
}
