// Copyright 2013 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package commands

import (
	"bytes"
	"fmt"
	"sort"
	"time"

	"github.com/juju/cmd"
	jc "github.com/juju/testing/checkers"
	"github.com/juju/utils"
	"github.com/juju/utils/exec"
	gc "gopkg.in/check.v1"
	"gopkg.in/juju/names.v2"

	"github.com/juju/juju/apiserver/common"
	"github.com/juju/juju/apiserver/params"
	"github.com/juju/juju/cmd/juju/action"
	"github.com/juju/juju/cmd/modelcmd"
	"github.com/juju/juju/testing"
)

type RunSuite struct {
	testing.FakeJujuXDGDataHomeSuite
}

var _ = gc.Suite(&RunSuite{})

func (*RunSuite) TestTargetArgParsing(c *gc.C) {
	for i, test := range []struct {
		message  string
		args     []string
		all      bool
		machines []string
		units    []string
		services []string
		commands string
		errMatch string
	}{{
		message:  "no args",
		errMatch: "no commands specified",
	}, {
		message:  "no target",
		args:     []string{"sudo reboot"},
		errMatch: "You must specify a target, either through --all, --machine, --application or --unit",
	}, {
		message:  "too many args",
		args:     []string{"--all", "sudo reboot", "oops"},
		errMatch: `unrecognized args: \["oops"\]`,
	}, {
		message:  "command to all machines",
		args:     []string{"--all", "sudo reboot"},
		all:      true,
		commands: "sudo reboot",
	}, {
		message:  "all and defined machines",
		args:     []string{"--all", "--machine=1,2", "sudo reboot"},
		errMatch: `You cannot specify --all and individual machines`,
	}, {
		message:  "command to machines 1, 2, and 1/kvm/0",
		args:     []string{"--machine=1,2,1/kvm/0", "sudo reboot"},
		commands: "sudo reboot",
		machines: []string{"1", "2", "1/kvm/0"},
	}, {
		message: "bad machine names",
		args:    []string{"--machine=foo,machine-2", "sudo reboot"},
		errMatch: "" +
			"The following run targets are not valid:\n" +
			"  \"foo\" is not a valid machine id\n" +
			"  \"machine-2\" is not a valid machine id",
	}, {
		message:  "all and defined applications",
		args:     []string{"--all", "--application=wordpress,mysql", "sudo reboot"},
		errMatch: `You cannot specify --all and individual applications`,
	}, {
		message:  "command to applications wordpress and mysql",
		args:     []string{"--application=wordpress,mysql", "sudo reboot"},
		commands: "sudo reboot",
		services: []string{"wordpress", "mysql"},
	}, {
		message: "bad application names",
		args:    []string{"--application", "foo,2,foo/0", "sudo reboot"},
		errMatch: "" +
			"The following run targets are not valid:\n" +
			"  \"2\" is not a valid application name\n" +
			"  \"foo/0\" is not a valid application name",
	}, {
		message:  "all and defined units",
		args:     []string{"--all", "--unit=wordpress/0,mysql/1", "sudo reboot"},
		errMatch: `You cannot specify --all and individual units`,
	}, {
		message:  "command to valid units",
		args:     []string{"--unit=wordpress/0,wordpress/1,mysql/0", "sudo reboot"},
		commands: "sudo reboot",
		units:    []string{"wordpress/0", "wordpress/1", "mysql/0"},
	}, {
		message: "bad unit names",
		args:    []string{"--unit", "foo,2,foo/0", "sudo reboot"},
		errMatch: "" +
			"The following run targets are not valid:\n" +
			"  \"foo\" is not a valid unit name\n" +
			"  \"2\" is not a valid unit name",
	}, {
		message:  "command to mixed valid targets",
		args:     []string{"--machine=0", "--unit=wordpress/0,wordpress/1", "--application=mysql", "sudo reboot"},
		commands: "sudo reboot",
		machines: []string{"0"},
		services: []string{"mysql"},
		units:    []string{"wordpress/0", "wordpress/1"},
	}} {
		c.Log(fmt.Sprintf("%v: %s", i, test.message))
		cmd := &runCommand{}
		runCmd := modelcmd.Wrap(cmd)
		testing.TestInit(c, runCmd, test.args, test.errMatch)
		if test.errMatch == "" {
			c.Check(cmd.all, gc.Equals, test.all)
			c.Check(cmd.machines, gc.DeepEquals, test.machines)
			c.Check(cmd.services, gc.DeepEquals, test.services)
			c.Check(cmd.units, gc.DeepEquals, test.units)
			c.Check(cmd.commands, gc.Equals, test.commands)
		}
	}
}

func (*RunSuite) TestTimeoutArgParsing(c *gc.C) {
	for i, test := range []struct {
		message  string
		args     []string
		errMatch string
		timeout  time.Duration
	}{{
		message: "default time",
		args:    []string{"--all", "sudo reboot"},
		timeout: 5 * time.Minute,
	}, {
		message:  "invalid time",
		args:     []string{"--timeout=foo", "--all", "sudo reboot"},
		errMatch: `invalid value "foo" for flag --timeout: time: invalid duration foo`,
	}, {
		message: "two hours",
		args:    []string{"--timeout=2h", "--all", "sudo reboot"},
		timeout: 2 * time.Hour,
	}, {
		message: "3 minutes 30 seconds",
		args:    []string{"--timeout=3m30s", "--all", "sudo reboot"},
		timeout: (3 * time.Minute) + (30 * time.Second),
	}} {
		c.Log(fmt.Sprintf("%v: %s", i, test.message))
		cmd := &runCommand{}
		runCmd := modelcmd.Wrap(cmd)
		testing.TestInit(c, runCmd, test.args, test.errMatch)
		if test.errMatch == "" {
			c.Check(cmd.timeout, gc.Equals, test.timeout)
		}
	}
}

func (s *RunSuite) TestConvertRunResults(c *gc.C) {
	for i, test := range []struct {
		message  string
		results  params.ActionResult
		query    actionQuery
		expected map[string]interface{}
	}{{
		message: "in case of error we print receiver and failed action id",
		results: makeActionResult(mockResponse{
			error: &params.Error{
				Message: "whoops",
			},
		}, ""),
		query: makeActionQuery(validUUID, "MachineId", names.NewMachineTag("1")),
		expected: map[string]interface{}{
			"Error":     "whoops",
			"MachineId": "1",
			"Action":    validUUID,
		},
	}, {
		message: "different action tag from query tag",
		results: makeActionResult(mockResponse{machineTag: "not-a-tag"}, "invalid"),
		query:   makeActionQuery(validUUID, "MachineId", names.NewMachineTag("1")),
		expected: map[string]interface{}{
			"Error":     `expected action tag "action-` + validUUID + `", got "invalid"`,
			"MachineId": "1",
			"Action":    validUUID,
		},
	}, {
		message: "different response tag from query tag",
		results: makeActionResult(mockResponse{machineTag: "not-a-tag"}, "action-"+validUUID),
		query:   makeActionQuery(validUUID, "MachineId", names.NewMachineTag("1")),
		expected: map[string]interface{}{
			"Error":     `expected action receiver "machine-1", got "not-a-tag"`,
			"MachineId": "1",
			"Action":    validUUID,
		},
	}, {
		message: "minimum is machine id",
		results: makeActionResult(mockResponse{machineTag: "machine-1"}, "action-"+validUUID),
		query:   makeActionQuery(validUUID, "MachineId", names.NewMachineTag("1")),
		expected: map[string]interface{}{
			"MachineId": "1",
			"Stdout":    "",
		},
	}, {
		message: "other fields are copied if there",
		results: makeActionResult(mockResponse{
			unitTag: "unit-unit-0",
			stdout:  "stdout",
			stderr:  "stderr",
			message: "msg",
			code:    "42",
		}, "action-"+validUUID),
		query: makeActionQuery(validUUID, "UnitId", names.NewUnitTag("unit/0")),
		expected: map[string]interface{}{
			"UnitId":     "unit/0",
			"Stdout":     "stdout",
			"Stderr":     "stderr",
			"Message":    "msg",
			"ReturnCode": 42,
		},
	}} {
		c.Log(fmt.Sprintf("%v: %s", i, test.message))
		result := ConvertActionResults(test.results, test.query)
		c.Check(result, jc.DeepEquals, test.expected)
	}
}

func (s *RunSuite) TestRunForMachineAndUnit(c *gc.C) {
	mock := s.setupMockAPI()
	machineResponse := mockResponse{
		stdout:     "megatron\n",
		machineTag: "machine-0",
	}
	unitResponse := mockResponse{
		stdout:  "bumblebee",
		unitTag: "unit-unit-0",
	}
	mock.setResponse("0", machineResponse)
	mock.setResponse("unit/0", unitResponse)

	machineResult := mock.runResponses["0"]
	unitResult := mock.runResponses["unit/0"]
	mock.actionResponses = map[string]params.ActionResult{
		mock.receiverIdMap["0"]:      machineResult,
		mock.receiverIdMap["unit/0"]: unitResult,
	}

	machineQuery := makeActionQuery(mock.receiverIdMap["0"], "MachineId", names.NewMachineTag("0"))
	unitQuery := makeActionQuery(mock.receiverIdMap["unit/0"], "UnitId", names.NewUnitTag("unit/0"))
	unformatted := []interface{}{
		ConvertActionResults(machineResult, machineQuery),
		ConvertActionResults(unitResult, unitQuery),
	}

	buff := &bytes.Buffer{}
	err := cmd.FormatJson(buff, unformatted)
	c.Assert(err, jc.ErrorIsNil)

	context, err := testing.RunCommand(c, newRunCommand(),
		"--format=json", "--machine=0", "--unit=unit/0", "hostname",
	)
	c.Assert(err, jc.ErrorIsNil)

	c.Check(testing.Stdout(context), gc.Equals, buff.String())
}

func (s *RunSuite) TestBlockRunForMachineAndUnit(c *gc.C) {
	mock := s.setupMockAPI()
	// Block operation
	mock.block = true
	_, err := testing.RunCommand(c, newRunCommand(),
		"--format=json", "--machine=0", "--unit=unit/0", "hostname",
	)
	testing.AssertOperationWasBlocked(c, err, ".*To enable changes.*")
}

func (s *RunSuite) TestAllMachines(c *gc.C) {
	mock := s.setupMockAPI()
	mock.setMachinesAlive("0", "1", "2")
	response0 := mockResponse{
		stdout:     "megatron\n",
		machineTag: "machine-0",
	}
	response1 := mockResponse{
		message:    "command timed out",
		machineTag: "machine-1",
	}
	response2 := mockResponse{
		message:    "command timed out",
		machineTag: "machine-2",
	}
	mock.setResponse("0", response0)
	mock.setResponse("1", response1)
	mock.setResponse("2", response2)

	machine0Result := mock.runResponses["0"]
	machine1Result := mock.runResponses["1"]
	mock.actionResponses = map[string]params.ActionResult{
		mock.receiverIdMap["0"]: machine0Result,
		mock.receiverIdMap["1"]: machine1Result,
	}

	machine0Query := makeActionQuery(mock.receiverIdMap["0"], "MachineId", names.NewMachineTag("0"))
	machine1Query := makeActionQuery(mock.receiverIdMap["1"], "MachineId", names.NewMachineTag("1"))
	unformatted := []interface{}{
		ConvertActionResults(machine0Result, machine0Query),
		ConvertActionResults(machine1Result, machine1Query),
		map[string]interface{}{
			"Action":    mock.receiverIdMap["2"],
			"MachineId": "2",
			"Error":     "action not found",
		},
	}

	buff := &bytes.Buffer{}
	err := cmd.FormatJson(buff, unformatted)
	c.Assert(err, jc.ErrorIsNil)

	context, err := testing.RunCommand(c, newRunCommand(), "--format=json", "--all", "hostname")
	c.Assert(err, jc.ErrorIsNil)

	c.Check(testing.Stdout(context), gc.Equals, buff.String())
	c.Check(testing.Stderr(context), gc.Equals, "")
}

func (s *RunSuite) TestBlockAllMachines(c *gc.C) {
	mock := s.setupMockAPI()
	// Block operation
	mock.block = true
	_, err := testing.RunCommand(c, newRunCommand(), "--format=json", "--all", "hostname")
	testing.AssertOperationWasBlocked(c, err, ".*To enable changes.*")
}

func (s *RunSuite) TestSingleResponse(c *gc.C) {
	mock := s.setupMockAPI()
	mock.setMachinesAlive("0")
	mockResponse := mockResponse{
		stdout:     "stdout\n",
		stderr:     "stderr\n",
		code:       "42",
		machineTag: "machine-0",
	}
	mock.setResponse("0", mockResponse)

	machineResult := mock.runResponses["0"]
	mock.actionResponses = map[string]params.ActionResult{
		mock.receiverIdMap["0"]: machineResult,
	}

	query := makeActionQuery(mock.receiverIdMap["0"], "MachineId", names.NewMachineTag("0"))
	unformatted := []interface{}{
		ConvertActionResults(machineResult, query),
	}

	jsonFormatted := &bytes.Buffer{}
	err := cmd.FormatJson(jsonFormatted, unformatted)
	c.Assert(err, jc.ErrorIsNil)

	yamlFormatted := &bytes.Buffer{}
	err = cmd.FormatYaml(yamlFormatted, unformatted)
	c.Assert(err, jc.ErrorIsNil)

	for i, test := range []struct {
		message    string
		format     string
		stdout     string
		stderr     string
		errorMatch string
	}{{
		message:    "smart (default)",
		stdout:     "stdout\n",
		stderr:     "stderr\n",
		errorMatch: "subprocess encountered error code 42",
	}, {
		message: "yaml output",
		format:  "yaml",
		stdout:  yamlFormatted.String(),
	}, {
		message: "json output",
		format:  "json",
		stdout:  jsonFormatted.String(),
	}} {
		c.Log(fmt.Sprintf("%v: %s", i, test.message))
		args := []string{}
		if test.format != "" {
			args = append(args, "--format", test.format)
		}
		args = append(args, "--all", "ignored")
		context, err := testing.RunCommand(c, newRunCommand(), args...)
		if test.errorMatch != "" {
			c.Check(err, gc.ErrorMatches, test.errorMatch)
		} else {
			c.Check(err, jc.ErrorIsNil)
		}
		c.Check(testing.Stdout(context), gc.Equals, test.stdout)
		c.Check(testing.Stderr(context), gc.Equals, test.stderr)
	}
}

func (s *RunSuite) setupMockAPI() *mockRunAPI {
	mock := &mockRunAPI{}
	s.PatchValue(&getRunAPIClient, func(_ *runCommand) (RunClient, error) {
		return mock, nil
	})
	return mock
}

type mockRunAPI struct {
	action.APIClient
	stdout string
	stderr string
	code   int
	// machines, services, units
	machines        map[string]bool
	runResponses    map[string]params.ActionResult
	actionResponses map[string]params.ActionResult
	receiverIdMap   map[string]string
	block           bool
}

type mockResponse struct {
	stdout     interface{}
	stderr     interface{}
	code       interface{}
	error      *params.Error
	message    string
	machineTag string
	unitTag    string
}

var _ RunClient = (*mockRunAPI)(nil)

func (m *mockRunAPI) setMachinesAlive(ids ...string) {
	if m.machines == nil {
		m.machines = make(map[string]bool)
	}
	for _, id := range ids {
		m.machines[id] = true
	}
}

func makeActionQuery(actionID string, receiverType string, receiverTag names.Tag) actionQuery {
	return actionQuery{
		actionTag: names.NewActionTag(actionID),
		receiver: actionReceiver{
			receiverType: receiverType,
			tag:          receiverTag,
		},
	}
}

func makeActionResult(mock mockResponse, actionTag string) params.ActionResult {
	var receiverTag string
	if mock.unitTag != "" {
		receiverTag = mock.unitTag
	} else {
		receiverTag = mock.machineTag
	}
	if actionTag == "" {
		actionTag = names.NewActionTag(utils.MustNewUUID().String()).String()
	}
	return params.ActionResult{
		Action: &params.Action{
			Tag:      actionTag,
			Receiver: receiverTag,
		},
		Message: mock.message,
		Error:   mock.error,
		Output: map[string]interface{}{
			"Stdout": mock.stdout,
			"Stderr": mock.stderr,
			"Code":   mock.code,
		},
	}
}

func (m *mockRunAPI) setResponse(id string, mock mockResponse) {
	if m.runResponses == nil {
		m.runResponses = make(map[string]params.ActionResult)
	}
	if m.receiverIdMap == nil {
		m.receiverIdMap = make(map[string]string)
	}
	actionTag := names.NewActionTag(utils.MustNewUUID().String())
	m.receiverIdMap[id] = actionTag.Id()
	m.runResponses[id] = makeActionResult(mock, actionTag.String())
}

func (*mockRunAPI) Close() error {
	return nil
}

func (m *mockRunAPI) RunOnAllMachines(commands string, timeout time.Duration) ([]params.ActionResult, error) {
	var result []params.ActionResult

	if m.block {
		return result, common.OperationBlockedError("the operation has been blocked")
	}
	sortedMachineIds := make([]string, 0, len(m.machines))
	for machineId := range m.machines {
		sortedMachineIds = append(sortedMachineIds, machineId)
	}
	sort.Strings(sortedMachineIds)

	for _, machineId := range sortedMachineIds {
		response, found := m.runResponses[machineId]
		if !found {
			// Consider this a timeout
			response = params.ActionResult{
				Action: &params.Action{
					Receiver: names.NewMachineTag(machineId).String(),
				},
				Message: exec.ErrCancelled.Error(),
			}
		}
		result = append(result, response)
	}

	return result, nil
}

func (m *mockRunAPI) Run(runParams params.RunParams) ([]params.ActionResult, error) {
	var result []params.ActionResult

	if m.block {
		return result, common.OperationBlockedError("the operation has been blocked")
	}
	// Just add in ids that match in order.
	for _, id := range runParams.Machines {
		response, found := m.runResponses[id]
		if found {
			result = append(result, response)
		}
	}
	// mock ignores services
	for _, id := range runParams.Units {
		response, found := m.runResponses[id]
		if found {
			result = append(result, response)
		}
	}

	return result, nil
}

func (m *mockRunAPI) Actions(actionTags params.Entities) (params.ActionResults, error) {
	results := params.ActionResults{Results: make([]params.ActionResult, len(actionTags.Entities))}

	for i, entity := range actionTags.Entities {
		response, found := m.actionResponses[entity.Tag[len("action-"):]]
		if !found {
			results.Results[i] = params.ActionResult{
				Error: &params.Error{
					Message: "action not found",
				},
			}
			continue
		}
		results.Results[i] = response
	}

	return results, nil
}

// validUUID is a UUID used in tests
var validUUID = "01234567-89ab-cdef-0123-456789abcdef"
