package server

import (
	"bytes"
	"context"
	"fmt"
	"math/rand"
	"os"
	"path/filepath"
	"testing"
	"time"

	gomock "github.com/golang/mock/gomock"
	"github.com/pingcap/errors"
	"github.com/spf13/viper"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap"
	"gopkg.in/natefinch/lumberjack.v2"
)

func TestConfigToYAML(t *testing.T) {
	cfg := Config{
		LogDir:                "dir",
		BucketURL:             "url",
		Region:                "region",
		AWS:                   &AWS{RoleARN: "arn", ExternalID: "id"},
		SlowLogMaxBackups:     1,
		CleanOldestLogPercent: 0.8,
	}

	data, err := cfg.ToYAML()
	t.Log("yaml: ", string(data))
	require.NoError(t, err)
	vp := viper.New()
	vp.SetConfigType("yaml")
	err = vp.ReadConfig(bytes.NewReader(data))
	require.NoError(t, err)
	cfgBack := new(Config)
	err = vp.Unmarshal(cfgBack)
	require.NoError(t, err)
	require.Equal(t, cfg, *cfgBack)
}

func TestConfigRedactedForLog(t *testing.T) {
	config := Config{
		BucketURL: "azblob://account.blob.core.windows.net/container",
		Region:    "eastus",
		Azure:     &Azure{SASToken: "sas-token-do-not-log"},
		AWS:       &AWS{RoleARN: "arn:aws:iam::123:role/safe-role"},
	}

	redacted := config.redactedForLog()
	require.Equal(t, "***", redacted.Azure.SASToken)
	require.Equal(t, config.BucketURL, redacted.BucketURL)
	require.Equal(t, config.AWS.RoleARN, redacted.AWS.RoleARN)
	require.Equal(t, "sas-token-do-not-log", config.Azure.SASToken, "redaction must not mutate the runtime config")
}

func TestListLog(t *testing.T) {
	dir := t.TempDir()
	name := "testlist.log"

	items, err := listLog(dir, name)
	require.NoError(t, err)
	require.Empty(t, items)

	lg := lumberjack.Logger{
		Filename: filepath.Join(dir, name),
	}
	_, err = lg.Write([]byte("a"))
	require.NoError(t, err)
	err = lg.Rotate()
	require.NoError(t, err)
	time.Sleep(time.Millisecond) // avoid may use same backup name
	_, err = lg.Write([]byte("b"))
	require.NoError(t, err)
	err = lg.Rotate()
	require.NoError(t, err)

	items, err = listLog(dir, name)
	require.NoError(t, err)
	require.Len(t, items, 2)
	data, err := os.ReadFile(filepath.Join(dir, items[0].name))
	require.NoError(t, err)
	require.Equal(t, []byte("a"), data)
	data, err = os.ReadFile(filepath.Join(dir, items[1].name))
	require.NoError(t, err)
	require.Equal(t, []byte("b"), data)
}

func TestListLogNoExt(t *testing.T) {
	dir := t.TempDir()
	name := "slowlog"
	t.Log("use TempDir: ", dir)

	items, err := listLog(dir, name)
	require.NoError(t, err)
	require.Empty(t, items)

	lg := lumberjack.Logger{
		Filename: filepath.Join(dir, name),
	}
	_, err = lg.Write([]byte("a"))
	require.NoError(t, err)
	err = lg.Rotate()
	require.NoError(t, err)
	time.Sleep(time.Millisecond) // avoid may use same backup name
	_, err = lg.Write([]byte("b"))
	require.NoError(t, err)
	err = lg.Rotate()
	require.NoError(t, err)

	items, err = listLog(dir, name)
	require.NoError(t, err)
	require.Len(t, items, 2)
	data, err := os.ReadFile(filepath.Join(dir, items[0].name))
	require.NoError(t, err)
	require.Equal(t, []byte("a"), data)
	data, err = os.ReadFile(filepath.Join(dir, items[1].name))
	require.NoError(t, err)
	require.Equal(t, []byte("b"), data)
}

func TestRotateIfNeed(t *testing.T) {
	dir := t.TempDir()
	name := "test.log"

	// Create a server with default rotation strategy
	s := &Server{
		logger: zap.NewNop(),
		Config: Config{
			RotationStrategy: string(RotationStrategyRename),
		},
	}

	reset := func(t *testing.T) {
		t.Helper()

		// delete all files under dir.
		logs, err := listLog(dir, name)
		require.NoError(t, err)
		for _, item := range logs {
			err := os.Remove(filepath.Join(dir, item.name))
			require.NoError(t, err)
		}
		err = os.Remove(filepath.Join(dir, name))
		if !os.IsNotExist(err) {
			require.NoError(t, err)
		}
	}

	t.Run("test not exist", func(t *testing.T) {
		reset(t)

		rotated, err := s.rotateIfNeed(dir, name)
		require.NoError(t, err)
		require.False(t, rotated, "should not rotate non-existent file")
		logs, err := listLog(dir, name)
		require.NoError(t, err)
		require.Empty(t, logs)
	})

	t.Run("test empty file", func(t *testing.T) {
		reset(t)

		fname := filepath.Join(dir, name)
		err := os.WriteFile(fname, nil, 0o600)
		require.NoError(t, err)
		logs, err := listLog(dir, name)
		require.NoError(t, err)
		require.Empty(t, logs)
	})

	t.Run("test will rotate", func(t *testing.T) {
		reset(t)

		fname := filepath.Join(dir, name)
		err := os.WriteFile(fname, []byte("data"), 0o600)
		require.NoError(t, err)

		rotated, err := s.rotateIfNeed(dir, name)
		require.NoError(t, err)
		require.True(t, rotated, "should rotate non-empty file")
		logs, err := listLog(dir, name)
		require.NoError(t, err)
		require.Len(t, logs, 1)

		fname = filepath.Join(dir, logs[0].name)
		readData, err := os.ReadFile(fname)
		require.NoError(t, err)
		require.Equal(t, []byte("data"), readData)
	})

	t.Run("test will rotate for JSON", func(t *testing.T) {
		reset(t)

		fname := filepath.Join(dir, name+".json")
		err := os.WriteFile(fname, []byte("data"), 0o600)
		require.NoError(t, err)

		rotated, err := s.rotateIfNeed(dir, name)
		require.NoError(t, err)
		require.True(t, rotated, "should rotate non-empty JSON file")
		logs, err := listLog(dir, name)
		require.NoError(t, err)
		require.Len(t, logs, 1)

		fname = filepath.Join(dir, logs[0].name)
		readData, err := os.ReadFile(fname)
		require.NoError(t, err)
		require.Equal(t, []byte("data"), readData)
	})
}

func TestServer(t *testing.T) {
	var dir string
	var audit *Server
	var vp *viper.Viper
	var mockHandler *MockLogHandler
	var configFile *os.File
	defer func() {
		if configFile != nil {
			os.Remove(configFile.Name())
		}
	}()

	reset := func(t *testing.T, yamlConfig string) {
		t.Helper()
		mockCtrl := gomock.NewController(t)
		mockHandler = NewMockLogHandler(mockCtrl)
		defaultLogHandler = mockHandler

		dir = filepath.Join(t.TempDir(), randString(5))
		var err error
		err = os.Mkdir(dir, 0o777)
		require.NoError(t, err)

		if configFile != nil {
			configFile.Close()
			os.Remove(configFile.Name())
		}
		configFile, err = os.CreateTemp(dir, "*.yaml")
		require.NoError(t, err)
		data := fmt.Sprintf(`
logDir: %s
metricsPort: 0
`, dir)
		data += yamlConfig
		_, err = configFile.WriteString(data)
		require.NoError(t, err)

		vp = viper.New()
		vp.SetConfigFile(configFile.Name())
		audit, err = New(vp)
		require.NoError(t, err)
		audit.skipCheckConn = true
	}

	t.Run("load config", func(t *testing.T) {
		reset(t, "")

		require.Equal(t, dir, audit.LogDir)
		// defaults in New()
		require.InEpsilon(t, 0.8, audit.CleanOldestLogPercent, 0.01)
		require.Equal(t, 1, audit.SlowLogMaxBackups)

		// test setting all config items.
		data := `
logDir: /tmp
bucketURL: url
region: region
aws:
  roleARN: roleARN
  externalID: externalID
slowLogMaxBackups: 10
cleanOldestLogPercent: 0.5
`
		err := configFile.Truncate(0)
		require.NoError(t, err)
		_, err = configFile.Seek(0, 0)
		require.NoError(t, err)
		_, err = configFile.WriteString(data)
		require.NoError(t, err)
		err = vp.ReadInConfig()
		require.NoError(t, err)
		audit.loadConfig()
		require.Equal(t, "/tmp", audit.LogDir)
		require.Equal(t, "url", audit.BucketURL)
		require.Equal(t, "region", audit.Region)
		require.Equal(t, "roleARN", audit.AWS.RoleARN)
		require.Equal(t, "externalID", audit.AWS.ExternalID)
		require.Equal(t, 10, audit.SlowLogMaxBackups)
		require.InEpsilon(t, 0.5, audit.CleanOldestLogPercent, 0.01)
	})

	t.Run("fail_push", func(t *testing.T) {
		reset(t, `
bucketURL: url
cleanOldestLogPercent: 1.0
`)

		// write two rotated file and one current.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName), []string{"a", "b", "c"})
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		// set up fail to push
		callCh := make(chan struct{})
		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.All(),
		).MinTimes(1).DoAndReturn(func(ctx context.Context, fname string, targetKey string) error {
			select {
			case callCh <- struct{}{}:
			default:
			}
			return errors.New("some error")
		})

		// run the audit
		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// check files will not be deleted since it fail to push
		<-callCh
		<-callCh // make sure already retry
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		// test clean backup logs and slow logs
		// setting cleanOldestLogPercent as 0 to make it delete all rotated files.
		str := `
logDir: %s
bucketURL: url
cleanOldestLogPercent: 0.0
`
		data := fmt.Sprintf(str, dir)
		err = os.WriteFile(configFile.Name(), []byte(data), 0o600)
		require.NoError(t, err)

		require.Equal(t, 1, audit.SlowLogMaxBackups)
		writeLog(t, filepath.Join(dir, defaultSlowLogFileName), []string{"a", "b", "c"})

		// wait it cleanup
		for i := 0; i < 100; i++ {
			items, err = listLog(dir, defaultAuditLogFileName)
			itemsSlow, errSlow := listLog(dir, defaultSlowLogFileName)
			if err == nil && len(items) == 0 &&
				errSlow == nil && len(itemsSlow) == 1 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)
		items, err = listLog(dir, defaultSlowLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 1)

		go audit.Shutdown(ctx, cancel)
		<-runDone
		require.Error(t, runErr)
	})

	t.Run("normal_push", func(t *testing.T) {
		reset(t, `
bucketURL: url
cleanOldestLogPercent: 1.0
`)

		// write two rotated file and one current.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName), []string{"a", "b", "c"})
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.All(),
		).Return(nil).MinTimes(3).MaxTimes(3)

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// wait it pushed the 2 rotated files
		for i := 0; i < 100; i++ {
			items, err = listLog(dir, defaultAuditLogFileName)
			if err == nil && len(items) == 0 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)

		go audit.Shutdown(ctx, cancel)
		<-runDone
		require.NoError(t, runErr)
	})

	t.Run(("no_push_with_empty_bucket_url"), func(t *testing.T) {
		reset(t, `
bucketURL: ""
cleanOldestLogPercent: 1.0
`)
		// write two rotated file and one current.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName), []string{"a", "b", "c"})
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		// should no push call
		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.All(),
		).Times(0)

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// no files should be deleted
		time.Sleep(time.Second)
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		go audit.Shutdown(ctx, cancel)
		<-runDone
		require.NoError(t, runErr)
	})

	t.Run("push_with_another_filename", func(t *testing.T) {
		reset(t, `
auditLogFileName: tidb-another-audit.log
bucketURL: url
cleanOldestLogPercent: 1.0
`)

		// write two rotated file and one current.
		writeLog(t, filepath.Join(dir, "tidb-another-audit.log"), []string{"a", "b", "c"})
		items, err := listLog(dir, "tidb-another-audit.log")
		require.NoError(t, err)
		require.Len(t, items, 2)

		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.All(),
		).Return(nil).MinTimes(3).MaxTimes(3)

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// wait it pushed the 2 rotated files
		for i := 0; i < 100; i++ {
			items, err = listLog(dir, "tidb-another-audit.log")
			if err == nil && len(items) == 0 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}
		items, err = listLog(dir, "tidb-another-audit.log")
		require.NoError(t, err)
		require.Empty(t, items)

		go audit.Shutdown(ctx, cancel)
		<-runDone
		require.NoError(t, runErr)
	})

	t.Run(("push_json_format"), func(t *testing.T) {
		reset(t, `
bucketURL: url
cleanOldestLogPercent: 1.0
`)

		// write two rotated file and one current.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName+".json"), []string{"a", "b", "c"})
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.All(),
		).Return(nil).MinTimes(3).MaxTimes(3)

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// wait it pushed the 2 rotated files
		for i := 0; i < 100; i++ {
			items, err = listLog(dir, defaultAuditLogFileName)
			if err == nil && len(items) == 0 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)

		go audit.Shutdown(ctx, cancel)
		<-runDone
		require.NoError(t, runErr)
	})

	t.Run(("push_both_formats"), func(t *testing.T) {
		reset(t, `
bucketURL: url
cleanOldestLogPercent: 1.0
`)
		// write two rotated file and one current with TEXT format.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName), []string{"a", "b", "c"})
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		time.Sleep(2500 * time.Millisecond) // ensure the JSON files have different modified time

		// write two rotated file and one current with JSON format.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName+".json"), []string{"x", "y", "z"})
		itemsJSON, err := listLog(dir, defaultAuditLogFileName) // should not include `.json` suffix
		require.NoError(t, err)
		require.Len(t, itemsJSON, 4) // 2 TEXT + 2 JSON

		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.All(),
		).Return(nil).MinTimes(6).MaxTimes(6)

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// wait it pushed the 2+2+1 rotated files
		for i := 0; i < 100; i++ {
			items, err = listLog(dir, defaultAuditLogFileName)
			if err == nil && len(items) == 0 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)

		go audit.Shutdown(ctx, cancel)
		<-runDone
		require.NoError(t, runErr)
	})

	t.Run(("push_both_formats_can_not_diff_modified_time"), func(t *testing.T) {
		reset(t, `
bucketURL: url
cleanOldestLogPercent: 1.0
`)
		// write two rotated file and one current with TEXT format.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName), []string{"a", "b", "c"})
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		// write two rotated file and one current with JSON format.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName+".json"), []string{"x", "y", "z"})
		itemsJSON, err := listLog(dir, defaultAuditLogFileName) // should not include `.json` suffix
		require.NoError(t, err)
		require.Len(t, itemsJSON, 4) // 2 TEXT + 2 JSON

		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.All(),
		).Return(nil).MinTimes(5).MaxTimes(5) // "tidb-audit.log.json" will not be pushed

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// wait it pushed the 2+2+1 rotated files
		for i := 0; i < 100; i++ {
			items, err = listLog(dir, defaultAuditLogFileName)
			if err == nil && len(items) == 0 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)

		go audit.Shutdown(ctx, cancel)
		<-runDone
		require.NoError(t, runErr)

		// the JSON file should still exist
		jsonFile := filepath.Join(dir, defaultAuditLogFileName+".json")
		data, err := os.ReadFile(jsonFile)
		require.NoError(t, err)
		require.Equal(t, []byte("z"), data) // check the first byte of the JSON file
	})

	t.Run("push_during_shuting_down", func(t *testing.T) {
		reset(t, `
bucketURL: url
cleanOldestLogPercent: 1.0
`)

		// write two rotated file and on current.
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName), []string{"a", "b", "c"})
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Len(t, items, 2)

		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.Any(),
		).Return(nil).AnyTimes()

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var runErr error
		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			audit.GracefulStopWaitSeconds = 5
			runErr = audit.Run(ctx)
			close(runDone)
		}()

		// wait for previously written files are pushed
		for i := 0; i < 100; i++ {
			items, err = listLog(dir, defaultAuditLogFileName)
			if err == nil && len(items) == 0 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}

		go audit.Shutdown(ctx, cancel)
		time.Sleep(time.Second) // make sure the following logs are written during shutting down
		writeLog(t, filepath.Join(dir, defaultAuditLogFileName), []string{"x", "y", "z"})

		<-runDone
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)
		require.NoError(t, runErr)
		require.LessOrEqual(t, audit.pushCountInShutdown, 1)
	})
}

func randString(n int) string {
	const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
	b := make([]byte, n)
	for i := range b {
		b[i] = letters[rand.Intn(len(letters))]
	}
	return string(b)
}

// intPtr returns a pointer to the given int value.
func intPtr(i int) *int { return &i }

func writeLog(t *testing.T, fname string, files []string) {
	t.Helper()

	lg := lumberjack.Logger{
		Filename: fname,
	}

	for idx, data := range files {
		if idx != 0 {
			time.Sleep(time.Millisecond) // avoid it using same name
			err := lg.Rotate()
			require.NoError(t, err)
		}
		_, err := lg.Write([]byte(data))
		require.NoError(t, err)
	}
	lg.Close()
}

func TestAppendSuffix(t *testing.T) {
	// Test default behavior (backward compatible with TiDB/Kong)
	s := &Server{}

	get, err := s.appendSuffix("s3://host/", "db-1949758074227003392-tidb-p6u2xw")
	require.NoError(t, err)
	require.Equal(t, "s3://host/tidb-p6u2xw", get)

	get, err = s.appendSuffix("s3://host/pre", "db-1949758074227003392-tidb-p6u2xw")
	require.NoError(t, err)
	require.Equal(t, "s3://host/pre/tidb-p6u2xw", get)

	get, err = s.appendSuffix("s3://host/pre/", "clustergateway-kong-1")
	require.NoError(t, err)
	require.Equal(t, "s3://host/pre/kong-1", get)
}

func TestAppendSuffixWithSubPath(t *testing.T) {
	tests := []struct {
		name      string
		mode      string
		subPath   string
		bucketURL string
		podName   string
		expected  string
	}{
		{
			name:      "default tidb detection",
			mode:      "",
			subPath:   "",
			bucketURL: "s3://bucket/path",
			podName:   "db-123-tidb-abc",
			expected:  "s3://bucket/path/tidb-abc",
		},
		{
			name:      "default kong detection",
			mode:      "",
			subPath:   "",
			bucketURL: "s3://bucket/path",
			podName:   "gateway-kong-xyz",
			expected:  "s3://bucket/path/kong-xyz",
		},
		{
			name:      "agent mode with default subPath",
			mode:      string(ModeAgent),
			subPath:   "",
			bucketURL: "s3://bucket/path",
			podName:   "myapp-pod-abc",
			expected:  "s3://bucket/path/dataplane-agent",
		},
		{
			name:      "agent mode with custom subPath",
			mode:      string(ModeAgent),
			subPath:   "primary",
			bucketURL: "s3://bucket/path",
			podName:   "myapp-pod-abc",
			expected:  "s3://bucket/path/primary",
		},
		{
			name:      "tidb mode ignores subPath",
			mode:      string(ModeTiDB),
			subPath:   "custom-",
			bucketURL: "s3://bucket/path",
			podName:   "db-123-tidb-abc",
			expected:  "s3://bucket/path/tidb-abc",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &Server{
				Config: Config{
					Mode:    tt.mode,
					SubPath: tt.subPath,
				},
			}
			got, err := s.appendSuffix(tt.bucketURL, tt.podName)
			require.NoError(t, err)
			require.Equal(t, tt.expected, got)
		})
	}
}

func TestValidateSubPath(t *testing.T) {
	tests := []struct {
		name    string
		subPath string
		mode    string
		wantErr bool
	}{
		{
			name:    "empty subPath in agent mode",
			subPath: "",
			mode:    string(ModeAgent),
			wantErr: false,
		},
		{
			name:    "valid subPath in agent mode",
			subPath: "primary",
			mode:    string(ModeAgent),
			wantErr: false,
		},
		{
			name:    "valid nested subPath",
			subPath: "logs/audit",
			mode:    string(ModeAgent),
			wantErr: false,
		},
		{
			name:    "path traversal attack",
			subPath: "../../../etc/passwd",
			mode:    string(ModeAgent),
			wantErr: true,
		},
		{
			name:    "absolute path",
			subPath: "/etc/passwd",
			mode:    string(ModeAgent),
			wantErr: true,
		},
		{
			name:    "invalid URL character ?",
			subPath: "path?query=1",
			mode:    string(ModeAgent),
			wantErr: true,
		},
		{
			name:    "invalid URL character #",
			subPath: "path#fragment",
			mode:    string(ModeAgent),
			wantErr: true,
		},
		{
			name:    "tidb mode ignores subPath validation",
			subPath: "../../../etc/passwd",
			mode:    string(ModeTiDB),
			wantErr: false, // tidb mode ignores SubPath
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validateSubPath(tt.subPath, tt.mode)
			if tt.wantErr {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
			}
		})
	}
}

func TestIsRotateFile(t *testing.T) {
	tests := []struct {
		name     string
		filename string
		expected bool
	}{
		{"normal", "tidb-audit-2025-08-01T12-00-00.000.log", true},
		{"no ext", "tidb-audit-2025-08-01T12-00-00.000", true},
		{"json ext", "tidb-audit.log-2025-08-01T09-42-10.442.json", true},
		{"no date", "tidb-audit.log", false},
		{"empty", "", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := isRotatedFile(defaultAuditLogFileName, tt.filename)
			require.Equal(t, tt.expected, result)
		})
	}
}

func TestGenTargetKey(t *testing.T) {
	tests := []struct {
		name     string
		prefix   string
		filename string
		expected string
	}{
		{"normal", "/bucket/path", "tidb-audit.log", "/bucket/path/tidb-audit.log"},
		{"date_for_text", "/bucket/path/{{.date}}", "tidb-audit-2025-08-01T09-42-10.442.log", "/bucket/path/20250801/tidb-audit-2025-08-01T09-42-10.442.log"},
		{"date_for_json", "/bucket/path/{{.date}}", "tidb-audit.log-2025-08-01T09-42-10.442.json", "/bucket/path/20250801/tidb-audit.log-2025-08-01T09-42-10.442.json"},
		{"no_supported_suffix", "/bucket/path/{{.date}}", "tidb-audit.log-2025-08-01T09-42-10.442", "/bucket/path/{{.date}}/tidb-audit.log-2025-08-01T09-42-10.442"},
		{"invalid_date", "/bucket/path/{{.date}}", "tidb-audit-abc-123.log", "/bucket/path/{{.date}}/tidb-audit-abc-123.log"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := genTargetKey(tt.prefix, tt.filename, "tidb-audit.log")
			require.Equal(t, tt.expected, result)
		})
	}
}

func TestAzureBucketURL(t *testing.T) {
	tests := []struct {
		name      string
		bucket    string
		account   string
		container string
	}{
		{
			name:      "valid url",
			bucket:    "https://myacct.blob.core.windows.net/mycontainer",
			account:   "myacct",
			container: "mycontainer",
		},
		{
			name:      "valid url with extra subpath",
			bucket:    "azblob://myacct.blob.core.windows.net/mycontainer/path",
			account:   "myacct",
			container: "mycontainer",
		},
		{
			name:      "no container",
			bucket:    "azblob://myacct.blob.core.windows.net/",
			account:   "",
			container: "",
		},
		{
			name:      "invalid account name",
			bucket:    "azblob://myacct-name.blob.core.windows.net/mycontainer",
			account:   "",
			container: "",
		},
		{
			name:      "invalid url",
			bucket:    "myacct-name.blob.core.windows.net/mycontainer",
			account:   "",
			container: "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			acct, container, _ := parseAccountContainer(tt.bucket)
			require.Equal(t, acct, tt.account)
			require.Equal(t, container, tt.container)
		})
	}
}

func TestLoadConfigNormalizesEmptyValues(t *testing.T) {
	dir := t.TempDir()

	tests := []struct {
		name                     string
		yamlConfig               string
		expectedMode             string
		expectedRotationStrategy string
	}{
		{
			name:                     "empty mode defaults to tidb",
			yamlConfig:               "mode: \"\"\n",
			expectedMode:             string(ModeTiDB),
			expectedRotationStrategy: string(RotationStrategyRename),
		},
		{
			name:                     "empty rotation strategy defaults to rename",
			yamlConfig:               "rotationStrategy: \"\"\n",
			expectedMode:             string(ModeTiDB),
			expectedRotationStrategy: string(RotationStrategyRename),
		},
		{
			name:                     "both empty default to tidb and rename",
			yamlConfig:               "mode: \"\"\nrotationStrategy: \"\"\n",
			expectedMode:             string(ModeTiDB),
			expectedRotationStrategy: string(RotationStrategyRename),
		},
		{
			name:                     "explicit agent mode is preserved",
			yamlConfig:               "mode: agent\n",
			expectedMode:             string(ModeAgent),
			expectedRotationStrategy: string(RotationStrategyRename),
		},
		{
			name:                     "explicit copytruncate is preserved",
			yamlConfig:               "rotationStrategy: copytruncate\n",
			expectedMode:             string(ModeTiDB),
			expectedRotationStrategy: string(RotationStrategyCopyTruncate),
		},
		{
			name:                     "no mode or rotation strategy in config uses defaults",
			yamlConfig:               "",
			expectedMode:             string(ModeTiDB),
			expectedRotationStrategy: string(RotationStrategyRename),
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			configFile, err := os.CreateTemp(dir, "*.yaml")
			require.NoError(t, err)
			defer os.Remove(configFile.Name())

			data := fmt.Sprintf("logDir: %s\nmetricsPort: 0\n%s", dir, tt.yamlConfig)
			_, err = configFile.WriteString(data)
			require.NoError(t, err)
			configFile.Close()

			vp := viper.New()
			vp.SetConfigFile(configFile.Name())
			server, err := New(vp)
			require.NoError(t, err)

			require.Equal(t, tt.expectedMode, server.Mode,
				"Mode should be normalized to %s", tt.expectedMode)
			require.Equal(t, tt.expectedRotationStrategy, server.RotationStrategy,
				"RotationStrategy should be normalized to %s", tt.expectedRotationStrategy)
		})
	}
}

func TestValidateMode(t *testing.T) {
	tests := []struct {
		name    string
		mode    string
		wantErr bool
	}{
		{"valid tidb mode", string(ModeTiDB), false},
		{"valid agent mode", string(ModeAgent), false},
		{"invalid mode", "invalid", true},
		{"empty mode", "", true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validateMode(tt.mode)
			if tt.wantErr {
				require.Error(t, err)
				require.Contains(t, err.Error(), "invalid mode")
			} else {
				require.NoError(t, err)
			}
		})
	}
}

func TestValidateRotationStrategy(t *testing.T) {
	tests := []struct {
		name     string
		strategy string
		wantErr  bool
	}{
		{"valid rename", string(RotationStrategyRename), false},
		{"valid copytruncate", string(RotationStrategyCopyTruncate), false},
		{"invalid strategy", "invalid", true},
		{"empty strategy", "", true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validateRotationStrategy(tt.strategy)
			if tt.wantErr {
				require.Error(t, err)
				require.Contains(t, err.Error(), "invalid rotationStrategy")
			} else {
				require.NoError(t, err)
			}
		})
	}
}

func TestValidatePushTrigger(t *testing.T) {
	tests := []struct {
		name    string
		trigger *PushTrigger
		wantErr bool
		errMsg  string
	}{
		{
			name:    "nil trigger is valid",
			trigger: nil,
			wantErr: false,
		},
		{
			name: "valid trigger with all fields",
			trigger: &PushTrigger{
				CheckInterval: 10 * time.Second,
				CleanInterval: 20 * time.Second,
				FilesPerPush:  intPtr(5),
			},
			wantErr: false,
		},
		{
			name: "valid trigger with only checkInterval",
			trigger: &PushTrigger{
				CheckInterval: 5 * time.Second,
			},
			wantErr: false,
		},
		{
			name: "negative checkInterval",
			trigger: &PushTrigger{
				CheckInterval: -1 * time.Second,
			},
			wantErr: true,
			errMsg:  "checkInterval must be non-negative",
		},
		{
			name: "checkInterval too small",
			trigger: &PushTrigger{
				CheckInterval: 500 * time.Millisecond,
			},
			wantErr: true,
			errMsg:  "checkInterval must be >= 1s",
		},
		{
			name: "negative cleanInterval",
			trigger: &PushTrigger{
				CleanInterval: -1 * time.Second,
			},
			wantErr: true,
			errMsg:  "cleanInterval must be non-negative",
		},
		{
			name: "cleanInterval too small",
			trigger: &PushTrigger{
				CleanInterval: 500 * time.Millisecond,
			},
			wantErr: true,
			errMsg:  "cleanInterval must be >= 1s",
		},
		{
			name: "negative filesPerPush",
			trigger: &PushTrigger{
				FilesPerPush: intPtr(-1),
			},
			wantErr: true,
			errMsg:  "filesPerPush must be non-negative",
		},
		{
			name: "zero filesPerPush is valid",
			trigger: &PushTrigger{
				FilesPerPush: intPtr(0),
			},
			wantErr: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validatePushTrigger(tt.trigger)
			if tt.wantErr {
				require.Error(t, err)
				require.Contains(t, err.Error(), tt.errMsg)
			} else {
				require.NoError(t, err)
			}
		})
	}
}

func TestGetCheckInterval(t *testing.T) {
	tests := []struct {
		name     string
		trigger  *PushTrigger
		expected time.Duration
	}{
		{
			name:     "nil trigger returns default",
			trigger:  nil,
			expected: defaultCheckLogDuration,
		},
		{
			name:     "zero interval returns default",
			trigger:  &PushTrigger{CheckInterval: 0},
			expected: defaultCheckLogDuration,
		},
		{
			name:     "custom interval",
			trigger:  &PushTrigger{CheckInterval: 30 * time.Second},
			expected: 30 * time.Second,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &Server{
				Config: Config{
					PushTrigger: tt.trigger,
				},
			}
			require.Equal(t, tt.expected, s.getCheckInterval())
		})
	}
}

func TestGetCleanInterval(t *testing.T) {
	tests := []struct {
		name     string
		trigger  *PushTrigger
		expected time.Duration
	}{
		{
			name:     "nil trigger returns default",
			trigger:  nil,
			expected: defaultCleanLogDuration,
		},
		{
			name:     "zero interval returns default",
			trigger:  &PushTrigger{CleanInterval: 0},
			expected: defaultCleanLogDuration,
		},
		{
			name:     "custom interval",
			trigger:  &PushTrigger{CleanInterval: 60 * time.Second},
			expected: 60 * time.Second,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &Server{
				Config: Config{
					PushTrigger: tt.trigger,
				},
			}
			require.Equal(t, tt.expected, s.getCleanInterval())
		})
	}
}

func TestGetFilesPerPush(t *testing.T) {
	tests := []struct {
		name     string
		trigger  *PushTrigger
		expected int
	}{
		{
			name:     "nil trigger returns default",
			trigger:  nil,
			expected: defaultFilesPerPush,
		},
		{
			name:     "nil filesPerPush returns default",
			trigger:  &PushTrigger{FilesPerPush: nil},
			expected: defaultFilesPerPush,
		},
		{
			name:     "zero files returns default",
			trigger:  &PushTrigger{FilesPerPush: intPtr(0)},
			expected: defaultFilesPerPush,
		},
		{
			name:     "custom files per push",
			trigger:  &PushTrigger{FilesPerPush: intPtr(10)},
			expected: 10,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &Server{
				Config: Config{
					PushTrigger: tt.trigger,
				},
			}
			require.Equal(t, tt.expected, s.getFilesPerPush())
		})
	}
}

func TestAgentModeSizeBasedRotation(t *testing.T) {
	var dir string
	var audit *Server
	var vp *viper.Viper
	var mockHandler *MockLogHandler
	var configFile *os.File
	defer func() {
		if configFile != nil {
			os.Remove(configFile.Name())
		}
	}()

	reset := func(t *testing.T, yamlConfig string) {
		t.Helper()
		mockCtrl := gomock.NewController(t)
		mockHandler = NewMockLogHandler(mockCtrl)
		defaultLogHandler = mockHandler

		dir = filepath.Join(t.TempDir(), randString(5))
		var err error
		err = os.Mkdir(dir, 0o777)
		require.NoError(t, err)

		if configFile != nil {
			configFile.Close()
			os.Remove(configFile.Name())
		}
		configFile, err = os.CreateTemp(dir, "*.yaml")
		require.NoError(t, err)
		data := fmt.Sprintf(`
logDir: %s
metricsPort: 0
`, dir)
		data += yamlConfig
		_, err = configFile.WriteString(data)
		require.NoError(t, err)

		vp = viper.New()
		vp.SetConfigFile(configFile.Name())
		audit, err = New(vp)
		require.NoError(t, err)
		audit.skipCheckConn = true
		// Set the mock handler after New() to override buildLogHandler
		audit.handler = mockHandler
	}

	t.Run("agent_mode_rotates_current_log_periodically", func(t *testing.T) {
		reset(t, `
mode: agent
bucketURL: s3://bucket/path
rotationStrategy: copytruncate
pushTrigger:
  maxSize: 1
`)
		// Write a current log file larger than maxSize (1 MiB) to trigger rotation
		fname := filepath.Join(dir, defaultAuditLogFileName)
		// Create a file larger than 1 MiB to trigger maxSize condition
		createFileWithSize(t, fname, 2*1024*1024) // 2 MiB

		// Initially, there should be no rotated files
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)

		// Expect at least one push call when the log is rotated and pushed
		pushCalled := make(chan struct{}, 1)
		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.Any(),
		).DoAndReturn(func(ctx context.Context, fname string, targetKey string) error {
			select {
			case pushCalled <- struct{}{}:
			default:
			}
			return nil
		}).MinTimes(1)

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()

		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			_ = audit.Run(ctx)
			close(runDone)
		}()

		// Wait for at least one push to happen
		select {
		case <-pushCalled:
			// success - the current log was rotated and pushed
		case <-time.After(5 * time.Second):
			t.Fatal("expected push to be called within timeout")
		}

		// Verify the current log file was rotated and pushed
		// After rotation and push, the rotated file should be deleted
		for i := 0; i < 50; i++ {
			items, err = listLog(dir, defaultAuditLogFileName)
			if err == nil && len(items) == 0 {
				break
			}
			time.Sleep(time.Millisecond * 100)
		}

		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items, "rotated files should be pushed and deleted")

		go audit.Shutdown(ctx, cancel)
		<-runDone
	})

	t.Run("tidb_mode_does_not_rotate_current_log_periodically", func(t *testing.T) {
		reset(t, `
mode: tidb
bucketURL: s3://bucket/path
`)
		// Write a current log file (not rotated yet)
		fname := filepath.Join(dir, defaultAuditLogFileName)
		err := os.WriteFile(fname, []byte("tidb log data"), 0o600)
		require.NoError(t, err)

		// Initially, there should be no rotated files
		items, err := listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items)

		// Allow push calls on shutdown (TiDB mode rotates on shutdown, which is expected behavior)
		mockHandler.EXPECT().Push(
			gomock.Any(),
			gomock.Any(),
			gomock.Any(),
		).Return(nil).AnyTimes()

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()

		runDone := make(chan struct{})
		go func() {
			defaultCheckLogDuration = time.Millisecond * 100
			defaultCleanLogDuration = time.Millisecond * 100
			_ = audit.Run(ctx)
			close(runDone)
		}()

		// Wait a bit to verify no rotation happens during runtime
		time.Sleep(time.Millisecond * 500)

		// Verify the current log file was NOT rotated during runtime
		items, err = listLog(dir, defaultAuditLogFileName)
		require.NoError(t, err)
		require.Empty(t, items, "TiDB mode should NOT rotate current log periodically during runtime")

		// Verify the current log file still exists with original content
		data, err := os.ReadFile(fname)
		require.NoError(t, err)
		require.Equal(t, []byte("tidb log data"), data, "TiDB mode should NOT modify current log during runtime")

		go audit.Shutdown(ctx, cancel)
		<-runDone
	})
}

// createFileWithSize creates a file with the specified size in bytes.
func createFileWithSize(t *testing.T, path string, size int64) {
	t.Helper()
	f, err := os.Create(path)
	require.NoError(t, err)
	defer f.Close()
	require.NoError(t, f.Truncate(size))
}

func TestGetMaxSize(t *testing.T) {
	tests := []struct {
		name     string
		trigger  *PushTrigger
		expected int
	}{
		{
			name:     "nil trigger returns default",
			trigger:  nil,
			expected: defaultMaxSize,
		},
		{
			name:     "nil maxSize returns default",
			trigger:  &PushTrigger{MaxSize: nil},
			expected: defaultMaxSize,
		},
		{
			name:     "zero maxSize means disabled",
			trigger:  &PushTrigger{MaxSize: intPtr(0)},
			expected: 0,
		},
		{
			name:     "custom maxSize",
			trigger:  &PushTrigger{MaxSize: intPtr(100)},
			expected: 100,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &Server{
				Config: Config{
					PushTrigger: tt.trigger,
				},
			}
			require.Equal(t, tt.expected, s.getMaxSize())
		})
	}
}

func TestGetMaxLifetime(t *testing.T) {
	tests := []struct {
		name     string
		trigger  *PushTrigger
		expected int
	}{
		{
			name:     "nil trigger returns default",
			trigger:  nil,
			expected: defaultMaxLifetime,
		},
		{
			name:     "nil maxLifetime returns default",
			trigger:  &PushTrigger{MaxLifetime: nil},
			expected: defaultMaxLifetime,
		},
		{
			name:     "zero maxLifetime means disabled",
			trigger:  &PushTrigger{MaxLifetime: intPtr(0)},
			expected: 0,
		},
		{
			name:     "custom maxLifetime",
			trigger:  &PushTrigger{MaxLifetime: intPtr(3600)},
			expected: 3600,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &Server{
				Config: Config{
					PushTrigger: tt.trigger,
				},
			}
			require.Equal(t, tt.expected, s.getMaxLifetime())
		})
	}
}

func TestShouldRotateInAgentMode(t *testing.T) {
	tests := []struct {
		name           string
		mode           string
		maxSize        *int
		maxLifetime    *int
		fileSize       int64 // in bytes, 0 means no file
		lastRotateTime time.Time
		expectRotate   bool
		expectReason   string
	}{
		{
			name:         "tidb mode never rotates",
			mode:         string(ModeTiDB),
			maxSize:      intPtr(10),
			maxLifetime:  intPtr(300),
			fileSize:     20 * 1024 * 1024, // 20 MiB
			expectRotate: false,
			expectReason: "",
		},
		{
			name:         "default maxSize triggered (nil uses default)",
			mode:         string(ModeAgent),
			maxSize:      nil, // use default 10 MiB
			maxLifetime:  nil,
			fileSize:     6 * 1024 * 1024, // 6 MiB
			expectRotate: false,
			expectReason: "",
		},
		{
			name:         "default maxSize not triggered",
			mode:         string(ModeAgent),
			maxSize:      nil, // use default 10 MiB
			maxLifetime:  nil,
			fileSize:     2 * 1024 * 1024, // 2 MiB
			expectRotate: false,
		},
		{
			name:         "maxSize disabled (explicit 0)",
			mode:         string(ModeAgent),
			maxSize:      intPtr(0), // disabled
			maxLifetime:  intPtr(0), // disabled
			fileSize:     100 * 1024 * 1024,
			expectRotate: false,
		},
		{
			name:         "custom size triggered",
			mode:         string(ModeAgent),
			maxSize:      intPtr(10),
			maxLifetime:  intPtr(0),
			fileSize:     12 * 1024 * 1024, // 12 MiB
			expectRotate: true,
			expectReason: "size",
		},
		{
			name:         "custom size not triggered",
			mode:         string(ModeAgent),
			maxSize:      intPtr(10),
			maxLifetime:  intPtr(0),
			fileSize:     5 * 1024 * 1024, // 5 MiB
			expectRotate: false,
		},
		{
			name:           "lifetime triggered",
			mode:           string(ModeAgent),
			maxSize:        intPtr(100), // large enough not to trigger
			maxLifetime:    intPtr(300), // 300 seconds
			fileSize:       1 * 1024 * 1024,
			lastRotateTime: time.Now().Add(-6 * time.Minute),
			expectRotate:   true,
			expectReason:   "elapsed",
		},
		{
			name:           "lifetime disabled (explicit 0)",
			mode:           string(ModeAgent),
			maxSize:        intPtr(100),
			maxLifetime:    intPtr(0), // disabled
			fileSize:       1 * 1024 * 1024,
			lastRotateTime: time.Now().Add(-60 * time.Minute),
			expectRotate:   false,
		},
		{
			name:           "lifetime not triggered",
			mode:           string(ModeAgent),
			maxSize:        intPtr(100),
			maxLifetime:    intPtr(600), // 600 seconds
			fileSize:       1 * 1024 * 1024,
			lastRotateTime: time.Now().Add(-5 * time.Minute),
			expectRotate:   false,
		},
		{
			name:           "either condition - size wins",
			mode:           string(ModeAgent),
			maxSize:        intPtr(1),
			maxLifetime:    intPtr(3600), // 3600 seconds
			fileSize:       2 * 1024 * 1024,
			lastRotateTime: time.Now().Add(-5 * time.Minute),
			expectRotate:   true,
			expectReason:   "size",
		},
		{
			name:           "either condition - lifetime wins",
			mode:           string(ModeAgent),
			maxSize:        intPtr(100), // large enough not to trigger
			maxLifetime:    intPtr(300), // 300 seconds
			fileSize:       10 * 1024 * 1024,
			lastRotateTime: time.Now().Add(-6 * time.Minute),
			expectRotate:   true,
			expectReason:   "elapsed",
		},
		{
			name:           "first check initializes lastRotateTime",
			mode:           string(ModeAgent),
			maxSize:        intPtr(100),
			maxLifetime:    intPtr(300),
			fileSize:       1 * 1024 * 1024,
			lastRotateTime: time.Time{}, // zero time
			expectRotate:   false,
		},
		{
			name:         "file does not exist - no rotation",
			mode:         string(ModeAgent),
			maxSize:      nil,
			maxLifetime:  nil,
			fileSize:     0, // file does not exist
			expectRotate: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dir := t.TempDir()
			fname := filepath.Join(dir, defaultAuditLogFileName)

			if tt.fileSize > 0 {
				createFileWithSize(t, fname, tt.fileSize)
			}

			now := time.Now()
			s := &Server{
				logger: zap.NewNop(),
				Config: Config{
					Mode:             tt.mode,
					LogDir:           dir,
					AuditLogFileName: defaultAuditLogFileName,
					PushTrigger: &PushTrigger{
						MaxSize:     tt.maxSize,
						MaxLifetime: tt.maxLifetime,
					},
				},
				lastRotateTime: tt.lastRotateTime,
				nowFunc: func() time.Time {
					return now
				},
			}

			shouldRotate, reason := s.shouldRotateInAgentMode()
			require.Equal(t, tt.expectRotate, shouldRotate, "shouldRotate mismatch")
			if tt.expectReason != "" {
				require.Contains(t, reason, tt.expectReason, "reason should contain expected string")
			}
		})
	}
}

func TestValidatePushTriggerMaxSizeAndMaxLifetime(t *testing.T) {
	tests := []struct {
		name    string
		trigger *PushTrigger
		wantErr bool
		errMsg  string
	}{
		{
			name: "negative maxSize",
			trigger: &PushTrigger{
				MaxSize: intPtr(-1),
			},
			wantErr: true,
			errMsg:  "maxSize must be non-negative",
		},
		{
			name: "negative maxLifetime",
			trigger: &PushTrigger{
				MaxLifetime: intPtr(-1),
			},
			wantErr: true,
			errMsg:  "maxLifetime must be non-negative",
		},
		{
			name: "zero maxSize is valid (means disabled)",
			trigger: &PushTrigger{
				MaxSize: intPtr(0),
			},
			wantErr: false,
		},
		{
			name: "zero maxLifetime is valid (means disabled)",
			trigger: &PushTrigger{
				MaxLifetime: intPtr(0),
			},
			wantErr: false,
		},
		{
			name: "valid with maxSize and maxLifetime",
			trigger: &PushTrigger{
				MaxSize:     intPtr(100),
				MaxLifetime: intPtr(3600),
			},
			wantErr: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validatePushTrigger(tt.trigger)
			if tt.wantErr {
				require.Error(t, err)
				require.Contains(t, err.Error(), tt.errMsg)
			} else {
				require.NoError(t, err)
			}
		})
	}
}

func TestAgentModeEmptyFileSkipsRotation(t *testing.T) {
	// Test that when maxLifetime expires but file is empty, we update lastRotateTime
	// without attempting rotation, preventing infinite retry loops.
	dir := t.TempDir()
	fname := filepath.Join(dir, defaultAuditLogFileName)

	// Create empty file
	err := os.WriteFile(fname, nil, 0o600)
	require.NoError(t, err)

	now := time.Now()
	s := &Server{
		logger: zap.NewNop(),
		Config: Config{
			Mode:             string(ModeAgent),
			LogDir:           dir,
			AuditLogFileName: defaultAuditLogFileName,
			PushTrigger: &PushTrigger{
				MaxSize:     intPtr(100), // large, won't trigger
				MaxLifetime: intPtr(300), // 300 seconds
			},
		},
		lastRotateTime: now.Add(-10 * time.Minute), // expired
		nowFunc: func() time.Time {
			return now
		},
	}

	// First call: should NOT rotate (file is empty)
	shouldRotate, reason := s.shouldRotateInAgentMode()
	require.False(t, shouldRotate, "should not rotate empty file")
	require.Empty(t, reason)

	// lastRotateTime should be updated to prevent repeated checks
	require.Equal(t, now, s.lastRotateTime, "lastRotateTime should be updated for empty file")

	// Second call: should NOT rotate again (lastRotateTime was just updated)
	shouldRotate2, _ := s.shouldRotateInAgentMode()
	require.False(t, shouldRotate2, "should not rotate again after lastRotateTime update")
}
