package webui

import (
	"fmt"
	"html/template"
	"net/http"
	"net/mail"
	"strconv"
	"strings"
	"time"

	"gopkg.in/ini.v1"
	"mailgoserver/internal/db"
)

// M is the per-page template data map — mirrors the kwargs Flask's render_template(...)
// is called with. Using a map (not per-page structs) keeps 18 template contexts from
// needing 18 Go struct types.
type M map[string]any

func (a *App) funcMap() template.FuncMap {
	return template.FuncMap{
		"formatDatetime": func(t time.Time) string { return formatDatetimeInZone(t, a.Cfg) },
		// strftime accepts either time.Time or *time.Time (nullable DB columns like
		// MailboxAppPassword.LastUsedAt) so callers don't need a separate deref helper.
		"strftime": func(layout string, v any) string {
			var t time.Time
			switch tv := v.(type) {
			case time.Time:
				t = tv
			case *time.Time:
				if tv == nil {
					return ""
				}
				t = *tv
			default:
				return ""
			}
			if t.IsZero() {
				return ""
			}
			return t.Format(pyToGoLayout(layout))
		},
		// isPast reports whether a nullable expiry timestamp has already passed —
		// used to badge an app password as "Expired" even while is_active is
		// still 1 (expiry and revocation are independent states).
		"isPast":      func(t *time.Time) bool { return t != nil && t.Before(time.Now()) },
		"ruleSummary": summarizeConditions,
		"title":       strings.Title,
		"upper":       strings.ToUpper,
		"lower":       strings.ToLower,
		"safe":        func(s string) template.HTML { return template.HTML(s) },
		"filesize":    humanFileSize,
		// senderName shows just the display name from a "Name <addr@example.com>"
		// cached_from value (the message's own From: header, cached verbatim — see
		// smtpserver's fromHeader) — falls back to the bare address when there's no
		// display name, or the value doesn't parse as one (e.g. an older row cached
		// before this, or a plain envelope address with no header form).
		"senderName": func(s string) string {
			if addr, err := mail.ParseAddress(s); err == nil && addr.Name != "" {
				return addr.Name
			}
			return s
		},
		// initial is the avatar-circle letter for a sender name/address — the first
		// rune, uppercased; falls back to "?" for an empty value rather than an empty
		// circle.
		"initial": func(s string) string {
			for _, r := range s {
				return strings.ToUpper(string(r))
			}
			return "?"
		},
		"dotToDash":   func(s string) string { return strings.ReplaceAll(s, ".", "-") },
		"add":         func(a, b int) int { return a + b },
		"sub":         func(a, b int) int { return a - b },
		"eq2":         func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
		// dget looks up an optional map key, returning "" if absent — mirrors Jinja's
		// `x if x is defined else ''` pattern used for context vars only some pages set
		// (e.g. sidebar badge counts, which only dashboard passes).
		"dget": func(m M, key string) any {
			if v, ok := m[key]; ok {
				return v
			}
			return ""
		},
		"list":             func(items ...string) []string { return items },
		"isStandardFolder": isStandardFolder,
		"folderIcon":       folderIcon,
		// emailOverallStatus mirrors the delivered/failed selectattr computation
		// dashboard.html and logs.html both do in the Python templates.
		"emailOverallStatus": func(recipients []db.EmailRecipientLog) string {
			delivered, failed := 0, 0
			for _, r := range recipients {
				if r.Status == "success" {
					delivered++
				} else {
					failed++
				}
			}
			switch {
			case delivered > 0 && failed > 0:
				return "partial"
			case delivered > 0:
				return "relayed"
			default:
				return "failed"
			}
		},
	}
}

// pyToGoLayout converts the handful of Python strftime directives this app actually
// uses into Go's reference-time layout.
func pyToGoLayout(py string) string {
	r := strings.NewReplacer(
		"%Y", "2006", "%m", "01", "%d", "02",
		"%H", "15", "%M", "04", "%S", "05",
	)
	return r.Replace(py)
}

func formatDatetimeInZone(t time.Time, cfg *ini.File) string {
	if t.IsZero() {
		return ""
	}
	tzName := cfg.Section("Server").Key("time_zone").MustString("UTC")
	loc, err := time.LoadLocation(tzName)
	if err != nil {
		loc = time.UTC
	}
	return t.In(loc).Format("2006-01-02 15:04:05")
}

func humanFileSize(size int64) string {
	const unit = 1024
	if size < unit {
		return fmt.Sprintf("%d B", size)
	}
	div, exp := int64(unit), 0
	for n := size / unit; n >= unit; n /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
}

// pages lists every template that extends base.html — each gets its own isolated
// template set (base.html + sidebar_email.html + itself) so their same-named
// {{define "content"}} blocks don't collide with each other (see loadTemplates).
var pages = []string{
	"dashboard.html", "domains.html", "add_domain.html", "edit_domain.html",
	"senders.html", "add_sender.html", "edit_sender.html",
	"mailboxes.html", "add_mailbox.html", "edit_mailbox.html", "mailbox_apppasswords.html", "mailbox_aliases.html",
	"mailbox_lists.html", "mailbox_rules.html",
	"ips.html", "add_ip.html", "edit_ip.html",
	"blacklist.html",
	"dkim.html", "edit_dkim.html",
	"settings.html", "letsencrypt.html", "logs.html", "error.html",
	"account.html", "first_login.html",
	"admins.html", "add_admin.html", "edit_admin.html",
}

// standalonePages don't use base.html's sidebar/dashboard chrome: pre-login screens
// (not authenticated yet) and the forced-MFA-setup flow (totp_setup.html included —
// deliberately isolated so an account with MFA enforced but not yet configured has
// no visible navigation to anything else, matching the enforcement gate in
// requireAuth/requireMailboxAuth that blocks every other route anyway).
var standalonePages = []string{
	"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
	"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
	"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
	"webmail_signatures.html", "webmail_contacts.html", "webmail_blocklist.html",
	// A bare HTML fragment (no <html>/base.html chrome at all), fetched via JS and
	// injected into logs.html's full-screen modal — not a page anyone navigates to
	// directly, so it doesn't need to look like a standalone document the way the
	// other entries in this list (all real standalone pages) do.
	"view_message_content.html",
	// Same idea as view_message_content.html above, but for webmail_folder.html's
	// reading pane instead of the admin log's modal.
	"webmail_message_pane.html",
}

// pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward
// entry point and so need webmail_compose_widget.html's floating-popup markup+JS
// parsed alongside them (see webmail_compose_widget.html's {{define "compose_widget"}}).
// webmail_compose.html itself is excluded — it's what opens inside the popup, not
// something that opens a popup of its own.
var pagesWithComposeWidget = []string{
	"webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html",
	"webmail_signatures.html", "webmail_contacts.html", "webmail_blocklist.html",
}

func hasComposeWidget(page string) bool {
	for _, p := range pagesWithComposeWidget {
		if p == page {
			return true
		}
	}
	return false
}

// pagesWithSettingsChrome are the settings sections consolidated into one
// Outlook-style left-nav/right-content layout — see webmail_settings_chrome.html.
var pagesWithSettingsChrome = []string{
	"webmail_account.html", "webmail_rules.html", "webmail_signatures.html", "webmail_certs.html",
	"webmail_contacts.html", "webmail_blocklist.html",
}

func hasSettingsChrome(page string) bool {
	for _, p := range pagesWithSettingsChrome {
		if p == page {
			return true
		}
	}
	return false
}

// pagesWithShortcuts are the two pages keyboard shortcuts make sense on — the
// message list (j/k/Enter/o) and a single open message (r/a/f/#). See
// webmail_shortcuts.html's {{define "webmail_shortcuts"}}.
var pagesWithShortcuts = []string{"webmail_folder.html", "webmail_message.html"}

func hasShortcuts(page string) bool {
	for _, p := range pagesWithShortcuts {
		if p == page {
			return true
		}
	}
	return false
}

// loadTemplates parses from the embedded assets FS (see embed.go), not the
// filesystem — the binary carries its own templates, so it runs from any working
// directory without needing the source tree alongside it.
func (a *App) loadTemplates() error {
	a.templates = map[string]*template.Template{}
	for _, page := range pages {
		t := template.New("base.html").Funcs(a.funcMap())
		t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/csrf_script.html", "templates/"+page)
		if err != nil {
			return fmt.Errorf("parse %s: %w", page, err)
		}
		a.templates[page] = t
	}
	for _, page := range standalonePages {
		t := template.New(page).Funcs(a.funcMap())
		files := []string{"templates/" + page, "templates/csrf_script.html"}
		if hasComposeWidget(page) {
			files = append(files, "templates/webmail_compose_widget.html")
		}
		if hasSettingsChrome(page) {
			files = append(files, "templates/webmail_settings_chrome.html")
		}
		if hasShortcuts(page) {
			files = append(files, "templates/webmail_shortcuts.html")
		}
		t, err := t.ParseFS(assets, files...)
		if err != nil {
			return fmt.Errorf("parse %s: %w", page, err)
		}
		a.templates[page] = t
	}
	return nil
}

func isStandalonePage(page string) bool {
	for _, p := range standalonePages {
		if p == page {
			return true
		}
	}
	return false
}

// render executes the named page template — as itself for standalone (pre-login)
// pages, or as "base.html" for everything else — mirroring flask.render_template.
func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M) {
	t, ok := a.templates[page]
	if !ok {
		http.Error(w, "template not found: "+page, http.StatusInternalServerError)
		return
	}
	if data == nil {
		data = M{}
	}
	// Set unconditionally for every page — pages with no session cookie yet (login)
	// just get "", which csrf_script.html's injected script treats as a no-op.
	data["csrf_token"] = a.csrfTokenFor(r)
	if isStandalonePage(page) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		if err := t.ExecuteTemplate(w, page, data); err != nil {
			a.Logger.Error("template render error (%s): %v", page, err)
		}
		return
	}
	data["flashes"] = popFlashes(w, r)
	data["health"] = a.checkHealth()
	// Drives the sidebar hiding Server Settings/Let's Encrypt for scoped admins (see
	// requireGlobalAdmin, which is the actual enforcement — this only controls the
	// link's visibility).
	if u := userFromContext(r); u != nil {
		data["is_global_admin"] = u.IsGlobalAdmin
	}
	// Sidebar badge counts (Domains/Senders/Mailboxes/IPs/DKIM Keys) — computed here,
	// centrally, so every authenticated page shows them, not just the dashboard (which
	// used to compute these itself and nowhere else did).
	counts := a.computeNavCounts(r)
	data["domain_count"] = counts.DomainCount
	data["sender_count"] = counts.SenderCount
	data["mailbox_count"] = counts.MailboxCount
	data["ip_count"] = counts.IPCount
	data["dkim_count"] = counts.DKIMCount
	data["blacklist_count"] = counts.BlacklistCount
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := t.ExecuteTemplate(w, "base.html", data); err != nil {
		a.Logger.Error("template render error (%s): %v", page, err)
	}
}

func atoi(s string) int {
	n, _ := strconv.Atoi(s)
	return n
}
