package main

// Reading confirmations back from a HAMLOG.online ADIF export.
//
// Their agent protocol only uploads — it has no verb for asking what has been
// confirmed — so the return path is a file: their site exports an ADIF, and the
// operator feeds it back here.
//
// That file must NOT be imported the ordinary way. A plain import matches a
// record to a local QSO on callsign + UTC MINUTE + band + mode, and their
// export is rebuilt from their own database: a minute of rounding, SSB where
// the log says USB, and the key no longer matches. "Update duplicates" then
// does what it is told — no duplicate found, so it inserts — and a few hundred
// copies of contacts the operator already had land in the log. That is exactly
// what happened once, and it is why this path exists instead.
//
// So: match only, never insert. Confirmations are stamped onto the QSOs already
// in the log, and anything that cannot be matched is REPORTED rather than
// added, because an unmatched confirmation is a question about the log (a
// minute off, a portable call) and not a contact to create.

import (
	"context"
	"fmt"
	"os"
	"strings"
	"time"

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

	"hamlog/internal/adif"
	"hamlog/internal/applog"
	"hamlog/internal/award"
	"hamlog/internal/qso"
)

// hamlogUnmatchedMax bounds what is kept for export. A whole log's worth of
// unmatched records means the file belongs to another station, not that the
// operator wants 50 000 of them written back out.
const hamlogUnmatchedMax = 20000

// HamlogCfmResult is what the import did.
type HamlogCfmResult struct {
	Total     int `json:"total"`     // records read from the file
	Confirmed int `json:"confirmed"` // records carrying HAMLOG's confirmation flag
	Matched   int `json:"matched"`   // local QSOs stamped
	ByClass   int `json:"by_class"`  // matched on mode CLASS rather than exact mode
	Unmatched int `json:"unmatched"` // confirmations with no local QSO
	// Samples names a few unmatched contacts, so "12 unmatched" can be looked
	// into rather than merely worried about. The full list is kept for export —
	// see ExportHamlogUnmatched — because 395 of them is not a sample-sized
	// problem: it is a list to work through.
	Samples []string `json:"samples"`
}

// hamlogCfmSamples caps the reported list — enough to see the pattern, not so
// many that the dialog becomes a log file.
const hamlogCfmSamples = 30

// ImportHamlogConfirmations stamps the confirmations from a HAMLOG.online ADIF
// export onto the matching local QSOs. It inserts nothing.
func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
	var res HamlogCfmResult
	if a.qso == nil {
		return res, fmt.Errorf("db not initialized")
	}
	if strings.TrimSpace(path) == "" {
		return res, fmt.Errorf("empty path")
	}
	f, err := os.Open(path)
	if err != nil {
		return res, err
	}
	defer f.Close()

	ctx := a.ctx
	if ctx == nil {
		ctx = context.Background()
	}
	// The same two indexes the LoTW download uses: the exact key, then the
	// mode-CLASS key for the contacts whose mode was written differently at the
	// other end (FT8 exported as DATA, SSB where the log says USB).
	keyIDs, err := a.qso.DedupeKeyIDs(ctx)
	if err != nil {
		return res, fmt.Errorf("read local log: %w", err)
	}
	classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)

	emit := func(line string) {
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
		}
	}
	emit("Reading " + path + "…")

	// What already counts towards an award, so each confirmation can be flagged
	// NEW. LoTW and paper QSL are the two award-valid sources; a HAMLOG
	// confirmation is "new" when it lands on a slot neither of them holds — which
	// is the only sense in which it changes anything.
	sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
	var items []ConfirmationItem
	var unmatched []qso.QSO

	perr := adif.Parse(f, func(rec adif.Record) error {
		q, ok := adif.RecordToQSO(rec)
		if !ok {
			return nil
		}
		res.Total++
		// Only the records they actually confirmed. The export carries the whole
		// log, and stamping "confirmed" on all of it would turn every uploaded
		// contact into a confirmed one.
		if !hamlogRecordConfirmed(rec) {
			return nil
		}
		res.Confirmed++

		minute := q.QSODate.UTC().Format("2006-01-02T15:04")
		id, found := keyIDs[qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)]
		if !found {
			// id 0 means the class key is ambiguous — several local QSOs share
			// it — and guessing between them would stamp the wrong one.
			if cid, ok := classIDs[qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)]; ok && cid != 0 {
				id, found = cid, true
				res.ByClass++
			}
		}
		if !found {
			res.Unmatched++
			if len(res.Samples) < hamlogCfmSamples {
				res.Samples = append(res.Samples, fmt.Sprintf("%s · %s · %s · %s",
					q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
			}
			if len(unmatched) < hamlogUnmatchedMax {
				unmatched = append(unmatched, q)
			}
			return nil
		}
		date := hamlogCfmDate(rec)
		if e := a.qso.SetExtra(ctx, id, award.HamlogQSLKey, "Y"); e != nil {
			return nil
		}
		_ = a.qso.SetExtra(ctx, id, hamlogQSLDateKey, date)
		// A confirmed contact is by definition one they hold, so the sent side
		// is true whether or not OpsLog is what uploaded it — a log uploaded
		// from their website would otherwise read "never sent, yet confirmed".
		_ = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y")
		res.Matched++

		// Feed the Results view, the same rows the LoTW download produces: an
		// import that only prints counts leaves the operator with no way to see
		// WHICH contacts were confirmed, which is the reason they ran it.
		a.enrichContactedFromCty(&q) // country/dxcc, for the entity flags
		it := ConfirmationItem{
			Callsign: q.Callsign,
			QSODate:  q.QSODate.UTC().Format(time.RFC3339),
			Band:     q.Band,
			Mode:     q.Mode,
			Country:  q.Country,
		}
		if q.DXCC != nil && *q.DXCC != 0 {
			n := *q.DXCC
			it.NewDXCC = !sets.DXCC[n]
			it.NewBand = !sets.Band[qso.BandKey(n, q.Band)]
			it.NewMode = !sets.Mode[qso.ModeClassKey(n, q.Mode)]
			it.NewSlot = !sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)]
			// Fold it in, so a repeat inside the same file isn't flagged twice.
			sets.DXCC[n] = true
			sets.Band[qso.BandKey(n, q.Band)] = true
			sets.Mode[qso.ModeClassKey(n, q.Mode)] = true
			sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)] = true
		}
		items = append(items, it)
		return nil
	})
	if perr != nil {
		return res, perr
	}
	a.invalidateAwardStats() // confirmations move award counts
	// Kept for ExportHamlogUnmatched. Held rather than written now: the operator
	// decides whether a list of 395 is worth a file.
	a.hamlogUnmatchedMu.Lock()
	a.hamlogUnmatched = unmatched
	a.hamlogUnmatchedMu.Unlock()
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
	}
	applog.Printf("hamlog cfm import: %d records, %d confirmed, %d matched (%d by mode class), %d unmatched",
		res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched)
	emit(fmt.Sprintf("%d records read, %d confirmed by HAMLOG.online, %d matched in the log (%d by mode class), %d unmatched",
		res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched))
	for _, s := range res.Samples {
		emit("  unmatched: " + s)
	}
	if res.Unmatched > len(res.Samples) {
		emit(fmt.Sprintf("  …and %d more — use \"Export unmatched (ADIF)…\" to get the whole list.",
			res.Unmatched-len(res.Samples)))
	}
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": res.Matched, "total": res.Confirmed})
	}
	return res, nil
}

// ExportHamlogUnmatched writes the confirmations the last import could not
// place onto a QSO.
//
// They are the interesting half of the result: each one is a contact HAMLOG
// believes it holds and this log does not agree about — a minute of drift, a
// portable call, a band written differently, or a QSO genuinely missing. A
// count cannot be worked through; a file can be opened, sorted and compared.
//
// Written as ADIF because that is what every other tool reads, and because it
// can be handed straight back to an import once the discrepancies are settled.
func (a *App) ExportHamlogUnmatched(path string) (int, error) {
	a.hamlogUnmatchedMu.Lock()
	rows := a.hamlogUnmatched
	a.hamlogUnmatchedMu.Unlock()
	if len(rows) == 0 {
		return 0, fmt.Errorf("nothing to export: the last import left no unmatched confirmations")
	}
	if strings.TrimSpace(path) == "" {
		return 0, fmt.Errorf("empty path")
	}
	recs := make([]string, 0, len(rows))
	for i := range rows {
		recs = append(recs, adif.FullRecordADIF(rows[i]))
	}
	if err := os.WriteFile(path, []byte(adif.BatchRecordsADIF(recs)), 0o644); err != nil {
		return 0, err
	}
	applog.Printf("hamlog cfm import: exported %d unmatched confirmation(s) to %s", len(rows), path)
	return len(rows), nil
}

// hamlogQSLDateKey stamps WHEN the confirmation was read back. Their export
// carries no confirmation date of its own, so this is the import date — which
// is honest about what it knows, unlike borrowing the QSO date.
const hamlogQSLDateKey = "APP_OPSLOG_HAMLOG_QSL_DATE"

// hamlogRecordConfirmed reads their flag off a raw ADIF record. The primary key
// is the one their own export writes; the alternates are the names OpsLog and
// other tools have used, so a file that went through another logger still reads.
func hamlogRecordConfirmed(rec adif.Record) bool {
	keys := append([]string{award.HamlogQSLKey}, award.HamlogAltKeys()...)
	for _, k := range keys {
		v := strings.ToUpper(strings.TrimSpace(rec[strings.ToLower(k)]))
		if v != "" && v != "N" && v != "NO" {
			return true
		}
	}
	return false
}

// hamlogCfmDate is the date to stamp: the import date, in ADIF form.
func hamlogCfmDate(rec adif.Record) string {
	// If a future export ever carries one, take it rather than today's date.
	for _, k := range []string{"app_hamlog_qso_cfm_date", "qslrdate"} {
		if v := strings.TrimSpace(rec[k]); len(v) == 8 {
			return v
		}
	}
	return time.Now().UTC().Format("20060102")
}
