package project

import (
	"cmp"
	"slices"

	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
	"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
)

type ProjectCollection struct {
	toPath             func(fileName string) tspath.Path
	configFileRegistry *ConfigFileRegistry
	// fileDefaultProjects is a map of file paths to the config file path (the key
	// into `configuredProjects`) of the default project for that file. If the file
	// belongs to the inferred project, the value is `inferredProjectName`. This map
	// contains quick lookups for only the associations discovered during the latest
	// snapshot update.
	fileDefaultProjects map[tspath.Path]tspath.Path
	// configuredProjects is the set of loaded projects associated with a tsconfig
	// file, keyed by the config file path.
	configuredProjects map[tspath.Path]*Project
	// inferredProject is a fallback project that is used when no configured
	// project can be found for an open file.
	inferredProject *Project
	// apiOpenedProjects is the set of projects that should be kept open for
	// API clients.
	apiOpenedProjects map[tspath.Path]struct{}
}

func (c *ProjectCollection) ConfiguredProject(path tspath.Path) *Project {
	return c.configuredProjects[path]
}

func (c *ProjectCollection) GetProjectByPath(projectPath tspath.Path) *Project {
	if project, ok := c.configuredProjects[projectPath]; ok {
		return project
	}

	if projectPath == inferredProjectName {
		return c.inferredProject
	}

	return nil
}

// ConfiguredProjects returns all configured projects in a stable order.
func (c *ProjectCollection) ConfiguredProjects() []*Project {
	projects := make([]*Project, 0, len(c.configuredProjects))
	c.fillConfiguredProjects(&projects)
	return projects
}

func (c *ProjectCollection) fillConfiguredProjects(projects *[]*Project) {
	for _, p := range c.configuredProjects {
		*projects = append(*projects, p)
	}
	slices.SortFunc(*projects, func(a, b *Project) int {
		return cmp.Compare(a.Name(), b.Name())
	})
}

// ProjectsByPath returns an ordered map of configured projects keyed by their config file path,
// plus the inferred project, if it exists, with the key `inferredProjectName`.
func (c *ProjectCollection) ProjectsByPath() *collections.OrderedMap[tspath.Path, *Project] {
	projects := collections.NewOrderedMapWithSizeHint[tspath.Path, *Project](
		len(c.configuredProjects) + core.IfElse(c.inferredProject != nil, 1, 0),
	)
	for _, project := range c.ConfiguredProjects() {
		projects.Set(project.configFilePath, project)
	}
	if c.inferredProject != nil {
		projects.Set(inferredProjectName, c.inferredProject)
	}
	return projects
}

// Projects returns all projects, including the inferred project if it exists, in a stable order.
func (c *ProjectCollection) Projects() []*Project {
	if c.inferredProject == nil {
		return c.ConfiguredProjects()
	}
	projects := make([]*Project, 0, len(c.configuredProjects)+1)
	c.fillConfiguredProjects(&projects)
	projects = append(projects, c.inferredProject)
	return projects
}

func (c *ProjectCollection) InferredProject() *Project {
	return c.inferredProject
}

// !!! result could be cached
func (c *ProjectCollection) GetDefaultProject(fileName string, path tspath.Path) *Project {
	if result, ok := c.fileDefaultProjects[path]; ok {
		if result == inferredProjectName {
			return c.inferredProject
		}
		return c.configuredProjects[result]
	}

	var (
		containingProjects                       []*Project
		firstConfiguredProject                   *Project
		firstNonSourceOfProjectReferenceRedirect *Project
		multipleDirectInclusions                 bool
	)
	for _, p := range c.ConfiguredProjects() {
		if p.containsFile(path) {
			containingProjects = append(containingProjects, p)
			if !multipleDirectInclusions && !p.IsSourceFromProjectReference(path) {
				if firstNonSourceOfProjectReferenceRedirect == nil {
					firstNonSourceOfProjectReferenceRedirect = p
				} else {
					multipleDirectInclusions = true
				}
			}
			if firstConfiguredProject == nil {
				firstConfiguredProject = p
			}
		}
	}
	if len(containingProjects) == 1 {
		return containingProjects[0]
	}
	if len(containingProjects) == 0 {
		if c.inferredProject != nil && c.inferredProject.containsFile(path) {
			return c.inferredProject
		}
		return nil
	}
	if !multipleDirectInclusions {
		if firstNonSourceOfProjectReferenceRedirect != nil {
			// Multiple projects include the file, but only one is a direct inclusion.
			return firstNonSourceOfProjectReferenceRedirect
		}
		// Multiple projects include the file, and none are direct inclusions.
		return firstConfiguredProject
	}
	// Multiple projects include the file directly.
	if defaultProject := c.findDefaultConfiguredProject(fileName, path); defaultProject != nil {
		return defaultProject
	}
	return firstConfiguredProject
}

func (c *ProjectCollection) findDefaultConfiguredProject(fileName string, path tspath.Path) *Project {
	if configFileName := c.configFileRegistry.GetConfigFileName(path); configFileName != "" {
		return c.findDefaultConfiguredProjectWorker(fileName, path, configFileName, nil, nil)
	}
	return nil
}

func (c *ProjectCollection) findDefaultConfiguredProjectWorker(fileName string, path tspath.Path, configFileName string, visited *collections.SyncSet[*Project], fallback *Project) *Project {
	configFilePath := c.toPath(configFileName)
	project, ok := c.configuredProjects[configFilePath]
	if !ok {
		return nil
	}
	if visited == nil {
		visited = &collections.SyncSet[*Project]{}
	}

	// Look in the config's project and its references recursively.
	search := core.BreadthFirstSearchParallelEx(
		project,
		func(project *Project) []*Project {
			if project.CommandLine == nil {
				return nil
			}
			return core.Map(project.CommandLine.ResolvedProjectReferencePaths(), func(configFileName string) *Project {
				return c.configuredProjects[c.toPath(configFileName)]
			})
		},
		func(project *Project) (isResult bool, stop bool) {
			if project.containsFile(path) {
				return true, !project.IsSourceFromProjectReference(path)
			}
			return false, false
		},
		core.BreadthFirstSearchOptions[*Project, *Project]{
			Visited: visited,
		},
		core.Identity,
	)

	if search.Stopped {
		// If we found a project that directly contains the file, return it.
		return search.Path[0]
	}
	if len(search.Path) > 0 && fallback == nil {
		// If we found a project that contains the file, but it is a source from
		// a project reference, record it as a fallback.
		fallback = search.Path[0]
	}

	// Look for tsconfig.json files higher up the directory tree and do the same. This handles
	// the common case where a higher-level "solution" tsconfig.json contains all projects in a
	// workspace.
	if config := c.configFileRegistry.GetConfig(path); config != nil && config.CompilerOptions().DisableSolutionSearching.IsTrue() {
		return fallback
	}
	if ancestorConfigName := c.configFileRegistry.GetAncestorConfigFileName(path, configFileName); ancestorConfigName != "" {
		return c.findDefaultConfiguredProjectWorker(fileName, path, ancestorConfigName, visited, fallback)
	}
	return fallback
}

// clone creates a shallow copy of the project collection.
func (c *ProjectCollection) clone() *ProjectCollection {
	return &ProjectCollection{
		toPath:              c.toPath,
		configuredProjects:  c.configuredProjects,
		inferredProject:     c.inferredProject,
		fileDefaultProjects: c.fileDefaultProjects,
	}
}

// findDefaultConfiguredProjectFromProgramInclusion finds the default configured project for a file
// based on the file's inclusion in existing projects. The projects should be sorted, as ties will
// be broken by slice order. `getProject` should return a project with an up-to-date program.
// Along with the resulting project path, a boolean is returned indicating whether there were multiple
// direct inclusions of the file in different projects, indicating that the caller may want to perform
// additional logic to determine the best project.
func findDefaultConfiguredProjectFromProgramInclusion(
	fileName string,
	path tspath.Path,
	projectPaths []tspath.Path,
	getProject func(tspath.Path) *Project,
) (result tspath.Path, multipleCandidates bool) {
	var (
		containingProjects                       []tspath.Path
		firstConfiguredProject                   tspath.Path
		firstNonSourceOfProjectReferenceRedirect tspath.Path
		multipleDirectInclusions                 bool
	)

	for _, projectPath := range projectPaths {
		p := getProject(projectPath)
		if p.containsFile(path) {
			containingProjects = append(containingProjects, projectPath)
			if !multipleDirectInclusions && !p.IsSourceFromProjectReference(path) {
				if firstNonSourceOfProjectReferenceRedirect == "" {
					firstNonSourceOfProjectReferenceRedirect = projectPath
				} else {
					multipleDirectInclusions = true
				}
			}
			if firstConfiguredProject == "" {
				firstConfiguredProject = projectPath
			}
		}
	}

	if len(containingProjects) == 1 {
		return containingProjects[0], false
	}
	if !multipleDirectInclusions {
		if firstNonSourceOfProjectReferenceRedirect != "" {
			// Multiple projects include the file, but only one is a direct inclusion.
			return firstNonSourceOfProjectReferenceRedirect, false
		}
		// Multiple projects include the file, and none are direct inclusions.
		return firstConfiguredProject, false
	}
	// Multiple projects include the file directly.
	return firstConfiguredProject, true
}
