package config

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

	"gopkg.in/yaml.v3"

	"nadir/internal/module"
	"nadir/internal/rbac"
)

// File is the on-disk YAML shape. Module keys and permission values may be
// "*" (must be quoted in YAML to avoid alias syntax).
//
// Example:
//
//	server:
//	  secure_tls: false
//	roles:
//	  admin:
//	    "*": ["*"]
//	  auditor:
//	    "*": [read]
//	assignments:
//	  urania: [admin]
//
// LogFiles is the allowlist of log files readable per unit via the file log
// source. The path query parameter is matched against this set, so an admin
// (not the caller) decides which files are exposable. Example:
//
//	log_files:
//	  nginx.service:
//	    - /var/log/nginx/access.log
//	    - /var/log/nginx/error.log
type File struct {
	Server      Server                         `yaml:"server"`
	Roles       map[string]map[string][]string `yaml:"roles"`
	Assignments map[string][]string            `yaml:"assignments"`
	LogFiles    map[string][]string            `yaml:"log_files"`
}

// Server holds process-level settings.
type Server struct {
	// SecureTLS controls the Secure attribute on the session cookie. A pointer
	// so an omitted key means "unset" (defaults to true / production-safe)
	// rather than the zero value false. Set false for local HTTP development.
	SecureTLS *bool  `yaml:"secure_tls"`
	Hostname  string `yaml:"hostname"`
	Port      string `yaml:"port"`
	// TLS is provided one of three ways, in priority order:
	//   1. TrustProxy true  - a reverse proxy (e.g. https://example.com) does
	//      TLS; nadir serves plaintext HTTP and trusts X-Forwarded-For. nadir
	//      must then be reachable ONLY by the proxy (bind localhost).
	//   2. TLSCert+TLSKey    - nadir terminates TLS with this PEM pair.
	//   3. neither           - nadir self-signs in memory (dev only).
	// Keep secure_tls true in modes 1 and 2 so the session cookie stays Secure.
	TrustProxy bool   `yaml:"trust_proxy"`
	TLSCert    string `yaml:"tls_cert"`
	TLSKey     string `yaml:"tls_key"`

	// ReleaseRepo is the Gitea repo URL used by /install.sh to fetch the
	// latest binary. Example: https://gitea.example.com/urania/nadir.
	// Empty disables the install.sh endpoint.
	ReleaseRepo string `yaml:"release_repo"`
}

// SecureCookie reports whether the session cookie should carry the Secure
// attribute. An omitted server.secure_tls defaults to true: the safe default is
// a cookie that only travels over HTTPS, so a config that forgets the key can't
// silently ship a session cookie a plaintext request would leak. Local
// plain-HTTP development opts out explicitly with secure_tls: false.
func (f *File) SecureCookie() bool {
	if f.Server.SecureTLS == nil {
		return true
	}
	return *f.Server.SecureTLS
}

// DefaultPath returns the default configuration path at
// ~/.config/nadir/config.yaml (honoring XDG_CONFIG_HOME via os.UserConfigDir).
func DefaultPath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("detect config dir: %w", err)
	}
	return filepath.Join(dir, "nadir", "config.yaml"), nil
}

// ExpandPath expands leading ~ to the current user's home directory.
func ExpandPath(path string) (string, error) {
	if strings.HasPrefix(path, "~/") {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("detect home dir: %w", err)
		}
		return filepath.Join(home, path[2:]), nil
	}
	return path, nil
}

// Load reads and parses the YAML file at path.
func Load(path string) (*File, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config %s: %w", path, err)
	}
	var f File
	if err := yaml.Unmarshal(data, &f); err != nil {
		return nil, fmt.Errorf("parse config %s: %w", path, err)
	}
	// release_repo, when set, is substituted into shell scripts and downloaded
	// over the wire. Validate shape + scheme + shell-safety once here so
	// /install.sh and the updater can use the string directly. Trim any trailing
	// slash so downstream string concatenation produces a clean URL.
	if f.Server.ReleaseRepo != "" {
		f.Server.ReleaseRepo = strings.TrimRight(f.Server.ReleaseRepo, "/")
		u, err := url.Parse(f.Server.ReleaseRepo)
		if err != nil {
			return nil, fmt.Errorf("server.release_repo: %w", err)
		}
		if u.Scheme != "https" {
			return nil, fmt.Errorf("server.release_repo must use https:// (got %q)", f.Server.ReleaseRepo)
		}
		if u.Host == "" {
			return nil, fmt.Errorf("server.release_repo missing host: %q", f.Server.ReleaseRepo)
		}
		parts := strings.Split(strings.Trim(u.Path, "/"), "/")
		if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
			return nil, fmt.Errorf("server.release_repo must be https://host/owner/repo, got %q", f.Server.ReleaseRepo)
		}
		// Shell-safety: reject characters that could break out of a double-quoted
		// string when substituted into the install.sh template.
		if strings.ContainsAny(f.Server.ReleaseRepo, "`$\"\\;|&") {
			return nil, fmt.Errorf("server.release_repo contains unsafe characters: %q", f.Server.ReleaseRepo)
		}
	}
	return &f, nil
}

// Apply validates the config against the loaded modules and installs roles
// + assignments into the RBAC store. Any reference to an unknown module or
// unknown permission causes a startup error.
func Apply(f *File, roles *rbac.RBAC, mods []module.Module) error {
	// Build a lookup: module ID -> the permissions it exposes.
	knownPerms := map[string]map[rbac.Permission]bool{}
	for _, m := range mods {
		set := map[rbac.Permission]bool{}
		for _, p := range m.Permissions() {
			set[p] = true
		}
		knownPerms[m.ID()] = set
	}

	// 1. Define roles, validating every named module + permission.
	for roleName, grants := range f.Roles {
		converted := map[string][]rbac.Permission{}
		for modKey, perms := range grants {
			if modKey != rbac.Wildcard {
				if _, ok := knownPerms[modKey]; !ok {
					return fmt.Errorf("role %q references unknown module %q", roleName, modKey)
				}
			}
			permList := make([]rbac.Permission, 0, len(perms))
			for _, raw := range perms {
				p := rbac.Permission(raw)
				if p == rbac.All {
					permList = append(permList, p)
					continue
				}
				if !permissionExists(p, modKey, knownPerms) {
					return fmt.Errorf("role %q grants %q on module %q, but that permission is not exported by any matching module", roleName, raw, modKey)
				}
				permList = append(permList, p)
			}
			converted[modKey] = permList
		}
		roles.DefineRole(rbac.Role{Name: roleName, ModuleGrants: converted})
	}

	// 2. Apply assignments, validating role names.
	for user, assigned := range f.Assignments {
		for _, roleName := range assigned {
			if !roles.RoleExists(roleName) {
				return fmt.Errorf("user %q assigned to unknown role %q", user, roleName)
			}
			roles.AssignRole(user, roleName)
		}
	}
	return nil
}

// permissionExists returns true if perm is exported by the named module,
// or by any module when modKey is the wildcard.
func permissionExists(perm rbac.Permission, modKey string, known map[string]map[rbac.Permission]bool) bool {
	if modKey == rbac.Wildcard {
		for _, perms := range known {
			if perms[perm] {
				return true
			}
		}
		return false
	}
	return known[modKey][perm]
}
