package plan

import (
	"sort"
	"strings"
	"testing"
	"time"

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

const implicitFailureSource = `apiVersion: loop.plan/v1
kind: PlanVersion
graph:
  entry: {nodeId: notify, port: in}
  nodes:
  - id: notify
    type: send_message
    sendMessage:
      target: "#all"
      text: hello
      completion: sent
  - id: success
    type: end
    end: {outcome: succeeded}
  edges:
  - id: completed
    source: {nodeId: notify, port: completed}
    target: {nodeId: success, port: in}
`

const implicitFailureExplicitSource = `apiVersion: loop.plan/v1
kind: PlanVersion
graph:
  entry: {nodeId: notify, port: in}
  nodes:
  - id: notify
    type: send_message
    sendMessage:
      target: "#all"
      text: hello
      completion: sent
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: message.failed}
  edges:
  - id: completed
    source: {nodeId: notify, port: completed}
    target: {nodeId: success, port: in}
  - id: failed
    source: {nodeId: notify, port: failed}
    target: {nodeId: failure, port: in}
`

func TestCompileAllowsOmittedFailurePlumbingAndProducesValidatedExplicitArtifact(t *testing.T) {
	artifact, diagnostics, err := Compile([]byte(implicitFailureSource))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	require.Equal(t, ArtifactVersion, artifact.ArtifactVersion)
	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.NotNil(t, artifactNodeByID(decoded, implicitFailedSinkID))
	require.Nil(t, artifactNodeByID(decoded, implicitTimedOutSinkID))
}

func TestCompileOnlyDefaultsWhollyOmittedSendPolicy(t *testing.T) {
	partialRetry := strings.Replace(implicitFailureSource, "      completion: sent", "      completion: sent\n      retry: {maxAttempts: 2}", 1)
	artifact, diagnostics, err := Compile([]byte(partialRetry))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "node.invalid_duration"), "diagnostics: %#v", diagnostics)

}

func TestCompileExplicitErrorEdgesOverrideImplicitRouting(t *testing.T) {
	artifact, diagnostics, err := Compile([]byte(implicitFailureExplicitSource))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	require.NoError(t, ValidateEnvelope(artifact))
	for _, node := range artifact.Nodes {
		require.NotEqual(t, OpcodeFailPlan, node.Opcode)
	}
	require.Len(t, artifact.Edges, 2)
}

func TestCompileReservesGeneratedFailureIdentities(t *testing.T) {
	nodeCollision := strings.ReplaceAll(implicitFailureSource, "notify", implicitFailureIDPrefix+"author")
	artifact, diagnostics, err := Compile([]byte(nodeCollision))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "graph.reserved_node_id"), "diagnostics: %#v", diagnostics)

	edgeCollision := strings.Replace(implicitFailureSource, "id: completed", "id: "+implicitFailureIDPrefix+"author", 1)
	artifact, diagnostics, err = Compile([]byte(edgeCollision))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "graph.reserved_edge_id"), "diagnostics: %#v", diagnostics)
}

func TestArtifactRejectsNonNormalizedImplicitFailureMaterial(t *testing.T) {
	artifact, diagnostics, err := Compile([]byte(implicitFailureSource))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	failure := artifactNodeByID(artifact, implicitFailedSinkID)
	require.NotNil(t, failure)
	failure.Config.FailPlan.FallbackCode = "changed.failure"
	artifact.Digest, err = artifact.computeDigest()
	require.NoError(t, err)
	require.ErrorContains(t, ValidateEnvelope(artifact), "not canonical")

	artifact, diagnostics, err = Compile([]byte(implicitFailureSource))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	edge := artifactEdgeByID(t, artifact, implicitFailureEdgeID("notify", "failed"))
	edge.ID = implicitFailureIDPrefix + "changed"
	sort.Slice(artifact.Edges, func(i, j int) bool { return artifact.Edges[i].ID < artifact.Edges[j].ID })
	artifact.Digest, err = artifact.computeDigest()
	require.NoError(t, err)
	require.ErrorContains(t, ValidateEnvelope(artifact), "non-canonical implicit failure identity")
}

func TestCountedLoopImplicitFailedEdgeIsAnExplicitScopeBoundaryInArtifact(t *testing.T) {
	source := strings.Replace(countedLoopPlan, `  - id: failure
    type: end
    end: {outcome: failed, code: loop_failed}
`, "", 1)
	source = strings.Replace(source, `  - id: failed
    source: {nodeId: repeat, port: failed}
    target: {nodeId: failure, port: in}
`, "", 1)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
	require.NoError(t, ValidateEnvelope(artifact))
	require.Len(t, artifact.Scopes, 1)
	generatedEdgeID := implicitFailureEdgeID("repeat", "failed")
	require.Equal(t, []string{generatedEdgeID}, artifact.Scopes[0].Loop.FailedEdges)
	edge := artifactEdgeByID(t, artifact, generatedEdgeID)
	require.Equal(t, []ArtifactScopeTransition{{
		ScopeKind: ArtifactScopeLoop, OwnerID: "repeat", Action: "failed",
	}}, edge.ScopeTransitions)
}

func TestLoweringMaterializesDeterministicImplicitFailureRoutingAndSendDefaults(t *testing.T) {
	document := implicitFailureTestDocument(APIVersion, false)
	artifact, err := buildArtifact(document, compilerAnalysis{})
	require.NoError(t, err)
	require.True(t, artifact.VerifyDigest())

	notify := artifactNodeByID(artifact, "notify")
	require.NotNil(t, notify)
	require.Equal(t, ArtifactRetryPolicy{MaxAttempts: 1, BackoffNanos: int64(time.Second)}, notify.Config.SendMessage.Retry)

	failed := artifactNodeByID(artifact, implicitFailedSinkID)
	require.NotNil(t, failed)
	require.Equal(t, OpcodeFailPlan, failed.Opcode)
	require.Equal(t, &ArtifactFailPlanConfig{FallbackCode: implicitUnhandledFailureCode, SourcePort: "failed"}, failed.Config.FailPlan)
	require.Nil(t, artifactNodeByID(artifact, implicitTimedOutSinkID))

	require.Equal(t, implicitFailedSinkID, artifactEdgeByID(t, artifact, implicitFailureEdgeID("notify", "failed")).Target.NodeID)
	require.False(t, artifactHasEdge(artifact, implicitFailureEdgeID("notify", "timed_out")))

	second, err := buildArtifact(implicitFailureTestDocument(APIVersion, false), compilerAnalysis{})
	require.NoError(t, err)
	require.Equal(t, artifact.Digest, second.Digest)
	firstWire, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	secondWire, err := second.DeterministicJSON()
	require.NoError(t, err)
	require.Equal(t, firstWire, secondWire)
}

func TestExplicitFailureEdgesOverrideImplicitRouting(t *testing.T) {
	artifact, err := buildArtifact(implicitFailureTestDocument(APIVersion, true), compilerAnalysis{})
	require.NoError(t, err)
	for _, node := range artifact.Nodes {
		require.NotEqual(t, OpcodeFailPlan, node.Opcode)
	}
	for _, edge := range artifact.Edges {
		require.False(t, strings.HasPrefix(edge.ID, implicitFailureIDPrefix))
	}
}

func implicitFailureTestDocument(apiVersion string, explicitFailures bool) *Document {
	document := &Document{
		APIVersion: apiVersion,
		Kind:       Kind,
		Graph: Graph{
			Entry: Entry{NodeID: "notify", Port: "in"},
			Nodes: []Node{
				{
					ID: "notify", Type: NodeSendMessage,
					SendMessage: &SendMessageConfig{Target: "#all", Text: "hello", Completion: CompletionSent},
				},
				{ID: "success", Type: NodeEnd, End: &EndConfig{Outcome: EndSucceeded}},
			},
			Edges: []Edge{{
				ID: "completed", Source: Endpoint{NodeID: "notify", Port: "completed"}, Target: Endpoint{NodeID: "success", Port: "in"},
			}},
		},
	}
	if explicitFailures {
		document.Graph.Nodes = append(document.Graph.Nodes, Node{
			ID: "failure", Type: NodeEnd, End: &EndConfig{Outcome: EndFailed, Code: "message.failed"},
		})
		document.Graph.Edges = append(document.Graph.Edges,
			Edge{ID: "failed", Source: Endpoint{NodeID: "notify", Port: "failed"}, Target: Endpoint{NodeID: "failure", Port: "in"}},
		)
	}
	return document
}

func artifactEdgeByID(t *testing.T, artifact *Artifact, id string) *ArtifactEdge {
	t.Helper()
	for index := range artifact.Edges {
		if artifact.Edges[index].ID == id {
			return &artifact.Edges[index]
		}
	}
	t.Fatalf("artifact edge %q is missing", id)
	return nil
}

func artifactHasEdge(artifact *Artifact, id string) bool {
	for index := range artifact.Edges {
		if artifact.Edges[index].ID == id {
			return true
		}
	}
	return false
}
