// Package agent provides an API for developers who wish to integrate the
// necronomicon agent into their code.
package agent

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"math"
	"reflect"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/goamz/goamz/s3"
	"github.com/sirupsen/logrus"
)

const (
	defaultCheckInterval = 30

	InvalidConfig = Error("Agent is not configured properly")
	// NoDeploymentsError is returned when there are no deployments for an agent request.
	NoDeploymentsError = Error("environment has no deployments")
	invalidIDEncoding  = Error("id encoding is invalid")
)

var (
	log = &logrus.Logger{Out: ioutil.Discard}
)

// SetLogger sets the instance of logrus.Logger to use for logging.
func SetLogger(logger *logrus.Logger) {
	log = logger
}

// Agent watches an S3 bucket and calls back functions when a
// watched environment is deployed.
type Agent struct {
	Bucket               *s3.Bucket
	Logger               Logger
	CheckInterval        int
	callbacks            map[string][]*DeployedEnvironmentCallbackInfo
	callbacksMutex       sync.Mutex
	finish               chan struct{}
	finishMutex          sync.Mutex
	lastSeenDeploymentID int
}

// Error is an implementation of the error interface using strings as its
// internal representation.
type Error string

// Error returns the string.
func (e Error) Error() string { return string(e) }

// Logger is a function the agent uses to report errors.
type Logger func(msg string, err error)

// DeployedEnvironmentCallback is a function that accepts a DeployedEnvironment
// and is used when registering callbacks with an agent.
type DeployedEnvironmentCallback func(DeployedEnvironment)
type DeployedEnvironmentCallbackInfo struct {
	deploymentID int
	namespaces   []string
	callbackFunc DeployedEnvironmentCallback
}

// DeployedEnvironment is the representation of an environment when it is deployed.
type DeployedEnvironment struct {
	EnvironmentDocV1
}

type EnvironmentDocV1 struct {
	Version     int                               `json:"version"`
	ID          int                               `json:"id"`
	Environment string                            `json:"environment"`
	Namespaces  map[string]map[string]interface{} `json:"namespaces"`
}

// Deployment is the representation of a deployment.
type Deployment struct {
	DeploymentDocV1
}

type DeploymentDocV1 struct {
	Version int                    `json:"version"`
	ID      int                    `json:"id"`
	Updated []DeploymentDocUpdated `json:"updated"`
}

type DeploymentDocUpdated struct {
	Environment string   `json:"environment"`
	Namespaces  []string `json:"namespaces"`
}

func encodeIDString(id int) string {
	return fmt.Sprintf("%0x-%d", math.MaxInt32-id, id)
}

func decodeIDString(str string) (int, error) {
	parts := strings.SplitN(str, "-", 2)
	var id int
	if len(parts) < 2 {
		return 0, invalidIDEncoding
	}
	id, err := strconv.Atoi(parts[1])
	if err != nil {
		return 0, err
	}
	return id, nil
}

func (agent *Agent) printLog(msg string, err error) {
	log.Error(msg, err)
	if agent.Logger != nil {
		agent.Logger(msg, err)
	}
}

// GetEnvironmentLatest retrieves the latest DeployedEnvironment for
// environment, and returns an error if the request fails. If no deployments
// exist for the environment, a NoDeploymentsError is returned.
func (agent *Agent) GetEnvironmentLatest(environment string) (*DeployedEnvironment, error) {
	pathPrefix := fmt.Sprintf("environments/%s/", environment)
	listResp, err := agent.Bucket.List(pathPrefix, "", "", 2)
	if err != nil {
		return nil, err
	}
	if len(listResp.Contents) == 0 {
		return nil, NoDeploymentsError
	}
	key := listResp.Contents[0].Key
	if key == pathPrefix {
		if len(listResp.Contents) > 1 {
			key = listResp.Contents[1].Key
		} else {
			return nil, NoDeploymentsError
		}
	}
	id, err := decodeIDString(key[len(pathPrefix):len(key)])
	if err != nil {
		return nil, err
	}
	return agent.GetEnvironment(environment, id)
}

// GetEnvironment retrieves the DeployedEnvironment specified by id for
// environment, and returns an error if the request fails.
func (agent *Agent) GetEnvironment(environment string, id int) (*DeployedEnvironment, error) {
	path := fmt.Sprintf("environments/%s/%s", environment, encodeIDString(id))
	data, err := agent.Bucket.Get(path)
	if err != nil {
		return nil, err
	}
	deployedEnvironment := new(DeployedEnvironment)
	err = json.Unmarshal(data, &deployedEnvironment)
	if err != nil {
		return nil, err
	}
	deployedEnvironment.ID = id

	log.Debugf("GetEnvironment(%v, %v) - %v", environment, id, string(data))
	return deployedEnvironment, nil
}

// GetDeploymentLatest retrieves the latest Deployment, and returns an error if
// the request fails. If no deployments exist, a NoDeploymentsError is returned.
func (agent *Agent) GetDeploymentLatest() (*Deployment, error) {
	pathPrefix := "deployments/"
	listResp, err := agent.Bucket.List(pathPrefix, "", "", 2)
	if err != nil {
		return nil, err
	}
	if len(listResp.Contents) == 0 {
		return nil, NoDeploymentsError
	}
	key := listResp.Contents[0].Key
	if key == pathPrefix {
		if len(listResp.Contents) > 1 {
			key = listResp.Contents[1].Key
		} else {
			return nil, NoDeploymentsError
		}
	}
	id, err := decodeIDString(key[len(pathPrefix):len(key)])
	if err != nil {
		return nil, err
	}
	return agent.GetDeployment(id)
}

// GetDeployment retrieves the Deployment for the specified id , and returns an
// error if the request fails.
func (agent *Agent) GetDeployment(id int) (*Deployment, error) {
	// TODO: implement a cache to prevent duplicated remote calls
	path := fmt.Sprintf("deployments/%s", encodeIDString(id))
	data, err := agent.Bucket.Get(path)
	if err != nil {
		return nil, err
	}
	deployment := new(Deployment)
	err = json.Unmarshal(data, &deployment)
	if err != nil {
		return nil, err
	}
	deployment.ID = id

	log.Debugf("GetDeployment(%v) - %v", id, string(data))
	return deployment, nil
}

// CallbackOnDeployedEnvironment registers callback to be called when a
// deployment occurs on environment.
func (agent *Agent) CallbackOnDeployedEnvironment(environment string, namespaces []string, callback DeployedEnvironmentCallback) {
	agent.callbacksMutex.Lock()
	if agent.callbacks == nil {
		agent.callbacks = make(map[string][]*DeployedEnvironmentCallbackInfo)
	}
	agent.callbacks[environment] = append(agent.callbacks[environment],
		&DeployedEnvironmentCallbackInfo{deploymentID: -1, namespaces: namespaces, callbackFunc: callback})
	agent.callbacksMutex.Unlock()
}

// WatchForDeployments invokes callbacks for all registered environments with
// the latest deployment of its registered environment and then runs
// continuously, watching for new deployments. When a new deployment occurs,
// callbacks are invoked for any registered environment that was updated.
func (agent *Agent) WatchForDeployments() {
	agent.finishMutex.Lock()
	if agent.finish != nil {
		agent.finishMutex.Unlock()
		return
	}
	agent.finish = make(chan struct{})
	agent.finishMutex.Unlock()

	if err := agent.checkAndSetDefault(); err != nil {
		agent.printLog(err.Error(), InvalidConfig)
		agent.StopWatchingForDeployments()
		return
	}
	agent.run()
	for {
		select {
		case <-time.After(time.Duration(agent.CheckInterval) * time.Second):
			agent.run()
		case <-agent.finish:
			return
		}
	}
}

func (agent *Agent) checkAndSetDefault() error {
	if agent.Bucket == nil {
		return Error("Bucket parameter must be set")
	}
	if agent.CheckInterval <= 0 {
		agent.CheckInterval = defaultCheckInterval
		log.Debugf("adjust checkinterval to %v", agent.CheckInterval)
	}
	return nil
}

// StopWatchingForDeployments stops the agent from watching for deployments.
func (agent *Agent) StopWatchingForDeployments() {
	close(agent.finish)
}

func (agent *Agent) run() {
	log.Debugln("agent run")
	deployment, err := agent.GetDeploymentLatest()
	if err != nil {
		agent.printLog("Could not get latest deployment", err)
		return
	}
	log.Debugf("current deployment #%v", deployment.ID)

	// Check newly registered or never called callbacks
	for environment, callbacks := range agent.callbacks {
		for _, callback := range callbacks {
			// Check if this callback never called
			if callback.deploymentID >= 0 {
				continue
			}
			agent.triggerCallback(environment, 0, callback)
		}
	}

	// Check if there's new deployments
	if agent.lastSeenDeploymentID >= deployment.ID {
		log.Debugln("no changes")
		return
	} else if agent.lastSeenDeploymentID <= 0 {
		agent.lastSeenDeploymentID = deployment.ID
		return
	}

	// Check all the deployments from the current to the last checkpoint
	for id := deployment.ID; id > agent.lastSeenDeploymentID; id-- {
		log.Debugln("checking deployment #%v", deployment.ID)

		deployment, err := agent.GetDeployment(id)
		if err != nil {
			agent.printLog(fmt.Sprintf("Could not get deployment %d", id), err)
			continue
		}
		// Checked if we have callbacks with those updated environments
		for _, updated := range deployment.Updated {
			// Do we have any callback for the environment?
			callbacks := agent.callbacks[updated.Environment]
			if callbacks == nil {
				continue
			}

			// Check all the callbacks for the environment.
			for _, callback := range callbacks {
				if callback.deploymentID >= id {
					continue
				}
				// Check if the callback is watching for specific namespaces
				if len(callback.namespaces) > 0 {
					found := false
					for _, ns := range callback.namespaces {
						for _, ns2 := range updated.Namespaces {
							if ns == ns2 {
								found = true
								break
							}
						}
					}
					if !found {
						continue
					}
				}
				agent.triggerCallback(updated.Environment, id, callback)
			}
		}
	}
	agent.lastSeenDeploymentID = deployment.ID
}

func (agent *Agent) triggerCallback(environment string, id int, callbackInfo *DeployedEnvironmentCallbackInfo) {
	var deployedEnvironment *DeployedEnvironment
	var err error
	if id > 0 {
		deployedEnvironment, err = agent.GetEnvironment(environment, id)
	} else {
		deployedEnvironment, err = agent.GetEnvironmentLatest(environment)
	}
	if err == NoDeploymentsError {
		callbackInfo.deploymentID = 0
		return
	} else if err != nil {
		agent.printLog(fmt.Sprintf("Could not get environment %s deployment %d", environment, id), err)
		return
	}
	callbackInfo.deploymentID = deployedEnvironment.ID
	log.Debugf("trigger callback - %s (id:%d)", environment, deployedEnvironment.ID)
	go callbackInfo.callbackFunc(*deployedEnvironment)
}

// GetRawValues returns the contents of namespaces as a string map of json.RawMessage. Paths to nested objects are joined with "."
func GetRawValues(namespaces map[string]map[string]interface{}) map[string]json.RawMessage {
	if namespaces == nil {
		return nil
	}
	var rawValues = make(map[string]json.RawMessage)
	for key, namespace := range namespaces {
		searchAndWriteRawValues(rawValues, key, namespace)
	}
	return rawValues
}

func searchAndWriteRawValues(rawValues map[string]json.RawMessage, prefix string, values map[string]interface{}) {
	for key, value := range values {
		path := fmt.Sprintf("%s.%s", prefix, key)
		if value == nil {
			writeRawValue(rawValues, path, value)
			continue
		}
		v := reflect.ValueOf(value)
		if v.Type() == reflect.TypeOf(map[string]interface{}{}) {
			var subValues map[string]interface{}
			subValues, _ = value.(map[string]interface{})
			searchAndWriteRawValues(rawValues, path, subValues)
		} else {
			writeRawValue(rawValues, path, value)
		}
	}
}

func writeRawValue(rawValues map[string]json.RawMessage, path string, value interface{}) {
	var marshaled []byte
	marshaled, _ = json.Marshal(value)
	rawValues[path] = json.RawMessage(marshaled)
}
