// Package email provides HTML email template rendering for transactional auth emails.
// Templates are embedded at build time and compiled at startup. CSS inlining runs at
// render time so class-based styles are compatible with email clients.
package email

import (
	"bytes"
	"embed"
	"fmt"
	"html/template"
	"io/fs"
	"sort"
	"strings"
	texttemplate "text/template"

	"github.com/aymerick/douceur/inliner"
	"gopkg.in/yaml.v3"
)

// BrandConfig holds per-project branding values injected into every transactional email.
// Set once at startup; never set by individual send calls.
type BrandConfig struct {
	AppName      string // service name shown in header and copy
	AppURL       string // base URL linked in the header logo/name
	SupportEmail string // displayed in footer for user questions
	LogoURL      string // optional logo image URL; hides logo area when empty
	PrimaryColor string // hex color e.g. "#6366f1"; default "#6366f1"
	TextColor    string // hex color e.g. "#111827"; default "#111827"
}

func (b BrandConfig) withDefaults() BrandConfig {
	if b.PrimaryColor == "" {
		b.PrimaryColor = "#6366f1"
	}
	if b.TextColor == "" {
		b.TextColor = "#111827"
	}
	return b
}

// TemplateMeta holds metadata from a template's meta.yaml file.
// Subject and Preheader are Go text/template strings rendered with EmailContext as data.
type TemplateMeta struct {
	Purpose   string `yaml:"purpose"`
	Category  string `yaml:"category"`
	Subject   string `yaml:"subject"`   // e.g. "Your {{.AppName}} sign in code"
	Preheader string `yaml:"preheader"` // e.g. "{{.Code}} — expires in {{.ExpiresIn}} minutes."
}

// RenderedEmail holds the fully rendered output ready for delivery.
type RenderedEmail struct {
	Subject      string
	HTML         string // CSS-inlined HTML; un-inlined fallback if CSSInlineErr is set
	PlainText    string // auto-derived from HTML
	Preheader    string
	CSSInlineErr error // non-nil if CSS inlining failed; email still sends but styles may render inconsistently
}

// EmailContext is the unified template data context for all email types.
// Fields that a given template does not use are simply zero-valued and ignored.
type EmailContext struct {
	// Brand fields — always overwritten by the renderer from BrandConfig.
	// Callers must not set these; they are populated by Render().
	AppName      string
	AppURL       template.URL
	SupportEmail string
	LogoURL      template.URL
	PrimaryColor template.CSS // typed for safe use in html/template CSS context
	TextColor    template.CSS // typed for safe use in html/template CSS context

	// Rendered from meta.yaml before template execution — set by the renderer.
	Preheader string

	// OTP fields (login_otp, verify_email).
	Code      string // digits-only OTP code
	ExpiresIn int    // minutes until expiry
	Purpose   string // "sign in" | "verify your email"

	// Link fields (magic_link, password_reset).
	ActionURL  template.URL // server-generated action URL
	ButtonText string       // e.g. "Sign In →" | "Reset Password →"

	// Welcome fields.
	Name string // user display name; may be empty
}

type compiledTemplate struct {
	meta TemplateMeta
	html *template.Template
}

// Renderer loads and renders transactional email templates from an embedded FS.
// Templates are compiled at construction time. CSS inlining runs at render time.
type Renderer struct {
	templates map[string]*compiledTemplate
	brand     BrandConfig
}

// NewRendererFromFS creates a Renderer from a caller-provided embed.FS.
// prefix is the directory inside the FS where templates live (e.g. "templates").
// The FS must contain _layout.html, _header.html, _footer.html, _button.html,
// _code_box.html at the top level of prefix, and one subdirectory per email type
// each containing meta.yaml and body.html.
func NewRendererFromFS(emailFS embed.FS, prefix string, brand BrandConfig) (*Renderer, error) {
	brand = brand.withDefaults()

	// Read shared layout partials.
	partials := []string{"_layout.html", "_header.html", "_footer.html", "_button.html", "_code_box.html"}
	var sharedBuilder strings.Builder
	for _, name := range partials {
		data, err := fs.ReadFile(emailFS, prefix+"/"+name)
		if err != nil {
			return nil, fmt.Errorf("read email partial %s: %w", name, err)
		}
		sharedBuilder.Write(data)
		sharedBuilder.WriteByte('\n')
	}
	sharedContent := sharedBuilder.String()

	r := &Renderer{
		templates: make(map[string]*compiledTemplate),
		brand:     brand,
	}

	// Walk the prefix directory for per-purpose subdirectories.
	entries, err := fs.ReadDir(emailFS, prefix)
	if err != nil {
		return nil, fmt.Errorf("read email template directory: %w", err)
	}

	for _, entry := range entries {
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), "_") {
			continue
		}
		purpose := entry.Name()

		metaBytes, err := fs.ReadFile(emailFS, prefix+"/"+purpose+"/meta.yaml")
		if err != nil {
			return nil, fmt.Errorf("read meta.yaml for %s: %w", purpose, err)
		}
		var meta TemplateMeta
		if err := yaml.Unmarshal(metaBytes, &meta); err != nil {
			return nil, fmt.Errorf("parse meta.yaml for %s: %w", purpose, err)
		}

		bodyBytes, err := fs.ReadFile(emailFS, prefix+"/"+purpose+"/body.html")
		if err != nil {
			return nil, fmt.Errorf("read body.html for %s: %w", purpose, err)
		}

		// Compose: shared partials define "layout", "header", "footer", "button",
		// "code_box" — body.html defines "body". All parsed into one template set.
		fullContent := sharedContent + string(bodyBytes)
		tpl, err := template.New("email").Parse(fullContent)
		if err != nil {
			return nil, fmt.Errorf("parse template for %s: %w", purpose, err)
		}

		r.templates[purpose] = &compiledTemplate{meta: meta, html: tpl}
	}

	return r, nil
}

// Render executes the named email template with the provided context.
// Brand fields in ctx are always overwritten from the renderer's BrandConfig.
func (r *Renderer) Render(purpose string, ctx EmailContext) (*RenderedEmail, error) {
	tmpl, ok := r.templates[purpose]
	if !ok {
		return nil, fmt.Errorf("unknown email purpose %q", purpose)
	}

	// Inject brand (callers must not set these).
	ctx.AppName = r.brand.AppName
	ctx.AppURL = template.URL(r.brand.AppURL)
	ctx.SupportEmail = r.brand.SupportEmail
	ctx.LogoURL = template.URL(r.brand.LogoURL)
	ctx.PrimaryColor = template.CSS(r.brand.PrimaryColor)
	ctx.TextColor = template.CSS(r.brand.TextColor)

	// Render preheader from its text/template string before HTML execution.
	ctx.Preheader = renderTextTemplate(tmpl.meta.Preheader, ctx)

	// Execute the "layout" HTML template — it calls "header", "body", "footer".
	var htmlBuf bytes.Buffer
	if err := tmpl.html.ExecuteTemplate(&htmlBuf, "layout", ctx); err != nil {
		return nil, fmt.Errorf("execute email template %s: %w", purpose, err)
	}

	// Inline CSS: douceur converts <style> class rules to inline style="" attributes
	// for email client compatibility (Gmail, Outlook, Apple Mail).
	htmlStr := htmlBuf.String()
	inlinedHTML, inlineErr := inliner.Inline(htmlStr)
	if inlineErr != nil {
		// Non-fatal: fall back to un-inlined HTML. CSSInlineErr is exposed so callers
		// can log the failure — emails still send but styles may not render in all clients.
		inlinedHTML = htmlStr
	}

	return &RenderedEmail{
		Subject:      renderTextTemplate(tmpl.meta.Subject, ctx),
		HTML:         inlinedHTML,
		PlainText:    htmlToPlainText(inlinedHTML),
		Preheader:    ctx.Preheader,
		CSSInlineErr: inlineErr,
	}, nil
}

// Purposes returns all registered template purposes, sorted alphabetically.
func (r *Renderer) Purposes() []string {
	purposes := make([]string, 0, len(r.templates))
	for p := range r.templates {
		purposes = append(purposes, p)
	}
	sort.Strings(purposes)
	return purposes
}

// SafeURL wraps a server-generated URL string as html/template.URL.
// Use this when the URL is produced by server code (not user input) to
// avoid html/template's URL sanitization in href attributes.
func SafeURL(s string) template.URL {
	return template.URL(s)
}

// renderTextTemplate renders a text/template string with ctx as data.
// Returns the raw string on any error — never panics.
func renderTextTemplate(tmplStr string, ctx EmailContext) string {
	t, err := texttemplate.New("").Parse(tmplStr)
	if err != nil {
		return tmplStr
	}
	var buf bytes.Buffer
	if err := t.Execute(&buf, ctx); err != nil {
		return tmplStr
	}
	return buf.String()
}
