// SPDX-License-Identifier: GPL-3.0-or-later

package updater

import (
	"context"
	"crypto/ed25519"
	"crypto/tls"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/rand"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"sync"
	"time"
)

// Dialer is the subset of the tor.Client interface the updater needs.
// All outbound update traffic goes through this dialer so it routes via Tor.
type Dialer interface {
	DialContext(ctx context.Context, network, address string) (net.Conn, error)
}

// ServiceRestarter restarts the running daemon process via the OS service
// manager (launchd/systemd/etc). After a successful binary replacement the
// updater calls this; the service manager re-launches the new binary.
type ServiceRestarter interface {
	Restart() error
}

// Logger is a minimal logging contract; the daemon's stdout is sufficient.
type Logger interface {
	Printf(format string, v ...any)
}

type stdLogger struct{}

func (stdLogger) Printf(f string, v ...any) { fmt.Printf("updater: "+f+"\n", v...) }

// State is a snapshot of update progress, exposed via the API.
type State struct {
	CurrentVersion string    `json:"current_version"`
	LatestVersion  string    `json:"latest_version,omitempty"`
	UpdateAvailable bool     `json:"update_available"`
	AutoUpdate     bool      `json:"auto_update"`
	Channel        string    `json:"channel"`
	LastChecked    time.Time `json:"last_checked,omitempty"`
	LastError      string    `json:"last_error,omitempty"`
	// Pending is set when an update has been downloaded and is waiting for
	// service restart. The file path identifies the staged binary.
	PendingPath string `json:"pending_path,omitempty"`
	// ReleaseNotesURL points at the release notes for LatestVersion.
	ReleaseNotesURL string `json:"release_notes_url,omitempty"`
}

// Config controls updater behaviour.
type Config struct {
	// CurrentVersion is the running daemon's version string, normally
	// injected via -ldflags at build time.
	CurrentVersion string

	// ManifestURL is the public HTTPS URL of the signed update manifest.
	// All requests go through the Tor dialer.
	ManifestURL string

	// Channel selects which manifest to fetch (e.g. "stable").
	Channel string

	// AutoUpdate enables background polling and automatic install. When
	// false the updater still exposes status (so the UI banner can warn)
	// but never installs anything without an explicit API request.
	AutoUpdate bool

	// PollInterval is the average gap between checks. A uniform jitter of
	// up to ±25% is added to avoid synchronised herds.
	PollInterval time.Duration

	// StagingDir is where downloaded artifacts are staged before being
	// promoted to the live binary path.
	StagingDir string

	// ExecPath is the path to the running daemon binary on disk; the
	// updater replaces this file in place after verification.
	ExecPath string

	// Dialer routes manifest and binary downloads through Tor.
	Dialer Dialer

	// Restarter is called after a successful self-replacement to bounce
	// the daemon under its service manager. May be nil — in that case the
	// updater leaves the new binary on disk and waits for the next manual
	// restart.
	Restarter ServiceRestarter

	// Logger receives non-fatal status messages.
	Logger Logger
}

// DefaultManifestURL is the production update endpoint. It is intentionally
// hardcoded: the public update host is part of the application's public
// brand domain and does not reveal internal infrastructure.
const DefaultManifestURL = "https://privatearchive.xyz/updates/stable.json"

// DefaultPollInterval is the average time between background update checks.
// A jitter of up to ±25% is added per check.
const DefaultPollInterval = 6 * time.Hour

// Updater orchestrates manifest fetching, signature verification, and
// in-place binary replacement.
type Updater struct {
	cfg Config
	pub ed25519.PublicKey

	mu    sync.RWMutex
	state State
	// cachedManifest is the most recently verified manifest, used to satisfy
	// proxy requests from the CLI and Tauri frontend without re-fetching.
	cachedManifest *Manifest

	stop chan struct{}
}

// New constructs an updater. The embedded public key is loaded once; if
// none was embedded, New returns an error and the daemon should run
// without an updater (logging a warning is fine — the absence of a key is
// not a security issue, only a missing capability).
func New(cfg Config) (*Updater, error) {
	if cfg.CurrentVersion == "" {
		return nil, errors.New("updater: CurrentVersion is required")
	}
	if cfg.ManifestURL == "" {
		cfg.ManifestURL = DefaultManifestURL
	}
	if cfg.Channel == "" {
		cfg.Channel = "stable"
	}
	if cfg.PollInterval == 0 {
		cfg.PollInterval = DefaultPollInterval
	}
	if cfg.Logger == nil {
		cfg.Logger = stdLogger{}
	}
	if cfg.StagingDir == "" {
		return nil, errors.New("updater: StagingDir is required")
	}
	if cfg.Dialer == nil {
		return nil, errors.New("updater: Dialer is required")
	}
	pub, err := PublicKey()
	if err != nil {
		return nil, fmt.Errorf("loading update public key: %w", err)
	}
	u := &Updater{
		cfg:  cfg,
		pub:  pub,
		stop: make(chan struct{}),
	}
	u.state = State{
		CurrentVersion: cfg.CurrentVersion,
		AutoUpdate:     cfg.AutoUpdate,
		Channel:        cfg.Channel,
	}
	return u, nil
}

// Run starts the background polling loop. It returns when ctx is cancelled
// or Stop is called. Safe to call from a goroutine.
func (u *Updater) Run(ctx context.Context) {
	// Initial check on startup, but with a small delay so Tor has time to
	// finish bootstrapping after the daemon comes up.
	t := time.NewTimer(30 * time.Second)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-u.stop:
			return
		case <-t.C:
		}
		// Inside a read-only AppImage the daemon must never self-replace its
		// bundled backend: the squashfs is read-only and the whole bundle is
		// updated atomically by the desktop frontend (Tauri) instead. We still
		// fetch+cache the manifest so the /updates/tauri-manifest endpoint has
		// data to hand the frontend — i.e. behave like AutoUpdate is off.
		if u.cfg.AutoUpdate && !runningInAppImage() {
			if err := u.CheckAndApply(ctx); err != nil {
				u.cfg.Logger.Printf("auto-update failed: %v", err)
			}
		} else {
			// Auto-install disabled (or read-only AppImage): still refresh
			// status so the UI banner / desktop updater can see a new version.
			if _, err := u.CheckOnly(ctx); err != nil {
				u.cfg.Logger.Printf("status refresh failed: %v", err)
			}
		}
		t.Reset(jitter(u.cfg.PollInterval))
	}
}

// runningInAppImage reports whether the daemon is executing from inside a
// mounted AppImage. The AppImage runtime exports $APPIMAGE (the bundle path)
// to the app's processes. Inside one, the backend binary lives on a read-only
// squashfs, so self-replacement is both impossible and unnecessary — Tauri
// updates the whole bundle atomically.
func runningInAppImage() bool {
	return os.Getenv("APPIMAGE") != ""
}

// Stop signals the Run loop to exit.
func (u *Updater) Stop() {
	select {
	case <-u.stop:
	default:
		close(u.stop)
	}
}

// State returns the current updater state snapshot.
func (u *Updater) State() State {
	u.mu.RLock()
	defer u.mu.RUnlock()
	return u.state
}

// SetAutoUpdate toggles automatic installation. Disabling does not roll
// back any update already on disk.
func (u *Updater) SetAutoUpdate(enabled bool) {
	u.mu.Lock()
	u.cfg.AutoUpdate = enabled
	u.state.AutoUpdate = enabled
	u.mu.Unlock()
}

// CheckOnly fetches and verifies the manifest, updating the visible state,
// but does not download or install any artifact.
func (u *Updater) CheckOnly(ctx context.Context) (*Manifest, error) {
	m, err := u.fetchManifest(ctx)
	if err != nil {
		u.recordError(err)
		return nil, err
	}
	u.mu.Lock()
	u.cachedManifest = m
	u.state.LatestVersion = m.Version
	u.state.UpdateAvailable = isNewer(m.Version, u.cfg.CurrentVersion)
	u.state.LastChecked = time.Now().UTC()
	u.state.LastError = ""
	u.state.ReleaseNotesURL = m.ReleaseNotesURL
	u.mu.Unlock()
	return m, nil
}

// CheckAndApply fetches the manifest and, if a newer version is available,
// downloads the backend artifact, verifies it, swaps it onto the live exec
// path, and asks the service manager to restart.
func (u *Updater) CheckAndApply(ctx context.Context) error {
	m, err := u.CheckOnly(ctx)
	if err != nil {
		return err
	}
	if !isNewer(m.Version, u.cfg.CurrentVersion) {
		return nil
	}
	platform, ok := m.Get(runtime.GOOS, runtime.GOARCH)
	if !ok || platform.Backend == nil {
		// A desktop-only platform entry (AppImage / .app / .msi) ships no
		// standalone backend binary to swap — the whole bundle is updated by
		// the desktop frontend via the /updates/binary/desktop proxy. That's a
		// no-op for the Go self-updater, not an error.
		if ok && platform.Desktop != nil {
			return nil
		}
		return fmt.Errorf("manifest has no backend artifact for %s/%s", runtime.GOOS, runtime.GOARCH)
	}
	if u.cfg.ExecPath == "" {
		return errors.New("ExecPath not configured; cannot self-replace")
	}
	staged, err := u.downloadAndVerify(ctx, platform.Backend, "privatebackup-backend.new")
	if err != nil {
		u.recordError(err)
		return err
	}
	u.mu.Lock()
	u.state.PendingPath = staged
	u.mu.Unlock()
	if err := promoteBinary(staged, u.cfg.ExecPath); err != nil {
		u.recordError(err)
		return fmt.Errorf("promoting binary: %w", err)
	}
	u.cfg.Logger.Printf("installed new backend %s -> %s", m.Version, u.cfg.ExecPath)
	if u.cfg.Restarter != nil {
		if err := u.cfg.Restarter.Restart(); err != nil {
			u.cfg.Logger.Printf("service restart after update failed: %v", err)
			// Not fatal — operator can restart manually; the new binary
			// is already in place.
		}
	}
	return nil
}

// FetchArtifact returns the verified bytes of a single artifact for a
// component. The CLI and Tauri front-end use this so their downloads also
// go via Tor (the backend is the only component with direct network
// access).
func (u *Updater) FetchArtifact(ctx context.Context, component Component) ([]byte, *Artifact, error) {
	u.mu.RLock()
	m := u.cachedManifest
	u.mu.RUnlock()
	if m == nil {
		var err error
		m, err = u.CheckOnly(ctx)
		if err != nil {
			return nil, nil, err
		}
	}
	platform, ok := m.Get(runtime.GOOS, runtime.GOARCH)
	if !ok {
		return nil, nil, fmt.Errorf("no artifacts for %s/%s", runtime.GOOS, runtime.GOARCH)
	}
	var artifact *Artifact
	switch component {
	case ComponentBackend:
		artifact = platform.Backend
	case ComponentCLI:
		artifact = platform.CLI
	case ComponentDesktop:
		artifact = platform.Desktop
	default:
		return nil, nil, fmt.Errorf("unknown component %q", component)
	}
	if artifact == nil {
		return nil, nil, fmt.Errorf("manifest has no %s artifact for this platform", component)
	}
	body, err := u.fetchURL(ctx, artifact.URL)
	if err != nil {
		return nil, nil, err
	}
	if err := VerifyArtifact(artifact, body); err != nil {
		return nil, nil, err
	}
	return body, artifact, nil
}

// CachedManifest returns the most recently verified manifest, or nil.
func (u *Updater) CachedManifest() *Manifest {
	u.mu.RLock()
	defer u.mu.RUnlock()
	return u.cachedManifest
}

func (u *Updater) recordError(err error) {
	u.mu.Lock()
	u.state.LastError = err.Error()
	u.state.LastChecked = time.Now().UTC()
	u.mu.Unlock()
}

func (u *Updater) httpClient() *http.Client {
	transport := &http.Transport{
		DialContext: u.cfg.Dialer.DialContext,
		TLSClientConfig: &tls.Config{
			MinVersion: tls.VersionTLS12,
		},
		// Modest timeouts: SOCKS handshake + TLS over Tor is slow but
		// not unboundedly so.
		IdleConnTimeout:   60 * time.Second,
		ForceAttemptHTTP2: false,
	}
	return &http.Client{
		Transport: transport,
		Timeout:   10 * time.Minute,
	}
}

func (u *Updater) fetchManifest(ctx context.Context) (*Manifest, error) {
	body, err := u.fetchURL(ctx, u.cfg.ManifestURL)
	if err != nil {
		return nil, fmt.Errorf("fetching manifest: %w", err)
	}
	var m Manifest
	if err := json.Unmarshal(body, &m); err != nil {
		return nil, fmt.Errorf("parsing manifest: %w", err)
	}
	if err := m.Verify(u.pub); err != nil {
		return nil, fmt.Errorf("verifying manifest: %w", err)
	}
	if m.Channel != u.cfg.Channel {
		return nil, fmt.Errorf("manifest channel %q does not match requested %q", m.Channel, u.cfg.Channel)
	}
	return &m, nil
}

func (u *Updater) fetchURL(ctx context.Context, url string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "privatearchive-updater")
	resp, err := u.httpClient().Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
	}
	// Cap response size to defend against unbounded download via the
	// manifest endpoint specifically; binary downloads are bounded by the
	// Size field once the manifest has been verified.
	limit := int64(256 << 20) // 256 MiB cap
	return io.ReadAll(io.LimitReader(resp.Body, limit))
}

func (u *Updater) downloadAndVerify(ctx context.Context, a *Artifact, stagedName string) (string, error) {
	body, err := u.fetchURL(ctx, a.URL)
	if err != nil {
		return "", err
	}
	if err := VerifyArtifact(a, body); err != nil {
		return "", err
	}
	staged := filepath.Join(u.cfg.StagingDir, stagedName)
	if err := writeStaged(staged, body); err != nil {
		return "", err
	}
	return staged, nil
}

// jitter returns d ± 25%.
func jitter(d time.Duration) time.Duration {
	if d <= 0 {
		return d
	}
	delta := time.Duration(rand.Int63n(int64(d) / 2))
	if rand.Intn(2) == 0 {
		return d + delta - d/4
	}
	return d - delta + d/4
}

// isNewer reports whether candidate is a strictly newer version than current.
// Both must follow semver-like dotted-numeric layout (1.2.3); pre-release
// suffixes are ignored. Anything we cannot parse is treated as "not newer"
// to avoid downgrading.
func isNewer(candidate, current string) bool {
	c := parseVersion(candidate)
	r := parseVersion(current)
	if c == nil || r == nil {
		return false
	}
	for i := 0; i < 3; i++ {
		if c[i] > r[i] {
			return true
		}
		if c[i] < r[i] {
			return false
		}
	}
	return false
}

func parseVersion(s string) []int {
	// Trim a leading "v" if present and strip any pre-release/build suffix.
	if len(s) > 0 && (s[0] == 'v' || s[0] == 'V') {
		s = s[1:]
	}
	for i, ch := range s {
		if ch == '-' || ch == '+' {
			s = s[:i]
			break
		}
	}
	parts := splitDots(s)
	if len(parts) > 3 {
		parts = parts[:3]
	}
	for len(parts) < 3 {
		parts = append(parts, "0")
	}
	out := make([]int, 3)
	for i, p := range parts {
		n, err := atoi(p)
		if err != nil {
			return nil
		}
		out[i] = n
	}
	return out
}

func splitDots(s string) []string {
	var out []string
	start := 0
	for i := 0; i < len(s); i++ {
		if s[i] == '.' {
			out = append(out, s[start:i])
			start = i + 1
		}
	}
	out = append(out, s[start:])
	return out
}

func atoi(s string) (int, error) {
	if s == "" {
		return 0, errors.New("empty")
	}
	n := 0
	for _, ch := range s {
		if ch < '0' || ch > '9' {
			return 0, fmt.Errorf("not a number: %q", s)
		}
		n = n*10 + int(ch-'0')
	}
	return n, nil
}
