// checkpoint is a package for checking version information and alerts
// for a HashiCorp product.
package checkpoint

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	mrand "math/rand"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"reflect"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/hashicorp/go-cleanhttp"
	uuid "github.com/hashicorp/go-uuid"
)

var magicBytes [4]byte = [4]byte{0x35, 0x77, 0x69, 0xFB}

// ReportParams are the parameters for configuring a telemetry report.
type ReportParams struct {
	// Signature is some random signature that should be stored and used
	// as a cookie-like value. This ensures that alerts aren't repeated.
	// If the signature is changed, repeat alerts may be sent down. The
	// signature should NOT be anything identifiable to a user (such as
	// a MAC address). It should be random.
	//
	// If SignatureFile is given, then the signature will be read from this
	// file. If the file doesn't exist, then a random signature will
	// automatically be generated and stored here. SignatureFile will be
	// ignored if Signature is given.
	Signature     string `json:"signature"`
	SignatureFile string `json:"-"`

	StartTime     time.Time   `json:"start_time"`
	EndTime       time.Time   `json:"end_time"`
	Arch          string      `json:"arch"`
	OS            string      `json:"os"`
	Payload       interface{} `json:"payload,omitempty"`
	Product       string      `json:"product"`
	RunID         string      `json:"run_id"`
	SchemaVersion string      `json:"schema_version"`
	Version       string      `json:"version"`
}

func (i *ReportParams) signature() string {
	signature := i.Signature
	if i.Signature == "" && i.SignatureFile != "" {
		var err error
		signature, err = checkSignature(i.SignatureFile)
		if err != nil {
			return ""
		}
	}
	return signature
}

// Report sends telemetry information to checkpoint
func Report(ctx context.Context, r *ReportParams) error {
	if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" {
		return nil
	}

	req, err := ReportRequest(r)
	if err != nil {
		return err
	}

	client := cleanhttp.DefaultClient()
	resp, err := client.Do(req.WithContext(ctx))
	if err != nil {
		return err
	}
	if resp.StatusCode != 201 {
		return fmt.Errorf("Unknown status: %d", resp.StatusCode)
	}

	return nil
}

// ReportRequest creates a request object for making a report
func ReportRequest(r *ReportParams) (*http.Request, error) {
	// Populate some fields automatically if we can
	if r.RunID == "" {
		uuid, err := uuid.GenerateUUID()
		if err != nil {
			return nil, err
		}
		r.RunID = uuid
	}
	if r.Arch == "" {
		r.Arch = runtime.GOARCH
	}
	if r.OS == "" {
		r.OS = runtime.GOOS
	}
	if r.Signature == "" {
		r.Signature = r.signature()
	}

	b, err := json.Marshal(r)
	if err != nil {
		return nil, err
	}

	u := &url.URL{
		Scheme: "https",
		Host:   "checkpoint-api.hashicorp.com",
		Path:   fmt.Sprintf("/v1/telemetry/%s", r.Product),
	}

	req, err := http.NewRequest("POST", u.String(), bytes.NewReader(b))
	if err != nil {
		return nil, err
	}
	req.Header.Add("Accept", "application/json")
	req.Header.Add("User-Agent", "HashiCorp/go-checkpoint")

	return req, nil
}

// CheckParams are the parameters for configuring a check request.
type CheckParams struct {
	// Product and version are used to lookup the correct product and
	// alerts for the proper version. The version is also used to perform
	// a version check.
	Product string
	Version string

	// Arch and OS are used to filter alerts potentially only to things
	// affecting a specific os/arch combination. If these aren't specified,
	// they'll be automatically filled in.
	Arch string
	OS   string

	// Signature is some random signature that should be stored and used
	// as a cookie-like value. This ensures that alerts aren't repeated.
	// If the signature is changed, repeat alerts may be sent down. The
	// signature should NOT be anything identifiable to a user (such as
	// a MAC address). It should be random.
	//
	// If SignatureFile is given, then the signature will be read from this
	// file. If the file doesn't exist, then a random signature will
	// automatically be generated and stored here. SignatureFile will be
	// ignored if Signature is given.
	Signature     string
	SignatureFile string

	// CacheFile, if specified, will cache the result of a check. The
	// duration of the cache is specified by CacheDuration, and defaults
	// to 48 hours if not specified. If the CacheFile is newer than the
	// CacheDuration, than the Check will short-circuit and use those
	// results.
	//
	// If the CacheFile directory doesn't exist, it will be created with
	// permissions 0755.
	CacheFile     string
	CacheDuration time.Duration

	// Force, if true, will force the check even if CHECKPOINT_DISABLE
	// is set. Within HashiCorp products, this is ONLY USED when the user
	// specifically requests it. This is never automatically done without
	// the user's consent.
	Force bool
}

// CheckResponse is the response for a check request.
type CheckResponse struct {
	Product             string
	CurrentVersion      string `json:"current_version"`
	CurrentReleaseDate  int    `json:"current_release_date"`
	CurrentDownloadURL  string `json:"current_download_url"`
	CurrentChangelogURL string `json:"current_changelog_url"`
	ProjectWebsite      string `json:"project_website"`
	Outdated            bool   `json:"outdated"`
	Alerts              []*CheckAlert
}

// CheckAlert is a single alert message from a check request.
//
// These never have to be manually constructed, and are typically populated
// into a CheckResponse as a result of the Check request.
type CheckAlert struct {
	ID      int
	Date    int
	Message string
	URL     string
	Level   string
}

// Check checks for alerts and new version information.
func Check(p *CheckParams) (*CheckResponse, error) {
	if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" && !p.Force {
		return &CheckResponse{}, nil
	}

	// set a default timeout of 3 sec for the check request (in milliseconds)
	timeout := 3000
	if _, err := strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT")); err == nil {
		timeout, _ = strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT"))
	}

	// If we have a cached result, then use that
	if r, err := checkCache(p.Version, p.CacheFile, p.CacheDuration); err != nil {
		return nil, err
	} else if r != nil {
		defer r.Close()
		return checkResult(r)
	}

	var u url.URL

	if p.Arch == "" {
		p.Arch = runtime.GOARCH
	}
	if p.OS == "" {
		p.OS = runtime.GOOS
	}

	// If we're given a SignatureFile, then attempt to read that.
	signature := p.Signature
	if p.Signature == "" && p.SignatureFile != "" {
		var err error
		signature, err = checkSignature(p.SignatureFile)
		if err != nil {
			return nil, err
		}
	}

	v := u.Query()
	v.Set("version", p.Version)
	v.Set("arch", p.Arch)
	v.Set("os", p.OS)
	v.Set("signature", signature)

	u.Scheme = "https"
	u.Host = "checkpoint-api.hashicorp.com"
	u.Path = fmt.Sprintf("/v1/check/%s", p.Product)
	u.RawQuery = v.Encode()

	req, err := http.NewRequest("GET", u.String(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Add("Accept", "application/json")
	req.Header.Add("User-Agent", "HashiCorp/go-checkpoint")

	client := cleanhttp.DefaultClient()

	// We use a short timeout since checking for new versions is not critical
	// enough to block on if checkpoint is broken/slow.
	client.Timeout = time.Duration(timeout) * time.Millisecond

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("Unknown status: %d", resp.StatusCode)
	}

	var r io.Reader = resp.Body
	if p.CacheFile != "" {
		// Make sure the directory holding our cache exists.
		if err := os.MkdirAll(filepath.Dir(p.CacheFile), 0755); err != nil {
			return nil, err
		}

		// We have to cache the result, so write the response to the
		// file as we read it.
		f, err := os.Create(p.CacheFile)
		if err != nil {
			return nil, err
		}

		// Write the cache header
		if err := writeCacheHeader(f, p.Version); err != nil {
			f.Close()
			os.Remove(p.CacheFile)
			return nil, err
		}

		defer f.Close()
		r = io.TeeReader(r, f)
	}

	return checkResult(r)
}

// CheckInterval is used to check for a response on a given interval duration.
// The interval is not exact, and checks are randomized to prevent a thundering
// herd. However, it is expected that on average one check is performed per
// interval. The returned channel may be closed to stop background checks.
func CheckInterval(p *CheckParams, interval time.Duration, cb func(*CheckResponse, error)) chan struct{} {
	doneCh := make(chan struct{})

	if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" {
		return doneCh
	}

	go func() {
		for {
			select {
			case <-time.After(randomStagger(interval)):
				resp, err := Check(p)
				cb(resp, err)
			case <-doneCh:
				return
			}
		}
	}()

	return doneCh
}

// randomStagger returns an interval that is between 3/4 and 5/4 of
// the given interval. The expected value is the interval.
func randomStagger(interval time.Duration) time.Duration {
	stagger := time.Duration(mrand.Int63()) % (interval / 2)
	return 3*(interval/4) + stagger
}

func checkCache(current string, path string, d time.Duration) (io.ReadCloser, error) {
	fi, err := os.Stat(path)
	if err != nil {
		if os.IsNotExist(err) {
			// File doesn't exist, not a problem
			return nil, nil
		}

		return nil, err
	}

	if d == 0 {
		d = 48 * time.Hour
	}

	if fi.ModTime().Add(d).Before(time.Now()) {
		// Cache is busted, delete the old file and re-request. We ignore
		// errors here because re-creating the file is fine too.
		os.Remove(path)
		return nil, nil
	}

	// File looks good so far, open it up so we can inspect the contents.
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}

	// Check the signature of the file
	var sig [4]byte
	if err := binary.Read(f, binary.LittleEndian, sig[:]); err != nil {
		f.Close()
		return nil, err
	}
	if !reflect.DeepEqual(sig, magicBytes) {
		// Signatures don't match. Reset.
		f.Close()
		return nil, nil
	}

	// Check the version. If it changed, then rewrite
	var length uint32
	if err := binary.Read(f, binary.LittleEndian, &length); err != nil {
		f.Close()
		return nil, err
	}
	data := make([]byte, length)
	if _, err := io.ReadFull(f, data); err != nil {
		f.Close()
		return nil, err
	}
	if string(data) != current {
		// Version changed, reset
		f.Close()
		return nil, nil
	}

	return f, nil
}

func checkResult(r io.Reader) (*CheckResponse, error) {
	var result CheckResponse
	dec := json.NewDecoder(r)
	if err := dec.Decode(&result); err != nil {
		return nil, err
	}

	return &result, nil
}

func checkSignature(path string) (string, error) {
	_, err := os.Stat(path)
	if err == nil {
		// The file exists, read it out
		sigBytes, err := ioutil.ReadFile(path)
		if err != nil {
			return "", err
		}

		// Split the file into lines
		lines := strings.SplitN(string(sigBytes), "\n", 2)
		if len(lines) > 0 {
			return strings.TrimSpace(lines[0]), nil
		}
	}

	// If this isn't a non-exist error, then return that.
	if !os.IsNotExist(err) {
		return "", err
	}

	// The file doesn't exist, so create a signature.
	var b [16]byte
	n := 0
	for n < 16 {
		n2, err := rand.Read(b[n:])
		if err != nil {
			return "", err
		}

		n += n2
	}
	signature := fmt.Sprintf(
		"%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])

	// Make sure the directory holding our signature exists.
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		return "", err
	}

	// Write the signature
	if err := ioutil.WriteFile(path, []byte(signature+"\n\n"+userMessage+"\n"), 0644); err != nil {
		return "", err
	}

	return signature, nil
}

func writeCacheHeader(f io.Writer, v string) error {
	// Write our signature first
	if err := binary.Write(f, binary.LittleEndian, magicBytes); err != nil {
		return err
	}

	// Write out our current version length
	var length uint32 = uint32(len(v))
	if err := binary.Write(f, binary.LittleEndian, length); err != nil {
		return err
	}

	_, err := f.Write([]byte(v))
	return err
}

// userMessage is suffixed to the signature file to provide feedback.
var userMessage = `
This signature is a randomly generated UUID used to de-duplicate
alerts and version information. This signature is random, it is
not based on any personally identifiable information. To create
a new signature, you can simply delete this file at any time.
See the documentation for the software using Checkpoint for more
information on how to disable it.
`