package agent

import (
	"encoding/json"
	"strings"
	"testing"

	"reasonix/internal/provider"
)

// realContract mirrors the shape compileExecutionContract emits: the
// source_event lives under planner_ir, and the block replaces the whole user
// turn.
func realContract(sourceEvent string) string {
	return "<memory-compiler-execution>\n" +
		`{"type":"memory_v5_execution_contract","instruction":"Execute source_event through planner_ir.",` +
		`"ir_explanation":{},"planner_ir":{"version":5,"goal":"g","source_event":` + jsonString(sourceEvent) + `}}` +
		"\n</memory-compiler-execution>"
}

func jsonString(s string) string {
	// minimal JSON string quoting for test fixtures (no control chars used here)
	return `"` + s + `"`
}

// TestStripTransientUserBlocksUnwrapsMemoryCompilerExecution guards the #5307
// contract: the Memory v5 <memory-compiler-execution> block REPLACES the user
// turn (the prompt survives only inside the contract's source_event), so the
// display/preview path must unwrap it to the original prompt — not drop it like
// a prepended transient block, which would blank out the turn.
func TestStripTransientUserBlocksUnwrapsMemoryCompilerExecution(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want string
	}{
		{
			name: "block only (compiled contract replaced the whole turn)",
			in:   realContract("add a config loader"),
			want: "add a config loader",
		},
		{
			name: "language blocks before the compiler block",
			// Real composition order: withTurnPreferences wraps the compiled
			// contract, so the language blocks lead and the compiler block
			// follows. Both must resolve to the original prompt.
			in: "<reasoning-language>zh</reasoning-language>\n\n" +
				"<response-language>zh</response-language>\n\n" + realContract("do the thing"),
			want: "do the thing",
		},
		{
			name: "top-level source_event fallback shape",
			in:   "<memory-compiler-execution>\n{\"source_event\":\"older shape\"}\n</memory-compiler-execution>",
			want: "older shape",
		},
		{
			name: "unrecoverable contract falls back to empty",
			in:   "<memory-compiler-execution>\n{\"type\":\"memory_v5_execution_contract\"}\n</memory-compiler-execution>",
			want: "",
		},
		{
			name: "non-contract content is untouched",
			in:   "just a normal prompt",
			want: "just a normal prompt",
		},
		{
			name: "hook context prefix is stripped",
			in:   "<hook-context event=\"SessionStart\">\nLoad conventions.\n</hook-context>\n\nship it",
			want: "ship it",
		},
		{
			name: "active goal prefix is stripped",
			in:   "<active-goal>\nFix all bugs\n</active-goal>\n\nfix the auth bug",
			want: "fix the auth bug",
		},
		{
			name: "automatic memory recall suffix is stripped",
			in:   "fix AuthHandler\n\n<memory-recall>\n- recalled fact\n</memory-recall>",
			want: "fix AuthHandler",
		},
		{
			name: "active goal after other transient prefixes is stripped",
			in: "<reasoning-language>\nuse Chinese\n</reasoning-language>\n\n" +
				"<memory-update>\n- note\n</memory-update>\n\n" +
				"<active-goal>\nDo X\n</active-goal>\n\nhelp me",
			want: "help me",
		},
		{
			name: "capability route prefix is stripped",
			in:   "<capability-route version=\"1\">\nRelevant capabilities:\n- skill:review prefer\n</capability-route>\n\nreview this",
			want: "review this",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := StripTransientUserBlocks(tc.in); got != tc.want {
				t.Fatalf("StripTransientUserBlocks(%q) = %q, want %q", tc.in, got, tc.want)
			}
		})
	}
}

// TestUserPreviewTextPreservesCompiledTurnPrompt is the regression for the bot
// finding: a session whose first turn was compiled must still show the user's
// prompt in history/sidebar previews, not a blank line.
func TestUserPreviewTextPreservesCompiledTurnPrompt(t *testing.T) {
	in := realContract("ship the refactor")
	if got := UserPreviewText(in); got != "ship the refactor" {
		t.Fatalf("UserPreviewText = %q, want %q (compiled turn must not blank the preview)", got, "ship the refactor")
	}
}

// TestSessionPreviewFromMessagesPreservesCompiledFirstTurn proves the end-to-end
// preview path (used for the picker/sidebar) recovers the prompt when the first
// persisted user turn is a compiled contract.
func TestSessionPreviewFromMessagesPreservesCompiledFirstTurn(t *testing.T) {
	msgs := []provider.Message{
		{Role: provider.RoleSystem, Content: "sys"},
		{Role: provider.RoleUser, Content: realContract("add pagination to the users endpoint")},
		{Role: provider.RoleAssistant, Content: "done"},
	}
	preview, turns := SessionPreviewFromMessages(msgs)
	if preview != "add pagination to the users endpoint" {
		t.Fatalf("preview = %q, want the compiled turn's source_event", preview)
	}
	if turns != 1 {
		t.Fatalf("user turns = %d, want 1", turns)
	}
}

// Reproduces #5361: the v1.12.0 goal loop (fixed in #5387) accreted nested
// memory-compiler-execution contracts — each turn's source_event string
// embedded the previous turn's full <memory-compiler-execution> block. The
// non-greedy unwrap regex stops at the FIRST </memory-compiler-execution>
// (which is inside the outer contract's JSON string), so it captures a
// truncated, invalid JSON body and leaves dangling tag/JSON garbage in the
// transcript ("一堆字符串"). Existing corrupted sessions must still render
// cleanly, so the display layer must unwrap robustly.
func TestUserPreviewTextUnwrapsNestedCompilerContracts(t *testing.T) {
	// Deeply accreted contract (a long goal loop re-compiled the echoed contract
	// many times). Two unwrap passes are not enough for N levels.
	deep := "fix the login bug"
	for range 6 {
		deep = mcContract(t, "follow-up step\n"+deep)
	}
	assertNoContractLeak(t, UserPreviewText(deep), "follow-up step")

	// A dangling / truncated block (streaming cut, or the model echoing a partial
	// contract) has no closing tag, so the strict regex never matches it.
	partial := "do the thing\n<memory-compiler-execution>\n{\"planner_ir\":{\"source_event\":\"do the thing\"," + strings.Repeat("x", 40)
	assertNoContractLeak(t, UserPreviewText(partial), "do the thing")
}

func assertNoContractLeak(t *testing.T, got, want string) {
	t.Helper()
	if strings.Contains(got, "<memory-compiler-execution>") || strings.Contains(got, "</memory-compiler-execution>") {
		t.Fatalf("preview leaked a contract tag (raw JSON shown to the user):\n%q", got)
	}
	if strings.Contains(got, "planner_ir") || strings.Contains(got, "memory_v5_execution_contract") {
		t.Fatalf("preview leaked contract JSON:\n%q", got)
	}
	if !strings.Contains(got, want) {
		t.Fatalf("preview lost the user's actual text %q, got:\n%q", want, got)
	}
}

// mcContract builds a <memory-compiler-execution> block whose
// planner_ir.source_event is the given text, matching the real contract shape.
func mcContract(t *testing.T, sourceEvent string) string {
	t.Helper()
	body, err := json.Marshal(struct {
		Type      string `json:"type"`
		PlannerIR struct {
			Version     int    `json:"version"`
			SourceEvent string `json:"source_event"`
		} `json:"planner_ir"`
	}{Type: "memory_v5_execution_contract", PlannerIR: struct {
		Version     int    `json:"version"`
		SourceEvent string `json:"source_event"`
	}{Version: 5, SourceEvent: sourceEvent}})
	if err != nil {
		t.Fatal(err)
	}
	return "<memory-compiler-execution>\n" + string(body) + "\n</memory-compiler-execution>"
}

func TestIsHostRecoveryGuidance(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want bool
	}{
		{name: "tool failed", in: HostRecoveryGuidanceToolFailedPrefix + ", continue unrelated work automatically.", want: true},
		{name: "transient", in: HostRecoveryGuidanceTransientPrefix + " Inspect its current state.", want: true},
		{name: "steer notice prefix", in: "↪ " + HostRecoveryGuidanceToolFailedPrefix + ", continue.", want: true},
		{name: "user quoting failure", in: "A tool failed yesterday, please retry the install.", want: false},
		{name: "user steer", in: "改用 Pillow 10 验证", want: false},
	}
	for _, tc := range cases {
		if got := IsHostRecoveryGuidance(tc.in); got != tc.want {
			t.Errorf("%s: IsHostRecoveryGuidance(%q) = %v, want %v", tc.name, tc.in, got, tc.want)
		}
	}
	if text, ok := VisibleSteerText(MidTurnSteerPrefix + "\n改用 Pillow 10 验证"); !ok || text != "改用 Pillow 10 验证" {
		t.Fatalf("VisibleSteerText user steer = %q %v", text, ok)
	}
	if text, ok := VisibleSteerText(MidTurnSteerPrefix + "\n" + HostRecoveryGuidanceToolFailedPrefix + ", continue."); ok {
		t.Fatalf("VisibleSteerText host recovery = %q, want hidden", text)
	}
}

func TestMessageOriginIsAuthoritativeWithLegacyFallback(t *testing.T) {
	hostText := completionContinueTailMessage()
	for _, tc := range []struct {
		name string
		msg  provider.Message
		want bool
	}{
		{name: "new host without keyword", msg: provider.Message{Role: provider.RoleUser, Origin: provider.MessageOriginHost, Content: "continue normally", RawContent: "user-looking raw text"}, want: true},
		{name: "new user quoting host text", msg: provider.Message{Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: hostText}, want: false},
		{name: "legacy host text", msg: provider.Message{Role: provider.RoleUser, Content: hostText}, want: true},
		{name: "legacy ordinary user", msg: provider.Message{Role: provider.RoleUser, Content: "continue normally"}, want: false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			if got := IsHostGeneratedUserMessage(tc.msg); got != tc.want {
				t.Fatalf("IsHostGeneratedUserMessage(%+v) = %v, want %v", tc.msg, got, tc.want)
			}
		})
	}
}

func TestRawSteerRemainsGuidanceNotAUserTurn(t *testing.T) {
	msg := provider.Message{
		Role: provider.RoleUser, Origin: provider.MessageOriginUser,
		Content: midTurnSteerMessage("use the smaller patch"), RawContent: "use the smaller patch",
	}
	if IsUserAuthoredTurnMessage(msg) {
		t.Fatal("a real steer with RawContent must not start a new user turn")
	}
}
