/*
   Copyright 2020 The Compose Specification Authors.

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
*/

package cli

import (
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/sirupsen/logrus"
	"go.yaml.in/yaml/v4"

	"github.com/compose-spec/compose-go/v2/consts"
	"github.com/compose-spec/compose-go/v2/dotenv"
	"github.com/compose-spec/compose-go/v2/loader"
	"github.com/compose-spec/compose-go/v2/types"
	"github.com/compose-spec/compose-go/v2/utils"
)

// ProjectOptions provides common configuration for loading a project.
type ProjectOptions struct {
	// Name is a valid Compose project name to be used or empty.
	//
	// If empty, the project loader will automatically infer a reasonable
	// project name if possible.
	Name string

	// WorkingDir is a file path to use as the project directory or empty.
	//
	// If empty, the project loader will automatically infer a reasonable
	// working directory if possible.
	WorkingDir string

	// ConfigPaths are file paths to one or more Compose files.
	//
	// These are applied in order by the loader following the override logic
	// as described in the spec.
	//
	// The first entry is required and is the primary Compose file.
	// For convenience, WithConfigFileEnv and WithDefaultConfigPath
	// are provided to populate this in a predictable manner.
	ConfigPaths []string

	// Environment are additional environment variables to make available
	// for interpolation.
	//
	// NOTE: For security, the loader does not automatically expose any
	// process environment variables. For convenience, WithOsEnv can be
	// used if appropriate.
	Environment types.Mapping

	// EnvFiles are file paths to ".env" files with additional environment
	// variable data.
	//
	// These are loaded in-order, so it is possible to override variables or
	// in subsequent files.
	//
	// This field is optional, but any file paths that are included here must
	// exist or an error will be returned during load.
	EnvFiles []string

	loadOptions []func(*loader.Options)

	// Callbacks to retrieve metadata information during parse defined before
	// creating the project
	Listeners []loader.Listener
	// ResourceLoaders manages support for remote resources
	ResourceLoaders []loader.ResourceLoader

	// configOrigin describes how ConfigPaths entries were selected when they
	// don't result from an explicit user selection (COMPOSE_FILE environment
	// variable, default file discovery), keyed by the value stored in
	// ConfigPaths. Used to give context in configuration load errors.
	configOrigin map[string]string
}

type ProjectOptionsFn func(*ProjectOptions) error

// NewProjectOptions creates ProjectOptions
func NewProjectOptions(configs []string, opts ...ProjectOptionsFn) (*ProjectOptions, error) {
	options := &ProjectOptions{
		ConfigPaths: configs,
		Environment: map[string]string{},
		Listeners:   []loader.Listener{},
	}
	for _, o := range opts {
		err := o(options)
		if err != nil {
			return nil, err
		}
	}
	return options, nil
}

// WithName defines ProjectOptions' name
func WithName(name string) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		// a project (once loaded) cannot have an empty name
		// however, on the options object, the name is optional: if unset,
		// a name will be inferred by the loader, so it's legal to set the
		// name to an empty string here
		if name != loader.NormalizeProjectName(name) {
			return loader.InvalidProjectNameErr(name)
		}
		o.Name = name
		return nil
	}
}

// WithWorkingDirectory defines ProjectOptions' working directory
func WithWorkingDirectory(wd string) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		if wd == "" {
			return nil
		}
		abs, err := filepath.Abs(wd)
		if err != nil {
			return err
		}
		o.WorkingDir = abs
		return nil
	}
}

// WithConfigFileEnv allow to set compose config file paths by COMPOSE_FILE environment variable
func WithConfigFileEnv(o *ProjectOptions) error {
	if len(o.ConfigPaths) > 0 {
		return nil
	}
	sep := o.Environment[consts.ComposePathSeparator]
	if sep == "" {
		sep = string(os.PathListSeparator)
	}
	f, ok := o.Environment[consts.ComposeFilePath]
	if ok {
		const origin = "set by COMPOSE_FILE environment variable"
		for _, entry := range strings.Split(f, sep) {
			path, err := o.acceptComposeFile(entry)
			if err != nil {
				return fmt.Errorf("compose file %q %s is invalid: %w", entry, origin, err)
			}
			o.ConfigPaths = append(o.ConfigPaths, path)
			o.setConfigOrigin(path, origin)
		}
	}
	return nil
}

// acceptComposeFile checks a compose file reference is usable: either stdin
// ("-"), a remote resource supported by a registered ResourceLoader, or a
// local path pointing to a regular file. Local paths are resolved to absolute.
func (o *ProjectOptions) acceptComposeFile(path string) (string, error) {
	if path == "-" {
		return path, nil
	}
	for _, r := range o.ResourceLoaders {
		if r.Accept(path) {
			return path, nil
		}
	}
	abs, err := filepath.Abs(path)
	if err != nil {
		return "", err
	}
	fi, err := os.Stat(abs)
	if err != nil {
		return "", err
	}
	if fi.IsDir() {
		return "", fmt.Errorf("%s is a directory", abs)
	}
	if !fi.Mode().IsRegular() {
		return "", fmt.Errorf("%s is not a regular file", abs)
	}
	return abs, nil
}

func (o *ProjectOptions) setConfigOrigin(path, origin string) {
	if o.configOrigin == nil {
		o.configOrigin = map[string]string{}
	}
	o.configOrigin[path] = origin
}

// configFileError decorates a compose file load error with the way the file
// was selected, when this selection wasn't an explicit user choice.
func (o *ProjectOptions) configFileError(filename string, err error) error {
	if origin, ok := o.configOrigin[filename]; ok {
		return fmt.Errorf("compose file %q %s is invalid: %w", filename, origin, err)
	}
	return fmt.Errorf("compose file %q is invalid: %w", filename, err)
}

// WithDefaultConfigPath searches for default config files from working directory
func WithDefaultConfigPath(o *ProjectOptions) error {
	if len(o.ConfigPaths) > 0 {
		return nil
	}
	pwd, err := o.GetWorkingDir()
	if err != nil {
		return err
	}
	for {
		candidates := findFiles(DefaultFileNames, pwd)
		if len(candidates) > 0 {
			winner := candidates[0]
			if len(candidates) > 1 {
				logrus.Warnf("Found multiple config files with supported names: %s", strings.Join(candidates, ", "))
				logrus.Warnf("Using %s", winner)
			}
			const origin = "found by default file discovery"
			o.ConfigPaths = append(o.ConfigPaths, winner)
			o.setConfigOrigin(winner, origin)

			overrides := findFiles(DefaultOverrideFileNames, pwd)
			if len(overrides) > 0 {
				if len(overrides) > 1 {
					logrus.Warnf("Found multiple override files with supported names: %s", strings.Join(overrides, ", "))
					logrus.Warnf("Using %s", overrides[0])
				}
				o.ConfigPaths = append(o.ConfigPaths, overrides[0])
				o.setConfigOrigin(overrides[0], origin)
			}
			return nil
		}
		parent := filepath.Dir(pwd)
		if parent == pwd {
			// no config file found, but that's not a blocker if caller only needs project name
			return nil
		}
		pwd = parent
	}
}

// WithEnv defines a key=value set of variables used for compose file interpolation
func WithEnv(env []string) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		for k, v := range utils.GetAsEqualsMap(env) {
			o.Environment[k] = v
		}
		return nil
	}
}

// WithDiscardEnvFile sets discards the `env_file` section after resolving to
// the `environment` section
func WithDiscardEnvFile(o *ProjectOptions) error {
	o.loadOptions = append(o.loadOptions, loader.WithDiscardEnvFiles)
	return nil
}

// WithLoadOptions provides a hook to control how compose files are loaded
func WithLoadOptions(loadOptions ...func(*loader.Options)) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, loadOptions...)
		return nil
	}
}

// WithDefaultProfiles uses the provided profiles (if any), and falls back to
// profiles specified via the COMPOSE_PROFILES environment variable otherwise.
func WithDefaultProfiles(profiles ...string) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		if len(profiles) == 0 {
			for _, s := range strings.Split(o.Environment[consts.ComposeProfiles], ",") {
				profiles = append(profiles, strings.TrimSpace(s))
			}
		}
		o.loadOptions = append(o.loadOptions, loader.WithProfiles(profiles))
		return nil
	}
}

// WithProfiles sets profiles to be activated
func WithProfiles(profiles []string) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, loader.WithProfiles(profiles))
		return nil
	}
}

// WithOsEnv imports environment variables from OS
func WithOsEnv(o *ProjectOptions) error {
	for k, v := range utils.GetAsEqualsMap(os.Environ()) {
		if _, set := o.Environment[k]; set {
			continue
		}
		o.Environment[k] = v
	}
	return nil
}

// WithEnvFile sets an alternate env file.
//
// Deprecated: use WithEnvFiles instead.
func WithEnvFile(file string) ProjectOptionsFn {
	var files []string
	if file != "" {
		files = []string{file}
	}
	return WithEnvFiles(files...)
}

// WithEnvFiles set env file(s) to be loaded to set project environment.
// defaults to local .env file if no explicit file is selected, until COMPOSE_DISABLE_ENV_FILE is set
func WithEnvFiles(file ...string) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		if len(file) > 0 {
			o.EnvFiles = file
			return nil
		}
		if v, ok := os.LookupEnv(consts.ComposeDisableDefaultEnvFile); ok {
			b, err := strconv.ParseBool(v)
			if err != nil {
				return err
			}
			if b {
				return nil
			}
		}

		wd, err := o.GetWorkingDir()
		if err != nil {
			return err
		}
		defaultDotEnv := filepath.Join(wd, ".env")

		s, err := os.Stat(defaultDotEnv)
		if os.IsNotExist(err) {
			return nil
		}
		if os.IsPermission(err) {
			// The default .env is an implicit convenience, not something the
			// user asked for: when permissions prevent even probing for it
			// (say, running with a working directory owned by another user),
			// warn and move on instead of failing the whole command. An
			// explicit --env-file still fails hard in WithDotEnv.
			logrus.Warnf("cannot access %s, ignoring default env file: %v", defaultDotEnv, err)
			return nil
		}
		if err != nil {
			return err
		}
		if !s.IsDir() {
			o.EnvFiles = []string{defaultDotEnv}
		}
		return nil
	}
}

// WithDotEnv imports environment variables from .env file
func WithDotEnv(o *ProjectOptions) error {
	envMap, err := dotenv.GetEnvFromFile(o.Environment, o.EnvFiles)
	if err != nil {
		return err
	}
	o.Environment.Merge(envMap)
	return nil
}

// WithInterpolation set ProjectOptions to enable/skip interpolation
func WithInterpolation(interpolation bool) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
			options.SkipInterpolation = !interpolation
		})
		return nil
	}
}

// WithNormalization set ProjectOptions to enable/skip normalization
func WithNormalization(normalization bool) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
			options.SkipNormalization = !normalization
		})
		return nil
	}
}

// WithConsistency set ProjectOptions to enable/skip consistency
func WithConsistency(consistency bool) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
			options.SkipConsistencyCheck = !consistency
		})
		return nil
	}
}

// WithResolvedPaths set ProjectOptions to enable paths resolution
func WithResolvedPaths(resolve bool) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
			options.ResolvePaths = resolve
		})
		return nil
	}
}

// WithResourceLoader register support for ResourceLoader to manage remote resources
func WithResourceLoader(r loader.ResourceLoader) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.ResourceLoaders = append(o.ResourceLoaders, r)
		o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
			options.ResourceLoaders = o.ResourceLoaders
		})
		return nil
	}
}

// WithExtension register a know extension `x-*` with the go struct type to decode into
func WithExtension(name string, typ any) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
			if options.KnownExtensions == nil {
				options.KnownExtensions = map[string]any{}
			}
			options.KnownExtensions[name] = typ
		})
		return nil
	}
}

// Append listener to event
func (o *ProjectOptions) WithListeners(listeners ...loader.Listener) {
	o.Listeners = append(o.Listeners, listeners...)
}

// WithoutEnvironmentResolution disable environment resolution
func WithoutEnvironmentResolution(o *ProjectOptions) error {
	o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
		options.SkipResolveEnvironment = true
	})
	return nil
}

// WithoutLabelsResolution disable resolution of `label_file` into service labels.
// When set, service `labels` may be incomplete: `label_file` entries are left
// unresolved, so callers must not treat `labels` as authoritative.
func WithoutLabelsResolution(o *ProjectOptions) error {
	o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
		options.SkipResolveLabels = true
	})
	return nil
}

// WithSelectedServices restricts the loaded project to the given services and their
// dependencies. An empty list means "all services". When set, services not in the
// list are dropped from the project before environment resolution, so their
// `env_file` / `label_file` entries are not loaded from disk.
func WithSelectedServices(services ...string) ProjectOptionsFn {
	return func(o *ProjectOptions) error {
		o.loadOptions = append(o.loadOptions, loader.WithSelectedServices(services))
		return nil
	}
}

// WithoutUnnecessaryResources drops networks/volumes/secrets/configs/models that
// are not referenced by services remaining after selection.
func WithoutUnnecessaryResources(o *ProjectOptions) error {
	o.loadOptions = append(o.loadOptions, loader.WithoutUnnecessaryResources)
	return nil
}

// DefaultFileNames defines the Compose file names for auto-discovery (in order of preference)
var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}

// DefaultOverrideFileNames defines the Compose override file names for auto-discovery (in order of preference)
var DefaultOverrideFileNames = []string{"compose.override.yml", "compose.override.yaml", "docker-compose.override.yml", "docker-compose.override.yaml"}

func (o *ProjectOptions) GetWorkingDir() (string, error) {
	if o.WorkingDir != "" {
		return filepath.Abs(o.WorkingDir)
	}
PATH:
	for _, path := range o.ConfigPaths {
		if path != "-" {
			for _, l := range o.ResourceLoaders {
				if l.Accept(path) {
					break PATH
				}
			}
			absPath, err := filepath.Abs(path)
			if err != nil {
				return "", err
			}
			return filepath.Dir(absPath), nil
		}
	}
	return os.Getwd()
}

// ReadConfigFiles reads ConfigFiles and populates the content field
func (o *ProjectOptions) ReadConfigFiles(ctx context.Context, workingDir string, options *ProjectOptions) (*types.ConfigDetails, error) {
	config, err := loader.LoadConfigFiles(ctx, options.ConfigPaths, workingDir, options.loadOptions...)
	if err != nil {
		return nil, err
	}
	configs := make([][]byte, len(config.ConfigFiles))

	for i, c := range config.ConfigFiles {
		var err error
		var b []byte
		if c.IsStdin() {
			b, err = io.ReadAll(os.Stdin)
			if err != nil {
				return nil, err
			}
		} else {
			f, err := filepath.Abs(c.Filename)
			if err != nil {
				return nil, err
			}
			b, err = os.ReadFile(f)
			if err != nil {
				// the raw read error on a directory is OS-specific and cryptic
				// ("is a directory", "Incorrect function.", ...)
				if fi, statErr := os.Stat(f); statErr == nil && fi.IsDir() {
					err = fmt.Errorf("%s is a directory", f)
				}
				return nil, options.configFileError(c.Filename, err)
			}
		}
		configs[i] = b
	}
	for i, c := range configs {
		config.ConfigFiles[i].Content = c
	}
	return config, nil
}

// LoadProject loads compose file according to options and bind to types.Project go structs
func (o *ProjectOptions) LoadProject(ctx context.Context) (*types.Project, error) {
	config, err := o.prepare(ctx)
	if err != nil {
		return nil, err
	}

	project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
		ConfigFiles: config.ConfigFiles,
		WorkingDir:  config.WorkingDir,
		Environment: o.Environment,
	}, o.loadOptions...)
	if err != nil {
		return nil, err
	}

	for _, config := range config.ConfigFiles {
		project.ComposeFiles = append(project.ComposeFiles, config.Filename)
	}

	return project, nil
}

// LoadModel loads compose file according to options and returns a raw (yaml tree) model
func (o *ProjectOptions) LoadModel(ctx context.Context) (map[string]any, error) {
	configDetails, err := o.prepare(ctx)
	if err != nil {
		return nil, err
	}

	return loader.LoadModelWithContext(ctx, *configDetails, o.loadOptions...)
}

// prepare converts ProjectOptions into loader's types.ConfigDetails and configures default load options
func (o *ProjectOptions) prepare(ctx context.Context) (*types.ConfigDetails, error) {
	defaultDir, err := o.GetWorkingDir()
	if err != nil {
		return &types.ConfigDetails{}, err
	}

	configDetails, err := o.ReadConfigFiles(ctx, defaultDir, o)
	if err != nil {
		return configDetails, err
	}

	isNamed := false
	if o.Name == "" {
		type named struct {
			Name string `yaml:"name,omitempty"`
		}
		// if any of the compose file is named, this is equivalent to user passing --project-name
		for _, cfg := range configDetails.ConfigFiles {
			var n named
			err = yaml.Unmarshal(cfg.Content, &n)
			if err != nil {
				return nil, err
			}
			if n.Name != "" {
				isNamed = true
				break
			}
		}
	}

	o.loadOptions = append(o.loadOptions,
		withNamePrecedenceLoad(defaultDir, isNamed, o),
		withConvertWindowsPaths(o),
		withListeners(o))

	return configDetails, nil
}

// ProjectFromOptions load a compose project based on command line options
// Deprecated: use ProjectOptions.LoadProject or ProjectOptions.LoadModel
func ProjectFromOptions(ctx context.Context, options *ProjectOptions) (*types.Project, error) {
	return options.LoadProject(ctx)
}

func withNamePrecedenceLoad(absWorkingDir string, namedInYaml bool, options *ProjectOptions) func(*loader.Options) {
	return func(opts *loader.Options) {
		if options.Name != "" {
			opts.SetProjectName(options.Name, true)
		} else if nameFromEnv, ok := options.Environment[consts.ComposeProjectName]; ok && nameFromEnv != "" {
			opts.SetProjectName(nameFromEnv, true)
		} else if !namedInYaml {
			dirname := filepath.Base(absWorkingDir)
			symlink, err := filepath.EvalSymlinks(absWorkingDir)
			if err == nil && filepath.Base(symlink) != dirname {
				logrus.Warnf("project has been loaded without an explicit name from a symlink. Using name %q", dirname)
			}
			opts.SetProjectName(
				loader.NormalizeProjectName(dirname),
				false,
			)
		}
	}
}

func withConvertWindowsPaths(options *ProjectOptions) func(*loader.Options) {
	return func(o *loader.Options) {
		if o.ResolvePaths {
			o.ConvertWindowsPaths = utils.StringToBool(options.Environment["COMPOSE_CONVERT_WINDOWS_PATHS"])
		}
	}
}

// save listeners from ProjectOptions (compose) to loader.Options
func withListeners(options *ProjectOptions) func(*loader.Options) {
	return func(opts *loader.Options) {
		opts.Listeners = append(opts.Listeners, options.Listeners...)
	}
}

func findFiles(names []string, pwd string) []string {
	candidates := []string{}
	for _, n := range names {
		f := filepath.Join(pwd, n)
		if _, err := os.Stat(f); err == nil {
			candidates = append(candidates, f)
		}
	}
	return candidates
}
