// Package webpub publishes the log as a file an operator can put on a website:
// a self-contained HTML page or a CSV, written locally and optionally uploaded
// by FTP/FTPS.
//
// Design notes that matter:
//
//   - The local file is ALWAYS written first and the upload layered on top. A
//     network failure then leaves a good file on disk rather than a truncated
//     one on the server, and the operator can publish it by any other means.
//   - The page is self-contained: no external CSS, font or script. It has to
//     work dropped into any hosting, including one that blocks third-party
//     requests, and it must not leak the reader's visit to anyone.
//   - Every ADIF field is offered, because an operator publishing a county hunt
//     or an award chase needs fields no curated list would have guessed. That
//     puts the judgement on them, and it is a real one: this page is PUBLIC, and
//     the catalogue includes the correspondent's address, e-mail and the
//     operator's own street. Nothing is published unless it is chosen, and the
//     default selection stays the eight columns a reader of someone else's log
//     actually looks for.
package webpub

import (
	"crypto/tls"
	"encoding/csv"
	"fmt"
	"html"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/jlaffaye/ftp"

	"hamlog/internal/geo"
	"hamlog/internal/qso"
)

// Config is the whole feature's configuration. Stored per profile.
type Config struct {
	Enabled  bool   `json:"enabled"`
	Format   string `json:"format"`    // "html" | "csv"
	Folder   string `json:"folder"`    // local output folder
	FileName string `json:"file_name"` // e.g. "log.html"
	Title    string `json:"title"`     // page heading; blank → callsign
	Count    int    `json:"count"`     // publish the last N QSOs
	// IntervalMin is the periodic refresh in minutes. 0 = only republish when a
	// QSO is logged. Every publish is debounced regardless (see Publisher).
	IntervalMin int      `json:"interval_min"`
	Columns     []string `json:"columns"`

	FTPEnabled  bool   `json:"ftp_enabled"`
	FTPHost     string `json:"ftp_host"`
	FTPPort     int    `json:"ftp_port"`
	FTPUser     string `json:"ftp_user"`
	FTPPassword string `json:"ftp_password"`
	FTPTLS      bool   `json:"ftp_tls"` // explicit AUTH TLS (FTPS)
	FTPFolder   string `json:"ftp_folder"`
	FTPFileName string `json:"ftp_file_name"`
}

// Column is one publishable field: a stable key, the header printed in the
// file, and how to read it off a QSO.
type Column struct {
	Key    string
	Header string
	// Group is the section a picker shows this under. With every ADIF field on
	// offer, a flat list is unusable — the grouping IS what makes it navigable.
	Group string
	Value func(q *qso.QSO) string
}

func str(p *int) string {
	if p == nil {
		return ""
	}
	return strconv.Itoa(*p)
}

func str64(p *int64) string {
	if p == nil {
		return ""
	}
	return strconv.FormatInt(*p, 10)
}

// flt drops a trailing ".0": a distance reads better as "1420" than "1420.0",
// and a bearing as "142.5" keeps the half-degree that matters.
func flt(p *float64) string {
	if p == nil {
		return ""
	}
	return strconv.FormatFloat(*p, 'f', -1, 64)
}

// distanceKm is the path length for the Distance column.
//
// The stored DISTANCE field is only ever filled by an ADIF import that carried
// one — OpsLog does not compute it when logging — so publishing it straight gave
// an empty column for every QSO made here, which is how this was reported.
//
// So it falls back to the two locators, which are on the QSO already. Rounded to
// whole kilometres: the grids are squares tens of kilometres across, and a
// decimal on a figure that imprecise claims an accuracy nobody has.
func distanceKm(q *qso.QSO) string {
	if q.Distance != nil && *q.Distance > 0 {
		return flt(q.Distance)
	}
	km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
	if !ok || km <= 0 {
		return ""
	}
	return strconv.FormatFloat(km, 'f', 0, 64)
}

func stamp(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.UTC().Format("2006-01-02 15:04")
}

// Columns is every field a QSO can carry, in ADIF order, grouped for the picker.
//
// Generated from the qso.QSO struct rather than curated by hand: the previous
// list held 23 of them, so anyone wanting to publish a county, a satellite pass
// or an award reference simply could not. A hand list also silently rots — a new
// ADIF field is added to the struct and nobody remembers this file exists.
//
// The stored configuration keeps KEYS, so order and grouping can change freely.
var Columns = []Column{
	// Date and time are one column in the database and two here: a log reads by
	// day, and a reader scanning for "that evening" should not have to parse a
	// timestamp. Frequency is stored in Hz and published in MHz.
	{"date", "Date", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("2006-01-02") }},
	{"time", "UTC", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("15:04") }},
	{"freq", "Freq", "QSO", func(q *qso.QSO) string {
		if q.FreqHz == nil || *q.FreqHz == 0 {
			return ""
		}
		return strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
	}},
	{"callsign", "Callsign", "QSO", func(q *qso.QSO) string { return q.Callsign }},
	{"qso_date_off", "End", "QSO", func(q *qso.QSO) string { return stamp(q.QSODateOff) }},
	{"band", "Band", "QSO", func(q *qso.QSO) string { return q.Band }},
	{"band_rx", "Band RX", "QSO", func(q *qso.QSO) string { return q.BandRX }},
	{"mode", "Mode", "QSO", func(q *qso.QSO) string { return q.Mode }},
	{"submode", "Submode", "QSO", func(q *qso.QSO) string { return q.Submode }},
	{"freq_rx_hz", "Freq RX", "QSO", func(q *qso.QSO) string { return str64(q.FreqRXHz) }},
	{"rst_sent", "RST S", "QSO", func(q *qso.QSO) string { return q.RSTSent }},
	{"rst_rcvd", "RST R", "QSO", func(q *qso.QSO) string { return q.RSTRcvd }},
	{"name", "Name", "QSO", func(q *qso.QSO) string { return q.Name }},
	{"qth", "QTH", "QSO", func(q *qso.QSO) string { return q.QTH }},
	{"address", "Address", "QSO", func(q *qso.QSO) string { return q.Address }},
	{"email", "Email", "QSO", func(q *qso.QSO) string { return q.Email }},
	{"web", "Web", "QSO", func(q *qso.QSO) string { return q.Web }},
	{"grid", "Grid", "Location", func(q *qso.QSO) string { return q.Grid }},
	{"gridsquare_ext", "Grid ext", "Location", func(q *qso.QSO) string { return q.GridExt }},
	{"vucc_grids", "VUCC", "Location", func(q *qso.QSO) string { return q.VUCCGrids }},
	{"country", "Country", "Location", func(q *qso.QSO) string { return q.Country }},
	{"state", "State", "Location", func(q *qso.QSO) string { return q.State }},
	{"cnty", "County", "Location", func(q *qso.QSO) string { return q.County }},
	{"dxcc", "Dxcc", "Location", func(q *qso.QSO) string { return str(q.DXCC) }},
	{"cont", "Cont", "Location", func(q *qso.QSO) string { return q.Continent }},
	{"cqz", "CQ", "Location", func(q *qso.QSO) string { return str(q.CQZ) }},
	{"ituz", "ITU", "Location", func(q *qso.QSO) string { return str(q.ITUZ) }},
	{"iota", "Iota", "Awards", func(q *qso.QSO) string { return q.IOTA }},
	{"sota_ref", "SOTA", "Awards", func(q *qso.QSO) string { return q.SOTARef }},
	{"pota_ref", "POTA", "Awards", func(q *qso.QSO) string { return q.POTARef }},
	{"age", "Age", "QSO", func(q *qso.QSO) string { return str(q.Age) }},
	{"lat", "Lat", "Location", func(q *qso.QSO) string { return flt(q.Lat) }},
	{"lon", "Lon", "Location", func(q *qso.QSO) string { return flt(q.Lon) }},
	{"rig", "Rig", "QSO", func(q *qso.QSO) string { return q.Rig }},
	{"ant", "Ant", "QSO", func(q *qso.QSO) string { return q.Ant }},
	{"qsl_sent", "QSL S", "QSL", func(q *qso.QSO) string { return q.QSLSent }},
	{"qsl_rcvd", "QSL R", "QSL", func(q *qso.QSO) string { return q.QSLRcvd }},
	{"qsl_sent_date", "Qsl sent date", "QSL", func(q *qso.QSO) string { return q.QSLSentDate }},
	{"qsl_rcvd_date", "Qsl rcvd date", "QSL", func(q *qso.QSO) string { return q.QSLRcvdDate }},
	{"qsl_via", "Qsl via", "QSL", func(q *qso.QSO) string { return q.QSLVia }},
	{"qsl_msg", "Qsl msg", "QSL", func(q *qso.QSO) string { return q.QSLMsg }},
	{"qslmsg_rcvd", "Qslmsg rcvd", "QSL", func(q *qso.QSO) string { return q.QSLMsgRcvd }},
	{"lotw_sent", "LoTW S", "QSL", func(q *qso.QSO) string { return q.LOTWSent }},
	{"lotw_rcvd", "LoTW R", "QSL", func(q *qso.QSO) string { return q.LOTWRcvd }},
	{"lotw_sent_date", "Lotw sent date", "QSL", func(q *qso.QSO) string { return q.LOTWSentDate }},
	{"lotw_rcvd_date", "Lotw rcvd date", "QSL", func(q *qso.QSO) string { return q.LOTWRcvdDate }},
	{"eqsl_sent", "eQSL S", "QSL", func(q *qso.QSO) string { return q.EQSLSent }},
	{"eqsl_rcvd", "eQSL R", "QSL", func(q *qso.QSO) string { return q.EQSLRcvd }},
	{"eqsl_sent_date", "Eqsl sent date", "QSL", func(q *qso.QSO) string { return q.EQSLSentDate }},
	{"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }},
	{"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }},
	{"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }},
	{"clublog_qso_download_date", "Clublog match date", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadDate }},
	{"clublog_qso_download_status", "Clublog match status", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadStatus }},
	{"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }},
	{"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }},
	{"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }},
	{"qrzcom_qso_upload_status", "Qrzcom qso upload status", "QSL", func(q *qso.QSO) string { return q.QRZComUploadStatus }},
	{"qrzcom_qso_download_date", "Qrzcom qso download date", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadDate }},
	{"qrzcom_qso_download_status", "Qrzcom qso download status", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadStatus }},
	{"contest_id", "Contest", "Contest", func(q *qso.QSO) string { return q.ContestID }},
	{"srx", "Srx", "Contest", func(q *qso.QSO) string { return str(q.SRX) }},
	{"stx", "Stx", "Contest", func(q *qso.QSO) string { return str(q.STX) }},
	{"srx_string", "SRX str", "QSO", func(q *qso.QSO) string { return q.SRXString }},
	{"stx_string", "STX str", "QSO", func(q *qso.QSO) string { return q.STXString }},
	{"check", "Check", "Contest", func(q *qso.QSO) string { return q.Check }},
	{"precedence", "Precedence", "Contest", func(q *qso.QSO) string { return q.Precedence }},
	{"arrl_sect", "Section", "Contest", func(q *qso.QSO) string { return q.ARRLSect }},
	{"prop_mode", "Prop", "QSO", func(q *qso.QSO) string { return q.PropMode }},
	{"sat_name", "Sat", "QSO", func(q *qso.QSO) string { return q.SatName }},
	{"sat_mode", "Sat mode", "QSO", func(q *qso.QSO) string { return q.SatMode }},
	{"ant_az", "Ant az", "QSO", func(q *qso.QSO) string { return flt(q.AntAz) }},
	{"ant_el", "Ant el", "QSO", func(q *qso.QSO) string { return flt(q.AntEl) }},
	{"ant_path", "Ant path", "QSO", func(q *qso.QSO) string { return q.AntPath }},
	{"station_callsign", "Station", "QSO", func(q *qso.QSO) string { return q.StationCallsign }},
	{"operator", "Operator", "QSO", func(q *qso.QSO) string { return q.Operator }},
	{"my_grid", "My grid", "My station", func(q *qso.QSO) string { return q.MyGrid }},
	{"my_gridsquare_ext", "My gridsquare ext", "My station", func(q *qso.QSO) string { return q.MyGridExt }},
	{"my_country", "My country", "My station", func(q *qso.QSO) string { return q.MyCountry }},
	{"my_state", "My state", "My station", func(q *qso.QSO) string { return q.MyState }},
	{"my_cnty", "My cnty", "My station", func(q *qso.QSO) string { return q.MyCounty }},
	{"my_iota", "My iota", "My station", func(q *qso.QSO) string { return q.MyIOTA }},
	{"my_sota_ref", "My sota ref", "My station", func(q *qso.QSO) string { return q.MySOTARef }},
	{"my_pota_ref", "My pota ref", "My station", func(q *qso.QSO) string { return q.MyPOTARef }},
	{"my_dxcc", "My dxcc", "My station", func(q *qso.QSO) string { return str(q.MyDXCC) }},
	{"my_cq_zone", "My cq zone", "My station", func(q *qso.QSO) string { return str(q.MyCQZone) }},
	{"my_itu_zone", "My itu zone", "My station", func(q *qso.QSO) string { return str(q.MyITUZone) }},
	{"my_lat", "My lat", "My station", func(q *qso.QSO) string { return flt(q.MyLat) }},
	{"my_lon", "My lon", "My station", func(q *qso.QSO) string { return flt(q.MyLon) }},
	{"my_street", "My street", "My station", func(q *qso.QSO) string { return q.MyStreet }},
	{"my_city", "My city", "My station", func(q *qso.QSO) string { return q.MyCity }},
	{"my_postal_code", "My postal code", "My station", func(q *qso.QSO) string { return q.MyPostalCode }},
	{"my_rig", "My rig", "My station", func(q *qso.QSO) string { return q.MyRig }},
	{"my_antenna", "My antenna", "My station", func(q *qso.QSO) string { return q.MyAntenna }},
	{"tx_pwr", "TX pwr", "QSO", func(q *qso.QSO) string { return flt(q.TXPower) }},
	{"comment", "Comment", "QSO", func(q *qso.QSO) string { return q.Comment }},
	{"notes", "Notes", "QSO", func(q *qso.QSO) string { return q.Notes }},
	{"sig", "Sig", "Awards", func(q *qso.QSO) string { return q.SIG }},
	{"sig_info", "Sig info", "Awards", func(q *qso.QSO) string { return q.SIGInfo }},
	{"my_sig", "My sig", "My station", func(q *qso.QSO) string { return q.MySIG }},
	{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
	{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
	{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
	{"distance", "Distance", "Location", distanceKm},
	{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
	{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
	{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
	{"sfi", "SFI", "QSO", func(q *qso.QSO) string { return flt(q.SFI) }},
	{"skcc", "Skcc", "Awards", func(q *qso.QSO) string { return q.SKCC }},
	{"fists", "Fists", "Awards", func(q *qso.QSO) string { return q.FISTS }},
	{"ten_ten", "10-10", "Awards", func(q *qso.QSO) string { return q.TenTen }},
	{"contacted_op", "Op worked", "QSO", func(q *qso.QSO) string { return q.ContactedOp }},
	{"eq_call", "Eq call", "QSO", func(q *qso.QSO) string { return q.EqCall }},
	{"pfx", "Pfx", "Location", func(q *qso.QSO) string { return q.PFX }},
	{"my_name", "My name", "My station", func(q *qso.QSO) string { return q.MyName }},
	{"class", "Class", "Contest", func(q *qso.QSO) string { return q.Class }},
	{"darc_dok", "DOK", "Awards", func(q *qso.QSO) string { return q.DarcDOK }},
	{"my_darc_dok", "My darc dok", "My station", func(q *qso.QSO) string { return q.MyDarcDOK }},
	{"region", "Region", "Location", func(q *qso.QSO) string { return q.Region }},
	{"silent_key", "SK", "QSO", func(q *qso.QSO) string { return q.SilentKey }},
	{"swl", "Swl", "QSO", func(q *qso.QSO) string { return q.SWL }},
	{"qso_complete", "Complete", "QSO", func(q *qso.QSO) string { return q.QSOComplete }},
	{"qso_random", "Random", "QSO", func(q *qso.QSO) string { return q.QSORandom }},
	{"credit_granted", "Credit granted", "QSL", func(q *qso.QSO) string { return q.CreditGranted }},
	{"credit_submitted", "Credit sub.", "QSL", func(q *qso.QSO) string { return q.CreditSubmitted }},
	{"my_arrl_sect", "My arrl sect", "My station", func(q *qso.QSO) string { return q.MyARRLSect }},
	{"my_vucc_grids", "My vucc grids", "My station", func(q *qso.QSO) string { return q.MyVUCCGrids }},
	{"award_refs", "Award refs", "Awards", func(q *qso.QSO) string { return q.AwardRefs }},
}

// DefaultColumns is what a fresh configuration publishes — the columns a reader
// of someone else's log actually looks for.
var DefaultColumns = []string{"date", "time", "callsign", "band", "mode", "rst_sent", "rst_rcvd", "country"}

// legacyKeys maps the short names the hand-written table used onto the ADIF
// names the generated one uses. A configuration saved before this change still
// publishes the same columns; without it, three of them would vanish silently on
// upgrade, which is the worst way for a setting to change.
var legacyKeys = map[string]string{
	"pota":    "pota_ref",
	"sota":    "sota_ref",
	"station": "station_callsign",
}

func columnsFor(keys []string) []Column {
	if len(keys) == 0 {
		keys = DefaultColumns
	}
	byKey := make(map[string]Column, len(Columns))
	for _, c := range Columns {
		byKey[c.Key] = c
	}
	out := make([]Column, 0, len(keys))
	for _, k := range keys {
		k = strings.TrimSpace(k)
		if alias, ok := legacyKeys[k]; ok {
			k = alias
		}
		if c, ok := byKey[k]; ok {
			out = append(out, c)
		}
	}
	if len(out) == 0 { // every stored key unknown (config from a newer build)
		return columnsFor(DefaultColumns)
	}
	return out
}

// KnownColumnKeys lists the offered columns, for the settings UI.
func KnownColumnKeys() []Column {
	out := make([]Column, len(Columns))
	copy(out, Columns)
	sort.SliceStable(out, func(i, j int) bool { return false }) // keep declared order
	return out
}

// Normalise fills in the defaults a half-filled config would otherwise carry
// into the renderer.
func (c *Config) Normalise() {
	if c.Format != "csv" {
		c.Format = "html"
	}
	if strings.TrimSpace(c.FileName) == "" {
		if c.Format == "csv" {
			c.FileName = "log.csv"
		} else {
			c.FileName = "log.html"
		}
	}
	if c.Count <= 0 {
		c.Count = 100
	}
	if c.FTPPort <= 0 {
		c.FTPPort = 21
	}
	if len(c.Columns) == 0 {
		c.Columns = append([]string{}, DefaultColumns...)
	}
	if strings.TrimSpace(c.FTPFileName) == "" {
		c.FTPFileName = c.FileName
	}
}

// Render builds the file contents for the given QSOs.
func Render(cfg Config, qsos []qso.QSO, stationCall string) ([]byte, error) {
	cfg.Normalise()
	cols := columnsFor(cfg.Columns)
	if cfg.Format == "csv" {
		return renderCSV(cols, qsos)
	}
	return renderHTML(cfg, cols, qsos, stationCall), nil
}

func renderCSV(cols []Column, qsos []qso.QSO) ([]byte, error) {
	var b strings.Builder
	w := csv.NewWriter(&b)
	head := make([]string, len(cols))
	for i, c := range cols {
		head[i] = c.Header
	}
	if err := w.Write(head); err != nil {
		return nil, err
	}
	row := make([]string, len(cols))
	for i := range qsos {
		for j, c := range cols {
			row[j] = c.Value(&qsos[i])
		}
		if err := w.Write(row); err != nil {
			return nil, err
		}
	}
	w.Flush()
	if err := w.Error(); err != nil {
		return nil, err
	}
	return []byte(b.String()), nil
}

// renderHTML writes a standalone page: inline CSS, inline sort script, no
// external request of any kind.
func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) []byte {
	title := strings.TrimSpace(cfg.Title)
	if title == "" {
		if stationCall != "" {
			title = stationCall + " — log"
		} else {
			title = "Log"
		}
	}
	esc := html.EscapeString

	var b strings.Builder
	b.WriteString(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>` + esc(title) + `</title>
<style>
:root{color-scheme:light dark;--bg:#fff;--fg:#16181d;--mut:#5b6270;--line:#e2e5ea;--head:#f4f6f8;--zebra:#fafbfc;--accent:#2a78d6}
@media (prefers-color-scheme:dark){:root{--bg:#16181d;--fg:#e6e8ec;--mut:#9aa2b1;--line:#2e343f;--head:#1f232b;--zebra:#1b1f26;--accent:#6da7ec}}
*{box-sizing:border-box}
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
     font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
/* The page grows with its table instead of being capped at a comfortable
   READING width. 1100px suits eight columns and hides the rest behind a
   scrollbar on a screen wide enough to show them all — which is how this was
   reported. max-content lets a wide table use the window; min-width keeps a
   narrow one from collapsing to nothing on a large display. */
.wrap{max-width:max-content;min-width:min(1100px,100%);margin:0 auto}
h1{margin:0 0 .25rem;font-size:1.35rem}
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
/* A visible scrollbar. Windows hides overlay scrollbars until something moves,
   so a table that scrolls looks exactly like a table that is missing columns. */
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px;
        scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.scroll::-webkit-scrollbar{height:10px}
.scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
/* width:auto, not 100%: columns take the width their contents need. With
   width:100% the browser squeezes them to fit the container first and only then
   overflows, so a callsign could end up wrapped while empty space sat further
   along the row. min-width keeps a two-column table filling the frame. */
table{border-collapse:collapse;width:auto;min-width:100%;font-variant-numeric:tabular-nums}
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
   text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
th:hover{color:var(--fg)}
th::after{content:'';font-size:.7em;opacity:.7}
th[data-asc="1"]::after{content:' \25B2'}
th[data-asc="0"]::after{content:' \25BC'}
tbody tr:nth-child(even){background:var(--zebra)}
tbody tr:last-child td{border-bottom:0}
td.call{font-family:ui-monospace,Consolas,monospace;font-weight:700;color:var(--accent)}
.foot{margin-top:.75rem;color:var(--mut);font-size:.75rem}
</style>
</head>
<body><div class="wrap">
<h1>` + esc(title) + `</h1>
<p class="meta">` + strconv.Itoa(len(qsos)) + ` QSO · ` + time.Now().UTC().Format("2006-01-02 15:04") + ` UTC</p>
<div class="scroll"><table><thead><tr>`)
	for _, c := range cols {
		b.WriteString(`<th>` + esc(c.Header) + `</th>`)
	}
	b.WriteString(`</tr></thead><tbody>`)
	for i := range qsos {
		// The row's position as published. Sorting is a view on top of it, so a
		// third click can put the table back the way the operator first saw it.
		b.WriteString(`<tr data-i="` + strconv.Itoa(i) + `">`)
		for _, c := range cols {
			cls := ""
			if c.Key == "callsign" {
				cls = ` class="call"`
			}
			b.WriteString(`<td` + cls + `>` + esc(c.Value(&qsos[i])) + `</td>`)
		}
		b.WriteString(`</tr>`)
	}
	b.WriteString(`</tbody></table></div>
<p class="foot">Generated by OpsLog</p>
</div>
<script>
// Click a header to sort: ascending, descending, then back to the published
// order. Kept tiny and dependency-free — the page has to work offline and on any
// hosting, so no library is loaded.
//
// NUM is deliberately strict: the whole cell must be a number. parseFloat alone
// accepts a numeric PREFIX, so "2026-08-10" became 2026 and every date in a year
// compared equal — the date column looked as though it simply would not sort.
// Callsigns starting with a digit (8B81SU) hit the same trap.
var NUM=/^[+-]?\d+(\.\d+)?$/;
document.querySelectorAll('th').forEach(function(th,i){
  th.addEventListener('click',function(){
    var tb=th.closest('table').tBodies[0],
        rows=Array.prototype.slice.call(tb.rows),
        cur=th.dataset.asc,
        next=cur===undefined?'1':(cur==='1'?'0':'');
    if(next===''){
      // Third click: restore the order the page was published in.
      rows.sort(function(a,b){return a.dataset.i-b.dataset.i});
    }else{
      var asc=next==='1';
      rows.sort(function(a,b){
        var x=a.cells[i].textContent.trim(), y=b.cells[i].textContent.trim();
        // Blanks always sink, whichever way the column is pointing: an empty
        // cell is missing data, not the smallest value.
        if(x===''||y==='') return x===y?0:(x===''?1:-1);
        var c=NUM.test(x)&&NUM.test(y)?(parseFloat(x)-parseFloat(y)):x.localeCompare(y);
        return asc?c:-c;
      });
    }
    rows.forEach(function(r){tb.appendChild(r)});
    th.closest('tr').querySelectorAll('th').forEach(function(o){delete o.dataset.asc});
    if(next!=='') th.dataset.asc=next;
  });
});
</script>
</body></html>
`)
	return []byte(b.String())
}

// WriteLocal writes the payload into the configured folder and returns the path.
func WriteLocal(cfg Config, data []byte) (string, error) {
	cfg.Normalise()
	dir := strings.TrimSpace(cfg.Folder)
	if dir == "" {
		return "", fmt.Errorf("no output folder set")
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", fmt.Errorf("create %s: %w", dir, err)
	}
	path := filepath.Join(dir, cfg.FileName)
	// Write to a temp file and rename over the target: a reader (or a syncing
	// client) never sees a half-written page.
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return "", fmt.Errorf("write %s: %w", tmp, err)
	}
	if err := os.Rename(tmp, path); err != nil {
		_ = os.Remove(tmp)
		return "", fmt.Errorf("replace %s: %w", path, err)
	}
	return path, nil
}

// Upload sends the payload to the configured FTP/FTPS server.
func Upload(cfg Config, data []byte) error {
	cfg.Normalise()
	host := strings.TrimSpace(cfg.FTPHost)
	if host == "" {
		return fmt.Errorf("no FTP server set")
	}
	c, err := dial(cfg)
	if err != nil {
		return err
	}
	defer func() { _ = c.Quit() }()

	if err := c.Login(cfg.FTPUser, cfg.FTPPassword); err != nil {
		return fmt.Errorf("login as %q: %w", cfg.FTPUser, err)
	}
	if dir := strings.TrimSpace(cfg.FTPFolder); dir != "" {
		if err := c.ChangeDir(dir); err != nil {
			return fmt.Errorf("enter remote folder %q: %w", dir, err)
		}
	}
	if err := c.Stor(cfg.FTPFileName, strings.NewReader(string(data))); err != nil {
		return fmt.Errorf("upload %q: %w", cfg.FTPFileName, err)
	}
	return nil
}

func dial(cfg Config) (*ftp.ServerConn, error) {
	addr := fmt.Sprintf("%s:%d", strings.TrimSpace(cfg.FTPHost), cfg.FTPPort)
	opts := []ftp.DialOption{ftp.DialWithTimeout(20 * time.Second)}
	if cfg.FTPTLS {
		// Explicit FTPS (AUTH TLS), the form virtually every web host offers.
		// InsecureSkipVerify is NOT set: a certificate that does not validate is
		// a real warning, and silently accepting it would defeat the point of
		// ticking the TLS box in the first place.
		opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{ServerName: strings.TrimSpace(cfg.FTPHost)}))
	}
	c, err := ftp.Dial(addr, opts...)
	if err != nil {
		return nil, fmt.Errorf("connect to %s: %w", addr, err)
	}
	return c, nil
}

// Test connects, logs in and enters the remote folder without uploading — the
// "Test connection" button. Returns a short human-readable success line.
func Test(cfg Config) (string, error) {
	cfg.Normalise()
	c, err := dial(cfg)
	if err != nil {
		return "", err
	}
	defer func() { _ = c.Quit() }()
	if err := c.Login(cfg.FTPUser, cfg.FTPPassword); err != nil {
		return "", fmt.Errorf("login as %q: %w", cfg.FTPUser, err)
	}
	if dir := strings.TrimSpace(cfg.FTPFolder); dir != "" {
		if err := c.ChangeDir(dir); err != nil {
			return "", fmt.Errorf("enter remote folder %q: %w", dir, err)
		}
	}
	cwd, _ := c.CurrentDir()
	return "connected — remote folder " + cwd, nil
}
