package extsvc

import (
	"context"
	"fmt"
	"math/rand"
	"net/http"
	"strings"
	"sync"
	"time"
)

// baseCall extracts the operator's base callsign from a possibly-affixed call:
// for slashed forms (F4BPO/P, FW/F4BPO, 9A/F4BPO/P) it returns the longest
// token, which is the real call; otherwise the call itself. Upper-cased.
func baseCall(s string) string {
	s = strings.ToUpper(strings.TrimSpace(s))
	if !strings.Contains(s, "/") {
		return s
	}
	best := ""
	for _, part := range strings.Split(s, "/") {
		if len(part) > len(best) {
			best = part
		}
	}
	return best
}

// sameBaseCall reports whether two callsigns belong to the same operator,
// ignoring portable prefixes/suffixes (F4BPO/P == F4BPO, FW/F4BPO == F4BPO).
func sameBaseCall(a, b string) bool {
	return baseCall(a) == baseCall(b)
}

// SameBaseCall is the exported form of sameBaseCall, so the host app can apply
// the same "same operator?" rule when filtering an on-close upload batch by the
// active logbook's callsign.
func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) }

// Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable.
type Deps struct {
	Client *http.Client

	// BuildADIF returns the ADIF record for a QSO id, with STATION_CALLSIGN
	// overridden by forceCall when non-empty. ok=false means "skip silently"
	// (row gone, missing required fields, …).
	BuildADIF func(id int64, forceCall string) (record string, ok bool)

	// MarkUploaded stamps the per-service upload status on the QSO row and
	// notifies the UI. Called once, on success.
	MarkUploaded func(svc Service, id int64, logID string)

	// NotifyError surfaces a failed upload (logging + optional UI event).
	NotifyError func(svc Service, id int64, err error)

	// ShouldUpload reports whether a QSO is eligible for upload to this
	// service, based on its sent status: QRZ/Club Log upload anything not
	// yet "Y"; LoTW uploads only QSOs whose lotw_sent matches the configured
	// Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO.
	ShouldUpload func(svc Service, id int64) bool

	// StationCallOf returns the QSO's STATION_CALLSIGN. Used to guard against
	// uploading a QSO into a logbook for a different callsign (the force-call
	// option would otherwise silently relabel it). "" → no station call known.
	StationCallOf func(id int64) string

	// CloseUploadIDs returns the QSO ids to upload for a service when the app
	// closes — scanning the WHOLE logbook, not just this session: LoTW returns
	// rows whose lotw_sent matches the configured status set; QRZ/Club Log
	// return anything not yet "Y". This is what makes an imported ADIF (old
	// QSOs still marked unsent) upload on close. nil → nothing to do.
	CloseUploadIDs func(svc Service) []int64

	// Logf is an optional diagnostic logger.
	Logf func(format string, args ...any)
}

// Manager owns the external-service config snapshot and schedules uploads
// when a QSO is logged. Immediate uploads run in their own goroutine;
// delayed uploads use a timer with a random 1–2 minute fuse.
type Manager struct {
	deps Deps

	mu               sync.Mutex
	cfg              ExternalServices
	rnd              *rand.Rand
	hamlogClosedOnce sync.Once
}

// maxUploadAttempts bounds retries of a transient upload failure.
const maxUploadAttempts = 4

func NewManager(deps Deps) *Manager {
	if deps.Client == nil {
		deps.Client = &http.Client{Timeout: 20 * time.Second}
	}
	return &Manager{
		deps: deps,
		// Seeded from the clock; the delay only needs to be unpredictable
		// enough to spread bursts, not cryptographically random.
		rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
	}
}

// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
// independent — never serialised through a shared worker, because a single slow
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
// every following QSO's upload and strand them all at "R" (the regression that hit
// the operator on the newest build while everyone on the old concurrent path was
// fine).
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
	go func() {
		ok, retryable := m.upload(svc, id, cfg)
		if !ok && retryable && attempt+1 < maxUploadAttempts {
			backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
			m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
			time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
		}
	}()
}

func (m *Manager) logf(format string, args ...any) {
	if m.deps.Logf != nil {
		m.deps.Logf(format, args...)
	}
}

// SetConfig replaces the active config snapshot (called after the user
// saves the External Services settings).
func (m *Manager) SetConfig(cfg ExternalServices) {
	m.mu.Lock()
	defer m.mu.Unlock()
	cfg.QRZ = cfg.QRZ.normalised()
	cfg.Clublog = cfg.Clublog.normalised()
	cfg.LoTW = cfg.LoTW.normalised()
	cfg.HRDLog = cfg.HRDLog.normalised()
	cfg.EQSL = cfg.EQSL.normalised()
	cfg.Cloudlog = cfg.Cloudlog.normalised()
	cfg.Hamlog = cfg.Hamlog.normalised()
	cfg.HamQTH = cfg.HamQTH.normalised()
	m.cfg = cfg

	// Summary of what is armed, written at startup and on every settings save.
	// It answers "is the service even switched on for this profile?" — the
	// settings are per-profile, so a service configured under another profile
	// looks enabled in the UI of the one and silent in the other.
	var on []string
	for _, s := range []struct {
		name string
		cfg  ServiceConfig
	}{
		{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
		{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
		{"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
	} {
		if s.cfg.AutoUpload {
			on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
		}
	}
	if len(on) == 0 {
		m.logf("extsvc: auto-upload disabled for every service")
	} else {
		m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
	}
}

// Config returns the current snapshot.
func (m *Manager) Config() ExternalServices {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.cfg
}

// delaySeconds returns a random 60–120s fuse for delayed uploads.
func (m *Manager) delaySeconds() time.Duration {
	m.mu.Lock()
	d := 60 + m.rnd.Intn(61) // [60, 120]
	m.mu.Unlock()
	return time.Duration(d) * time.Second
}

// OnQSOLogged is called after a QSO is inserted (manual entry or UDP
// auto-log). It fans out to every enabled, auto-upload service in the
// configured timing mode. Returns immediately.
func (m *Manager) OnQSOLogged(id int64) {
	cfg := m.Config()

	// QRZ.com
	if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
		m.route(ServiceQRZ, id, qrz)
	}
	// Club Log — email + password + callsign are enough (no API key).
	if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
		m.route(ServiceClublog, id, cl)
	}
	// LoTW — needs TQSL + a station location.
	if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
		m.route(ServiceLoTW, id, lt)
	}
	// HRDLog — needs the station callsign + the account upload code.
	if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
		m.route(ServiceHRDLog, id, h)
	}
	// eQSL — needs the account username (callsign) + password.
	if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
		m.route(ServiceEQSL, id, e)
	}
	// Cloudlog / Wavelog — the instance URL, an API key and the station id.
	if c := cfg.Cloudlog; c.AutoUpload {
		// Say WHY nothing happens when the toggle is on but a field is missing.
		// Without this the whole path was silent — the operator saw no upload and
		// no log line, with no way to tell "disabled" from "broken".
		var missing []string
		if c.URL == "" {
			missing = append(missing, "URL")
		}
		if c.APIKey == "" {
			missing = append(missing, "API key")
		}
		if c.StationID == "" {
			missing = append(missing, "station ID")
		}
		if len(missing) > 0 {
			m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
				strings.Join(missing, ", "), id)
		} else {
			m.route(ServiceCloudlog, id, c)
		}
	}
	// HAMLOG.online is closed to uploads — see ErrHamlogClosed. Said once per
	// session rather than per QSO, because an operator who left the switch on
	// deserves to know why nothing leaves, and does not deserve it every minute.
	if cfg.Hamlog.AutoUpload {
		m.hamlogClosedOnce.Do(func() {
			m.logf("extsvc: %v", ErrHamlogClosed)
		})
	}
	// HamQTH — the callbook credentials double as the logbook login.
	if h := cfg.HamQTH; h.AutoUpload {
		if h.Username == "" || h.Password == "" {
			m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id)
		} else {
			m.route(ServiceHamQTH, id, h)
		}
	}
}

// route sends a logged QSO down the configured timing path: queue it for the
// app-close batch, or schedule an immediate / delayed upload.
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
	if cfg.UploadMode == ModeOnClose {
		// Nothing to queue: on-close upload sweeps the whole logbook from the
		// database at shutdown (see FlushOnClose), so this QSO is picked up by
		// its sent-status then — no in-memory tracking needed.
		return
	}
	m.scheduleUpload(svc, id, cfg)
}

// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
// one never holds up the rest.
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
	if cfg.UploadMode == ModeDelayed {
		d := m.delaySeconds()
		m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
		time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
		return
	}
	m.attemptUpload(svc, id, cfg, 0)
}

// onCloseServices returns the services configured for on-close auto-upload,
// with the minimum credentials to actually run.
func (m *Manager) onCloseServices() []Service {
	cfg := m.Config()
	var out []Service
	if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" {
		out = append(out, ServiceQRZ)
	}
	if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" {
		out = append(out, ServiceClublog)
	}
	if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
		out = append(out, ServiceLoTW)
	}
	if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
		out = append(out, ServiceHRDLog)
	}
	if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
		out = append(out, ServiceEQSL)
	}
	if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
		out = append(out, ServiceCloudlog)
	}

	if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
		out = append(out, ServiceHamQTH)
	}
	return out
}

// CloseUploadCount returns how many QSOs across the whole logbook would be
// uploaded at app close (sum over every on-close service). The shutdown
// sequence uses it to decide whether to show the upload step and its label.
func (m *Manager) CloseUploadCount() int {
	if m.deps.CloseUploadIDs == nil {
		return 0
	}
	n := 0
	for _, svc := range m.onCloseServices() {
		n += len(m.deps.CloseUploadIDs(svc))
	}
	return n
}

// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
// logbook (not just this session). Called from the shutdown sequence. QRZ and
// the rest go one-by-one (fast HTTP, no batch API); LoTW is signed and uploaded
// as a single TQSL batch, and Club Log goes through its batch endpoint.
// Returns the number of QSOs uploaded successfully.
func (m *Manager) FlushOnClose() int {
	if m.deps.CloseUploadIDs == nil {
		return 0
	}
	cfg := m.Config()
	uploaded := 0
	for _, svc := range m.onCloseServices() {
		ids := m.deps.CloseUploadIDs(svc)
		if len(ids) == 0 {
			continue
		}
		switch svc {
		case ServiceLoTW:
			uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
		case ServiceClublog:
			uploaded += m.flushClublogBatch(ids, cfg.Clublog)
		case ServiceEQSL:
			uploaded += m.flushEQSLBatch(ids, cfg.EQSL)
		case ServiceQRZ:
			uploaded += m.flushOneByOne(svc, ids, cfg.QRZ)
		case ServiceHRDLog:
			uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
		case ServiceCloudlog:
			uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)

		case ServiceHamQTH:
			uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
		}
	}
	return uploaded
}

// uploadPace is the shortest gap between two consecutive single-QSO uploads in
// an on-close sweep. QRZ, HRDLog and Cloudlog have no batch endpoint — HRDLog's
// NewEntry.aspx keeps only the first record of a multi-record ADIF — so a sweep
// of a freshly imported log is unavoidably one request per contact. It does not
// have to arrive as fast as the link allows, though: that burst is what a
// service reads as a robot, and what got an operator's IP threatened at Club Log
// (see flushClublogBatch). The gap costs nothing in practice, since a round trip
// to any of these already takes longer than it.
const uploadPace = 200 * time.Millisecond

// flushOneByOne uploads ids one request at a time, paced. For the services that
// have no batch API; everything else has its own flush<Service>Batch.
func (m *Manager) flushOneByOne(svc Service, ids []int64, cfg ServiceConfig) int {
	uploaded := 0
	for i, id := range ids {
		if i > 0 {
			time.Sleep(uploadPace)
		}
		if ok, _ := m.upload(svc, id, cfg); ok {
			uploaded++
		}
	}
	return uploaded
}

// eqslBatchChunk is how many QSOs go into one ImportADIF.cfm request. eQSL's own
// limit is ten times this (eqslBatchMax); the smaller chunk keeps one refused
// record from taking a thousand others down with it, and keeps the form body
// small enough to be unremarkable.
const eqslBatchChunk = 100

// flushEQSLBatch uploads the on-close eQSL QSOs through ImportADIF.cfm in
// batches instead of one request per contact. Same reasoning as
// flushClublogBatch — eQSL's import endpoint has always taken a whole file, so
// the one-at-a-time loop was making hundreds of requests it never needed to.
func (m *Manager) flushEQSLBatch(ids []int64, cfg ServiceConfig) int {
	uploaded := 0
	var records []string
	var kept []int64

	send := func() {
		if len(records) == 0 {
			return
		}
		// nil client: UploadEQSLBatch then builds one with a 30 s timeout rather
		// than reusing the 20 s budget of a single realtime QSO.
		res, err := UploadEQSLBatch(context.Background(), nil, cfg.Username, cfg.Password, cfg.QTHNickname, records)
		if err != nil || !res.OK {
			if err == nil {
				err = errFromResult(res)
			}
			m.logf("extsvc: eqsl batch upload (%d QSOs) failed: %v", len(kept), err)
			if m.deps.NotifyError != nil {
				m.deps.NotifyError(ServiceEQSL, 0, err)
			}
		} else {
			// res.Ignored means eQSL took the file but left records out. Say the
			// count out loud: the whole chunk is still marked sent (eQSL never
			// says WHICH it dropped, and in practice they are QSOs it already
			// had), so the log line is the only trace of the shortfall.
			if res.Ignored {
				m.logf("extsvc: eqsl batch upload PARTIAL (%d QSOs sent) %s", len(kept), res.Message)
			} else {
				m.logf("extsvc: eqsl batch upload OK (%d QSOs) %s", len(kept), res.Message)
			}
			if m.deps.MarkUploaded != nil {
				for _, id := range kept {
					m.deps.MarkUploaded(ServiceEQSL, id, res.LogID)
				}
			}
			uploaded += len(kept)
		}
		records = records[:0]
		kept = kept[:0]
	}

	for _, id := range ids {
		if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceEQSL, id) {
			continue
		}
		// eQSL keeps the QSO's own station call; the account is identified by the
		// credentials and the optional QTH nickname — as in upload().
		rec, ok := m.deps.BuildADIF(id, "")
		if !ok {
			continue
		}
		records = append(records, rec)
		kept = append(kept, id)
		if len(records) >= eqslBatchChunk {
			send()
		}
	}
	send()
	return uploaded
}

// clublogBatchChunk is how many QSOs go into one putlogs.php request. Club Log
// dedupes server-side, so chunking is not about correctness — it keeps a single
// malformed record from failing a whole ten-thousand-QSO document, and matches
// what the QSL Manager's bulk upload already uses.
const clublogBatchChunk = 100

// flushClublogBatch uploads the on-close Club Log QSOs through the BATCH
// endpoint (putlogs.php) rather than one realtime.php call each.
//
// It used to walk the ids and call UploadClublog per QSO. On-close upload sweeps
// the WHOLE logbook, so importing an ADIF — or simply switching Club Log on over
// an existing log — turned one app close into hundreds of realtime.php posts.
// That endpoint is reserved for an operator logging contacts as they work them,
// and Club Log blocks the IP of anything that batches through it: an OpsLog user
// was flagged by G7VJR for 185 QSOs in four minutes, which is this loop, not a
// pile-up. Batch upload is the mechanism Club Log provides for exactly this.
func (m *Manager) flushClublogBatch(ids []int64, cfg ServiceConfig) int {
	uploaded := 0
	var records []string
	var kept []int64

	send := func() {
		if len(records) == 0 {
			return
		}
		// nil client on purpose: UploadClublogADIF then builds one with a 120 s
		// timeout. m.deps.Client is the 20 s budget of a single realtime QSO,
		// which a hundred-QSO document on a slow link would blow through.
		res, err := UploadClublogADIF(context.Background(), nil, cfg, strings.Join(records, "\n"))
		if err != nil || !res.OK {
			if err == nil {
				err = errFromResult(res)
			}
			m.logf("extsvc: clublog batch upload (%d QSOs) failed: %v", len(kept), err)
			if m.deps.NotifyError != nil {
				m.deps.NotifyError(ServiceClublog, 0, err)
			}
		} else {
			m.logf("extsvc: clublog batch upload OK (%d QSOs) %s", len(kept), res.Message)
			if m.deps.MarkUploaded != nil {
				for _, id := range kept {
					m.deps.MarkUploaded(ServiceClublog, id, res.LogID)
				}
			}
			uploaded += len(kept)
		}
		records = records[:0]
		kept = kept[:0]
	}

	for _, id := range ids {
		// Skip QSOs not eligible (already sent). The wrong-logbook guard that
		// upload() applies per QSO is not repeated here: closeUploadIDs has
		// already filtered the sweep down to this logbook's callsign.
		if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceClublog, id) {
			continue
		}
		// Club Log takes the logbook callsign as its own form field, so the ADIF
		// keeps the QSO's own station call (no override) — as in upload().
		rec, ok := m.deps.BuildADIF(id, "")
		if !ok {
			continue
		}
		records = append(records, rec)
		kept = append(kept, id)
		if len(records) >= clublogBatchChunk {
			send()
		}
	}
	send()
	return uploaded
}

// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
// stamps each as uploaded on success.
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
	var records []string
	var kept []int64
	for _, id := range ids {
		// Skip QSOs not eligible (sent status doesn't match Upload flag).
		if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
			continue
		}
		// Override STATION_CALLSIGN so /P etc. signs against the base cert.
		if rec, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign); ok {
			records = append(records, rec)
			kept = append(kept, id)
		}
	}
	if len(records) == 0 {
		return 0
	}
	res, err := UploadLoTW(context.Background(), cfg, "", strings.Join(records, "\n"))
	if err != nil || !res.OK {
		if err == nil {
			err = errFromResult(res)
		}
		m.logf("extsvc: lotw batch upload (%d QSOs) failed: %v", len(kept), err)
		if m.deps.NotifyError != nil {
			m.deps.NotifyError(ServiceLoTW, 0, err)
		}
		return 0
	}
	m.logf("extsvc: lotw batch upload OK (%d QSOs)", len(kept))
	if m.deps.MarkUploaded != nil {
		for _, id := range kept {
			m.deps.MarkUploaded(ServiceLoTW, id, res.LogID)
		}
	}
	return len(kept)
}

// upload performs the actual push and returns true on success. It builds a
// fresh, lifecycle-independent context so a delayed upload still completes
// even if it fires close to shutdown.
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
// marked), and retryable=true when it failed in a way worth retrying later (an
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
// (not eligible, wrong station callsign, no record).
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable bool) {
	// Skip QSOs that aren't eligible (already sent, or sent status doesn't
	// match the configured Upload flag).
	if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
		m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
		return false, false
	}

	// Station-callsign guard. Each logbook belongs to one callsign:
	//   QRZ/LoTW → the ForceStationCallsign (the call this logbook signs as)
	//   Club Log → the logbook Callsign param
	// If the QSO's own STATION_CALLSIGN is a DIFFERENT operator, uploading
	// would push it into the wrong logbook (and the force-call option would
	// silently relabel it). Block it with a clear error. Portable variants of
	// the SAME call (F4BPO/P, FW/F4BPO…) are allowed.
	owner := ""
	switch svc {
	case ServiceQRZ, ServiceLoTW:
		owner = cfg.ForceStationCallsign
	case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
		owner = cfg.Callsign
	case ServiceEQSL:
		owner = cfg.Username
	}
	if owner != "" && m.deps.StationCallOf != nil {
		qcall := m.deps.StationCallOf(id)
		if qcall != "" && !sameBaseCall(qcall, owner) {
			err := fmt.Errorf("station callsign %s does not match %s logbook %s — not uploaded",
				strings.ToUpper(qcall), svc, strings.ToUpper(owner))
			m.logf("extsvc: %s upload of QSO %d BLOCKED: %v", svc, id, err)
			if m.deps.NotifyError != nil {
				m.deps.NotifyError(svc, id, err)
			}
			return false, false
		}
	}

	// One line per attempt, BEFORE the request: a failure that never returns
	// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
	// all, and "did it even try?" is the first question when an upload is missing.
	m.logf("extsvc: %s uploading QSO %d…", svc, id)

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	var res UploadResult
	var err error
	switch svc {
	case ServiceQRZ:
		// QRZ rewrites STATION_CALLSIGN to the registered call.
		record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
	case ServiceClublog:
		// Club Log takes the logbook callsign as a separate param, so the
		// ADIF keeps the QSO's own station call (no override).
		record, ok := m.deps.BuildADIF(id, "")
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
	case ServiceLoTW:
		// LoTW signs via TQSL; an optional force-call overrides STATION_CALLSIGN
		// so the same cert can sign F4BPO, F4BPO/P, TM2Q… per profile.
		record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadLoTW(ctx, cfg, "", record)
	case ServiceHRDLog:
		// HRDLog takes the station callsign as a separate param, so the ADIF
		// keeps the QSO's own station call (no override), like Club Log.
		record, ok := m.deps.BuildADIF(id, "")
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
	case ServiceEQSL:
		// eQSL keeps the QSO's own station call; the account is identified by
		// the Username + Password, with an optional QTH nickname.
		record, ok := m.deps.BuildADIF(id, "")
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
	case ServiceCloudlog:
		// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
		// so the ADIF keeps the QSO's own station call (no override).
		record, ok := m.deps.BuildADIF(id, "")
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
	case ServiceHamlog:
		// The station callsign is whatever the QSO carries: HAMLOG files the
		// contact under the account the API key belongs to, and KEYSTATUS is how
		// the operator checks that account is the right one.
		record, ok := m.deps.BuildADIF(id, "")
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
	case ServiceHamQTH:
		// The c parameter names the logbook when the account holds several;
		// the QSO keeps its own STATION_CALLSIGN in the ADIF.
		record, ok := m.deps.BuildADIF(id, "")
		if !ok {
			m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
			return false, false
		}
		res, err = UploadHamQTH(ctx, m.deps.Client, cfg, record)
	default:
		return false, false
	}

	if err != nil || !res.OK {
		if err == nil {
			err = errFromResult(res)
		}
		m.logf("extsvc: %s upload of QSO %d failed: %v", svc, id, err)
		if m.deps.NotifyError != nil {
			m.deps.NotifyError(svc, id, err)
		}
		return false, true // transient (rate-limit / network) → worth a retry
	}

	m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
	if m.deps.MarkUploaded != nil {
		m.deps.MarkUploaded(svc, id, res.LogID)
	}
	return true, false
}
