package heartbeat

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"sync"
	"time"

	"code.justin.tv/systems/sandstorm/internal/atomic"
	"code.justin.tv/systems/sandstorm/logging"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/credentials"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/lambda"
	"github.com/aws/aws-sdk-go/service/lambda/lambdaiface"
)

const defaultHost = "UnknownHost"
const defaultService = "UnknownService"

var defaultInterval = time.Hour

//Config for inventory client
type Config struct {
	Interval                       time.Duration
	Service                        string
	Host                           string
	FQDN                           string
	InstanceID                     string
	ManagerVersion                 string
	AWSRegion                      string
	PutHeartbeatLambdaFunctionName string
}

//Client for inventory
type Client struct {
	lambda         lambdaiface.LambdaAPI
	Config         Config
	heartbeatState *heartbeatState
	Logger         logging.Logger
	running        atomic.Bool
	cancel         chan struct{}
	cancelConfirm  chan struct{}
	flushChan      chan struct{}
	mutex          sync.Mutex
}

// New creates a new client
func New(credentials *credentials.Credentials, config Config, logger logging.Logger) (client *Client) {
	if logger == nil {
		logger = &logging.NoopLogger{}
	}

	config = buildConfig(config)

	return &Client{
		lambda: lambda.New(session.New(&aws.Config{
			Region:      aws.String(config.AWSRegion),
			Credentials: credentials,
		})),
		heartbeatState: newHeartbeatState(),
		Logger:         logger,
		Config:         buildConfig(config),
		cancel:         make(chan struct{}),
		cancelConfirm:  make(chan struct{}),
		flushChan:      make(chan struct{}, 1),
	}
}

func defaultConfig() Config {
	return Config{
		Interval: defaultInterval,
		Host:     hostName(),
		FQDN:     getFqdn(),
		Service:  serviceName(),
	}
}

func buildConfig(providedConfig Config) Config {
	config := providedConfig
	defaultConfig := defaultConfig()
	if config.Interval == 0 {
		config.Interval = defaultConfig.Interval
	}
	if config.Service == "" {
		config.Service = defaultConfig.Service
	}
	if config.Host == "" {
		config.Host = defaultConfig.Host
	}
	if config.FQDN == "" {
		config.FQDN = defaultConfig.FQDN
	}
	return config
}

// UpdateHeartbeat updates the current state of heartbeats to send to
// the inventory server.
func (client *Client) UpdateHeartbeat(secret *Secret) {
	if secret == nil {
		return
	}
	client.Logger.Debugf("updating heartbeat for secret: %s", secret.Name)
	secret.FetchedAt = time.Now().Unix()

	client.mutex.Lock()
	client.heartbeatState.secretsToReport[secret.Name] = secret
	client.mutex.Unlock()

	return
}

// Start sends the current state of heartbeats to inventory server
func (client *Client) Start() {
	client.running.Set()

	defer func() {
		client.running.Unset()
	}()

	client.Logger.Debugf(
		"reporting secret status to inventory at every %d seconds",
		client.Config.Interval/time.Second,
	)
	for {
		timer := time.NewTimer(client.Config.Interval)
		select {
		case <-client.cancel:
			client.Logger.Infof("Flushing one last time due to cancel request")
			client.SendHeartbeat()
			client.sendCancelConfirm()
			return
		case <-client.flushChan:
			client.SendHeartbeat()
		case <-timer.C:
			// send the heartbeats to server and sleep for specified interval
			client.SendHeartbeat()
			client.Logger.Debugf("Sleeping for %v seconds", client.Config.Interval)
		}
	}
}

func (client *Client) sendCancelConfirm() {
	timer := time.NewTimer(client.Config.Interval)
	select {
	case client.cancelConfirm <- struct{}{}:
		return
	case <-timer.C:
		client.Logger.Warnf("timeout when sending cancel confirmation")
		return
	}
}

// Stop stops the reporter goroutine
func (client *Client) Stop() (err error) {
	if !client.running.IsSet() {
		return
	}

	timer := time.NewTimer(10 * time.Second)
	err = client.sendStopRequest(timer)
	if err != nil {
		return
	}
	err = client.receiveCancelConfirm(timer)
	return
}

func (client *Client) sendStopRequest(timer *time.Timer) (err error) {
	select {
	case client.cancel <- struct{}{}:
		return
	case <-timer.C:
		err = errors.New("could not stop reporter gorountine")
		return
	}
}

func (client *Client) receiveCancelConfirm(timer *time.Timer) (err error) {
	select {
	case <-client.cancelConfirm:
		return
	case <-timer.C:
		err = errors.New("timeout when receiving cancel confirmation")
		return
	}
}

// FlushHeartbeat to manually flush the heartbeat
func (client *Client) FlushHeartbeat(ctx context.Context) {
	select {
	case client.flushChan <- struct{}{}:
	case <-ctx.Done():
	}
	return
}

// SendHeartbeat to manually flush the heartbeat. usually called in Start goroutine.
func (client *Client) SendHeartbeat() {
	if err := client.putHeartbeat(); err != nil {
		client.Logger.Debugf("failed to send heartbeat to inventory: err: %s", err.Error())
	}
	return
}

//Send the current heart state to inventory server
func (client *Client) putHeartbeat() (err error) {
	client.mutex.Lock()
	secretsToReport := client.heartbeatState.secretsToReport
	if len(secretsToReport) == 0 {
		client.Logger.Debugln("Nothing to report.")
		client.mutex.Unlock()
		return nil
	}
	client.Logger.Debugf("Reporting heartbeat for %d secrets.", len(secretsToReport))
	req, err := client.buildPutHeartbeatRequest(secretsToReport)
	client.mutex.Unlock()

	if err != nil {
		return fmt.Errorf("Failed to build heartbeat request. Error: %s", err.Error())
	}

	_, err = client.lambda.Invoke(&lambda.InvokeInput{
		FunctionName:   aws.String(client.Config.PutHeartbeatLambdaFunctionName),
		InvocationType: aws.String("RequestResponse"),
		Payload:        req,
	})

	return err
}

func (client *Client) buildPutHeartbeatRequest(secretsToReport map[string]*Secret) ([]byte, error) {
	hb := &heartbeat{}
	hb.Service = client.Config.Service
	hb.Host = client.Config.Host
	hb.FQDN = client.Config.FQDN
	hb.InstanceID = client.Config.InstanceID
	hb.ManagerVersion = client.Config.ManagerVersion

	//Build secrets to be send over to inventory
	var secrets []*Secret

	// build an inventory.heartbeats to send to the servers.
	for _, secret := range secretsToReport {
		secrets = append(secrets, secret)
	}
	hb.Secrets = secrets

	bs := bytes.NewBuffer(nil)
	if err := json.NewEncoder(bs).Encode(struct {
		BodyJJSON *heartbeat `json:"body_json"`
	}{hb}); err != nil {
		return nil, err
	}

	return bs.Bytes(), nil
}

//serviceName return processName. if non found return unknownService
func serviceName() (service string) {
	service = os.Args[0]
	if service == "" {
		service = defaultService
	}
	return
}

//hostName return hostname of the machine. if non found return unknownHost
func hostName() (host string) {
	host, err := os.Hostname()
	if err != nil {
		host = defaultHost
	}
	return
}

func getFqdn() (fqdn string) {
	var out bytes.Buffer
	cmd := exec.Command("hostname", "-f")
	cmd.Stdout = &out
	err := cmd.Run()
	if err != nil {
		fqdn = "Unknown FQDN"
		return
	}
	fqdn = out.String()
	fqdn = strings.TrimSpace(fqdn)
	return
}
