// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License"). You may
// not use this file except in compliance with the License. A copy of the
// License is located at
//
//	http://aws.amazon.com/apache2.0/
//
// or in the "license" file accompanying this file. This file is distributed
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
// express or implied. See the License for the specific language governing
// permissions and limitations under the License.

package userdata

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	"mime"
	"mime/multipart"
	"net/mail"
	"net/textproto"
	"strings"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/service/ecs"
)

// UserDataBuilder contains functionality to create user data scripts for Container Instances
type UserDataBuilder interface {
	AddFile(fileName string) error
	Build() (string, error)
}

// Builder implements UserDataBuilder
type Builder struct {
	writer      *multipart.Writer
	clusterName string
	userdata    *bytes.Buffer
	tags        []*ecs.Tag
}

// NewBuilder creates a Builder object for a given clusterName
func NewBuilder(clusterName string, tags []*ecs.Tag) UserDataBuilder {
	buf := new(bytes.Buffer)
	writer := multipart.NewWriter(buf)

	builder := &Builder{
		writer:      writer,
		clusterName: clusterName,
		userdata:    buf,
		tags:        tags,
	}

	return builder
}

// AddFile adds new userdata from a file
func (b *Builder) AddFile(fileName string) error {
	data, err := ioutil.ReadFile(fileName)
	if err != nil {
		return err
	}
	extraUserData := string(data)

	if ok, headers, body := isMultipart(extraUserData); ok { // extraUserData is multipart
		if err = b.processExistingMultipart(headers, body); err != nil {
			return err
		}
	} else { // extraUserData is not already multipart
		if err = b.writeExtraUserDataMimePart(extraUserData); err != nil {
			return err
		}
	}
	return nil
}

// Build the userdata for the given cluster
// Build() is not idempotent and can only be called once
func (b *Builder) Build() (string, error) {
	// add user data for joining the ECS Cluster
	if err := b.writeClusterUserDataMimePart(); err != nil {
		return "", err
	}
	if err := b.writer.Close(); err != nil {
		return "", err
	}
	header := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\nMIME-Version: 1.0\n\n", b.writer.Boundary())
	archive := append([]byte(header), b.userdata.Bytes()...)
	return unixifyLineEndings(string(archive)), nil
}

func (b *Builder) writePart(header textproto.MIMEHeader, body []byte) error {
	newPart, err := b.writer.CreatePart(header)
	if err != nil {
		return err
	}
	if _, err = newPart.Write(body); err != nil {
		return err
	}
	return nil
}

// unpacks an existing multipart archive and writes it using `writer`
func (b *Builder) processExistingMultipart(headers map[string]string, body io.Reader) error {
	partReader := multipart.NewReader(body, headers["boundary"])
	for {
		part, err := partReader.NextPart()
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}
		partBody, err := ioutil.ReadAll(part)
		if err != nil {
			return err
		}
		if err = b.writePart(part.Header, partBody); err != nil {
			return err
		}
		if err = part.Close(); err != nil {
			return err
		}
	}
	return nil
}

// Determines if the given string is already a multipart archive
// If it is, then it returns true, the multipart archive headers, and an io.Reader
// which can read the body of the multipart archive
func isMultipart(data string) (bool, map[string]string, io.Reader) {
	msg, err := mail.ReadMessage(strings.NewReader(data))
	if err != nil {
		return false, nil, nil
	}

	mediaType, headers, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
	if err != nil {
		return false, nil, nil
	}

	if strings.HasPrefix(mediaType, "multipart/") {
		return true, headers, msg.Body
	}
	return false, nil, nil
}

func (b *Builder) getClusterUserData() (string, error) {
	joinClusterUserData := `
#!/bin/bash
echo ECS_CLUSTER=%s >> /etc/ecs/ecs.config
`
	if len(b.tags) > 0 {
		tags := convertTags(b.tags)
		bits, err := json.Marshal(tags)
		if err != nil {
			return "", err
		}
		joinClusterUserData += fmt.Sprintf("echo 'ECS_CONTAINER_INSTANCE_TAGS=%s' >> /etc/ecs/ecs.config", string(bits))
	}
	return fmt.Sprintf(joinClusterUserData, b.clusterName), nil
}

func convertTags(tags []*ecs.Tag) map[string]string {
	converted := make(map[string]string)
	for _, tag := range tags {
		converted[aws.StringValue(tag.Key)] = aws.StringValue(tag.Value)
	}
	return converted
}

// writes the user data script to join the ecs cluster to a multipart archive
func (b *Builder) writeClusterUserDataMimePart() error {
	header := make(textproto.MIMEHeader)
	header.Add("Content-Type", "text/text/x-shellscript; charset=\"utf-8\"")
	header.Add("MIME-Version", "1.0")

	userData, err := b.getClusterUserData()
	if err != nil {
		return err
	}
	return b.writePart(header, []byte(userData))
}

// takes user inputted user data and writes it as one part in the mime multipart archive
// `extraUserData` is any user data passed in by the user which is not already a multipart archive
func (b *Builder) writeExtraUserDataMimePart(extraUserData string) error {
	header := make(textproto.MIMEHeader)
	// Setting the content type as text/plain is safe because Cloud Init will read its contents to determine its type
	header.Add("Content-Type", "text/text/plain; charset=\"utf-8\"")
	header.Add("MIME-Version", "1.0")

	return b.writePart(header, []byte(extraUserData))
}

// replaces all "\r\n" with "\n"
func unixifyLineEndings(s string) string {
	return strings.Replace(s, "\r\n", "\n", -1)
}
