package plan

import (
	"errors"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"
)

const scopeSendMessagePlan = `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  destination: {type: scope, required: true}
graph:
  entry:
    nodeId: notify
    port: in
    input:
      destination: {ref: plan.input.destination}
  nodes:
  - id: notify
    type: send_message
    input:
      destination: {type: scope, required: true}
    sendMessage:
      scope: {ref: input.destination}
      text: hello
      completion: sent
  - id: success
    type: end
    end: {outcome: succeeded}
  edges:
  - id: completed
    source: {nodeId: notify, port: completed}
    target: {nodeId: success, port: in}
`

func TestCompileScopeSendMessageEmitsArtifactV2(t *testing.T) {
	t.Parallel()

	artifact, diagnostics, err := Compile([]byte(scopeSendMessagePlan))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	require.Equal(t, ArtifactVersionV2, artifact.ArtifactVersion)
	require.Equal(t, CompilerVersionV2, artifact.CompilerVersion)

	send := artifactNodeByID(artifact, "notify").Config.SendMessage
	require.Nil(t, send.Target)
	require.NotNil(t, send.Scope)
	require.Equal(t, TypeScope, send.Scope.ResultType)
	require.Equal(t, ArtifactOpLoad, send.Scope.Op)
	require.Equal(t, ArtifactLoadActivationInput, send.Scope.Load.Space)
	require.Equal(t, []string{"destination"}, send.Scope.Load.Path)
	require.NoError(t, ValidateEnvelope(artifact))

	wire, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	decoded, err := DecodeArtifact(wire)
	require.NoError(t, err)
	require.Equal(t, artifact.Digest, decoded.Digest)
	require.Nil(t, artifactNodeByID(decoded, "notify").Config.SendMessage.Target)
	require.NotNil(t, artifactNodeByID(decoded, "notify").Config.SendMessage.Scope)
}

func TestCompileScopeLiteralIsNormalizedAndNominal(t *testing.T) {
	t.Parallel()

	literal := strings.Replace(
		scopeSendMessagePlan,
		"scope: {ref: input.destination}",
		"scope: {value: {scopeId: tsk_abcdefghijklmn, kind: task}}",
		1,
	)
	artifact, diagnostics, err := Compile([]byte(literal))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	scope := artifactNodeByID(artifact, "notify").Config.SendMessage.Scope
	require.Equal(t, TypeScope, scope.ResultType)
	require.JSONEq(t, `{"kind":"task","scopeId":"tsk_abcdefghijklmn"}`, string(scope.Value))

	stringDestination := strings.Replace(
		scopeSendMessagePlan,
		"scope: {ref: input.destination}",
		`scope: {value: "#all"}`,
		1,
	)
	artifact, diagnostics, err = Compile([]byte(stringDestination))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "send_message.scope_type"), "diagnostics: %#v", diagnostics)

	channelReference := strings.ReplaceAll(scopeSendMessagePlan, "type: scope", "type: channel")
	artifact, diagnostics, err = Compile([]byte(channelReference))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "send_message.scope_type"), "diagnostics: %#v", diagnostics)

	optionalReference := strings.ReplaceAll(scopeSendMessagePlan, "required: true", "required: false")
	artifact, diagnostics, err = Compile([]byte(optionalReference))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "send_message.scope_type"), "diagnostics: %#v", diagnostics)
}

func TestCompileRejectsScopeInterpolatedIntoLegacyTarget(t *testing.T) {
	t.Parallel()

	for _, target := range []string{
		`target: "{{ input.destination }}"`,
		`target: "{{ [input.destination] }}"`,
	} {
		source := strings.Replace(
			scopeSendMessagePlan,
			"scope: {ref: input.destination}",
			target,
			1,
		)
		artifact, diagnostics, err := Compile([]byte(source))
		require.NoError(t, err)
		require.Nil(t, artifact)
		diagnostic, ok := findCode(diagnostics, "send_message.target_type")
		require.True(t, ok, "diagnostics: %#v", diagnostics)
		require.Equal(t, "graph.nodes[0].sendMessage.target", diagnostic.Path)
	}

	legacy, diagnostics, err := Compile([]byte(validCallbackPlan))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "channel target compatibility diagnostics: %#v", diagnostics)
	require.Equal(t, ArtifactVersionV1, legacy.ArtifactVersion)
}

func TestCompileRejectsSendMessageScopeFromItsOwnCallbackOutput(t *testing.T) {
	t.Parallel()

	source := strings.Replace(
		scopeSendMessagePlan,
		"scope: {ref: input.destination}",
		"scope: {ref: output.destination}\n      callbackOutput:\n        destination: {type: scope, required: true}",
		1,
	)
	source = strings.Replace(source, "completion: sent", "completion: callback", 1)

	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Nil(t, artifact)
	diagnostic, ok := findCode(diagnostics, "expression.unknown_reference")
	require.True(t, ok, "diagnostics: %#v", diagnostics)
	require.Equal(t, "graph.nodes[0].sendMessage.scope.ref", diagnostic.Path)
}

func TestCompileSendMessageRequiresExactlyOneDestination(t *testing.T) {
	t.Parallel()

	neither := strings.Replace(implicitFailureSource, `      target: "#all"`+"\n", "", 1)
	artifact, diagnostics, err := Compile([]byte(neither))
	require.NoError(t, err)
	require.Nil(t, artifact)
	diagnostic, ok := findCode(diagnostics, "send_message.destination_required")
	require.True(t, ok, "diagnostics: %#v", diagnostics)
	require.Equal(t, "graph.nodes[0].sendMessage", diagnostic.Path)

	both := strings.Replace(
		implicitFailureSource,
		`      target: "#all"`,
		`      target: "#all"
      scope: {value: {kind: channel, scopeId: chn_abcdefghijklmn}}`,
		1,
	)
	artifact, diagnostics, err = Compile([]byte(both))
	require.NoError(t, err)
	require.Nil(t, artifact)
	diagnostic, ok = findCode(diagnostics, "send_message.destination_conflict")
	require.True(t, ok, "diagnostics: %#v", diagnostics)
	require.Equal(t, "graph.nodes[0].sendMessage", diagnostic.Path)
}

func TestCompileStartThreadPublishesTrustedSystemOutput(t *testing.T) {
	t.Parallel()

	source := strings.Replace(
		implicitFailureSource,
		"kind: PlanVersion",
		"kind: PlanVersion\noutput:\n  message_id: {type: string, required: true}\n  thread_scope: {type: scope, required: true}",
		1,
	)
	source = strings.Replace(
		source,
		"      completion: sent",
		"      completion: sent\n      startThread: true",
		1,
	)
	source = strings.Replace(
		source,
		"    target: {nodeId: success, port: in}",
		"    target: {nodeId: success, port: in}\n    bindings:\n      message_id: {ref: output.messageId}\n      thread_scope: {ref: output.threadScope}",
		1,
	)

	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	require.Equal(t, ArtifactVersionV2, artifact.ArtifactVersion)
	require.Equal(t, CompilerVersionV2, artifact.CompilerVersion)

	send := artifactNodeByID(artifact, "notify")
	require.NotNil(t, send)
	require.True(t, send.Config.SendMessage.StartThread)
	require.Equal(t, ArtifactContract{
		{Name: "messageId", Schema: ArtifactSchema{Type: TypeString, Required: true, Values: []string{}}},
		{Name: "threadScope", Schema: ArtifactSchema{Type: TypeScope, Required: true, Values: []string{}}},
	}, artifactPortContract(send.OutputPorts, "completed"))
	require.NoError(t, ValidateEnvelope(artifact))
}

func TestCompileStartThreadRejectsNonChannelDestinationsAndCallbackCollisions(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name        string
		source      string
		diagnostics []string
	}{
		{
			name: "callback completion",
			source: strings.Replace(
				validCallbackPlan,
				"      completion: callback",
				"      completion: callback\n      startThread: true",
				1,
			),
			diagnostics: []string{"send_message.start_thread_completion"},
		},
		{
			name: "callback message id collision",
			source: strings.Replace(
				validCallbackPlan,
				"      reply: {type: string, required: true}",
				"      messageId: {type: string, required: true}",
				1,
			),
			diagnostics: []string{"send_message.system_output_reserved"},
		},
		{
			name: "callback thread scope collision",
			source: strings.Replace(
				validCallbackPlan,
				"      reply: {type: string, required: true}",
				"      threadScope: {type: scope, required: true}",
				1,
			),
			diagnostics: []string{"send_message.system_output_reserved"},
		},
		{
			name: "literal dm",
			source: strings.Replace(
				strings.Replace(implicitFailureSource, `target: "#all"`, `target: "dm:@agent"`, 1),
				"      completion: sent",
				"      completion: sent\n      startThread: true",
				1,
			),
			diagnostics: []string{"send_message.start_thread_destination"},
		},
		{
			name: "literal reply thread",
			source: strings.Replace(
				strings.Replace(implicitFailureSource, `target: "#all"`, `target: "#all:abcdef12"`, 1),
				"      completion: sent",
				"      completion: sent\n      startThread: true",
				1,
			),
			diagnostics: []string{"send_message.start_thread_destination"},
		},
		{
			name: "literal task",
			source: strings.Replace(
				strings.Replace(implicitFailureSource, `target: "#all"`, `target: "#tsk_abc123"`, 1),
				"      completion: sent",
				"      completion: sent\n      startThread: true",
				1,
			),
			diagnostics: []string{"send_message.start_thread_destination"},
		},
		{
			name: "literal team",
			source: strings.Replace(
				strings.Replace(implicitFailureSource, `target: "#all"`, `target: "#team_abcdefghijklmn"`, 1),
				"      completion: sent",
				"      completion: sent\n      startThread: true",
				1,
			),
			diagnostics: []string{"send_message.start_thread_destination"},
		},
		{
			name: "literal non-channel scope",
			source: strings.Replace(
				strings.Replace(
					implicitFailureSource,
					`target: "#all"`,
					`scope: {value: {kind: task, scopeId: tsk_abcdefghijklmn}}`,
					1,
				),
				"      completion: sent",
				"      completion: sent\n      startThread: true",
				1,
			),
			diagnostics: []string{"send_message.start_thread_destination"},
		},
	}
	for _, test := range tests {
		test := test
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			artifact, diagnostics, err := Compile([]byte(test.source))
			require.NoError(t, err)
			require.Nil(t, artifact)
			for _, code := range test.diagnostics {
				require.True(t, hasCode(diagnostics, code), "diagnostics: %#v", diagnostics)
			}
		})
	}
}

func TestNormalizeScopePlanInputIsStrictAndNormalized(t *testing.T) {
	t.Parallel()

	contract := ArtifactContract{{
		Name: "destination",
		Schema: ArtifactSchema{
			Type: TypeScope, Required: true, Values: []string{},
		},
	}}
	input, err := NormalizeFinalInputJSON(contract, []byte(`{
		"destination":{"scopeId":"chn_abcdefghijklmn","kind":"channel"}
	}`))
	require.NoError(t, err)
	payload, err := input.DeterministicJSON()
	require.NoError(t, err)
	require.JSONEq(t, `{"destination":{"kind":"channel","scopeId":"chn_abcdefghijklmn"}}`, string(payload))

	for _, test := range []struct {
		name string
		raw  string
	}{
		{name: "target string", raw: `{"destination":"#all"}`},
		{name: "team", raw: `{"destination":{"kind":"team","scopeId":"chn_abcdefghijklmn"}}`},
		{name: "unknown field", raw: `{"destination":{"kind":"channel","scopeId":"chn_abcdefghijklmn","target":"#all"}}`},
		{name: "nonnormalized id", raw: `{"destination":{"kind":"channel","scopeId":" chn_abcdefghijklmn"}}`},
	} {
		t.Run(test.name, func(t *testing.T) {
			_, err := NormalizeFinalInputJSON(contract, []byte(test.raw))
			require.Error(t, err)
			var inputError InputError
			require.True(t, errors.As(err, &inputError))
			require.Equal(t, "input.scope", inputError.Code)
			require.Equal(t, "$.destination", inputError.Path)
		})
	}
}

func TestArtifactScopeVersionCompatibility(t *testing.T) {
	t.Parallel()

	scopeContractWithLegacyTarget := strings.Replace(
		implicitFailureSource,
		"kind: PlanVersion\n",
		"kind: PlanVersion\ninput:\n  destination: {type: scope}\n",
		1,
	)
	contractArtifact := compileArtifactForTest(t, scopeContractWithLegacyTarget)
	require.Equal(t, ArtifactVersionV2, contractArtifact.ArtifactVersion)
	require.Equal(t, CompilerVersionV2, contractArtifact.CompilerVersion)
	require.NotNil(t, artifactNodeByID(contractArtifact, "notify").Config.SendMessage.Target)
	require.Nil(t, artifactNodeByID(contractArtifact, "notify").Config.SendMessage.Scope)

	scopeArtifact := compileArtifactForTest(t, scopeSendMessagePlan)
	scopeArtifact.ArtifactVersion = ArtifactVersionV1
	require.ErrorContains(t, ValidateEnvelope(scopeArtifact), "does not support scope values")

	scopeArtifact.ArtifactVersion = ArtifactVersionV2
	send := artifactNodeByID(scopeArtifact, "notify").Config.SendMessage
	send.Scope = nil
	digest, err := scopeArtifact.computeDigest()
	require.NoError(t, err)
	scopeArtifact.Digest = digest
	require.ErrorContains(t, ValidateEnvelope(scopeArtifact), "exactly one of target or scope")

	scopeArtifact = compileArtifactForTest(t, scopeSendMessagePlan)
	send = artifactNodeByID(scopeArtifact, "notify").Config.SendMessage
	scopeExpression := *send.Scope
	send.Scope = nil
	send.Target = &ArtifactTemplate{Segments: []ArtifactTemplateSegment{{
		Kind:       ArtifactTemplateSegmentExpr,
		Expression: &scopeExpression,
	}}}
	digest, err = scopeArtifact.computeDigest()
	require.NoError(t, err)
	scopeArtifact.Digest = digest
	require.ErrorContains(t, ValidateEnvelope(scopeArtifact), "cannot interpolate a scope value")

	scopeArtifact = compileArtifactForTest(t, scopeSendMessagePlan)
	send = artifactNodeByID(scopeArtifact, "notify").Config.SendMessage
	scopeExpression = *send.Scope
	send.Scope = nil
	send.Target = &ArtifactTemplate{Segments: []ArtifactTemplateSegment{{
		Kind: ArtifactTemplateSegmentExpr,
		Expression: &ArtifactExpression{
			Op:         ArtifactOpArray,
			ResultType: TypeArray,
			Args:       []ArtifactExpression{scopeExpression},
		},
	}}}
	digest, err = scopeArtifact.computeDigest()
	require.NoError(t, err)
	scopeArtifact.Digest = digest
	require.ErrorContains(t, ValidateEnvelope(scopeArtifact), "cannot interpolate a scope value")

	legacyArtifact := compileArtifactForTest(t, implicitFailureSource)
	require.Equal(t, ArtifactVersionV1, legacyArtifact.ArtifactVersion)
	require.Equal(t, CompilerVersionV1, legacyArtifact.CompilerVersion)
	legacyWire, err := legacyArtifact.DeterministicJSON()
	require.NoError(t, err)
	require.NotContains(t, string(legacyWire), `"startThread"`)
	require.Empty(t, artifactPortContract(artifactNodeByID(legacyArtifact, "notify").OutputPorts, "completed"))
	require.NoError(t, ValidateEnvelope(legacyArtifact))

	scopeArtifact = compileArtifactForTest(t, scopeSendMessagePlan)
	send = artifactNodeByID(scopeArtifact, "notify").Config.SendMessage
	send.Target = artifactNodeByID(legacyArtifact, "notify").Config.SendMessage.Target
	digest, err = scopeArtifact.computeDigest()
	require.NoError(t, err)
	scopeArtifact.Digest = digest
	require.ErrorContains(t, ValidateEnvelope(scopeArtifact), "exactly one of target or scope")

	legacyArtifact.ArtifactVersion = ArtifactVersionV2
	legacyArtifact.CompilerVersion = CompilerVersionV2
	require.NoError(t, ValidateEnvelope(legacyArtifact), "v2 reader must retain the legacy target destination")

	callbackArtifact := compileArtifactForTest(t, validCallbackPlan)
	callbackNode := artifactNodeByID(callbackArtifact, "notify")
	callbackNode.Config.SendMessage.CallbackOutput[0].Name = sendMessageOutputMessageID
	artifactPortContract(callbackNode.OutputPorts, "completed")[0].Name = sendMessageOutputMessageID
	digest, err = callbackArtifact.computeDigest()
	require.NoError(t, err)
	callbackArtifact.Digest = digest
	require.ErrorContains(t, ValidateEnvelope(callbackArtifact), `sendMessage.callbackOutput field "messageId" is invalid`)
}

func TestValidateArtifactSchemaRejectsInvalidScopeInArrayDefault(t *testing.T) {
	t.Parallel()

	validDefault := ArtifactLiteral(
		`[{"kind":"channel","scopeId":"chn_abcdefghijklmn"}]`,
	)
	schema := ArtifactSchema{
		Type:    TypeArray,
		Default: &validDefault,
		Values:  []string{},
		Items: &ArtifactSchema{
			Type: TypeScope, Values: []string{},
		},
	}
	require.NoError(t, validateArtifactSchema("input.destinations", schema, 0))

	invalidDefault := ArtifactLiteral(
		`[{"kind":"team","scopeId":"chn_abcdefghijklmn"}]`,
	)
	schema.Default = &invalidDefault
	require.ErrorContains(
		t,
		validateArtifactSchema("input.destinations", schema, 0),
		`unsupported message scope kind "team"`,
	)
}

func TestScopeDestinationStateReadIsTracked(t *testing.T) {
	t.Parallel()

	source := strings.Replace(
		scopeSendMessagePlan,
		"input:\n  destination: {type: scope, required: true}",
		"state:\n  destination: {type: scope, required: true, default: {kind: channel, scopeId: chn_abcdefghijklmn}}",
		1,
	)
	source = strings.Replace(source, "      destination: {ref: plan.input.destination}", "      destination: {ref: state.destination}", 1)
	artifact := compileArtifactForTest(t, source)
	send := artifactNodeByID(artifact, "notify")
	require.Equal(t, []string{"destination"}, send.StateEffects.Reads)
}
