// Package lotwusers tracks which callsigns are LoTW users and when they last
// uploaded, from ARRL's public "lotw-user-activity.csv" feed. OpsLog shows a
// colour-coded badge next to the entered callsign (recent upload = likely to
// confirm on LoTW). The list is cached on disk so it survives restarts.
package lotwusers

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"
)

// feedURL is ARRL's public LoTW last-upload activity list (CSV:
// "CALLSIGN,YYYY-MM-DD,HH:MM:SS", one line per registered LoTW callsign).
const feedURL = "https://lotw.arrl.org/lotw-user-activity.csv"

const cacheFile = "lotw-user-activity.csv"

// Info is a lookup result for one callsign.
type Info struct {
	IsUser     bool   `json:"is_user"`
	LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
	DaysAgo    int    `json:"days_ago"`              // days since last upload (-1 if unknown)
	// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
	// last regenerated it, and FeedStale says that date is more than two days old.
	// Without them "last uploaded 7 days ago" is unreadable: it may mean the
	// station went quiet, or that the FEED has been frozen for a week — which is
	// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
	FeedDate  string `json:"feed_date,omitempty"`
	FeedStale bool   `json:"feed_stale"`
}

// Manager holds the parsed list + cache location.
type Manager struct {
	mu      sync.RWMutex
	users   map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
	updated time.Time            // when the cache was last refreshed
	newest  time.Time            // newest upload date in the file = when ARRL built it
	dir     string
	client  *http.Client
}

// NewManager loads any on-disk cache and returns a ready manager.
func NewManager(dataDir string) *Manager {
	m := &Manager{
		users:  map[string]time.Time{},
		dir:    dataDir,
		client: &http.Client{Timeout: 90 * time.Second},
	}
	m.loadCache()
	return m
}

func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }

func (m *Manager) loadCache() {
	data, err := os.ReadFile(m.path())
	if err != nil {
		return
	}
	m.parse(data)
	if fi, e := os.Stat(m.path()); e == nil {
		m.mu.Lock()
		m.updated = fi.ModTime()
		m.mu.Unlock()
	}
}

// Download fetches the latest list, caches it to disk and replaces the in-memory
// map. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
	if err != nil {
		return 0, err
	}
	req.Header.Set("User-Agent", "OpsLog")
	resp, err := m.client.Do(req)
	if err != nil {
		return 0, fmt.Errorf("lotwusers: request failed: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return 0, fmt.Errorf("lotwusers: http %d", resp.StatusCode)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
	if err != nil {
		return 0, fmt.Errorf("lotwusers: read: %w", err)
	}
	n := m.parse(body)
	if n == 0 {
		return 0, fmt.Errorf("lotwusers: feed parsed to 0 callsigns")
	}
	_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
	m.mu.Lock()
	m.updated = time.Now()
	m.mu.Unlock()
	return n, nil
}

// parse loads the CSV bytes into the map and returns the count.
func (m *Manager) parse(data []byte) int {
	users := make(map[string]time.Time, 1<<20)
	var newest time.Time
	sc := bufio.NewScanner(bytes.NewReader(data))
	sc.Buffer(make([]byte, 1024*1024), 1024*1024)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		parts := strings.Split(line, ",")
		if len(parts) < 2 {
			continue
		}
		call := strings.ToUpper(strings.TrimSpace(parts[0]))
		if call == "" || strings.EqualFold(call, "callsign") {
			continue // header row
		}
		t, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1]))
		if err != nil {
			continue
		}
		users[call] = t
		if t.After(newest) {
			newest = t
		}
	}
	if len(users) == 0 {
		return 0
	}
	m.mu.Lock()
	m.users = users
	m.newest = newest
	m.mu.Unlock()
	return len(users)
}

// feedInfo fills the feed-age fields shared by every Lookup result.
func (m *Manager) feedInfo(i Info) Info {
	if !m.newest.IsZero() {
		i.FeedDate = m.newest.Format("2006-01-02")
		i.FeedStale = time.Since(m.newest) > 48*time.Hour
	}
	return i
}

// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
// the longest slash-separated segment — the base call LoTW is keyed on.
func (m *Manager) Lookup(callsign string) Info {
	c := strings.ToUpper(strings.TrimSpace(callsign))
	if c == "" {
		return Info{DaysAgo: -1}
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	if len(m.users) == 0 {
		return Info{DaysAgo: -1}
	}
	t, ok := m.users[c]
	if !ok && strings.Contains(c, "/") {
		base := ""
		for _, seg := range strings.Split(c, "/") {
			if len(seg) > len(base) {
				base = seg
			}
		}
		t, ok = m.users[base]
	}
	if !ok {
		return m.feedInfo(Info{DaysAgo: -1})
	}
	days := int(time.Since(t).Hours() / 24)
	if days < 0 {
		days = 0
	}
	return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
}

// Count returns how many callsigns are loaded.
func (m *Manager) Count() int {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return len(m.users)
}

// Updated returns when the list was last refreshed (zero if never).
func (m *Manager) Updated() time.Time {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.updated
}
