//
//  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 component

import (
	"context"
	"strings"
	"testing"

	"gorm.io/gorm"
)

// TestMatchOutputStructure_ValidJSON_AllKeysPresent: response has all
// expected keys → ok.
func TestMatchOutputStructure_ValidJSON_AllKeysPresent(t *testing.T) {
	resp := `{"name":"Alice","age":30}`
	parsed, ok := matchOutputStructure(resp, map[string]any{"name": "", "age": 0})
	if !ok {
		t.Fatalf("expected match")
	}
	if parsed["name"] != "Alice" {
		t.Errorf("name=%v, want Alice", parsed["name"])
	}
	if parsed["age"].(float64) != 30 {
		t.Errorf("age=%v, want 30", parsed["age"])
	}
}

// TestMatchOutputStructure_ValidJSON_MissingKey: parse OK but a key
// is missing → not ok.
func TestMatchOutputStructure_ValidJSON_MissingKey(t *testing.T) {
	resp := `{"name":"Alice"}`
	_, ok := matchOutputStructure(resp, map[string]any{"name": "", "age": 0})
	if ok {
		t.Fatalf("expected mismatch (age key missing)")
	}
}

// TestMatchOutputStructure_NotJSON: invalid JSON → not ok.
func TestMatchOutputStructure_NotJSON(t *testing.T) {
	resp := "this is not json"
	_, ok := matchOutputStructure(resp, map[string]any{"x": 0})
	if ok {
		t.Fatalf("expected mismatch (not valid JSON)")
	}
}

// TestMatchOutputStructure_EmptyExpected: no expected keys → any
// JSON object passes (vacuous truth).
func TestMatchOutputStructure_EmptyExpected(t *testing.T) {
	resp := `{"anything":1}`
	_, ok := matchOutputStructure(resp, map[string]any{})
	if !ok {
		t.Fatalf("expected match with empty expected set")
	}
}

// TestBuildStructuredRetryMessages_AppendsRetryTurn: the returned
// message list's last message is the retry user turn, and the system
// message still reflects the citation prompt (when cite=true).
func TestBuildStructuredRetryMessages_AppendsRetryTurn(t *testing.T) {
	msgs := buildStructuredRetryMessages("sys", "user", nil, true,
		map[string]any{"name": "", "age": 0},
		"first response was not JSON")
	if len(msgs) < 1 {
		t.Fatalf("expected at least 1 message, got %d", len(msgs))
	}
	last := msgs[len(msgs)-1]
	if last.Role != "user" {
		t.Fatalf("expected last role=user, got %v", last.Role)
	}
	if !strings.Contains(last.Content, "name") || !strings.Contains(last.Content, "age") {
		t.Errorf("retry prompt missing expected keys; got: %s", last.Content)
	}
	if !strings.Contains(last.Content, "first response was not JSON") {
		t.Errorf("retry prompt should reference the previous response; got: %s", last.Content[:200])
	}
	if msgs[0].Role != "system" || !strings.Contains(msgs[0].Content, "[ID:") {
		t.Errorf("system message lost; got role=%v content[:80]=%q", msgs[0].Role, msgs[0].Content[:80])
	}
}

// TestLLM_Invoke_OutputStructure_ValidFirstTry: stub returns valid
// JSON on the first call → no retry; outputs["structured"] populated.
func TestLLM_Invoke_OutputStructure_ValidFirstTry(t *testing.T) {
	stub := &stubInvoker{resp: &ChatInvokeResponse{
		Content: `{"name":"Alice","age":30}`,
		Model:   "echo",
	}}
	withStubInvoker(t, stub)

	c := NewLLMComponent(LLMParam{
		ModelID:         "echo",
		OutputStructure: map[string]any{"name": "", "age": 0},
	})
	out, err := c.Invoke(context.Background(), nil, map[string]any{"user_prompt": "who?"})
	if err != nil {
		t.Fatalf("Invoke: %v", err)
	}
	if stub.calls != 1 {
		t.Errorf("expected 1 call (no retry), got %d", stub.calls)
	}
	structured, ok := out["structured"].(map[string]any)
	if !ok {
		t.Fatalf("structured not populated; out=%+v", out)
	}
	if structured["name"] != "Alice" {
		t.Errorf("name=%v, want Alice", structured["name"])
	}
}

// TestLLM_Invoke_OutputStructure_RetryOnInvalid: stub returns
// non-JSON first, then valid JSON → retry happens; outputs["structured"]
// populated from second call.
func TestLLM_Invoke_OutputStructure_RetryOnInvalid(t *testing.T) {
	calls := 0
	inv := &callCountingInvoker{
		responses: []*ChatInvokeResponse{
			{Content: "not json at all", Model: "echo"},
			{Content: `{"name":"Bob"}`, Model: "echo"},
		},
		onCall: func() { calls++ },
	}
	withStubInvoker(t, inv)

	c := NewLLMComponent(LLMParam{
		ModelID:         "echo",
		OutputStructure: map[string]any{"name": ""},
	})
	out, err := c.Invoke(context.Background(), nil, map[string]any{"user_prompt": "who?"})
	if err != nil {
		t.Fatalf("Invoke: %v", err)
	}
	if calls != 2 {
		t.Errorf("expected 2 calls (first + retry), got %d", calls)
	}
	structured, ok := out["structured"].(map[string]any)
	if !ok {
		t.Fatalf("structured not populated after retry; out=%+v", out)
	}
	if structured["name"] != "Bob" {
		t.Errorf("name=%v, want Bob", structured["name"])
	}
	if out["content"] != `{"name":"Bob"}` {
		t.Errorf("content not updated to validated response; got %q", out["content"])
	}
}

// TestLLM_Invoke_OutputStructure_RetryStillFails: stub returns
// non-JSON both times → no structured output, content kept as first
// response (caller can still see it), no error.
func TestLLM_Invoke_OutputStructure_RetryStillFails(t *testing.T) {
	calls := 0
	inv := &callCountingInvoker{
		responses: []*ChatInvokeResponse{
			{Content: "not json", Model: "echo"},
			{Content: "still not json", Model: "echo"},
		},
		onCall: func() { calls++ },
	}
	withStubInvoker(t, inv)

	c := NewLLMComponent(LLMParam{
		ModelID:         "echo",
		OutputStructure: map[string]any{"x": 0},
	})
	out, err := c.Invoke(context.Background(), nil, map[string]any{"user_prompt": "go"})
	if err != nil {
		t.Fatalf("Invoke should not error on parse failure: %v", err)
	}
	if calls != 2 {
		t.Errorf("expected 2 calls (first + 1 retry), got %d", calls)
	}
	if _, hasStructured := out["structured"]; hasStructured {
		t.Errorf("structured should be absent after failed retry; out=%+v", out)
	}
	// content stays as the original (failed) first response
	if out["content"] != "not json" {
		t.Errorf("content should remain the first response; got %q", out["content"])
	}
}

// callCountingInvoker is a test-only ChatInvoker that returns
// pre-programmed responses in sequence and counts invocations.
type callCountingInvoker struct {
	responses []*ChatInvokeResponse
	onCall    func()
	calls     int
}

func (c *callCountingInvoker) Invoke(_ context.Context, _ *gorm.DB, _ ChatInvokeRequest) (*ChatInvokeResponse, error) {
	if c.onCall != nil {
		c.onCall()
	}
	c.calls++
	if c.calls-1 < len(c.responses) {
		return c.responses[c.calls-1], nil
	}
	return &ChatInvokeResponse{Content: "exhausted"}, nil
}

func (c *callCountingInvoker) Stream(_ context.Context, _ ChatInvokeRequest) (<-chan map[string]any, error) {
	return nil, nil
}

func (c *callCountingInvoker) Inputs() map[string]string  { return nil }
func (c *callCountingInvoker) Outputs() map[string]string { return nil }
