🏡 index : quine-global/hyper.quineglobal.com.git

package http

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"app/html"
)

const releasesAPI = "https://api.github.com/repos/quine-global/hyper/releases?per_page=30"

// ReleaseCache is a thread-safe cache of GitHub releases.
type ReleaseCache struct {
	log         *slog.Logger
	mu          sync.RWMutex
	releases    []html.Release
	lastFetched time.Time
	fetchMu     sync.Mutex // TryLock prevents concurrent fetches
}

// Get returns cached releases, falling back to hardcoded data if cache is empty.
func (c *ReleaseCache) Get() []html.Release {
	c.mu.RLock()
	defer c.mu.RUnlock()
	if len(c.releases) == 0 {
		return []html.Release{}
	}
	return c.releases
}

// ForceRefresh does a blocking, unconditional fetch regardless of cache age.
// It waits for any in-progress fetch to finish before starting its own.
func (c *ReleaseCache) ForceRefresh(ctx context.Context) error {
	c.fetchMu.Lock()
	defer c.fetchMu.Unlock()

	releases, err := fetchReleases(ctx)
	if err != nil {
		c.log.Warn("Force refresh failed", "error", err)
		return err
	}
	if len(releases) == 0 {
		return nil
	}

	c.mu.Lock()
	c.releases = releases
	c.lastFetched = time.Now()
	c.mu.Unlock()

	c.log.Info("Force-refreshed releases from GitHub", "count", len(releases))
	return nil
}

// TryRefresh fetches fresh releases if the cache is older than minAge.
// If a fetch is already in progress it returns immediately (non-blocking).
func (c *ReleaseCache) TryRefresh(ctx context.Context, minAge time.Duration) {
	if !c.fetchMu.TryLock() {
		return
	}
	defer c.fetchMu.Unlock()

	c.mu.RLock()
	stale := time.Since(c.lastFetched) >= minAge
	c.mu.RUnlock()

	if !stale {
		return
	}

	releases, err := fetchReleases(ctx)
	if err != nil {
		c.log.Warn("Failed to fetch releases from GitHub", "error", err)
		return
	}
	if len(releases) == 0 {
		return // keep existing cache rather than replacing with empty
	}

	c.mu.Lock()
	c.releases = releases
	c.lastFetched = time.Now()
	c.mu.Unlock()

	c.log.Info("Refreshed releases from GitHub", "count", len(releases))
}

type ghRelease struct {
	TagName     string    `json:"tag_name"`
	Prerelease  bool      `json:"prerelease"`
	Draft       bool      `json:"draft"`
	PublishedAt time.Time `json:"published_at"`
	Assets      []ghAsset `json:"assets"`
}

type ghAsset struct {
	Name               string `json:"name"`
	BrowserDownloadURL string `json:"browser_download_url"`
}

func fetchReleases(ctx context.Context) ([]html.Release, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var raw []ghRelease
	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
		return nil, err
	}

	releases := make([]html.Release, 0, len(raw))
	for _, gr := range raw {
		if gr.Draft {
			continue
		}
		version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v"))
		r := html.Release{
			TagName:     gr.TagName,
			Version:     version,
			CanaryBuild: canaryBuild,
			Date:        gr.PublishedAt.Format("Jan 2006"),
			PublishedAt: gr.PublishedAt,
			IsCanary:    gr.Prerelease || canaryBuild > 0,
			Assets:      make(map[string]map[string]string),
		}
		for _, a := range gr.Assets {
			os, arch, ok := classifyAsset(a.Name)
			if !ok {
				continue
			}
			if r.Assets[os] == nil {
				r.Assets[os] = make(map[string]string)
			}
			// Keep first match per os/arch (most specific asset listed first by GitHub)
			if _, exists := r.Assets[os][arch]; !exists {
				r.Assets[os][arch] = a.BrowserDownloadURL
			}
		}
		releases = append(releases, r)
	}
	return releases, nil
}

// parseTag splits a tag like "4.0.0-q-canary.12" into base version "4.0.0" and build number 12.
// Returns build 0 for stable tags.
func parseTag(tag string) (version string, canaryBuild int) {
	// Canary: "4.0.0-q-canary.12" → ("4.0.0", 12)
	const canaryMarker = "-q-canary."
	if idx := strings.Index(tag, canaryMarker); idx != -1 {
		if n, err := strconv.Atoi(tag[idx+len(canaryMarker):]); err == nil {
			return tag[:idx], n
		}
	}
	// Stable Quine builds: "4.0.0-q" or "4.0.0q" → "4.0.0"
	tag = strings.TrimSuffix(tag, "-q")
	tag = strings.TrimSuffix(tag, "q")
	return tag, 0
}

// classifyAsset maps an asset filename to an OS and architecture.
func classifyAsset(name string) (os, arch string, ok bool) {
	l := strings.ToLower(name)
	switch {
	case strings.HasSuffix(l, ".exe"):
		return "windows", "x64", true
	case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".dmg"):
		return "mac", "arm64", true
	case strings.HasSuffix(l, ".dmg"):
		return "mac", "x64", true
	case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".appimage"):
		return "linux", "arm64", true
	case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".appimage"):
		return "", "", false
	case strings.HasSuffix(l, ".appimage"):
		return "linux", "x64", true
	case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".rpm"):
		return "linux-rpm", "arm64", true
	case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".rpm"):
		return "", "", false
	case strings.HasSuffix(l, ".rpm"):
		return "linux-rpm", "x64", true
	case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".deb"):
		return "linux-deb", "arm64", true
	case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".deb"):
		return "", "", false
	case strings.HasSuffix(l, ".deb"):
		return "linux-deb", "x64", true
	case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".snap"):
		return "", "", false
	case strings.HasSuffix(l, ".snap"):
		return "linux-snap", "x64", true
	case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".pacman"):
		return "linux-pacman", "arm64", true
	case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".pacman"):
		return "", "", false
	case strings.HasSuffix(l, ".pacman"):
		return "linux-pacman", "x64", true
	case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".flatpak"):
		return "linux-flatpak", "arm64", true
	case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".flatpak"):
		return "", "", false
	case strings.HasSuffix(l, ".flatpak"):
		return "linux-flatpak", "x64", true
	default:
		return "", "", false
	}
}