package api

import (
	"context"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/ponzischeme89/memby/server/internal/appupdate"
)

// ProtocolVersion changes only when the client/server wire contract is no longer
// mutually compatible. App release versions remain independent and are handled by the
// update policy. Exported so the startup line can state which contract this build
// speaks, next to the build's own version.
const ProtocolVersion = 1

// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful.
type updatePolicyCache struct {
	mu    sync.RWMutex
	value appupdate.Policy
}

func (c *updatePolicyCache) get() appupdate.Policy {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.value
}

func (c *updatePolicyCache) set(value appupdate.Policy) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value = value
}

// LoadUpdatePolicy primes the cached policy. Called at boot and after every change.
func (s *Server) LoadUpdatePolicy(ctx context.Context) error {
	policy, err := s.store.UpdatePolicy(ctx)
	if err != nil {
		return err
	}
	s.updatePolicy.set(policy)
	return nil
}

// WatchUpdatePolicy re-reads the policy periodically, so a change made directly in the
// database is picked up without a restart.
func (s *Server) WatchUpdatePolicy(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			if err := s.LoadUpdatePolicy(ctx); err != nil {
				s.log.Warn("update policy refresh failed", "error", err)
			}
		}
	}
}

// clientVersion reads the version a TV reports. Absent means an older build that predates
// the header — [appupdate.Decide] treats that as "say nothing".
func clientVersion(r *http.Request) string {
	return strings.TrimSpace(r.Header.Get("X-Memby-Version"))
}

func clientProtocol(r *http.Request) string {
	return strings.TrimSpace(r.Header.Get("X-Memby-Protocol"))
}

func clientProtocolNumber(r *http.Request) int {
	reported, _ := strconv.Atoi(clientProtocol(r))
	return reported
}

func compatibilityFor(r *http.Request) (bool, string) {
	reported, err := strconv.Atoi(clientProtocol(r))
	if err != nil || reported != ProtocolVersion {
		if clientProtocol(r) == "" {
			return false, "This Memby app is too old to verify compatibility with the server. Update the app."
		}
		return false, "Memby app/server mismatch: app protocol " + clientProtocol(r) +
			", server protocol " + strconv.Itoa(ProtocolVersion) + ". Update the app or server."
	}
	return true, ""
}

// destructiveUpdateFloor returns the operator-selected compatibility floor once an
// actionable release at or above it exists. This keeps a policy saved ahead of its APK
// from locking televisions out.
func destructiveUpdateFloor(policy appupdate.Policy) string {
	if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" {
		return ""
	}
	floor := strings.TrimSpace(policy.RetireBelowVersion)
	if floor == "" || appupdate.CompareVersions(policy.LatestVersion, floor) < 0 {
		return ""
	}
	return floor
}

// effectiveUpdatePolicy applies the destructive floor without weakening a higher
// non-destructive minimum the operator has already selected.
func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
	floor := destructiveUpdateFloor(policy)
	if floor == "" {
		return policy
	}
	if strings.TrimSpace(policy.MinimumVersion) == "" ||
		appupdate.CompareVersions(policy.MinimumVersion, floor) < 0 {
		policy.MinimumVersion = floor
	}
	return policy
}

func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
	return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r))
}

// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a
// newer release without wanting every otherwise supported session destroyed. Only builds
// below the active destructive floor are signed out.
func mustRetireForUpdate(decision appupdate.Decision, version, floor string) bool {
	return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" &&
		strings.TrimSpace(floor) != "" && appupdate.CompareVersions(version, floor) < 0
}

// requireSupportedClient prevents a retired build from signing straight back in after
// the authenticated gate has removed its old session. Its public update check remains
// available and will keep returning the actionable mandatory verdict.
func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		policy := s.updatePolicy.get()
		decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
		if mustRetireForUpdate(decision, clientVersion(r), destructiveUpdateFloor(policy)) {
			w.Header().Set("X-Memby-Update-Required", decision.Version)
			writeJSON(w, http.StatusUpgradeRequired, decision)
			return
		}
		next(w, r)
	}
}

// handleUpdate answers the client's version check.
//
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
// app build, not a viewer or login. The verdict comes from memory; when a bearer token is
// present the route resolves it only to attribute an offered update to the affected viewer.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
	decision := s.updateDecision(r)
	// Only a verdict that asks a television to do something is worth a line. Every TV
	// checks on every launch, and "nothing to say" logged each time would bury the
	// launch where an update was actually offered — or forced.
	if decision.Status != "" && decision.Status != appupdate.StatusNone {
		s.loggerFor(r.Context()).Info("update offered",
			"status", decision.Status,
			"from", clientLogValue(clientVersion(r)),
			"to", decision.Version,
		)
	}
	writeJSON(w, http.StatusOK, decision)
}
