package sidecarchan

import (
	"context"
	"encoding/json"
	"io"
	"net"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// shortTempDir returns a directory under /tmp (not the per-test
// t.TempDir(), which on macOS is buried under /var/folders/... and
// blows past the 104-char sun_path limit for unix sockets). Cleaned up
// at test end.
func shortTempDir(t *testing.T) string {
	t.Helper()
	dir, err := os.MkdirTemp("/tmp", "scsock")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = os.RemoveAll(dir) })
	return dir
}

// TestSocketChannel_OpenReturnsSocketPathInstruction pins the contract
// the per-provider adapter relies on to construct the model-facing
// prompt: Open must give back an Instruction string that mentions the
// concrete socket path, and an Env map that wires HARNESS_SEND_SOCKET
// for the CLI subprocess.
func TestSocketChannel_OpenReturnsSocketPathInstruction(t *testing.T) {
	dir := shortTempDir(t)
	ch := NewSocketChannel(dir)
	defer ch.CloseAll()

	info, err := ch.Open("agent-1")
	if err != nil {
		t.Fatal(err)
	}
	expectedPath := filepath.Join(dir, "agent-1.sock")
	if !strings.Contains(info.Instruction, expectedPath) {
		t.Errorf("Instruction missing socket path %q: %s", expectedPath, info.Instruction)
	}
	if info.Env["HARNESS_SEND_SOCKET"] != expectedPath {
		t.Errorf("Env[HARNESS_SEND_SOCKET] = %q, want %q", info.Env["HARNESS_SEND_SOCKET"], expectedPath)
	}
	// The socket file must exist after Open — the listener has to be
	// bound BEFORE the model subprocess is spawned, otherwise the
	// model's first harness-send invocation races us and fails.
	if _, err := os.Stat(expectedPath); err != nil {
		t.Errorf("socket file not created at %s: %v", expectedPath, err)
	}
}

// TestSocketChannel_ReceivesPayloadWrittenByClient is the happy path:
// after Open, a client connects, writes a JSON payload, closes the
// connection — and Receive returns exactly those bytes (no length
// prefix, no transformation).
func TestSocketChannel_ReceivesPayloadWrittenByClient(t *testing.T) {
	dir := shortTempDir(t)
	ch := NewSocketChannel(dir)
	defer ch.CloseAll()
	info, err := ch.Open("a1")
	if err != nil {
		t.Fatal(err)
	}

	payload := `{"text":"hi from model","tool_calls":[{"name":"delegate","args":{"to":"fe-lead"}}],"tokens":{"prompt":12,"completion":7}}`

	// Simulate the wrapper binary: dial, write, close.
	go func() {
		// Tiny pause to make sure Receive is already blocking on Accept.
		// In practice the model spawns harness-send after the prompt
		// arrives, so the harness is always already listening.
		time.Sleep(5 * time.Millisecond)
		conn, derr := net.Dial("unix", info.Env["HARNESS_SEND_SOCKET"])
		if derr != nil {
			t.Errorf("dial: %v", derr)
			return
		}
		_, _ = io.WriteString(conn, payload)
		_ = conn.Close()
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	got, err := ch.Receive(ctx, "a1")
	if err != nil {
		t.Fatal(err)
	}
	if got != payload {
		t.Errorf("Receive payload mismatch\n got: %q\nwant: %q", got, payload)
	}
	// The received bytes must be valid JSON — the channel guarantees a
	// parseable body. parseEnvelopeOut downstream is allowed to assume
	// no TUI rendering artifacts.
	if !json.Valid([]byte(got)) {
		t.Errorf("Receive produced invalid JSON: %q", got)
	}
}

// TestSocketChannel_AgentsAreIsolated verifies a write to agent A's
// socket is NOT delivered to agent B's Receive. Critical — otherwise
// one stray harness-send invocation could re-route another agent's
// turn output.
func TestSocketChannel_AgentsAreIsolated(t *testing.T) {
	dir := shortTempDir(t)
	ch := NewSocketChannel(dir)
	defer ch.CloseAll()
	infoA, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}
	_, err = ch.Open("b")
	if err != nil {
		t.Fatal(err)
	}

	// Write to A's socket only.
	go func() {
		time.Sleep(5 * time.Millisecond)
		conn, _ := net.Dial("unix", infoA.Env["HARNESS_SEND_SOCKET"])
		_, _ = io.WriteString(conn, `{"text":"for-A"}`)
		_ = conn.Close()
	}()

	// Receive on A: should get the payload.
	ctxA, cancelA := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancelA()
	got, err := ch.Receive(ctxA, "a")
	if err != nil {
		t.Fatal(err)
	}
	if got != `{"text":"for-A"}` {
		t.Errorf("A received wrong payload: %q", got)
	}

	// Receive on B: should NOT see A's write — must time out.
	ctxB, cancelB := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancelB()
	if _, err := ch.Receive(ctxB, "b"); err == nil {
		t.Fatal("agent B saw a payload — channels are not isolated")
	}
}

// TestSocketChannel_ReceiveCancellation: a hung subprocess that never
// invokes harness-send must let the orchestrator break out via ctx
// cancel. Without this, a dead agent would hang Receive forever and
// the reformat-retry loop would never advance.
func TestSocketChannel_ReceiveCancellation(t *testing.T) {
	dir := shortTempDir(t)
	ch := NewSocketChannel(dir)
	defer ch.CloseAll()
	_, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	start := time.Now()
	_, err = ch.Receive(ctx, "a")
	if err == nil {
		t.Fatal("expected cancellation error, got nil")
	}
	if d := time.Since(start); d > 500*time.Millisecond {
		t.Errorf("Receive took %v to honor ctx cancel — listener must select on ctx.Done()", d)
	}
}

// TestSocketChannel_CloseRemovesSocketFile: agent termination must
// release the OS socket file so a re-spawn with the same id doesn't
// hit EADDRINUSE.
func TestSocketChannel_CloseRemovesSocketFile(t *testing.T) {
	dir := shortTempDir(t)
	ch := NewSocketChannel(dir)
	defer ch.CloseAll()
	info, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}
	path := info.Env["HARNESS_SEND_SOCKET"]
	if _, err := os.Stat(path); err != nil {
		t.Fatalf("socket not present after Open: %v", err)
	}
	if err := ch.Close("a"); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Errorf("socket file still present after Close: %v", err)
	}
}

// TestSocketChannel_MultipleWritesOnOneConnectionAreConcatenated:
// the wrapper binary may write its payload in chunks (e.g. argv vs
// stdin) before closing. The channel must collect the full byte stream
// up to EOF and return it as one message.
func TestSocketChannel_MultipleWritesOnOneConnectionAreConcatenated(t *testing.T) {
	dir := shortTempDir(t)
	ch := NewSocketChannel(dir)
	defer ch.CloseAll()
	info, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}

	go func() {
		time.Sleep(5 * time.Millisecond)
		conn, _ := net.Dial("unix", info.Env["HARNESS_SEND_SOCKET"])
		_, _ = io.WriteString(conn, `{"text":"part1`)
		time.Sleep(5 * time.Millisecond)
		_, _ = io.WriteString(conn, ` and part2","tokens":{"prompt":0,"completion":0}}`)
		_ = conn.Close()
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	got, err := ch.Receive(ctx, "a")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(got, "part1 and part2") {
		t.Errorf("Receive did not concatenate fragmented writes: %q", got)
	}
	if !json.Valid([]byte(got)) {
		t.Errorf("concatenated body is not valid JSON: %q", got)
	}
}
