package daemon

import (
	"strings"
	"testing"

	"github.com/multica-ai/multica/server/pkg/agent"
)

func TestClassifyPoisonedOutput(t *testing.T) {
	cases := []struct {
		name       string
		output     string
		wantOK     bool
		wantReason string
	}{
		{
			name:       "iteration limit canonical",
			output:     "I reached the iteration limit and couldn't generate a summary.",
			wantOK:     true,
			wantReason: FailureReasonIterationLimit,
		},
		{
			name:       "iteration limit case insensitive",
			output:     "I REACHED THE ITERATION LIMIT and stopped",
			wantOK:     true,
			wantReason: FailureReasonIterationLimit,
		},
		{
			name:       "fallback meta message",
			output:     "Put your final update inside the content string. Keep it concise.",
			wantOK:     true,
			wantReason: FailureReasonAgentFallbackMsg,
		},
		{
			name:   "real conclusion is not poisoned",
			output: "Fixed the bug in auth.go and pushed PR #42.",
			wantOK: false,
		},
		{
			name:   "empty output",
			output: "",
			wantOK: false,
		},
		{
			name:   "mentions iteration but not the marker",
			output: "Each iteration of the loop processes one record.",
			wantOK: false,
		},
		{
			// Regression guard for the GPT-Boy review on MUL-1630:
			// a real review/analysis that quotes both markers must not
			// be misclassified. Without the length cap, this entire
			// PR's review thread would tank as a poisoned failure.
			name: "long review quoting both markers is not poisoned",
			output: `Review for the rerun fix.

Detection markers under consideration:
- "I reached the iteration limit and couldn't generate a summary."
- "Put your final update inside the content string. Keep it concise."

The implementation looks correct: the daemon classifies these as
fallback output, persists a dedicated failure_reason, and the SQL
filter excludes them from the resume lookup. Resume-safe auto-retry
still keeps the resume contract, while poisoned sessions are filtered.
Approving with a follow-up note about the matcher being too permissive
on long outputs.`,
			wantOK: false,
		},
		{
			name:   "marker buried inside a long agent conclusion",
			output: strings.Repeat("All checks passed and the bug is fixed. ", 10) + "i reached the iteration limit while debugging earlier.",
			wantOK: false,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			reason, ok := classifyPoisonedOutput(tc.output)
			if ok != tc.wantOK {
				t.Fatalf("classifyPoisonedOutput(%q) ok=%v, want %v", tc.output, ok, tc.wantOK)
			}
			if ok && reason != tc.wantReason {
				t.Fatalf("classifyPoisonedOutput(%q) reason=%q, want %q", tc.output, reason, tc.wantReason)
			}
		})
	}
}

func TestClassifyPoisonedError(t *testing.T) {
	cases := []struct {
		name       string
		errMsg     string
		wantOK     bool
		wantReason string
	}{
		{
			// MUL-1921 reproducer: a markdown image in the issue
			// description was downloaded as a 146-byte CDN auth-error
			// XML, then surfaced to the LLM as a base64 PNG. The API
			// rejected it and every follow-up task replayed the same
			// poisoned conversation.
			name:       "claude could not process image",
			errMsg:     `API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"Could not process image"},"request_id":"req_011CarVEtBLj95zD7i8xardY"}`,
			wantOK:     true,
			wantReason: FailureReasonAPIInvalidRequest,
		},
		{
			name:       "prompt too long is also poisoning",
			errMsg:     `API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 213000 tokens > 200000 maximum"}}`,
			wantOK:     true,
			wantReason: FailureReasonAPIInvalidRequest,
		},
		{
			name:       "case insensitive",
			errMsg:     `api error: 400 {"type":"INVALID_REQUEST_ERROR"}`,
			wantOK:     true,
			wantReason: FailureReasonAPIInvalidRequest,
		},
		{
			// Rate-limit must NOT be classified as poisoning — those
			// recover on retry and we want session resume to keep the
			// in-flight conversation memory.
			name:   "429 rate limit is transient",
			errMsg: `API Error: 429 {"type":"error","error":{"type":"rate_limit_error","message":"Number of request tokens has exceeded your per-minute rate limit"}}`,
			wantOK: false,
		},
		{
			name:   "5xx overloaded is transient",
			errMsg: `API Error: 529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`,
			wantOK: false,
		},
		{
			// 401/403 mean the daemon's credentials are bad; resuming
			// the session won't fix it but the failure is environmental,
			// not a poisoned conversation. Out of scope for this
			// classifier.
			name:   "401 auth error",
			errMsg: `API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"invalid api key"}}`,
			wantOK: false,
		},
		{
			// A tool surfacing a 400 from somewhere unrelated must not
			// trigger the classifier — only the combination of 400 +
			// invalid_request_error indicates a corrupted body.
			name:   "tool 400 without invalid_request_error",
			errMsg: `agent tool returned status 400: not found`,
			wantOK: false,
		},
		{
			name:   "empty error message",
			errMsg: "",
			wantOK: false,
		},
		{
			name:   "unrelated execution error",
			errMsg: "claude execution timeout after 10m",
			wantOK: false,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			reason, ok := classifyPoisonedError(tc.errMsg)
			if ok != tc.wantOK {
				t.Fatalf("classifyPoisonedError(%q) ok=%v, want %v", tc.errMsg, ok, tc.wantOK)
			}
			if ok && reason != tc.wantReason {
				t.Fatalf("classifyPoisonedError(%q) reason=%q, want %q", tc.errMsg, reason, tc.wantReason)
			}
		})
	}
}

func TestClassifyResumeUnsafeTimeout(t *testing.T) {
	cases := []struct {
		name       string
		provider   string
		errMsg     string
		wantOK     bool
		wantReason string
	}{
		{
			name:       "codex semantic inactivity",
			provider:   "codex",
			errMsg:     agent.CodexSemanticInactivityMarker + " after 10m0s without agent progress (last activity: tool-result:exec_command)",
			wantOK:     true,
			wantReason: FailureReasonCodexSemanticInactivity,
		},
		{
			name:       "codex first turn no progress",
			provider:   "codex",
			errMsg:     agent.CodexFirstTurnNoProgressMarker + ` after 30s: received turn start but no item, turn/completed, or error event`,
			wantOK:     true,
			wantReason: FailureReasonCodexSemanticInactivity,
		},
		{
			name:     "codex ordinary timeout remains resumable",
			provider: "codex",
			errMsg:   "codex timed out after 30m0s",
			wantOK:   false,
		},
		{
			name:     "other provider same text is not classified",
			provider: "claude",
			errMsg:   agent.CodexSemanticInactivityMarker + " after 10m0s without agent progress",
			wantOK:   false,
		},
		{
			name:     "empty error",
			provider: "codex",
			errMsg:   "",
			wantOK:   false,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			reason, ok := classifyResumeUnsafeTimeout(tc.provider, tc.errMsg)
			if ok != tc.wantOK {
				t.Fatalf("classifyResumeUnsafeTimeout(%q, %q) ok=%v, want %v", tc.provider, tc.errMsg, ok, tc.wantOK)
			}
			if ok && reason != tc.wantReason {
				t.Fatalf("classifyResumeUnsafeTimeout(%q, %q) reason=%q, want %q", tc.provider, tc.errMsg, reason, tc.wantReason)
			}
		})
	}
}
