package cli

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"reflect"
	"strings"
	"sync"
	"testing"
)

type sessionRequestLog struct {
	mu       sync.Mutex
	requests []string
}

func (l *sessionRequestLog) append(r *http.Request) {
	l.mu.Lock()
	defer l.mu.Unlock()
	entry := r.Method + " " + r.URL.Path
	if r.URL.RawQuery != "" {
		entry += "?" + r.URL.RawQuery
	}
	l.requests = append(l.requests, entry)
}

func (l *sessionRequestLog) all() []string {
	l.mu.Lock()
	defer l.mu.Unlock()
	return append([]string(nil), l.requests...)
}

func sessionCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) {
	t.Helper()
	log := &sessionRequestLog{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.append(r)
		w.Header().Set("Content-Type", "application/json")
		switch {
		case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions":
			active := r.URL.Query().Get("active")
			switch active {
			case "false":
				_, _ = io.WriteString(w, `{"sessions":[`+
					sessionJSON("demo-old", "demo", "worker", "terminated", true)+`,`+
					sessionJSON("demo-orch", "demo", "orchestrator", "terminated", true)+`]}`)
			default:
				_, _ = io.WriteString(w, `{"sessions":[`+
					sessionJSON("demo-2", "demo", "orchestrator", "idle", false)+`,`+
					sessionJSON("demo-1", "demo", "worker", "working", false)+`]}`)
			}
		case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1":
			_, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "working", false)+`}`)
		case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
			_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
		case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/pr/claim":
			var req claimPRRequest
			if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
				http.Error(w, err.Error(), http.StatusBadRequest)
				return
			}
			if !req.AllowTakeover {
				w.WriteHeader(http.StatusConflict)
				_, _ = io.WriteString(w, `{"error":"conflict","code":"PR_CLAIMED_BY_ACTIVE_SESSION","message":"PR is already claimed by active session demo-2 (omit --no-takeover to steal)"}`)
				return
			}
			_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","prs":[{"url":`+jsonQuote(req.PR)+`,"number":142,"state":"open","ci":"passing","review":"review_required","mergeability":"mergeable","reviewComments":false,"updatedAt":"2026-06-04T12:00:00Z"}],"branchChanged":true,"takenOverFrom":["demo-0"]}`)
		case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/cleanup":
			_, _ = io.WriteString(w, `{"ok":true,"cleaned":["demo-old","demo-orch"],"skipped":[]}`)
		case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/kill":
			_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","freed":true}`)
		case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/restore":
			_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`)
		case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/sessions/demo-1":
			var req sessionRenameRequest
			if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
				http.Error(w, err.Error(), http.StatusBadRequest)
				return
			}
			_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","displayName":`+jsonQuote(req.DisplayName)+`}`)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(srv.Close)
	return srv, log
}

func sessionJSON(id, project, kind, status string, terminated bool) string {
	b, _ := json.Marshal(map[string]any{
		"id":           id,
		"projectId":    project,
		"kind":         kind,
		"harness":      "codex",
		"displayName":  "Current Name",
		"activity":     map[string]any{"state": "idle", "lastActivityAt": "2026-06-02T12:00:00Z"},
		"isTerminated": terminated,
		"createdAt":    "2026-06-02T11:00:00Z",
		"updatedAt":    "2026-06-02T12:00:00Z",
		"status":       status,
	})
	return string(b)
}

func jsonQuote(s string) string {
	b, _ := json.Marshal(s)
	return string(b)
}

func TestSessionList_ProjectFilterAndDefaultFiltering(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "ls", "--project", "demo")
	if err != nil {
		t.Fatalf("session ls failed: %v\nstderr=%s", err, errOut)
	}
	if !strings.Contains(out, "demo:") || !strings.Contains(out, "demo-1") {
		t.Fatalf("output missing worker session:\n%s", out)
	}
	if strings.Contains(out, "demo-2") {
		t.Fatalf("orchestrator session should be hidden without --all:\n%s", out)
	}
	if !strings.Contains(out, "1 terminated session hidden") {
		t.Fatalf("hidden terminated hint missing:\n%s", out)
	}
	want := []string{
		"GET /api/v1/sessions?active=true&project=demo",
		"GET /api/v1/sessions?active=false&project=demo",
	}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

func TestSessionList_JSONOutputDecodes(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, _ := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "ls", "--project", "demo", "--json")
	if err != nil {
		t.Fatalf("session ls --json failed: %v\nstderr=%s", err, errOut)
	}
	var got sessionListOutput
	if err := json.Unmarshal([]byte(out), &got); err != nil {
		t.Fatalf("session ls --json output is not decodable: %v\noutput=%s", err, out)
	}
	if got.Meta.HiddenTerminatedCount != 1 {
		t.Fatalf("hiddenTerminatedCount = %d, want 1", got.Meta.HiddenTerminatedCount)
	}
	if len(got.Data) != 1 {
		t.Fatalf("len(data) = %d, want 1; data=%#v", len(got.Data), got.Data)
	}
	if got.Data[0].ID != "demo-1" || got.Data[0].ProjectID != "demo" || got.Data[0].Role != "worker" {
		t.Fatalf("unexpected JSON entry: %#v", got.Data[0])
	}
}

func TestSessionGet_SuccessWithProjectScope(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "get", "demo-1", "-p", "demo")
	if err != nil {
		t.Fatalf("session get failed: %v\nstderr=%s", err, errOut)
	}
	if !strings.Contains(out, "id: demo-1") || !strings.Contains(out, "project: demo") {
		t.Fatalf("unexpected get output:\n%s", out)
	}
	want := []string{"GET /api/v1/sessions/demo-1"}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

func TestSessionGet_JSONOutputDecodes(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, _ := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "get", "demo-1", "--project", "demo", "--json")
	if err != nil {
		t.Fatalf("session get --json failed: %v\nstderr=%s", err, errOut)
	}
	var got sessionResponse
	if err := json.Unmarshal([]byte(out), &got); err != nil {
		t.Fatalf("session get --json output is not decodable: %v\noutput=%s", err, out)
	}
	if got.Session.ID != "demo-1" || got.Session.ProjectID != "demo" || got.Session.Status != "working" {
		t.Fatalf("unexpected session JSON: %#v", got.Session)
	}
}

func TestSessionKill_SuccessWithProjectScope(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "kill", "demo-1", "--project", "demo")
	if err != nil {
		t.Fatalf("session kill failed: %v\nstderr=%s", err, errOut)
	}
	if !strings.Contains(out, "session demo-1 killed") {
		t.Fatalf("unexpected kill output:\n%s", out)
	}
	want := []string{"GET /api/v1/sessions/demo-1", "POST /api/v1/sessions/demo-1/kill"}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

// TestSessionKill_PreservedWorkspaceNote: freed=false means the daemon
// terminated the session but kept the worktree (uncommitted changes are never
// force-deleted) — the CLI must say so instead of implying a full teardown.
func TestSessionKill_PreservedWorkspaceNote(t *testing.T) {
	cfg := setConfigEnv(t)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/kill" {
			_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","freed":false}`)
			return
		}
		http.NotFound(w, r)
	}))
	t.Cleanup(srv.Close)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "kill", "demo-1")
	if err != nil {
		t.Fatalf("session kill failed: %v\nstderr=%s", err, errOut)
	}
	if !strings.Contains(out, "session demo-1 killed (workspace preserved)") {
		t.Fatalf("unexpected kill output:\n%s", out)
	}
}

func TestSessionRestore_SuccessWithProjectScope(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "restore", "demo-1", "-p", "demo")
	if err != nil {
		t.Fatalf("session restore failed: %v\nstderr=%s", err, errOut)
	}
	if !strings.Contains(out, "session demo-1 restored") || !strings.Contains(out, "project: demo") {
		t.Fatalf("unexpected restore output:\n%s", out)
	}
	want := []string{"GET /api/v1/sessions/demo-1", "POST /api/v1/sessions/demo-1/restore"}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

func TestSessionCleanup_YesSkipsPrompt(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		In:           strings.NewReader("no\n"),
		ProcessAlive: func(int) bool { return true },
	}, "session", "cleanup", "--project", "demo", "--yes")
	if err != nil {
		t.Fatalf("session cleanup failed: %v\nstderr=%s", err, errOut)
	}
	if strings.Contains(out, "Type yes to confirm") {
		t.Fatalf("--yes should skip confirmation prompt:\n%s", out)
	}
	for _, want := range []string{"Checking for completed sessions", "Would clean demo-old", "Would clean demo-orch", "Cleaned: demo-old", "Cleaned: demo-orch", "Cleanup complete. 2 sessions cleaned."} {
		if !strings.Contains(out, want) {
			t.Fatalf("cleanup output missing %q:\n%s", want, out)
		}
	}
	want := []string{
		"GET /api/v1/sessions?active=false&project=demo",
		"POST /api/v1/sessions/cleanup?project=demo",
	}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

// TestSessionCleanup_ReportsSkippedWorkspaces: a session whose workspace was
// preserved must be listed with its reason and counted in the summary —
// previously the CLI printed "Would clean N" then "0 sessions cleaned" with no
// explanation, leaking workspaces invisibly.
func TestSessionCleanup_ReportsSkippedWorkspaces(t *testing.T) {
	cfg := setConfigEnv(t)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch {
		case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions":
			_, _ = io.WriteString(w, `{"sessions":[`+
				sessionJSON("demo-old", "demo", "worker", "terminated", true)+`,`+
				sessionJSON("demo-orch", "demo", "orchestrator", "terminated", true)+`]}`)
		case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/cleanup":
			_, _ = io.WriteString(w, `{"ok":true,"cleaned":["demo-old"],"skipped":[{"sessionId":"demo-orch","reason":"workspace has uncommitted changes"}]}`)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(srv.Close)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "cleanup", "--project", "demo", "--yes")
	if err != nil {
		t.Fatalf("session cleanup failed: %v\nstderr=%s", err, errOut)
	}
	for _, want := range []string{
		"Cleaned: demo-old",
		"Skipped: demo-orch (workspace has uncommitted changes)",
		"Cleanup complete. 1 session cleaned, 1 skipped.",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("cleanup output missing %q:\n%s", want, out)
		}
	}
}

func TestSessionCleanup_PromptFailsWithoutInput(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, _, err := executeCLI(t, Deps{
		In:           strings.NewReader(""),
		ProcessAlive: func(int) bool { return true },
	}, "session", "cleanup", "--project", "demo")
	if err == nil {
		t.Fatal("expected cleanup prompt without input to fail")
	}
	if got := ExitCode(err); got != 1 {
		t.Fatalf("exit code = %d, want 1", got)
	}
	if !strings.Contains(out, "Type yes to confirm") {
		t.Fatalf("output missing confirmation prompt:\n%s", out)
	}
	want := []string{"GET /api/v1/sessions?active=false&project=demo"}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

func TestSessionRename_SuccessWithProjectScope(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "rename", "demo-1", "New Name", "-p", "demo")
	if err != nil {
		t.Fatalf("session rename failed: %v\nstderr=%s", err, errOut)
	}
	if !strings.Contains(out, `session demo-1 renamed to "New Name"`) {
		t.Fatalf("unexpected rename output:\n%s", out)
	}
	want := []string{"GET /api/v1/sessions/demo-1", "PATCH /api/v1/sessions/demo-1"}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

func TestSessionCommands_MissingIDIsUsageError(t *testing.T) {
	setConfigEnv(t)
	for _, sub := range []string{"get", "kill", "restore"} {
		t.Run(sub, func(t *testing.T) {
			_, _, err := executeCLI(t, Deps{}, "session", sub)
			if err == nil {
				t.Fatal("expected missing id to fail")
			}
			if got := ExitCode(err); got != 2 {
				t.Fatalf("exit code = %d, want 2 (err=%v)", got, err)
			}
		})
	}
}

func TestSessionRename_MissingNameIsUsageError(t *testing.T) {
	setConfigEnv(t)

	_, _, err := executeCLI(t, Deps{}, "session", "rename", "demo-1")
	if err == nil {
		t.Fatal("expected missing name to fail")
	}
	if got := ExitCode(err); got != 2 {
		t.Fatalf("exit code = %d, want 2 (err=%v)", got, err)
	}
}

func TestSessionGet_ProjectMismatchDoesNotPassScope(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, _ := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	_, _, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "get", "demo-1", "--project", "other")
	if err == nil {
		t.Fatal("expected project mismatch to fail")
	}
	if got := ExitCode(err); got != 2 {
		t.Fatalf("exit code = %d, want 2", got)
	}
	if !strings.Contains(err.Error(), "not in project other") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestSessionRename_ProjectMismatchDoesNotPatch(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	_, _, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
	}, "session", "rename", "demo-1", "New Name", "--project", "other")
	if err == nil {
		t.Fatal("expected project mismatch to fail")
	}
	if got := ExitCode(err); got != 2 {
		t.Fatalf("exit code = %d, want 2", got)
	}
	if !strings.Contains(err.Error(), "not in project other") {
		t.Fatalf("unexpected error: %v", err)
	}
	want := []string{"GET /api/v1/sessions/demo-1"}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests = %#v, want %#v", got, want)
	}
}

func TestSessionClaimPR_ProjectScopeMismatchIsUsage(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, log := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "session", "claim-pr", "demo-1", "https://github.com/aoagents/agent-orchestrator/pull/142", "-p", "other")
	if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "session demo-1 is not in project other") {
		t.Fatalf("err=%v exit=%d, want project mismatch usage", err, ExitCode(err))
	}
	want := []string{"GET /api/v1/sessions/demo-1"}
	if got := log.all(); !reflect.DeepEqual(got, want) {
		t.Fatalf("requests=%#v want %#v", got, want)
	}
}

func TestSessionClaimPR_JSONAndNoTakeoverError(t *testing.T) {
	cfg := setConfigEnv(t)
	srv, _ := sessionCommandServer(t)
	writeRunFileFor(t, cfg, srv)

	out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "session", "claim-pr", "demo-1", "https://github.com/aoagents/agent-orchestrator/pull/142", "--json")
	if err != nil {
		t.Fatalf("claim-pr --json failed: %v stderr=%s", err, errOut)
	}
	var got claimPRResponse
	if err := json.Unmarshal([]byte(out), &got); err != nil || got.SessionID != "demo-1" || len(got.PRs) != 1 || got.PRs[0].Number != 142 {
		t.Fatalf("bad json err=%v got=%#v out=%s", err, got, out)
	}

	_, _, err = executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "session", "claim-pr", "demo-1", "https://github.com/aoagents/agent-orchestrator/pull/142", "--no-takeover")
	if err == nil || ExitCode(err) != 1 || !strings.Contains(err.Error(), "PR_CLAIMED_BY_ACTIVE_SESSION") {
		t.Fatalf("err=%v exit=%d, want takeover refusal runtime error", err, ExitCode(err))
	}
}

func TestSessionClaimPR_GHFallbackWhenProjectRepoMissing(t *testing.T) {
	cfg := setConfigEnv(t)
	log := &sessionRequestLog{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.append(r)
		w.Header().Set("Content-Type", "application/json")
		switch {
		case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1":
			_, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "working", false)+`}`)
		case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
			_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"","defaultBranch":"main"}}`)
		case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/pr/claim":
			var req claimPRRequest
			_ = json.NewDecoder(r.Body).Decode(&req)
			_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","prs":[{"url":`+jsonQuote(req.PR)+`,"number":142,"state":"open","ci":"passing","review":"review_required","mergeability":"mergeable","reviewComments":false,"updatedAt":"2026-06-04T12:00:00Z"}],"branchChanged":false,"takenOverFrom":[]}`)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(srv.Close)
	writeRunFileFor(t, cfg, srv)
	var ghDir string
	out, _, err := executeCLI(t, Deps{
		ProcessAlive: func(int) bool { return true },
		CommandOutputInDir: func(_ context.Context, dir, name string, args ...string) ([]byte, error) {
			ghDir = dir
			if name != "gh" {
				t.Fatalf("command name=%s", name)
			}
			return []byte("https://github.com/aoagents/agent-orchestrator\n"), nil
		},
	}, "session", "claim-pr", "demo-1", "142")
	if err != nil {
		t.Fatalf("claim-pr fallback failed: %v", err)
	}
	if ghDir != "/repo/demo" || !strings.Contains(out, "claimed PR #142") {
		t.Fatalf("ghDir=%q out=%s", ghDir, out)
	}
}
