// attach.go - conpty Attach: a loopback Stream over the B3 pty-host. Unlike
// tmux, conpty does not spawn an attach CLI; it dials the session's
// loopback host and speaks the B1 framing protocol directly. The host replays
// the scrollback Snapshot as the first MsgTerminalData on connect, so a fresh
// Read naturally yields the repaint first.
package conpty

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"sync"

	"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

var _ ports.Attacher = (*Runtime)(nil)

// Attach opens a fresh attach Stream for the session by dialing its loopback
// pty-host. rows/cols size the host's PTY from birth when known (a MsgResize is
// sent right after connect). ctx cancellation closes the Stream.
func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) {
	sess := r.resolve(handle.ID)
	if sess == nil {
		return nil, fmt.Errorf("conpty: session %q not found", handle.ID)
	}
	conn, err := dialHost(sess.addr, dialTimeout)
	if err != nil {
		return nil, fmt.Errorf("conpty: dial host for %q: %w", handle.ID, err)
	}

	pr, pw := io.Pipe()
	s := &loopbackStream{conn: conn, pr: pr, pw: pw}

	// Pump host frames: MsgTerminalData payloads go into the pipe that Read
	// drains. The first such frame is the scrollback snapshot, so the replay
	// arrives before any live output.
	go s.pump()

	// ctx cancellation must terminate the stream (mirrors the unix/windows
	// spawn paths closing the PTY on ctx.Done).
	go func() {
		<-ctx.Done()
		_ = s.Close()
	}()

	if rows > 0 && cols > 0 {
		if err := s.Resize(rows, cols); err != nil {
			_ = s.Close()
			return nil, err
		}
	}
	return s, nil
}

// loopbackStream is a ports.Stream backed by a single loopback connection to the
// pty-host. The pump goroutine reframes host output into an io.Pipe so Read
// presents a plain byte stream; Write/Resize encode client frames onto the conn.
type loopbackStream struct {
	conn io.ReadWriteCloser
	pr   *io.PipeReader
	pw   *io.PipeWriter

	closeOnce sync.Once
}

// pump reads framed host messages and writes MsgTerminalData payloads into the
// pipe. It closes the pipe when the connection ends so Read returns EOF.
func (s *loopbackStream) pump() {
	parser := NewMessageParser(func(msgType byte, payload []byte) {
		if msgType == MsgTerminalData {
			// Write blocks until Read drains, preserving back-pressure and order.
			_, _ = s.pw.Write(payload)
		}
	})
	buf := make([]byte, 4096)
	for {
		n, err := s.conn.Read(buf)
		if n > 0 {
			parser.Feed(buf[:n])
		}
		if err != nil {
			_ = s.pw.CloseWithError(err)
			return
		}
	}
}

func (s *loopbackStream) Read(p []byte) (int, error) { return s.pr.Read(p) }

func (s *loopbackStream) Write(p []byte) (int, error) {
	frame, err := EncodeMessage(MsgTerminalInput, p)
	if err != nil {
		return 0, err
	}
	if _, err := s.conn.Write(frame); err != nil {
		return 0, err
	}
	return len(p), nil
}

func (s *loopbackStream) Resize(rows, cols uint16) error {
	payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)})
	frame, err := EncodeMessage(MsgResize, payload) // small JSON payload, never overflows uint32
	if err != nil {
		return err
	}
	_, err = s.conn.Write(frame)
	return err
}

// Close closes the conn and the pipe. Idempotent. Closing the conn unblocks
// pump's Read, which then closes the pipe-writer too; closing both here makes
// Close safe to call directly (e.g. on ctx cancel) without waiting for pump.
func (s *loopbackStream) Close() error {
	var err error
	s.closeOnce.Do(func() {
		err = s.conn.Close()
		_ = s.pw.Close()
		_ = s.pr.Close()
	})
	return err
}
