package plan

import (
	"fmt"
	"strings"
	"testing"
)

func TestCompileCallbackPlanProducesDeterministicArtifact(t *testing.T) {
	raw := []byte(validCallbackPlan)
	artifact, diagnostics, err := Compile(raw)
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	if HasErrors(diagnostics) {
		t.Fatalf("Compile() diagnostics = %#v", diagnostics)
	}
	if artifact == nil || !artifact.VerifyDigest() {
		t.Fatalf("artifact/digest invalid: %#v", artifact)
	}
	encoded, err := artifact.DeterministicJSON()
	if err != nil {
		t.Fatalf("DeterministicJSON() error = %v", err)
	}
	wire := string(encoded)
	for _, forbidden := range []string{`"APIVersion"`, `"ArtifactVersion"`, `"Document"`} {
		if strings.Contains(wire, forbidden) {
			t.Fatalf("artifact leaked non-normalized field %s: %s", forbidden, wire)
		}
	}

	reordered := strings.ReplaceAll(validCallbackPlan,
		`  - id: delivered
    source: {nodeId: notify, port: completed}
    target: {nodeId: success, port: in}
    bindings:
      reply: {ref: output.reply}
  - id: delivery_failed
    source: {nodeId: notify, port: failed}
    target: {nodeId: failure, port: in}`,
		`  - id: delivery_failed
    source: {nodeId: notify, port: failed}
    target: {nodeId: failure, port: in}
  - id: delivered
    source: {nodeId: notify, port: completed}
    target: {nodeId: success, port: in}
    bindings:
      reply: {ref: output.reply}`,
	)
	if reordered == validCallbackPlan {
		t.Fatalf("reordered source did not change edge order; determinism is not exercised")
	}
	second, diagnostics, err := Compile([]byte(reordered))
	if err != nil || HasErrors(diagnostics) {
		t.Fatalf("reordered Compile() = (%v, %#v)", err, diagnostics)
	}
	if artifact.Digest != second.Digest {
		t.Fatalf("digest depends on edge authoring order: %s != %s", artifact.Digest, second.Digest)
	}
}

func TestParseStrictlyRejectsLegacyTransitionAndIdentityFields(t *testing.T) {
	for _, field := range []string{"next", "default", "onApprove", "from", "runAs"} {
		t.Run(field, func(t *testing.T) {
			raw := strings.Replace(validCallbackPlan, "      target: \"{{ input.channel }}\"", "      target: \"{{ input.channel }}\"\n      "+field+": legacy", 1)
			_, diagnostics := Parse([]byte(raw))
			if !hasCode(diagnostics, "syntax.unknown_field") {
				t.Fatalf("Parse() diagnostics = %#v, want syntax.unknown_field", diagnostics)
			}
			if diagnostics[0].Line == 0 || diagnostics[0].Column == 0 {
				t.Fatalf("diagnostic lacks source position: %#v", diagnostics[0])
			}
			if diagnostics[0].NodeID != "notify" {
				t.Fatalf("diagnostic lacks stable node identity: %#v", diagnostics[0])
			}
		})
	}
}

func TestCompileStrictlyRejectsLegacyHeaderWithoutFallback(t *testing.T) {
	legacy := strings.Replace(
		validCallbackPlan,
		"apiVersion: loop.plan/v1\nkind: PlanVersion",
		"apiVersion: loop.workflow/v1\nkind: Workflow",
		1,
	)

	artifact, diagnostics, err := Compile([]byte(legacy))
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	if artifact != nil {
		t.Fatalf("Compile() artifact = %#v, want nil (legacy fallback must not occur)", artifact)
	}
	for _, code := range []string{"header.api_version", "header.kind"} {
		if !hasCode(diagnostics, code) {
			t.Fatalf("Compile() diagnostics = %#v, want %s", diagnostics, code)
		}
	}
}

func TestCompileMissingNodeConfigsReturnDiagnosticsWithoutPanicking(t *testing.T) {
	for _, capability := range compilerAuthoringCapabilities.Nodes {
		nodeType := capability.Type
		t.Run(string(nodeType), func(t *testing.T) {
			raw := []byte(fmt.Sprintf(`apiVersion: loop.plan/v1
kind: PlanVersion
graph:
  entry: {nodeId: broken, port: in}
  nodes:
  - id: broken
    type: %s
  edges: []
`, nodeType))

			artifact, diagnostics, err := Compile(raw)
			if err != nil {
				t.Fatalf("Compile() error = %v", err)
			}
			if artifact != nil {
				t.Fatalf("Compile() artifact = %#v, want nil", artifact)
			}
			if !hasCode(diagnostics, "node.config_required") {
				t.Fatalf("Compile() diagnostics = %#v, want node.config_required", diagnostics)
			}
		})
	}
}

func TestCompileDiagnosticCarriesStableGraphIdentityAndPosition(t *testing.T) {
	raw := strings.Replace(validCallbackPlan, "nodeId: success", "nodeId: missing", 1)
	_, diagnostics, err := Compile([]byte(raw))
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	diagnostic, ok := findCode(diagnostics, "graph.unknown_target")
	if !ok {
		t.Fatalf("Compile() diagnostics = %#v", diagnostics)
	}
	if diagnostic.EdgeID != "delivered" || diagnostic.Line == 0 || diagnostic.Column == 0 {
		t.Fatalf("diagnostic identity/position = %#v", diagnostic)
	}
}

func TestCompileCountedLoopOwnsCycleAndPublishesIterationContract(t *testing.T) {
	artifact, diagnostics, err := Compile([]byte(validLoopPlan))
	if err != nil || HasErrors(diagnostics) {
		t.Fatalf("Compile() = (%v, %#v)", err, diagnostics)
	}
	var loop *ArtifactAgentTeamConfig
	for i := range artifact.Nodes {
		if artifact.Nodes[i].ID == "repeat" {
			loop = artifact.Nodes[i].Config.Loop
		}
	}
	if loop == nil || loop.Mode != LoopCounted || loop.Iterations != 3 {
		t.Fatalf("loop iteration contract = %#v", loop)
	}
}

func TestCompileRejectsCycleOutsideLoopContinueContract(t *testing.T) {
	raw := strings.Replace(validLoopPlan,
		"target: {nodeId: repeat, port: continue}",
		"target: {nodeId: route, port: in}", 1)
	_, diagnostics, err := Compile([]byte(raw))
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	if !hasCode(diagnostics, "graph.unowned_cycle") {
		t.Fatalf("Compile() diagnostics = %#v, want graph.unowned_cycle", diagnostics)
	}
}

func TestCompileForkJoinAllAndAny(t *testing.T) {
	for _, mode := range []string{"all", "any"} {
		t.Run(mode, func(t *testing.T) {
			raw := strings.Replace(validForkJoinPlan, "mode: all", "mode: "+mode, 1)
			if mode == "any" {
				raw = strings.Replace(raw, "branches.left.value", "winner.value.value", 1)
			}
			_, diagnostics, err := Compile([]byte(raw))
			if err != nil || HasErrors(diagnostics) {
				t.Fatalf("Compile() = (%v, %#v)", err, diagnostics)
			}
		})
	}
}

func TestAllMatchSwitchCanOwnDynamicJoinLineage(t *testing.T) {
	raw := allMatchJoinPlan()
	_, diagnostics, err := Compile([]byte(raw))
	if err != nil || HasErrors(diagnostics) {
		t.Fatalf("all_match Compile() = (%v, %#v)", err, diagnostics)
	}

	firstMatch := strings.Replace(raw, "mode: all_match", "mode: first_match", 1)
	_, diagnostics, err = Compile([]byte(firstMatch))
	if err != nil {
		t.Fatalf("first_match Compile() error = %v", err)
	}
	if !hasCode(diagnostics, "join.invalid_fork_owner") {
		t.Fatalf("first_match diagnostics = %#v, want join.invalid_fork_owner", diagnostics)
	}
}

func TestCompileRejectsCrossWiredParallelBranches(t *testing.T) {
	raw := strings.Replace(validForkJoinPlan, "target: {nodeId: gather, port: left}", "target: {nodeId: gather, port: __left__}", 1)
	raw = strings.Replace(raw, "target: {nodeId: gather, port: right}", "target: {nodeId: gather, port: left}", 1)
	raw = strings.Replace(raw, "target: {nodeId: gather, port: __left__}", "target: {nodeId: gather, port: right}", 1)
	_, diagnostics, err := Compile([]byte(raw))
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	if !hasCode(diagnostics, "parallel.cross_wired_branch") {
		t.Fatalf("Compile() diagnostics = %#v, want parallel.cross_wired_branch", diagnostics)
	}
}

func TestCompileRejectsParallelBranchThatEscapesBeforeJoin(t *testing.T) {
	raw := strings.Replace(validForkJoinPlan, "target: {nodeId: gather, port: left}", "target: {nodeId: failure, port: in}", 1)
	_, diagnostics, err := Compile([]byte(raw))
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	if !hasCode(diagnostics, "parallel.open_branch") {
		t.Fatalf("Compile() diagnostics = %#v, want parallel.open_branch", diagnostics)
	}
}

func TestCompileRejectsJoinNotDominatedByItsOwner(t *testing.T) {
	_, diagnostics, err := Compile([]byte(undominatedJoinPlan()))
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	if !hasCode(diagnostics, "parallel.undominated_join") {
		t.Fatalf("Compile() diagnostics = %#v, want parallel.undominated_join", diagnostics)
	}
}

func TestCompileRejectsParallelStateWriteConflict(t *testing.T) {
	_, diagnostics, err := Compile([]byte(parallelWriteConflictPlan))
	if err != nil {
		t.Fatalf("Compile() error = %v", err)
	}
	diagnostic, ok := findCode(diagnostics, "parallel.state_write_conflict")
	if !ok {
		t.Fatalf("Compile() diagnostics = %#v, want parallel.state_write_conflict", diagnostics)
	}
	if diagnostic.NodeID != "fanout" || !strings.Contains(diagnostic.Message, `state "shared"`) {
		t.Fatalf("conflict diagnostic = %#v", diagnostic)
	}
}

func TestCompileRejectsInventedSchemaVocabularyAndInvalidSwitchDefault(t *testing.T) {
	integerSchema := strings.Replace(validCallbackPlan, "type: channel", "type: integer", 1)
	_, diagnostics, _ := Compile([]byte(integerSchema))
	if !hasCode(diagnostics, "schema.unknown_type") {
		t.Fatalf("integer diagnostics = %#v", diagnostics)
	}

	withoutDefault := strings.Replace(validLoopPlan, "      isDefault: true", "      when: 'input.answer == \"other\"'", 1)
	_, diagnostics, _ = Compile([]byte(withoutDefault))
	if !hasCode(diagnostics, "switch.default_count") {
		t.Fatalf("switch diagnostics = %#v", diagnostics)
	}
}

func TestCompileRemainingActionDTOsWithExplicitOutcomePorts(t *testing.T) {
	tests := []struct {
		name  string
		state string
		node  string
		ports []string
	}{
		{
			name:  "set variable",
			state: "state:\n  count: {type: number, required: true, default: 0}\n",
			node: `  - id: action
    type: set_variable
    setVariable:
      assignments:
      - state: count
        value: {expr: 'length(["one", "two"])'}`,
			ports: []string{"completed", "failed"},
		},
		{
			name: "wait",
			node: `  - id: action
    type: wait
    wait:
      until: {value: PT1M}
      timeout: PT2M
      retry: {maxAttempts: 2, backoff: 1s}`,
			ports: []string{"completed", "failed", "timed_out"},
		},
		{
			name: "request approval",
			node: `  - id: action
    type: request_approval
    requestApproval:
      assignee: alice
      summary: Approve this run
      retry: {maxAttempts: 2, backoff: 1s}`,
			ports: []string{"approved", "rejected", "failed"},
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			raw := linearActionPlan(test.state, test.node, test.ports)
			_, diagnostics, err := Compile([]byte(raw))
			if err != nil || HasErrors(diagnostics) {
				t.Fatalf("Compile() = (%v, %#v)\n%s", err, diagnostics, raw)
			}
		})
	}
}

func linearActionPlan(state, node string, ports []string) string {
	var edges strings.Builder
	for i, port := range ports {
		target := "failure"
		if i == 0 {
			target = "success"
		}
		fmt.Fprintf(&edges, "  - id: route_%s\n    source: {nodeId: action, port: %s}\n    target: {nodeId: %s, port: in}\n", port, port, target)
	}
	return "apiVersion: loop.plan/v1\nkind: PlanVersion\n" + state + `graph:
  entry: {nodeId: action, port: in}
  nodes:
` + node + `
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: action_failed}
  edges:
` + edges.String()
}

func allMatchJoinPlan() string {
	plan := strings.Replace(validForkJoinPlan, `    type: fork
    input:
      value: {type: string, required: true}
    fork:
      branches: [left, right]`, `    type: switch
    input:
      value: {type: string, required: true}
    switch:
      mode: all_match
      cases:
      - port: left
        when: 'contains(input.value, "left") == true'
      - port: right
        when: 'contains(input.value, "right") == true'
      - port: fallback
        isDefault: true`, 1)
	return strings.Replace(plan, `  - id: joined
    source: {nodeId: gather, port: completed}`, `  - id: fallback
    source: {nodeId: fanout, port: fallback}
    target: {nodeId: gather, port: fallback}
    bindings:
      value: {ref: input.value}
  - id: joined
    source: {nodeId: gather, port: completed}`, 1)
}

func undominatedJoinPlan() string {
	plan := strings.Replace(validForkJoinPlan, "    nodeId: fanout", "    nodeId: router", 1)
	plan = strings.Replace(plan, `  nodes:
  - id: fanout`, `  nodes:
  - id: router
    type: switch
    input:
      value: {type: string, required: true}
    switch:
      mode: first_match
      cases:
      - port: via_owner
        when: 'input.value == "owner"'
      - port: bypass
        isDefault: true
  - id: fanout`, 1)
	return strings.Replace(plan, `  edges:
  - id: left`, `  edges:
  - id: via_owner
    source: {nodeId: router, port: via_owner}
    target: {nodeId: fanout, port: in}
    bindings:
      value: {ref: input.value}
  - id: bypass
    source: {nodeId: router, port: bypass}
    target: {nodeId: gather, port: left}
    bindings:
      value: {ref: input.value}
  - id: left`, 1)
}

func hasCode(diagnostics []Diagnostic, code string) bool {
	_, ok := findCode(diagnostics, code)
	return ok
}

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

const validCallbackPlan = `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  channel: {type: channel, required: true}
output:
  reply: {type: string, required: true}
graph:
  entry:
    nodeId: notify
    port: in
    input:
      channel: {ref: plan.input.channel}
  nodes:
  - id: notify
    type: send_message
    input:
      channel: {type: channel, required: true}
    sendMessage:
      target: "{{ input.channel }}"
      text: "@agent answer in {{ length(input.channel) }} characters or more"
      completion: callback
      callbackOutput:
        reply: {type: string, required: true}
      retry: {maxAttempts: 3, backoff: 1s}
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: message_failed}
  edges:
  - id: delivered
    source: {nodeId: notify, port: completed}
    target: {nodeId: success, port: in}
    bindings:
      reply: {ref: output.reply}
  - id: delivery_failed
    source: {nodeId: notify, port: failed}
    target: {nodeId: failure, port: in}`

const validLoopPlan = `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  answer: {type: string, required: true}
output:
  answer: {type: string, required: true}
graph:
  entry:
    nodeId: repeat
    port: enter
    input:
      answer: {ref: plan.input.answer}
  nodes:
  - id: repeat
    type: loop
    loop:
      mode: counted
      iterations: 3
      state:
        answer: {type: string, required: true}
  - id: route
    type: switch
    input:
      answer: {type: string, required: true}
    switch:
      mode: first_match
      cases:
      - port: again
        when: 'input.answer == "again"'
      - port: stop
        when: 'contains(input.answer, "stop") == true'
      - port: fallback
        isDefault: true
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: loop_failed}
  edges:
  - id: body
    source: {nodeId: repeat, port: body}
    target: {nodeId: route, port: in}
    bindings:
      answer: {ref: loop.state.answer}
  - id: again
    source: {nodeId: route, port: again}
    target: {nodeId: repeat, port: continue}
    bindings:
      answer: {ref: input.answer}
  - id: stop
    source: {nodeId: route, port: stop}
    target: {nodeId: repeat, port: break}
    bindings:
      answer: {ref: input.answer}
  - id: fallback
    source: {nodeId: route, port: fallback}
    target: {nodeId: repeat, port: break}
    bindings:
      answer: {ref: input.answer}
  - id: done
    source: {nodeId: repeat, port: done}
    target: {nodeId: success, port: in}
    bindings:
      answer: {ref: loop.state.answer}
  - id: loop_failed
    source: {nodeId: repeat, port: failed}
    target: {nodeId: failure, port: in}`

const validForkJoinPlan = `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  value: {type: string, required: true}
output:
  value: {type: string, required: true}
graph:
  entry:
    nodeId: fanout
    port: in
    input:
      value: {ref: plan.input.value}
  nodes:
  - id: fanout
    type: fork
    input:
      value: {type: string, required: true}
    fork:
      branches: [left, right]
  - id: gather
    type: join
    input:
      value: {type: string, required: true}
    join:
      forkId: fanout
      mode: all
      result:
        value: {type: string, required: true}
      merge:
        value: {ref: branches.left.value}
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: join_failed}
  edges:
  - id: left
    source: {nodeId: fanout, port: left}
    target: {nodeId: gather, port: left}
    bindings:
      value: {ref: input.value}
  - id: right
    source: {nodeId: fanout, port: right}
    target: {nodeId: gather, port: right}
    bindings:
      value: {ref: input.value}
  - id: joined
    source: {nodeId: gather, port: completed}
    target: {nodeId: success, port: in}
    bindings:
      value: {ref: output.value}
  - id: join_failed
    source: {nodeId: gather, port: failed}
    target: {nodeId: failure, port: in}
`

const parallelWriteConflictPlan = `apiVersion: loop.plan/v1
kind: PlanVersion
input:
  value: {type: string, required: true}
output:
  value: {type: string, required: true}
state:
  shared: {type: string, required: true, default: ""}
graph:
  entry:
    nodeId: fanout
    port: in
    input:
      value: {ref: plan.input.value}
  nodes:
  - id: fanout
    type: fork
    input:
      value: {type: string, required: true}
    fork:
      branches: [left, right]
  - id: left_writer
    type: set_variable
    setVariable:
      assignments:
      - state: shared
        value: {ref: plan.input.value}
  - id: right_writer
    type: set_variable
    setVariable:
      assignments:
      - state: shared
        value: {ref: plan.input.value}
  - id: gather
    type: join
    input:
      value: {type: string, required: true}
    join:
      forkId: fanout
      mode: all
      result:
        value: {type: string, required: true}
      merge:
        value: {ref: branches.left.value}
  - id: success
    type: end
    end: {outcome: succeeded}
  - id: failure
    type: end
    end: {outcome: failed, code: join_failed}
  edges:
  - id: start_left
    source: {nodeId: fanout, port: left}
    target: {nodeId: left_writer, port: in}
  - id: start_right
    source: {nodeId: fanout, port: right}
    target: {nodeId: right_writer, port: in}
  - id: left_completed
    source: {nodeId: left_writer, port: completed}
    target: {nodeId: gather, port: left}
    bindings:
      value: {ref: plan.input.value}
  - id: left_failed
    source: {nodeId: left_writer, port: failed}
    target: {nodeId: gather, port: left}
    bindings:
      value: {ref: plan.input.value}
  - id: right_completed
    source: {nodeId: right_writer, port: completed}
    target: {nodeId: gather, port: right}
    bindings:
      value: {ref: plan.input.value}
  - id: right_failed
    source: {nodeId: right_writer, port: failed}
    target: {nodeId: gather, port: right}
    bindings:
      value: {ref: plan.input.value}
  - id: joined
    source: {nodeId: gather, port: completed}
    target: {nodeId: success, port: in}
    bindings:
      value: {ref: output.value}
  - id: join_failed
    source: {nodeId: gather, port: failed}
    target: {nodeId: failure, port: in}
`
