// Package disco handles Terraform's remote service discovery protocol.
//
// This protocol allows mapping from a service hostname, as produced by the
// svchost package, to a set of services supported by that host and the
// endpoint information for each supported service.
package disco

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"mime"
	"net/http"
	"net/url"
	"time"

	cleanhttp "github.com/hashicorp/go-cleanhttp"
	"github.com/hashicorp/terraform/svchost"
	"github.com/hashicorp/terraform/svchost/auth"
	"github.com/hashicorp/terraform/version"
)

const (
	discoPath        = "/.well-known/terraform.json"
	maxRedirects     = 3               // arbitrary-but-small number to prevent runaway redirect loops
	discoTimeout     = 4 * time.Second // arbitrary-but-small time limit to prevent UI "hangs" during discovery
	maxDiscoDocBytes = 1 * 1024 * 1024 // 1MB - to prevent abusive services from using loads of our memory
)

var userAgent = fmt.Sprintf("Terraform/%s (service discovery)", version.String())
var httpTransport = cleanhttp.DefaultPooledTransport() // overridden during tests, to skip TLS verification

// Disco is the main type in this package, which allows discovery on given
// hostnames and caches the results by hostname to avoid repeated requests
// for the same information.
type Disco struct {
	hostCache map[svchost.Hostname]Host
	credsSrc  auth.CredentialsSource

	// Transport is a custom http.Transport to use.
	// A package default is used if this is nil.
	Transport *http.Transport
}

func NewDisco() *Disco {
	return &Disco{}
}

// SetCredentialsSource provides a credentials source that will be used to
// add credentials to outgoing discovery requests, where available.
//
// If this method is never called, no outgoing discovery requests will have
// credentials.
func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
	d.credsSrc = src
}

// ForceHostServices provides a pre-defined set of services for a given
// host, which prevents the receiver from attempting network-based discovery
// for the given host. Instead, the given services map will be returned
// verbatim.
//
// When providing "forced" services, any relative URLs are resolved against
// the initial discovery URL that would have been used for network-based
// discovery, yielding the same results as if the given map were published
// at the host's default discovery URL, though using absolute URLs is strongly
// recommended to make the configured behavior more explicit.
func (d *Disco) ForceHostServices(host svchost.Hostname, services map[string]interface{}) {
	if d.hostCache == nil {
		d.hostCache = map[svchost.Hostname]Host{}
	}
	if services == nil {
		services = map[string]interface{}{}
	}
	d.hostCache[host] = Host{
		discoURL: &url.URL{
			Scheme: "https",
			Host:   string(host),
			Path:   discoPath,
		},
		services: services,
	}
}

// Discover runs the discovery protocol against the given hostname (which must
// already have been validated and prepared with svchost.ForComparison) and
// returns an object describing the services available at that host.
//
// If a given hostname supports no Terraform services at all, a non-nil but
// empty Host object is returned. When giving feedback to the end user about
// such situations, we say e.g. "the host <name> doesn't provide a module
// registry", regardless of whether that is due to that service specifically
// being absent or due to the host not providing Terraform services at all,
// since we don't wish to expose the detail of whole-host discovery to an
// end-user.
func (d *Disco) Discover(host svchost.Hostname) Host {
	if d.hostCache == nil {
		d.hostCache = map[svchost.Hostname]Host{}
	}
	if cache, cached := d.hostCache[host]; cached {
		return cache
	}

	ret := d.discover(host)
	d.hostCache[host] = ret
	return ret
}

// DiscoverServiceURL is a convenience wrapper for discovery on a given
// hostname and then looking up a particular service in the result.
func (d *Disco) DiscoverServiceURL(host svchost.Hostname, serviceID string) *url.URL {
	return d.Discover(host).ServiceURL(serviceID)
}

// discover implements the actual discovery process, with its result cached
// by the public-facing Discover method.
func (d *Disco) discover(host svchost.Hostname) Host {
	discoURL := &url.URL{
		Scheme: "https",
		Host:   string(host),
		Path:   discoPath,
	}

	t := d.Transport
	if t == nil {
		t = httpTransport
	}

	client := &http.Client{
		Transport: t,
		Timeout:   discoTimeout,

		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			log.Printf("[DEBUG] Service discovery redirected to %s", req.URL)
			if len(via) > maxRedirects {
				return errors.New("too many redirects") // (this error message will never actually be seen)
			}
			return nil
		},
	}

	var header = http.Header{}
	header.Set("User-Agent", userAgent)

	req := &http.Request{
		Method: "GET",
		URL:    discoURL,
		Header: header,
	}

	if d.credsSrc != nil {
		creds, err := d.credsSrc.ForHost(host)
		if err == nil {
			if creds != nil {
				creds.PrepareRequest(req) // alters req to include credentials
			}
		} else {
			log.Printf("[WARNING] Failed to get credentials for %s: %s (ignoring)", host, err)
		}
	}

	log.Printf("[DEBUG] Service discovery for %s at %s", host, discoURL)

	ret := Host{
		discoURL: discoURL,
	}

	resp, err := client.Do(req)
	if err != nil {
		log.Printf("[WARNING] Failed to request discovery document: %s", err)
		return ret // empty
	}
	if resp.StatusCode != 200 {
		log.Printf("[WARNING] Failed to request discovery document: %s", resp.Status)
		return ret // empty
	}

	// If the client followed any redirects, we will have a new URL to use
	// as our base for relative resolution.
	ret.discoURL = resp.Request.URL

	contentType := resp.Header.Get("Content-Type")
	mediaType, _, err := mime.ParseMediaType(contentType)
	if err != nil {
		log.Printf("[WARNING] Discovery URL has malformed Content-Type %q", contentType)
		return ret // empty
	}
	if mediaType != "application/json" {
		log.Printf("[DEBUG] Discovery URL returned Content-Type %q, rather than application/json", mediaType)
		return ret // empty
	}

	// (this doesn't catch chunked encoding, because ContentLength is -1 in that case...)
	if resp.ContentLength > maxDiscoDocBytes {
		// Size limit here is not a contractual requirement and so we may
		// adjust it over time if we find a different limit is warranted.
		log.Printf("[WARNING] Discovery doc response is too large (got %d bytes; limit %d)", resp.ContentLength, maxDiscoDocBytes)
		return ret // empty
	}

	// If the response is using chunked encoding then we can't predict
	// its size, but we'll at least prevent reading the entire thing into
	// memory.
	lr := io.LimitReader(resp.Body, maxDiscoDocBytes)

	servicesBytes, err := ioutil.ReadAll(lr)
	if err != nil {
		log.Printf("[WARNING] Error reading discovery document body: %s", err)
		return ret // empty
	}

	var services map[string]interface{}
	err = json.Unmarshal(servicesBytes, &services)
	if err != nil {
		log.Printf("[WARNING] Failed to decode discovery document as a JSON object: %s", err)
		return ret // empty
	}

	ret.services = services
	return ret
}

// Forget invalidates any cached record of the given hostname. If the host
// has no cache entry then this is a no-op.
func (d *Disco) Forget(host svchost.Hostname) {
	delete(d.hostCache, host)
}

// ForgetAll is like Forget, but for all of the hostnames that have cache entries.
func (d *Disco) ForgetAll() {
	d.hostCache = nil
}
