// Copyright 2016-2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//	http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package npm

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"slices"

	"gopkg.in/yaml.v3"
)

var ErrNotInWorkspace = errors.New("not in a workspace")

// FindWorkspaceRoot determines if we are in a yarn/npm workspace setup and
// returns the root directory of the workspace.  If the startingPath is
// not in a workspace, it returns ErrNotInWorkspace.
func FindWorkspaceRoot(startingPath string) (string, error) {
	stat, err := os.Stat(startingPath)
	if err != nil {
		return "", err
	}
	if !stat.IsDir() {
		startingPath = filepath.Dir(startingPath)
	}
	// We start at the location of the first `package.json` we find.
	packageJSONDir, err := searchup(startingPath, "package.json")
	if err != nil {
		return "", fmt.Errorf("did not find package.json in %s: %w", startingPath, err)
	}
	currentDir := packageJSONDir
	nextDir := filepath.Dir(currentDir)
	for currentDir != nextDir { // We're at the root when the nextDir is the same as the currentDir.
		p := filepath.Join(currentDir, "package.json")
		_, err := os.Stat(p)
		if err != nil {
			if os.IsNotExist(err) {
				// No package.json in this directory, continue the search in the next directory up.
				currentDir = nextDir
				nextDir = filepath.Dir(currentDir)
				continue
			}
			return "", err
		}

		// First look for pnpm workspace configuration
		pnpmWorkspace := filepath.Join(currentDir, "pnpm-workspace.yaml")
		_, err = os.Stat(pnpmWorkspace)
		if err == nil {
			// We have a pnpm-workspace.yaml
			workspaces, err := parsePnpmWorkspace(pnpmWorkspace)
			if err != nil {
				return "", err
			}
			matches, err := matchesWorkspaceGlobs(workspaces, currentDir, packageJSONDir)
			if err != nil {
				return "", err
			}
			if matches {
				return currentDir, nil
			}
		}

		// No pnpm-workspace.yaml, check for npm/yarn workspaces in the package.json
		workspaces, err := parseWorkspaces(p)
		if err != nil {
			return "", fmt.Errorf("failed to parse workspaces from %s: %w", p, err)
		}
		matches, err := matchesWorkspaceGlobs(workspaces, currentDir, packageJSONDir)
		if err != nil {
			return "", err
		}
		if matches {
			return currentDir, nil
		}

		currentDir = nextDir
		nextDir = filepath.Dir(currentDir)
	}

	// We walked all the way to the root and didn't find a workspace configuration.
	return "", ErrNotInWorkspace
}

// matchesWorkspaceGlobs checks if the workspaces globs match `toCheck`  when evaluated relative
// to currentDir.
//
// For example, if currentDir is `/some/project/` and workspaces is `["packages/*"]`, this
// function will return true for toCheck = `/some/project/packages/foo/`.
func matchesWorkspaceGlobs(workspaces []string, currentDir string, toCheck string) (bool, error) {
	for _, workspace := range workspaces {
		paths, err := filepath.Glob(filepath.Join(currentDir, workspace, "package.json"))
		if err != nil {
			return false, err
		}
		if paths != nil && slices.Contains(paths, filepath.Join(toCheck, "package.json")) {
			return true, nil
		}
	}
	return false, nil
}

// parseWorkspaces reads a package.json file and returns the list of workspaces.
// This supports the simple format for npm and yarn:
//
//	{
//	  "workspaces": ["workspace-a", "workspace-b"]
//	}
//
// As well as the extended format for yarn:
//
//	{
//		"workspaces": {
//			"packages": ["packages/*"],
//			"nohoist": ["**/react-native", "**/react-native/**"]
//		}
//	}
func parseWorkspaces(p string) ([]string, error) {
	pkgContents, err := os.ReadFile(p)
	if err != nil {
		return []string{}, err
	}
	pkg := struct {
		Workspaces []string `json:"workspaces"`
	}{}
	err = json.Unmarshal(pkgContents, &pkg)
	if err == nil {
		return pkg.Workspaces, nil
	}
	// Failed to parse the simple format, try to parse extended yarn workspaces format
	pkgExtended := struct {
		Workspaces struct {
			Packages []string `json:"packages"`
		} `json:"workspaces"`
	}{}
	err = json.Unmarshal(pkgContents, &pkgExtended)
	if err != nil {
		return []string{}, err
	}
	return pkgExtended.Workspaces.Packages, nil
}

func searchup(currentDir, fileToFind string) (string, error) {
	if _, err := os.Stat(filepath.Join(currentDir, fileToFind)); err == nil {
		return currentDir, nil
	}
	parentDir := filepath.Dir(currentDir)
	if currentDir == parentDir {
		return "", nil
	}
	return searchup(parentDir, fileToFind)
}

func parsePnpmWorkspace(p string) ([]string, error) {
	workspaceContent, err := os.ReadFile(p)
	if err != nil {
		return []string{}, err
	}
	workspace := struct {
		Packages []string `yaml:"packages"`
	}{}
	err = yaml.Unmarshal(workspaceContent, &workspace)
	if err != nil {
		return []string{}, err
	}
	return workspace.Packages, nil
}