package tsoptions_test

import (
	"path/filepath"
	"slices"
	"strings"
	"testing"

	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/diagnostics"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/diagnosticwriter"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/repo"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/baseline"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/filefixture"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tsoptions"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tsoptions/tsoptionstest"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs/osvfs"
	"github.com/go-json-experiment/json"
	"github.com/google/go-cmp/cmp/cmpopts"
	"gotest.tools/v3/assert"
)

func TestCommandLineParseResult(t *testing.T) {
	t.Parallel()
	repo.SkipIfNoTypeScriptSubmodule(t)

	parseCommandLineSubScenarios := []*subScenarioInput{
		// --lib es6 0.ts
		{"Parse single option of library flag", []string{"--lib", "es6", "0.ts"}},
		{"Handles may only be used with --build flags", []string{"--build", "--clean", "--dry", "--force", "--verbose"}},
		// --declarations --allowTS
		{"Handles did you mean for misspelt flags", []string{"--declarations", "--allowTS"}},
		// --lib es5,es2015.symbol.wellknown 0.ts
		{"Parse multiple options of library flags", []string{"--lib", "es5,es2015.symbol.wellknown", "0.ts"}},
		// --lib es5,invalidOption 0.ts
		{"Parse invalid option of library flags", []string{"--lib", "es5,invalidOption", "0.ts"}},
		// 0.ts --jsx
		{"Parse empty options of --jsx", []string{"0.ts", "--jsx"}},
		// 0.ts --
		{"Parse empty options of --module", []string{"0.ts", "--module"}},
		// 0.ts --newLine
		{"Parse empty options of --newLine", []string{"0.ts", "--newLine"}},
		// 0.ts --target
		{"Parse empty options of --target", []string{"0.ts", "--target"}},
		// 0.ts --moduleResolution
		{"Parse empty options of --moduleResolution", []string{"0.ts", "--moduleResolution"}},
		// 0.ts --lib
		{"Parse empty options of --lib", []string{"0.ts", "--lib"}},
		// 0.ts --lib
		// This test is an error because the empty string is falsey
		{"Parse empty string of --lib", []string{"0.ts", "--lib", ""}},
		// 0.ts --lib
		{"Parse immediately following command line argument of --lib", []string{"0.ts", "--lib", "--sourcemap"}},
		// --lib es5, es7 0.ts
		{"Parse --lib option with extra comma", []string{"--lib", "es5,", "es7", "0.ts"}},
		// --lib es5, es7 0.ts
		{"Parse --lib option with trailing white-space", []string{"--lib", "es5, ", "es7", "0.ts"}},
		// --lib es5,es2015.symbol.wellknown --target es5 0.ts
		{"Parse multiple compiler flags with input files at the end", []string{"--lib", "es5,es2015.symbol.wellknown", "--target", "es5", "0.ts"}},
		// --module commonjs --target es5 0.ts --lib es5,es2015.symbol.wellknown
		{"Parse multiple compiler flags with input files in the middle", []string{"--module", "commonjs", "--target", "es5", "0.ts", "--lib", "es5,es2015.symbol.wellknown"}},
		// --module commonjs --target es5 --lib es5 0.ts --library es2015.array,es2015.symbol.wellknown
		{"Parse multiple library compiler flags ", []string{"--module", "commonjs", "--target", "es5", "--lib", "es5", "0.ts", "--lib", "es2015.core, es2015.symbol.wellknown "}},
		{"Parse explicit boolean flag value", []string{"--strictNullChecks", "false", "0.ts"}},
		{"Parse non boolean argument after boolean flag", []string{"--noImplicitAny", "t", "0.ts"}},
		{"Parse implicit boolean flag value", []string{"--strictNullChecks"}},
		{"parse --incremental", []string{"--incremental", "0.ts"}},
		{"parse --tsBuildInfoFile", []string{"--tsBuildInfoFile", "build.tsbuildinfo", "0.ts"}},
		{"allows tsconfig only option to be set to null", []string{"--composite", "null", "-tsBuildInfoFile", "null", "0.ts"}},

		// ****** Watch Options ******
		{"parse --watchFile", []string{"--watchFile", "UseFsEvents", "0.ts"}},
		{"parse --watchDirectory", []string{"--watchDirectory", "FixedPollingInterval", "0.ts"}},
		{"parse --fallbackPolling", []string{"--fallbackPolling", "PriorityInterval", "0.ts"}},
		{"parse --synchronousWatchDirectory", []string{"--synchronousWatchDirectory", "0.ts"}},
		{"errors on missing argument to --fallbackPolling", []string{"0.ts", "--fallbackPolling"}},
		{"parse --excludeDirectories", []string{"--excludeDirectories", "**/temp", "0.ts"}},
		{"errors on invalid excludeDirectories", []string{"--excludeDirectories", "**/../*", "0.ts"}},
		{"parse --excludeFiles", []string{"--excludeFiles", "**/temp/*.ts", "0.ts"}},
		{"errors on invalid excludeFiles", []string{"--excludeFiles", "**/../*", "0.ts"}},
	}

	for _, testCase := range parseCommandLineSubScenarios {
		testCase.createSubScenario("parseCommandLine").assertParseResult(t)
	}
}

func TestParseCommandLineVerifyNull(t *testing.T) {
	t.Parallel()
	repo.SkipIfNoTypeScriptSubmodule(t)

	// run test for boolean
	subScenarioInput{"allows setting option type boolean to false", []string{"--composite", "false", "0.ts"}}.createSubScenario("parseCommandLine").assertParseResult(t)

	verifyNullSubScenarios := []verifyNull{
		{
			subScenario:  "option of type boolean",
			optionName:   "composite",
			nonNullValue: "true",
		},
		{
			subScenario: "option of type object",
			optionName:  "paths",
		},
		{
			subScenario:  "option of type list",
			optionName:   "rootDirs",
			nonNullValue: "abc,xyz",
		},
		createVerifyNullForNonNullIncluded("option of type string", tsoptions.CommandLineOptionTypeString, "hello"),
		createVerifyNullForNonNullIncluded("option of type number", tsoptions.CommandLineOptionTypeNumber, "10"),
		// todo: make the following work for tests -- currently it is difficult to do extra options of enum type
		// createVerifyNullForNonNullIncluded("option of type custom map", CommandLineOptionTypeEnum, "node"),
	}

	for _, verifyNullCase := range verifyNullSubScenarios {
		createSubScenario(
			"parseCommandLine",
			verifyNullCase.subScenario+" allows setting it to null",
			[]string{"--" + verifyNullCase.optionName, "null", "0.ts"},
			verifyNullCase.optDecls,
		).assertParseResult(t)

		if verifyNullCase.nonNullValue != "" {
			createSubScenario(
				"parseCommandLine",
				verifyNullCase.subScenario+" errors if non null value is passed",
				[]string{"--" + verifyNullCase.optionName, verifyNullCase.nonNullValue, "0.ts"},
				verifyNullCase.optDecls,
			).assertParseResult(t)
		}

		createSubScenario(
			"parseCommandLine",
			verifyNullCase.subScenario+" errors if its followed by another option",
			[]string{"0.ts", "--strictNullChecks", "--" + verifyNullCase.optionName},
			verifyNullCase.optDecls,
		).assertParseResult(t)

		createSubScenario(
			"parseCommandLine",
			verifyNullCase.subScenario+" errors if its last option",
			[]string{"0.ts", "--" + verifyNullCase.optionName},
			verifyNullCase.optDecls,
		).assertParseResult(t)
	}
}

func createVerifyNullForNonNullIncluded(subScenario string, kind tsoptions.CommandLineOptionKind, nonNullValue string) verifyNull {
	return verifyNull{
		subScenario:  subScenario,
		optionName:   "optionName",
		nonNullValue: nonNullValue,
		optDecls: slices.Concat(tsoptions.OptionsDeclarations, []*tsoptions.CommandLineOption{{
			Name:                    "optionName",
			Kind:                    kind,
			IsTSConfigOnly:          true,
			Category:                diagnostics.Backwards_Compatibility,
			Description:             diagnostics.Enable_project_compilation,
			DefaultValueDescription: nil,
		}}),
	}
}

func (f commandLineSubScenario) assertParseResult(t *testing.T) {
	t.Helper()
	t.Run(f.testName, func(t *testing.T) {
		t.Parallel()
		originalBaseline := f.baseline.ReadFile(t)
		tsBaseline := parseExistingCompilerBaseline(t, originalBaseline)

		// f.workerDiagnostic is either defined or set to default pointer in `createSubScenario`
		parsed := tsoptions.ParseCommandLineTestWorker(f.optDecls, f.commandLine, osvfs.FS())

		newBaselineFileNames := strings.Join(parsed.FileNames, ",")
		assert.Equal(t, tsBaseline.fileNames, newBaselineFileNames)

		o, _ := json.Marshal(parsed.Options)
		newParsedCompilerOptions := &core.CompilerOptions{}
		e := json.Unmarshal(o, newParsedCompilerOptions)
		assert.NilError(t, e)
		assert.DeepEqual(t, tsBaseline.options, newParsedCompilerOptions, cmpopts.IgnoreUnexported(core.CompilerOptions{}))

		newParsedWatchOptions := core.WatchOptions{}
		e = json.Unmarshal(o, &newParsedWatchOptions)
		assert.NilError(t, e)

		// !!! useful for debugging but will not pass due to `none` as enum options
		// assert.DeepEqual(t, tsBaseline.watchoptions, newParsedWatchOptions)

		var formattedErrors strings.Builder
		diagnosticwriter.WriteFormatDiagnostics(&formattedErrors, parsed.Errors, &diagnosticwriter.FormattingOptions{NewLine: "\n"})
		newBaselineErrors := formattedErrors.String()

		// !!!
		// useful for debugging--compares the new errors with the old errors. currently will NOT pass because of unimplemented options, not completely identical enum options, etc
		// assert.Equal(t, tsBaseline.errors, newBaselineErrors)

		baseline.Run(t, f.testName+".js", formatNewBaseline(f.commandLine, o, newBaselineFileNames, newBaselineErrors), baseline.Options{Subfolder: "tsoptions/commandLineParsing"})
	})
}

func parseExistingCompilerBaseline(t *testing.T, baseline string) *TestCommandLineParser {
	_, rest, _ := strings.Cut(baseline, "CompilerOptions::\n")
	compilerOptions, rest, watchFound := strings.Cut(rest, "\nWatchOptions::\n")
	watchOptions, rest, _ := strings.Cut(rest, "\nFileNames::\n")
	fileNames, errors, _ := strings.Cut(rest, "\nErrors::\n")

	baselineCompilerOptions := &core.CompilerOptions{}
	e := json.Unmarshal([]byte(compilerOptions), &baselineCompilerOptions)
	assert.NilError(t, e)

	baselineWatchOptions := &core.WatchOptions{}
	if watchFound && watchOptions != "" {
		e2 := json.Unmarshal([]byte(watchOptions), &baselineWatchOptions)
		assert.NilError(t, e2)
	}

	return &TestCommandLineParser{
		options:      baselineCompilerOptions,
		watchoptions: baselineWatchOptions,
		fileNames:    fileNames,
		errors:       errors,
	}
}

func formatNewBaseline(
	commandLine []string,
	opts []byte,
	fileNames string,
	errors string,
) string {
	var formatted strings.Builder
	formatted.WriteString("Args::\n")
	formatted.WriteString("[\"" + strings.Join(commandLine, "\", \"") + "\"]")
	formatted.WriteString("\n\nCompilerOptions::\n")
	formatted.Write(opts)
	// todo: watch options not implemented
	// formatted.WriteString("WatchOptions::\n")
	formatted.WriteString("\n\nFileNames::\n")
	formatted.WriteString(fileNames)
	formatted.WriteString("\n\nErrors::\n")
	formatted.WriteString(errors)
	return formatted.String()
}

func (f commandLineSubScenario) assertBuildParseResult(t *testing.T) {
	t.Helper()
	t.Run(f.testName, func(t *testing.T) {
		t.Parallel()
		originalBaseline := f.baseline.ReadFile(t)
		tsBaseline := parseExistingCompilerBaselineBuild(t, originalBaseline)

		// f.workerDiagnostic is either defined or set to default pointer in `createSubScenario`
		parsed := tsoptions.ParseBuildCommandLine(f.commandLine, &tsoptionstest.VfsParseConfigHost{
			Vfs:              osvfs.FS(),
			CurrentDirectory: tspath.NormalizeSlashes(repo.TypeScriptSubmodulePath),
		})

		newBaselineProjects := strings.Join(parsed.Projects, ",")
		assert.Equal(t, tsBaseline.projects, newBaselineProjects)

		o, _ := json.Marshal(parsed.BuildOptions)
		newParsedBuildOptions := &core.BuildOptions{}
		e := json.Unmarshal(o, newParsedBuildOptions)
		assert.NilError(t, e)
		assert.DeepEqual(t, tsBaseline.options, newParsedBuildOptions, cmpopts.IgnoreUnexported(core.BuildOptions{}))

		compilerOpts, _ := json.Marshal(parsed.CompilerOptions)
		newParsedCompilerOptions := &core.CompilerOptions{}
		e = json.Unmarshal(compilerOpts, newParsedCompilerOptions)
		assert.NilError(t, e)
		assert.DeepEqual(t, tsBaseline.compilerOptions, newParsedCompilerOptions, cmpopts.IgnoreUnexported(core.CompilerOptions{}))

		newParsedWatchOptions := core.WatchOptions{}
		e = json.Unmarshal(o, &newParsedWatchOptions)
		assert.NilError(t, e)

		// !!! useful for debugging but will not pass due to `none` as enum options
		// assert.DeepEqual(t, tsBaseline.watchoptions, newParsedWatchOptions)

		var formattedErrors strings.Builder
		diagnosticwriter.WriteFormatDiagnostics(&formattedErrors, parsed.Errors, &diagnosticwriter.FormattingOptions{NewLine: "\n"})
		newBaselineErrors := formattedErrors.String()

		// !!!
		// useful for debugging--compares the new errors with the old errors. currently will NOT pass because of unimplemented options, not completely identical enum options, etc
		// assert.Equal(t, tsBaseline.errors, newBaselineErrors)

		baseline.Run(t, f.testName+".js", formatNewBaselineBuild(f.commandLine, o, compilerOpts, newBaselineProjects, newBaselineErrors), baseline.Options{Subfolder: "tsoptions/commandLineParsing"})
	})
}

func parseExistingCompilerBaselineBuild(t *testing.T, baseline string) *TestCommandLineParserBuild {
	_, rest, _ := strings.Cut(baseline, "buildOptions::\n")
	buildOptions, rest, watchFound := strings.Cut(rest, "\nWatchOptions::\n")
	watchOptions, rest, _ := strings.Cut(rest, "\nProjects::\n")
	projects, errors, _ := strings.Cut(rest, "\nErrors::\n")

	baselineBuildOptions := &core.BuildOptions{}
	e := json.Unmarshal([]byte(buildOptions), &baselineBuildOptions)
	assert.NilError(t, e)

	baselineCompilerOptions := &core.CompilerOptions{}
	e = json.Unmarshal([]byte(buildOptions), &baselineCompilerOptions)
	assert.NilError(t, e)

	baselineWatchOptions := &core.WatchOptions{}
	if watchFound && watchOptions != "" {
		e2 := json.Unmarshal([]byte(watchOptions), &baselineWatchOptions)
		assert.NilError(t, e2)
	}

	return &TestCommandLineParserBuild{
		options:         baselineBuildOptions,
		compilerOptions: baselineCompilerOptions,
		watchoptions:    baselineWatchOptions,
		projects:        projects,
		errors:          errors,
	}
}

func formatNewBaselineBuild(
	commandLine []string,
	opts []byte,
	compilerOpts []byte,
	projects string,
	errors string,
) string {
	var formatted strings.Builder
	formatted.WriteString("Args::\n")
	if len(commandLine) == 0 {
		formatted.WriteString("[]")
	} else {
		formatted.WriteString("[\"" + strings.Join(commandLine, "\", \"") + "\"]")
	}
	formatted.WriteString("\n\nbuildOptions::\n")
	formatted.Write(opts)
	formatted.WriteString("\n\ncompilerOptions::\n")
	formatted.Write(compilerOpts)
	// todo: watch options not implemented
	// formatted.WriteString("WatchOptions::\n")
	formatted.WriteString("\n\nProjects::\n")
	formatted.WriteString(projects)
	formatted.WriteString("\n\nErrors::\n")
	formatted.WriteString(errors)
	return formatted.String()
}

func createSubScenario(scenarioKind string, subScenarioName string, commandline []string, opts ...[]*tsoptions.CommandLineOption) *commandLineSubScenario {
	subScenarioName = scenarioKind + "/" + subScenarioName
	baselineFileName := "tests/baselines/reference/config/commandLineParsing/" + subScenarioName + ".js"

	result := &commandLineSubScenario{
		filefixture.FromFile(subScenarioName, filepath.Join(repo.TypeScriptSubmodulePath, baselineFileName)),
		subScenarioName,
		commandline,
		nil,
	}
	if len(opts) > 0 {
		result.optDecls = opts[0]
	}
	return result
}

type subScenarioInput struct {
	name            string
	commandLineArgs []string
}

func (f subScenarioInput) createSubScenario(scenarioKind string) *commandLineSubScenario {
	return createSubScenario(scenarioKind, f.name, f.commandLineArgs)
}

type commandLineSubScenario struct {
	baseline    filefixture.Fixture
	testName    string
	commandLine []string
	optDecls    []*tsoptions.CommandLineOption
}

type verifyNull struct {
	subScenario  string
	optionName   string
	nonNullValue string
	optDecls     []*tsoptions.CommandLineOption
}

type TestCommandLineParser struct {
	options           *core.CompilerOptions
	watchoptions      *core.WatchOptions
	fileNames, errors string
}

type TestCommandLineParserBuild struct {
	options          *core.BuildOptions
	compilerOptions  *core.CompilerOptions
	watchoptions     *core.WatchOptions
	projects, errors string
}

func TestParseBuildCommandLine(t *testing.T) {
	t.Parallel()
	repo.SkipIfNoTypeScriptSubmodule(t)

	parseCommandLineSubScenarios := []*subScenarioInput{
		{"parse build without any options ", []string{}},
		{"Parse multiple options", []string{"--verbose", "--force", "tests"}},
		{"Parse option with invalid option", []string{"--verbose", "--invalidOption"}},
		{"Parse multiple flags with input projects at the end", []string{"--force", "--verbose", "src", "tests"}},
		{"Parse multiple flags with input projects in the middle", []string{"--force", "src", "tests", "--verbose"}},
		{"Parse multiple flags with input projects in the beginning", []string{"src", "tests", "--force", "--verbose"}},
		{"parse build with --incremental", []string{"--incremental", "tests"}},
		{"parse build with --locale en-us", []string{"--locale", "en-us", "src"}},
		{"parse build with --tsBuildInfoFile", []string{"--tsBuildInfoFile", "build.tsbuildinfo", "tests"}},
		{"reports other common may not be used with --build flags", []string{"--strict"}},
		{`--clean and --force together is invalid`, []string{"--clean", "--force"}},
		{`--clean and --verbose together is invalid`, []string{"--clean", "--verbose"}},
		{`--clean and --watch together is invalid`, []string{"--clean", "--watch"}},
		{`--watch and --dry together is invalid`, []string{"--watch", "--dry"}},
		{"parse --watchFile", []string{"--watchFile", "UseFsEvents", "--verbose"}},
		{"parse --watchDirectory", []string{"--watchDirectory", "FixedPollingInterval", "--verbose"}},
		{"parse --fallbackPolling", []string{"--fallbackPolling", "PriorityInterval", "--verbose"}},
		{"parse --synchronousWatchDirectory", []string{"--synchronousWatchDirectory", "--verbose"}},
		{"errors on missing argument", []string{"--verbose", "--fallbackPolling"}},
		{"errors on invalid excludeDirectories", []string{"--excludeDirectories", "**/../*"}},
		{"parse --excludeFiles", []string{"--excludeFiles", "**/temp/*.ts"}},
		{"errors on invalid excludeFiles", []string{"--excludeFiles", "**/../*"}},
	}

	for _, testCase := range parseCommandLineSubScenarios {
		testCase.createSubScenario("parseBuildOptions").assertBuildParseResult(t)
	}
}

func TestAffectsBuildInfo(t *testing.T) {
	t.Parallel()
	t.Run("should have affectsBuildInfo true for every option with affectsSemanticDiagnostics", func(t *testing.T) {
		t.Parallel()
		for _, option := range tsoptions.OptionsDeclarations {
			if option.AffectsSemanticDiagnostics {
				// semantic diagnostics affect the build info, so ensure they're included
				assert.Assert(t, option.AffectsBuildInfo)
			}
		}
	})
}
