// E2E test que arranca el binario kanban en un puerto efimero con un fake
// claude script y verifica que /api/chat/ws emite los eventos esperados.
//
// Cubre el bug del proxy Vite (ws: true) tambien en el camino directo: la
// conexion WebSocket se establece contra el puerto del backend y atraviesa
// el upgrade real de nhooyr.io/websocket.
//
// Ejecucion:
//   cd apps/kanban/e2e && go test -v -run TestE2E_ChatWS ./...
//
// Build artifacts: el test compila el binario kanban en un dir temporal.
// El fake claude tambien se escribe en ese dir.
package e2e

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"nhooyr.io/websocket"
)

// buildKanbanBinary compila el backend en un binario temporal y retorna su
// path. Se compila una vez por test.
func buildKanbanBinary(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	bin := filepath.Join(dir, "kanban")
	cmd := exec.Command("go", "build", "-tags", "fts5", "-o", bin, ".")
	cmd.Dir = "../backend"
	cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("go build kanban: %v\n%s", err, out)
	}
	return bin
}

// fakeClaudeScript writes a bash script that emits NDJSON stream-json events
// and exits 0.
func fakeClaudeBin(t *testing.T, payload string) string {
	t.Helper()
	if _, err := os.Stat("/bin/bash"); err != nil {
		t.Skip("/bin/bash not available")
	}
	dir := t.TempDir()
	path := filepath.Join(dir, "claude")
	body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n"
	if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
		t.Fatalf("write fake claude: %v", err)
	}
	return path
}

// freePort returns a random unused TCP port.
func freePort(t *testing.T) int {
	t.Helper()
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	defer l.Close()
	return l.Addr().(*net.TCPAddr).Port
}

// kanbanLauncher starts kanban on a random port with a fake claude binary
// and a fresh DB. Returns the URL and a cleanup func.
type kanbanLauncher struct {
	URL    string
	Port   int
	DBDir  string
	Cancel context.CancelFunc
	Stderr *bytes.Buffer
	Stdout *bytes.Buffer
}

func startKanban(t *testing.T, fakeClaude string) *kanbanLauncher {
	t.Helper()
	bin := buildKanbanBinary(t)
	dbDir := t.TempDir()
	dbPath := filepath.Join(dbDir, "kanban.db")
	port := freePort(t)

	ctx, cancel := context.WithCancel(context.Background())
	cmd := exec.CommandContext(ctx, bin,
		"--port", fmt.Sprint(port),
		"--db", dbPath,
		"--initial-admin", "e2e:e2etest",
	)
	cmd.Env = append(os.Environ(),
		"KANBAN_CLAUDE_BIN="+fakeClaude,
		"KANBAN_LISTEN_PORT="+fmt.Sprint(port),
		"KANBAN_INITIAL_ADMIN=e2e:e2etest",
	)
	stdout := &bytes.Buffer{}
	stderr := &bytes.Buffer{}
	cmd.Stdout = stdout
	cmd.Stderr = stderr
	if err := cmd.Start(); err != nil {
		cancel()
		t.Fatalf("start kanban: %v", err)
	}

	l := &kanbanLauncher{
		URL:    fmt.Sprintf("http://127.0.0.1:%d", port),
		Port:   port,
		DBDir:  dbDir,
		Cancel: cancel,
		Stdout: stdout,
		Stderr: stderr,
	}

	// Espera a que el server responda /api/board (publico-ish: 401 sin sesion).
	deadline := time.Now().Add(15 * time.Second)
	for time.Now().Before(deadline) {
		resp, err := http.Get(l.URL + "/api/board")
		if err == nil {
			resp.Body.Close()
			t.Cleanup(func() {
				cancel()
				_ = cmd.Wait()
				if t.Failed() {
					t.Logf("kanban stdout:\n%s", stdout.String())
					t.Logf("kanban stderr:\n%s", stderr.String())
				}
			})
			return l
		}
		time.Sleep(150 * time.Millisecond)
	}
	cancel()
	_ = cmd.Wait()
	t.Fatalf("kanban no respondio en 15s\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String())
	return nil
}

// loginGetCookie performs API login and returns the cookie jar with session.
func loginGetCookie(t *testing.T, baseURL string) http.CookieJar {
	t.Helper()
	jar, _ := cookiejar.New(nil)
	cli := &http.Client{Jar: jar, Timeout: 5 * time.Second}
	body, _ := json.Marshal(map[string]string{"username": "e2e", "password": "e2etest"})
	resp, err := cli.Post(baseURL+"/api/auth/login", "application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatalf("login: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		buf, _ := io.ReadAll(resp.Body)
		t.Fatalf("login status %d: %s", resp.StatusCode, buf)
	}
	return jar
}

// dialWS opens a WebSocket using the session cookie from the jar.
func dialWS(t *testing.T, baseURL string, jar http.CookieJar) *websocket.Conn {
	t.Helper()
	u, _ := url.Parse(baseURL)
	wsURL := "ws://" + u.Host + "/api/chat/ws"
	hdr := http.Header{}
	for _, c := range jar.Cookies(u) {
		hdr.Add("Cookie", c.Name+"="+c.Value)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr})
	if err != nil {
		t.Fatalf("ws dial %s: %v", wsURL, err)
	}
	return conn
}

type wsMsg struct {
	Type         string          `json:"type"`
	Text         string          `json:"text,omitempty"`
	Tool         string          `json:"tool,omitempty"`
	ToolID       string          `json:"tool_id,omitempty"`
	Input        json.RawMessage `json:"input,omitempty"`
	Result       string          `json:"result,omitempty"`
	IsError      bool            `json:"is_error,omitempty"`
	BoardChanged bool            `json:"board_changed,omitempty"`
	Error        string          `json:"error,omitempty"`
}

func readMsg(t *testing.T, conn *websocket.Conn) wsMsg {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_, data, err := conn.Read(ctx)
	if err != nil {
		t.Fatalf("read: %v", err)
	}
	var m wsMsg
	if err := json.Unmarshal(data, &m); err != nil {
		t.Fatalf("unmarshal %q: %v", string(data), err)
	}
	return m
}

func writeMsg(t *testing.T, conn *websocket.Conn, payload any) {
	t.Helper()
	body, _ := json.Marshal(payload)
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := conn.Write(ctx, websocket.MessageText, body); err != nil {
		t.Fatalf("write: %v", err)
	}
}

// --- Tests ----------------------------------------------------------------

// TestE2E_ChatWS_StreamsTextAndCloses arranca el backend con fake claude
// que emite un mensaje de texto + result, abre WebSocket via cookie de
// sesion y comprueba que llegan deltas + done.
func TestE2E_ChatWS_StreamsTextAndCloses(t *testing.T) {
	payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}`

	srv := startKanban(t, fakeClaudeBin(t, payload))
	jar := loginGetCookie(t, srv.URL)
	conn := dialWS(t, srv.URL, jar)
	defer conn.Close(websocket.StatusNormalClosure, "")

	writeMsg(t, conn, map[string]any{
		"messages": []map[string]string{{"role": "user", "content": "saluda"}},
	})

	var deltas []string
	var sawDone bool
	for i := 0; i < 12 && !sawDone; i++ {
		m := readMsg(t, conn)
		switch m.Type {
		case "delta":
			deltas = append(deltas, m.Text)
		case "done":
			sawDone = true
		case "error":
			t.Fatalf("error event: %s", m.Error)
		}
	}
	if !sawDone {
		t.Fatalf("never received done event")
	}
	if got := strings.Join(deltas, ""); got != "Hola mundo" {
		t.Fatalf("expected 'Hola mundo', got %q", got)
	}
}

// TestE2E_ChatWS_ToolUseFlow comprueba que tool_use + tool_result se
// reenvian correctamente y que board_changed=true cuando la tool muta.
func TestE2E_ChatWS_ToolUseFlow(t *testing.T) {
	payload := `{"type":"system","subtype":"init"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}`

	srv := startKanban(t, fakeClaudeBin(t, payload))
	jar := loginGetCookie(t, srv.URL)
	conn := dialWS(t, srv.URL, jar)
	defer conn.Close(websocket.StatusNormalClosure, "")

	writeMsg(t, conn, map[string]any{
		"messages": []map[string]string{{"role": "user", "content": "crea Backlog"}},
	})

	var sawToolUse, sawToolResult, sawDelta, sawDone, boardChanged bool
	var toolName string
	for i := 0; i < 16 && !sawDone; i++ {
		m := readMsg(t, conn)
		switch m.Type {
		case "tool_use":
			sawToolUse = true
			toolName = m.Tool
			if !strings.Contains(string(m.Input), "Backlog") {
				t.Errorf("input missing Backlog: %s", m.Input)
			}
		case "tool_result":
			sawToolResult = true
		case "delta":
			sawDelta = true
		case "done":
			sawDone = true
			boardChanged = m.BoardChanged
		case "error":
			t.Fatalf("error: %s", m.Error)
		}
	}
	if !sawToolUse || !sawToolResult || !sawDelta || !sawDone {
		t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v",
			sawToolUse, sawToolResult, sawDelta, sawDone)
	}
	if toolName != "create_column" {
		t.Errorf("expected tool stripped to create_column, got %q", toolName)
	}
	if !boardChanged {
		t.Errorf("expected board_changed=true")
	}
}

// TestE2E_ChatWS_RejectsUnauthenticated: sin cookie de sesion el upgrade
// debe fallar con 401 (auth middleware /api/chat/ws).
func TestE2E_ChatWS_RejectsUnauthenticated(t *testing.T) {
	payload := `{"type":"result","subtype":"success","is_error":false,"result":""}`
	srv := startKanban(t, fakeClaudeBin(t, payload))

	u, _ := url.Parse(srv.URL)
	wsURL := "ws://" + u.Host + "/api/chat/ws"
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_, _, err := websocket.Dial(ctx, wsURL, nil)
	if err == nil {
		t.Fatalf("expected dial to fail without cookie")
	}
	if !strings.Contains(err.Error(), "401") {
		t.Fatalf("expected 401 in error, got: %v", err)
	}
}

// TestE2E_InternalToolEndpoint_RealBackend: comprueba que el endpoint
// /api/tool/{name} esta vivo y rechaza tokens invalidos. Sirve para
// confirmar que la auth bypass + token check funciona en el binario
// real (no solo en httptest).
func TestE2E_InternalToolEndpoint_RealBackend(t *testing.T) {
	payload := `{"type":"result","is_error":false,"result":""}`
	srv := startKanban(t, fakeClaudeBin(t, payload))

	// Sin token -> 401
	req, _ := http.NewRequest("POST", srv.URL+"/api/tool/list_board", strings.NewReader("{}"))
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("post: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != 401 {
		t.Fatalf("expected 401 without token, got %d", resp.StatusCode)
	}
}
