// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package views

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

	"github.com/zclconf/go-cty/cty"

	"github.com/opentofu/opentofu/internal/addrs"
	"github.com/opentofu/opentofu/internal/command/format"
	"github.com/opentofu/opentofu/internal/plans"
	"github.com/opentofu/opentofu/internal/providers"
	"github.com/opentofu/opentofu/internal/states"
	"github.com/opentofu/opentofu/internal/tofu"
)

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

func NewUiHook(view *View) *UiHook {
	return &UiHook{
		view:            view,
		periodicUiTimer: defaultPeriodicUiTimer,
		resources:       make(map[string]uiResourceState),
	}
}

func NewUIOptionalHook(view *View) tofu.Hook {
	if view.concise {
		return &tofu.NilHook{}
	}
	return NewUiHook(view)
}

type UiHook struct {
	tofu.NilHook

	viewLock sync.Mutex
	view     *View

	periodicUiTimer time.Duration

	resourcesLock sync.Mutex
	resources     map[string]uiResourceState
}

var _ tofu.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
	uiResourceNoOp
)

func (h *UiHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (tofu.HookAction, error) {
	dispAddr := addr.String()
	if gen != states.CurrentGen {
		dispAddr = fmt.Sprintf("%s (deposed object %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
	case plans.NoOp:
		op = uiResourceNoOp
	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.println(fmt.Sprintf("(Unknown action %s for %s)", action, dispAddr))
		return tofu.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 = ""
	}

	if operation != "" {
		h.println(fmt.Sprintf(
			h.view.colorize.Color("[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.resourcesLock.Lock()
	h.resources[key] = uiState
	h.resourcesLock.Unlock()

	// Start goroutine that shows progress
	if op != uiResourceNoOp {
		go h.stillApplying(uiState)
	}

	return tofu.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.println(fmt.Sprintf(
			h.view.colorize.Color("[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) (tofu.HookAction, error) {
	id := addr.String()

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

	delete(h.resources, id)
	h.resourcesLock.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 uiResourceNoOp:
		// We don't make any announcements about no-op changes
		return tofu.HookActionContinue, nil
	case uiResourceUnknown:
		return tofu.HookActionContinue, nil
	}

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

	addrStr := addr.String()
	if depKey, ok := gen.(states.DeposedKey); ok {
		addrStr = fmt.Sprintf("%s (deposed object %s)", addrStr, depKey)
	}

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

	h.println(colorized)

	return tofu.HookActionContinue, nil
}

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

func (h *UiHook) ProvisionOutput(addr addrs.AbsResourceInstance, typeName string, msg string) {
	var buf bytes.Buffer

	prefix := fmt.Sprintf(
		h.view.colorize.Color("[reset][bold]%s (%s):[reset] "),
		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.println(strings.TrimSpace(buf.String()))
}

func (h *UiHook) PreRefresh(addr addrs.AbsResourceInstance, gen states.Generation, priorState cty.Value) (tofu.HookAction, error) {
	var stateIdSuffix string
	if k, v := format.ObjectValueID(priorState); k != "" && v != "" {
		stateIdSuffix = fmt.Sprintf(" [%s=%s]", k, v)
	}

	addrStr := addr.String()
	if depKey, ok := gen.(states.DeposedKey); ok {
		addrStr = fmt.Sprintf("%s (deposed object %s)", addrStr, depKey)
	}
	h.println(fmt.Sprintf(
		h.view.colorize.Color("[reset][bold]%s: Refreshing state...%s"),
		addrStr, stateIdSuffix))
	return tofu.HookActionContinue, nil
}

func (h *UiHook) PreImportState(addr addrs.AbsResourceInstance, importID string) (tofu.HookAction, error) {
	h.println(fmt.Sprintf(
		h.view.colorize.Color("[reset][bold]%s: Importing from ID %q..."),
		addr, importID,
	))
	return tofu.HookActionContinue, nil
}

func (h *UiHook) PostImportState(addr addrs.AbsResourceInstance, imported []providers.ImportedResource) (tofu.HookAction, error) {
	h.println(fmt.Sprintf(
		h.view.colorize.Color("[reset][bold][green]%s: Import prepared!"),
		addr,
	))
	for _, s := range imported {
		h.println(fmt.Sprintf(
			h.view.colorize.Color("[reset][green]  Prepared %s for import"),
			s.TypeName,
		))
	}

	return tofu.HookActionContinue, nil
}

func (h *UiHook) PrePlanImport(addr addrs.AbsResourceInstance, importID string) (tofu.HookAction, error) {
	h.println(fmt.Sprintf(
		h.view.colorize.Color("[reset][bold]%s: Preparing import... [id=%s]"),
		addr, importID,
	))

	return tofu.HookActionContinue, nil
}

func (h *UiHook) PreApplyImport(addr addrs.AbsResourceInstance, importing plans.ImportingSrc) (tofu.HookAction, error) {
	h.println(fmt.Sprintf(
		h.view.colorize.Color("[reset][bold]%s: Importing... [id=%s]"),
		addr, importing.ID,
	))

	return tofu.HookActionContinue, nil
}

func (h *UiHook) PostApplyImport(addr addrs.AbsResourceInstance, importing plans.ImportingSrc) (tofu.HookAction, error) {
	h.println(fmt.Sprintf(
		h.view.colorize.Color("[reset][bold]%s: Import complete [id=%s]"),
		addr, importing.ID,
	))

	return tofu.HookActionContinue, nil
}

// Wrap calls to the view so that concurrent calls do not interleave println.
func (h *UiHook) println(s string) {
	h.viewLock.Lock()
	defer h.viewLock.Unlock()
	h.view.streams.Println(s)
}

// 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 carriage-return-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)
}
