package udp

import (
	"bytes"
	"encoding/binary"
	"fmt"
	"strings"
)

// WSJT-X / JTDX / MSHV UDP protocol (WSJT-X v2 schema).
//
// Wire format:
//   uint32 magic   (0xadbccbda)
//   uint32 schema  (2 or 3)
//   uint32 type    (message id)
//   QString id     (the program's "id" — typically "WSJT-X")
//   ... type-specific payload ...
//
// QString = int32 length followed by `length` UTF-8 bytes, or -1 for nil.
// QUtf8 in newer versions; same wire format for the common case.
//
// We only care about two messages here:
//   Status   (type 1) → exposes the current DX call so HamLog can pre-fill
//   LoggedADIF (type 12) → carries the ADIF of the just-logged QSO
// Everything else (heartbeat, decodes, clears, status of other VFOs) is
// ignored.

const (
	wsjtMagic = 0xadbccbda

	wsjtMsgHeartbeat  = 0
	wsjtMsgStatus     = 1
	wsjtMsgDecode     = 2
	wsjtMsgClear      = 3
	wsjtMsgQSOLogged  = 5
	wsjtMsgLoggedADIF = 12
)

// WSJTEvent is the parsed, typed result of decoding a single packet.
// One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message.
type WSJTEvent struct {
	DXCall     string // current "DX Call" field in the WSJT app (Status)
	DXGrid     string // optional grid for that call (Status)
	Mode       string // FT8 / FT4 / …
	FreqHz     int64  // current dial freq when available (Status)
	LoggedADIF string // full ADIF text when message is LoggedADIF
	ProgramID  string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup

	// Decode (type 2): the transmitting station heard on the band. FreqHz is NOT
	// set here (Decode carries only the audio offset); the caller adds the last
	// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
	IsDecode    bool
	DecodeCall  string // the sender (DE) callsign extracted from the message text
	DecodeGrid  string // 4-char grid: from a CQ, or a reply that answers with its locator
	DeltaFreqHz int64  // audio offset within the passband (Hz)
	SNR         int    // reported signal-to-noise (dB)
	IsCQ        bool   // the decode was a CQ call
	// DeltaTime is how far into the slot the transmission started, in seconds —
	// WSJT-X's "DT" column. Read and discarded before; kept now because it is
	// shown, and because a Reply has to replay the decode field for field.
	DeltaTime float64
	// DecodeMsg is the decoded text as WSJT-X printed it ("CQ K1ABC FN42",
	// "F4BPO K1ABC -07"). Kept whole rather than only its parsed pieces: the
	// exchange is what tells an operator where a station is in a QSO, and no set
	// of extracted fields says "R-09" the way the line itself does.
	DecodeMsg string
	// DecodeMsgRaw is the message EXACTLY as the datagram carried it, trailing
	// spaces and all. DecodeMsg above is trimmed for display; a Reply has to
	// send this one, because the receiving application matches a Reply against
	// its own decode list field for field and a stripped space is a mismatch it
	// reports nowhere.
	DecodeMsgRaw string
	// DecodeMsSinceMidnight is the decode's own timestamp, in milliseconds since
	// 00:00 UTC, as the sender reported it. It is what groups decodes into T/R
	// PERIODS — arrival time cannot, since a whole period's decodes land in one
	// burst and a slow link shifts the lot into the next slot.
	DecodeMsSinceMidnight uint32
	DecodeIsNew           bool // sender's "is_new": first time this line was decoded
	LowConfidence         bool // sender is unsure of the decode
	OffAir                bool // decoded from a file, not off the air

	// ---- Status extras ----

	// TxMessage is what the operator is sending right now ("CQ F4BPO JN18"),
	// with Transmitting saying whether the carrier is actually up. Both come
	// from Status, so they arrive about once a second.
	TxMessage    string
	Transmitting bool
	// TxEnabled is the sender's "Enable Tx" toggle. It is what the WATCHDOG
	// turns off: the application stops transmitting but leaves the DX call set,
	// so a caller that reads only the DX call believes the QSO is still running
	// and waits for ever. This is the difference between "between overs" and
	// "given up".
	TxEnabled bool
	DECall    string // the operator's own callsign, as the digital app knows it
	DEGrid    string // and their square
	// TRPeriod is the transmit/receive period in seconds (15 for FT8, 7 or 8 for
	// FT4 depending on the sender's rounding). The authority on how long a slot
	// is — better than inferring it from the mode name, which says nothing about
	// a custom period.
	TRPeriod int
}

// maxFwdHeader bounds how far into a packet the WSJT-X magic may sit behind a
// forwarder's header. The one seen in the field ("127.0.0.1:2237|") is 15 bytes;
// 64 leaves room for a longer address without ever scanning a real payload.
const maxFwdHeader = 64

// stripForwarderHeader removes the origin header a UDP relay prepends.
//
// A relay that re-broadcasts WSJT-X traffic has to say where each datagram came
// from, and it does so as plain text in front of the payload:
//
//	"127.0.0.1:2237|" + <the original, untouched WSJT-X packet>
//
// The magic then sits 15 bytes in, every packet fails on "bad magic", and an
// operator running MSHV behind such a relay gets nothing at all. There is no
// need for a separate service type: what follows the header IS a WSJT-X packet,
// so the whole parser and everything downstream apply unchanged.
//
// Deliberately narrow. The magic must appear within maxFwdHeader bytes AND
// everything before it must be printable ASCII — a truncated or corrupt packet
// that happens to contain those four bytes somewhere is not resurrected into a
// QSO. Anything else is returned untouched, and still fails as it did.
func stripForwarderHeader(pkt []byte) []byte {
	if len(pkt) < 4 {
		return pkt
	}
	if binary.BigEndian.Uint32(pkt) == wsjtMagic {
		return pkt // no header — the overwhelmingly common case
	}
	limit := len(pkt) - 4
	if limit > maxFwdHeader {
		limit = maxFwdHeader
	}
	for i := 1; i <= limit; i++ {
		if binary.BigEndian.Uint32(pkt[i:]) != wsjtMagic {
			continue
		}
		for _, b := range pkt[:i] {
			if b < 0x20 || b >= 0x7f {
				return pkt // not a text header — leave it alone
			}
		}
		return pkt[i:]
	}
	return pkt
}

// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
// don't care about (heartbeat, clears, etc.).
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
	if len(pkt) < 12 {
		return WSJTEvent{}, false, fmt.Errorf("packet too short")
	}
	// A relay (W&P and friends) puts its own origin header in front — skip it so
	// the packet parses exactly as if it had arrived from WSJT-X directly.
	pkt = stripForwarderHeader(pkt)
	r := bytes.NewReader(pkt)
	var magic, schema, mtype uint32
	if err := binary.Read(r, binary.BigEndian, &magic); err != nil {
		return WSJTEvent{}, false, err
	}
	if magic != wsjtMagic {
		return WSJTEvent{}, false, fmt.Errorf("bad magic %#x", magic)
	}
	if err := binary.Read(r, binary.BigEndian, &schema); err != nil {
		return WSJTEvent{}, false, err
	}
	_ = schema
	if err := binary.Read(r, binary.BigEndian, &mtype); err != nil {
		return WSJTEvent{}, false, err
	}
	id, err := readQString(r)
	if err != nil {
		return WSJTEvent{}, false, fmt.Errorf("read id: %w", err)
	}

	ev := WSJTEvent{ProgramID: id}
	switch mtype {
	case wsjtMsgStatus:
		// Status payload order (v2):
		//   quint64 dial_frequency
		//   QUtf8   mode
		//   QUtf8   dx_call
		//   QUtf8   report
		//   QUtf8   tx_mode
		//   bool    tx_enabled
		//   bool    transmitting
		//   bool    decoding
		//   qint32  rx_df
		//   qint32  tx_df
		//   QUtf8   de_call
		//   QUtf8   de_grid
		//   QUtf8   dx_grid
		//   ... (more fields appended in later schemas, we stop reading
		//        after dx_grid which is all we need)
		var dialHz uint64
		if err := binary.Read(r, binary.BigEndian, &dialHz); err != nil {
			return WSJTEvent{}, false, err
		}
		ev.FreqHz = int64(dialHz)
		mode, err := readQString(r)
		if err != nil {
			return WSJTEvent{}, false, err
		}
		ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
		dxCall, err := readQString(r)
		if err != nil {
			return WSJTEvent{}, false, err
		}
		ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall))
		// report, tx_mode → skipped.
		for _, name := range []string{"report", "tx_mode"} {
			if _, err := readQString(r); err != nil {
				return ev, true, fmt.Errorf("read %s: %w", name, err)
			}
		}
		// tx_enabled, transmitting, decoding (1 byte each). The middle one is
		// worth keeping: it says the carrier is up, which is what turns TxMessage
		// from "what I would send" into "what is going out".
		var txEnabled, transmitting, decoding uint8
		for _, p := range []*uint8{&txEnabled, &transmitting, &decoding} {
			if err := binary.Read(r, binary.BigEndian, p); err != nil {
				return ev, true, err
			}
		}
		ev.Transmitting = transmitting != 0
		ev.TxEnabled = txEnabled != 0
		// rx_df, tx_df
		var i32 int32
		for i := 0; i < 2; i++ {
			if err := binary.Read(r, binary.BigEndian, &i32); err != nil {
				return ev, true, err
			}
		}
		deCall, err := readQString(r)
		if err != nil {
			return ev, true, err
		}
		ev.DECall = strings.ToUpper(strings.TrimSpace(deCall))
		deGrid, err := readQString(r)
		if err != nil {
			return ev, true, err
		}
		ev.DEGrid = strings.ToUpper(strings.TrimSpace(deGrid))
		dxGrid, err := readQString(r)
		if err != nil {
			return ev, true, err
		}
		ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))

		// Everything past here was APPENDED to the schema over successive
		// releases, and JTDX and MSHV each stop at their own point. A short
		// packet is therefore normal, not an error: read as far as the sender
		// went and keep what we got. That is why the tail below swallows its
		// errors instead of reporting them — the fields already parsed are good.
		var b uint8
		if binary.Read(r, binary.BigEndian, &b) != nil { // tx_watchdog
			return ev, true, nil
		}
		if _, err := readQString(r); err != nil { // sub_mode
			return ev, true, nil
		}
		if binary.Read(r, binary.BigEndian, &b) != nil { // fast_mode
			return ev, true, nil
		}
		if binary.Read(r, binary.BigEndian, &b) != nil { // special_operation_mode
			return ev, true, nil
		}
		var u32 uint32
		if binary.Read(r, binary.BigEndian, &u32) != nil { // frequency_tolerance
			return ev, true, nil
		}
		if binary.Read(r, binary.BigEndian, &u32) != nil { // tr_period (seconds)
			return ev, true, nil
		}
		// 0xFFFFFFFF is WSJT-X's "not applicable" for the quint32 fields.
		if u32 > 0 && u32 < 3600 {
			ev.TRPeriod = int(u32)
		}
		if _, err := readQString(r); err != nil { // configuration_name
			return ev, true, nil
		}
		if txMsg, err := readQString(r); err == nil {
			ev.TxMessage = strings.TrimSpace(txMsg)
		}
		return ev, true, nil

	case wsjtMsgDecode:
		// Decode payload (v2):
		//   bool    is_new
		//   quint32 time (ms since midnight)
		//   qint32  snr
		//   double  delta_time (seconds)
		//   quint32 delta_frequency (Hz, audio offset in the passband)
		//   QUtf8   mode
		//   QUtf8   message (the decoded text, e.g. "CQ K1ABC FN42")
		//   bool    low_confidence
		//   bool    off_air
		var b uint8
		if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
			return WSJTEvent{}, false, err
		}
		ev.DecodeIsNew = b != 0
		var t32, df uint32
		var snr int32
		if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
			return WSJTEvent{}, false, err
		}
		if err := binary.Read(r, binary.BigEndian, &snr); err != nil {
			return WSJTEvent{}, false, err
		}
		var dt float64
		if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
			return WSJTEvent{}, false, err
		}
		ev.DeltaTime = dt
		if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
			return WSJTEvent{}, false, err
		}
		mode, err := readQString(r)
		if err != nil {
			return WSJTEvent{}, false, err
		}
		msg, err := readQString(r)
		if err != nil {
			return WSJTEvent{}, false, err
		}
		// low_confidence and off_air were appended later; absent on older senders.
		var lowConf, offAir uint8
		_ = binary.Read(r, binary.BigEndian, &lowConf)
		_ = binary.Read(r, binary.BigEndian, &offAir)

		call, isCQ, grid := wsjtSender(msg)
		if call == "" {
			return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
		}
		ev.IsDecode = true
		ev.DecodeCall = call
		ev.IsCQ = isCQ
		ev.DecodeGrid = grid
		ev.DeltaFreqHz = int64(df)
		ev.SNR = int(snr)
		ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
		ev.DecodeMsg = strings.TrimSpace(msg)
		ev.DecodeMsgRaw = msg
		ev.DecodeMsSinceMidnight = t32
		ev.LowConfidence = lowConf != 0
		ev.OffAir = offAir != 0
		return ev, true, nil

	case wsjtMsgLoggedADIF:
		// Payload: a single QString containing the ADIF record.
		adif, err := readQString(r)
		if err != nil {
			return WSJTEvent{}, false, err
		}
		ev.LoggedADIF = adif
		return ev, true, nil
	}
	return WSJTEvent{}, false, nil
}

// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message,
// whether it was a CQ, and the grid when the message carries one. Grammar:
//
//	CQ [modifier] <de_call> [grid]      → de_call, isCQ=true, grid
//	<to_call> <de_call> [report|…]      → de_call, isCQ=false
//
// Only a CQ carries a grid: the standard exchange puts a signal report in that
// third slot, never a locator.
//
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
// unbracket strips the angle brackets FT8 puts around a HASHED callsign.
//
// A compound or non-standard call does not fit in a 77-bit message, so the
// protocol sends a hash of it and the receiving software prints what it
// resolved between angle brackets: "F4BPO <ZA/IW2JOP> -24". The brackets are
// notation, not part of the callsign — but every character test here rejected
// them, so the whole decode was thrown away as unparseable. Every reply from a
// station with a compound call was invisible: precisely the stations worth
// seeing, and precisely the message that says one of them answered you.
func unbracket(s string) string {
	return strings.TrimSuffix(strings.TrimPrefix(s, "<"), ">")
}

func wsjtSender(message string) (call string, isCQ bool, grid string) {
	msg := strings.ToUpper(strings.TrimSpace(message))
	// The compound form — "OK1UA RR73; DH1HRN <ZA/IW2JOP> -08" — is one station
	// signing off with another while answering a third. The sender is in the
	// part AFTER the semicolon; reading the line from the left names OK1UA, who
	// did not send it.
	if i := strings.LastIndexByte(msg, ';'); i >= 0 {
		msg = strings.TrimSpace(msg[i+1:])
	}
	f := strings.Fields(msg)
	if len(f) == 0 {
		return "", false, ""
	}
	if f[0] == "CQ" {
		// Skip an optional modifier after CQ (DX / a region like NA / a zone like
		// 020) — it never looks like a callsign (no letter+digit mix).
		idx := 1
		if len(f) > 2 && !looksLikeCall(unbracket(f[1])) {
			idx = 2
		}
		if idx < len(f) {
			c := unbracket(f[idx])
			// A GRID sitting in the callsign slot means the real call was
			// unparseable and the skip above went one word too far. JN36 has letters
			// and digits and passes every shape test there is, so without this the
			// station's grid gets logged, spotted and coloured as its callsign.
			if looksLikeCall(c) && !isGridField(c) {
				if idx+1 < len(f) && isGridField(f[idx+1]) {
					grid = f[idx+1]
				}
				return c, true, grid
			}
		}
		return "", true, ""
	}
	// Standard exchange: the DE (sender) call is the second token.
	//
	// And the THIRD token is a grid whenever the station is answering with its
	// locator — "C91RU IZ5EME JN52", the ordinary first reply to a CQ. This used
	// to return no grid at all on the grounds that "the exchange carries none",
	// which is simply not true: it is the commonest grid-bearing message on a
	// busy band, and only a CQ being read meant a station whose CQ we missed had
	// no known grid — so it could never be flagged as a new one. GridTracker
	// reads both, which is why its wanted-grid count ran so far ahead of ours.
	//
	// isGridField is what keeps the reports and sign-offs out: -05, R-05, RRR,
	// 73 and RR73 all fail it, on length, charset or by name.
	if len(f) >= 2 {
		if c := unbracket(f[1]); looksLikeCall(c) {
			if len(f) >= 3 && isGridField(f[2]) {
				return c, false, f[2]
			}
			return c, false, ""
		}
	}
	return "", false, ""
}

// isGridField reports a 4-character Maidenhead field+square (JN36).
//
// RR73 is the reason this is not a bare pattern match: it is a sign-off, not a
// locator, yet R falls inside A–R and 73 inside 00–99, so it satisfies the
// Maidenhead shape exactly. WSJT-X never puts it in the slot after a CQ call,
// but a station sending "CQ RR73" style free text would silently plant a
// nonexistent grid in the log's grid index, and nothing downstream could tell.
func isGridField(s string) bool {
	if len(s) != 4 || s == "RR73" {
		return false
	}
	return s[0] >= 'A' && s[0] <= 'R' && s[1] >= 'A' && s[1] <= 'R' &&
		s[2] >= '0' && s[2] <= '9' && s[3] >= '0' && s[3] <= '9'
}

// looksLikeCall is a loose callsign test: 3–12 chars of A–Z/0–9//, with at least
// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that
// would otherwise pass.
func looksLikeCall(s string) bool {
	switch s {
	case "RR73", "RRR", "73":
		return false
	}
	if len(s) < 3 || len(s) > 12 {
		return false
	}
	hasL, hasD := false, false
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case c >= 'A' && c <= 'Z':
			hasL = true
		case c >= '0' && c <= '9':
			hasD = true
		case c == '/':
		default:
			return false
		}
	}
	return hasL && hasD
}

// readQString reads a Qt QString as written by QDataStream: an int32 byte
// length (or -1 for null) followed by the UTF-8 bytes.
func readQString(r *bytes.Reader) (string, error) {
	var n int32
	if err := binary.Read(r, binary.BigEndian, &n); err != nil {
		return "", err
	}
	if n <= 0 {
		return "", nil
	}
	if int(n) > r.Len() {
		return "", fmt.Errorf("short string: want %d have %d", n, r.Len())
	}
	buf := make([]byte, n)
	if _, err := r.Read(buf); err != nil {
		return "", err
	}
	return string(buf), nil
}

// decodeModeChar maps the single character WSJT-X puts in a Decode's mode field
// to the mode it stands for.
//
// A Decode does NOT carry the mode's name. It carries the one-character marker
// that appears in the decode line and in ALL.TXT — "~" for FT8, "+" for FT4 —
// and that character was being passed straight through as if it were a mode.
// Everything downstream then compared "~" against the modes in the log, matched
// nothing, and called every station on an already-worked band a new MODE.
//
// The table covers what is common; anything missing falls back to the mode from
// the sender's last Status, which carries the real name — so an unlisted or
// future marker degrades to correct rather than to nonsense.
var decodeModeChar = map[string]string{
	"~": "FT8",
	"+": "FT4",
	"#": "JT65",
	"@": "JT9",
	"&": "MSK144",
	"`": "FST4",
}

// ambiguousModeChar is a marker the forks do not agree on.
//
// ":" is Q65 in WSJT-X and FT4 in JTDX, so the character alone cannot answer:
// a JTDX operator decoding FT4 was told they were on Q65, and every verdict
// downstream — new mode, new slot, the mode filter — was computed against a
// mode nobody was using.
//
// The sender's own Status settles it. It comes from the same program, names the
// mode in full, and is re-sent whenever it changes, so it knows what that
// program is decoding in a way one character never can. The fallback is
// WSJT-X's reading, which is the older and commoner one.
var ambiguousModeChar = map[string]string{
	":": "Q65",
}

// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
// is the mode from the same program's last Status, used when the field is a
// marker we do not know, or empty.
func DecodeModeName(raw, statusMode string) string {
	raw = strings.TrimSpace(raw)
	if m, ok := decodeModeChar[raw]; ok {
		return m
	}
	if fallback, ok := ambiguousModeChar[raw]; ok {
		// Believe the program over the character it happened to print.
		if st := strings.ToUpper(strings.TrimSpace(statusMode)); st != "" {
			return st
		}
		return fallback
	}
	// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
	// Anything shorter, or carrying punctuation, is a marker rather than a name.
	if len(raw) >= 2 {
		named := true
		for _, r := range raw {
			if !(r >= 'A' && r <= 'Z') && !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
				named = false
				break
			}
		}
		if named {
			return strings.ToUpper(raw)
		}
	}
	return strings.ToUpper(strings.TrimSpace(statusMode))
}
