package sat

import (
	_ "embed"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
)

// The frequency side of a satellite: what to listen on, what to transmit on,
// and how the two are tied together.
//
// The elements say where a bird is; this says what to do with the radio when it
// is there. They are separate on purpose — the elements change every few days
// and come from a feed, while a transponder plan changes when a satellite is
// commanded into another mode, which is a matter for the operator and AMSAT's
// published chart.
//
// The shipped list is a STARTING POINT, not an authority: satellites are
// switched between modes, transponders are turned off for a season, and new
// ones fly. It is copied to the data directory on first use and read from there
// afterwards, so an operator can correct a frequency without waiting for a
// release — and keep the correction across updates.

//go:embed birds.json
var shippedBirds []byte

// BirdsName is the editable copy in the data directory.
const BirdsName = "satellites.json"

// Transponder is one usable path through a satellite.
type Transponder struct {
	Label string `json:"label"`
	Mode  string `json:"mode"` // ADIF: FM, SSB, CW, DATA

	// The downlink and uplink passbands, in Hz. A single frequency (an FM
	// repeater, a beacon) sets only the "lo" of each side.
	DownLo int64 `json:"down_lo"`
	DownHi int64 `json:"down_hi,omitempty"`
	UpLo   int64 `json:"up_lo,omitempty"`
	UpHi   int64 `json:"up_hi,omitempty"`

	// Inverting: the transponder turns the passband over, so tuning UP the
	// downlink means going DOWN the uplink. Getting this backwards puts the
	// operator's transmission at the far end of the passband from the station
	// they can hear — which is the classic first evening on a linear bird.
	Inverting bool `json:"inverting,omitempty"`

	// CTCSS is the subaudible tone an FM uplink needs, in Hz. Zero = none.
	CTCSS float64 `json:"ctcss,omitempty"`
}

// Linear reports a transponder with a passband rather than a single channel.
func (t Transponder) Linear() bool { return t.DownHi > t.DownLo && t.UpHi > t.UpLo }

// UplinkFor is where to transmit in order to be heard at downHz on the
// downlink.
//
// On a channel (FM) the answer is the uplink frequency, whatever the operator
// is tuned to. On a linear transponder it is a position in the passband — the
// same distance in from the edge, and from the OTHER edge when the transponder
// inverts.
func (t Transponder) UplinkFor(downHz int64) int64 {
	if t.UpLo <= 0 {
		return 0 // receive-only: a beacon, or a downlink we have no way to answer
	}
	if !t.Linear() {
		return t.UpLo
	}
	if downHz < t.DownLo {
		downHz = t.DownLo
	}
	if downHz > t.DownHi {
		downHz = t.DownHi
	}
	offset := downHz - t.DownLo
	if t.Inverting {
		return t.UpHi - offset
	}
	return t.UpLo + offset
}

// DownlinkFor is the inverse: where a station transmitting at upHz comes out.
// It exists for the operator who tunes the uplink first — rarer, but the split
// has to be consistent whichever end they take hold of.
func (t Transponder) DownlinkFor(upHz int64) int64 {
	if !t.Linear() {
		return t.DownLo
	}
	if upHz < t.UpLo {
		upHz = t.UpLo
	}
	if upHz > t.UpHi {
		upHz = t.UpHi
	}
	if t.Inverting {
		return t.DownLo + (t.UpHi - upHz)
	}
	return t.DownLo + (upHz - t.UpLo)
}

// Centre is the middle of the downlink passband — where to park when the
// operator picks a satellite and has not yet chosen a frequency in it.
func (t Transponder) Centre() int64 {
	if !t.Linear() {
		return t.DownLo
	}
	return t.DownLo + (t.DownHi-t.DownLo)/2
}

// Bird is one satellite's frequency plan.
type Bird struct {
	Name    string   `json:"name"`
	Aliases []string `json:"aliases,omitempty"`
	// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
	// QO-100 is the reason the flag exists, and it changes what the whole
	// tracking side does — there is nothing to predict and nothing to follow.
	Geostationary bool          `json:"geostationary,omitempty"`
	Transponders  []Transponder `json:"transponders"`
}

// Matches reports whether a name from an element feed is this satellite.
//
// The same rules Find uses, exposed for the other direction: the caller holds a
// bird and is scanning an element set spelled by somebody else.
func (b Bird) Matches(feedName string) bool {
	cands := []string{feedName}
	if i := strings.IndexByte(feedName, '('); i > 0 {
		cands = append(cands, feedName[:i], strings.Trim(feedName[i:], "()"))
	}
	names := append([]string{b.Name}, b.Aliases...)
	if i := strings.IndexByte(b.Name, '('); i > 0 {
		names = append(names, b.Name[:i], strings.Trim(b.Name[i:], "()"))
	}
	for _, n := range names {
		ln := loose(n)
		if ln == "" {
			continue
		}
		for _, c := range cands {
			if ln == loose(c) {
				return true
			}
		}
	}
	return false
}

// Birds is the frequency plan for every satellite the station knows.
type Birds struct {
	mu    sync.RWMutex
	list  []Bird
	byKey map[string]int // name and aliases, loosely normalised → index in list
}

// loose is the matching form of a satellite name: upper case, letters and
// digits only.
//
// Feeds, AMSAT and operators all spell the same bird differently — "ES'HAIL 2",
// "ESHAIL-2", "Es'hail 2" — and none of them is wrong. Comparing the letters and
// digits alone is what lets the frequency plan meet the element set without a
// dozen aliases per satellite.
func loose(name string) string {
	var b strings.Builder
	for _, r := range strings.ToUpper(name) {
		if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// LoadBirds reads the plan from the data directory, writing the shipped copy
// there first if there is none.
//
// A file the operator has broken is NOT overwritten: it is reported and the
// shipped list is used for this session, so a stray comma costs a correction
// rather than the corrections of the last two years.
func LoadBirds(dir string) (*Birds, error) {
	b := &Birds{}
	path := filepath.Join(dir, BirdsName)
	data, err := os.ReadFile(path)
	switch {
	case err == nil:
		if perr := b.parse(data); perr != nil {
			_ = b.parse(shippedBirds)
			return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
		}
		return b, nil
	case os.IsNotExist(err):
		if perr := b.parse(shippedBirds); perr != nil {
			return nil, perr
		}
		if werr := os.MkdirAll(dir, 0o755); werr == nil {
			_ = os.WriteFile(path, shippedBirds, 0o644)
		}
		return b, nil
	default:
		_ = b.parse(shippedBirds)
		return b, err
	}
}

func (b *Birds) parse(data []byte) error {
	var list []Bird
	if err := json.Unmarshal(data, &list); err != nil {
		return err
	}
	byKey := make(map[string]int, len(list)*3)
	put := func(name string, i int) {
		if k := loose(name); k != "" {
			// First writer wins: a satellite's own name must never be displaced by
			// another bird's alias.
			if _, seen := byKey[k]; !seen {
				byKey[k] = i
			}
		}
	}
	for i, bird := range list {
		put(bird.Name, i)
	}
	for i, bird := range list {
		for _, a := range bird.Aliases {
			put(a, i)
		}
		// "RADFXSAT (FOX-1B)" is one string in the feed and two names to an
		// operator; index both halves so either spelling finds the bird.
		if j := strings.IndexByte(bird.Name, '('); j > 0 {
			put(bird.Name[:j], i)
			put(strings.Trim(bird.Name[j:], "()"), i)
		}
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	b.list, b.byKey = list, byKey
	return nil
}

// Find looks a satellite up by name or alias.
//
// Celestrak says "RADFXSAT (FOX-1B)" where every operator says AO-91, so the
// bracketed halves are tried on their own before giving up — that is how most
// feed names differ from the name on the chart.
func (b *Birds) Find(name string) (Bird, bool) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	try := func(s string) (Bird, bool) {
		if i, ok := b.byKey[loose(s)]; ok {
			return b.list[i], true
		}
		return Bird{}, false
	}
	if bird, ok := try(name); ok {
		return bird, true
	}
	if i := strings.IndexByte(name, '('); i > 0 {
		if bird, ok := try(name[:i]); ok {
			return bird, true
		}
		if bird, ok := try(strings.Trim(name[i:], "()")); ok {
			return bird, true
		}
	}
	return Bird{}, false
}

// All lists the plan, in name order.
func (b *Birds) All() []Bird {
	b.mu.RLock()
	defer b.mu.RUnlock()
	out := append([]Bird(nil), b.list...)
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

// Len is how many satellites carry a frequency plan.
func (b *Birds) Len() int {
	b.mu.RLock()
	defer b.mu.RUnlock()
	return len(b.list)
}
