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

import (
	"context"
	"runtime"
	"sync"
	"testing"

	"ragflow/internal/dao"
	"ragflow/internal/entity"
	"ragflow/internal/ingestion/pipeline"
	"ragflow/internal/ingestion/testutil"
	servicepkg "ragflow/internal/service"
	"ragflow/internal/service/document"
)

// TestProgressSink_CanConstructDocumentServiceWithoutServerConfig ensures the
// sink's DocumentService dependency can be built in a headless/test environment
// where server config is not initialized. NewDocumentService historically read
// server.GetConfig().DocEngine.Type, which nil-dereferenced without config; the
// sink must not pull the process-wide config just to mirror run progress.
func TestProgressSink_CanConstructDocumentServiceWithoutServerConfig(t *testing.T) {
	db := testutil.SetupTestDB(t)
	cleanup := testutil.ReplaceDBForTest(t, db)
	defer cleanup()

	// No server config is initialized in the test env; this must not panic.
	svc := document.NewDocumentService()
	if svc == nil {
		t.Fatal("expected non-nil DocumentService")
	}
}

// TestProgressSink_EagerlyConstructsDocumentService ensures the sink builds its
// DocumentService at construction time rather than lazily on the first progress
// event. Lazy construction is a data race under eino's parallel-branch progress
// callbacks (see TestProgressSink_OnComponentProgress_NoDataRace); eager
// construction makes docSvc immutable after newProgressSink returns.
func TestProgressSink_EagerlyConstructsDocumentService(t *testing.T) {
	db := testutil.SetupTestDB(t)
	cleanup := testutil.ReplaceDBForTest(t, db)
	defer cleanup()

	ctx := t.Context()
	sink := newProgressSink(ctx, servicepkg.NewIngestionTaskService())
	if sink.docSvc == nil {
		t.Fatal("expected sink to eagerly construct its DocumentService, got nil (lazy)")
	}
}

// TestProgressSink_DocService_NoDataRace guards against regressing to lazy
// DocumentService construction. eino's compose graph runs parallel branches
// concurrently (compose/chain_parallel.go, branch.go), so the progress callback
// can fire from multiple goroutines; docSvc must be a pre-built, immutable
// DocumentService, not lazily check-then-act on s.docSvc.
//
// The race is hit directly on docSvc rather than through OnComponentProgress
// because the latter serializes on the single test-DB connection before
// reaching docSvc, which masks the race.
func TestProgressSink_DocService_NoDataRace(t *testing.T) {
	db := testutil.SetupTestDB(t)
	cleanup := testutil.ReplaceDBForTest(t, db)
	defer cleanup()

	ctx := t.Context()
	// Deliberately do NOT inject a stub docSvc: the sink's own DocumentService
	// must already be constructed (not lazily built mid-call) when the
	// goroutines below race into docSvc.
	sink := newProgressSink(ctx, servicepkg.NewIngestionTaskService())

	const n = 30
	var wg sync.WaitGroup
	start := make(chan struct{})
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			_ = sink.docSvc
		}()
	}
	close(start)
	wg.Wait()
}

// TestProgressSink_Total_NoDataRace guards the total denominator against being
// a non-atomic shared field. OnComponentTotal (writer, Run goroutine) and
// OnComponentProgress (reader, concurrent eino branches) share total; a plain
// int is a data race per the Go memory model. The read is hit directly on the
// field rather than through OnComponentProgress because the latter serializes
// on the single test-DB connection before reaching the read, masking the race.
func TestProgressSink_Total_NoDataRace(t *testing.T) {
	db := testutil.SetupTestDB(t)
	cleanup := testutil.ReplaceDBForTest(t, db)
	defer cleanup()
	_, _, _, taskID := testutil.SeedTestData(t, db, testutil.WithPipelineID("flow-1"))

	ctx := t.Context()
	sink := newProgressSink(ctx, servicepkg.NewIngestionTaskService())

	const n = 30
	var wg sync.WaitGroup
	start := make(chan struct{})
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			sink.OnComponentTotal(ctx, taskID, 5) // writes s.total
		}()
	}
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			v := sink.total.Load() // reads s.total atomically
			runtime.KeepAlive(v)
		}()
	}
	close(start)
	wg.Wait()
}

type stubDocProgressSvc struct {
	gotDocID    string
	gotProgress float64
	gotRun      string
	gotMsg      string
	calls       int
}

func (s *stubDocProgressSvc) UpdateRunProgress(ctx context.Context, docID string, progress float64, run, progressMsg string) error {
	s.calls++
	s.gotDocID = docID
	s.gotProgress = progress
	s.gotRun = run
	s.gotMsg = progressMsg
	return nil
}

// TestProgressSinkPersistsViaService verifies the sink is the single writer of
// ingestion_task.component_total, ingestion_task_log, and document run-progress
// - all through the service layer, not the DAO.
func TestProgressSinkPersistsViaService(t *testing.T) {
	db := testutil.SetupTestDB(t)
	cleanup := testutil.ReplaceDBForTest(t, db)
	defer cleanup()
	_, _, docID, taskID := testutil.SeedTestData(t, db, testutil.WithPipelineID("flow-1"))

	ctx := t.Context()
	sink := newProgressSink(ctx, servicepkg.NewIngestionTaskService())
	stub := &stubDocProgressSvc{}
	sink.docSvc = stub

	sink.OnComponentTotal(ctx, taskID, 2)
	task, err := dao.NewIngestionTaskDAO().GetByID(ctx, db, taskID)
	if err != nil {
		t.Fatalf("load task: %v", err)
	}
	if task.ComponentTotal != 2 {
		t.Fatalf("component_total = %d, want 2", task.ComponentTotal)
	}

	sink.OnComponentProgress(ctx, pipeline.ProgressEvent{
		TaskID:     taskID,
		DocumentID: docID,
		Component:  "Parser",
		Phase:      1,
		Message:    "Parser Done",
	})

	logs, err := dao.NewIngestionTaskLogDAO().ListLogsByTaskID(ctx, db, taskID)
	if err != nil {
		t.Fatalf("list logs: %v", err)
	}
	if len(logs) != 1 {
		t.Fatalf("expected 1 component-progress row, got %d", len(logs))
	}
	if logs[0].Component != "Parser" || logs[0].Phase != 1 || logs[0].Message != "Parser Done" {
		t.Fatalf("unexpected log row: %+v", logs[0])
	}

	// 1 of 2 components done -> RUNNING (run "1"), progress 0.5.
	if stub.calls != 1 {
		t.Fatalf("UpdateRunProgress calls = %d, want 1", stub.calls)
	}
	if stub.gotDocID != docID {
		t.Fatalf("docID = %q, want %q", stub.gotDocID, docID)
	}
	if stub.gotProgress != 0.5 {
		t.Fatalf("progress = %v, want 0.5", stub.gotProgress)
	}
	if stub.gotRun != "1" {
		t.Fatalf("run = %q, want 1 (RUNNING)", stub.gotRun)
	}
	if stub.gotMsg != "Parser Done" {
		t.Fatalf("progress_msg = %q, want Parser Done", stub.gotMsg)
	}
}

// TestProgressSinkEmptyDocumentIDSkipsMirror verifies the log row is still
// recorded when no owning document is bound, but the document mirror is skipped.
func TestProgressSinkEmptyDocumentIDSkipsMirror(t *testing.T) {
	db := testutil.SetupTestDB(t)
	cleanup := testutil.ReplaceDBForTest(t, db)
	defer cleanup()
	_, _, _, taskID := testutil.SeedTestData(t, db, testutil.WithPipelineID("flow-1"))

	ctx := t.Context()
	sink := newProgressSink(ctx, servicepkg.NewIngestionTaskService())
	stub := &stubDocProgressSvc{}
	sink.docSvc = stub

	sink.OnComponentProgress(ctx, pipeline.ProgressEvent{
		TaskID:    taskID,
		Component: "Chunker",
		Phase:     1,
		Message:   "Chunker Done",
	})

	logs, err := dao.NewIngestionTaskLogDAO().ListLogsByTaskID(ctx, db, taskID)
	if err != nil {
		t.Fatalf("list logs: %v", err)
	}
	if len(logs) != 1 {
		t.Fatalf("expected 1 component-progress row, got %d", len(logs))
	}
	if stub.calls != 0 {
		t.Fatalf("UpdateRunProgress calls = %d, want 0 (no document bound)", stub.calls)
	}
}

// TestDeriveDocumentProgress exercises every branch of the run-label derivation
// logic. The function is called from OnComponentProgress with a non-nil agg
// (guarded by the caller), so the nil case is documented as a known panic.
func TestDeriveDocumentProgress(t *testing.T) {
	tests := []struct {
		name     string
		agg      *dao.TaskProgress
		total    int
		wantRun  string
		wantProg float64
	}{
		{
			name:     "failed component → fail",
			agg:      &dao.TaskProgress{Failed: 1, Done: 0, Running: 0, Percent: 0},
			total:    5,
			wantRun:  string(entity.TaskStatusFail),
			wantProg: 0.0,
		},
		{
			name:     "all done → done",
			agg:      &dao.TaskProgress{Failed: 0, Done: 5, Running: 0, Percent: 100},
			total:    5,
			wantRun:  string(entity.TaskStatusDone),
			wantProg: 1.0,
		},
		{
			name:     "partial done → running",
			agg:      &dao.TaskProgress{Failed: 0, Done: 3, Running: 0, Percent: 60},
			total:    5,
			wantRun:  string(entity.TaskStatusRunning),
			wantProg: 0.6,
		},
		{
			name:     "running only → running",
			agg:      &dao.TaskProgress{Failed: 0, Done: 0, Running: 2, Percent: 0},
			total:    5,
			wantRun:  string(entity.TaskStatusRunning),
			wantProg: 0.0,
		},
		{
			name:     "nothing started → unstart",
			agg:      &dao.TaskProgress{Failed: 0, Done: 0, Running: 0, Percent: 0},
			total:    5,
			wantRun:  string(entity.TaskStatusUnstart),
			wantProg: 0.0,
		},
		{
			name:     "total zero, nothing done → done (0==0)",
			agg:      &dao.TaskProgress{Failed: 0, Done: 0, Running: 0, Percent: 0},
			total:    0,
			wantRun:  string(entity.TaskStatusDone),
			wantProg: 0.0,
		},
		{
			name:     "failed overrides done=total",
			agg:      &dao.TaskProgress{Failed: 1, Done: 5, Running: 0, Percent: 100},
			total:    5,
			wantRun:  string(entity.TaskStatusFail),
			wantProg: 1.0,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			prog, run := deriveDocumentProgress(tt.agg, tt.total)
			if prog != tt.wantProg {
				t.Errorf("progress = %v, want %v", prog, tt.wantProg)
			}
			if run != tt.wantRun {
				t.Errorf("run = %q, want %q", run, tt.wantRun)
			}
		})
	}
}
