package email

import (
	"errors"
	"fmt"
	"time"
)

// FetchSince is the whole read path in one call: connect, log in, examine the
// mailbox read-only, list what arrived since a date, fetch and parse the ones
// the caller has not seen, log out.
//
// It is a function rather than a long-lived object because a mail poller should
// not hold an authenticated session (and therefore his credential in a live TLS
// state) between polls. Connect, read, drop.
//
// skip decides which UIDs are already known — the poller's seen-set. max bounds
// one poll: a mailbox that received 400 messages overnight must not turn into
// 400 LLM calls, and the newest max are the ones a task could still be hiding
// in. Junk messages are returned too, flagged, so the caller can mark them seen
// without a second protocol round.
type FetchSince struct {
	Addr    string // host or host:993
	User    string
	Mailbox string // e.g. "INBOX"
	Timeout time.Duration
	Since   time.Time
	Max     int
	Skip    func(uid uint32) bool
	// OnSearch, when set, is handed the whole SEARCH result before anything is
	// fetched, ascending, seen UIDs included. It is how the poller learns which
	// UIDs are still inside the lookback window: anything below the lowest one
	// can never be searched for again, and therefore can never be read again.
	// Without that the poller cannot tell a UID it has not got to yet from one
	// that has aged out of the window.
	OnSearch func(uids []uint32)

	// dial — the connection seam, unexported on purpose: see dialer(). Tests
	// inside this package set it through export_test.go; nothing outside can.
	dial func(addr string, timeout time.Duration) (*Conn, error)
}

// dial is the connection seam. nil means Dial (implicit TLS); the tests set it
// through export_test.go. It is unexported and there is no exported wrapper
// that takes a dialer, so no code outside this package can point the reader at
// a non-TLS transport and hand it the password. That is a property the compiler
// enforces, not a claim about the callers that happen to exist today.
func (f FetchSince) dialer() func(addr string, timeout time.Duration) (*Conn, error) {
	if f.dial != nil {
		return f.dial
	}
	return Dial
}

// Run performs one read. password is passed here, not stored in the struct, so
// the configuration of a mailbox and the secret for it are never the same value
// sitting in the same place.
//
// One message that cannot be read does not abandon the poll: the rest of the
// mailbox is still worth reading, and returning early meant one oversized or
// unreadable message permanently hid every older message behind it, poll after
// poll. The returned error joins whatever failed, and the messages that did
// come back come back with it.
func (f FetchSince) Run(password string) ([]Message, error) {
	if f.Addr == "" || f.User == "" || f.Mailbox == "" {
		return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)")
	}
	// Timeout is validated like the other three fields. Zero disables every
	// deadline in the path — net.Dialer{Timeout: 0} and a Conn that never calls
	// SetDeadline — so a dead server parks the poller forever on a socket read,
	// with his credential live in a TLS state. That is the exact thing the
	// connect-read-drop shape exists to avoid.
	if f.Timeout <= 0 {
		return nil, fmt.Errorf("email: timeout must be positive")
	}
	dial := f.dialer()
	c, err := dial(f.Addr, f.Timeout)
	if err != nil {
		return nil, err
	}
	defer c.Close()
	if err := c.Login(f.User, password); err != nil {
		return nil, err
	}
	defer c.Logout()
	if err := c.Select(f.Mailbox); err != nil {
		return nil, err
	}
	uids, err := c.SearchSince(f.Since)
	if err != nil {
		return nil, err
	}

	if f.OnSearch != nil {
		f.OnSearch(uids)
	}

	// Newest UIDs first — IMAP hands them back ascending, and when Max clips the
	// list the recent mail is what matters.
	wanted := make([]uint32, 0, len(uids))
	for i := len(uids) - 1; i >= 0; i-- {
		if f.Skip != nil && f.Skip(uids[i]) {
			continue
		}
		wanted = append(wanted, uids[i])
		if f.Max > 0 && len(wanted) >= f.Max {
			break
		}
	}

	out := make([]Message, 0, len(wanted))
	var failed []error
	for _, uid := range wanted {
		raw, err := c.Fetch(uid)
		if errors.Is(err, ErrMessageTooLarge) {
			// Too big to read is a permanent verdict, not a failure to retry:
			// the message will be the same size next poll. Carried as bulk so
			// the poller marks it seen and stops fetching it, exactly like a
			// newsletter. Nothing is sent to the model.
			out = append(out, Message{UID: uid, Junk: true, JunkReason: "oversize"})
			continue
		}
		if err != nil {
			// The error names the UID, never the message. Collected rather than
			// returned, so the messages behind this one are still read.
			failed = append(failed, fmt.Errorf("email: fetch uid %d: %w", uid, err))
			continue
		}
		if len(raw) == 0 {
			continue // vanished between SEARCH and FETCH
		}
		msg, err := ParseMessage(uid, raw)
		if err != nil {
			continue // unparsable headers — nothing to review, skip silently
		}
		out = append(out, msg)
	}
	return out, errors.Join(failed...)
}
