package module_test

import (
	"io"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"sync"
	"testing"

	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/jsonutil"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/module"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/repo"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/baseline"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs/vfstest"
	"github.com/go-json-experiment/json"
	"github.com/go-json-experiment/json/jsontext"
	"gotest.tools/v3/assert"
	"gotest.tools/v3/assert/cmp"
)

var skip = []string{
	"allowJsCrossMonorepoPackage.ts",
	"APILibCheck.ts",
	"APISample_compile.ts",
	"APISample_jsdoc.ts",
	"APISample_linter.ts",
	"APISample_parseConfig.ts",
	"APISample_transform.ts",
	"APISample_Watch.ts",
	"APISample_watcher.ts",
	"APISample_WatchWithDefaults.ts",
	"APISample_WatchWithOwnWatchHost.ts",
	"bundlerNodeModules1(module=esnext).ts",
	"bundlerNodeModules1(module=preserve).ts",
	"commonJsExportTypeDeclarationError.ts",
	"commonSourceDir5.ts",
	"commonSourceDirectory.ts",
	"computedEnumMemberSyntacticallyString2(isolatedmodules=false).ts",
	"computedEnumMemberSyntacticallyString2(isolatedmodules=true).ts",
	"declarationEmitCommonSourceDirectoryDoesNotContainAllFiles.ts",
	"declarationEmitForGlobalishSpecifierSymlink.ts",
	"declarationEmitForGlobalishSpecifierSymlink2.ts",
	"declarationEmitReexportedSymlinkReference.ts",
	"declarationEmitReexportedSymlinkReference2.ts",
	"declarationEmitReexportedSymlinkReference3.ts",
	"declarationEmitSymlinkPaths.ts",
	"decoratorMetadataTypeOnlyExport.ts",
	"decoratorMetadataTypeOnlyImport.ts",
	"enumNoInitializerFollowsNonLiteralInitializer.ts",
	"enumWithNonLiteralStringInitializer.ts",
	"es6ImportWithJsDocTags.ts",
	"importAttributes9.ts",
	"importSpecifiers_js.ts",
	"importTag17.ts",
	"isolatedModulesShadowGlobalTypeNotValue(isolatedmodules=false,verbatimmodulesyntax=false).ts",
	"isolatedModulesShadowGlobalTypeNotValue(isolatedmodules=false,verbatimmodulesyntax=true).ts",
	"isolatedModulesShadowGlobalTypeNotValue(isolatedmodules=true,verbatimmodulesyntax=false).ts",
	"isolatedModulesShadowGlobalTypeNotValue(isolatedmodules=true,verbatimmodulesyntax=true).ts",
	"jsDeclarationEmitExportedClassWithExtends.ts",
	"jsxNamespaceGlobalReexport.tsx",
	"jsxNamespaceGlobalReexportMissingAliasTarget.tsx",
	"mergeSymbolReexportedTypeAliasInstantiation.ts",
	"mergeSymbolReexportInterface.ts",
	"mergeSymbolRexportFunction.ts",
	"missingMemberErrorHasShortPath.ts",
	"moduleResolutionAsTypeReferenceDirective.ts",
	"moduleResolutionAsTypeReferenceDirectiveAmbient.ts",
	"moduleResolutionAsTypeReferenceDirectiveScoped.ts",
	"moduleResolutionWithSymlinks_notInNodeModules.ts",
	"moduleResolutionWithSymlinks_preserveSymlinks.ts",
	"moduleResolutionWithSymlinks_referenceTypes.ts",
	"moduleResolutionWithSymlinks_withOutDir.ts",
	"moduleResolutionWithSymlinks.ts",
	"nodeAllowJsPackageSelfName(module=node16).ts",
	"nodeAllowJsPackageSelfName(module=nodenext).ts",
	"nodeAllowJsPackageSelfName2.ts",
	"nodeModulesAllowJsConditionalPackageExports(module=node16).ts",
	"nodeModulesAllowJsConditionalPackageExports(module=nodenext).ts",
	"nodeModulesAllowJsPackageExports(module=node16).ts",
	"nodeModulesAllowJsPackageExports(module=nodenext).ts",
	"nodeModulesAllowJsPackageImports(module=node16).ts",
	"nodeModulesAllowJsPackageImports(module=nodenext).ts",
	"nodeModulesAllowJsPackagePatternExports(module=node16).ts",
	"nodeModulesAllowJsPackagePatternExports(module=nodenext).ts",
	"nodeModulesAllowJsPackagePatternExportsTrailers(module=node16).ts",
	"nodeModulesAllowJsPackagePatternExportsTrailers(module=nodenext).ts",
	"nodeModulesConditionalPackageExports(module=node16).ts",
	"nodeModulesConditionalPackageExports(module=nodenext).ts",
	"nodeModulesDeclarationEmitWithPackageExports(module=node16).ts",
	"nodeModulesDeclarationEmitWithPackageExports(module=nodenext).ts",
	"nodeModulesExportsBlocksTypesVersions(module=node16).ts",
	"nodeModulesExportsBlocksTypesVersions(module=nodenext).ts",
	"nodeModulesImportResolutionIntoExport(module=node16).ts",
	"nodeModulesImportResolutionIntoExport(module=nodenext).ts",
	"nodeModulesImportResolutionNoCycle(module=node16).ts",
	"nodeModulesImportResolutionNoCycle(module=nodenext).ts",
	"nodeModulesPackageExports(module=node16).ts",
	"nodeModulesPackageExports(module=nodenext).ts",
	"nodeModulesPackagePatternExports(module=node16).ts",
	"nodeModulesPackagePatternExports(module=nodenext).ts",
	"nodeModulesPackagePatternExportsExclude(module=node16).ts",
	"nodeModulesPackagePatternExportsExclude(module=nodenext).ts",
	"nodeModulesPackagePatternExportsTrailers(module=node16).ts",
	"nodeModulesPackagePatternExportsTrailers(module=nodenext).ts",
	"nodeNextImportModeImplicitIndexResolution.ts",
	"nodeNextImportModeImplicitIndexResolution2.ts",
	"nodeNextPackageImportMapRootDir.ts",
	"nodeNextPackageSelfNameWithOutDir.ts",
	"nodeNextPackageSelfNameWithOutDirDeclDir.ts",
	"nodeNextPackageSelfNameWithOutDirDeclDirComposite.ts",
	"nodeNextPackageSelfNameWithOutDirDeclDirCompositeNestedDirs.ts",
	"nodeNextPackageSelfNameWithOutDirDeclDirNestedDirs.ts",
	"nodeNextPackageSelfNameWithOutDirDeclDirRootDir.ts",
	"nodeNextPackageSelfNameWithOutDirRootDir.ts",
	"resolutionModeImportType1(moduleresolution=bundler).ts",
	"resolutionModeImportType1(moduleresolution=node10).ts",
	"resolutionModeTypeOnlyImport1(moduleresolution=bundler).ts",
	"resolutionModeTypeOnlyImport1(moduleresolution=node10).ts",
	"selfNameAndImportsEmitInclusion.ts",
	"selfNameModuleAugmentation.ts",
	"symbolLinkDeclarationEmitModuleNames.ts",
	"symbolLinkDeclarationEmitModuleNamesImportRef.ts",
	"symbolLinkDeclarationEmitModuleNamesRootDir.ts",
	"symlinkedWorkspaceDependenciesNoDirectLinkGeneratesDeepNonrelativeName.ts",
	"symlinkedWorkspaceDependenciesNoDirectLinkGeneratesNonrelativeName.ts",
	"symlinkedWorkspaceDependenciesNoDirectLinkOptionalGeneratesNonrelativeName.ts",
	"symlinkedWorkspaceDependenciesNoDirectLinkPeerGeneratesNonrelativeName.ts",
	"typeGuardNarrowsIndexedAccessOfKnownProperty8.ts",
	"typesVersions.ambientModules.ts",
	"typesVersions.multiFile.ts",
	"typesVersionsDeclarationEmit.ambient.ts",
	"typesVersionsDeclarationEmit.multiFile.ts",
	"typesVersionsDeclarationEmit.multiFileBackReferenceToSelf.ts",
	"typesVersionsDeclarationEmit.multiFileBackReferenceToUnmapped.ts",
}

type vfsModuleResolutionHost struct {
	mu               sync.Mutex
	fs               vfs.FS
	currentDirectory string
	traces           []string
}

func fixRoot(path string) string {
	rootLength := tspath.GetRootLength(path)
	if rootLength == 0 {
		return tspath.CombinePaths("/.src", path)
	}
	return path
}

func newVFSModuleResolutionHost(files map[string]string, currentDirectory string) *vfsModuleResolutionHost {
	fs := make(map[string]string, len(files))
	for name, content := range files {
		fs[fixRoot(name)] = content
	}
	if currentDirectory == "" {
		currentDirectory = "/.src"
	} else if currentDirectory[0] != '/' {
		currentDirectory = "/.src/" + currentDirectory
	}
	return &vfsModuleResolutionHost{
		fs:               vfstest.FromMap(fs, true /*useCaseSensitiveFileNames*/),
		currentDirectory: currentDirectory,
	}
}

func (v *vfsModuleResolutionHost) FS() vfs.FS {
	return v.fs
}

// GetCurrentDirectory implements ModuleResolutionHost.
func (v *vfsModuleResolutionHost) GetCurrentDirectory() string {
	return v.currentDirectory
}

// Trace implements ModuleResolutionHost.
func (v *vfsModuleResolutionHost) Trace(msg string) {
	v.mu.Lock()
	defer v.mu.Unlock()
	v.traces = append(v.traces, msg)
}

type functionCall struct {
	call        string
	args        rawArgs
	returnValue map[string]any
}
type traceTestCase struct {
	name             string
	currentDirectory string
	trace            bool
	compilerOptions  *core.CompilerOptions
	files            map[string]string
	calls            []functionCall
}
type rawFile struct {
	Name    string `json:"name"`
	Content string `json:"content"`
}
type rawArgs struct {
	// getPackageScopeForPath
	Directory string `json:"directory"`

	// resolveModuleName, resolveTypeReferenceDirective
	Name            string                `json:"name"`
	ContainingFile  string                `json:"containingFile"`
	CompilerOptions *core.CompilerOptions `json:"compilerOptions"`
	ResolutionMode  int                   `json:"resolutionMode"`
	RedirectedRef   *struct {
		SourceFile struct {
			FileName string `json:"fileName"`
		} `json:"sourceFile"`
		CommandLine struct {
			CompilerOptions *core.CompilerOptions `json:"options"`
		} `json:"commandLine"`
	} `json:"redirectedReference"`
}
type rawTest struct {
	Test             string         `json:"test"`
	CurrentDirectory string         `json:"currentDirectory"`
	Trace            bool           `json:"trace"`
	Files            []rawFile      `json:"files"`
	Call             string         `json:"call"`
	Args             rawArgs        `json:"args"`
	Return           map[string]any `json:"return"`
}

var typesVersionsMessageRegex = regexp.MustCompile(`that matches compiler version '[^']+'`)

func sanitizeTraceOutput(trace string) string {
	return typesVersionsMessageRegex.ReplaceAllString(trace, "that matches compiler version '3.1.0-dev'")
}

type RedirectRef struct {
	fileName string
	options  *core.CompilerOptions
}

func (r *RedirectRef) ConfigName() string {
	return r.fileName
}

func (r *RedirectRef) CompilerOptions() *core.CompilerOptions {
	return r.options
}

var _ module.ResolvedProjectReference = (*RedirectRef)(nil)

func doCall(t *testing.T, resolver *module.Resolver, call functionCall, skipLocations bool) {
	switch call.call {
	case "resolveModuleName", "resolveTypeReferenceDirective":
		var redirectedReference module.ResolvedProjectReference
		if call.args.RedirectedRef != nil {
			redirectedReference = &RedirectRef{
				fileName: call.args.RedirectedRef.SourceFile.FileName,
				options:  call.args.RedirectedRef.CommandLine.CompilerOptions,
			}
		}

		errorMessageArgs := []any{call.args.Name, call.args.ContainingFile}
		if call.call == "resolveModuleName" {
			resolved, _ := resolver.ResolveModuleName(call.args.Name, call.args.ContainingFile, core.ModuleKind(call.args.ResolutionMode), redirectedReference)
			assert.Check(t, resolved != nil, "ResolveModuleName should not return nil", errorMessageArgs)
			if expectedResolvedModule, ok := call.returnValue["resolvedModule"].(map[string]any); ok {
				assert.Check(t, resolved.IsResolved(), errorMessageArgs)
				assert.Check(t, cmp.Equal(resolved.ResolvedFileName, expectedResolvedModule["resolvedFileName"].(string)), errorMessageArgs)
				assert.Check(t, cmp.Equal(resolved.Extension, expectedResolvedModule["extension"].(string)), errorMessageArgs)
				assert.Check(t, cmp.Equal(resolved.ResolvedUsingTsExtension, expectedResolvedModule["resolvedUsingTsExtension"].(bool)), errorMessageArgs)
				assert.Check(t, cmp.Equal(resolved.IsExternalLibraryImport, expectedResolvedModule["isExternalLibraryImport"].(bool)), errorMessageArgs)
			} else {
				assert.Check(t, !resolved.IsResolved(), errorMessageArgs)
			}
		} else {
			resolved, _ := resolver.ResolveTypeReferenceDirective(call.args.Name, call.args.ContainingFile, core.ModuleKind(call.args.ResolutionMode), redirectedReference)
			assert.Check(t, resolved != nil, "ResolveTypeReferenceDirective should not return nil", errorMessageArgs)
			if expectedResolvedTypeReferenceDirective, ok := call.returnValue["resolvedTypeReferenceDirective"].(map[string]any); ok {
				assert.Check(t, resolved.IsResolved(), errorMessageArgs)
				assert.Check(t, cmp.Equal(resolved.ResolvedFileName, expectedResolvedTypeReferenceDirective["resolvedFileName"].(string)), errorMessageArgs)
				assert.Check(t, cmp.Equal(resolved.Primary, expectedResolvedTypeReferenceDirective["primary"].(bool)), errorMessageArgs)
				assert.Check(t, cmp.Equal(resolved.IsExternalLibraryImport, expectedResolvedTypeReferenceDirective["isExternalLibraryImport"].(bool)), errorMessageArgs)
			} else {
				assert.Check(t, !resolved.IsResolved(), errorMessageArgs)
			}
		}
	case "getPackageScopeForPath":
		resolver.GetPackageScopeForPath(call.args.Directory)
	default:
		t.Errorf("Unexpected call: %s", call.call)
	}
}

func runTraceBaseline(t *testing.T, test traceTestCase) {
	t.Run(test.name, func(t *testing.T) {
		t.Parallel()

		host := newVFSModuleResolutionHost(test.files, test.currentDirectory)
		resolver := module.NewResolver(host, test.compilerOptions, "", "")

		for _, call := range test.calls {
			doCall(t, resolver, call, false /*skipLocations*/)
			if t.Failed() {
				t.FailNow()
			}
		}

		t.Run("concurrent", func(t *testing.T) {
			concurrentHost := newVFSModuleResolutionHost(test.files, test.currentDirectory)
			concurrentResolver := module.NewResolver(concurrentHost, test.compilerOptions, "", "")

			var wg sync.WaitGroup
			for _, call := range test.calls {
				wg.Add(1)
				go func() {
					defer wg.Done()
					doCall(t, concurrentResolver, call, true /*skipLocations*/)
				}()
			}

			wg.Wait()
		})

		if test.trace {
			t.Run("trace", func(t *testing.T) {
				output, err := jsonutil.MarshalIndent(resolver, "", "    ")
				if err != nil {
					t.Fatal(err)
				}
				baseline.Run(
					t,
					tspath.RemoveFileExtension(test.name)+".trace.json",
					sanitizeTraceOutput(string(output)),
					baseline.Options{Subfolder: "module/resolver"},
				)
			})
		}
	})
}

func TestModuleResolver(t *testing.T) {
	t.Parallel()
	testsFilePath := filepath.Join(repo.TestDataPath, "fixtures", "module", "resolvertests.json")
	// Read file one line at a time
	file, err := os.Open(testsFilePath)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		file.Close()
	})
	decoder := jsontext.NewDecoder(file)
	var currentTestCase traceTestCase
	for {
		if decoder.PeekKind() == 0 {
			_, err := decoder.ReadToken()
			if err == io.EOF { //nolint:errorlint
				break
			}
			t.Fatal(err)
		}

		var testJSON rawTest
		if err := json.UnmarshalDecode(decoder, &testJSON); err != nil {
			t.Fatal(err)
		}
		if testJSON.Files != nil {
			if currentTestCase.name != "" && !slices.Contains(skip, currentTestCase.name) {
				runTraceBaseline(t, currentTestCase)
			}
			currentTestCase = traceTestCase{
				name:             testJSON.Test,
				currentDirectory: testJSON.CurrentDirectory,
				// !!! no traces are passing yet because of missing cache implementation
				trace: false,
				files: make(map[string]string, len(testJSON.Files)),
			}
			for _, file := range testJSON.Files {
				currentTestCase.files[file.Name] = file.Content
			}
		} else if testJSON.Call != "" {
			currentTestCase.calls = append(currentTestCase.calls, functionCall{
				call:        testJSON.Call,
				args:        testJSON.Args,
				returnValue: testJSON.Return,
			})
			if currentTestCase.compilerOptions == nil && testJSON.Args.CompilerOptions != nil {
				currentTestCase.compilerOptions = testJSON.Args.CompilerOptions
			}
		} else {
			t.Fatalf("Unexpected JSON: %v", testJSON)
		}
	}
}
