//
//  Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
//

package canvas

import (
	"reflect"
	"sync/atomic"
	"testing"
)

func TestCanvasStateSerializer_RoundTrip(t *testing.T) {
	src := NewCanvasState("run_abc", "session_xyz")
	src.Outputs["retrieval_0"] = map[string]any{
		"chunks":   []string{"a", "b", "c"},
		"doc_aggs": map[string]int{"doc1": 3, "doc2": 1},
	}
	src.Outputs["llm_0"] = map[string]any{
		"answer":  "the sky is blue",
		"tokens":  17,
		"model":   "gpt-4o-mini",
		"stopped": true,
	}
	src.Sys["query"] = "what color is the sky?"
	src.Sys["user_id"] = "u_42"
	src.Sys["files"] = []any{"f1", "f2"}
	src.Env["DEPLOY_REGION"] = "us-west-2"
	src.Env["MODEL_TIER"] = "small"
	src.Path = []string{"begin_0", "retrieval_0", "llm_0", "message_0"}
	src.History = []map[string]any{
		{"role": "user", "content": "earlier turn"},
		{"role": "assistant", "content": "earlier reply"},
	}
	src.Globals["shared_key"] = "v1"
	src.CancelFlag.Store(true)

	ser := CanvasStateSerializer{}
	data, err := ser.Marshal(src)
	if err != nil {
		t.Fatalf("Marshal: %v", err)
	}
	if len(data) == 0 {
		t.Fatal("Marshal returned empty bytes")
	}

	dst := NewCanvasState("", "")
	if err := ser.Unmarshal(data, dst); err != nil {
		t.Fatalf("Unmarshal: %v", err)
	}

	if dst.RunID != src.RunID {
		t.Fatalf("RunID = %q, want %q", dst.RunID, src.RunID)
	}
	if dst.SessionID != src.SessionID {
		t.Fatalf("SessionID = %q, want %q", dst.SessionID, src.SessionID)
	}
	// JSON round-trip coerces numbers to float64, so we re-marshal both
	// sides and compare bytes — that is the real contract of the
	// serializer (lossless across the eino checkpoint boundary).
	srcBytes, _ := ser.Marshal(src)
	dstBytes, _ := ser.Marshal(dst)
	if string(srcBytes) != string(dstBytes) {
		t.Fatalf("round-trip not stable:\n src→bytes: %s\n dst→bytes: %s",
			srcBytes, dstBytes)
	}
	// Direct checks for the non-JSON-coerced fields.
	// Note: CancelFlag is *atomic.Bool; encoding/json does not marshal
	// its unexported fields, so the flag is reset to its zero value on
	// round-trip. That is acceptable for the canvas checkpoint
	// contract — the cancel signal lives in Redis (cancel.go) and a
	// resumed run gets a fresh context. The non-nil pointer is the
	// invariant that matters: nodes must always be able to call .Load()
	// without checking for nil first.
	if dst.CancelFlag == nil {
		t.Fatal("CancelFlag is nil after Unmarshal; downstream .Load() would panic")
	}
	// Spot check that nested maps survive.
	if dst.Outputs["llm_0"]["model"] != "gpt-4o-mini" {
		t.Fatalf("nested map lost: %v", dst.Outputs)
	}
	if v, _ := dst.Sys["user_id"].(string); v != "u_42" {
		t.Fatalf("Sys[user_id] = %v", dst.Sys["user_id"])
	}
	// Suppress unused import warning when reflect.DeepEqual is removed.
	_ = reflect.DeepEqual
}

func TestCanvasStateSerializer_EmptyState(t *testing.T) {
	// Edge case: zero-value state must round-trip without error.
	src := NewCanvasState("r", "t")
	ser := CanvasStateSerializer{}
	data, err := ser.Marshal(src)
	if err != nil {
		t.Fatalf("Marshal empty: %v", err)
	}
	dst := NewCanvasState("", "")
	if err := ser.Unmarshal(data, dst); err != nil {
		t.Fatalf("Unmarshal empty: %v", err)
	}
	if dst.RunID != "r" || dst.SessionID != "t" {
		t.Fatalf("ids not preserved: %q %q", dst.RunID, dst.SessionID)
	}
}

func TestCanvasStateSerializer_UnmarshalIntoExistingPointer(t *testing.T) {
	// The eino contract: Unmarshal fills a caller-owned pointer. Confirm
	// nested maps are populated (not just the top-level struct).
	src := NewCanvasState("r2", "t2")
	src.Outputs["only"] = map[string]any{"k": "v"}
	src.Sys["x"] = 1
	ser := CanvasStateSerializer{}
	data, _ := ser.Marshal(src)

	dst := NewCanvasState("old", "old")
	if err := ser.Unmarshal(data, dst); err != nil {
		t.Fatalf("Unmarshal: %v", err)
	}
	if dst.Outputs["only"]["k"] != "v" {
		t.Fatalf("nested map not preserved: %v", dst.Outputs)
	}
	if v, ok := dst.Sys["x"].(float64); !ok || v != 1 {
		t.Fatalf("Sys[x] = %v (%T), want float64(1)", dst.Sys["x"], dst.Sys["x"])
	}
	// Ids are overwritten by the round-trip.
	if dst.RunID != "r2" || dst.SessionID != "t2" {
		t.Fatalf("ids not overwritten: %q %q", dst.RunID, dst.SessionID)
	}
}

// Ensure atomic.Bool preserves its zero value through JSON when set to false
// (avoids future regression on CancelFlag handling).
func TestCanvasStateSerializer_CancelFlagZero(t *testing.T) {
	src := NewCanvasState("r3", "t3")
	ser := CanvasStateSerializer{}
	data, _ := ser.Marshal(src)
	dst := NewCanvasState("", "")
	if err := ser.Unmarshal(data, dst); err != nil {
		t.Fatalf("Unmarshal: %v", err)
	}
	if dst.CancelFlag == nil {
		t.Fatal("CancelFlag is nil after Unmarshal")
	}
	if dst.CancelFlag.Load() {
		t.Fatal("CancelFlag is true, want false")
	}
	// Cross-check the atomic is the same struct shape.
	var _ *atomic.Bool = dst.CancelFlag
}
