package plan

import (
	"strings"
	"testing"
	"time"

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

const waitWithoutRetrySource = `apiVersion: loop.plan/v1
kind: PlanVersion
graph:
  entry: {nodeId: pause, port: in}
  nodes:
  - id: pause
    type: wait
    wait:
      until: {value: PT1S}
      timeout: PT1M
  - id: succeeded
    type: end
    end: {outcome: succeeded}
  edges:
  - id: completed
    source: {nodeId: pause, port: completed}
    target: {nodeId: succeeded, port: in}
`

const approvalWithoutRetrySource = `apiVersion: loop.plan/v1
kind: PlanVersion
graph:
  entry: {nodeId: approve, port: in}
  nodes:
  - id: approve
    type: request_approval
    requestApproval:
      assignee: owner
      summary: Approve this Run
  - id: succeeded
    type: end
    end: {outcome: succeeded}
  - id: rejected
    type: end
    end: {outcome: failed, code: approval.rejected}
  edges:
  - id: approved
    source: {nodeId: approve, port: approved}
    target: {nodeId: succeeded, port: in}
  - id: rejected
    source: {nodeId: approve, port: rejected}
    target: {nodeId: rejected, port: in}
`

type retryActionTest struct {
	name         string
	source       string
	nodeID       string
	insertBefore string
	retryFrom    func(*ArtifactNode) ArtifactRetryPolicy
}

func retryActionTests() []retryActionTest {
	return []retryActionTest{
		{
			name:         "send message",
			source:       implicitFailureSource,
			nodeID:       "notify",
			insertBefore: "  - id: success\n",
			retryFrom: func(node *ArtifactNode) ArtifactRetryPolicy {
				return node.Config.SendMessage.Retry
			},
		},
		{
			name:         "wait",
			source:       waitWithoutRetrySource,
			nodeID:       "pause",
			insertBefore: "      timeout: PT1M\n",
			retryFrom: func(node *ArtifactNode) ArtifactRetryPolicy {
				return node.Config.Wait.Retry
			},
		},
		{
			name:         "request approval",
			source:       approvalWithoutRetrySource,
			nodeID:       "approve",
			insertBefore: "      summary: Approve this Run\n",
			retryFrom: func(node *ArtifactNode) ArtifactRetryPolicy {
				return node.Config.RequestApproval.Retry
			},
		},
	}
}

func withActionRetry(test retryActionTest, retry string) string {
	return strings.Replace(test.source, test.insertBefore, "      retry: "+retry+"\n"+test.insertBefore, 1)
}

func TestCompileDefaultsWhollyOmittedActionRetry(t *testing.T) {
	defaultRetry := ArtifactRetryPolicy{
		MaxAttempts:  1,
		BackoffNanos: int64(time.Second),
	}
	for _, test := range retryActionTests() {
		t.Run(test.name, func(t *testing.T) {
			artifact, diagnostics, err := Compile([]byte(test.source))
			require.NoError(t, err)
			require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
			require.NoError(t, ValidateEnvelope(artifact))

			node := artifactNodeByID(artifact, test.nodeID)
			require.NotNil(t, node)
			require.Equal(t, defaultRetry, test.retryFrom(node))

			explicit := withActionRetry(test, "{maxAttempts: 1, backoff: PT1S}")
			explicitArtifact, explicitDiagnostics, err := Compile([]byte(explicit))
			require.NoError(t, err)
			require.False(t, HasErrors(explicitDiagnostics), "diagnostics: %#v", explicitDiagnostics)
			require.Equal(t, artifact.Digest, explicitArtifact.Digest)
			omittedWire, err := artifact.DeterministicJSON()
			require.NoError(t, err)
			explicitWire, err := explicitArtifact.DeterministicJSON()
			require.NoError(t, err)
			require.Equal(t, omittedWire, explicitWire)

			nonDefault := withActionRetry(test, "{maxAttempts: 2, backoff: PT2S}")
			nonDefaultArtifact, nonDefaultDiagnostics, err := Compile([]byte(nonDefault))
			require.NoError(t, err)
			require.False(t, HasErrors(nonDefaultDiagnostics), "diagnostics: %#v", nonDefaultDiagnostics)
			require.Equal(t, ArtifactRetryPolicy{
				MaxAttempts:  2,
				BackoffNanos: int64(2 * time.Second),
			}, test.retryFrom(artifactNodeByID(nonDefaultArtifact, test.nodeID)))
			require.NotEqual(t, artifact.Digest, nonDefaultArtifact.Digest)
		})
	}
}

func TestCompileRejectsInvalidAuthoredActionRetry(t *testing.T) {
	tests := []struct {
		name  string
		value string
		codes []string
	}{
		{
			name:  "missing backoff",
			value: "{maxAttempts: 2}",
			codes: []string{"node.invalid_duration"},
		},
		{
			name:  "missing max attempts",
			value: "{backoff: PT1S}",
			codes: []string{"retry.invalid_attempts"},
		},
		{
			name:  "empty mapping",
			value: "{}",
			codes: []string{"node.invalid_duration", "retry.invalid_attempts"},
		},
		{
			name:  "explicit zero values",
			value: `{maxAttempts: 0, backoff: ""}`,
			codes: []string{"node.invalid_duration", "retry.invalid_attempts"},
		},
		{
			name:  "null",
			value: "null",
			codes: []string{"syntax.type_mismatch"},
		},
		{
			name:  "bare key",
			value: "",
			codes: []string{"syntax.type_mismatch"},
		},
		{
			name:  "too many attempts",
			value: "{maxAttempts: 101, backoff: PT1S}",
			codes: []string{"retry.too_many_attempts"},
		},
		{
			name:  "unknown field",
			value: "{maxAttempts: 2, backoff: PT1S, unknown: true}",
			codes: []string{"syntax.unknown_field"},
		},
	}
	for _, action := range retryActionTests() {
		t.Run(action.name, func(t *testing.T) {
			for _, test := range tests {
				t.Run(test.name, func(t *testing.T) {
					invalid := withActionRetry(action, test.value)
					artifact, diagnostics, err := Compile([]byte(invalid))
					require.NoError(t, err)
					require.Nil(t, artifact)
					for _, code := range test.codes {
						require.True(t, hasCode(diagnostics, code), "missing %s in diagnostics: %#v", code, diagnostics)
					}
				})
			}
		})
	}
}

func TestCompileRejectsAliasedNullActionRetry(t *testing.T) {
	for _, action := range retryActionTests() {
		t.Run(action.name, func(t *testing.T) {
			withAnchor := strings.Replace(action.source, "kind: PlanVersion\n", "kind: PlanVersion\nstate: &null_retry null\n", 1)
			invalid := withActionRetry(retryActionTest{
				source:       withAnchor,
				insertBefore: action.insertBefore,
			}, "*null_retry")
			artifact, diagnostics, err := Compile([]byte(invalid))
			require.NoError(t, err)
			require.Nil(t, artifact)
			require.True(t, hasCode(diagnostics, "syntax.type_mismatch"), "diagnostics: %#v", diagnostics)
		})
	}
}

func TestCompileRejectsAliasedRetryKeyWithNullValue(t *testing.T) {
	for _, action := range retryActionTests() {
		t.Run(action.name, func(t *testing.T) {
			withAnchor := strings.Replace(
				action.source,
				"kind: PlanVersion\n",
				"kind: PlanVersion\ninput:\n  retry_key_holder: {type: string, default: &retry_key retry}\n",
				1,
			)
			invalid := strings.Replace(
				withAnchor,
				action.insertBefore,
				"      *retry_key: null\n"+action.insertBefore,
				1,
			)
			artifact, diagnostics, err := Compile([]byte(invalid))
			require.NoError(t, err)
			require.Nil(t, artifact)
			require.True(t, hasCode(diagnostics, "syntax.type_mismatch"), "diagnostics: %#v", diagnostics)
		})
	}
}

func TestCompileRejectsBinaryRetryKeyWithNullValue(t *testing.T) {
	for _, action := range retryActionTests() {
		t.Run(action.name, func(t *testing.T) {
			invalid := strings.Replace(
				action.source,
				action.insertBefore,
				"      !!binary cmV0cnk=: null\n"+action.insertBefore,
				1,
			)
			artifact, diagnostics, err := Compile([]byte(invalid))
			require.NoError(t, err)
			require.Nil(t, artifact)
			require.True(t, hasCode(diagnostics, "syntax.unsupported_key"), "diagnostics: %#v", diagnostics)
		})
	}
}

func TestCompileRejectsBinaryStructuralKeysOnRetryPath(t *testing.T) {
	waitTest := retryActionTests()[1]
	withNullRetry := withActionRetry(waitTest, "null")
	tests := []struct {
		name string
		old  string
		new  string
	}{
		{name: "graph", old: "graph:\n", new: "!!binary Z3JhcGg=:\n"},
		{name: "nodes", old: "  nodes:\n", new: "  !!binary bm9kZXM=:\n"},
		{name: "action", old: "    wait:\n", new: "    !!binary d2FpdA==:\n"},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			invalid := strings.Replace(withNullRetry, test.old, test.new, 1)
			artifact, diagnostics, err := Compile([]byte(invalid))
			require.NoError(t, err)
			require.Nil(t, artifact)
			require.True(t, hasCode(diagnostics, "syntax.unsupported_key"), "diagnostics: %#v", diagnostics)
		})
	}
}

func TestCompileRejectsNullRetryInsideAliasedActionConfig(t *testing.T) {
	const source = `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  config_holder:
    type: object
    default: &wait_config
      until: {value: PT1S}
      timeout: PT1M
      retry: null
graph:
  entry: {nodeId: pause, port: in}
  nodes:
  - id: pause
    type: wait
    wait: *wait_config
  - id: succeeded
    type: end
    end: {outcome: succeeded}
  edges:
  - id: completed
    source: {nodeId: pause, port: completed}
    target: {nodeId: succeeded, port: in}
`
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "syntax.type_mismatch"), "diagnostics: %#v", diagnostics)
}

func TestCompileRejectsYAMLMergeKeysInsteadOfDefaultingMergedNullRetry(t *testing.T) {
	const source = `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  defaults_holder:
    type: object
    default: &defaults
      retry: null
graph:
  entry: {nodeId: pause, port: in}
  nodes:
  - id: pause
    type: wait
    wait:
      <<: *defaults
      until: {value: PT1S}
      timeout: PT1M
  - id: succeeded
    type: end
    end: {outcome: succeeded}
  edges:
  - id: completed
    source: {nodeId: pause, port: completed}
    target: {nodeId: succeeded, port: in}
`
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.Nil(t, artifact)
	require.True(t, hasCode(diagnostics, "syntax.unsupported_merge_key"), "diagnostics: %#v", diagnostics)
}

func TestCompileAllowsQuotedLiteralMergeKey(t *testing.T) {
	source := strings.Replace(
		waitWithoutRetrySource,
		"kind: PlanVersion\n",
		"kind: PlanVersion\ninput:\n  literal: {type: object, default: {\"<<\": literal}}\n",
		1,
	)
	artifact, diagnostics, err := Compile([]byte(source))
	require.NoError(t, err)
	require.NotNil(t, artifact)
	require.False(t, HasErrors(diagnostics), "diagnostics: %#v", diagnostics)
}

func TestIndexSourceDoesNotExpandAliasKeyValuesIntoPaths(t *testing.T) {
	raw := []byte(
		"anchor: &long_key " + strings.Repeat("x", 64<<10) + "\n" +
			"container:\n" +
			"  *long_key: value\n",
	)
	index := indexSource(raw)
	require.NotNil(t, index.root)
	for path := range index.byPath {
		require.Less(t, len(path), 256, "position path expanded an alias target (%d bytes)", len(path))
	}
}
