package plan

import (
	"fmt"
	"strings"
	"testing"

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

const agentResolvedWorkScopePlan = `apiVersion: loop.plan/v1
kind: PlanVersion
graph:
  entry: {nodeId: implement, port: in}
  nodes:
  - id: implement
    type: send_message
    sendMessage:
      target: "#all"
      text: implement the requested change
      completion: callback
      workScope: null
      relatedScopes: []
  - id: success
    type: end
    end: {outcome: succeeded}
  edges:
  - id: implemented
    source: {nodeId: implement, port: completed}
    target: {nodeId: success, port: in}
`

const fixedWorkScopePlan = `apiVersion: loop.plan/v1
kind: PlanVersion
state:
  implementation:
    type: scope
    default: {kind: task, scopeId: tsk_abcdefghijklmn}
  requirement:
    type: scope
    default: {kind: thread, scopeId: thr_abcdefghijklmn}
graph:
  entry: {nodeId: implement, port: in}
  nodes:
  - id: implement
    type: send_message
    sendMessage:
      target: "#all"
      text: implement the requested change
      completion: callback
      workScope: {ref: state.implementation}
      relatedScopes:
      - {ref: state.requirement}
      - {value: {kind: channel, scopeId: chn_abcdefghijklmn}}
      callbackOutput:
        result: {type: string, required: true}
  - id: success
    type: end
    end: {outcome: succeeded}
  edges:
  - id: implemented
    source: {nodeId: implement, port: completed}
    target: {nodeId: success, port: in}
`

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

	document, diagnostics := Parse([]byte(agentResolvedWorkScopePlan))
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	config := document.Graph.Nodes[0].SendMessage
	require.True(t, config.hasWorkScope())
	require.Nil(t, config.WorkScope, "explicit null is the Agent-resolved mode")
	require.True(t, config.hasRelatedScopes(), "an authored empty list retains source presence")

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

	node := artifactNodeByID(artifact, "implement")
	send := node.Config.SendMessage
	require.Equal(t, ArtifactWorkScopeAgent, send.WorkScopeMode)
	require.Nil(t, send.WorkScope)
	require.Empty(t, send.RelatedScopes)
	require.Empty(t, send.CallbackOutput)
	require.Equal(t, ArtifactContract{{
		Name: sendMessageOutputWorkScope,
		Schema: ArtifactSchema{
			Type: TypeScope, Required: true, Values: []string{},
		},
	}}, artifactPortContract(node.OutputPorts, "completed"))
	require.NoError(t, ValidateEnvelope(artifact))

	wire, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	require.Contains(t, string(wire), `"artifactVersion":"loop.plan.artifact/v3"`)
	require.Contains(t, string(wire), `"workScopeMode":"agent"`)
	require.NotContains(t, string(wire), `"workScope":`)
	decoded, err := DecodeArtifact(wire)
	require.NoError(t, err)
	require.Equal(t, ArtifactWorkScopeAgent, artifactNodeByID(decoded, "implement").Config.SendMessage.WorkScopeMode)
	require.Equal(t, artifact.Digest, decoded.Digest)

	withoutEmptyRelated := strings.Replace(agentResolvedWorkScopePlan, "      relatedScopes: []\n", "", 1)
	withoutEmptyRelatedArtifact := compileArtifactForTest(t, withoutEmptyRelated)
	require.Equal(t, ArtifactVersionV3, withoutEmptyRelatedArtifact.ArtifactVersion)
	require.Equal(t, artifact.Digest, withoutEmptyRelatedArtifact.Digest, "omitted and empty relatedScopes are semantically equivalent")
}

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

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

	node := artifactNodeByID(artifact, "implement")
	send := node.Config.SendMessage
	require.Equal(t, ArtifactWorkScopeFixed, send.WorkScopeMode)
	require.NotNil(t, send.WorkScope)
	require.Equal(t, TypeScope, send.WorkScope.ResultType)
	require.Equal(t, ArtifactLoadRunState, send.WorkScope.Load.Space)
	require.Equal(t, []string{"implementation"}, send.WorkScope.Load.Path)
	require.Len(t, send.RelatedScopes, 2)
	require.Equal(t, ArtifactLoadRunState, send.RelatedScopes[0].Load.Space)
	require.Equal(t, []string{"requirement"}, send.RelatedScopes[0].Load.Path)
	require.JSONEq(t, `{"kind":"channel","scopeId":"chn_abcdefghijklmn"}`, string(send.RelatedScopes[1].Value))
	require.Equal(t, []string{"implementation", "requirement"}, node.StateEffects.Reads)

	completed := artifactPortContract(node.OutputPorts, "completed")
	require.Equal(t, []string{"result", sendMessageOutputWorkScope}, []string{completed[0].Name, completed[1].Name})
	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.Len(t, artifactNodeByID(decoded, "implement").Config.SendMessage.RelatedScopes, 2)

	workScopeOnly := strings.Replace(
		fixedWorkScopePlan,
		"      callbackOutput:\n        result: {type: string, required: true}\n",
		"",
		1,
	)
	artifact, diagnostics, err = Compile([]byte(workScopeOnly))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	require.Empty(t, artifactNodeByID(artifact, "implement").Config.SendMessage.CallbackOutput)
	require.NoError(t, ValidateEnvelope(artifact))
}

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

	artifact, diagnostics, err := Compile([]byte(relatedScopeCountPlan(MaxSendMessageRelatedScopes)))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	require.Len(
		t,
		artifactNodeByID(artifact, "implement").Config.SendMessage.RelatedScopes,
		MaxSendMessageRelatedScopes,
	)

	artifact, diagnostics, err = Compile([]byte(relatedScopeCountPlan(MaxSendMessageRelatedScopes + 1)))
	require.NoError(t, err)
	require.Nil(t, artifact)
	diagnostic, ok := findCode(diagnostics, "resource.too_many_related_scopes")
	require.True(t, ok, "diagnostics: %#v", diagnostics)
	require.Equal(t, "graph.nodes[0].sendMessage.relatedScopes", diagnostic.Path)
	require.Equal(t, "implement", diagnostic.NodeID)
	require.Contains(t, diagnostic.Message, fmt.Sprintf("%d scopes", MaxSendMessageRelatedScopes))
}

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

	oversized := make([]ValueExpr, MaxSendMessageRelatedScopes+1)
	document := &Document{
		Graph: Graph{Nodes: []Node{
			{
				ID:   "implement",
				Type: NodeSendMessage,
				SendMessage: &SendMessageConfig{
					Completion:    CompletionCallback,
					RelatedScopes: RelatedScopeExprs{Items: oversized},
				},
			},
			{
				ID:   "review",
				Type: NodeSendMessage,
				SendMessage: &SendMessageConfig{
					Completion:    CompletionCallback,
					RelatedScopes: RelatedScopeExprs{Items: oversized},
				},
			},
		}},
	}

	diagnostics := documentBudgetDiagnostics(document)
	require.Equal(t, []Diagnostic{
		{
			Severity: SeverityError,
			Code:     "resource.too_many_related_scopes",
			Path:     "graph.nodes[0].sendMessage.relatedScopes",
			Message:  fmt.Sprintf("relatedScopes must not contain more than %d scopes", MaxSendMessageRelatedScopes),
			NodeID:   "implement",
		},
		{
			Severity: SeverityError,
			Code:     "resource.too_many_related_scopes",
			Path:     "graph.nodes[1].sendMessage.relatedScopes",
			Message:  fmt.Sprintf("relatedScopes must not contain more than %d scopes", MaxSendMessageRelatedScopes),
			NodeID:   "review",
		},
	}, diagnostics)
}

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

	artifact := compileArtifactForTest(t, relatedScopeCountPlan(MaxSendMessageRelatedScopes))
	wire, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	decoded, err := DecodeArtifact(wire)
	require.NoError(t, err)
	require.Len(
		t,
		artifactNodeByID(decoded, "implement").Config.SendMessage.RelatedScopes,
		MaxSendMessageRelatedScopes,
	)

	send := artifactNodeByID(artifact, "implement").Config.SendMessage
	send.RelatedScopes = append(send.RelatedScopes, ArtifactExpression{
		Op:         ArtifactOpConst,
		ResultType: TypeScope,
		Value:      ArtifactLiteral(`{"kind":"task","scopeId":"tsk_context_64"}`),
	})
	artifact.Digest, err = artifact.computeDigest()
	require.NoError(t, err)
	wire, err = artifact.DeterministicJSON()
	require.NoError(t, err)
	_, err = DecodeArtifact(wire)
	require.ErrorContains(t, err, fmt.Sprintf("more than %d scopes", MaxSendMessageRelatedScopes))
}

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

	legacy := compileArtifactForTest(t, validCallbackPlan)
	require.Equal(t, ArtifactVersionV1, legacy.ArtifactVersion)
	require.Equal(t, CompilerVersionV1, legacy.CompilerVersion)
	legacyWire, err := legacy.DeterministicJSON()
	require.NoError(t, err)
	require.NotContains(t, string(legacyWire), `"workScopeMode"`)
	require.NotContains(t, string(legacyWire), `"relatedScopes"`)

	legacyNamedWorkScope := compileArtifactForTest(t, strings.Replace(
		validCallbackPlan,
		"        reply: {type: string, required: true}",
		"        reply: {type: string, required: true}\n        workScope: {type: string, required: true}",
		1,
	))
	require.Equal(t, ArtifactVersionV1, legacyNamedWorkScope.ArtifactVersion)
	require.Empty(t, artifactNodeByID(legacyNamedWorkScope, "notify").Config.SendMessage.WorkScopeMode)

	scopeDestination := compileArtifactForTest(t, scopeSendMessagePlan)
	require.Equal(t, ArtifactVersionV2, scopeDestination.ArtifactVersion)
	require.Equal(t, CompilerVersionV2, scopeDestination.CompilerVersion)
	require.Empty(t, artifactNodeByID(scopeDestination, "notify").Config.SendMessage.WorkScopeMode)
}

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

	tests := []struct {
		name   string
		source string
		code   string
	}{
		{
			name: "related scopes without work scope",
			source: strings.Replace(
				validCallbackPlan,
				"      callbackOutput:",
				"      relatedScopes:\n      - {value: {kind: task, scopeId: tsk_abcdefghijklmn}}\n      callbackOutput:",
				1,
			),
			code: "send_message.work_scope_required",
		},
		{
			name: "empty related scopes without work scope",
			source: strings.Replace(
				validCallbackPlan,
				"      callbackOutput:",
				"      relatedScopes: []\n      callbackOutput:",
				1,
			),
			code: "send_message.work_scope_required",
		},
		{
			name: "work scope on sent completion",
			source: strings.Replace(
				scopeSendMessagePlan,
				"      completion: sent",
				"      completion: sent\n      workScope: null",
				1,
			),
			code: "send_message.work_scope_not_allowed",
		},
		{
			name: "work scope has wrong type",
			source: strings.Replace(
				validCallbackPlan,
				"      callbackOutput:",
				"      workScope: {value: not-a-scope}\n      callbackOutput:",
				1,
			),
			code: "send_message.work_scope_type",
		},
		{
			name: "related scope has wrong type",
			source: strings.Replace(
				validCallbackPlan,
				"      callbackOutput:",
				"      workScope: null\n      relatedScopes:\n      - {value: not-a-scope}\n      callbackOutput:",
				1,
			),
			code: "send_message.related_scope_type",
		},
		{
			name: "reserved callback output",
			source: strings.Replace(strings.Replace(
				validCallbackPlan,
				"      callbackOutput:",
				"      workScope: null\n      callbackOutput:",
				1,
			),
				"        reply: {type: string, required: true}",
				"        workScope: {type: scope, required: true}",
				1,
			),
			code: "send_message.system_output_reserved",
		},
		{
			name: "null related scopes",
			source: strings.Replace(
				validCallbackPlan,
				"      callbackOutput:",
				"      workScope: null\n      relatedScopes: null\n      callbackOutput:",
				1,
			),
			code: "syntax.type_mismatch",
		},
	}

	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)
			require.True(t, hasCode(diagnostics, test.code), "diagnostics: %#v", diagnostics)
		})
	}
}

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

	agent := compileArtifactForTest(t, agentResolvedWorkScopePlan)
	agent.ArtifactVersion = ArtifactVersionV2
	var err error
	agent.Digest, err = agent.computeDigest()
	require.NoError(t, err)
	require.ErrorContains(t, ValidateEnvelope(agent), "does not support Agent workScope")

	fixed := compileArtifactForTest(t, fixedWorkScopePlan)
	send := artifactNodeByID(fixed, "implement").Config.SendMessage
	send.WorkScopeMode = ArtifactWorkScopeAgent
	fixed.Digest, err = fixed.computeDigest()
	require.NoError(t, err)
	require.ErrorContains(t, ValidateEnvelope(fixed), "agent workScopeMode cannot carry")

	fixed = compileArtifactForTest(t, fixedWorkScopePlan)
	send = artifactNodeByID(fixed, "implement").Config.SendMessage
	send.WorkScopeMode = ""
	send.WorkScope = nil
	fixed.Digest, err = fixed.computeDigest()
	require.NoError(t, err)
	require.ErrorContains(t, ValidateEnvelope(fixed), "relatedScopes requires workScope")
}

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

	legacy := compileArtifactForTest(t, validCallbackPlan)
	wire, err := legacy.DeterministicJSON()
	require.NoError(t, err)
	for _, field := range []string{
		`"workScopeMode":""`,
		`"workScope":null`,
		`"relatedScopes":null`,
		`"relatedScopes":[]`,
	} {
		field := field
		t.Run(field, func(t *testing.T) {
			t.Parallel()
			tampered := strings.Replace(
				string(wire),
				`"sendMessage":{`,
				`"sendMessage":{`+field+`,`,
				1,
			)
			_, err := DecodeArtifact([]byte(tampered))
			require.ErrorContains(t, err, "does not support")
		})
	}

	unknown := strings.Replace(
		string(wire),
		`"sendMessage":{`,
		`"sendMessage":{"futureAgentScope":true,`,
		1,
	)
	_, err = DecodeArtifact([]byte(unknown))
	require.ErrorContains(t, err, "unknown field")
}

func relatedScopeCountPlan(count int) string {
	var authored strings.Builder
	authored.WriteString("      relatedScopes:\n")
	for index := 0; index < count; index++ {
		fmt.Fprintf(
			&authored,
			"      - {value: {kind: task, scopeId: tsk_context_%02d}}\n",
			index,
		)
	}
	return strings.Replace(
		agentResolvedWorkScopePlan,
		"      relatedScopes: []\n",
		authored.String(),
		1,
	)
}
