package imapserver

import (
	"errors"
	"net/mail"
	"sort"
	"strings"

	"github.com/emersion/go-imap/v2"
	goimapserver "github.com/emersion/go-imap/v2/imapserver"

	"mailgoserver/internal/abuseguard"
	"mailgoserver/internal/db"
)

const inboxName = "INBOX"

var _ goimapserver.Session = (*Session)(nil)

// Session implements goimapserver.Session against one mailbox's messages via
// mailstore. Sequence numbers are recomputed fresh from the DB on every command
// rather than cached/tracked across concurrent updates.
// ponytail: no MailboxTracker/IDLE push support — Idle just blocks until the client
// sends DONE, so a connected client still gets new mail via NOOP/periodic re-SELECT,
// just not an instant push. Add a tracker if that matters.
type Session struct {
	backend        *Backend
	peerIP         string
	mailbox        *db.Mailbox // set once Login succeeds
	selectedFolder string      // set by Select; defaults to INBOX if empty
}

func (s *Session) Close() error { return nil }

// Login accepts only an app password (esrv_mailbox_app_passwords) — never the
// mailbox's own portal password, since IMAP AUTH has no interactive MFA step.
func (s *Session) Login(username, password string) error {
	mbox, err := s.backend.DB.VerifyMailboxAppPassword(username, password)
	if err != nil {
		_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Authentication error: "+err.Error())
		abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
		return err
	}
	if mbox == nil {
		_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Invalid credentials")
		abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
		return goimapserver.ErrAuthFailed
	}
	_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, true, "Successful IMAP app-password authentication")
	s.mailbox = mbox
	return nil
}

func (s *Session) requireAuth() error {
	if s.mailbox == nil {
		return errors.New("not authenticated")
	}
	return nil
}

func notFoundErr() error {
	return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "No such mailbox"}
}

func isInbox(name string) bool { return strings.EqualFold(name, inboxName) }

// folderExists reports whether name is a real folder for this mailbox — INBOX always
// is (even empty), any other name only if a filter rule's move_to_folder action has
// actually delivered something there (see mailstore's ApplyRules). Returns the
// canonical stored name (case as written in the DB) so callers use it consistently.
func (s *Session) folderExists(name string) (string, bool, error) {
	if isInbox(name) {
		return inboxName, true, nil
	}
	folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID)
	if err != nil {
		return "", false, err
	}
	for _, f := range folders {
		if strings.EqualFold(f, name) {
			return f, true, nil
		}
	}
	return "", false, nil
}

// messages loads every stored message in the currently selected folder, ordered
// ascending by UID — this ordering IS the sequence-number mapping (index+1 == seqNum).
func (s *Session) messages() ([]db.MailboxMessage, error) {
	folder := s.selectedFolder
	if folder == "" {
		folder = inboxName
	}
	return s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder)
}

func (s *Session) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) {
	if err := s.requireAuth(); err != nil {
		return nil, err
	}
	folder, ok, err := s.folderExists(mailbox)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, notFoundErr()
	}
	s.selectedFolder = folder
	msgs, err := s.messages()
	if err != nil {
		return nil, err
	}

	flagSet := map[imap.Flag]struct{}{}
	var firstUnseen uint32
	for i, m := range msgs {
		for _, f := range splitFlags(m.Flags) {
			flagSet[f] = struct{}{}
		}
		if firstUnseen == 0 && !hasFlag(m.Flags, imap.FlagSeen) {
			firstUnseen = uint32(i) + 1
		}
	}
	var flags []imap.Flag
	for f := range flagSet {
		flags = append(flags, f)
	}
	sort.Slice(flags, func(i, j int) bool { return flags[i] < flags[j] })
	permanent := append(append([]imap.Flag{}, flags...), imap.FlagWildcard)

	return &imap.SelectData{
		Flags:             flags,
		PermanentFlags:    permanent,
		NumMessages:       uint32(len(msgs)),
		FirstUnseenSeqNum: firstUnseen,
		UIDNext:           nextUID(msgs),
		UIDValidity:       uint32(s.mailbox.ID),
	}, nil
}

func (s *Session) Unselect() error {
	s.selectedFolder = ""
	return nil
}

func (s *Session) Create(mailbox string, options *imap.CreateOptions) error {
	return errors.New("creating mailboxes is not supported")
}

func (s *Session) Delete(mailbox string) error {
	return errors.New("deleting mailboxes is not supported")
}

func (s *Session) Rename(mailbox, newName string, options *imap.RenameOptions) error {
	return errors.New("renaming mailboxes is not supported")
}

func (s *Session) Subscribe(mailbox string) error {
	_, ok, err := s.folderExists(mailbox)
	if err != nil {
		return err
	}
	if !ok {
		return notFoundErr()
	}
	return nil
}

func (s *Session) Unsubscribe(mailbox string) error {
	_, ok, err := s.folderExists(mailbox)
	if err != nil {
		return err
	}
	if !ok {
		return notFoundErr()
	}
	return nil
}

// specialUseAttrs tags a folder with its RFC 6154 SPECIAL-USE attribute, if any —
// desktop IMAP clients (Thunderbird, Apple Mail, K-9, etc.) use this to recognize
// Trash/Junk/Sent/Drafts regardless of the exact folder name, though in practice not
// every client honors SPECIAL-USE reliably, which is why the folder itself is named
// "Junk" (see db.StandardMailboxFolders) rather than relying on this attribute alone.
func specialUseAttrs(folder string) []imap.MailboxAttr {
	switch folder {
	case "Trash":
		return []imap.MailboxAttr{imap.MailboxAttrTrash}
	case "Junk":
		return []imap.MailboxAttr{imap.MailboxAttrJunk}
	case "Sent":
		return []imap.MailboxAttr{imap.MailboxAttrSent}
	case "Drafts":
		return []imap.MailboxAttr{imap.MailboxAttrDrafts}
	default:
		return nil
	}
}

// List reports every folder the mailbox has: the standard folders (INBOX, Junk, Sent,
// Drafts, Trash — always, even empty) plus any custom folder a filter rule's
// move_to_folder action or explicit webmail folder creation has produced — the same
// full list db.AllFoldersForMailbox gives the webmail UI, so a desktop IMAP client
// sees exactly the same folders webmail does instead of only ones that happen to
// already hold a message (DistinctFoldersForMailbox alone).
func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
	if err := s.requireAuth(); err != nil {
		return err
	}
	folders, err := s.backend.DB.AllFoldersForMailbox(s.mailbox.ID)
	if err != nil {
		return err
	}
	if len(patterns) == 0 {
		patterns = []string{""}
	}
	for _, pattern := range patterns {
		if pattern == "" {
			continue
		}
		for _, folder := range folders {
			if !goimapserver.MatchList(folder, '/', ref, pattern) {
				continue
			}
			if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/', Attrs: specialUseAttrs(folder)}); err != nil {
				return err
			}
		}
	}
	return nil
}

func (s *Session) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) {
	if err := s.requireAuth(); err != nil {
		return nil, err
	}
	folder, ok, err := s.folderExists(mailbox)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, notFoundErr()
	}
	msgs, err := s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder)
	if err != nil {
		return nil, err
	}
	data := &imap.StatusData{Mailbox: folder, UIDValidity: uint32(s.mailbox.ID), UIDNext: nextUID(msgs)}
	if options.NumMessages {
		n := uint32(len(msgs))
		data.NumMessages = &n
	}
	if options.NumUnseen {
		var n uint32
		for _, m := range msgs {
			if !hasFlag(m.Flags, imap.FlagSeen) {
				n++
			}
		}
		data.NumUnseen = &n
	}
	if options.Size {
		var size int64
		for _, m := range msgs {
			size += m.SizeBytes
		}
		data.Size = &size
	}
	return data, nil
}

func (s *Session) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
	return nil, errors.New("APPEND is not supported yet")
}

func (s *Session) Poll(w *goimapserver.UpdateWriter, allowExpunge bool) error { return nil }

func (s *Session) Idle(w *goimapserver.UpdateWriter, stop <-chan struct{}) error {
	<-stop
	return nil
}

// Expunge permanently removes every \Deleted-flagged message matched by uids (or all
// of them, if uids is nil) — this is how a client actually deletes mail (STORE
// \Deleted, then EXPUNGE), calling all the way down to mailstore so the on-disk
// ciphertext is removed too, not just the index row.
func (s *Session) Expunge(w *goimapserver.ExpungeWriter, uids *imap.UIDSet) error {
	if err := s.requireAuth(); err != nil {
		return err
	}
	msgs, err := s.messages()
	if err != nil {
		return err
	}
	for i := len(msgs) - 1; i >= 0; i-- {
		m := msgs[i]
		if uids != nil && !uids.Contains(imap.UID(m.ID)) {
			continue
		}
		if !hasFlag(m.Flags, imap.FlagDeleted) {
			continue
		}
		if err := s.backend.Mailstore.DeleteMessage(s.mailbox.ID, m.ID); err != nil {
			return err
		}
		if err := w.WriteExpunge(uint32(i) + 1); err != nil {
			return err
		}
	}
	return nil
}

// Search supports structural criteria (sequence/UID sets, flags, size, boolean
// combinators) without decrypting anything.
// ponytail: no header/body/text/date matching (would require decrypting every
// candidate message) — SEARCH FROM/SUBJECT/BODY/SINCE etc. are treated as always
// matching rather than filtering. Add if a client's search relies on it.
func (s *Session) Search(kind goimapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
	if err := s.requireAuth(); err != nil {
		return nil, err
	}
	msgs, err := s.messages()
	if err != nil {
		return nil, err
	}
	var data imap.SearchData
	var seqSet imap.SeqSet
	var uidSet imap.UIDSet
	for i, m := range msgs {
		seqNum := uint32(i) + 1
		if !matchesSearch(m, seqNum, criteria) {
			continue
		}
		uidSet.AddNum(imap.UID(m.ID))
		seqSet.AddNum(seqNum)
		data.Count++
		num := seqNum
		if kind == goimapserver.NumKindUID {
			num = uint32(m.ID)
		}
		if data.Min == 0 || num < data.Min {
			data.Min = num
		}
		if num > data.Max {
			data.Max = num
		}
	}
	if kind == goimapserver.NumKindUID {
		data.All = uidSet
	} else {
		data.All = seqSet
	}
	return &data, nil
}

func matchesSearch(m db.MailboxMessage, seqNum uint32, c *imap.SearchCriteria) bool {
	if c == nil {
		return true
	}
	for _, ss := range c.SeqNum {
		if !ss.Contains(seqNum) {
			return false
		}
	}
	for _, us := range c.UID {
		if !us.Contains(imap.UID(m.ID)) {
			return false
		}
	}
	for _, f := range c.Flag {
		if !hasFlag(m.Flags, f) {
			return false
		}
	}
	for _, f := range c.NotFlag {
		if hasFlag(m.Flags, f) {
			return false
		}
	}
	if c.Larger > 0 && m.SizeBytes <= c.Larger {
		return false
	}
	if c.Smaller > 0 && m.SizeBytes >= c.Smaller {
		return false
	}
	for _, not := range c.Not {
		if matchesSearch(m, seqNum, &not) {
			return false
		}
	}
	for _, or := range c.Or {
		if !matchesSearch(m, seqNum, &or[0]) && !matchesSearch(m, seqNum, &or[1]) {
			return false
		}
	}
	return true
}

// Fetch streams FETCH responses for every message matched by numSet. Fetching a
// non-peek body section marks the message \Seen, mirroring standard IMAP semantics.
func (s *Session) Fetch(w *goimapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
	if err := s.requireAuth(); err != nil {
		return err
	}
	msgs, err := s.messages()
	if err != nil {
		return err
	}
	markSeen := false
	for _, bs := range options.BodySection {
		if !bs.Peek {
			markSeen = true
		}
	}
	for i, m := range msgs {
		seqNum := uint32(i) + 1
		if !numSetContains(numSet, seqNum, imap.UID(m.ID)) {
			continue
		}
		if markSeen && !hasFlag(m.Flags, imap.FlagSeen) {
			newFlags := addFlag(m.Flags, imap.FlagSeen)
			if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil {
				return err
			}
			m.Flags = newFlags
		}
		if err := s.writeFetch(w.CreateMessage(seqNum), m, options); err != nil {
			return err
		}
	}
	return nil
}

// writeFetch writes one message's FETCH response.
// ponytail: no MIME-aware sub-part/header-only body-section extraction — any
// requested BODY[...] section returns the full raw RFC822 message regardless of the
// requested part/specifier. Real clients' basic "fetch the whole message" flow works
// fine with this; upgrade to real section extraction if header-only/sub-part fetches
// are needed later.
func (s *Session) writeFetch(w *goimapserver.FetchResponseWriter, m db.MailboxMessage, options *imap.FetchOptions) error {
	w.WriteUID(imap.UID(m.ID))
	if options.Flags {
		w.WriteFlags(splitFlags(m.Flags))
	}
	if options.InternalDate {
		w.WriteInternalDate(m.InternalDate)
	}
	if options.RFC822Size {
		w.WriteRFC822Size(m.SizeBytes)
	}
	if options.Envelope {
		w.WriteEnvelope(buildEnvelope(m))
	}
	if len(options.BodySection) > 0 {
		raw, err := s.backend.Mailstore.FetchMessage(s.mailbox.ID, m.ID)
		if err != nil {
			return err
		}
		for _, bs := range options.BodySection {
			wc := w.WriteBodySection(bs, int64(len(raw)))
			if _, werr := wc.Write(raw); werr != nil {
				wc.Close()
				return werr
			}
			if cerr := wc.Close(); cerr != nil {
				return cerr
			}
		}
	}
	return w.Close()
}

func buildEnvelope(m db.MailboxMessage) *imap.Envelope {
	env := &imap.Envelope{Date: m.InternalDate, Subject: m.CachedSubject, MessageID: m.MessageIDHeader}
	if addr, err := mail.ParseAddress(m.CachedFrom); err == nil {
		mailbox, host := splitAddr(addr.Address)
		env.From = []imap.Address{{Name: addr.Name, Mailbox: mailbox, Host: host}}
	}
	return env
}

func splitAddr(addr string) (mailbox, host string) {
	i := strings.LastIndex(addr, "@")
	if i < 0 {
		return addr, ""
	}
	return addr[:i], addr[i+1:]
}

// Store applies a flag change to every message matched by numSet, then (unless
// .SILENT was requested) reports the resulting flags back via a FETCH response,
// mirroring standard STORE semantics.
func (s *Session) Store(w *goimapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
	if err := s.requireAuth(); err != nil {
		return err
	}
	msgs, err := s.messages()
	if err != nil {
		return err
	}
	for i, m := range msgs {
		seqNum := uint32(i) + 1
		if !numSetContains(numSet, seqNum, imap.UID(m.ID)) {
			continue
		}
		newFlags := applyStoreFlags(m.Flags, flags)
		if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil {
			return err
		}
	}
	if flags.Silent {
		return nil
	}
	return s.Fetch(w, numSet, &imap.FetchOptions{Flags: true})
}

func (s *Session) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) {
	return nil, errors.New("COPY is not supported")
}

func nextUID(msgs []db.MailboxMessage) imap.UID {
	if len(msgs) == 0 {
		return 1
	}
	return imap.UID(msgs[len(msgs)-1].ID) + 1
}

func numSetContains(numSet imap.NumSet, seqNum uint32, uid imap.UID) bool {
	switch ns := numSet.(type) {
	case imap.SeqSet:
		return ns.Contains(seqNum)
	case imap.UIDSet:
		return ns.Contains(uid)
	}
	return false
}

func splitFlags(s string) []imap.Flag {
	if s == "" {
		return nil
	}
	parts := strings.Fields(s)
	out := make([]imap.Flag, len(parts))
	for i, p := range parts {
		out[i] = imap.Flag(p)
	}
	return out
}

func hasFlag(flags string, target imap.Flag) bool {
	for _, f := range strings.Fields(flags) {
		if imap.Flag(f) == target {
			return true
		}
	}
	return false
}

func addFlag(flags string, f imap.Flag) string {
	if hasFlag(flags, f) {
		return flags
	}
	if flags == "" {
		return string(f)
	}
	return flags + " " + string(f)
}

func applyStoreFlags(current string, store *imap.StoreFlags) string {
	set := map[imap.Flag]struct{}{}
	for _, f := range splitFlags(current) {
		set[f] = struct{}{}
	}
	switch store.Op {
	case imap.StoreFlagsSet:
		set = map[imap.Flag]struct{}{}
		fallthrough
	case imap.StoreFlagsAdd:
		for _, f := range store.Flags {
			set[f] = struct{}{}
		}
	case imap.StoreFlagsDel:
		for _, f := range store.Flags {
			delete(set, f)
		}
	}
	out := make([]string, 0, len(set))
	for f := range set {
		out = append(out, string(f))
	}
	sort.Strings(out)
	return strings.Join(out, " ")
}
