package project

import (
	"context"
	"fmt"
	"slices"
	"strings"
	"sync"

	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/compiler"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/ls"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/project/ata"
	"github.com/microsoft/typescript-go/internal/project/logging"
	"github.com/microsoft/typescript-go/internal/tsoptions"
	"github.com/microsoft/typescript-go/internal/tspath"
)

const (
	inferredProjectName = "/dev/null/inferred" // lowercase so toPath is a no-op regardless of settings
	hr                  = "-----------------------------------------------"
)

//go:generate go tool golang.org/x/tools/cmd/stringer -type=Kind -trimprefix=Kind -output=project_stringer_generated.go
//go:generate npx dprint fmt project_stringer_generated.go

type Kind int

const (
	KindInferred Kind = iota
	KindConfigured
)

type ProgramUpdateKind int

const (
	ProgramUpdateKindNone ProgramUpdateKind = iota
	ProgramUpdateKindCloned
	ProgramUpdateKindSameFileNames
	ProgramUpdateKindNewFiles
)

type PendingReload int

const (
	PendingReloadNone PendingReload = iota
	PendingReloadFileNames
	PendingReloadFull
)

// Project represents a TypeScript project.
// If changing struct fields, also update the Clone method.
type Project struct {
	Kind             Kind
	currentDirectory string
	configFileName   string
	configFilePath   tspath.Path

	dirty         bool
	dirtyFilePath tspath.Path

	host                            *compilerHost
	CommandLine                     *tsoptions.ParsedCommandLine
	commandLineWithTypingsFiles     *tsoptions.ParsedCommandLine
	commandLineWithTypingsFilesOnce sync.Once
	Program                         *compiler.Program
	// The kind of update that was performed on the program last time it was updated.
	ProgramUpdateKind ProgramUpdateKind
	// The ID of the snapshot that created the program stored in this project.
	ProgramLastUpdate uint64
	// Set of projects that this project could be referencing.
	// Only set before actually loading config file to get actual project references
	potentialProjectReferences *collections.Set[tspath.Path]

	programFilesWatch *WatchedFiles[*collections.SyncSet[tspath.Path]]
	typingsWatch      *WatchedFiles[PatternsAndIgnored]

	checkerPool *checkerPool

	// installedTypingsInfo is the value of `project.ComputeTypingsInfo()` that was
	// used during the most recently completed typings installation.
	installedTypingsInfo *ata.TypingsInfo
	// typingsFiles are the root files added by the typings installer.
	typingsFiles []string
}

var _ ls.Project = (*Project)(nil)

func NewConfiguredProject(
	configFileName string,
	configFilePath tspath.Path,
	builder *ProjectCollectionBuilder,
	logger *logging.LogTree,
) *Project {
	return NewProject(configFileName, KindConfigured, tspath.GetDirectoryPath(configFileName), builder, logger)
}

func NewInferredProject(
	currentDirectory string,
	compilerOptions *core.CompilerOptions,
	rootFileNames []string,
	builder *ProjectCollectionBuilder,
	logger *logging.LogTree,
) *Project {
	p := NewProject(inferredProjectName, KindInferred, currentDirectory, builder, logger)
	if compilerOptions == nil {
		compilerOptions = &core.CompilerOptions{
			AllowJs:                    core.TSTrue,
			Module:                     core.ModuleKindESNext,
			ModuleResolution:           core.ModuleResolutionKindBundler,
			Target:                     core.ScriptTargetLatestStandard,
			Jsx:                        core.JsxEmitReactJSX,
			AllowImportingTsExtensions: core.TSTrue,
			StrictNullChecks:           core.TSTrue,
			StrictFunctionTypes:        core.TSTrue,
			SourceMap:                  core.TSTrue,
			AllowNonTsExtensions:       core.TSTrue,
			ResolveJsonModule:          core.TSTrue,
		}
	}
	p.CommandLine = tsoptions.NewParsedCommandLine(
		compilerOptions,
		rootFileNames,
		tspath.ComparePathsOptions{
			UseCaseSensitiveFileNames: builder.fs.fs.UseCaseSensitiveFileNames(),
			CurrentDirectory:          currentDirectory,
		},
	)
	return p
}

func NewProject(
	configFileName string,
	kind Kind,
	currentDirectory string,
	builder *ProjectCollectionBuilder,
	logger *logging.LogTree,
) *Project {
	if logger != nil {
		logger.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), configFileName, currentDirectory))
	}
	project := &Project{
		configFileName:   configFileName,
		Kind:             kind,
		currentDirectory: currentDirectory,
		dirty:            true,
	}

	project.configFilePath = tspath.ToPath(configFileName, currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames())
	project.programFilesWatch = NewWatchedFiles(
		"program files for "+configFileName,
		lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete,
		lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport,
		createResolutionLookupGlobMapper(builder.sessionOptions.CurrentDirectory, builder.sessionOptions.DefaultLibraryPath, project.currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()),
	)
	if builder.sessionOptions.TypingsLocation != "" {
		project.typingsWatch = NewWatchedFiles(
			"typings installer files",
			lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete,
			lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport,
			core.Identity,
		)
	}
	return project
}

func (p *Project) Name() string {
	return p.configFileName
}

// DisplayName returns a short, human-readable name for the project,
// relative to the given workspace root directory.
// For configured projects, this is the config file path made relative.
// For inferred projects, this is the last component of the current directory.
func (p *Project) DisplayName(cwd string) string {
	if p.Kind == KindInferred {
		return tspath.GetBaseFileName(p.currentDirectory)
	}
	return tspath.ConvertToRelativePath(p.configFileName, tspath.ComparePathsOptions{
		CurrentDirectory: cwd,
	})
}

func (p *Project) ID() tspath.Path {
	return p.configFilePath
}

// ConfigFileName panics if Kind() is not KindConfigured.
func (p *Project) ConfigFileName() string {
	if p.Kind != KindConfigured {
		panic("ConfigFileName called on non-configured project")
	}
	return p.configFileName
}

// ConfigFilePath panics if Kind() is not KindConfigured.
func (p *Project) ConfigFilePath() tspath.Path {
	if p.Kind != KindConfigured {
		panic("ConfigFilePath called on non-configured project")
	}
	return p.configFilePath
}

func (p *Project) Id() tspath.Path {
	return p.configFilePath
}

func (p *Project) GetProgram() *compiler.Program {
	return p.Program
}

// GetProjectDiagnostics returns program diagnostics combined with any global
// diagnostics discovered during checking. These are the diagnostics reported on
// the tsconfig.json file.
func (p *Project) GetProjectDiagnostics(ctx context.Context) []*ast.Diagnostic {
	var globalDiags []*ast.Diagnostic
	if p.checkerPool != nil {
		globalDiags = p.checkerPool.GetGlobalDiagnostics()
	}
	return compiler.SortAndDeduplicateDiagnostics(slices.Concat(
		p.Program.GetConfigFileParsingDiagnostics(),
		p.Program.GetProgramDiagnostics(),
		globalDiags,
	))
}

func (p *Project) HasFile(fileName string) bool {
	return p.containsFile(p.toPath(fileName))
}

func (p *Project) containsFile(path tspath.Path) bool {
	return p.Program != nil && p.Program.GetSourceFileByPath(path) != nil
}

func (p *Project) IsSourceFromProjectReference(path tspath.Path) bool {
	return p.Program != nil && p.Program.IsSourceFromProjectReference(path)
}

func (p *Project) Clone() *Project {
	return &Project{
		Kind:             p.Kind,
		currentDirectory: p.currentDirectory,
		configFileName:   p.configFileName,
		configFilePath:   p.configFilePath,

		dirty:         p.dirty,
		dirtyFilePath: p.dirtyFilePath,

		host:                        p.host,
		CommandLine:                 p.CommandLine,
		commandLineWithTypingsFiles: p.commandLineWithTypingsFiles,
		Program:                     p.Program,
		ProgramUpdateKind:           ProgramUpdateKindNone,
		ProgramLastUpdate:           p.ProgramLastUpdate,
		potentialProjectReferences:  p.potentialProjectReferences,

		programFilesWatch: p.programFilesWatch,
		typingsWatch:      p.typingsWatch,

		checkerPool: p.checkerPool,

		installedTypingsInfo: p.installedTypingsInfo,
		typingsFiles:         p.typingsFiles,
	}
}

// SetCommandLine reassigns the project's command line and resets all state derived
// from it. Changing the command line always requires a full program rebuild, so the
// project is marked fully dirty. It also resets:
//   - the memoized command line augmented with typings files (and its sync.Once, so
//     the augmented command line is rebuilt from the new command line on next access);
//   - potentialProjectReferences, the pre-load placeholder derived from the old
//     command line (always nil for inferred projects, which have no project references).
func (p *Project) SetCommandLine(commandLine *tsoptions.ParsedCommandLine) {
	p.CommandLine = commandLine
	p.commandLineWithTypingsFiles = nil
	p.commandLineWithTypingsFilesOnce = sync.Once{}
	p.potentialProjectReferences = nil
	p.dirty = true
	p.dirtyFilePath = ""
}

// getCommandLineWithTypingsFiles returns the command line augmented with typing files if ATA is enabled.
func (p *Project) getCommandLineWithTypingsFiles() *tsoptions.ParsedCommandLine {
	if len(p.typingsFiles) == 0 {
		return p.CommandLine
	}

	// Check if ATA is enabled for this project
	typeAcquisition := p.GetTypeAcquisition()
	if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() {
		return p.CommandLine
	}

	p.commandLineWithTypingsFilesOnce.Do(func() {
		if p.commandLineWithTypingsFiles == nil {
			// Create an augmented command line that includes typing files
			originalRootNames := p.CommandLine.FileNames()
			newRootNames := make([]string, 0, len(originalRootNames)+len(p.typingsFiles))
			newRootNames = append(newRootNames, originalRootNames...)
			newRootNames = append(newRootNames, p.typingsFiles...)

			// Create a new ParsedCommandLine with the augmented root file names
			p.commandLineWithTypingsFiles = tsoptions.NewParsedCommandLine(
				p.CommandLine.CompilerOptions(),
				newRootNames,
				tspath.ComparePathsOptions{
					UseCaseSensitiveFileNames: p.host.FS().UseCaseSensitiveFileNames(),
					CurrentDirectory:          p.currentDirectory,
				},
			)
		}
	})
	return p.commandLineWithTypingsFiles
}

func (p *Project) setPotentialProjectReference(configFilePath tspath.Path) {
	if p.potentialProjectReferences == nil {
		p.potentialProjectReferences = &collections.Set[tspath.Path]{}
	} else {
		p.potentialProjectReferences = p.potentialProjectReferences.Clone()
	}
	p.potentialProjectReferences.Add(configFilePath)
}

func (p *Project) hasPotentialProjectReference(projectTreeRequest *ProjectTreeRequest) bool {
	if p.CommandLine != nil {
		for _, path := range p.CommandLine.ResolvedProjectReferencePaths() {
			if projectTreeRequest.IsProjectReferenced(p.toPath(path)) {
				return true
			}
		}
	} else if p.potentialProjectReferences != nil {
		for path := range p.potentialProjectReferences.Keys() {
			if projectTreeRequest.IsProjectReferenced(path) {
				return true
			}
		}
	}
	return false
}

type CreateProgramResult struct {
	Program    *compiler.Program
	UpdateKind ProgramUpdateKind
}

func (p *Project) CreateProgram() CreateProgramResult {
	updateKind := ProgramUpdateKindNewFiles
	var programCloned bool
	var newProgram *compiler.Program

	// Define a fresh CreateCheckerPool closure for this call. Each invocation of
	// CreateProgram must use its own closure so that concurrent goroutines cloning
	// the same project never share a captured variable through a stale closure
	// stored in the old program's options.
	createCheckerPool := func(program *compiler.Program) compiler.CheckerPool {
		return newCheckerPool(p.host.sessionOptions.CheckerPoolOptions, program, p.log)
	}

	// Create the command line, potentially augmented with typing files
	commandLine := p.getCommandLineWithTypingsFiles()

	if p.dirtyFilePath != "" && p.Program != nil && p.Program.CommandLine() == commandLine {
		var dirtyFile *ast.SourceFile
		newProgram, dirtyFile, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool)
		if programCloned {
			updateKind = ProgramUpdateKindCloned
			for _, file := range newProgram.SourceFiles() {
				// Use pointer identity: dirtyFile is the exact instance UpdateProgram acquired,
				// and it is the only file whose refcount is already accounted for.
				if file != dirtyFile {
					// UpdateProgram acquired the changed file only, so we need to ref everything else
					p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind))
				}
			}
			for _, file := range newProgram.DuplicateSourceFiles() {
				p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind))
			}
		} else if dirtyFile != nil {
			// UpdateProgram always acquires the dirty file before deciding whether it can
			// reuse the old program. If it falls back to a full rebuild, release that
			// speculative acquire so the rebuilt program is the only remaining owner.
			p.host.builder.parseCache.Deref(NewParseCacheKey(dirtyFile.ParseOptions(), dirtyFile.Hash, dirtyFile.ScriptKind))
		}
	} else {
		var typingsLocation string
		if p.GetTypeAcquisition().Enable.IsTrue() {
			typingsLocation = p.host.sessionOptions.TypingsLocation
		}
		newProgram = compiler.NewProgram(
			compiler.ProgramOptions{
				Host:                        p.host,
				Config:                      commandLine,
				UseSourceOfProjectReference: true,
				TypingsLocation:             typingsLocation,
				CreateCheckerPool:           createCheckerPool,
			},
		)
	}

	if !programCloned && p.Program != nil && p.Program.HasSameFileNames(newProgram) {
		updateKind = ProgramUpdateKindSameFileNames
	}

	newProgram.BindSourceFiles()

	return CreateProgramResult{
		Program:    newProgram,
		UpdateKind: updateKind,
	}
}

func (p *Project) CloneWatchers() *WatchedFiles[*collections.SyncSet[tspath.Path]] {
	return p.programFilesWatch.Clone(p.host.sourceFS.seenFiles)
}

func (p *Project) log(msg string) {
	// !!!
}

func (p *Project) toPath(fileName string) tspath.Path {
	return tspath.ToPath(fileName, p.currentDirectory, p.host.FS().UseCaseSensitiveFileNames())
}

func (p *Project) print(writeFileNames bool, writeFileExplanation bool, builder *strings.Builder) string {
	builder.WriteString(fmt.Sprintf("\nProject '%s'\n", p.Name()))
	if p.Program == nil {
		builder.WriteString("\tFiles (0) NoProgram\n")
	} else {
		sourceFiles := p.Program.GetSourceFiles()
		builder.WriteString(fmt.Sprintf("\tFiles (%d)\n", len(sourceFiles)))
		if writeFileNames {
			for _, sourceFile := range sourceFiles {
				builder.WriteString("\t\t")
				builder.WriteString(sourceFile.FileName())
				builder.WriteString("\n")
			}
			// !!!
			// if writeFileExplanation {}
		}
	}
	builder.WriteString(hr)
	return builder.String()
}

// GetTypeAcquisition returns the type acquisition settings for this project.
func (p *Project) GetTypeAcquisition() *core.TypeAcquisition {
	if p.Kind == KindInferred {
		// For inferred projects, use default settings
		return &core.TypeAcquisition{
			Enable:                              core.TSTrue,
			Include:                             nil,
			Exclude:                             nil,
			DisableFilenameBasedTypeAcquisition: core.TSFalse,
		}
	}

	if p.CommandLine != nil {
		return p.CommandLine.TypeAcquisition()
	}

	return nil
}

// GetUnresolvedImports extracts unresolved imports from this project's program.
func (p *Project) GetUnresolvedImports() *collections.Set[string] {
	if p.Program == nil {
		return nil
	}

	return p.Program.GetUnresolvedImports()
}

// ShouldTriggerATA determines if ATA should be triggered for this project.
func (p *Project) ShouldTriggerATA(snapshotID uint64) bool {
	if p.Program == nil || p.CommandLine == nil {
		return false
	}

	typeAcquisition := p.GetTypeAcquisition()
	if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() {
		return false
	}

	if p.installedTypingsInfo == nil || p.ProgramLastUpdate == snapshotID && p.ProgramUpdateKind == ProgramUpdateKindNewFiles {
		return true
	}

	return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo())
}

func (p *Project) ComputeTypingsInfo() ata.TypingsInfo {
	return ata.TypingsInfo{
		CompilerOptions:   p.CommandLine.CompilerOptions(),
		TypeAcquisition:   p.GetTypeAcquisition(),
		UnresolvedImports: p.GetUnresolvedImports(),
	}
}
