// Package scp provides Super Check Partial (SCP) and N+1 callsign suggestions
// from the community MASTER.SCP master file (supercheckpartial.com) — the same
// aid contest loggers (N1MM, DXLog, Log4OM) show to catch/correct a busted call.
//
//   - Partial (SCP): every master call that CONTAINS what you've typed, so a
//     mistyped or half-copied call surfaces the real ones.
//   - N+1: master calls exactly one edit away (one char substituted, added or
//     removed) from the full call you typed — the classic "did I bust it?" check.
//
// The list is downloaded once and cached on disk so it survives restarts.
package scp

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

// masterURL is the community Super Check Partial master file: one callsign per
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"

// clublogURL is Club Log's own SCP list, rebuilt weekly from real DX logs:
// every call from a current entity with 40+ QSOs in the last 3 years (~180k).
// Broader than MASTER.SCP (which leans contest), so the manager merges the two
// when the Club Log source is enabled.
const clublogURL = "https://cdn.clublog.org/clublog.scp.gz"

const cacheFile = "MASTER.SCP"
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed

// Result is the two suggestion lists for a typed fragment.
type Result struct {
	Partial []string `json:"partial"` // master calls containing the fragment
	NPlus1  []string `json:"nplus1"`  // master calls one edit away from the full call
}

// Manager holds the parsed call list + cache location.
type Manager struct {
	mu      sync.RWMutex
	calls   []string  // UPPER, de-duplicated, sorted
	updated time.Time // when the cache was last refreshed
	dir     string
	client  *http.Client
	clublog atomic.Bool // merge Club Log's list into the master list
}

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

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

// SetClublogEnabled turns the Club Log source on/off and reparses the on-disk
// caches so the in-memory list reflects the choice immediately.
func (m *Manager) SetClublogEnabled(on bool) {
	if m.clublog.Swap(on) != on {
		m.loadCache()
	}
}

// ClublogEnabled reports whether the Club Log source is merged in.
func (m *Manager) ClublogEnabled() bool { return m.clublog.Load() }

func (m *Manager) loadCache() {
	data, _ := os.ReadFile(m.path())
	var extra []byte
	if m.clublog.Load() {
		extra, _ = os.ReadFile(m.clublogPath())
	}
	if data == nil && extra == nil {
		return
	}
	m.parse(data, extra)
	if fi, e := os.Stat(m.path()); e == nil {
		m.mu.Lock()
		m.updated = fi.ModTime()
		m.mu.Unlock()
	}
}

// Download fetches the latest MASTER.SCP, caches it and replaces the in-memory
// list. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, 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("scp: request failed: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return 0, fmt.Errorf("scp: http %d", resp.StatusCode)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
	if err != nil {
		return 0, fmt.Errorf("scp: read: %w", err)
	}
	var extra []byte
	if m.clublog.Load() {
		if cl, cerr := m.downloadClublog(ctx); cerr == nil {
			extra = cl
		} else {
			// The master list alone is still worth having; fall back to any
			// cached Club Log list rather than dropping the source silently.
			extra, _ = os.ReadFile(m.clublogPath())
		}
	}
	n := m.parse(body, extra)
	if n == 0 {
		return 0, fmt.Errorf("scp: file 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
}

// downloadClublog fetches Club Log's gzipped SCP list and caches it decompressed.
func (m *Manager) downloadClublog(ctx context.Context) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "OpsLog")
	resp, err := m.client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("scp: clublog request failed: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("scp: clublog http %d", resp.StatusCode)
	}
	gz, err := gzip.NewReader(io.LimitReader(resp.Body, 32*1024*1024))
	if err != nil {
		return nil, fmt.Errorf("scp: clublog gunzip: %w", err)
	}
	body, err := io.ReadAll(io.LimitReader(gz, 64*1024*1024))
	if err != nil {
		return nil, fmt.Errorf("scp: clublog read: %w", err)
	}
	_ = os.WriteFile(m.clublogPath(), body, 0o644)
	return body, nil
}

// parse loads the SCP bytes into the sorted call slice and returns the count.
func (m *Manager) parse(datasets ...[]byte) int {
	seen := make(map[string]struct{}, 1<<17)
	for _, data := range datasets {
		if len(data) == 0 {
			continue
		}
		sc := bufio.NewScanner(bytes.NewReader(data))
		sc.Buffer(make([]byte, 1024*1024), 1024*1024)
		for sc.Scan() {
			line := strings.ToUpper(strings.TrimSpace(sc.Text()))
			if line == "" || strings.HasPrefix(line, "#") {
				continue // blank / header comment
			}
			// A call token only (the files are one call per line, but guard
			// against stray trailing fields).
			if i := strings.IndexAny(line, " \t,;"); i >= 0 {
				line = line[:i]
			}
			if !plausibleCall(line) {
				continue
			}
			seen[line] = struct{}{}
		}
	}
	if len(seen) == 0 {
		return 0
	}
	calls := make([]string, 0, len(seen))
	for c := range seen {
		calls = append(calls, c)
	}
	sort.Strings(calls)
	m.mu.Lock()
	m.calls = calls
	m.mu.Unlock()
	return len(calls)
}

// plausibleCall keeps a token that looks like a callsign: length 3–12, at least
// one digit and one letter, only A–Z/0–9//.
func plausibleCall(s string) bool {
	if len(s) < 3 || len(s) > 12 {
		return false
	}
	hasDigit, hasAlpha := false, false
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case c >= '0' && c <= '9':
			hasDigit = true
		case c >= 'A' && c <= 'Z':
			hasAlpha = true
		case c == '/':
		default:
			return false
		}
	}
	return hasDigit && hasAlpha
}

// Lookup returns the Partial (substring) and N+1 (one-edit) suggestions for the
// typed fragment. limit caps EACH list. Partial needs ≥2 chars; N+1 needs ≥3
// (a plausible whole call) and is skipped otherwise.
func (m *Manager) Lookup(fragment string, limit int) Result {
	q := strings.ToUpper(strings.TrimSpace(fragment))
	if len(q) < 2 {
		return Result{}
	}
	if limit <= 0 {
		limit = 50
	}
	m.mu.RLock()
	calls := m.calls
	m.mu.RUnlock()

	wantN1 := len(q) >= 3
	type pm struct {
		call   string
		prefix bool
	}
	var partial []pm
	var nplus1 []string
	for _, c := range calls {
		if c == q {
			partial = append(partial, pm{c, true}) // exact = strongest match
			continue
		}
		if idx := strings.Index(c, q); idx >= 0 {
			partial = append(partial, pm{c, idx == 0})
		}
		if wantN1 && len(nplus1) < limit*2 && editDistanceOne(q, c) {
			nplus1 = append(nplus1, c)
		}
	}
	// Prefix matches first, then the rest (both already alphabetical since `calls`
	// is sorted and we scanned in order).
	sort.SliceStable(partial, func(i, j int) bool {
		if partial[i].prefix != partial[j].prefix {
			return partial[i].prefix
		}
		return false
	})
	out := Result{Partial: []string{}, NPlus1: []string{}}
	for _, p := range partial {
		if len(out.Partial) >= limit {
			break
		}
		out.Partial = append(out.Partial, p.call)
	}
	if len(nplus1) > limit {
		nplus1 = nplus1[:limit]
	}
	out.NPlus1 = append(out.NPlus1, nplus1...)
	return out
}

// editDistanceOne reports whether a and b are exactly one edit apart (one
// substitution, insertion or deletion) — never equal (distance 0 returns false).
func editDistanceOne(a, b string) bool {
	la, lb := len(a), len(b)
	if la == lb {
		diff := 0
		for i := 0; i < la; i++ {
			if a[i] != b[i] {
				diff++
				if diff > 1 {
					return false
				}
			}
		}
		return diff == 1
	}
	// Ensure a is the shorter one; lengths must differ by exactly 1.
	if la > lb {
		a, b = b, a
		la, lb = lb, la
	}
	if lb-la != 1 {
		return false
	}
	// b is a with one extra char: walk both, allowing a single skip in b.
	i, j, skipped := 0, 0, false
	for i < la && j < lb {
		if a[i] == b[j] {
			i++
			j++
			continue
		}
		if skipped {
			return false
		}
		skipped = true
		j++ // skip the extra char in the longer string
	}
	return true
}

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

// 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
}
