package habitat

import (
	"context"
	"crypto/sha256"
	"errors"
	"fmt"
	"io"
	"net/url"
	"strings"

	version "github.com/hashicorp/go-version"
	"github.com/hashicorp/terraform/communicator"
	"github.com/hashicorp/terraform/communicator/remote"
	"github.com/hashicorp/terraform/configs/hcl2shim"
	"github.com/hashicorp/terraform/helper/schema"
	"github.com/hashicorp/terraform/helper/validation"
	"github.com/hashicorp/terraform/terraform"
	"github.com/mitchellh/go-linereader"
)

type provisioner struct {
	Version          string
	AutoUpdate       bool
	HttpDisable      bool
	Services         []Service
	PermanentPeer    bool
	ListenCtl        string
	ListenGossip     string
	ListenHTTP       string
	Peer             string
	Peers            []string
	RingKey          string
	RingKeyContent   string
	CtlSecret        string
	SkipInstall      bool
	UseSudo          bool
	ServiceType      string
	ServiceName      string
	URL              string
	Channel          string
	Events           string
	Organization     string
	GatewayAuthToken string
	BuilderAuthToken string
	SupOptions       string
	AcceptLicense    bool

	installHabitat      provisionFn
	startHabitat        provisionFn
	uploadRingKey       provisionFn
	uploadCtlSecret     provisionFn
	startHabitatService provisionServiceFn

	osType string
}

type provisionFn func(terraform.UIOutput, communicator.Communicator) error
type provisionServiceFn func(terraform.UIOutput, communicator.Communicator, Service) error

func Provisioner() terraform.ResourceProvisioner {
	return &schema.Provisioner{
		Schema: map[string]*schema.Schema{
			"version": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"auto_update": &schema.Schema{
				Type:     schema.TypeBool,
				Optional: true,
				Default:  false,
			},
			"http_disable": &schema.Schema{
				Type:     schema.TypeBool,
				Optional: true,
				Default:  false,
			},
			"peer": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"peers": &schema.Schema{
				Type:     schema.TypeList,
				Elem:     &schema.Schema{Type: schema.TypeString},
				Optional: true,
			},
			"service_type": &schema.Schema{
				Type:         schema.TypeString,
				Optional:     true,
				Default:      "systemd",
				ValidateFunc: validation.StringInSlice([]string{"systemd", "unmanaged"}, false),
			},
			"service_name": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
				Default:  "hab-supervisor",
			},
			"use_sudo": &schema.Schema{
				Type:     schema.TypeBool,
				Optional: true,
				Default:  true,
			},
			"accept_license": &schema.Schema{
				Type:     schema.TypeBool,
				Required: true,
			},
			"permanent_peer": &schema.Schema{
				Type:     schema.TypeBool,
				Optional: true,
				Default:  false,
			},
			"listen_ctl": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"listen_gossip": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"listen_http": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"ring_key": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"ring_key_content": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"ctl_secret": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"url": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
				ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) {
					u, err := url.Parse(val.(string))
					if err != nil {
						errs = append(errs, fmt.Errorf("invalid URL specified for %q: %v", key, err))
					}

					if u.Scheme == "" {
						errs = append(errs, fmt.Errorf("invalid URL specified for %q (scheme must be specified)", key))
					}

					return warns, errs
				},
			},
			"channel": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"events": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"organization": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"gateway_auth_token": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"builder_auth_token": &schema.Schema{
				Type:     schema.TypeString,
				Optional: true,
			},
			"service": &schema.Schema{
				Type: schema.TypeSet,
				Elem: &schema.Resource{
					Schema: map[string]*schema.Schema{
						"name": &schema.Schema{
							Type:     schema.TypeString,
							Required: true,
						},
						"binds": &schema.Schema{
							Type:     schema.TypeList,
							Elem:     &schema.Schema{Type: schema.TypeString},
							Optional: true,
						},
						"bind": &schema.Schema{
							Type: schema.TypeSet,
							Elem: &schema.Resource{
								Schema: map[string]*schema.Schema{
									"alias": &schema.Schema{
										Type:     schema.TypeString,
										Required: true,
									},
									"service": &schema.Schema{
										Type:     schema.TypeString,
										Required: true,
									},
									"group": &schema.Schema{
										Type:     schema.TypeString,
										Required: true,
									},
								},
							},
							Optional: true,
						},
						"topology": &schema.Schema{
							Type:         schema.TypeString,
							Optional:     true,
							ValidateFunc: validation.StringInSlice([]string{"leader", "standalone"}, false),
						},
						"user_toml": &schema.Schema{
							Type:     schema.TypeString,
							Optional: true,
						},
						"strategy": &schema.Schema{
							Type:         schema.TypeString,
							Optional:     true,
							ValidateFunc: validation.StringInSlice([]string{"none", "rolling", "at-once"}, false),
						},
						"channel": &schema.Schema{
							Type:     schema.TypeString,
							Optional: true,
						},
						"group": &schema.Schema{
							Type:     schema.TypeString,
							Optional: true,
						},
						"url": &schema.Schema{
							Type:     schema.TypeString,
							Optional: true,
							ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) {
								u, err := url.Parse(val.(string))
								if err != nil {
									errs = append(errs, fmt.Errorf("invalid URL specified for %q: %v", key, err))
								}

								if u.Scheme == "" {
									errs = append(errs, fmt.Errorf("invalid URL specified for %q (scheme must be specified)", key))
								}

								return warns, errs
							},
						},
						"application": &schema.Schema{
							Type:     schema.TypeString,
							Optional: true,
						},
						"environment": &schema.Schema{
							Type:     schema.TypeString,
							Optional: true,
						},
						"service_key": &schema.Schema{
							Type:     schema.TypeString,
							Optional: true,
						},
					},
				},
				Optional: true,
			},
		},
		ApplyFunc:    applyFn,
		ValidateFunc: validateFn,
	}
}

func applyFn(ctx context.Context) error {
	o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput)
	s := ctx.Value(schema.ProvRawStateKey).(*terraform.InstanceState)
	d := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData)

	p, err := decodeConfig(d)
	if err != nil {
		return err
	}

	// Automatically determine the OS type
	switch t := s.Ephemeral.ConnInfo["type"]; t {
	case "ssh", "":
		p.osType = "linux"
	case "winrm":
		p.osType = "windows"
	default:
		return fmt.Errorf("unsupported connection type: %s", t)
	}

	switch p.osType {
	case "linux":
		p.installHabitat = p.linuxInstallHabitat
		p.uploadRingKey = p.linuxUploadRingKey
		p.uploadCtlSecret = p.linuxUploadCtlSecret
		p.startHabitat = p.linuxStartHabitat
		p.startHabitatService = p.linuxStartHabitatService
	case "windows":
		return fmt.Errorf("windows is not supported yet for the habitat provisioner")
	default:
		return fmt.Errorf("unsupported os type: %s", p.osType)
	}

	// Get a new communicator
	comm, err := communicator.New(s)
	if err != nil {
		return err
	}

	retryCtx, cancel := context.WithTimeout(ctx, comm.Timeout())
	defer cancel()

	// Wait and retry until we establish the connection
	err = communicator.Retry(retryCtx, func() error {
		return comm.Connect(o)
	})

	if err != nil {
		return err
	}
	defer comm.Disconnect()

	if !p.SkipInstall {
		o.Output("Installing habitat...")
		if err := p.installHabitat(o, comm); err != nil {
			return err
		}
	}

	if p.RingKeyContent != "" {
		o.Output("Uploading supervisor ring key...")
		if err := p.uploadRingKey(o, comm); err != nil {
			return err
		}
	}

	if p.CtlSecret != "" {
		o.Output("Uploading ctl secret...")
		if err := p.uploadCtlSecret(o, comm); err != nil {
			return err
		}
	}

	o.Output("Starting the habitat supervisor...")
	if err := p.startHabitat(o, comm); err != nil {
		return err
	}

	if p.Services != nil {
		for _, service := range p.Services {
			o.Output("Starting service: " + service.Name)
			if err := p.startHabitatService(o, comm, service); err != nil {
				return err
			}
		}
	}

	return nil
}

func validateFn(c *terraform.ResourceConfig) (ws []string, es []error) {
	ringKeyContent, ok := c.Get("ring_key_content")
	if ok && ringKeyContent != "" && ringKeyContent != hcl2shim.UnknownVariableValue {
		ringKey, ringOk := c.Get("ring_key")
		if ringOk && ringKey == "" {
			es = append(es, errors.New("if ring_key_content is specified, ring_key must be specified as well"))
		}
	}

	v, ok := c.Get("version")
	if ok && v != nil && strings.TrimSpace(v.(string)) != "" {
		if _, err := version.NewVersion(v.(string)); err != nil {
			es = append(es, errors.New(v.(string)+" is not a valid version."))
		}
	}

	acceptLicense, ok := c.Get("accept_license")
	if ok && !acceptLicense.(bool) {
		if v != nil && strings.TrimSpace(v.(string)) != "" {
			versionOld, _ := version.NewVersion("0.79.0")
			versionRequired, _ := version.NewVersion(v.(string))
			if versionRequired.GreaterThan(versionOld) {
				es = append(es, errors.New("Habitat end user license agreement needs to be accepted, set the accept_license argument to true to accept"))
			}
		} else { // blank means latest version
			es = append(es, errors.New("Habitat end user license agreement needs to be accepted, set the accept_license argument to true to accept"))
		}
	}

	// Validate service level configs
	services, ok := c.Get("service")
	if ok {
		data, dataOk := services.(string)
		if dataOk {
			es = append(es, fmt.Errorf("service '%v': must be a block", data))
		}
	}

	return ws, es
}

type Service struct {
	Name            string
	Strategy        string
	Topology        string
	Channel         string
	Group           string
	URL             string
	Binds           []Bind
	BindStrings     []string
	UserTOML        string
	AppName         string
	Environment     string
	ServiceGroupKey string
}

func (s *Service) getPackageName(fullName string) string {
	return strings.Split(fullName, "/")[1]
}

func (s *Service) getServiceNameChecksum() string {
	return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Name)))
}

type Bind struct {
	Alias   string
	Service string
	Group   string
}

func (b *Bind) toBindString() string {
	return fmt.Sprintf("%s:%s.%s", b.Alias, b.Service, b.Group)
}

func decodeConfig(d *schema.ResourceData) (*provisioner, error) {
	p := &provisioner{
		Version:          d.Get("version").(string),
		AutoUpdate:       d.Get("auto_update").(bool),
		HttpDisable:      d.Get("http_disable").(bool),
		Peer:             d.Get("peer").(string),
		Peers:            getPeers(d.Get("peers").([]interface{})),
		Services:         getServices(d.Get("service").(*schema.Set).List()),
		UseSudo:          d.Get("use_sudo").(bool),
		AcceptLicense:    d.Get("accept_license").(bool),
		ServiceType:      d.Get("service_type").(string),
		ServiceName:      d.Get("service_name").(string),
		RingKey:          d.Get("ring_key").(string),
		RingKeyContent:   d.Get("ring_key_content").(string),
		CtlSecret:        d.Get("ctl_secret").(string),
		PermanentPeer:    d.Get("permanent_peer").(bool),
		ListenCtl:        d.Get("listen_ctl").(string),
		ListenGossip:     d.Get("listen_gossip").(string),
		ListenHTTP:       d.Get("listen_http").(string),
		URL:              d.Get("url").(string),
		Channel:          d.Get("channel").(string),
		Events:           d.Get("events").(string),
		Organization:     d.Get("organization").(string),
		BuilderAuthToken: d.Get("builder_auth_token").(string),
		GatewayAuthToken: d.Get("gateway_auth_token").(string),
	}

	return p, nil
}

func getPeers(v []interface{}) []string {
	peers := make([]string, 0, len(v))
	for _, rawPeerData := range v {
		peers = append(peers, rawPeerData.(string))
	}
	return peers
}

func getServices(v []interface{}) []Service {
	services := make([]Service, 0, len(v))
	for _, rawServiceData := range v {
		serviceData := rawServiceData.(map[string]interface{})
		name := (serviceData["name"].(string))
		strategy := (serviceData["strategy"].(string))
		topology := (serviceData["topology"].(string))
		channel := (serviceData["channel"].(string))
		group := (serviceData["group"].(string))
		url := (serviceData["url"].(string))
		app := (serviceData["application"].(string))
		env := (serviceData["environment"].(string))
		userToml := (serviceData["user_toml"].(string))
		serviceGroupKey := (serviceData["service_key"].(string))
		var bindStrings []string
		binds := getBinds(serviceData["bind"].(*schema.Set).List())
		for _, b := range serviceData["binds"].([]interface{}) {
			bind, err := getBindFromString(b.(string))
			if err != nil {
				return nil
			}
			binds = append(binds, bind)
		}

		service := Service{
			Name:            name,
			Strategy:        strategy,
			Topology:        topology,
			Channel:         channel,
			Group:           group,
			URL:             url,
			UserTOML:        userToml,
			BindStrings:     bindStrings,
			Binds:           binds,
			AppName:         app,
			Environment:     env,
			ServiceGroupKey: serviceGroupKey,
		}
		services = append(services, service)
	}
	return services
}

func getBinds(v []interface{}) []Bind {
	binds := make([]Bind, 0, len(v))
	for _, rawBindData := range v {
		bindData := rawBindData.(map[string]interface{})
		alias := bindData["alias"].(string)
		service := bindData["service"].(string)
		group := bindData["group"].(string)
		bind := Bind{
			Alias:   alias,
			Service: service,
			Group:   group,
		}
		binds = append(binds, bind)
	}
	return binds
}

func (p *provisioner) copyOutput(o terraform.UIOutput, r io.Reader) {
	lr := linereader.New(r)
	for line := range lr.Ch {
		o.Output(line)
	}
}

func (p *provisioner) runCommand(o terraform.UIOutput, comm communicator.Communicator, command string) error {
	outR, outW := io.Pipe()
	errR, errW := io.Pipe()

	go p.copyOutput(o, outR)
	go p.copyOutput(o, errR)
	defer outW.Close()
	defer errW.Close()

	cmd := &remote.Cmd{
		Command: command,
		Stdout:  outW,
		Stderr:  errW,
	}

	if err := comm.Start(cmd); err != nil {
		return fmt.Errorf("error executing command %q: %v", cmd.Command, err)
	}

	if err := cmd.Wait(); err != nil {
		return err
	}

	return nil
}

func getBindFromString(bind string) (Bind, error) {
	t := strings.FieldsFunc(bind, func(d rune) bool {
		switch d {
		case ':', '.':
			return true
		}
		return false
	})
	if len(t) != 3 {
		return Bind{}, errors.New("invalid bind specification: " + bind)
	}
	return Bind{Alias: t[0], Service: t[1], Group: t[2]}, nil
}
