// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package test

import (
	"bytes"
	"errors"
	"fmt"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/tailscale/golang-x-crypto/internal/testenv"
	"golang.org/x/crypto/ssh"
	"golang.org/x/crypto/ssh/testdata"
)

type serverTest struct {
	// name is a freeform string identifying the test and the file in which
	// the expected results will be stored.
	name string
	// config contains the server configuration to use for this test.
	config *ssh.ServerConfig
}

// connFromCommand starts opens a listening socket and starts the reference
// client to connect to it. It returns a recordingConn that wraps the resulting
// connection.
func (test *serverTest) connFromCommand(t *testing.T) (conn *recordingConn, err error) {
	sshCLI, err := exec.LookPath("ssh")
	if err != nil {
		t.Skipf("skipping test: %v", err)
	}
	l, err := net.ListenTCP("tcp", &net.TCPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 0,
	})
	if err != nil {
		return nil, err
	}
	defer l.Close()

	port := l.Addr().(*net.TCPAddr).Port
	dir, err := os.MkdirTemp("", "sshtest")
	if err != nil {
		t.Fatal(err)
	}

	filename := "id_ed25519"
	writeFile(filepath.Join(dir, filename), testdata.PEMBytes["ed25519"])
	writeFile(filepath.Join(dir, filename+".pub"), ssh.MarshalAuthorizedKey(testPublicKeys["ed25519"]))
	var args []string
	args = append(args, "-v", "-i", filepath.Join(dir, filename), "-o", "StrictHostKeyChecking=no")
	args = append(args, "-oKexAlgorithms=+diffie-hellman-group14-sha1")
	args = append(args, "-p", strconv.Itoa(port))
	args = append(args, "testuser@127.0.0.1")
	args = append(args, "true")
	cmd := testenv.Command(t, sshCLI, args...)
	cmd.Stdin = nil
	var output bytes.Buffer
	cmd.Stdout = &output
	cmd.Stderr = &output
	if err := cmd.Start(); err != nil {
		return nil, err
	}

	t.Cleanup(func() {
		if err := os.RemoveAll(dir); err != nil {
			t.Error(err)
		}
		// Don't check for errors; if it fails it's most
		// likely "os: process already finished", and we don't
		// care about that.
		cmd.Process.Kill()
		cmd.Wait()
		if t.Failed() {
			t.Logf("OpenSSH output:\n\n%s", cmd.Stdout)
		}
	})

	connChan := make(chan any, 1)
	go func() {
		tcpConn, err := l.Accept()
		if err != nil {
			connChan <- err
			return
		}
		connChan <- tcpConn
	}()

	var tcpConn net.Conn
	select {
	case connOrError := <-connChan:
		if err, ok := connOrError.(error); ok {
			return nil, err
		}
		tcpConn = connOrError.(net.Conn)
	case <-time.After(2 * time.Second):
		return nil, errors.New("timed out waiting for connection from child process")
	}

	record := &recordingConn{
		Conn:           tcpConn,
		clientToServer: false,
	}

	return record, nil
}

func (test *serverTest) dataPath() string {
	return filepath.Join("..", "testdata", "Server-"+test.name)
}

func (test *serverTest) loadData() (flows [][]byte, err error) {
	in, err := os.Open(test.dataPath())
	if err != nil {
		return nil, err
	}
	defer in.Close()
	return parseTestData(in)
}

func (test *serverTest) run(t *testing.T, write bool) {
	var serverConn net.Conn
	var recordingConn *recordingConn

	setDeterministicRandomSource(&test.config.Config)

	if write {
		var err error
		recordingConn, err = test.connFromCommand(t)
		if err != nil {
			t.Fatalf("Failed to start subcommand: %v", err)
		}
		serverConn = recordingConn
	} else {
		timer := time.AfterFunc(10*time.Second, func() {
			fmt.Println("This test may be stuck, try running using -timeout 10s")
		})
		t.Cleanup(func() {
			timer.Stop()
		})
		flows, err := test.loadData()
		if err != nil {
			t.Fatalf("Failed to load data from %s", test.dataPath())
		}
		serverConn = newReplayingConn(t, flows)
	}

	server, chans, reqs, err := ssh.NewServerConn(serverConn, test.config)
	if err != nil {
		t.Fatalf("Failed to create server conn: %v", err)
	}
	defer server.Close()

	go ssh.DiscardRequests(reqs)

	done := make(chan bool)

	for newChannel := range chans {
		if newChannel.ChannelType() != "session" {
			newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
			continue
		}

		channel, requests, err := newChannel.Accept()
		if err != nil {
			continue
		}

		go func(in <-chan *ssh.Request) {
			for req := range in {
				switch req.Type {
				case "exec":
					if req.WantReply {
						req.Reply(true, nil)
					}
					channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatusMsg{Status: 0}))
					channel.Close()
					done <- true
				default:
					if req.WantReply {
						req.Reply(false, nil)
					}
				}
			}
		}(requests)
	}

	<-done

	if write {
		path := test.dataPath()
		out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
		if err != nil {
			t.Fatalf("Failed to create output file: %v", err)
		}
		defer out.Close()
		recordingConn.Close()

		recordingConn.WriteTo(out)
		t.Logf("Wrote %s\n", path)
	}
}

func recordingsServerConfig() *ssh.ServerConfig {
	config := &ssh.ServerConfig{
		PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
			return nil, nil
		},
	}
	config.SetDefaults()
	// Remove ML-KEM since it only works with Go 1.24.
	config.SetDefaults()
	if config.KeyExchanges[0] == ssh.KeyExchangeMLKEM768X25519 {
		config.KeyExchanges = config.KeyExchanges[1:]
	}
	config.AddHostKey(testSigners["rsa"])
	return config
}

func TestServerKeyExchanges(t *testing.T) {
	config := ssh.ClientConfig{}
	config.SetDefaults()

	var keyExchanges []string
	for _, kex := range config.KeyExchanges {
		// Exclude ecdh for now, to make them deterministic we should use see a
		// stream of fixed bytes as the random source.
		// Exclude ML-KEM because server side is not deterministic.
		if !strings.HasPrefix(kex, "ecdh-") && !strings.HasPrefix(kex, "mlkem") {
			keyExchanges = append(keyExchanges, kex)
		}
	}
	// Add diffie-hellman-group16-sha512 as it is not enabled by default.
	keyExchanges = append(keyExchanges, "diffie-hellman-group16-sha512")

	for _, kex := range keyExchanges {
		c := recordingsServerConfig()
		c.KeyExchanges = []string{kex}
		test := serverTest{
			name:   "KEX-" + kex,
			config: c,
		}
		runTestAndUpdateIfNeeded(t, test.name, test.run)
	}
}

func TestServerCiphers(t *testing.T) {
	config := ssh.ClientConfig{}
	config.SetDefaults()

	for _, ciph := range config.Ciphers {
		c := recordingsServerConfig()
		c.Ciphers = []string{ciph}
		test := serverTest{
			name:   "Cipher-" + ciph,
			config: c,
		}
		runTestAndUpdateIfNeeded(t, test.name, test.run)
	}
}

func TestServerMACs(t *testing.T) {
	config := ssh.ClientConfig{}
	config.SetDefaults()

	for _, mac := range config.MACs {
		c := recordingsServerConfig()
		c.MACs = []string{mac}
		test := serverTest{
			name:   "MAC-" + mac,
			config: c,
		}
		runTestAndUpdateIfNeeded(t, test.name, test.run)
	}
}
