package main

// QSL card designer bindings. All logic lives in internal/qslcard — this file
// only adapts it to the Wails boundary (thin methods on *App, same receiver
// as app.go so bindings keep working). Template documents cross the bridge
// as JSON strings: the schema is the single source of truth and the frontend
// mirrors it in qslTypes.ts.

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"hamlog/internal/applog"
	"hamlog/internal/dxcc"
	"hamlog/internal/email"
	"hamlog/internal/qslcard"
	"hamlog/internal/qso"

	wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)

// Settings keys for the eQSL e-mail templates (subject/body share the
// {CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} variables of the recording e-mail).
const (
	keyQSLEmailSubject = "qsl.email_subject"
	keyQSLEmailBody    = "qsl.email_body"
	keyQSLAutoSend     = "qsl.auto_send"       // "1" → render+send an eQSL on log when an e-mail and default template exist
	keyQSLDefaultMsg   = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
)

// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"

// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
// the card: received → "TNX QSL" (thanks for your card), not received → "PSE QSL"
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"

// qslCredit closes the default QSL e-mail: who made the card, and where the
// recipient can get the same program.
//
// A DEFAULT, not a signature. It lives in the body template like every other
// line, so an operator who does not want it deletes it once and it is gone —
// and one who has already written their own body never sees it appear, because
// a stored template is returned verbatim and the default is only the fallback.
// Appending it at send time instead would have made it unremovable, which is
// not a thing to do to someone's outgoing mail.
const qslCredit = "--\nDesigned & sent by OpsLog — " + releasesPageURL

const (
	defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
	defaultQSLEmailBody    = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE}  ·  {BAND}  ·  {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
)

// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
func (a *App) qslDir() string { return filepath.Join(a.dataDir, "qsl") }

// QSLTemplateInfo is the template-list row shown in the designer.
type QSLTemplateInfo struct {
	ID        int64  `json:"id"`
	Name      string `json:"name"`
	ProfileID *int64 `json:"profile_id,omitempty"`
	IsDefault bool   `json:"is_default"`
	UpdatedAt string `json:"updated_at"`
}

// QSLFontInfo is one font face offered by the designer, TTF bytes included
// so the frontend can build @font-face rules (and inline them into the SVG
// before rasterization).
type QSLFontInfo struct {
	Family   string `json:"family"`
	Kind     string `json:"kind"` // "display" | "script" | "system"
	Variable bool   `json:"variable"`
	DataB64  string `json:"data_b64"`
}

// QSLPresetInfo describes one style preset for the picker.
type QSLPresetInfo struct {
	Name     string              `json:"name"`
	Label    string              `json:"label"`
	Params   []string            `json:"params"` // accepted style_params keys
	Defaults qslcard.StyleParams `json:"defaults"`
}

// QSLPickPhotos opens a multi-select dialog for the designer drop zone's
// "browse" path (drag & drop hands the webview File objects without paths,
// so picking through the native dialog is the reliable route to real paths).
func (a *App) QSLPickPhotos() ([]string, error) {
	return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
		Title: "Choose card photos (1–5)",
		Filters: []wruntime.FileFilter{
			{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
		},
	})
}

// QSLGenerateProposals analyzes the photos and returns 3 distinct template
// documents (JSON) from the automatic placement engine.
func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
	if len(photoPaths) == 0 {
		return nil, fmt.Errorf("no photos selected")
	}
	if len(photoPaths) > 5 {
		return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
	}
	photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
	for _, p := range photoPaths {
		ph, err := qslcard.AnalyzePhoto(p)
		if err != nil {
			return nil, fmt.Errorf("analyze %s: %w", filepath.Base(p), err)
		}
		photos = append(photos, ph)
	}
	info, err := a.qslProfileInfo()
	if err != nil {
		return nil, err
	}
	var engine qslcard.LayoutEngine = qslcard.HeuristicEngine{}
	templates, err := engine.Propose(photos, info)
	if err != nil {
		return nil, err
	}
	out := make([]string, len(templates))
	for i, t := range templates {
		b, err := qslcard.Encode(t)
		if err != nil {
			return nil, err
		}
		out[i] = string(b)
	}
	return out, nil
}

// QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
// shared/global ones), defaults first — so the default is per-profile, not a
// single global list shared by every profile.
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
	if a.qslTemplates == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	var recs []qslcard.Record
	var err error
	if p, e := a.profiles.Active(a.ctx); e == nil {
		recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
	} else {
		recs, err = a.qslTemplates.List(a.ctx)
	}
	if err != nil {
		return nil, err
	}
	out := make([]QSLTemplateInfo, 0, len(recs))
	for _, r := range recs {
		out = append(out, QSLTemplateInfo{
			ID: r.ID, Name: r.Name, ProfileID: r.ProfileID,
			IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
		})
	}
	return out, nil
}

// QSLGetTemplate returns a stored template document (JSON).
func (a *App) QSLGetTemplate(id int64) (string, error) {
	if a.qslTemplates == nil {
		return "", fmt.Errorf("db not initialized")
	}
	rec, err := a.qslTemplates.Get(a.ctx, id)
	if err != nil {
		return "", err
	}
	return rec.JSON, nil
}

// QSLSaveTemplate validates and stores a template. id 0 creates; the photos
// the document references at their original (absolute) paths are copied into
// the template's asset folder so the design survives the user moving files.
// Returns the template id.
func (a *App) QSLSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) {
	if a.qslTemplates == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	name = strings.TrimSpace(name)
	if name == "" {
		return 0, fmt.Errorf("template name required")
	}
	t, err := qslcard.Parse([]byte(doc))
	if err != nil {
		return 0, err
	}
	t.Name = name
	if err := qslcard.Validate(t, nil); err != nil {
		return 0, err
	}

	rec := qslcard.Record{ID: id, Name: name}
	if forActiveProfile {
		if p, err := a.profiles.Active(a.ctx); err == nil {
			rec.ProfileID = &p.ID
		}
	}
	// Two-phase save: the row first (a new template needs its id to own an
	// asset folder), then photo import + the final document.
	rec.JSON = doc
	if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
		return 0, err
	}
	dir := qslcard.TemplateDir(a.qslDir(), rec.ID)
	if err := qslcard.ImportPhotos(&t, dir); err != nil {
		return 0, err
	}
	if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
		return 0, err
	}
	final, err := qslcard.Encode(t)
	if err != nil {
		return 0, err
	}
	rec.JSON = string(final)
	if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
		return 0, err
	}
	applog.Printf("qsl: template %q saved (id %d)", name, rec.ID)
	return rec.ID, nil
}

// QSLSetDefaultTemplate marks a template as default for its profile scope.
func (a *App) QSLSetDefaultTemplate(id int64) error {
	if a.qslTemplates == nil {
		return fmt.Errorf("db not initialized")
	}
	return a.qslTemplates.SetDefault(a.ctx, id)
}

// QSLDeleteTemplate removes a template and its asset folder.
func (a *App) QSLDeleteTemplate(id int64) error {
	if a.qslTemplates == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.qslTemplates.Delete(a.ctx, id); err != nil {
		return err
	}
	return qslcard.RemoveTemplateDir(a.qslDir(), id)
}

// QSLSavePreview stores the rasterized thumbnail (PNG, base64) the frontend
// renders at save time, shown in the template list.
func (a *App) QSLSavePreview(id int64, pngB64 string) error {
	data, err := base64.StdEncoding.DecodeString(pngB64)
	if err != nil {
		return fmt.Errorf("decode preview: %w", err)
	}
	dir := qslcard.TemplateDir(a.qslDir(), id)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("create template dir: %w", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "preview.png"), data, 0o644); err != nil {
		return fmt.Errorf("write preview: %w", err)
	}
	return nil
}

// QSLPreviewDataURL returns the stored thumbnail as a data URL ("" if the
// template has none yet).
func (a *App) QSLPreviewDataURL(id int64) (string, error) {
	data, err := os.ReadFile(filepath.Join(qslcard.TemplateDir(a.qslDir(), id), "preview.png"))
	if os.IsNotExist(err) {
		return "", nil
	}
	if err != nil {
		return "", fmt.Errorf("read preview: %w", err)
	}
	return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil
}

// QSLPhotoDataURL returns a template photo as a data URL. templateID 0 reads
// a draft photo at its original absolute path (pre-save editing); otherwise
// ref is a name inside the template's asset folder.
func (a *App) QSLPhotoDataURL(templateID int64, ref string) (string, error) {
	var path string
	switch {
	case templateID > 0 && !qslcard.IsDraftPhoto(ref):
		path = filepath.Join(qslcard.TemplateDir(a.qslDir(), templateID), filepath.Base(ref))
	case qslcard.IsDraftPhoto(ref):
		path = ref
	default:
		return "", fmt.Errorf("photo %q not available without a template id", ref)
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return "", fmt.Errorf("read photo: %w", err)
	}
	mime := "image/jpeg"
	if strings.EqualFold(filepath.Ext(path), ".png") {
		mime = "image/png"
	}
	return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}

// QSLFlagDataURL returns an embedded flag SVG as a data URL ("" = no flag).
func (a *App) QSLFlagDataURL(iso string) (string, error) {
	if iso == "" {
		return "", nil
	}
	svg, err := qslcard.FlagSVG(iso)
	if err != nil {
		return "", err
	}
	return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(svg), nil
}

// QSLFonts returns the embedded designer fonts (plus Cooper Black when the
// machine has it) for @font-face registration.
func (a *App) QSLFonts() ([]QSLFontInfo, error) {
	fonts, err := qslcard.Fonts()
	if err != nil {
		return nil, err
	}
	out := make([]QSLFontInfo, 0, len(fonts))
	for _, f := range fonts {
		out = append(out, QSLFontInfo{
			Family: f.Family, Kind: f.Kind, Variable: f.Variable,
			DataB64: base64.StdEncoding.EncodeToString(f.Data),
		})
	}
	return out, nil
}

// QSLStylePresets lists the built-in style presets for the picker.
func (a *App) QSLStylePresets() []QSLPresetInfo {
	out := make([]QSLPresetInfo, 0, len(qslcard.Presets))
	for _, p := range qslcard.Presets {
		params := make([]string, 0, len(p.AllowedParams))
		for k := range p.AllowedParams {
			params = append(params, k)
		}
		sort.Strings(params)
		out = append(out, QSLPresetInfo{Name: p.Name, Label: p.Label, Params: params, Defaults: p.Defaults})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

// QSLResolvePreview fills a template's placeholders with the active profile
// and a representative sample QSO, for the live editor preview. Returns the
// RenderModel as JSON.
func (a *App) QSLResolvePreview(doc string) (string, error) {
	t, err := qslcard.Parse([]byte(doc))
	if err != nil {
		return "", err
	}
	vars, country, err := a.qslVars(sampleQSO())
	if err != nil {
		return "", err
	}
	return encodeRenderModel(qslcard.Resolve(t, vars, country))
}

// QSLDefaultTemplateID returns the template the eQSL send flow should use
// for the active profile (0 when none exists yet).
func (a *App) QSLDefaultTemplateID() (int64, error) {
	if a.qslTemplates == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return 0, fmt.Errorf("no active profile: %w", err)
	}
	rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
	if err != nil {
		return 0, nil // no templates yet — the UI offers to open the designer
	}
	return rec.ID, nil
}

// RenderEQSL loads a QSO and a template and returns the fully-resolved
// render model (JSON) for the frontend to rasterize.
func (a *App) RenderEQSL(qsoID int64, templateID int64) (string, error) {
	if a.qso == nil || a.qslTemplates == nil {
		return "", fmt.Errorf("db not initialized")
	}
	q, err := a.qso.GetByID(a.ctx, qsoID)
	if err != nil {
		return "", err
	}
	rec, err := a.qslTemplates.Get(a.ctx, templateID)
	if err != nil {
		return "", fmt.Errorf("template %d: %w", templateID, err)
	}
	t, err := qslcard.Parse([]byte(rec.JSON))
	if err != nil {
		return "", err
	}
	dir := qslcard.TemplateDir(a.qslDir(), templateID)
	if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
		return "", err
	}
	vars, country, err := a.qslVars(q)
	if err != nil {
		return "", err
	}
	return encodeRenderModel(qslcard.Resolve(t, vars, country))
}

// SendEQSL e-mails the rasterized card (JPEG, base64) to the QSO's
// correspondent, archives it in the outbox and stamps eqsl_sent on success.
func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	q, err := a.qso.GetByID(a.ctx, qsoID)
	if err != nil {
		return err
	}
	to := strings.TrimSpace(q.Email)
	if to == "" {
		return fmt.Errorf("no e-mail address for %s — run a QRZ/HamQTH lookup first", q.Callsign)
	}
	data, err := base64.StdEncoding.DecodeString(jpegB64)
	if err != nil {
		return fmt.Errorf("decode card image: %w", err)
	}
	outbox := filepath.Join(a.qslDir(), "outbox")
	if err := os.MkdirAll(outbox, 0o755); err != nil {
		return fmt.Errorf("create outbox: %w", err)
	}
	safeCall := strings.Map(func(r rune) rune {
		if r == '/' || r == '\\' || r == ':' {
			return '_'
		}
		return r
	}, q.Callsign)
	path := filepath.Join(outbox, fmt.Sprintf("%s_%s.jpg", safeCall, q.QSODate.UTC().Format("20060102_1504")))
	if err := os.WriteFile(path, data, 0o644); err != nil {
		return fmt.Errorf("write outbox file: %w", err)
	}

	s, _ := a.GetEmailSettings()
	subject, _ := a.settings.Get(a.ctx, keyQSLEmailSubject)
	if subject == "" {
		subject = defaultQSLEmailSubject
	}
	body, _ := a.settings.Get(a.ctx, keyQSLEmailBody)
	if body == "" {
		body = defaultQSLEmailBody
	}
	if err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), path); err != nil {
		applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
		return err
	}
	// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
	// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
	//
	// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
	// read up top is now stale after the slow e-mail send, and rewriting the whole
	// row would revert any column an auto-upload changed meanwhile — that's how
	// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
	if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
		applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
		return fmt.Errorf("QSL card sent but status not saved: %w", err)
	}
	applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
	wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
	return nil
}

// qslDefaultMessage returns the operator's default QSL message (Settings), used
// on the card when a QSO has no QSLMSG of its own.
func (a *App) qslDefaultMessage() string {
	if a.settings == nil {
		return ""
	}
	s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
	return s
}

// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	v := ""
	if on {
		v = time.Now().UTC().Format(time.RFC3339)
	}
	return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
}

// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
// the default QSL message printed on the card when a QSO has none of its own.
type QSLEmailTemplates struct {
	Subject        string `json:"subject"`
	Body           string `json:"body"`
	AutoSend       bool   `json:"auto_send"`
	DefaultMessage string `json:"default_message"`
}

// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
	out := QSLEmailTemplates{Subject: defaultQSLEmailSubject, Body: defaultQSLEmailBody}
	if a.settings == nil {
		return out, nil
	}
	m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
	if err != nil {
		return out, err
	}
	if s := m[keyQSLEmailSubject]; s != "" {
		out.Subject = s
	}
	if b := m[keyQSLEmailBody]; b != "" {
		out.Body = b
	}
	out.AutoSend = m[keyQSLAutoSend] == "1"
	out.DefaultMessage = m[keyQSLDefaultMsg]
	return out, nil
}

// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag.
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
		return err
	}
	if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
		return err
	}
	v := "0"
	if t.AutoSend {
		v = "1"
	}
	if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
		return err
	}
	return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
}

// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
// user enabled auto-send and the prerequisites hold: a recipient e-mail, a
// default template for the active profile, and the QSO not already confirmed.
// Rasterization happens in the webview, so this only emits an event ("qsl:autosend")
// the frontend acts on — never sends from here. Silent when any condition fails.
func (a *App) maybeAutoSendEQSL(q qso.QSO) {
	if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil {
		return
	}
	if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
		return
	}
	if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
		return
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return
	}
	rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
	if err != nil {
		applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err)
		return
	}
	wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{
		"qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign,
	})
}

// ── helpers ─────────────────────────────────────────────────────────────

func encodeRenderModel(m qslcard.RenderModel) (string, error) {
	b, err := json.Marshal(m)
	if err != nil {
		return "", fmt.Errorf("encode render model: %w", err)
	}
	return string(b), nil
}

// qslProfileInfo extracts the placement-engine inputs from the active
// profile, filling missing zones from cty.dat.
func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
	if a.profiles == nil {
		return qslcard.ProfileInfo{}, fmt.Errorf("db not initialized")
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return qslcard.ProfileInfo{}, fmt.Errorf("no active profile: %w", err)
	}
	info := qslcard.ProfileInfo{
		Callsign: p.Callsign,
		Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
		Grid:     p.MyGrid,
		Rig:      p.MyRig,
		Antenna:  p.MyAntenna,
	}
	if p.MyCQZone != nil {
		info.CQZone = *p.MyCQZone
	}
	if p.MyITUZone != nil {
		info.ITUZone = *p.MyITUZone
	}
	if (info.CQZone == 0 || info.ITUZone == 0) && a.dxcc != nil {
		if m, ok := a.dxcc.Lookup(p.Callsign); ok {
			if info.CQZone == 0 {
				info.CQZone = m.CQZone
			}
			if info.ITUZone == 0 {
				info.ITUZone = m.ITUZone
			}
		}
	}
	// Most users define rig/antenna in the operating manager (band defaults),
	// not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band
	// default so the designer knows the station line has data to show.
	if (info.Rig == "" || info.Antenna == "") && a.operating != nil {
		for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} {
			d, _, e := a.operating.BandDefault(a.ctx, p.ID, band)
			if e != nil {
				continue
			}
			if info.Rig == "" {
				info.Rig = d.StationName
			}
			if info.Antenna == "" {
				info.Antenna = d.AntennaName
			}
			if info.Rig != "" && info.Antenna != "" {
				break
			}
		}
	}
	return info, nil
}

// qslRigAntenna resolves the rig and antenna names for a QSO: the values
// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's
// default for the QSO's band (so the preview and back-entered QSOs aren't
// blank).
func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) {
	rig, ant = q.MyRig, q.MyAntenna
	if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil {
		return rig, ant
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return rig, ant
	}
	band := q.Band
	if band == "" {
		band = "20m"
	}
	if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil {
		if rig == "" {
			rig = d.StationName
		}
		if ant == "" {
			ant = d.AntennaName
		}
	}
	return rig, ant
}

// qslVars builds the full placeholder map (profile + QSO) and the country
// block resolution for one render.
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
	info, err := a.qslProfileInfo()
	if err != nil {
		return nil, qslcard.CountryInfo{}, err
	}
	zone := func(z int) string { // unknown zone reads better blank than "0"
		if z == 0 {
			return ""
		}
		return strconv.Itoa(z)
	}
	// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
	// operator's default message (Settings) is used instead.
	msg := q.QSLMsg
	if strings.TrimSpace(msg) == "" {
		msg = a.qslDefaultMessage()
	}
	// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
	// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
	pseTnx := "PSE QSL"
	if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
		pseTnx = "TNX QSL"
	}
	vars := map[string]string{
		"profile.callsign":      info.Callsign,
		"profile.operator_name": info.Operator,
		"profile.grid":          info.Grid,
		"profile.cq_zone":       zone(info.CQZone),
		"profile.itu_zone":      zone(info.ITUZone),
		"profile.rig":           info.Rig,
		"profile.antenna":       info.Antenna,

		"qso.callsign": q.Callsign,
		"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
		"qso.time_on":  q.QSODate.UTC().Format("15:04"),
		"qso.band":     q.Band,
		"qso.mode":     q.Mode,
		"qso.submode":  q.Submode,
		"qso.rst_sent": q.RSTSent,
		"qso.qsl_msg":  msg,
		"qso.pse_tnx":  pseTnx,
		"qso.name":     q.Name,
	}
	vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
	if q.FreqHz != nil {
		// Megahertz, without the unit. The column is labelled FREQ and the box is
		// narrow — "10.136 MHz" ran into the MODE column beside it, and a card that
		// says FREQ 10.136 has never confused anybody.
		vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
	}
	return vars, a.qslCountryInfo(), nil
}

// qslCountryInfo resolves the operator's own country (label + flag) from the
// active profile: explicit MY_DXCC wins, then a cty.dat lookup of the
// callsign. No match → label only or nothing (graceful).
func (a *App) qslCountryInfo() qslcard.CountryInfo {
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return qslcard.CountryInfo{}
	}
	out := qslcard.CountryInfo{Label: p.MyCountry}
	entityNum := 0
	if p.MyDXCC != nil {
		entityNum = *p.MyDXCC
		if out.Label == "" {
			out.Label = dxcc.NameForDXCC(entityNum)
		}
	} else if a.dxcc != nil {
		if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
			if out.Label == "" {
				out.Label = m.Entity.Name
			}
			entityNum = dxcc.EntityDXCC(m.Entity.Name)
		}
	}
	out.FlagISO = qslcard.FlagISO(entityNum)
	return out
}

// sampleQSO is the canned contact used by the editor's live preview.
//
// Every field the QSO box can show must be FILLED here, and filled with a value
// of realistic width. The preview is the only place an operator can see whether
// a column fits before printing a card — a blank one silently promises room it
// will not have. The frequency is the widest of them, hence a real 20 m SSB one
// rather than a round number.
func sampleQSO() qso.QSO {
	hz := int64(14285000)
	return qso.QSO{
		Callsign: "DL1ABC",
		QSODate:  time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
		Band:     "20m",
		Mode:     "SSB",
		Submode:  "USB",
		FreqHz:   &hz,
		RSTSent:  "59",
		QSLMsg:   "TNX FB QSO — 73!",
	}
}

// QSLForeignTemplate is one card design belonging to ANOTHER profile, offered
// for copying into the active one.
type QSLForeignTemplate struct {
	ID          int64  `json:"id"`
	Name        string `json:"name"`
	ProfileID   int64  `json:"profile_id"`
	ProfileName string `json:"profile_name"`
}

// QSLListOtherProfileTemplates lists the designs the ACTIVE profile cannot see.
//
// A second profile is usually the same operator with a different rig or a
// different locator — same callsign, same cards. Redrawing a design because the
// antenna changed is work nobody should have to do, and the designs a profile
// already sees (its own, and the shared ones) are deliberately left out: they
// are not something to copy.
func (a *App) QSLListOtherProfileTemplates() ([]QSLForeignTemplate, error) {
	if a.qslTemplates == nil || a.profiles == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	active := int64(-1)
	if p, err := a.profiles.Active(a.ctx); err == nil {
		active = p.ID
	}
	names := map[int64]string{}
	if list, err := a.ListProfiles(); err == nil {
		for _, p := range list {
			names[p.ID] = p.Name
		}
	}
	all, err := a.qslTemplates.List(a.ctx)
	if err != nil {
		return nil, err
	}
	out := []QSLForeignTemplate{}
	for _, r := range all {
		// Shared designs (no profile) already appear in every profile's list.
		if r.ProfileID == nil || *r.ProfileID == active {
			continue
		}
		out = append(out, QSLForeignTemplate{
			ID: r.ID, Name: r.Name, ProfileID: *r.ProfileID,
			ProfileName: firstNonEmptyStr(names[*r.ProfileID], fmt.Sprintf("profile %d", *r.ProfileID)),
		})
	}
	return out, nil
}

// QSLCopyTemplateToActiveProfile duplicates another profile's design into the
// active one and returns the new id.
//
// A COPY, not a move or a share. The original profile keeps its design
// untouched, and the copy is free to diverge — a second profile usually exists
// because something differs, and that something often ends up on the card.
func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) {
	if a.qslTemplates == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	src, err := a.qslTemplates.Get(a.ctx, id)
	if err != nil {
		return 0, err
	}
	// The stored document references photos by NAME, relative to the template's
	// own asset folder — so copying the JSON alone would point the new design at
	// files that are not in its folder, and the save would fail validation.
	//
	// The files are copied too, giving the duplicate its own folder. Sharing the
	// source's would mean deleting one design silently emptied the other.
	rec := qslcard.Record{Name: src.Name + " (copy)", JSON: src.JSON}
	if p, e := a.profiles.Active(a.ctx); e == nil {
		rec.ProfileID = &p.ID
	}
	// Saved first: a new template needs its id before it can own a folder.
	if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
		return 0, err
	}
	srcDir := qslcard.TemplateDir(a.qslDir(), id)
	dstDir := qslcard.TemplateDir(a.qslDir(), rec.ID)
	// A design with no pictures at all has no folder, and that is not a failure.
	// Anything else IS: this used to be written as "ignore os.IsNotExist", which
	// also swallowed every ENOENT raised while copying — so a failed copy looked
	// like a design that had nothing to copy, and the operator got "hero photo
	// not found" with no clue why.
	if _, statErr := os.Stat(srcDir); statErr == nil {
		if err := copyDirContents(srcDir, dstDir); err != nil {
			// Roll back rather than leave a design whose pictures are missing.
			_ = a.qslTemplates.Delete(a.ctx, rec.ID)
			_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
			return 0, fmt.Errorf("copy template assets: %w", err)
		}
	}
	// Every failure from here rolls back BOTH halves: deleting only the row would
	// leave the copied pictures on disk under an id nothing refers to.
	rollback := func() {
		_ = a.qslTemplates.Delete(a.ctx, rec.ID)
		_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
	}
	t, err := qslcard.Parse([]byte(rec.JSON))
	if err != nil {
		rollback()
		return 0, err
	}
	if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dstDir)); err != nil {
		rollback()
		return 0, fmt.Errorf("copied design is incomplete: %w", err)
	}
	applog.Printf("qsl: copied template %q (id %d) into the active profile as %q (id %d)",
		src.Name, id, rec.Name, rec.ID)
	return rec.ID, nil
}

// copyQSLTemplatesToProfile duplicates one profile's QSL designs into another,
// PICTURES INCLUDED.
//
// This was an INSERT … SELECT, which cloned the rows and nothing else. A design
// references its photos by name, relative to its own asset folder, so every
// duplicated design pointed at a folder that did not exist: no thumbnail in the
// designer, and nothing to print. The rows looked right, which is why it went
// unnoticed — the damage is entirely on disk.
//
// Row by row rather than in one statement, because each new design needs its id
// before it can own a folder. A design whose files cannot be copied is dropped
// rather than kept empty: an operator who sees the design listed will believe
// it works.
func (a *App) copyQSLTemplatesToProfile(fromProfile, toProfile int64) error {
	if a.qslTemplates == nil {
		return fmt.Errorf("db not initialized")
	}
	all, err := a.qslTemplates.List(a.ctx)
	if err != nil {
		return err
	}
	for _, src := range all {
		if src.ProfileID == nil || *src.ProfileID != fromProfile {
			continue
		}
		rec := qslcard.Record{Name: src.Name, JSON: src.JSON, IsDefault: src.IsDefault}
		to := toProfile
		rec.ProfileID = &to
		if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
			return err
		}
		srcDir := qslcard.TemplateDir(a.qslDir(), src.ID)
		if _, statErr := os.Stat(srcDir); statErr != nil {
			continue // a design with no pictures at all — nothing to carry over
		}
		if err := copyDirContents(srcDir, qslcard.TemplateDir(a.qslDir(), rec.ID)); err != nil {
			_ = a.qslTemplates.Delete(a.ctx, rec.ID)
			_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
			return fmt.Errorf("copy assets of %q: %w", src.Name, err)
		}
	}
	return nil
}

func firstNonEmptyStr(a, b string) string {
	if strings.TrimSpace(a) != "" {
		return a
	}
	return b
}
