package adif

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"time"

	"hamlog/internal/qso"
)

// ExportResult summarises an ADIF export for the UI.
type ExportResult struct {
	Path   string `json:"path"`
	Count  int    `json:"count"`
	SizeKB int64  `json:"size_kb"`
}

// Exporter streams every QSO in a repo to an ADIF (.adi) file.
type Exporter struct {
	Repo *qso.Repo

	// AppName / AppVersion populate the ADIF header comments. Optional.
	AppName    string
	AppVersion string

	// IncludeAppFields controls whether application-specific fields (ADIF
	// "APP_<programid>_<name>" tags, e.g. Log4OM's APP_LOG4OM_* or our own
	// OpsLog extensions) are written. Leave false for a clean standard-ADIF
	// export destined for another logger; set true for a full OpsLog→OpsLog
	// round-trip that preserves everything.
	IncludeAppFields bool

	// Fields, when non-nil, restricts the export to exactly these ADIF tags
	// (uppercase) — the "choose which fields to export" mode. nil = write every
	// field (the standard/full behaviour above). When set it overrides
	// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
	Fields map[string]bool
}

// iterator streams QSOs through fn. The three concrete sources (all, filtered,
// by-ids) all match this shape so the document writer stays source-agnostic.
type iterator func(ctx context.Context, fn func(qso.QSO) error) error

// ExportFile creates path (overwriting if it exists) and writes every QSO.
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
	return e.exportFileWith(ctx, path, e.Repo.IterateAll)
}

// ExportFileFiltered writes only the QSOs matching f (no row limit).
func (e *Exporter) ExportFileFiltered(ctx context.Context, path string, f qso.QueryFilter) (ExportResult, error) {
	return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
		return e.Repo.IterateFiltered(ctx, f, fn)
	})
}

// ExportFileByIDs writes only the QSOs with the given ids.
func (e *Exporter) ExportFileByIDs(ctx context.Context, path string, ids []int64) (ExportResult, error) {
	return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
		return e.Repo.IterateByIDs(ctx, ids, fn)
	})
}

func (e *Exporter) exportFileWith(ctx context.Context, path string, iter iterator) (ExportResult, error) {
	f, err := os.Create(path)
	if err != nil {
		return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
	}
	defer f.Close()

	count, err := e.writeDoc(ctx, f, iter)
	if err != nil {
		return ExportResult{Path: path, Count: count}, err
	}
	info, _ := f.Stat()
	return ExportResult{Path: path, Count: count, SizeKB: info.Size() / 1024}, nil
}

// Export writes a complete ADIF document (header + records + EOF) to w for
// every QSO. Returns the number of QSOs written.
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
	return e.writeDoc(ctx, w, e.Repo.IterateAll)
}

// writeDoc writes the ADIF header then streams every QSO from iter.
func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (int, error) {
	bw := bufio.NewWriterSize(w, 64*1024)
	defer bw.Flush()

	app := strings.TrimSpace(e.AppName)
	if app == "" {
		app = "HamLog"
	}
	ver := strings.TrimSpace(e.AppVersion)
	now := time.Now().UTC().Format("20060102 150405")
	fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
	fmt.Fprintf(bw, "<ADIF_VER:%d>%s <PROGRAMID:%d>%s", len(adifVersion), adifVersion, len(app), app)
	if ver != "" {
		fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
	}
	fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)

	count := 0
	err := iter(ctx, func(q qso.QSO) error {
		writeRecord(bw, q, e.IncludeAppFields, e.Fields)
		count++
		return nil
	})
	return count, err
}

// SingleRecordADIF returns one QSO serialised as an ADIF record (fields
// terminated by <EOR>), with no document header. Used by the external-
// service uploaders (QRZ.com / Clublog / …) which want a bare record as
// the ADIF parameter of their HTTP API.
func SingleRecordADIF(q qso.QSO) string {
	var b strings.Builder
	bw := bufio.NewWriter(&b)
	// Uploads target other services — keep it standard (no app-specific tags),
	// and say nothing about the receive side when there is nothing to say: in
	// ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger
	// given both draws both. Wavelog reads a simplex FT8 contact uploaded with
	// BAND_RX filled in as split and shows it as "17m/17m".
	writeRecord(bw, q, false, nil)
	bw.Flush()
	return b.String()
}

// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link.
//
// The receive side is written even when it repeats the transmit side, which is
// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX
// and found nothing there for contacts logged by a path that left it blank. A
// logger on the same desk is being handed a copy of our record, not published
// to a service that will draw conclusions from every tag present.
func ForwardRecordADIF(q qso.QSO) string {
	var b strings.Builder
	bw := bufio.NewWriter(&b)
	writeRecord(bw, q, false, nil, keepRX)
	bw.Flush()
	return b.String()
}

// keepRX marks a record whose receive side must be written out in full.
type rxMode int

const keepRX rxMode = 1

// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
// so it can be written out and read back with nothing dropped. Used by the
// offline queue: a QSO parked in the safety file must come back identical
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
func FullRecordADIF(q qso.QSO) string {
	var b strings.Builder
	bw := bufio.NewWriter(&b)
	writeRecord(bw, q, true, nil)
	bw.Flush()
	return b.String()
}

// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
func BatchRecordsADIF(records []string) string {
	var b strings.Builder
	now := time.Now().UTC().Format("20060102 150405")
	fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
		len(adifVersion), adifVersion, now)
	for _, r := range records {
		r = strings.TrimRight(r, "\r\n")
		if r == "" {
			continue
		}
		b.WriteString(r)
		b.WriteString("\n")
	}
	return b.String()
}

// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) {
	// The receive side, unless it merely repeats the transmit side. See
	// SingleRecordADIF and ForwardRecordADIF.
	bandRX, freqRX := q.BandRX, q.FreqRXHz
	if len(rx) == 0 || rx[0] != keepRX {
		if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) {
			bandRX = ""
		}
		if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz {
			freqRX = nil
		}
	}
	// allow == nil → write every promoted field (standard/full behaviour).
	// Otherwise a promoted tag is written only when it's in the chosen set.
	// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
	// stay one-liners.
	ok := func(tag string) bool { return allow == nil || allow[tag] }
	w := func(tag, v string) {
		if ok(tag) {
			writeField(bw, tag, v)
		}
	}
	wi := func(tag string, p *int) {
		if ok(tag) {
			writeIntPtr(bw, tag, p)
		}
	}
	wf := func(tag string, p *float64, decimals int) {
		if ok(tag) {
			writeFloatPtr(bw, tag, p, decimals)
		}
	}
	// --- Core ---
	w("CALL", q.Callsign)

	if !q.QSODate.IsZero() {
		w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
		w("TIME_ON", q.QSODate.UTC().Format("150405"))
	}
	if !q.QSODateOff.IsZero() {
		w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
		w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
	}
	w("BAND", q.Band)
	w("BAND_RX", bandRX)

	mode, submode := modeForExport(q.Mode, q.Submode)
	w("MODE", mode)
	w("SUBMODE", submode)

	if q.FreqHz != nil && *q.FreqHz > 0 {
		w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
	}
	if freqRX != nil && *freqRX > 0 {
		w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64))
	}

	w("RST_SENT", q.RSTSent)
	w("RST_RCVD", q.RSTRcvd)

	// --- Contacted ---
	w("NAME", q.Name)
	w("QTH", q.QTH)
	w("ADDRESS", q.Address)
	w("EMAIL", q.Email)
	w("WEB", q.Web)
	w("GRIDSQUARE", q.Grid)
	w("GRIDSQUARE_EXT", q.GridExt)
	w("VUCC_GRIDS", q.VUCCGrids)
	w("COUNTRY", q.Country)
	w("STATE", q.State)
	w("CNTY", adifCounty(q.State, q.County))
	wi("DXCC", q.DXCC)
	w("CONT", q.Continent)
	wi("CQZ", q.CQZ)
	wi("ITUZ", q.ITUZ)
	w("IOTA", q.IOTA)
	w("SOTA_REF", q.SOTARef)
	w("POTA_REF", q.POTARef)
	wi("AGE", q.Age)
	wf("LAT", q.Lat, 6)
	wf("LON", q.Lon, 6)
	w("RIG", q.Rig)
	w("ANT", q.Ant)

	// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
	w("QSL_SENT", q.QSLSent)
	w("QSL_RCVD", q.QSLRcvd)
	w("QSLSDATE", q.QSLSentDate)
	w("QSLRDATE", q.QSLRcvdDate)
	w("QSL_VIA", q.QSLVia)
	// M (manager) is import-only in the QSL Via enumeration: we keep it when a
	// file gives it to us, but a file we write must not carry it.
	if q.QSLSentVia != QSLViaManager {
		w("QSL_SENT_VIA", q.QSLSentVia)
	}
	if q.QSLRcvdVia != QSLViaManager {
		w("QSL_RCVD_VIA", q.QSLRcvdVia)
	}
	w("QSLMSG", q.QSLMsg)
	w("QSLMSG_RCVD", q.QSLMsgRcvd)
	w("LOTW_QSL_SENT", q.LOTWSent)
	w("LOTW_QSL_RCVD", q.LOTWRcvd)
	w("LOTW_QSLSDATE", q.LOTWSentDate)
	w("LOTW_QSLRDATE", q.LOTWRcvdDate)
	w("EQSL_QSL_SENT", q.EQSLSent)
	w("EQSL_QSL_RCVD", q.EQSLRcvd)
	w("EQSL_QSLSDATE", q.EQSLSentDate)
	w("EQSL_QSLRDATE", q.EQSLRcvdDate)
	w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
	w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
	w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
	w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
	w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
	w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
	w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
	w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
	w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
	w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)

	// --- Contest ---
	w("CONTEST_ID", q.ContestID)
	wi("SRX", q.SRX)
	wi("STX", q.STX)
	w("SRX_STRING", q.SRXString)
	w("STX_STRING", q.STXString)
	w("CHECK", q.Check)
	w("PRECEDENCE", q.Precedence)
	w("ARRL_SECT", q.ARRLSect)

	// --- Satellite / propagation ---
	w("PROP_MODE", q.PropMode)
	w("SAT_NAME", q.SatName)
	w("SAT_MODE", q.SatMode)
	wf("ANT_AZ", q.AntAz, 1)
	wf("ANT_EL", q.AntEl, 1)
	w("ANT_PATH", q.AntPath)

	// --- My station / operator ---
	w("STATION_CALLSIGN", q.StationCallsign)
	w("OPERATOR", q.Operator)
	w("MY_GRIDSQUARE", q.MyGrid)
	w("MY_GRIDSQUARE_EXT", q.MyGridExt)
	w("MY_COUNTRY", q.MyCountry)
	w("MY_STATE", q.MyState)
	w("MY_CNTY", adifCounty(q.MyState, q.MyCounty))
	w("MY_IOTA", q.MyIOTA)
	w("MY_SOTA_REF", q.MySOTARef)
	w("MY_POTA_REF", q.MyPOTARef)
	wi("MY_DXCC", q.MyDXCC)
	wi("MY_CQ_ZONE", q.MyCQZone)
	wi("MY_ITU_ZONE", q.MyITUZone)
	wf("MY_LAT", q.MyLat, 6)
	wf("MY_LON", q.MyLon, 6)
	w("MY_STREET", q.MyStreet)
	w("MY_CITY", q.MyCity)
	w("MY_POSTAL_CODE", q.MyPostalCode)
	w("MY_RIG", q.MyRig)
	w("MY_ANTENNA", q.MyAntenna)

	// --- Misc ---
	wf("TX_PWR", q.TXPower, 1)
	w("COMMENT", q.Comment)
	w("NOTES", q.Notes)

	// --- ADIF 3.1.7 additional promoted fields ---
	w("SIG", q.SIG)
	w("SIG_INFO", q.SIGInfo)
	w("MY_SIG", q.MySIG)
	w("MY_SIG_INFO", q.MySIGInfo)
	w("WWFF_REF", q.WWFFRef)
	w("MY_WWFF_REF", q.MyWWFFRef)
	wf("DISTANCE", q.Distance, 1)
	wf("RX_PWR", q.RXPower, 1)
	wf("A_INDEX", q.AIndex, 0)
	wf("K_INDEX", q.KIndex, 0)
	wf("SFI", q.SFI, 0)
	w("SKCC", q.SKCC)
	w("FISTS", q.FISTS)
	w("TEN_TEN", q.TenTen)
	w("CONTACTED_OP", q.ContactedOp)
	w("EQ_CALL", q.EqCall)
	w("PFX", q.PFX)
	w("MY_NAME", q.MyName)
	w("CLASS", q.Class)
	w("DARC_DOK", q.DarcDOK)
	w("MY_DARC_DOK", q.MyDarcDOK)
	w("REGION", q.Region)
	w("SILENT_KEY", q.SilentKey)
	w("SWL", q.SWL)
	w("QSO_COMPLETE", q.QSOComplete)
	w("QSO_RANDOM", q.QSORandom)
	w("CREDIT_GRANTED", q.CreditGranted)
	w("CREDIT_SUBMITTED", q.CreditSubmitted)
	w("MY_ARRL_SECT", q.MyARRLSect)
	w("MY_VUCC_GRIDS", q.MyVUCCGrids)

	// --- Extras (unpromoted ADIF fields preserved verbatim) ---
	// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
	// application-specific tags AND any non-standard / vendor tag — keeping
	// the file strictly portable to other loggers. Full mode keeps every
	// extra for a lossless OpsLog round-trip.
	for k, v := range q.Extras {
		tag := strings.ToUpper(k)
		if allow != nil {
			// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
			if !allow[tag] {
				continue
			}
		} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
			continue
		}
		writeField(bw, tag, v)
	}

	bw.WriteString("<EOR>\n")
}

// writeField writes one `<TAG:length>value` pair, no-op when value is empty.
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
// already a UTF-8 byte string.
func writeField(bw *bufio.Writer, tag, v string) {
	v = oneLine(v)
	if v == "" {
		return
	}
	fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
}

// oneLine flattens a value onto a single line.
//
// ADIF counts bytes, so a value carrying line breaks is still read correctly —
// and it turns the file into something nobody can read. ADDRESS is a multi-line
// field by the standard, and callbooks and other loggers fill it that way: a
// value of "Kabul" followed by four blank lines and "Afghanistan" came out of
// OpsLog as one record spread down a dozen lines, with the next record
// apparently starting in the middle of the page.
//
// The breaks are dropped rather than escaped: the parts are trimmed and joined
// with a comma, which is how an address reads on one line anyway, and empty
// fragments go. The length prefix is computed after this, so the record stays
// exact.
func oneLine(v string) string {
	if !strings.ContainsAny(v, "\r\n\t") {
		return v
	}
	parts := strings.FieldsFunc(v, func(r rune) bool { return r == '\r' || r == '\n' })
	out := make([]string, 0, len(parts))
	for _, part := range parts {
		part = strings.TrimSpace(strings.ReplaceAll(part, "\t", " "))
		if part != "" {
			out = append(out, part)
		}
	}
	return strings.Join(out, ", ")
}

func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
	if p == nil {
		return
	}
	s := strconv.Itoa(*p)
	writeField(bw, tag, s)
}

func writeFloatPtr(bw *bufio.Writer, tag string, p *float64, decimals int) {
	if p == nil {
		return
	}
	s := strconv.FormatFloat(*p, 'f', decimals, 64)
	writeField(bw, tag, s)
}

// parentMode maps a "specific" mode (the ones we promote on import) back
// to its ADIF parent. Symmetric with promotableSubmodes in import.go.
var parentMode = map[string]string{
	"FT2": "MFSK", "FT4": "MFSK", "JS8": "MFSK", "MSK144": "MFSK",
	"ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK",
	"MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "MFSK",
	"OLIVIA": "MFSK",
	"PSK31":  "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
	"QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK",
	"FREEDV": "DIGITALVOICE",
	"VARA":   "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
	"THOR4": "THOR", "THOR8": "THOR", "THOR16": "THOR", "THOR32": "THOR",
	"DOMINOF": "DOMINO", "DOMINOEX": "DOMINO",
	"HELL80": "HELL", "FMHELL": "HELL",
}

// modeForExport returns the (MODE, SUBMODE) pair to write. If we promoted
// on import (Mode=FT4 Submode=""), we re-derive the parent so the file
// is import-compatible with strict ADIF tools.
func modeForExport(mode, submode string) (string, string) {
	if submode != "" {
		// Already a (parent, child) pair — pass through unchanged.
		return mode, submode
	}
	if parent, ok := parentMode[mode]; ok {
		return parent, mode
	}
	return mode, ""
}

// adifCounty renders CNTY the way ADIF specifies: "STATE,COUNTY", e.g.
// "GA,BARROW".
//
// OpsLog stores the two apart, and exported only the county — which a receiving
// logger cannot resolve, since county names repeat across states (there is a
// Washington County in thirty of them). The award engine here was never
// affected because it reads the columns, not the ADIF; the damage was to
// everyone we send a file to.
//
// The county alone is still written when no state is known: a bare name is
// worth more than nothing, and inventing a prefix would be worse than either.
// A county that ALREADY carries a comma is passed through — it has been
// formatted once, and doubling the prefix would corrupt it.
func adifCounty(state, county string) string {
	c := strings.TrimSpace(county)
	s := strings.TrimSpace(state)
	if c == "" || s == "" || strings.Contains(c, ",") {
		return c
	}
	// The "STATE,County" join is the ADIF secondary-subdivision format, and that
	// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
	// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
	if len(s) != 2 {
		return c
	}
	return strings.ToUpper(s) + "," + c
}
