package sat

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

// Where the elements come from, and where they are kept.
//
// Celestrak's amateur group is the list every tracker in this hobby uses; the
// PE0SAT mirror is there for the day Celestrak is down or rate-limiting, which
// it does when a hundred trackers all wake up at the top of the hour.
const (
	FeedCelestrak = "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle"
	FeedPE0SAT    = "http://tle.pe0sat.nl/kepler/amateur.txt"
	// CacheName is the file kept in the data directory. Plain TLE text, so an
	// operator can open it, read it, and paste a line into a tracker that is not
	// this one.
	CacheName = "satellites.tle"
	// StaleAfter is when elements stop being worth trusting silently. SGP4 drifts
	// a few hundred metres a day for a low orbit, which is nothing for a pass
	// prediction and everything for a rotator at high elevation — so the age is
	// SHOWN rather than enforced, and this is only the point at which OpsLog
	// offers to fetch again.
	StaleAfter = 3 * 24 * time.Hour
)

// ParseTLESet reads a whole feed or cache file: three lines per satellite —
// name, then the two element lines — or two where the name is absent.
//
// A malformed satellite is SKIPPED, not fatal. A feed of two hundred birds with
// one bad checksum must still give the operator the other hundred and
// ninety-nine, and the count of what was dropped is returned so the app can say
// so instead of quietly holding a shorter list.
func ParseTLESet(r io.Reader) (els []Element, skipped int, err error) {
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
	var pending []string
	flush := func() {
		defer func() { pending = nil }()
		var name, l1, l2 string
		switch len(pending) {
		case 3:
			name, l1, l2 = pending[0], pending[1], pending[2]
		case 2:
			l1, l2 = pending[0], pending[1]
		default:
			if len(pending) > 0 {
				skipped++
			}
			return
		}
		e, perr := ParseElement(name, l1, l2)
		if perr != nil {
			skipped++
			return
		}
		els = append(els, e)
	}
	for sc.Scan() {
		line := strings.TrimRight(sc.Text(), " \t\r")
		if strings.TrimSpace(line) == "" {
			flush()
			continue
		}
		// A "1 " or "2 " line is an element line; anything else starts a new
		// satellite. That rule reads both the three-line and the two-line form
		// without the file having to say which it is.
		isElement := len(line) > 2 && (line[0] == '1' || line[0] == '2') && line[1] == ' '
		if !isElement && len(pending) > 0 {
			flush()
		}
		pending = append(pending, line)
		if len(pending) == 3 || (len(pending) == 2 && strings.HasPrefix(pending[0], "1 ")) {
			flush()
		}
	}
	flush()
	if err := sc.Err(); err != nil {
		return els, skipped, fmt.Errorf("sat: reading the element set: %w", err)
	}
	if len(els) == 0 {
		return nil, skipped, fmt.Errorf("sat: no usable elements in that set (%d entries refused)", skipped)
	}
	return els, skipped, nil
}

// Fetcher loads element sets from the feeds and keeps a copy on disk.
type Fetcher struct {
	Dir     string        // where the cache file lives — the app's data directory
	Feeds   []string      // tried in order; the first that answers wins
	Timeout time.Duration // per feed
	Logf    func(string, ...any)
}

// NewFetcher builds one with the usual feeds.
func NewFetcher(dir string) *Fetcher {
	return &Fetcher{
		Dir:     dir,
		Feeds:   []string{FeedCelestrak, FeedPE0SAT},
		Timeout: 20 * time.Second,
		Logf:    func(string, ...any) {},
	}
}

func (f *Fetcher) cachePath() string { return filepath.Join(f.Dir, CacheName) }

// LoadCache reads the elements kept from last time, with the file's own
// modification time as the fetch time.
//
// This is what makes the first screen after a launch a full one: an operator who
// opens the satellite tab on a train, or on a shack PC with no internet, still
// gets last week's elements — which are perfectly good for knowing what passes
// tonight — instead of an empty list and a spinner.
func (f *Fetcher) LoadCache() ([]Element, time.Time, error) {
	p := f.cachePath()
	fh, err := os.Open(p)
	if err != nil {
		return nil, time.Time{}, err
	}
	defer fh.Close()
	els, skipped, err := ParseTLESet(fh)
	if err != nil {
		return nil, time.Time{}, err
	}
	at := time.Time{}
	if st, serr := os.Stat(p); serr == nil {
		at = st.ModTime()
	}
	if skipped > 0 {
		f.Logf("sat: %d cached entries were unusable and were skipped", skipped)
	}
	return els, at, nil
}

// Fetch downloads a fresh set and writes the cache.
//
// The cache is only replaced once a feed has produced usable elements: a feed
// that answers with an error page, a captive-portal login or an empty file must
// not take away the set the station already had.
func (f *Fetcher) Fetch(ctx context.Context) ([]Element, error) {
	var lastErr error
	for _, url := range f.Feeds {
		els, body, err := f.fetchOne(ctx, url)
		if err != nil {
			f.Logf("sat: %s: %v", shortHost(url), err)
			lastErr = err
			continue
		}
		if err := f.writeCache(body); err != nil {
			// Not fatal: the elements are in hand and the station can track
			// tonight. Only the next cold start loses by it, and it says so.
			f.Logf("sat: could not write the element cache: %v", err)
		}
		f.Logf("sat: %d satellites from %s", len(els), shortHost(url))
		return els, nil
	}
	if lastErr == nil {
		lastErr = fmt.Errorf("no feed configured")
	}
	return nil, fmt.Errorf("sat: could not fetch the element set: %w", lastErr)
}

func (f *Fetcher) fetchOne(ctx context.Context, url string) ([]Element, []byte, error) {
	to := f.Timeout
	if to <= 0 {
		to = 20 * time.Second
	}
	ctx, cancel := context.WithTimeout(ctx, to)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, nil, err
	}
	// Named, because Celestrak asks that clients identify themselves and answers
	// an anonymous flood with a rate limit.
	req.Header.Set("User-Agent", "OpsLog satellite tracker")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, nil, fmt.Errorf("HTTP %s", resp.Status)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
	if err != nil {
		return nil, nil, err
	}
	els, skipped, err := ParseTLESet(strings.NewReader(string(body)))
	if err != nil {
		return nil, nil, err
	}
	if skipped > 0 {
		f.Logf("sat: %s: %d entries were unusable and were skipped", shortHost(url), skipped)
	}
	return els, body, nil
}

func (f *Fetcher) writeCache(body []byte) error {
	if strings.TrimSpace(f.Dir) == "" {
		return fmt.Errorf("no data directory")
	}
	if err := os.MkdirAll(f.Dir, 0o755); err != nil {
		return err
	}
	// Written beside and renamed: a power cut mid-write must not leave a
	// half-file that parses as twenty satellites instead of two hundred.
	tmp := f.cachePath() + ".tmp"
	if err := os.WriteFile(tmp, body, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, f.cachePath())
}

// shortHost is a feed's host, for a log line that fits.
func shortHost(url string) string {
	s := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
	if i := strings.IndexAny(s, "/?"); i > 0 {
		s = s[:i]
	}
	return s
}
