// Smoke test for the stdio transport: it builds the real binary, runs
// it as a subprocess against an httptest mock backend, and drives the
// MCP handshake over stdin/stdout.
package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"sort"
	"testing"
	"time"

	"security-center-mcp/internal/tools"
)

const smokeAPIKey = "sc_smoke_test_key_000000"

// rpcFrame is one newline-delimited JSON-RPC message on the wire.
type rpcFrame struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      int64           `json:"id"`
	Result  json.RawMessage `json:"result"`
	Error   *struct {
		Code    int64  `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

// buildServer compiles the MCP server binary once for the whole test
// run.
func buildServer(t *testing.T) string {
	t.Helper()
	bin := filepath.Join(t.TempDir(), "security-center-mcp")
	build := exec.Command("go", "build", "-o", bin, ".")
	if out, err := build.CombinedOutput(); err != nil {
		t.Fatalf("go build: %v\n%s", err, out)
	}
	return bin
}

// startServer spawns the server with cfg env and returns a writer for
// protocol frames plus a channel of decoded stdout lines.
func startServer(t *testing.T, bin, baseURL string) (stdin *os.File, frames chan rpcFrame) {
	t.Helper()
	cmd := exec.Command(bin)
	cmd.Env = append(os.Environ(),
		"SECURITY_CENTER_BASE_URL="+baseURL,
		"SECURITY_CENTER_API_KEY="+smokeAPIKey,
	)

	in, err := cmd.StdinPipe()
	if err != nil {
		t.Fatalf("stdin pipe: %v", err)
	}
	out, err := cmd.StdoutPipe()
	if err != nil {
		t.Fatalf("stdout pipe: %v", err)
	}
	if err := cmd.Start(); err != nil {
		t.Fatalf("start server: %v", err)
	}
	t.Cleanup(func() {
		in.Close()
		cmd.Wait()
	})

	frames = make(chan rpcFrame, 16)
	go func() {
		defer close(frames)
		scanner := bufio.NewScanner(out)
		scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
		for scanner.Scan() {
			var f rpcFrame
			if err := json.Unmarshal(scanner.Bytes(), &f); err != nil {
				continue // not a protocol frame; ignore
			}
			frames <- f
		}
	}()
	return in.(*os.File), frames
}

// send writes one JSON-RPC request frame.
func send(t *testing.T, stdin *os.File, id int64, method string, params any) {
	t.Helper()
	msg := map[string]any{"jsonrpc": "2.0", "method": method}
	if id >= 0 {
		msg["id"] = id
	}
	if params != nil {
		msg["params"] = params
	}
	line, err := json.Marshal(msg)
	if err != nil {
		t.Fatalf("marshal frame: %v", err)
	}
	if _, err := fmt.Fprintf(stdin, "%s\n", line); err != nil {
		t.Fatalf("write frame: %v", err)
	}
}

// await returns the response frame with the given id, skipping
// notifications and responses to other requests.
func await(t *testing.T, frames chan rpcFrame, id int64) rpcFrame {
	t.Helper()
	deadline := time.After(10 * time.Second)
	for {
		select {
		case f, ok := <-frames:
			if !ok {
				t.Fatalf("server closed stdout before answering id %d", id)
			}
			if f.ID == id {
				return f
			}
		case <-deadline:
			t.Fatalf("timeout waiting for response id %d", id)
		}
	}
}

// initialize performs the MCP handshake (initialize + initialized).
func initialize(t *testing.T, stdin *os.File, frames chan rpcFrame) {
	t.Helper()
	send(t, stdin, 1, "initialize", map[string]any{
		"protocolVersion": "2025-06-18",
		"capabilities":    map[string]any{},
		"clientInfo":      map[string]any{"name": "smoke-test", "version": "0"},
	})
	resp := await(t, frames, 1)
	if resp.Error != nil {
		t.Fatalf("initialize: rpc error %d: %s", resp.Error.Code, resp.Error.Message)
	}
	var result struct {
		ServerInfo struct {
			Name string `json:"name"`
		} `json:"serverInfo"`
	}
	if err := json.Unmarshal(resp.Result, &result); err != nil {
		t.Fatalf("initialize: decode result: %v", err)
	}
	if result.ServerInfo.Name != "security-center-mcp" {
		t.Fatalf("initialize: serverInfo.name = %q, want security-center-mcp", result.ServerInfo.Name)
	}
	send(t, stdin, -1, "notifications/initialized", nil)
}

func TestStdioSmoke(t *testing.T) {
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/v1/caasm/assets" {
			http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
			return
		}
		if got := r.Header.Get("X-API-Key"); got != smokeAPIKey {
			http.Error(w, "bad api key "+got, http.StatusUnauthorized)
			return
		}
		if got := r.URL.Query().Get("q"); got != "web" {
			http.Error(w, "missing q=web, got "+got, http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"code":0,"message":"ok","data":{"assets":[{"id":1,"name":"web-01"}],"total":1},"timestamp":1}`)
	}))
	defer backend.Close()

	stdin, frames := startServer(t, buildServer(t), backend.URL)
	initialize(t, stdin, frames)

	// tools/list must return the 20 registry tools.
	send(t, stdin, 2, "tools/list", map[string]any{})
	resp := await(t, frames, 2)
	if resp.Error != nil {
		t.Fatalf("tools/list: rpc error %d: %s", resp.Error.Code, resp.Error.Message)
	}
	var list struct {
		Tools []struct {
			Name string `json:"name"`
		} `json:"tools"`
	}
	if err := json.Unmarshal(resp.Result, &list); err != nil {
		t.Fatalf("tools/list: decode result: %v", err)
	}
	got := make([]string, 0, len(list.Tools))
	for _, tool := range list.Tools {
		got = append(got, tool.Name)
	}
	want := make([]string, 0, 20)
	for _, tool := range tools.Registry() {
		want = append(want, tool.Name)
	}
	// The SDK serves tools in sorted order; compare as sets.
	sort.Strings(got)
	sort.Strings(want)
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("tools/list: got %d tools %v, want %d %v", len(got), got, len(want), want)
	}

	// tools/call search_assets must hit the mock backend and pass the
	// envelope's data through as the tool result.
	send(t, stdin, 3, "tools/call", map[string]any{
		"name":      "search_assets",
		"arguments": map[string]any{"q": "web"},
	})
	resp = await(t, frames, 3)
	if resp.Error != nil {
		t.Fatalf("tools/call: rpc error %d: %s", resp.Error.Code, resp.Error.Message)
	}
	var call struct {
		Content []struct {
			Type string `json:"type"`
			Text string `json:"text"`
		} `json:"content"`
		IsError bool `json:"isError"`
	}
	if err := json.Unmarshal(resp.Result, &call); err != nil {
		t.Fatalf("tools/call: decode result: %v", err)
	}
	if call.IsError {
		t.Fatalf("tools/call: isError set: %+v", call.Content)
	}
	if len(call.Content) != 1 || call.Content[0].Type != "text" {
		t.Fatalf("tools/call: unexpected content %+v", call.Content)
	}
	var data map[string]any
	if err := json.Unmarshal([]byte(call.Content[0].Text), &data); err != nil {
		t.Fatalf("tools/call: decode text payload: %v", err)
	}
	assets, ok := data["assets"].([]any)
	if !ok || len(assets) != 1 {
		t.Fatalf("tools/call: data not passed through: %s", call.Content[0].Text)
	}

	// A missing required argument must surface as JSON-RPC invalid
	// params (-32602), not as a tool result.
	send(t, stdin, 4, "tools/call", map[string]any{
		"name":      "get_asset",
		"arguments": map[string]any{},
	})
	resp = await(t, frames, 4)
	if resp.Error == nil {
		t.Fatalf("tools/call get_asset: want rpc error, got result %s", resp.Result)
	}
	if resp.Error.Code != -32602 {
		t.Fatalf("tools/call get_asset: error code = %d, want -32602 (%s)", resp.Error.Code, resp.Error.Message)
	}
}
