package api

// In-app update endpoints (#397). The Android APK ships bundled with
// the server image so the client can self-update without an external
// app store. CI sequencing bakes the APK + sidecar version file into
// /app/client/ at image build time.
//
// Both endpoints are authenticated — the bandwidth cost of the APK
// (~30-60 MB) makes anonymous access an abuse vector. The Flutter
// client's polling only fires after login (banner mounts in the post-
// login shell), so this gate is invisible to the actual update flow.
//
// /api/client/apk additionally rate-limits per user to a single
// download every 60s. Real install flows fire one download per
// update; anything tighter is scripted/abusive.
//
// Returns 404 gracefully when the APK isn't present (dev environments,
// pre-CI-wiring); the Flutter client treats 404 as "no update channel
// available."

import (
	"errors"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)

const (
	defaultClientAPKDir = "/app/client"
	clientAPKFilename   = "minstrel.apk"
	clientVersionFile   = "minstrel.apk.version"

	// clientAPKMinInterval throttles per-user APK downloads. 60s is
	// generous for a real install flow (one download) and tight enough
	// to suppress accidental hammering or scripted abuse.
	clientAPKMinInterval = 60 * time.Second
)

// clientAPKDir resolves the directory holding the bundled APK. Env
// var MINSTREL_CLIENT_APK_DIR overrides for dev; default matches the
// Dockerfile's COPY destination.
func clientAPKDir() string {
	if d, ok := os.LookupEnv("MINSTREL_CLIENT_APK_DIR"); ok && d != "" {
		return d
	}
	return defaultClientAPKDir
}

// clientAPKLastDownload tracks the last APK-download timestamp per
// user id (UUID hex string). Cheap in-memory map under mutex; a single
// household has at most a handful of users so the map never grows.
// Reset on process restart, which is fine — abuse protection, not
// audit. testResetClientAPKRateLimit() lets tests start clean.
var (
	clientAPKLastDownload   = map[string]time.Time{}
	clientAPKLastDownloadMu sync.Mutex
)

func testResetClientAPKRateLimit() {
	clientAPKLastDownloadMu.Lock()
	defer clientAPKLastDownloadMu.Unlock()
	clientAPKLastDownload = map[string]time.Time{}
}

// clientAPKAllowDownload checks + updates the per-user rate-limit
// state. Returns the wait duration if blocked, or 0 if allowed.
func clientAPKAllowDownload(userID string, now time.Time) time.Duration {
	clientAPKLastDownloadMu.Lock()
	defer clientAPKLastDownloadMu.Unlock()
	if last, ok := clientAPKLastDownload[userID]; ok {
		elapsed := now.Sub(last)
		if elapsed < clientAPKMinInterval {
			return clientAPKMinInterval - elapsed
		}
	}
	clientAPKLastDownload[userID] = now
	return 0
}

type clientVersionResponse struct {
	Version   string `json:"version"`
	APKURL    string `json:"apk_url"`
	SizeBytes int64  `json:"size_bytes"`
}

// handleClientVersion returns the bundled Android client version + a
// URL to fetch the APK. 404 when no APK is bundled.
func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
	dir := clientAPKDir()
	apkPath := filepath.Join(dir, clientAPKFilename)
	versionPath := filepath.Join(dir, clientVersionFile)

	stat, err := os.Stat(apkPath)
	if errors.Is(err, os.ErrNotExist) {
		http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound)
		return
	}
	if err != nil {
		writeErrWithLog(w, h.logger, "client_version: stat apk", err)
		return
	}

	versionBytes, err := os.ReadFile(versionPath)
	if errors.Is(err, os.ErrNotExist) {
		http.Error(w, `{"error":{"code":"no_client_version","message":"apk present but version file missing"}}`, http.StatusNotFound)
		return
	}
	if err != nil {
		writeErrWithLog(w, h.logger, "client_version: read version", err)
		return
	}

	writeJSON(w, http.StatusOK, clientVersionResponse{
		Version:   strings.TrimSpace(string(versionBytes)),
		APKURL:    "/api/client/apk",
		SizeBytes: stat.Size(),
	})
}

// handleClientAPK streams the bundled APK with the correct
// Content-Type so Android's PackageInstaller accepts it. Per-user
// rate-limited (clientAPKMinInterval).
func (h *handlers) handleClientAPK(w http.ResponseWriter, r *http.Request) {
	user, ok := auth.UserFromContext(r.Context())
	if !ok {
		http.Error(w, `{"error":{"code":"unauthenticated","message":"login required"}}`, http.StatusUnauthorized)
		return
	}
	userID := syncpkg.FormatUUID(user.ID)
	if wait := clientAPKAllowDownload(userID, time.Now()); wait > 0 {
		w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
		http.Error(w, `{"error":{"code":"rate_limited","message":"too many downloads; try again shortly"}}`, http.StatusTooManyRequests)
		return
	}

	apkPath := filepath.Join(clientAPKDir(), clientAPKFilename)
	f, err := os.Open(apkPath)
	if errors.Is(err, os.ErrNotExist) {
		http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound)
		return
	}
	if err != nil {
		writeErrWithLog(w, h.logger, "client_apk: open", err)
		return
	}
	defer func() { _ = f.Close() }()

	stat, err := f.Stat()
	if err != nil {
		writeErrWithLog(w, h.logger, "client_apk: stat", err)
		return
	}

	// Use http.ServeContent so Range requests work — install flows on
	// flaky networks may resume rather than restart.
	w.Header().Set("Content-Type", "application/vnd.android.package-archive")
	w.Header().Set("Content-Disposition", `attachment; filename="minstrel.apk"`)
	http.ServeContent(w, r, clientAPKFilename, stat.ModTime(), f)
}
