package sidecarchan

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"time"

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

// MailboxChannel implements tmux.SidecarChannel over a per-agent
// directory: the model uses its CLI's Write tool to drop a JSON file at
// a fixed path (atomic-rename pattern), and the channel polls for it.
//
// Per agent: a directory at <baseDir>/<agentID>/ with one canonical
// mailbox file `outbox.json`. The model is instructed to write
// `outbox.json` via an atomic temp-then-rename so the harness never
// reads a half-written file.
//
// Polling cadence: PollInterval (default 50ms). The harness's per-turn
// deadline (set by the orchestrator via ctx) bounds total wait. A
// future iteration could switch to fsnotify; polling keeps the
// dependency surface minimal and works under tmux's detached pty.
type MailboxChannel struct {
	baseDir      string
	PollInterval time.Duration

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

type mailboxAgent struct {
	dir      string // <baseDir>/<agentID>
	filePath string // <dir>/outbox.json
}

// NewMailboxChannel returns a channel rooted at baseDir.
func NewMailboxChannel(baseDir string) *MailboxChannel {
	return &MailboxChannel{
		baseDir:      baseDir,
		PollInterval: 50 * time.Millisecond,
		agents:       map[string]*mailboxAgent{},
	}
}

// Open creates the agent's outbox directory and returns the path the
// model is expected to write to.
func (m *MailboxChannel) Open(agentID string) (tmux.ChannelPromptInfo, error) {
	agentDir := filepath.Join(m.baseDir, agentID)
	if err := os.MkdirAll(agentDir, 0o700); err != nil {
		return tmux.ChannelPromptInfo{}, fmt.Errorf("mailbox: mkdir %s: %w", agentDir, err)
	}
	a := &mailboxAgent{
		dir:      agentDir,
		filePath: filepath.Join(agentDir, "outbox.json"),
	}

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

	return tmux.ChannelPromptInfo{
		Instruction: "To finish a turn, atomically Write your structured JSON to " +
			a.filePath + " — write `outbox.json.tmp` first, then rename to `outbox.json`. " +
			"The harness reads and removes the file once it appears.",
		Env: map[string]string{
			"HARNESS_MAILBOX_PATH": a.filePath,
		},
	}, nil
}

// Receive polls the agent's outbox until the canonical file exists or
// ctx is canceled. On success the file is read and removed atomically.
func (m *MailboxChannel) Receive(ctx context.Context, agentID string) (string, error) {
	m.mu.Lock()
	a, ok := m.agents[agentID]
	m.mu.Unlock()
	if !ok {
		return "", fmt.Errorf("mailbox channel: unknown agent %q (not Open'd)", agentID)
	}

	interval := m.PollInterval
	if interval <= 0 {
		interval = 50 * time.Millisecond
	}
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		body, ok, err := m.tryRead(a.filePath)
		if err != nil {
			return "", err
		}
		if ok {
			return body, nil
		}
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-ticker.C:
		}
	}
}

// tryRead returns (body, true, nil) if the canonical file is present
// and readable. Returns (_, false, nil) if it isn't yet there — the
// poll loop should try again. An io error other than not-exist is
// surfaced.
//
// We deliberately do NOT touch the .tmp sibling — that's the model's
// in-flight write. Only the renamed `outbox.json` counts as ready.
func (m *MailboxChannel) tryRead(filePath string) (string, bool, error) {
	data, err := os.ReadFile(filePath)
	if err != nil {
		if os.IsNotExist(err) {
			return "", false, nil
		}
		return "", false, fmt.Errorf("mailbox: read %s: %w", filePath, err)
	}
	// Consume: remove the file so the next Receive call doesn't return
	// the same payload twice. Errors here would mean a race (someone
	// already removed it) — best-effort.
	_ = os.Remove(filePath)
	return string(data), true, nil
}

// Close removes the agent's outbox directory and unregisters it.
func (m *MailboxChannel) Close(agentID string) error {
	m.mu.Lock()
	a, ok := m.agents[agentID]
	if ok {
		delete(m.agents, agentID)
	}
	m.mu.Unlock()
	if !ok {
		return nil
	}
	return os.RemoveAll(a.dir)
}

// CloseAll removes every tracked agent. For test teardown.
func (m *MailboxChannel) CloseAll() {
	m.mu.Lock()
	ids := make([]string, 0, len(m.agents))
	for id := range m.agents {
		ids = append(ids, id)
	}
	m.mu.Unlock()
	for _, id := range ids {
		_ = m.Close(id)
	}
}
