package deliberi

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"html/template"
	"log/slog"
	"slices"
	"strings"
	gotemplate "text/template"
	"time"

	"tangled.org/core/deliberi/config"
	deldb "tangled.org/core/deliberi/db"
	"tangled.org/core/deliberi/emailtemplate"
	"tangled.org/core/deliberi/mailer"
	"tangled.org/core/deliberi/models"
	"tangled.org/core/idresolver"
)

const threadTextTmpl = `Hi {{.RecipientHandle}},

New activity on {{wrap 70 "" .Title}}{{if .Repo}} in {{.Repo}}{{end}}:

{{range .Events}}- {{wrap 70 "  " .Header}}
{{end}}
View it on Tangled: {{.URL}}

---
Manage notification settings: {{.SettingsURL}}
`

const threadBodyHTMLTmpl = `<p style="font-size:15px;line-height:24px;margin:0 0 8px 0;">Hi {{.RecipientHandle}},</p>
<p style="font-size:15px;line-height:24px;margin:0 0 16px 0;">New activity on <span style="font-weight:500;">{{.Title}}</span>{{if .Repo}} in {{.Repo}}{{end}}:</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-top:1px solid #e5e5e5;">
  {{range .Events}}
  <tr>
    <td style="padding:12px 0;border-bottom:1px solid #e5e5e5;">
      <table cellpadding="0" cellspacing="0" width="100%" role="presentation">
        <tr>
          <td style="width:16px;vertical-align:top;padding-top:2px;">
            <img src="{{.IconURL}}" width="16" height="16" alt="" style="display:block;border:0;">
          </td>
          <td style="padding-left:8px;">
            <p style="margin:0;font-size:13px;line-height:20px;color:#0a0a0a;"><span style="font-weight:500;">{{.Actor}}</span> {{.Phrase}}</p>
          </td>
        </tr>
      </table>
    </td>
  </tr>
  {{end}}
</table>
<p style="font-size:13px;line-height:20px;margin:16px 0 0 0;">
  <a href="{{.URL}}" style="color:#0a0a0a;text-decoration:underline;">View it on Tangled</a>
</p>
<p style="font-size:13px;line-height:20px;margin:24px 0 0 0;">
  <a href="{{.SettingsURL}}" style="color:#525252;text-decoration:underline;">Manage notification settings</a>
</p>`

type threadEvent struct {
	IconURL string
	Actor   string
	Phrase  string
	Header  string
}

type threadData struct {
	RecipientHandle string
	Title           string
	Repo            string
	Events          []threadEvent
	URL             string
	SettingsURL     string
}

type threadEmail struct {
	subject, text, html string
	headers             map[string]string
}

func notifPhrase(n *models.Notification) string {
	switch n.Type {
	case models.NotificationTypeIssueCreated:
		return "opened this issue"
	case models.NotificationTypePullCreated:
		return "opened this pull request"
	case models.NotificationTypeIssueCommented, models.NotificationTypePullCommented:
		return "commented"
	case models.NotificationTypeIssueClosed:
		return "closed this issue"
	case models.NotificationTypeIssueReopen:
		return "reopened this issue"
	case models.NotificationTypePullMerged:
		return "merged this pull request"
	case models.NotificationTypePullClosed:
		return "closed this pull request"
	case models.NotificationTypePullReopen:
		return "reopened this pull request"
	case models.NotificationTypeUserMentioned:
		return "mentioned you"
	default:
		return "updated this"
	}
}

func wordwrap(width int, indent, text string) string {
	words := strings.Fields(text)
	if len(words) == 0 {
		return text
	}
	var b strings.Builder
	col := 0
	for i, w := range words {
		if i == 0 {
			b.WriteString(w)
			col = len(w)
			continue
		}
		if col+1+len(w) > width {
			b.WriteString("\n" + indent)
			b.WriteString(w)
			col = len(indent) + len(w)
		} else {
			b.WriteByte(' ')
			b.WriteString(w)
			col += 1 + len(w)
		}
	}
	return b.String()
}

// every email about one issue or pull points at the same root id, which mail
// clients thread on. the root itself is never sent, so no id is ever reused
func threadRoot(entityAt, domain string) string {
	sum := sha256.Sum256([]byte(entityAt))
	return "<thread." + hex.EncodeToString(sum[:16]) + "@" + domain + ">"
}

type emailSender interface {
	Send(mailer.Message) error
	Domain() string
}

type Dispatcher struct {
	db        *deldb.DB
	sender    emailSender
	baseURL   string
	assetsURL string
	resolver  *idresolver.Resolver
	logger    *slog.Logger
	batchWait time.Duration
	interval  time.Duration

	textTmpl *gotemplate.Template
	htmlTmpl *template.Template
}

func NewDispatcher(database *deldb.DB, sender emailSender, resend config.ResendConfig, baseURL string, resolver *idresolver.Resolver, logger *slog.Logger, dev bool) *Dispatcher {
	batchWait := 10 * time.Minute
	interval := 5 * time.Minute
	if dev {
		batchWait = 30 * time.Second
		interval = 15 * time.Second
	}
	return &Dispatcher{
		db:        database,
		sender:    sender,
		baseURL:   strings.TrimRight(baseURL, "/"),
		assetsURL: strings.TrimRight(resend.AssetsURL, "/") + "/",
		resolver:  resolver,
		logger:    logger,
		batchWait: batchWait,
		interval:  interval,
		textTmpl:  gotemplate.Must(gotemplate.New("thread-text").Funcs(gotemplate.FuncMap{"wrap": wordwrap}).Parse(threadTextTmpl)),
		htmlTmpl:  template.Must(template.New("thread-body").Parse(threadBodyHTMLTmpl)),
	}
}

func (d *Dispatcher) Start(ctx context.Context) {
	d.logger.Info("email dispatcher started", "interval", d.interval, "batchWait", d.batchWait)
	ticker := time.NewTicker(d.interval)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			d.dispatch(ctx)
		case <-ctx.Done():
			d.logger.Info("email dispatcher stopped")
			return
		}
	}
}

func (d *Dispatcher) dispatch(ctx context.Context) {
	cutoff := time.Now().Add(-d.batchWait)
	recipients, err := deldb.GetPendingEmailDigestRecipients(d.db, cutoff)
	if err != nil {
		d.logger.Error("email dispatcher: failed to get recipients", "err", err)
		return
	}
	for _, did := range recipients {
		if err := d.sendPending(ctx, did, cutoff); err != nil {
			d.logger.Error("email dispatcher: failed to send", "did", did, "err", err)
		}
	}
}

func (d *Dispatcher) sendPending(ctx context.Context, recipientDid string, cutoff time.Time) error {
	em, err := deldb.GetPrimaryEmail(d.db, recipientDid)
	if err != nil || !em.Verified {
		return nil
	}

	notifs, err := deldb.GetPendingNotificationsForEmailDigest(d.db, recipientDid, cutoff)
	if err != nil {
		return fmt.Errorf("get pending notifications: %w", err)
	}
	if len(notifs) == 0 {
		return nil
	}

	handle := d.handle(ctx, recipientDid)

	var order []string
	byEntity := map[string][]*models.Notification{}
	for _, n := range notifs {
		if _, ok := byEntity[n.EntityAt]; !ok {
			order = append(order, n.EntityAt)
		}
		byEntity[n.EntityAt] = append(byEntity[n.EntityAt], n)
	}

	var failed error
	for _, entity := range order {
		group := byEntity[entity]
		slices.SortStableFunc(group, func(a, b *models.Notification) int { return a.Created.Compare(b.Created) })

		mail, err := d.renderThread(ctx, handle, group)
		if err != nil {
			failed = fmt.Errorf("render %s: %w", entity, err)
			continue
		}
		if err := d.sender.Send(mailer.Message{To: em.Address, Subject: mail.subject, Text: mail.text, Html: mail.html, Headers: mail.headers}); err != nil {
			failed = fmt.Errorf("send %s: %w", entity, err)
			continue
		}
		d.logger.Info("email dispatcher: sent", "did", recipientDid, "entity", entity, "notifications", len(group))

		ids := make([]int64, len(group))
		for i, n := range group {
			ids[i] = n.ID
		}
		if err := deldb.MarkEmailed(d.db, ids); err != nil {
			d.logger.Error("email dispatcher: failed to mark emailed", "did", recipientDid, "err", err)
		}
	}
	return failed
}

func (d *Dispatcher) handle(ctx context.Context, did string) string {
	if id, err := d.resolver.ResolveIdent(ctx, did); err == nil && !id.Handle.IsInvalidHandle() {
		return id.Handle.String()
	}
	return did
}

// group is one entity's notifications, oldest first
func (d *Dispatcher) renderThread(ctx context.Context, recipientHandle string, group []*models.Notification) (mail threadEmail, err error) {
	latest := group[len(group)-1]

	repo := ""
	if latest.RepoDid != "" {
		repo = latest.RepoOwnerHandle(d.resolver)
		if latest.RepoName != "" {
			repo += "/" + latest.RepoName
		}
	}
	title := latest.EntityTitle
	if title == "" {
		title = "an issue"
		if latest.EntityKind == models.EntityKindPull {
			title = "a pull request"
		}
	}

	events := make([]threadEvent, 0, len(group))
	for _, n := range group {
		actor := d.handle(ctx, n.ActorDid)
		phrase := notifPhrase(n)
		events = append(events, threadEvent{
			IconURL: d.assetsURL + n.Icon() + ".png",
			Actor:   actor,
			Phrase:  phrase,
			Header:  actor + " " + phrase,
		})
	}

	data := threadData{
		RecipientHandle: recipientHandle,
		Title:           title,
		Repo:            repo,
		Events:          events,
		URL:             d.baseURL + latest.URL(d.resolver),
		SettingsURL:     d.baseURL + "/settings/notifications",
	}

	// mail clients thread on matching subjects too, so this must not vary between emails
	mail.subject = title
	if repo != "" {
		mail.subject = "[" + repo + "] " + title
	}
	root := threadRoot(latest.EntityAt, d.sender.Domain())
	mail.headers = map[string]string{"In-Reply-To": root, "References": root}

	var textBuf bytes.Buffer
	if err = d.textTmpl.Execute(&textBuf, data); err != nil {
		return
	}
	mail.text = textBuf.String()

	var htmlBuf bytes.Buffer
	if err = d.htmlTmpl.Execute(&htmlBuf, data); err != nil {
		return
	}
	mail.html = emailtemplate.Shell(mail.subject, htmlBuf.String(), d.assetsURL)
	return
}
