package ui

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"image"
	"io"
	"mime"
	"net/mail"
	"net/url"
	"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
	composeMail2News        *widget.Entry
	composeFrom             *widget.Entry
	composeSubject          *widget.Entry
	composeReferences       *widget.Entry
	composeFollowupTo       *widget.Entry
	composeBody             *widget.Entry
	composeCryptoMode       *widget.Select
	composeCryptoKey        *widget.Entry
	composeCryptoSigningKey *widget.Entry
	composeCryptoKeyURL     *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
}

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.composeMail2News = widget.NewEntry()
	a.composeMail2News.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))
	a.composeFrom.Disable()
	a.composeCryptoMode = widget.NewSelect([]string{
		"Plain",
		"Sign with Ed25519",
		"Encrypt with age",
		"Encrypt with age and sign",
	}, nil)
	a.composeCryptoMode.SetSelected("Plain")
	a.composeCryptoKey = widget.NewMultiLineEntry()
	a.composeCryptoKey.SetPlaceHolder("age recipient (native X25519 or SSH Ed25519/RSA)")
	a.composeCryptoKey.Wrapping = fyne.TextWrapOff
	a.composeCryptoSigningKey = widget.NewMultiLineEntry()
	a.composeCryptoSigningKey.SetPlaceHolder("Ed25519 private key, base64 or hexadecimal")
	a.composeCryptoSigningKey.Wrapping = fyne.TextWrapOff
	a.composeCryptoKeyURL = widget.NewEntry()
	a.composeCryptoKeyURL.SetPlaceHolder("Optional HTTPS URL for the public key")
	post := widget.NewButtonWithIcon("Post article", theme.MailSendIcon(), a.postArticle)
	form := widget.NewForm(
		widget.NewFormItem("Newsgroups", a.composeGroups),
		widget.NewFormItem("Delivery", a.composeDelivery),
		widget.NewFormItem("mail2news recipient", a.composeMail2News),
		widget.NewFormItem("From", a.composeFrom),
		widget.NewFormItem("Subject", a.composeSubject),
		widget.NewFormItem("References", a.composeReferences),
		widget.NewFormItem("Followup-To", a.composeFollowupTo),
		widget.NewFormItem("Crypto mode", a.composeCryptoMode),
		widget.NewFormItem("Age recipient", a.composeCryptoKey),
		widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey),
		widget.NewFormItem("Public-key URL", a.composeCryptoKeyURL),
	)
	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.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("Crypto providers: age for software encryption. OpenPGP is available only through a YubiKey integration and is not exposed as a standalone format.")
	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,
		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.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.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.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)
}

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{"OpenPGP", "age", "Ed25519", "YubiCrypt"}, nil)
	a.cryptoAlgorithm.SetSelected("OpenPGP")
	a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify", "Encrypt", "Decrypt"}, nil)
	a.cryptoOperation.SetSelected("Sign")
	a.cryptoMessage = widget.NewMultiLineEntry()
	a.cryptoMessage.SetPlaceHolder("Message or ciphertext")
	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("Aegis does not generate or save keys. Key fields are used only for this session. 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 / ciphertext"), 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")
		}
	case "Encrypt":
		if algorithm == "OpenPGP" {
			a.cryptoPrimaryLabel.SetText("Recipient public key")
			a.cryptoSecondaryLabel.SetText("Optional signer private key")
		} else if algorithm == "YubiCrypt" {
			a.cryptoPrimaryLabel.SetText("RSA recipient certificate/key (PEM)")
			a.cryptoSecondaryBox.Hide()
		} else {
			a.cryptoPrimaryLabel.SetText("Recipient key")
			a.cryptoSecondaryLabel.SetText("Not used")
		}
	case "Decrypt":
		if algorithm == "YubiCrypt" {
			a.cryptoPrimaryBox.Hide()
			a.cryptoSecretBox.Show()
			a.cryptoSecretLabel.SetText("YubiKey PIV PIN")
			a.cryptoSecondaryBox.Hide()
		} else {
			a.cryptoPrimaryLabel.SetText("Private key / identity")
			a.cryptoSecondaryLabel.SetText("Not used")
		}
	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" || (operation == "Encrypt" && algorithm == "OpenPGP") {
		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 or ciphertext is required"), a.window)
		return
	}
	if algorithm == "YubiCrypt" && operation != "Verify" && operation != "Encrypt" && strings.TrimSpace(secret) == "" {
		dialog.ShowError(errors.New("YubiKey PIV PIN is required"), a.window)
		return
	}
	if algorithm == "YubiCrypt" && operation == "Encrypt" && strings.TrimSpace(primary) == "" {
		dialog.ShowError(errors.New("RSA recipient certificate/key 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 "OpenPGP":
			switch operation {
			case "Sign":
				result, err = cryptokit.SignOpenPGPDetached(message, primary)
			case "Verify":
				err = cryptokit.VerifyOpenPGPDetached(message, primary, secondary)
				result = "OpenPGP signature verified."
			case "Encrypt":
				result, err = cryptokit.EncryptOpenPGP(message, primary, secondary)
			case "Decrypt":
				var plaintext []byte
				plaintext, err = cryptokit.DecryptOpenPGP(message, primary)
				result = string(plaintext)
			}
		case "age":
			switch operation {
			case "Encrypt":
				var ciphertext []byte
				ciphertext, err = cryptokit.EncryptAge(message, primary)
				result = string(ciphertext)
			case "Decrypt":
				var plaintext []byte
				plaintext, err = cryptokit.DecryptAge(message, primary)
				result = string(plaintext)
			default:
				err = errors.New("age supports Encrypt and Decrypt")
			}
		case "Ed25519":
			switch operation {
			case "Sign":
				result, err = cryptokit.SignEd25519(message, primary)
			case "Verify":
				err = cryptokit.VerifyEd25519(message, primary, secondary)
				result = "Ed25519 signature verified."
			default:
				err = errors.New("raw Ed25519 supports Sign and Verify; use age SSH keys for encryption")
			}
		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)
			case "Encrypt":
				var ciphertext []byte
				ciphertext, err = cryptokit.EncryptYubiCrypt(message, primary)
				result = string(ciphertext)
			case "Decrypt":
				var plaintext []byte
				plaintext, err = cryptokit.DecryptYubiCrypt(message, secret)
				result = string(plaintext)
			}
		}
		fyne.Do(func() {
			a.setBusy(false, "Cryptography 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("mail2news recipient", 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", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To",
		"Reply-To", "Organization", "User-Agent", "MIME-Version", "Content-Type",
		"Content-Transfer-Encoding", "Face", "OpenPGP", "X-OpenPGP", "X-Signature",
		"X-Aegis-Crypto-Version", "X-Aegis-Encryption", "X-Aegis-Signature",
		"X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint", "X-Aegis-Public-Key-URL",
		"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
	if delivery == "SMTP mail2news" {
		recipient := strings.TrimSpace(a.composeMail2News.Text)
		if recipient == "" {
			recipient = strings.TrimSpace(settings.SMTPRecipient)
		}
		if recipient == "" {
			dialog.ShowError(errors.New("mail2news recipient is required for SMTP delivery"), a.window)
			return
		}
		if _, err := mail.ParseAddress(recipient); err != nil {
			dialog.ShowError(fmt.Errorf("invalid mail2news recipient: %w", err), a.window)
			return
		}
		recipients = []string{recipient}
	}
	article, err := buildArticleWithIdentityHeaders(groups, from, subject, a.composeBody.Text, identityHeaders, articleCryptoOptions{
		Mode:              a.composeCryptoMode.Selected,
		EncryptionKey:     a.composeCryptoKey.Text,
		SigningKey:        signingKey,
		PublicKeyURL:      a.composeCryptoKeyURL.Text,
		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.composeMail2News.SetText(settings.SMTPRecipient)
			a.composeCryptoKey.SetText("")
			a.composeCryptoSigningKey.SetText("")
			a.composeCryptoKeyURL.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
	EncryptionKey     string
	SigningKey        string
	PublicKeyURL      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 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)
	encryptionKey := strings.TrimSpace(options.EncryptionKey)
	publicKeyURL := strings.TrimSpace(options.PublicKeyURL)
	if publicKeyURL != "" {
		parsed, parseErr := url.Parse(publicKeyURL)
		if parseErr != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || strings.ContainsAny(publicKeyURL, "\r\n") {
			return nil, "", "", "", errors.New("public-key URL must be a valid HTTPS URL without line breaks")
		}
	}

	switch mode {
	case "Plain":
		if signingKey != "" || encryptionKey != "" || publicKeyURL != "" {
			return nil, "", "", "", errors.New("plain mode does not accept cryptographic key fields")
		}
	case "Sign with Ed25519":
		if signingKey == "" {
			return nil, "", "", "", errors.New("an Ed25519 private key is required for signing")
		}
	case "Encrypt with age":
		if encryptionKey == "" {
			return nil, "", "", "", errors.New("an age recipient is required for encryption")
		}
		ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey)
		if encryptErr != nil {
			return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr)
		}
		wireBody = normalizeCRLF(string(ciphertext))
		contentType = "application/vnd.aegis.age"
		transferEncoding = "7bit"
	case "Encrypt with age and sign":
		if encryptionKey == "" || signingKey == "" {
			return nil, "", "", "", errors.New("an age recipient and an Ed25519 private key are required")
		}
		ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey)
		if encryptErr != nil {
			return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr)
		}
		wireBody = normalizeCRLF(string(ciphertext))
		contentType = "application/vnd.aegis.age"
		transferEncoding = "7bit"
	default:
		return nil, "", "", "", fmt.Errorf("unsupported article crypto mode %q", mode)
	}

	if mode == "Sign with Ed25519" || mode == "Encrypt with age and sign" {
		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,
		)
		if publicKeyURL != "" {
			headers = append(headers, foldHeader("X-Aegis-Public-Key-URL", publicKeyURL))
		}
	} else if publicKeyURL != "" {
		return nil, "", "", "", errors.New("public-key URL requires an Ed25519 signature mode")
	}
	if mode == "Encrypt with age" || mode == "Encrypt with age and sign" {
		headers = append([]string{"X-Aegis-Crypto-Version: 1", "X-Aegis-Encryption: age"}, headers...)
	}
	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, " ")
}
