// Package evals provides an evaluation framework for agentcore agents.
//
// Modeled after deepagents/libs/evals, it enables running real-LLM agent
// evaluations with trajectory scoring, success assertions, and report generation.
//
// Core concepts:
//
//   - EvalCase: a single test case (query, expected behavior, scorers)
//   - TrajectoryScorer: builder for soft expectations + hard success checks
//   - RunEval: executes the agent against a case, captures trajectory
//   - EvalReport: aggregates multiple case results into a summary
//
// Usage in go test:
//
//	func TestMyAgent(t *testing.T) {
//	    evals.RunT(t, &evals.EvalConfig{
//	        Model: myModel,
//	        Agent: myAgent,
//	        Cases: []evals.EvalCase{
//	            {
//	                Name:  "write_hello_py",
//	                Query: "Create a hello.py file",
//	                Scorers: []evals.Scorer{
//	                    evals.FinalTextContains("Hello, World!"),
//	                    evals.FileContentEquals("hello.py", `print("Hello, World!")`),
//	                },
//	            },
//	        },
//	    })
//	}
package evals

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"ragflow/internal/harness/core"
	"ragflow/internal/harness/core/schema"
)

// ========================================================================
// Core types
// ========================================================================

// EvalCase defines a single evaluation scenario.
type EvalCase struct {
	// Name identifies this case in reports.
	Name string

	// Query is the user input to the agent.
	Query string

	// Agent overrides cfg.Agent for this specific case.
	// When nil, cfg.Agent is used (or a default agent created from cfg.Model).
	Agent core.Agent

	// Scorers are checked against the agent's output.
	// All scorers must pass for the case to succeed.
	Scorers []Scorer

	// Tags for categorising results.
	Tags []string

	// WorkDir is the temporary working directory for this case.
	// When set, file-related scorers use this as the base.
	WorkDir string
}

// Scorer evaluates an agent's output against an expectation.
// Returns nil on success, or an error describing the failure.
type Scorer func(ctx context.Context, result *EvalResult) error

// EvalResult captures the agent's output for a single case.
type EvalResult struct {
	Case     EvalCase
	Messages []*schema.Message // full conversation trajectory
	Events   []*core.AgentEvent
	Duration time.Duration
	Err      error             // agent execution error (if any)
	Snapshot map[string]string // file snapshots after execution
}

// CaseReport is the output of evaluating a single case.
type CaseReport struct {
	CaseName string
	Passed   bool
	Duration time.Duration
	Failures []Failure
	Tags     []string
}

// Failure describes a single assertion failure.
type Failure struct {
	ScorerName string
	Message    string
}

// EvalReport aggregates results from multiple cases.
type EvalReport struct {
	Cases    []CaseReport
	Total    int
	Passed   int
	Failed   int
	Duration time.Duration
}

// EvalConfig configures an evaluation run.
type EvalConfig struct {
	// Model is the chat model for the agent. Required.
	Model core.Model[*schema.Message]

	// Agent is the agent to evaluate. If nil, a default ReAct agent is created.
	Agent core.Agent

	// Cases to evaluate.
	Cases []EvalCase

	// MaxConcurrency limits parallel case execution. 0 = unlimited.
	MaxConcurrency int

	// Timeout per case. 0 = no timeout.
	Timeout time.Duration

	// ReportDir is where JSON/HTML reports are written.
	// When empty, no files are written.
	ReportDir string

	// LLMJudgeModel is used for LLM-as-judge scorers.
	// When nil, LLMJudge scorers fall back to string matching.
	LLMJudgeModel core.Model[*schema.Message]
}

// ========================================================================
// RunT — single-function entry point for go test
// ========================================================================

// RunT runs all eval cases and reports results via testing.T.
// It's the primary entry point for use in go test functions.
//
//go:generate echo "RunT is designed for use with go test"
func RunT(t testingT, cfg *EvalConfig) {
	if cfg == nil {
		t.Fatal("evals: EvalConfig is nil")
		return
	}

	report := Run(context.Background(), cfg)

	for _, c := range report.Cases {
		if !c.Passed {
			t.Errorf("[FAIL] %s (%v):", c.CaseName, c.Duration)
			for _, f := range c.Failures {
				t.Errorf("  %s: %s", f.ScorerName, f.Message)
			}
		} else {
			t.Logf("[PASS] %s (%v)", c.CaseName, c.Duration)
		}
	}

	t.Logf("Eval summary: %d/%d passed (%v)", report.Passed, report.Total, report.Duration)

	if cfg.ReportDir != "" {
		if err := writeReport(cfg.ReportDir, report); err != nil {
			t.Logf("evals: write report: %v", err)
		}
	}
}

// testingT is the minimal interface we need from testing.T.
type testingT interface {
	Fatal(args ...any)
	Errorf(format string, args ...any)
	Logf(format string, args ...any)
}

// ========================================================================
// Run — execute all eval cases
// ========================================================================

// Run executes all eval cases and returns a report.
func Run(ctx context.Context, cfg *EvalConfig) *EvalReport {
	start := time.Now()
	report := &EvalReport{}

	if cfg.MaxConcurrency <= 1 {
		// Sequential execution.
		for _, c := range cfg.Cases {
			cr := runCase(ctx, cfg, c)
			report.Cases = append(report.Cases, cr)
			if cr.Passed {
				report.Passed++
			} else {
				report.Failed++
			}
		}
	} else {
		// Parallel execution with bounded concurrency.
		var mu sync.Mutex
		var wg sync.WaitGroup
		sem := make(chan struct{}, cfg.MaxConcurrency)

		for _, c := range cfg.Cases {
			wg.Add(1)
			sem <- struct{}{}
			go func(case_ EvalCase) {
				defer wg.Done()
				defer func() { <-sem }()
				cr := runCase(ctx, cfg, case_)
				mu.Lock()
				report.Cases = append(report.Cases, cr)
				if cr.Passed {
					report.Passed++
				} else {
					report.Failed++
				}
				mu.Unlock()
			}(c)
		}
		wg.Wait()
	}

	report.Total = report.Passed + report.Failed
	report.Duration = time.Since(start)
	return report
}

func runCase(ctx context.Context, cfg *EvalConfig, c EvalCase) CaseReport {
	cr := CaseReport{CaseName: c.Name, Tags: c.Tags}
	start := time.Now()

	// Use case-specific agent, or shared agent, or create a default.
	agent := c.Agent
	if agent == nil {
		agent = cfg.Agent
	}
	if agent == nil {
		agent = core.NewReActAgent(&core.ReActConfig[*schema.Message]{
			Model: cfg.Model,
		}).WithName("eval_agent")
	}

	// Run the agent.
	runner := core.NewTypedRunner(core.RunnerConfig[*schema.Message]{Agent: agent})
	iter := runner.Run(ctx, []*schema.Message{schema.UserMessage(c.Query)})

	result := &EvalResult{
		Case:     c,
		Snapshot: takeSnapshot(c.WorkDir),
	}

	for {
		ev, ok := iter.Next()
		if !ok {
			break
		}
		result.Events = append(result.Events, ev)
		if ev.Err != nil {
			result.Err = ev.Err
			break
		}
		if ev.Output != nil && ev.Output.MessageOutput != nil && !ev.Output.MessageOutput.IsStreaming && ev.Output.MessageOutput.Message != nil {
			result.Messages = append(result.Messages, ev.Output.MessageOutput.Message)
		}
	}

	result.Duration = time.Since(start)
	cr.Duration = result.Duration

	// Run scorers.
	for _, scorer := range c.Scorers {
		if err := scorer(ctx, result); err != nil {
			cr.Failures = append(cr.Failures, Failure{
				ScorerName: scorerName(scorer),
				Message:    err.Error(),
			})
		}
	}

	cr.Passed = len(cr.Failures) == 0
	return cr
}

// ========================================================================
// Built-in Scorers
// ========================================================================

// FinalTextContains returns a Scorer that checks the agent's final output
// contains the given substring (case-insensitive).
func FinalTextContains(substr string) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		text := lastAssistantText(r)
		if text == "" {
			return fmt.Errorf("final text is empty, expected to contain %q", substr)
		}
		if !containsFold(text, substr) {
			return fmt.Errorf("final text does not contain %q:\n%s", substr, truncate(text, 500))
		}
		return nil
	}
}

// FinalTextExcludes returns a Scorer that checks the agent's final output
// does NOT contain the given substring.
func FinalTextExcludes(substr string) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		text := lastAssistantText(r)
		if containsFold(text, substr) {
			return fmt.Errorf("final text contains forbidden %q:\n%s", substr, truncate(text, 500))
		}
		return nil
	}
}

// AgentError returns a Scorer that passes if the agent completed without error.
func AgentError() Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		if r.Err != nil {
			return fmt.Errorf("agent error: %w", r.Err)
		}
		return nil
	}
}

// AgentErrorContains returns a Scorer that passes if the agent error contains
// the given substring.
func AgentErrorContains(substr string) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		if r.Err == nil {
			return fmt.Errorf("expected agent error containing %q, got none", substr)
		}
		if !containsFold(r.Err.Error(), substr) {
			return fmt.Errorf("agent error %q does not contain %q", r.Err.Error(), substr)
		}
		return nil
	}
}

// FileContentEquals returns a Scorer that checks a file's content matches exactly.
func FileContentEquals(path, expectedContent string) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		data, err := os.ReadFile(path)
		if err != nil {
			return fmt.Errorf("read %s: %w", path, err)
		}
		got := string(data)
		if got != expectedContent {
			return fmt.Errorf("file %s content mismatch:\nexpected:\n%s\n\ngot:\n%s", path, expectedContent, truncate(got, 500))
		}
		return nil
	}
}

// FileContains returns a Scorer that checks a file contains the substring.
func FileContains(path, substr string) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		data, err := os.ReadFile(path)
		if err != nil {
			return fmt.Errorf("read %s: %w", path, err)
		}
		if !containsFold(string(data), substr) {
			return fmt.Errorf("file %s does not contain %q", path, substr)
		}
		return nil
	}
}

// ToolCalled returns a Scorer that checks the agent called a specific tool.
func ToolCalled(toolName string) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		for _, msg := range r.Messages {
			for _, tc := range msg.ToolCalls {
				if tc.Function.Name == toolName {
					return nil
				}
			}
		}
		return fmt.Errorf("agent did not call tool %q", toolName)
	}
}

// Steps returns a Scorer that counts agent steps and passes if >= min.
func Steps(minSteps int) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		steps := countAssistantMessages(r.Messages)
		if steps < minSteps {
			return fmt.Errorf("expected at least %d agent steps, got %d", minSteps, steps)
		}
		return nil
	}
}

// LLMJudge returns a Scorer that uses a judge LLM to evaluate the agent's
// output against the given criteria.
//
// The judge model receives a structured prompt with the original query, the
// agent's final output, and the evaluation instruction. It must respond with
// PASS or FAIL.
//
// When judgeModel is nil, the scorer returns an error asking to configure one.
func LLMJudge(judgeModel core.Model[*schema.Message], instruction string) Scorer {
	return func(ctx context.Context, r *EvalResult) error {
		text := lastAssistantText(r)
		if text == "" {
			return fmt.Errorf("no assistant output to judge")
		}
		if judgeModel == nil {
			return fmt.Errorf("LLM judge model is nil. Set evals.LLMJudgeModel or pass a model.\nInstruction: %s", instruction)
		}
		return judgeOutput(ctx, judgeModel, r.Case.Query, text, instruction)
	}
}

// judgeOutput sends the agent output to a judge LLM and returns the verdict.
func judgeOutput(ctx context.Context, model core.Model[*schema.Message], query, output, instruction string) error {
	prompt := fmt.Sprintf(judgePromptTemplate, query, output, instruction)
	judgeMsgs := []*schema.Message{
		schema.UserMessage(prompt),
	}

	result, err := model.Generate(ctx, judgeMsgs)
	if err != nil {
		return fmt.Errorf("LLM judge error: %w", err)
	}
	if result == nil {
		return fmt.Errorf("LLM judge returned nil response")
	}

	response := strings.TrimSpace(result.Content)
	if strings.HasPrefix(response, "PASS") {
		return nil
	}
	return fmt.Errorf("LLM judge: %s", response)
}

const judgePromptTemplate = `You are evaluating an AI coding assistant's output.

### User Query
%s

### Assistant Output
%s

### Evaluation Criteria
%s

Determine if the assistant's output satisfies the criteria.

Reply with EXACTLY one line:
- If the criteria are met: PASS
- If the criteria are NOT met: FAIL: <reason>`

// ========================================================================
// Helpers
// ========================================================================

func lastAssistantText(r *EvalResult) string {
	for i := len(r.Messages) - 1; i >= 0; i-- {
		if r.Messages[i].Role == schema.RoleAssistant {
			return r.Messages[i].Content
		}
	}
	return ""
}

func countAssistantMessages(msgs []*schema.Message) int {
	count := 0
	for _, m := range msgs {
		if m.Role == schema.RoleAssistant {
			count++
		}
	}
	return count
}

func containsFold(s, substr string) bool {
	s, substr = strings.ToLower(s), strings.ToLower(substr)
	return strings.Contains(s, substr)
}

func truncate(s string, max int) string {
	if len(s) <= max {
		return s
	}
	return s[:max] + "..."
}

func scorerName(s Scorer) string {
	return fmt.Sprintf("%T", s)
}

func takeSnapshot(workDir string) map[string]string {
	if workDir == "" {
		return nil
	}
	snap := make(map[string]string)
	filepath.Walk(workDir, func(path string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return nil
		}
		data, _ := os.ReadFile(path)
		snap[path] = string(data)
		return nil
	})
	return snap
}

func writeReport(dir string, report *EvalReport) error {
	os.MkdirAll(dir, 0755)

	// Write summary JSON.
	summary := fmt.Sprintf(`{"total":%d,"passed":%d,"failed":%d,"duration":"%s"}`,
		report.Total, report.Passed, report.Failed, report.Duration)
	if err := os.WriteFile(filepath.Join(dir, "summary.json"), []byte(summary), 0644); err != nil {
		return err
	}

	// Write per-case results.
	var buf strings.Builder
	buf.WriteString("Case,Duration,Passed,Failures\n")
	for _, c := range report.Cases {
		failures := ""
		if len(c.Failures) > 0 {
			var msgs []string
			for _, f := range c.Failures {
				msgs = append(msgs, f.ScorerName+": "+f.Message)
			}
			failures = strings.Join(msgs, "; ")
		}
		buf.WriteString(fmt.Sprintf("%s,%v,%v,%s\n", c.CaseName, c.Duration, c.Passed, failures))
	}
	return os.WriteFile(filepath.Join(dir, "results.csv"), []byte(buf.String()), 0644)
}
