// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later

package repo

import (
	"fmt"
	"net"
	"net/http"
	"strings"

	licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
	repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/httplib"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
)

// CDNHandler serves release assets via the CDN hostname.
// URL format: /:owner/:repo/releases/:tag/:filename
// Only assets with cdn_public=true are served.
func CDNHandler(w http.ResponseWriter, req *http.Request) {
	if !setting.CDN.Enabled {
		http.NotFound(w, req)
		return
	}

	if !cdnCheckIPAllowed(req) {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}

	if !cdnCheckReferrerAllowed(req) {
		http.Error(w, "Forbidden: referrer not allowed", http.StatusForbidden)
		return
	}

	// Parse: /:owner/:repo/releases/:tag/:filename
	urlPath := strings.TrimPrefix(req.URL.Path, "/")
	parts := strings.SplitN(urlPath, "/", 6)

	// Minimum: owner/repo/releases/tag/filename = 5 parts
	if len(parts) < 5 || parts[2] != "releases" {
		http.Error(w, "Not Found: expected /:owner/:repo/releases/:tag/:filename", http.StatusNotFound)
		return
	}

	ownerName := parts[0]
	repoName := parts[1]
	tagName := parts[3]
	fileName := parts[4]
	// Allow filenames with slashes (parts[5] if present)
	if len(parts) == 6 {
		fileName = parts[4] + "/" + parts[5]
	}

	// Load repository
	repo, err := repo_model.GetRepositoryByOwnerAndName(req.Context(), ownerName, repoName)
	if err != nil {
		if repo_model.IsErrRepoNotExist(err) {
			http.NotFound(w, req)
		} else {
			log.Error("CDN: GetRepositoryByOwnerAndName %s/%s: %v", ownerName, repoName, err)
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		}
		return
	}

	// Look up the release by tag
	release, err := repo_model.GetRelease(req.Context(), repo.ID, tagName)
	if err != nil {
		if repo_model.IsErrReleaseNotExist(err) {
			http.NotFound(w, req)
		} else {
			log.Error("CDN: GetRelease %s/%s tag=%s: %v", ownerName, repoName, tagName, err)
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		}
		return
	}

	// Don't serve draft releases via CDN
	if release.IsDraft {
		http.NotFound(w, req)
		return
	}

	// If the release is assigned to an update stream, CDN is disabled -
	// the update server handles distribution for streamed releases.
	if stream := licenses_model.GetReleaseStream(req.Context(), release.ID); stream != "" {
		http.Error(w, "Forbidden: release is served via update stream", http.StatusForbidden)
		return
	}

	// Find the specific attachment by filename
	attach, err := repo_model.GetAttachmentByReleaseIDFileName(req.Context(), release.ID, fileName)
	if err != nil || attach == nil {
		http.NotFound(w, req)
		return
	}

	// Only serve assets marked as CDN public
	if !attach.CDNPublic {
		http.Error(w, "Forbidden: asset is not CDN-enabled", http.StatusForbidden)
		return
	}

	// Check file size limit
	if setting.CDN.MaxFileSize > 0 && attach.Size > setting.CDN.MaxFileSize {
		http.Error(w, "File too large for CDN delivery", http.StatusRequestEntityTooLarge)
		return
	}

	// CORS headers
	if len(setting.CDN.AllowedOrigins) > 0 {
		origin := req.Header.Get("Origin")
		for _, allowed := range setting.CDN.AllowedOrigins {
			if allowed == "*" || allowed == origin {
				w.Header().Set("Access-Control-Allow-Origin", allowed)
				break
			}
		}
	} else {
		w.Header().Set("Access-Control-Allow-Origin", "*")
	}
	w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
	w.Header().Set("Access-Control-Max-Age", "86400")

	if req.Method == http.MethodOptions {
		w.WriteHeader(http.StatusNoContent)
		return
	}

	// ETag based on attachment UUID (immutable for same content)
	etag := `"` + attach.UUID + `"`
	w.Header().Set("Etag", etag)

	// 304 Not Modified check
	if inm := req.Header.Get("If-None-Match"); inm != "" {
		for item := range strings.SplitSeq(inm, ",") {
			item = strings.TrimPrefix(strings.TrimSpace(item), "W/")
			if item == etag {
				w.WriteHeader(http.StatusNotModified)
				return
			}
		}
	}

	// Last-Modified
	lastModified := attach.CreatedUnix.AsTimePtr()
	if lastModified != nil {
		w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat))
	}

	// CDN cache headers
	cacheTTL := int(setting.CDN.CacheTTL.Seconds())
	w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, no-transform", cacheTTL))

	// Increment download count
	if err := attach.IncreaseDownloadCount(req.Context()); err != nil {
		log.Error("CDN: IncreaseDownloadCount: %v", err)
	}

	// Try direct storage URL (S3/object storage)
	if setting.Attachment.Storage.ServeDirect() {
		u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, req.Method, nil)
		if u != nil && err == nil {
			http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect)
			return
		}
	}

	// Serve from local storage
	fr, err := storage.Attachments.Open(attach.RelativePath())
	if err != nil {
		log.Error("CDN: storage.Open %s: %v", attach.RelativePath(), err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	defer fr.Close()

	httplib.ServeUserContentByFile(req, w, fr, httplib.ServeHeaderOptions{
		Filename:      attach.Name,
		CacheIsPublic: true,
		CacheDuration: setting.CDN.CacheTTL,
	})
}

// cdnCheckIPAllowed checks if the request IP is in the configured allowlist.
func cdnCheckIPAllowed(req *http.Request) bool {
	if len(setting.CDN.AllowedIPs) == 0 {
		return true
	}

	remoteIP := cdnGetRemoteIP(req)
	if remoteIP == nil {
		return false
	}

	for _, cidr := range setting.CDN.AllowedIPs {
		cidr = strings.TrimSpace(cidr)
		if cidr == "" {
			continue
		}

		if !strings.Contains(cidr, "/") {
			if remoteIP.Equal(net.ParseIP(cidr)) {
				return true
			}
			continue
		}

		_, network, err := net.ParseCIDR(cidr)
		if err != nil {
			log.Warn("CDN: invalid CIDR in AllowedIPs: %s", cidr)
			continue
		}
		if network.Contains(remoteIP) {
			return true
		}
	}
	return false
}

// cdnCheckReferrerAllowed checks if the request referrer domain is allowed.
func cdnCheckReferrerAllowed(req *http.Request) bool {
	if len(setting.CDN.AllowedDomains) == 0 {
		return true
	}

	referer := req.Header.Get("Referer")
	if referer == "" {
		return true // direct requests always allowed
	}

	for _, domain := range setting.CDN.AllowedDomains {
		domain = strings.TrimSpace(strings.ToLower(domain))
		if domain == "" {
			continue
		}
		if domain == "*" {
			return true
		}
		refLower := strings.ToLower(referer)
		if strings.Contains(refLower, "://"+domain+"/") || strings.Contains(refLower, "://"+domain+":") ||
			strings.HasSuffix(refLower, "://"+domain) {
			return true
		}
		if strings.HasPrefix(domain, "*.") {
			baseDomain := domain[2:]
			if strings.Contains(refLower, "."+baseDomain+"/") || strings.Contains(refLower, "."+baseDomain+":") ||
				strings.HasSuffix(refLower, "."+baseDomain) {
				return true
			}
		}
	}
	return false
}

// cdnGetRemoteIP extracts the client IP, checking proxy headers.
func cdnGetRemoteIP(req *http.Request) net.IP {
	if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
		parts := strings.SplitN(xff, ",", 2)
		if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil {
			return ip
		}
	}
	if xri := req.Header.Get("X-Real-IP"); xri != "" {
		if ip := net.ParseIP(strings.TrimSpace(xri)); ip != nil {
			return ip
		}
	}
	host, _, err := net.SplitHostPort(req.RemoteAddr)
	if err != nil {
		return net.ParseIP(req.RemoteAddr)
	}
	return net.ParseIP(host)
}
