package main

import (
	"bytes"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"os/user"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/Mic92/sops-nix/pkgs/sops-install-secrets/sshkeys"
	agessh "github.com/Mic92/ssh-to-age"

	"github.com/joho/godotenv"
	"github.com/mozilla-services/yaml"
	"go.mozilla.org/sops/v3/decrypt"
)

type secret struct {
	Name         string     `json:"name"`
	Key          string     `json:"key"`
	Path         string     `json:"path"`
	Owner        string     `json:"owner"`
	Group        string     `json:"group"`
	SopsFile     string     `json:"sopsFile"`
	Format       FormatType `json:"format"`
	Mode         string     `json:"mode"`
	RestartUnits []string   `json:"restartUnits"`
	ReloadUnits  []string   `json:"reloadUnits"`
	value        []byte
	mode         os.FileMode
	owner        int
	group        int
}

type loggingConfig struct {
	KeyImport     bool `json:"keyImport"`
	SecretChanges bool `json:"secretChanges"`
}

type manifest struct {
	Secrets           []secret      `json:"secrets"`
	SecretsMountPoint string        `json:"secretsMountPoint"`
	SymlinkPath       string        `json:"symlinkPath"`
	KeepGenerations   int           `json:"keepGenerations"`
	SSHKeyPaths       []string      `json:"sshKeyPaths"`
	GnupgHome         string        `json:"gnupgHome"`
	AgeKeyFile        string        `json:"ageKeyFile"`
	AgeSshKeyPaths    []string      `json:"ageSshKeyPaths"`
	UseTmpfs          bool          `json:"useTmpfs"`
	UserMode          bool          `json:"userMode"`
	Logging           loggingConfig `json:"logging"`
}

type secretFile struct {
	cipherText []byte
	keys       map[string]interface{}
	/// First secret that defined this secretFile, used for error messages
	firstSecret *secret
}

type FormatType string

const (
	Yaml   FormatType = "yaml"
	Json   FormatType = "json"
	Binary FormatType = "binary"
	Dotenv FormatType = "dotenv"
	Ini    FormatType = "ini"
)

func IsValidFormat(format string) bool {
	switch format {
	case string(Yaml),
		string(Json),
		string(Binary),
		string(Dotenv),
		string(Ini):
		return true
	default:
		return false
	}
}

func (f *FormatType) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	t := FormatType(s)
	switch t {
	case "":
		*f = Yaml
	case Yaml, Json, Binary, Dotenv, Ini:
		*f = t
	}

	return nil
}

func (f FormatType) MarshalJSON() ([]byte, error) {
	return json.Marshal(string(f))
}

type CheckMode string

const (
	Manifest CheckMode = "manifest"
	SopsFile CheckMode = "sopsfile"
	Off      CheckMode = "off"
)

type options struct {
	checkMode    CheckMode
	manifest     string
	ignorePasswd bool
}

type appContext struct {
	manifest     manifest
	secretFiles  map[string]secretFile
	checkMode    CheckMode
	ignorePasswd bool
}

func readManifest(path string) (*manifest, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("Failed to open manifest: %w", err)
	}
	defer file.Close()
	dec := json.NewDecoder(file)
	var m manifest
	if err := dec.Decode(&m); err != nil {
		return nil, fmt.Errorf("Failed to parse manifest: %w", err)
	}
	return &m, nil
}

func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, secret *secret) bool {
	validUG := true
	if stat, ok := info.Sys().(*syscall.Stat_t); ok {
		validUG = validUG && int(stat.Uid) == secret.owner
		validUG = validUG && int(stat.Gid) == secret.group
	} else {
		panic("Failed to cast fileInfo Sys() to *syscall.Stat_t. This is possibly an unsupported OS.")
	}
	return linkTarget == targetFile && validUG
}

func symlinkSecret(targetFile string, secret *secret, userMode bool) error {
	for {
		stat, err := os.Lstat(secret.Path)
		if os.IsNotExist(err) {
			if err := os.Symlink(targetFile, secret.Path); err != nil {
				return fmt.Errorf("Cannot create symlink '%s': %w", secret.Path, err)
			}
			if !userMode {
				if err := SecureSymlinkChown(secret.Path, targetFile, secret.owner, secret.group); err != nil {
					return fmt.Errorf("Cannot chown symlink '%s': %w", secret.Path, err)
				}
			}
			return nil
		} else if err != nil {
			return fmt.Errorf("Cannot stat '%s': %w", secret.Path, err)
		}
		if stat.Mode()&os.ModeSymlink == os.ModeSymlink {
			linkTarget, err := os.Readlink(secret.Path)
			if os.IsNotExist(err) {
				continue
			} else if err != nil {
				return fmt.Errorf("Cannot read symlink '%s': %w", secret.Path, err)
			} else if linksAreEqual(linkTarget, targetFile, stat, secret) {
				return nil
			}
		}
		if err := os.Remove(secret.Path); err != nil {
			return fmt.Errorf("Cannot override %s: %w", secret.Path, err)
		}
	}
}

func symlinkSecrets(targetDir string, secrets []secret, userMode bool) error {
	for _, secret := range secrets {
		targetFile := filepath.Join(targetDir, secret.Name)
		if targetFile == secret.Path {
			continue
		}
		parent := filepath.Dir(secret.Path)
		if err := os.MkdirAll(parent, os.ModePerm); err != nil {
			return fmt.Errorf("Cannot create parent directory of '%s': %w", secret.Path, err)
		}
		if err := symlinkSecret(targetFile, &secret, userMode); err != nil {
			return fmt.Errorf("Failed to symlink secret '%s': %w", secret.Path, err)
		}
	}
	return nil
}

type plainData struct {
	data   map[string]interface{}
	binary []byte
}

func recurseSecretKey(keys map[string]interface{}, wantedKey string) (string, error) {
	var val interface{}
	var ok bool
	currentKey := wantedKey
	currentData := keys
	keyUntilNow := ""

	for {
		slashIndex := strings.IndexByte(currentKey, '/')
		if slashIndex == -1 {
			// We got to the end
			val, ok = currentData[currentKey]
			if !ok {
				if keyUntilNow != "" {
					keyUntilNow += "/"
				}
				return "", fmt.Errorf("The key '%s%s' cannot be found", keyUntilNow, currentKey)
			}
			break
		}
		thisKey := currentKey[:slashIndex]
		if keyUntilNow == "" {
			keyUntilNow = thisKey
		} else {
			keyUntilNow += "/" + thisKey
		}
		currentKey = currentKey[(slashIndex + 1):]
		val, ok = currentData[thisKey]
		if !ok {
			return "", fmt.Errorf("The key '%s' cannot be found", keyUntilNow)
		}
		valWithWrongType, ok := val.(map[interface{}]interface{})
		if !ok {
			return "", fmt.Errorf("Key '%s' does not refer to a dictionary", keyUntilNow)
		}
		currentData = make(map[string]interface{})
		for key, value := range valWithWrongType {
			currentData[key.(string)] = value
		}
	}

	strVal, ok := val.(string)
	if !ok {
		return "", fmt.Errorf("The value of key '%s' is not a string", keyUntilNow)
	}
	return strVal, nil
}

func decryptSecret(s *secret, sourceFiles map[string]plainData) error {
	sourceFile := sourceFiles[s.SopsFile]
	if sourceFile.data == nil || sourceFile.binary == nil {
		plain, err := decrypt.File(s.SopsFile, string(s.Format))
		if err != nil {
			return fmt.Errorf("Failed to decrypt '%s': %w", s.SopsFile, err)
		}

		switch s.Format {
		case Binary, Dotenv, Ini:
			sourceFile.binary = plain
		case Yaml:
			if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil {
				return fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFile, err)
			}
		case Json:
			if err := json.Unmarshal(plain, &sourceFile.data); err != nil {
				return fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err)
			}
		default:
			return fmt.Errorf("Secret of type %s in %s is not supported", s.Format, s.SopsFile)
		}
	}
	switch s.Format {
	case Binary, Dotenv, Ini:
		s.value = sourceFile.binary
	case Yaml, Json:
		strVal, err := recurseSecretKey(sourceFile.data, s.Key)
		if err != nil {
			return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err)
		}
		s.value = []byte(strVal)
	}
	sourceFiles[s.SopsFile] = sourceFile
	return nil
}

func decryptSecrets(secrets []secret) error {
	sourceFiles := make(map[string]plainData)
	for i := range secrets {
		if err := decryptSecret(&secrets[i], sourceFiles); err != nil {
			return err
		}
	}
	return nil
}

const (
	RAMFS_MAGIC int32 = -2054924042
	TMPFS_MAGIC int32 = 16914836
)

func prepareSecretsDir(secretMountpoint string, linkName string, keysGid int, userMode bool) (*string, error) {
	var generation uint64
	linkTarget, err := os.Readlink(linkName)
	if err == nil {
		if strings.HasPrefix(linkTarget, secretMountpoint) {
			targetBasename := filepath.Base(linkTarget)
			generation, err = strconv.ParseUint(targetBasename, 10, 64)
			if err != nil {
				return nil, fmt.Errorf("Cannot parse %s of %s as a number: %w", targetBasename, linkTarget, err)
			}
		}
	} else if !os.IsNotExist(err) {
		return nil, fmt.Errorf("Cannot access %s: %w", linkName, err)
	}
	generation++
	dir := filepath.Join(secretMountpoint, strconv.Itoa(int(generation)))
	if _, err := os.Stat(dir); !os.IsNotExist(err) {
		if err := os.RemoveAll(dir); err != nil {
			return nil, fmt.Errorf("Cannot remove existing %s: %w", dir, err)
		}
	}
	if err := os.Mkdir(dir, os.FileMode(0o751)); err != nil {
		return nil, fmt.Errorf("mkdir(): %w", err)
	}
	if !userMode {
		if err := os.Chown(dir, 0, int(keysGid)); err != nil {
			return nil, fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", dir, keysGid, err)
		}
	}
	return &dir, nil
}

func writeSecrets(secretDir string, secrets []secret, keysGid int, userMode bool) error {
	for _, secret := range secrets {
		fp := filepath.Join(secretDir, secret.Name)

		dirs := strings.Split(filepath.Dir(secret.Name), "/")
		pathSoFar := secretDir
		for _, dir := range dirs {
			pathSoFar = filepath.Join(pathSoFar, dir)
			if err := os.MkdirAll(pathSoFar, 0o751); err != nil {
				return fmt.Errorf("Cannot create directory '%s' for %s: %w", pathSoFar, fp, err)
			}
			if !userMode {
				if err := os.Chown(pathSoFar, 0, int(keysGid)); err != nil {
					return fmt.Errorf("Cannot own directory '%s' for %s: %w", pathSoFar, fp, err)
				}
			}
		}

		if err := os.WriteFile(fp, []byte(secret.value), secret.mode); err != nil {
			return fmt.Errorf("Cannot write %s: %w", fp, err)
		}
		if !userMode {
			if err := os.Chown(fp, secret.owner, secret.group); err != nil {
				return fmt.Errorf("Cannot change owner/group of '%s' to %d/%d: %w", fp, secret.owner, secret.group, err)
			}
		}
	}
	return nil
}

func lookupGroup(groupname string) (int, error) {
	group, err := user.LookupGroup(groupname)
	if err != nil {
		return 0, fmt.Errorf("Failed to lookup 'keys' group: %w", err)
	}
	gid, err := strconv.ParseInt(group.Gid, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("Cannot parse keys gid %s: %w", group.Gid, err)
	}
	return int(gid), nil
}

func lookupKeysGroup() (int, error) {
	gid, err1 := lookupGroup("keys")
	if err1 == nil {
		return gid, nil
	}
	gid, err2 := lookupGroup("nogroup")
	if err2 == nil {
		return gid, nil
	}
	return 0, fmt.Errorf("Can't find group 'keys' nor 'nogroup' (%w).", err2)
}

func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
	if app.checkMode == Manifest {
		return &secretFile{firstSecret: s}, nil
	}

	cipherText, err := os.ReadFile(s.SopsFile)
	if err != nil {
		return nil, fmt.Errorf("Failed reading %s: %w", s.SopsFile, err)
	}

	var keys map[string]interface{}

	switch s.Format {
	case Binary:
		if err := json.Unmarshal(cipherText, &keys); err != nil {
			return nil, fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err)
		}
		return &secretFile{cipherText: cipherText, firstSecret: s}, nil
	case Yaml:
		if err := yaml.Unmarshal(cipherText, &keys); err != nil {
			return nil, fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFile, err)
		}
	case Dotenv:
		env, err := godotenv.Unmarshal(string(cipherText))
		if err != nil {
			return nil, fmt.Errorf("Cannot parse dotenv of '%s': %w", s.SopsFile, err)
		}
		keys = map[string]interface{}{}
		for k, v := range env {
			keys[k] = v
		}
	case Json:
		if err := json.Unmarshal(cipherText, &keys); err != nil {
			return nil, fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err)
		}
	}

	return &secretFile{
		cipherText:  cipherText,
		keys:        keys,
		firstSecret: s,
	}, nil
}

func (app *appContext) validateSopsFile(s *secret, file *secretFile) error {
	if file.firstSecret.Format != s.Format {
		return fmt.Errorf("secret %s defined the format of %s as %s, but it was specified as %s in %s before",
			s.Name, s.SopsFile, s.Format,
			file.firstSecret.Format, file.firstSecret.Name)
	}
	if app.checkMode != Manifest && (!(s.Format == Binary || s.Format == Dotenv || s.Format == Ini)) {
		_, err := recurseSecretKey(file.keys, s.Key)
		if err != nil {
			return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err)
		}
	}
	return nil
}

func (app *appContext) validateSecret(secret *secret) error {
	mode, err := strconv.ParseUint(secret.Mode, 8, 16)
	if err != nil {
		return fmt.Errorf("Invalid number in mode: %d: %w", mode, err)
	}
	secret.mode = os.FileMode(mode)

	if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" {
		secret.owner = 0
		secret.group = 0
	} else if app.checkMode == Off || app.ignorePasswd {
		// we only access to the user/group during deployment
		owner, err := user.Lookup(secret.Owner)
		if err != nil {
			return fmt.Errorf("Failed to lookup user '%s': %w", secret.Owner, err)
		}
		ownerNr, err := strconv.ParseUint(owner.Uid, 10, 64)
		if err != nil {
			return fmt.Errorf("Cannot parse uid %s: %w", owner.Uid, err)
		}
		secret.owner = int(ownerNr)

		group, err := user.LookupGroup(secret.Group)
		if err != nil {
			return fmt.Errorf("Failed to lookup group '%s': %w", secret.Group, err)
		}
		groupNr, err := strconv.ParseUint(group.Gid, 10, 64)
		if err != nil {
			return fmt.Errorf("Cannot parse gid %s: %w", group.Gid, err)
		}
		secret.group = int(groupNr)
	}

	if secret.Format == "" {
		secret.Format = "yaml"
	}

	if !IsValidFormat(string(secret.Format)) {
		return fmt.Errorf("Unsupported format %s for secret %s", secret.Format, secret.Name)
	}

	file, ok := app.secretFiles[secret.SopsFile]
	if !ok {
		maybeFile, err := app.loadSopsFile(secret)
		if err != nil {
			return err
		}
		app.secretFiles[secret.SopsFile] = *maybeFile
		file = *maybeFile
	}

	return app.validateSopsFile(secret, &file)
}

func (app *appContext) validateManifest() error {
	m := &app.manifest
	if m.GnupgHome != "" {
		errorFmt := "gnupgHome and %s were specified in the manifest. " +
			"Both options are mutually exclusive."
		if len(m.SSHKeyPaths) > 0 {
			return fmt.Errorf(errorFmt, "sshKeyPaths")
		}
		if m.AgeKeyFile != "" {
			return fmt.Errorf(errorFmt, "ageKeyFile")
		}
	}

	for i := range m.Secrets {
		if err := app.validateSecret(&m.Secrets[i]); err != nil {
			return err
		}
	}
	return nil
}

func atomicSymlink(oldname, newname string) error {
	// Fast path: if newname does not exist yet, we can skip the whole dance
	// below.
	if err := os.Symlink(oldname, newname); err == nil || !os.IsExist(err) {
		return err
	}

	// We need to use ioutil.TempDir, as we cannot overwrite a ioutil.TempFile,
	// and removing+symlinking creates a TOCTOU race.
	d, err := os.MkdirTemp(filepath.Dir(newname), "."+filepath.Base(newname))
	if err != nil {
		return err
	}
	cleanup := true
	defer func() {
		if cleanup {
			os.RemoveAll(d)
		}
	}()

	symlink := filepath.Join(d, "tmp.symlink")
	if err := os.Symlink(oldname, symlink); err != nil {
		return err
	}

	if err := os.Rename(symlink, newname); err != nil {
		return err
	}

	cleanup = false
	return os.RemoveAll(d)
}

func pruneGenerations(secretsMountPoint, secretsDir string, keepGenerations int) error {
	if keepGenerations == 0 {
		return nil // Nothing to prune
	}

	// Prepare our failsafe
	currentGeneration, err := strconv.Atoi(path.Base(secretsDir))
	if err != nil {
		return fmt.Errorf("Logic error, current generation is not numeric: %w", err)
	}

	// Read files in the mount directory
	file, err := os.Open(secretsMountPoint)
	if err != nil {
		return fmt.Errorf("Cannot open %s: %w", secretsMountPoint, err)
	}
	defer file.Close()

	generations, err := file.Readdirnames(0)
	if err != nil {
		return fmt.Errorf("Cannot read %s: %w", secretsMountPoint, err)
	}
	for _, generationName := range generations {
		generationNum, err := strconv.Atoi(generationName)
		// Not a number? Not relevant
		if err != nil {
			continue
		}
		// Not strictly necessary but a good failsafe to
		// make sure we don't prune the current generation
		if generationNum == currentGeneration {
			continue
		}
		if currentGeneration-keepGenerations >= generationNum {
			os.RemoveAll(path.Join(secretsMountPoint, generationName))
		}
	}

	return nil
}

func importSSHKeys(logcfg loggingConfig, keyPaths []string, gpgHome string) error {
	secringPath := filepath.Join(gpgHome, "secring.gpg")

	secring, err := os.OpenFile(secringPath, os.O_WRONLY|os.O_CREATE, 0o600)
	if err != nil {
		return fmt.Errorf("Cannot create %s: %w", secringPath, err)
	}
	for _, p := range keyPaths {
		sshKey, err := os.ReadFile(p)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Cannot read ssh key '%s': %s\n", p, err)
			continue
		}
		gpgKey, err := sshkeys.SSHPrivateKeyToPGP(sshKey)
		if err != nil {
			fmt.Fprintf(os.Stderr, "%s\n", err)
			continue
		}

		if err := gpgKey.SerializePrivate(secring, nil); err != nil {
			fmt.Fprintf(os.Stderr, "Cannot write secring: %s\n", err)
			continue
		}

		if logcfg.KeyImport {
			fmt.Printf("%s: Imported %s as GPG key with fingerprint %s\n", path.Base(os.Args[0]), p, hex.EncodeToString(gpgKey.PrimaryKey.Fingerprint[:]))
		}
	}

	return nil
}

func importAgeSSHKeys(logcfg loggingConfig, keyPaths []string, ageFile os.File) error {
	for _, p := range keyPaths {
		// Read the key
		sshKey, err := os.ReadFile(p)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Cannot read ssh key '%s': %s\n", p, err)
			continue
		}
		// Convert the key to age
		privKey, pubKey, err := agessh.SSHPrivateKeyToAge(sshKey)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Cannot convert ssh key '%s': %s\n", p, err)
			continue
		}
		// Append it to the file
		_, err = ageFile.WriteString(*privKey + "\n")
		if err != nil {
			fmt.Fprintf(os.Stderr, "Cannot write key to age file: %s\n", err)
			continue
		}

		if logcfg.KeyImport {
			fmt.Fprintf(os.Stderr, "%s: Imported %s as age key with fingerprint %s\n", path.Base(os.Args[0]), p, *pubKey)
			continue
		}
	}

	return nil
}

// Like filepath.Walk but symlink-aware.
// Inspired by https://github.com/facebookarchive/symwalk
func symlinkWalk(filename string, linkDirname string, walkFn filepath.WalkFunc) error {
	symWalkFunc := func(path string, info os.FileInfo, err error) error {
		if fname, err := filepath.Rel(filename, path); err == nil {
			path = filepath.Join(linkDirname, fname)
		} else {
			return err
		}

		if err == nil && info.Mode()&os.ModeSymlink == os.ModeSymlink {
			finalPath, err := filepath.EvalSymlinks(path)
			if err != nil {
				return err
			}
			info, err := os.Lstat(finalPath)
			if err != nil {
				return walkFn(path, info, err)
			}
			if info.IsDir() {
				return symlinkWalk(finalPath, path, walkFn)
			}
		}

		return walkFn(path, info, err)
	}
	return filepath.Walk(filename, symWalkFunc)
}

func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, secretDir string, secrets []secret) error {
	var restart []string
	var reload []string

	newSecrets := make(map[string]bool)
	modifiedSecrets := make(map[string]bool)
	removedSecrets := make(map[string]bool)

	// When the symlink path does not exist yet, we are being run in stage-2-init.sh
	// where switch-to-configuration is not run so the services would only be restarted
	// the next time switch-to-configuration is run.
	if _, err := os.Stat(symlinkPath); os.IsNotExist(err) {
		return nil
	}

	// Find modified/new secrets
	for _, secret := range secrets {
		oldPath := filepath.Join(symlinkPath, secret.Name)
		newPath := filepath.Join(secretDir, secret.Name)

		// Read the old file
		oldData, err := os.ReadFile(oldPath)
		if err != nil {
			if os.IsNotExist(err) {
				// File did not exist before
				restart = append(restart, secret.RestartUnits...)
				reload = append(reload, secret.ReloadUnits...)
				newSecrets[secret.Name] = true
				continue
			}
			return err
		}

		// Read the new file
		newData, err := os.ReadFile(newPath)
		if err != nil {
			return err
		}

		if !bytes.Equal(oldData, newData) {
			restart = append(restart, secret.RestartUnits...)
			reload = append(reload, secret.ReloadUnits...)
			modifiedSecrets[secret.Name] = true
		}
	}

	writeLines := func(list []string, file string) error {
		if len(list) != 0 {
			f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
			if err != nil {
				return err
			}
			defer f.Close()
			for _, unit := range list {
				if _, err = f.WriteString(unit + "\n"); err != nil {
					return err
				}
			}
		}
		return nil
	}
	var dryPrefix string
	if isDry {
		dryPrefix = "/run/nixos/dry-activation"
	} else {
		dryPrefix = "/run/nixos/activation"
	}
	if err := writeLines(restart, dryPrefix+"-restart-list"); err != nil {
		return err
	}
	if err := writeLines(reload, dryPrefix+"-reload-list"); err != nil {
		return err
	}

	// Do not output changes if not requested
	if !logcfg.SecretChanges {
		return nil
	}

	// Find removed secrets
	err := symlinkWalk(symlinkPath, symlinkPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		path = strings.TrimPrefix(path, symlinkPath+string(os.PathSeparator))
		for _, secret := range secrets {
			if secret.Name == path {
				return nil
			}
		}
		removedSecrets[path] = true
		return nil
	})
	if err != nil {
		return err
	}

	// Output new/modified/removed secrets
	outputChanged := func(changed map[string]bool, regularPrefix, dryPrefix string) {
		if len(changed) > 0 {
			s := ""
			if len(changed) != 1 {
				s = "s"
			}
			if isDry {
				fmt.Printf("%s secret%s: ", dryPrefix, s)
			} else {
				fmt.Printf("%s secret%s: ", regularPrefix, s)
			}
			comma := ""
			for name := range changed {
				fmt.Printf("%s%s", comma, name)
				comma = ", "
			}
			fmt.Println()
		}
	}
	outputChanged(newSecrets, "adding", "would add")
	outputChanged(modifiedSecrets, "modifying", "would modify")
	outputChanged(removedSecrets, "removing", "would remove")

	return nil
}

type keyring struct {
	path string
}

func (k *keyring) Remove() {
	os.RemoveAll(k.path)
	os.Unsetenv("GNUPGHOME")
}

func setupGPGKeyring(logcfg loggingConfig, sshKeys []string, parentDir string) (*keyring, error) {
	dir, err := os.MkdirTemp(parentDir, "gpg")
	if err != nil {
		return nil, fmt.Errorf("Cannot create gpg home in '%s': %s", parentDir, err)
	}
	k := keyring{dir}

	if err := importSSHKeys(logcfg, sshKeys, dir); err != nil {
		os.RemoveAll(dir)
		return nil, err
	}
	os.Setenv("GNUPGHOME", dir)

	return &k, nil
}

func parseFlags(args []string) (*options, error) {
	var opts options
	fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
	fs.Usage = func() {
		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [OPTION] manifest.json\n", args[0])
		fs.PrintDefaults()
	}
	var checkMode string
	fs.StringVar(&checkMode, "check-mode", "off", `Validate configuration without installing it (possible values: "manifest","sopsfile","off")`)
	fs.BoolVar(&opts.ignorePasswd, "ignore-passwd", false, `Don't look up anything in /etc/passwd. Causes everything to be owned by root:root or the user executing the tool in user mode`)
	if err := fs.Parse(args[1:]); err != nil {
		return nil, err
	}

	switch CheckMode(checkMode) {
	case Manifest, SopsFile, Off:
		opts.checkMode = CheckMode(checkMode)
	default:
		return nil, fmt.Errorf("Invalid value provided for -check-mode flag: %s", opts.checkMode)
	}

	if fs.NArg() != 1 {
		flag.Usage()
		return nil, flag.ErrHelp
	}
	opts.manifest = fs.Arg(0)
	return &opts, nil
}

func replaceRuntimeDir(path, rundir string) (ret string) {
	parts := strings.Split(path, "%%")
	first := true
	for _, part := range parts {
		if !first {
			ret += "%"
		}
		first = false
		ret += strings.ReplaceAll(part, "%r", rundir)
	}
	return
}

func installSecrets(args []string) error {
	opts, err := parseFlags(args)
	if err != nil {
		return err
	}

	manifest, err := readManifest(opts.manifest)
	if err != nil {
		return err
	}

	if manifest.UserMode {
		rundir, err := RuntimeDir()
		if opts.checkMode == Off && err != nil {
			return fmt.Errorf("Error: %v", err)
		}
		manifest.SecretsMountPoint = replaceRuntimeDir(manifest.SecretsMountPoint, rundir)
		manifest.SymlinkPath = replaceRuntimeDir(manifest.SymlinkPath, rundir)
		var newSecrets []secret
		for _, secret := range manifest.Secrets {
			secret.Path = replaceRuntimeDir(secret.Path, rundir)
			newSecrets = append(newSecrets, secret)
		}
		manifest.Secrets = newSecrets
	}

	app := appContext{
		manifest:     *manifest,
		checkMode:    opts.checkMode,
		ignorePasswd: opts.ignorePasswd,
		secretFiles:  make(map[string]secretFile),
	}

	if err := app.validateManifest(); err != nil {
		return fmt.Errorf("Manifest is not valid: %w", err)
	}

	if app.checkMode != Off {
		return nil
	}

	var keysGid int
	if opts.ignorePasswd {
		keysGid = 0
	} else {
		keysGid, err = lookupKeysGroup()
		if err != nil {
			return err
		}
	}

	isDry := os.Getenv("NIXOS_ACTION") == "dry-activate"

	if err := MountSecretFs(manifest.SecretsMountPoint, keysGid, manifest.UseTmpfs, manifest.UserMode); err != nil {
		return fmt.Errorf("Failed to mount filesystem for secrets: %w", err)
	}

	if len(manifest.SSHKeyPaths) != 0 {
		keyring, err := setupGPGKeyring(manifest.Logging, manifest.SSHKeyPaths, manifest.SecretsMountPoint)
		if err != nil {
			return fmt.Errorf("Error setting up gpg keyring: %w", err)
		}
		defer keyring.Remove()
	} else if manifest.GnupgHome != "" {
		os.Setenv("GNUPGHOME", manifest.GnupgHome)
	}

	// Import age keys
	if len(manifest.AgeSshKeyPaths) != 0 || manifest.AgeKeyFile != "" {
		keyfile := filepath.Join(manifest.SecretsMountPoint, "age-keys.txt")
		os.Setenv("SOPS_AGE_KEY_FILE", keyfile)
		// Create the keyfile
		ageFile, err := os.OpenFile(keyfile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
		if err != nil {
			return fmt.Errorf("Cannot create '%s': %w", keyfile, err)
		}
		defer ageFile.Close()
		fmt.Fprintf(ageFile, "# generated by sops-nix at %s\n", time.Now().Format(time.RFC3339))

		// Import SSH keys
		if len(manifest.AgeSshKeyPaths) != 0 {
			err = importAgeSSHKeys(manifest.Logging, manifest.AgeSshKeyPaths, *ageFile)
			if err != nil {
				return err
			}
		}
		// Import the keyfile
		if manifest.AgeKeyFile != "" {
			// Read the keyfile
			contents, err := os.ReadFile(manifest.AgeKeyFile)
			if err != nil {
				return fmt.Errorf("Cannot read keyfile '%s': %w", manifest.AgeKeyFile, err)
			}
			// Append it to the file
			_, err = ageFile.WriteString(string(contents) + "\n")
			if err != nil {
				return fmt.Errorf("Cannot write key to age file: %w", err)
			}
		}
	}

	if err := decryptSecrets(manifest.Secrets); err != nil {
		return err
	}

	secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGid, manifest.UserMode)
	if err != nil {
		return fmt.Errorf("Failed to prepare new secrets directory: %w", err)
	}
	if err := writeSecrets(*secretDir, manifest.Secrets, keysGid, manifest.UserMode); err != nil {
		return fmt.Errorf("Cannot write secrets: %w", err)
	}
	if !manifest.UserMode {
		if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil {
			return fmt.Errorf("Cannot request units to restart: %w", err)
		}
	}
	// No need to perform the actual symlinking
	if isDry {
		return nil
	}
	if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets, manifest.UserMode); err != nil {
		return fmt.Errorf("Failed to prepare symlinks to secret store: %w", err)
	}
	if err := atomicSymlink(*secretDir, manifest.SymlinkPath); err != nil {
		return fmt.Errorf("Cannot update secrets symlink: %w", err)
	}
	if err := pruneGenerations(manifest.SecretsMountPoint, *secretDir, manifest.KeepGenerations); err != nil {
		return fmt.Errorf("Cannot prune old secrets generations: %w", err)
	}

	return nil
}

func main() {
	if err := installSecrets(os.Args); err != nil {
		if err == flag.ErrHelp {
			return
		}
		fmt.Fprintf(os.Stderr, "%s: %s\n", os.Args[0], err)
		os.Exit(1)
	}
}