package packagemanager

import (
	"fmt"
	"strings"

	"github.com/Masterminds/semver"
	"github.com/vercel/turbo/cli/internal/fs"
	"github.com/vercel/turbo/cli/internal/lockfile"
	"github.com/vercel/turbo/cli/internal/turbopath"
	"github.com/vercel/turbo/cli/internal/yaml"
)

// PnpmWorkspaces is a representation of workspace package globs found
// in pnpm-workspace.yaml
type PnpmWorkspaces struct {
	Packages []string `yaml:"packages,omitempty"`
}

func readPnpmWorkspacePackages(workspaceFile turbopath.AbsoluteSystemPath) ([]string, error) {
	bytes, err := workspaceFile.ReadFile()
	if err != nil {
		return nil, fmt.Errorf("%v: %w", workspaceFile, err)
	}
	var pnpmWorkspaces PnpmWorkspaces
	if err := yaml.Unmarshal(bytes, &pnpmWorkspaces); err != nil {
		return nil, fmt.Errorf("%v: %w", workspaceFile, err)
	}
	return pnpmWorkspaces.Packages, nil
}

func getPnpmWorkspaceGlobs(rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
	pkgGlobs, err := readPnpmWorkspacePackages(rootpath.UntypedJoin("pnpm-workspace.yaml"))
	if err != nil {
		return nil, err
	}

	if len(pkgGlobs) == 0 {
		return nil, fmt.Errorf("pnpm-workspace.yaml: no packages found. Turborepo requires pnpm workspaces and thus packages to be defined in the root pnpm-workspace.yaml")
	}

	filteredPkgGlobs := []string{}
	for _, pkgGlob := range pkgGlobs {
		if !strings.HasPrefix(pkgGlob, "!") {
			filteredPkgGlobs = append(filteredPkgGlobs, pkgGlob)
		}
	}
	return filteredPkgGlobs, nil
}

func getPnpmWorkspaceIgnores(pm PackageManager, rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
	// Matches upstream values:
	// function: https://github.com/pnpm/pnpm/blob/d99daa902442e0c8ab945143ebaf5cdc691a91eb/packages/find-packages/src/index.ts#L27
	// key code: https://github.com/pnpm/pnpm/blob/d99daa902442e0c8ab945143ebaf5cdc691a91eb/packages/find-packages/src/index.ts#L30
	// call site: https://github.com/pnpm/pnpm/blob/d99daa902442e0c8ab945143ebaf5cdc691a91eb/packages/find-workspace-packages/src/index.ts#L32-L39
	ignores := []string{
		"**/node_modules/**",
		"**/bower_components/**",
	}
	pkgGlobs, err := readPnpmWorkspacePackages(rootpath.UntypedJoin("pnpm-workspace.yaml"))
	if err != nil {
		return nil, err
	}
	for _, pkgGlob := range pkgGlobs {
		if strings.HasPrefix(pkgGlob, "!") {
			ignores = append(ignores, pkgGlob[1:])
		}
	}
	return ignores, nil
}

var nodejsPnpm = PackageManager{
	Name:       "nodejs-pnpm",
	Slug:       "pnpm",
	Command:    "pnpm",
	Specfile:   "package.json",
	Lockfile:   "pnpm-lock.yaml",
	PackageDir: "node_modules",
	// pnpm v7+ changed their handling of '--'. We no longer need to pass it to pass args to
	// the script being run, and in fact doing so will cause the '--' to be passed through verbatim,
	// potentially breaking scripts that aren't expecting it.
	// We are allowed to use nil here because ArgSeparator already has a type, so it's a typed nil,
	// This could just as easily be []string{}, but the style guide says to prefer
	// nil for empty slices.
	ArgSeparator:               nil,
	WorkspaceConfigurationPath: "pnpm-workspace.yaml",

	getWorkspaceGlobs: getPnpmWorkspaceGlobs,

	getWorkspaceIgnores: getPnpmWorkspaceIgnores,

	Matches: func(manager string, version string) (bool, error) {
		if manager != "pnpm" {
			return false, nil
		}

		v, err := semver.NewVersion(version)
		if err != nil {
			return false, fmt.Errorf("could not parse pnpm version: %w", err)
		}
		c, err := semver.NewConstraint(">=7.0.0")
		if err != nil {
			return false, fmt.Errorf("could not create constraint: %w", err)
		}

		return c.Check(v), nil
	},

	detect: func(projectDirectory turbopath.AbsoluteSystemPath, packageManager *PackageManager) (bool, error) {
		specfileExists := projectDirectory.UntypedJoin(packageManager.Specfile).FileExists()
		lockfileExists := projectDirectory.UntypedJoin(packageManager.Lockfile).FileExists()

		return (specfileExists && lockfileExists), nil
	},

	canPrune: func(cwd turbopath.AbsoluteSystemPath) (bool, error) {
		return true, nil
	},

	UnmarshalLockfile: func(_rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error) {
		return lockfile.DecodePnpmLockfile(contents)
	},

	prunePatches: func(pkgJSON *fs.PackageJSON, patches []turbopath.AnchoredUnixPath) error {
		return pnpmPrunePatches(pkgJSON, patches)
	},
}

func pnpmPrunePatches(pkgJSON *fs.PackageJSON, patches []turbopath.AnchoredUnixPath) error {
	pkgJSON.Mu.Lock()
	defer pkgJSON.Mu.Unlock()

	keysToDelete := []string{}
	pnpmConfig, ok := pkgJSON.RawJSON["pnpm"].(map[string]interface{})
	if !ok {
		return fmt.Errorf("Invalid structure for pnpm field in package.json")
	}
	patchedDependencies, ok := pnpmConfig["patchedDependencies"].(map[string]interface{})
	if !ok {
		return fmt.Errorf("Invalid structure for patchedDependencies field in package.json")
	}

	for dependency, untypedPatch := range patchedDependencies {
		patch, ok := untypedPatch.(string)
		if !ok {
			return fmt.Errorf("Expected only strings in patchedDependencies. Got %v", untypedPatch)
		}

		inPatches := false

		for _, wantedPatch := range patches {
			if wantedPatch.ToString() == patch {
				inPatches = true
				break
			}
		}

		if !inPatches {
			keysToDelete = append(keysToDelete, dependency)
		}
	}

	for _, key := range keysToDelete {
		delete(patchedDependencies, key)
	}

	return nil
}
