// Package secret provides functionality for generating and storing secrets
// both in a remote swarm and locally within supported storage such as pass
// stores.
package secret

import (
	"context"
	"fmt"
	"slices"
	"strconv"
	"strings"
	"sync"

	appPkg "coopcloud.tech/abra/pkg/app"
	"coopcloud.tech/abra/pkg/client"
	"coopcloud.tech/abra/pkg/config"
	"coopcloud.tech/abra/pkg/envfile"
	"coopcloud.tech/abra/pkg/log"
	"coopcloud.tech/abra/pkg/upstream/stack"
	loader "coopcloud.tech/abra/pkg/upstream/stack"
	"github.com/decentral1se/passgen"
	"github.com/docker/docker/api/types"
	dockerClient "github.com/docker/docker/client"
)

// Secret represents a secret.
type Secret struct {
	// Version comes from the secret version environment variable.
	// For example:
	//  SECRET_FOO=v1
	Version string
	// Length comes from the length modifier at the secret version environment
	// variable. For Example:
	//   SECRET_FOO=v1 # length=12
	Length int
	// RemoteName is the name of the secret on the server. For example:
	//   name: ${STACK_NAME}_test_pass_two_${SECRET_TEST_PASS_TWO_VERSION}
	// With the following:
	//   STACK_NAME=test_example_com
	//   SECRET_TEST_PASS_TWO_VERSION=v2
	// Will have this remote name:
	//   test_example_com_test_pass_two_v2
	RemoteName string
}

// GeneratePasswords generates passwords.
func GeneratePasswords(count, length uint) ([]string, error) {
	passwords, err := passgen.GeneratePasswords(
		count,
		length,
		passgen.AlphabetDefault,
	)
	if err != nil {
		return nil, err
	}

	log.Debugf("generated %s", strings.Join(passwords, ", "))

	return passwords, nil
}

// GeneratePassphrases generates human readable and rememberable passphrases.
func GeneratePassphrases(count uint) ([]string, error) {
	passphrases, err := passgen.GeneratePassphrases(
		count,
		passgen.PassphraseWordCountDefault,
		rune('-'),
		passgen.PassphraseCasingDefault,
		passgen.WordListDefault,
	)
	if err != nil {
		return nil, err
	}

	log.Debugf("generated %s", strings.Join(passphrases, ", "))

	return passphrases, nil
}

// ReadSecretsConfig reads secret names/versions from the recipe config. The
// function generalises appEnv/composeFiles because some times you have an app
// and some times you don't (as the caller). We need to be able to handle the
// "app new" case where we pass in the .env.sample and the "secret generate"
// case where the app is created.
func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName string) (map[string]Secret, error) {
	appEnv, appModifiers, err := envfile.ReadEnvWithModifiers(appEnvPath)
	if err != nil {
		return nil, err
	}
	// Set the STACK_NAME to be able to generate the remote name correctly.
	appEnv["STACK_NAME"] = stackName

	opts := stack.Deploy{Composefiles: composeFiles}
	composeConfig, err := loader.LoadComposefile(opts, appEnv)
	if err != nil {
		return nil, err
	}
	// Read the compose files without injecting environment variables.
	configWithoutEnv, err := loader.LoadComposefile(opts, map[string]string{}, loader.SkipInterpolation)
	if err != nil {
		return nil, err
	}

	var enabledSecrets []string
	for _, service := range composeConfig.Services {
		for _, secret := range service.Secrets {
			enabledSecrets = append(enabledSecrets, secret.Source)
		}
	}

	if len(enabledSecrets) == 0 {
		log.Debugf("not generating app secrets, none enabled in recipe config")
		return nil, nil
	}

	secretValues := map[string]Secret{}
	for secretId, secretConfig := range composeConfig.Secrets {
		if string(secretConfig.Name[len(secretConfig.Name)-1]) == "_" {
			return nil, fmt.Errorf("missing version for secret? (%s)", secretId)
		}

		if !(slices.Contains(enabledSecrets, secretId)) {
			log.Warnf("%s not enabled in recipe config, skipping", secretId)
			continue
		}

		lastIdx := strings.LastIndex(secretConfig.Name, "_")
		secretVersion := secretConfig.Name[lastIdx+1:]
		value := Secret{Version: secretVersion, RemoteName: secretConfig.Name}

		if len(value.RemoteName) > config.MAX_DOCKER_SECRET_LENGTH {
			return nil, fmt.Errorf("secret %s is > %d chars when combined with %s", secretId, config.MAX_DOCKER_SECRET_LENGTH, stackName)
		}

		// Check if the length modifier is set for this secret.
		for envName, modifierValues := range appModifiers {
			// configWithoutEnv contains the raw name as defined in the compose.yaml
			// The name will look something like this:
			//   name: ${STACK_NAME}_test_pass_two_${SECRET_TEST_PASS_TWO_VERSION}
			// To check if the current modifier is for the current secret we check
			// if the raw name contains the env name (e.g. SECRET_TEST_PASS_TWO_VERSION).
			if !strings.Contains(configWithoutEnv.Secrets[secretId].Name, envName) {
				continue
			}
			lengthRaw, ok := modifierValues["length"]
			if ok {
				length, err := strconv.Atoi(lengthRaw)
				if err != nil {
					return nil, err
				}
				value.Length = length
			}
			break
		}
		secretValues[secretId] = value
	}

	return secretValues, nil
}

// GenerateSecrets generates secrets locally and sends them to a remote server for storage.
func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server string) (map[string]string, error) {
	secretsGenerated := map[string]string{}
	var mutex sync.Mutex
	var wg sync.WaitGroup
	ch := make(chan error, len(secrets))
	for n, v := range secrets {
		wg.Add(1)

		go func(secretName string, secret Secret) {
			defer wg.Done()

			log.Debugf("attempting to generate and store %s on %s", secret.RemoteName, server)

			if secret.Length > 0 {
				passwords, err := GeneratePasswords(1, uint(secret.Length))
				if err != nil {
					ch <- err
					return
				}

				if err := client.StoreSecret(cl, secret.RemoteName, passwords[0], server); err != nil {
					if strings.Contains(err.Error(), "AlreadyExists") {
						log.Warnf("%s already exists", secret.RemoteName)
						ch <- nil
					} else {
						ch <- err
					}
					return
				}

				mutex.Lock()
				defer mutex.Unlock()
				secretsGenerated[secretName] = passwords[0]
			} else {
				passphrases, err := GeneratePassphrases(1)
				if err != nil {
					ch <- err
					return
				}

				if err := client.StoreSecret(cl, secret.RemoteName, passphrases[0], server); err != nil {
					if strings.Contains(err.Error(), "AlreadyExists") {
						log.Warnf("%s already exists", secret.RemoteName)
						ch <- nil
					} else {
						ch <- err
					}
					return
				}

				mutex.Lock()
				defer mutex.Unlock()
				secretsGenerated[secretName] = passphrases[0]
			}
			ch <- nil
		}(n, v)
	}

	wg.Wait()

	for range secrets {
		err := <-ch
		if err != nil {
			return nil, err
		}
	}

	log.Debugf("generated and stored %v on %s", secrets, server)

	return secretsGenerated, nil
}

type secretStatus struct {
	LocalName       string
	RemoteName      string
	Version         string
	CreatedOnRemote bool
}

type secretStatuses []secretStatus

// PollSecretsStatus checks status of secrets by comparing the local recipe
// config and deploymend server state.
func PollSecretsStatus(cl *dockerClient.Client, app appPkg.App) (secretStatuses, error) {
	var secStats secretStatuses

	composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
	if err != nil {
		return secStats, err
	}

	secretsConfig, err := ReadSecretsConfig(app.Path, composeFiles, app.StackName())
	if err != nil {
		return secStats, err
	}

	filters, err := app.Filters(false, false)
	if err != nil {
		return secStats, err
	}

	secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
	if err != nil {
		return secStats, err
	}

	remoteSecretNames := make(map[string]bool)
	for _, cont := range secretList {
		remoteSecretNames[cont.Spec.Annotations.Name] = true
	}

	for secretName, val := range secretsConfig {
		createdRemote := false

		secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, val.Version)
		if _, ok := remoteSecretNames[secretRemoteName]; ok {
			createdRemote = true
		}

		secStats = append(secStats, secretStatus{
			LocalName:       secretName,
			RemoteName:      secretRemoteName,
			Version:         val.Version,
			CreatedOnRemote: createdRemote,
		})
	}

	return secStats, nil
}
