package plan

import (
	"reflect"
	"strings"
	"testing"
)

func TestSchemaAssignabilityRetainsArrayEnumAndNominalContracts(t *testing.T) {
	tests := []struct {
		name      string
		input     string
		state     string
		value     string
		wantError bool
		wantCode  string
	}{
		{
			name:  "array reference item mismatch",
			input: `{type: array, required: true, items: {type: number}}`,
			state: `{type: array, required: true, items: {type: string}}`,
			value: `{ref: plan.input.value}`, wantError: true, wantCode: "set_variable.type_mismatch",
		},
		{
			name:  "array expression item mismatch",
			input: `{type: string, required: true}`,
			state: `{type: array, required: true, items: {type: string}}`,
			value: `{expr: '[1, 2]'}`, wantError: true, wantCode: "set_variable.type_mismatch",
		},
		{
			name:  "nested array contract",
			input: `{type: array, required: true, items: {type: array, items: {type: number}}}`,
			state: `{type: array, required: true, items: {type: array, items: {type: number}}}`,
			value: `{ref: plan.input.value}`,
		},
		{
			name:  "nested array recursive mismatch",
			input: `{type: array, required: true, items: {type: array, items: {type: number}}}`,
			state: `{type: array, required: true, items: {type: array, items: {type: string}}}`,
			value: `{ref: plan.input.value}`, wantError: true, wantCode: "set_variable.type_mismatch",
		},
		{
			name:  "array object contract",
			input: `{type: array, required: true, items: {type: object}}`,
			state: `{type: array, required: true, items: {type: object}}`,
			value: `{ref: plan.input.value}`,
		},
		{
			name:  "enum source is not target subset",
			input: `{type: enum, required: true, values: [red, blue]}`,
			state: `{type: enum, required: true, values: [red]}`,
			value: `{ref: plan.input.value}`, wantError: true, wantCode: "set_variable.type_mismatch",
		},
		{
			name:  "enum source subset",
			input: `{type: enum, required: true, values: [red]}`,
			state: `{type: enum, required: true, values: [red, blue]}`,
			value: `{ref: plan.input.value}`,
		},
		{
			name:  "enum literal member",
			input: `{type: string, required: true}`,
			state: `{type: enum, required: true, values: [red, blue]}`,
			value: `{expr: '"red"'}`,
		},
		{
			name:  "enum literal outside set",
			input: `{type: string, required: true}`,
			state: `{type: enum, required: true, values: [red, blue]}`,
			value: `{expr: '"green"'}`, wantError: true, wantCode: "set_variable.type_mismatch",
		},
		{
			name:  "agent is not member",
			input: `{type: agent, required: true}`,
			state: `{type: member, required: true}`,
			value: `{ref: plan.input.value}`, wantError: true, wantCode: "set_variable.type_mismatch",
		},
		{
			name:  "string is not agent",
			input: `{type: string, required: true}`,
			state: `{type: agent, required: true}`,
			value: `{ref: plan.input.value}`, wantError: true, wantCode: "set_variable.type_mismatch",
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			artifact, diagnostics, err := Compile([]byte(setVariableContractPlan(test.input, test.state, test.value)))
			if err != nil {
				t.Fatalf("Compile() error = %v", err)
			}
			if test.wantError {
				if !hasCode(diagnostics, test.wantCode) {
					t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.wantCode)
				}
				return
			}
			if HasErrors(diagnostics) || artifact == nil {
				t.Fatalf("Compile() = (%#v, %#v)", artifact, diagnostics)
			}
		})
	}
}

func TestRequiredTargetsRejectOptionalReferencesAndUnsafeNullableUse(t *testing.T) {
	optionalReference := setVariableContractPlan(
		`{type: string}`,
		`{type: string, required: true}`,
		`{ref: plan.input.value}`,
	)
	_, diagnostics, err := Compile([]byte(optionalReference))
	if err != nil || !hasCode(diagnostics, "set_variable.type_mismatch") {
		t.Fatalf("optional ref Compile() = (%v, %#v)", err, diagnostics)
	}

	unsafeFunction := setVariableContractPlan(
		`{type: string}`,
		`{type: number, required: true}`,
		`{expr: 'length(plan.input.value)'}`,
	)
	_, diagnostics, err = Compile([]byte(unsafeFunction))
	if err != nil || !hasCode(diagnostics, "expression.type_mismatch") {
		t.Fatalf("nullable function Compile() = (%v, %#v)", err, diagnostics)
	}

	nullExpression := setVariableContractPlan(
		`{type: number, required: true}`,
		`{type: number, required: true}`,
		`{expr: 'null'}`,
	)
	_, diagnostics, err = Compile([]byte(nullExpression))
	if err != nil || !hasCode(diagnostics, "set_variable.type_mismatch") {
		t.Fatalf("null Compile() = (%v, %#v)", err, diagnostics)
	}
}

func TestNullableEqualityAgainstNullIsSafe(t *testing.T) {
	raw := `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  value: {type: string}
graph:
  entry: {nodeId: route, port: in}
  nodes:
  - id: route
    type: switch
    input:
      value: {type: string}
    switch:
      mode: first_match
      cases:
      - port: missing
        when: 'input.value == null'
      - port: present
        isDefault: true
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: route_failed}
  edges:
  - id: missing
    source: {nodeId: route, port: missing}
    target: {nodeId: success, port: in}
  - id: present
    source: {nodeId: route, port: present}
    target: {nodeId: failure, port: in}`
	_, diagnostics, err := Compile([]byte(raw))
	if err != nil || HasErrors(diagnostics) {
		t.Fatalf("Compile() = (%v, %#v)", err, diagnostics)
	}
}

func TestTypedComparisonRejectsNumberAndString(t *testing.T) {
	raw := strings.Replace(validLoopPlan,
		`answer: {type: string, required: true}`,
		`answer: {type: number, required: true}`, 5)
	raw = strings.Replace(raw, `input.answer == "again"`, `input.answer == "not-a-number"`, 1)
	raw = strings.Replace(raw, `contains(input.answer, "stop") == true`, `input.answer > 0`, 1)
	_, diagnostics, err := Compile([]byte(raw))
	if err != nil || !hasCode(diagnostics, "expression.type_mismatch") {
		t.Fatalf("Compile() = (%v, %#v), want expression.type_mismatch", err, diagnostics)
	}
}

func TestWaitUntilLiteralMustBeDurationOrDatetime(t *testing.T) {
	validDatetime := linearActionPlan("", `  - id: action
    type: wait
    wait:
      until: {value: "2030-01-02T03:04:05Z"}
      timeout: PT2M
      retry: {maxAttempts: 1, backoff: 1s}`, []string{"completed", "failed", "timed_out"})
	_, diagnostics, err := Compile([]byte(validDatetime))
	if err != nil || HasErrors(diagnostics) {
		t.Fatalf("datetime Compile() = (%v, %#v)", err, diagnostics)
	}

	banana := strings.Replace(validDatetime, `2030-01-02T03:04:05Z`, `banana`, 1)
	_, diagnostics, err = Compile([]byte(banana))
	if err != nil || !hasCode(diagnostics, "wait.until_type") {
		t.Fatalf("banana Compile() = (%v, %#v), want wait.until_type", err, diagnostics)
	}
}

func TestEndCodeAndSwitchOrderingContracts(t *testing.T) {
	missing := strings.Replace(validCallbackPlan, `end: {outcome: failed, code: message_failed}`, `end: {outcome: failed}`, 1)
	_, diagnostics, _ := Compile([]byte(missing))
	if !hasCode(diagnostics, "end.code_required") {
		t.Fatalf("missing code diagnostics = %#v", diagnostics)
	}

	onSuccess := strings.Replace(validCallbackPlan, `end: {outcome: succeeded}`, `end: {outcome: succeeded, code: no}`, 1)
	_, diagnostics, _ = Compile([]byte(onSuccess))
	if !hasCode(diagnostics, "end.code_not_allowed") {
		t.Fatalf("success code diagnostics = %#v", diagnostics)
	}

	invalid := strings.Replace(validCallbackPlan, `code: message_failed`, `code: "Not Stable!"`, 1)
	_, diagnostics, _ = Compile([]byte(invalid))
	if !hasCode(diagnostics, "end.invalid_code") {
		t.Fatalf("invalid code diagnostics = %#v", diagnostics)
	}

	defaultFirst := strings.Replace(validLoopPlan, `      - port: again
        when: 'input.answer == "again"'
      - port: stop
        when: 'contains(input.answer, "stop") == true'
      - port: fallback
        isDefault: true`, `      - port: fallback
        isDefault: true
      - port: again
        when: 'input.answer == "again"'
      - port: stop
        when: 'contains(input.answer, "stop") == true'`, 1)
	_, diagnostics, _ = Compile([]byte(defaultFirst))
	if !hasCode(diagnostics, "switch.default_not_last") {
		t.Fatalf("default ordering diagnostics = %#v", diagnostics)
	}
}

func TestValidateWithoutSourceIndexReturnsDiagnosticsInsteadOfPanicking(t *testing.T) {
	document, diagnostics := Parse([]byte(validCallbackPlan))
	if HasErrors(diagnostics) {
		t.Fatalf("Parse() diagnostics = %#v", diagnostics)
	}
	document.Graph.Entry.NodeID = "missing"
	diagnostics = Validate(document)
	if !hasCode(diagnostics, "graph.entry_unknown_node") {
		t.Fatalf("Validate() diagnostics = %#v", diagnostics)
	}
}

func TestOmittedBindingsLowerOnlyProvableWholePayloadPassThrough(t *testing.T) {
	omittedEntry := strings.Replace(validCallbackPlan, `    port: in
    input:
      channel: {ref: plan.input.channel}`, `    port: in`, 1)
	artifact, diagnostics, err := Compile([]byte(omittedEntry))
	if err != nil || HasErrors(diagnostics) {
		t.Fatalf("entry Compile() = (%v, %#v)", err, diagnostics)
	}
	if len(artifact.Entry.Bindings) != 1 || artifact.Entry.Bindings[0].Value.Load == nil || artifact.Entry.Bindings[0].Value.Load.Space != ArtifactLoadPlanInput || !reflect.DeepEqual(artifact.Entry.Bindings[0].Value.Load.Path, []string{"channel"}) {
		t.Fatalf("lowered entry binding = %#v", artifact.Entry.Bindings)
	}

	omittedEdge := strings.Replace(validCallbackPlan, `    bindings:
      reply: {ref: output.reply}`, ``, 1)
	artifact, diagnostics, err = Compile([]byte(omittedEdge))
	if err != nil || HasErrors(diagnostics) {
		t.Fatalf("edge Compile() = (%v, %#v)", err, diagnostics)
	}
	for _, edge := range artifact.Edges {
		if edge.ID == "delivered" && (len(edge.Bindings) != 1 || edge.Bindings[0].Value.Load == nil || edge.Bindings[0].Value.Load.Space != ArtifactLoadActivationOutput || !reflect.DeepEqual(edge.Bindings[0].Value.Load.Path, []string{"reply"})) {
			t.Fatalf("lowered edge bindings = %#v", edge.Bindings)
		}
	}

	incompatible := strings.Replace(omittedEntry,
		`channel: {type: channel, required: true}`, `channel: {type: agent, required: true}`, 1)
	_, diagnostics, err = Compile([]byte(incompatible))
	if err != nil || !hasCode(diagnostics, "binding.passthrough_incompatible") {
		t.Fatalf("incompatible Compile() = (%v, %#v)", err, diagnostics)
	}
	if hasCode(diagnostics, "binding.required_target") {
		t.Fatalf("incompatible pass-through must have one aggregate diagnostic: %#v", diagnostics)
	}

	ordinaryAction := `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  value: {type: string, required: true}
output:
  value: {type: string, required: true}
graph:
  entry: {nodeId: approve, port: in}
  nodes:
  - id: approve
    type: request_approval
    input:
      value: {type: string, required: true}
    requestApproval:
      assignee: alice
      summary: approve
      retry: {maxAttempts: 1, backoff: 1s}
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: approval_failed}
  edges:
  - id: approved
    source: {nodeId: approve, port: approved}
    target: {nodeId: success, port: in}
  - id: rejected
    source: {nodeId: approve, port: rejected}
    target: {nodeId: failure, port: in}
  - id: failed
    source: {nodeId: approve, port: failed}
    target: {nodeId: failure, port: in}`
	_, diagnostics, err = Compile([]byte(ordinaryAction))
	if err != nil || !hasCode(diagnostics, "binding.passthrough_incompatible") {
		t.Fatalf("ordinary action Compile() = (%v, %#v)", err, diagnostics)
	}
}

func setVariableContractPlan(inputSchema, stateSchema, value string) string {
	return `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  value: ` + inputSchema + `
state:
  result: ` + stateSchema + `
graph:
  entry: {nodeId: action, port: in}
  nodes:
  - id: action
    type: set_variable
    setVariable:
      assignments:
      - state: result
        value: ` + value + `
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: assignment_failed}
  edges:
  - id: completed
    source: {nodeId: action, port: completed}
    target: {nodeId: success, port: in}
  - id: failed
    source: {nodeId: action, port: failed}
    target: {nodeId: failure, port: in}`
}
