// Package sidecarchan provides concrete tmux.SidecarChannel implementations
// that bypass TUI screen-scraping by giving the model an explicit side
// channel for structured turn responses.
//
// Two implementations live here:
//
//   - SocketChannel: per-agent unix socket. The model invokes a wrapper
//     binary (`harness-send`) via the CLI's Bash tool; the binary connects
//     and writes its payload. End-of-connection = end-of-message.
//
//   - MailboxChannel: per-agent outbox directory. The model uses the CLI's
//     Write tool to drop a turn-NNN.json file; the channel watches the dir.
//
// Both eliminate the parse-failures-from-TUI-rendering class of bugs by
// taking the data path off the screen buffer entirely.
package sidecarchan

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"os"
	"path/filepath"
	"sync"

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

// SocketChannel implements tmux.SidecarChannel over per-agent unix sockets.
//
// Lifecycle per agent:
//  1. Open(agentID): bind a unix socket at <baseDir>/<agentID>.sock, start
//     accepting connections in the background, queue completed payloads
//     onto an in-memory channel.
//  2. Receive(ctx, agentID): block on the channel, return the next payload
//     or ctx.Err() on cancel.
//  3. Close(agentID): stop the accept loop, remove the socket file.
//
// The wrapper binary's behavior: dial → write payload → close. We treat
// end-of-connection as end-of-message because the binary is single-shot
// (one CLI invocation = one turn output). This is simpler than length
// prefixing and matches the Bash-tool invocation pattern.
type SocketChannel struct {
	baseDir string

	mu     sync.Mutex
	agents map[string]*socketAgent
}

type socketAgent struct {
	path     string
	listener net.Listener
	queue    chan string
	stop     chan struct{}
	wg       sync.WaitGroup
}

// NewSocketChannel constructs a channel that creates per-agent sockets
// under baseDir. The directory is created if it doesn't exist.
func NewSocketChannel(baseDir string) *SocketChannel {
	return &SocketChannel{
		baseDir: baseDir,
		agents:  map[string]*socketAgent{},
	}
}

// Open binds a listener for agentID and returns the path + env var the
// per-provider adapter should expose to the CLI subprocess.
func (s *SocketChannel) Open(agentID string) (tmux.ChannelPromptInfo, error) {
	if err := os.MkdirAll(s.baseDir, 0o700); err != nil {
		return tmux.ChannelPromptInfo{}, fmt.Errorf("mkdir socket dir: %w", err)
	}
	path := filepath.Join(s.baseDir, agentID+".sock")
	// A leftover socket from a previous run with the same agent id would
	// cause Listen to fail with EADDRINUSE — unix sockets don't auto-unbind
	// when the process exits abnormally. Best-effort remove.
	_ = os.Remove(path)
	ln, err := net.Listen("unix", path)
	if err != nil {
		return tmux.ChannelPromptInfo{}, fmt.Errorf("listen unix %s: %w", path, err)
	}

	a := &socketAgent{
		path:     path,
		listener: ln,
		// Buffer is small but >0 so a model that fires harness-send twice
		// in quick succession doesn't block the second writer. The
		// orchestrator drains one per turn; extras pile up here for the
		// next turn's Receive call.
		queue: make(chan string, 8),
		stop:  make(chan struct{}),
	}
	a.wg.Add(1)
	go s.acceptLoop(a)

	s.mu.Lock()
	s.agents[agentID] = a
	s.mu.Unlock()

	return tmux.ChannelPromptInfo{
		Instruction: "To finish a turn, run via Bash: `harness-send '<JSON>'` " +
			"(or pipe JSON to its stdin). The socket is at " + path + ". " +
			"The wrapper binary's HARNESS_SEND_SOCKET env var is preset; you do not need to pass --socket.",
		Env: map[string]string{
			"HARNESS_SEND_SOCKET": path,
		},
	}, nil
}

// Receive blocks until a payload arrives for agentID or ctx is canceled.
func (s *SocketChannel) Receive(ctx context.Context, agentID string) (string, error) {
	s.mu.Lock()
	a, ok := s.agents[agentID]
	s.mu.Unlock()
	if !ok {
		return "", fmt.Errorf("socket channel: unknown agent %q (not Open'd)", agentID)
	}
	select {
	case msg, open := <-a.queue:
		if !open {
			return "", errors.New("socket channel: closed")
		}
		return msg, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

// Close stops the listener for agentID and removes the socket file.
// Idempotent.
func (s *SocketChannel) Close(agentID string) error {
	s.mu.Lock()
	a, ok := s.agents[agentID]
	if ok {
		delete(s.agents, agentID)
	}
	s.mu.Unlock()
	if !ok {
		return nil
	}
	close(a.stop)
	_ = a.listener.Close() // unblocks Accept with net.ErrClosed
	a.wg.Wait()
	_ = os.Remove(a.path)
	return nil
}

// CloseAll terminates every agent. Useful for test teardown.
func (s *SocketChannel) CloseAll() {
	s.mu.Lock()
	ids := make([]string, 0, len(s.agents))
	for id := range s.agents {
		ids = append(ids, id)
	}
	s.mu.Unlock()
	for _, id := range ids {
		_ = s.Close(id)
	}
}

// acceptLoop runs until the listener is closed or stop is signaled. Each
// accepted connection is drained to EOF and enqueued as one payload.
func (s *SocketChannel) acceptLoop(a *socketAgent) {
	defer a.wg.Done()
	for {
		conn, err := a.listener.Accept()
		if err != nil {
			return
		}
		a.wg.Add(1)
		go func(c net.Conn) {
			defer a.wg.Done()
			defer c.Close()
			payload, err := io.ReadAll(c)
			if err != nil {
				return
			}
			select {
			case a.queue <- string(payload):
			case <-a.stop:
			}
		}(conn)
	}
}
