package api

import (
	"archive/zip"
	"crypto/sha256"
	"crypto/subtle"
	_ "embed"
	"encoding/hex"
	"fmt"
	"html/template"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"

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

const maxReleaseSize = 250 << 20

//go:embed install.html
var installPageSource string

var installPage = template.Must(template.New("install").Parse(installPageSource))

var (
	releaseVersionPattern  = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
	releaseFilenamePattern = regexp.MustCompile(`^memby-\d+\.\d+\.\d+\.apk$`)
)

// releasePublishAuth is deliberately separate from adminAuth: CI can publish an APK but
// cannot take the service offline, force an update, or read household analytics.
func (s *Server) releasePublishAuth(h http.HandlerFunc) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if s.cfg.ReleasePublishToken == "" {
			http.NotFound(w, r)
			return
		}
		presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
		if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.ReleasePublishToken)) != 1 {
			writeError(w, http.StatusUnauthorized, "invalid release token")
			return
		}
		h(w, r)
	})
}

// handleReleasePublish accepts the signed APK produced by Gitea Actions, persists it,
// and atomically makes it the version offered to TVs.
func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, maxReleaseSize)
	if err := r.ParseMultipartForm(16 << 20); err != nil {
		writeError(w, http.StatusBadRequest, "invalid release upload")
		return
	}

	version := strings.TrimSpace(r.FormValue("version"))
	if !releaseVersionPattern.MatchString(version) {
		writeError(w, http.StatusBadRequest, "version must look like 0.1.54")
		return
	}
	mandatory, validMandatory := parseMandatoryRelease(r.FormValue("mandatory"))
	if !validMandatory {
		writeError(w, http.StatusBadRequest, "mandatory must be true or false")
		return
	}

	current := s.updatePolicy.get()
	if current.LatestVersion != "" &&
		appupdate.CompareVersions(version, current.LatestVersion) < 0 {
		writeError(w, http.StatusConflict, "refusing to publish an older version")
		return
	}

	source, _, err := r.FormFile("apk")
	if err != nil {
		writeError(w, http.StatusBadRequest, "signed APK is required")
		return
	}
	defer source.Close()

	if err := os.MkdirAll(s.cfg.ReleaseDir, 0o750); err != nil {
		s.loggerFor(r.Context()).Error("release directory unavailable", "error", err)
		writeError(w, http.StatusInternalServerError, "release storage unavailable")
		return
	}
	temp, err := os.CreateTemp(s.cfg.ReleaseDir, ".memby-upload-*")
	if err != nil {
		s.loggerFor(r.Context()).Error("release temp file failed", "error", err)
		writeError(w, http.StatusInternalServerError, "release storage unavailable")
		return
	}
	tempName := temp.Name()
	defer os.Remove(tempName)

	digest := sha256.New()
	written, copyErr := io.Copy(io.MultiWriter(temp, digest), source)
	syncErr := temp.Sync()
	closeErr := temp.Close()
	if copyErr != nil || closeErr != nil || written < 4 {
		writeError(w, http.StatusBadRequest, "could not store APK")
		return
	}
	if syncErr != nil {
		writeError(w, http.StatusInternalServerError, "could not safely store APK")
		return
	}
	actualSHA256 := hex.EncodeToString(digest.Sum(nil))
	if expected := strings.ToLower(strings.TrimSpace(r.FormValue("sha256"))); expected != "" &&
		(expected != actualSHA256 || len(expected) != sha256.Size*2) {
		writeError(w, http.StatusBadRequest, "APK checksum does not match")
		return
	}

	// Parse the archive, rather than checking only its first two bytes. AndroidManifest.xml
	// is compulsory in an APK; this rejects truncated ZIPs and renamed logs/HTML.
	archive, err := zip.OpenReader(tempName)
	if err != nil {
		writeError(w, http.StatusBadRequest, "uploaded file is not an APK")
		return
	}
	hasManifest := false
	for _, entry := range archive.File {
		if entry.Name == "AndroidManifest.xml" {
			hasManifest = true
			break
		}
	}
	archive.Close()
	if !hasManifest {
		writeError(w, http.StatusBadRequest, "uploaded APK has no Android manifest")
		return
	}

	filename := fmt.Sprintf("memby-%s.apk", version)
	destination := filepath.Join(s.cfg.ReleaseDir, filename)
	newFile := true
	if existingSHA256, hashErr := fileSHA256(destination); hashErr == nil {
		if existingSHA256 != actualSHA256 {
			writeError(w, http.StatusConflict,
				"that version already exists with different APK contents; publish a new version")
			return
		}
		newFile = false
	} else if !os.IsNotExist(hashErr) {
		s.loggerFor(r.Context()).Error("existing release could not be verified", "error", hashErr)
		writeError(w, http.StatusInternalServerError, "could not verify existing release")
		return
	}
	if newFile {
		if err := os.Rename(tempName, destination); err != nil {
			s.loggerFor(r.Context()).Error("release publish rename failed", "error", err)
			writeError(w, http.StatusInternalServerError, "could not publish APK")
			return
		}
	}
	if err := os.Chmod(destination, 0o640); err != nil {
		s.loggerFor(r.Context()).Warn("release permissions could not be tightened", "error", err)
	}

	policy := appupdate.Policy{
		Enabled:            true,
		LatestVersion:      version,
		MinimumVersion:     current.MinimumVersion,
		RetireBelowVersion: current.RetireBelowVersion,
		DownloadURL:        s.cfg.PublicURL + s.signedReleasePath(filename),
		SHA256:             actualSHA256,
		SizeBytes:          written,
		Notes:              strings.TrimSpace(r.FormValue("notes")),
	}
	if mandatory {
		// Setting the floor to the release being published makes every older client
		// receive a mandatory verdict, which has no dismiss/skip path on the TV.
		policy.MinimumVersion = version
	}
	if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
		if newFile {
			_ = os.Remove(destination)
		}
		s.loggerFor(r.Context()).Error("release policy write failed", "error", err)
		writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved")
		return
	}
	if err := s.LoadUpdatePolicy(r.Context()); err != nil {
		s.loggerFor(r.Context()).Error("release policy reload failed", "error", err)
		writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded")
		return
	}

	s.loggerFor(r.Context()).Info("release published", "version", version, "mandatory", mandatory,
		"bytes", written, "file", filename)
	writeJSON(w, http.StatusCreated, s.updatePolicy.get())
}

func parseMandatoryRelease(value string) (mandatory, valid bool) {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", "0", "false":
		return false, true
	case "1", "true":
		return true, true
	default:
		return false, false
	}
}

func fileSHA256(path string) (string, error) {
	file, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer file.Close()
	digest := sha256.New()
	if _, err := io.Copy(digest, file); err != nil {
		return "", err
	}
	return hex.EncodeToString(digest.Sum(nil)), nil
}

type installPageData struct {
	Authenticated bool
	Ready         bool
	Version       string
	Notes         string
	DownloadURL   string
	Size          string
	Error         string
	LoginNext     string
}

func preventDiscovery(w http.ResponseWriter) {
	// These cover general search engines, crawler-specific implementations and caches.
	// They are intentionally also applied to APK responses so a discovered download URL
	// does not appear as a searchable binary result.
	w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet, noimageindex")
	w.Header().Set("Referrer-Policy", "no-referrer")
	w.Header().Set("X-Content-Type-Options", "nosniff")
}

func handleRobots(w http.ResponseWriter, _ *http.Request) {
	preventDiscovery(w)
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Cache-Control", "public, max-age=86400")
	_, _ = io.WriteString(w, "User-agent: *\nDisallow: /\n")
}

// handleInstallPage is deliberately public: it is the bootstrap path for a television
// that does not have Memby yet. It exposes only the signed APK and operator-authored
// release notes, never household or Emby data.
func (s *Server) handleInstallPage(w http.ResponseWriter, r *http.Request) {
	s.renderInstallPage(w, r, "", http.StatusOK)
}

func (s *Server) renderInstallPage(
	w http.ResponseWriter,
	r *http.Request,
	message string,
	status int,
) {
	if len(s.installerSecret()) == 0 {
		http.NotFound(w, r)
		return
	}
	authenticated := s.validInstallerSession(r)
	if !authenticated {
		s.renderAccessLogin(w, r, message, status, "/install")
		return
	}
	policy := s.updatePolicy.get()
	version := strings.TrimSpace(policy.LatestVersion)
	filename := fmt.Sprintf("memby-%s.apk", version)
	info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename))
	ready := authenticated && releaseVersionPattern.MatchString(version) &&
		err == nil && !info.IsDir()

	data := installPageData{
		Authenticated: true,
		Ready:         ready,
		Version:       version,
		Notes:         strings.TrimSpace(policy.Notes),
		Error:         message,
	}
	if ready {
		data.DownloadURL = "/updates/latest.apk"
		data.Size = fmt.Sprintf("%.1f MB", float64(info.Size())/(1024*1024))
	}

	s.writeInstallPage(w, data, status)
}

func (s *Server) renderAccessLogin(
	w http.ResponseWriter,
	r *http.Request,
	message string,
	status int,
	next string,
) {
	if len(s.installerSecret()) == 0 {
		http.NotFound(w, r)
		return
	}
	s.writeInstallPage(w, installPageData{
		Error:     message,
		LoginNext: cleanInstallerDestination(next),
	}, status)
}

func (s *Server) writeInstallPage(w http.ResponseWriter, data installPageData, status int) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Content-Security-Policy",
		"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "+
			"base-uri 'none'; frame-ancestors 'none'")
	w.Header().Set("Permissions-Policy",
		"camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()")
	preventDiscovery(w)
	if status == http.StatusOK && data.Authenticated && !data.Ready {
		status = http.StatusServiceUnavailable
	}
	if status != http.StatusOK {
		w.WriteHeader(status)
	}
	if err := installPage.Execute(w, data); err != nil && s.log != nil {
		s.log.Error("install page render failed", "error", err)
	}
}

// handleLatestReleaseDownload gives first-time installers a stable address. Serve the
// package directly: some Android TV downloaders hand both sides of an HTTP redirect to
// the package installer, causing a successful install followed by a spurious parse error.
func (s *Server) handleLatestReleaseDownload(w http.ResponseWriter, r *http.Request) {
	if !s.validInstallerSession(r) {
		http.NotFound(w, r)
		return
	}
	version := strings.TrimSpace(s.updatePolicy.get().LatestVersion)
	if !releaseVersionPattern.MatchString(version) {
		http.NotFound(w, r)
		return
	}
	filename := fmt.Sprintf("memby-%s.apk", version)
	if info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename)); err != nil || info.IsDir() {
		http.NotFound(w, r)
		return
	}
	preventDiscovery(w)
	w.Header().Set("Content-Type", "application/vnd.android.package-archive")
	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
	w.Header().Set("Cache-Control", "private, no-store")
	http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
}

// handleReleaseDownload serves immutable, signed APKs. Access requires either a short
// browser installer session or the signed release URL returned to an authenticated app.
func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
	filename := r.PathValue("filename")
	if !releaseFilenamePattern.MatchString(filename) ||
		!s.allowedReleaseDownload(r, filename) {
		// A 404 does not confirm whether a guessed version exists.
		http.NotFound(w, r)
		return
	}
	preventDiscovery(w)
	w.Header().Set("Content-Type", "application/vnd.android.package-archive")
	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
	w.Header().Set("Cache-Control", "private, no-store")
	http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
}
