package synthetic

import (
	"math"
	"testing"
)

// evalDef compiles a SignalDef and evaluates it against per-source values keyed
// by source node id.
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
	t.Helper()
	rg, err := compileGraph(def)
	if err != nil {
		t.Fatalf("compileGraph: %v", err)
	}
	out, err := rg.eval(srcVals)
	if err != nil {
		t.Fatalf("eval: %v", err)
	}
	return out
}

// TestGraphMultiInputDAG verifies that an intermediate op can take two
// independently-wired sources — the capability the old linear pipeline lacked.
func TestGraphMultiInputDAG(t *testing.T) {
	def := SignalDef{
		Name: "diff",
		Graph: &Graph{
			Output: "out",
			Nodes: []GraphNode{
				{ID: "a", Kind: "source", DS: "x", Signal: "left"},
				{ID: "b", Kind: "source", DS: "x", Signal: "right"},
				{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
				{ID: "out", Kind: "output", Inputs: []string{"sub"}},
			},
		},
	}
	got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
	if got != 7 {
		t.Errorf("subtract DAG: want 7, got %v", got)
	}
}

// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
// wired order.
func TestGraphExprNamedInputs(t *testing.T) {
	def := SignalDef{
		Name: "formula",
		Graph: &Graph{
			Output: "out",
			Nodes: []GraphNode{
				{ID: "a", Kind: "source", DS: "x", Signal: "p"},
				{ID: "b", Kind: "source", DS: "x", Signal: "q"},
				{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
					Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
				{ID: "out", Kind: "output", Inputs: []string{"e"}},
			},
		},
	}
	got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
	if math.Abs(got-10) > 1e-9 {
		t.Errorf("expr named inputs: want 10, got %v", got)
	}
}

// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
func TestGraphFanInToExpr(t *testing.T) {
	def := SignalDef{
		Name: "combo",
		Graph: &Graph{
			Output: "out",
			Nodes: []GraphNode{
				{ID: "a", Kind: "source", DS: "x", Signal: "p"},
				{ID: "b", Kind: "source", DS: "x", Signal: "q"},
				{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
				{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
				{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
				{ID: "out", Kind: "output", Inputs: []string{"e"}},
			},
		},
	}
	// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
	got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
	if math.Abs(got-15) > 1e-9 {
		t.Errorf("fan-in DAG: want 15, got %v", got)
	}
}

// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
// evaluates correctly via the graph runtime.
func TestGraphLegacyConversion(t *testing.T) {
	def := SignalDef{
		Name:     "legacy",
		DS:       "x",
		Signal:   "p",
		Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
	}
	rg, err := compileGraph(def)
	if err != nil {
		t.Fatalf("compileGraph: %v", err)
	}
	if len(rg.sources) != 1 {
		t.Fatalf("want 1 source, got %d", len(rg.sources))
	}
	got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
	if err != nil {
		t.Fatalf("eval: %v", err)
	}
	if got != 12 {
		t.Errorf("legacy gain: want 12, got %v", got)
	}
}

// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
func TestGraphCycleRejected(t *testing.T) {
	def := SignalDef{
		Name: "cyclic",
		Graph: &Graph{
			Output: "out",
			Nodes: []GraphNode{
				{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
				{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
				{ID: "out", Kind: "output", Inputs: []string{"a"}},
			},
		},
	}
	if _, err := compileGraph(def); err == nil {
		t.Error("expected cycle to be rejected")
	}
}
