package sidecarchan_test

// End-to-end tests that wire each concrete channel into a tmux.Runtime
// and exercise the full CallLLM path. The pane is faked (no real
// tmux/CLI subprocess); the "model" is a goroutine that simulates the
// model's behavior — writes to the socket or drops a file in the
// mailbox — so we exercise the runtime+channel composition without
// depending on a live LLM.

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

	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/runtime"
	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/runtime/tmux"
	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/runtime/tmux/sidecarchan"
)

// shortTempDir returns a /tmp-rooted tempdir to keep unix socket
// paths under macOS's sun_path[104] limit.
func shortTempDir(t *testing.T) string {
	t.Helper()
	dir, err := os.MkdirTemp("/tmp", "scint")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = os.RemoveAll(dir) })
	return dir
}

// noopPane is a PaneIO that records sent payloads and never returns
// anything from Receive (pane.Receive should not be called when a
// Channel is configured).
type noopPane struct {
	sent []string
}

func (p *noopPane) Send(ctx context.Context, payload string) error {
	p.sent = append(p.sent, payload)
	return nil
}
func (p *noopPane) Receive(ctx context.Context) (string, error) {
	// If this is ever called, the integration is broken — the channel
	// is supposed to be the receive source.
	<-ctx.Done()
	return "", ctx.Err()
}

// TestRuntime_WithSocketChannel_EndToEnd: spawn an agent, simulate the
// model invoking harness-send (write JSON to the socket), CallLLM
// returns the parsed LLMResponse. Asserts the full glue from
// SocketChannel.Open → Runtime.Spawn → Runtime.CallLLM → channel
// Receive → parseEnvelopeOut.
func TestRuntime_WithSocketChannel_EndToEnd(t *testing.T) {
	dir := shortTempDir(t)
	ch := sidecarchan.NewSocketChannel(dir)
	defer ch.CloseAll()

	pane := &noopPane{}
	rt := tmux.New(func(ctx context.Context, spec runtime.SpawnSpec, chInfo *tmux.ChannelPromptInfo) (tmux.PaneIO, error) {
		// The factory in production would Env-wire chInfo into tmux's
		// new-session; here we just assert it was passed through.
		if chInfo == nil {
			t.Errorf("factory did not receive chInfo despite Channel being set")
		}
		if chInfo.Env["HARNESS_SEND_SOCKET"] == "" {
			t.Errorf("HARNESS_SEND_SOCKET missing from chInfo.Env")
		}
		return pane, nil
	})
	rt.Channel = ch

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	agentID := "intg-1"
	if _, err := rt.Spawn(ctx, runtime.SpawnSpec{AgentID: agentID, Prompt: "you are a test agent"}); err != nil {
		t.Fatal(err)
	}

	// Simulate the model after it receives the prompt: invoke
	// harness-send by directly dialing the socket.
	go func() {
		// Wait until CallLLM is blocked on channel.Receive — we know
		// Send happened first because pane.Send is synchronous in
		// CallLLM.
		time.Sleep(20 * time.Millisecond)
		sockPath := filepath.Join(dir, agentID+".sock")
		conn, err := net.Dial("unix", sockPath)
		if err != nil {
			t.Errorf("model dial: %v", err)
			return
		}
		_, _ = io.WriteString(conn,
			`{"text":"hi from socket","tool_calls":[{"name":"delegate","args":{"to":"fe-lead"}}],"tokens":{"prompt":11,"completion":4}}`)
		_ = conn.Close()
	}()

	resp, err := rt.CallLLM(ctx, agentID, runtime.LLMRequest{Prompt: "do something"})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Text != "hi from socket" {
		t.Errorf("Text = %q, want \"hi from socket\"", resp.Text)
	}
	if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "delegate" {
		t.Errorf("ToolCalls = %+v, want one delegate call", resp.ToolCalls)
	}
	if resp.Tokens.Prompt != 11 || resp.Tokens.Completion != 4 {
		t.Errorf("Tokens = %+v", resp.Tokens)
	}
	if resp.ParseFailures != 0 {
		t.Errorf("ParseFailures = %d, want 0 (channel delivers clean JSON, no retry)", resp.ParseFailures)
	}
	// Pane received exactly one outgoing prompt — the per-turn payload.
	// Confirms the runtime still sends through the pane in channel mode.
	if len(pane.sent) != 1 {
		t.Errorf("pane.sent count = %d, want 1", len(pane.sent))
	}

	// Termination cleans up the socket file.
	_ = rt.Terminate(ctx, agentID, "test done")
	if _, err := os.Stat(filepath.Join(dir, agentID+".sock")); !os.IsNotExist(err) {
		t.Errorf("socket file leaked after Terminate: %v", err)
	}
}

// TestRuntime_WithMailboxChannel_EndToEnd: full path for option 2.
// The "model" writes outbox.json atomically; CallLLM returns the
// parsed payload.
func TestRuntime_WithMailboxChannel_EndToEnd(t *testing.T) {
	dir := t.TempDir()
	ch := sidecarchan.NewMailboxChannel(dir)
	// Tighten poll cadence for the test so we don't wait ~50ms per
	// iteration unnecessarily.
	ch.PollInterval = 5 * time.Millisecond
	defer ch.CloseAll()

	pane := &noopPane{}
	rt := tmux.New(func(ctx context.Context, spec runtime.SpawnSpec, chInfo *tmux.ChannelPromptInfo) (tmux.PaneIO, error) {
		if chInfo == nil {
			t.Errorf("factory did not receive chInfo despite Channel being set")
		}
		if chInfo.Env["HARNESS_MAILBOX_PATH"] == "" {
			t.Errorf("HARNESS_MAILBOX_PATH missing from chInfo.Env")
		}
		return pane, nil
	})
	rt.Channel = ch

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	agentID := "intg-mbx"
	if _, err := rt.Spawn(ctx, runtime.SpawnSpec{AgentID: agentID, Prompt: "you are a test agent"}); err != nil {
		t.Fatal(err)
	}

	outboxPath := filepath.Join(dir, agentID, "outbox.json")
	// Simulate the model: atomic temp-then-rename.
	go func() {
		time.Sleep(20 * time.Millisecond)
		tmp := outboxPath + ".tmp"
		body := `{"text":"hi from mailbox","tool_calls":[{"name":"self_report","args":{"notes":"intg"}}],"tokens":{"prompt":8,"completion":3}}`
		if err := os.WriteFile(tmp, []byte(body), 0o600); err != nil {
			t.Errorf("write tmp: %v", err)
			return
		}
		if err := os.Rename(tmp, outboxPath); err != nil {
			t.Errorf("rename: %v", err)
		}
	}()

	resp, err := rt.CallLLM(ctx, agentID, runtime.LLMRequest{Prompt: "do something"})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Text != "hi from mailbox" {
		t.Errorf("Text = %q, want \"hi from mailbox\"", resp.Text)
	}
	if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "self_report" {
		t.Errorf("ToolCalls = %+v", resp.ToolCalls)
	}
	if resp.ParseFailures != 0 {
		t.Errorf("ParseFailures = %d, want 0", resp.ParseFailures)
	}

	// The outbox file should have been consumed.
	if _, err := os.Stat(outboxPath); !os.IsNotExist(err) {
		t.Errorf("outbox file lingered after CallLLM: %v", err)
	}

	// Termination removes the agent's outbox dir.
	parent := filepath.Dir(outboxPath)
	_ = rt.Terminate(ctx, agentID, "test done")
	if _, err := os.Stat(parent); !os.IsNotExist(err) {
		t.Errorf("outbox dir leaked after Terminate: %v", err)
	}
}

// TestRuntime_PaneChannelBackwardsCompatibility: no channel set →
// behaves exactly as before (the pane.Receive path is used). Ensures
// the channel injection is purely additive.
func TestRuntime_PaneChannelBackwardsCompatibility(t *testing.T) {
	fake := tmux.NewFakePane()
	fake.QueueResponse("```harness-out\n{\"text\":\"from-pane\",\"tokens\":{\"prompt\":1,\"completion\":1}}\n```")

	rt := tmux.New(func(ctx context.Context, spec runtime.SpawnSpec, chInfo *tmux.ChannelPromptInfo) (tmux.PaneIO, error) {
		if chInfo != nil {
			t.Errorf("chInfo should be nil when Channel is not set")
		}
		return fake, nil
	})
	// Channel intentionally left nil.

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if _, err := rt.Spawn(ctx, runtime.SpawnSpec{AgentID: "a"}); err != nil {
		t.Fatal(err)
	}
	resp, err := rt.CallLLM(ctx, "a", runtime.LLMRequest{Prompt: "x"})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Text != "from-pane" {
		t.Errorf("Text = %q, want from-pane (channel-less path)", resp.Text)
	}
	if !strings.Contains(strings.Join(fake.Sent(), " "), `"prompt":"x"`) {
		t.Errorf("pane did not receive the prompt: %v", fake.Sent())
	}
}
