package fourslash

import (
	"fmt"
	"io"
	"iter"
	"maps"
	"slices"
	"strings"
	"testing"

	"github.com/go-json-experiment/json"
	"github.com/go-json-experiment/json/jsontext"
	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/compiler"
	"github.com/microsoft/typescript-go/internal/ls/lsconv"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/project"
	"github.com/microsoft/typescript-go/internal/testutil/fsbaselineutil"
	"github.com/microsoft/typescript-go/internal/tspath"
	"github.com/microsoft/typescript-go/internal/vfs/iovfs"
	"gotest.tools/v3/assert"
)

type stateBaseline struct {
	baseline      strings.Builder
	fsDiffer      *fsbaselineutil.FSDiffer
	isInitialized bool

	serializedProjects           map[string]projectInfo
	serializedOpenFiles          map[string]*openFileInfo
	serializedConfigFileRegistry *project.ConfigFileRegistry
}

func newStateBaseline(fsFromMap iovfs.FsWithSys) *stateBaseline {
	stateBaseline := &stateBaseline{
		fsDiffer: &fsbaselineutil.FSDiffer{
			FS:           fsFromMap,
			WrittenFiles: &collections.SyncSet[string]{},
		},
	}
	fmt.Fprintf(&stateBaseline.baseline, "UseCaseSensitiveFileNames: %v\n", fsFromMap.UseCaseSensitiveFileNames())
	stateBaseline.fsDiffer.BaselineFSwithDiff(&stateBaseline.baseline)
	return stateBaseline
}

type requestOrMessage struct {
	Method lsproto.Method `json:"method"`
	Params any            `json:"params,omitzero"`
}

func (f *FourslashTest) baselineRequestOrNotification(t *testing.T, method lsproto.Method, params any) {
	t.Helper()

	if !f.testData.isStateBaseliningEnabled() {
		return
	}

	res, _ := json.Marshal(requestOrMessage{method, params}, jsontext.WithIndent("  "))
	f.stateBaseline.baseline.WriteString("\n" + string(res) + "\n")
	f.stateBaseline.isInitialized = true
}

func (f *FourslashTest) baselineProjectsAfterNotification(t *testing.T, fileName string) {
	t.Helper()
	if !f.testData.isStateBaseliningEnabled() {
		return
	}
	// Do hover so we have snapshot to check things on!!
	_, _, resultOk := sendRequestWorker(t, f, lsproto.TextDocumentHoverInfo, &lsproto.HoverParams{
		TextDocument: lsproto.TextDocumentIdentifier{
			Uri: lsconv.FileNameToDocumentURI(fileName),
		},
		Position: lsproto.Position{
			Line:      uint32(0),
			Character: uint32(0),
		},
	})
	assert.Assert(t, resultOk)
	f.baselineState(t)
}

func (f *FourslashTest) baselineState(t *testing.T) {
	t.Helper()

	if !f.testData.isStateBaseliningEnabled() {
		return
	}

	serialized := f.serializedState(t)
	if serialized != "" {
		f.stateBaseline.baseline.WriteString("\n")
		f.stateBaseline.baseline.WriteString(serialized)
	}
}

func (f *FourslashTest) serializedState(t *testing.T) string {
	t.Helper()

	var builder strings.Builder
	f.stateBaseline.fsDiffer.BaselineFSwithDiff(&builder)
	if strings.TrimSpace(builder.String()) == "" {
		builder.Reset()
	}

	f.printStateDiff(t, &builder)
	return builder.String()
}

type projectInfo = *compiler.Program

type openFileInfo struct {
	defaultProjectName string
	allProjects        []string
}

type diffTableOptions struct {
	indent   string
	sortKeys bool
}

type diffTable struct {
	diff    collections.OrderedMap[string, string]
	options diffTableOptions
}

func (d *diffTable) add(key, value string) {
	d.diff.Set(key, value)
}

func (d *diffTable) print(w io.Writer, header string) {
	count := d.diff.Size()
	if count == 0 {
		return
	}
	if header != "" {
		fmt.Fprintf(w, "%s%s\n", d.options.indent, header)
	}
	diffKeys := make([]string, 0, count)
	keyWidth := 0
	indent := d.options.indent + "  "
	for key := range d.diff.Keys() {
		keyWidth = max(keyWidth, len(key))
		diffKeys = append(diffKeys, key)
	}
	if d.options.sortKeys {
		slices.Sort(diffKeys)
	}

	for _, key := range diffKeys {
		value := d.diff.GetOrZero(key)
		fmt.Fprintf(w, "%s%-*s %s\n", indent, keyWidth+1, key, value)
	}
}

type diffTableWriter struct {
	hasChange bool
	header    string
	diffs     map[string]func(io.Writer)
}

func newDiffTableWriter(header string) *diffTableWriter {
	return &diffTableWriter{header: header, diffs: make(map[string]func(io.Writer))}
}

func (d *diffTableWriter) setHasChange() {
	d.hasChange = true
}

func (d *diffTableWriter) add(key string, fn func(io.Writer)) {
	d.diffs[key] = fn
}

func (d *diffTableWriter) print(w io.Writer) {
	if d.hasChange {
		fmt.Fprintf(w, "%s::\n", d.header)
		keys := slices.Collect(maps.Keys(d.diffs))
		slices.Sort(keys)
		for _, key := range keys {
			d.diffs[key](w)
		}
	}
}

func areIterSeqEqual(a, b iter.Seq[tspath.Path]) bool {
	aSlice := slices.Collect(a)
	bSlice := slices.Collect(b)
	slices.Sort(aSlice)
	slices.Sort(bSlice)
	return slices.Equal(aSlice, bSlice)
}

func printSlicesWithDiffTable(w io.Writer, header string, newSlice []string, getOldSlice func() []string, options diffTableOptions, topChange string, isDefault func(entry string) bool) {
	var oldSlice []string
	if topChange == "*modified*" {
		oldSlice = getOldSlice()
	}
	table := diffTable{options: options}
	for _, entry := range newSlice {
		entryChange := ""
		if isDefault != nil && isDefault(entry) {
			entryChange = "(default) "
		}
		if topChange == "*modified*" && !slices.Contains(oldSlice, entry) {
			entryChange = "*new*"
		}
		table.add(entry, entryChange)
	}
	if topChange == "*modified*" {
		for _, entry := range oldSlice {
			if !slices.Contains(newSlice, entry) {
				table.add(entry, "*deleted*")
			}
		}
	}
	table.print(w, header)
}

func sliceFromIterSeqPath(seq iter.Seq[tspath.Path]) []string {
	var result []string
	for path := range seq {
		result = append(result, string(path))
	}
	slices.Sort(result)
	return result
}

func printPathIterSeqWithDiffTable(w io.Writer, header string, newIterSeq iter.Seq[tspath.Path], getOldIterSeq func() iter.Seq[tspath.Path], options diffTableOptions, topChange string) {
	printSlicesWithDiffTable(
		w,
		header,
		sliceFromIterSeqPath(newIterSeq),
		func() []string { return sliceFromIterSeqPath(getOldIterSeq()) },
		options,
		topChange,
		nil,
	)
}

func (f *FourslashTest) printStateDiff(t *testing.T, w io.Writer) {
	if !f.stateBaseline.isInitialized {
		return
	}
	session := f.server.Session()
	snapshot, release := session.Snapshot()
	defer release()

	f.printProjectsDiff(t, snapshot, w)
	f.printOpenFilesDiff(t, snapshot, w)
	f.printConfigFileRegistryDiff(t, snapshot, w)
}

func (f *FourslashTest) printProjectsDiff(t *testing.T, snapshot *project.Snapshot, w io.Writer) {
	t.Helper()

	currentProjects := make(map[string]projectInfo)
	options := diffTableOptions{indent: "  "}
	projectsDiffTable := newDiffTableWriter("Projects")

	for _, project := range snapshot.ProjectCollection.Projects() {
		program := project.GetProgram()
		var oldProgram *compiler.Program
		currentProjects[project.Name()] = program
		projectChange := ""
		if existing, ok := f.stateBaseline.serializedProjects[project.Name()]; ok {
			oldProgram = existing
			if oldProgram != program {
				projectChange = "*modified*"
				projectsDiffTable.setHasChange()
			} else {
				projectChange = ""
			}
		} else {
			projectChange = "*new*"
			projectsDiffTable.setHasChange()
		}

		projectsDiffTable.add(project.Name(), func(w io.Writer) {
			fmt.Fprintf(w, "  [%s] %s\n", project.Name(), projectChange)
			subDiff := diffTable{options: options}
			if program != nil {
				for _, file := range program.GetSourceFiles() {
					fileDiff := ""
					// No need to write "*new*" for files as its obvious
					fileName := file.FileName()
					if projectChange == "*modified*" {
						if oldProgram == nil {
							if !isLibFile(fileName) {
								fileDiff = "*new*"
							}
						} else if oldFile := oldProgram.GetSourceFileByPath(file.Path()); oldFile == nil {
							fileDiff = "*new*"
						} else if oldFile != file {
							fileDiff = "*modified*"
						}
					}
					if fileDiff != "" || !isLibFile(fileName) {
						subDiff.add(fileName, fileDiff)
					}
				}
			}
			if oldProgram != program && oldProgram != nil {
				for _, file := range oldProgram.GetSourceFiles() {
					if program == nil || program.GetSourceFileByPath(file.Path()) == nil {
						subDiff.add(file.FileName(), "*deleted*")
					}
				}
			}
			subDiff.print(w, "")
		})
	}

	for projectName, info := range f.stateBaseline.serializedProjects {
		if _, found := currentProjects[projectName]; !found {
			projectsDiffTable.setHasChange()
			projectsDiffTable.add(projectName, func(w io.Writer) {
				fmt.Fprintf(w, "  [%s] *deleted*\n", projectName)
				subDiff := diffTable{options: options}
				if info != nil {
					for _, file := range info.GetSourceFiles() {
						if fileName := file.FileName(); !isLibFile(fileName) {
							subDiff.add(fileName, "")
						}
					}
				}
				subDiff.print(w, "")
			})
		}
	}
	f.stateBaseline.serializedProjects = currentProjects
	projectsDiffTable.print(w)
}

func (f *FourslashTest) printOpenFilesDiff(t *testing.T, snapshot *project.Snapshot, w io.Writer) {
	t.Helper()

	currentOpenFiles := make(map[string]*openFileInfo)
	filesDiffTable := newDiffTableWriter("Open Files")
	options := diffTableOptions{indent: "  ", sortKeys: true}
	for fileName := range f.openFiles {
		path := tspath.ToPath(fileName, "/", f.vfs.UseCaseSensitiveFileNames())
		defaultProject := snapshot.ProjectCollection.GetDefaultProject(fileName, path)
		newFileInfo := &openFileInfo{}
		if defaultProject != nil {
			newFileInfo.defaultProjectName = defaultProject.Name()
		}
		for _, project := range snapshot.ProjectCollection.Projects() {
			if program := project.GetProgram(); program != nil && program.GetSourceFileByPath(path) != nil {
				newFileInfo.allProjects = append(newFileInfo.allProjects, project.Name())
			}
		}
		slices.Sort(newFileInfo.allProjects)
		currentOpenFiles[fileName] = newFileInfo
		openFileChange := ""
		var oldFileInfo *openFileInfo
		if existing, ok := f.stateBaseline.serializedOpenFiles[fileName]; ok {
			oldFileInfo = existing
			if existing.defaultProjectName != newFileInfo.defaultProjectName || !slices.Equal(existing.allProjects, newFileInfo.allProjects) {
				openFileChange = "*modified*"
				filesDiffTable.setHasChange()
			} else {
				openFileChange = ""
			}
		} else {
			openFileChange = "*new*"
			filesDiffTable.setHasChange()
		}

		filesDiffTable.add(fileName, func(w io.Writer) {
			fmt.Fprintf(w, "  [%s] %s\n", fileName, openFileChange)
			printSlicesWithDiffTable(
				w,
				"",
				newFileInfo.allProjects,
				func() []string { return oldFileInfo.allProjects },
				options,
				openFileChange,
				func(projectName string) bool { return projectName == newFileInfo.defaultProjectName },
			)
		})
	}
	for fileName := range f.stateBaseline.serializedOpenFiles {
		if _, found := currentOpenFiles[fileName]; !found {
			filesDiffTable.setHasChange()
			filesDiffTable.add(fileName, func(w io.Writer) {
				fmt.Fprintf(w, "  [%s] *closed*\n", fileName)
			})
		}
	}
	f.stateBaseline.serializedOpenFiles = currentOpenFiles
	filesDiffTable.print(w)
}

func (f *FourslashTest) printConfigFileRegistryDiff(t *testing.T, snapshot *project.Snapshot, w io.Writer) {
	t.Helper()
	configFileRegistry := snapshot.ProjectCollection.ConfigFileRegistry()

	configDiffsTable := newDiffTableWriter("Config")
	configFileNamesDiffsTable := newDiffTableWriter("Config File Names")

	if f.stateBaseline.serializedConfigFileRegistry == configFileRegistry {
		return
	}
	options := diffTableOptions{indent: "    ", sortKeys: true}
	configFileRegistry.ForEachTestConfigEntry(func(path tspath.Path, entry *project.TestConfigEntry) {
		configChange := ""
		oldEntry := f.stateBaseline.serializedConfigFileRegistry.GetTestConfigEntry(path)
		if oldEntry == nil {
			configChange = "*new*"
			configDiffsTable.setHasChange()
		} else if oldEntry != entry {
			if !areIterSeqEqual(oldEntry.RetainingProjects, entry.RetainingProjects) ||
				!areIterSeqEqual(oldEntry.RetainingOpenFiles, entry.RetainingOpenFiles) ||
				!areIterSeqEqual(oldEntry.RetainingConfigs, entry.RetainingConfigs) {
				configChange = "*modified*"
				configDiffsTable.setHasChange()
			}
		}
		configDiffsTable.add(string(path), func(w io.Writer) {
			fmt.Fprintf(w, "  [%s] %s\n", entry.FileName, configChange)
			// Print the details of the config entry
			var retainingProjectsModified string
			var retainingOpenFilesModified string
			var retainingConfigsModified string
			if configChange == "*modified*" {
				if !areIterSeqEqual(entry.RetainingProjects, oldEntry.RetainingProjects) {
					retainingProjectsModified = " *modified*"
				}
				if !areIterSeqEqual(entry.RetainingOpenFiles, oldEntry.RetainingOpenFiles) {
					retainingOpenFilesModified = " *modified*"
				}
				if !areIterSeqEqual(entry.RetainingConfigs, oldEntry.RetainingConfigs) {
					retainingConfigsModified = " *modified*"
				}
			}
			printPathIterSeqWithDiffTable(w, "RetainingProjects:"+retainingProjectsModified, entry.RetainingProjects, func() iter.Seq[tspath.Path] { return oldEntry.RetainingProjects }, options, configChange)
			printPathIterSeqWithDiffTable(w, "RetainingOpenFiles:"+retainingOpenFilesModified, entry.RetainingOpenFiles, func() iter.Seq[tspath.Path] { return oldEntry.RetainingOpenFiles }, options, configChange)
			printPathIterSeqWithDiffTable(w, "RetainingConfigs:"+retainingConfigsModified, entry.RetainingConfigs, func() iter.Seq[tspath.Path] { return oldEntry.RetainingConfigs }, options, configChange)
		})
	})
	configFileRegistry.ForEachTestConfigFileNamesEntry(func(path tspath.Path, entry *project.TestConfigFileNamesEntry) {
		configFileNamesChange := ""
		oldEntry := f.stateBaseline.serializedConfigFileRegistry.GetTestConfigFileNamesEntry(path)
		if oldEntry == nil {
			configFileNamesChange = "*new*"
			configFileNamesDiffsTable.setHasChange()
		} else if oldEntry.NearestConfigFileName != entry.NearestConfigFileName ||
			!maps.Equal(oldEntry.Ancestors, entry.Ancestors) {
			configFileNamesChange = "*modified*"
			configFileNamesDiffsTable.setHasChange()
		}
		configFileNamesDiffsTable.add(string(path), func(w io.Writer) {
			fmt.Fprintf(w, "  [%s] %s\n", path, configFileNamesChange)
			var nearestConfigFileNameModified string
			var ancestorDiffModified string
			if configFileNamesChange == "*modified*" {
				if oldEntry.NearestConfigFileName != entry.NearestConfigFileName {
					nearestConfigFileNameModified = " *modified*"
				}
				if !maps.Equal(oldEntry.Ancestors, entry.Ancestors) {
					ancestorDiffModified = " *modified*"
				}
			}
			fmt.Fprintf(w, "    NearestConfigFileName: %s%s\n", entry.NearestConfigFileName, nearestConfigFileNameModified)
			ancestorDiff := diffTable{options: options}
			for config, ancestorOfConfig := range entry.Ancestors {
				ancestorChange := ""
				if configFileNamesChange == "*modified*" {
					if oldConfigFileName, ok := oldEntry.Ancestors[config]; ok {
						if oldConfigFileName != ancestorOfConfig {
							ancestorChange = "*modified*"
						}
					} else {
						ancestorChange = "*new*"
					}
				}
				ancestorDiff.add(config, fmt.Sprintf("%s %s", ancestorOfConfig, ancestorChange))
			}
			if configFileNamesChange == "*modified*" {
				for ancestorPath, oldConfigFileName := range oldEntry.Ancestors {
					if _, ok := entry.Ancestors[ancestorPath]; !ok {
						ancestorDiff.add(ancestorPath, oldConfigFileName+" *deleted*")
					}
				}
			}
			ancestorDiff.print(w, "Ancestors:"+ancestorDiffModified)
		})
	})

	f.stateBaseline.serializedConfigFileRegistry.ForEachTestConfigEntry(func(path tspath.Path, entry *project.TestConfigEntry) {
		if configFileRegistry.GetTestConfigEntry(path) == nil {
			configDiffsTable.setHasChange()
			configDiffsTable.add(string(path), func(w io.Writer) {
				fmt.Fprintf(w, "  [%s] *deleted*\n", entry.FileName)
			})
		}
	})
	f.stateBaseline.serializedConfigFileRegistry.ForEachTestConfigFileNamesEntry(func(path tspath.Path, entry *project.TestConfigFileNamesEntry) {
		if configFileRegistry.GetTestConfigFileNamesEntry(path) == nil {
			configFileNamesDiffsTable.setHasChange()
			configFileNamesDiffsTable.add(string(path), func(w io.Writer) {
				fmt.Fprintf(w, "  [%s] *deleted*\n", path)
			})
		}
	})
	f.stateBaseline.serializedConfigFileRegistry = configFileRegistry
	configDiffsTable.print(w)
	configFileNamesDiffsTable.print(w)
}
