package nntp

import (
	"bufio"
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/binary"
	"fmt"
	"io"
	"math/big"
	"net"
	"strings"
	"sync"
	"testing"
	"time"
)

type fakeServer struct {
	listener net.Listener
	posted   string
	mu       sync.Mutex
}

func startFakeServer(t *testing.T) *fakeServer {
	t.Helper()
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	return startFakeServerOn(t, listener)
}

func startFakeTLSServer(t *testing.T) (*fakeServer, *tls.Config) {
	t.Helper()
	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: big.NewInt(1),
		Subject:      pkix.Name{CommonName: "Aegis NNTP test"},
		NotBefore:    now.Add(-time.Minute),
		NotAfter:     now.Add(time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		IPAddresses:  []net.IP{net.ParseIP("127.0.0.1")},
	}
	der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
	if err != nil {
		t.Fatal(err)
	}
	certificate := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey}
	listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
		Certificates: []tls.Certificate{certificate},
		MinVersion:   tls.VersionTLS12,
	})
	if err != nil {
		t.Fatal(err)
	}
	parsed, err := x509.ParseCertificate(der)
	if err != nil {
		t.Fatal(err)
	}
	roots := x509.NewCertPool()
	roots.AddCert(parsed)
	return startFakeServerOn(t, listener), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
}

func startFakeServerOn(t *testing.T, listener net.Listener) *fakeServer {
	t.Helper()
	server := &fakeServer{listener: listener}
	go server.serve(t)
	t.Cleanup(func() { listener.Close() })
	return server
}

func (s *fakeServer) address() (string, string) {
	host, port, _ := net.SplitHostPort(s.listener.Addr().String())
	return host, port
}

func (s *fakeServer) serve(t *testing.T) {
	conn, err := s.listener.Accept()
	if err != nil {
		return
	}
	defer conn.Close()
	reader := bufio.NewReader(conn)
	writer := bufio.NewWriter(conn)
	write := func(value string) {
		_, _ = writer.WriteString(value)
		_ = writer.Flush()
	}
	write("200 fake server ready\r\n")
	for {
		line, err := reader.ReadString('\n')
		if err != nil {
			return
		}
		line = strings.TrimSpace(line)
		switch {
		case line == "AUTHINFO USER reader":
			write("381 password required\r\n")
		case line == "AUTHINFO PASS secret":
			write("281 authentication accepted\r\n")
		case line == "MODE READER":
			write("200 reader mode\r\n")
		case line == "CAPABILITIES":
			write("101 capabilities follow\r\nVERSION 2\r\nOVER\r\nPOST\r\n.\r\n")
		case line == "LIST ACTIVE":
			write("215 list follows\r\ncomp.lang.go 120 101 y\r\nempty.group 0 1 n\r\n.\r\n")
		case line == "GROUP comp.lang.go":
			write("211 19 101 120 comp.lang.go\r\n")
		case line == "OVER 119-120":
			write("224 overview follows\r\n119\tFirst\tAlice <a@example.org>\tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob <b@example.org>\tMon, 1 Jan 2024 01:00:00 +0000\t<120@example>\t<119@example>\t120\t6\r\n.\r\n")
		case line == "ARTICLE 120":
			write("220 120 <120@example> article follows\r\nSubject: Second\r\n\r\nHello\r\n..dot line\r\n.\r\n")
		case line == "POST":
			write("340 send article\r\n")
			var article strings.Builder
			for {
				postedLine, readErr := reader.ReadString('\n')
				if readErr != nil {
					return
				}
				if postedLine == ".\r\n" {
					break
				}
				article.WriteString(postedLine)
			}
			s.mu.Lock()
			s.posted = article.String()
			s.mu.Unlock()
			write("240 article received\r\n")
		case line == "QUIT":
			write("205 closing connection\r\n")
			return
		default:
			write(fmt.Sprintf("500 unsupported: %s\r\n", line))
		}
	}
}

func startSOCKSProxy(t *testing.T, upstreamAddress string) string {
	t.Helper()
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { listener.Close() })
	go func() {
		connection, acceptErr := listener.Accept()
		if acceptErr != nil {
			return
		}
		defer connection.Close()
		hello := make([]byte, 3)
		if _, err := io.ReadFull(connection, hello); err != nil || hello[0] != 5 {
			return
		}
		if _, err := connection.Write([]byte{5, 0}); err != nil {
			return
		}
		header := make([]byte, 4)
		if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 1 {
			return
		}
		switch header[3] {
		case 1:
			_, err = io.ReadFull(connection, make([]byte, 4))
		case 3:
			length := make([]byte, 1)
			if _, err = io.ReadFull(connection, length); err == nil {
				_, err = io.ReadFull(connection, make([]byte, int(length[0])))
			}
		case 4:
			_, err = io.ReadFull(connection, make([]byte, 16))
		default:
			return
		}
		if err != nil {
			return
		}
		port := make([]byte, 2)
		if _, err := io.ReadFull(connection, port); err != nil || binary.BigEndian.Uint16(port) == 0 {
			return
		}
		upstream, err := net.DialTimeout("tcp", upstreamAddress, 2*time.Second)
		if err != nil {
			_, _ = connection.Write([]byte{5, 1, 0, 1, 0, 0, 0, 0, 0, 0})
			return
		}
		defer upstream.Close()
		if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 1}); err != nil {
			return
		}
		done := make(chan struct{})
		go func() {
			_, _ = io.Copy(upstream, connection)
			_ = upstream.Close()
			close(done)
		}()
		_, _ = io.Copy(connection, upstream)
		<-done
	}()
	return listener.Addr().String()
}

func TestClientWorkflow(t *testing.T) {
	server, tlsConfig := startFakeTLSServer(t)
	host, port := server.address()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	client, err := Dial(ctx, DialConfig{
		Host:      host,
		Port:      port,
		UseTLS:    true,
		Username:  "reader",
		Password:  "secret",
		ProxyType: "DIRECT",
		tlsConfig: tlsConfig,
	})
	if err != nil {
		t.Fatalf("Dial() error = %v", err)
	}
	defer client.Close()

	capabilities, err := client.Capabilities()
	if err != nil || len(capabilities) != 3 {
		t.Fatalf("Capabilities() = %v, %v", capabilities, err)
	}
	groups, err := client.ListActive()
	if err != nil {
		t.Fatal(err)
	}
	if len(groups) != 2 || groups[0].EstimatedPost != 20 || groups[1].EstimatedPost != 0 {
		t.Fatalf("ListActive() = %#v", groups)
	}
	status, headers, err := client.LatestOverview("comp.lang.go", 2)
	if err != nil || status.Count != 19 || status.High != 120 {
		t.Fatalf("LatestOverview() status = %#v, error = %v", status, err)
	}
	if err != nil || len(headers) != 2 || headers[1].Subject != "Second" {
		t.Fatalf("LatestOverview() headers = %#v, error = %v", headers, err)
	}
	article, err := client.ArticleInGroup("comp.lang.go", 120)
	if err != nil || !strings.Contains(article, "\n.dot line") {
		t.Fatalf("Article() = %q, %v", article, err)
	}
	if err := client.Post("From: reader@example.org\nNewsgroups: comp.lang.go\nSubject: Test\n\n.line"); err != nil {
		t.Fatal(err)
	}
	server.mu.Lock()
	posted := server.posted
	server.mu.Unlock()
	if !strings.Contains(posted, "\r\n..line\r\n") {
		t.Fatalf("posted article was not dot-stuffed: %q", posted)
	}
}

func TestTLSCertificateVerificationCannotBeSkipped(t *testing.T) {
	server, _ := startFakeTLSServer(t)
	host, port := server.address()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	client, err := Dial(ctx, DialConfig{Host: host, Port: port, UseTLS: true, ProxyType: "DIRECT"})
	if client != nil {
		client.Close()
	}
	if err == nil {
		t.Fatal("Dial() trusted an unknown TLS certificate")
	}
}

func TestRejectsCommandInjection(t *testing.T) {
	if _, err := Dial(context.Background(), DialConfig{Host: "example.org\r\nQUIT", Port: "119"}); err == nil {
		t.Fatal("Dial() accepted command injection in host")
	}
	client := &Client{}
	if _, err := client.SelectGroup("comp.lang.go\r\nPOST"); err == nil {
		t.Fatal("SelectGroup() accepted command injection")
	}
}

func TestValidateArticleTextConstraints(t *testing.T) {
	tests := []struct {
		name    string
		article string
		wantErr bool
	}{
		{name: "valid UTF-8 and CRLF", article: "Subject: café\r\n\r\ncorps UTF-8\r\n"},
		{name: "valid LF normalized by Post", article: "Subject: test\n\nbody"},
		{name: "998 octets", article: "Subject: " + strings.Repeat("a", 989)},
		{name: "oversized line", article: strings.Repeat("a", maxPostLineBytes+1), wantErr: true},
		{name: "dot-stuffed oversized line", article: "." + strings.Repeat("a", maxPostLineBytes-1), wantErr: true},
		{name: "NUL", article: "Subject: test\x00", wantErr: true},
		{name: "invalid UTF-8", article: string([]byte{'a', 0xff}), wantErr: true},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			err := ValidateArticle(test.article)
			if (err != nil) != test.wantErr {
				t.Fatalf("ValidateArticle() error = %v, wantErr %t", err, test.wantErr)
			}
		})
	}
}

func TestRejectsAuthenticationWithoutTLS(t *testing.T) {
	if _, err := Dial(context.Background(), DialConfig{
		Host: "127.0.0.1", Port: "119", Username: "reader", Password: "secret",
	}); err == nil {
		t.Fatal("Dial() accepted authentication without TLS")
	}
}

func TestSOCKS5Connection(t *testing.T) {
	server := startFakeServer(t)
	proxyAddress := startSOCKSProxy(t, server.listener.Addr().String())
	host, port := server.address()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	client, err := Dial(ctx, DialConfig{
		Host: host, Port: port, ProxyType: "SOCKS5", ProxyAddress: proxyAddress,
	})
	if err != nil {
		t.Fatalf("Dial() through SOCKS5 error = %v", err)
	}
	defer client.Close()
	groups, err := client.ListActive()
	if err != nil || len(groups) != 2 {
		t.Fatalf("ListActive() through SOCKS5 = %#v, %v", groups, err)
	}
}

func TestPopulationEstimate(t *testing.T) {
	tests := []struct {
		low, high int64
		want      int64
	}{
		{101, 120, 20},
		{1, 1, 1},
		{1, 0, 0},
		{0, 0, 0},
	}
	for _, test := range tests {
		if got := estimatePopulation(test.low, test.high); got != test.want {
			t.Errorf("estimatePopulation(%d, %d) = %d, want %d", test.low, test.high, got, test.want)
		}
	}
}
