package webui

import (
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"github.com/microcosm-cc/bluemonday"
	"mailgoserver/internal/db"
	"mailgoserver/internal/mailview"
)

const webmailPageSize = 25

// htmlBodyPolicy sanitizes an HTML email body before it's ever embedded into a page
// as template.HTML — an email body is attacker-controlled content (anyone can send a
// mailbox a message), so rendering it unsanitized would be a straightforward stored
// XSS vector. UGCPolicy allows the common formatting tags/attributes a real email
// body uses while stripping <script>, event handlers, javascript: URLs, etc.
// AllowDataURIImages additionally permits img[src] as a base64 data: URI, restricted
// to actual decodable image/{gif,jpeg,png,webp} content (not a blanket data: URI
// allowance) — needed so a screenshot pasted into the Quill compose editor (which
// embeds pastes as inline base64 images) still renders once sanitized, both in the
// sender's own Sent view and the recipient's inbox.
var htmlBodyPolicy = func() *bluemonday.Policy {
	p := bluemonday.UGCPolicy()
	p.AllowDataURIImages()
	return p
}()

// plainTextPolicy strips all HTML tags, leaving only text content — used to derive a
// plain-text fallback part from an HTML compose body (multipart/alternative) and to
// quote a plain-text-only original message's body when replying/forwarding.
var plainTextPolicy = bluemonday.StrictPolicy()

// standardMailFolders are always shown in the folder sidebar even when empty — the
// rest of a mailbox's folder list is whatever filter-rule move_to_folder actions (or,
// later, explicit folder creation) have actually produced messages in.
var standardMailFolders = []string{"INBOX", "Spam", "Sent", "Drafts", "Trash"}

func mergeFolders(custom []string) []string {
	seen := make(map[string]bool, len(standardMailFolders)+len(custom))
	out := make([]string, 0, len(standardMailFolders)+len(custom))
	for _, f := range standardMailFolders {
		seen[f] = true
		out = append(out, f)
	}
	for _, f := range custom {
		if !seen[f] {
			seen[f] = true
			out = append(out, f)
		}
	}
	return out
}

// isStandardFolder reports whether name is one of the built-in folders every mailbox
// always has — these can never be created, renamed, or deleted through the folder
// management UI.
func isStandardFolder(name string) bool {
	for _, f := range standardMailFolders {
		if f == name {
			return true
		}
	}
	return false
}

// allFoldersFor is the full folder list for a mailbox: standard folders, plus every
// folder that either holds at least one message (DistinctFoldersForMailbox) or was
// explicitly created and is still empty (ListMailboxFolders) — a folder can exist via
// either path, sometimes both.
func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
	fromMessages, err := a.DB.DistinctFoldersForMailbox(mailboxID)
	if err != nil {
		return nil, err
	}
	explicit, err := a.DB.ListMailboxFolders(mailboxID)
	if err != nil {
		return nil, err
	}
	return mergeFolders(append(fromMessages, explicit...)), nil
}

// folderRow adds template-ready fields to a listed message so webmail_folder.html
// stays dumb (no string-searching Flags, no subject-comparison logic, itself).
type folderRow struct {
	db.MailboxMessage
	Unread bool
	// GroupExtra is set on the newest row of a same-subject run: how many older
	// messages are collapsed under it (0 = not part of a group). Collapsed is set on
	// each of those older rows, which the template hides until the group's expand
	// toggle is clicked.
	GroupExtra int
	Collapsed  bool
}

// sortLink builds the href for a clickable "From"/"Date" column header: clicking an
// inactive column sorts by it descending; clicking the already-active column flips
// direction; unreadOnly (and folder/query, via the caller building this against the
// current URL) carries over so toggling sort never drops the unread filter.
func sortLink(col string, unreadOnly bool, activeSortBy, activeSortDir string) string {
	v := url.Values{}
	dir := "desc"
	if activeSortBy == col {
		if activeSortDir == "asc" {
			dir = "desc"
		} else {
			dir = "asc"
		}
	}
	if col != "" {
		v.Set("sort", col)
	}
	if dir != "desc" {
		v.Set("dir", dir)
	}
	if unreadOnly {
		v.Set("unread", "1")
	}
	if encoded := v.Encode(); encoded != "" {
		return "?" + encoded
	}
	return "?"
}

func isUnread(flags string) bool {
	for _, f := range strings.Fields(flags) {
		if f == `\Seen` {
			return false
		}
	}
	return true
}

// normalizeSubjectForGrouping strips Re:/Fwd:/Fw: prefixes and case for comparison.
// This is subject-based grouping, not References/In-Reply-To thread reconstruction
// — a reply with a hand-edited subject line won't group with its original, and two
// unrelated messages that happen to share a subject long after each other in
// mailbox history won't either, since grouping only ever joins consecutive rows in
// the already-sorted page (see groupConsecutiveBySubject). That's the accepted
// tradeoff for not needing a schema change or store-time header parsing.
func normalizeSubjectForGrouping(subject string) string {
	s := strings.TrimSpace(subject)
	for {
		lower := strings.ToLower(s)
		switch {
		case strings.HasPrefix(lower, "re:"):
			s = strings.TrimSpace(s[3:])
		case strings.HasPrefix(lower, "fwd:"):
			s = strings.TrimSpace(s[4:])
		case strings.HasPrefix(lower, "fw:"):
			s = strings.TrimSpace(s[3:])
		default:
			return strings.ToLower(s)
		}
	}
}

// groupConsecutiveBySubject annotates rows in place (same order, same length) with
// GroupExtra/Collapsed rather than restructuring them into a nested shape — the
// template can then render it exactly like a flat row list, just hiding Collapsed
// rows by default and showing a "+N more" toggle on the row above them.
func groupConsecutiveBySubject(rows []folderRow) []folderRow {
	out := make([]folderRow, len(rows))
	copy(out, rows)
	i := 0
	for i < len(out) {
		key := normalizeSubjectForGrouping(out[i].CachedSubject)
		j := i + 1
		for key != "" && j < len(out) && normalizeSubjectForGrouping(out[j].CachedSubject) == key {
			out[j].Collapsed = true
			j++
		}
		out[i].GroupExtra = j - i - 1
		i = j
	}
	return out
}

// webmailMailRoot sends a bare /webmail/mail visit to the inbox — there's no
// meaningful "all folders" view.
func (a *App) webmailMailRoot(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}

// webmailFolderView lists one folder's messages, newest first, paginated — or, when
// ?q= is set, a search across that folder's (or every folder's, if active_folder is
// empty — see webmailSearch) cached subject/from/to instead.
func (a *App) webmailFolderView(w http.ResponseWriter, r *http.Request) {
	a.renderFolderOrSearch(w, r, r.PathValue("folder"), strings.TrimSpace(r.URL.Query().Get("q")))
}

// renderFolderOrSearch is shared by webmailFolderView (folder browsing) and
// webmailSearch (all-folders search, webmail_search.go) — same page template, same
// pagination shape, differing only in which folder (if any) is scoped and whether a
// query narrows the result set.
func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folder, query string) {
	mbox := mailboxFromContext(r)

	folders, err := a.allFoldersFor(mbox.ID)
	if err != nil {
		a.Logger.Error("list folders for mailbox %d: %v", mbox.ID, err)
	}
	unreadCounts, err := a.DB.CountUnreadByFolder(mbox.ID)
	if err != nil {
		a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
	}
	folderCounts, err := a.DB.CountMessagesByFolder(mbox.ID)
	if err != nil {
		a.Logger.Error("count messages by folder for mailbox %d: %v", mbox.ID, err)
	}

	page := atoi(r.URL.Query().Get("page"))
	if page < 1 {
		page = 1
	}
	offset := (page - 1) * webmailPageSize
	unreadOnly := r.URL.Query().Get("unread") == "1"
	sortBy := r.URL.Query().Get("sort")   // "" (id/date, default) or "from"
	sortDir := r.URL.Query().Get("dir")   // "" (desc, default) or "asc"

	var total int
	var rows []db.MailboxMessage
	if query != "" {
		total, err = a.DB.CountSearchMessagesInFolder(mbox.ID, folder, query)
		if err != nil {
			a.Logger.Error("count search results for mailbox %d: %v", mbox.ID, err)
		}
		rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
	} else {
		total, err = a.DB.CountMessagesInFolder(mbox.ID, folder, unreadOnly)
		if err != nil {
			a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
		}
		rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, unreadOnly, sortBy, sortDir, offset, webmailPageSize)
	}
	if err != nil {
		setFlash(w, "error", "Error loading messages")
	}
	messages := make([]folderRow, 0, len(rows))
	for _, m := range rows {
		messages = append(messages, folderRow{MailboxMessage: m, Unread: isUnread(m.Flags)})
	}
	// Grouping a cross-folder search's results by subject would mix messages that
	// happen to share a subject across unrelated folders — only group a real,
	// single-folder, unfiltered listing. Off by default (mbox.GroupMessages) — a
	// per-mailbox display preference, toggled from Account.
	if query == "" && folder != "" && mbox.GroupMessages {
		messages = groupConsecutiveBySubject(messages)
	}

	unreadToggleV := url.Values{}
	if !unreadOnly {
		unreadToggleV.Set("unread", "1")
	}
	if sortBy != "" {
		unreadToggleV.Set("sort", sortBy)
	}
	if sortDir != "" {
		unreadToggleV.Set("dir", sortDir)
	}
	unreadOnlyHref := "?" + unreadToggleV.Encode()

	pageHref := func(n int) string {
		v := url.Values{}
		v.Set("page", strconv.Itoa(n))
		if unreadOnly {
			v.Set("unread", "1")
		}
		if sortBy != "" {
			v.Set("sort", sortBy)
		}
		if sortDir != "" {
			v.Set("dir", sortDir)
		}
		return "?" + v.Encode()
	}

	a.render(w, r, "webmail_folder.html", M{
		"mailbox": mbox, "folders": folders, "active_folder": folder,
		"messages": messages, "page": page, "total": total,
		"has_next": offset+len(rows) < total, "has_prev": page > 1,
		"search_query": query, "unread_counts": unreadCounts, "folder_counts": folderCounts,
		"unread_only": unreadOnly, "sort_by": sortBy, "sort_dir": sortDir,
		"sort_from_href":   sortLink("from", unreadOnly, sortBy, sortDir),
		"sort_date_href":   sortLink("", unreadOnly, sortBy, sortDir),
		"unread_only_href": unreadOnlyHref,
		"prev_href":        pageHref(page - 1),
		"next_href":        pageHref(page + 1),
		"flashes":          popFlashes(w, r),
	})
}

// loadMessageForView decrypts, parses, and marks one message read — the shared core
// behind both webmailMessageView (the full standalone page, for direct links/
// bookmarks) and webmailMessagePane (a bare fragment, AJAX-loaded into the folder
// view's Outlook-style reading pane) so the crypto/parse/mark-read logic exists in
// exactly one place. Redirects and returns ok=false itself on any failure, so callers
// just need to bail out when ok is false.
func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *db.Mailbox, folder string, uid int64) (data M, ok bool) {
	msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
	if !ok {
		return nil, false
	}
	raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
	if err != nil {
		a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
		setFlash(w, "error", "Error loading message")
		http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
		return nil, false
	}
	unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
	parsed, err := mailview.Parse(unwrapped)
	if err != nil {
		a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
		setFlash(w, "error", "Error reading message")
		http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
		return nil, false
	}

	if isUnread(msgRow.Flags) {
		newFlags := strings.TrimSpace(msgRow.Flags + ` \Seen`)
		if err := a.DB.SetMessageFlags(mbox.ID, uid, newFlags); err != nil {
			a.Logger.Error("mark message %d read: %v", uid, err)
		}
	}

	folders, _ := a.allFoldersFor(mbox.ID)
	var htmlBody template.HTML
	if parsed.HTMLBody != "" {
		htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
	}

	return M{
		"mailbox": mbox, "folders": folders, "active_folder": folder,
		"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
		"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
	}, true
}

// webmailMessageView renders one message as its own full page — direct links/
// bookmarks still work even though the folder view's reading pane (webmailMessagePane)
// is how it's normally opened now.
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	folder := r.PathValue("folder")
	uid := int64(atoi(r.PathValue("uid")))

	data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
	if !ok {
		return
	}
	data["flashes"] = popFlashes(w, r)
	a.render(w, r, "webmail_message.html", data)
}

// webmailMessagePane is webmailMessageView's bare-fragment twin — AJAX-fetched into
// the folder view's reading pane (see webmail_folder.html) instead of navigating to a
// whole new page, mirroring the admin dashboard's message-log modal (view_message.go).
func (a *App) webmailMessagePane(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	folder := r.PathValue("folder")
	uid := int64(atoi(r.PathValue("uid")))

	data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
	if !ok {
		return
	}
	a.render(w, r, "webmail_message_pane.html", data)
}

// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
// this mailbox, or isn't in the folder the URL claims — mirrors the admin side's
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
// as authorization, always re-check server-side.
func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
	msg, ok := a.messageAccessible(mailboxID, folder, uid)
	if !ok {
		http.NotFound(w, r)
		return nil, false
	}
	return msg, true
}

// messageAccessible is webmailMessageWithAccess without the side effect of writing a
// 404 response — for webmailBulkAction, where one stale/mismatched uid among a batch
// selected from the page's own checkboxes should just be skipped, not abort (and
// double-write a response for) the whole request.
func (a *App) messageAccessible(mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
	msg, err := a.DB.GetMessageByUID(mailboxID, uid)
	if err != nil || msg == nil || msg.Folder != folder {
		return nil, false
	}
	return msg, true
}

// webmailMessageDelete moves a message to Trash — or, if it's already in Trash,
// permanently deletes it (ciphertext, index row, and frees the quota).
func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	folder := r.PathValue("folder")
	uid := int64(atoi(r.PathValue("uid")))
	if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
		return
	}

	if folder == "Trash" {
		if err := a.Mailstore.DeleteMessage(mbox.ID, uid); err != nil {
			setFlash(w, "error", "Error deleting message")
		} else {
			setFlash(w, "success", "Message permanently deleted")
		}
		http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
		return
	}
	if err := a.DB.MoveMessage(mbox.ID, uid, "Trash"); err != nil {
		setFlash(w, "error", "Error moving message to Trash")
	} else {
		setFlash(w, "success", "Message moved to Trash")
	}
	http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}

// webmailMessageMove reassigns a message to a different (existing or freshly named)
// folder, e.g. from the message view's "Move to..." control.
func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	folder := r.PathValue("folder")
	uid := int64(atoi(r.PathValue("uid")))
	if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
		return
	}

	target := strings.TrimSpace(r.FormValue("target_folder"))
	if target == "" {
		setFlash(w, "error", "Choose a folder to move to")
		http.Redirect(w, r, MailboxPrefix+"/mail/"+folder+"/"+strconv.FormatInt(uid, 10), http.StatusFound)
		return
	}
	if err := a.DB.MoveMessage(mbox.ID, uid, target); err != nil {
		setFlash(w, "error", "Error moving message")
	} else {
		setFlash(w, "success", "Message moved to "+target)
	}
	http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}

// webmailBulkAction applies one action (delete/move/read/unread) to every uid selected
// via the folder view's checkboxes — the Outlook-style toolbar's bulk equivalent of
// webmailMessageDelete/webmailMessageMove/the auto-mark-read-on-open behavior, all
// through one endpoint rather than four near-identical ones. Every uid is
// independently re-checked against this mailbox+folder (webmailMessageWithAccess) —
// the folder path segment is never trusted as authorization by itself, same as the
// single-message actions.
func (a *App) webmailBulkAction(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	folder := r.PathValue("folder")
	if err := r.ParseForm(); err != nil {
		setFlash(w, "error", "Invalid form data")
		http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
		return
	}
	action := r.FormValue("action")
	target := strings.TrimSpace(r.FormValue("target_folder"))
	if action == "move" && target == "" {
		setFlash(w, "error", "Choose a folder to move to")
		http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
		return
	}

	n := 0
	for _, uidStr := range r.Form["uid"] {
		uid := int64(atoi(uidStr))
		if uid == 0 {
			continue
		}
		if _, ok := a.messageAccessible(mbox.ID, folder, uid); !ok {
			// A mismatched/stale uid here just means stale client state (the page's
			// own checkboxes) — skip it, don't hard-fail the whole batch over one bad
			// entry the way the single-message actions correctly do for a URL-level uid.
			continue
		}
		var err error
		switch action {
		case "delete":
			if folder == "Trash" {
				err = a.Mailstore.DeleteMessage(mbox.ID, uid)
			} else {
				err = a.DB.MoveMessage(mbox.ID, uid, "Trash")
			}
		case "move":
			err = a.DB.MoveMessage(mbox.ID, uid, target)
		case "read":
			err = a.DB.SetMessageFlags(mbox.ID, uid, `\Seen`)
		case "unread":
			err = a.DB.SetMessageFlags(mbox.ID, uid, "")
		default:
			continue
		}
		if err != nil {
			a.Logger.Error("bulk %s on message %d for mailbox %d: %v", action, uid, mbox.ID, err)
			continue
		}
		n++
	}
	if n > 0 {
		setFlash(w, "success", fmt.Sprintf("%d message(s) updated", n))
	} else {
		setFlash(w, "error", "No messages were selected")
	}
	http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}

// webmailAttachmentDownload re-decrypts and re-parses the whole message on every
// download — there's no separate on-disk attachment cache, and message sizes on a
// self-hosted mail server are small enough that this is simpler than building one.
func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	folder := r.PathValue("folder")
	uid := int64(atoi(r.PathValue("uid")))
	idx := atoi(r.PathValue("idx"))

	if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
		return
	}
	raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
	parsed, err := mailview.Parse(unwrapped)
	if err != nil || idx < 0 || idx >= len(parsed.Attachments) {
		http.NotFound(w, r)
		return
	}
	att := parsed.Attachments[idx]
	w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(att.Filename, `"`, "")+`"`)
	w.Header().Set("Content-Type", att.ContentType)
	w.Write(att.Data)
}

const maxFolderNameLen = 60

// webmailAddFolder creates a new custom folder from the sidebar's "+ New folder"
// form. A standard folder name, an empty name, or a name that already exists is
// rejected with a flash rather than silently accepted/ignored.
func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	name := strings.TrimSpace(r.FormValue("name"))

	fail := func(msg string) {
		setFlash(w, "error", msg)
		http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
	}
	switch {
	case name == "":
		fail("Folder name is required")
		return
	case len(name) > maxFolderNameLen:
		fail("Folder name is too long")
		return
	case isStandardFolder(name):
		fail(name + " already exists")
		return
	}
	existing, err := a.allFoldersFor(mbox.ID)
	if err != nil {
		fail("Error creating folder")
		return
	}
	for _, f := range existing {
		if strings.EqualFold(f, name) {
			fail("A folder named " + f + " already exists")
			return
		}
	}
	if err := a.DB.CreateMailboxFolder(mbox.ID, name); err != nil {
		fail("Error creating folder")
		return
	}
	setFlash(w, "success", "Folder "+name+" created")
	http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
}

// webmailDeleteFolder removes a custom folder, moving any messages still in it to
// INBOX first — a folder is never left holding mail nothing can browse to anymore.
// Standard folders (checked server-side, not just hidden client-side) can't be
// removed this way.
func (a *App) webmailDeleteFolder(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	name := r.PathValue("name")

	if isStandardFolder(name) {
		setFlash(w, "error", name+" is a standard folder and can't be removed")
		http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
		return
	}
	if err := a.DB.MoveAllMessagesInFolder(mbox.ID, name, "INBOX"); err != nil {
		setFlash(w, "error", "Error removing folder")
		http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
		return
	}
	if err := a.DB.DeleteMailboxFolder(mbox.ID, name); err != nil {
		setFlash(w, "error", "Error removing folder")
	} else {
		setFlash(w, "success", "Folder "+name+" removed — any mail in it moved to INBOX")
	}
	http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}
