package command

import (
	"bufio"
	"bytes"
	"fmt"
	"strings"
	"sync"
	"time"
	"unicode"

	"github.com/mitchellh/cli"
	"github.com/mitchellh/colorstring"
	"github.com/zclconf/go-cty/cty"

	"github.com/hashicorp/terraform/addrs"
	"github.com/hashicorp/terraform/command/format"
	"github.com/hashicorp/terraform/plans"
	"github.com/hashicorp/terraform/providers"
	"github.com/hashicorp/terraform/states"
	"github.com/hashicorp/terraform/terraform"
)

const defaultPeriodicUiTimer = 10 * time.Second
const maxIdLen = 80

type UiHook struct {
	terraform.NilHook

	Colorize        *colorstring.Colorize
	Ui              cli.Ui
	PeriodicUiTimer time.Duration

	l         sync.Mutex
	once      sync.Once
	resources map[string]uiResourceState
	ui        cli.Ui
}

var _ terraform.Hook = (*UiHook)(nil)

// uiResourceState tracks the state of a single resource
type uiResourceState struct {
	DispAddr       string
	IDKey, IDValue string
	Op             uiResourceOp
	Start          time.Time

	DoneCh chan struct{} // To be used for cancellation

	done chan struct{} // used to coordinate tests
}

// uiResourceOp is an enum for operations on a resource
type uiResourceOp byte

const (
	uiResourceUnknown uiResourceOp = iota
	uiResourceCreate
	uiResourceModify
	uiResourceDestroy
	uiResourceRead
)

func (h *UiHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) {
	h.once.Do(h.init)

	dispAddr := addr.String()
	if gen != states.CurrentGen {
		dispAddr = fmt.Sprintf("%s (%s)", dispAddr, gen)
	}

	var operation string
	var op uiResourceOp
	idKey, idValue := format.ObjectValueIDOrName(priorState)
	switch action {
	case plans.Delete:
		operation = "Destroying..."
		op = uiResourceDestroy
	case plans.Create:
		operation = "Creating..."
		op = uiResourceCreate
	case plans.Update:
		operation = "Modifying..."
		op = uiResourceModify
	case plans.Read:
		operation = "Reading..."
		op = uiResourceRead
	default:
		// We don't expect any other actions in here, so anything else is a
		// bug in the caller but we'll ignore it in order to be robust.
		h.ui.Output(fmt.Sprintf("(Unknown action %s for %s)", action, dispAddr))
		return terraform.HookActionContinue, nil
	}

	var stateIdSuffix string
	if idKey != "" && idValue != "" {
		stateIdSuffix = fmt.Sprintf(" [%s=%s]", idKey, idValue)
	} else {
		// Make sure they are both empty so we can deal with this more
		// easily in the other hook methods.
		idKey = ""
		idValue = ""
	}

	h.ui.Output(h.Colorize.Color(fmt.Sprintf(
		"[reset][bold]%s: %s%s[reset]",
		dispAddr,
		operation,
		stateIdSuffix,
	)))

	key := addr.String()
	uiState := uiResourceState{
		DispAddr: key,
		IDKey:    idKey,
		IDValue:  idValue,
		Op:       op,
		Start:    time.Now().Round(time.Second),
		DoneCh:   make(chan struct{}),
		done:     make(chan struct{}),
	}

	h.l.Lock()
	h.resources[key] = uiState
	h.l.Unlock()

	// Start goroutine that shows progress
	go h.stillApplying(uiState)

	return terraform.HookActionContinue, nil
}

func (h *UiHook) stillApplying(state uiResourceState) {
	defer close(state.done)
	for {
		select {
		case <-state.DoneCh:
			return

		case <-time.After(h.PeriodicUiTimer):
			// Timer up, show status
		}

		var msg string
		switch state.Op {
		case uiResourceModify:
			msg = "Still modifying..."
		case uiResourceDestroy:
			msg = "Still destroying..."
		case uiResourceCreate:
			msg = "Still creating..."
		case uiResourceRead:
			msg = "Still reading..."
		case uiResourceUnknown:
			return
		}

		idSuffix := ""
		if state.IDKey != "" {
			idSuffix = fmt.Sprintf("%s=%s, ", state.IDKey, truncateId(state.IDValue, maxIdLen))
		}

		h.ui.Output(h.Colorize.Color(fmt.Sprintf(
			"[reset][bold]%s: %s [%s%s elapsed][reset]",
			state.DispAddr,
			msg,
			idSuffix,
			time.Now().Round(time.Second).Sub(state.Start),
		)))
	}
}

func (h *UiHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, applyerr error) (terraform.HookAction, error) {

	id := addr.String()

	h.l.Lock()
	state := h.resources[id]
	if state.DoneCh != nil {
		close(state.DoneCh)
	}

	delete(h.resources, id)
	h.l.Unlock()

	var stateIdSuffix string
	if k, v := format.ObjectValueID(newState); k != "" && v != "" {
		stateIdSuffix = fmt.Sprintf(" [%s=%s]", k, v)
	}

	var msg string
	switch state.Op {
	case uiResourceModify:
		msg = "Modifications complete"
	case uiResourceDestroy:
		msg = "Destruction complete"
	case uiResourceCreate:
		msg = "Creation complete"
	case uiResourceRead:
		msg = "Read complete"
	case uiResourceUnknown:
		return terraform.HookActionContinue, nil
	}

	if applyerr != nil {
		// Errors are collected and printed in ApplyCommand, no need to duplicate
		return terraform.HookActionContinue, nil
	}

	colorized := h.Colorize.Color(fmt.Sprintf(
		"[reset][bold]%s: %s after %s%s[reset]",
		addr, msg, time.Now().Round(time.Second).Sub(state.Start), stateIdSuffix))

	h.ui.Output(colorized)

	return terraform.HookActionContinue, nil
}

func (h *UiHook) PreDiff(addr addrs.AbsResourceInstance, gen states.Generation, priorState, proposedNewState cty.Value) (terraform.HookAction, error) {
	return terraform.HookActionContinue, nil
}

func (h *UiHook) PreProvisionInstanceStep(addr addrs.AbsResourceInstance, typeName string) (terraform.HookAction, error) {
	h.ui.Output(h.Colorize.Color(fmt.Sprintf(
		"[reset][bold]%s: Provisioning with '%s'...[reset]",
		addr, typeName,
	)))
	return terraform.HookActionContinue, nil
}

func (h *UiHook) ProvisionOutput(addr addrs.AbsResourceInstance, typeName string, msg string) {
	var buf bytes.Buffer
	buf.WriteString(h.Colorize.Color("[reset]"))

	prefix := fmt.Sprintf("%s (%s): ", addr, typeName)
	s := bufio.NewScanner(strings.NewReader(msg))
	s.Split(scanLines)
	for s.Scan() {
		line := strings.TrimRightFunc(s.Text(), unicode.IsSpace)
		if line != "" {
			buf.WriteString(fmt.Sprintf("%s%s\n", prefix, line))
		}
	}

	h.ui.Output(strings.TrimSpace(buf.String()))
}

func (h *UiHook) PreRefresh(addr addrs.AbsResourceInstance, gen states.Generation, priorState cty.Value) (terraform.HookAction, error) {
	h.once.Do(h.init)

	var stateIdSuffix string
	if k, v := format.ObjectValueID(priorState); k != "" && v != "" {
		stateIdSuffix = fmt.Sprintf(" [%s=%s]", k, v)
	}

	h.ui.Output(h.Colorize.Color(fmt.Sprintf(
		"[reset][bold]%s: Refreshing state...%s",
		addr, stateIdSuffix)))
	return terraform.HookActionContinue, nil
}

func (h *UiHook) PreImportState(addr addrs.AbsResourceInstance, importID string) (terraform.HookAction, error) {
	h.once.Do(h.init)

	h.ui.Output(h.Colorize.Color(fmt.Sprintf(
		"[reset][bold]%s: Importing from ID %q...",
		addr, importID,
	)))
	return terraform.HookActionContinue, nil
}

func (h *UiHook) PostImportState(addr addrs.AbsResourceInstance, imported []providers.ImportedResource) (terraform.HookAction, error) {
	h.once.Do(h.init)

	h.ui.Output(h.Colorize.Color(fmt.Sprintf(
		"[reset][bold][green]%s: Import prepared!", addr)))
	for _, s := range imported {
		h.ui.Output(h.Colorize.Color(fmt.Sprintf(
			"[reset][green]  Prepared %s for import",
			s.TypeName,
		)))
	}

	return terraform.HookActionContinue, nil
}

func (h *UiHook) init() {
	if h.Colorize == nil {
		panic("colorize not given")
	}
	if h.PeriodicUiTimer == 0 {
		h.PeriodicUiTimer = defaultPeriodicUiTimer
	}

	h.resources = make(map[string]uiResourceState)

	// Wrap the ui so that it is safe for concurrency regardless of the
	// underlying reader/writer that is in place.
	h.ui = &cli.ConcurrentUi{Ui: h.Ui}
}

// scanLines is basically copied from the Go standard library except
// we've modified it to also fine `\r`.
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
	if atEOF && len(data) == 0 {
		return 0, nil, nil
	}
	if i := bytes.IndexByte(data, '\n'); i >= 0 {
		// We have a full newline-terminated line.
		return i + 1, dropCR(data[0:i]), nil
	}
	if i := bytes.IndexByte(data, '\r'); i >= 0 {
		// We have a full newline-terminated line.
		return i + 1, dropCR(data[0:i]), nil
	}
	// If we're at EOF, we have a final, non-terminated line. Return it.
	if atEOF {
		return len(data), dropCR(data), nil
	}
	// Request more data.
	return 0, nil, nil
}

// dropCR drops a terminal \r from the data.
func dropCR(data []byte) []byte {
	if len(data) > 0 && data[len(data)-1] == '\r' {
		return data[0 : len(data)-1]
	}
	return data
}

func truncateId(id string, maxLen int) string {
	// Note that the id may contain multibyte characters.
	// We need to truncate it to maxLen characters, not maxLen bytes.
	rid := []rune(id)
	totalLength := len(rid)
	if totalLength <= maxLen {
		return id
	}
	if maxLen < 5 {
		// We don't shorten to less than 5 chars
		// as that would be pointless with ... (3 chars)
		maxLen = 5
	}

	dots := []rune("...")
	partLen := maxLen / 2

	leftIdx := partLen - 1
	leftPart := rid[0:leftIdx]

	rightIdx := totalLength - partLen - 1

	overlap := maxLen - (partLen*2 + len(dots))
	if overlap < 0 {
		rightIdx -= overlap
	}

	rightPart := rid[rightIdx:]

	return string(leftPart) + string(dots) + string(rightPart)
}
