package dxcc import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "sync" "sync/atomic" "time" ) // CtyDatURL is the canonical source of cty.dat. AD1C ships updates roughly // monthly; we cache the file on disk so we don't hammer it. const CtyDatURL = "https://www.country-files.com/cty/cty.dat" // Manager owns the on-disk cty.dat cache and the parsed DB. Safe for // concurrent reads after Load; concurrent reloads serialize on its lock. type Manager struct { cacheDir string mu sync.RWMutex db *DB src ctySource // metadata about whichever copy we loaded loading atomic.Bool } type ctySource struct { Path string `json:"path"` LoadedAt time.Time `json:"loaded_at"` FileModTime time.Time `json:"file_mod_time"` Entities int `json:"entities"` Downloaded bool `json:"downloaded"` } // NewManager prepares a manager rooted at cacheDir (created if missing). // Does not load anything — call EnsureLoaded after. func NewManager(cacheDir string) *Manager { return &Manager{cacheDir: cacheDir} } // Path returns the on-disk path where cty.dat is/should be cached. func (m *Manager) Path() string { return filepath.Join(m.cacheDir, "cty.dat") } // EnsureLoaded loads cty.dat from disk; if missing, downloads it first. // Safe to call repeatedly — only the first run actually downloads. func (m *Manager) EnsureLoaded(ctx context.Context) error { if _, err := os.Stat(m.Path()); os.IsNotExist(err) { if err := m.Download(ctx); err != nil { return fmt.Errorf("download cty.dat: %w", err) } } return m.LoadFromDisk() } // LoadFromDisk parses the cached cty.dat into a fresh DB and swaps it in. func (m *Manager) LoadFromDisk() error { f, err := os.Open(m.Path()) if err != nil { return err } defer f.Close() info, _ := f.Stat() db, err := Load(f) if err != nil { return err } m.mu.Lock() m.db = db m.src = ctySource{ Path: m.Path(), LoadedAt: time.Now(), FileModTime: info.ModTime(), Entities: len(db.entities), } m.mu.Unlock() return nil } // Download fetches a fresh cty.dat from CtyDatURL and atomically replaces // the on-disk cache. Does NOT reload it into memory — caller can chain // LoadFromDisk for that. func (m *Manager) Download(ctx context.Context) error { if !m.loading.CompareAndSwap(false, true) { return fmt.Errorf("cty.dat download already in progress") } defer m.loading.Store(false) if err := os.MkdirAll(m.cacheDir, 0o755); err != nil { return err } req, err := http.NewRequestWithContext(ctx, "GET", CtyDatURL, nil) if err != nil { return err } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d", resp.StatusCode) } // Write to a temp file in the same dir, then atomic rename — avoids a // half-written file if we crash mid-download. tmp, err := os.CreateTemp(m.cacheDir, "cty-*.tmp") if err != nil { return err } tmpPath := tmp.Name() _, err = io.Copy(tmp, resp.Body) tmp.Close() if err != nil { os.Remove(tmpPath) return err } if err := os.Rename(tmpPath, m.Path()); err != nil { os.Remove(tmpPath) return err } return nil } // Refresh = Download + LoadFromDisk in one call. func (m *Manager) Refresh(ctx context.Context) error { if err := m.Download(ctx); err != nil { return err } return m.LoadFromDisk() } // Lookup is a passthrough to the loaded DB. Returns false if no DB is // loaded yet (callers should treat that as graceful degradation). func (m *Manager) Lookup(callsign string) (Match, bool) { m.mu.RLock() db := m.db m.mu.RUnlock() if db == nil { return Match{}, false } return db.Lookup(callsign) } // Info returns metadata about the currently-loaded cty.dat (or zero value // if nothing loaded). func (m *Manager) Info() ctySource { m.mu.RLock() defer m.mu.RUnlock() return m.src }