package engine

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
	"unicode/utf8"

	"github.com/luoyuctl/agenttrace/internal/i18n"
)

func mustJSON(v interface{}) string {
	b, _ := json.Marshal(v)
	return string(b)
}

func makeJSONL(lines []interface{}) string {
	var parts []string
	for _, l := range lines {
		parts = append(parts, mustJSON(l))
	}
	return strings.Join(parts, "\n") + "\n"
}

func writeLoadableHermesSession(t *testing.T, dir, name, start string) string {
	t.Helper()
	startTime, err := time.Parse(time.RFC3339, start)
	if err != nil {
		t.Fatal(err)
	}
	path := filepath.Join(dir, name+".jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{
			"role":      "user",
			"content":   "please inspect the failing test",
			"timestamp": startTime.Format(time.RFC3339),
			"ModelUsed": "gpt-4.1",
		},
		map[string]interface{}{
			"role":      "assistant",
			"content":   "I will inspect it.",
			"reasoning": strings.Repeat("reasoning ", 80),
			"timestamp": startTime.Add(time.Second).Format(time.RFC3339),
			"ModelUsed": "gpt-4.1",
			"tool_calls": []interface{}{
				map[string]interface{}{"id": "tc1", "name": "read_file", "args": "go.mod"},
			},
		},
		map[string]interface{}{
			"role":         "tool",
			"content":      `{"success":true}`,
			"tool_call_id": "tc1",
			"timestamp":    startTime.Add(2 * time.Second).Format(time.RFC3339),
		},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}
	return path
}

func TestParseHermesJSONL(t *testing.T) {
	raw := makeJSONL([]interface{}{
		map[string]interface{}{"role": "session_meta", "session_id": "s1"},
		map[string]interface{}{"role": "user", "content": "hello", "timestamp": "2026-01-01T00:00:00Z"},
		map[string]interface{}{"role": "assistant", "content": "hi", "timestamp": "2026-01-01T00:00:01Z", "tool_calls": []interface{}{map[string]interface{}{"id": "tc1", "name": "read_file"}}},
		map[string]interface{}{"role": "tool", "content": "file content", "tool_call_id": "tc1", "timestamp": "2026-01-01T00:00:02Z"},
	})
	events, err := parseHermesJSONL(raw)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(events) != 4 {
		t.Fatalf("expected 4 events, got %d", len(events))
	}
	if events[2].ToolCalls[0].Name != "read_file" {
		t.Errorf("tool call name: got %q", events[2].ToolCalls[0].Name)
	}
	if events[3].ToolCallID != "tc1" {
		t.Errorf("ToolCallID: got %q", events[3].ToolCallID)
	}
}

func TestParseHermesJSONL_Empty(t *testing.T) {
	events, err := parseHermesJSONL("")
	if err != nil {
		t.Fatalf("empty input should not error: %v", err)
	}
	if len(events) != 0 {
		t.Errorf("expected 0 events from empty input, got %d", len(events))
	}
}

func TestParseHermesJSON(t *testing.T) {
	doc := map[string]interface{}{
		"model": "claude-sonnet-4",
		"usage": map[string]interface{}{"input_tokens": 100, "output_tokens": 200},
		"messages": []interface{}{
			map[string]interface{}{"role": "user", "content": "hello", "timestamp": "2026-01-01T00:00:00Z"},
			map[string]interface{}{"role": "assistant", "content": "", "timestamp": "2026-01-01T00:00:01Z", "tool_calls": []interface{}{map[string]interface{}{"id": "tc1", "function": map[string]interface{}{"name": "read_file"}}}},
			map[string]interface{}{"role": "tool", "content": "ok", "tool_call_id": "tc1", "timestamp": "2026-01-01T00:00:02Z", "is_error": false},
		},
	}
	events, err := parseHermesJSON(doc)
	if err != nil {
		t.Fatalf("error: %v", err)
	}
	// Bug1: no duplicate tool events
	toolCount := 0
	for _, ev := range events {
		if ev.Role == "tool" {
			toolCount++
		}
	}
	if toolCount != 1 {
		t.Errorf("Bug1: tool dupe, got %d tool events", toolCount)
	}
	// Bug2: usage meta
	hasMeta := false
	for _, ev := range events {
		if ev.Role == "meta" && ev.Usage != nil {
			hasMeta = true
			if ev.Usage["input_tokens"] != 100 {
				t.Errorf("Bug2: wrong input tokens: %v", ev.Usage)
			}
		}
	}
	if !hasMeta {
		t.Error("Bug2: missing meta")
	}
	// Bug3: timestamps
	for _, ev := range events {
		if ev.Role != "meta" && ev.Timestamp == "" {
			t.Errorf("Bug3: role=%q missing ts", ev.Role)
		}
	}
}

func TestLoadSessionBuildsMetricsFromFile(t *testing.T) {
	dir := t.TempDir()
	path := writeLoadableHermesSession(t, dir, "load-session", "2026-01-02T10:00:00Z")

	session, err := LoadSession(path)
	if err != nil {
		t.Fatal(err)
	}
	if session.Name != "load-session" || session.Path != path {
		t.Fatalf("bad session identity: %+v", session)
	}
	m := session.Metrics
	if m.SourceTool != "hermes_jsonl" || m.ModelUsed != "gpt-4.1" {
		t.Fatalf("bad source/model metrics: %+v", m)
	}
	if m.UserMessages != 1 || m.AssistantTurns != 1 || m.ToolCallsTotal != 1 || m.ToolCallsOK != 1 {
		t.Fatalf("bad interaction metrics: %+v", m)
	}
	if m.SessionStart != "2026-01-02T10:00:00Z" || m.SessionEnd != "2026-01-02T10:00:02Z" || m.DurationSec != 2 {
		t.Fatalf("bad session timing: %+v", m)
	}
	if session.Health <= 0 || session.Health > 100 {
		t.Fatalf("bad health score: %d", session.Health)
	}
}

func TestLoadAllSkipsBadFilesAndSortsBySessionStart(t *testing.T) {
	dir := t.TempDir()
	writeLoadableHermesSession(t, dir, "old", "2026-01-02T10:00:00Z")
	writeLoadableHermesSession(t, dir, "new", "2026-01-02T11:00:00Z")
	if err := os.WriteFile(filepath.Join(dir, "bad.jsonl"), []byte("not json"), 0644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	subdir := filepath.Join(dir, "nested")
	if err := os.MkdirAll(subdir, 0755); err != nil {
		t.Fatal(err)
	}
	writeLoadableHermesSession(t, subdir, "nested", "2026-01-02T12:00:00Z")

	sessions := LoadAll(dir)
	if len(sessions) != 2 {
		t.Fatalf("expected two loadable top-level sessions, got %d: %+v", len(sessions), sessions)
	}
	if sessions[0].Name != "new" || sessions[1].Name != "old" {
		t.Fatalf("sessions should be sorted newest first, got %+v", sessions)
	}
}

func TestFindSessionFilesCustomDirIncludesNestedSessionRoots(t *testing.T) {
	dir := t.TempDir()
	nested := filepath.Join(dir, "2026", "05", "04")
	if err := os.MkdirAll(nested, 0755); err != nil {
		t.Fatal(err)
	}
	top := writeLoadableHermesSession(t, dir, "top", "2026-01-02T10:00:00Z")
	deep := writeLoadableHermesSession(t, nested, "deep", "2026-01-02T11:00:00Z")

	files := FindSessionFiles(dir)
	if !containsPath(files, top) || !containsPath(files, deep) {
		t.Fatalf("expected explicit dir discovery to include top and nested sessions, got %v", files)
	}
	if files[0] != deep {
		t.Fatalf("expected nested newer session first, got %v", files)
	}
}

func TestParseClaudeCode_ToolResultArray(t *testing.T) {
	doc := map[string]interface{}{
		"model": "claude",
		"messages": []interface{}{
			map[string]interface{}{"role": "user", "content": []interface{}{map[string]interface{}{"type": "text", "text": "do"}}},
			map[string]interface{}{"role": "assistant", "content": []interface{}{map[string]interface{}{"type": "tool_use", "id": "tu1", "name": "bash"}}},
			map[string]interface{}{"role": "user", "content": []interface{}{map[string]interface{}{"type": "tool_result", "tool_use_id": "tu1", "is_error": false, "content": []interface{}{map[string]interface{}{"type": "text", "text": "success"}}}}},
		},
	}
	events, _ := parseClaudeCode(doc, nil)
	found := false
	for _, ev := range events {
		if ev.Role == "tool" && ev.ToolCallID == "tu1" && ev.Content == "success" {
			found = true
		}
	}
	if !found {
		t.Error("Bug6: tool_result array content not extracted")
	}
}

func TestParseClaudeCodeJSONLWithPreamble(t *testing.T) {
	path := filepath.Join("..", "..", "testdata", "claude-code-preamble.jsonl")
	if got := DetectFormat(path).Format; got != "claude_code_jsonl" {
		t.Fatalf("claude preamble format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "claude-sonnet-4")
	if m.SourceTool != "claude_code" || m.UserMessages != 1 || m.ToolCallsTotal != 1 || m.ToolCallsOK != 1 {
		t.Fatalf("bad claude preamble metrics: %+v", m)
	}
	if m.TokensInput < 120 || m.TokensOutput != 40 || m.TokensCacheR != 10 || m.TokensCacheW != 5 {
		t.Fatalf("bad claude preamble usage: %+v", m)
	}
	if m.ReasoningBlocks != 1 || m.ToolUsage["read_file"] != 1 {
		t.Fatalf("bad claude preamble reasoning/tool usage: %+v", m)
	}
}

func TestCollectSessionFilesIncludesClaudeSubagents(t *testing.T) {
	root := filepath.Join(t.TempDir(), ".claude", "projects")
	subagentDir := filepath.Join(root, "project-alpha", "session-abc", "subagents")
	if err := os.MkdirAll(subagentDir, 0755); err != nil {
		t.Fatal(err)
	}
	subagentPath := filepath.Join(subagentDir, "agent-a1.jsonl")
	if err := os.WriteFile(subagentPath, []byte(`{"type":"assistant","message":{"role":"assistant","model":"claude-haiku-4-5","usage":{"input_tokens":1}}}`), 0644); err != nil {
		t.Fatal(err)
	}
	metaPath := filepath.Join(subagentDir, "agent-a1.meta.json")
	if err := os.WriteFile(metaPath, []byte(`{"agentId":"a1"}`), 0644); err != nil {
		t.Fatal(err)
	}
	files := collectSessionFiles(root)
	if !containsPath(files, subagentPath) {
		t.Fatalf("expected claude subagent transcript to be discovered")
	}
	if containsPath(files, metaPath) {
		t.Fatalf("expected claude subagent metadata to be skipped")
	}
}

func TestParseClaudeCodeJSONLDeduplicatesAssistantUsage(t *testing.T) {
	raw := strings.Join([]string{
		`{"type":"assistant","timestamp":"2026-05-03T10:00:00Z","message":{"id":"msg_1","role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":100,"output_tokens":10},"content":[{"type":"text","text":"hello"}]}}`,
		`{"type":"assistant","timestamp":"2026-05-03T10:00:01Z","message":{"id":"msg_1","role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":100,"output_tokens":10},"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{}}]}}`,
	}, "\n")
	events, err := parseClaudeCodeJSONL(raw)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "claude-sonnet-4-6")
	if m.TokensInput != 100 || m.TokensOutput != 10 {
		t.Fatalf("expected duplicate assistant usage to count once: %+v", m)
	}
}

func TestParseClaudeCodeJSONLParallelToolBatchIsSingleTurn(t *testing.T) {
	raw := strings.Join([]string{
		`{"type":"assistant","timestamp":"2026-05-03T10:00:00Z","message":{"id":"msg_parallel","role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":200,"output_tokens":30,"cache_read_input_tokens":70,"cache_creation_input_tokens":8},"content":[{"type":"thinking","thinking":"Need to inspect and test in parallel."},{"type":"text","text":"I'll inspect both files."},{"type":"tool_use","id":"tool_read","name":"Read","input":{"file_path":"README.md"}},{"type":"tool_use","id":"tool_test","name":"Bash","input":{"command":"go test ./..."}}]}}`,
		`{"type":"user","timestamp":"2026-05-03T10:00:01Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_read","content":"readme text","is_error":false}]}}`,
		`{"type":"user","timestamp":"2026-05-03T10:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_test","content":"test failed","is_error":true}]}}`,
	}, "\n")
	events, err := parseClaudeCodeJSONL(raw)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "claude-sonnet-4-6")
	if m.AssistantTurns != 1 || m.ToolCallsTotal != 2 || m.ToolCallsOK != 1 || m.ToolCallsFail != 1 {
		t.Fatalf("expected one assistant turn with two tool calls and one failure: %+v", m)
	}
	if m.TokensInput != 200 || m.TokensOutput != 30 || m.TokensCacheR != 70 || m.TokensCacheW != 8 {
		t.Fatalf("bad cache/token attribution for parallel tool batch: %+v", m)
	}
	if m.ReasoningBlocks != 1 || m.ToolUsage["Read"] != 1 || m.ToolUsage["Bash"] != 1 {
		t.Fatalf("bad reasoning/tool usage for parallel tool batch: %+v", m)
	}
}

func TestParseClaudeCodeJSONLWithPreambleMalformed(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "broken-claude.jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{"type": "file-history-snapshot", "messageId": "snapshot-1", "snapshot": map[string]interface{}{}},
		map[string]interface{}{"type": "system", "sessionId": "session-1", "timestamp": "2026-05-03T10:00:00Z"},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}
	if got := DetectFormat(path).Format; got != "claude_code_jsonl" {
		t.Fatalf("expected malformed claude JSONL to be detected, got %s", got)
	}
	if _, err := Parse(path); err == nil {
		t.Fatalf("expected malformed claude JSONL to fail")
	}
}

func TestParseGeneric_Invalid(t *testing.T) {
	_, err := parseGeneric("not json at all")
	if err == nil {
		t.Error("Bug7: expected error for invalid input")
	}
}

func TestDetectFormat_GeminiAndOpenClaw(t *testing.T) {
	dir := t.TempDir()
	geminiPath := filepath.Join(dir, "gemini.json")
	geminiDoc := map[string]interface{}{
		"modelVersion": "gemini-2.5-pro",
		"contents": []interface{}{
			map[string]interface{}{"role": "user", "parts": []interface{}{map[string]interface{}{"text": "hi"}}},
		},
	}
	if err := os.WriteFile(geminiPath, []byte(mustJSON(geminiDoc)), 0644); err != nil {
		t.Fatal(err)
	}
	if got := DetectFormat(geminiPath).Format; got != "gemini_cli" {
		t.Fatalf("gemini format: %s", got)
	}

	openClawPath := filepath.Join(dir, "openclaw.json")
	openClawDoc := map[string]interface{}{
		"provider": "openclaw",
		"model":    "claude-sonnet-4",
		"messages": []interface{}{
			map[string]interface{}{"role": "assistant", "content": "ok"},
		},
	}
	if err := os.WriteFile(openClawPath, []byte(mustJSON(openClawDoc)), 0644); err != nil {
		t.Fatal(err)
	}
	if got := DetectFormat(openClawPath).Format; got != "openclaw" {
		t.Fatalf("openclaw format: %s", got)
	}
}

func TestParseAiderChatHistory(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, ".aider.chat.history.md")
	raw := `
# aider chat started at 2026-05-02 10:00:00

> aider --model gpt-4.1

#### Add a CI health gate

Implemented the gate with focused tests.

> Tokens: 1.2k sent, 300 cache write, 400 cache hit, 345 received. Cost: $0.004 message, $0.004 session.

#### Run tests

All tests pass.
`
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}
	if got := DetectFormat(path).Format; got != "aider_chat_history" {
		t.Fatalf("aider format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "gpt-4.1")
	if m.SourceTool != "aider" || m.UserMessages != 2 || m.AssistantTurns != 2 {
		t.Fatalf("bad aider metrics: %+v", m)
	}
	if m.ModelUsed != "gpt-4.1" || m.TokensInput != 1200 || m.TokensCacheW != 300 || m.TokensCacheR != 400 || m.TokensOutput != 345 {
		t.Fatalf("bad aider usage/model: %+v", m)
	}
}

func TestParseOhMyPiSessionJSONL(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "session.jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{
			"type":      "session",
			"version":   3,
			"id":        "1f9d2a6b9c0d1234",
			"timestamp": "2026-02-16T10:20:30.000Z",
			"cwd":       "/work/pi",
		},
		map[string]interface{}{
			"type":      "message",
			"id":        "u1",
			"parentId":  nil,
			"timestamp": "2026-02-16T10:21:00.000Z",
			"message": map[string]interface{}{
				"role":      "user",
				"content":   []interface{}{map[string]interface{}{"type": "text", "text": "Inspect the failing test"}},
				"timestamp": float64(1771237260000),
			},
		},
		map[string]interface{}{
			"type":      "message",
			"id":        "a1",
			"parentId":  "u1",
			"timestamp": "2026-02-16T10:21:10.000Z",
			"message": map[string]interface{}{
				"role":     "assistant",
				"provider": "anthropic",
				"model":    "claude-sonnet-4-5",
				"content": []interface{}{
					map[string]interface{}{"type": "thinking", "thinking": "Need to inspect logs first."},
					map[string]interface{}{"type": "text", "text": "I will inspect the failure."},
					map[string]interface{}{"type": "toolCall", "id": "tc1", "name": "read", "arguments": map[string]interface{}{"path": "go.mod"}},
				},
				"usage": map[string]interface{}{
					"input":      100,
					"output":     20,
					"cacheRead":  7,
					"cacheWrite": 3,
				},
				"timestamp": float64(1771237270000),
			},
		},
		map[string]interface{}{
			"type":      "message",
			"id":        "t1",
			"parentId":  "a1",
			"timestamp": "2026-02-16T10:21:12.000Z",
			"message": map[string]interface{}{
				"role":       "toolResult",
				"toolCallId": "tc1",
				"toolName":   "read",
				"content":    []interface{}{map[string]interface{}{"type": "text", "text": "module github.com/luoyuctl/agenttrace"}},
				"isError":    false,
				"timestamp":  float64(1771237272000),
			},
		},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}

	if got := DetectFormat(path).Format; got != "oh_my_pi" {
		t.Fatalf("oh-my-pi format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "claude-sonnet-4-5")
	if m.SourceTool != "oh_my_pi" || m.UserMessages != 1 || m.AssistantTurns != 1 || m.ToolCallsTotal != 1 || m.ToolCallsOK != 1 {
		t.Fatalf("bad oh-my-pi metrics: %+v", m)
	}
	if m.ModelUsed != "claude-sonnet-4-5" || m.TokensInput != 100 || m.TokensOutput != 20 || m.TokensCacheR != 7 || m.TokensCacheW != 3 {
		t.Fatalf("bad oh-my-pi model/usage: %+v", m)
	}
	if m.ReasoningBlocks != 1 || m.ToolUsage["read"] != 1 {
		t.Fatalf("bad oh-my-pi reasoning/tool usage: %+v", m)
	}
}

func TestParsePiSessionJSONLUsesPiSourceFromPath(t *testing.T) {
	home := t.TempDir()
	sessionDir := filepath.Join(home, ".pi", "agent", "sessions")
	if err := os.MkdirAll(sessionDir, 0755); err != nil {
		t.Fatal(err)
	}
	path := filepath.Join(sessionDir, "session.jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{"type": "session", "version": 3, "id": "pi-session", "cwd": "/work/pi"},
		map[string]interface{}{
			"type": "message",
			"message": map[string]interface{}{
				"role":    "user",
				"content": "hello from pi",
			},
		},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}

	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	if m := Analyze(events, "mimo-v2.5-pro"); m.SourceTool != "pi" {
		t.Fatalf("expected PI source for ~/.pi session, got %+v", m)
	}
}

func TestParseOhMyPiSessionJSONL_InvalidHeader(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "broken.jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{"type": "session", "version": 3, "cwd": "/work/pi"},
		map[string]interface{}{"type": "message", "message": map[string]interface{}{"role": "user", "content": "hello"}},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}

	if got := DetectFormat(path).Format; got != "oh_my_pi" {
		t.Fatalf("expected malformed oh-my-pi session to be detected, got %s", got)
	}
	if _, err := Parse(path); err == nil {
		t.Fatalf("expected invalid oh-my-pi header to fail")
	}
}

func TestFindSessionFilesIncludesPiSessionDir(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)

	sessionDir := filepath.Join(home, ".pi", "agent", "sessions")
	if err := os.MkdirAll(sessionDir, 0755); err != nil {
		t.Fatal(err)
	}
	path := filepath.Join(sessionDir, "session.jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{"type": "session", "version": 3, "id": "pi-session", "cwd": "/work/pi"},
		map[string]interface{}{
			"type": "message",
			"message": map[string]interface{}{
				"role":    "user",
				"content": "hello from pi",
			},
		},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}

	if got := DetectFormat(path).Format; got != "oh_my_pi" {
		t.Fatalf("pi session format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	if m := Analyze(events, "mimo-v2.5-pro"); m.SourceTool != "pi" {
		t.Fatalf("expected PI source for auto-discovered PI session, got %+v", m)
	}
	files := FindSessionFiles("")
	if !containsPath(files, path) {
		t.Fatalf("expected PI session file, got %v", files)
	}
}

func TestParseQwenCodeStreamJSONL(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "qwen-stream.jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{
			"type":       "system",
			"subtype":    "session_start",
			"uuid":       "sys-1",
			"session_id": "session-1",
			"model":      "qwen3-coder-plus",
			"timestamp":  "2026-05-03T10:00:00Z",
		},
		map[string]interface{}{
			"type":       "assistant",
			"uuid":       "assistant-1",
			"session_id": "session-1",
			"timestamp":  "2026-05-03T10:00:02Z",
			"message": map[string]interface{}{
				"id":    "msg-1",
				"type":  "message",
				"role":  "assistant",
				"model": "qwen3-coder-plus",
				"content": []interface{}{
					map[string]interface{}{"type": "reasoning", "text": "Need to inspect package files."},
					map[string]interface{}{"type": "text", "text": "I'll inspect the package."},
					map[string]interface{}{"type": "tool_use", "id": "tool-1", "name": "read_file", "input": map[string]interface{}{"path": "package.json"}},
				},
				"usage": map[string]interface{}{
					"input_tokens":                120,
					"output_tokens":               45,
					"cache_read_input_tokens":     10,
					"cache_creation_input_tokens": 5,
				},
			},
		},
		map[string]interface{}{
			"type":       "user",
			"uuid":       "user-1",
			"session_id": "session-1",
			"timestamp":  "2026-05-03T10:00:03Z",
			"message": map[string]interface{}{
				"role":    "user",
				"content": "Please continue after inspecting it.",
			},
		},
		map[string]interface{}{
			"type":       "user",
			"uuid":       "tool-result-1",
			"session_id": "session-1",
			"timestamp":  "2026-05-03T10:00:04Z",
			"message": map[string]interface{}{
				"role": "user",
				"content": []interface{}{
					map[string]interface{}{"type": "tool_result", "tool_use_id": "tool-1", "content": "package metadata", "is_error": false},
				},
			},
		},
		map[string]interface{}{
			"type":        "result",
			"subtype":     "success",
			"uuid":        "result-1",
			"session_id":  "session-1",
			"is_error":    false,
			"duration_ms": 1234,
			"result":      "I'll inspect the package.",
			"usage": map[string]interface{}{
				"input_tokens":  120,
				"output_tokens": 45,
			},
		},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}

	if got := DetectFormat(path).Format; got != "qwen_code" {
		t.Fatalf("qwen format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "qwen3-coder-plus")
	if m.SourceTool != "qwen_code" || m.UserMessages != 1 || m.AssistantTurns != 1 || m.ToolCallsTotal != 1 || m.ToolCallsOK != 1 {
		t.Fatalf("bad qwen metrics: %+v", m)
	}
	if m.ModelUsed != "qwen3-coder-plus" || m.TokensInput != 120 || m.TokensOutput != 45 || m.TokensCacheR != 10 || m.TokensCacheW != 5 {
		t.Fatalf("bad qwen usage/model: %+v", m)
	}
	if m.ReasoningBlocks != 1 || m.ToolUsage["read_file"] != 1 {
		t.Fatalf("bad qwen reasoning/tool usage: %+v", m)
	}
}

func TestParseQwenCodeJSONOutputArray(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "qwen-output.json")
	raw := []interface{}{
		map[string]interface{}{
			"type":       "system",
			"subtype":    "session_start",
			"uuid":       "sys-1",
			"session_id": "session-1",
			"model":      "qwen3-coder-plus",
		},
		map[string]interface{}{
			"type":       "result",
			"subtype":    "success",
			"uuid":       "result-1",
			"session_id": "session-1",
			"is_error":   false,
			"result":     "The capital of France is Paris.",
			"stats": map[string]interface{}{
				"models": map[string]interface{}{
					"qwen3-coder-plus": map[string]interface{}{
						"tokens": map[string]interface{}{"input": 20, "output": 7},
					},
				},
			},
		},
	}
	if err := os.WriteFile(path, []byte(mustJSON(raw)), 0644); err != nil {
		t.Fatal(err)
	}

	if got := DetectFormat(path).Format; got != "qwen_code" {
		t.Fatalf("qwen array format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "qwen3-coder-plus")
	if m.SourceTool != "qwen_code" || m.AssistantTurns != 1 || m.TokensInput != 20 || m.TokensOutput != 7 {
		t.Fatalf("bad qwen json metrics: %+v", m)
	}
}

func TestParseQwenCodeJSONObjectOutput(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "qwen-result.json")
	raw := map[string]interface{}{
		"response": "Done.",
		"stats": map[string]interface{}{
			"models": map[string]interface{}{
				"qwen3-coder-plus": map[string]interface{}{
					"tokens": map[string]interface{}{"input": 31, "output": 9, "cacheRead": 4},
				},
			},
		},
	}
	if err := os.WriteFile(path, []byte(mustJSON(raw)), 0644); err != nil {
		t.Fatal(err)
	}

	if got := DetectFormat(path).Format; got != "qwen_code" {
		t.Fatalf("qwen object format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "qwen3-coder-plus")
	if m.SourceTool != "qwen_code" || m.AssistantTurns != 1 || m.TokensInput != 31 || m.TokensOutput != 9 || m.TokensCacheR != 4 {
		t.Fatalf("bad qwen object metrics: %+v", m)
	}
}

func TestParseQwenCodeStreamJSONL_NoMessages(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "empty-qwen.jsonl")
	raw := makeJSONL([]interface{}{
		map[string]interface{}{
			"type":       "system",
			"subtype":    "session_start",
			"uuid":       "sys-1",
			"session_id": "session-1",
			"model":      "qwen3-coder-plus",
		},
	})
	if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
		t.Fatal(err)
	}

	if got := DetectFormat(path).Format; got != "qwen_code" {
		t.Fatalf("qwen empty format: %s", got)
	}
	if _, err := Parse(path); err == nil {
		t.Fatalf("expected qwen stream without assistant/result content to fail")
	}
}

func TestFindSessionFilesIncludesQwenProjectChats(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
	chatDir := filepath.Join(home, ".qwen", "projects", "repo", "chats")
	if err := os.MkdirAll(chatDir, 0755); err != nil {
		t.Fatal(err)
	}
	path := filepath.Join(chatDir, "chat.jsonl")
	if err := os.WriteFile(path, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}

	files := FindSessionFiles("")
	if len(files) != 1 || files[0] != path {
		t.Fatalf("expected qwen chat file, got %v", files)
	}
	cache := SessionCache{Entries: map[string]CacheEntry{}, Dirs: map[string]DirCacheEntry{}}
	files = FindSessionFilesCached("", cache)
	if len(files) != 1 || files[0] != path {
		t.Fatalf("expected cached qwen chat file, got %v", files)
	}
}

func TestFindSessionFilesIncludesAiderHistory(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, ".aider.chat.history.md")
	if err := os.WriteFile(path, []byte("# aider chat started at 2026-05-02 10:00:00\n\n#### hi\n\nhello\n"), 0644); err != nil {
		t.Fatal(err)
	}
	files := FindSessionFiles(dir)
	if len(files) != 1 || files[0] != path {
		t.Fatalf("expected aider history file, got %v", files)
	}
	cache := SessionCache{Entries: map[string]CacheEntry{}, Dirs: map[string]DirCacheEntry{}}
	files = FindSessionFilesCached(dir, cache)
	if len(files) != 1 || files[0] != path {
		t.Fatalf("expected cached aider history file, got %v", files)
	}
}

func TestParseCursorSQLiteJSONExport(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "cursor-export.json")
	raw := map[string]interface{}{
		"aiService.prompts": []interface{}{
			map[string]interface{}{"text": "Refactor the auth flow", "commandType": 1},
		},
		"aiService.generations": []interface{}{
			map[string]interface{}{
				"unixMs":          float64(1710000000000),
				"generationUUID":  "gen-1",
				"type":            "composer",
				"textDescription": "Updated the login component and tests",
			},
		},
		"composer.composerData": map[string]interface{}{
			"allComposers": []interface{}{
				map[string]interface{}{
					"composerId":    "cmp-1",
					"name":          "Auth cleanup",
					"subtitle":      "2 files changed",
					"lastUpdatedAt": float64(1710000060000),
				},
			},
		},
	}
	if err := os.WriteFile(path, []byte(mustJSON(raw)), 0644); err != nil {
		t.Fatal(err)
	}
	if got := DetectFormat(path).Format; got != "cursor" {
		t.Fatalf("cursor format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	m := Analyze(events, "default")
	if m.SourceTool != "cursor" || m.UserMessages != 1 || m.AssistantTurns != 2 {
		t.Fatalf("bad cursor metrics: %+v", m)
	}
	if m.SessionStart == "" || m.SessionEnd == "" {
		t.Fatalf("expected cursor timestamps, got %+v", m)
	}
}

func TestParseCursorGenerationArray(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "cursor-generations.json")
	raw := []interface{}{
		map[string]interface{}{
			"unixMs":          float64(1710000000000),
			"generationUUID":  "gen-1",
			"type":            "chat",
			"textDescription": "Explained the failing test",
		},
	}
	if err := os.WriteFile(path, []byte(mustJSON(raw)), 0644); err != nil {
		t.Fatal(err)
	}
	if got := DetectFormat(path).Format; got != "cursor" {
		t.Fatalf("cursor array format: %s", got)
	}
	events, err := Parse(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(events) != 1 || events[0].SourceTool != "cursor" || events[0].Role != "assistant" {
		t.Fatalf("bad cursor array events: %+v", events)
	}
}

func TestDetectLargeParams_UsesToolArguments(t *testing.T) {
	arg := strings.Repeat("x", 10001)
	events := []Event{{
		Role:      "assistant",
		ToolCalls: []ToolCall{{Name: "write_file", Args: arg}},
	}}
	large := DetectLargeParams(events)
	if len(large) != 1 {
		t.Fatalf("expected one large arg call, got %d", len(large))
	}
	if large[0].ToolName != "write_file" || large[0].ParamSize != len(arg) {
		t.Fatalf("bad large param result: %+v", large[0])
	}
}

func TestFindSessionFilesAutoDiscoverySortsByModTime(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
	oldDir := filepath.Join(home, ".codex", "sessions")
	newDir := filepath.Join(home, ".gemini", "sessions")
	if err := os.MkdirAll(oldDir, 0755); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(newDir, 0755); err != nil {
		t.Fatal(err)
	}
	oldPath := filepath.Join(oldDir, "z-old.jsonl")
	newPath := filepath.Join(newDir, "a-new.jsonl")
	if err := os.WriteFile(oldPath, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(newPath, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	oldTime := time.Now().Add(-time.Hour)
	newTime := time.Now()
	if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(newPath, newTime, newTime); err != nil {
		t.Fatal(err)
	}
	files := FindSessionFiles("")
	if len(files) != 2 {
		t.Fatalf("files: %v", files)
	}
	if files[0] != newPath {
		t.Fatalf("expected newest first, got %v", files)
	}
}

func TestFindSessionFilesCachedUsesDirIndexAndFindsNewFiles(t *testing.T) {
	dir := t.TempDir()
	first := filepath.Join(dir, "first.jsonl")
	if err := os.WriteFile(first, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	firstTime := time.Now().Add(-time.Hour)
	if err := os.Chtimes(first, firstTime, firstTime); err != nil {
		t.Fatal(err)
	}

	cache := SessionCache{
		Entries: map[string]CacheEntry{},
		Dirs:    map[string]DirCacheEntry{},
	}
	files := FindSessionFilesCached(dir, cache)
	if len(files) != 1 || files[0] != first {
		t.Fatalf("first scan files: %v", files)
	}
	if _, ok := cache.Dirs[dir]; !ok {
		t.Fatalf("expected directory listing cached")
	}

	second := filepath.Join(dir, "second.jsonl")
	if err := os.WriteFile(second, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	secondTime := time.Now()
	if err := os.Chtimes(second, secondTime, secondTime); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(dir, secondTime, secondTime); err != nil {
		t.Fatal(err)
	}

	files = FindSessionFilesCached(dir, cache)
	if len(files) != 2 {
		t.Fatalf("second scan files: %v", files)
	}
	if files[0] != second {
		t.Fatalf("expected newest file first, got %v", files)
	}
}

func TestFindSessionFilesCachedSkipsDeletedCachedFiles(t *testing.T) {
	dir := t.TempDir()
	stale := filepath.Join(dir, "stale.jsonl")
	if err := os.WriteFile(stale, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	fileInfo, err := os.Stat(stale)
	if err != nil {
		t.Fatal(err)
	}
	dirInfo, err := os.Stat(dir)
	if err != nil {
		t.Fatal(err)
	}

	cache := SessionCache{
		Entries: map[string]CacheEntry{
			stale: {
				ModTime: fileInfo.ModTime().UnixNano(),
				Size:    fileInfo.Size(),
				Session: Session{Name: "stale", Path: stale},
			},
		},
		Dirs: map[string]DirCacheEntry{
			dir: {
				ModTime: dirInfo.ModTime().UnixNano(),
				Files:   []string{stale},
			},
		},
	}
	if err := os.Remove(stale); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(dir, dirInfo.ModTime(), dirInfo.ModTime()); err != nil {
		t.Fatal(err)
	}

	files := FindSessionFilesCached(dir, cache)
	if len(files) != 0 {
		t.Fatalf("deleted cached files should be skipped, got %v", files)
	}
}

func TestValidCachedSessionCountUsesCurrentFileMetadata(t *testing.T) {
	dir := t.TempDir()
	fresh := filepath.Join(dir, "fresh.jsonl")
	stale := filepath.Join(dir, "stale.jsonl")
	if err := os.WriteFile(fresh, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(stale, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	freshInfo, err := os.Stat(fresh)
	if err != nil {
		t.Fatal(err)
	}
	staleInfo, err := os.Stat(stale)
	if err != nil {
		t.Fatal(err)
	}
	cache := SessionCache{Entries: map[string]CacheEntry{
		fresh: {ModTime: freshInfo.ModTime().UnixNano(), Size: freshInfo.Size(), Session: Session{Name: "fresh"}},
		stale: {ModTime: staleInfo.ModTime().Add(-time.Hour).UnixNano(), Size: staleInfo.Size(), Session: Session{Name: "stale"}},
	}}

	if got := ValidCachedSessionCount([]string{fresh, stale}, cache); got != 1 {
		t.Fatalf("expected one valid cached session, got %d", got)
	}
	if _, ok := cache.Entries[stale]; ok {
		t.Fatalf("stale cache entry should be evicted")
	}
}

func TestFindReportableSessionFilesCachedSkipsSQLiteBackedDirs(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
	sessionsDir := filepath.Join(home, ".hermes", "sessions")
	if err := os.MkdirAll(sessionsDir, 0755); err != nil {
		t.Fatal(err)
	}
	sessionPath := filepath.Join(sessionsDir, "legacy.jsonl")
	if err := os.WriteFile(sessionPath, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(home, ".hermes", "state.db"), []byte("sqlite"), 0644); err != nil {
		t.Fatal(err)
	}

	rawFiles := FindSessionFilesCached("", SessionCache{Entries: map[string]CacheEntry{}, Dirs: map[string]DirCacheEntry{}})
	if len(rawFiles) != 1 || rawFiles[0] != sessionPath {
		t.Fatalf("raw cached discovery should still expose files: %v", rawFiles)
	}
	reportable := FindReportableSessionFilesCached("", SessionCache{Entries: map[string]CacheEntry{}, Dirs: map[string]DirCacheEntry{}})
	if len(reportable) != 0 {
		t.Fatalf("reportable discovery should skip sqlite-backed file dirs: %v", reportable)
	}
}

func TestLoadSessionCacheDefersEntryDecode(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	cachePath := sessionCachePath()
	if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
		t.Fatal(err)
	}
	payload := `{
		"entries": {
			"/tmp/good.jsonl": {"mod_time": 1, "size": 2, "session": {"Name": "good", "Health": 90}},
			"/tmp/bad.jsonl": {"mod_time": "bad"}
		},
		"dirs": {
			"/tmp": {"mod_time": 3, "files": ["/tmp/good.jsonl"], "dirs": []},
			"/bad": {"mod_time": "bad"}
		}
	}`
	if err := os.WriteFile(cachePath, []byte(payload), 0644); err != nil {
		t.Fatal(err)
	}

	cache := LoadSessionCache()
	if len(cache.Entries) != 0 {
		t.Fatalf("expected entries to stay lazy until requested: %+v", cache.Entries)
	}
	if len(cache.rawEntries) != 1 {
		t.Fatalf("expected raw entries retained for lazy decode: %+v", cache.rawEntries)
	}
	if len(cache.Dirs) != 1 || len(cache.Dirs["/tmp"].Files) != 1 {
		t.Fatalf("expected only good dir loaded: %+v", cache.Dirs)
	}
}

func TestSessionCachePersistsAndClears(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
	sessionPath := filepath.Join(home, "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	info, err := os.Stat(sessionPath)
	if err != nil {
		t.Fatal(err)
	}

	cachePath := SessionCachePath()
	if filepath.Base(cachePath) != "sessions.json" || filepath.Base(filepath.Dir(cachePath)) != "agenttrace" {
		t.Fatalf("unexpected cache path: %s", cachePath)
	}
	if err := ClearSessionCache(); err != nil {
		t.Fatalf("clearing a missing cache should be a no-op: %v", err)
	}

	want := SessionCache{
		Entries: map[string]CacheEntry{
			sessionPath: {
				ModTime: info.ModTime().UnixNano(),
				Size:    info.Size(),
				Session: Session{Name: "cached", Health: 88},
			},
		},
		Dirs: map[string]DirCacheEntry{
			"/tmp": {
				ModTime: 30,
				Files:   []string{sessionPath},
				Dirs:    []string{"/tmp/nested"},
			},
		},
	}
	if err := SaveSessionCache(want); err != nil {
		t.Fatalf("save cache: %v", err)
	}
	if _, err := os.Stat(cachePath); err != nil {
		t.Fatalf("expected cache file to exist: %v", err)
	}

	got := LoadSessionCache()
	if len(got.Entries) != 0 || len(got.rawEntries) != 1 {
		t.Fatalf("cache entry should be lazy after load: entries=%+v raw=%+v", got.Entries, got.rawEntries)
	}
	cached, ok := CachedSession(sessionPath, got)
	if !ok || cached.Name != "cached" {
		t.Fatalf("cache entry did not round-trip: ok=%v session=%+v entries=%+v", ok, cached, got.Entries)
	}
	if len(got.Dirs["/tmp"].Files) != 1 || got.Dirs["/tmp"].Dirs[0] != "/tmp/nested" {
		t.Fatalf("directory cache did not round-trip: %+v", got.Dirs)
	}

	if err := ClearSessionCache(); err != nil {
		t.Fatalf("clear cache: %v", err)
	}
	if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("expected cache file removed, stat err=%v", err)
	}
	if err := ClearSessionCache(); err != nil {
		t.Fatalf("clearing an already removed cache should be a no-op: %v", err)
	}
}

func TestSessionCachePathUsesOverride(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("AGENTTRACE_SESSION_CACHE_DIR", dir)

	if got := SessionCachePath(); got != filepath.Join(dir, "sessions.json") {
		t.Fatalf("unexpected override cache path: %s", got)
	}
}

func TestPricingSourceLabelsStaleLiteLLMCache(t *testing.T) {
	prev := dynamicPricing
	dynamicPricing = &pricingStore{
		entries:  map[string]Price{"gpt-test": {Input: 1, Output: 2}},
		loadedAt: time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC),
		source:   "cache(stale)",
	}
	t.Cleanup(func() { dynamicPricing = prev })

	got := PricingSource()
	if !strings.Contains(got, "LiteLLM") || !strings.Contains(got, "stale cache") {
		t.Fatalf("stale cache should still be labeled as LiteLLM datasource, got %q", got)
	}
}

func TestWasteReportTextLocalizesSuggestionLabel(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.ZH)
	t.Cleanup(func() { i18n.SetLang(prev) })

	out := WasteReportText(WasteReport{
		WasteScore: 22,
		WasteLevel: "yellow",
		Cache: CacheEfficiency{
			Rating:           "none",
			TotalInputTokens: 28000,
			Suggestion:       i18n.T("cache_suggestion_none"),
		},
		Bloat:      ToolBloatAnalysis{BloatLevel: "low"},
		TopActions: []string{i18n.T("cache_suggestion_none")},
	})
	if strings.Contains(out, "Suggestion:") || !strings.Contains(out, "建议:") || strings.Contains(out, "%%") {
		t.Fatalf("waste report should localize suggestion label and percent signs:\n%s", out)
	}
}

func TestSaveSessionCachePreservesUnhydratedRawEntries(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))

	oldPath := filepath.Join(home, "old.jsonl")
	newPath := filepath.Join(home, "new.jsonl")
	for _, path := range []string{oldPath, newPath} {
		if err := os.WriteFile(path, []byte("{}\n"), 0644); err != nil {
			t.Fatal(err)
		}
	}
	oldInfo, err := os.Stat(oldPath)
	if err != nil {
		t.Fatal(err)
	}
	oldEntry := CacheEntry{
		ModTime: oldInfo.ModTime().UnixNano(),
		Size:    oldInfo.Size(),
		Session: Session{Name: "old", Health: 80},
	}
	oldRaw, err := json.Marshal(oldEntry)
	if err != nil {
		t.Fatal(err)
	}
	cachePath := sessionCachePath()
	if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
		t.Fatal(err)
	}
	payload := map[string]any{
		"entries": map[string]json.RawMessage{oldPath: oldRaw},
	}
	data, err := json.Marshal(payload)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(cachePath, data, 0644); err != nil {
		t.Fatal(err)
	}

	cache := LoadSessionCache()
	newInfo, err := os.Stat(newPath)
	if err != nil {
		t.Fatal(err)
	}
	cache.Entries[newPath] = CacheEntry{
		ModTime: newInfo.ModTime().UnixNano(),
		Size:    newInfo.Size(),
		Session: Session{Name: "new", Health: 90},
	}
	if err := SaveSessionCache(cache); err != nil {
		t.Fatalf("save cache: %v", err)
	}

	loaded := LoadSessionCache()
	if len(loaded.rawEntries) != 2 {
		t.Fatalf("expected raw and new entries after save: %+v", loaded.rawEntries)
	}
	if got, ok := CachedSession(oldPath, loaded); !ok || got.Name != "old" {
		t.Fatalf("old raw entry not preserved: ok=%v got=%+v", ok, got)
	}
	if got, ok := CachedSession(newPath, loaded); !ok || got.Name != "new" {
		t.Fatalf("new entry not saved: ok=%v got=%+v", ok, got)
	}
}

func TestCachedSessionRelocalizesLanguageDependentDiagnostics(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.ZH)
	t.Cleanup(func() { i18n.SetLang(prev) })

	dir := t.TempDir()
	path := filepath.Join(dir, "cached.jsonl")
	if err := os.WriteFile(path, []byte("{}\n"), 0644); err != nil {
		t.Fatal(err)
	}
	info, err := os.Stat(path)
	if err != nil {
		t.Fatal(err)
	}

	cache := SessionCache{Entries: map[string]CacheEntry{
		path: {
			ModTime: info.ModTime().UnixNano(),
			Size:    info.Size(),
			Session: Session{
				Name: "cached",
				Metrics: Metrics{
					GapsSec:        []float64{61, 302},
					AssistantTurns: 3,
				},
				Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh, Detail: "2 gap(s) >60s, max=302s"}},
				ContextUtil: ContextUtilization{
					RiskLevel:  "warning",
					Suggestion: "context filling up",
				},
				LargeParams:      []LargeParamCall{{ToolName: "terminal", ParamSize: 12000, Detail: "terminal arguments are 12000 chars"}},
				UnusedTools:      []UnusedToolInfo{{ToolName: "terminal", CallCount: 1, Detail: "tool 'terminal' called only 1x"}},
				LoopFingerprints: []LoopFingerprint{{ToolName: "terminal", Count: 3, Detail: "tool 'terminal' returned same result 3x"}},
				ToolWarnings:     []ToolWarning{{ToolName: "terminal", Pattern: "empty_args", Count: 1, Detail: "terminal called with empty arguments"}},
			},
		},
	}}

	got, ok := CachedSession(path, cache)
	if !ok {
		t.Fatalf("expected cached session")
	}
	for _, want := range []string{"最长", "上下文", "参数", "仅调用", "参数为空"} {
		joined := strings.Join([]string{
			got.Anomalies[0].Detail,
			got.ContextUtil.Suggestion,
			got.LargeParams[0].Detail,
			got.UnusedTools[0].Detail,
			got.ToolWarnings[0].Detail,
		}, "\n")
		if !strings.Contains(joined, want) {
			t.Fatalf("cached session missing localized text %q:\n%s", want, joined)
		}
	}
	if strings.Contains(got.Anomalies[0].Detail, "gap(s)") || strings.Contains(got.ContextUtil.Suggestion, "context") {
		t.Fatalf("cached session kept stale English diagnostics: %+v", got)
	}
}

func TestAnalyze(t *testing.T) {
	events := []Event{
		{Role: "user", Content: "hello", SourceTool: "h", Timestamp: "2026-01-01T00:00:00Z"},
		{Role: "assistant", Content: "hi", SourceTool: "h", Timestamp: "2026-01-01T00:00:01Z"},
		{Role: "assistant", ToolCalls: []ToolCall{{Name: "read_file", ID: "t1", Args: `{"path":"README.md"}`}}, SourceTool: "h", Timestamp: "2026-01-01T00:00:02Z"},
		{Role: "tool", Content: "ok", ToolCallID: "t1", SourceTool: "h", Timestamp: "2026-01-01T00:00:03Z"},
	}
	m := Analyze(events, "default")
	if m.UserMessages != 1 {
		t.Errorf("UserMessages: %d", m.UserMessages)
	}
	if m.AssistantTurns != 2 {
		t.Errorf("AssistantTurns: %d", m.AssistantTurns)
	}
	if m.ToolCallsTotal != 1 {
		t.Errorf("ToolCallsTotal: %d", m.ToolCallsTotal)
	}
	if m.ToolCallsOK != 1 {
		t.Errorf("ToolCallsOK: %d", m.ToolCallsOK)
	}
	if m.CostEstimated < 0 {
		t.Error("cost should be >=0")
	}
	if m.FileUsage["README.md"] != 1 {
		t.Fatalf("expected file surface from tool args, got %+v", m.FileUsage)
	}
	if m.ToolAuthority[ToolAuthorityReadOnlyFiles] != 1 || m.HighestAuthority != ToolAuthorityReadOnlyFiles {
		t.Fatalf("expected read-only authority, got highest=%q counts=%+v", m.HighestAuthority, m.ToolAuthority)
	}
}

func TestClassifyToolAuthority(t *testing.T) {
	tests := []struct {
		name string
		call ToolCall
		want string
	}{
		{name: "read", call: ToolCall{Name: "read_file", Args: `{"path":"README.md"}`}, want: ToolAuthorityReadOnlyFiles},
		{name: "write", call: ToolCall{Name: "write_file", Args: `{"path":"README.md"}`}, want: ToolAuthorityWriteFiles},
		{name: "test", call: ToolCall{Name: "terminal", Args: `{"cmd":"go test ./..."}`}, want: ToolAuthorityTestOrBuild},
		{name: "package", call: ToolCall{Name: "bash", Args: `{"cmd":"npm install"}`}, want: ToolAuthorityPackageInstall},
		{name: "shell", call: ToolCall{Name: "terminal", Args: `{"cmd":"sed -n '1,20p' main.go"}`}, want: ToolAuthorityShellExec},
		{name: "git", call: ToolCall{Name: "bash", Args: `{"cmd":"git push origin HEAD"}`}, want: ToolAuthorityGitWrite},
		{name: "network", call: ToolCall{Name: "bash", Args: `{"cmd":"curl https://example.com"}`}, want: ToolAuthorityNetworkAccess},
		{name: "publish", call: ToolCall{Name: "bash", Args: `{"cmd":"npm publish"}`}, want: ToolAuthorityExternalPublish},
		{name: "unknown", call: ToolCall{Name: "mystery", Args: `{"value":"x"}`}, want: ToolAuthorityUnknown},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := ClassifyToolAuthority(tt.call); got != tt.want {
				t.Fatalf("ClassifyToolAuthority() = %q, want %q", got, tt.want)
			}
		})
	}
	if got := HigherToolAuthority(ToolAuthorityShellExec, ToolAuthorityUnknown); got != ToolAuthorityUnknown {
		t.Fatalf("unknown authority should rank conservatively, got %q", got)
	}
	if !IsHighAuthorityCategory(ToolAuthorityWriteFiles) || IsHighAuthorityCategory(ToolAuthorityReadOnlyFiles) {
		t.Fatalf("unexpected high-authority category boundary")
	}
}

func TestReportOverviewJSONIncludesOperationalSummary(t *testing.T) {
	sessions := []Session{
		{
			Name:   "good",
			Health: 90,
			Metrics: Metrics{
				SourceTool:       "claude_code_jsonl",
				ModelUsed:        "claude-sonnet-4",
				TokensInput:      100,
				TokensOutput:     50,
				TokensCacheW:     25,
				TokensCacheR:     50,
				ToolCallsOK:      9,
				ToolCallsFail:    1,
				ToolUsage:        map[string]int{"read_file": 1},
				FileUsage:        map[string]int{"README.md": 1},
				ToolAuthority:    map[string]int{ToolAuthorityReadOnlyFiles: 1},
				HighestAuthority: ToolAuthorityReadOnlyFiles,
				CostEstimated:    0.25,
				DurationSec:      60,
			},
		},
		{
			Name:      "bad",
			Health:    40,
			Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}},
			Metrics: Metrics{
				SourceTool:       "codex_cli",
				ModelUsed:        "gpt-5.1",
				TokensInput:      200,
				TokensOutput:     100,
				ToolCallsFail:    2,
				ToolUsage:        map[string]int{"bash": 1},
				FileUsage:        map[string]int{"cmd/agenttrace/main.go": 1},
				ToolAuthority:    map[string]int{ToolAuthorityShellExec: 1},
				HighestAuthority: ToolAuthorityShellExec,
				CostEstimated:    0.75,
				DurationSec:      180,
			},
		},
	}

	var payload struct {
		Version string `json:"version"`
		Summary struct {
			TotalSessions  int     `json:"total_sessions"`
			Critical       int     `json:"critical"`
			TotalCost      float64 `json:"total_cost"`
			TotalDuration  float64 `json:"total_duration_seconds"`
			TotalTokens    int     `json:"total_tokens"`
			ToolFailRate   float64 `json:"tool_fail_rate"`
			AnomaliesTotal int     `json:"anomalies_total"`
			ToolAuthority  struct {
				Highest string         `json:"highest"`
				Counts  map[string]int `json:"counts"`
			} `json:"tool_authority"`
			HealthTrend struct {
				Direction  string `json:"direction"`
				Regressing bool   `json:"regressing"`
				Message    string `json:"message"`
				Points     []struct {
					Name   string `json:"name"`
					Health int    `json:"health"`
				} `json:"points"`
			} `json:"health_trend"`
		} `json:"summary"`
		FailureFamilies []string `json:"failure_families"`
		Surfaces        struct {
			Tools               []string `json:"tools"`
			Files               []string `json:"files"`
			AuthorityCategories []string `json:"authority_categories"`
			HighAuthorityTools  []string `json:"high_authority_tools"`
		} `json:"surfaces"`
		ByAgent []struct {
			Name     string  `json:"name"`
			Sessions int     `json:"sessions"`
			Cost     float64 `json:"cost"`
		} `json:"by_agent"`
		RecentSessions []struct {
			Name      string `json:"name"`
			Health    int    `json:"health"`
			Anomalies int    `json:"anomalies"`
			Authority string `json:"highest_tool_authority"`
		} `json:"recent_sessions"`
		Anomalies []AnomalyTop `json:"anomalies"`
	}
	if err := json.Unmarshal([]byte(ReportOverviewJSON(ComputeOverview(sessions), sessions)), &payload); err != nil {
		t.Fatalf("invalid overview json: %v", err)
	}
	if payload.Version != Version || payload.Summary.TotalSessions != 2 || payload.Summary.Critical != 1 {
		t.Fatalf("bad summary: %+v", payload.Summary)
	}
	if payload.Summary.TotalCost != 1 || payload.Summary.TotalDuration != 240 || payload.Summary.TotalTokens != 525 || payload.Summary.ToolFailRate != 25 {
		t.Fatalf("missing operational totals: %+v", payload.Summary)
	}
	if payload.Summary.AnomaliesTotal != 1 {
		t.Fatalf("missing anomaly total: %+v", payload.Summary)
	}
	if payload.Summary.HealthTrend.Direction == "" || payload.Summary.HealthTrend.Message == "" || len(payload.Summary.HealthTrend.Points) != 2 {
		t.Fatalf("missing health trend: %+v", payload.Summary.HealthTrend)
	}
	if !containsString(payload.FailureFamilies, "hanging") ||
		!containsString(payload.Surfaces.Tools, "bash") ||
		!containsString(payload.Surfaces.Files, "README.md") ||
		!containsString(payload.Surfaces.AuthorityCategories, ToolAuthorityReadOnlyFiles) ||
		!containsString(payload.Surfaces.AuthorityCategories, ToolAuthorityShellExec) ||
		!containsString(payload.Surfaces.HighAuthorityTools, "bash") {
		t.Fatalf("missing deterministic comparison surfaces: families=%v surfaces=%+v", payload.FailureFamilies, payload.Surfaces)
	}
	if payload.Summary.ToolAuthority.Highest != ToolAuthorityShellExec || payload.Summary.ToolAuthority.Counts[ToolAuthorityReadOnlyFiles] != 1 {
		t.Fatalf("missing tool authority summary: %+v", payload.Summary.ToolAuthority)
	}
	for _, recent := range payload.RecentSessions {
		if recent.Authority == "" {
			t.Fatalf("recent session missing highest authority: %+v", payload.RecentSessions)
		}
	}
	if len(payload.ByAgent) != 2 || len(payload.RecentSessions) != 2 || len(payload.Anomalies) != 1 {
		t.Fatalf("missing overview sections: agents=%d recent=%d anomalies=%d",
			len(payload.ByAgent), len(payload.RecentSessions), len(payload.Anomalies))
	}
}

func TestAddBaselineComparisonFlagsDeterministicRegressions(t *testing.T) {
	baselineSessions := []Session{{
		Name:      "base",
		Health:    90,
		Anomalies: []Anomaly{{Type: "tool_failures"}},
		Metrics: Metrics{
			TokensInput:      100,
			ToolCallsOK:      1,
			ToolUsage:        map[string]int{"read_file": 1},
			FileUsage:        map[string]int{"README.md": 1},
			ToolAuthority:    map[string]int{ToolAuthorityReadOnlyFiles: 1},
			HighestAuthority: ToolAuthorityReadOnlyFiles,
			CostEstimated:    0.10,
			DurationSec:      100,
		},
	}}
	baselinePath := filepath.Join(t.TempDir(), "baseline.json")
	if err := os.WriteFile(baselinePath, []byte(ReportOverviewJSON(ComputeOverview(baselineSessions), baselineSessions)), 0644); err != nil {
		t.Fatal(err)
	}

	currentSessions := []Session{{
		Name:      "current",
		Health:    40,
		Anomalies: []Anomaly{{Type: "hanging"}, {Type: "tool_failures"}},
		Metrics: Metrics{
			TokensInput:      150,
			ToolCallsOK:      1,
			ToolUsage:        map[string]int{"bash": 1, "read_file": 1},
			FileUsage:        map[string]int{"README.md": 1, "cmd/agenttrace/main.go": 1},
			ToolAuthority:    map[string]int{ToolAuthorityShellExec: 1, ToolAuthorityReadOnlyFiles: 1},
			HighestAuthority: ToolAuthorityShellExec,
			CostEstimated:    0.13,
			DurationSec:      120,
		},
	}}
	out, err := AddBaselineComparison(
		ReportOverviewJSON(ComputeOverview(currentSessions), currentSessions),
		baselinePath,
		BaselineThresholds{MaxDurationDeltaPct: 10, MaxCostDeltaPct: 20, MaxTokenDeltaPct: 25},
	)
	if err != nil {
		t.Fatal(err)
	}

	var payload struct {
		BaselineComparison BaselineComparison `json:"baseline_comparison"`
	}
	if err := json.Unmarshal([]byte(out), &payload); err != nil {
		t.Fatalf("invalid comparison json: %v", err)
	}
	cmp := payload.BaselineComparison
	if !cmp.SlowerThanBaseline || !cmp.CostAboveThreshold || !cmp.TokensAboveThreshold {
		t.Fatalf("expected threshold regressions, got %+v", cmp)
	}
	if cmp.DurationDeltaPct != 20 || cmp.CostDeltaPct != 30 || cmp.TokenDeltaPct != 50 {
		t.Fatalf("unexpected deltas: %+v", cmp)
	}
	if !containsString(cmp.NewFailureFamilies, "hanging") ||
		!cmp.BroaderToolSurface ||
		!containsString(cmp.NewTools, "bash") ||
		!cmp.BroaderFileSurface ||
		!containsString(cmp.NewFiles, "cmd/agenttrace/main.go") ||
		!containsString(cmp.NewToolAuthorityCategories, ToolAuthorityShellExec) ||
		!containsString(cmp.NewHighAuthorityToolUse, "bash") {
		t.Fatalf("missing comparison surface regressions: %+v", cmp)
	}
}

func TestAddBaselineComparisonRejectsMissingOrIncompatibleBaseline(t *testing.T) {
	current := ReportOverviewJSON(ComputeOverview([]Session{{Metrics: Metrics{TokensInput: 1}}}), []Session{{Metrics: Metrics{TokensInput: 1}}})
	if _, err := AddBaselineComparison(current, filepath.Join(t.TempDir(), "missing.json"), BaselineThresholds{}); err == nil || !strings.Contains(err.Error(), "missing baseline report") {
		t.Fatalf("expected missing baseline diagnostic, got %v", err)
	}

	path := filepath.Join(t.TempDir(), "baseline.json")
	if err := os.WriteFile(path, []byte(`{"version":"v0.0.0","summary":{"total_tokens":1}}`), 0644); err != nil {
		t.Fatal(err)
	}
	if _, err := AddBaselineComparison(current, path, BaselineThresholds{}); err == nil || !strings.Contains(err.Error(), "incompatible") {
		t.Fatalf("expected incompatible baseline diagnostic, got %v", err)
	}
}

func TestReportOverviewJSONLimitsAnomalies(t *testing.T) {
	var sessions []Session
	for i := 0; i < 55; i++ {
		sessions = append(sessions, Session{
			Name:      fmt.Sprintf("s%02d", i),
			Health:    40,
			Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}},
		})
	}

	var payload struct {
		Summary struct {
			AnomaliesTotal     int  `json:"anomalies_total"`
			AnomaliesReturned  int  `json:"anomalies_returned"`
			AnomaliesTruncated bool `json:"anomalies_truncated"`
		} `json:"summary"`
		Anomalies []AnomalyTop `json:"anomalies"`
	}
	if err := json.Unmarshal([]byte(ReportOverviewJSON(ComputeOverview(sessions), sessions)), &payload); err != nil {
		t.Fatalf("invalid overview json: %v", err)
	}
	if payload.Summary.AnomaliesTotal != 55 || payload.Summary.AnomaliesReturned != 50 || !payload.Summary.AnomaliesTruncated {
		t.Fatalf("bad anomaly limit summary: %+v", payload.Summary)
	}
	if len(payload.Anomalies) != 50 {
		t.Fatalf("expected 50 returned anomalies, got %d", len(payload.Anomalies))
	}
}

func TestReportOverviewFormatsUseCanonicalRecentSessionOrder(t *testing.T) {
	sessions := []Session{
		{
			Name:   "mid",
			Health: 70,
			Metrics: Metrics{
				SessionStart:  "2026-05-02T10:10:00Z",
				SourceTool:    "codex_cli",
				ModelUsed:     "gpt-5.1",
				TokensInput:   20,
				TokensOutput:  10,
				CostEstimated: 0.02,
			},
		},
		{
			Name:   "old",
			Health: 90,
			Metrics: Metrics{
				SessionStart:  "2026-05-02T10:00:00Z",
				SourceTool:    "aider",
				ModelUsed:     "gpt-4.1",
				TokensInput:   10,
				TokensOutput:  5,
				CostEstimated: 0.01,
			},
		},
		{
			Name:   "new",
			Health: 40,
			Metrics: Metrics{
				SessionStart:  "2026-05-02T10:35:00Z",
				SourceTool:    "gemini_cli",
				ModelUsed:     "gemini-2.5-pro",
				TokensInput:   30,
				TokensOutput:  15,
				CostEstimated: 0.03,
			},
		},
	}
	ov := ComputeOverview(sessions)

	var payload struct {
		RecentSessions []struct {
			Name string `json:"name"`
		} `json:"recent_sessions"`
	}
	if err := json.Unmarshal([]byte(ReportOverviewJSON(ov, sessions)), &payload); err != nil {
		t.Fatalf("invalid overview json: %v", err)
	}
	if got := []string{payload.RecentSessions[0].Name, payload.RecentSessions[1].Name, payload.RecentSessions[2].Name}; strings.Join(got, ",") != "new,mid,old" {
		t.Fatalf("json recent sessions should be newest first, got %v", got)
	}

	md := ReportOverviewMarkdown(ov, sessions)
	assertOrderedSubstrings(t, md, []string{"| new |", "| mid |", "| old |"})

	html := ReportOverviewHTML(ov, sessions)
	assertOrderedSubstrings(t, html, []string{">new<", ">mid<", ">old<"})
}

func TestComputeOverviewSortsAnomaliesBySeverity(t *testing.T) {
	sessions := []Session{
		{
			Name:      "low",
			Anomalies: []Anomaly{{Type: "no_tools", Severity: SeverityLow}},
		},
		{
			Name:      "unknown",
			Anomalies: []Anomaly{{Type: "legacy"}},
		},
		{
			Name:      "high",
			Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}},
		},
		{
			Name:      "medium",
			Anomalies: []Anomaly{{Type: "latency", Severity: SeverityMedium}},
		},
	}

	got := ComputeOverview(sessions).AnomaliesTop
	if len(got) != 4 {
		t.Fatalf("expected four anomalies, got %d: %+v", len(got), got)
	}
	want := []string{"high", "medium", "low", "unknown"}
	for i, name := range want {
		if got[i].Session != name {
			t.Fatalf("anomaly %d should be %q, got %+v", i, name, got)
		}
	}
}

func TestComputeOverviewSortsAnomaliesDeterministicallyWithinSeverity(t *testing.T) {
	sessions := []Session{
		{
			Name:      "z-session",
			Anomalies: []Anomaly{{Type: "tool_failures", Severity: SeverityHigh}, {Type: "hanging", Severity: SeverityHigh}},
		},
		{
			Name:      "a-session",
			Anomalies: []Anomaly{{Type: "latency", Severity: SeverityHigh}},
		},
		{
			Name:      "m-session",
			Anomalies: []Anomaly{{Type: "no_tools", Severity: SeverityLow}},
		},
	}

	got := ComputeOverview(sessions).AnomaliesTop
	want := []string{"a-session:latency", "z-session:hanging", "z-session:tool_failures", "m-session:no_tools"}
	var names []string
	for _, item := range got {
		names = append(names, item.Session+":"+item.Type)
	}
	if strings.Join(names, ",") != strings.Join(want, ",") {
		t.Fatalf("anomalies should have deterministic severity/session/type order, got %v", names)
	}
}

func TestReportOverviewMarkdownIncludesCISummary(t *testing.T) {
	sessions := []Session{
		{
			Name:   "good|session",
			Health: 92,
			Metrics: Metrics{
				SourceTool:     "aider",
				ModelUsed:      "gpt-4.1",
				TokensInput:    1000,
				TokensOutput:   500,
				TokensCacheW:   25,
				TokensCacheR:   50,
				ToolCallsOK:    4,
				ToolCallsFail:  1,
				ToolUsage:      map[string]int{"read_file": 5},
				AssistantTurns: 2,
				DurationSec:    45,
				CostEstimated:  0.12,
			},
		},
		{
			Name:      "bad",
			Health:    30,
			Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}},
			Metrics: Metrics{
				SourceTool:     "cursor",
				ModelUsed:      "default",
				TokensInput:    300,
				TokensOutput:   200,
				ToolCallsFail:  5,
				AssistantTurns: 1,
				DurationSec:    90,
				CostEstimated:  0.34,
			},
		},
	}
	out := ReportOverviewMarkdown(ComputeOverview(sessions), sessions)
	for _, want := range []string{
		"# agenttrace overview",
		"| Sessions | 2 |",
		"| Total tokens | 2075 |",
		"| Health Trend |",
		"| Tool failures | 6 / 10 (60.0%) |",
		"## Incident timeline",
		"Last milestone",
		"Touched surface",
		"| Aider | 1 |",
		"| Cursor | 1 |",
		"good\\|session",
		"| bad | hanging |",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("markdown report missing %q:\n%s", want, out)
		}
	}
}

func TestBuildIncidentTimelineKeepsEvidenceCompact(t *testing.T) {
	got := BuildIncidentTimeline(Session{
		Name: "incident",
		Metrics: Metrics{
			AssistantTurns: 4,
			DurationSec:    125,
			GapsSec:        []float64{2, 75},
			ToolCallsOK:    3,
			ToolCallsFail:  2,
			ToolUsage:      map[string]int{"read_file": 2, "shell\nsecret args": 3},
			TokensInput:    42000,
			TokensOutput:   9000,
			CostEstimated:  0.80,
		},
		LoopFingerprints: []LoopFingerprint{{ToolName: "shell\nsecret args", Count: 3, Severity: "critical"}},
		LoopCost:         LoopCost{TotalLoopCost: 0.30},
	})
	if len(got.Items) < 5 {
		t.Fatalf("expected compact incident evidence, got %+v", got.Items)
	}
	kinds := map[string]bool{}
	for _, item := range got.Items {
		kinds[item.Kind] = true
		if strings.Contains(item.Detail, "\n") {
			t.Fatalf("timeline detail should stay one-line and redacted enough for reports: %+v", item)
		}
		if strings.Contains(item.Detail, "secret") {
			t.Fatalf("timeline detail should not expose whitespace-suffixed tool text: %+v", item)
		}
	}
	for _, want := range []string{"milestone", "idle_gap", "failure_loop", "touched_surface", "burn_divergence"} {
		if !kinds[want] {
			t.Fatalf("missing incident timeline kind %q in %+v", want, got.Items)
		}
	}
}

func TestReportOverviewJSONIncludesIncidentTimelines(t *testing.T) {
	sessions := []Session{{
		Name:   "slow",
		Health: 45,
		Metrics: Metrics{
			AssistantTurns: 3,
			DurationSec:    180,
			GapsSec:        []float64{90},
			ToolCallsOK:    1,
			ToolCallsFail:  2,
			ToolUsage:      map[string]int{"shell": 3},
		},
	}}
	var payload struct {
		IncidentTimelines []IncidentTimelineSummary `json:"incident_timelines"`
	}
	if err := json.Unmarshal([]byte(ReportOverviewJSON(ComputeOverview(sessions), sessions)), &payload); err != nil {
		t.Fatalf("parse overview json: %v", err)
	}
	if len(payload.IncidentTimelines) != 1 || len(payload.IncidentTimelines[0].Items) == 0 {
		t.Fatalf("expected incident timeline JSON evidence, got %+v", payload.IncidentTimelines)
	}
}

func TestReportOverviewTextShowsIncidentAndAuthorityCues(t *testing.T) {
	sessions := []Session{{
		Name:   "risky",
		Health: 45,
		Metrics: Metrics{
			SourceTool:       "codex_cli",
			ModelUsed:        "gpt-5.1",
			AssistantTurns:   3,
			DurationSec:      180,
			GapsSec:          []float64{90},
			ToolCallsOK:      1,
			ToolCallsFail:    2,
			ToolUsage:        map[string]int{"terminal": 3},
			ToolAuthority:    map[string]int{ToolAuthorityTestOrBuild: 1},
			HighestAuthority: ToolAuthorityTestOrBuild,
		},
	}}
	out := ReportOverview(ComputeOverview(sessions), sessions)
	for _, want := range []string{
		"Incident timeline",
		"Last milestone",
		"Touched surface",
		"Tool authority",
		"Highest category: test_or_build",
		"Authority category counts: test_or_build=1",
		"High-authority tools: terminal",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("text overview missing %q:\n%s", want, out)
		}
	}
}

func TestReportOverviewTextWrapsAuthoritySummary(t *testing.T) {
	tools := map[string]int{}
	for i := 0; i < 10; i++ {
		tools[fmt.Sprintf("terminal-very-long-authority-tool-%02d", i)] = 1
	}
	sessions := []Session{{
		Name:   "wide-authority",
		Health: 90,
		Metrics: Metrics{
			SourceTool: "codex_cli",
			ModelUsed:  "gpt-5.1",
			ToolUsage:  tools,
			ToolAuthority: map[string]int{
				ToolAuthorityReadOnlyFiles:   11,
				ToolAuthorityTestOrBuild:     12,
				ToolAuthorityWriteFiles:      13,
				ToolAuthorityPackageInstall:  14,
				ToolAuthorityNetworkAccess:   15,
				ToolAuthorityShellExec:       16,
				ToolAuthorityGitWrite:        17,
				ToolAuthorityExternalPublish: 18,
				ToolAuthorityUnknown:         19,
			},
			HighestAuthority: ToolAuthorityUnknown,
		},
	}}

	out := ReportOverview(ComputeOverview(sessions), sessions)
	for _, want := range []string{
		"Tool authority",
		"Highest category",
		"Authority category counts",
		"High-authority tools",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("text overview missing %q:\n%s", want, out)
		}
	}

	var authorityLines []string
	inAuthority := false
	for _, line := range strings.Split(out, "\n") {
		if strings.Contains(line, "── Tool authority ──") {
			inAuthority = true
			continue
		}
		if inAuthority && strings.TrimSpace(line) == "" {
			break
		}
		if inAuthority {
			authorityLines = append(authorityLines, line)
			if got := utf8.RuneCountInString(line); got > 100 {
				t.Fatalf("authority line too wide (%d): %q\n%s", got, line, out)
			}
		}
	}
	if len(authorityLines) < 5 {
		t.Fatalf("expected wrapped authority evidence lines, got %d:\n%s", len(authorityLines), out)
	}
}

func TestReportOverviewTextBoundsIncidentTimelineRows(t *testing.T) {
	longTool := "terminal-" + strings.Repeat("very-long-tool-name-", 5)
	sessions := []Session{{
		Name:   "wide-incident",
		Health: 90,
		Metrics: Metrics{
			SourceTool:     "codex_cli",
			ModelUsed:      "gpt-5.1",
			AssistantTurns: 3,
			DurationSec:    180,
			ToolCallsOK:    12,
			ToolUsage:      map[string]int{longTool: 12},
		},
	}}

	out := ReportOverview(ComputeOverview(sessions), sessions)
	for _, want := range []string{"Incident timeline", "Touched surface", "..."} {
		if !strings.Contains(out, want) {
			t.Fatalf("text overview missing %q:\n%s", want, out)
		}
	}

	inTimeline := false
	checked := 0
	for _, line := range strings.Split(out, "\n") {
		if strings.Contains(line, "── Incident timeline ──") {
			inTimeline = true
			continue
		}
		if inTimeline && strings.TrimSpace(line) == "" {
			break
		}
		if inTimeline {
			checked++
			if got := utf8.RuneCountInString(line); got > 100 {
				t.Fatalf("incident line too wide (%d): %q\n%s", got, line, out)
			}
		}
	}
	if checked == 0 {
		t.Fatalf("expected incident timeline rows:\n%s", out)
	}
}

func TestReportOverviewTextTruncatesUnicodeSafely(t *testing.T) {
	longName := "x" + strings.Repeat("项目", 24) + "-异常会话"
	longTool := "x" + strings.Repeat("工具", 32)
	sessions := []Session{{
		Name:      longName,
		Health:    45,
		Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}},
		Metrics: Metrics{
			SourceTool:       "codex_cli",
			ModelUsed:        "gpt-5.1",
			AssistantTurns:   3,
			DurationSec:      180,
			GapsSec:          []float64{90},
			ToolCallsOK:      1,
			ToolCallsFail:    2,
			ToolUsage:        map[string]int{longTool: 3},
			ToolAuthority:    map[string]int{ToolAuthorityTestOrBuild: 1},
			HighestAuthority: ToolAuthorityTestOrBuild,
		},
	}}

	out := ReportOverview(ComputeOverview(sessions), sessions)
	if !utf8.ValidString(out) {
		t.Fatalf("text overview should stay valid UTF-8 after truncation:\n%x", out)
	}
	for _, want := range []string{
		"Incident timeline",
		"Touched surface",
		"Tool authority",
		"Recent Anomalies",
		"...",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("text overview missing %q:\n%s", want, out)
		}
	}
	if strings.ContainsRune(out, '\uFFFD') {
		t.Fatalf("text overview contains replacement characters after truncation:\n%s", out)
	}
}

func TestTextCellCollapsesWhitespaceAndTruncatesUTF8(t *testing.T) {
	got := textCell("命令\n执行  失败 😅", 8)
	if got != "命令 执行..." {
		t.Fatalf("unexpected UTF-8 safe text cell: %q", got)
	}
}

func TestReportOverviewCostLabelUsesEstimatedTotalCost(t *testing.T) {
	sessions := []Session{{
		Name:   "demo",
		Health: 88,
		Metrics: Metrics{
			SourceTool:    "codex_cli",
			ModelUsed:     "gpt-5.1",
			TokensInput:   1000,
			TokensOutput:  500,
			CostEstimated: 0.42,
		},
	}}
	ov := ComputeOverview(sessions)
	for name, out := range map[string]string{
		"text":     ReportOverview(ov, sessions),
		"markdown": ReportOverviewMarkdown(ov, sessions),
		"html":     ReportOverviewHTML(ov, sessions),
	} {
		if !strings.Contains(out, "Total estimated cost") {
			t.Fatalf("%s overview missing estimated total cost label:\n%s", name, out)
		}
		if strings.Contains(out, "Money Wasted") {
			t.Fatalf("%s overview kept old waste wording:\n%s", name, out)
		}
	}
}

func assertOrderedSubstrings(t *testing.T, haystack string, needles []string) {
	t.Helper()
	last := -1
	for _, needle := range needles {
		idx := strings.Index(haystack, needle)
		if idx < 0 {
			t.Fatalf("missing %q in:\n%s", needle, haystack)
		}
		if idx <= last {
			t.Fatalf("%q should appear after previous marker in:\n%s", needle, haystack)
		}
		last = idx
	}
}

func TestReportOverviewMarkdownChineseLabels(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.ZH)
	t.Cleanup(func() { i18n.SetLang(prev) })

	sessions := []Session{{
		Name:      "bad",
		Health:    30,
		Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}},
		Metrics: Metrics{
			SourceTool:    "cursor",
			ModelUsed:     "default",
			TokensInput:   300,
			TokensOutput:  200,
			ToolCallsFail: 5,
			CostEstimated: 0.34,
		},
	}}
	out := ReportOverviewMarkdown(ComputeOverview(sessions), sessions)
	for _, want := range []string{"# agenttrace 概览", "| 指标 | 值 |", "| 会话 | 1 |", "健康趋势", "## 最近会话", "挂起"} {
		if !strings.Contains(out, want) {
			t.Fatalf("Chinese markdown report missing %q:\n%s", want, out)
		}
	}
	for _, unwanted := range []string{"Metric", "Recent sessions", "Tool failures", "| bad | hanging |"} {
		if strings.Contains(out, unwanted) {
			t.Fatalf("Chinese markdown report leaked English label %q:\n%s", unwanted, out)
		}
	}
}

func TestReportTextChineseLabelsAnomalySeverityAndLoopCost(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.ZH)
	t.Cleanup(func() { i18n.SetLang(prev) })

	out := ReportText(Metrics{
		LoopGroups:      1,
		LoopCostEst:     0.25,
		LoopRetryEvents: 2,
	}, []Anomaly{{Type: "redaction", Severity: SeverityHigh, Emoji: "!"}}, 42)

	for _, want := range []string{"[严重] 思维脱敏", "循环成本", "工具循环成本", "重试事件"} {
		if !strings.Contains(out, want) {
			t.Fatalf("Chinese report text missing %q:\n%s", want, out)
		}
	}
	for _, unwanted := range []string{"[HIGH]", "redaction", "LOOP COST", "Tool Loop Cost", "Retry Events"} {
		if strings.Contains(out, unwanted) {
			t.Fatalf("Chinese report text leaked English label %q:\n%s", unwanted, out)
		}
	}
}

func TestReportOverviewMarkdownShowsAuthority(t *testing.T) {
	sessions := []Session{
		{
			Name:   "build",
			Health: 92,
			Metrics: Metrics{
				SourceTool:       "aider",
				ModelUsed:        "gpt-4.1",
				ToolCallsOK:      1,
				ToolUsage:        map[string]int{"go test": 1},
				ToolAuthority:    map[string]int{ToolAuthorityTestOrBuild: 1},
				HighestAuthority: ToolAuthorityTestOrBuild,
			},
		},
		{
			Name:   "shell",
			Health: 70,
			Metrics: Metrics{
				SourceTool:       "cursor",
				ModelUsed:        "default",
				ToolCallsOK:      1,
				ToolUsage:        map[string]int{"bash|prod\nsecret args": 1},
				ToolAuthority:    map[string]int{ToolAuthorityShellExec: 1},
				HighestAuthority: ToolAuthorityShellExec,
			},
		},
	}
	out := ReportOverviewMarkdown(ComputeOverview(sessions), sessions)
	for _, want := range []string{
		"## Tool authority",
		"| Highest category | `shell_exec` |",
		"| High-authority tools | `bash\\|prod<br>secret args` |",
		"### Authority category counts",
		"| `test_or_build` | 1 |",
		"| `shell_exec` | 1 |",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("markdown report missing %q:\n%s", want, out)
		}
	}
	for _, unwanted := range []string{"`bash|prod", "secret args` |\n|"} {
		if strings.Contains(out, unwanted) {
			t.Fatalf("markdown report did not escape high-authority tool name %q:\n%s", unwanted, out)
		}
	}
}

func TestReportOverviewHTMLIsShareableAndEscaped(t *testing.T) {
	sessions := []Session{
		{
			Name:   `good<script>`,
			Health: 92,
			Metrics: Metrics{
				SourceTool:       "aider",
				ModelUsed:        "gpt-4.1",
				TokensInput:      1000,
				TokensOutput:     500,
				TokensCacheW:     25,
				TokensCacheR:     50,
				ToolCallsOK:      4,
				ToolCallsFail:    1,
				ToolUsage:        map[string]int{"go test": 1},
				ToolAuthority:    map[string]int{ToolAuthorityTestOrBuild: 1},
				HighestAuthority: ToolAuthorityTestOrBuild,
				CostEstimated:    0.12,
			},
		},
		{
			Name:      "bad",
			Health:    30,
			Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}},
			Metrics: Metrics{
				SourceTool:       "cursor",
				ModelUsed:        "default",
				TokensInput:      300,
				TokensOutput:     200,
				ToolCallsFail:    5,
				ToolUsage:        map[string]int{"bash": 1},
				ToolAuthority:    map[string]int{ToolAuthorityShellExec: 1},
				HighestAuthority: ToolAuthorityShellExec,
				CostEstimated:    0.34,
			},
		},
	}
	out := ReportOverviewHTML(ComputeOverview(sessions), sessions)
	for _, want := range []string{
		"<!doctype html>",
		"<title>agenttrace overview</title>",
		"AI agent session overview",
		"Total tokens",
		"<strong>2075</strong>",
		"Tool failures",
		"Tool authority",
		"Highest category",
		"Authority category counts",
		"High-authority tools",
		"<code>shell_exec</code>",
		"<code>bash</code>",
		"Aider",
		"Cursor",
		"good&lt;script&gt;",
		"health-bad",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("html report missing %q:\n%s", want, out)
		}
	}
	if strings.Contains(out, "good<script>") {
		t.Fatalf("html report did not escape session name:\n%s", out)
	}
}

func TestReportOverviewHTMLChineseLabels(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.ZH)
	t.Cleanup(func() { i18n.SetLang(prev) })

	sessions := []Session{{
		Name:      "bad<script>",
		Health:    30,
		Anomalies: []Anomaly{{Type: "tool_failures", Severity: SeverityHigh}},
		Metrics: Metrics{
			SourceTool:    "cursor",
			ModelUsed:     "default",
			TokensInput:   300,
			TokensOutput:  200,
			ToolCallsFail: 5,
			CostEstimated: 0.34,
		},
	}}
	out := ReportOverviewHTML(ComputeOverview(sessions), sessions)
	for _, want := range []string{`<html lang="zh">`, "<title>agenttrace 概览</title>", "AI 代理会话概览", "工具失败", "最近会话", "bad&lt;script&gt;"} {
		if !strings.Contains(out, want) {
			t.Fatalf("Chinese html report missing %q:\n%s", want, out)
		}
	}
	for _, unwanted := range []string{"AI agent session overview", "Recent sessions", "Tool failures", "bad<script>"} {
		if strings.Contains(out, unwanted) {
			t.Fatalf("Chinese html report leaked English/raw label %q:\n%s", unwanted, out)
		}
	}
}

func TestAnalyze_ToolFailures(t *testing.T) {
	events := []Event{
		{Role: "user", Content: "x", SourceTool: "c"},
		{Role: "assistant", ToolCalls: []ToolCall{{Name: "bad", ID: "t1"}}, SourceTool: "c"},
		{Role: "tool", Content: `{"error":"fail"}`, ToolCallID: "t1", IsError: true, SourceTool: "c"},
	}
	m := Analyze(events, "default")
	if m.ToolCallsOK != 0 {
		t.Errorf("ToolCallsOK: %d", m.ToolCallsOK)
	}
	if m.ToolCallsFail != 1 {
		t.Errorf("ToolCallsFail: %d", m.ToolCallsFail)
	}
}

func TestDetectAnomalies_Perfect(t *testing.T) {
	m := Metrics{UserMessages: 5, AssistantTurns: 5, ToolCallsTotal: 20, ToolCallsOK: 20, ReasoningBlocks: 5, ReasoningChars: 4000}
	anoms := DetectAnomalies(m)
	if len(anoms) != 0 {
		t.Errorf("expected 0, got %d", len(anoms))
	}
}

func TestDetectAnomalies_Hanging(t *testing.T) {
	m := Metrics{UserMessages: 5, AssistantTurns: 5, ToolCallsTotal: 10, ToolCallsOK: 10, ReasoningBlocks: 5, ReasoningChars: 4000, GapsSec: []float64{10, 15, 350}}
	anoms := DetectAnomalies(m)
	for _, a := range anoms {
		if a.Type == "hanging" {
			return
		}
	}
	t.Error("no hanging anomaly for gap>300s")
}

func TestDetectAnomalies_ToolFail(t *testing.T) {
	m := Metrics{UserMessages: 5, AssistantTurns: 5, ToolCallsTotal: 100, ToolCallsOK: 60, ToolCallsFail: 40, ReasoningBlocks: 5, ReasoningChars: 4000}
	anoms := DetectAnomalies(m)
	for _, a := range anoms {
		if a.Type == "tool_failures" {
			return
		}
	}
	t.Error("no tool_failures anomaly for 40% fail")
}

func TestDetectAnomalies_Shallow(t *testing.T) {
	m := Metrics{UserMessages: 5, AssistantTurns: 5, ToolCallsTotal: 10, ToolCallsOK: 10, ReasoningBlocks: 3, ReasoningChars: 300, ReasoningLens: []int{100, 100, 100}}
	anoms := DetectAnomalies(m)
	for _, a := range anoms {
		if a.Type == "shallow_thinking" {
			return
		}
	}
	t.Error("no shallow_thinking anomaly")
}

func TestHealthScore_Perfect(t *testing.T) {
	s := HealthScore(Metrics{}, nil)
	if s != 100 {
		t.Errorf("expected 100, got %d", s)
	}
}

func TestHealthScore_WithAnomalies(t *testing.T) {
	anoms := []Anomaly{{Severity: SeverityHigh}, {Severity: SeverityMedium}}
	s := HealthScore(Metrics{}, anoms)
	if s != 58 {
		t.Errorf("expected 58, got %d", s)
	}
}

func TestHealthScore_Floor(t *testing.T) {
	anoms := []Anomaly{{Severity: SeverityHigh}, {Severity: SeverityHigh}, {Severity: SeverityHigh}, {Severity: SeverityHigh}}
	s := HealthScore(Metrics{}, anoms)
	if s != 0 {
		t.Errorf("expected 0, got %d", s)
	}
}

func TestDiffSessions(t *testing.T) {
	a := Session{Name: "A", Health: 90, Metrics: Metrics{AssistantTurns: 10, ToolCallsTotal: 20, ToolCallsOK: 18, ToolCallsFail: 2, CostEstimated: 0.05, ModelUsed: "opus", DurationSec: 60}}
	b := Session{Name: "B", Health: 72, Metrics: Metrics{AssistantTurns: 24, ToolCallsTotal: 40, ToolCallsOK: 32, ToolCallsFail: 8, CostEstimated: 0.12, ModelUsed: "haiku", DurationSec: 180}, Anomalies: []Anomaly{{Type: "tool_failures"}}}
	diff := DiffSessions(a, b)
	if diff.SessionA != "A" || diff.SessionB != "B" {
		t.Error("wrong session names")
	}
	if len(diff.Entries) < 7 {
		t.Errorf("entries: %d", len(diff.Entries))
	}
}

func TestGenerateFixes_Hanging(t *testing.T) {
	m := Metrics{GapsSec: []float64{350}}
	anoms := []Anomaly{{Type: "hanging", Severity: SeverityHigh}}
	i18n.Current = i18n.ZH
	fixes := GenerateFixes(m, anoms)
	i18n.Current = i18n.EN
	if len(fixes) == 0 {
		t.Fatal("no fixes")
	}
	if fixes[0].Category != "hanging" {
		t.Errorf("category: %q", fixes[0].Category)
	}
}

func TestGenerateFixes_English(t *testing.T) {
	m := Metrics{GapsSec: []float64{350}}
	anoms := []Anomaly{{Type: "hanging", Severity: SeverityHigh}}
	fixes := GenerateFixes(m, anoms)
	if len(fixes) == 0 {
		t.Fatal("no fixes")
	}
	if fixes[0].Category != "hanging" {
		t.Errorf("category: %q", fixes[0].Category)
	}
}

func TestAnalyzeHealthTrend_Stable(t *testing.T) {
	sessions := []Session{{Name: "s1", Health: 85}, {Name: "s2", Health: 83}, {Name: "s3", Health: 86}, {Name: "s4", Health: 84}}
	if AnalyzeHealthTrend(sessions).Regressing {
		t.Error("should not regress")
	}
}

func TestAnalyzeHealthTrend_Declining(t *testing.T) {
	sessions := []Session{{Name: "s1", Health: 90}, {Name: "s2", Health: 70}, {Name: "s3", Health: 50}}
	trend := AnalyzeHealthTrend(sessions)
	if !trend.Regressing {
		t.Error("should regress")
	}
	if strings.Contains(trend.Message, "%!") {
		t.Fatalf("trend message has bad formatting: %q", trend.Message)
	}
}

func TestAnalyzeHealthTrend_DownMessage(t *testing.T) {
	sessions := []Session{{Name: "s1", Health: 100}, {Name: "s2", Health: 10}, {Name: "s3", Health: 66}}
	trend := AnalyzeHealthTrend(sessions)
	if trend.Direction != "down" {
		t.Fatalf("expected down trend, got %+v", trend)
	}
	if !strings.Contains(trend.Message, "Declining") {
		t.Fatalf("down trend should not render as stable: %q", trend.Message)
	}
}

func TestAnalyzeHealthTrendUsesSessionTimeOrder(t *testing.T) {
	sessions := []Session{
		{Name: "new", Health: 50, Metrics: Metrics{SessionStart: "2026-01-03T00:00:00Z"}},
		{Name: "mid", Health: 70, Metrics: Metrics{SessionStart: "2026-01-02T00:00:00Z"}},
		{Name: "old", Health: 90, Metrics: Metrics{SessionStart: "2026-01-01T00:00:00Z"}},
	}
	trend := AnalyzeHealthTrend(sessions)
	if !trend.Regressing {
		t.Fatalf("newest-first sessions should still detect decline: %+v", trend)
	}
	if trend.Points[0].Name != "old" || trend.Points[2].Name != "new" {
		t.Fatalf("trend points should be old-to-new, got %+v", trend.Points)
	}
}

func TestValidateToolPatterns_DeadLoop(t *testing.T) {
	events := make([]Event, 6)
	for i := range events {
		events[i] = Event{Role: "assistant", ToolCalls: []ToolCall{{Name: "read_file", Args: `{"path":"README.md"}`}}}
	}
	warnings := ValidateToolPatterns(events)
	for _, w := range warnings {
		if w.Pattern == "dead_loop" {
			return
		}
	}
	t.Error("no dead_loop warning for 6 consecutive same tool calls")
}

func TestValidateToolPatterns_DifferentArgsAreNotDeadLoop(t *testing.T) {
	events := make([]Event, 6)
	for i := range events {
		events[i] = Event{Role: "assistant", ToolCalls: []ToolCall{{Name: "read_file", Args: fmt.Sprintf(`{"path":"file%d.md"}`, i)}}}
	}
	for _, w := range ValidateToolPatterns(events) {
		if w.Pattern == "dead_loop" || w.Pattern == "redundant" {
			t.Fatalf("different args should not trigger %s warning: %+v", w.Pattern, w)
		}
	}
}

func TestValidateToolPatterns_EmptyContentDoesNotMeanEmptyArgs(t *testing.T) {
	events := []Event{{
		Role:      "assistant",
		Content:   "",
		ToolCalls: []ToolCall{{Name: "read_file", Args: `{"path":"README.md"}`}},
	}}
	for _, w := range ValidateToolPatterns(events) {
		if w.Pattern == "empty_args" {
			t.Fatalf("assistant content should not be treated as tool args: %+v", w)
		}
	}
}

func TestValidateToolPatterns_ConsecutiveFailedRetries(t *testing.T) {
	events := []Event{
		{Role: "assistant", ToolCalls: []ToolCall{{ID: "a", Name: "search", Args: `{"q":"x"}`}}},
		{Role: "tool", ToolCallID: "a", IsError: true},
		{Role: "assistant", ToolCalls: []ToolCall{{ID: "b", Name: "search", Args: `{"q":"x"}`}}},
		{Role: "tool", ToolCallID: "b", IsError: true},
		{Role: "assistant", ToolCalls: []ToolCall{{ID: "c", Name: "search", Args: `{"q":"x"}`}}},
		{Role: "tool", ToolCallID: "c", IsError: true},
	}
	warnings := ValidateToolPatterns(events)
	for _, w := range warnings {
		if w.Pattern == "fail_retry_chain" && w.ToolName == "search" && w.Count == 3 {
			return
		}
	}
	t.Fatalf("expected fail_retry_chain warning, got %+v", warnings)
}

func TestValidateToolPatterns_NoIssues(t *testing.T) {
	events := []Event{{Role: "user", Content: "hi"}, {Role: "assistant", Content: "hey"}}
	if len(ValidateToolPatterns(events)) != 0 {
		t.Error("expected 0 warnings")
	}
}

func TestPredictCostAnomaly_Normal(t *testing.T) {
	sessions := []Session{
		{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 0.05}},
		{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 0.06}},
	}
	current := Session{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 0.07}}
	if PredictCostAnomaly(sessions, current).Triggered {
		t.Error("should not trigger")
	}
}

func TestPredictCostAnomaly_Critical(t *testing.T) {
	sessions := []Session{
		{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 0.05}},
	}
	current := Session{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 0.25}}
	alert := PredictCostAnomaly(sessions, current)
	if !alert.Triggered || alert.Level != "critical" {
		t.Errorf("level: %s, triggered: %v", alert.Level, alert.Triggered)
	}
}

func TestPredictCostAnomalyEnglishMessageOrdersBaselineAndRatio(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.EN)
	t.Cleanup(func() { i18n.SetLang(prev) })

	sessions := []Session{
		{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 0.05}},
	}
	current := Session{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 0.25}}
	alert := PredictCostAnomaly(sessions, current)

	if !strings.Contains(alert.Message, "avg $0.01/turn, 5x") {
		t.Fatalf("cost alert swapped baseline and ratio: %q", alert.Message)
	}
	if strings.Contains(alert.Message, "0x avg $5.00") {
		t.Fatalf("cost alert kept the old misleading format: %q", alert.Message)
	}
}

func TestPredictCostAnomalyLoopWasteMessageShowsAmountsAndPercent(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.EN)
	t.Cleanup(func() { i18n.SetLang(prev) })

	sessions := []Session{
		{Metrics: Metrics{AssistantTurns: 10, CostEstimated: 1.00}},
	}
	current := Session{
		Metrics:  Metrics{AssistantTurns: 10, CostEstimated: 1.00},
		LoopCost: LoopCost{TotalLoopCost: 0.80},
	}
	alert := PredictCostAnomaly(sessions, current)

	if !alert.Triggered || !strings.Contains(alert.Message, "$0.8000 of $1.0000 total (80%)") {
		t.Fatalf("loop waste alert should show loop cost, total cost, and percent: %+v", alert)
	}
}

func TestPredictCostAnomalyClampsLoopWasteToTotal(t *testing.T) {
	prev := i18n.Current
	i18n.SetLang(i18n.EN)
	t.Cleanup(func() { i18n.SetLang(prev) })

	sessions := []Session{
		{Metrics: Metrics{AssistantTurns: 1, CostEstimated: 0.0008}},
	}
	current := Session{
		Metrics:  Metrics{AssistantTurns: 1, CostEstimated: 0.0008},
		LoopCost: LoopCost{TotalLoopCost: 0.0750},
	}
	alert := PredictCostAnomaly(sessions, current)

	if !alert.Triggered || !strings.Contains(alert.Message, "$0.0008 of $0.0008 total (100%)") {
		t.Fatalf("loop waste alert should clamp loop cost to total: %+v", alert)
	}
	if strings.Contains(alert.Message, "$0.0750") || strings.Contains(alert.Message, "9375%") {
		t.Fatalf("loop waste alert kept impossible cost relationship: %q", alert.Message)
	}
}

func TestWasteReportClampsLoopWasteToTotal(t *testing.T) {
	s := Session{
		Metrics:  Metrics{CostEstimated: 0.0008},
		LoopCost: LoopCost{ToolLoopCost: 0.0450, RetryCost: 0.0300, TotalLoopCost: 0.0750},
	}

	report := ComputeWasteReportFromSession(&s)

	if report.LoopCost != 0.0008 || report.LoopPercent != 100 {
		t.Fatalf("waste report should clamp loop waste to total, got cost=%f percent=%f", report.LoopCost, report.LoopPercent)
	}
	if report.TotalWasted > s.Metrics.CostEstimated {
		t.Fatalf("total wasted should not exceed total cost for loop-only waste: got %.4f total %.4f", report.TotalWasted, s.Metrics.CostEstimated)
	}
}

func containsString(items []string, want string) bool {
	for _, item := range items {
		if item == want {
			return true
		}
	}
	return false
}
