// Copyright (c) HashiCorp, Inc.

// Package svchost deals with the representations of the so-called "friendly
// hostnames" that we use to represent systems that provide Terraform-native
// remote services, such as module registry, remote operations, etc.
//
// Friendly hostnames are specified such that, as much as possible, they
// are consistent with how web browsers think of hostnames, so that users
// can bring their intuitions about how hostnames behave when they access
// a Terraform Enterprise instance's web UI (or indeed any other website)
// and have this behave in a similar way.
package svchost

import (
	"errors"
	"fmt"
	"strconv"
	"strings"

	"golang.org/x/net/idna"
)

// Hostname is specialized name for string that indicates that the string
// has been converted to (or was already in) the storage and comparison form.
//
// Hostname values are not suitable for display in the user-interface. Use
// the ForDisplay method to obtain a form suitable for display in the UI.
//
// Unlike user-supplied hostnames, strings of type Hostname (assuming they
// were constructed by a function within this package) can be compared for
// equality using the standard Go == operator.
type Hostname string

// acePrefix is the ASCII Compatible Encoding prefix, used to indicate that
// a domain name label is in "punycode" form.
const acePrefix = "xn--"

// displayProfile is a very liberal idna profile that we use to do
// normalization for display without imposing validation rules.
var displayProfile = idna.New(
	idna.MapForLookup(),
	idna.Transitional(true),
)

// ForDisplay takes a user-specified hostname and returns a normalized form of
// it suitable for display in the UI.
//
// If the input is so invalid that no normalization can be performed then
// this will return the input, assuming that the caller still wants to
// display _something_. This function is, however, more tolerant than the
// other functions in this package and will make a best effort to prepare
// _any_ given hostname for display.
//
// For validation, use either IsValid (for explicit validation) or
// ForComparison (which implicitly validates, returning an error if invalid).
func ForDisplay(given string) string {
	var portPortion string
	if colonPos := strings.Index(given, ":"); colonPos != -1 {
		given, portPortion = given[:colonPos], given[colonPos:]
	}
	portPortion, _ = normalizePortPortion(portPortion)

	ascii, err := displayProfile.ToASCII(given)
	if err != nil {
		return given + portPortion
	}
	display, err := displayProfile.ToUnicode(ascii)
	if err != nil {
		return given + portPortion
	}
	return display + portPortion
}

// IsValid returns true if the given user-specified hostname is a valid
// service hostname.
//
// Validity is determined by complying with the RFC 5891 requirements for
// names that are valid for domain lookup (section 5), with the additional
// requirement that user-supplied forms must not _already_ contain
// Punycode segments.
func IsValid(given string) bool {
	_, err := ForComparison(given)
	return err == nil
}

// ForComparison takes a user-specified hostname and returns a normalized
// form of it suitable for storage and comparison. The result is not suitable
// for display to end-users because it uses Punycode to represent non-ASCII
// characters, and this form is unreadable for non-ASCII-speaking humans.
//
// The result is typed as Hostname -- a specialized name for string -- so that
// other APIs can make it clear within the type system whether they expect a
// user-specified or display-form hostname or a value already normalized for
// comparison.
//
// The returned Hostname is not valid if the returned error is non-nil.
func ForComparison(given string) (Hostname, error) {
	var portPortion string
	if colonPos := strings.Index(given, ":"); colonPos != -1 {
		given, portPortion = given[:colonPos], given[colonPos:]
	}

	var err error
	portPortion, err = normalizePortPortion(portPortion)
	if err != nil {
		// We can get in here if someone has incorrectly specified a URL
		// instead of a hostname, because normalizePortPortion will try to
		// treat the colon after the scheme as the port number separator.
		// We'll return a more specific error message for that situation.
		given = strings.ToLower(given)
		if given == "https" || given == "http" {
			// Technically it's valid to have a host called "https" or "http"
			// which would generate a false positive here with input like
			// "http:foo", but we can only get here if the hostname exactly
			// matches one of the schemes _and_ the port number is also invalid.
			return Hostname(""), fmt.Errorf("need just a hostname and optional port number, not a full URL")
		}
		return Hostname(""), err
	}

	if given == "" {
		return Hostname(""), fmt.Errorf("empty string is not a valid hostname")
	}

	// First we'll apply our additional constraint that Punycode must not
	// be given directly by the user. This is not an IDN specification
	// requirement, but we prohibit it to force users to use human-readable
	// hostname forms within Terraform configuration.
	labels := labelIter{orig: given}
	for ; !labels.done(); labels.next() {
		label := labels.label()
		if label == "" {
			return Hostname(""), fmt.Errorf(
				"hostname contains empty label (two consecutive periods)",
			)
		}
		if strings.HasPrefix(label, acePrefix) {
			return Hostname(""), fmt.Errorf(
				"hostname label %q specified in punycode format; service hostnames must be given in unicode",
				label,
			)
		}
	}

	result, err := idna.Lookup.ToASCII(given)
	if err != nil {
		return Hostname(""), err
	}
	return Hostname(result + portPortion), nil
}

// ForDisplay returns a version of the receiver that is appropriate for display
// in the UI. This includes converting any punycode labels to their
// corresponding Unicode characters.
//
// A round-trip through ForComparison and this ForDisplay method does not
// guarantee the same result as calling this package's top-level ForDisplay
// function, since a round-trip through the Hostname type implies stricter
// handling than we do when doing basic display-only processing.
func (h Hostname) ForDisplay() string {
	given := string(h)
	var portPortion string
	if colonPos := strings.Index(given, ":"); colonPos != -1 {
		given, portPortion = given[:colonPos], given[colonPos:]
	}
	// We don't normalize the port portion here because we assume it's
	// already been normalized on the way in.

	result, err := idna.Lookup.ToUnicode(given)
	if err != nil {
		// Should never happen, since type Hostname indicates that a string
		// passed through our validation rules.
		panic(fmt.Errorf("ForDisplay called on invalid Hostname: %s", err))
	}
	return result + portPortion
}

func (h Hostname) String() string {
	return string(h)
}

func (h Hostname) GoString() string {
	return fmt.Sprintf("svchost.Hostname(%q)", string(h))
}

// normalizePortPortion attempts to normalize the "port portion" of a hostname,
// which begins with the first colon in the hostname and should be followed
// by a string of decimal digits.
//
// If the port portion is valid, a normalized version of it is returned along
// with a nil error.
//
// If the port portion is invalid, the input string is returned verbatim along
// with a non-nil error.
//
// An empty string is a valid port portion representing the absence of a port.
// If non-empty, the first character must be a colon.
func normalizePortPortion(s string) (string, error) {
	if s == "" {
		return s, nil
	}

	if s[0] != ':' {
		// should never happen, since caller tends to guarantee the presence
		// of a colon due to how it's extracted from the string.
		return s, errors.New("port portion is missing its initial colon")
	}

	numStr := s[1:]
	num, err := strconv.Atoi(numStr)
	if err != nil {
		return s, errors.New("port portion contains non-digit characters")
	}
	if num == 443 {
		return "", nil // ":443" is the default
	}
	if num > 65535 {
		return s, errors.New("port number is greater than 65535")
	}
	return fmt.Sprintf(":%d", num), nil
}
