// Package services — EmailTemplateService — manages the active and
// versioned email-template rows surfaced through /admin/email-templates.
//
// The service is intentionally tolerant of a nil DB: knowledge-platform-only
// deployments (no DATABASE_URL) still send invitations and reminders if
// SMTP is configured, falling back to the embedded per-language template
// files. Mutating methods (Save / Reset / RestoreVersion) require a DB and
// return ErrTemplateStoreUnavailable when called against a nil store.
//
// Active row precedence: a row in paliad.email_templates wins over the
// embedded default. Removing the row (Reset) restores the default. Saves
// also append to paliad.email_template_versions; the most recent
// EmailTemplateVersionRetention versions per (key, lang) are kept.
//
// See docs/design-email-templates-2026-04-29.md for the full design and
// the rationale for keeping subjects editable but seeded with explicit
// SLO-framing comments.
package services

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	htmltemplate "html/template"
	"slices"
	"strings"
	texttemplate "text/template"
	"time"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"

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

// Canonical template keys. The editor and seed loop iterate this list; new
// templates land by adding a key here and shipping a per-language body file
// under internal/templates/email/.
const (
	EmailTemplateKeyInvitation     = "invitation"
	EmailTemplateKeyDeadlineDigest = "deadline_digest"
	EmailTemplateKeyBase           = "base"
)

// CanonicalEmailTemplateKeys is the closed set in canonical display order.
var CanonicalEmailTemplateKeys = []string{
	EmailTemplateKeyInvitation,
	EmailTemplateKeyDeadlineDigest,
	EmailTemplateKeyBase,
}

// EmailTemplateVersionRetention caps the per-(key, lang) version history.
// Storage is negligible (3 keys × 2 langs × 20 = 120 rows steady-state) so
// 20 leaves enough headroom that the version a user wants to restore is
// almost always still around.
const EmailTemplateVersionRetention = 20

// EmailTemplateLanguages is the closed set of editor-supported languages.
var EmailTemplateLanguages = []string{"de", "en"}

// EmailTemplateRow is the active subject+body for one (key, lang). When the
// row came from the embedded fallback (no DB override) IsDefault is true and
// UpdatedAt / UpdatedBy are nil.
type EmailTemplateRow struct {
	Key       string     `db:"key"        json:"key"`
	Lang      string     `db:"lang"       json:"lang"`
	Subject   string     `db:"subject"    json:"subject"`
	Body      string     `db:"body"       json:"body"`
	UpdatedAt *time.Time `db:"updated_at" json:"updated_at,omitempty"`
	UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
	IsDefault bool       `db:"-"          json:"is_default"`
}

// EmailTemplateVersionRow is one entry in the per-(key, lang) save log.
type EmailTemplateVersionRow struct {
	ID      uuid.UUID  `db:"id"       json:"id"`
	Key     string     `db:"key"      json:"key"`
	Lang    string     `db:"lang"     json:"lang"`
	Subject string     `db:"subject"  json:"subject"`
	Body    string     `db:"body"     json:"body"`
	SavedAt time.Time  `db:"saved_at" json:"saved_at"`
	SavedBy *uuid.UUID `db:"saved_by" json:"saved_by,omitempty"`
	Note    string     `db:"note"     json:"note"`
}

// Sentinel errors so handlers can map cleanly to status codes.
var (
	ErrTemplateUnknownKey       = errors.New("unknown email template key")
	ErrTemplateUnknownLang      = errors.New("unknown email template language")
	ErrTemplateBodySyntax       = errors.New("template body has invalid syntax")
	ErrTemplateSubjectSyntax    = errors.New("template subject has invalid syntax")
	ErrTemplateMissingContent   = errors.New(`template body must contain {{define "content"}}…{{end}}`)
	ErrTemplateMissingBaseBlock = errors.New(`base template must keep {{block "content" .}}{{end}}`)
	ErrTemplateStoreUnavailable = errors.New("email template store unavailable (DATABASE_URL not set)")
	ErrTemplateVersionNotFound  = errors.New("email template version not found")
)

// EmailTemplateService is the read/write authority for active + versioned
// rows. db may be nil — see package docs.
type EmailTemplateService struct {
	db *sqlx.DB
}

// NewEmailTemplateService accepts a possibly-nil DB. Callers can hand it the
// shared sqlx.DB pool or pass nil for fallback-only mode.
func NewEmailTemplateService(db *sqlx.DB) *EmailTemplateService {
	return &EmailTemplateService{db: db}
}

// HasStore reports whether mutating operations will succeed. False during
// knowledge-platform-only deployments.
func (s *EmailTemplateService) HasStore() bool { return s != nil && s.db != nil }

// IsCanonicalKey reports whether key is in the editor's closed set.
func IsCanonicalKey(key string) bool {
	return slices.Contains(CanonicalEmailTemplateKeys, key)
}

func canonicaliseLang(lang string) (string, error) {
	switch lang {
	case "":
		return "de", nil
	case "de", "en":
		return lang, nil
	default:
		return "", ErrTemplateUnknownLang
	}
}

// GetActive returns the active subject+body for (key, lang). DB row wins
// when present; the embedded default applies otherwise. Errors only when
// the key/lang are unknown or the embedded default is missing.
func (s *EmailTemplateService) GetActive(ctx context.Context, key, lang string) (EmailTemplateRow, error) {
	if !IsCanonicalKey(key) {
		return EmailTemplateRow{}, ErrTemplateUnknownKey
	}
	lang, err := canonicaliseLang(lang)
	if err != nil {
		return EmailTemplateRow{}, err
	}
	if s.HasStore() {
		var row EmailTemplateRow
		err := s.db.GetContext(ctx, &row, `
			SELECT key, lang, subject, body, updated_at, updated_by
			  FROM paliad.email_templates
			 WHERE key = $1 AND lang = $2`, key, lang)
		if err == nil {
			return row, nil
		}
		if !errors.Is(err, sql.ErrNoRows) {
			return EmailTemplateRow{}, fmt.Errorf("read email_templates: %w", err)
		}
	}
	return embeddedDefault(key, lang)
}

// embeddedDefault returns the on-disk default for (key, lang). Subject
// defaults are Go consts (defaultSubjects) for two reasons: subjects are
// short, and the digest subject's conditional logic benefits from being
// colocated with the service that documents its intent.
func embeddedDefault(key, lang string) (EmailTemplateRow, error) {
	body, err := readEmbeddedBody(key, lang)
	if err != nil {
		return EmailTemplateRow{}, err
	}
	return EmailTemplateRow{
		Key:       key,
		Lang:      lang,
		Subject:   defaultSubjects[key][lang],
		Body:      body,
		IsDefault: true,
	}, nil
}

func readEmbeddedBody(key, lang string) (string, error) {
	path := fmt.Sprintf("email/%s.%s.html", key, lang)
	data, err := templates.EmailFS.ReadFile(path)
	if err != nil {
		return "", fmt.Errorf("embedded body %s: %w", path, err)
	}
	return string(data), nil
}

// SaveInput is the payload for Save. Validation happens before any DB
// interaction so a typo is reported as 422 without a tx round-trip.
type SaveInput struct {
	Key     string
	Lang    string
	Subject string
	Body    string
	Note    string    // free-form admin annotation, optional
	SavedBy uuid.UUID // uuid.Nil when actor is unknown — column is nullable
}

// Save validates and upserts the active row, appends a version, and GCs the
// version log to EmailTemplateVersionRetention. All in one transaction.
func (s *EmailTemplateService) Save(ctx context.Context, in SaveInput) (EmailTemplateVersionRow, error) {
	if !s.HasStore() {
		return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
	}
	if !IsCanonicalKey(in.Key) {
		return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
	}
	lang, err := canonicaliseLang(in.Lang)
	if err != nil {
		return EmailTemplateVersionRow{}, err
	}
	in.Lang = lang

	if err := ValidateTemplate(in.Key, in.Subject, in.Body); err != nil {
		return EmailTemplateVersionRow{}, err
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx, `
		INSERT INTO paliad.email_templates (key, lang, subject, body, updated_at, updated_by)
		VALUES ($1, $2, $3, $4, now(), $5)
		ON CONFLICT (key, lang) DO UPDATE
		  SET subject = EXCLUDED.subject,
		      body = EXCLUDED.body,
		      updated_at = now(),
		      updated_by = EXCLUDED.updated_by`,
		in.Key, in.Lang, in.Subject, in.Body, nullableUUID(in.SavedBy)); err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("upsert active: %w", err)
	}

	ver, err := insertVersion(ctx, tx, in.Key, in.Lang, in.Subject, in.Body, in.SavedBy, in.Note)
	if err != nil {
		return EmailTemplateVersionRow{}, err
	}
	if err := gcVersions(ctx, tx, in.Key, in.Lang); err != nil {
		return EmailTemplateVersionRow{}, err
	}
	if err := tx.Commit(); err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
	}
	return ver, nil
}

// Reset deletes the active row and appends a 'reset' version capturing the
// embedded default. Subsequent renders fall back to the embedded body.
func (s *EmailTemplateService) Reset(ctx context.Context, key, lang string, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
	if !s.HasStore() {
		return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
	}
	if !IsCanonicalKey(key) {
		return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
	}
	lang, err := canonicaliseLang(lang)
	if err != nil {
		return EmailTemplateVersionRow{}, err
	}
	def, err := embeddedDefault(key, lang)
	if err != nil {
		return EmailTemplateVersionRow{}, err
	}
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.email_templates WHERE key = $1 AND lang = $2`,
		key, lang); err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("delete active: %w", err)
	}
	ver, err := insertVersion(ctx, tx, key, lang, def.Subject, def.Body, savedBy, "reset")
	if err != nil {
		return EmailTemplateVersionRow{}, err
	}
	if err := gcVersions(ctx, tx, key, lang); err != nil {
		return EmailTemplateVersionRow{}, err
	}
	if err := tx.Commit(); err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
	}
	return ver, nil
}

// ListVersions returns the most recent EmailTemplateVersionRetention rows.
func (s *EmailTemplateService) ListVersions(ctx context.Context, key, lang string) ([]EmailTemplateVersionRow, error) {
	if !s.HasStore() {
		return nil, ErrTemplateStoreUnavailable
	}
	if !IsCanonicalKey(key) {
		return nil, ErrTemplateUnknownKey
	}
	lang, err := canonicaliseLang(lang)
	if err != nil {
		return nil, err
	}
	rows := []EmailTemplateVersionRow{}
	if err := s.db.SelectContext(ctx, &rows, `
		SELECT id, key, lang, subject, body, saved_at, saved_by, note
		  FROM paliad.email_template_versions
		 WHERE key = $1 AND lang = $2
		 ORDER BY saved_at DESC
		 LIMIT $3`, key, lang, EmailTemplateVersionRetention); err != nil {
		return nil, fmt.Errorf("list versions: %w", err)
	}
	return rows, nil
}

// RestoreVersion copies a historical version back into the active row,
// appending a fresh version that records the restore source.
func (s *EmailTemplateService) RestoreVersion(ctx context.Context, key, lang string, versionID, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
	if !s.HasStore() {
		return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
	}
	if !IsCanonicalKey(key) {
		return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
	}
	lang, err := canonicaliseLang(lang)
	if err != nil {
		return EmailTemplateVersionRow{}, err
	}
	var src EmailTemplateVersionRow
	err = s.db.GetContext(ctx, &src, `
		SELECT id, key, lang, subject, body, saved_at, saved_by, note
		  FROM paliad.email_template_versions
		 WHERE id = $1 AND key = $2 AND lang = $3`, versionID, key, lang)
	if errors.Is(err, sql.ErrNoRows) {
		return EmailTemplateVersionRow{}, ErrTemplateVersionNotFound
	}
	if err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("fetch version: %w", err)
	}
	return s.Save(ctx, SaveInput{
		Key:     key,
		Lang:    lang,
		Subject: src.Subject,
		Body:    src.Body,
		Note:    fmt.Sprintf("restore from %s", versionID),
		SavedBy: savedBy,
	})
}

// ValidateTemplate checks subject + body against the templating engines.
// The structural checks ensure content templates re-define {{block "content"}}
// (otherwise the body silently vanishes inside the base wrapper) and that
// the base body still contains the {{block "content" .}} call (otherwise
// every email loses its body). Returns nil iff both are syntactically and
// structurally valid.
func ValidateTemplate(key, subject, body string) error {
	if subject != "" {
		if _, err := texttemplate.New("subject").Parse(subject); err != nil {
			return fmt.Errorf("%w: %v", ErrTemplateSubjectSyntax, err)
		}
	}
	if _, err := htmltemplate.New("body").Parse(body); err != nil {
		return fmt.Errorf("%w: %v", ErrTemplateBodySyntax, err)
	}
	if key == EmailTemplateKeyBase {
		if !strings.Contains(body, `block "content"`) {
			return ErrTemplateMissingBaseBlock
		}
	} else {
		if !strings.Contains(body, `define "content"`) {
			return ErrTemplateMissingContent
		}
	}
	return nil
}

// --- internal helpers -------------------------------------------------------

func insertVersion(ctx context.Context, tx *sqlx.Tx, key, lang, subject, body string, savedBy uuid.UUID, note string) (EmailTemplateVersionRow, error) {
	var ver EmailTemplateVersionRow
	err := tx.GetContext(ctx, &ver, `
		INSERT INTO paliad.email_template_versions (key, lang, subject, body, saved_by, note)
		VALUES ($1, $2, $3, $4, $5, $6)
		RETURNING id, key, lang, subject, body, saved_at, saved_by, note`,
		key, lang, subject, body, nullableUUID(savedBy), note)
	if err != nil {
		return EmailTemplateVersionRow{}, fmt.Errorf("insert version: %w", err)
	}
	return ver, nil
}

func gcVersions(ctx context.Context, tx *sqlx.Tx, key, lang string) error {
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM paliad.email_template_versions
		 WHERE key = $1 AND lang = $2
		   AND id NOT IN (
		     SELECT id FROM paliad.email_template_versions
		      WHERE key = $1 AND lang = $2
		      ORDER BY saved_at DESC
		      LIMIT $3
		   )`, key, lang, EmailTemplateVersionRetention); err != nil {
		return fmt.Errorf("gc versions: %w", err)
	}
	return nil
}

func nullableUUID(u uuid.UUID) any {
	if u == uuid.Nil {
		return nil
	}
	return u
}

// defaultSubjects is the embedded-fallback subject template per (key, lang).
// They're text/template (not html/template); subjects are plain strings,
// no HTML escaping required.
//
// Keep the SYSTEMAUSFALL / SYSTEM FAILURE phrasing — see
// docs/design-reminder-redesign-2026-04-28.md. Softening it weakens the
// zero-overdue SLO and the comment in the seed exists so the next admin
// who edits sees the rationale instead of pattern-matching the framing as
// noise.
var defaultSubjects = map[string]map[string]string{
	EmailTemplateKeyInvitation: {
		"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
		"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
	},
	EmailTemplateKeyDeadlineDigest: {
		"de": digestSubjectDE,
		"en": digestSubjectEN,
	},
	EmailTemplateKeyBase: {"de": "", "en": ""},
}

const digestSubjectDE = `{{- /* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
{{- if and .IsEvening (gt .OverdueCount 0) -}}
[Paliad] SYSTEMAUSFALL: {{.OverdueCount}} überfällig — plus {{.DueTodayCount}} heute offen
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
[Paliad] DRINGEND — {{.DueTodayCount}} heute noch offen
{{- else if .IsEvening -}}
[Paliad] {{.OverdueCount}} überfällig
{{- else if gt .OverdueCount 0 -}}
[Paliad] ÜBERFÄLLIG: {{.OverdueCount}} — plus {{.OpenTotal}} weitere
{{- else if eq .OpenTotal 1 -}}
[Paliad] Frist-Erinnerung: 1 offen
{{- else -}}
[Paliad] Frist-Erinnerung: {{.OpenTotal}} offen
{{- end -}}`

const digestSubjectEN = `{{- /* keep the SYSTEM FAILURE phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
{{- if and .IsEvening (gt .OverdueCount 0) -}}
[Paliad] SYSTEM FAILURE: {{.OverdueCount}} overdue — plus {{.DueTodayCount}} still open today
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
[Paliad] URGENT — {{.DueTodayCount}} still open today
{{- else if .IsEvening -}}
[Paliad] {{.OverdueCount}} overdue
{{- else if gt .OverdueCount 0 -}}
[Paliad] OVERDUE: {{.OverdueCount}} — plus {{.OpenTotal}} more
{{- else if eq .OpenTotal 1 -}}
[Paliad] Deadline reminder: 1 open
{{- else -}}
[Paliad] Deadline reminder: {{.OpenTotal}} open
{{- end -}}`
