package remotecontrol

import (
	"context"
	"embed"
	"errors"
	"fmt"
	"io/fs"
	"net"
	"net/http"
	"sync"
	"time"

	"github.com/coder/websocket"
	"github.com/coder/websocket/wsjson"
)

// spaFiles holds the embedded browser SPA. The placeholder shipped here is
// fleshed out by the web-SPA node; the server only needs a valid FS to embed.
//
//go:embed web
var spaFiles embed.FS

// Default configuration values (see tasks/spec-remote-control.md §3.2).
const (
	defaultPairTTL = 10 * time.Minute
	cookieName     = "pigo_rc"
	maxFrameBytes  = 64 * 1024

	// outputFlushInterval is the coalescing tick: adjacent output writes buffered
	// within one interval are sent as a single frame, so a fast stream produces a
	// few batched frames rather than one per write, while still hitting the PRD's
	// sub-100ms latency target (§8.2).
	outputFlushInterval = 16 * time.Millisecond
	// outputQueueMax bounds the pending buffer. A producer that would push it past
	// this blocks until the pump drains, applying backpressure — the terminal
	// mirror must not drop bytes (§5.4), so we never discard, only slow down.
	outputQueueMax = 256 * 1024
	// replayRingBytes bounds the rolling output history kept for late-join /
	// reconnect replay so a freshly connected browser sees recent context (§8.2).
	replayRingBytes = 256 * 1024
)

// Config controls a remote-control server instance.
type Config struct {
	PairTTL        time.Duration // pairing-token lifetime; 0 → defaultPairTTL
	Host           string        // LAN IP for the printed URL; "" → auto-detect
	Port           int           // 0 → auto-pick, fall back on conflict
	ConfirmTimeout time.Duration // 0 → wait forever for a remote decision

	// OnClientConnect, if set, is invoked (on the WebSocket goroutine) when a
	// browser pairs and connects, with the client's remote address. The REPL uses
	// it to print a one-line terminal notice so the operator notices remote access
	// (§7.3). It must not block for long.
	OnClientConnect func(remoteAddr string)
	// OnClientDisconnect, if set, is invoked when the controlling client's
	// WebSocket closes. It must not block for long.
	OnClientDisconnect func()
}

// Handler receives frames the browser sends. The REPL bridge implements it;
// tests supply a fake. Callbacks run on the WebSocket read goroutine and must
// not block for long.
type Handler interface {
	// OnInput is called when the client submits a prompt line.
	OnInput(text string)
	// OnDecide is called when the client answers a confirmation request.
	OnDecide(confirmID string, approve, always bool)
}

// serverState tracks the lifecycle for gating requests.
type serverState int

const (
	stateIdle serverState = iota
	stateListening
	stateEnded
)

// Server is an in-process HTTP + WebSocket server that mirrors the CLI session
// to a single paired browser on the LAN.
type Server struct {
	cfg     Config
	tokens  *TokenStore
	handler Handler
	spa     fs.FS

	mu         sync.Mutex
	state      serverState
	ln         net.Listener
	httpServer *http.Server
	host       string
	port       int

	client       *websocket.Conn
	clientCtx    context.Context
	clientCancel context.CancelFunc
	writeMu      sync.Mutex // serializes writes to client

	// Output coalescing + backpressure + replay (#445). outMu guards all of the
	// fields below; outCond signals both the pump (new pending output) and any
	// producer blocked by backpressure (pump drained pending). The pump is the
	// sole sender of output frames, so replay and live output can never interleave
	// or duplicate.
	outMu      sync.Mutex
	outCond    *sync.Cond
	outPending []byte     // coalesced, not-yet-sent output
	outClosed  bool       // set on Stop; releases blocked producers
	needReplay bool       // a fresh client connected; next flush replays the ring
	ring       ringBuffer // rolling last-N bytes for late-join replay
	pumpCancel context.CancelFunc
	pumpDone   chan struct{}
}

// NewServer builds a server. handler may be nil (frames from the client are
// then ignored), which is useful for output-only smoke tests.
func NewServer(cfg Config, handler Handler) *Server {
	if cfg.PairTTL <= 0 {
		cfg.PairTTL = defaultPairTTL
	}
	sub, err := fs.Sub(spaFiles, "web")
	if err != nil {
		// The embed path is a compile-time constant, so this cannot fail in a
		// correctly built binary; fall back to the raw FS defensively.
		sub = spaFiles
	}
	s := &Server{
		cfg:     cfg,
		tokens:  NewTokenStore(),
		handler: handler,
		spa:     sub,
		state:   stateIdle,
		ring:    ringBuffer{max: replayRingBytes},
	}
	s.outCond = sync.NewCond(&s.outMu)
	return s
}

// SetHandler installs the handler that receives client frames. It exists to
// break the construction cycle between the server (a Bridge's Sink) and the
// Bridge (the server's Handler): build the server, build the Bridge with the
// server as Sink, then SetHandler(bridge). It must be called before Start so
// the WebSocket read goroutine never observes a mid-flight swap.
func (s *Server) SetHandler(h Handler) {
	s.mu.Lock()
	s.handler = h
	s.mu.Unlock()
}

// Start resolves a LAN address, binds a listener, mints a one-time pairing
// token, begins serving, and returns the full pairing URL to print. It is
// non-blocking: the HTTP server runs on its own goroutine.
func (s *Server) Start() (string, error) {
	host := s.cfg.Host
	if host == "" {
		detected, err := DetectRoutableIP()
		if err != nil {
			return "", err
		}
		host = detected
	}
	ln, port, err := ListenFreePort(host, s.cfg.Port)
	if err != nil {
		return "", err
	}
	token, err := s.tokens.NewPairing(s.cfg.PairTTL)
	if err != nil {
		ln.Close()
		return "", err
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", s.handleHealthz)
	mux.HandleFunc("/pair", s.handlePair)
	mux.HandleFunc("/ws", s.handleWS)
	mux.HandleFunc("/", s.handleRoot)

	pumpCtx, pumpCancel := context.WithCancel(context.Background())
	pumpDone := make(chan struct{})

	s.mu.Lock()
	s.ln = ln
	s.host = host
	s.port = port
	s.httpServer = &http.Server{Handler: mux}
	s.state = stateListening
	s.pumpCancel = pumpCancel
	s.pumpDone = pumpDone
	s.mu.Unlock()

	go s.httpServer.Serve(ln)
	go s.outputPump(pumpCtx, pumpDone)

	return fmt.Sprintf("http://%s:%d/pair?t=%s", host, port, token), nil
}

// Stop notifies the client, closes the WebSocket, shuts down the HTTP server,
// and wipes all tokens. It is safe to call more than once.
func (s *Server) Stop(ctx context.Context) error {
	s.mu.Lock()
	if s.state == stateEnded {
		s.mu.Unlock()
		return nil
	}
	s.state = stateEnded
	srv := s.httpServer
	client := s.client
	cancel := s.clientCancel
	pumpCancel := s.pumpCancel
	pumpDone := s.pumpDone
	s.mu.Unlock()

	// Release any producer blocked on backpressure, then stop the output pump and
	// wait for its final flush so the last buffered output reaches the client
	// before we announce the session end.
	s.outMu.Lock()
	s.outClosed = true
	s.outCond.Broadcast()
	s.outMu.Unlock()
	if pumpCancel != nil {
		pumpCancel()
		<-pumpDone
	}

	if client != nil {
		// Best-effort: tell the browser the session ended, then close.
		s.writeFrame(client, Frame{Type: FrameStatus, State: StatusEnded})
		client.Close(websocket.StatusNormalClosure, "session ended")
	}
	if cancel != nil {
		cancel()
	}
	s.tokens.Clear()
	if srv != nil {
		return srv.Shutdown(ctx)
	}
	return nil
}

func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))
}

// handlePair validates the one-time token, issues a session cookie, and
// redirects to the SPA root. Invalid/expired/used tokens get 401.
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
	if s.ended() {
		http.Error(w, "session ended", http.StatusGone)
		return
	}
	token := r.URL.Query().Get("t")
	if token == "" || !s.tokens.ConsumePairing(token) {
		http.Error(w, "pairing link invalid or expired", http.StatusUnauthorized)
		return
	}
	cred, err := s.tokens.IssueSession()
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     cookieName,
		Value:    cred,
		Path:     "/",
		HttpOnly: true,
		// Lax, not Strict: the pairing link is opened as a top-level navigation from
		// a QR scan (no same-site referrer), and some mobile browsers drop a Strict
		// cookie across the /pair→/ redirect that follows — sending the browser to
		// the unauthenticated page. Lax is sent on top-level GET navigations (which
		// is all this cookie is read on) while still withholding it from cross-site
		// subrequests, so it fixes the redirect without weakening the guard.
		SameSite: http.SameSiteLaxMode,
	})
	http.Redirect(w, r, "/", http.StatusFound)
}

// handleRoot serves the SPA to authenticated clients and an instructional page
// otherwise.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
	if s.ended() {
		http.Error(w, "session ended", http.StatusGone)
		return
	}
	if !s.authed(r) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(`<!doctype html><meta charset="utf-8">` +
			`<p>Open the pairing link printed in your terminal to connect.</p>`))
		return
	}
	http.FileServer(http.FS(s.spa)).ServeHTTP(w, r)
}

// handleWS upgrades to a WebSocket for the single controlling client.
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
	if s.ended() {
		http.Error(w, "session ended", http.StatusGone)
		return
	}
	if !s.authed(r) {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	s.mu.Lock()
	if s.client != nil {
		s.mu.Unlock()
		http.Error(w, "another device is controlling this session", http.StatusConflict)
		return
	}
	s.mu.Unlock()

	conn, err := websocket.Accept(w, r, nil)
	if err != nil {
		return
	}
	conn.SetReadLimit(maxFrameBytes)

	ctx, cancel := context.WithCancel(r.Context())
	s.mu.Lock()
	s.client = conn
	s.clientCtx = ctx
	s.clientCancel = cancel
	s.mu.Unlock()

	// Announce connection to the client, then arm a replay so the pump resends the
	// rolling scrollback (last replayRingBytes) as the first output frame. Live
	// output produced after this point is appended behind the snapshot by the
	// pump, so ordering is preserved and nothing is duplicated.
	s.writeFrame(conn, Frame{Type: FrameStatus, State: StatusConnected})
	s.outMu.Lock()
	s.needReplay = true
	s.outCond.Signal()
	s.outMu.Unlock()

	// Notify the terminal operator that a client connected (§7.3).
	if s.cfg.OnClientConnect != nil {
		s.cfg.OnClientConnect(r.RemoteAddr)
	}

	defer func() {
		cancel()
		s.mu.Lock()
		if s.client == conn {
			s.client = nil
			s.clientCtx = nil
			s.clientCancel = nil
		}
		s.mu.Unlock()
		conn.Close(websocket.StatusNormalClosure, "")
		if s.cfg.OnClientDisconnect != nil {
			s.cfg.OnClientDisconnect()
		}
	}()

	for {
		var f Frame
		if err := wsjson.Read(ctx, conn, &f); err != nil {
			return // client disconnected or context cancelled
		}
		s.dispatch(f)
	}
}

// dispatch routes an inbound client frame to the handler.
func (s *Server) dispatch(f Frame) {
	if s.handler == nil {
		return
	}
	switch f.Type {
	case FrameInput:
		s.handler.OnInput(f.Text)
	case FrameDecide:
		s.handler.OnDecide(f.ConfirmID, f.Approve, f.Always)
	}
}

// Broadcast sends a frame to the connected client, if any. It is safe to call
// from any goroutine.
func (s *Server) Broadcast(f Frame) {
	s.mu.Lock()
	conn := s.client
	s.mu.Unlock()
	if conn == nil {
		return
	}
	s.writeFrame(conn, f)
}

// SendOutput streams session text to the client. It never drops bytes: the text
// is appended to the ring (for replay) and to the pending buffer that the pump
// coalesces and flushes. If pending output has grown past outputQueueMax the
// call blocks until the pump drains it, applying backpressure to the producer
// rather than discarding output (§5.4, §8.4). It returns immediately once the
// server is stopping so a shutting-down producer never wedges.
func (s *Server) SendOutput(text string) {
	if text == "" {
		return
	}
	b := []byte(text)

	s.outMu.Lock()
	defer s.outMu.Unlock()

	// Always record into the ring so a late-joining / reconnecting client can be
	// replayed the recent scrollback, even while a producer is momentarily blocked
	// on backpressure below.
	s.ring.write(b)

	// Backpressure: wait until the pump has drained enough that appending stays
	// within the bound. Bail out if the server is stopping.
	for !s.outClosed && len(s.outPending) >= outputQueueMax {
		s.outCond.Wait()
	}
	if s.outClosed {
		return
	}
	s.outPending = append(s.outPending, b...)
	s.outCond.Signal()
}

// SendConfirm asks the client to approve a risky tool call.
func (s *Server) SendConfirm(confirmID, tool, summary string) {
	s.Broadcast(Frame{Type: FrameConfirm, ConfirmID: confirmID, Tool: tool, Summary: summary})
}

// HasClient reports whether a controlling client is currently connected.
func (s *Server) HasClient() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.client != nil
}

// Addr returns the bound host:port, or "" before Start.
func (s *Server) Addr() string {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.ln == nil {
		return ""
	}
	return net.JoinHostPort(s.host, fmt.Sprint(s.port))
}

func (s *Server) writeFrame(conn *websocket.Conn, f Frame) {
	s.writeMu.Lock()
	defer s.writeMu.Unlock()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = wsjson.Write(ctx, conn, f)
}

func (s *Server) ended() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.state == stateEnded
}

func (s *Server) authed(r *http.Request) bool {
	c, err := r.Cookie(cookieName)
	if err != nil {
		return false
	}
	return s.tokens.ValidateSession(c.Value)
}

// outputPump is the sole producer of output frames. It coalesces buffered
// output on a fixed tick and flushes it as a single frame, so a fast stream
// becomes a few batched frames while staying under the latency target: the
// first write after an idle period flushes immediately, and writes arriving
// during the following outputFlushInterval are batched. It also owns replay:
// when a client
// (re)connects, it prepends the ring snapshot before the live pending buffer.
// Being the only writer of output frames, replay and live output can never
// interleave or duplicate. It exits after a final flush when its context is
// cancelled (on Stop).
func (s *Server) outputPump(ctx context.Context, done chan<- struct{}) {
	defer close(done)

	ticker := time.NewTicker(outputFlushInterval)
	defer ticker.Stop()

	// A tiny goroutine wakes the Cond wait below whenever the context is
	// cancelled, so the pump does not sleep past shutdown.
	go func() {
		<-ctx.Done()
		s.outMu.Lock()
		s.outCond.Broadcast()
		s.outMu.Unlock()
	}()

	for {
		s.outMu.Lock()
		// Wait until there is something to do: pending output, an armed replay, or
		// shutdown.
		for len(s.outPending) == 0 && !s.needReplay && ctx.Err() == nil {
			s.outCond.Wait()
		}
		replay := s.needReplay
		s.needReplay = false
		var snapshot []byte
		if replay {
			snapshot = s.ring.snapshot()
		}
		// Coalesce: take everything buffered so far as one batch.
		pending := s.outPending
		s.outPending = nil
		// Wake any producer blocked on backpressure now that pending is drained.
		s.outCond.Broadcast()
		stopping := ctx.Err() != nil
		s.outMu.Unlock()

		s.mu.Lock()
		conn := s.client
		s.mu.Unlock()

		if conn != nil {
			// Replay first so the reconnecting browser restores context, then the
			// live batch. The ring already contains everything appended via
			// SendOutput, so on a fresh connection the snapshot covers pending too;
			// avoid double-sending by preferring the snapshot when it is present.
			if len(snapshot) > 0 {
				s.writeFrame(conn, Frame{Type: FrameOutput, Text: string(snapshot)})
			} else if len(pending) > 0 {
				s.writeFrame(conn, Frame{Type: FrameOutput, Text: string(pending)})
			}
		}

		if stopping {
			// Final drain done; exit.
			return
		}

		// Rate the loop on the ticker so bursts coalesce instead of spinning.
		select {
		case <-ctx.Done():
			// Loop once more to perform the final flush of anything buffered while
			// we were writing above.
			s.finalFlush()
			return
		case <-ticker.C:
		}
	}
}

// finalFlush drains any remaining pending output once during shutdown so the
// last bytes reach the client before the session-ended notice.
func (s *Server) finalFlush() {
	s.outMu.Lock()
	pending := s.outPending
	s.outPending = nil
	s.outCond.Broadcast()
	s.outMu.Unlock()
	if len(pending) == 0 {
		return
	}
	s.mu.Lock()
	conn := s.client
	s.mu.Unlock()
	if conn != nil {
		s.writeFrame(conn, Frame{Type: FrameOutput, Text: string(pending)})
	}
}

// ringBuffer is a bounded rolling byte buffer holding the most recent max bytes
// of session output for late-join / reconnect replay. It is not safe for
// concurrent use; the server guards it with outMu.
type ringBuffer struct {
	buf []byte
	max int
}

// write appends p, discarding oldest bytes so the buffer never exceeds max.
func (r *ringBuffer) write(p []byte) {
	if r.max <= 0 {
		return
	}
	if len(p) >= r.max {
		// Keep only the trailing max bytes of the new data.
		r.buf = append(r.buf[:0], p[len(p)-r.max:]...)
		return
	}
	r.buf = append(r.buf, p...)
	if len(r.buf) > r.max {
		// Drop the oldest overflow. Copy down so the backing array does not grow
		// without bound over the life of the session.
		drop := len(r.buf) - r.max
		r.buf = append(r.buf[:0], r.buf[drop:]...)
	}
}

// snapshot returns a copy of the current contents.
func (r *ringBuffer) snapshot() []byte {
	if len(r.buf) == 0 {
		return nil
	}
	out := make([]byte, len(r.buf))
	copy(out, r.buf)
	return out
}

// ErrNotStarted is returned by operations that require a running server.
var ErrNotStarted = errors.New("remotecontrol: server not started")
