package main

import (
	"io"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
)

func TestCountGenericSetsContentMatchFromKeywordChecks(t *testing.T) {
	stringMatchMu.Lock()
	stringMatchLanguages = make(map[string]map[string]struct{})
	stringMatchMu.Unlock()

	dir := t.TempDir()

	objcPath := filepath.Join(dir, "sample.m")
	objcSource := "#import <Foundation/Foundation.h>\n@interface Sample\n@end\n"
	if err := os.WriteFile(objcPath, []byte(objcSource), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", objcPath, err)
	}

	objcStats := countGeneric(objcPath)
	if len(objcStats) != 1 {
		t.Fatalf("countGeneric(%q) returned %d stats, want 1", objcPath, len(objcStats))
	}
	if objcStats[0].Language != "Objective C" {
		t.Fatalf("countGeneric(%q) language = %q, want %q", objcPath, objcStats[0].Language, "Objective C")
	}
	if !objcStats[0].contentMatch {
		t.Fatalf("countGeneric(%q) contentMatch = false, want true", objcPath)
	}

	goPath := filepath.Join(dir, "sample.go")
	goSource := "package main\nfunc main() {}\n"
	if err := os.WriteFile(goPath, []byte(goSource), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", goPath, err)
	}

	goStats := countGeneric(goPath)
	if len(goStats) != 1 {
		t.Fatalf("countGeneric(%q) returned %d stats, want 1", goPath, len(goStats))
	}
	if goStats[0].Language != "Go" {
		t.Fatalf("countGeneric(%q) language = %q, want %q", goPath, goStats[0].Language, "Go")
	}
	if goStats[0].contentMatch {
		t.Fatalf("countGeneric(%q) contentMatch = true, want false", goPath)
	}
}

func TestCountGenericSetsContentMatchFromHashbang(t *testing.T) {
	stringMatchMu.Lock()
	stringMatchLanguages = make(map[string]map[string]struct{})
	stringMatchMu.Unlock()

	dir := t.TempDir()
	cgiPath := filepath.Join(dir, "sample.cgi")
	cgiSource := "#!/usr/bin/perl\nprint \"Content-type: text/plain\\n\\n\";\n"
	if err := os.WriteFile(cgiPath, []byte(cgiSource), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", cgiPath, err)
	}

	stats := countGeneric(cgiPath)
	if len(stats) != 1 {
		t.Fatalf("countGeneric(%q) returned %d stats, want 1", cgiPath, len(stats))
	}
	if stats[0].Language != "Perl" {
		t.Fatalf("countGeneric(%q) language = %q, want %q", cgiPath, stats[0].Language, "Perl")
	}
	if !stats[0].contentMatch {
		t.Fatalf("countGeneric(%q) contentMatch = false, want true", cgiPath)
	}
}

func TestCountGenericLetsHashbangOverrideMisleadingExtension(t *testing.T) {
	dir := t.TempDir()

	cases := []struct {
		name   string
		source string
		want   string
	}{
		{
			name:   "shell.ts",
			source: "#!/bin/bash\nprintf '%s\\n' ok\n",
			want:   "bash",
		},
		{
			name:   "perl.ts",
			source: "#!/usr/bin/env perl\nprint qq(ok\\n);\n",
			want:   "Perl",
		},
		{
			name:   "actual.ts",
			source: "const answer: number = 42;\n",
			want:   "TypeScript",
		},
	}

	for _, tt := range cases {
		path := filepath.Join(dir, tt.name)
		if err := os.WriteFile(path, []byte(tt.source), 0644); err != nil {
			t.Fatalf("WriteFile(%q): %v", path, err)
		}

		stats := countGeneric(path)
		if len(stats) != 1 {
			t.Fatalf("countGeneric(%q) returned %d stats, want 1", path, len(stats))
		}
		if stats[0].Language != tt.want {
			t.Fatalf("countGeneric(%q) language = %q, want %q", path, stats[0].Language, tt.want)
		}
		if tt.want != "TypeScript" && !stats[0].contentMatch {
			t.Fatalf("countGeneric(%q) contentMatch = false, want true", path)
		}
	}
}

func TestRustRawStringDelimiters(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "raw.rs")
	source := `fn main() {
    let raw = r#"embedded " quote and /* not comment */"#;
    let bytes = br##"embedded " quote
/* not comment */
still bytes"##;
    let cstr = cr#"embedded " quote and // not comment"#;
}
`
	if err := os.WriteFile(path, []byte(source), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", path, err)
	}

	oldStdout := os.Stdout
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatalf("os.Pipe(): %v", err)
	}
	os.Stdout = w
	defer func() { os.Stdout = oldStdout }()
	stats := countGeneric(path)
	os.Stdout = oldStdout
	if err := w.Close(); err != nil {
		t.Fatalf("Close stdout pipe writer: %v", err)
	}
	out, err := io.ReadAll(r)
	if err != nil {
		t.Fatalf("ReadAll(stdout pipe): %v", err)
	}
	if strings.Contains(string(out), "ERROR -") {
		t.Fatalf("countGeneric(%q) emitted scanner error: %s", path, out)
	}

	if len(stats) != 1 {
		t.Fatalf("countGeneric(%q) returned %d stats, want 1", path, len(stats))
	}
	if stats[0].Language != "Rust" {
		t.Fatalf("countGeneric(%q) language = %q, want %q", path, stats[0].Language, "Rust")
	}
	if stats[0].LLOC != 3 {
		t.Fatalf("countGeneric(%q) LLOC = %d, want 3", path, stats[0].LLOC)
	}
}

func collectWalkedFiles(t *testing.T, root string, noGitmodules bool) []string {
	t.Helper()

	fileQueue := make(chan string, 16)
	go func() {
		walkDirectories([]string{root}, noGitmodules, fileQueue)
		close(fileQueue)
	}()

	paths := make([]string, 0)
	for path := range fileQueue {
		rel, err := filepath.Rel(root, path)
		if err != nil {
			t.Fatalf("filepath.Rel(%q, %q): %v", root, path, err)
		}
		paths = append(paths, filepath.ToSlash(rel))
	}
	sort.Strings(paths)
	return paths
}

func pathListContains(paths []string, want string) bool {
	for _, path := range paths {
		if path == want {
			return true
		}
	}
	return false
}

func TestWalkDirectoriesNoGitmodules(t *testing.T) {
	dir := t.TempDir()
	if err := os.MkdirAll(filepath.Join(dir, "vendor", "sub"), 0755); err != nil {
		t.Fatalf("MkdirAll(submodule): %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(`[submodule "vendor/sub"]
	path = vendor/sub
	url = https://example.invalid/sub.git
`), 0644); err != nil {
		t.Fatalf("WriteFile(.gitmodules): %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "root.go"), []byte("package main\n"), 0644); err != nil {
		t.Fatalf("WriteFile(root.go): %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "vendor", "sub", "inside.go"), []byte("package sub\n"), 0644); err != nil {
		t.Fatalf("WriteFile(inside.go): %v", err)
	}

	defaultPaths := collectWalkedFiles(t, dir, false)
	if !pathListContains(defaultPaths, "root.go") {
		t.Fatalf("default walk paths = %#v, want root.go", defaultPaths)
	}
	if pathListContains(defaultPaths, "vendor/sub/inside.go") {
		t.Fatalf("default walk paths = %#v, did not want submodule file", defaultPaths)
	}

	noGitmodulesPaths := collectWalkedFiles(t, dir, true)
	if !pathListContains(noGitmodulesPaths, "vendor/sub/inside.go") {
		t.Fatalf("no-gitmodules walk paths = %#v, want submodule file", noGitmodulesPaths)
	}
}

func TestWasGeneratedAutomaticallyEscapesCommentLeaders(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "generated.plus")
	if err := os.WriteFile(path, []byte("++ generated by test fixture\n"), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", path, err)
	}

	if !wasGeneratedAutomatically(new(countContext), path, []string{"//", "++"}) {
		t.Fatalf("wasGeneratedAutomatically(%q) = false, want true", path)
	}
}

func TestDetectlHeredocTrimsLineSyntax(t *testing.T) {
	cases := []struct {
		line string
		want string
	}{
		{line: "print <<EOF;\n", want: "EOF"},
		{line: "print <<\"EOF\";\r\n", want: "EOF"},
		{line: "print <<'END', $next\n", want: "END"},
		{line: "print << EOF\n", want: "EOF"},
		{line: "print 'no heredoc'\n", want: ""},
	}

	for _, tt := range cases {
		got := detectHeredoc([]byte(tt.line))
		if got != tt.want {
			t.Fatalf("detectPerlHeredoc(%q) = %q, want %q", tt.line, got, tt.want)
		}
	}
}

func TestUniqueStringMatchExtensions(t *testing.T) {
	stringMatchMu.Lock()
	stringMatchLanguages = make(map[string]map[string]struct{})
	stringMatchMu.Unlock()

	trackStringMatch("example.m", "Objective C")
	trackStringMatch("example.M", "Matlab")
	trackStringMatch("another.m", "Objective C")
	trackStringMatch("main.go", "Go")
	trackStringMatch("second.go", "Go")
	trackStringMatch("build.build", "NAnt script")
	trackStringMatch("Dockerfile", "Docker")

	got := uniqueStringMatchExtensions()
	want := []string{".build", ".go"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("uniqueStringMatchExtensions() = %#v, want %#v", got, want)
	}
}

func TestReassignCHeaderCountsCarriesAllCounts(t *testing.T) {
	counts := map[string]countRecord{
		"C-header": {language: "C-header", slinecount: 7, llinecount: 3, filecount: 2},
		"C":        {language: "C", slinecount: 11, llinecount: 5, filecount: 1},
	}

	reassignCHeaderCounts(counts)

	if _, ok := counts["C-header"]; ok {
		t.Fatalf("C-header entry remains after reassignment")
	}
	got := counts["C"]
	if got.slinecount != 18 || got.llinecount != 8 || got.filecount != 3 {
		t.Fatalf("C counts = %#v, want SLOC=18 LLOC=8 filecount=3", got)
	}
}

func TestHasNeverInterestingSuffixHandlesCompoundSuffixes(t *testing.T) {
	if !hasNeverInterestingSuffix("dist/app.min.js") {
		t.Fatalf("hasNeverInterestingSuffix() did not match .min.js")
	}
	if hasNeverInterestingSuffix("dist/app.js") {
		t.Fatalf("hasNeverInterestingSuffix() matched plain .js")
	}
}

func TestCountGenericUsesLongestExtension(t *testing.T) {
	oldLanguages := genericLanguages
	genericLanguages = []genericLanguage{
		{Name: "Kotlin Script", Ext: []string{".kts"}, Bc: []delimSpec{{Open: "//", Close: "\n"}}},
		{Name: "Gradle Kotlin Script", Ext: []string{".gradle.kts"}, Bc: []delimSpec{{Open: "//", Close: "\n"}}},
	}
	defer func() { genericLanguages = oldLanguages }()

	dir := t.TempDir()
	path := filepath.Join(dir, "build.gradle.kts")
	if err := os.WriteFile(path, []byte("plugins {}\n"), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", path, err)
	}

	stats := countGeneric(path)
	if len(stats) != 1 {
		t.Fatalf("countGeneric(%q) returned %d stats, want 1", path, len(stats))
	}
	if stats[0].Language != "Gradle Kotlin Script" {
		t.Fatalf("countGeneric(%q) language = %q, want %q", path, stats[0].Language, "Gradle Kotlin Script")
	}
}

func TestInWafBuildDirUsesCandidatePath(t *testing.T) {
	cwd := t.TempDir()
	if err := os.WriteFile(filepath.Join(cwd, "waf"), []byte("#!/usr/bin/env python3\n"), 0644); err != nil {
		t.Fatalf("WriteFile(waf): %v", err)
	}
	if err := os.Mkdir(filepath.Join(cwd, "build"), 0755); err != nil {
		t.Fatalf("Mkdir(build): %v", err)
	}
	originalWd, err := os.Getwd()
	if err != nil {
		t.Fatalf("Getwd(): %v", err)
	}
	if err := os.Chdir(cwd); err != nil {
		t.Fatalf("Chdir(%q): %v", cwd, err)
	}
	defer func() {
		if err := os.Chdir(originalWd); err != nil {
			t.Fatalf("Chdir(%q): %v", originalWd, err)
		}
	}()

	other := t.TempDir()
	otherBuildFile := filepath.Join(other, "build")
	if err := os.WriteFile(otherBuildFile, []byte("not a waf build directory\n"), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", otherBuildFile, err)
	}
	if inWafBuildDir(otherBuildFile) {
		t.Fatalf("inWafBuildDir(%q) = true, want false", otherBuildFile)
	}

	project := t.TempDir()
	if err := os.WriteFile(filepath.Join(project, "waf"), []byte("#!/usr/bin/env python3\n"), 0644); err != nil {
		t.Fatalf("WriteFile(project waf): %v", err)
	}
	buildDir := filepath.Join(project, "build")
	if err := os.Mkdir(buildDir, 0755); err != nil {
		t.Fatalf("Mkdir(%q): %v", buildDir, err)
	}
	buildFile := filepath.Join(buildDir, "generated.go")
	if err := os.WriteFile(buildFile, []byte("package main\n"), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", buildFile, err)
	}
	if !inWafBuildDir(buildFile) {
		t.Fatalf("inWafBuildDir(%q) = false, want true", buildFile)
	}
}

func TestWalkRootsContinuesAfterInvalidRoot(t *testing.T) {
	dir := t.TempDir()
	validPath := filepath.Join(dir, "valid.go")
	if err := os.WriteFile(validPath, []byte("package main\nfunc main() {}\n"), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", validPath, err)
	}

	oldPipeline := pipeline
	pipeline = make(chan SourceStat, 4)
	defer func() { pipeline = oldPipeline }()

	walkRoots([]string{filepath.Join(dir, "missing.go"), validPath}, true, false, 1)

	for len(pipeline) > 0 {
		st := <-pipeline
		if st.Path == validPath && st.Language == "Go" && st.SLOC > 0 {
			return
		}
	}
	t.Fatalf("walkRoots did not process valid root after invalid root")
}

func TestInferUniqueContentMatchedTypes(t *testing.T) {
	stringMatchMu.Lock()
	stringMatchLanguages = make(map[string]map[string]struct{})
	stringMatchMu.Unlock()

	dir := t.TempDir()

	matchedPath := filepath.Join(dir, "matched.bas")
	matchedSource := "Dim answer As Integer\nanswer = 42\n"
	if err := os.WriteFile(matchedPath, []byte(matchedSource), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", matchedPath, err)
	}

	ambiguousPath := filepath.Join(dir, "ambiguous.bas")
	ambiguousSource := "PRINT \"hello\"\n"
	if err := os.WriteFile(ambiguousPath, []byte(ambiguousSource), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", ambiguousPath, err)
	}

	matchedStats := countGeneric(matchedPath)
	ambiguousStats := countGeneric(ambiguousPath)
	if len(matchedStats) != 1 || len(ambiguousStats) != 1 {
		t.Fatalf("unexpected countGeneric result lengths: matched=%d ambiguous=%d", len(matchedStats), len(ambiguousStats))
	}
	if ambiguousStats[0].Language != "BASIC" {
		t.Fatalf("countGeneric(%q) language = %q, want %q before inference", ambiguousPath, ambiguousStats[0].Language, "BASIC")
	}
	if ambiguousStats[0].contentMatch {
		t.Fatalf("countGeneric(%q) contentMatch = true, want false before inference", ambiguousPath)
	}

	stats := []SourceStat{matchedStats[0], ambiguousStats[0]}
	inferUniqueContentMatchedTypes(stats, true)

	if stats[1].Language != "FreeBasic" {
		t.Fatalf("inferred language = %q, want %q", stats[1].Language, "FreeBasic")
	}
	if stats[1].contentMatch {
		t.Fatalf("inferred stat contentMatch = true, want false")
	}
	if stats[1].SLOC == 0 {
		t.Fatalf("inferred stat SLOC = 0, want non-zero")
	}
}

func TestInferUniqueContentMatchedTypesIgnoresSingleLanguageVariants(t *testing.T) {
	stringMatchMu.Lock()
	stringMatchLanguages = make(map[string]map[string]struct{})
	stringMatchMu.Unlock()

	dir := t.TempDir()

	matchedPath := filepath.Join(dir, "matched.inc")
	matchedSource := "unit SampleInc;\n\ninterface\n\nprocedure Hello;\n\nimplementation\n\nprocedure Hello;\nbegin\n  WriteLn('Hello, world');\nend;\n\nbegin\nend.\n"
	if err := os.WriteFile(matchedPath, []byte(matchedSource), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", matchedPath, err)
	}

	failPath := filepath.Join(dir, "fail.inc")
	failSource := "procedure Hello;\nbegin\n  WriteLn('Hello, world');\nend;\n"
	if err := os.WriteFile(failPath, []byte(failSource), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", failPath, err)
	}

	matchedStats := countGeneric(matchedPath)
	failStats := countGeneric(failPath)
	if len(matchedStats) != 1 || len(failStats) != 1 {
		t.Fatalf("unexpected countGeneric result lengths: matched=%d fail=%d", len(matchedStats), len(failStats))
	}
	if matchedStats[0].Language != "Pascal" || !matchedStats[0].contentMatch {
		t.Fatalf("matched Pascal .inc classification = %#v, want Pascal with contentMatch", matchedStats[0])
	}
	if failStats[0].Language != "" {
		t.Fatalf("countGeneric(%q) language = %q, want unknown before inference", failPath, failStats[0].Language)
	}

	stats := []SourceStat{matchedStats[0], failStats[0]}
	inferUniqueContentMatchedTypes(stats, true)

	if stats[1].Language != "" {
		t.Fatalf("single-language variant inference changed language to %q, want unknown", stats[1].Language)
	}
}

func TestInferUniqueContentMatchedTypesCanBeDisabled(t *testing.T) {
	stringMatchMu.Lock()
	stringMatchLanguages = make(map[string]map[string]struct{})
	stringMatchMu.Unlock()

	dir := t.TempDir()

	matchedPath := filepath.Join(dir, "matched.bas")
	if err := os.WriteFile(matchedPath, []byte("Dim answer As Integer\nanswer = 42\n"), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", matchedPath, err)
	}

	ambiguousPath := filepath.Join(dir, "ambiguous.bas")
	if err := os.WriteFile(ambiguousPath, []byte("PRINT \"hello\"\n"), 0644); err != nil {
		t.Fatalf("WriteFile(%q): %v", ambiguousPath, err)
	}

	matchedStats := countGeneric(matchedPath)
	ambiguousStats := countGeneric(ambiguousPath)
	if len(matchedStats) != 1 || len(ambiguousStats) != 1 {
		t.Fatalf("unexpected countGeneric result lengths: matched=%d ambiguous=%d", len(matchedStats), len(ambiguousStats))
	}

	stats := []SourceStat{matchedStats[0], ambiguousStats[0]}
	inferUniqueContentMatchedTypes(stats, false)

	if stats[1].Language != "BASIC" {
		t.Fatalf("disabled inference changed language to %q, want BASIC", stats[1].Language)
	}
}
