package email

import (
	"bufio"
	"crypto/tls"
	"errors"
	"fmt"
	"io"
	"net"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// A minimal IMAP4rev1 client — LOGIN, SELECT, UID SEARCH, UID FETCH with
// BODY.PEEK, LOGOUT, and nothing else.
//
// Why hand-rolled instead of go-imap: the whole surface Maven needs is five
// commands, and this is the one code path that holds his mailbox credential and
// reads his private mail. A ~200-line client with no dependencies is auditable
// in one sitting; a general-purpose IMAP library is a much larger amount of
// code doing much more than we asked, in the most sensitive place in the tree.
// If IDLE, CONDSTORE or server-side threading ever become worth having, that
// trade should be re-made deliberately.
//
// BODY.PEEK[] rather than BODY[] is load-bearing: Maven reads his mail and must
// leave no trace of having done so. Reading a message here does not mark it
// \Seen, so the unread state in his own mail client stays his.

// DefaultIMAPPort — implicit-TLS IMAP. There is no cleartext and no STARTTLS
// path in this client: an option to send his password over a plain socket is an
// option to get it wrong once.
const DefaultIMAPPort = "993"

// MaxMessageBytes — the largest message this client will read into memory.
//
// The literal size comes off the wire, so an unbounded read is an allocation
// the server picks: "{2147483647}" is a 2GB make() before a single byte
// arrives, and one ordinary mail with a 60MB attachment is a 60MB peak RSS on a
// box already holding a 1.7B model resident. All of it would then be thrown
// away, because plaintextBody skips attachments and the body is truncated to
// MaxBodyBytes anyway.
//
// 2 MiB is well above what prose plus quoted history plus base64 HTML needs and
// well below what hurts. A larger message is drained and reported as
// ErrMessageTooLarge rather than read.
const MaxMessageBytes = 2 << 20

// readChunk — how much of a literal is read between deadline refreshes. The
// per-connection timeout must stay an IDLE timeout: with one deadline around
// the whole read it becomes a whole-message budget, and a healthy but slow
// uplink then fails the same message on every poll forever.
const readChunk = 64 << 10

// ErrMessageTooLarge — the server announced a literal above MaxMessageBytes.
// The connection stays usable (the bytes are drained), and the caller decides
// what to do with the UID. FetchSince retires it rather than retrying it.
var ErrMessageTooLarge = errors.New("email: message larger than the read cap")

// Conn — one authenticated IMAP connection. Not safe for concurrent use; the
// poller drives one connection at a time.
type Conn struct {
	rwc     io.ReadWriteCloser
	r       *bufio.Reader
	tag     int
	timeout time.Duration
}

// Dial opens an implicit-TLS connection and reads the server greeting.
func Dial(addr string, timeout time.Duration) (*Conn, error) {
	host, _, err := net.SplitHostPort(addr)
	if err != nil {
		host, addr = addr, net.JoinHostPort(addr, DefaultIMAPPort)
	}
	d := &net.Dialer{Timeout: timeout}
	// ServerName is set from the host we asked for: certificate verification is
	// the only thing standing between his password and a MITM on the way out.
	c, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
	if err != nil {
		return nil, fmt.Errorf("email: dial %s: %w", addr, err)
	}
	return NewConn(c, timeout)
}

// NewConn wraps an already-open stream (the tests speak IMAP over a pipe) and
// consumes the greeting.
func NewConn(rwc io.ReadWriteCloser, timeout time.Duration) (*Conn, error) {
	c := &Conn{rwc: rwc, r: bufio.NewReaderSize(rwc, 64<<10), timeout: timeout}
	line, err := c.readLine()
	if err != nil {
		return nil, fmt.Errorf("email: greeting: %w", err)
	}
	if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
		c.rwc.Close()
		return nil, fmt.Errorf("email: server refused connection: %s", line)
	}
	return c, nil
}

func (c *Conn) Close() error { return c.rwc.Close() }

// Login authenticates with LOGIN. The password is passed as an argument and
// never stored on the Conn: nothing in this package keeps a credential alive
// past the command that uses it, so no struct dump or panic trace can carry it.
func (c *Conn) Login(user, pass string) error {
	// A credential with a line break in it is rejected here, not silently
	// repaired. quote() strips CR and LF so a stray newline can never become a
	// second command, but stripping alone means a password file that picked up
	// a newline authenticates as a DIFFERENT string and comes back as the
	// server's generic NO, which is a long debugging session. This error names
	// the problem and cannot leak the value.
	if strings.ContainsAny(user, "\r\n") {
		return fmt.Errorf("email: login: username contains a line break")
	}
	if strings.ContainsAny(pass, "\r\n") {
		return fmt.Errorf("email: login: password contains a line break")
	}
	// The command line itself is never logged (see exec) — a LOGIN line IS the
	// credential.
	if _, err := c.exec(fmt.Sprintf("LOGIN %s %s", quote(user), quote(pass))); err != nil {
		return fmt.Errorf("email: login: %w", err)
	}
	return nil
}

// Select opens a mailbox read-only. EXAMINE, not SELECT: read-only at the
// protocol level means no command in this session can change a flag, expunge a
// message, or move anything, even by mistake.
func (c *Conn) Select(mailbox string) error {
	if _, err := c.exec(fmt.Sprintf("EXAMINE %s", quote(mailbox))); err != nil {
		return fmt.Errorf("email: examine %s: %w", mailbox, err)
	}
	return nil
}

// SearchSince returns the UIDs of messages received on or after since. An
// unlimited search is not offered: the first poll against a years-old mailbox
// would otherwise fetch everything and hand a decade of mail to the model.
//
// The IMAP SINCE key has date granularity (and compares the server's internal
// date), so the result can include messages slightly older than since. The
// caller dedupes by UID anyway, so a wider window costs one extra fetch.
func (c *Conn) SearchSince(since time.Time) ([]uint32, error) {
	cmd := fmt.Sprintf("UID SEARCH SINCE %s", since.Format("2-Jan-2006"))
	lines, err := c.exec(cmd)
	if err != nil {
		return nil, fmt.Errorf("email: search: %w", err)
	}
	var uids []uint32
	for _, l := range lines {
		rest, ok := untagged(l, "SEARCH")
		if !ok {
			continue
		}
		for _, f := range strings.Fields(rest) {
			n, err := strconv.ParseUint(f, 10, 32)
			if err == nil {
				uids = append(uids, uint32(n))
			}
		}
	}
	return uids, nil
}

var literalSize = regexp.MustCompile(`\{(\d+)\}$`)

// Fetch returns the raw RFC 5322 bytes of one message, by UID.
//
// Returns (nil, nil) when the UID no longer exists — a message he deleted
// between SEARCH and FETCH is normal, not an error. A FETCH response that came
// back with no literal in it is NOT that case and is an error, so a message
// that exists and was readable is never dropped without a log line.
//
// A literal above MaxMessageBytes is drained without being kept and reported as
// ErrMessageTooLarge.
func (c *Conn) Fetch(uid uint32) ([]byte, error) {
	tag := c.nextTag()
	if err := c.send(fmt.Sprintf("%s UID FETCH %d (BODY.PEEK[])", tag, uid)); err != nil {
		return nil, err
	}
	var raw []byte
	var sawFetch, tooLarge bool
	for {
		line, err := c.readLine()
		if err != nil {
			return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
		}
		if done, err := c.tagged(tag, line); done {
			switch {
			case err != nil:
				return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
			case tooLarge:
				return nil, fmt.Errorf("email: fetch %d: %w", uid, ErrMessageTooLarge)
			case sawFetch && raw == nil:
				// The server answered for this UID but not with a literal (a
				// quoted string, say). Silently skipping it would look exactly
				// like a vanished message.
				return nil, fmt.Errorf("email: fetch %d: no message literal in the FETCH response", uid)
			}
			return raw, nil
		}
		if strings.HasPrefix(line, "* ") && strings.Contains(line, " FETCH ") {
			sawFetch = true
		}
		m := literalSize.FindStringSubmatch(strings.TrimSpace(line))
		if m == nil {
			continue
		}
		n, err := strconv.Atoi(m[1])
		if err != nil || n < 0 {
			continue
		}
		if n > MaxMessageBytes {
			// Drained rather than read: the stream has to stay aligned for the
			// tagged completion, but nothing is allocated and nothing is parsed.
			tooLarge = true
			if err := c.discard(int64(n)); err != nil {
				return nil, fmt.Errorf("email: fetch %d: drain literal: %w", uid, err)
			}
			continue
		}
		buf, err := c.readN(n)
		if err != nil {
			return nil, fmt.Errorf("email: fetch %d: literal: %w", uid, err)
		}
		if raw == nil {
			raw = buf
		}
	}
}

// Logout ends the session politely. A failure is not worth reporting — the
// connection is being closed either way.
func (c *Conn) Logout() {
	_, _ = c.exec("LOGOUT")
}

// ---- protocol plumbing -----------------------------------------------------

func (c *Conn) nextTag() string {
	c.tag++
	return fmt.Sprintf("a%03d", c.tag)
}

// exec sends one command and returns the untagged response lines.
//
// Neither the command nor the response is ever logged here. LOGIN goes through
// this function, and a debug line "sent: a001 LOGIN ..." is how a credential
// ends up in a log file forever.
func (c *Conn) exec(cmd string) ([]string, error) {
	tag := c.nextTag()
	if err := c.send(tag + " " + cmd); err != nil {
		return nil, err
	}
	var lines []string
	for {
		line, err := c.readLine()
		if err != nil {
			return nil, err
		}
		if done, err := c.tagged(tag, line); done {
			return lines, err
		}
		lines = append(lines, line)
		// A response line may carry a literal (e.g. a header FETCH). Nothing we
		// send asks for one outside Fetch, but skip it if it appears so the
		// stream stays aligned.
		if m := literalSize.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
			if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
				if err := c.discard(int64(n)); err != nil {
					return nil, err
				}
			}
		}
	}
}

// tagged reports whether line completes the command with this tag, and turns a
// NO/BAD completion into an error. The error text is the server's, which never
// echoes a password.
func (c *Conn) tagged(tag, line string) (bool, error) {
	if !strings.HasPrefix(line, tag+" ") {
		return false, nil
	}
	rest := strings.TrimSpace(line[len(tag):])
	switch {
	case strings.HasPrefix(rest, "OK"):
		return true, nil
	case strings.HasPrefix(rest, "NO"), strings.HasPrefix(rest, "BAD"):
		return true, fmt.Errorf("server said: %s", rest)
	default:
		return true, fmt.Errorf("unexpected completion: %s", rest)
	}
}

func (c *Conn) send(line string) error {
	c.setDeadline()
	if _, err := io.WriteString(c.rwc, line+"\r\n"); err != nil {
		return fmt.Errorf("email: write: %w", err)
	}
	return nil
}

// readN reads exactly n bytes, refreshing the deadline every readChunk so the
// timeout stays an idle timeout rather than a budget for the whole literal.
func (c *Conn) readN(n int) ([]byte, error) {
	buf := make([]byte, n)
	for off := 0; off < n; {
		end := off + readChunk
		if end > n {
			end = n
		}
		c.setDeadline()
		got, err := io.ReadFull(c.r, buf[off:end])
		off += got
		if err != nil {
			return nil, err
		}
	}
	return buf, nil
}

// discard throws away n bytes of literal, same chunked deadline refresh as
// readN and no allocation proportional to n.
func (c *Conn) discard(n int64) error {
	for n > 0 {
		chunk := int64(readChunk)
		if chunk > n {
			chunk = n
		}
		c.setDeadline()
		got, err := io.CopyN(io.Discard, c.r, chunk)
		n -= got
		if err != nil {
			return err
		}
	}
	return nil
}

func (c *Conn) readLine() (string, error) {
	c.setDeadline()
	line, err := c.r.ReadString('\n')
	if err != nil {
		return "", err
	}
	return strings.TrimRight(line, "\r\n"), nil
}

// setDeadline applies the per-connection timeout when the transport supports
// one. A hung IMAP server must not park the poller forever.
func (c *Conn) setDeadline() {
	if c.timeout <= 0 {
		return
	}
	if d, ok := c.rwc.(interface{ SetDeadline(time.Time) error }); ok {
		_ = d.SetDeadline(time.Now().Add(c.timeout))
	}
}

// untagged splits "* SEARCH 1 2 3" into its payload when the key matches.
//
// The key must be the whole word: a prefix test would also match a future
// extension's "* SEARCHRES", and reading its payload as UIDs is the kind of
// thing that ages badly next to an IMAP capability nobody asked for.
func untagged(line, key string) (string, bool) {
	if !strings.HasPrefix(line, "* ") {
		return "", false
	}
	rest := strings.TrimSpace(line[2:])
	if !strings.HasPrefix(rest, key) {
		return "", false
	}
	rest = rest[len(key):]
	if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
		return "", false
	}
	return strings.TrimSpace(rest), true
}

// quote renders an IMAP quoted string. Passwords routinely contain characters
// that would otherwise end the argument early, and CR/LF are stripped rather
// than escaped because there is no legal way to send them — a credential file
// with a stray newline must not become a second command.
func quote(s string) string {
	s = strings.NewReplacer("\r", "", "\n", "").Replace(s)
	return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
