// Package dxped reads the two feeds the DX world announces itself on.
//
// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose
// description is a fixed, dash-separated sentence — dates, entity, callsign,
// QSL route, source, then the operators/bands/modes prose. It is what a
// DXpedition list is actually made of.
//
// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It
// carries no structure to act on, but the headline nearly always names the
// callsign, so the calls are mined from the title and the reader can act on
// them the same way (watchlist, chase status).
//
// Both are cached: the announcements change a few times a day, the news a few
// times an hour, and neither is worth a request per screen repaint.
package dxped

import (
	"context"
	"encoding/xml"
	"fmt"
	"html"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync"
	"time"
)

const (
	adxoURL    = "https://www.ng3k.com/adxo.xml"
	dxworldURL = "https://dx-world.net/feed/"

	adxoTTL    = 1 * time.Hour
	dxworldTTL = 30 * time.Minute
)

// Activation is one announced operation, as ADXO describes it.
type Activation struct {
	DXCC      string   `json:"dxcc"`
	Callsign  string   `json:"callsign"` // display form; "A, B" when several
	Calls     []string `json:"calls"`    // the individual callsigns, normalised
	StartDate string   `json:"start_date"`
	EndDate   string   `json:"end_date"`
	Bands     []string `json:"bands"`
	Modes     []string `json:"modes"`
	QSL       string   `json:"qsl"`
	Operators string   `json:"operators"`
	Source    string   `json:"source"`
	Link      string   `json:"link"`
	Status    string   `json:"status"` // "active" | "upcoming"
}

// News is one DX-World post.
type News struct {
	Title    string   `json:"title"`
	Link     string   `json:"link"`
	PubDate  string   `json:"pub_date"` // RFC3339, "" when unparseable
	Excerpt  string   `json:"excerpt"`
	Creator  string   `json:"creator"`
	ImageURL string   `json:"image_url"`
	Tag      string   `json:"tag"`   // NEWS / UPDATE / NEW ACTIVITY…
	Calls    []string `json:"calls"` // callsigns mined from the headline
}

// Manager holds both caches and fetches on demand.
type Manager struct {
	mu       sync.RWMutex
	acts     []Activation
	actsAt   time.Time
	news     []News
	newsAt   time.Time
	client   *http.Client
	fetching sync.Mutex // one refresh at a time, whichever pane asked
}

func New() *Manager {
	return &Manager{client: &http.Client{Timeout: 30 * time.Second}}
}

// Activations returns the cached announcements, refreshing when stale.
func (m *Manager) Activations(ctx context.Context) ([]Activation, error) {
	m.mu.RLock()
	fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil
	out := m.acts
	m.mu.RUnlock()
	if fresh {
		return out, nil
	}
	m.fetching.Lock()
	defer m.fetching.Unlock()
	// Someone else may have refreshed while we waited for the lock.
	m.mu.RLock()
	fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil
	out = m.acts
	m.mu.RUnlock()
	if fresh {
		return out, nil
	}
	acts, err := m.fetchADXO(ctx)
	if err != nil {
		// Stale beats empty: a feed that is down should not blank a list the
		// operator was reading a minute ago.
		m.mu.RLock()
		defer m.mu.RUnlock()
		return m.acts, err
	}
	m.mu.Lock()
	m.acts, m.actsAt = acts, time.Now()
	m.mu.Unlock()
	return acts, nil
}

// News returns the cached DX-World posts, refreshing when stale.
func (m *Manager) News(ctx context.Context) ([]News, error) {
	m.mu.RLock()
	fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil
	out := m.news
	m.mu.RUnlock()
	if fresh {
		return out, nil
	}
	m.fetching.Lock()
	defer m.fetching.Unlock()
	m.mu.RLock()
	fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil
	out = m.news
	m.mu.RUnlock()
	if fresh {
		return out, nil
	}
	news, err := m.fetchDXWorld(ctx)
	if err != nil {
		m.mu.RLock()
		defer m.mu.RUnlock()
		return m.news, err
	}
	m.mu.Lock()
	m.news, m.newsAt = news, time.Now()
	m.mu.Unlock()
	return news, nil
}

// Invalidate drops both caches so the next read refetches.
func (m *Manager) Invalidate() {
	m.mu.Lock()
	m.actsAt, m.newsAt = time.Time{}, time.Time{}
	m.mu.Unlock()
}

func (m *Manager) get(ctx context.Context, url string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "OpsLog")
	resp, err := m.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("http %d", resp.StatusCode)
	}
	return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}

// ── ADXO ────────────────────────────────────────────────────────────────

type rssItem struct {
	Title       string `xml:"title"`
	Description string `xml:"description"`
	Link        string `xml:"link"`
	PubDate     string `xml:"pubDate"`
	Creator     string `xml:"creator"`
	Enclosure   struct {
		URL string `xml:"url,attr"`
	} `xml:"enclosure"`
	MediaContent struct {
		URL string `xml:"url,attr"`
	} `xml:"content"`
}

type rssFeed struct {
	Items []rssItem `xml:"channel>item"`
}

func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) {
	body, err := m.get(ctx, adxoURL)
	if err != nil {
		return nil, fmt.Errorf("adxo: %w", err)
	}
	var feed rssFeed
	if err := xml.Unmarshal(body, &feed); err != nil {
		return nil, fmt.Errorf("adxo: parse: %w", err)
	}
	now := time.Now()
	out := make([]Activation, 0, len(feed.Items))
	for _, it := range feed.Items {
		a := parseActivation(it.Description, it.Link)
		if a == nil {
			continue
		}
		a.Status = activationStatus(a.StartDate, a.EndDate, now)
		if a.Status == "ended" {
			continue // the list is about what is on or coming
		}
		out = append(out, *a)
	}
	return out, nil
}

var spaceRe = regexp.MustCompile(`\s+`)

// parseActivation reads one ADXO description. Its shape is fixed and has been
// for twenty years:
//
//	"Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date)
//	 -- By ops; bands; modes; notes"
func parseActivation(desc, link string) *Activation {
	desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ")
	desc = strings.TrimSpace(html.UnescapeString(desc))
	if desc == "" {
		return nil
	}
	parts := strings.Split(desc, " -- ")
	if len(parts) < 3 {
		return nil
	}
	a := &Activation{Link: link}
	a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0]))
	a.DXCC = strings.TrimSpace(parts[1])
	a.Callsign = strings.TrimSpace(parts[2])

	for _, p := range parts[3:] {
		p = strings.TrimSpace(p)
		switch {
		case strings.HasPrefix(p, "QSL:"):
			a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:"))
		case strings.HasPrefix(p, "Source:"):
			a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:"))
		}
	}

	// The tail carries "By <ops>; <bands>; <modes>; <notes>".
	tail := parts[len(parts)-1]
	if i := strings.Index(tail, "By "); i >= 0 {
		sub := strings.Split(tail[i+3:], ";")
		if len(sub) > 0 {
			a.Operators = strings.TrimSpace(sub[0])
		}
		if len(sub) > 1 {
			a.Bands = parseBands(sub[1])
		}
		if len(sub) > 2 {
			a.Modes = parseModes(sub[2])
		}
	}

	// The callsign field often holds only the PREFIX; the real calls hide in the
	// operators prose as "W2APF as PJ2/W2APF". Prefer those when present.
	if calls := callsAfterAs(a.Operators); len(calls) > 0 {
		prefix := strings.ToUpper(strings.TrimSpace(parts[2]))
		for i := range calls {
			calls[i] = normalizeCall(calls[i], prefix)
		}
		a.Calls = calls
		a.Callsign = strings.Join(calls, ", ")
	} else if c := strings.ToUpper(a.Callsign); plausibleCall(c) {
		a.Calls = []string{c}
	}
	return a
}

// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and
// "Mar 3-20, 2026" (the month carried over).
var (
	fullRangeRe  = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`)
	shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`)
	singleDayRe  = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`)
)

func parseDateRange(s string) (start, end string) {
	if m := fullRangeRe.FindStringSubmatch(s); m != nil {
		return m[1] + ", " + m[3], m[2] + ", " + m[3]
	}
	if m := shortRangeRe.FindStringSubmatch(s); m != nil {
		return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4]
	}
	if m := singleDayRe.FindStringSubmatch(s); m != nil {
		return m[1] + ", " + m[2], m[1] + ", " + m[2]
	}
	return s, s
}

func parseADXODate(s string) (time.Time, bool) {
	for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} {
		if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
			return t, true
		}
	}
	return time.Time{}, false
}

func activationStatus(start, end string, now time.Time) string {
	s, okS := parseADXODate(start)
	e, okE := parseADXODate(end)
	if !okS || !okE {
		return "upcoming" // unreadable dates: keep it, an operator can read them
	}
	switch {
	case now.Before(s):
		return "upcoming"
	// The end date is a DAY, so an operation is on until that day is over.
	case now.After(e.Add(24 * time.Hour)):
		return "ended"
	default:
		return "active"
	}
}

// callsAfterAs mines "…as CALL…" out of the operators prose:
//
//	"W2APF as PJ2/W2APF"                  → PJ2/W2APF
//	"SQ2RAD as VP2EAD, M0PLX as VP2ELX"   → VP2EAD, VP2ELX
var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`)

func callsAfterAs(operators string) []string {
	var out []string
	seen := map[string]bool{}
	for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) {
		c := strings.ToUpper(strings.TrimSpace(m[1]))
		if !plausibleCall(c) || seen[c] {
			continue
		}
		seen[c] = true
		out = append(out, c)
	}
	return out
}

// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and
// matching the log against JG8NQJ/JD1 would find nothing.
func normalizeCall(call, dxccPrefix string) string {
	if dxccPrefix == "" || !strings.Contains(call, "/") {
		return call
	}
	left, right, _ := strings.Cut(call, "/")
	if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) {
		return right + "/" + left
	}
	return call
}

var (
	// A span carries its unit only once — "160-6m" is the commonest way ADXO
	// states coverage, and reading it as "6m" alone loses the whole low end.
	bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`)
	// The modes ADXO actually writes. A list beats a pattern here: "FT8" and
	// "SSB" have no shape in common, and inventing one invites "QSL" as a mode.
	knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"}
)

func parseBands(s string) []string {
	var out []string
	seen := map[string]bool{}
	add := func(num, unit string) {
		if num == "" {
			return
		}
		b := strings.ToLower(num + unit)
		if seen[b] {
			return
		}
		seen[b] = true
		out = append(out, b)
	}
	// A span keeps only its endpoints: ADXO states coverage in prose, and the
	// two ends are the part it gives reliably.
	for _, m := range bandRe.FindAllStringSubmatch(s, -1) {
		add(m[1], m[3])
		add(m[2], m[3])
	}
	return out
}

func parseModes(s string) []string {
	up := strings.ToUpper(s)
	var out []string
	seen := map[string]bool{}
	for _, mode := range knownModes {
		if seen[mode] {
			continue
		}
		if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) {
			seen[mode] = true
			out = append(out, mode)
		}
	}
	return out
}

// ── DX-World ────────────────────────────────────────────────────────────

var (
	htmlTagRe   = regexp.MustCompile(`<[^>]+>`)
	tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`)
)

func stripHTML(s string) string {
	s = htmlTagRe.ReplaceAllString(s, " ")
	s = html.UnescapeString(s)
	return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
}

func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) {
	body, err := m.get(ctx, dxworldURL)
	if err != nil {
		return nil, fmt.Errorf("dx-world: %w", err)
	}
	var feed rssFeed
	if err := xml.Unmarshal(body, &feed); err != nil {
		return nil, fmt.Errorf("dx-world: parse: %w", err)
	}
	out := make([]News, 0, len(feed.Items))
	for _, it := range feed.Items {
		title := stripHTML(it.Title)
		tag, title := splitTag(title)
		n := News{
			Title:   title,
			Link:    strings.TrimSpace(it.Link),
			Creator: strings.TrimSpace(it.Creator),
			Tag:     tag,
			Calls:   callsInHeadline(title),
		}
		if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
			n.PubDate = t.UTC().Format(time.RFC3339)
		} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
			n.PubDate = t.UTC().Format(time.RFC3339)
		}
		ex := stripHTML(it.Description)
		if t2, rest := splitTag(ex); t2 != "" {
			if n.Tag == "" {
				n.Tag = t2
			}
			ex = rest
		}
		n.Excerpt = truncateRunes(ex, 400)
		if u := strings.TrimSpace(it.Enclosure.URL); u != "" {
			n.ImageURL = u
		} else if u := strings.TrimSpace(it.MediaContent.URL); u != "" {
			n.ImageURL = u
		}
		out = append(out, n)
	}
	return out, nil
}

// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts.
func splitTag(s string) (tag, rest string) {
	m := tagPrefixRe.FindStringSubmatchIndex(s)
	if m == nil {
		return "", s
	}
	tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]]))
	rest = strings.TrimSpace(s[m[1]:])
	rest = strings.TrimPrefix(strings.TrimPrefix(rest, "– "), "- ")
	return tag, strings.TrimSpace(rest)
}

func truncateRunes(s string, max int) string {
	r := []rune(s)
	if len(r) <= max {
		return s
	}
	return strings.TrimSpace(string(r[:max])) + "…"
}

// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon"
// or "TX5S team lands". The reader can then chase or watch them, which is the
// whole reason a news feed sits next to the announcements.
func callsInHeadline(title string) []string {
	var out []string
	seen := map[string]bool{}
	for _, tok := range strings.FieldsFunc(title, func(r rune) bool {
		return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'))
	}) {
		c := strings.ToUpper(tok)
		if !plausibleCall(c) || seen[c] {
			continue
		}
		seen[c] = true
		out = append(out, c)
	}
	return out
}

var (
	bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`)
	// Jargon shaped exactly like a callsign. Every one of these was seen in a
	// real headline before it earned its place here.
	notACall = map[string]bool{
		"FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true,
		"MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true,
		"IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true,
		"CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true,
	}
)

// plausibleCall keeps tokens shaped like an amateur callsign: 3–12 characters
// of A–Z/0–9 (with optional /prefix or /suffix), at least one letter AND one
// digit, and a letter somewhere after the first digit — which is what separates
// a callsign from a band or a year.
func plausibleCall(s string) bool {
	s = strings.ToUpper(strings.TrimSpace(s))
	if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) {
		return false
	}
	// Judge the longest part — the real call in "PJ2/W2APF" either way.
	base := s
	if strings.Contains(s, "/") {
		base = ""
		for _, p := range strings.Split(s, "/") {
			if len(p) > len(base) {
				base = p
			}
		}
	}
	var hasLetter, hasDigit, letterAfterDigit bool
	seenDigit := false
	for _, r := range base {
		switch {
		case r >= 'A' && r <= 'Z':
			hasLetter = true
			if seenDigit {
				letterAfterDigit = true
			}
		case r >= '0' && r <= '9':
			hasDigit = true
			seenDigit = true
		default:
			return false
		}
	}
	return hasLetter && hasDigit && letterAfterDigit
}
