package components

import (
	"os"
	"strconv"
	"strings"

	"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
	"github.com/jesseduffield/lazygit/pkg/config"
	integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
	"github.com/jesseduffield/lazygit/pkg/utils"
	"github.com/samber/lo"
)

// IntegrationTest describes an integration test that will be run against the lazygit gui.

// our unit tests will use this description to avoid a panic caused by attempting
// to get the test's name via it's file's path.
const unitTestDescription = "test test"

const (
	defaultWidth  = 150
	defaultHeight = 100
)

type IntegrationTest struct {
	name         string
	description  string
	extraCmdArgs []string
	extraEnvVars map[string]string
	skip         bool
	setupRepo    func(shell *Shell)
	setupConfig  func(config *config.AppConfig)
	run          func(
		testDriver *TestDriver,
		keys config.KeybindingConfig,
	)
	gitVersion GitVersionRestriction
	width      int
	height     int
	isDemo     bool
}

var _ integrationTypes.IntegrationTest = &IntegrationTest{}

type NewIntegrationTestArgs struct {
	// Briefly describes what happens in the test and what it's testing for
	Description string
	// prepares a repo for testing
	SetupRepo func(shell *Shell)
	// takes a config and mutates. The mutated context will end up being passed to the gui
	SetupConfig func(config *config.AppConfig)
	// runs the test
	Run func(t *TestDriver, keys config.KeybindingConfig)
	// additional args passed to lazygit
	ExtraCmdArgs []string
	ExtraEnvVars map[string]string
	// for when a test is flakey
	Skip bool
	// to run a test only on certain git versions
	GitVersion GitVersionRestriction
	// width and height when running in headless mode, for testing
	// the UI in different sizes.
	// If these are set, the test must be run in headless mode
	Width  int
	Height int
	// If true, this is not a test but a demo to be added to our docs
	IsDemo bool
}

type GitVersionRestriction struct {
	// Only one of these fields can be non-empty; use functions below to construct
	from     string
	before   string
	includes []string
}

// Verifies the version is at least the given version (inclusive)
func AtLeast(version string) GitVersionRestriction {
	return GitVersionRestriction{from: version}
}

// Verifies the version is before the given version (exclusive)
func Before(version string) GitVersionRestriction {
	return GitVersionRestriction{before: version}
}

func Includes(versions ...string) GitVersionRestriction {
	return GitVersionRestriction{includes: versions}
}

func (self GitVersionRestriction) shouldRunOnVersion(version *git_commands.GitVersion) bool {
	if self.from != "" {
		from, err := git_commands.ParseGitVersion(self.from)
		if err != nil {
			panic("Invalid git version string: " + self.from)
		}
		return version.IsAtLeastVersion(from)
	}
	if self.before != "" {
		before, err := git_commands.ParseGitVersion(self.before)
		if err != nil {
			panic("Invalid git version string: " + self.before)
		}
		return version.IsOlderThanVersion(before)
	}
	if len(self.includes) != 0 {
		return lo.SomeBy(self.includes, func(str string) bool {
			v, err := git_commands.ParseGitVersion(str)
			if err != nil {
				panic("Invalid git version string: " + str)
			}
			return version.Major == v.Major && version.Minor == v.Minor && version.Patch == v.Patch
		})
	}
	return true
}

func NewIntegrationTest(args NewIntegrationTestArgs) *IntegrationTest {
	name := ""
	if args.Description != unitTestDescription {
		// this panics if we're in a unit test for our integration tests,
		// so we're using "test test" as a sentinel value
		name = testNameFromCurrentFilePath()
	}

	return &IntegrationTest{
		name:         name,
		description:  args.Description,
		extraCmdArgs: args.ExtraCmdArgs,
		extraEnvVars: args.ExtraEnvVars,
		skip:         args.Skip,
		setupRepo:    args.SetupRepo,
		setupConfig:  args.SetupConfig,
		run:          args.Run,
		gitVersion:   args.GitVersion,
		width:        args.Width,
		height:       args.Height,
		isDemo:       args.IsDemo,
	}
}

func (self *IntegrationTest) Name() string {
	return self.name
}

func (self *IntegrationTest) Description() string {
	return self.description
}

func (self *IntegrationTest) ExtraCmdArgs() []string {
	return self.extraCmdArgs
}

func (self *IntegrationTest) ExtraEnvVars() map[string]string {
	return self.extraEnvVars
}

func (self *IntegrationTest) Skip() bool {
	return self.skip
}

func (self *IntegrationTest) IsDemo() bool {
	return self.isDemo
}

func (self *IntegrationTest) ShouldRunForGitVersion(version *git_commands.GitVersion) bool {
	return self.gitVersion.shouldRunOnVersion(version)
}

func (self *IntegrationTest) SetupConfig(config *config.AppConfig) {
	self.setupConfig(config)
}

func (self *IntegrationTest) SetupRepo(shell *Shell) {
	self.setupRepo(shell)
}

func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) {
	pwd, err := os.Getwd()
	if err != nil {
		panic(err)
	}

	shell := NewShell(
		pwd,
		// passing the full environment because it's already been filtered down
		// in the parent process.
		os.Environ(),
		func(errorMsg string) { gui.Fail(errorMsg) },
	)
	keys := gui.Keys()
	testDriver := NewTestDriver(gui, shell, keys, InputDelay())

	if InputDelay() > 0 {
		// Setting caption to clear the options menu from whatever it starts with
		testDriver.SetCaption("")
		testDriver.SetCaptionPrefix("")
	}

	self.run(testDriver, keys)

	gui.CheckAllToastsAcknowledged()

	if InputDelay() > 0 {
		// Clear whatever caption there was so it doesn't linger
		testDriver.SetCaption("")
		testDriver.SetCaptionPrefix("")
		// the dev would want to see the final state if they're running in slow mode
		testDriver.Wait(2000)
	}
}

func (self *IntegrationTest) HeadlessDimensions() (int, int) {
	if self.width == 0 && self.height == 0 {
		return defaultWidth, defaultHeight
	}

	return self.width, self.height
}

func (self *IntegrationTest) RequiresHeadless() bool {
	return self.width != 0 && self.height != 0
}

func testNameFromCurrentFilePath() string {
	path := utils.FilePath(3)
	return TestNameFromFilePath(path)
}

func TestNameFromFilePath(path string) string {
	name := strings.Split(path, "integration/tests/")[1]

	return name[:len(name)-len(".go")]
}

// this is the delay in milliseconds between keypresses or mouse clicks
// defaults to zero
func InputDelay() int {
	delayStr := os.Getenv("INPUT_DELAY")
	if delayStr == "" {
		return 0
	}

	delay, err := strconv.Atoi(delayStr)
	if err != nil {
		panic(err)
	}
	return delay
}
