package plan

import (
	"strings"
	"testing"

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

func selectOnePlan(responder string) string {
	return `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  worker: {type: agent, required: true}
  candidates:
    type: array
    required: true
    items: {type: agent, required: true}
graph:
  entry:
    nodeId: start
    port: in
    input:
      worker: {ref: plan.input.worker}
      candidates: {ref: plan.input.candidates}
  nodes:
    - id: start
      type: send_message
      input:
        worker: {type: agent, required: true}
        candidates:
          type: array
          required: true
          items: {type: agent, required: true}
      sendMessage:
        scope: {value: {kind: channel, scopeId: chn_game}}
        responder: ` + responder + `
        text: "Pick one."
        completion: callback
        callbackOutput:
          result: {type: string, required: true}
    - id: done
      type: end
      end: {outcome: succeeded}
  edges:
    - id: e1
      source: {nodeId: start, port: completed}
      target: {nodeId: done, port: in}
`
}

func TestResponderSelectOneLiteralCompiles(t *testing.T) {
	source := selectOnePlan(`{selectOne: {value: [agt_a, agt_b, agt_c]}}`)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Empty(t, diagnostics)
	require.NotNil(t, artifact)
	require.Equal(t, ArtifactVersionV4, artifact.ArtifactVersion)
	node := artifactNodeByID(artifact, "start")
	require.Nil(t, node.Config.SendMessage.Responder)
	require.NotNil(t, node.Config.SendMessage.SelectOne)
	require.Equal(t, TypeArray, node.Config.SendMessage.SelectOne.ResultType)
	require.Equal(t, ArtifactOpConst, node.Config.SendMessage.SelectOne.Op)
}

func TestResponderSelectOneRefCompilesAndFreezesAtRuntime(t *testing.T) {
	source := selectOnePlan(`{selectOne: {ref: input.candidates}}`)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Empty(t, diagnostics)
	require.NotNil(t, artifact)
	node := artifactNodeByID(artifact, "start")
	require.NotNil(t, node.Config.SendMessage.SelectOne)
	require.Equal(t, ArtifactLoadActivationInput, node.Config.SendMessage.SelectOne.Load.Space)
}

func TestResponderSelectOneMutuallyExclusiveWithFixed(t *testing.T) {
	// A responder cannot carry both a fixed agent and selectOne.
	source := strings.Replace(
		selectOnePlan(`{ref: input.worker}`),
		"responder: {ref: input.worker}",
		"responder: {ref: input.worker, selectOne: [agt_a]}",
		1,
	)
	_, parseDiagnostics := Parse([]byte(source))
	found := false
	for _, diagnostic := range parseDiagnostics {
		if strings.Contains(diagnostic.Message, "mutually exclusive") {
			found = true
		}
	}
	require.True(t, found, "expected a mutual-exclusion parse diagnostic, got %#v", parseDiagnostics)
}

func TestResponderSelectOneEmptyLiteralRejected(t *testing.T) {
	source := selectOnePlan(`{selectOne: {value: []}}`)
	document, diagnostics := Parse([]byte(source))
	require.Empty(t, diagnostics, "parse must succeed for validation to run: %#v", diagnostics)
	diagnostics = Validate(document)
	require.True(t, selectOneHasDiagnosticCode(diagnostics, "send_message.responder_select_one_empty"), "expected empty-pool diagnostic, got %#v", diagnostics)
}

func TestResponderSelectOneUpperBoundRejected(t *testing.T) {
	var candidates strings.Builder
	candidates.WriteString("{selectOne: {value: [")
	for index := 0; index < MaxAllowedResponders+1; index++ {
		if index > 0 {
			candidates.WriteString(", ")
		}
		candidates.WriteString("agt_" + string(rune('a'+index%26)) + string(rune('0'+index%10)))
	}
	candidates.WriteString("]}}")
	source := selectOnePlan(candidates.String())
	document, diagnostics := Parse([]byte(source))
	require.Empty(t, diagnostics, "parse must succeed for validation to run: %#v", diagnostics)
	diagnostics = Validate(document)
	require.True(t, selectOneHasDiagnosticCode(diagnostics, "send_message.responder_select_one_upper_bound"), "expected upper-bound diagnostic, got %#v", diagnostics)
}

func TestResponderSelectOneWrongTypeRejected(t *testing.T) {
	source := selectOnePlan(`{selectOne: {ref: input.worker}}`)
	document, diagnostics := Parse([]byte(source))
	require.Empty(t, diagnostics, "parse must succeed for validation to run: %#v", diagnostics)
	diagnostics = Validate(document)
	require.True(t, selectOneHasDiagnosticCode(diagnostics, "send_message.responder_select_one_type"), "expected type diagnostic, got %#v", diagnostics)
}

func TestResponderSelectOneArtifactRoundTripAndDecode(t *testing.T) {
	source := selectOnePlan(`{selectOne: {value: [agt_a, agt_b, agt_c]}}`)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Empty(t, diagnostics)

	deterministic, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	decoded, err := DecodeArtifact(deterministic)
	require.NoError(t, err)
	node := artifactNodeByID(decoded, "start")
	require.NotNil(t, node.Config.SendMessage.SelectOne)
	require.Nil(t, node.Config.SendMessage.Responder)
}

func TestResponderSelectOneDecodeRejectsOverLimit(t *testing.T) {
	// A decoded artifact that smuggles more than MaxAllowedResponders
	// candidates must be rejected by decode, not just by Plan validation.
	source := selectOnePlan(`{selectOne: {value: [agt_a, agt_b, agt_c]}}`)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Empty(t, diagnostics)

	// Mutate the compiled artifact's const array to exceed the bound.
	node := artifactNodeByID(artifact, "start")
	require.NotNil(t, node.Config.SendMessage.SelectOne)
	require.Equal(t, ArtifactOpConst, node.Config.SendMessage.SelectOne.Op)
	decodedValue, err := decodeArtifactLiteral(node.Config.SendMessage.SelectOne.Value)
	require.NoError(t, err)
	values := decodedValue.([]any)
	for len(values) <= MaxAllowedResponders {
		values = append(values, "agt_x")
	}
	compiled, err := compileArtifactLiteral(values, nil)
	require.NoError(t, err)
	node.Config.SendMessage.SelectOne.Value = compiled
	artifact.Digest, err = artifact.computeDigest()
	require.NoError(t, err)
	deterministic, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	_, err = DecodeArtifact(deterministic)
	require.Error(t, err)
	require.Contains(t, err.Error(), "more than 64 candidates")
}

func TestResponderSelectOneDecodeRejectsEmpty(t *testing.T) {
	source := selectOnePlan(`{selectOne: {value: [agt_a]}}`)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Empty(t, diagnostics)
	node := artifactNodeByID(artifact, "start")
	if _, err := decodeArtifactLiteral(node.Config.SendMessage.SelectOne.Value); err != nil {
		t.Fatalf("decode artifact literal: %v", err)
	}
	compiled, err := compileArtifactLiteral([]any{}, nil)
	require.NoError(t, err)
	node.Config.SendMessage.SelectOne.Value = compiled
	artifact.Digest, err = artifact.computeDigest()
	require.NoError(t, err)
	deterministic, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	_, err = DecodeArtifact(deterministic)
	require.Error(t, err)
	require.Contains(t, err.Error(), "must not be empty")
}

func TestResponderSelectOneOldFixedArtifactStillDecodes(t *testing.T) {
	// Backward compatibility: a published fixed-responder artifact (no
	// selectOne field) must still decode unchanged.
	source := selectOnePlan(`{ref: input.worker}`)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Empty(t, diagnostics)
	deterministic, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	decoded, err := DecodeArtifact(deterministic)
	require.NoError(t, err)
	node := artifactNodeByID(decoded, "start")
	require.NotNil(t, node.Config.SendMessage.Responder)
	require.Nil(t, node.Config.SendMessage.SelectOne)
	require.Equal(t, TypeAgent, node.Config.SendMessage.Responder.ResultType)
}

func selectOneHasDiagnosticCode(diagnostics []Diagnostic, code string) bool {
	for _, diagnostic := range diagnostics {
		if diagnostic.Code == code {
			return true
		}
	}
	return false
}

// TestResponderSelectOneStrategyCompilesAndCarriesIntoArtifact proves the
// additive selectOne strategy parses from the Plan and lowers into the compiled
// artifact: an omitted strategy stays ordered (empty), and "random" is carried
// as ArtifactSelectOneStrategyRandom so the capacity scheduler can randomize.
func TestResponderSelectOneStrategyCompilesAndCarriesIntoArtifact(t *testing.T) {
	for _, tc := range []struct {
		name      string
		responder string
		want      ArtifactSelectOneStrategy
	}{
		{name: "omitted stays ordered", responder: `{selectOne: {value: [agt_a, agt_b]}}`, want: ArtifactSelectOneStrategyOrdered},
		{name: "explicit ordered", responder: `{selectOne: {value: [agt_a, agt_b]}, strategy: ordered}`, want: ArtifactSelectOneStrategyOrdered},
		{name: "random", responder: `{selectOne: {value: [agt_a, agt_b]}, strategy: random}`, want: ArtifactSelectOneStrategyRandom},
	} {
		t.Run(tc.name, func(t *testing.T) {
			source := selectOnePlan(tc.responder)
			artifact, diagnostics, err := Compile([]byte(source))
			require.NoError(t, err)
			require.Empty(t, diagnostics)
			require.NotNil(t, artifact)
			node := artifactNodeByID(artifact, "start")
			require.NotNil(t, node.Config.SendMessage.SelectOne)
			require.Equal(t, tc.want, node.Config.SendMessage.SelectOneStrategy.Normalized())
		})
	}
}

// TestResponderSelectOneStrategyRejectsUnknown proves an unknown strategy value
// is a compile diagnostic error, keeping the contract closed.
func TestResponderSelectOneStrategyRejectsUnknown(t *testing.T) {
	source := selectOnePlan(`{selectOne: {value: [agt_a, agt_b]}, strategy: shuffled}`)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, HasErrors(diagnostics))
}

// TestResponderSelectOneRandomPoolRejectsTooFewDeduplicatedCandidates proves a
// random-strategy pool must have at least two deduplicated candidates at the
// authoring/compile boundary (a one-candidate pool and a duplicate-only pool
// are both rejected fail-closed).
func TestResponderSelectOneRandomPoolRejectsTooFewDeduplicatedCandidates(t *testing.T) {
	for _, responder := range []string{
		`{selectOne: {value: [agt_a]}, strategy: random}`,
		`{selectOne: {value: [agt_a, agt_a]}, strategy: random}`,
	} {
		source := selectOnePlan(responder)
		document, parseDiagnostics := Parse([]byte(source))
		require.Empty(t, parseDiagnostics, "parse must succeed for validation: %#v", parseDiagnostics)
		diagnostics := Validate(document)
		require.True(t, selectOneHasDiagnosticCode(diagnostics, "send_message.responder_select_one_random_pool_too_small"), "expected random-pool-too-small diagnostic, got %#v", diagnostics)
	}
}
