package config

import (
	"errors"
	"fmt"
	urlpkg "net/url"
	"os"
	"path/filepath"
	"strings"

	"gopkg.in/yaml.v2"

	"github.com/photoprism/photoprism/internal/service/cluster"
	"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/dns"
	"github.com/photoprism/photoprism/pkg/http/header"
	"github.com/photoprism/photoprism/pkg/list"
	"github.com/photoprism/photoprism/pkg/rnd"
)

// DefaultPortalUrl specifies the default portal URL with variable cluster domain.
var DefaultPortalUrl = "https://portal.${PHOTOPRISM_CLUSTER_DOMAIN}"

// DefaultNodeRole is the default node role assigned when none is configured.
var DefaultNodeRole = cluster.RoleApp

// DefaultJWTAllowedScopes lists default OAuth scopes for cluster-issued JWTs.
var DefaultJWTAllowedScopes = "config cluster vision metrics"

// ClusterDomain returns the cluster DOMAIN (lowercase DNS name; 1–63 chars).
func (c *Config) ClusterDomain() string {
	if c.options.ClusterDomain != "" {
		return strings.ToLower(c.options.ClusterDomain)
	}

	if _, d, found := c.deriveNodeNameAndDomainFromHttpHost(); found && d != "" {
		return d
	}

	// Attempt to derive from system configuration when not explicitly set.
	if d := dns.GetSystemDomain(); d != "" {
		return d
	}

	return ""
}

// ClusterCIDR returns the configured cluster CIDR used for IP-based allowances.
func (c *Config) ClusterCIDR() string {
	return strings.TrimSpace(c.options.ClusterCIDR)
}

// ClusterUUID returns a stable UUIDv4 that uniquely identifies the Portal.
// Precedence: env PHOTOPRISM_CLUSTER_UUID -> options.yml (ClusterUUID) -> auto-generate and persist.
func (c *Config) ClusterUUID() string {
	// Return if the configured cluster UUID is not in the expected format.
	if !rnd.IsUUID(c.options.ClusterUUID) {
		return ""
	}

	// Respect explicit CLI value if provided.
	if c.cliCtx != nil && c.cliCtx.IsSet("cluster-uuid") {
		return c.options.ClusterUUID
	}

	return c.options.ClusterUUID
}

// PortalUrl returns the URL of the cluster management portal server, if configured.
func (c *Config) PortalUrl() string {
	if c.options.PortalUrl == "" {
		return ""
	}

	d := c.ClusterDomain()

	// Return empty string if default and there's no cluster domain configured.
	if d == "" && c.options.PortalUrl == DefaultPortalUrl {
		return ""
	}

	// Replace variables with the configured cluster domain.
	c.options.PortalUrl = ExpandVars(c.options.PortalUrl, map[string]string{
		"cluster-domain":            d,
		"CLUSTER_DOMAIN":            d,
		"PHOTOPRISM_CLUSTER_DOMAIN": d,
	})

	return c.options.PortalUrl
}

// Portal returns true if the configured node type is "portal".
func (c *Config) Portal() bool {
	return c.NodeRole() == cluster.RolePortal
}

// PortalConfigPath returns the path to the default configuration for cluster portals.
func (c *Config) PortalConfigPath() string {
	return filepath.Join(c.ConfigPath(), fs.PortalDir)
}

// PortalThemePath returns the path to the theme files for cluster portals to use.
func (c *Config) PortalThemePath() string {
	themeDir := filepath.Join(c.PortalConfigPath(), fs.ThemeDir)

	if fs.PathExists(themeDir) && fs.FileExists(filepath.Join(themeDir, fs.AppJsFile)) {
		return themeDir
	}

	// Fallback to the default theme directory in the main config path.
	return c.ThemePath()
}

// NodeConfigPath returns the path to the default configuration for cluster nodes.
func (c *Config) NodeConfigPath() string {
	return filepath.Join(c.ConfigPath(), fs.NodeDir)
}

// NodeThemePath returns the path to the theme files for cluster nodes to use.
func (c *Config) NodeThemePath() string {
	return filepath.Join(c.NodeConfigPath(), fs.ThemeDir)
}

// NodeThemeVersion returns the version to the theme files of the cluster node.
func (c *Config) NodeThemeVersion() string {
	if version, err := theme.DetectVersion(c.NodeThemePath()); err == nil {
		return version
	}

	return ""
}

// JoinToken returns the portal join token used when registering nodes. It
// lazily loads the token from disk (or generates a new one) and caches it in
// memory. Example format: k9sEFe6-A7gt6zqm-gY9gFh0.
func (c *Config) JoinToken() string {
	// Read token from config options (memory).
	if rnd.IsJoinToken(c.options.JoinToken, false) {
		return c.options.JoinToken
	}

	// Read token from file if possible. Uses a cache to reduce I/O.
	if fileName := c.JoinTokenFile(); fileName != "" {
		if c.cache == nil {
			// Skip cache lookup.
		} else if s, hit := c.cache.Get(fileName); hit && s != nil {
			return s.(string)
		}

		if fs.FileExistsNotEmpty(fileName) {
			if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 { //nolint:gosec // path derived from config directory
				log.Warnf("config: could not read cluster join token from %s (%s)", fileName, err)
			} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
				if c.cache != nil {
					c.cache.SetDefault(fileName, s)
				}
				return s
			} else {
				log.Warnf("config: cluster join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
			}
		}
	}

	// Do not proceed with generating a token on nodes.
	if !c.Portal() {
		return ""
	} else if token, _, err := c.SaveJoinToken(""); err != nil {
		log.Errorf("config: %v", err)
		return ""
	} else {
		return token
	}
}

// SaveJoinToken writes a fresh portal join token to disk and updates the
// in-memory value. When customToken is provided it must already be valid.
func (c *Config) SaveJoinToken(customToken string) (token string, fileName string, err error) {
	fileName = c.JoinTokenFile()

	if fileName == "" {
		return "", "", fmt.Errorf("invalid cluster join token path")
	}

	dir := filepath.Dir(fileName)
	if dir == "" {
		return "", "", fmt.Errorf("invalid cluster secrets directory")
	}

	if customToken != "" {
		if !rnd.IsJoinToken(customToken, false) {
			return "", "", fmt.Errorf("insecure custom cluster join token specified")
		}
		token = customToken
	} else {
		token = rnd.JoinToken()
		if !rnd.IsJoinToken(token, true) {
			return "", "", fmt.Errorf("invalid cluster join token generated")
		}
	}

	// Create secret directory.
	if err = fs.MkdirAll(dir); err != nil {
		// Use memory to store join token if directory is not writable.
		c.options.JoinToken = token
		return "", "", fmt.Errorf("could not create cluster secrets path (%w)", err)
	}

	// Write secret to file.
	if err = fs.WriteFile(fileName, []byte(token), fs.ModeSecretFile); err != nil {
		// Use memory to store join token if file is not writable.
		c.options.JoinToken = token
		return "", "", fmt.Errorf("could not write cluster join token (%w)", err)
	}

	// Use an in-memory cache with a
	// short TTL to cache the token.
	if c.cache != nil {
		c.cache.SetDefault(fileName, token)
		c.options.JoinToken = ""
	} else {
		// Store token in Options
		// if cache is unavailable.
		c.options.JoinToken = token
	}

	return token, fileName, nil
}

// clearJoinTokenFileCache invalidates the cached join token file cache.
func (c *Config) clearJoinTokenFileCache() {
	if c.cache != nil {
		c.cache.Delete(c.JoinTokenFile())
	}
}

// JoinTokenFile returns the path where the portal join token is stored for the
// active configuration (portal nodes use config/portal/secrets/join_token,
// regular nodes use config/node/secrets/join_token).
func (c *Config) JoinTokenFile() string {
	if c.Portal() {
		return c.PortalJoinTokenFile()
	}

	return c.NodeJoinTokenFile()
}

// PortalJoinTokenFile returns the filepath where the portal cluster join token is stored.
func (c *Config) PortalJoinTokenFile() string {
	if filePath := FlagFilePath("JOIN_TOKEN"); filePath != "" {
		return filePath
	}

	return filepath.Join(c.PortalConfigPath(), fs.SecretsDir, fs.JoinTokenFile)

}

// NodeJoinTokenFile returns the filepath where the node cluster join token is stored.
func (c *Config) NodeJoinTokenFile() string {
	if filePath := FlagFilePath("JOIN_TOKEN"); filePath != "" {
		return filePath
	}

	return filepath.Join(c.NodeConfigPath(), fs.SecretsDir, fs.JoinTokenFile)
}

// deriveNodeNameAndDomainFromHttpHost attempts to derive cluster host and domain name from the site URL.
func (c *Config) deriveNodeNameAndDomainFromHttpHost() (hostName, domainName string, found bool) {
	if fqdn := c.SiteDomain(); fqdn != "" && !header.IsIP(fqdn) {
		hostName, domainName, found = strings.Cut(fqdn, ".")
		if hostName = clean.DNSLabel(hostName); found && dns.IsLabel(hostName) && dns.IsDomain(domainName) {
			c.options.NodeName = hostName
			if c.options.ClusterDomain == "" {
				c.options.ClusterDomain = strings.ToLower(domainName)
			}
			return c.options.NodeName, c.options.ClusterDomain, found
		}
	}

	return "", "", false
}

// NodeName returns the cluster node NAME (unique in cluster domain; [a-z0-9-]{1,32}).
func (c *Config) NodeName() string {
	if n := clean.DNSLabel(c.options.NodeName); n != "" {
		return n
	}

	if h, _, found := c.deriveNodeNameAndDomainFromHttpHost(); found && h != "" {
		return h
	}

	// Default: portal nodes → "portal".
	if c.Portal() {
		return "portal"
	}

	// Instances/services: derive from hostname via DNSLabel normalization.
	if hn, _ := dns.GetHostname(); hn != "" {
		if cand := clean.DNSLabel(hn); cand != "" {
			return cand
		}
	}

	// Fallback to a stable short identifier
	s := c.SerialChecksum()
	return "node-" + s
}

// NodeRole returns the cluster node role (portal, app, or service).
func (c *Config) NodeRole() string {
	if c.Edition() == Portal {
		c.options.NodeRole = cluster.RolePortal
		return c.options.NodeRole
	}

	switch c.options.NodeRole {
	case cluster.RolePortal, cluster.RoleApp, cluster.RoleService:
		return c.options.NodeRole
	default:
		return DefaultNodeRole
	}
}

// NodeUUID returns the UUID (v7) that identifies this node.
func (c *Config) NodeUUID() string {
	if c.options.NodeUUID != "" {
		return c.options.NodeUUID
	}

	// Generate, persist, and cache a UUIDv7 if still empty.
	uuid := rnd.UUIDv7()
	c.options.NodeUUID = uuid

	if err := c.SaveNodeUUID(uuid); err != nil {
		log.Warnf("config: could not save node UUID to %s (%s)", c.OptionsYaml(), err)
	}

	return uuid
}

// NodeClientID returns the OAuth client ID registered with the portal (auto-assigned via join token).
func (c *Config) NodeClientID() string {
	return clean.ID(c.options.NodeClientID)
}

// NodeClientSecret returns the node OAuth client secret, reading it from disk
// when necessary. Portal registration writes this secret so nodes can obtain
// access tokens in future runs.
func (c *Config) NodeClientSecret() string {
	if c.options.NodeClientSecret != "" {
		return c.options.NodeClientSecret
	}

	fileName := c.NodeClientSecretFile()

	if fileName == "" {
		return ""
	}

	if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { //nolint:gosec // path derived from config directory
		// Do not cache the value. Always read from the disk to ensure
		// that updates from other processes are observed.
		return string(b)
	}

	if err := os.Chmod(filepath.Dir(fileName), fs.ModeDir); err != nil {
		log.Debugf("config: failed to set node secrets dir permissions (%s)", err)
	}

	if _, err := os.Stat(fileName); os.IsNotExist(err) {
		log.Debugf("config: node client secret file %s not found", clean.Log(fileName))
	} else if err != nil {
		log.Warnf("config: failed to read node client secret from %s (%s)", clean.Log(fileName), err)
	}

	return c.options.NodeClientSecret
}

// SaveNodeClientSecret stores a new node client secret on disk and updates the
// in-memory value. The secret must already pass rnd.IsClientSecret.
func (c *Config) SaveNodeClientSecret(clientSecret string) (fileName string, err error) {
	fileName = c.NodeClientSecretFile()

	if !rnd.IsClientSecret(clientSecret) {
		return fileName, errors.New("invalid node client secret")
	}

	dir := filepath.Dir(fileName)
	if fileName == "" || dir == "" {
		return fileName, fmt.Errorf("invalid node client secret filename %s", clean.Log(fileName))
	}

	// Create secret directory.
	if err = fs.MkdirAll(dir); err != nil {
		// Use memory to store client secret if directory is not writable.
		c.options.NodeClientSecret = clientSecret
		return fileName, fmt.Errorf("could not create node secrets path (%s)", err)
	}

	// Write secret to file.
	if err = fs.WriteFile(fileName, []byte(clientSecret), fs.ModeSecretFile); err != nil {
		// Use memory to store client secret if file is not writable.
		c.options.NodeClientSecret = clientSecret
		return "", fmt.Errorf("could not write node client secret (%s)", err)
	}

	c.options.NodeClientSecret = ""

	return fileName, nil
}

// NodeClientSecretFile returns the path holding the node client secret (defaults
// to config/node/secrets/client_secret unless overridden via *_FILE).
func (c *Config) NodeClientSecretFile() string {
	if filePath := FlagFilePath("NODE_CLIENT_SECRET"); filePath != "" {
		return filePath
	}

	return filepath.Join(c.NodeConfigPath(), fs.SecretsDir, fs.ClientSecretFile)
}

// JWKSUrl returns the configured JWKS endpoint for portal-issued JWTs. Nodes normally
// persist this URL from the portal's register response, which derives it from SiteUrl;
// manual overrides are only required for custom deployments.
func (c *Config) JWKSUrl() string {
	return strings.TrimSpace(c.options.JWKSUrl)
}

// SetJWKSUrl updates the configured JWKS endpoint for portal-issued JWTs.
func (c *Config) SetJWKSUrl(url string) {
	if c == nil || c.options == nil {
		return
	}

	trimmed := strings.TrimSpace(url)
	if trimmed == "" {
		c.options.JWKSUrl = ""
		return
	}

	parsed, err := urlpkg.Parse(trimmed)
	if err != nil || parsed == nil || parsed.Scheme == "" || parsed.Host == "" {
		log.Warnf("config: ignoring JWKS URL %q (%v)", trimmed, err)
		return
	}

	scheme := strings.ToLower(parsed.Scheme)
	host := parsed.Hostname()

	switch scheme {
	case "https":
		// Always allowed.
	case "http":
		if !dns.IsLoopbackHost(host) {
			log.Warnf("config: rejecting JWKS URL %q (http only allowed for localhost/loopback)", trimmed)
			return
		}
	default:
		log.Warnf("config: rejecting JWKS URL %q (unsupported scheme)", trimmed)
		return
	}

	c.options.JWKSUrl = trimmed
}

// JWKSCacheTTL returns the JWKS cache lifetime in seconds (default 300, max 3600).
func (c *Config) JWKSCacheTTL() int {
	if c.options.JWKSCacheTTL <= 0 {
		return 300
	}
	if c.options.JWKSCacheTTL > 3600 {
		return 3600
	}
	return c.options.JWKSCacheTTL
}

// JWTLeeway returns the permitted clock skew in seconds (default 60, max 300).
func (c *Config) JWTLeeway() int {
	if c.options.JWTLeeway <= 0 {
		return 60
	}
	if c.options.JWTLeeway > 300 {
		return 300
	}
	return c.options.JWTLeeway
}

// JWTAllowedScopes returns an optional allow-list of accepted JWT scopes.
func (c *Config) JWTAllowedScopes() list.Attr {
	if s := strings.TrimSpace(c.options.JWTScope); s != "" {
		parsed := list.ParseAttr(strings.ToLower(s))
		if len(parsed) > 0 {
			return parsed
		}
	}

	return list.ParseAttr(DefaultJWTAllowedScopes)
}

// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
// Portal validation permits HTTPS for external hosts and HTTP only for loopback
// or cluster-internal service domains (e.g., *.svc, *.cluster.local, *.internal).
func (c *Config) AdvertiseUrl() string {
	if c.options.AdvertiseUrl != "" {
		return strings.TrimRight(c.options.AdvertiseUrl, "/") + "/"
	}
	// Derive from cluster domain and node name if available; otherwise fall back to SiteUrl().
	if d := c.ClusterDomain(); d != "" {
		if n := c.NodeName(); n != "" && dns.IsLabel(n) {
			return "https://" + n + "." + d + "/"
		}
	}
	return c.SiteUrl()
}

// SaveClusterUUID writes or updates the ClusterUUID key in options.yml without
// touching unrelated keys. Creates the file and directories if needed.
func (c *Config) SaveClusterUUID(uuid string) error {
	if !rnd.IsUUID(uuid) {
		return errors.New("invalid cluster UUID")
	}

	// Always resolve against the current ConfigPath and remember it explicitly
	// so subsequent calls don't accidentally point to a previous default.
	cfgDir := c.ConfigPath()
	if err := fs.MkdirAll(cfgDir); err != nil {
		return err
	}

	fileName := c.OptionsYaml()

	var m Values

	if fs.FileExists(fileName) {
		if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { //nolint:gosec // path derived from config directory
			_ = yaml.Unmarshal(b, &m)
		}
	}

	if m == nil {
		m = Values{}
	}

	m["ClusterUUID"] = uuid

	if b, err := yaml.Marshal(m); err != nil {
		return err
	} else if err = os.WriteFile(fileName, b, fs.ModeFile); err != nil {
		return err
	}

	c.options.ClusterUUID = uuid

	// Remember options.yml path for subsequent loads and ensure in-memory options see the value.
	if c.options != nil {
		_ = c.options.Load(fileName)
	}

	return nil
}

// SaveNodeUUID writes or updates the NodeUUID key in options.yml without touching unrelated keys.
func (c *Config) SaveNodeUUID(uuid string) error {
	if !rnd.IsUUID(uuid) {
		return errors.New("invalid node UUID")
	}

	cfgDir := c.ConfigPath()

	if err := fs.MkdirAll(cfgDir); err != nil {
		return err
	}

	fileName := c.OptionsYaml()

	var m Values
	if fs.FileExists(fileName) {
		if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { //nolint:gosec // path derived from config directory
			_ = yaml.Unmarshal(b, &m)
		}
	}
	if m == nil {
		m = Values{}
	}
	m["NodeUUID"] = uuid
	if b, err := yaml.Marshal(m); err != nil {
		return err
	} else if err = os.WriteFile(fileName, b, fs.ModeFile); err != nil {
		return err
	}

	c.options.NodeUUID = uuid

	return nil
}
