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

import (
	"context"
	"encoding/json"
	"errors"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/alicebob/miniredis/v2"
	"github.com/gin-gonic/gin"
	goredis "github.com/redis/go-redis/v9"

	"ragflow/internal/common"
	"ragflow/internal/dao"
	"ragflow/internal/entity"
	pipelinepkg "ragflow/internal/ingestion/pipeline"
	"ragflow/internal/ingestion/task"
	"ragflow/internal/service"
)

// TestParseAgentLogs pins the contract between GetAgentLogs and the
// front-end DataFlow Log box. The Python pipeline (rag/flow/pipeline.py
// callback) stores a JSON *array* ([{component_id, trace:[...]}]); the
// front-end's useFetchMessageTrace (web/src/hooks/use-agent-request.ts)
// requires response.data to be that array. The original handler unmarshalled
// into a map[string]interface{} and silently dropped the array, leaving the
// Log box stuck on an empty SkeletonCard. A missing/empty/corrupt payload must
// still collapse to an empty object to mirror the Python get_agent_logs
// missing-key contract (api/apps/restful_apis/agent_api.py:1087).
func TestParseAgentLogs(t *testing.T) {
	cases := []struct {
		name     string
		payload  string
		wantKind byte // '[' for array, '{' for object
	}{
		{
			name:     "missing_key_returns_empty_object",
			payload:  "",
			wantKind: '{',
		},
		{
			name: "present_array_returns_array",
			payload: `[` +
				`{"component_id":"File","trace":[` +
				`{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}` +
				`]},` +
				`{"component_id":"END","trace":[{"progress":1,"message":"done","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` +
				`]`,
			wantKind: '[',
		},
		{
			name:     "corrupt_payload_falls_back_to_empty_object",
			payload:  `{"component_id":`,
			wantKind: '{',
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := parseAgentLogs(tc.payload)
			b, err := json.Marshal(got)
			if err != nil {
				t.Fatalf("marshal: %v", err)
			}
			if len(b) == 0 || b[0] != tc.wantKind {
				t.Fatalf("want JSON kind %q, got %s", tc.wantKind, string(b))
			}
			if tc.wantKind == '[' {
				var arr []map[string]interface{}
				if err := json.Unmarshal(b, &arr); err != nil {
					t.Fatalf("round-trip: %v", err)
				}
				if arr[0]["component_id"] != "File" {
					t.Fatalf("component_id lost after round-trip: %v", arr[0])
				}
				if arr[len(arr)-1]["component_id"] != "END" {
					t.Fatalf("END marker lost after round-trip: %v", arr[len(arr)-1])
				}
			}
		})
	}
}

// TestGetAgentLogs_E2EViaMiniredis exercises the full HTTP handler path
// (auth -> canvas-access -> Redis fetch -> shape) against an in-memory
// miniredis so we can assert the wire response shape the front-end depends on:
//   - present key  -> response.data is the JSON array ([...]) with the END marker
//   - missing key  -> response.data is an empty object ("{}"), matching the
//     Python get_agent_logs missing-key contract.
//
// The Redis client is injected via WithRedisGetter (a miniredis-backed
// go-redis client); the canvas-access gate is satisfied by a real in-memory
// UserCanvas row, mirroring TestGetAgentWebhookLogsReturnsEmptyPoll.
func TestGetAgentLogs_E2EViaMiniredis(t *testing.T) {
	gin.SetMode(gin.TestMode)

	db := setupHandlerAgentsTestDB(t)
	orig := dao.DB
	dao.DB = db
	t.Cleanup(func() { dao.DB = orig })

	db.Create(&entity.UserCanvas{ID: "c1", UserID: "u1", Title: sptr("Test")})

	mr, err := miniredis.Run()
	if err != nil {
		t.Fatalf("miniredis.Run: %v", err)
	}
	t.Cleanup(mr.Close)

	rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
	t.Cleanup(func() { _ = rdb.Close() })

	logKey := "c1-msg1-logs"
	arrayPayload := `[` +
		`{"component_id":"File","trace":[{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}]},` +
		`{"component_id":"END","trace":[{"progress":1,"message":"done","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` +
		`]`
	if err := rdb.Set(context.Background(), logKey, arrayPayload, 0).Err(); err != nil {
		t.Fatalf("seed redis: %v", err)
	}

	h := NewAgentHandler(service.NewAgentService(), nil).
		WithRedisGetter(func(key string) (string, error) {
			return rdb.Get(context.Background(), key).Result()
		})

	run := func(messageID string) map[string]interface{} {
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		c.Request = httptest.NewRequest("GET", "/api/v1/agents/c1/logs/"+messageID, nil)
		c.Set("user", &entity.User{ID: "u1"})
		c.Set("user_id", "u1")
		c.Params = gin.Params{
			{Key: "canvas_id", Value: "c1"},
			{Key: "message_id", Value: messageID},
		}
		h.GetAgentLogs(c)
		var resp map[string]interface{}
		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
			t.Fatalf("decode (msg=%s): %v body=%s", messageID, err, w.Body.String())
		}
		return resp
	}

	// Present key -> data must be the array, with File first and END last.
	present := run("msg1")
	if code, _ := present["code"].(float64); int(code) != int(common.CodeSuccess) {
		t.Fatalf("present code=%v want 0 body=%s", code, mustJSON(present))
	}
	dataRaw, _ := json.Marshal(present["data"])
	trimmed := strings.TrimSpace(string(dataRaw))
	if len(trimmed) == 0 || trimmed[0] != '[' {
		t.Fatalf("present response.data must be a JSON array, got %s", string(dataRaw))
	}
	var arr []map[string]interface{}
	if err := json.Unmarshal(dataRaw, &arr); err != nil {
		t.Fatalf("present data not array: %v body=%s", err, string(dataRaw))
	}
	if arr[0]["component_id"] != "File" {
		t.Errorf("first component_id=%v want File", arr[0]["component_id"])
	}
	if arr[len(arr)-1]["component_id"] != "END" {
		t.Errorf("last component_id=%v want END", arr[len(arr)-1]["component_id"])
	}

	// Missing key -> data must be an empty object (Python parity), code 0.
	missing := run("missing")
	if code, _ := missing["code"].(float64); int(code) != int(common.CodeSuccess) {
		t.Fatalf("missing code=%v want 0 body=%s", code, mustJSON(missing))
	}
	dataRawMissing, _ := json.Marshal(missing["data"])
	if strings.TrimSpace(string(dataRawMissing)) != "{}" {
		t.Fatalf("missing response.data must be {} (object), got %s", string(dataRawMissing))
	}
}

// clientConsidersComplete replicates the front-end completion predicate from
// web/src/pages/agent/hooks/use-fetch-pipeline-log.ts: the run is "really
// done" only when the LAST array element has component_id == "END" AND its
// first trace entry carries a non-empty message. If any part of that signal
// is missing, the Log box keeps polling forever.
func clientConsidersComplete(arr []map[string]interface{}) bool {
	if len(arr) == 0 {
		return false
	}
	last := arr[len(arr)-1]
	if last["component_id"] != "END" {
		return false
	}
	trace, ok := last["trace"].([]interface{})
	if !ok || len(trace) == 0 {
		return false
	}
	first, ok := trace[0].(map[string]interface{})
	if !ok {
		return false
	}
	msg, _ := first["message"].(string)
	return strings.TrimSpace(msg) != ""
}

// TestGetAgentLogs_EndSignalCompletion pins the exact signal the front-end
// polls for: the response array must end with an END element whose first
// trace message is non-empty. The handler must preserve that signal
// byte-for-byte through JSON round-tripping.
func TestGetAgentLogs_EndSignalCompletion(t *testing.T) {
	gin.SetMode(gin.TestMode)

	db := setupHandlerAgentsTestDB(t)
	orig := dao.DB
	dao.DB = db
	t.Cleanup(func() { dao.DB = orig })

	db.Create(&entity.UserCanvas{ID: "c1", UserID: "u1", Title: sptr("Test")})

	mr, err := miniredis.Run()
	if err != nil {
		t.Fatalf("miniredis.Run: %v", err)
	}
	t.Cleanup(mr.Close)

	rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
	t.Cleanup(func() { _ = rdb.Close() })

	// End element with a NON-empty message -> client must consider it done.
	goodPayload := `[` +
		`{"component_id":"File","trace":[{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}]},` +
		`{"component_id":"END","trace":[{"progress":1,"message":"run finished","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` +
		`]`
	if err := rdb.Set(context.Background(), "c1-msg-good-logs", goodPayload, 0).Err(); err != nil {
		t.Fatalf("seed redis: %v", err)
	}

	// End element with an EMPTY message -> client must NOT consider it done
	// (would poll forever). Locks the contract that the debug writer must
	// emit a non-empty END message.
	badPayload := `[` +
		`{"component_id":"File","trace":[{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}]},` +
		`{"component_id":"END","trace":[{"progress":1,"message":"","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` +
		`]`
	if err := rdb.Set(context.Background(), "c1-msg-bad-logs", badPayload, 0).Err(); err != nil {
		t.Fatalf("seed redis: %v", err)
	}

	h := NewAgentHandler(service.NewAgentService(), nil).
		WithRedisGetter(func(key string) (string, error) {
			return rdb.Get(context.Background(), key).Result()
		})

	call := func(messageID string) []map[string]interface{} {
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		c.Request = httptest.NewRequest("GET", "/api/v1/agents/c1/logs/"+messageID, nil)
		c.Set("user", &entity.User{ID: "u1"})
		c.Set("user_id", "u1")
		c.Params = gin.Params{
			{Key: "canvas_id", Value: "c1"},
			{Key: "message_id", Value: messageID},
		}
		h.GetAgentLogs(c)
		var resp map[string]interface{}
		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
			t.Fatalf("decode (msg=%s): %v body=%s", messageID, err, w.Body.String())
		}
		if code, _ := resp["code"].(float64); int(code) != int(common.CodeSuccess) {
			t.Fatalf("code=%v want 0 body=%s", code, mustJSON(resp))
		}
		dataRaw, _ := json.Marshal(resp["data"])
		var arr []map[string]interface{}
		if err := json.Unmarshal(dataRaw, &arr); err != nil {
			t.Fatalf("data not array (msg=%s): %v body=%s", messageID, err, string(dataRaw))
		}
		return arr
	}

	good := call("msg-good")
	if !clientConsidersComplete(good) {
		t.Fatalf("expected client to consider run complete for non-empty END message; arr=%s", mustJSON(good))
	}

	bad := call("msg-bad")
	if clientConsidersComplete(bad) {
		t.Fatalf("client must NOT consider run complete for empty END message (would poll forever); arr=%s", mustJSON(bad))
	}
}

// capturedStore is an in-memory task.DebugLogStore used to assert that
// runCanvasPipelineDebug actually wrote the debug log array under the expected key.
type capturedStore struct {
	mu   sync.Mutex
	data map[string]string
}

func (s *capturedStore) Set(key, value string, _ time.Duration) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.data == nil {
		s.data = map[string]string{}
	}
	s.data[key] = value
	return true
}

func (s *capturedStore) get(key string) (string, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	v, ok := s.data[key]
	return v, ok
}

// fakeDebugExecutor is a debugExecutor stand-in for runCanvasPipelineDebug. It
// captures the sink attached via WithProgressSink and replays a couple of
// component lifecycle events on Execute, mirroring what the real
// PipelineExecutor emits through its progress callback.
type fakeDebugExecutor struct {
	capturedSink pipelinepkg.ProgressSink
	result       *task.PipelineResult
	runErr       error
}

func (f *fakeDebugExecutor) WithProgressSink(sink pipelinepkg.ProgressSink) *task.PipelineExecutor {
	f.capturedSink = sink
	return nil // return ignored by runCanvasPipelineDebug
}

func (f *fakeDebugExecutor) Execute(ctx context.Context) (*task.PipelineResult, error) {
	if f.capturedSink != nil {
		f.capturedSink.OnComponentTotal(ctx, "t1", 1)
		f.capturedSink.OnComponentProgress(ctx, pipelinepkg.ProgressEvent{
			TaskID: "t1", Component: "File", Phase: 0, Message: "File Started",
		})
		f.capturedSink.OnComponentProgress(ctx, pipelinepkg.ProgressEvent{
			TaskID: "t1", Component: "File", Phase: 1, Message: "File Done",
		})
	}
	return f.result, f.runErr
}

// TestRespondWithDebugResult_Shape pins the wire contract the front-end reads
// after kicking off a debug run: response.data must carry both message_id (the
// polling key) and chunks (the rendered output). Empty chunks must stay an
// empty array (never null), and a run error must surface as a server-error
// code rather than success.
func TestRespondWithDebugResult_Shape(t *testing.T) {
	gin.SetMode(gin.TestMode)

	// Success with chunks: data.message_id + data.chunks both present.
	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)
	respondWithDebugResult(c, &task.PipelineResult{
		MessageID: "m-abc",
		Chunks:    []map[string]any{{"content": "hello"}},
	}, nil)
	var resp map[string]interface{}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v body=%s", err, w.Body.String())
	}
	if code, _ := resp["code"].(float64); int(code) != int(common.CodeSuccess) {
		t.Fatalf("code=%v want 0 body=%s", code, mustJSON(resp))
	}
	data, ok := resp["data"].(map[string]interface{})
	if !ok {
		t.Fatalf("data not object: %v", resp)
	}
	if data["message_id"] != "m-abc" {
		t.Fatalf("data.message_id=%v want m-abc", data["message_id"])
	}
	chunks, ok := data["chunks"].([]interface{})
	if !ok {
		t.Fatalf("data.chunks not array: %v", data["chunks"])
	}
	if len(chunks) != 1 {
		t.Fatalf("data.chunks len=%d want 1", len(chunks))
	}

	// Empty chunks: still an object with message_id and an empty array.
	w2 := httptest.NewRecorder()
	c2, _ := gin.CreateTestContext(w2)
	respondWithDebugResult(c2, &task.PipelineResult{MessageID: "m-empty"}, nil)
	var resp2 map[string]interface{}
	if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
		t.Fatalf("decode empty: %v body=%s", err, w2.Body.String())
	}
	data2, ok := resp2["data"].(map[string]interface{})
	if !ok {
		t.Fatalf("data not object: %v", resp2)
	}
	if data2["message_id"] != "m-empty" {
		t.Fatalf("data.message_id=%v want m-empty", data2["message_id"])
	}
	chunks2, ok := data2["chunks"].([]interface{})
	if !ok {
		t.Fatalf("data.chunks not array: %v", data2["chunks"])
	}
	if len(chunks2) != 0 {
		t.Fatalf("data.chunks len=%d want 0", len(chunks2))
	}

	// Error: must NOT be a success code.
	w3 := httptest.NewRecorder()
	c3, _ := gin.CreateTestContext(w3)
	respondWithDebugResult(c3, nil, errors.New("boom"))
	var resp3 map[string]interface{}
	if err := json.Unmarshal(w3.Body.Bytes(), &resp3); err != nil {
		t.Fatalf("decode err: %v body=%s", err, w3.Body.String())
	}
	if code, _ := resp3["code"].(float64); int(code) == int(common.CodeSuccess) {
		t.Fatalf("error case must not be success: %v", resp3)
	}
}

// TestRespondWithDebugResult_ErrorCarriesMessageID pins that a run failure
// still surfaces the polling key: the response must keep a non-success code
// (the existing contract forbids masking errors as success) BUT the error
// envelope's data must carry message_id so the front-end can poll the failure
// timeline that DebugLogSink.Flush already wrote to Redis.
func TestRespondWithDebugResult_ErrorCarriesMessageID(t *testing.T) {
	gin.SetMode(gin.TestMode)

	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)
	respondWithDebugResult(c, &task.PipelineResult{MessageID: "m-err"}, errors.New("boom"))
	var resp map[string]interface{}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v body=%s", err, w.Body.String())
	}
	if code, _ := resp["code"].(float64); int(code) == int(common.CodeSuccess) {
		t.Fatalf("error case must not be success: %v", resp)
	}
	data, ok := resp["data"].(map[string]interface{})
	if !ok {
		t.Fatalf("error envelope data must be an object carrying message_id: %v", resp)
	}
	if data["message_id"] != "m-err" {
		t.Fatalf("error envelope data.message_id=%v want m-err", data["message_id"])
	}
}

// TestRunCanvasPipelineDebug_ErrorStillExposesMessageID asserts the full write-side
// wiring when the executor fails: runCanvasPipelineDebug must (a) still return a
// non-nil result carrying MessageID even though exec.Execute returned nil, and
// (b) have already flushed the failure log under "{canvasID}-{messageID}-logs"
// so the front-end can poll that exact key. This is the scenario that broke
// before — the failure log was written but unreachable because message_id was
// dropped on the error path.
func TestRunCanvasPipelineDebug_ErrorStillExposesMessageID(t *testing.T) {
	ctx := context.Background()
	store := &capturedStore{}

	h := NewAgentHandler(service.NewAgentService(), nil).
		WithRedisStore(store).
		WithNewExecutor(func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) {
			return &fakeDebugExecutor{
				// Simulate an executor that fails and returns no result.
				result: nil,
				runErr: errors.New("executor blew up"),
			}, nil
		})

	result, err := h.runCanvasPipelineDebug(ctx, &entity.User{ID: "u1"}, "c1", "f.txt", []byte("data"))
	if err == nil {
		t.Fatalf("expected run error")
	}
	if result == nil {
		t.Fatalf("result is nil; front-end would have no polling key on failure")
	}
	if result.MessageID == "" {
		t.Fatalf("result.MessageID empty on failure; front-end cannot poll the failure log")
	}

	// The failure log must be written under the composed key.
	key := "c1-" + result.MessageID + "-logs"
	raw, ok := store.get(key)
	if !ok {
		t.Fatalf("failure log not written under key %q; store keys=%v", key, keysOf(store))
	}
	var arr []map[string]interface{}
	if err := json.Unmarshal([]byte(raw), &arr); err != nil {
		t.Fatalf("stored failure log not array: %v raw=%s", err, raw)
	}
	if !clientConsidersComplete(arr) {
		t.Fatalf("stored failure log not completion-complete (END last + non-empty msg); arr=%s", raw)
	}
}

// TestRunCanvasPipelineDebug_WiresMessageIDAndLog asserts the full write-side wiring
// of runCanvasPipelineDebug without a live Redis or real canvas: a fake executor
// emits component progress, the injected DebugLogSink flushes the
// [{component_id, trace}] array (with the END marker last) to the captured
// store under the key "{canvasID}-{messageID}-logs", and the returned result
// carries message_id so the front-end can poll that exact key. The array must
// satisfy the completion predicate (END last, non-empty END message) so the
// Log box stops polling.
func TestRunCanvasPipelineDebug_WiresMessageIDAndLog(t *testing.T) {
	ctx := context.Background()
	store := &capturedStore{}

	h := NewAgentHandler(service.NewAgentService(), nil).
		WithRedisStore(store).
		WithNewExecutor(func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) {
			return &fakeDebugExecutor{
				result: &task.PipelineResult{
					Chunks: []map[string]any{{"content": "chunk-1"}},
				},
			}, nil
		})

	result, err := h.runCanvasPipelineDebug(ctx, &entity.User{ID: "u1"}, "c1", "f.txt", []byte("data"))
	if err != nil {
		t.Fatalf("runCanvasPipelineDebug: %v", err)
	}
	if result == nil {
		t.Fatalf("result is nil")
	}
	if result.MessageID == "" {
		t.Fatalf("result.MessageID empty; front-end would have no polling key")
	}

	// The log array must be written under the composed key.
	key := "c1-" + result.MessageID + "-logs"
	raw, ok := store.get(key)
	if !ok {
		t.Fatalf("log not written under key %q; store keys=%v", key, keysOf(store))
	}

	var arr []map[string]interface{}
	if err := json.Unmarshal([]byte(raw), &arr); err != nil {
		t.Fatalf("stored log not array: %v raw=%s", err, raw)
	}
	if !clientConsidersComplete(arr) {
		t.Fatalf("stored log not completion-complete (END last + non-empty msg); arr=%s", raw)
	}
}

func keysOf(s *capturedStore) []string {
	s.mu.Lock()
	defer s.mu.Unlock()
	keys := make([]string, 0, len(s.data))
	for k := range s.data {
		keys = append(keys, k)
	}
	return keys
}

// miniredisDebugStore adapts a go-redis client to task.DebugLogStore so the
// write path can be exercised against a real (in-memory) Redis in tests. It is
// the production-faithful counterpart of capturedStore: capturedStore proves
// the key shape, miniredisDebugStore proves the bytes survive a real Redis
// round-trip that the read path consumes.
type miniredisDebugStore struct {
	rdb *goredis.Client
}

func (s miniredisDebugStore) Set(key, value string, ttl time.Duration) bool {
	if err := s.rdb.Set(context.Background(), key, value, ttl).Err(); err != nil {
		return false
	}
	return true
}

// TestRunCanvasPipelineDebug_WriteThenReadViaMiniredis locks the write/read contract
// end-to-end: runCanvasPipelineDebug writes the debug log array under
// "{canvasID}-{messageID}-logs" into a real (miniredis) Redis via the injected
// DebugLogStore, and the SAME handler's GetAgentLogs reads it back through the
// injected redisGetter — proving the writer's key shape exactly matches the
// reader's and that the stored bytes round-trip into the [{component_id,
// trace}] array the front-end polls for (File first, END last, non-empty END
// message). This is the only test that would catch a key-format drift between
// the two sides, since the write test uses a capturedStore and the read test
// seeds Redis directly rather than going through the writer.
func TestRunCanvasPipelineDebug_WriteThenReadViaMiniredis(t *testing.T) {
	gin.SetMode(gin.TestMode)
	ctx := context.Background()

	db := setupHandlerAgentsTestDB(t)
	orig := dao.DB
	dao.DB = db
	t.Cleanup(func() { dao.DB = orig })
	db.Create(&entity.UserCanvas{ID: "c1", UserID: "u1", Title: sptr("Test")})

	mr, err := miniredis.Run()
	if err != nil {
		t.Fatalf("miniredis.Run: %v", err)
	}
	t.Cleanup(mr.Close)
	rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
	t.Cleanup(func() { _ = rdb.Close() })

	// Both seams point at the same miniredis: the writer stores via
	// WithRedisStore and the reader fetches via WithRedisGetter, mirroring
	// production where both hit one Redis.
	h := NewAgentHandler(service.NewAgentService(), nil).
		WithRedisStore(miniredisDebugStore{rdb: rdb}).
		WithRedisGetter(func(key string) (string, error) {
			return rdb.Get(ctx, key).Result()
		}).
		WithNewExecutor(func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) {
			return &fakeDebugExecutor{
				result: &task.PipelineResult{Chunks: []map[string]any{{"content": "chunk-1"}}},
			}, nil
		})

	// Write side.
	writeResult, werr := h.runCanvasPipelineDebug(ctx, &entity.User{ID: "u1"}, "c1", "f.txt", []byte("data"))
	if werr != nil {
		t.Fatalf("runCanvasPipelineDebug: %v", werr)
	}
	if writeResult == nil || writeResult.MessageID == "" {
		t.Fatalf("runCanvasPipelineDebug returned no message_id")
	}
	messageID := writeResult.MessageID

	// The key must exist in the same miniredis the reader will query.
	if !mr.Exists("c1-" + messageID + "-logs") {
		t.Fatalf("log key c1-%s-logs not present in redis after write", messageID)
	}

	// Read side: same handler, same miniredis, driven through the real
	// GetAgentLogs HTTP path.
	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)
	c.Request = httptest.NewRequest("GET", "/api/v1/agents/c1/logs/"+messageID, nil)
	c.Set("user", &entity.User{ID: "u1"})
	c.Set("user_id", "u1")
	c.Params = gin.Params{
		{Key: "canvas_id", Value: "c1"},
		{Key: "message_id", Value: messageID},
	}
	h.GetAgentLogs(c)

	var resp map[string]interface{}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode read response: %v body=%s", err, w.Body.String())
	}
	if code, _ := resp["code"].(float64); int(code) != int(common.CodeSuccess) {
		t.Fatalf("read code=%v want 0 body=%s", code, mustJSON(resp))
	}
	dataRaw, _ := json.Marshal(resp["data"])
	trimmed := strings.TrimSpace(string(dataRaw))
	if len(trimmed) == 0 || trimmed[0] != '[' {
		t.Fatalf("read response.data must be a JSON array, got %s", string(dataRaw))
	}
	var arr []map[string]interface{}
	if err := json.Unmarshal(dataRaw, &arr); err != nil {
		t.Fatalf("read data not array: %v body=%s", err, string(dataRaw))
	}
	if arr[0]["component_id"] != "File" {
		t.Errorf("first component_id=%v want File", arr[0]["component_id"])
	}
	if arr[len(arr)-1]["component_id"] != "END" {
		t.Errorf("last component_id=%v want END", arr[len(arr)-1]["component_id"])
	}
	if !clientConsidersComplete(arr) {
		t.Errorf("stored log not completion-complete (END last + non-empty msg); arr=%s", string(dataRaw))
	}
}
