package tsctests

import (
	"fmt"
	"io"
	"io/fs"
	"maps"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/compiler"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/execute/incremental"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/execute/tsc"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/harnessutil"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/stringtestutil"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tsoptions"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs/iovfs"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs/vfstest"
)

type FileMap map[string]any

var tscLibPath = "/home/src/tslibs/TS/Lib"

var tscDefaultLibContent = stringtestutil.Dedent(`
/// <reference no-default-lib="true"/>
interface Boolean {}
interface Function {}
interface CallableFunction {}
interface NewableFunction {}
interface IArguments {}
interface Number { toExponential: any; }
interface Object {}
interface RegExp {}
interface String { charAt: any; }
interface Array<T> { length: number; [n: number]: T; }
interface ReadonlyArray<T> {}
interface SymbolConstructor {
    (desc?: string | number): symbol;
    for(name: string): symbol;
    readonly toStringTag: symbol;
}
declare var Symbol: SymbolConstructor;
interface Symbol {
    readonly [Symbol.toStringTag]: string;
}
declare const console: { log(msg: any): void; };
`)

func getTestLibPathFor(libName string) string {
	var libFile string
	if value, ok := tsoptions.LibMap.Get(libName); ok {
		libFile = value.(string)
	} else {
		libFile = "lib." + libName + ".d.ts"
	}
	return tscLibPath + "/" + libFile
}

type TestClock struct {
	start time.Time
	now   time.Time
	nowMu sync.Mutex
}

func (t *TestClock) Now() time.Time {
	t.nowMu.Lock()
	defer t.nowMu.Unlock()
	if t.now.IsZero() {
		t.now = t.start
	}
	t.now = t.now.Add(1 * time.Second) // Simulate some time passing
	return t.now
}

func (t *TestClock) SinceStart() time.Duration {
	return t.Now().Sub(t.start)
}

func NewTscSystem(files FileMap, useCaseSensitiveFileNames bool, cwd string) *testSys {
	clock := &TestClock{start: time.Now()}
	return &testSys{
		fs: &testFs{
			FS: vfstest.FromMapWithClock(files, useCaseSensitiveFileNames, clock),
		},
		cwd:   cwd,
		clock: clock,
	}
}

func newTestSys(tscInput *tscInput, forIncrementalCorrectness bool) *testSys {
	cwd := tscInput.cwd
	if cwd == "" {
		cwd = "/home/src/workspaces/project"
	}
	libPath := tscLibPath
	if tscInput.windowsStyleRoot != "" {
		libPath = tscInput.windowsStyleRoot + libPath[1:]
	}
	currentWrite := &strings.Builder{}
	sys := NewTscSystem(tscInput.files, !tscInput.ignoreCase, cwd)
	sys.defaultLibraryPath = libPath
	sys.currentWrite = currentWrite
	sys.tracer = harnessutil.NewTracerForBaselining(tspath.ComparePathsOptions{
		UseCaseSensitiveFileNames: !tscInput.ignoreCase,
		CurrentDirectory:          cwd,
	}, currentWrite)
	sys.env = tscInput.env
	sys.forIncrementalCorrectness = forIncrementalCorrectness

	// Ensure the default library file is present
	sys.ensureLibPathExists("lib.d.ts")
	for _, libFile := range tsoptions.TargetToLibMap() {
		sys.ensureLibPathExists(libFile)
	}
	for libFile := range tsoptions.LibFilesSet.Keys() {
		sys.ensureLibPathExists(libFile)
	}
	return sys
}

type diffEntry struct {
	content       string
	mTime         time.Time
	isWritten     bool
	symlinkTarget string
}

type snapshot struct {
	snap        map[string]*diffEntry
	defaultLibs *collections.SyncSet[string]
}

type testSys struct {
	currentWrite              *strings.Builder
	programBaselines          strings.Builder
	programIncludeBaselines   strings.Builder
	tracer                    *harnessutil.TracerForBaselining
	serializedDiff            *snapshot
	forIncrementalCorrectness bool

	fs                 *testFs
	defaultLibraryPath string
	cwd                string
	env                map[string]string
	clock              *TestClock
}

var (
	_ tsc.System             = (*testSys)(nil)
	_ tsc.CommandLineTesting = (*testSys)(nil)
)

func (s *testSys) Now() time.Time {
	return s.clock.Now()
}

func (s *testSys) SinceStart() time.Duration {
	return s.clock.SinceStart()
}

func (s *testSys) FS() vfs.FS {
	return s.fs
}

func (s *testSys) fsFromFileMap() iovfs.FsWithSys {
	return s.fs.FS.(iovfs.FsWithSys)
}

func (s *testSys) mapFs() *vfstest.MapFS {
	return s.fsFromFileMap().FSys().(*vfstest.MapFS)
}

func (s *testSys) ensureLibPathExists(path string) {
	path = s.defaultLibraryPath + "/" + path
	if _, ok := s.fsFromFileMap().ReadFile(path); !ok {
		if s.fs.defaultLibs == nil {
			s.fs.defaultLibs = &collections.SyncSet[string]{}
		}
		s.fs.defaultLibs.Add(path)
		err := s.fsFromFileMap().WriteFile(path, tscDefaultLibContent, false)
		if err != nil {
			panic("Failed to write default library file: " + err.Error())
		}
	}
}

func (s *testSys) DefaultLibraryPath() string {
	return s.defaultLibraryPath
}

func (s *testSys) GetCurrentDirectory() string {
	return s.cwd
}

func (s *testSys) Writer() io.Writer {
	return s.currentWrite
}

func (s *testSys) WriteOutputIsTTY() bool {
	return true
}

func (s *testSys) GetWidthOfTerminal() int {
	if widthStr := s.GetEnvironmentVariable("TS_TEST_TERMINAL_WIDTH"); widthStr != "" {
		return core.Must(strconv.Atoi(widthStr))
	}
	return 0
}

func (s *testSys) GetEnvironmentVariable(name string) string {
	return s.env[name]
}

func (s *testSys) OnEmittedFiles(result *compiler.EmitResult, mTimesCache *collections.SyncMap[tspath.Path, time.Time]) {
	if result != nil {
		for _, file := range result.EmittedFiles {
			modTime := s.mapFs().GetModTime(file)
			if s.serializedDiff != nil {
				if diff, ok := s.serializedDiff.snap[file]; ok && diff.mTime.Equal(modTime) {
					// Even though written, timestamp was reverted
					continue
				}
			}

			// Ensure that the timestamp for emitted files is in the order
			now := s.Now()
			if err := s.fsFromFileMap().Chtimes(file, time.Time{}, now); err != nil {
				panic("Failed to change time for emitted file: " + file + ": " + err.Error())
			}
			// Update the mTime cache in --b mode to store the updated timestamp so tests will behave deteministically when finding newest output
			if mTimesCache != nil {
				path := tspath.ToPath(file, s.GetCurrentDirectory(), s.FS().UseCaseSensitiveFileNames())
				if _, found := mTimesCache.Load(path); found {
					mTimesCache.Store(path, now)
				}
			}
		}
	}
}

func (s *testSys) OnListFilesStart(w io.Writer) {
	fmt.Fprintln(w, listFileStart)
}

func (s *testSys) OnListFilesEnd(w io.Writer) {
	fmt.Fprintln(w, listFileEnd)
}

func (s *testSys) OnStatisticsStart(w io.Writer) {
	fmt.Fprintln(w, statisticsStart)
}

func (s *testSys) OnStatisticsEnd(w io.Writer) {
	fmt.Fprintln(w, statisticsEnd)
}

func (s *testSys) OnBuildStatusReportStart(w io.Writer) {
	fmt.Fprintln(w, buildStatusReportStart)
}

func (s *testSys) OnBuildStatusReportEnd(w io.Writer) {
	fmt.Fprintln(w, buildStatusReportEnd)
}

func (s *testSys) OnWatchStatusReportStart() {
	fmt.Fprintln(s.Writer(), watchStatusReportStart)
}

func (s *testSys) OnWatchStatusReportEnd() {
	fmt.Fprintln(s.Writer(), watchStatusReportEnd)
}

func (s *testSys) GetTrace(w io.Writer) func(str string) {
	return func(str string) {
		fmt.Fprintln(w, traceStart)
		defer fmt.Fprintln(w, traceEnd)
		// With tsc -b building projects in parallel we cannot serialize the package.json lookup trace
		// so trace as if it wasnt cached
		s.tracer.TraceWithWriter(w, str, w == s.Writer())
	}
}

func (s *testSys) writeHeaderToBaseline(builder *strings.Builder, program *incremental.Program) {
	if builder.Len() != 0 {
		builder.WriteString("\n")
	}

	if configFilePath := program.Options().ConfigFilePath; configFilePath != "" {
		builder.WriteString(tspath.GetRelativePathFromDirectory(s.cwd, configFilePath, tspath.ComparePathsOptions{
			UseCaseSensitiveFileNames: s.FS().UseCaseSensitiveFileNames(),
			CurrentDirectory:          s.GetCurrentDirectory(),
		}) + "::\n")
	}
}

func (s *testSys) OnProgram(program *incremental.Program) {
	s.writeHeaderToBaseline(&s.programBaselines, program)

	testingData := program.GetTestingData()
	s.programBaselines.WriteString("SemanticDiagnostics::\n")
	for _, file := range program.GetProgram().GetSourceFiles() {
		if diagnostics, ok := testingData.SemanticDiagnosticsPerFile.Load(file.Path()); ok {
			if oldDiagnostics, ok := testingData.OldProgramSemanticDiagnosticsPerFile.Load(file.Path()); !ok || oldDiagnostics != diagnostics {
				s.programBaselines.WriteString("*refresh*    " + file.FileName() + "\n")
			}
		} else {
			s.programBaselines.WriteString("*not cached* " + file.FileName() + "\n")
		}
	}

	// Write signature updates
	s.programBaselines.WriteString("Signatures::\n")
	for _, file := range program.GetProgram().GetSourceFiles() {
		if kind, ok := testingData.UpdatedSignatureKinds[file.Path()]; ok {
			switch kind {
			case incremental.SignatureUpdateKindComputedDts:
				s.programBaselines.WriteString("(computed .d.ts) " + file.FileName() + "\n")
			case incremental.SignatureUpdateKindStoredAtEmit:
				s.programBaselines.WriteString("(stored at emit) " + file.FileName() + "\n")
			case incremental.SignatureUpdateKindUsedVersion:
				s.programBaselines.WriteString("(used version)   " + file.FileName() + "\n")
			}
		}
	}

	var filesWithoutIncludeReason []string
	var fileNotInProgramWithIncludeReason []string
	includeReasons := program.GetProgram().GetIncludeReasons()
	for _, file := range program.GetProgram().GetSourceFiles() {
		if _, ok := includeReasons[file.Path()]; !ok {
			filesWithoutIncludeReason = append(filesWithoutIncludeReason, string(file.Path()))
		}
	}
	for path := range includeReasons {
		if program.GetProgram().GetSourceFileByPath(path) == nil && !program.GetProgram().IsMissingPath(path) {
			fileNotInProgramWithIncludeReason = append(fileNotInProgramWithIncludeReason, string(path))
		}
	}
	if len(filesWithoutIncludeReason) > 0 || len(fileNotInProgramWithIncludeReason) > 0 {
		s.writeHeaderToBaseline(&s.programIncludeBaselines, program)
		s.programIncludeBaselines.WriteString("!!! Expected all files to have include reasons\nfilesWithoutIncludeReason::\n")
		for _, file := range filesWithoutIncludeReason {
			s.programIncludeBaselines.WriteString("  " + file + "\n")
		}
		s.programIncludeBaselines.WriteString("filesNotInProgramWithIncludeReason::\n")
		for _, file := range fileNotInProgramWithIncludeReason {
			s.programIncludeBaselines.WriteString("  " + file + "\n")
		}
	}
}

func (s *testSys) baselinePrograms(baseline *strings.Builder, header string) string {
	baseline.WriteString(s.programBaselines.String())
	s.programBaselines.Reset()
	var result string
	if s.programIncludeBaselines.Len() > 0 {
		result += fmt.Sprintf("\n\n%s\n!!! Include reasons expectations don't match pls review!!!\n", header)
		result += s.programIncludeBaselines.String()
		s.programIncludeBaselines.Reset()
		baseline.WriteString(result)
	}
	return result
}

func (s *testSys) serializeState(baseline *strings.Builder) {
	s.baselineOutput(baseline)
	s.baselineFSwithDiff(baseline)
	// todo watch
	// this.serializeWatches(baseline);
	// this.timeoutCallbacks.serialize(baseline);
	// this.immediateCallbacks.serialize(baseline);
	// this.pendingInstalls.serialize(baseline);
	// this.service?.baseline();
}

var (
	fakeTimeStamp = "HH:MM:SS AM"
	fakeDuration  = "d.ddds"

	buildStartingAt        = "build starting at "
	buildFinishedIn        = "build finished in "
	listFileStart          = "!!! List files start"
	listFileEnd            = "!!! List files end"
	statisticsStart        = "!!! Statistics start"
	statisticsEnd          = "!!! Statistics end"
	buildStatusReportStart = "!!! Build Status Report Start"
	buildStatusReportEnd   = "!!! Build Status Report End"
	watchStatusReportStart = "!!! Watch Status Report Start"
	watchStatusReportEnd   = "!!! Watch Status Report End"
	traceStart             = "!!! Trace start"
	traceEnd               = "!!! Trace end"
)

func (s *testSys) baselineOutput(baseline io.Writer) {
	fmt.Fprint(baseline, "\nOutput::\n")
	output := s.getOutput(false)
	fmt.Fprint(baseline, output)
}

type outputSanitizer struct {
	forComparing bool
	lines        []string
	index        int
	outputLines  []string
}

func (o *outputSanitizer) addOutputLine(s string) {
	if change := strings.ReplaceAll(s, fmt.Sprintf("'%s'", core.Version()), fmt.Sprintf("'%s'", harnessutil.FakeTSVersion)); change != s {
		s = change
	}
	if change := strings.ReplaceAll(s, "Version "+core.Version(), "Version "+harnessutil.FakeTSVersion); change != s {
		s = change
	}
	o.outputLines = append(o.outputLines, s)
}

func (o *outputSanitizer) sanitizeBuildStatusTimeStamp() string {
	statusLine := o.lines[o.index]
	hhSeparator := strings.IndexRune(statusLine, ':')
	if hhSeparator < 2 {
		panic("Expected timestamp")
	}
	return statusLine[:hhSeparator-2] + fakeTimeStamp + statusLine[hhSeparator+len(fakeTimeStamp)-2:]
}

func (o *outputSanitizer) transformLines() string {
	for ; o.index < len(o.lines); o.index++ {
		line := o.lines[o.index]
		if strings.HasPrefix(line, buildStartingAt) {
			if !o.forComparing {
				o.addOutputLine(buildStartingAt + fakeTimeStamp)
			}
			continue
		}
		if strings.HasPrefix(line, buildFinishedIn) {
			if !o.forComparing {
				o.addOutputLine(buildFinishedIn + fakeDuration)
			}
			continue
		}
		if !o.addOrSkipLinesForComparing(listFileStart, listFileEnd, false, nil) &&
			!o.addOrSkipLinesForComparing(statisticsStart, statisticsEnd, true, nil) &&
			!o.addOrSkipLinesForComparing(traceStart, traceEnd, false, nil) &&
			!o.addOrSkipLinesForComparing(buildStatusReportStart, buildStatusReportEnd, false, o.sanitizeBuildStatusTimeStamp) &&
			!o.addOrSkipLinesForComparing(watchStatusReportStart, watchStatusReportEnd, false, o.sanitizeBuildStatusTimeStamp) {
			o.addOutputLine(line)
		}
	}
	return strings.Join(o.outputLines, "\n")
}

func (o *outputSanitizer) addOrSkipLinesForComparing(
	lineStart string,
	lineEnd string,
	skipEvenIfNotComparing bool,
	sanitizeFirstLine func() string,
) bool {
	if o.lines[o.index] != lineStart {
		return false
	}
	o.index++
	isFirstLine := true
	for ; o.index < len(o.lines); o.index++ {
		if o.lines[o.index] == lineEnd {
			return true
		}
		if !o.forComparing && !skipEvenIfNotComparing {
			line := o.lines[o.index]
			if isFirstLine && sanitizeFirstLine != nil {
				line = sanitizeFirstLine()
				isFirstLine = false
			}
			o.addOutputLine(line)
		}
	}
	panic("Expected lineEnd" + lineEnd + " not found after " + lineStart)
}

func (s *testSys) getOutput(forComparing bool) string {
	lines := strings.Split(s.currentWrite.String(), "\n")
	transformer := &outputSanitizer{
		forComparing: forComparing,
		lines:        lines,
		outputLines:  make([]string, 0, len(lines)),
	}
	return transformer.transformLines()
}

func (s *testSys) clearOutput() {
	s.currentWrite.Reset()
	s.tracer.Reset()
}

func (s *testSys) baselineFSwithDiff(baseline io.Writer) {
	// todo: baselines the entire fs, possibly doesn't correctly diff all cases of emitted files, since emit isn't fully implemented and doesn't always emit the same way as strada
	snap := map[string]*diffEntry{}

	diffs := map[string]string{}

	for path, file := range s.mapFs().Entries() {
		if file.Mode&fs.ModeSymlink != 0 {
			target, ok := s.mapFs().GetTargetOfSymlink(path)
			if !ok {
				panic("Failed to resolve symlink target: " + path)
			}
			newEntry := &diffEntry{symlinkTarget: target}
			snap[path] = newEntry
			s.addFsEntryDiff(diffs, newEntry, path)
			continue
		} else if file.Mode.IsRegular() {
			newEntry := &diffEntry{content: string(file.Data), mTime: file.ModTime, isWritten: s.fs.writtenFiles.Has(path)}
			snap[path] = newEntry
			s.addFsEntryDiff(diffs, newEntry, path)
		}
	}
	if s.serializedDiff != nil {
		for path := range s.serializedDiff.snap {
			if fileInfo := s.mapFs().GetFileInfo(path); fileInfo == nil {
				// report deleted
				s.addFsEntryDiff(diffs, nil, path)
			}
		}
	}
	var defaultLibs collections.SyncSet[string]
	if s.fs.defaultLibs != nil {
		s.fs.defaultLibs.Range(func(libPath string) bool {
			defaultLibs.Add(libPath)
			return true
		})
	}
	s.serializedDiff = &snapshot{
		snap:        snap,
		defaultLibs: &defaultLibs,
	}
	diffKeys := slices.Collect(maps.Keys(diffs))
	slices.Sort(diffKeys)
	for _, path := range diffKeys {
		fmt.Fprint(baseline, "//// ["+path+"] ", diffs[path], "\n")
	}
	fmt.Fprintln(baseline)
	s.fs.writtenFiles = collections.SyncSet[string]{} // Reset written files after baseline
}

func (s *testSys) addFsEntryDiff(diffs map[string]string, newDirContent *diffEntry, path string) {
	var oldDirContent *diffEntry
	var defaultLibs *collections.SyncSet[string]
	if s.serializedDiff != nil {
		oldDirContent = s.serializedDiff.snap[path]
		defaultLibs = s.serializedDiff.defaultLibs
	}
	// todo handle more cases of fs changes
	if oldDirContent == nil {
		if s.fs.defaultLibs == nil || !s.fs.defaultLibs.Has(path) {
			if newDirContent.symlinkTarget != "" {
				diffs[path] = "-> " + newDirContent.symlinkTarget + " *new*"
			} else {
				diffs[path] = "*new* \n" + newDirContent.content
			}
		}
	} else if newDirContent == nil {
		diffs[path] = "*deleted*"
	} else if newDirContent.content != oldDirContent.content {
		diffs[path] = "*modified* \n" + newDirContent.content
	} else if newDirContent.isWritten {
		diffs[path] = "*rewrite with same content*"
	} else if newDirContent.mTime != oldDirContent.mTime {
		diffs[path] = "*mTime changed*"
	} else if defaultLibs != nil && defaultLibs.Has(path) && s.fs.defaultLibs != nil && !s.fs.defaultLibs.Has(path) {
		// Lib file that was read
		diffs[path] = "*Lib*\n" + newDirContent.content
	}
}

func (s *testSys) writeFileNoError(path string, content string, writeByteOrderMark bool) {
	if err := s.fsFromFileMap().WriteFile(path, content, writeByteOrderMark); err != nil {
		panic(err)
	}
}

func (s *testSys) removeNoError(path string) {
	if err := s.fsFromFileMap().Remove(path); err != nil {
		panic(err)
	}
}

func (s *testSys) readFileNoError(path string) string {
	content, ok := s.fsFromFileMap().ReadFile(path)
	if !ok {
		panic("File not found: " + path)
	}
	return content
}

func (s *testSys) renameFileNoError(oldPath string, newPath string) {
	s.writeFileNoError(newPath, s.readFileNoError(oldPath), false)
	s.removeNoError(oldPath)
}

func (s *testSys) replaceFileText(path string, oldText string, newText string) {
	content := s.readFileNoError(path)
	content = strings.Replace(content, oldText, newText, 1)
	s.writeFileNoError(path, content, false)
}

func (s *testSys) replaceFileTextAll(path string, oldText string, newText string) {
	content := s.readFileNoError(path)
	content = strings.ReplaceAll(content, oldText, newText)
	s.writeFileNoError(path, content, false)
}

func (s *testSys) appendFile(path string, text string) {
	content := s.readFileNoError(path)
	s.writeFileNoError(path, content+text, false)
}

func (s *testSys) prependFile(path string, text string) {
	content := s.readFileNoError(path)
	s.writeFileNoError(path, text+content, false)
}
