package views

import (
	"bytes"
	"encoding/json"
	"fmt"
	"sort"
	"strings"

	"github.com/zclconf/go-cty/cty"
	"github.com/zclconf/go-cty/cty/convert"
	ctyjson "github.com/zclconf/go-cty/cty/json"

	"github.com/hashicorp/terraform/command/arguments"
	"github.com/hashicorp/terraform/repl"
	"github.com/hashicorp/terraform/states"
	"github.com/hashicorp/terraform/tfdiags"
)

// The Output view renders either one or all outputs, depending on whether or
// not the name argument is empty.
type Output interface {
	Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics
	Diagnostics(diags tfdiags.Diagnostics)
}

// NewOutput returns an initialized Output implementation for the given ViewType.
func NewOutput(vt arguments.ViewType, view *View) Output {
	switch vt {
	case arguments.ViewJSON:
		return &OutputJSON{view: view}
	case arguments.ViewRaw:
		return &OutputRaw{view: view}
	case arguments.ViewHuman:
		return &OutputHuman{view: view}
	default:
		panic(fmt.Sprintf("unknown view type %v", vt))
	}
}

// The OutputHuman implementation renders outputs in a format equivalent to HCL
// source. This uses the same formatting logic as in the console REPL.
type OutputHuman struct {
	view *View
}

var _ Output = (*OutputHuman)(nil)

func (v *OutputHuman) Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics

	if len(outputs) == 0 {
		diags = diags.Append(noOutputsWarning())
		return diags
	}

	if name != "" {
		output, ok := outputs[name]
		if !ok {
			diags = diags.Append(missingOutputError(name))
			return diags
		}
		result := repl.FormatValue(output.Value, 0)
		v.view.streams.Println(result)
		return nil
	}

	outputBuf := new(bytes.Buffer)
	if len(outputs) > 0 {
		// Output the outputs in alphabetical order
		keyLen := 0
		ks := make([]string, 0, len(outputs))
		for key := range outputs {
			ks = append(ks, key)
			if len(key) > keyLen {
				keyLen = len(key)
			}
		}
		sort.Strings(ks)

		for _, k := range ks {
			v := outputs[k]
			if v.Sensitive {
				outputBuf.WriteString(fmt.Sprintf("%s = <sensitive>\n", k))
				continue
			}

			result := repl.FormatValue(v.Value, 0)
			outputBuf.WriteString(fmt.Sprintf("%s = %s\n", k, result))
		}
	}

	v.view.streams.Println(strings.TrimSpace(outputBuf.String()))

	return nil
}

func (v *OutputHuman) Diagnostics(diags tfdiags.Diagnostics) {
	v.view.Diagnostics(diags)
}

// The OutputRaw implementation renders single string, number, or boolean
// output values directly and without quotes or other formatting. This is
// intended for use in shell scripting or other environments where the exact
// type of an output value is not important.
type OutputRaw struct {
	view *View

	// Unit tests may set rawPrint to capture the output from the Output
	// method, which would normally go to stdout directly.
	rawPrint func(string)
}

var _ Output = (*OutputRaw)(nil)

func (v *OutputRaw) Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics

	if len(outputs) == 0 {
		diags = diags.Append(noOutputsWarning())
		return diags
	}

	if name == "" {
		diags = diags.Append(fmt.Errorf("Raw output format is only supported for single outputs"))
		return diags
	}

	output, ok := outputs[name]
	if !ok {
		diags = diags.Append(missingOutputError(name))
		return diags
	}

	strV, err := convert.Convert(output.Value, cty.String)
	if err != nil {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Unsupported value for raw output",
			fmt.Sprintf(
				"The -raw option only supports strings, numbers, and boolean values, but output value %q is %s.\n\nUse the -json option for machine-readable representations of output values that have complex types.",
				name, output.Value.Type().FriendlyName(),
			),
		))
		return diags
	}
	if strV.IsNull() {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Unsupported value for raw output",
			fmt.Sprintf(
				"The value for output value %q is null, so -raw mode cannot print it.",
				name,
			),
		))
		return diags
	}
	if !strV.IsKnown() {
		// Since we're working with values from the state it would be very
		// odd to end up in here, but we'll handle it anyway to avoid a
		// panic in case our rules somehow change in future.
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Unsupported value for raw output",
			fmt.Sprintf(
				"The value for output value %q won't be known until after a successful terraform apply, so -raw mode cannot print it.",
				name,
			),
		))
		return diags
	}
	// If we get out here then we should have a valid string to print.
	// We're writing it using Print here so that a shell caller will get
	// exactly the value and no extra whitespace (including trailing newline).
	v.view.streams.Print(strV.AsString())
	return nil
}

func (v *OutputRaw) Diagnostics(diags tfdiags.Diagnostics) {
	v.view.Diagnostics(diags)
}

// The OutputJSON implementation renders outputs as JSON values. When rendering
// a single output, only the value is displayed. When rendering all outputs,
// the result is a JSON object with keys matching the output names and object
// values including type and sensitivity metadata.
type OutputJSON struct {
	view *View
}

var _ Output = (*OutputJSON)(nil)

func (v *OutputJSON) Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics

	if name != "" {
		output, ok := outputs[name]
		if !ok {
			diags = diags.Append(missingOutputError(name))
			return diags
		}
		value := output.Value

		jsonOutput, err := ctyjson.Marshal(value, value.Type())
		if err != nil {
			diags = diags.Append(err)
			return diags
		}

		v.view.streams.Println(string(jsonOutput))

		return nil
	}

	// Due to a historical accident, the switch from state version 2 to
	// 3 caused our JSON output here to be the full metadata about the
	// outputs rather than just the output values themselves as we'd
	// show in the single value case. We must now maintain that behavior
	// for compatibility, so this is an emulation of the JSON
	// serialization of outputs used in state format version 3.
	type OutputMeta struct {
		Sensitive bool            `json:"sensitive"`
		Type      json.RawMessage `json:"type"`
		Value     json.RawMessage `json:"value"`
	}
	outputMetas := map[string]OutputMeta{}

	for n, os := range outputs {
		jsonVal, err := ctyjson.Marshal(os.Value, os.Value.Type())
		if err != nil {
			diags = diags.Append(err)
			return diags
		}
		jsonType, err := ctyjson.MarshalType(os.Value.Type())
		if err != nil {
			diags = diags.Append(err)
			return diags
		}
		outputMetas[n] = OutputMeta{
			Sensitive: os.Sensitive,
			Type:      json.RawMessage(jsonType),
			Value:     json.RawMessage(jsonVal),
		}
	}

	jsonOutputs, err := json.MarshalIndent(outputMetas, "", "  ")
	if err != nil {
		diags = diags.Append(err)
		return diags
	}

	v.view.streams.Println(string(jsonOutputs))

	return nil
}

func (v *OutputJSON) Diagnostics(diags tfdiags.Diagnostics) {
	v.view.Diagnostics(diags)
}

// For text and raw output modes, an empty map of outputs is considered a
// separate and higher priority failure mode than an output not being present
// in a non-empty map. This warning diagnostic explains how this might have
// happened.
func noOutputsWarning() tfdiags.Diagnostic {
	return tfdiags.Sourceless(
		tfdiags.Warning,
		"No outputs found",
		"The state file either has no outputs defined, or all the defined "+
			"outputs are empty. Please define an output in your configuration "+
			"with the `output` keyword and run `terraform refresh` for it to "+
			"become available. If you are using interpolation, please verify "+
			"the interpolated value is not empty. You can use the "+
			"`terraform console` command to assist.",
	)
}

// Attempting to display a missing output results in this failure, which
// includes suggestions on how to rectify the problem.
func missingOutputError(name string) tfdiags.Diagnostic {
	return tfdiags.Sourceless(
		tfdiags.Error,
		fmt.Sprintf("Output %q not found", name),
		"The output variable requested could not be found in the state "+
			"file. If you recently added this to your configuration, be "+
			"sure to run `terraform apply`, since the state won't be updated "+
			"with new output variables until that command is run.",
	)
}
