package api

import (
	"archive/zip"
	gofs "io/fs"
	"net"
	"path/filepath"

	"github.com/gin-gonic/gin"

	"github.com/photoprism/photoprism/internal/auth/acl"
	"github.com/photoprism/photoprism/internal/config"
	"github.com/photoprism/photoprism/internal/entity"
	"github.com/photoprism/photoprism/internal/event"
	"github.com/photoprism/photoprism/internal/photoprism/get"
	reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
	"github.com/photoprism/photoprism/internal/service/cluster/theme"
	"github.com/photoprism/photoprism/pkg/clean"
	"github.com/photoprism/photoprism/pkg/fs"
	"github.com/photoprism/photoprism/pkg/http/header"
	"github.com/photoprism/photoprism/pkg/log/status"
)

// ClusterGetTheme returns custom theme files as zip, if available.
//
//	@Summary	returns custom theme files as zip, if available
//	@Id			ClusterGetTheme
//	@Tags		Cluster
//	@Produce	application/zip
//	@Success	200				{file}		application/zip
//	@Failure	401,403,404,429	{object}	i18n.Response
//	@Router		/api/v1/cluster/theme [get]
func ClusterGetTheme(router *gin.RouterGroup) {
	router.GET("/cluster/theme", func(c *gin.Context) {
		// Get app config and client IP.
		conf := get.Config()
		clientIp := ClientIP(c)

		// Optional IP-based allowance via ClusterCIDR.
		refID := "-"
		var session *entity.Session

		if cidr := conf.ClusterCIDR(); cidr != "" {
			if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
				if ip := net.ParseIP(clientIp); ip != nil && ipnet.Contains(ip) {
					// Allowed by CIDR; proceed without session.
					refID = "cidr"
				}
			}
		}

		// If not allowed by CIDR, require regular auth.
		if refID == "-" {
			s := Auth(c, acl.ResourceCluster, acl.ActionDownload)
			if s.Abort(c) {
				return
			}
			refID = s.RefID
			session = s
		}

		/*
			TODO - Consider the following optional hardening measures:
			  1. Track a hadError flag to log "partial success" if some files fail to zip.
			  2. Set limits (total size/entry count) in case theme directories grow unexpectedly.
			  3. Optionally, return a 404 or 204 error code when no files are added, though an empty zip file is acceptable.
		*/

		// Abort if this is not a portal server.
		if !conf.Portal() {
			AbortFeatureDisabled(c)
			return
		}

		themePath := conf.PortalThemePath()

		// Resolve symbolic links.
		if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
			event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme resolve", status.Error(err)}, refID)
			AbortNotFound(c)
			return
		} else {
			themePath = resolved
		}

		// Check if theme path exists.
		if !fs.PathExists(themePath) {
			event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme path", status.NotFound}, refID)
			AbortNotFound(c)
			return
		}

		// Require a non-empty app.js file to avoid distributing empty themes.
		// This aligns with bootstrap behavior, which only installs a theme when
		// app.js exists locally or can be fetched from the Portal.
		if !fs.FileExistsNotEmpty(filepath.Join(themePath, "app.js")) {
			event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme app.js", status.NotFound}, refID)
			AbortNotFound(c)
			return
		}

		event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme create archive", "%s", "started"}, refID, clean.Log(themePath))

		if version, err := theme.DetectVersion(themePath); err == nil {
			updateNodeThemeVersion(conf, session, version, clientIp, refID)
		} else {
			event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "detect theme version", "%s", status.Failed}, refID, clean.Error(err))
		}

		// Add response headers.
		AddDownloadHeader(c, "theme.zip")
		AddContentTypeHeader(c, header.ContentTypeZip)

		// Create zip writer to stream the theme files.
		zipWriter := zip.NewWriter(c.Writer)
		defer func(w *zip.Writer) {
			if closeErr := w.Close(); closeErr != nil {
				event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme close", status.Error(closeErr)}, refID)
			}
		}(zipWriter)

		err := filepath.WalkDir(themePath, func(filePath string, info gofs.DirEntry, walkErr error) error {
			// Handle errors.
			if walkErr != nil {
				event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme traverse", status.Error(walkErr)}, refID)

				// If the error occurs on a directory, skip descending to avoid cascading errors.
				if info != nil && info.IsDir() {
					return gofs.SkipDir
				}

				return nil

			}

			// Get file base name.
			name := info.Name()

			// Skip any subdirectories to enhance security.
			if info.IsDir() {
				if filePath != themePath {
					return gofs.SkipDir
				}

				return nil
			}

			// Skip non-regular files and symlinks.
			if !info.Type().IsRegular() || info.Type()&gofs.ModeSymlink != 0 {
				return nil
			}

			// Skip hidden files by name.
			if fs.FileNameHidden(name) {
				return nil
			}

			// Get the relative file name to use as alias in the zip.
			alias := filepath.ToSlash(fs.RelName(filePath, themePath))

			event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme add", "%s", status.Added}, refID, clean.Log(alias))

			// Stream zipped file contents.
			if zipErr := fs.ZipFile(zipWriter, filePath, alias, false); zipErr != nil {
				event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme add %s", status.Error(zipErr)}, refID, clean.Log(alias))
			}

			return nil
		})

		// Log result.
		if err != nil {
			event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", status.Error(err)}, refID)
		} else {
			event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", status.Succeeded}, refID)
		}
	})
}

// updateNodeThemeVersion persists the reported theme version for the active
// node when the request is authenticated as a cluster client.
func updateNodeThemeVersion(conf *config.Config, session *entity.Session, version, clientIP, refID string) {
	if conf == nil || session == nil {
		return
	}

	normalized := clean.TypeUnicode(version)

	if normalized == "" {
		return
	}

	client := session.GetClient()

	if client == nil || client.ClientUID == "" {
		return
	}

	regy, err := reg.NewClientRegistryWithConfig(conf)

	if err != nil {
		event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata registry", "%s", status.Failed}, refID, clean.Error(err))
		return
	}

	var node *reg.Node

	if client.NodeUUID != "" {
		if n, err := regy.Get(client.NodeUUID); err == nil {
			node = n
		}
	}

	if node == nil {
		if n, err := regy.FindByClientID(client.ClientUID); err == nil {
			node = n
		}
	}

	if node == nil {
		event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata node", status.Skipped}, refID)
		return
	}

	if node.Theme == normalized {
		return
	}

	node.Theme = normalized

	if err = regy.Put(node); err != nil {
		event.AuditWarn([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", status.Error(err)}, refID)
		return
	}

	event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", status.Updated}, refID)
}
