// Package services — MailService — SMTP delivery for transactional email.
//
// Handles two flows today: deadline reminders (reminder_service.go) and
// colleague invitations (handlers/invite.go). Body and subject for both come
// from EmailTemplateService — a DB-backed override falls through to the
// embedded per-language file when no override exists. See
// docs/design-email-templates-2026-04-29.md for the override semantics.
//
// Config is read from env vars at startup; when any required var is unset
// the service logs a warning and becomes a silent no-op. This lets the
// server run locally without SMTP credentials — no crashes, no surprise
// 500s.
//
// Port 465 uses implicit TLS (tls.Dial from the start), not STARTTLS. The
// Hostinger submission endpoint only accepts implicit TLS on that port.
package services

import (
	"bytes"
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	htmltemplate "html/template"
	"log/slog"
	"maps"
	"mime"
	"net"
	"net/smtp"
	"os"
	"regexp"
	"strings"
	texttemplate "text/template"
	"time"

	"mgit.msbls.de/m/paliad/internal/branding"
)

// MailConfig holds resolved SMTP settings. Built once at startup.
type MailConfig struct {
	Host     string
	Port     string
	Username string
	Password string
	From     string
	FromName string
	UseTLS   bool
}

// MailService sends branded HTML+text email over SMTP. Safe to use
// concurrently. When the service is disabled (missing env vars), every Send*
// call logs and returns nil so callers can treat it as fire-and-forget.
//
// Templates are looked up via EmailTemplateService at render time, so an
// admin save propagates without a process restart. The service is created
// without one and gets it via SetTemplateService — wiring order in main.go
// (mail before DB pool) makes constructor injection awkward.
type MailService struct {
	cfg          MailConfig
	enabled      bool
	templateSvc  *EmailTemplateService
}

// NewMailService reads SMTP_* from the environment. Returns a non-nil
// service either way; callers check Enabled() if they care whether mail
// actually went out. Template lookup is bound later via SetTemplateService.
func NewMailService() (*MailService, error) {
	cfg := MailConfig{
		Host:     strings.TrimSpace(os.Getenv("SMTP_HOST")),
		Port:     strings.TrimSpace(os.Getenv("SMTP_PORT")),
		Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
		Password: os.Getenv("SMTP_PASSWORD"),
		From:     strings.TrimSpace(os.Getenv("SMTP_FROM")),
		FromName: strings.TrimSpace(os.Getenv("SMTP_FROM_NAME")),
		UseTLS:   !strings.EqualFold(os.Getenv("SMTP_USE_TLS"), "false"),
	}
	if cfg.Port == "" {
		cfg.Port = "465"
	}
	if cfg.From == "" && cfg.Username != "" {
		cfg.From = cfg.Username
	}
	if cfg.FromName == "" {
		cfg.FromName = "Paliad"
	}

	enabled := cfg.Host != "" && cfg.Username != "" && cfg.Password != "" && cfg.From != ""
	if !enabled {
		slog.Warn("mail: SMTP_* env vars incomplete — email delivery disabled",
			"host_set", cfg.Host != "",
			"username_set", cfg.Username != "",
			"password_set", cfg.Password != "",
			"from_set", cfg.From != "",
		)
	} else {
		slog.Info("mail: SMTP configured", "host", cfg.Host, "port", cfg.Port, "from", cfg.From)
	}

	// Default to a fallback-only EmailTemplateService (nil DB). Tests that
	// don't wire a DB still get rendering against the embedded files.
	return &MailService{
		cfg:         cfg,
		enabled:     enabled,
		templateSvc: NewEmailTemplateService(nil),
	}, nil
}

// SetTemplateService swaps the in-use EmailTemplateService. main.go calls
// this once after the DB pool is up so DB-backed overrides start applying.
// Safe to call before any Send/Render — there's no concurrent access yet.
func (s *MailService) SetTemplateService(t *EmailTemplateService) {
	if t == nil {
		t = NewEmailTemplateService(nil)
	}
	s.templateSvc = t
}

// Enabled reports whether SMTP is configured. Handlers can surface a clearer
// error when invite/reminder features require a live SMTP connection.
func (s *MailService) Enabled() bool { return s.enabled }

// Send delivers one multipart/alternative email (HTML + text). A zero or
// nil-value service (Enabled() == false) no-ops and returns nil.
//
// textBody may be empty; when omitted we auto-derive a plain-text fallback
// from the HTML so every message still has both parts (some clients flag
// HTML-only mail as spam).
func (s *MailService) Send(to, subject, htmlBody, textBody string) error {
	if !s.enabled {
		slog.Debug("mail: Send skipped (disabled)", "to", to, "subject", subject)
		return nil
	}
	if to == "" {
		return errors.New("mail: empty recipient")
	}
	if subject == "" {
		return errors.New("mail: empty subject")
	}
	if textBody == "" {
		textBody = htmlToText(htmlBody)
	}

	msg := buildMIME(s.cfg.From, s.cfg.FromName, to, subject, htmlBody, textBody)
	return s.deliver(to, msg)
}

// TemplateData is the shape passed to SendTemplate. Lang defaults to "de"
// when empty. Subject is no longer set by the caller — it's looked up and
// rendered from the (key, lang) row.
type TemplateData struct {
	To   string
	Lang string
	Name string
	Data map[string]any
}

// SendTemplate renders subject + body via EmailTemplateService and sends.
// Render errors surface even when SMTP is disabled — that catches typos and
// missing fields in dev/test where SMTP isn't configured.
func (s *MailService) SendTemplate(in TemplateData) error {
	subject, html, err := s.RenderTemplate(in)
	if err != nil {
		return err
	}
	if !s.enabled {
		slog.Debug("mail: SendTemplate skipped (disabled)", "to", in.To, "template", in.Name)
		return nil
	}
	return s.Send(in.To, subject, html, htmlToText(html))
}

// RenderTemplate produces the rendered subject and HTML body. Falls back to
// the embedded default if a DB row is malformed at parse time — admins can
// never wedge the send path with a bad save (per design-email-templates §3).
// Exposed for tests and the admin preview endpoint.
func (s *MailService) RenderTemplate(in TemplateData) (subject string, html string, err error) {
	lang := in.Lang
	if lang == "" {
		lang = "de"
	}

	ctx := context.Background()

	// Build payload once — both subject and body use the same data.
	payload := map[string]any{
		"Lang":    lang,
		"Firm":    branding.Name,
	}
	maps.Copy(payload, in.Data)

	// Body comes from (key, lang); on parse error fall back to the embedded
	// default so a corrupt DB row never breaks delivery.
	body, _, err := s.lookupBody(ctx, in.Name, lang)
	if err != nil {
		return "", "", fmt.Errorf("lookup body %s: %w", in.Name, err)
	}

	// Base wrapper: same lookup, key='base'.
	baseBody, _, err := s.lookupBody(ctx, EmailTemplateKeyBase, lang)
	if err != nil {
		return "", "", fmt.Errorf("lookup base: %w", err)
	}

	// Compose: parse base, then layer the content body on top. If either
	// parse fails on the active row, retry with the embedded default.
	html, err = renderBaseAndContent(baseBody, body, payload)
	if err != nil {
		// Active row was bad. Pull the embedded fallback for both and retry —
		// log loudly so the admin can fix it, but keep email working.
		slog.Error("mail: active template parse failed, falling back to embedded default",
			"key", in.Name, "lang", lang, "err", err)
		fbContent, fbErr := readEmbeddedBody(in.Name, lang)
		if fbErr != nil {
			return "", "", fmt.Errorf("fallback body %s: %w", in.Name, fbErr)
		}
		fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
		if fbErr != nil {
			return "", "", fmt.Errorf("fallback base: %w", fbErr)
		}
		html, err = renderBaseAndContent(fbBase, fbContent, payload)
		if err != nil {
			return "", "", fmt.Errorf("render embedded fallback %s: %w", in.Name, err)
		}
	}

	// Subject: same fallback discipline. Empty subject template (key='base'
	// case, when SendTemplate is somehow asked for the wrapper directly) is
	// allowed and returns "".
	subjectTpl, _, err := s.lookupSubject(ctx, in.Name, lang)
	if err != nil {
		return "", "", fmt.Errorf("lookup subject %s: %w", in.Name, err)
	}
	subject, err = renderSubject(subjectTpl, payload)
	if err != nil {
		slog.Error("mail: active subject parse failed, falling back to embedded default",
			"key", in.Name, "lang", lang, "err", err)
		fbSubj := defaultSubjects[in.Name][lang]
		fbSubject, fbErr := renderSubject(fbSubj, payload)
		if fbErr != nil {
			return "", "", fmt.Errorf("render embedded fallback subject %s: %w", in.Name, fbErr)
		}
		subject = fbSubject
	}
	return subject, html, nil
}

// lookupBody returns the body string + IsDefault marker. When the template
// service has no DB or the row is missing, the embedded body wins.
func (s *MailService) lookupBody(ctx context.Context, key, lang string) (string, bool, error) {
	row, err := s.templateSvc.GetActive(ctx, key, lang)
	if err != nil {
		return "", false, err
	}
	return row.Body, row.IsDefault, nil
}

// lookupSubject mirrors lookupBody for subjects.
func (s *MailService) lookupSubject(ctx context.Context, key, lang string) (string, bool, error) {
	row, err := s.templateSvc.GetActive(ctx, key, lang)
	if err != nil {
		return "", false, err
	}
	return row.Subject, row.IsDefault, nil
}

// RenderPreview renders user-supplied subject+body against the active base
// wrapper (or embedded fallback) for (lang). Used by the admin preview
// endpoint — never persists. Sample data is supplied by the caller and is
// merged on top of {Lang, Firm} so previews see the same baseline payload
// the production render would.
//
// For key=='base' the user-supplied body IS the wrapper; we layer a small
// built-in content sample on top so the preview shows what an inner email
// looks like inside the proposed wrapper.
func (s *MailService) RenderPreview(key, lang, subjectSrc, bodySrc string, data map[string]any) (string, string, error) {
	if lang == "" {
		lang = "de"
	}
	payload := map[string]any{
		"Lang": lang,
		"Firm": branding.Name,
	}
	maps.Copy(payload, data)

	var (
		baseBody, contentBody string
		err                   error
	)
	if key == EmailTemplateKeyBase {
		baseBody = bodySrc
		contentBody = previewBaseInnerContent(lang)
	} else {
		baseBody, _, err = s.lookupBody(context.Background(), EmailTemplateKeyBase, lang)
		if err != nil {
			return "", "", fmt.Errorf("lookup base: %w", err)
		}
		contentBody = bodySrc
	}

	html, err := renderBaseAndContent(baseBody, contentBody, payload)
	if err != nil {
		return "", "", err
	}
	subject, err := renderSubject(subjectSrc, payload)
	if err != nil {
		return "", "", err
	}
	return subject, html, nil
}

// previewBaseInnerContent is the placeholder body wrapped during a base
// preview. Kept tiny so the preview pane shows the wrapper, not a fake email.
func previewBaseInnerContent(lang string) string {
	if lang == "en" {
		return `{{define "content"}}
<p>Inner content of the specific email is rendered here.</p>
<p>Example link: <a href="https://paliad.de">paliad.de</a>.</p>
{{end}}`
	}
	return `{{define "content"}}
<p>Inhalt der spezifischen Mail wird hier gerendert.</p>
<p>Beispiel-Link: <a href="https://paliad.de">paliad.de</a>.</p>
{{end}}`
}

func renderBaseAndContent(baseBody, contentBody string, payload map[string]any) (string, error) {
	tpl, err := htmltemplate.New("base.html").Parse(baseBody)
	if err != nil {
		return "", fmt.Errorf("parse base: %w", err)
	}
	if _, err := tpl.Parse(contentBody); err != nil {
		return "", fmt.Errorf("parse content: %w", err)
	}
	var out bytes.Buffer
	if err := tpl.ExecuteTemplate(&out, "base.html", payload); err != nil {
		return "", fmt.Errorf("execute: %w", err)
	}
	return out.String(), nil
}

func renderSubject(src string, payload map[string]any) (string, error) {
	if strings.TrimSpace(src) == "" {
		return "", nil
	}
	tpl, err := texttemplate.New("subject").Parse(src)
	if err != nil {
		return "", fmt.Errorf("parse subject: %w", err)
	}
	var out bytes.Buffer
	if err := tpl.Execute(&out, payload); err != nil {
		return "", fmt.Errorf("execute subject: %w", err)
	}
	return out.String(), nil
}

// --- SMTP transport ---------------------------------------------------------

func (s *MailService) deliver(to string, msg []byte) error {
	addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
	tlsCfg := &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}

	var (
		client *smtp.Client
		err    error
	)
	if s.cfg.UseTLS {
		// Implicit TLS (port 465). Establish the TLS connection first, then
		// hand it to smtp.NewClient. STARTTLS upgrades on port 587 would go
		// through smtp.Dial + client.StartTLS — different code path, not
		// needed for Hostinger's submission endpoint.
		conn, dialErr := tls.Dial("tcp", addr, tlsCfg)
		if dialErr != nil {
			return fmt.Errorf("smtp tls dial: %w", dialErr)
		}
		client, err = smtp.NewClient(conn, s.cfg.Host)
	} else {
		client, err = smtp.Dial(addr)
	}
	if err != nil {
		return fmt.Errorf("smtp connect: %w", err)
	}
	defer client.Close()

	if err := client.Hello(hostnameForHelo()); err != nil {
		return fmt.Errorf("smtp helo: %w", err)
	}
	auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
	if err := client.Auth(auth); err != nil {
		return fmt.Errorf("smtp auth: %w", err)
	}
	if err := client.Mail(s.cfg.From); err != nil {
		return fmt.Errorf("smtp mail from: %w", err)
	}
	if err := client.Rcpt(to); err != nil {
		return fmt.Errorf("smtp rcpt to: %w", err)
	}
	w, err := client.Data()
	if err != nil {
		return fmt.Errorf("smtp data: %w", err)
	}
	if _, err := w.Write(msg); err != nil {
		w.Close()
		return fmt.Errorf("smtp write: %w", err)
	}
	if err := w.Close(); err != nil {
		return fmt.Errorf("smtp close data: %w", err)
	}
	return client.Quit()
}

func hostnameForHelo() string {
	if h, err := os.Hostname(); err == nil && h != "" {
		return h
	}
	return "localhost"
}

// --- MIME construction ------------------------------------------------------

// buildMIME assembles a multipart/alternative message with a fixed boundary.
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
// (umlauts) render correctly in every client.
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
	return buildMIMEWithReplyTo(from, fromName, "", to, subject, htmlBody, textBody)
}

// buildMIMEWithReplyTo is buildMIME plus an optional Reply-To header.
// Bulk-broadcast email uses this so replies route to the human sender even
// though From: stays on the SMTP infrastructure address.
func buildMIMEWithReplyTo(from, fromName, replyTo, to, subject, htmlBody, textBody string) []byte {
	boundary := "paliad-mixed-" + randBoundary()
	fromHeader := from
	if fromName != "" {
		fromHeader = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", fromName), from)
	}
	var b bytes.Buffer
	fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
	if replyTo != "" {
		fmt.Fprintf(&b, "Reply-To: %s\r\n", replyTo)
	}
	fmt.Fprintf(&b, "To: %s\r\n", to)
	fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
	fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
	fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
	fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)

	// Plain text part
	fmt.Fprintf(&b, "--%s\r\n", boundary)
	fmt.Fprintf(&b, "Content-Type: text/plain; charset=\"utf-8\"\r\n")
	fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
	b.WriteString(textBody)
	b.WriteString("\r\n")

	// HTML part
	fmt.Fprintf(&b, "--%s\r\n", boundary)
	fmt.Fprintf(&b, "Content-Type: text/html; charset=\"utf-8\"\r\n")
	fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
	b.WriteString(htmlBody)
	b.WriteString("\r\n")

	fmt.Fprintf(&b, "--%s--\r\n", boundary)
	return b.Bytes()
}

// randBoundary produces a short unique boundary marker. Crypto-strength
// isn't required — we only need to avoid collision with the body content.
func randBoundary() string {
	return fmt.Sprintf("%d", time.Now().UnixNano())
}

// --- HTML → plain text ------------------------------------------------------

var (
	stripTagRE  = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(script|style)>`)
	stripHTMLRE = regexp.MustCompile(`<[^>]+>`)
	wsRE        = regexp.MustCompile(`[ \t]+`)
	nlRE        = regexp.MustCompile(`\n{3,}`)
)

// htmlToText produces a readable plain-text version of an HTML body. Good
// enough for the fallback part of a multipart/alternative message — users
// whose clients render HTML will see the styled version; this is only read
// by text-only clients and spam filters.
func htmlToText(html string) string {
	s := stripTagRE.ReplaceAllString(html, "")
	// Convert common block breaks to newlines before stripping.
	s = strings.NewReplacer(
		"<br>", "\n", "<br/>", "\n", "<br />", "\n",
		"</p>", "\n\n", "</div>", "\n", "</tr>", "\n",
		"</li>", "\n", "</h1>", "\n\n", "</h2>", "\n\n", "</h3>", "\n\n",
	).Replace(s)
	s = stripHTMLRE.ReplaceAllString(s, "")
	s = strings.NewReplacer(
		"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", "\"",
		"&mdash;", "—", "&ndash;", "–", "&auml;", "ä", "&ouml;", "ö",
		"&uuml;", "ü", "&Auml;", "Ä", "&Ouml;", "Ö", "&Uuml;", "Ü", "&szlig;", "ß",
		"&hellip;", "…",
	).Replace(s)
	s = wsRE.ReplaceAllString(s, " ")
	s = nlRE.ReplaceAllString(s, "\n\n")
	return strings.TrimSpace(s)
}
