package runtimeproc

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"
)

type fakeManager struct {
	processes []ProcessInfo
	killed    []int
	killErr   error
}

func TestRuntimeLikeProcessRecognizesKimi(t *testing.T) {
	for _, proc := range []ProcessInfo{
		{Exe: "kimi", Cmdline: "kimi --wire"},
		{Exe: "python", Cmdline: "python -m kimi_cli --wire"},
	} {
		if !runtimeLikeProcess(proc) {
			t.Fatalf("runtimeLikeProcess(%+v) = false, want Kimi runtime", proc)
		}
	}
}

func (m *fakeManager) ListProcesses(context.Context) ([]ProcessInfo, error) {
	out := make([]ProcessInfo, len(m.processes))
	copy(out, m.processes)
	return out, nil
}

func (m *fakeManager) KillTree(_ context.Context, pid int) error {
	m.killed = append(m.killed, pid)
	return m.killErr
}

func TestJanitorRequiresTwoSuspiciousScansBeforeKill(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	startedAt := time.Now()
	reg := NewRegistry(filepath.Join(t.TempDir(), "runtime-processes.json"))
	reg.Register(Entry{PID: 42, AgentID: "agent-a", Runtime: "opencode", WorkDir: workDir, StartedAt: startedAt})
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 42, Exe: "opencode", Cmdline: "opencode serve", StartedAt: startedAt}}}
	now := time.Date(2026, 5, 15, 13, 0, 0, 0, time.UTC)
	janitor := NewJanitor(JanitorOptions{
		Registry:      reg,
		Manager:       mgr,
		AgentRootDirs: []string{root},
		ActiveWorkDirs: func() map[string]struct{} {
			return nil
		},
		GracePeriod: time.Minute,
		Now: func() time.Time {
			return now
		},
	})

	first, err := janitor.ScanOnce(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(first.Killed) != 0 || len(mgr.killed) != 0 {
		t.Fatalf("first scan killed=%v manager=%v, want no kill", first.Killed, mgr.killed)
	}

	now = now.Add(time.Minute + time.Second)
	second, err := janitor.ScanOnce(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(second.Killed) != 1 || second.Killed[0].PID != 42 {
		t.Fatalf("second scan killed=%v, want pid 42", second.Killed)
	}
	if len(mgr.killed) != 1 || mgr.killed[0] != 42 {
		t.Fatalf("manager killed=%v, want [42]", mgr.killed)
	}
}

func TestJanitorSkipsActiveWorkDir(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	startedAt := time.Now()
	reg := NewRegistry(filepath.Join(t.TempDir(), "runtime-processes.json"))
	reg.Register(Entry{PID: 42, AgentID: "agent-a", Runtime: "opencode", WorkDir: workDir, StartedAt: startedAt})
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 42, Exe: "opencode", Cmdline: "opencode serve", StartedAt: startedAt}}}
	janitor := NewJanitor(JanitorOptions{
		Registry:      reg,
		Manager:       mgr,
		AgentRootDirs: []string{root},
		ActiveWorkDirs: func() map[string]struct{} {
			return map[string]struct{}{workDir: {}}
		},
		GracePeriod: 0,
	})

	for i := 0; i < 2; i++ {
		if _, err := janitor.ScanOnce(context.Background()); err != nil {
			t.Fatal(err)
		}
	}
	if len(mgr.killed) != 0 {
		t.Fatalf("killed active process: %v", mgr.killed)
	}
}

func TestJanitorSkipsActiveSessionWorkDirDiscoveredByCWD(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a", "sessions", "session-a")
	cwd := filepath.Join(workDir, "repo")
	if err := os.MkdirAll(cwd, 0o755); err != nil {
		t.Fatal(err)
	}
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 77, Exe: "node", Cmdline: "node codex", CWD: cwd}}}
	janitor := NewJanitor(JanitorOptions{
		Manager:       mgr,
		AgentRootDirs: []string{root},
		ActiveWorkDirs: func() map[string]struct{} {
			return map[string]struct{}{workDir: {}}
		},
		GracePeriod: 0,
	})

	for i := 0; i < 2; i++ {
		if _, err := janitor.ScanOnce(context.Background()); err != nil {
			t.Fatal(err)
		}
	}
	if len(mgr.killed) != 0 {
		t.Fatalf("killed active session process: %v", mgr.killed)
	}
}

func TestJanitorRegistryMatchesSessionWorkDirByCWD(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a", "sessions", "session-a")
	cwd := filepath.Join(workDir, "repo")
	if err := os.MkdirAll(cwd, 0o755); err != nil {
		t.Fatal(err)
	}
	startedAt := time.Now()
	reg := NewRegistry(filepath.Join(t.TempDir(), "runtime-processes.json"))
	reg.Register(Entry{PID: 42, AgentID: "agent-a", Runtime: "codex", WorkDir: workDir, StartedAt: startedAt})
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 42, Exe: "node", Cmdline: "node codex", CWD: cwd, StartedAt: startedAt}}}
	now := time.Date(2026, 5, 15, 13, 0, 0, 0, time.UTC)
	janitor := NewJanitor(JanitorOptions{
		Registry:      reg,
		Manager:       mgr,
		AgentRootDirs: []string{root},
		GracePeriod:   time.Second,
		Now: func() time.Time {
			return now
		},
	})

	if _, err := janitor.ScanOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	now = now.Add(2 * time.Second)
	result, err := janitor.ScanOnce(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(result.Killed) != 1 || result.Killed[0].WorkDir != cleanPath(workDir) || result.Killed[0].Reason != "registry" {
		t.Fatalf("killed=%v, want registry-owned session workdir %q", result.Killed, cleanPath(workDir))
	}
}

func TestJanitorDiscoversRuntimeProcessByCWD(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	cwd := filepath.Join(workDir, "repo")
	if err := os.MkdirAll(cwd, 0o755); err != nil {
		t.Fatal(err)
	}
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 77, Exe: "node", Cmdline: "node opencode", CWD: cwd}}}
	now := time.Date(2026, 5, 15, 13, 0, 0, 0, time.UTC)
	janitor := NewJanitor(JanitorOptions{
		Manager:       mgr,
		AgentRootDirs: []string{root},
		GracePeriod:   time.Second,
		Now: func() time.Time {
			return now
		},
	})

	if _, err := janitor.ScanOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	now = now.Add(2 * time.Second)
	result, err := janitor.ScanOnce(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(result.Killed) != 1 || result.Killed[0].WorkDir != cleanPath(workDir) {
		t.Fatalf("killed=%v, want cwd-owned workdir %q", result.Killed, cleanPath(workDir))
	}
}

func TestJanitorDiscoversRegistryDescendant(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	startedAt := time.Now()
	reg := NewRegistry(filepath.Join(t.TempDir(), "runtime-processes.json"))
	reg.Register(Entry{PID: 42, AgentID: "agent-a", Runtime: "opencode", WorkDir: workDir, StartedAt: startedAt})
	mgr := &fakeManager{processes: []ProcessInfo{
		{PID: 42, Exe: "opencode", Cmdline: "opencode serve", StartedAt: startedAt},
		{PID: 43, PPID: 42, Exe: "helper", Cmdline: "helper"},
	}}
	now := time.Date(2026, 5, 15, 13, 0, 0, 0, time.UTC)
	janitor := NewJanitor(JanitorOptions{
		Registry:      reg,
		Manager:       mgr,
		AgentRootDirs: []string{root},
		GracePeriod:   time.Second,
		Now: func() time.Time {
			return now
		},
	})

	if _, err := janitor.ScanOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	now = now.Add(2 * time.Second)
	result, err := janitor.ScanOnce(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(result.Killed) != 2 {
		t.Fatalf("killed=%v, want root and descendant", result.Killed)
	}
	if got := mgr.killed; len(got) != 2 || got[0] != 42 || got[1] != 43 {
		t.Fatalf("manager killed=%v, want [42 43]", got)
	}
}

func TestJanitorIgnoresRegistryPIDReusedByDifferentRuntimeProcess(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	startedAt := time.Date(2026, 5, 15, 13, 0, 0, 0, time.UTC)
	reg := NewRegistry(filepath.Join(t.TempDir(), "runtime-processes.json"))
	reg.Register(Entry{PID: 42, AgentID: "agent-a", Runtime: "opencode", WorkDir: workDir, StartedAt: startedAt})
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 42, Exe: "opencode", Cmdline: "opencode serve", StartedAt: startedAt.Add(10 * time.Minute)}}}
	janitor := NewJanitor(JanitorOptions{
		Registry:      reg,
		Manager:       mgr,
		AgentRootDirs: []string{root},
		GracePeriod:   time.Second,
		Now: func() time.Time {
			return startedAt.Add(20 * time.Minute)
		},
	})

	for i := 0; i < 2; i++ {
		result, err := janitor.ScanOnce(context.Background())
		if err != nil {
			t.Fatal(err)
		}
		if result.Suspicious != 0 || len(result.Killed) != 0 {
			t.Fatalf("scan %d result=%+v, want no suspicious kill for recycled pid", i, result)
		}
	}
	if len(mgr.killed) != 0 {
		t.Fatalf("killed recycled pid: %v", mgr.killed)
	}
}

func TestJanitorPrunesSuspiciousStateForExitedProcesses(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	cwd := filepath.Join(workDir, "repo")
	if err := os.MkdirAll(cwd, 0o755); err != nil {
		t.Fatal(err)
	}
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 77, Exe: "node", Cmdline: "node opencode", CWD: cwd}}}
	janitor := NewJanitor(JanitorOptions{Manager: mgr, AgentRootDirs: []string{root}, GracePeriod: time.Minute})

	if _, err := janitor.ScanOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	if len(janitor.suspicious) != 1 {
		t.Fatalf("suspicious=%v, want one entry after first scan", janitor.suspicious)
	}
	mgr.processes = nil
	if _, err := janitor.ScanOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	if len(janitor.suspicious) != 0 {
		t.Fatalf("suspicious=%v, want pruned after process exits", janitor.suspicious)
	}
}

func TestJanitorDoesNotKillNonRuntimeProcessInAgentWorkdir(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	cwd := filepath.Join(workDir, "repo")
	if err := os.MkdirAll(cwd, 0o755); err != nil {
		t.Fatal(err)
	}
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 99, Exe: "python", Cmdline: "python script.py", CWD: cwd}}}
	janitor := NewJanitor(JanitorOptions{Manager: mgr, AgentRootDirs: []string{root}, GracePeriod: 0})

	for i := 0; i < 2; i++ {
		result, err := janitor.ScanOnce(context.Background())
		if err != nil {
			t.Fatal(err)
		}
		if result.Suspicious != 0 || len(result.Killed) != 0 {
			t.Fatalf("scan %d result=%+v, want non-runtime process ignored", i, result)
		}
	}
	if len(mgr.killed) != 0 {
		t.Fatalf("killed non-runtime process: %v", mgr.killed)
	}
}

func TestRegistryPersistsEntries(t *testing.T) {
	path := filepath.Join(t.TempDir(), "runtime-processes.json")
	startedAt := time.Date(2026, 5, 15, 13, 0, 0, 0, time.UTC)
	reg := NewRegistry(path)
	reg.Register(Entry{PID: 42, AgentID: "agent-a", Runtime: "opencode", WorkDir: "./agent-a", StartedAt: startedAt})

	reloaded := NewRegistry(path)
	entry, ok := reloaded.EntryByPID(42)
	if !ok {
		t.Fatalf("reloaded registry missing pid 42")
	}
	if entry.AgentID != "agent-a" || entry.Runtime != "opencode" || entry.WorkDir == "" || !entry.StartedAt.Equal(startedAt) {
		t.Fatalf("reloaded entry=%+v", entry)
	}
}

func TestJanitorRetriesWhenKillTreeFails(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	cwd := filepath.Join(workDir, "repo")
	if err := os.MkdirAll(cwd, 0o755); err != nil {
		t.Fatal(err)
	}
	now := time.Date(2026, 5, 15, 13, 0, 0, 0, time.UTC)
	mgr := &fakeManager{
		processes: []ProcessInfo{{PID: 77, Exe: "node", Cmdline: "node opencode", CWD: cwd}},
		killErr:   errors.New("boom"),
	}
	janitor := NewJanitor(JanitorOptions{
		Manager:       mgr,
		AgentRootDirs: []string{root},
		GracePeriod:   time.Second,
		Now: func() time.Time {
			return now
		},
	})

	if _, err := janitor.ScanOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	now = now.Add(2 * time.Second)
	result, err := janitor.ScanOnce(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(result.Killed) != 0 || len(mgr.killed) != 1 {
		t.Fatalf("after failed kill result=%+v killed=%v, want attempted but not reported killed", result, mgr.killed)
	}
	if len(janitor.suspicious) != 1 {
		t.Fatalf("suspicious=%v, want retained for retry", janitor.suspicious)
	}

	mgr.killErr = nil
	now = now.Add(time.Second)
	result, err = janitor.ScanOnce(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(result.Killed) != 1 || len(mgr.killed) != 2 {
		t.Fatalf("after retry result=%+v killed=%v, want successful retry", result, mgr.killed)
	}
}

func TestJanitorDoesNotUseCmdlineWorkdirWithoutRuntimeLikeProcess(t *testing.T) {
	root := t.TempDir()
	workDir := filepath.Join(root, "agent-a")
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		t.Fatal(err)
	}
	mgr := &fakeManager{processes: []ProcessInfo{{PID: 88, Exe: "python", Cmdline: "python script.py " + workDir}}}
	janitor := NewJanitor(JanitorOptions{Manager: mgr, AgentRootDirs: []string{root}, GracePeriod: 0})

	for i := 0; i < 2; i++ {
		result, err := janitor.ScanOnce(context.Background())
		if err != nil {
			t.Fatal(err)
		}
		if result.Suspicious != 0 || len(result.Killed) != 0 {
			t.Fatalf("scan %d result=%+v, want cmdline path ignored without runtime-like process", i, result)
		}
	}
}
