package mcp

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"
)

// The stdio transport is tested against a real subprocess — this test binary,
// re-executed with MAVEN_MCP_FAKE set, acting as a minimal MCP server. No
// python, no fixture file, no network.
func TestMain(m *testing.M) {
	if os.Getenv("MAVEN_MCP_FAKE") != "" {
		fakeStdioServer()
		return
	}
	os.Exit(m.Run())
}

func fakeStdioServer() {
	h := echoServer()
	sc := bufio.NewScanner(os.Stdin)
	out := bufio.NewWriter(os.Stdout)
	defer out.Flush()
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		var req struct {
			ID     *int64          `json:"id"`
			Method string          `json:"method"`
			Params json.RawMessage `json:"params"`
		}
		if json.Unmarshal([]byte(line), &req) != nil {
			continue
		}
		if req.ID == nil {
			// A notification gets no reply, but we emit an unrelated
			// notification so the client's frame-skipping is exercised.
			_, _ = out.WriteString("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\"}\n")
			_ = out.Flush()
			continue
		}
		// "mute" answers the handshake and then goes silent: a python server
		// that hit an unhandled exception in its own read loop but did not
		// exit is the ordinary way to get here.
		if os.Getenv("MAVEN_MCP_FAKE") == "mute" && req.Method == "tools/call" {
			select {} // never answer, never exit
		}
		// "flood" writes one enormous line with no newline in it.
		if os.Getenv("MAVEN_MCP_FAKE") == "flood" && req.Method == "tools/call" {
			for i := 0; i < 64; i++ {
				_, _ = out.Write(make([]byte, 1<<20))
			}
			_ = out.Flush()
			continue
		}
		result, rerr := h(req.Method, req.Params)
		resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
		if rerr != nil {
			resp["error"] = map[string]any{"code": rerr.Code, "message": rerr.Message}
		} else {
			resp["result"] = result
		}
		raw, _ := json.Marshal(resp)
		_, _ = out.Write(append(raw, '\n'))
		_ = out.Flush()
		if os.Getenv("MAVEN_MCP_FAKE") == "die" && req.Method == "tools/list" {
			return // hang up, so the reconnect path has something to see
		}
	}
}

func stdioManager(t *testing.T, mode string) *Manager {
	t.Helper()
	self, err := os.Executable()
	if err != nil {
		t.Skipf("no executable path: %v", err)
	}
	if _, err := exec.LookPath(self); err != nil && !strings.Contains(self, "/") {
		t.Skip("test binary not executable")
	}
	m, err := NewManager(nil, []ServerConfig{{
		Name:    "fake",
		Command: self,
		Env:     []string{"MAVEN_MCP_FAKE=" + mode},
		Enabled: true,
	}})
	if err != nil {
		t.Fatal(err)
	}
	m.Connect(context.Background())
	return m
}

func TestStdioTransportEndToEnd(t *testing.T) {
	m := stdioManager(t, "1")
	defer m.Close()
	st := m.Status()
	if len(st) != 1 || !st[0].Connected {
		t.Fatalf("status = %+v", st)
	}
	if st[0].Transport != "stdio" {
		t.Fatalf("transport = %q", st[0].Transport)
	}
	if got := len(m.Tools()); got != 2 {
		t.Fatalf("tools = %d", got)
	}
	out, err := m.Call(context.Background(), "fake", "read_thing", map[string]any{"q": "стдио"})
	if err != nil {
		t.Fatalf("call: %v", err)
	}
	if out != "read_thing:стдио" {
		t.Fatalf("out = %q", out)
	}
	res := m.Resources(context.Background())
	if len(res) != 1 || res[0].URI != "note://one" {
		t.Fatalf("resources = %+v", res)
	}
	body, err := m.ReadResource(context.Background(), "fake", "note://one")
	if err != nil {
		t.Fatal(err)
	}
	if body != "тело ресурса" {
		t.Fatalf("body = %q", body)
	}
}

func TestStdioServerThatDiesIsNotUsable(t *testing.T) {
	m := stdioManager(t, "die")
	defer m.Close()
	// The server hung up after tools/list; the next call must fail cleanly
	// rather than hang or panic.
	if _, err := m.Call(context.Background(), "fake", "read_thing", nil); err == nil {
		t.Fatal("a call into a dead server must error")
	}
}

func TestStdioMissingCommand(t *testing.T) {
	m, err := NewManager(nil, []ServerConfig{{
		Name: "nope", Command: "/nonexistent/mcp-server-that-is-not-there", Enabled: true,
	}})
	if err != nil {
		t.Fatal(err)
	}
	m.Connect(context.Background())
	st := m.Status()
	if st[0].Connected || st[0].Err == "" {
		t.Fatalf("a missing binary must be recorded, not fatal: %+v", st)
	}
	if got := len(m.Tools()); got != 0 {
		t.Fatalf("tools = %d", got)
	}
	if !strings.Contains(fmt.Sprint(st[0].Err), "start") {
		t.Logf("err = %q", st[0].Err)
	}
}

// A stdio server that accepts a call and then answers nothing must not wedge
// the manager. Before the read moved onto its own goroutine, the read held the
// transport lock, Refresh took that lock through alive() while holding the
// manager lock, and from then on Tools, Status and Call blocked for EVERY
// server — including turns that touch no MCP tool at all.
func TestStdioSilentServerDoesNotWedgeTheManager(t *testing.T) {
	m := stdioManager(t, "mute")
	defer m.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()
	done := make(chan error, 1)
	go func() {
		_, err := m.Call(ctx, "fake", "read_thing", nil)
		done <- err
	}()
	select {
	case err := <-done:
		if err == nil {
			t.Fatal("a call into a silent server must fail, not succeed")
		}
	case <-time.After(5 * time.Second):
		t.Fatal("the call never returned: the context is not observed during the read")
	}

	// The manager must still answer while (and after) that call was stuck.
	ready := make(chan struct{})
	go func() {
		m.Refresh(context.Background())
		m.Tools()
		m.Status()
		close(ready)
	}()
	select {
	case <-ready:
	case <-time.After(5 * time.Second):
		t.Fatal("Refresh/Tools/Status deadlocked behind the hung call")
	}
}

// One frame is bounded by the reader's buffer, not measured after the whole
// thing has already been assembled in mavend's heap.
func TestStdioOversizedFrameIsRefused(t *testing.T) {
	m := stdioManager(t, "flood")
	defer m.Close()
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if _, err := m.Call(ctx, "fake", "read_thing", nil); err == nil {
		t.Fatal("a 64 MiB frame must be refused")
	}
}
