// Package tmux is the TmuxPaneCLI runtime: an LLM agent runs in a tmux pane
// (typically `claude-code`, `codex`, or `gemini`), and a sidecar in-process
// goroutine bridges the harness envelope protocol via `tmux send-keys` +
// pipe-pane capture. See plan §13.
//
// The most fragile interface in the design is the sidecar parser: tmux-pane
// output mixes prose with structured blocks. We parse strict envelope-out
// blocks delimited by fenced ```harness-out``` JSON. Malformed blocks
// trigger reformat retries (the orchestrator-side counter tracks rate via
// parse_failures).
//
// Phase F scope: a self-contained sidecar that talks to a *script-driven*
// fake pane (no real tmux subprocess yet — the real subprocess wiring is a
// thin shell-out layer that can be added without touching the parser). The
// fake pane is a goroutine that feeds the sidecar canned output, so the
// parser + reformat-retry semantics are testable.
package tmux

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"sync"

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

const (
	openFence  = "```harness-out"
	closeFence = "```"
)

// PaneIO abstracts the tmux pane's read/write interface. Real impl shells out
// to `tmux send-keys` + pipe-pane. The test impl is an in-process queue.
type PaneIO interface {
	// Send writes an envelope-in block to the pane. The pane is expected to
	// produce an envelope-out block on its next turn.
	Send(ctx context.Context, payload string) error
	// Receive blocks until the pane emits a complete envelope-out block or
	// the ctx is canceled. Returns the raw block contents (without fences).
	Receive(ctx context.Context) (string, error)
}

// SidecarChannel is an alternative source of turn responses for runtimes
// that want to bypass TUI scraping. The default flow reads from PaneIO and
// extracts a `harness-out` block from rendered TUI output; with a channel
// set on Runtime, CallLLM reads its turn response from the channel instead.
//
// Two production implementations live in the sidecarchan subpackage:
//   - SocketChannel: per-agent unix socket. The model invokes a wrapper
//     binary via the CLI's Bash tool; the binary connects and writes its
//     argv/stdin payload. End-of-connection = end-of-message.
//   - MailboxChannel: per-agent directory. The model uses the CLI's Write
//     tool to drop a turn.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.
type SidecarChannel interface {
	// Open is invoked once per agent at Spawn time. Returns a description
	// of how the model should use the channel — this gets folded into the
	// agent's prompt — and any extra env vars the per-provider adapter
	// should set on the CLI subprocess.
	Open(agentID string) (ChannelPromptInfo, error)
	// Receive blocks until the next turn response arrives for agentID, or
	// ctx is canceled. Returns the response body as a JSON string ready
	// for parseEnvelopeOut.
	Receive(ctx context.Context, agentID string) (string, error)
	// Close releases any resources for agentID. Idempotent.
	Close(agentID string) error
}

// ChannelPromptInfo is what a channel hands back to the adapter at Open
// time. The adapter folds Instruction into the prompt the model sees and
// sets Env on the CLI subprocess.
type ChannelPromptInfo struct {
	// Instruction is the model-facing description of how to send a turn
	// response on this channel.
	Instruction string
	// Env are extra environment variables to set on the CLI subprocess.
	Env map[string]string
}

// Runtime implements runtime.Runtime over tmux panes.
type Runtime struct {
	// NewPane creates the per-agent pane. The optional chInfo parameter
	// carries the channel-Open result (socket path / mailbox path + env
	// vars) when Channel is configured. nil when Channel is nil.
	NewPane         func(ctx context.Context, spec runtime.SpawnSpec, chInfo *ChannelPromptInfo) (PaneIO, error)
	MaxParseRetries int

	// Channel, if non-nil, replaces PaneIO.Receive as the source of
	// structured turn responses. The pane is still used to Send the
	// prompt to the model.
	Channel SidecarChannel

	// PreFlightFn, when set, is invoked once per run before any agents
	// spawn. Used to verify that the underlying CLI is installed AND
	// the user is authenticated to the provider. Failure aborts the
	// run with a clear error instead of letting agents spawn into a
	// hung auth flow. Set by per-provider adapter packages.
	PreFlightFn func(ctx context.Context) error

	mu          sync.Mutex
	panes       map[string]PaneIO
	status      map[string]string
	systemPrompts map[string]string // per-agent system prompt + "did we send it yet"
	systemSent  map[string]bool
}

// PreFlight runs the configured PreFlightFn (if any). Used by callers
// that want to abort a run early on auth / install failures.
func (r *Runtime) PreFlight(ctx context.Context) error {
	if r.PreFlightFn == nil {
		return nil
	}
	return r.PreFlightFn(ctx)
}

// New returns a Runtime. NewPane is the factory the caller injects; for tests
// it returns a FakePane.
func New(newPane func(ctx context.Context, spec runtime.SpawnSpec, chInfo *ChannelPromptInfo) (PaneIO, error)) *Runtime {
	return &Runtime{
		NewPane:         newPane,
		MaxParseRetries: 2,
		panes:           map[string]PaneIO{},
		status:          map[string]string{},
		systemPrompts:   map[string]string{},
		systemSent:      map[string]bool{},
	}
}

// Spawn creates the pane and registers it. spec.Prompt becomes the system
// prompt prepended to the agent's very first turn. If a Channel is
// configured, Channel.Open is called first and its result is threaded
// into NewPane.
func (r *Runtime) Spawn(ctx context.Context, spec runtime.SpawnSpec) (string, error) {
	var chInfo *ChannelPromptInfo
	if r.Channel != nil {
		info, err := r.Channel.Open(spec.AgentID)
		if err != nil {
			return "", fmt.Errorf("sidecar channel open: %w", err)
		}
		chInfo = &info
	}
	pane, err := r.NewPane(ctx, spec, chInfo)
	if err != nil {
		if r.Channel != nil {
			_ = r.Channel.Close(spec.AgentID)
		}
		return "", err
	}
	r.mu.Lock()
	r.panes[spec.AgentID] = pane
	r.status[spec.AgentID] = "running"
	r.systemPrompts[spec.AgentID] = spec.Prompt
	r.systemSent[spec.AgentID] = false
	r.mu.Unlock()
	return spec.AgentID, nil
}

// CallLLM sends the input envelope to the pane and parses the response.
// On parse failure, retries up to MaxParseRetries with a "reformat your last
// output" message — the test harness's most fragile interface gets explicit
// retry semantics. See plan §13 sidecar parsing.
//
// On the first turn we prepend the system prompt (role md) so the CLI sees
// its identity + tools + storage conventions + peers before any real work.
func (r *Runtime) CallLLM(ctx context.Context, agentID string, req runtime.LLMRequest) (*runtime.LLMResponse, error) {
	r.mu.Lock()
	pane, ok := r.panes[agentID]
	systemPrompt := r.systemPrompts[agentID]
	sentBefore := r.systemSent[agentID]
	r.mu.Unlock()
	if !ok {
		return nil, runtime.ErrUnknownAgent
	}

	in := map[string]any{"prompt": req.Prompt}
	if req.IncomingMessage != nil {
		in["incoming"] = req.IncomingMessage
	}
	in["available_tools"] = req.AvailableTools
	sendingSystem := !sentBefore && systemPrompt != ""
	if sendingSystem {
		in["system_prompt"] = systemPrompt
	}
	inj, _ := json.Marshal(in)
	if err := pane.Send(ctx, string(inj)); err != nil {
		// V87: don't flip systemSent until the send actually succeeded.
		// Otherwise a failed first call would leave the agent flagged
		// as "system prompt delivered" forever, and the LLM would never
		// see its role briefing.
		return nil, err
	}
	if sendingSystem {
		r.mu.Lock()
		r.systemSent[agentID] = true
		r.mu.Unlock()
	}

	parseFailures := 0
	for attempt := 0; attempt <= r.MaxParseRetries; attempt++ {
		raw, err := r.receiveOne(ctx, agentID, pane)
		if err != nil {
			return nil, err
		}
		resp, perr := parseEnvelopeOut(raw)
		if perr == nil {
			resp.ParseFailures = parseFailures
			return resp, nil
		}
		parseFailures++
		// Reformat retry.
		if err := pane.Send(ctx, `{"reformat":true,"error":"`+perr.Error()+`"}`); err != nil {
			return nil, err
		}
	}
	return &runtime.LLMResponse{ParseFailures: parseFailures}, fmt.Errorf("sidecar: max parse retries (%d) exhausted", r.MaxParseRetries)
}

// receiveOne reads the next turn response. When Channel is configured,
// it is the source of truth; the pane is only used to deliver the
// prompt. Otherwise the legacy path scrapes the rendered pane.
//
// Channels deliver clean JSON — no TUI rendering, no fences. We wrap
// the body in a synthetic ```harness-out``` fence so the existing
// parser (which expects a fenced block) handles it unchanged. This
// avoids forking the parse path between pane and channel modes.
func (r *Runtime) receiveOne(ctx context.Context, agentID string, pane PaneIO) (string, error) {
	if r.Channel != nil {
		body, err := r.Channel.Receive(ctx, agentID)
		if err != nil {
			return "", err
		}
		return openFence + "\n" + body + "\n" + closeFence, nil
	}
	return pane.Receive(ctx)
}

// Terminate marks the agent terminated. If the pane is a RealPane, calls
// Close() so the tmux session is killed and the pipe-pane log file is
// released — closes the cleanup gap. If a Channel is configured, its
// per-agent resources are released too.
func (r *Runtime) Terminate(ctx context.Context, agentID, reason string) error {
	r.mu.Lock()
	pane := r.panes[agentID]
	delete(r.panes, agentID)
	r.status[agentID] = "terminated"
	r.mu.Unlock()
	if pane != nil {
		if closer, ok := pane.(interface{ Close() error }); ok {
			_ = closer.Close()
		}
	}
	if r.Channel != nil {
		_ = r.Channel.Close(agentID)
	}
	return nil
}

// Health returns the agent status.
func (r *Runtime) Health(ctx context.Context, agentID string) (string, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if s, ok := r.status[agentID]; ok {
		return s, nil
	}
	return "", runtime.ErrUnknownAgent
}

// ExtractBlock pulls out the first ```harness-out``` JSON block. Returns
// (contents, true) if found, or ("", false) if absent. Robust to mixed
// prose, CRLF, and ANSI escape sequences from terminal UIs.
//
// L1 actual-root-cause fix: pipe-pane logs from claude-code's TUI embed
// color codes (\x1b[...m), cursor moves, OSC strings, and stray control
// bytes. Without stripping, the JSON inside the fence contains \x1b chars
// and json.Unmarshal fails with "invalid character '\x1b'" — which the
// harness re-tries up to MaxParseRetries times, burning 25s of real LLM
// tokens per attempt. The stop-hook errors I initially blamed were a
// red herring; the parse failures are 100% deterministic on ANSI-bearing
// output.
func ExtractBlock(raw string) (string, bool) {
	// Strip terminal control sequences FIRST so the fence search isn't
	// thrown off by, say, a cursor-position escape splitting "```harn" from
	// "ess-out".
	clean := stripANSI(raw)
	start := strings.Index(clean, openFence)
	if start < 0 {
		return "", false
	}
	// Move past the opening fence + optional language marker line.
	rest := clean[start+len(openFence):]
	// Skip optional trailing chars on the open-fence line.
	if nl := strings.Index(rest, "\n"); nl >= 0 {
		rest = rest[nl+1:]
	}
	end := strings.Index(rest, closeFence)
	if end < 0 {
		return "", false
	}
	return strings.TrimSpace(rest[:end]), true
}

// stripANSI removes ANSI escape sequences and stray C0 control bytes from s.
// Handles:
//   - CSI:  ESC [ params... letter      (colors, cursor moves)
//   - OSC:  ESC ] ... BEL or ESC \      (window titles, hyperlinks)
//   - DCS/SOS/PM/APC: ESC X ... ESC \
//   - Bare ESC + single char            (e.g. ESC = / ESC >)
//   - C0 controls except \t \n \r (and DEL 0x7f)
func stripANSI(s string) string {
	hasEsc := strings.ContainsAny(s, "\x1b\x07")
	if !hasEsc {
		return scrubControls(s)
	}
	var b strings.Builder
	b.Grow(len(s))
	i := 0
	for i < len(s) {
		c := s[i]
		if c != 0x1b {
			if c < 0x20 && c != '\t' && c != '\n' && c != '\r' {
				i++
				continue
			}
			if c == 0x7f {
				i++
				continue
			}
			b.WriteByte(c)
			i++
			continue
		}
		if i+1 >= len(s) {
			break
		}
		next := s[i+1]
		switch next {
		case '[':
			j := i + 2
			for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) {
				j++
			}
			if j < len(s) {
				j++
			}
			i = j
		case ']':
			j := i + 2
			for j < len(s) {
				if s[j] == 0x07 {
					j++
					break
				}
				if s[j] == 0x1b && j+1 < len(s) && s[j+1] == '\\' {
					j += 2
					break
				}
				j++
			}
			i = j
		case 'P', 'X', '^', '_':
			j := i + 2
			for j+1 < len(s) {
				if s[j] == 0x1b && s[j+1] == '\\' {
					j += 2
					break
				}
				j++
			}
			i = j
		default:
			i += 2
		}
	}
	return b.String()
}

func scrubControls(s string) string {
	clean := true
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c < 0x20 && c != '\t' && c != '\n' && c != '\r') || c == 0x7f {
			clean = false
			break
		}
	}
	if clean {
		return s
	}
	var b strings.Builder
	b.Grow(len(s))
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c < 0x20 && c != '\t' && c != '\n' && c != '\r') || c == 0x7f {
			continue
		}
		b.WriteByte(c)
	}
	return b.String()
}

// parseEnvelopeOut extracts and validates the structured response.
func parseEnvelopeOut(raw string) (*runtime.LLMResponse, error) {
	body, ok := ExtractBlock(raw)
	if !ok {
		return nil, errors.New("no ```harness-out``` block found")
	}
	var wire struct {
		Text      string             `json:"text"`
		ToolCalls []runtime.ToolCall `json:"tool_calls"`
		Tokens    runtime.TokenUsage `json:"tokens"`
	}
	if err := json.Unmarshal([]byte(body), &wire); err != nil {
		return nil, fmt.Errorf("malformed JSON: %w", err)
	}
	return &runtime.LLMResponse{
		Text:      wire.Text,
		ToolCalls: wire.ToolCalls,
		Tokens:    wire.Tokens,
	}, nil
}
