package main

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"reasonix/internal/agent"
	"reasonix/internal/control"
	"reasonix/internal/memory"
	"reasonix/internal/provider"
)

func TestMemorySuggestionsReturnsNonNilArraysBeforeStartup(t *testing.T) {
	isolateDesktopUserDirs(t)

	view := NewApp().MemorySuggestions()
	if view.Memories == nil || view.Skills == nil {
		t.Fatalf("MemorySuggestions() arrays must be non-nil before startup: %+v", view)
	}
	raw, err := json.Marshal(view)
	if err != nil {
		t.Fatalf("marshal MemorySuggestions(): %v", err)
	}
	for _, bad := range []string{`"memories":null`, `"skills":null`} {
		if strings.Contains(string(raw), bad) {
			t.Fatalf("MemorySuggestions() JSON contains %s; frontend expects []: %s", bad, raw)
		}
	}
}

func TestMemorySuggestionsAcceptMemoryCandidate(t *testing.T) {
	isolateDesktopUserDirs(t)
	userDir := t.TempDir()
	cwd := t.TempDir()
	sessionDir := t.TempDir()
	store := memory.StoreFor(userDir, cwd)
	writeSuggestionSession(t, sessionDir, "pref.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复，除非我明确要求英文。"},
		provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
	)

	app := NewApp()
	app.setTestCtrl(control.New(control.Options{
		Memory:     &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
		SessionDir: sessionDir,
	}), "test-model")
	app.tabs["test"].WorkspaceRoot = cwd

	view := app.MemorySuggestions()
	if len(view.Memories) == 0 {
		t.Fatalf("MemorySuggestions() memories = %+v, want at least one candidate", view.Memories)
	}
	if view.Memories[0].Scope != string(memory.FactScopeProject) {
		t.Fatalf("candidate scope = %q, want project", view.Memories[0].Scope)
	}
	path, err := app.AcceptMemorySuggestion(view.Memories[0])
	if err != nil {
		t.Fatalf("AcceptMemorySuggestion: %v", err)
	}
	if path == "" {
		t.Fatal("AcceptMemorySuggestion returned empty path")
	}
	got := store.List()
	if len(got) != 1 || got[0].Scope != memory.FactScopeProject || !strings.Contains(got[0].Body, "中文回复") {
		t.Fatalf("saved memories = %+v, want confirmed candidate body", got)
	}
}

func TestMemorySuggestionsForTabUsesSelectedTab(t *testing.T) {
	isolateDesktopUserDirs(t)
	activeUserDir := t.TempDir()
	selectedUserDir := t.TempDir()
	activeCwd := t.TempDir()
	selectedCwd := t.TempDir()
	activeSessionDir := t.TempDir()
	selectedSessionDir := t.TempDir()
	activeStore := memory.StoreFor(activeUserDir, activeCwd)
	selectedStore := memory.StoreFor(selectedUserDir, selectedCwd)
	writeSuggestionSession(t, selectedSessionDir, "selected.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复，除非我明确要求英文。"},
		provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
	)

	app := NewApp()
	app.setTestCtrl(control.New(control.Options{
		Memory:     &memory.Set{Store: activeStore, CWD: activeCwd, UserDir: activeUserDir},
		SessionDir: activeSessionDir,
	}), "test-model")
	app.tabs["test"].WorkspaceRoot = activeCwd
	app.tabs["selected"] = &WorkspaceTab{
		ID:            "selected",
		Scope:         "project",
		WorkspaceRoot: selectedCwd,
		Ctrl: control.New(control.Options{
			Memory:     &memory.Set{Store: selectedStore, CWD: selectedCwd, UserDir: selectedUserDir},
			SessionDir: selectedSessionDir,
		}),
		Ready:       true,
		disabledMCP: map[string]ServerView{},
	}

	if view := app.MemorySuggestions(); len(view.Memories) != 0 {
		t.Fatalf("active tab suggestions = %+v, want none", view.Memories)
	}
	view := app.MemorySuggestionsForTab("selected")
	if len(view.Memories) == 0 {
		t.Fatalf("MemorySuggestionsForTab(selected) memories = %+v, want at least one candidate", view.Memories)
	}
	path, err := app.AcceptMemorySuggestionForTab("selected", view.Memories[0])
	if err != nil {
		t.Fatalf("AcceptMemorySuggestionForTab: %v", err)
	}
	if !strings.HasPrefix(path, selectedStore.Dir) && !strings.HasPrefix(path, selectedStore.GlobalDir) {
		t.Fatalf("memory path = %q, want selected store under %q or %q", path, selectedStore.Dir, selectedStore.GlobalDir)
	}
	if got := activeStore.List(); len(got) != 0 {
		t.Fatalf("active store should remain untouched, got %+v", got)
	}
	got := selectedStore.List()
	if len(got) != 1 || !strings.Contains(got[0].Body, "中文回复") {
		t.Fatalf("selected store = %+v, want confirmed candidate body", got)
	}

	skillPath, err := app.AcceptSkillSuggestionForTab("selected", SkillSuggestion{
		ID:          "selected-skill",
		Name:        "selected-workflow",
		Description: "Selected workspace workflow",
		Scope:       "project",
		Body:        "Use the selected workspace context before changing files.",
	})
	if err != nil {
		t.Fatalf("AcceptSkillSuggestionForTab: %v", err)
	}
	wantSkillPath := filepath.Join(selectedCwd, ".reasonix", "skills", "selected-workflow", "SKILL.md")
	if skillPath != wantSkillPath {
		t.Fatalf("skill path = %q, want %q", skillPath, wantSkillPath)
	}
	if _, err := os.Stat(filepath.Join(activeCwd, ".reasonix", "skills", "selected-workflow", "SKILL.md")); !os.IsNotExist(err) {
		t.Fatalf("active workspace should not receive selected skill, stat err = %v", err)
	}
	body, err := os.ReadFile(skillPath)
	if err != nil {
		t.Fatalf("read selected skill: %v", err)
	}
	if !strings.Contains(string(body), "selected workspace context") {
		t.Fatalf("selected skill body missing candidate content:\n%s", body)
	}
}

func TestMemorySuggestionsAcceptSkillCandidate(t *testing.T) {
	isolateDesktopUserDirs(t)
	userDir := t.TempDir()
	cwd := t.TempDir()
	sessionDir := t.TempDir()
	store := memory.StoreFor(userDir, cwd)
	writeSuggestionSession(t, sessionDir, "pr-a.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "把这个 PR 合并到本地并说明主要做了什么。"},
		provider.Message{Role: provider.RoleAssistant, Content: "已检查。"},
	)
	writeSuggestionSession(t, sessionDir, "pr-b.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "解决该 pr 下机器人提出来的问题，合理的问题进行修复。"},
		provider.Message{Role: provider.RoleAssistant, Content: "已处理。"},
	)

	app := NewApp()
	app.setTestCtrl(control.New(control.Options{
		Memory:     &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
		SessionDir: sessionDir,
	}), "test-model")
	app.tabs["test"].WorkspaceRoot = cwd

	view := app.MemorySuggestions()
	var candidate SkillSuggestion
	for _, item := range view.Skills {
		if item.Name == "reasonix-pr-followup" {
			candidate = item
			break
		}
	}
	if candidate.Name == "" {
		t.Fatalf("MemorySuggestions() skills = %+v, want reasonix-pr-followup", view.Skills)
	}
	path, err := app.AcceptSkillSuggestion(candidate)
	if err != nil {
		t.Fatalf("AcceptSkillSuggestion: %v", err)
	}
	wantSuffix := filepath.Join(".reasonix", "skills", "reasonix-pr-followup", "SKILL.md")
	if !strings.HasSuffix(path, wantSuffix) {
		t.Fatalf("skill path = %q, want suffix %q", path, wantSuffix)
	}
	body, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read skill: %v", err)
	}
	if !strings.Contains(string(body), "Review or update a Reasonix GitHub PR") {
		t.Fatalf("skill body missing description: %s", body)
	}
}

func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider.Message) {
	t.Helper()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	sess := agent.NewSession("")
	for _, msg := range messages {
		sess.Add(msg)
	}
	if err := sess.Save(filepath.Join(dir, name)); err != nil {
		t.Fatalf("save session %s: %v", name, err)
	}
}

// TestHistoryEnglishCandidateNameBackwardCompat: an English statement whose
// asciiSlug is short (<56 chars) must produce the same Name as old code
// (plain slug, no hash suffix), so an already-accepted memory under the old
// Name is not duplicated after upgrade.
func TestHistoryEnglishCandidateNameBackwardCompat(t *testing.T) {
	isolateDesktopUserDirs(t)
	userDir := t.TempDir()
	cwd := t.TempDir()
	sessionDir := t.TempDir()
	store := memory.StoreFor(userDir, cwd)
	writeSuggestionSession(t, sessionDir, "en.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "Always prefer English for code comments."},
		provider.Message{Role: provider.RoleAssistant, Content: "Got it."},
	)

	app := NewApp()
	app.setTestCtrl(control.New(control.Options{
		Memory:     &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
		SessionDir: sessionDir,
	}), "test-model")
	app.tabs["test"].WorkspaceRoot = cwd

	view := app.MemorySuggestions()
	if len(view.Memories) == 0 {
		t.Fatalf("no candidates")
	}
	// Old code: suggestionName("", statement, "memory-candidate-1") = asciiSlug(statement)
	oldName := asciiSlug("Always prefer English for code comments.")
	if view.Memories[0].Name != oldName {
		t.Fatalf("Name = %q, want old-compatible %q (no hash suffix for short ASCII slugs)", view.Memories[0].Name, oldName)
	}
}

// TestHistoryMemoryCandidateNamesUniqueForCJK: two pure-CJK statements that
// differ in content but produce the same empty asciiSlug must still get
// distinct Name/ID. Without the hash suffix they would both fall back to
// "memory-candidate-<ordinal>" — but ordinals depend on iteration order and
// wouldn't survive refresh, and Store.Save overwrites by name.
func TestHistoryMemoryCandidateNamesUniqueForCJK(t *testing.T) {
	isolateDesktopUserDirs(t)
	userDir := t.TempDir()
	cwd := t.TempDir()
	sessionDir := t.TempDir()
	store := memory.StoreFor(userDir, cwd)
	// Two pure-CJK "always" statements that pass extractMemoryStatement but
	// share the exact same empty asciiSlug.
	writeSuggestionSession(t, sessionDir, "zh-a.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "以后始终使用甲方案处理合并冲突。"},
		provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
	)
	writeSuggestionSession(t, sessionDir, "zh-b.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "以后始终使用乙方案处理部署回滚。"},
		provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
	)

	app := NewApp()
	app.setTestCtrl(control.New(control.Options{
		Memory:     &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
		SessionDir: sessionDir,
	}), "test-model")
	app.tabs["test"].WorkspaceRoot = cwd

	view := app.MemorySuggestions()
	if len(view.Memories) < 2 {
		t.Fatalf("memories = %+v, want at least 2 CJK candidates", view.Memories)
	}
	names := map[string]bool{}
	ids := map[string]bool{}
	for _, m := range view.Memories {
		if names[m.Name] {
			t.Fatalf("duplicate Name %q among history candidates", m.Name)
		}
		if ids[m.ID] {
			t.Fatalf("duplicate ID %q among history candidates", m.ID)
		}
		names[m.Name] = true
		ids[m.ID] = true
	}

	// Accept both → two distinct persisted memories.
	for _, c := range view.Memories {
		if _, err := app.AcceptMemorySuggestion(c); err != nil {
			t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
		}
	}
	saved := store.List()
	if len(saved) != len(view.Memories) {
		t.Fatalf("saved %d memories, want %d (Name collision caused overwrite)", len(saved), len(view.Memories))
	}
}

// TestHistoryMemoryCandidateNamesStableAcrossRefreshes: the hash suffix must
// be derived from the statement, not from iteration order or random state, so
// a refresh keeps the same ID and the frontend's accepted-state map stays valid.
func TestHistoryMemoryCandidateNamesStableAcrossRefreshes(t *testing.T) {
	isolateDesktopUserDirs(t)
	userDir := t.TempDir()
	cwd := t.TempDir()
	sessionDir := t.TempDir()
	store := memory.StoreFor(userDir, cwd)
	writeSuggestionSession(t, sessionDir, "pref.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复，除非我明确要求英文。"},
		provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
	)

	app := NewApp()
	app.setTestCtrl(control.New(control.Options{
		Memory:     &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
		SessionDir: sessionDir,
	}), "test-model")
	app.tabs["test"].WorkspaceRoot = cwd

	first := app.MemorySuggestions()
	second := app.MemorySuggestions()
	if len(first.Memories) == 0 || len(first.Memories) != len(second.Memories) {
		t.Fatalf("memories counts differ across refreshes: %d vs %d", len(first.Memories), len(second.Memories))
	}
	for i := range first.Memories {
		if first.Memories[i].ID != second.Memories[i].ID || first.Memories[i].Name != second.Memories[i].Name {
			t.Fatalf("refresh changed candidate #%d: %q/%q → %q/%q",
				i, first.Memories[i].Name, first.Memories[i].ID,
				second.Memories[i].Name, second.Memories[i].ID)
		}
	}
}

func TestMemorySuggestionsDeduplicateAllScopedFactsAndInstructionBodies(t *testing.T) {
	isolateDesktopUserDirs(t)
	userDir := t.TempDir()
	cwd := t.TempDir()
	sessionDir := t.TempDir()
	store := memory.StoreFor(userDir, cwd)
	if _, err := (memory.Store{Dir: store.GlobalDir}).Save(memory.Memory{
		Name: "response-language", Description: "Global response language", Scope: memory.FactScopeGlobal, Type: memory.TypeUser,
		Body: "Always answer in Chinese unless the user explicitly asks for English.",
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := (memory.Store{Dir: store.Dir}).Save(memory.Memory{
		Name: "response-language-project", Description: "Project response language", Scope: memory.FactScopeProject, Type: memory.TypeProject,
		Body: "Always answer in Chinese unless the user explicitly asks for English.",
	}); err != nil {
		t.Fatal(err)
	}
	set := &memory.Set{Store: store, CWD: cwd, UserDir: userDir, Docs: []memory.Source{{Body: "Always use tabs for indentation."}}}
	writeSuggestionSession(t, sessionDir, "dedupe.jsonl",
		provider.Message{Role: provider.RoleUser, Content: "Always answer in Chinese unless the user explicitly asks for English."},
		provider.Message{Role: provider.RoleUser, Content: "Always use tabs for indentation."},
	)
	got := suggestMemories(set, loadSuggestionSessions(sessionDir, suggestionSessionLimit))
	if len(got) != 0 {
		t.Fatalf("suggestions = %+v, want all candidates covered by scoped facts/docs to be omitted", got)
	}
}
