package registration

import (
	"context"
	"errors"
	"net/http"
	"path"
	"time"

	"golang.org/x/sync/errgroup"

	"code.justin.tv/sse/malachai/pkg/config"
	"code.justin.tv/sse/malachai/pkg/log"
	"code.justin.tv/sse/malachai/pkg/registration/iamgenerator"
	"code.justin.tv/sse/malachai/pkg/rorolepicker"
	"code.justin.tv/systems/sandstorm/manager"
	"code.justin.tv/systems/sandstorm/policy"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
	"github.com/aws/aws-sdk-go/service/iam"
	"github.com/aws/aws-sdk-go/service/iam/iamiface"
	"github.com/aws/aws-sdk-go/service/sns"
	"github.com/aws/aws-sdk-go/service/sns/snsiface"
	"github.com/go-redis/redis"
	"github.com/gofrs/uuid"
)

// errors
var (
	ErrServiceNameAlreadyRegistered = errors.New("service with same name already registered")
)

type registrar struct {
	db      dynamodbiface.DynamoDBAPI
	redis   redis.Cmdable
	sns     snsiface.SNSAPI
	cfg     *Config
	ig      iamgenerator.IAMGenerator
	log     log.S2SLogger
	secrets manager.API
	iam     iamiface.IAMAPI

	res *config.Resources
}

//UpdateServiceRequest input to update an existing malachai service.
type UpdateServiceRequest struct {
	ServiceName     string
	UpdatePolicy    bool
	OwnerLDAPGroups *[]string
	AllowedArns     *[]string
	UpdatedByUser   string
}

//UpdateServiceByIDRequest input to update an existing malachai service.
type UpdateServiceByIDRequest struct {
	ServiceID       string
	UpdatePolicy    bool
	OwnerLDAPGroups *[]string
	AllowedArns     *[]string
	UpdatedByUser   string
}

// Registrar registers services and provides lookups
type Registrar interface {
	Get(name string) (svc *Service, err error)
	GetServiceByID(serviceID string) (svc *Service, err error)
	GetServicePageWithContext(ctx context.Context, input *GetServicePageInput) (output *GetServicePageOutput, err error)
	ListServiceNames(ctx context.Context) (output *ListServiceNamesOutput, err error)
	Deregister(serviceName string) (err error)
	DeregisterByID(serviceID string) (svc *Service, err error)
	Register(request *Request) (svc *Service, err error)
	UpdateService(request *UpdateServiceRequest) (svc *Service, err error)
	UpdateServiceByID(request *UpdateServiceByIDRequest) (svc *Service, err error)
	SyncReadOnlyRole(ctx context.Context, readOnlyRoleName string) error

	// consider using GetServicePageWithContext instead
	GetServicesWithContext(ctx context.Context) (output *GetServicesOutput, err error)
}

// New initializes a new registration client. Pass in a nil or empty Config to
// use default values.
func New(cfg *Config, logger log.S2SLogger) (reg Registrar, err error) {
	return newRegistrar(cfg, logger)
}

func newRegistrar(cfg *Config, logger log.S2SLogger) (reg *registrar, err error) {
	if cfg == nil {
		cfg = new(Config)
	}

	err = cfg.FillDefaults()
	if err != nil {
		return
	}

	res, err := config.GetResources(cfg.Environment)
	if err != nil {
		return nil, err
	}

	logger.Infof("creating sandstorm session with role chain: %s, %s",
		cfg.RoleArn, cfg.SandstormRoleArn)

	sess, err := session.NewSession(config.AWSConfig(cfg.AWSConfigBase, cfg.Region, cfg.RoleArn))
	if err != nil {
		return
	}

	sandstormSess, err := session.NewSession(config.AWSConfig(cfg.AWSConfigBase, cfg.Region, cfg.RoleArn, cfg.SandstormRoleArn))
	if err != nil {
		return
	}

	spg := &policy.IAMPolicyGenerator{
		AuxPolicyArn:                 cfg.SandstormAuxPolicyArn,
		DynamoDBSecretsTableArn:      cfg.SandstormSecretsTableArn,
		DynamoDBSecretsAuditTableArn: cfg.SandstormSecretsAuditTableArn,
		DynamoDBNamespaceTableArn:    cfg.SandstormNamespaceTableArn,
		RoleOwnerTableName:           cfg.SandstormRoleOwnerTableName,
		IAM:                          iam.New(sandstormSess),
		DynamoDB:                     dynamodb.New(sandstormSess),
		Logger:                       &log.NoOpLogger{},
	}

	iamClient := iam.New(sess)
	ig, err := iamgenerator.New(spg, iamClient, &iamgenerator.Config{
		Environment:   cfg.Environment,
		AWSConfigBase: cfg.AWSConfigBase,
	})
	if err != nil {
		return
	}

	redisOptions, err := redis.ParseURL(cfg.ServicePaginationRedisURL)
	if err != nil {
		return
	}

	reg = &registrar{
		cfg:   cfg,
		db:    dynamodb.New(sess),
		redis: redis.NewClient(redisOptions),
		sns:   sns.New(sess),
		ig:    ig,
		iam:   iamClient,
		log:   logger,
		secrets: manager.New(manager.Config{
			AWSConfig:   config.AWSConfig(cfg.AWSConfigBase, cfg.Region, cfg.RoleArn, cfg.SandstormRoleArn),
			Environment: cfg.Environment,
		}),
		res: res,
	}
	return
}

// Request describes a service registration request
type Request struct {
	Name            string
	OwnerLDAPGroups []string
	AssumeRoleArns  []string

	// if Private is set to true, event will be broadcasted to a separate
	// changelog category
	Private bool

	// user that created service
	CreatedByUser string
}

func (req *Request) validate() (err error) {
	if req.Name == "" {
		return errors.New("name is required")
	}

	return nil
}

func sandstormSecretName(environment, serviceID string) (keyName string) {
	// sse/malachai/<environment>/signing_keys/<caller_service_id>
	return path.Join("sse/malachai/", environment, "signing_keys", serviceID)
}

func (reg *registrar) Register(request *Request) (svc *Service, err error) {
	err = request.validate()
	if err != nil {
		return
	}
	serviceExist, err := reg.serviceAlreadyRegistered(request.Name)
	if err != nil {
		return
	}

	if serviceExist {
		reg.log.Errorf("service registration failed, service with name '%s' already exists", request.Name)
		err = ErrServiceNameAlreadyRegistered
		return
	}

	reg.log.Debugf("registering a new service: %s", request.Name)

	svc = new(Service)

	id, err := uuid.NewV4()
	if err != nil {
		return
	}

	svc.ID = id.String()
	svc.CreatedAt = time.Now().UTC()
	svc.Name = request.Name
	svc.OwnerLDAPGroups = request.OwnerLDAPGroups
	svc.AllowedArns = request.AssumeRoleArns
	svc.SandstormSecretName = sandstormSecretName(reg.cfg.Environment, svc.ID)
	svc.LastUpdatedByUser = request.CreatedByUser
	svc.Private = request.Private

	readOnlyRoleName, err := rorolepicker.PickAuxRole(reg.res.ServicesReadOnlyRoleNamePrefix, svc.Name, reg.res.ServicesReadOnlyRoleCount)
	if err != nil {
		return
	}
	svc.ServicesReadOnlyRoleName = readOnlyRoleName

	svc.CapabilitiesTopicArn, err = reg.createSNSTopic(svc.getCapabilitiesTopic())
	if err != nil {
		return
	}

	role, err := reg.ig.RegisterServiceRole(&iamgenerator.RegisterServiceRoleRequest{
		Service:        svc.forIAMGenerator(),
		AssumeRoleArns: request.AssumeRoleArns,
	})
	if err != nil {
		reg.log.Errorf("failed to register service with name: '%s', err: %s", svc.Name, err.Error())
		return
	}
	reg.log.Debugf("successfully registered service role for service with name '%s'", svc.Name)

	svc.RoleArn = role.RoleArn
	// TODO these will be removed
	svc.CalleeRoleArn = role.RoleArn
	svc.CallerRoleArn = role.RoleArn

	svc.SandstormRoleArn = role.SandstormRoleArn
	svc.SandstormRoleName = role.SandstormRoleName

	item, err := dynamodbattribute.MarshalMap(&svc)
	if err != nil {
		return
	}

	putInput := &dynamodb.PutItemInput{
		Item:                item,
		TableName:           aws.String(reg.cfg.TableName),
		ConditionExpression: aws.String("attribute_not_exists(service_name)"),
	}
	_, err = reg.db.PutItem(putInput)
	if err != nil {
		reg.log.Errorf("failed to put item with name: '%s' in services table. err: %s", svc.Name, err.Error())
		if aErr, ok := err.(awserr.RequestFailure); ok {
			if aErr.StatusCode() == http.StatusBadRequest {
				err = ErrServiceNameAlreadyRegistered
				//This should never happen, we ensured that the service was not present in the DB before proceeding.
				reg.log.Error("unexpected error putting service registration to dynamodb, err: " + err.Error())
			}
		}
	}
	if err == nil {
		reg.log.Debugf("service registration for service with name '%s' complete", request.Name)
	}

	svc.iam = reg.ig
	return
}

func (reg *registrar) createSNSTopic(topicName string) (topicArn string, err error) {
	params := &sns.CreateTopicInput{
		Name: aws.String(topicName),
	}

	output, err := reg.sns.CreateTopic(params)
	if err != nil {
		reg.log.Errorf("failed to create SNS topic named '%s', err: %s", topicName, err.Error())
		return
	}

	topicArn = aws.StringValue(output.TopicArn)
	reg.log.Infof("successfully created SNS topic named '%s' with arn: '%s'", topicName, topicArn)
	return
}

func (reg *registrar) serviceAlreadyRegistered(serviceName string) (exist bool, err error) {

	exist = false

	getItemInput := &dynamodb.GetItemInput{
		Key: map[string]*dynamodb.AttributeValue{
			"service_name": {
				S: aws.String(serviceName),
			},
		},
		TableName: aws.String(reg.cfg.TableName),
	}

	resp, err := reg.db.GetItem(getItemInput)
	if err != nil {
		reg.log.Error("failed to get service details from dynamodb: " + err.Error())
		return
	}
	if len(resp.Item) > 0 {
		exist = true
	}
	return
}

func (reg *registrar) Deregister(serviceName string) (err error) {
	svc, err := reg.Get(serviceName)
	if err != nil {
		if err == ErrServiceDoesNotExist {
			reg.log.Warnf("deregistering service failed. Service with name '%s' does not exist", serviceName)
			err = nil
		} else {
			reg.log.Warn("failed to get service details from dynamodb. err: " + err.Error())
		}
		return
	}

	err = reg.deregister(svc)
	return
}

func (reg *registrar) DeregisterByID(serviceID string) (svc *Service, err error) {
	svc, err = reg.GetServiceByID(serviceID)
	if err != nil {
		return
	}

	err = reg.deregister(svc)
	return
}

func (reg *registrar) deregister(svc *Service) (err error) {
	var eg errgroup.Group

	if svc.CapabilitiesTopicArn != "" {
		eg.Go(func() (err error) {
			_, err = reg.sns.DeleteTopic(&sns.DeleteTopicInput{
				TopicArn: aws.String(svc.CapabilitiesTopicArn),
			})

			if err != nil {
				reg.log.Infof("error deleting sns topic: %s", err.Error())
				if awsErr, ok := err.(awserr.Error); ok {
					if awsErr.Code() == sns.ErrCodeNotFoundException {
						err = nil
					}
				}
			}
			return
		})
	}

	eg.Go(func() (err error) {
		err = reg.ig.DeregisterServiceRole(svc.forIAMGenerator())
		if err != nil {
			// this can fail for several reasons - role does not exist, no owners, etc.
			// we need to make the failure cases more defined in the package before we
			// can reliably bubble up the correct errors. for now, priority is deleting
			// the service.
			reg.log.Warnf("failed to deregister service role: %s", err.Error())
			err = nil
		}
		return
	})

	eg.Go(func() (err error) {
		err = reg.secrets.Delete(svc.SandstormSecretName)
		if err != nil {
			reg.log.Infof("error deleting service: %s", err.Error())
		}
		return
	})

	err = eg.Wait()
	if err != nil {
		reg.log.Warnf("failed to deregister service, err: %s", err.Error())
		return
	}

	_, err = reg.db.DeleteItem(&dynamodb.DeleteItemInput{
		Key: map[string]*dynamodb.AttributeValue{
			"service_name": {S: aws.String(svc.Name)},
		},
		TableName: aws.String(reg.cfg.TableName),
	})
	if err != nil {
		reg.log.Errorf("failed to remove service from service table, err: %s", err.Error())
	} else {
		reg.log.Info("successfully removed service from service table")
	}
	return
}

func (reg *registrar) UpdateServiceByID(req *UpdateServiceByIDRequest) (svc *Service, err error) {
	svc, err = reg.GetServiceByID(req.ServiceID)
	if err != nil {
		return
	}

	err = reg.updateService(svc, &updateServiceRequest{
		AllowedArns:     req.AllowedArns,
		OwnerLDAPGroups: req.OwnerLDAPGroups,
		UpdatePolicy:    req.UpdatePolicy,
		UpdatedByUser:   req.UpdatedByUser,
	})
	return
}

// UpdateService updates the auto generated policy attached to service
// role with the newer version of policy.
// This detaches/deletes the old policies after attaching the new policy.
func (reg *registrar) UpdateService(req *UpdateServiceRequest) (svc *Service, err error) {
	svc, err = reg.Get(req.ServiceName)
	if err != nil {
		return
	}

	err = reg.updateService(svc, &updateServiceRequest{
		AllowedArns:     req.AllowedArns,
		OwnerLDAPGroups: req.OwnerLDAPGroups,
		UpdatePolicy:    req.UpdatePolicy,
		UpdatedByUser:   req.UpdatedByUser,
	})
	return
}

//updateServiceRequest input to update an existing malachai service.
type updateServiceRequest struct {
	UpdatePolicy    bool
	OwnerLDAPGroups *[]string
	AllowedArns     *[]string
	UpdatedByUser   string
}

func (reg *registrar) updateService(svc *Service, req *updateServiceRequest) (err error) {
	if req.UpdatePolicy {
		err = reg.ig.UpdateServiceRolePolicy(&iamgenerator.UpdateServiceRolePolicyRequest{
			Service: svc.forIAMGenerator(),
		})
		if err != nil {
			reg.log.Errorf("failed to update service role policies, service with name '%s', err: %s", svc.Name, err.Error())
			return
		}
		reg.log.Infof("successfully updated service role policies for service with name '%s'", svc.Name)
	}

	if req.AllowedArns == nil && req.OwnerLDAPGroups == nil {
		// No changes
		return
	}

	if req.AllowedArns != nil {
		err = reg.ig.UpdateAssumeRolePolicy(svc.forIAMGenerator(), *req.AllowedArns)
		if err != nil {
			reg.log.Errorf("failed to update assume role policy for service with name '%s', err: %s", svc.Name, err.Error())
			return
		}
		svc.AllowedArns = *req.AllowedArns
	}

	if req.OwnerLDAPGroups != nil {
		svc.OwnerLDAPGroups = *req.OwnerLDAPGroups
	}

	svc.UpdatedAt = time.Now().UTC()
	svc.LastUpdatedByUser = req.UpdatedByUser

	item, err := dynamodbattribute.MarshalMap(svc)
	if err != nil {
		return
	}

	putInput := &dynamodb.PutItemInput{
		Item:                item,
		TableName:           aws.String(reg.cfg.TableName),
		ConditionExpression: aws.String("attribute_exists(service_name)"),
	}
	_, err = reg.db.PutItem(putInput)
	if err != nil {
		//This should never happen, we ensured that the service was not present in the DB before proceeding.
		reg.log.Errorf("failed to update service, err: %s", err.Error())
		return
	}
	reg.log.Infof("service registration for '%s' complete.", svc.Name)
	return
}
