package ui

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"image"
	"io"
	"mime"
	"net/mail"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"aegis/internal/config"
	"aegis/internal/cryptokit"
	"aegis/internal/filter"
	"aegis/internal/identity"
	"aegis/internal/nntp"
	vfaceprofile "aegis/internal/profile"
	"aegis/internal/smtpclient"
	"aegis/internal/store"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/canvas"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/dialog"
	"fyne.io/fyne/v2/layout"
	"fyne.io/fyne/v2/theme"
	"fyne.io/fyne/v2/widget"
)

const overviewLimit = int64(500)

const messageIDDomain = "aegis.virebent.art"

type application struct {
	window         fyne.Window
	configPath     string
	settings       config.Settings
	state          *store.Store
	filterRules    []filter.Rule
	vfaceProfile   *vfaceprofile.Profile
	vfaceVaultPath string

	clientMu sync.RWMutex
	client   *nntp.Client

	status      *widget.Label
	progress    *widget.ProgressBarInfinite
	connect     *widget.Button
	disconnect  *widget.Button
	refresh     *widget.Button
	groupSearch *widget.Entry
	groupList   *widget.List
	groupDetail *widget.Label
	subscribe   *widget.Button
	unsubscribe *widget.Button
	loadGroup   *widget.Button

	groups        []nntp.GroupInfo
	visibleGroups []nntp.GroupInfo
	selectedGroup string

	headerSearch      *widget.Entry
	headerList        *widget.List
	articleHeaders    *widget.Label
	showAllHeaders    *widget.Check
	body              *widget.Entry
	articleText       string
	headers           []nntp.ArticleHeader
	visibleHeader     []nntp.ArticleHeader
	loadedGroup       string
	selectedHeader    nntp.ArticleHeader
	hasSelectedHeader bool
	markRead          *widget.Button
	bookmark          *widget.Button
	reply             *widget.Button

	composeGroups           *widget.Entry
	composeDelivery         *widget.Select
	composeTo               *widget.Entry
	composeFrom             *widget.Entry
	composeSubject          *widget.Entry
	composeReferences       *widget.Entry
	composeFollowupTo       *widget.Entry
	composeBody             *widget.Entry
	composeCryptoMode       *widget.Select
	composeCryptoSigningKey *widget.Entry

	cryptoAlgorithm      *widget.Select
	cryptoOperation      *widget.Select
	cryptoMessage        *widget.Entry
	cryptoPrimary        *widget.Entry
	cryptoSecret         *widget.Entry
	cryptoSecondary      *widget.Entry
	cryptoPrimaryLabel   *widget.Label
	cryptoSecretLabel    *widget.Label
	cryptoSecondaryLabel *widget.Label
	cryptoPrimaryBox     *fyne.Container
	cryptoSecretBox      *fyne.Container
	cryptoSecondaryBox   *fyne.Container
	cryptoOutput         *widget.Label

	hostEntry          *widget.Entry
	portEntry          *widget.Entry
	tlsCheck           *widget.Check
	startTLSCheck      *widget.Check
	tlsSkipVerify      *widget.Check
	usernameEntry      *widget.Entry
	passwordEntry      *widget.Entry
	saslSelect         *widget.Select
	compressionCheck   *widget.Check
	proxySelect        *widget.Select
	proxyEntry         *widget.Entry
	smtpHostEntry      *widget.Entry
	smtpPortEntry      *widget.Entry
	smtpModeSelect     *widget.Select
	smtpUserEntry      *widget.Entry
	smtpEmailEntry     *widget.Entry
	smtpRecipientEntry *widget.Entry
	smtpPasswordEntry  *widget.Entry
	smtpSkipVerify     *widget.Check
	displayEntry       *widget.Entry
	emailEntry         *widget.Entry
	filterField        *widget.Select
	filterOperator     *widget.Select
	filterPattern      *widget.Entry
	filterAction       *widget.Select
	filterTag          *widget.Entry
	vfaceUsernameEntry *widget.Entry
	vfaceEmailEntry    *widget.Entry
	vfacePasswordEntry *widget.Entry
	vfaceConfirmEntry  *widget.Entry
	vfaceStatus        *widget.Label
	vfaceImage         *canvas.Image
	vfaceHash          *widget.Label
	vfacePublicKey     *widget.Label
	vfaceKeyStatus     *widget.Label
}

func Run() {
	configPath, pathErr := config.DefaultPath()
	settings := config.Default()
	loadErr := pathErr
	if pathErr == nil {
		var err error
		settings, err = config.Load(configPath)
		if err != nil {
			loadErr = err
			settings = config.Default()
		}
	}

	fyneApp := app.NewWithID("art.virebent.aegis")
	window := fyneApp.NewWindow("Aegis Usenet Client")
	window.Resize(fyne.NewSize(1180, 800))
	a := &application{window: window, configPath: configPath, settings: settings}
	if statePath, err := store.DefaultPath(); err == nil {
		if localState, stateErr := store.Open(statePath); stateErr == nil {
			a.state = localState
			a.filterRules = localState.Snapshot().Filters
		} else if loadErr == nil {
			loadErr = stateErr
		}
	}
	window.SetContent(a.build())
	if loadErr != nil {
		dialog.ShowError(loadErr, window)
	}
	window.ShowAndRun()
	a.closeClient()
}

func (a *application) build() fyne.CanvasObject {
	a.status = widget.NewLabel("Offline. Configure the server, then connect.")
	a.progress = widget.NewProgressBarInfinite()
	a.progress.Hide()

	reader := a.buildReader()
	composer := a.buildComposer()
	settings := a.buildSettings()
	tabs := container.NewAppTabs(
		container.NewTabItemWithIcon("News Reader", theme.HomeIcon(), reader),
		container.NewTabItemWithIcon("Compose", theme.MailComposeIcon(), composer),
		container.NewTabItemWithIcon("Profilo e VFace", theme.AccountIcon(), a.buildProfile()),
		container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settings),
	)
	return container.NewBorder(nil, container.NewVBox(a.progress, a.status), nil, nil, tabs)
}

func (a *application) buildReader() fyne.CanvasObject {
	a.groupSearch = widget.NewEntry()
	a.groupSearch.SetPlaceHolder("Filter available newsgroups...")
	a.groupSearch.OnChanged = func(string) { a.filterGroups() }
	a.groupDetail = widget.NewLabel("Select a group to see its estimated population before subscribing.")
	a.groupDetail.Wrapping = fyne.TextWrapWord
	a.groupList = widget.NewList(
		func() int { return len(a.visibleGroups) },
		func() fyne.CanvasObject { return widget.NewLabel("Newsgroup") },
		func(id widget.ListItemID, object fyne.CanvasObject) {
			group := a.visibleGroups[id]
			prefix := "  "
			if a.isSubscribed(group.Name) {
				prefix = "✓ "
			}
			object.(*widget.Label).SetText(fmt.Sprintf("%s%s  (≈ %s posts)", prefix, group.Name, formatCount(group.EstimatedPost)))
		},
	)
	a.groupList.OnSelected = func(id widget.ListItemID) {
		if id < 0 || id >= len(a.visibleGroups) {
			return
		}
		group := a.visibleGroups[id]
		a.selectedGroup = group.Name
		a.groupDetail.SetText(fmt.Sprintf(
			"%s\nEstimated posts: %s (article numbers %d-%d)\nPosting flag: %s\nThe estimate may include gaps on the server.",
			group.Name, formatCount(group.EstimatedPost), group.Low, group.High, group.Posting,
		))
	}

	a.subscribe = widget.NewButtonWithIcon("Subscribe", theme.ContentAddIcon(), a.subscribeSelected)
	a.unsubscribe = widget.NewButtonWithIcon("Unsubscribe", theme.ContentRemoveIcon(), a.unsubscribeSelected)
	a.loadGroup = widget.NewButtonWithIcon("Load articles", theme.ViewRefreshIcon(), a.loadSelectedGroup)
	groupButtons := container.NewGridWithColumns(3, a.subscribe, a.unsubscribe, a.loadGroup)
	groupPane := container.NewBorder(
		container.NewVBox(widget.NewLabelWithStyle("Available groups", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), a.groupSearch),
		container.NewVBox(a.groupDetail, groupButtons), nil, nil, a.groupList,
	)

	a.body = widget.NewMultiLineEntry()
	a.body.SetPlaceHolder("Select an article to download it from the server...")
	a.body.TextStyle = fyne.TextStyle{Monospace: true}
	a.body.Disable()
	a.articleHeaders = widget.NewLabel("No article selected.")
	a.articleHeaders.Selectable = true
	a.articleHeaders.TextStyle = fyne.TextStyle{Monospace: true}
	a.articleHeaders.Wrapping = fyne.TextWrapOff
	a.showAllHeaders = widget.NewCheck("Show all headers", func(bool) { a.refreshArticleHeaders() })
	headerDisplay := container.NewBorder(a.showAllHeaders, nil, nil, nil, container.NewVScroll(a.articleHeaders))

	a.headerSearch = widget.NewEntry()
	a.headerSearch.SetPlaceHolder("Search loaded subjects or authors...")
	a.headerSearch.OnChanged = func(string) { a.filterHeaders() }
	a.headerList = widget.NewList(
		func() int { return len(a.visibleHeader) },
		func() fyne.CanvasObject {
			return container.NewHBox(
				widget.NewIcon(theme.DocumentIcon()),
				widget.NewLabel("Subject"),
				layout.NewSpacer(),
				widget.NewLabel("Author"),
			)
		},
		func(id widget.ListItemID, object fyne.CanvasObject) {
			header := a.visibleHeader[id]
			box := object.(*fyne.Container)
			prefix := ""
			if a.headerRead(header) {
				prefix = "✓ "
			}
			if a.headerBookmarked(header) {
				prefix += "★ "
			}
			box.Objects[1].(*widget.Label).SetText(prefix + header.Subject)
			box.Objects[3].(*widget.Label).SetText(header.From)
		},
	)
	a.headerList.OnSelected = func(id widget.ListItemID) {
		if id < 0 || id >= len(a.visibleHeader) {
			return
		}
		header := a.visibleHeader[id]
		a.selectedHeader = header
		a.hasSelectedHeader = true
		a.refreshArticleActions()
		a.loadArticle(a.loadedGroup, header)
	}
	headerPane := container.NewBorder(a.headerSearch, nil, nil, nil, a.headerList)
	bodyPane := container.NewVSplit(headerDisplay, a.body)
	bodyPane.SetOffset(0.24)
	rightSplit := container.NewVSplit(headerPane, bodyPane)
	rightSplit.SetOffset(0.45)
	mainSplit := container.NewHSplit(groupPane, rightSplit)
	mainSplit.SetOffset(0.36)

	a.connect = widget.NewButtonWithIcon("Connect", theme.LoginIcon(), a.connectServer)
	a.disconnect = widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), a.disconnectServer)
	a.disconnect.Disable()
	a.refresh = widget.NewButtonWithIcon("Refresh groups", theme.ViewRefreshIcon(), a.refreshGroups)
	a.refresh.Disable()
	a.markRead = widget.NewButton("Mark read", a.toggleRead)
	a.bookmark = widget.NewButton("Bookmark", a.toggleBookmark)
	a.reply = widget.NewButton("Reply", a.replyToSelected)
	a.markRead.Disable()
	a.bookmark.Disable()
	a.reply.Disable()
	toolbar := container.NewHBox(a.connect, a.disconnect, a.refresh, layout.NewSpacer(), a.reply, a.markRead, a.bookmark)
	return container.NewBorder(toolbar, nil, nil, nil, mainSplit)
}

func (a *application) buildComposer() fyne.CanvasObject {
	a.composeGroups = widget.NewEntry()
	a.composeGroups.SetPlaceHolder("comp.lang.go,example.group")
	a.composeDelivery = widget.NewSelect([]string{"NNTP direct posting", "SMTP mail2news"}, nil)
	a.composeDelivery.SetSelected("NNTP direct posting")
	a.composeTo = widget.NewEntry()
	a.composeTo.SetText(a.settings.SMTPRecipient)
	a.composeFrom = widget.NewEntry()
	a.composeSubject = widget.NewEntry()
	a.composeReferences = widget.NewEntry()
	a.composeReferences.SetPlaceHolder("Filled automatically for replies")
	a.composeFollowupTo = widget.NewEntry()
	a.composeFollowupTo.SetPlaceHolder("Optional Followup-To newsgroup")
	a.composeBody = widget.NewMultiLineEntry()
	a.composeBody.SetPlaceHolder("Article body...")
	a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email))
	// Keep the identity visible in the normal foreground color. VFace, when
	// unlocked, still replaces this value before posting.
	a.composeCryptoMode = widget.NewSelect([]string{
		"Plain",
		"Sign with Ed25519",
	}, nil)
	a.composeCryptoMode.SetSelected("Plain")
	a.composeCryptoSigningKey = widget.NewMultiLineEntry()
	a.composeCryptoSigningKey.SetPlaceHolder("Optional Ed25519 private key; VFace supplies it automatically")
	a.composeCryptoSigningKey.Wrapping = fyne.TextWrapOff
	post := widget.NewButtonWithIcon("Post article", theme.MailSendIcon(), a.postArticle)
	form := widget.NewForm(
		widget.NewFormItem("Newsgroups", a.composeGroups),
		widget.NewFormItem("Delivery", a.composeDelivery),
		widget.NewFormItem("To", a.composeTo),
		widget.NewFormItem("From", a.composeFrom),
		widget.NewFormItem("Subject", a.composeSubject),
		widget.NewFormItem("References", a.composeReferences),
		widget.NewFormItem("Followup-To", a.composeFollowupTo),
		widget.NewFormItem("Mode", a.composeCryptoMode),
		widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey),
	)
	return container.NewBorder(form, post, nil, nil, a.composeBody)
}

func (a *application) buildProfile() fyne.CanvasObject {
	a.vfaceUsernameEntry = widget.NewEntry()
	a.vfaceUsernameEntry.SetPlaceHolder("Pseudonymous username")
	a.vfaceEmailEntry = widget.NewEntry()
	a.vfaceEmailEntry.SetPlaceHolder("Pseudonymous email address")
	a.vfacePasswordEntry = widget.NewPasswordEntry()
	a.vfacePasswordEntry.SetPlaceHolder("Vault password, minimum 12 characters")
	a.vfaceConfirmEntry = widget.NewPasswordEntry()
	a.vfaceConfirmEntry.SetPlaceHolder("Repeat vault password")
	a.vfaceStatus = widget.NewLabel("No VFace identity loaded. VFace is optional.")
	a.vfaceStatus.Wrapping = fyne.TextWrapWord
	a.vfaceHash = widget.NewLabel("")
	a.vfaceHash.Wrapping = fyne.TextWrapWord
	a.vfacePublicKey = widget.NewLabel("")
	a.vfacePublicKey.Wrapping = fyne.TextWrapBreak
	a.vfacePublicKey.Selectable = true
	a.vfaceKeyStatus = widget.NewLabel("")
	a.vfaceKeyStatus.Wrapping = fyne.TextWrapWord
	a.vfaceImage = canvas.NewImageFromImage(image.NewRGBA(image.Rect(0, 0, 48, 48)))
	a.vfaceImage.FillMode = canvas.ImageFillContain
	a.vfaceImage.SetMinSize(fyne.NewSize(96, 96))
	a.vfaceImage.Hide()
	path, err := vfaceprofile.DefaultPath()
	if err == nil {
		a.vfaceVaultPath = path
	}
	pathLabel := widget.NewLabel("Vault: " + a.vfaceVaultPath)
	pathLabel.Wrapping = fyne.TextWrapBreak
	create := widget.NewButton("Create or replace VFace identity", a.createVFaceProfile)
	load := widget.NewButton("Load VFace identity", a.loadVFaceProfile)
	lock := widget.NewButton("Lock identity", a.lockVFaceProfile)
	form := widget.NewForm(
		widget.NewFormItem("VFace username", a.vfaceUsernameEntry),
		widget.NewFormItem("VFace email", a.vfaceEmailEntry),
		widget.NewFormItem("Vault password", a.vfacePasswordEntry),
		widget.NewFormItem("Confirm password", a.vfaceConfirmEntry),
	)
	provider := widget.NewLabel("VFace creates an Ed25519 key pair. The public key is part of the identity; the private key remains encrypted in the local vault and is used for signing. Message encryption is intentionally not part of the Usenet client.")
	provider.Wrapping = fyne.TextWrapWord
	return container.NewVScroll(container.NewVBox(
		widget.NewLabelWithStyle("Optional pseudonymous identity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
		form,
		container.NewHBox(create, load, lock),
		pathLabel,
		a.vfaceStatus,
		a.vfaceImage,
		a.vfaceHash,
		a.vfacePublicKey,
		a.vfaceKeyStatus,
		provider,
	))
}

func (a *application) createVFaceProfile() {
	password := a.vfacePasswordEntry.Text
	if password != a.vfaceConfirmEntry.Text {
		dialog.ShowError(errors.New("VFace passwords do not match"), a.window)
		return
	}
	value, err := vfaceprofile.Generate(a.vfaceUsernameEntry.Text, a.vfaceEmailEntry.Text)
	if err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	if a.vfaceVaultPath == "" {
		dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window)
		return
	}
	a.setBusy(true, "Creating encrypted VFace vault...")
	go func() {
		saveErr := vfaceprofile.Save(a.vfaceVaultPath, value, password)
		fyne.Do(func() {
			a.setBusy(false, "")
			if saveErr != nil {
				dialog.ShowError(saveErr, a.window)
				return
			}
			a.vfaceProfile = &value
			a.vfaceConfirmEntry.SetText("")
			a.renderVFaceProfile(value)
			a.updateComposeIdentity()
			a.composeCryptoMode.SetSelected("Sign with Ed25519")
			a.vfaceStatus.SetText("VFace identity created and encrypted on disk.")
		})
	}()
}

func (a *application) loadVFaceProfile() {
	if a.vfaceVaultPath == "" {
		dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window)
		return
	}
	password := a.vfacePasswordEntry.Text
	a.setBusy(true, "Loading encrypted VFace identity...")
	go func() {
		value, err := vfaceprofile.Load(a.vfaceVaultPath, password)
		fyne.Do(func() {
			a.setBusy(false, "")
			if err != nil {
				dialog.ShowError(err, a.window)
				return
			}
			a.vfaceProfile = &value
			a.vfaceUsernameEntry.SetText(value.Username)
			a.vfaceEmailEntry.SetText(value.Email)
			a.renderVFaceProfile(value)
			a.updateComposeIdentity()
			a.composeCryptoMode.SetSelected("Sign with Ed25519")
			a.vfaceStatus.SetText("VFace identity loaded from encrypted disk vault.")
		})
	}()
}

func (a *application) lockVFaceProfile() {
	a.vfaceProfile = nil
	if a.vfaceImage != nil {
		a.vfaceImage.Hide()
	}
	if a.vfacePublicKey != nil {
		a.vfacePublicKey.SetText("")
	}
	if a.vfaceKeyStatus != nil {
		a.vfaceKeyStatus.SetText("")
	}
	if a.composeCryptoMode != nil && a.composeCryptoMode.Selected == "Sign with Ed25519" {
		a.composeCryptoMode.SetSelected("Plain")
	}
	if a.vfaceStatus != nil {
		a.vfaceStatus.SetText("VFace identity locked. VFace is optional.")
	}
	a.updateComposeIdentity()
}

func (a *application) renderVFaceProfile(value vfaceprofile.Profile) {
	profile, err := identity.GenerateVFace(value.Username, value.Email, value.PublicKey)
	if err != nil {
		a.vfaceStatus.SetText("VFace profile unavailable: " + err.Error())
		return
	}
	preview, err := identity.DecodeFacePNG(profile.FaceBase64)
	if err == nil {
		a.vfaceImage.Image = preview
		a.vfaceImage.Show()
		a.vfaceImage.Refresh()
	}
	a.vfaceHash.SetText("Identity SHA-256: " + profile.IdentityHash + "\nPNG SHA-256: " + profile.PNGHash)
	a.vfacePublicKey.SetText("Ed25519 public key (selectable):\n" + value.PublicKey)
	a.vfaceKeyStatus.SetText("Ed25519 key pair ready. Private key is encrypted in the local vault and available for signing.")
}

func (a *application) updateComposeIdentity() {
	if a.composeFrom == nil {
		return
	}
	if a.vfaceProfile != nil {
		a.composeFrom.SetText(formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email))
	} else {
		a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email))
	}
}

func (a *application) buildCrypto() fyne.CanvasObject {
	a.cryptoAlgorithm = widget.NewSelect([]string{"Ed25519", "YubiCrypt"}, nil)
	a.cryptoAlgorithm.SetSelected("Ed25519")
	a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify"}, nil)
	a.cryptoOperation.SetSelected("Sign")
	a.cryptoMessage = widget.NewMultiLineEntry()
	a.cryptoMessage.SetPlaceHolder("Message")
	a.cryptoMessage.Wrapping = fyne.TextWrapOff
	a.cryptoPrimary = widget.NewMultiLineEntry()
	a.cryptoPrimary.SetPlaceHolder("Key material supplied by you")
	a.cryptoPrimary.Wrapping = fyne.TextWrapOff
	a.cryptoSecret = widget.NewPasswordEntry()
	a.cryptoSecret.SetPlaceHolder("YubiKey PIV PIN, session only")
	a.cryptoSecondary = widget.NewMultiLineEntry()
	a.cryptoSecondary.SetPlaceHolder("Optional second key or signature")
	a.cryptoSecondary.Wrapping = fyne.TextWrapOff
	a.cryptoPrimaryLabel = widget.NewLabel("Private key / identity")
	a.cryptoSecretLabel = widget.NewLabel("Secret")
	a.cryptoSecondaryLabel = widget.NewLabel("Public key / recipient")
	a.cryptoOutput = widget.NewLabel("No result yet.")
	a.cryptoOutput.Selectable = true
	a.cryptoOutput.Wrapping = fyne.TextWrapOff
	a.cryptoOutput.TextStyle = fyne.TextStyle{Monospace: true}
	a.cryptoAlgorithm.OnChanged = func(string) { a.refreshCryptoFields() }
	a.cryptoOperation.OnChanged = func(string) { a.refreshCryptoFields() }
	run := widget.NewButtonWithIcon("Run operation", theme.MediaPlayIcon(), a.runCryptoOperation)
	clear := widget.NewButtonWithIcon("Clear", theme.DeleteIcon(), func() {
		a.cryptoMessage.SetText("")
		a.cryptoPrimary.SetText("")
		a.cryptoSecret.SetText("")
		a.cryptoSecondary.SetText("")
		a.cryptoOutput.SetText("No result yet.")
	})
	note := widget.NewLabel("This panel is limited to signing and verification. YubiCrypt requires the optional yubicrypt executable, a YubiKey, pcscd and the PIV PIN.")
	note.Wrapping = fyne.TextWrapWord
	form := widget.NewForm(
		widget.NewFormItem("Format", a.cryptoAlgorithm),
		widget.NewFormItem("Operation", a.cryptoOperation),
	)
	messageBox := container.NewVBox(widget.NewLabel("Message"), a.cryptoMessage)
	a.cryptoPrimaryBox = container.NewVBox(a.cryptoPrimaryLabel, a.cryptoPrimary)
	a.cryptoSecretBox = container.NewVBox(a.cryptoSecretLabel, a.cryptoSecret)
	a.cryptoSecondaryBox = container.NewVBox(a.cryptoSecondaryLabel, a.cryptoSecondary)
	a.refreshCryptoFields()
	keys := container.NewVBox(a.cryptoPrimaryBox, a.cryptoSecretBox, a.cryptoSecondaryBox)
	input := container.NewVSplit(messageBox, keys)
	input.SetOffset(0.42)
	result := container.NewBorder(widget.NewLabel("Result, selectable for copy"), nil, nil, nil, container.NewVScroll(a.cryptoOutput))
	main := container.NewVSplit(container.NewVSplit(form, input), result)
	main.SetOffset(0.34)
	return container.NewBorder(nil, container.NewVBox(note, container.NewHBox(run, clear)), nil, nil, main)
}

func (a *application) refreshCryptoFields() {
	if a.cryptoAlgorithm == nil || a.cryptoOperation == nil || a.cryptoPrimaryBox == nil || a.cryptoSecretBox == nil || a.cryptoSecondaryBox == nil {
		return
	}
	algorithm := a.cryptoAlgorithm.Selected
	operation := a.cryptoOperation.Selected
	a.cryptoPrimaryBox.Show()
	a.cryptoSecretBox.Hide()
	a.cryptoSecondaryBox.Show()
	switch operation {
	case "Verify":
		if algorithm == "YubiCrypt" {
			a.cryptoPrimaryBox.Hide()
			a.cryptoSecondaryBox.Hide()
		} else {
			a.cryptoPrimaryLabel.SetText("Signature")
			a.cryptoSecondaryLabel.SetText("Public key")
		}
	default:
		if algorithm == "YubiCrypt" {
			a.cryptoPrimaryBox.Hide()
			a.cryptoSecretBox.Show()
			a.cryptoSecretLabel.SetText("YubiKey PIV PIN")
			a.cryptoSecondaryBox.Hide()
		} else {
			a.cryptoPrimaryLabel.SetText("Private key")
			a.cryptoSecondaryLabel.SetText("Not used")
		}
	}
	a.cryptoSecondary.Disable()
	if operation == "Verify" {
		a.cryptoSecondary.Enable()
	}
	a.cryptoPrimaryBox.Refresh()
	a.cryptoSecretBox.Refresh()
	a.cryptoSecondaryBox.Refresh()
}

func (a *application) runCryptoOperation() {
	algorithm := a.cryptoAlgorithm.Selected
	operation := a.cryptoOperation.Selected
	message := []byte(a.cryptoMessage.Text)
	primary := a.cryptoPrimary.Text
	secret := a.cryptoSecret.Text
	secondary := a.cryptoSecondary.Text
	if len(strings.TrimSpace(string(message))) == 0 {
		dialog.ShowError(errors.New("message is required"), a.window)
		return
	}
	if algorithm == "YubiCrypt" && operation == "Sign" && strings.TrimSpace(secret) == "" {
		dialog.ShowError(errors.New("YubiKey PIV PIN is required"), a.window)
		return
	}
	if algorithm != "YubiCrypt" && strings.TrimSpace(primary) == "" {
		dialog.ShowError(errors.New("primary key material is required"), a.window)
		return
	}
	a.setBusy(true, "Running "+algorithm+" "+operation+"...")
	go func() {
		var result string
		var err error
		switch algorithm {
		case "Ed25519":
			switch operation {
			case "Sign":
				result, err = cryptokit.SignEd25519(message, primary)
			case "Verify":
				err = cryptokit.VerifyEd25519(message, primary, secondary)
				result = "Ed25519 signature verified."
			}
		case "YubiCrypt":
			switch operation {
			case "Sign":
				var signed []byte
				signed, err = cryptokit.SignYubiCrypt(message, secret)
				result = string(signed)
			case "Verify":
				var verified []byte
				verified, err = cryptokit.VerifyYubiCrypt(message)
				result = string(verified)
			}
		}
		fyne.Do(func() {
			a.setBusy(false, "Signing operation completed.")
			if err != nil {
				dialog.ShowError(err, a.window)
				return
			}
			a.cryptoOutput.SetText(result)
		})
	}()
}

func (a *application) buildSettings() fyne.CanvasObject {
	a.hostEntry = widget.NewEntry()
	a.hostEntry.SetText(a.settings.Host)
	a.portEntry = widget.NewEntry()
	a.portEntry.SetText(a.settings.Port)
	a.tlsCheck = widget.NewCheck("Use TLS", nil)
	a.tlsCheck.SetChecked(a.settings.UseTLS)
	a.startTLSCheck = widget.NewCheck("Use STARTTLS", nil)
	a.startTLSCheck.SetChecked(a.settings.StartTLS)
	a.tlsSkipVerify = widget.NewCheck("Do not verify the TLS certificate (unsafe, explicit opt-in)", nil)
	a.tlsSkipVerify.SetChecked(a.settings.SkipTLSVerify)
	a.usernameEntry = widget.NewEntry()
	a.usernameEntry.SetText(a.settings.Username)
	a.passwordEntry = widget.NewPasswordEntry()
	a.passwordEntry.SetPlaceHolder("Session only, never saved")
	a.saslSelect = widget.NewSelect([]string{"None", "PLAIN"}, nil)
	if a.settings.SASLMechanism == "PLAIN" {
		a.saslSelect.SetSelected("PLAIN")
	} else {
		a.saslSelect.SetSelected("None")
	}
	a.compressionCheck = widget.NewCheck("Use COMPRESS DEFLATE when advertised", nil)
	a.compressionCheck.SetChecked(a.settings.UseCompression)
	a.proxySelect = widget.NewSelect([]string{"DIRECT", "SOCKS5"}, nil)
	a.proxySelect.SetSelected(a.settings.ProxyType)
	a.proxyEntry = widget.NewEntry()
	a.proxyEntry.SetText(a.settings.ProxyAddress)
	a.smtpHostEntry = widget.NewEntry()
	a.smtpHostEntry.SetText(a.settings.SMTPHost)
	a.smtpPortEntry = widget.NewEntry()
	a.smtpPortEntry.SetText(a.settings.SMTPPort)
	a.smtpModeSelect = widget.NewSelect([]string{"Cleartext (no TLS)", "TLS", "STARTTLS"}, nil)
	if a.settings.SMTPMode == "TLS" || a.settings.SMTPMode == "STARTTLS" {
		a.smtpModeSelect.SetSelected(a.settings.SMTPMode)
	} else {
		a.smtpModeSelect.SetSelected("Cleartext (no TLS)")
	}
	a.smtpUserEntry = widget.NewEntry()
	a.smtpUserEntry.SetText(a.settings.SMTPUsername)
	a.smtpEmailEntry = widget.NewEntry()
	a.smtpEmailEntry.SetText(a.settings.SMTPEmail)
	a.smtpRecipientEntry = widget.NewEntry()
	a.smtpRecipientEntry.SetText(a.settings.SMTPRecipient)
	a.smtpPasswordEntry = widget.NewPasswordEntry()
	a.smtpPasswordEntry.SetPlaceHolder("Session only, never saved")
	a.smtpSkipVerify = widget.NewCheck("Do not verify SMTP TLS certificate (unsafe)", nil)
	a.smtpSkipVerify.SetChecked(a.settings.SMTPSkipVerify)
	a.displayEntry = widget.NewEntry()
	a.displayEntry.SetText(a.settings.DisplayName)
	a.emailEntry = widget.NewEntry()
	a.emailEntry.SetText(a.settings.Email)
	a.filterField = widget.NewSelect([]string{"subject", "from", "newsgroups", "message-id", "references", "date", "body", "header", "vface-hash", "read", "any"}, nil)
	a.filterField.SetSelected("subject")
	a.filterOperator = widget.NewSelect([]string{"contains", "exact", "regexp", "glob"}, nil)
	a.filterOperator.SetSelected("contains")
	a.filterPattern = widget.NewEntry()
	a.filterPattern.SetPlaceHolder("es. [spam], example.org, <user@...>")
	a.filterAction = widget.NewSelect([]string{"hide", "mark-read", "highlight", "tag", "mute-thread", "keep"}, nil)
	a.filterAction.SetSelected("hide")
	a.filterTag = widget.NewEntry()
	a.filterTag.SetPlaceHolder("Tag, se l'azione è tag")
	save := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), a.saveSettings)
	form := widget.NewForm(
		widget.NewFormItem("NNTP host", a.hostEntry),
		widget.NewFormItem("Port", a.portEntry),
		widget.NewFormItem("Transport", container.NewVBox(a.tlsCheck, a.startTLSCheck, a.tlsSkipVerify, a.compressionCheck)),
		widget.NewFormItem("NNTP username", a.usernameEntry),
		widget.NewFormItem("NNTP password", a.passwordEntry),
		widget.NewFormItem("SASL", a.saslSelect),
		widget.NewFormItem("Proxy", a.proxySelect),
		widget.NewFormItem("SOCKS5 address", a.proxyEntry),
		widget.NewFormItem("SMTP mail2news host", a.smtpHostEntry),
		widget.NewFormItem("SMTP port", a.smtpPortEntry),
		widget.NewFormItem("SMTP transport", a.smtpModeSelect),
		widget.NewFormItem("SMTP username", a.smtpUserEntry),
		widget.NewFormItem("SMTP email", a.smtpEmailEntry),
		widget.NewFormItem("Default To", a.smtpRecipientEntry),
		widget.NewFormItem("SMTP password", a.smtpPasswordEntry),
		widget.NewFormItem("SMTP TLS", a.smtpSkipVerify),
		widget.NewFormItem("NNTP display name", a.displayEntry),
		widget.NewFormItem("NNTP email", a.emailEntry),
	)
	filters := widget.NewButton("Filters", a.showFilterEditor)
	content := container.NewVBox(form, filters, save)
	return container.NewVScroll(content)
}

func (a *application) readSettingsForm() config.Settings {
	settings := a.settings
	settings.Host = strings.TrimSpace(a.hostEntry.Text)
	settings.Port = strings.TrimSpace(a.portEntry.Text)
	settings.UseTLS = a.tlsCheck.Checked
	settings.StartTLS = a.startTLSCheck.Checked
	settings.SkipTLSVerify = a.tlsSkipVerify.Checked
	settings.Username = strings.TrimSpace(a.usernameEntry.Text)
	if a.saslSelect.Selected == "PLAIN" {
		settings.SASLMechanism = "PLAIN"
	} else {
		settings.SASLMechanism = ""
	}
	settings.UseCompression = a.compressionCheck.Checked
	settings.ProxyType = a.proxySelect.Selected
	settings.ProxyAddress = strings.TrimSpace(a.proxyEntry.Text)
	settings.SMTPHost = strings.TrimSpace(a.smtpHostEntry.Text)
	settings.SMTPPort = strings.TrimSpace(a.smtpPortEntry.Text)
	if a.smtpModeSelect.Selected == "TLS" || a.smtpModeSelect.Selected == "STARTTLS" {
		settings.SMTPMode = a.smtpModeSelect.Selected
	} else {
		settings.SMTPMode = ""
	}
	settings.SMTPUsername = strings.TrimSpace(a.smtpUserEntry.Text)
	settings.SMTPEmail = strings.TrimSpace(a.smtpEmailEntry.Text)
	settings.SMTPRecipient = strings.TrimSpace(a.smtpRecipientEntry.Text)
	settings.SMTPSkipVerify = a.smtpSkipVerify.Checked
	settings.DisplayName = strings.TrimSpace(a.displayEntry.Text)
	settings.Email = strings.TrimSpace(a.emailEntry.Text)
	return settings
}

func (a *application) saveSettings() {
	settings := a.readSettingsForm()
	if err := settings.Validate(); err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	if a.configPath == "" {
		dialog.ShowError(errors.New("configuration path is unavailable"), a.window)
		return
	}
	if err := config.Save(a.configPath, settings); err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	a.settings = settings
	a.updateComposeIdentity()
	a.status.SetText("Settings saved. Password retained only for this session.")
}

func (a *application) addFilterRule() {
	if a.state == nil {
		dialog.ShowError(errors.New("local state is unavailable"), a.window)
		return
	}
	rule := filter.Rule{
		ID: fmt.Sprintf("rule-%d", time.Now().UnixNano()), Enabled: true,
		Field: filter.Field(a.filterField.Selected), Operator: filter.Operator(a.filterOperator.Selected),
		Pattern: strings.TrimSpace(a.filterPattern.Text), Action: filter.Action(a.filterAction.Selected), Tag: strings.TrimSpace(a.filterTag.Text),
	}
	if _, err := filter.Evaluate(filter.Article{Headers: map[string]string{}}, []filter.Rule{rule}); err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	a.filterRules = append(a.filterRules, rule)
	a.state.SetFilters(a.filterRules)
	if err := a.state.Save(); err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	a.filterPattern.SetText("")
	a.filterTag.SetText("")
	a.filterHeaders()
	a.status.SetText("Filtro locale aggiunto.")
}

func (a *application) showFilterEditor() {
	form := widget.NewForm(
		widget.NewFormItem("Field", a.filterField),
		widget.NewFormItem("Operator", a.filterOperator),
		widget.NewFormItem("Pattern", a.filterPattern),
		widget.NewFormItem("Action", a.filterAction),
		widget.NewFormItem("Tag", a.filterTag),
	)
	note := widget.NewLabel("Local filter only. It never deletes or rewrites server articles.")
	note.Wrapping = fyne.TextWrapWord
	content := container.NewVBox(note, form)
	dialog.ShowCustomConfirm("Filters", "Add filter", "Close", content, func(confirmed bool) {
		if confirmed {
			a.addFilterRule()
		}
	}, a.window)
}

func (a *application) connectServer() {
	settings := a.readSettingsForm()
	if err := settings.Validate(); err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	password := a.passwordEntry.Text
	a.settings = settings
	a.setBusy(true, "Connecting securely to "+settings.Host+"...")
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
		defer cancel()
		client, err := nntp.Dial(ctx, nntp.DialConfig{
			Host:               settings.Host,
			Port:               settings.Port,
			UseTLS:             settings.UseTLS,
			StartTLS:           settings.StartTLS,
			InsecureSkipVerify: settings.SkipTLSVerify,
			Username:           settings.Username,
			Password:           password,
			SASLMechanism:      settings.SASLMechanism,
			UseCompression:     settings.UseCompression,
			ProxyType:          settings.ProxyType,
			ProxyAddress:       settings.ProxyAddress,
		})
		if err != nil {
			a.asyncError("Connection failed", err)
			return
		}
		groups, err := client.ListActive()
		if err != nil {
			client.Close()
			a.asyncError("Connected, but LIST ACTIVE failed", err)
			return
		}
		a.replaceClient(client)
		fyne.Do(func() {
			a.groups = groups
			a.filterGroups()
			a.connect.Disable()
			a.disconnect.Enable()
			a.refresh.Enable()
			a.setBusy(false, fmt.Sprintf("Connected. %s available newsgroups loaded.", formatCount(int64(len(groups)))))
		})
	}()
}

func (a *application) disconnectServer() {
	a.closeClient()
	a.groups = nil
	a.visibleGroups = nil
	a.headers = nil
	a.visibleHeader = nil
	a.articleText = ""
	a.selectedGroup = ""
	a.loadedGroup = ""
	a.groupList.Refresh()
	a.headerList.Refresh()
	a.body.SetText("")
	a.articleHeaders.SetText("No article selected.")
	a.connect.Enable()
	a.disconnect.Disable()
	a.refresh.Disable()
	a.status.SetText("Disconnected.")
}

func (a *application) refreshGroups() {
	client := a.currentClient()
	if client == nil {
		dialog.ShowError(errors.New("connect to a server first"), a.window)
		return
	}
	a.setBusy(true, "Refreshing available newsgroups...")
	go func() {
		groups, err := client.ListActive()
		if err != nil {
			a.asyncError("Cannot refresh groups", err)
			return
		}
		fyne.Do(func() {
			a.groups = groups
			a.filterGroups()
			a.setBusy(false, fmt.Sprintf("%s available newsgroups loaded.", formatCount(int64(len(groups)))))
		})
	}()
}

func (a *application) subscribeSelected() {
	if a.selectedGroup == "" {
		dialog.ShowError(errors.New("select a newsgroup first"), a.window)
		return
	}
	if !a.isSubscribed(a.selectedGroup) {
		a.settings.Subscriptions = append(a.settings.Subscriptions, a.selectedGroup)
		sort.Strings(a.settings.Subscriptions)
		if err := a.persistSubscriptions(); err != nil {
			dialog.ShowError(err, a.window)
			return
		}
	}
	a.composeGroups.SetText(a.selectedGroup)
	a.groupList.Refresh()
	a.status.SetText("Subscribed to " + a.selectedGroup + ".")
}

func (a *application) unsubscribeSelected() {
	if a.selectedGroup == "" {
		dialog.ShowError(errors.New("select a newsgroup first"), a.window)
		return
	}
	groups := a.settings.Subscriptions[:0]
	for _, group := range a.settings.Subscriptions {
		if group != a.selectedGroup {
			groups = append(groups, group)
		}
	}
	a.settings.Subscriptions = groups
	if err := a.persistSubscriptions(); err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	a.groupList.Refresh()
	a.status.SetText("Unsubscribed from " + a.selectedGroup + ".")
}

func (a *application) persistSubscriptions() error {
	if a.configPath == "" {
		return errors.New("configuration path is unavailable")
	}
	settings := a.readSettingsForm()
	settings.Subscriptions = append([]string(nil), a.settings.Subscriptions...)
	if err := config.Save(a.configPath, settings); err != nil {
		return err
	}
	a.settings = settings
	return nil
}

func (a *application) loadSelectedGroup() {
	group := a.selectedGroup
	if group == "" {
		dialog.ShowError(errors.New("select a newsgroup first"), a.window)
		return
	}
	if !a.isSubscribed(group) {
		dialog.ShowError(errors.New("subscribe to the newsgroup before loading its articles"), a.window)
		return
	}
	client := a.currentClient()
	if client == nil {
		dialog.ShowError(errors.New("connect to a server first"), a.window)
		return
	}
	a.setBusy(true, "Loading recent headers from "+group+"...")
	go func() {
		status, headers, err := client.LatestOverview(group, overviewLimit)
		if err != nil {
			a.asyncError("Cannot load article overview", err)
			return
		}
		fyne.Do(func() {
			a.loadedGroup = group
			a.headers = headers
			if a.state != nil {
				for _, header := range headers {
					_ = a.state.UpsertArticle(store.Article{Key: articleKey(group, header), Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID})
				}
				_ = a.state.Save()
			}
			a.filterHeaders()
			a.body.SetText("")
			a.composeGroups.SetText(group)
			a.setBusy(false, fmt.Sprintf("%s: loaded %d recent headers, server reports %d articles.", group, len(headers), status.Count))
		})
	}()
}

func (a *application) loadArticle(group string, header nntp.ArticleHeader) {
	client := a.currentClient()
	if client == nil || group == "" {
		return
	}
	a.setBusy(true, fmt.Sprintf("Downloading article %d from %s...", header.Number, group))
	go func() {
		article, err := client.ArticleInGroup(group, header.Number)
		if err != nil {
			a.asyncError("Cannot download article", err)
			return
		}
		fyne.Do(func() {
			a.body.SetText(article)
			a.articleText = article
			if a.state != nil {
				key := articleKey(group, header)
				_ = a.state.UpsertArticle(store.Article{Key: key, Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID, Raw: article})
				_ = a.state.SetRead(key, true)
				_ = a.state.Save()
			}
			a.refreshArticleHeaders()
			a.refreshArticleActions()
			a.headerList.Refresh()
			a.setBusy(false, fmt.Sprintf("Article %d downloaded from %s.", header.Number, group))
		})
	}()
}

func (a *application) replyToSelected() {
	if !a.hasSelectedHeader || strings.TrimSpace(a.articleText) == "" {
		return
	}
	message, err := mail.ReadMessage(strings.NewReader(a.articleText))
	if err != nil {
		dialog.ShowError(fmt.Errorf("cannot parse selected article: %w", err), a.window)
		return
	}
	body, err := io.ReadAll(io.LimitReader(message.Body, 2<<20))
	if err != nil {
		dialog.ShowError(fmt.Errorf("cannot read selected article: %w", err), a.window)
		return
	}
	groups := strings.TrimSpace(message.Header.Get("Followup-To"))
	if groups == "" || strings.EqualFold(groups, "poster") {
		groups = strings.TrimSpace(message.Header.Get("Newsgroups"))
	}
	a.composeGroups.SetText(groups)
	subject := strings.TrimSpace(message.Header.Get("Subject"))
	if !strings.HasPrefix(strings.ToLower(subject), "re:") {
		subject = "Re: " + subject
	}
	a.composeSubject.SetText(subject)
	references := strings.TrimSpace(message.Header.Get("References"))
	messageID := strings.TrimSpace(message.Header.Get("Message-ID"))
	if messageID != "" {
		if references != "" {
			references += " "
		}
		references += messageID
	}
	a.composeReferences.SetText(references)
	a.composeFollowupTo.SetText(strings.TrimSpace(message.Header.Get("Followup-To")))
	a.composeBody.SetText(quoteBody(string(body), message.Header.Get("From")))
	a.status.SetText("Reply preparata con quoting e References. Controlla Followup-To prima dell'invio.")
}

func quoteBody(body, from string) string {
	body = normalizeCRLF(body)
	lines := strings.Split(strings.TrimSuffix(body, "\r\n"), "\r\n")
	var builder strings.Builder
	if strings.TrimSpace(from) != "" {
		builder.WriteString("On behalf of ")
		builder.WriteString(strings.TrimSpace(from))
		builder.WriteString(" wrote:\r\n")
	}
	for _, line := range lines {
		builder.WriteString("> ")
		builder.WriteString(line)
		builder.WriteString("\r\n")
	}
	return builder.String()
}

func (a *application) refreshArticleHeaders() {
	if a.articleHeaders == nil {
		return
	}
	text := formatArticleHeaders(a.articleText, a.showAllHeaders != nil && a.showAllHeaders.Checked)
	verification := identity.VerifyArticle(a.articleText)
	if verification.SignaturePresent || verification.PublicKey != "" {
		status := "VFace invalid"
		if verification.VFaceValid {
			status = "VFace valid"
		}
		if verification.SignaturePresent {
			if verification.SignatureValid {
				status += "; Ed25519 signature valid"
			} else {
				status += "; Ed25519 signature invalid"
			}
		}
		text += "\n\nVerification: " + status
		if verification.Error != nil {
			text += " (" + verification.Error.Error() + ")"
		}
	}
	a.articleHeaders.SetText(text)
}

func formatArticleHeaders(article string, showAll bool) string {
	if strings.TrimSpace(article) == "" {
		return "No article selected."
	}
	if showAll {
		raw := articleHeaderBlock(article)
		if raw != "" {
			return raw
		}
	}
	message, err := mail.ReadMessage(strings.NewReader(article))
	if err != nil {
		raw := articleHeaderBlock(article)
		if raw == "" {
			return "The article headers could not be parsed."
		}
		return raw
	}
	important := []string{
		"From", "To", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To",
		"Reply-To", "Organization", "User-Agent", "MIME-Version", "Content-Type",
		"Content-Transfer-Encoding", "Face", "X-Signature",
		"X-Aegis-Signature", "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint",
		"X-VFace-Version", "X-Ed25519-Pub", "X-Ed25519-Sig", "Identity-Hash",
		"X-VFace-Hash", "X-VFace-PNG-SHA256", "X-VFace-Verify",
	}
	var lines []string
	for _, name := range important {
		if value := strings.TrimSpace(message.Header.Get(name)); value != "" {
			lines = append(lines, name+": "+value)
		}
	}
	if len(lines) == 0 {
		return "No recognized headers in this article."
	}
	return strings.Join(lines, "\n")
}

func articleHeaderBlock(article string) string {
	article = strings.ReplaceAll(article, "\r\n", "\n")
	article = strings.ReplaceAll(article, "\r", "\n")
	if separator := strings.Index(article, "\n\n"); separator >= 0 {
		return strings.TrimSpace(article[:separator])
	}
	return ""
}

func (a *application) postArticle() {
	delivery := a.composeDelivery.Selected
	settings := a.readSettingsForm()
	if settings.SMTPHost == "" {
		delivery = "NNTP direct posting"
	}
	client := a.currentClient()
	if delivery != "SMTP mail2news" && client == nil {
		dialog.ShowError(errors.New("connect to a server first"), a.window)
		return
	}
	if err := settings.Validate(); err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	groups, err := normalizeGroups(a.composeGroups.Text)
	if err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	from := formatFrom(settings.DisplayName, settings.Email)
	identityHeaders := []string(nil)
	expectedPublicKey := ""
	signingKey := strings.TrimSpace(a.composeCryptoSigningKey.Text)
	if a.vfaceProfile != nil {
		profile, profileErr := identity.GenerateVFace(a.vfaceProfile.Username, a.vfaceProfile.Email, a.vfaceProfile.PublicKey)
		if profileErr != nil {
			dialog.ShowError(fmt.Errorf("invalid loaded VFace identity: %w", profileErr), a.window)
			return
		}
		from = formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email)
		identityHeaders = profile.Headers()
		expectedPublicKey = profile.PublicKey
		if signingKey == "" {
			signingKey = a.vfaceProfile.PrivateKey
		}
	}
	subject := strings.TrimSpace(a.composeSubject.Text)
	if from == "" || subject == "" || strings.ContainsAny(from+subject, "\r\n") {
		dialog.ShowError(errors.New("From and Subject are required and must be one line"), a.window)
		return
	}
	if _, err := mail.ParseAddress(from); err != nil {
		dialog.ShowError(fmt.Errorf("invalid From address: %w", err), a.window)
		return
	}
	var recipients []string
	to := strings.TrimSpace(a.composeTo.Text)
	if delivery == "SMTP mail2news" {
		if to == "" {
			to = strings.TrimSpace(settings.SMTPRecipient)
		}
		if to == "" {
			dialog.ShowError(errors.New("To address is required for SMTP delivery"), a.window)
			return
		}
		if _, err := mail.ParseAddress(to); err != nil {
			dialog.ShowError(fmt.Errorf("invalid To address: %w", err), a.window)
			return
		}
		recipients = []string{to}
	} else if to != "" {
		if _, err := mail.ParseAddress(to); err != nil {
			dialog.ShowError(fmt.Errorf("invalid To address: %w", err), a.window)
			return
		}
	}
	article, err := buildArticleWithIdentityHeaders(groups, from, subject, a.composeBody.Text, identityHeaders, articleCryptoOptions{
		Mode:              a.composeCryptoMode.Selected,
		To:                to,
		SigningKey:        signingKey,
		ExpectedPublicKey: expectedPublicKey,
		References:        strings.TrimSpace(a.composeReferences.Text),
		FollowupTo:        strings.TrimSpace(a.composeFollowupTo.Text),
	})
	if err != nil {
		dialog.ShowError(err, a.window)
		return
	}
	a.setBusy(true, "Posting article...")
	go func() {
		var postErr error
		if delivery == "SMTP mail2news" {
			smtpFrom := strings.TrimSpace(settings.SMTPEmail)
			if smtpFrom == "" {
				smtpFrom = from
			}
			postErr = smtpclient.Send(smtpclient.Config{
				Host: settings.SMTPHost, Port: settings.SMTPPort, Mode: settings.SMTPMode,
				Username: settings.SMTPUsername, Password: a.smtpPasswordEntry.Text,
				InsecureSkipVerify: settings.SMTPSkipVerify,
				ProxyType:          settings.ProxyType, ProxyAddress: settings.ProxyAddress,
			}, smtpFrom, recipients, []byte(article))
		} else {
			postErr = client.Post(article)
		}
		if postErr != nil {
			a.asyncError("Posting failed", postErr)
			return
		}
		fyne.Do(func() {
			a.composeSubject.SetText("")
			a.composeBody.SetText("")
			a.composeReferences.SetText("")
			a.composeFollowupTo.SetText("")
			a.composeTo.SetText(settings.SMTPRecipient)
			a.composeCryptoSigningKey.SetText("")
			a.composeCryptoMode.SetSelected("Plain")
			a.setBusy(false, "Article accepted by the NNTP server.")
			dialog.ShowInformation("Article posted", "The NNTP server accepted the article.", a.window)
		})
	}()
}

func buildTextArticle(groups []string, from, subject, body string) (string, error) {
	return buildTextArticleWithCrypto(groups, from, subject, body, articleCryptoOptions{Mode: "Plain"})
}

type articleCryptoOptions struct {
	Mode              string
	To                string
	SigningKey        string
	ExpectedPublicKey string
	References        string
	FollowupTo        string
}

func buildTextArticleWithCrypto(groups []string, from, subject, body string, options articleCryptoOptions) (string, error) {
	face, err := identity.GenerateFace(from)
	if err != nil {
		return "", fmt.Errorf("generate Face header: %w", err)
	}
	return buildArticleWithIdentityHeaders(groups, from, subject, body, []string{identity.FormatFaceHeader(face)}, options)
}

func buildTextArticleWithVFace(groups []string, from, subject, body string, profile identity.VFace, options articleCryptoOptions) (string, error) {
	if profile.IdentityHash == "" {
		return buildArticleWithIdentityHeaders(groups, from, subject, body, nil, options)
	}
	return buildArticleWithIdentityHeaders(groups, from, subject, body, profile.Headers(), options)
}

func buildArticleWithIdentityHeaders(groups []string, from, subject, body string, identityHeaders []string, options articleCryptoOptions) (string, error) {
	fromHeader, err := formatFromHeader(from)
	if err != nil {
		return "", err
	}
	if subject == "" || strings.ContainsAny(subject, "\r\n") {
		return "", errors.New("Subject is required and must be one line")
	}
	if !utf8.ValidString(body) {
		return "", errors.New("article body is not valid UTF-8")
	}
	messageID, err := generateMessageID()
	if err != nil {
		return "", err
	}
	body = strings.ReplaceAll(body, "\r\n", "\n")
	body = strings.ReplaceAll(body, "\r", "\n")
	body = strings.ReplaceAll(body, "\n", "\r\n")
	mode := strings.TrimSpace(options.Mode)
	if mode == "" {
		mode = "Plain"
	}
	cryptoHeaders, contentType, contentTransferEncoding, wireBody, err := prepareArticleCrypto(body, mode, options)
	if err != nil {
		return "", err
	}
	headers := []string{
		"From: " + fromHeader,
		"Message-ID: " + messageID,
		"Newsgroups: " + strings.Join(groups, ","),
		"Subject: " + mime.QEncoding.Encode("UTF-8", subject),
		"Date: " + time.Now().Format(time.RFC1123Z),
		"User-Agent: Aegis/0.1",
		"MIME-Version: 1.0",
		"Content-Type: " + contentType,
		"Content-Transfer-Encoding: " + contentTransferEncoding,
	}
	if to := strings.TrimSpace(options.To); to != "" {
		if strings.ContainsAny(to, "\r\n") {
			return "", errors.New("To must not contain line breaks")
		}
		if _, err := mail.ParseAddress(to); err != nil {
			return "", fmt.Errorf("invalid To address: %w", err)
		}
		headers = append(headers, foldHeader("To", to))
	}
	if options.References != "" {
		if strings.ContainsAny(options.References, "\r\n") {
			return "", errors.New("References must not contain line breaks")
		}
		headers = append(headers, foldHeader("References", options.References))
	}
	if options.FollowupTo != "" {
		if !validFollowupTo(options.FollowupTo) {
			return "", errors.New("Followup-To must be poster or valid newsgroup names")
		}
		headers = append(headers, foldHeader("Followup-To", options.FollowupTo))
	}
	headers = append(headers, identityHeaders...)
	headers = append(headers, cryptoHeaders...)
	article := strings.Join([]string{
		strings.Join(headers, "\r\n"),
		"",
		wireBody,
	}, "\r\n")
	if err := nntp.ValidateArticle(article); err != nil {
		return "", fmt.Errorf("article format is not Usenet-safe: %w", err)
	}
	return article, nil
}

func validFollowupTo(value string) bool {
	if strings.EqualFold(strings.TrimSpace(value), "poster") {
		return true
	}
	_, err := normalizeGroups(value)
	return err == nil
}

func generateMessageID() (string, error) {
	randomPart := make([]byte, 16)
	if _, err := rand.Read(randomPart); err != nil {
		return "", fmt.Errorf("generate Message-ID: %w", err)
	}
	return "<" + hex.EncodeToString(randomPart) + "@" + messageIDDomain + ">", nil
}

func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (headers []string, contentType, transferEncoding, wireBody string, err error) {
	wireBody = body
	contentType = "text/plain; charset=UTF-8"
	transferEncoding = "8bit"
	signingKey := strings.TrimSpace(options.SigningKey)

	switch mode {
	case "Plain":
	case "Sign with Ed25519":
		if signingKey == "" {
			return nil, "", "", "", errors.New("an Ed25519 private key is required for signing")
		}
	default:
		return nil, "", "", "", fmt.Errorf("unsupported article signing mode %q", mode)
	}

	if mode == "Sign with Ed25519" {
		if signingKey == "" {
			return nil, "", "", "", errors.New("an Ed25519 private key is required for signing")
		}
		signature, signErr := cryptokit.SignEd25519([]byte(wireBody), signingKey)
		if signErr != nil {
			return nil, "", "", "", fmt.Errorf("sign article body: %w", signErr)
		}
		publicKey, publicErr := cryptokit.Ed25519PublicKey(signingKey)
		if publicErr != nil {
			return nil, "", "", "", fmt.Errorf("derive Ed25519 public key: %w", publicErr)
		}
		fingerprint, fingerprintErr := cryptokit.Ed25519PublicKeyFingerprint(publicKey)
		if fingerprintErr != nil {
			return nil, "", "", "", fmt.Errorf("fingerprint Ed25519 public key: %w", fingerprintErr)
		}
		if options.ExpectedPublicKey != "" {
			expectedKey, expectedErr := cryptokit.CanonicalEd25519PublicKey(options.ExpectedPublicKey)
			if expectedErr != nil {
				return nil, "", "", "", fmt.Errorf("canonicalize profile Ed25519 public key: %w", expectedErr)
			}
			if publicKey != expectedKey {
				return nil, "", "", "", errors.New("signing key does not match the VFace profile public key")
			}
		}
		headers = append(headers,
			"X-Aegis-Crypto-Version: 1",
			"X-Aegis-Signature: ed25519; "+signature,
			"X-Aegis-Public-Key: "+publicKey,
			"X-Ed25519-Sig: "+signature,
			"X-Aegis-Key-Fingerprint: "+fingerprint,
		)
	}
	return headers, contentType, transferEncoding, wireBody, nil
}

func normalizeCRLF(value string) string {
	value = strings.ReplaceAll(value, "\r\n", "\n")
	value = strings.ReplaceAll(value, "\r", "\n")
	return strings.ReplaceAll(value, "\n", "\r\n")
}

func foldHeader(name, value string) string {
	const maxHeaderValue = 76
	if len(name)+2+len(value) <= 998 {
		return name + ": " + value
	}
	var out strings.Builder
	out.WriteString(name)
	out.WriteString(":")
	for len(value) > 0 {
		out.WriteString("\r\n ")
		limit := maxHeaderValue
		if len(value) < limit {
			limit = len(value)
		}
		out.WriteString(value[:limit])
		value = value[limit:]
	}
	return out.String()
}

func formatFromHeader(value string) (string, error) {
	address, err := mail.ParseAddress(strings.TrimSpace(value))
	if err != nil {
		return "", fmt.Errorf("invalid From address: %w", err)
	}
	if address.Name == "" {
		return address.Address, nil
	}
	if isASCII(address.Name) {
		return (&mail.Address{Name: address.Name, Address: address.Address}).String(), nil
	}
	return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">", nil
}

func isASCII(value string) bool {
	for i := 0; i < len(value); i++ {
		if value[i] > 0x7f {
			return false
		}
	}
	return true
}

func (a *application) filterGroups() {
	query := strings.ToLower(strings.TrimSpace(a.groupSearch.Text))
	a.visibleGroups = a.visibleGroups[:0]
	for _, group := range a.groups {
		if query == "" || strings.Contains(strings.ToLower(group.Name), query) {
			a.visibleGroups = append(a.visibleGroups, group)
		}
	}
	if a.groupList != nil {
		a.groupList.Refresh()
	}
}

func (a *application) filterHeaders() {
	query := strings.ToLower(strings.TrimSpace(a.headerSearch.Text))
	a.visibleHeader = a.visibleHeader[:0]
	for _, header := range a.headers {
		if query != "" && !strings.Contains(strings.ToLower(header.Subject), query) && !strings.Contains(strings.ToLower(header.From), query) && !strings.Contains(strings.ToLower(header.MessageID), query) && !strings.Contains(strings.ToLower(header.References), query) {
			continue
		}
		article := filter.Article{From: header.From, Subject: header.Subject, Newsgroups: a.loadedGroup, MessageID: header.MessageID, References: header.References, Read: a.headerRead(header)}
		result, err := filter.Evaluate(article, a.filterRules)
		if err != nil {
			a.status.SetText("Filter error: " + err.Error())
			continue
		}
		if result.Hidden || result.MuteThread {
			continue
		}
		a.visibleHeader = append(a.visibleHeader, header)
	}
	if a.headerList != nil {
		a.headerList.Refresh()
	}
}

func articleKey(group string, header nntp.ArticleHeader) string {
	if header.MessageID != "" {
		return group + ":" + header.MessageID
	}
	return fmt.Sprintf("%s:%d", group, header.Number)
}

func (a *application) headerRead(header nntp.ArticleHeader) bool {
	if a.state == nil {
		return false
	}
	article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)]
	return ok && article.Read
}

func (a *application) headerBookmarked(header nntp.ArticleHeader) bool {
	if a.state == nil {
		return false
	}
	article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)]
	return ok && article.Bookmarked
}

func (a *application) refreshArticleActions() {
	if a.markRead == nil || a.bookmark == nil || a.reply == nil {
		return
	}
	if !a.hasSelectedHeader {
		a.markRead.Disable()
		a.bookmark.Disable()
		a.reply.Disable()
		return
	}
	a.markRead.Enable()
	a.bookmark.Enable()
	if strings.TrimSpace(a.articleText) == "" {
		a.reply.Disable()
	} else {
		a.reply.Enable()
	}
	if a.headerRead(a.selectedHeader) {
		a.markRead.SetText("Mark unread")
	} else {
		a.markRead.SetText("Mark read")
	}
	if a.headerBookmarked(a.selectedHeader) {
		a.bookmark.SetText("Remove bookmark")
	} else {
		a.bookmark.SetText("Bookmark")
	}
}

func (a *application) toggleRead() {
	if a.state == nil || !a.hasSelectedHeader {
		return
	}
	key := articleKey(a.loadedGroup, a.selectedHeader)
	_ = a.state.SetRead(key, !a.headerRead(a.selectedHeader))
	_ = a.state.Save()
	a.refreshArticleActions()
	a.headerList.Refresh()
}

func (a *application) toggleBookmark() {
	if a.state == nil || !a.hasSelectedHeader {
		return
	}
	key := articleKey(a.loadedGroup, a.selectedHeader)
	_ = a.state.SetBookmarked(key, !a.headerBookmarked(a.selectedHeader))
	_ = a.state.Save()
	a.refreshArticleActions()
	a.headerList.Refresh()
}

func (a *application) isSubscribed(group string) bool {
	for _, subscribed := range a.settings.Subscriptions {
		if subscribed == group {
			return true
		}
	}
	return false
}

func (a *application) setBusy(busy bool, message string) {
	if busy {
		a.progress.Show()
	} else {
		a.progress.Hide()
	}
	a.status.SetText(message)
}

func (a *application) asyncError(title string, err error) {
	fyne.Do(func() {
		a.setBusy(false, title+".")
		dialog.ShowError(fmt.Errorf("%s: %w", title, err), a.window)
	})
}

func (a *application) currentClient() *nntp.Client {
	a.clientMu.RLock()
	defer a.clientMu.RUnlock()
	return a.client
}

func (a *application) replaceClient(client *nntp.Client) {
	a.clientMu.Lock()
	old := a.client
	a.client = client
	a.clientMu.Unlock()
	if old != nil {
		_ = old.Close()
	}
}

func (a *application) closeClient() {
	a.clientMu.Lock()
	client := a.client
	a.client = nil
	a.clientMu.Unlock()
	if client != nil {
		_ = client.Close()
	}
}

func normalizeGroups(value string) ([]string, error) {
	parts := strings.Split(value, ",")
	groups := make([]string, 0, len(parts))
	seen := make(map[string]struct{}, len(parts))
	for _, part := range parts {
		group := strings.TrimSpace(part)
		if group == "" {
			continue
		}
		if err := config.ValidateGroupName(group); err != nil {
			return nil, err
		}
		if _, ok := seen[group]; ok {
			continue
		}
		seen[group] = struct{}{}
		groups = append(groups, group)
	}
	if len(groups) == 0 {
		return nil, errors.New("at least one newsgroup is required")
	}
	return groups, nil
}

func formatFrom(name, email string) string {
	if email == "" {
		return ""
	}
	return (&mail.Address{Name: name, Address: email}).String()
}

func formatCount(value int64) string {
	if value < 1000 {
		return strconv.FormatInt(value, 10)
	}
	parts := make([]string, 0, 4)
	for value > 0 {
		part := value % 1000
		value /= 1000
		if value > 0 {
			parts = append(parts, fmt.Sprintf("%03d", part))
		} else {
			parts = append(parts, strconv.FormatInt(part, 10))
		}
	}
	for left, right := 0, len(parts)-1; left < right; left, right = left+1, right-1 {
		parts[left], parts[right] = parts[right], parts[left]
	}
	return strings.Join(parts, " ")
}
