// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package addrs

import (
	"fmt"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"golang.org/x/net/idna"
)

// Plugin encapsulates a single plugin type.
type Plugin struct {
	Hostname  string
	Namespace string
	Type      string
}

func (p Plugin) RealRelativePath() string {
	return p.Namespace + "/packer-plugin-" + p.Type
}

func (p Plugin) Parts() []string {
	return []string{p.Hostname, p.Namespace, p.Type}
}

func (p Plugin) String() string {
	return strings.Join(p.Parts(), "/")
}

// ParsePluginPart processes an addrs.Plugin namespace or type string
// provided by an end-user, producing a normalized version if possible or
// an error if the string contains invalid characters.
//
// A plugin part is processed in the same way as an individual label in a DNS
// domain name: it is transformed to lowercase per the usual DNS case mapping
// and normalization rules and may contain only letters, digits, and dashes.
// Additionally, dashes may not appear at the start or end of the string.
//
// These restrictions are intended to allow these names to appear in fussy
// contexts such as directory/file names on case-insensitive filesystems,
// repository names on GitHub, etc. We're using the DNS rules in particular,
// rather than some similar rules defined locally, because the hostname part
// of an addrs.Plugin is already a hostname and it's ideal to use exactly
// the same case folding and normalization rules for all of the parts.
//
// It's valid to pass the result of this function as the argument to a
// subsequent call, in which case the result will be identical.
func ParsePluginPart(given string) (string, error) {
	if len(given) == 0 {
		return "", fmt.Errorf("must have at least one character")
	}

	// We're going to process the given name using the same "IDNA" library we
	// use for the hostname portion, since it already implements the case
	// folding rules we want.
	//
	// The idna library doesn't expose individual label parsing directly, but
	// once we've verified it doesn't contain any dots we can just treat it
	// like a top-level domain for this library's purposes.
	if strings.ContainsRune(given, '.') {
		return "", fmt.Errorf("dots are not allowed")
	}

	// We don't allow names containing multiple consecutive dashes, just as
	// a matter of preference: they look confusing, or incorrect.
	// This also, as a side-effect, prevents the use of the "punycode"
	// indicator prefix "xn--" that would cause the IDNA library to interpret
	// the given name as punycode, because that would be weird and unexpected.
	if strings.Contains(given, "--") {
		return "", fmt.Errorf("cannot use multiple consecutive dashes")
	}

	result, err := idna.Lookup.ToUnicode(given)
	if err != nil {
		return "", fmt.Errorf("must contain only letters, digits, and dashes, and may not use leading or trailing dashes: %w", err)
	}

	return result, nil
}

// IsPluginPartNormalized compares a given string to the result of ParsePluginPart(string)
func IsPluginPartNormalized(str string) (bool, error) {
	normalized, err := ParsePluginPart(str)
	if err != nil {
		return false, err
	}
	if str == normalized {
		return true, nil
	}
	return false, nil
}

// ParsePluginSourceString parses the source attribute and returns a plugin.
// This is intended primarily to parse the FQN-like strings
//
// The following are valid source string formats:
//
//	name
//	namespace/name
//	hostname/namespace/name
func ParsePluginSourceString(str string) (*Plugin, hcl.Diagnostics) {
	ret := &Plugin{
		Hostname:  "",
		Namespace: "",
	}
	var diags hcl.Diagnostics

	// split the source string into individual components
	parts := strings.Split(str, "/")
	if len(parts) != 3 {
		diags = diags.Append(&hcl.Diagnostic{
			Severity: hcl.DiagError,
			Summary:  "Invalid plugin source string",
			Detail:   `The "source" attribute must be in the format "hostname/namespace/name"`,
		})
		return nil, diags
	}

	// check for an invalid empty string in any part
	for i := range parts {
		if parts[i] == "" {
			diags = diags.Append(&hcl.Diagnostic{
				Severity: hcl.DiagError,
				Summary:  "Invalid plugin source string",
				Detail:   `The "source" attribute must be in the format "hostname/namespace/name"`,
			})
			return nil, diags
		}
	}

	// check the 'name' portion, which is always the last part
	givenName := parts[len(parts)-1]
	name, err := ParsePluginPart(givenName)
	if err != nil {
		diags = diags.Append(&hcl.Diagnostic{
			Severity: hcl.DiagError,
			Summary:  "Invalid plugin type",
			Detail:   fmt.Sprintf(`Invalid plugin type %q in source %q: %s"`, givenName, str, err),
		})
		return nil, diags
	}
	ret.Type = name

	// the namespace is always the second-to-last part
	givenNamespace := parts[len(parts)-2]
	namespace, err := ParsePluginPart(givenNamespace)
	if err != nil {
		diags = diags.Append(&hcl.Diagnostic{
			Severity: hcl.DiagError,
			Summary:  "Invalid plugin namespace",
			Detail:   fmt.Sprintf(`Invalid plugin namespace %q in source %q: %s"`, namespace, str, err),
		})
		return nil, diags
	}
	ret.Namespace = namespace

	// the hostname is always the first part in a three-part source string
	ret.Hostname = parts[0]

	// Due to how plugin executables are named and plugin git repositories
	// are conventionally named, it's a reasonable and
	// apparently-somewhat-common user error to incorrectly use the
	// "packer-plugin-" prefix in a plugin source address. There is
	// no good reason for a plugin to have the prefix "packer-" anyway,
	// so we've made that invalid from the start both so we can give feedback
	// to plugin developers about the packer- prefix being redundant
	// and give specialized feedback to folks who incorrectly use the full
	// packer-plugin- prefix to help them self-correct.
	const redundantPrefix = "packer-"
	const userErrorPrefix = "packer-plugin-"
	if strings.HasPrefix(ret.Type, redundantPrefix) {
		if strings.HasPrefix(ret.Type, userErrorPrefix) {
			// Likely user error. We only return this specialized error if
			// whatever is after the prefix would otherwise be a
			// syntactically-valid plugin type, so we don't end up advising
			// the user to try something that would be invalid for another
			// reason anyway.
			// (This is mainly just for robustness, because the validation
			// we already did above should've rejected most/all ways for
			// the suggestedType to end up invalid here.)
			suggestedType := ret.Type[len(userErrorPrefix):]
			if _, err := ParsePluginPart(suggestedType); err == nil {
				suggestedAddr := ret
				suggestedAddr.Type = suggestedType
				diags = diags.Append(&hcl.Diagnostic{
					Severity: hcl.DiagError,
					Summary:  "Invalid plugin type",
					Detail: fmt.Sprintf("Plugin source %q has a type with the prefix %q, which isn't valid. "+
						"Although that prefix is often used in the names of version control repositories for Packer plugins, "+
						"plugin source strings should not include it.\n"+
						"\nDid you mean %q?", ret, userErrorPrefix, suggestedAddr),
				})
				return nil, diags
			}
		}
		// Otherwise, probably instead an incorrectly-named plugin, perhaps
		// arising from a similar instinct to what causes there to be
		// thousands of Python packages on PyPI with "python-"-prefixed
		// names.
		diags = diags.Append(&hcl.Diagnostic{
			Severity: hcl.DiagError,
			Summary:  "Invalid plugin type",
			Detail:   fmt.Sprintf("Plugin source %q has a type with the prefix %q, which isn't allowed because it would be redundant to name a Packer plugin with that prefix. If you are the author of this plugin, rename it to not include the prefix.", ret, redundantPrefix),
		})
		return nil, diags
	}

	return ret, diags
}
