package aws

import (
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"

	"github.com/pkg/errors"
	"github.com/spf13/cobra"

	awssdk "github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/service/iam"

	"k8s.io/apimachinery/pkg/util/yaml"

	credreqv1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1"
	"github.com/openshift/cloud-credential-operator/pkg/aws"
	"github.com/openshift/cloud-credential-operator/pkg/cmd/provisioning"
)

const (
	rolePolicyDocmentTemplate = `{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "%s" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": %s } ] }`

	secretManifestsTemplate = `apiVersion: v1
stringData:
  credentials: |-
    [default]
    role_arn = %s
    web_identity_token_file = /var/run/secrets/openshift/serviceaccount/token
kind: Secret
metadata:
  name: %s
  namespace: %s
type: Opaque`
)

var (
	// CreateIAMRolesOpts captures the options that affect creation/updating
	// of the IAM Roles.
	CreateIAMRolesOpts = options{
		TargetDir: "",
	}
)

func createIAMRoles(client aws.Client, identityProviderARN, PermissionsBoundaryARN, name, credReqDir, targetDir string, generateOnly bool) error {
	// Process directory
	credRequests, err := getListOfCredentialsRequests(credReqDir)
	if err != nil {
		return errors.Wrap(err, "Failed to process files containing CredentialsRequests")
	}

	// Create IAM Roles (with policies)
	if err := processCredentialsRequests(client, credRequests, identityProviderARN, PermissionsBoundaryARN, name, targetDir, generateOnly); err != nil {
		return errors.Wrap(err, "Failed while processing each CredentialsRequest")
	}

	return nil
}

func processCredentialsRequests(awsClient aws.Client, credReqs []*credreqv1.CredentialsRequest, identityProviderARN, PermissionsBoundaryARN, name, targetDir string, generateOnly bool) error {

	issuerURL, err := getIssuerURLFromIdentityProvider(awsClient, identityProviderARN)
	if err != nil {
		return err
	}

	for i, cr := range credReqs {
		// infraName-targetNamespace-targetSecretName
		_, err = createRole(awsClient, name, cr, i, identityProviderARN, issuerURL, PermissionsBoundaryARN, targetDir, generateOnly)
		if err != nil {
			return err
		}

	}
	return nil
}

func createRole(awsClient aws.Client, name string, credReq *credreqv1.CredentialsRequest, roleNum int, oidcProviderARN, issuerURL, PermissionsBoundaryARN, targetDir string, generateOnly bool) (string, error) {
	roleName := fmt.Sprintf("%s-%s-%s", name, credReq.Spec.SecretRef.Namespace, credReq.Spec.SecretRef.Name)

	// Decode AWSProviderSpec
	codec, err := credreqv1.NewCodec()
	if err != nil {
		return "", errors.Wrap(err, "Failed to create credReq codec")
	}

	awsProviderSpec := credreqv1.AWSProviderSpec{}
	if err := codec.DecodeProviderSpec(credReq.Spec.ProviderSpec, &awsProviderSpec); err != nil {
		return "", errors.Wrap(err, "Failed to decode the provider spec")
	}

	if awsProviderSpec.Kind != "AWSProviderSpec" {
		return "", fmt.Errorf("CredentialsRequest %s/%s is not of type AWS", credReq.Namespace, credReq.Name)
	}

	// Ensure role name is no longer than 64 charactters
	var shortenedRoleName string
	if len(roleName) > 64 {
		shortenedRoleName = roleName[0:64]
	} else {
		shortenedRoleName = roleName
	}

	rolePolicyDocument, err := createRolePolicyDocument(oidcProviderARN, issuerURL, credReq.Spec.SecretRef.Namespace, credReq.Spec.ServiceAccountNames)
	if err != nil {
		return "", errors.Wrap(err, "Error while create Role policy document")
	}

	roleDescription := fmt.Sprintf("OpenShift role for %s/%s", credReq.Spec.SecretRef.Namespace, credReq.Spec.SecretRef.Name)

	rolePolicy := createRolePolicy(awsProviderSpec.StatementEntries)

	switch generateOnly {
	case true:
		// Generate Role
		// Generated JSON must be valid input for AWS IAM CreateRole API
		roleTemplate := map[string]interface{}{
			"RoleName":                 shortenedRoleName,
			"Description":              roleDescription,
			"AssumeRolePolicyDocument": rolePolicyDocument,
			"Tags": []map[string]string{
				{
					"Key":   fmt.Sprintf("%s/%s", ccoctlAWSResourceTagKeyPrefix, name),
					"Value": ownedCcoctlAWSResourceTagValue,
				},
				{
					"Key":   nameTagKey,
					"Value": name,
				},
			},
		}
		if PermissionsBoundaryARN != "" {
			roleTemplate["PermissionsBoundary"] = PermissionsBoundaryARN
		}
		roleJSON, err := json.Marshal(&roleTemplate)
		if err != nil {
			return "", errors.Wrap(err, "failed to convert Role to JSON")
		}
		roleFilename := fmt.Sprintf(roleFilenameFormat, roleNum, roleName)
		roleFullPath := filepath.Join(targetDir, roleFilename)
		if err := saveToFile(roleDescription, roleFullPath, roleJSON); err != nil {
			return "", errors.Wrap(err, "failed to save Role content JSON")
		}

		// Generate Role Policy
		// Generated JSON must be valid input for AWS IAM PutRolePolicy API
		rolePolicyTemplate := map[string]string{
			"PolicyDocument": rolePolicy,
			"PolicyName":     shortenedRoleName,
			"RoleName":       shortenedRoleName,
		}
		rolePolicyJSON, err := json.Marshal(&rolePolicyTemplate)
		if err != nil {
			return "", errors.Wrap(err, "failed to convert Role Policy to JSON")
		}
		rolePolicyFilename := fmt.Sprintf(rolePolicyFilenameFormat, roleNum, roleName)
		rolePolicyFullPath := filepath.Join(targetDir, rolePolicyFilename)
		if err := saveToFile(roleDescription, rolePolicyFullPath, rolePolicyJSON); err != nil {
			return "", errors.Wrap(err, "failed to save Role Policy content JSON")
		}

		if err := writeCredReqSecret(credReq, targetDir, ""); err != nil {
			return "", errors.Wrap(err, "failed to save Secret for install manifests")
		}

		return "", nil

	default:
		var role *iam.Role
		outRole, err := awsClient.GetRole(&iam.GetRoleInput{
			RoleName: awssdk.String(shortenedRoleName),
		})

		if err != nil {
			var aerr awserr.Error
			if errors.As(err, &aerr) {
				switch aerr.Code() {
				case iam.ErrCodeNoSuchEntityException:

					roleInput := &iam.CreateRoleInput{
						RoleName:                 awssdk.String(shortenedRoleName),
						Description:              awssdk.String(roleDescription),
						AssumeRolePolicyDocument: awssdk.String(rolePolicyDocument),
						Tags: []*iam.Tag{
							{
								Key:   awssdk.String(fmt.Sprintf("%s/%s", ccoctlAWSResourceTagKeyPrefix, name)),
								Value: awssdk.String(ownedCcoctlAWSResourceTagValue),
							},
							{
								Key:   awssdk.String(nameTagKey),
								Value: awssdk.String(name),
							},
						},
					}
					if PermissionsBoundaryARN != "" {
						roleInput.PermissionsBoundary = awssdk.String(PermissionsBoundaryARN)
					}
					roleOutput, err := awsClient.CreateRole(roleInput)
					if err != nil {
						return "", errors.Wrap(err, "Failed to create role")
					}

					role = roleOutput.Role
					log.Printf("Role %s created", *role.Arn)

					if err := writeCredReqSecret(credReq, targetDir, *role.Arn); err != nil {
						return "", errors.Wrap(err, "failed to save Secret for install manifests")
					}

				default:
					return "", err
				}

			}
		} else {
			role = outRole.Role
			log.Printf("Existing role %s found", *role.Arn)
		}

		_, err = awsClient.PutRolePolicy(&iam.PutRolePolicyInput{
			PolicyName:     awssdk.String(shortenedRoleName),
			RoleName:       role.RoleName,
			PolicyDocument: awssdk.String(rolePolicy),
		})
		if err != nil {
			return "", errors.Wrap(err, "Failed to put role policy")
		}
		log.Printf("Updated Role policy for Role %s", *role.RoleName)

		return *role.Arn, nil
	}
}

func createRolePolicyDocument(oidcProviderARN, issuerURL, namespace string, serviceAccountNames []string) (string, error) {
	var conditionString string
	if len(serviceAccountNames) > 0 {
		var serviceAccountListString string
		for i, sa := range serviceAccountNames {
			if i == 0 {
				serviceAccountListString = fmt.Sprintf(`[ "system:serviceaccount:%s:%s" `, namespace, sa)
			} else {
				serviceAccountListString = fmt.Sprintf(`%s , "system:serviceaccount:%s:%s"`, serviceAccountListString, namespace, sa)
			}
		}
		serviceAccountListString += " ]"

		conditionString = fmt.Sprintf(` { "StringEquals": { "%s:sub": %s } }`, issuerURL, serviceAccountListString)
	} else {
		// TODO: maybe return an error once all CredentialsRequest start including ServiceAccountNames
		conditionString = fmt.Sprintf(`{ "StringEquals": { "%s:aud": "openshift" } }`, issuerURL)
	}
	policy := fmt.Sprintf(rolePolicyDocmentTemplate, oidcProviderARN, conditionString)

	return policy, nil
}

func getListOfCredentialsRequests(dir string) ([]*credreqv1.CredentialsRequest, error) {
	credRequests := []*credreqv1.CredentialsRequest{}
	files, err := ioutil.ReadDir(dir)
	if err != nil {
		return nil, err
	}

	for _, file := range files {
		f, err := os.Open(filepath.Join(dir, file.Name()))
		if err != nil {
			return nil, errors.Wrap(err, "Failed to open file")
		}
		defer f.Close()
		decoder := yaml.NewYAMLOrJSONDecoder(f, 4096)
		for {
			cr := &credreqv1.CredentialsRequest{}
			if err := decoder.Decode(cr); err != nil {
				if err == io.EOF {
					break
				}
				return nil, errors.Wrap(err, "Failed to decode to CredentialsRequest")
			}
			credRequests = append(credRequests, cr)
		}

	}

	return credRequests, nil
}

func getIssuerURLFromIdentityProvider(awsClient aws.Client, idProviderARN string) (string, error) {
	idProvider, err := awsClient.GetOpenIDConnectProvider(&iam.GetOpenIDConnectProviderInput{
		OpenIDConnectProviderArn: awssdk.String(idProviderARN),
	})

	if err != nil {
		return "", errors.Wrap(err, "failed to get IAM Identity Provider")
	}

	return *idProvider.Url, nil
}

func createIAMRolesCmd(cmd *cobra.Command, args []string) {
	s, err := awsSession(CreateIAMRolesOpts.Region)
	if err != nil {
		log.Fatal(err)
	}

	awsClient := aws.NewClientFromSession(s)

	err = createIAMRoles(awsClient, CreateIAMRolesOpts.IdentityProviderARN, CreateIAMRolesOpts.PermissionsBoundaryARN, CreateIAMRolesOpts.Name, CreateIAMRolesOpts.CredRequestDir, CreateIAMRolesOpts.TargetDir, CreateIAMRolesOpts.DryRun)
	if err != nil {
		log.Fatal(err)
	}
}

// StatementEntry is a simple type used to serialize to AWS' PolicyDocument format.
type StatementEntry struct {
	Effect   string
	Action   []string
	Resource string
	// Must "omitempty" otherwise we send unacceptable JSON to the AWS API when no
	// condition is defined.
	Condition credreqv1.IAMPolicyCondition `json:",omitempty"`
}

// PolicyDocument is a simple type used to serialize to AWS' PolicyDocument format.
type PolicyDocument struct {
	Version   string
	Statement []StatementEntry
}

func createRolePolicy(statements []credreqv1.StatementEntry) string {
	policyDocument := PolicyDocument{
		Version:   "2012-10-17",
		Statement: []StatementEntry{},
	}

	for _, entry := range statements {
		policyDocument.Statement = append(policyDocument.Statement,
			StatementEntry{
				Effect:    entry.Effect,
				Action:    entry.Action,
				Resource:  entry.Resource,
				Condition: entry.PolicyCondition,
			})
	}

	b, err := json.Marshal(&policyDocument)
	if err != nil {
		log.Fatalf("Failed to marshal the policy to JSON: %s", err)
	}

	return string(b)
}

// writeCredReqSecret will take a credentialsRequest and a Role ARN and store
// a Secret with an AWS config in the 'credentials' field of the Secret.
func writeCredReqSecret(cr *credreqv1.CredentialsRequest, targetDir, roleARN string) error {
	manifestsDir := filepath.Join(targetDir, manifestsDirName)

	fileName := fmt.Sprintf("%s-%s-credentials.yaml", cr.Spec.SecretRef.Namespace, cr.Spec.SecretRef.Name)
	filePath := filepath.Join(manifestsDir, fileName)

	fileData := fmt.Sprintf(secretManifestsTemplate, roleARN, cr.Spec.SecretRef.Name, cr.Spec.SecretRef.Namespace)

	// roleARN would be an empty string if ccoctl was in --dry-run mode
	// so lets make sure we have an invalide Secret until the user
	// has populated the Secret manually.
	if roleARN == "" {
		fileData = fileData + "\nPOPULATE ROLE ARN AND DELETE THIS LINE"
	}

	if err := ioutil.WriteFile(filePath, []byte(fileData), 0600); err != nil {
		return errors.Wrap(err, "Failed to save Secret file")
	}

	log.Printf("Saved credentials configuration to: %s", filePath)

	return nil
}

// initEnvForCreateIAMRolesCmd will ensure the destination directory is ready to receive the generated
// files, and will create the directory if necessary.
func initEnvForCreateIAMRolesCmd(cmd *cobra.Command, args []string) {
	if CreateIAMRolesOpts.TargetDir == "" {
		pwd, err := os.Getwd()
		if err != nil {
			log.Fatalf("Failed to get current directory: %s", err)
		}

		CreateIAMRolesOpts.TargetDir = pwd
	}

	fPath, err := filepath.Abs(CreateIAMRolesOpts.TargetDir)
	if err != nil {
		log.Fatalf("Failed to resolve full path: %s", err)
	}

	// create target dir if necessary
	err = provisioning.EnsureDir(fPath)
	if err != nil {
		log.Fatalf("failed to create target directory at %s", fPath)
	}

	// create manifests dir if necessary
	manifestsDir := filepath.Join(fPath, manifestsDirName)
	err = provisioning.EnsureDir(manifestsDir)
	if err != nil {
		log.Fatalf("failed to create manifests directory at %s", manifestsDir)
	}
}

// NewCreateIAMRolesCmd provides the "create-iam-roles" subcommand
func NewCreateIAMRolesCmd() *cobra.Command {
	createIAMRolesCmd := &cobra.Command{
		Use:              "create-iam-roles",
		Short:            "Create IAM roles",
		Run:              createIAMRolesCmd,
		PersistentPreRun: initEnvForCreateIAMRolesCmd,
	}

	createIAMRolesCmd.PersistentFlags().StringVar(&CreateIAMRolesOpts.Name, "name", "", "User-define name for all created AWS resources (can be separate from the cluster's infra-id)")
	createIAMRolesCmd.MarkPersistentFlagRequired("name")
	createIAMRolesCmd.PersistentFlags().StringVar(&CreateIAMRolesOpts.CredRequestDir, "credentials-requests-dir", "", "Directory containing files of CredentialsRequests to create IAM Roles for (can be created by running 'oc adm release extract --credentials-requests --cloud=aws' against an OpenShift release image)")
	createIAMRolesCmd.MarkPersistentFlagRequired("credentials-requests-dir")
	createIAMRolesCmd.PersistentFlags().StringVar(&CreateIAMRolesOpts.IdentityProviderARN, "identity-provider-arn", "", "ARN of IAM Identity provider for IAM Role trust relationship (can be created with the 'create identity-provider' sub-command)")
	createIAMRolesCmd.MarkPersistentFlagRequired("identity-provider-arn")
	createIAMRolesCmd.PersistentFlags().StringVar(&CreateIAMRolesOpts.PermissionsBoundaryARN, "permissions-boundary-arn", "", "ARN of IAM policy to use as the permissions boundary for created roles")
	createIAMRolesCmd.PersistentFlags().StringVar(&CreateIAMRolesOpts.Region, "region", "", "AWS region endpoint only required for GovCloud")
	createIAMRolesCmd.PersistentFlags().BoolVar(&CreateIAMRolesOpts.DryRun, "dry-run", false, "Skip creating objects, and just save what would have been created into files")
	createIAMRolesCmd.PersistentFlags().StringVar(&CreateIAMRolesOpts.TargetDir, "output-dir", "", "Directory to place generated files (defaults to current directory)")

	return createIAMRolesCmd
}
