package baseline

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"testing"

	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/repo"
	"github.com/microsoft/typescript-go/internal/stringutil"
	"github.com/peter-evans/patience"
)

type Options struct {
	Subfolder           string
	IsSubmodule         bool
	IsSubmoduleAccepted bool
	DiffFixupOld        func(string) string
	DiffFixupNew        func(string) string
	SkipDiffWithOld     bool
}

const NoContent = "<no content>"

func Run(t *testing.T, fileName string, actual string, opts Options) {
	origSubfolder := opts.Subfolder

	{
		subfolder := opts.Subfolder
		if opts.IsSubmodule {
			subfolder = filepath.Join("submodule", subfolder)
		}

		localPath := filepath.Join(localRoot, subfolder, fileName)
		referencePath := filepath.Join(referenceRoot, subfolder, fileName)

		writeComparison(t, actual, localPath, referencePath, false)
	}

	if !opts.IsSubmodule || opts.SkipDiffWithOld {
		// Not a submodule, no diffs.
		return
	}

	submoduleReference := filepath.Join(submoduleReferenceRoot, fileName)
	submoduleExpected := readFileOrNoContent(submoduleReference)

	const (
		submoduleFolder         = "submodule"
		submoduleAcceptedFolder = "submoduleAccepted"
	)

	diffFileName := fileName + ".diff"
	isSubmoduleAccepted := opts.IsSubmoduleAccepted || submoduleAcceptedFileNames().Has(origSubfolder+"/"+diffFileName)

	outRoot := core.IfElse(isSubmoduleAccepted, submoduleAcceptedFolder, submoduleFolder)
	unusedOutRoot := core.IfElse(isSubmoduleAccepted, submoduleFolder, submoduleAcceptedFolder)

	{
		localPath := filepath.Join(localRoot, outRoot, origSubfolder, diffFileName)
		referencePath := filepath.Join(referenceRoot, outRoot, origSubfolder, diffFileName)

		diff := getBaselineDiff(t, actual, submoduleExpected, fileName, opts.DiffFixupOld, opts.DiffFixupNew)
		writeComparison(t, diff, localPath, referencePath, false)
	}

	// Delete the other diff file if it exists
	{
		localPath := filepath.Join(localRoot, unusedOutRoot, origSubfolder, diffFileName)
		referencePath := filepath.Join(referenceRoot, unusedOutRoot, origSubfolder, diffFileName)
		writeComparison(t, NoContent, localPath, referencePath, false)
	}
}

var submoduleAcceptedFileNames = sync.OnceValue(func() *collections.Set[string] {
	var set collections.Set[string]

	submoduleAccepted := filepath.Join(repo.TestDataPath, "submoduleAccepted.txt")
	if content, err := os.ReadFile(submoduleAccepted); err == nil {
		for line := range strings.SplitSeq(string(content), "\n") {
			line = strings.TrimSpace(line)
			if line == "" || line[0] == '#' {
				continue
			}
			set.Add(line)
		}
	} else {
		panic(fmt.Sprintf("failed to read submodule accepted file: %v", err))
	}

	return &set
})

func readFileOrNoContent(fileName string) string {
	content, err := os.ReadFile(fileName)
	if err != nil {
		return NoContent
	}
	return string(content)
}

func DiffText(oldName string, newName string, expected string, actual string) string {
	lines := patience.Diff(stringutil.SplitLines(expected), stringutil.SplitLines(actual))
	return patience.UnifiedDiffTextWithOptions(lines, patience.UnifiedDiffOptions{
		Precontext:  3,
		Postcontext: 3,
		SrcHeader:   oldName,
		DstHeader:   newName,
	})
}

func getBaselineDiff(t *testing.T, actual string, expected string, fileName string, fixupOld func(string) string, fixupNew func(string) string) string {
	if fixupOld != nil {
		expected = fixupOld(expected)
	}
	if fixupNew != nil {
		actual = fixupNew(actual)
	}
	if actual == expected {
		return NoContent
	}
	s := DiffText("old."+fileName, "new."+fileName, expected, actual)

	// If the diff is empty (just headers, no hunks), return NoContent
	if !strings.Contains(s, "@@") {
		return NoContent
	}

	// Remove line numbers from unified diff headers; this avoids adding/deleting
	// lines in our baselines from causing knock-on header changes later in the diff.

	aCurLine := 1
	bCurLine := 1
	s = fixUnifiedDiff.ReplaceAllStringFunc(s, func(match string) string {
		var aLine, aLineCount, bLine, bLineCount int
		if _, err := fmt.Sscanf(match, "@@ -%d,%d +%d,%d @@", &aLine, &aLineCount, &bLine, &bLineCount); err != nil {
			panic(fmt.Sprintf("failed to parse unified diff header: %v", err))
		}
		aDiff := aLine - aCurLine
		bDiff := bLine - bCurLine
		aCurLine = aLine
		bCurLine = bLine

		// Keep surrounded by @@, to make GitHub's grammar happy.
		// https://github.com/textmate/diff.tmbundle/blob/0593bb775eab1824af97ef2172fd38822abd97d7/Syntaxes/Diff.plist#L68
		return fmt.Sprintf("@@= skipped -%d, +%d lines =@@", aDiff, bDiff)
	})

	return s
}

var fixUnifiedDiff = regexp.MustCompile(`@@ -\d+,\d+ \+\d+,\d+ @@`)

func RunAgainstSubmodule(t *testing.T, fileName string, actual string, opts Options) {
	local := filepath.Join(localRoot, opts.Subfolder, fileName)
	reference := filepath.Join(submoduleReferenceRoot, opts.Subfolder, fileName)
	writeComparison(t, actual, local, reference, true)
}

func writeComparison(t *testing.T, actualContent string, local, reference string, comparingAgainstSubmodule bool) {
	if actualContent == "" {
		panic("the generated content was \"\". Return 'baseline.NoContent' if no baselining is required.")
	}

	if err := os.MkdirAll(filepath.Dir(local), 0o755); err != nil {
		t.Error(fmt.Errorf("failed to create directories for the local baseline file %s: %w", local, err))
		return
	}

	if _, err := os.Stat(local); err == nil {
		if err := os.Remove(local); err != nil {
			t.Error(fmt.Errorf("failed to remove the local baseline file %s: %w", local, err))
			return
		}
	}

	expected := NoContent
	foundExpected := false
	if content, err := os.ReadFile(reference); err == nil {
		expected = string(content)
		foundExpected = true
	}

	if expected != actualContent || actualContent == NoContent && foundExpected {
		if actualContent == NoContent {
			if err := os.WriteFile(local+".delete", []byte{}, 0o644); err != nil {
				t.Error(fmt.Errorf("failed to write the local baseline file %s: %w", local+".delete", err))
				return
			}
		} else {
			if err := os.WriteFile(local, []byte(actualContent), 0o644); err != nil {
				t.Error(fmt.Errorf("failed to write the local baseline file %s: %w", local, err))
				return
			}
		}

		if _, err := os.Stat(reference); err != nil {
			if comparingAgainstSubmodule {
				t.Errorf("the baseline file %s does not exist in the TypeScript submodule", reference)
			} else {
				t.Errorf("new baseline created at %s.", local)
			}
		} else if comparingAgainstSubmodule {
			t.Errorf("the baseline file %s does not match the reference in the TypeScript submodule", reference)
		} else {
			t.Errorf("the baseline file %s has changed. (Run `hereby baseline-accept` if the new baseline is correct.)", reference)
		}
	}
}

var (
	localRoot              = filepath.Join(repo.TestDataPath, "baselines", "local")
	referenceRoot          = filepath.Join(repo.TestDataPath, "baselines", "reference")
	submoduleReferenceRoot = filepath.Join(repo.TypeScriptSubmodulePath, "tests", "baselines", "reference")
)
