package main

import (
	"crypto/ecdsa"
	"crypto/ed25519"
	"crypto/elliptic"
	"crypto/rand"
	"errors"
	"fmt"
	"io"
	"net"
	"os/exec"
	"testing"

	"golang.org/x/crypto/ssh"
)

// execHandler runs the remote command for one session and returns its exit
// code.
type execHandler func(cmd string, stdin io.Reader, stdout, stderr io.Writer) int

// testServer is a minimal in-process SSH server for tests: one host key, one
// accepted client public key, and a pluggable exec handler. No sshd needed.
type testServer struct {
	ln       net.Listener
	hostKey  ssh.Signer
	extraKey ssh.Signer
	authPub  ssh.PublicKey
	handle   execHandler
}

func newTestServer(t *testing.T, clientPub ssh.PublicKey, handle execHandler) *testServer {
	t.Helper()
	return newTestServerKeys(t, clientPub, handle, false)
}

// newTestServerKeys builds the server with an ed25519 host key and, when
// alsoECDSA is set, an ECDSA one too. A server offering both is the stock sshd
// case: x/crypto's default client preference would pick the ECDSA key, so this
// is what proves rsh's host-key algorithm policy is doing its job.
func newTestServerKeys(t *testing.T, clientPub ssh.PublicKey, handle execHandler, alsoECDSA bool) *testServer {
	t.Helper()
	_, hpriv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	signer, err := ssh.NewSignerFromKey(hpriv)
	if err != nil {
		t.Fatal(err)
	}
	var extra ssh.Signer
	if alsoECDSA {
		epriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
		if err != nil {
			t.Fatal(err)
		}
		if extra, err = ssh.NewSignerFromKey(epriv); err != nil {
			t.Fatal(err)
		}
	}
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	s := &testServer{ln: ln, hostKey: signer, extraKey: extra, authPub: clientPub, handle: handle}
	go s.serve()
	t.Cleanup(func() { ln.Close() })
	return s
}

func (s *testServer) port() int { return s.ln.Addr().(*net.TCPAddr).Port }

func (s *testServer) serve() {
	cfg := &ssh.ServerConfig{
		PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
			if s.authPub != nil && string(key.Marshal()) == string(s.authPub.Marshal()) {
				return &ssh.Permissions{}, nil
			}
			return nil, fmt.Errorf("unknown public key")
		},
	}
	cfg.AddHostKey(s.hostKey)
	if s.extraKey != nil {
		cfg.AddHostKey(s.extraKey)
	}
	for {
		nConn, err := s.ln.Accept()
		if err != nil {
			return
		}
		go s.handleConn(nConn, cfg)
	}
}

func (s *testServer) handleConn(nConn net.Conn, cfg *ssh.ServerConfig) {
	conn, chans, reqs, err := ssh.NewServerConn(nConn, cfg)
	if err != nil {
		return
	}
	defer conn.Close()
	go ssh.DiscardRequests(reqs)
	for newCh := range chans {
		if newCh.ChannelType() != "session" {
			newCh.Reject(ssh.UnknownChannelType, "only session")
			continue
		}
		ch, requests, err := newCh.Accept()
		if err != nil {
			return
		}
		go s.handleSession(ch, requests)
	}
}

func (s *testServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) {
	for req := range requests {
		if req.Type != "exec" {
			if req.WantReply {
				req.Reply(false, nil)
			}
			continue
		}
		var payload struct{ Command string }
		ssh.Unmarshal(req.Payload, &payload)
		if req.WantReply {
			req.Reply(true, nil)
		}
		code := s.handle(payload.Command, ch, ch, ch.Stderr())
		ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
		ch.Close()
		return
	}
}

// shellExecIn runs cmd via /bin/sh -c, exactly as sshd would, wiring the
// channel to the process. Used by the real-rsync end-to-end test.
//
// stdin is fed through a detached goroutine rather than c.Stdin so that Wait
// does not block on the stdin copy: rsync's remote receiver exits while the
// client still holds the channel open, and a c.Run with a non-file Stdin would
// deadlock waiting for that copy to finish.
func shellExecIn(dir, cmd string, stdin io.Reader, stdout, stderr io.Writer) int {
	c := exec.Command("/bin/sh", "-c", cmd)
	c.Dir = dir
	c.Stdout = stdout
	c.Stderr = stderr
	inPipe, err := c.StdinPipe()
	if err != nil {
		return 1
	}
	if err := c.Start(); err != nil {
		return 1
	}
	go func() {
		io.Copy(inPipe, stdin)
		inPipe.Close()
	}()
	err = c.Wait()
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		return ee.ExitCode()
	}
	if err != nil {
		return 1
	}
	return 0
}
