package server

import (
	"errors"
	"os"
	"path/filepath"
	"strings"
	"syscall"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

func TestGenerateBackupName(t *testing.T) {
	tests := []struct {
		name     string
		filename string
		time     time.Time
		want     string
	}{
		{
			name:     "standard log file",
			filename: "app.log",
			time:     time.Date(2025, 8, 1, 9, 42, 10, 442000000, time.UTC),
			want:     "app-2025-08-01T09-42-10.442.log",
		},
		{
			name:     "tidb audit log",
			filename: "tidb-audit.log",
			time:     time.Date(2025, 8, 1, 9, 42, 10, 442000000, time.UTC),
			want:     "tidb-audit-2025-08-01T09-42-10.442.log",
		},
		{
			name:     "no extension",
			filename: "slowlog",
			time:     time.Date(2025, 8, 1, 9, 42, 10, 442000000, time.UTC),
			want:     "slowlog-2025-08-01T09-42-10.442",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := generateBackupName(tt.filename, tt.time)
			require.Equal(t, tt.want, got)
		})
	}
}

//nolint:gosec // test code
func TestRenameRotator(t *testing.T) {
	t.Run("rotates non-empty file", func(t *testing.T) {
		dir := t.TempDir()
		filename := "test.log"
		fpath := filepath.Join(dir, filename)

		// Write test data
		err := os.WriteFile(fpath, []byte("test data\n"), 0o644)
		require.NoError(t, err)

		rotator := &RenameRotator{}
		backupName, err := rotator.Rotate(dir, filename)
		require.NoError(t, err)
		require.NotEmpty(t, backupName)
		require.True(t, strings.HasPrefix(backupName, "test-"))
		require.True(t, strings.HasSuffix(backupName, ".log"))

		// Verify at least one backup file exists (lumberjack creates its own backup)
		files, err := os.ReadDir(dir)
		require.NoError(t, err)

		var foundBackup bool
		for _, f := range files {
			if !strings.HasPrefix(f.Name(), "test-") || !strings.HasSuffix(f.Name(), ".log") {
				continue
			}
			foundBackup = true
			// Verify backup file has content
			data, err := os.ReadFile(filepath.Join(dir, f.Name()))
			require.NoError(t, err)
			require.Equal(t, []byte("test data\n"), data)
		}
		require.True(t, foundBackup, "backup file should exist")

		// Verify original file is recreated (empty or exists)
		_, err = os.Stat(fpath)
		require.NoError(t, err)
	})

	t.Run("skips empty file", func(t *testing.T) {
		dir := t.TempDir()
		filename := "empty.log"
		fpath := filepath.Join(dir, filename)

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

		rotator := &RenameRotator{}
		backupName, err := rotator.Rotate(dir, filename)
		require.NoError(t, err)
		require.Empty(t, backupName)
	})

	t.Run("returns error for non-existent file", func(t *testing.T) {
		dir := t.TempDir()
		rotator := &RenameRotator{}
		_, err := rotator.Rotate(dir, "nonexistent.log")
		require.Error(t, err)
	})
}

//nolint:gosec // test code
func TestCopyTruncateRotator(t *testing.T) {
	t.Run("rotates non-empty file", func(t *testing.T) {
		dir := t.TempDir()
		filename := "test.log"
		fpath := filepath.Join(dir, filename)
		testData := []byte("test data for copytruncate\n")

		// Write test data
		err := os.WriteFile(fpath, testData, 0o644)
		require.NoError(t, err)

		rotator := &CopyTruncateRotator{}
		backupName, err := rotator.Rotate(dir, filename)
		require.NoError(t, err)
		require.NotEmpty(t, backupName)
		require.True(t, strings.HasPrefix(backupName, "test-"))
		require.True(t, strings.HasSuffix(backupName, ".log"))

		// Verify backup file contains original data
		backupPath := filepath.Join(dir, backupName)
		backupData, err := os.ReadFile(backupPath)
		require.NoError(t, err)
		require.Equal(t, testData, backupData)

		// Verify original file is truncated (empty)
		originalData, err := os.ReadFile(fpath)
		require.NoError(t, err)
		require.Empty(t, originalData)
	})

	t.Run("skips empty file", func(t *testing.T) {
		dir := t.TempDir()
		filename := "empty.log"
		fpath := filepath.Join(dir, filename)

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

		rotator := &CopyTruncateRotator{}
		backupName, err := rotator.Rotate(dir, filename)
		require.NoError(t, err)
		require.Empty(t, backupName)
	})

	t.Run("returns error for non-existent file", func(t *testing.T) {
		dir := t.TempDir()
		rotator := &CopyTruncateRotator{}
		_, err := rotator.Rotate(dir, "nonexistent.log")
		require.Error(t, err)
	})

	t.Run("preserves file permissions", func(t *testing.T) {
		dir := t.TempDir()
		filename := "perms.log"
		fpath := filepath.Join(dir, filename)

		// Write test data with specific permissions
		err := os.WriteFile(fpath, []byte("test data"), 0o644)
		require.NoError(t, err)

		rotator := &CopyTruncateRotator{}
		backupName, err := rotator.Rotate(dir, filename)
		require.NoError(t, err)
		require.NotEmpty(t, backupName)

		// Verify original file still exists with same permissions
		stat, err := os.Stat(fpath)
		require.NoError(t, err)
		require.Equal(t, os.FileMode(0o644), stat.Mode().Perm())
	})

	t.Run("write-rotate-write with separate FDs", func(t *testing.T) {
		// This test simulates the real-world scenario where:
		// - A writer (e.g., TiDB audit log) keeps a file descriptor open with O_APPEND
		// - An external rotator performs copy-truncate rotation
		// - The writer continues to write using the same FD
		//
		// Key insight: When using O_APPEND, each write atomically seeks to EOF first.
		// After truncation, EOF is at position 0, so the next write starts from 0.
		// This means O_APPEND mode does NOT create sparse files after copytruncate.
		dir := t.TempDir()
		filename := "dual-fd.log"
		fpath := filepath.Join(dir, filename)

		// Writer opens file with O_APPEND (simulating TiDB audit log writer)
		writer, err := os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
		require.NoError(t, err)
		defer writer.Close()

		// First write
		firstData := []byte("first data\n")
		n, err := writer.Write(firstData)
		require.NoError(t, err)
		require.Equal(t, len(firstData), n)
		require.NoError(t, writer.Sync())

		// Rotate (opens its own FD internally)
		rotator := &CopyTruncateRotator{}
		backupName, err := rotator.Rotate(dir, filename)
		require.NoError(t, err)
		require.NotEmpty(t, backupName)

		// Second write (using the same FD with O_APPEND)
		// O_APPEND atomically seeks to EOF before each write,
		// so after truncation, write starts from position 0
		secondData := []byte("second data\n")
		n, err = writer.Write(secondData)
		require.NoError(t, err)
		require.Equal(t, len(secondData), n)
		require.NoError(t, writer.Sync())

		// Verify backup file contains the first write data
		backupPath := filepath.Join(dir, backupName)
		backupContent, err := os.ReadFile(backupPath)
		require.NoError(t, err)
		require.Equal(t, firstData, backupContent)

		// Verify original file: with O_APPEND, second write starts from position 0
		// (no sparse hole because O_APPEND seeks to EOF which is 0 after truncation)
		originalContent, err := os.ReadFile(fpath)
		require.NoError(t, err)
		require.Equal(t, secondData, originalContent)
	})

	t.Run("write-rotate-write without O_APPEND creates sparse file", func(t *testing.T) {
		// This test demonstrates the sparse file behavior when NOT using O_APPEND.
		// Without O_APPEND, each FD maintains its own independent file offset.
		// After truncation, the writer's offset is unchanged, creating a sparse hole.
		dir := t.TempDir()
		filename := "sparse-fd.log"
		fpath := filepath.Join(dir, filename)

		// Writer opens file WITHOUT O_APPEND
		writer, err := os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY, 0o644)
		require.NoError(t, err)
		defer writer.Close()

		// First write (writer's offset moves to len(firstData))
		firstData := []byte("first data\n")
		n, err := writer.Write(firstData)
		require.NoError(t, err)
		require.Equal(t, len(firstData), n)
		require.NoError(t, writer.Sync())

		// Rotate (opens its own FD internally)
		rotator := &CopyTruncateRotator{}
		backupName, err := rotator.Rotate(dir, filename)
		require.NoError(t, err)
		require.NotEmpty(t, backupName)

		// Second write (writer's offset is still at len(firstData) = 11)
		// Without O_APPEND, write starts at current offset, creating sparse hole
		secondData := []byte("second data\n")
		n, err = writer.Write(secondData)
		require.NoError(t, err)
		require.Equal(t, len(secondData), n)
		require.NoError(t, writer.Sync())

		// Verify backup file contains the first write data
		backupPath := filepath.Join(dir, backupName)
		backupContent, err := os.ReadFile(backupPath)
		require.NoError(t, err)
		require.Equal(t, firstData, backupContent)

		// Verify original file: sparse file with null bytes at the beginning
		// File structure: [11 null bytes] + "second data\n"
		originalContent, err := os.ReadFile(fpath)
		require.NoError(t, err)

		expectedSize := len(firstData) + len(secondData)
		require.Len(t, originalContent, expectedSize)

		// First part should be null bytes (sparse hole)
		require.Equal(t, make([]byte, len(firstData)), originalContent[:len(firstData)])

		// Second part should be the second write data
		require.Equal(t, secondData, originalContent[len(firstData):])
	})
}

func TestNewRotator(t *testing.T) {
	tests := []struct {
		name     string
		strategy RotationStrategy
		wantType string
	}{
		{
			name:     "rename strategy",
			strategy: RotationStrategyRename,
			wantType: "*server.RenameRotator",
		},
		{
			name:     "copytruncate strategy",
			strategy: RotationStrategyCopyTruncate,
			wantType: "*server.CopyTruncateRotator",
		},
		{
			name:     "unknown defaults to rename",
			strategy: RotationStrategy("unknown"),
			wantType: "*server.RenameRotator",
		},
		{
			name:     "empty defaults to rename",
			strategy: RotationStrategy(""),
			wantType: "*server.RenameRotator",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rotator := NewRotator(tt.strategy)
			switch tt.wantType {
			case "*server.RenameRotator":
				_, ok := rotator.(*RenameRotator)
				require.True(t, ok, "expected RenameRotator")
			case "*server.CopyTruncateRotator":
				_, ok := rotator.(*CopyTruncateRotator)
				require.True(t, ok, "expected CopyTruncateRotator")
			}
		})
	}
}

func TestIsRealError(t *testing.T) {
	tests := []struct {
		name string
		err  error
		want bool
	}{
		{
			name: "nil error",
			err:  nil,
			want: false,
		},
		{
			name: "errno 0 (success)",
			err:  syscall.Errno(0),
			want: false,
		},
		{
			name: "errno 1 (EPERM)",
			err:  syscall.Errno(1),
			want: true,
		},
		{
			name: "errno 2 (ENOENT)",
			err:  syscall.Errno(2),
			want: true,
		},
		{
			name: "regular error",
			err:  errors.New("some error"),
			want: true,
		},
		{
			name: "wrapped errno 0",
			err:  os.NewSyscallError("sync", syscall.Errno(0)),
			want: false,
		},
		{
			name: "wrapped errno 1",
			err:  os.NewSyscallError("sync", syscall.Errno(1)),
			want: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := isRealError(tt.err)
			require.Equal(t, tt.want, got)
		})
	}
}
