//go:generate mockery --name Loader --inpackage --filename loader_mock.go

package network

import (
	"context"
	"errors"
	"fmt"
	"isle/bootstrap"
	"isle/daemon/children"
	"isle/daemon/daecommon"
	"isle/nebula"
	"isle/toolkit"
	"os"

	"dev.mediocregopher.com/mediocre-go-lib.git/mlog"
)

func networkStateDir(
	networksStateDir toolkit.Dir, networkID string, mayExist bool,
) (
	toolkit.Dir, error,
) {
	return networksStateDir.MkChildDir(networkID, mayExist)
}

func networkRuntimeDir(
	networksRuntimeDir toolkit.Dir, networkID string, mayExist bool,
) (
	toolkit.Dir, error,
) {
	return networksRuntimeDir.MkChildDir(networkID, mayExist)
}

func cleanupDirs(
	networkStateDir, networkRuntimeDir toolkit.Dir,
) error {
	var errs []error

	if err := os.RemoveAll(networkStateDir.Path); err != nil {
		errs = append(errs, fmt.Errorf(
			"removing %q: %w", networkStateDir.Path, err,
		))
	}

	if err := os.RemoveAll(networkRuntimeDir.Path); err != nil {
		errs = append(errs, fmt.Errorf(
			"removing %q: %w", networkRuntimeDir.Path, err,
		))
	}

	return errors.Join(errs...)
}

func networkDirs(
	networksStateDir, networksRuntimeDir toolkit.Dir,
	networkID string,
	mayExist bool,
) (
	stateDir, runtimeDir toolkit.Dir, err error,
) {
	h := new(toolkit.MkDirHelper)
	stateDir, _ = h.Maybe(
		networkStateDir(networksStateDir, networkID, mayExist),
	)
	runtimeDir, _ = h.Maybe(
		networkRuntimeDir(networksRuntimeDir, networkID, mayExist),
	)
	err = h.Err()
	return
}

// Loader is responsible for joining/creating Networks and making them loadable
// later.
type Loader interface {

	// Loadable returns the CreationParams for all Networks which can be Loaded.
	Loadable(context.Context) ([]bootstrap.CreationParams, error)

	// StoredConfig returns the NetworkConfig currently stored for the network
	// with the given ID, or the default NetworkConfig if none is stored.
	StoredConfig(
		ctx context.Context, networkID string,
	) (
		daecommon.NetworkConfig, error,
	)

	// Load initializes and returns a Network instance for a network which was
	// previously joined or created, and which has the given CreationParams.
	Load(
		context.Context,
		*mlog.Logger,
		bootstrap.CreationParams,
		*Opts,
	) (
		Network, error,
	)

	// Join initializes and returns a Network instance for an existing network
	// which was not previously joined to on this host. Once Join has been
	// called for a particular network it will error on subsequent calls for
	// that same network, Load should be used instead.
	Join(
		context.Context,
		*mlog.Logger,
		JoiningBootstrap,
		*Opts,
	) (
		Network, error,
	)

	// Create initializes and returns a Network for a brand new network which
	// uses the given creation parameters.
	//
	//   - name: Human-readable name of the network.
	//   - domain: Primary domain name that network services are served under.
	//   - ipNet: An IP subnet, in CIDR form, which will be the overall range of
	//     possible IPs in the network. The first IP in this network range will
	//     become this first host's IP.
	//   - hostName: The name of this first host in the network.
	//
	// Errors:
	//   - ErrInvalidConfig - If the Opts.Config field is not valid. It must be
	//     non-nil and have at least 3 storage allocations.
	Create(
		context.Context,
		*mlog.Logger,
		bootstrap.CreationParams,
		nebula.IPNet,
		nebula.HostName,
		*Opts,
	) (
		Network, error,
	)
}

// LoaderOpts are optional parameters which can be passed in when initializing a
// new Loader instance. A nil LoaderOpts is equivalent to a zero value.
type LoaderOpts struct {
	// Defaults to that returned by daecommon.GetEnvVars.
	EnvVars daecommon.EnvVars

	constructors constructors // defaults to newConstructors()
}

func (o *LoaderOpts) withDefaults() *LoaderOpts {
	if o == nil {
		o = new(LoaderOpts)
	}

	if o.EnvVars == (daecommon.EnvVars{}) {
		o.EnvVars = daecommon.GetEnvVars()
	}

	if o.constructors == nil {
		o.constructors = newConstructors()
	}

	return o
}

type loader struct {
	opts               *LoaderOpts
	envBinDirPath      string
	networksStateDir   toolkit.Dir
	networksRuntimeDir toolkit.Dir
	nebulaDeviceNamer  *children.NebulaDeviceNamer
}

// NewLoader returns a new Loader which will use the given directories to load
// and create Network instances.
func NewLoader(
	ctx context.Context,
	logger *mlog.Logger,
	envBinDirPath string,
	opts *LoaderOpts,
) (
	Loader, error,
) {
	opts = opts.withDefaults()

	if err := migrateToMultiNetworkStateDirectory(
		ctx,
		logger.WithNamespace("migration-multi-network-state-dir"),
		opts.EnvVars.StateDir,
	); err != nil {
		return nil, fmt.Errorf(
			"migrating to multi-network state directory: %w", err,
		)
	}

	h := new(toolkit.MkDirHelper)
	networksStateDir, _ := h.Maybe(
		opts.EnvVars.StateDir.MkChildDir("networks", true),
	)

	networksRuntimeDir, _ := h.Maybe(
		opts.EnvVars.RuntimeDir.MkChildDir("networks", true),
	)

	if err := h.Err(); err != nil {
		return nil, fmt.Errorf("creating networks sub-directories: %w", err)
	}

	return &loader{
		opts,
		envBinDirPath,
		networksStateDir,
		networksRuntimeDir,
		children.NewNebulaDeviceNamer(),
	}, nil
}

func (l *loader) Loadable(
	ctx context.Context,
) (
	[]bootstrap.CreationParams, error,
) {
	networkStateDirs, err := l.networksStateDir.ChildDirs()
	if err != nil {
		return nil, fmt.Errorf(
			"listing children of %q: %w", l.networksStateDir.Path, err,
		)
	}

	creationParams := make([]bootstrap.CreationParams, 0, len(networkStateDirs))

	for _, networkStateDir := range networkStateDirs {
		thisCreationParams, err := loadCreationParams(networkStateDir)
		if err != nil {
			return nil, fmt.Errorf(
				"loading creation params from %q: %w",
				networkStateDir.Path,
				err,
			)
		}
		creationParams = append(creationParams, thisCreationParams)
	}

	return creationParams, nil
}

func (l *loader) StoredConfig(
	_ context.Context, networkID string,
) (
	daecommon.NetworkConfig, error,
) {
	networkStateDir, err := networkStateDir(l.networksStateDir, networkID, true)
	if err != nil {
		return daecommon.NetworkConfig{}, fmt.Errorf(
			"getting network state dir: %w", err,
		)
	}

	return loadConfig(networkStateDir)
}

func (l *loader) isJoined(ctx context.Context, networkID string) (bool, error) {
	allJoinedCreationParams, err := l.Loadable(ctx)
	if err != nil {
		return false, fmt.Errorf("getting already joined networks: %w", err)
	}

	for _, joinedCreationParams := range allJoinedCreationParams {
		if joinedCreationParams.ID == networkID {
			return true, nil
		}
	}

	return false, nil
}

func (l *loader) Load(
	ctx context.Context,
	logger *mlog.Logger,
	creationParams bootstrap.CreationParams,
	opts *Opts,
) (
	Network, error,
) {
	networkID := creationParams.ID

	if isJoined, err := l.isJoined(ctx, networkID); err != nil {
		return nil, fmt.Errorf("checking if network is already joined: %w", err)
	} else if !isJoined {
		return nil, errors.New("network is not yet joined")
	}

	networkStateDir, networkRuntimeDir, err := networkDirs(
		l.networksStateDir, l.networksRuntimeDir, networkID, true,
	)
	if err != nil {
		return nil, fmt.Errorf(
			"creating sub-directories for network %q: %w", networkID, err,
		)
	}

	return l.opts.constructors.load(
		ctx,
		logger,
		l.envBinDirPath,
		l.nebulaDeviceNamer,
		networkStateDir,
		networkRuntimeDir,
		opts,
	)
}

func (l *loader) Join(
	ctx context.Context,
	logger *mlog.Logger,
	joiningBootstrap JoiningBootstrap,
	opts *Opts,
) (
	Network, error,
) {
	var (
		creationParams = joiningBootstrap.Bootstrap.NetworkCreationParams
		networkID      = creationParams.ID
	)

	networkStateDir, networkRuntimeDir, err := networkDirs(
		l.networksStateDir, l.networksRuntimeDir, networkID, false,
	)
	if err != nil {
		return nil, fmt.Errorf(
			"creating sub-directories for network %q: %w", networkID, err,
		)
	}

	n, err := l.opts.constructors.join(
		ctx,
		logger,
		l.envBinDirPath,
		l.nebulaDeviceNamer,
		joiningBootstrap,
		networkStateDir,
		networkRuntimeDir,
		opts,
	)

	if err != nil {
		return nil, errors.Join(
			err, cleanupDirs(networkStateDir, networkRuntimeDir),
		)
	}

	return n, nil
}

func (l *loader) Create(
	ctx context.Context,
	logger *mlog.Logger,
	creationParams bootstrap.CreationParams,
	ipNet nebula.IPNet,
	hostName nebula.HostName,
	opts *Opts,
) (
	Network, error,
) {
	networkID := creationParams.ID

	networkStateDir, networkRuntimeDir, err := networkDirs(
		l.networksStateDir, l.networksRuntimeDir, networkID, false,
	)
	if err != nil {
		return nil, fmt.Errorf(
			"creating sub-directories for network %q: %w", networkID, err,
		)
	}

	n, err := l.opts.constructors.create(
		ctx,
		logger,
		l.envBinDirPath,
		l.nebulaDeviceNamer,
		networkStateDir,
		networkRuntimeDir,
		creationParams,
		ipNet,
		hostName,
		opts,
	)

	if err != nil {
		return nil, errors.Join(
			err, cleanupDirs(networkStateDir, networkRuntimeDir),
		)
	}

	return n, nil
}