// Package deb implements nfpm.Packager providing .deb bindings.
package deb

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"crypto/md5" // nolint:gas
	"crypto/sha1"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"text/template"
	"time"

	"github.com/blakesmith/ar"
	"github.com/goreleaser/chglog"
	"github.com/goreleaser/nfpm/v2"
	"github.com/goreleaser/nfpm/v2/deprecation"
	"github.com/goreleaser/nfpm/v2/files"
	"github.com/goreleaser/nfpm/v2/internal/sign"
	"github.com/klauspost/compress/zstd"
	"github.com/ulikunitz/xz"
)

const packagerName = "deb"

// nolint: gochecknoinits
func init() {
	nfpm.RegisterPackager(packagerName, Default)
}

// nolint: gochecknoglobals
var archToDebian = map[string]string{
	"386":      "i386",
	"arm64":    "arm64",
	"arm5":     "armel",
	"arm6":     "armhf",
	"arm7":     "armhf",
	"mips64le": "mips64el",
	"mipsle":   "mipsel",
	"ppc64le":  "ppc64el",
	"s390":     "s390x",
}

func ensureValidArch(info *nfpm.Info) *nfpm.Info {
	if info.Deb.Arch != "" {
		info.Arch = info.Deb.Arch
	} else if arch, ok := archToDebian[info.Arch]; ok {
		info.Arch = arch
	}

	return info
}

// Default deb packager.
// nolint: gochecknoglobals
var Default = &Deb{}

// Deb is a deb packager implementation.
type Deb struct{}

// ConventionalFileName returns a file name according
// to the conventions for debian packages. See:
// https://manpages.debian.org/buster/dpkg-dev/dpkg-name.1.en.html
func (*Deb) ConventionalFileName(info *nfpm.Info) string {
	info = ensureValidArch(info)

	version := info.Version
	if info.Prerelease != "" {
		version += "~" + info.Prerelease
	}

	if info.VersionMetadata != "" {
		version += "+" + info.VersionMetadata
	}

	if info.Release != "" {
		version += "-" + info.Release
	}

	// package_version_architecture.package-type
	return fmt.Sprintf("%s_%s_%s.deb", info.Name, version, info.Arch)
}

// ConventionalExtension returns the file name conventionally used for Deb packages
func (*Deb) ConventionalExtension() string {
	return ".deb"
}

// ErrInvalidSignatureType happens if the signature type of a deb is not one of
// origin, maint or archive.
var ErrInvalidSignatureType = errors.New("invalid signature type")

// Package writes a new deb package to the given writer using the given info.
func (d *Deb) Package(info *nfpm.Info, deb io.Writer) (err error) { // nolint: funlen
	info = ensureValidArch(info)

	err = nfpm.PrepareForPackager(withChangelogIfRequested(info), packagerName)
	if err != nil {
		return err
	}

	// Set up some deb specific defaults
	d.SetPackagerDefaults(info)

	dataTarball, md5sums, instSize, dataTarballName, err := createDataTarball(info)
	if err != nil {
		return err
	}

	controlTarGz, err := createControl(instSize, md5sums, info)
	if err != nil {
		return err
	}

	debianBinary := []byte("2.0\n")

	w := ar.NewWriter(deb)
	if err := w.WriteGlobalHeader(); err != nil {
		return fmt.Errorf("cannot write ar header to deb file: %w", err)
	}

	if err := addArFile(w, "debian-binary", debianBinary); err != nil {
		return fmt.Errorf("cannot pack debian-binary: %w", err)
	}

	if err := addArFile(w, "control.tar.gz", controlTarGz); err != nil {
		return fmt.Errorf("cannot add control.tar.gz to deb: %w", err)
	}

	if err := addArFile(w, dataTarballName, dataTarball); err != nil {
		return fmt.Errorf("cannot add data.tar.gz to deb: %w", err)
	}

	if info.Deb.Signature.KeyFile != "" || info.Deb.Signature.SignFn != nil {
		sig, sigType, err := doSign(info, debianBinary, controlTarGz, dataTarball)
		if err != nil {
			return err
		}

		if err := addArFile(w, "_gpg"+sigType, sig); err != nil {
			return &nfpm.ErrSigningFailure{
				Err: fmt.Errorf("add signature to ar file: %w", err),
			}
		}
	}

	return nil
}

func doSign(info *nfpm.Info, debianBinary, controlTarGz, dataTarball []byte) ([]byte, string, error) {
	switch info.Deb.Signature.Method {
	case "dpkg-sig":
		return dpkgSign(info, debianBinary, controlTarGz, dataTarball)
	default:
		return debSign(info, debianBinary, controlTarGz, dataTarball)
	}
}

func dpkgSign(info *nfpm.Info, debianBinary, controlTarGz, dataTarball []byte) ([]byte, string, error) {
	sigType := "builder"
	if info.Deb.Signature.Type != "" {
		sigType = info.Deb.Signature.Type
	}

	data, err := readDpkgSigData(info, debianBinary, controlTarGz, dataTarball)
	if err != nil {
		return nil, sigType, &nfpm.ErrSigningFailure{Err: err}
	}

	var sig []byte
	if signFn := info.Deb.Signature.SignFn; signFn != nil {
		sig, err = signFn(data)
	} else {
		sig, err = sign.PGPClearSignWithKeyID(data, info.Deb.Signature.KeyFile, info.Deb.Signature.KeyPassphrase, info.Deb.Signature.KeyID)
	}
	if err != nil {
		return nil, sigType, &nfpm.ErrSigningFailure{Err: err}
	}
	return sig, sigType, nil
}

func debSign(info *nfpm.Info, debianBinary, controlTarGz, dataTarball []byte) ([]byte, string, error) {
	data := readDebsignData(debianBinary, controlTarGz, dataTarball)

	sigType := "origin"
	if info.Deb.Signature.Type != "" {
		sigType = info.Deb.Signature.Type
	}

	if sigType != "origin" && sigType != "maint" && sigType != "archive" {
		return nil, sigType, &nfpm.ErrSigningFailure{
			Err: ErrInvalidSignatureType,
		}
	}

	var sig []byte
	var err error
	if signFn := info.Deb.Signature.SignFn; signFn != nil {
		sig, err = signFn(data)
	} else {
		sig, err = sign.PGPArmoredDetachSignWithKeyID(data, info.Deb.Signature.KeyFile, info.Deb.Signature.KeyPassphrase, info.Deb.Signature.KeyID)
	}
	if err != nil {
		return nil, sigType, &nfpm.ErrSigningFailure{Err: err}
	}
	return sig, sigType, nil
}

func readDebsignData(debianBinary, controlTarGz, dataTarball []byte) io.Reader {
	return io.MultiReader(bytes.NewReader(debianBinary), bytes.NewReader(controlTarGz),
		bytes.NewReader(dataTarball))
}

// reference: https://manpages.debian.org/jessie/dpkg-sig/dpkg-sig.1.en.html
const dpkgSigTemplate = `
Hash: SHA1

Version: 4
Signer: {{ .Signer }}
Date: {{ .Date }}
Role: {{ .Role }}
Files:
{{range .Files}}{{ .Md5Sum }} {{ .Sha1Sum }} {{ .Size }} {{ .Name }}{{end}}
`

type dpkgSigData struct {
	Signer string
	Date   time.Time
	Role   string
	Files  []dpkgSigFileLine
	Info   *nfpm.Info
}
type dpkgSigFileLine struct {
	Md5Sum  [16]byte
	Sha1Sum [20]byte
	Size    int
	Name    string
}

func newDpkgSigFileLine(name string, fileContent []byte) dpkgSigFileLine {
	return dpkgSigFileLine{
		Name:    name,
		Md5Sum:  md5.Sum(fileContent),
		Sha1Sum: sha1.Sum(fileContent),
		Size:    len(fileContent),
	}
}

func readDpkgSigData(info *nfpm.Info, debianBinary, controlTarGz, dataTarball []byte) (io.Reader, error) {
	data := dpkgSigData{
		Signer: info.Deb.Signature.Signer,
		Date:   time.Now(),
		Role:   info.Deb.Signature.Type,
		Files: []dpkgSigFileLine{
			newDpkgSigFileLine("debian-binary", debianBinary),
			newDpkgSigFileLine("control.tar.gz", controlTarGz),
			newDpkgSigFileLine("data.tar.gz", dataTarball),
		},
	}
	temp, _ := template.New("dpkg-sig").Parse(dpkgSigTemplate)
	buf := &bytes.Buffer{}
	err := temp.Execute(buf, data)
	if err != nil {
		return nil, fmt.Errorf("dpkg-sig template error: %w", err)
	}
	return buf, nil
}

func (*Deb) SetPackagerDefaults(info *nfpm.Info) {
	// Priority should be set on all packages per:
	//   https://www.debian.org/doc/debian-policy/ch-archive.html#priorities
	// "optional" seems to be the safe/sane default here
	if info.Priority == "" {
		info.Priority = "optional"
	}

	// The safe thing here feels like defaulting to something like below.
	// That will prevent existing configs from breaking anyway...  Wondering
	// if in the long run we should be more strict about this and error when
	// not set?
	if info.Maintainer == "" {
		deprecation.Println("Leaving the 'maintainer' field unset will not be allowed in a future version")
		info.Maintainer = "Unset Maintainer <unset@localhost>"
	}
}

func addArFile(w *ar.Writer, name string, body []byte) error {
	header := ar.Header{
		Name:    files.ToNixPath(name),
		Size:    int64(len(body)),
		Mode:    0o644,
		ModTime: time.Now(),
	}
	if err := w.WriteHeader(&header); err != nil {
		return fmt.Errorf("cannot write file header: %w", err)
	}
	_, err := w.Write(body)
	return err
}

type nopCloser struct {
	io.Writer
}

func (nopCloser) Close() error { return nil }

func createDataTarball(info *nfpm.Info) (dataTarBall, md5sums []byte,
	instSize int64, name string, err error,
) {
	var (
		dataTarball            bytes.Buffer
		dataTarballWriteCloser io.WriteCloser
	)

	switch info.Deb.Compression {
	case "", "gzip": // the default for now
		dataTarballWriteCloser = gzip.NewWriter(&dataTarball)
		name = "data.tar.gz"
	case "xz":
		dataTarballWriteCloser, err = xz.NewWriter(&dataTarball)
		if err != nil {
			return nil, nil, 0, "", err
		}
		name = "data.tar.xz"
	case "zstd":
		dataTarballWriteCloser, err = zstd.NewWriter(&dataTarball)
		if err != nil {
			return nil, nil, 0, "", err
		}
		name = "data.tar.zst"
	case "none":
		dataTarballWriteCloser = nopCloser{Writer: &dataTarball}
		name = "data.tar"
	default:
		return nil, nil, 0, "", fmt.Errorf("unknown compression algorithm: %s", info.Deb.Compression)
	}

	// the writer is properly closed later, this is just in case that we error out
	defer dataTarballWriteCloser.Close() // nolint: errcheck

	md5sums, instSize, err = fillDataTar(info, dataTarballWriteCloser)
	if err != nil {
		return nil, nil, 0, "", err
	}

	if err := dataTarballWriteCloser.Close(); err != nil {
		return nil, nil, 0, "", fmt.Errorf("closing data tarball: %w", err)
	}

	return dataTarball.Bytes(), md5sums, instSize, name, nil
}

func fillDataTar(info *nfpm.Info, w io.Writer) (md5sums []byte, instSize int64, err error) {
	out := tar.NewWriter(w)

	// the writer is properly closed later, this is just in case that we have
	// an error in another part of the code.
	defer out.Close() // nolint: errcheck

	md5buf, instSize, err := createFilesInsideDataTar(info, out)
	if err != nil {
		return nil, 0, err
	}

	if err := out.Close(); err != nil {
		return nil, 0, fmt.Errorf("closing data.tar.gz: %w", err)
	}

	return md5buf.Bytes(), instSize, nil
}

func createFilesInsideDataTar(info *nfpm.Info, tw *tar.Writer) (md5buf bytes.Buffer, instSize int64, err error) {
	// create files and implicit directories
	for _, file := range info.Contents {
		var size int64 // declare early to avoid shadowing err
		switch file.Type {
		case files.TypeRPMGhost:
			// skip ghost files in deb
			continue
		case files.TypeDir, files.TypeImplicitDir:
			err = tw.WriteHeader(&tar.Header{
				Name:     files.AsExplicitRelativePath(file.Destination),
				Mode:     int64(file.FileInfo.Mode),
				Typeflag: tar.TypeDir,
				Format:   tar.FormatGNU,
				Uname:    file.FileInfo.Owner,
				Gname:    file.FileInfo.Group,
				ModTime:  file.FileInfo.MTime,
			})
		case files.TypeSymlink:
			err = newItemInsideTar(tw, []byte{}, &tar.Header{
				Name:     files.AsExplicitRelativePath(file.Destination),
				Linkname: file.Source,
				Typeflag: tar.TypeSymlink,
				ModTime:  file.FileInfo.MTime,
				Format:   tar.FormatGNU,
			})
		case files.TypeDebChangelog:
			size, err = createChangelogInsideDataTar(tw, &md5buf, info, file.Destination)
		default:
			size, err = copyToTarAndDigest(file, tw, &md5buf)
		}
		if err != nil {
			return md5buf, 0, err
		}
		instSize += size
	}

	return md5buf, instSize, nil
}

func copyToTarAndDigest(file *files.Content, tw *tar.Writer, md5w io.Writer) (int64, error) {
	tarFile, err := os.OpenFile(file.Source, os.O_RDONLY, 0o600) //nolint:gosec
	if err != nil {
		return 0, fmt.Errorf("could not add tarFile to the archive: %w", err)
	}
	// don't care if it errs while closing...
	defer tarFile.Close() // nolint: errcheck,gosec

	header, err := tar.FileInfoHeader(file, file.Source)
	if err != nil {
		return 0, err
	}

	// tar.FileInfoHeader only uses file.Mode().Perm() which masks the mode with
	// 0o777 which we don't want because we want to be able to set the suid bit.
	header.Mode = int64(file.Mode())
	header.Format = tar.FormatGNU
	header.Name = files.AsExplicitRelativePath(file.Destination)
	header.Uname = file.FileInfo.Owner
	header.Gname = file.FileInfo.Group
	if err := tw.WriteHeader(header); err != nil {
		return 0, fmt.Errorf("cannot write header of %s to data.tar.gz: %w", file.Source, err)
	}
	digest := md5.New() // nolint:gas
	if _, err := io.Copy(tw, io.TeeReader(tarFile, digest)); err != nil {
		return 0, fmt.Errorf("%s: failed to copy: %w", file.Source, err)
	}
	if _, err := fmt.Fprintf(md5w, "%x  %s\n", digest.Sum(nil), header.Name); err != nil {
		return 0, fmt.Errorf("%s: failed to write md5: %w", file.Source, err)
	}
	return file.Size(), nil
}

func withChangelogIfRequested(info *nfpm.Info) *nfpm.Info {
	if info.Changelog == "" {
		return info
	}

	// https://www.debian.org/doc/manuals/developers-reference/pkgs.de.html#recording-changes-in-the-package
	// https://lintian.debian.org/tags/debian-changelog-file-missing-or-wrong-name
	info.Contents = append(info.Contents, &files.Content{
		Destination: fmt.Sprintf("/usr/share/doc/%s/changelog.Debian.gz", info.Name),
		Type:        files.TypeDebChangelog, // this type is handeled in createDataTarball
	})

	return info
}

func createChangelogInsideDataTar(
	tarw *tar.Writer,
	g io.Writer,
	info *nfpm.Info,
	fileName string,
) (int64, error) {
	var buf bytes.Buffer
	// we need here a non timestamped compression -> https://github.com/klauspost/pgzip doesn't support that
	// https://github.com/klauspost/pgzip/blob/v1.2.6/gzip.go#L322 vs.
	// https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/compress/gzip/gzip.go;l=157
	out, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
	if err != nil {
		return 0, fmt.Errorf("could not create gzip writer: %w", err)
	}
	// the writers are properly closed later, this is just in case that we have
	// an error in another part of the code.
	defer out.Close() // nolint: errcheck

	changelogContent, err := formatChangelog(info)
	if err != nil {
		return 0, err
	}

	if _, err = io.WriteString(out, changelogContent); err != nil {
		return 0, err
	}

	if err = out.Close(); err != nil {
		return 0, fmt.Errorf("closing %s: %w", filepath.Base(fileName), err)
	}

	changelogData := buf.Bytes()

	digest := md5.New() // nolint:gas
	if _, err = digest.Write(changelogData); err != nil {
		return 0, err
	}

	if _, err = fmt.Fprintf(
		g,
		"%x  %s\n",
		digest.Sum(nil),
		files.AsExplicitRelativePath(fileName),
	); err != nil {
		return 0, err
	}

	if err = newFileInsideTar(tarw, fileName, changelogData); err != nil {
		return 0, err
	}

	return int64(len(changelogData)), nil
}

func formatChangelog(info *nfpm.Info) (string, error) {
	changelog, err := info.GetChangeLog()
	if err != nil {
		return "", err
	}

	tpl, err := chglog.DebTemplate()
	if err != nil {
		return "", err
	}

	formattedChangelog, err := chglog.FormatChangelog(changelog, tpl)
	if err != nil {
		return "", err
	}

	return strings.TrimSpace(formattedChangelog) + "\n", nil
}

// nolint:funlen
func createControl(instSize int64, md5sums []byte, info *nfpm.Info) (controlTarGz []byte, err error) {
	var buf bytes.Buffer
	compress := gzip.NewWriter(&buf)
	out := tar.NewWriter(compress)
	// the writers are properly closed later, this is just in case that we have
	// an error in another part of the code.
	defer out.Close()      // nolint: errcheck
	defer compress.Close() // nolint: errcheck

	var body bytes.Buffer
	if err = writeControl(&body, controlData{
		Info:          info,
		InstalledSize: instSize / 1024,
	}); err != nil {
		return nil, err
	}

	filesToCreate := map[string][]byte{
		"./control":   body.Bytes(),
		"./md5sums":   md5sums,
		"./conffiles": conffiles(info),
	}

	triggers := createTriggers(info)
	if len(triggers) > 0 {
		filesToCreate["./triggers"] = triggers
	}

	for name, content := range filesToCreate {
		if err := newFileInsideTar(out, name, content); err != nil {
			return nil, err
		}
	}

	type fileAndMode struct {
		fileName string
		mode     int64
	}

	specialFiles := map[string]*fileAndMode{}
	specialFiles[info.Scripts.PreInstall] = &fileAndMode{
		fileName: "preinst",
		mode:     0o755,
	}
	specialFiles[info.Scripts.PostInstall] = &fileAndMode{
		fileName: "postinst",
		mode:     0o755,
	}
	specialFiles[info.Scripts.PreRemove] = &fileAndMode{
		fileName: "prerm",
		mode:     0o755,
	}
	specialFiles[info.Scripts.PostRemove] = &fileAndMode{
		fileName: "postrm",
		mode:     0o755,
	}
	specialFiles[info.Overridables.Deb.Scripts.Rules] = &fileAndMode{
		fileName: "rules",
		mode:     0o755,
	}
	specialFiles[info.Overridables.Deb.Scripts.Templates] = &fileAndMode{
		fileName: "templates",
		mode:     0o644,
	}
	specialFiles[info.Overridables.Deb.Scripts.Config] = &fileAndMode{
		fileName: "config",
		mode:     0o755,
	}

	for path, destMode := range specialFiles {
		if path != "" {
			if err := newFilePathInsideTar(out, path, destMode.fileName, destMode.mode); err != nil {
				return nil, err
			}
		}
	}

	if err := out.Close(); err != nil {
		return nil, fmt.Errorf("closing control.tar.gz: %w", err)
	}
	if err := compress.Close(); err != nil {
		return nil, fmt.Errorf("closing control.tar.gz: %w", err)
	}
	return buf.Bytes(), nil
}

func newItemInsideTar(out *tar.Writer, content []byte, header *tar.Header) error {
	if err := out.WriteHeader(header); err != nil {
		return fmt.Errorf("cannot write header of %s file to control.tar.gz: %w", header.Name, err)
	}
	if _, err := out.Write(content); err != nil {
		return fmt.Errorf("cannot write %s file to control.tar.gz: %w", header.Name, err)
	}
	return nil
}

func newFileInsideTar(out *tar.Writer, name string, content []byte) error {
	return newItemInsideTar(out, content, &tar.Header{
		Name:     files.AsExplicitRelativePath(name),
		Size:     int64(len(content)),
		Mode:     0o644,
		ModTime:  time.Now(),
		Typeflag: tar.TypeReg,
		Format:   tar.FormatGNU,
	})
}

func newFilePathInsideTar(out *tar.Writer, path, dest string, mode int64) error {
	file, err := os.Open(path) //nolint:gosec
	if err != nil {
		return err
	}
	content, err := io.ReadAll(file)
	if err != nil {
		return err
	}
	return newItemInsideTar(out, content, &tar.Header{
		Name:     files.AsExplicitRelativePath(dest),
		Size:     int64(len(content)),
		Mode:     mode,
		ModTime:  time.Now(),
		Typeflag: tar.TypeReg,
		Format:   tar.FormatGNU,
	})
}

func conffiles(info *nfpm.Info) []byte {
	// nolint: prealloc
	var confs []string
	for _, file := range info.Contents {
		switch file.Type {
		case files.TypeConfig, files.TypeConfigNoReplace:
			confs = append(confs, files.NormalizeAbsoluteFilePath(file.Destination))
		}
	}
	return []byte(strings.Join(confs, "\n") + "\n")
}

func createTriggers(info *nfpm.Info) []byte {
	var buffer bytes.Buffer

	// https://man7.org/linux/man-pages/man5/deb-triggers.5.html
	triggerEntries := []struct {
		Directive    string
		TriggerNames *[]string
	}{
		{"interest", &info.Deb.Triggers.Interest},
		{"interest-await", &info.Deb.Triggers.InterestAwait},
		{"interest-noawait", &info.Deb.Triggers.InterestNoAwait},
		{"activate", &info.Deb.Triggers.Activate},
		{"activate-await", &info.Deb.Triggers.ActivateAwait},
		{"activate-noawait", &info.Deb.Triggers.ActivateNoAwait},
	}

	for _, triggerEntry := range triggerEntries {
		for _, triggerName := range *triggerEntry.TriggerNames {
			fmt.Fprintf(&buffer, "%s %s\n", triggerEntry.Directive, triggerName)
		}
	}

	return buffer.Bytes()
}

const controlTemplate = `
{{- /* Mandatory fields */ -}}
Package: {{.Info.Name}}
Version: {{ if .Info.Epoch}}{{ .Info.Epoch }}:{{ end }}{{.Info.Version}}
         {{- if .Info.Prerelease}}~{{ .Info.Prerelease }}{{- end }}
         {{- if .Info.VersionMetadata}}+{{ .Info.VersionMetadata }}{{- end }}
         {{- if .Info.Release}}-{{ .Info.Release }}{{- end }}
Section: {{.Info.Section}}
Priority: {{.Info.Priority}}
Architecture: {{ if ne .Info.Platform "linux"}}{{ .Info.Platform }}-{{ end }}{{.Info.Arch}}
{{- /* Optional fields */ -}}
{{- if .Info.Maintainer}}
Maintainer: {{.Info.Maintainer}}
{{- end }}
Installed-Size: {{.InstalledSize}}
{{- with .Info.Replaces}}
Replaces: {{join .}}
{{- end }}
{{- with nonEmpty .Info.Provides}}
Provides: {{join .}}
{{- end }}
{{- with .Info.Deb.Predepends}}
Pre-Depends: {{join .}}
{{- end }}
{{- with .Info.Depends}}
Depends: {{join .}}
{{- end }}
{{- with .Info.Recommends}}
Recommends: {{join .}}
{{- end }}
{{- with .Info.Suggests}}
Suggests: {{join .}}
{{- end }}
{{- with .Info.Conflicts}}
Conflicts: {{join .}}
{{- end }}
{{- with .Info.Deb.Breaks}}
Breaks: {{join .}}
{{- end }}
{{- if .Info.Homepage}}
Homepage: {{.Info.Homepage}}
{{- end }}
{{- /* Mandatory fields */}}
Description: {{multiline .Info.Description}}
{{- range $key, $value := .Info.Deb.Fields }}
{{- if $value }}
{{$key}}: {{$value}}
{{- end }}
{{- end }}
`

type controlData struct {
	Info          *nfpm.Info
	InstalledSize int64
}

func writeControl(w io.Writer, data controlData) error {
	tmpl := template.New("control")
	tmpl.Funcs(template.FuncMap{
		"join": func(strs []string) string {
			return strings.Trim(strings.Join(strs, ", "), " ")
		},
		"multiline": func(strs string) string {
			ret := strings.ReplaceAll(strs, "\n", "\n ")
			return strings.Trim(ret, " \n")
		},
		"nonEmpty": func(strs []string) []string {
			var result []string
			for _, s := range strs {
				s := strings.TrimSpace(s)
				if s == "" {
					continue
				}
				result = append(result, s)
			}
			return result
		},
	})
	return template.Must(tmpl.Parse(controlTemplate)).Execute(w, data)
}
