package api

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"net/http"
	"net/url"
	"strings"
	"time"

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

const (
	installerCookieName = "memby_installer"
	installerSessionTTL = 30 * time.Minute
	installerDeviceID   = "memby-web-installer"
	installerDeviceName = "Memby Web Installer"

	// installerRenewWithin is how close to expiry a session must be before an operator's
	// own request re-issues it. Half the TTL, so a cookie is rewritten at most once every
	// fifteen minutes rather than on every request of a working session.
	installerRenewWithin = installerSessionTTL / 2
)

func (s *Server) installerSecret() []byte {
	if s.cfg.ReleasePublishToken == "" {
		return nil
	}
	// Domain separation means a cookie/signature is not the release publisher token and
	// cannot be presented to the upload endpoint.
	sum := sha256.Sum256([]byte("memby installer access v1\x00" + s.cfg.ReleasePublishToken))
	return sum[:]
}

func (s *Server) signInstallerValue(purpose string, payload []byte) []byte {
	mac := hmac.New(sha256.New, s.installerSecret())
	_, _ = mac.Write([]byte(purpose))
	_, _ = mac.Write([]byte{0})
	_, _ = mac.Write(payload)
	return mac.Sum(nil)
}

func (s *Server) newInstallerSession() (string, error) {
	payload := make([]byte, 8+16)
	binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(installerSessionTTL).Unix()))
	if _, err := rand.Read(payload[8:]); err != nil {
		return "", err
	}
	signature := s.signInstallerValue("session", payload)
	return base64.RawURLEncoding.EncodeToString(payload) + "." +
		base64.RawURLEncoding.EncodeToString(signature), nil
}

// installerSessionExpiry reports when the request's session runs out. A cookie that is
// missing, malformed, forged or already expired is reported the same way: no session.
func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
	if len(s.installerSecret()) == 0 {
		return time.Time{}, false
	}
	cookie, err := r.Cookie(installerCookieName)
	if err != nil {
		return time.Time{}, false
	}
	parts := strings.Split(cookie.Value, ".")
	if len(parts) != 2 {
		return time.Time{}, false
	}
	payload, err := base64.RawURLEncoding.DecodeString(parts[0])
	if err != nil || len(payload) != 24 {
		return time.Time{}, false
	}
	signature, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) {
		return time.Time{}, false
	}
	expires := int64(binary.BigEndian.Uint64(payload[:8]))
	now := time.Now().Unix()
	if expires <= now || expires > now+int64(installerSessionTTL/time.Second)+60 {
		return time.Time{}, false
	}
	return time.Unix(expires, 0), true
}

func (s *Server) validInstallerSession(r *http.Request) bool {
	_, ok := s.installerSessionExpiry(r)
	return ok
}

// renewInstallerSession slides a valid session's expiry forward. The TTL was absolute and
// nothing extended it, so an operator working the admin console was signed out from under
// themselves after thirty minutes and the page's poll became a permanent "invalid admin
// token" banner with no sign-in to return to. Callers must only reach here for a request
// an operator actually made — see operatorPresent — or an abandoned tab's own polling
// would keep the session alive indefinitely, which is what the TTL exists to stop.
func (s *Server) renewInstallerSession(w http.ResponseWriter, r *http.Request) {
	expires, ok := s.installerSessionExpiry(r)
	if !ok || time.Until(expires) > installerRenewWithin {
		return
	}
	session, err := s.newInstallerSession()
	if err != nil {
		s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
		return
	}
	s.setInstallerCookie(w, session)
}

func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
	http.SetCookie(w, &http.Cookie{
		Name:     installerCookieName,
		Value:    value,
		Path:     "/",
		MaxAge:   int(installerSessionTTL / time.Second),
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteStrictMode,
	})
}

func (s *Server) clearInstallerCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     installerCookieName,
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteStrictMode,
	})
}

func (s *Server) releaseAccessToken(filename string) string {
	if len(s.installerSecret()) == 0 || !releaseFilenamePattern.MatchString(filename) {
		return ""
	}
	return base64.RawURLEncoding.EncodeToString(
		s.signInstallerValue("release", []byte(filename)),
	)
}

func (s *Server) signedReleasePath(filename string) string {
	token := s.releaseAccessToken(filename)
	if token == "" {
		return ""
	}
	return "/updates/" + filename + "?access=" + url.QueryEscape(token)
}

func (s *Server) allowedReleaseDownload(r *http.Request, filename string) bool {
	if s.validInstallerSession(r) {
		return true
	}
	expected := s.releaseAccessToken(filename)
	presented := strings.TrimSpace(r.URL.Query().Get("access"))
	return expected != "" && hmac.Equal([]byte(presented), []byte(expected))
}

func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
	if len(s.installerSecret()) == 0 {
		http.NotFound(w, r)
		return
	}
	// Password authentication necessarily registers a device with Emby. Do not start
	// it unless the service credential needed to remove that temporary record exists.
	if s.cfg.SyncAPIKey == "" {
		s.loggerFor(r.Context()).Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured")
		s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
			http.StatusServiceUnavailable, "/install")
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
	if err := r.ParseForm(); err != nil {
		s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusBadRequest, "/install")
		return
	}
	next := cleanInstallerDestination(r.FormValue("next"))
	username := strings.TrimSpace(r.FormValue("username"))
	password := r.FormValue("password")
	if username == "" || password == "" {
		s.renderAccessLogin(w, r, "Enter your username and password.", http.StatusBadRequest, next)
		return
	}

	auth, err := s.emby.Authenticate(
		r.Context(), username, password, installerDeviceID, installerDeviceName, "",
	)
	if err != nil {
		s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username)
		s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next)
		return
	}
	// Authentication creates an Emby access token. The installer needs only proof that
	// it succeeded, so retire the upstream session immediately and never persist it.
	if err := s.emby.Logout(r.Context(), emby.Credentials{
		UserID: auth.User.ID, Token: auth.AccessToken,
		DeviceID: installerDeviceID, DeviceName: installerDeviceName,
	}); err != nil {
		s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err)
	}
	if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{
		UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
		DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
	}, installerDeviceID); err != nil {
		s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err)
		s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
			http.StatusBadGateway, next)
		return
	}

	session, err := s.newInstallerSession()
	if err != nil {
		s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not start installer session")
		return
	}
	s.setInstallerCookie(w, session)
	http.Redirect(w, r, next, http.StatusSeeOther)
}

func (s *Server) handleInstallLogout(w http.ResponseWriter, r *http.Request) {
	s.clearInstallerCookie(w)
	http.Redirect(w, r, "/install", http.StatusSeeOther)
}

func cleanInstallerDestination(value string) string {
	value = strings.TrimSpace(value)
	if value == "/admin/" {
		return "/admin/"
	}
	if strings.HasPrefix(value, "/admin/") {
		page := strings.TrimPrefix(value, "/admin/")
		if adminPages[page] {
			return value
		}
	}
	return "/install"
}
