package project_test

import (
	"context"
	"slices"
	"strings"
	"testing"

	"github.com/microsoft/typescript-go/internal/bundled"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/diagnostics"
	"github.com/microsoft/typescript-go/internal/ls/lsutil"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/project"
	"github.com/microsoft/typescript-go/internal/testutil/projecttestutil"
	"github.com/microsoft/typescript-go/internal/tspath"
	"gotest.tools/v3/assert"
)

type publishDiagnosticsCall = struct {
	Ctx    context.Context
	Params *lsproto.PublishDiagnosticsParams
}

// filterDiagnosticsByURI returns all PublishDiagnostics calls matching the given URI,
// starting from the given index.
func filterDiagnosticsByURI(calls []publishDiagnosticsCall, uri lsproto.DocumentUri, from int) []publishDiagnosticsCall {
	var result []publishDiagnosticsCall
	for i := from; i < len(calls); i++ {
		if calls[i].Params.Uri == uri {
			result = append(result, calls[i])
		}
	}
	return result
}

// These tests explicitly verify ProgramUpdateKind using subtests with shared helpers.
func TestProjectProgramUpdateKind(t *testing.T) {
	t.Parallel()
	if !bundled.Embedded {
		t.Skip("bundled files are not embedded")
	}

	// Use the default session setup for tests.

	t.Run("NewFiles on initial build", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": "{}",
			"/src/index.ts":      "export const x = 1;",
		}
		session, _ := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		snapshot := session.Snapshot()
		configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
		assert.Assert(t, configured != nil)
		assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindNewFiles)
	})

	t.Run("Cloned on single-file change", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": "{}",
			"/src/index.ts":      "console.log('Hello');",
		}
		session, _ := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.DidChangeFile(context.Background(), "file:///src/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{
			Partial: &lsproto.TextDocumentContentChangePartial{Text: "\n", Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 20}, End: lsproto.Position{Line: 0, Character: 20}}},
		}})
		_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		snapshot := session.Snapshot()
		configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
		assert.Assert(t, configured != nil)
		assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindCloned)
	})

	t.Run("SameFileNames on config change without root changes", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": `{"compilerOptions": {"strict": true}}`,
			"/src/index.ts":      "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		err = utils.FS().WriteFile("/src/tsconfig.json", `{"compilerOptions": {"strict": false}}`)
		assert.NilError(t, err)
		session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), Type: lsproto.FileChangeTypeChanged}})
		_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		snapshot := session.Snapshot()
		configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
		assert.Assert(t, configured != nil)
		assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames)
	})

	t.Run("NewFiles on root addition", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": "{}",
			"/src/index.ts":      "export {}",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		content := "export const y = 2;"
		err = utils.FS().WriteFile("/src/newfile.ts", content)
		assert.NilError(t, err)
		session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/newfile.ts"), Type: lsproto.FileChangeTypeCreated}})
		session.DidOpenFile(context.Background(), "file:///src/newfile.ts", 1, content, lsproto.LanguageKindTypeScript)
		_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/newfile.ts"))
		assert.NilError(t, err)
		snapshot := session.Snapshot()
		configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
		assert.Assert(t, configured != nil)
		assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindNewFiles)
	})

	t.Run("SameFileNames when adding an unresolvable import with multi-file change", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": "{}",
			"/src/index.ts":      "export const x = 1;",
			"/src/other.ts":      "export const z = 3;",
		}
		session, _ := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		// Change index.ts to add an unresolvable import
		session.DidChangeFile(context.Background(), "file:///src/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{
			Partial: &lsproto.TextDocumentContentChangePartial{Text: "\nimport \"./does-not-exist\";\n", Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 0}, End: lsproto.Position{Line: 0, Character: 0}}},
		}})
		_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		snapshot := session.Snapshot()
		configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
		assert.Assert(t, configured != nil)
		assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames)
	})
}

func TestProject(t *testing.T) {
	t.Parallel()
	if !bundled.Embedded {
		t.Skip("bundled files are not embedded")
	}

	t.Run("commandLineWithTypingsFiles is reset on CommandLine change", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/user/username/projects/project1/app.js":       ``,
			"/user/username/projects/project1/package.json": `{"name":"p1","dependencies":{"jquery":"^3.1.0"}}`,
			"/user/username/projects/project2/app.js":       ``,
		}

		session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{
			PackageToFile: map[string]string{
				// Provide typings content to be installed for jquery so ATA actually installs something
				"jquery": `declare const $: { x: number }`,
			},
		})

		// 1) Open an inferred project file that triggers ATA
		uri1 := lsproto.DocumentUri("file:///user/username/projects/project1/app.js")
		session.DidOpenFile(context.Background(), uri1, 1, files["/user/username/projects/project1/app.js"].(string), lsproto.LanguageKindJavaScript)

		// 2) Wait for ATA/background tasks to finish, then get a language service for the first file
		session.WaitForBackgroundTasks()
		// Sanity check: ensure ATA performed at least one install
		npmCalls := utils.NpmExecutor().NpmInstallCalls()
		assert.Assert(t, len(npmCalls) > 0, "expected at least one npm install call from ATA")
		_, err := session.GetLanguageService(context.Background(), uri1)
		assert.NilError(t, err)

		// 3) Open another inferred project file
		uri2 := lsproto.DocumentUri("file:///user/username/projects/project2/app.js")
		session.DidOpenFile(context.Background(), uri2, 1, ``, lsproto.LanguageKindJavaScript)

		// 4) Get a language service for the second file
		//    If commandLineWithTypingsFiles was not reset, the new program command line
		//    won't include the newly opened file and this will fail.
		_, err = session.GetLanguageService(context.Background(), uri2)
		assert.NilError(t, err)
	})

	t.Run("inferred project rebuilt twice in one snapshot after typings install does not crash", func(t *testing.T) {
		t.Parallel()
		// Reproduces the nil-command-line crash in (*Program).SingleThreaded (issue #3873).
		//
		// `getCommandLineWithTypingsFiles` memoizes `commandLineWithTypingsFiles` behind a
		// `sync.Once`. When `CommandLine` (or the inferred roots) change, the cached value is
		// reset to nil but the `Once` is NOT reset. `Project.Clone()` produces a fresh `Once`,
		// so the first mutation of a project within a snapshot normally clears the staleness.
		// But if the *same* in-snapshot clone has its program built, then its roots change, then
		// its program is built again, the second build hits a consumed `Once` and reads the nil
		// cached value, passing a nil command line to `compiler.NewProgram`.
		//
		// This sequence is produced within a single snapshot by `DidRequestFile`'s
		// build -> cleanupInferredProject -> build path, when:
		//   1. the inferred project has installed typings (so the `Once` path is taken), and
		//   2. a pending content change marks the inferred project dirty (forcing build #1), and
		//   3. a pending tsconfig creation moves another open file out of the inferred project,
		//      so `cleanupInferredProject` changes the inferred roots between the two builds.
		files := map[string]any{
			"/user/username/projects/project1/a.js":         ``,
			"/user/username/projects/project1/package.json": `{"name":"p1","dependencies":{"jquery":"^3.1.0"}}`,
			"/user/username/projects/project1/b.ts":         `export const y = 1;`,
		}

		session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{
			PackageToFile: map[string]string{
				"jquery": `declare const $: { x: number }`,
			},
		})

		aURI := lsproto.DocumentUri("file:///user/username/projects/project1/a.js")
		bURI := lsproto.DocumentUri("file:///user/username/projects/project1/b.ts")

		// 1) Open the file that triggers ATA, plus another file that joins the inferred project.
		session.DidOpenFile(context.Background(), aURI, 1, ``, lsproto.LanguageKindJavaScript)
		session.DidOpenFile(context.Background(), bURI, 1, files["/user/username/projects/project1/b.ts"].(string), lsproto.LanguageKindTypeScript)

		// 2) Let ATA install jquery typings, then build the inferred program so its
		//    typings files are populated.
		session.WaitForBackgroundTasks()
		npmCalls := utils.NpmExecutor().NpmInstallCalls()
		assert.Assert(t, len(npmCalls) > 0, "expected at least one npm install call from ATA")
		_, err := session.GetLanguageService(context.Background(), aURI)
		assert.NilError(t, err)

		// 3) Queue two pending changes that will be flushed together in the next snapshot:
		//    - a content change to a.js (marks the inferred project dirty -> first program build)
		//    - creation of a tsconfig.json that captures b.ts (moves it out of the inferred
		//      project, so cleanupInferredProject shrinks the inferred roots between builds).
		session.DidChangeFile(context.Background(), aURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `// changed`}},
		})
		err = utils.FS().WriteFile("/user/username/projects/project1/tsconfig.json", `{"compilerOptions":{},"files":["b.ts"]}`)
		assert.NilError(t, err)
		session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{
			{
				Type: lsproto.FileChangeTypeCreated,
				Uri:  "file:///user/username/projects/project1/tsconfig.json",
			},
		})

		// 4) Flush the pending changes and the request in a single snapshot. With the bug,
		//    the inferred project's program is built, its roots change, and it is rebuilt with
		//    a nil command line, panicking in (*Program).SingleThreaded.
		defer func() {
			if r := recover(); r != nil {
				t.Fatalf("crash reproduced (issue #3873): %v", r)
			}
		}()
		_, err = session.GetLanguageService(context.Background(), aURI)
		assert.NilError(t, err)
	})
}

func TestPushDiagnostics(t *testing.T) {
	t.Parallel()
	if !bundled.Embedded {
		t.Skip("bundled files are not embedded")
	}

	t.Run("publishes program diagnostics on initial program creation", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
			"/src/index.ts":      "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)

		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		assert.Assert(t, len(calls) > 0, "expected at least one PublishDiagnostics call")

		// Find the call for tsconfig.json
		var tsconfigCall *struct {
			Ctx    context.Context
			Params *lsproto.PublishDiagnosticsParams
		}
		for i := range calls {
			if calls[i].Params.Uri == "file:///src/tsconfig.json" {
				tsconfigCall = &calls[i]
				break
			}
		}
		assert.Assert(t, tsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
		assert.Assert(t, len(tsconfigCall.Params.Diagnostics) > 0, "expected at least one diagnostic")
	})

	t.Run("publishes config parsing diagnostics on initial program creation", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": `{
				"compilerOptions": {
					"target": "nope"
				}
			}`,
			"/src/index.ts": "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)

		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
		assert.Assert(t, len(tsconfigCalls) > 0, "expected PublishDiagnostics call for tsconfig.json")
		lastTsconfigCall := tsconfigCalls[len(tsconfigCalls)-1]

		expectedMessage := "Argument for '--target' option must be:"
		assert.Assert(t, slices.ContainsFunc(lastTsconfigCall.Params.Diagnostics, func(diag *lsproto.Diagnostic) bool {
			return strings.Contains(diag.Message.AsString(), expectedMessage)
		}), "expected invalid target diagnostic on tsconfig.json, got: %v", lastTsconfigCall.Params.Diagnostics)
	})

	t.Run("clears diagnostics when project is removed", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json":  `{"compilerOptions": {"baseUrl": "."}}`,
			"/src/index.ts":       "export const x = 1;",
			"/src2/tsconfig.json": `{"compilerOptions": {}}`,
			"/src2/index.ts":      "export const y = 2;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		// Open a file in a different project to trigger cleanup of the first
		session.DidCloseFile(context.Background(), "file:///src/index.ts")
		session.DidOpenFile(context.Background(), "file:///src2/index.ts", 1, files["/src2/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src2/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		// Should have at least one call for the first project with diagnostics,
		// and one clearing it after switching projects
		var firstProjectCalls []struct {
			Ctx    context.Context
			Params *lsproto.PublishDiagnosticsParams
		}
		for i := range calls {
			if calls[i].Params.Uri == "file:///src/tsconfig.json" {
				firstProjectCalls = append(firstProjectCalls, calls[i])
			}
		}
		assert.Assert(t, len(firstProjectCalls) >= 2, "expected at least 2 PublishDiagnostics calls for first project")
		// Last call should clear diagnostics
		lastCall := firstProjectCalls[len(firstProjectCalls)-1]
		assert.Equal(t, len(lastCall.Params.Diagnostics), 0, "expected empty diagnostics after project cleanup")
	})

	t.Run("updates diagnostics when program changes", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
			"/src/index.ts":      "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		initialCallCount := len(utils.Client().PublishDiagnosticsCalls())

		// Change the tsconfig to remove baseUrl
		err = utils.FS().WriteFile("/src/tsconfig.json", `{"compilerOptions": {}}`)
		assert.NilError(t, err)
		session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), Type: lsproto.FileChangeTypeChanged}})
		_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		assert.Assert(t, len(calls) > initialCallCount, "expected additional PublishDiagnostics call after change")

		// Find the last call for tsconfig.json
		var lastTsconfigCall *struct {
			Ctx    context.Context
			Params *lsproto.PublishDiagnosticsParams
		}
		for i := len(calls) - 1; i >= 0; i-- {
			if calls[i].Params.Uri == "file:///src/tsconfig.json" {
				lastTsconfigCall = &calls[i]
				break
			}
		}
		assert.Assert(t, lastTsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
		// After fixing the error, there should be no program diagnostics
		assert.Equal(t, len(lastTsconfigCall.Params.Diagnostics), 0, "expected no diagnostics after removing baseUrl option")
	})

	t.Run("does not publish for inferred projects", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/index.ts": "let x: number = 'not a number';",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		// Should not have any calls since inferred projects don't have tsconfig.json
		assert.Equal(t, len(calls), 0, "expected no PublishDiagnostics calls for inferred projects")
	})

	t.Run("does not publish when validation is disabled", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": `{
				"compilerOptions": {
					"target": "nope"
				}
			}`,
			"/src/index.ts": "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		prefs := lsutil.NewDefaultUserPreferences()
		prefs.EnableValidation = core.TSFalse
		session.Configure(prefs)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		for _, call := range calls {
			assert.Equal(t, len(call.Params.Diagnostics), 0, "expected only empty PublishDiagnostics calls when validation is disabled")
		}
	})

	t.Run("clears diagnostics when validation is disabled", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": `{
				"compilerOptions": {
					"target": "nope"
				}
			}`,
			"/src/index.ts": "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
		assert.Assert(t, len(tsconfigCalls) > 0, "expected initial PublishDiagnostics call for tsconfig.json")
		assert.Assert(t, len(tsconfigCalls[len(tsconfigCalls)-1].Params.Diagnostics) > 0, "expected initial diagnostics")

		prefs := lsutil.NewDefaultUserPreferences()
		prefs.EnableValidation = core.TSFalse
		session.Configure(prefs)
		_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		calls = utils.Client().PublishDiagnosticsCalls()
		tsconfigCalls = filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", len(calls)-1)
		if len(tsconfigCalls) == 0 {
			tsconfigCalls = filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
		}
		lastTsconfigCall := tsconfigCalls[len(tsconfigCalls)-1]
		assert.Equal(t, len(lastTsconfigCall.Params.Diagnostics), 0, "expected diagnostics to be cleared when validation is disabled")
	})

	t.Run("publishes global diagnostics after checking", func(t *testing.T) {
		t.Parallel()
		// Use a target/lib that does not include Disposable, then write code that needs it.
		// This triggers a deferred "Cannot find global type 'Disposable'" global diagnostic
		// during checking, which should be accumulated and published on the tsconfig URI.
		files := map[string]any{
			"/src/tsconfig.json": `{
				"compilerOptions": {
					"target": "es2020"
				}
			}`,
			"/src/index.ts": `export function f() {
				using x = { [Symbol.dispose]() {} };
			}`,
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		// Request semantic diagnostics to trigger checking, which triggers the global type resolvers.
		ls, err := session.GetLanguageService(projecttestutil.WithRequestID(context.Background()), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		// Drain background tasks from DidOpenFile (publishProgramDiagnostics, etc.)
		// before triggering global diagnostics, to avoid racing with publishGlobalDiagnostics.
		session.WaitForBackgroundTasks()

		_, err = ls.ProvideDiagnostics(projecttestutil.WithRequestID(context.Background()), lsproto.DocumentUri("file:///src/index.ts"))
		assert.NilError(t, err)
		// Enqueue global diagnostics publishing (normally done by the LSP server after each request).
		session.EnqueuePublishGlobalDiagnostics()
		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		// Find the last call for tsconfig.json
		var lastTsconfigCall *struct {
			Ctx    context.Context
			Params *lsproto.PublishDiagnosticsParams
		}
		for i := len(calls) - 1; i >= 0; i-- {
			if calls[i].Params.Uri == "file:///src/tsconfig.json" {
				lastTsconfigCall = &calls[i]
				break
			}
		}
		assert.Assert(t, lastTsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
		// Should have global diagnostics (e.g., Cannot find global type 'Disposable')
		hasGlobalDiag := false
		for _, diag := range lastTsconfigCall.Params.Diagnostics {
			if strings.Contains(diag.Message.AsString(), "Cannot find global") {
				hasGlobalDiag = true
				break
			}
		}
		assert.Assert(t, hasGlobalDiag, "expected a 'Cannot find global' diagnostic on tsconfig.json, got: %v", lastTsconfigCall.Params.Diagnostics)
	})

	t.Run("cleans tsconfig diagnostics after TS files close and restores them after TS file is reopened", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
			"/src/index.ts":      "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		uri := lsproto.DocumentUri("file:///src/index.ts")
		session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), uri)
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		calls := utils.Client().PublishDiagnosticsCalls()
		tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
		assert.Assert(t, len(tsconfigCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after opening file")
		assert.Equal(t, len(tsconfigCalls[0].Params.Diagnostics), 1, "expected one diagnostic on tsconfig.json after opening file")

		callsBeforeClose := len(calls)

		session.DidCloseFile(context.Background(), uri)
		session.WaitForBackgroundTasks()

		// Cleans up diagnostics after close
		calls = utils.Client().PublishDiagnosticsCalls()
		clearCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", callsBeforeClose)
		assert.Assert(t, len(clearCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after project close")
		lastClearCall := clearCalls[len(clearCalls)-1]
		assert.Equal(t, len(lastClearCall.Params.Diagnostics), 0, "expected empty diagnostics after project close")

		callsBeforeReopen := len(calls)

		session.DidOpenFile(context.Background(), uri, 2, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
		_, err = session.GetLanguageService(context.Background(), uri)
		assert.NilError(t, err)
		session.WaitForBackgroundTasks()

		// Restores diagnostics after reopen
		calls = utils.Client().PublishDiagnosticsCalls()
		reopenedCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", callsBeforeReopen)
		assert.Assert(t, len(reopenedCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after reopening file")
		lastReopenedCall := reopenedCalls[len(reopenedCalls)-1]
		assert.Equal(t, len(lastReopenedCall.Params.Diagnostics), 1, "expected one diagnostic on tsconfig.json after reopening file")
	})
}

func TestDisplayName(t *testing.T) {
	t.Parallel()
	if !bundled.Embedded {
		t.Skip("bundled files are not embedded")
	}

	t.Run("configured project returns relative config path", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/home/projects/tsconfig.json": `{}`,
			"/home/projects/index.ts":      "export const x = 1;",
		}
		session, _ := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
		assert.NilError(t, err)

		snapshot := session.Snapshot()
		configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/tsconfig.json"))
		assert.Assert(t, configured != nil)
		assert.Equal(t, configured.DisplayName("/home/projects"), "tsconfig.json")
	})

	t.Run("configured project with nested config", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/home/projects/sub/tsconfig.json": `{}`,
			"/home/projects/sub/index.ts":      "export const x = 1;",
		}
		session, _ := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///home/projects/sub/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/sub/index.ts"))
		assert.NilError(t, err)

		snapshot := session.Snapshot()
		configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/sub/tsconfig.json"))
		assert.Assert(t, configured != nil)
		assert.Equal(t, configured.DisplayName("/home/projects"), "sub/tsconfig.json")
	})

	t.Run("inferred project returns directory base name", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/home/projects/index.ts": "export const x = 1;",
		}
		session, _ := projecttestutil.SetupWithOptions(files, &project.SessionOptions{
			CurrentDirectory:       "/home/projects",
			DefaultLibraryPath:     bundled.LibPath(),
			PositionEncoding:       lsproto.PositionEncodingKindUTF8,
			WatchEnabled:           true,
			LoggingEnabled:         true,
			PushDiagnosticsEnabled: true,
		})
		session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
		assert.NilError(t, err)

		snapshot := session.Snapshot()
		inferred := snapshot.ProjectCollection.InferredProject()
		assert.Assert(t, inferred != nil)
		name := inferred.DisplayName("/home")
		assert.Equal(t, name, "projects")
	})
}

func TestProgressNotifications(t *testing.T) {
	t.Parallel()
	if !bundled.Embedded {
		t.Skip("bundled files are not embedded")
	}

	t.Run("emits progress for configured project loading", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/home/projects/tsconfig.json": `{}`,
			"/home/projects/index.ts":      "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
		assert.NilError(t, err)

		startCalls := utils.Client().ProgressStartCalls()
		finishCalls := utils.Client().ProgressFinishCalls()

		assert.Assert(t, len(startCalls) > 0, "expected at least one ProgressStart call")
		assert.Assert(t, len(finishCalls) > 0, "expected at least one ProgressFinish call")

		foundProjectStart := false
		for _, call := range startCalls {
			if call.Message == diagnostics.Project_0 {
				foundProjectStart = true
				break
			}
		}
		assert.Assert(t, foundProjectStart, "expected ProgressStart with Project_0 message")

		foundProjectFinish := false
		for _, call := range finishCalls {
			if call.Message == diagnostics.Project_0 {
				foundProjectFinish = true
				break
			}
		}
		assert.Assert(t, foundProjectFinish, "expected ProgressFinish with Project_0 message")
	})

	t.Run("emits progress for inferred project loading", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/home/projects/index.ts": "export const x = 1;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
		assert.NilError(t, err)

		startCalls := utils.Client().ProgressStartCalls()
		finishCalls := utils.Client().ProgressFinishCalls()

		assert.Assert(t, len(startCalls) > 0, "expected at least one ProgressStart call")
		assert.Assert(t, len(finishCalls) > 0, "expected at least one ProgressFinish call")

		foundProjectStart := false
		for _, call := range startCalls {
			if call.Message == diagnostics.Project_0 {
				foundProjectStart = true
				break
			}
		}
		assert.Assert(t, foundProjectStart, "expected ProgressStart with Project_0 message")
	})

	t.Run("each start has a matching finish", func(t *testing.T) {
		t.Parallel()
		files := map[string]any{
			"/home/projects/tsconfig.json": `{}`,
			"/home/projects/a.ts":          "export const a = 1;",
			"/home/projects/b.ts":          "export const b = 2;",
		}
		session, utils := projecttestutil.Setup(files)
		session.DidOpenFile(context.Background(), "file:///home/projects/a.ts", 1, "export const a = 1;", lsproto.LanguageKindTypeScript)
		_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/a.ts"))
		assert.NilError(t, err)

		startCalls := utils.Client().ProgressStartCalls()
		finishCalls := utils.Client().ProgressFinishCalls()

		starts := 0
		finishes := 0
		for _, call := range startCalls {
			if call.Message == diagnostics.Project_0 {
				starts++
			}
		}
		for _, call := range finishCalls {
			if call.Message == diagnostics.Project_0 {
				finishes++
			}
		}
		assert.Equal(t, starts, finishes, "ProgressStart and ProgressFinish calls for Project_0 should be balanced")
	})
}
