package uno

import (
	"context"
	"errors"
	"os"
	"reflect"
	"testing"
	"time"

	"github.com/alexliesenfeld/health"
	"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
	flag "github.com/spf13/pflag"
	"go.uber.org/zap"
)

func TestUNO_Descriptor(t *testing.T) {
	descriptor := UNO{}.Descriptor()

	actual := reflect.TypeOf(descriptor.New())
	expect := reflect.TypeOf(new(UNO))

	if actual != expect {
		t.Errorf("expected '%s' but got '%s'", expect, actual)
	}
}

func TestUNO_Provision(t *testing.T) {
	tests := []struct {
		name               string
		ctx                *gotenberg.Context
		expectProvisionErr bool
	}{
		{
			name: "nominal behavior",
			ctx: func() *gotenberg.Context {
				provider := struct {
					gotenberg.ModuleMock
					gotenberg.LoggerProviderMock
				}{}
				provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
					return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
						return provider
					}}
				}
				provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
					return zap.NewNop(), nil
				}

				return gotenberg.NewContext(
					gotenberg.ParsedFlags{
						FlagSet: new(UNO).Descriptor().FlagSet,
					},
					[]gotenberg.ModuleDescriptor{
						provider.Descriptor(),
					},
				)
			}(),
		},
		{
			name: "threshold from deprecated flag --unoconv-disable-listener",
			ctx: func() *gotenberg.Context {
				provider := struct {
					gotenberg.ModuleMock
					gotenberg.LoggerProviderMock
				}{}
				provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
					return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
						return provider
					}}
				}
				provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
					return zap.NewNop(), nil
				}

				return gotenberg.NewContext(
					gotenberg.ParsedFlags{
						FlagSet: func() *flag.FlagSet {
							fs := new(UNO).Descriptor().FlagSet
							err := fs.Parse([]string{"--unoconv-disable-listener=true"})

							if err != nil {
								t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
							}

							return fs
						}(),
					},
					[]gotenberg.ModuleDescriptor{
						provider.Descriptor(),
					},
				)
			}(),
		},
		{
			name: "no logger provider",
			ctx: func() *gotenberg.Context {
				return gotenberg.NewContext(
					gotenberg.ParsedFlags{
						FlagSet: new(UNO).Descriptor().FlagSet,
					},
					[]gotenberg.ModuleDescriptor{},
				)
			}(),
			expectProvisionErr: true,
		},
		{
			name: "no logger from logger provider",
			ctx: func() *gotenberg.Context {
				provider := struct {
					gotenberg.ModuleMock
					gotenberg.LoggerProviderMock
				}{}
				provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
					return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
						return provider
					}}
				}
				provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
					return nil, errors.New("foo")
				}

				return gotenberg.NewContext(
					gotenberg.ParsedFlags{
						FlagSet: new(UNO).Descriptor().FlagSet,
					},
					[]gotenberg.ModuleDescriptor{
						provider.Descriptor(),
					},
				)
			}(),
			expectProvisionErr: true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			mod := new(UNO)
			err := mod.Provision(tc.ctx)

			if tc.expectProvisionErr && err == nil {
				t.Errorf("expected mod.Provision() error, but got none")
			}

			if !tc.expectProvisionErr && err != nil {
				t.Errorf("expected no error from mod.Provision(), but got: %v", err)
			}
		})
	}
}

func TestUNO_Validate(t *testing.T) {
	tests := []struct {
		name               string
		unoconvBinPath     string
		libreOfficeBinPath string
		expectValidateErr  bool
	}{
		{
			name:               "nominal behavior",
			unoconvBinPath:     os.Getenv("UNOCONV_BIN_PATH"),
			libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
		},
		{
			name:               "unoconv bin path does not exist",
			unoconvBinPath:     "/foo",
			libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
			expectValidateErr:  true,
		},
		{
			name:               "LibreOffice bin path does not exist",
			unoconvBinPath:     os.Getenv("UNOCONV_BIN_PATH"),
			libreOfficeBinPath: "/foo",
			expectValidateErr:  true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			mod := UNO{
				unoconvBinPath:     tc.unoconvBinPath,
				libreOfficeBinPath: tc.libreOfficeBinPath,
			}

			err := mod.Validate()

			if tc.expectValidateErr && err == nil {
				t.Errorf("expected mod.Validate() error, but got none")
			}

			if !tc.expectValidateErr && err != nil {
				t.Errorf("expected no error from mod.Validate(), but got: %v", err)
			}
		})
	}
}

func TestUNO_Start(t *testing.T) {

}

func TestUNO_StartupMessage(t *testing.T) {
	tests := []struct {
		name          string
		mod           UNO
		expectMessage string
	}{
		{
			name: "long-running LibreOffice listener ready to start",
			mod: UNO{
				libreOfficeRestartThreshold: 10,
			},
			expectMessage: "long-running LibreOffice listener ready to start",
		},
		{
			name: "long-running LibreOffice listener disabled",
			mod: UNO{
				libreOfficeRestartThreshold: 0,
			},
			expectMessage: "long-running LibreOffice listener disabled",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			actual := tc.mod.StartupMessage()

			if tc.expectMessage != actual {
				t.Errorf("expected '%s' from mod.StartupMessage(), but got '%s'", tc.expectMessage, actual)
			}
		})
	}
}

func TestUNO_Stop(t *testing.T) {
	tests := []struct {
		name          string
		mod           UNO
		expectStopErr bool
	}{
		{
			name: "nominal behavior",
			mod: UNO{
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					stopMock: func(logger *zap.Logger) error {
						return nil
					},
				},
				logger: zap.NewNop(),
			},
		},
		{
			name: "no long-running LibreOffice listener",
			mod: UNO{
				libreOfficeRestartThreshold: 0,
				logger:                      zap.NewNop(),
			},
		},
		{
			name: "stop error",
			mod: UNO{
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					stopMock: func(logger *zap.Logger) error {
						return errors.New("foo")
					},
				},
				logger: zap.NewNop(),
			},
			expectStopErr: true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
			cancel()

			err := tc.mod.Stop(ctx)

			if tc.expectStopErr && err == nil {
				t.Errorf("expected mod.Stop() error, but got none")
			}

			if !tc.expectStopErr && err != nil {
				t.Errorf("expected no error from mod.Stop(), but got: %v", err)
			}
		})
	}
}

func TestUNO_Metrics(t *testing.T) {
	tests := []struct {
		name                                          string
		mod                                           UNO
		expectUnoconvActiveInstancesCount             float64
		expectLibreOfficeListenerActiveInstancesCount float64
		expectLibreOfficeListenerQueueLength          float64
	}{
		{
			name: "with healthy long-running LibreOffice listener",
			mod: UNO{
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					queueMock: func() int {
						return 0
					},
					healthyMock: func() bool {
						return true
					},
				},
			},
			expectLibreOfficeListenerActiveInstancesCount: 1,
		},
		{
			name: "with unhealthy long-running LibreOffice listener",
			mod: UNO{
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					queueMock: func() int {
						return 0
					},
					healthyMock: func() bool {
						return false
					},
				},
			},
		},
		{
			name: "with no long-running LibreOffice listener",
			mod: UNO{
				libreOfficeRestartThreshold: 0,
				listener: listenerMock{
					queueMock: func() int {
						return 0
					},
					healthyMock: func() bool {
						return false
					},
				},
			},
		},
		{
			name: "with a queue of 3",
			mod: UNO{
				libreOfficeRestartThreshold: 0,
				listener: listenerMock{
					queueMock: func() int {
						return 3
					},
					healthyMock: func() bool {
						return true
					},
				},
			},
			expectLibreOfficeListenerQueueLength: 3,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			metrics, err := tc.mod.Metrics()
			if err != nil {
				t.Fatalf("expected no error from mod.Metrics(), but got: %v", err)
			}

			for _, metric := range metrics {
				switch metric.Name {
				case "unoconv_active_instances_count":
					actual := metric.Read()
					if actual != tc.expectUnoconvActiveInstancesCount {
						t.Errorf("expected 'unoconv_active_instances_count' to be %.0f, but got %.0f", tc.expectUnoconvActiveInstancesCount, actual)
					}
				case "libreoffice_listener_active_instances_count":
					actual := metric.Read()
					if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
						t.Errorf("expected 'libreoffice_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
					}
				case "unoconv_listener_active_instances_count":
					actual := metric.Read()
					if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
						t.Errorf("expected 'unoconv_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
					}
				case "libreoffice_listener_queue_length":
					actual := metric.Read()
					if actual != tc.expectLibreOfficeListenerQueueLength {
						t.Errorf("expected 'libreoffice_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
					}
				case "unoconv_listener_queue_length":
					actual := metric.Read()
					if actual != tc.expectLibreOfficeListenerQueueLength {
						t.Errorf("expected 'unoconv_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
					}
				}
			}
		})
	}
}

func TestUNO_Checks(t *testing.T) {
	tests := []struct {
		name                     string
		mod                      UNO
		expectAvailabilityStatus health.AvailabilityStatus
	}{
		{
			name: "no long-running LibreOffice listener",
			mod: UNO{
				libreOfficeRestartThreshold: 0,
			},
		},
		{
			name: "with healthy long-running LibreOffice listener",
			mod: UNO{
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					healthyMock: func() bool {
						return true
					},
				},
			},
			expectAvailabilityStatus: health.StatusUp,
		},
		{
			name: "with unhealthy long-running LibreOffice listener",
			mod: UNO{
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					healthyMock: func() bool {
						return false
					},
				},
			},
			expectAvailabilityStatus: health.StatusDown,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			checks, err := tc.mod.Checks()
			if err != nil {
				t.Fatalf("expected no error from mod.Checks(), but got: %v", err)
			}

			if len(checks) == 0 {
				return
			}

			if len(checks) != 1 {
				t.Fatalf("expected 1 check from mod.Checks(), but got %d", len(checks))
			}

			checker := health.NewChecker(checks...)
			result := checker.Check(context.Background())

			if result.Status != tc.expectAvailabilityStatus {
				t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
			}
		})
	}
}

func TestUNO_PDF(t *testing.T) {
	tests := []struct {
		name         string
		mod          UNO
		ctx          context.Context
		logger       *zap.Logger
		inputPath    string
		options      Options
		expectPDFErr bool
		teardown     func(mod UNO) error
	}{
		{
			name: "nominal behavior with no long-running LibreOffice listener",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
		},
		{
			name: "nominal behavior with a long-running LibreOffice listener",
			mod: func() UNO {
				mod := UNO{
					unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
					libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
					libreOfficeStartTimeout:     time.Duration(10) * time.Second,
					libreOfficeRestartThreshold: 10,
					logger:                      zap.NewNop(),
				}
				mod.listener = newLibreOfficeListener(
					mod.logger,
					mod.libreOfficeBinPath,
					mod.libreOfficeStartTimeout,
					mod.libreOfficeRestartThreshold,
				)

				return mod
			}(),
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			teardown: func(mod UNO) error {
				ctx, cancel := context.WithCancel(context.Background())
				cancel()

				return mod.Stop(ctx)
			},
		},
		{
			name: "convert with a debug logger",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewExample(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
		},
		{
			name: "convert with landscape",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			options: Options{
				Landscape: true,
			},
		},
		{
			name: "convert with page ranges",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			options: Options{
				PageRanges: "1-2",
			},
		},
		{
			name: "convert with invalid page ranges",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			options: Options{
				PageRanges: "foo",
			},
			expectPDFErr: true,
		},
		{
			name: "convert to PDF/A-1a",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			options: Options{
				PDFformat: gotenberg.FormatPDFA1a,
			},
		},
		{
			name: "convert to PDF/A-2b",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			options: Options{
				PDFformat: gotenberg.FormatPDFA2b,
			},
		},
		{
			name: "convert to PDF/A-3b",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			options: Options{
				PDFformat: gotenberg.FormatPDFA3b,
			},
		},
		{
			name: "convert to invalid PDF format",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:       context.Background(),
			logger:    zap.NewNop(),
			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
			options: Options{
				PDFformat: "foo",
			},
			expectPDFErr: true,
		},
		{
			name: "nil context",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx:          nil,
			logger:       zap.NewNop(),
			inputPath:    "/tests/test/testdata/libreoffice/sample1.docx",
			expectPDFErr: true,
		},
		{
			name: "expired context",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 0,
			},
			ctx: func() context.Context {
				ctx, cancel := context.WithCancel(context.Background())
				cancel()

				return ctx
			}(),
			logger:       zap.NewNop(),
			inputPath:    "/tests/test/testdata/libreoffice/sample1.docx",
			expectPDFErr: true,
		},
		{
			name: "cannot lock long-running LibreOffice listener",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					lockMock: func(ctx context.Context, logger *zap.Logger) error {
						return errors.New("foo")
					},
				},
				logger: zap.NewNop(),
			},
			ctx:          context.Background(),
			logger:       zap.NewNop(),
			inputPath:    "/tests/test/testdata/libreoffice/sample1.docx",
			expectPDFErr: true,
		},
		{
			name: "cannot unlock long-running LibreOffice listener",
			mod: UNO{
				unoconvBinPath:              os.Getenv("UNOCONV_BIN_PATH"),
				libreOfficeBinPath:          os.Getenv("LIBREOFFICE_BIN_PATH"),
				libreOfficeStartTimeout:     time.Duration(10) * time.Second,
				libreOfficeRestartThreshold: 10,
				listener: listenerMock{
					lockMock: func(ctx context.Context, logger *zap.Logger) error {
						return nil
					},
					unlockMock: func(logger *zap.Logger) error {
						return errors.New("foo")
					},
					portMock: func() int {
						return 2002
					},
				},
				logger: zap.NewNop(),
			},
			ctx:          context.Background(),
			logger:       zap.NewNop(),
			inputPath:    "/tests/test/testdata/libreoffice/sample1.docx",
			expectPDFErr: true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			defer func() {
				if tc.teardown == nil {
					return
				}

				err := tc.teardown(tc.mod)
				if err != nil {
					t.Errorf("expected no error from tc.teardown(), but got: %v", err)
				}
			}()

			outputDir, err := gotenberg.MkdirAll()
			if err != nil {
				t.Fatalf("expected no error from gotenberg.MkdirAll(), but got: %v", err)
			}

			defer func() {
				err := os.RemoveAll(outputDir)
				if err != nil {
					t.Errorf("expected no error from os.RemoveAll(), but got: %v", err)
				}
			}()

			err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options)

			if tc.expectPDFErr && err == nil {
				t.Fatalf("expected mod.PDF() error, but got none")
			}

			if !tc.expectPDFErr && err != nil {
				t.Fatalf("expected no error from mod.PDF(), but got: %v", err)
			}
		})
	}
}

func TestUNO_Extensions(t *testing.T) {
	mod := new(UNO)
	extensions := mod.Extensions()

	actual := len(extensions)
	expect := 79

	if actual != expect {
		t.Errorf("expected %d extensions, but got %d", expect, actual)
	}
}

func TestUNO_UNO(t *testing.T) {
	mod := new(UNO)

	_, err := mod.UNO()
	if err != nil {
		t.Errorf("expected no error from mod.UNO(), but got: %v", err)
	}
}

type listenerMock struct {
	startMock   func(logger *zap.Logger) error
	stopMock    func(logger *zap.Logger) error
	restartMock func(logger *zap.Logger) error
	lockMock    func(ctx context.Context, logger *zap.Logger) error
	unlockMock  func(logger *zap.Logger) error
	portMock    func() int
	queueMock   func() int
	healthyMock func() bool
}

func (listener listenerMock) start(logger *zap.Logger) error {
	return listener.startMock(logger)
}

func (listener listenerMock) stop(logger *zap.Logger) error {
	return listener.stopMock(logger)
}

func (listener listenerMock) restart(logger *zap.Logger) error {
	return listener.restartMock(logger)
}

func (listener listenerMock) lock(ctx context.Context, logger *zap.Logger) error {
	return listener.lockMock(ctx, logger)
}

func (listener listenerMock) unlock(logger *zap.Logger) error {
	return listener.unlockMock(logger)
}

func (listener listenerMock) port() int {
	return listener.portMock()
}

func (listener listenerMock) queue() int {
	return listener.queueMock()
}

func (listener listenerMock) healthy() bool {
	return listener.healthyMock()
}

// Interface guards.
var (
	_ listener = (*listenerMock)(nil)
)
