package email

import (
	"bufio"
	"errors"
	"fmt"
	"net"
	"strconv"
	"strings"
	"testing"
	"time"
)

// fakeIMAP is a scripted server: enough of IMAP to exercise the client, and
// nothing more. It records the commands it received so a test can assert on the
// protocol (BODY.PEEK rather than BODY, EXAMINE rather than SELECT).
type fakeIMAP struct {
	msgs   map[uint32]string
	uids   []uint32
	cmds   []string
	failOn string // substring of a command to answer NO
	// oversize — UIDs answered with a literal of this many bytes, which the
	// server then actually sends. Used to exercise the read cap.
	oversize map[uint32]int
	// quoted — UIDs answered with a quoted string instead of a literal.
	quoted map[uint32]bool
}

func (f *fakeIMAP) serve(t *testing.T, c net.Conn) {
	t.Helper()
	defer c.Close()
	fmt.Fprint(c, "* OK fake IMAP ready\r\n")
	r := bufio.NewReader(c)
	for {
		line, err := r.ReadString('\n')
		if err != nil {
			return
		}
		line = strings.TrimRight(line, "\r\n")
		parts := strings.SplitN(line, " ", 2)
		if len(parts) != 2 {
			return
		}
		tag, cmd := parts[0], parts[1]
		f.cmds = append(f.cmds, cmd)
		if f.failOn != "" && strings.Contains(cmd, f.failOn) {
			fmt.Fprintf(c, "%s NO computer says no\r\n", tag)
			continue
		}
		upper := strings.ToUpper(cmd)
		switch {
		case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
			fmt.Fprintf(c, "%s OK done\r\n", tag)
		case strings.HasPrefix(upper, "UID SEARCH"):
			var ids []string
			for _, u := range f.uids {
				ids = append(ids, strconv.FormatUint(uint64(u), 10))
			}
			fmt.Fprintf(c, "* SEARCH %s\r\n", strings.Join(ids, " "))
			fmt.Fprintf(c, "%s OK search done\r\n", tag)
		case strings.HasPrefix(upper, "UID FETCH"):
			uid64, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
			uid := uint32(uid64)
			if n, big := f.oversize[uid]; big {
				fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, n)
				fmt.Fprint(c, strings.Repeat("x", n))
				fmt.Fprint(c, ")\r\n")
				fmt.Fprintf(c, "%s OK fetch done\r\n", tag)
				continue
			}
			if f.quoted[uid] {
				fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] \"short\")\r\n", uid64)
				fmt.Fprintf(c, "%s OK fetch done\r\n", tag)
				continue
			}
			raw, ok := f.msgs[uid]
			if ok {
				fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, len(raw))
				fmt.Fprint(c, raw)
				fmt.Fprint(c, ")\r\n")
			}
			fmt.Fprintf(c, "%s OK fetch done\r\n", tag)
		case strings.HasPrefix(upper, "LOGOUT"):
			fmt.Fprint(c, "* BYE\r\n")
			fmt.Fprintf(c, "%s OK bye\r\n", tag)
			return
		default:
			fmt.Fprintf(c, "%s BAD unknown\r\n", tag)
		}
	}
}

// dialFake wires a client Conn to an in-process server over net.Pipe.
func dialFake(t *testing.T, f *fakeIMAP) *Conn {
	t.Helper()
	cli, srv := net.Pipe()
	go f.serve(t, srv)
	c, err := NewConn(cli, 5*time.Second)
	if err != nil {
		t.Fatalf("greeting: %v", err)
	}
	t.Cleanup(func() { c.Close() })
	return c
}

func TestIMAPRoundTrip(t *testing.T) {
	body := "Subject: hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCall the bank.\r\n"
	f := &fakeIMAP{uids: []uint32{4, 9}, msgs: map[uint32]string{4: body, 9: body}}
	c := dialFake(t, f)

	if err := c.Login("kami", `pa"ss\word`); err != nil {
		t.Fatalf("login: %v", err)
	}
	if err := c.Select("INBOX"); err != nil {
		t.Fatalf("select: %v", err)
	}
	uids, err := c.SearchSince(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
	if err != nil {
		t.Fatalf("search: %v", err)
	}
	if len(uids) != 2 || uids[0] != 4 || uids[1] != 9 {
		t.Fatalf("uids = %v, want [4 9]", uids)
	}
	raw, err := c.Fetch(9)
	if err != nil {
		t.Fatalf("fetch: %v", err)
	}
	if string(raw) != body {
		t.Errorf("fetched %q, want the literal verbatim", raw)
	}
	c.Logout()

	joined := strings.Join(f.cmds, "\n")
	// Read-only at the protocol level, and peeking — Maven must leave no trace
	// of having read his mail.
	if !strings.Contains(joined, "EXAMINE") || strings.Contains(joined, "SELECT ") {
		t.Errorf("want EXAMINE (read-only), got:\n%s", joined)
	}
	if !strings.Contains(joined, "BODY.PEEK[]") {
		t.Errorf("want BODY.PEEK, got:\n%s", joined)
	}
	// The password must have been quoted and escaped, not truncated at the quote.
	if !strings.Contains(joined, `"pa\"ss\\word"`) {
		t.Errorf("password not quoted correctly:\n%s", joined)
	}
	// SINCE must carry the IMAP date form.
	if !strings.Contains(joined, "SINCE 1-Aug-2026") {
		t.Errorf("want a SINCE date, got:\n%s", joined)
	}
}

func TestIMAPServerNoIsAnError(t *testing.T) {
	f := &fakeIMAP{failOn: "LOGIN"}
	c := dialFake(t, f)
	err := c.Login("kami", "wrong")
	if err == nil {
		t.Fatal("a NO completion must be an error")
	}
	// The error is the server's text; it must not echo the credential.
	if strings.Contains(err.Error(), "wrong") {
		t.Errorf("error leaks the password: %v", err)
	}
}

func TestIMAPFetchMissingUID(t *testing.T) {
	f := &fakeIMAP{uids: []uint32{1}, msgs: map[uint32]string{}}
	c := dialFake(t, f)
	raw, err := c.Fetch(1)
	if err != nil {
		t.Fatalf("fetch: %v", err)
	}
	if raw != nil {
		t.Errorf("a vanished UID should give nil, got %q", raw)
	}
}

func TestQuoteStripsNewlines(t *testing.T) {
	if got := quote("pass\r\nA1 LOGOUT"); strings.ContainsAny(got, "\r\n") {
		t.Errorf("quote kept a line break: %q", got)
	}
}

// A credential with a line break in it is a broken password file, not a
// password. Stripping it silently authenticates as a different string and the
// server answers its generic NO.
func TestLoginRejectsCredentialWithNewline(t *testing.T) {
	f := &fakeIMAP{}
	c := dialFake(t, f)
	err := c.Login("kami", "s3cr3t\nA1 LOGOUT")
	if err == nil {
		t.Fatal("a password with a line break must be rejected")
	}
	if strings.Contains(err.Error(), "s3cr3t") {
		t.Errorf("error leaks the password: %v", err)
	}
	if len(f.cmds) != 0 {
		t.Errorf("nothing should have been sent, got %v", f.cmds)
	}
}

// The literal size comes off the wire. Without a cap the server picks the
// allocation, and one 60MB attachment is 60MB of peak RSS on a box holding a
// 1.7B model, all of it thrown away by plaintextBody afterwards.
func TestFetchRefusesOversizedLiteral(t *testing.T) {
	f := &fakeIMAP{uids: []uint32{1}, oversize: map[uint32]int{1: MaxMessageBytes + 1}}
	c := dialFake(t, f)
	raw, err := c.Fetch(1)
	if !errors.Is(err, ErrMessageTooLarge) {
		t.Fatalf("fetch err = %v, want ErrMessageTooLarge", err)
	}
	if raw != nil {
		t.Errorf("an oversized message must not be kept, got %d bytes", len(raw))
	}
	// The stream stayed aligned: the connection is still usable.
	if err := c.Select("INBOX"); err != nil {
		t.Errorf("connection unusable after draining: %v", err)
	}
}

// A FETCH that answered without a literal is not a vanished message, and must
// not be skipped as silently as one.
func TestFetchNonLiteralResponseIsAnError(t *testing.T) {
	f := &fakeIMAP{uids: []uint32{1}, quoted: map[uint32]bool{1: true}}
	c := dialFake(t, f)
	if _, err := c.Fetch(1); err == nil {
		t.Fatal("a FETCH response with no literal must be reported, not dropped")
	}
}

func TestUntaggedMatchesWholeKeyOnly(t *testing.T) {
	if _, ok := untagged("* SEARCHRES 1 2 3", "SEARCH"); ok {
		t.Error("SEARCH must not match SEARCHRES")
	}
	if rest, ok := untagged("* SEARCH 1 2 3", "SEARCH"); !ok || rest != "1 2 3" {
		t.Errorf("untagged = (%q, %v), want (\"1 2 3\", true)", rest, ok)
	}
}
