package cli

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"charm.land/lipgloss/v2"
	composetypes "github.com/compose-spec/compose-go/v2/types"
	mapset "github.com/deckarep/golang-set/v2"
	"github.com/docker/cli/cli/command"
	"github.com/docker/cli/cli/flags"
	composeapi "github.com/docker/compose/v2/pkg/api"
	composev2 "github.com/docker/compose/v2/pkg/compose"
	"github.com/docker/compose/v2/pkg/progress"
	"github.com/psviderski/uncloud/pkg/client"
	"github.com/psviderski/uncloud/pkg/client/compose"
)

// BuildServicesOptions contains options for building services in a Compose project.
type BuildServicesOptions struct {
	// BuildArgs sets build-time variables for services. Used in Dockerfiles that declare variables with ARG.
	BuildArgs []string
	// Check the build configuration for services without building them.
	Check bool
	// Deps enables to also build services declared as dependencies of the selected Services.
	Deps bool
	// NoCache disables the use of cache when building images.
	NoCache bool
	// Pull attempts to pull newer versions of the base images before building.
	Pull bool
	// Services specifies which services to build. If empty, all services with a build config are built.
	Services []string

	// Push targets are mutually exclusive.
	// PushCluster uploads the built images to cluster machines after building.
	PushCluster bool
	// PushRegistry uploads the built images to external registries after building.
	PushRegistry bool

	// Cluster-specific options (only used if PushCluster is true).
	// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
	Machines []string
}

// BuildServices builds images for services in the Compose project.
func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project, opts BuildServicesOptions) error {
	// Validate push targets.
	if opts.PushCluster && opts.PushRegistry {
		return fmt.Errorf("cannot specify both PushCluster and PushRegistry: choose one push target")
	}

	// Build service images using Compose implementation.
	dockerCli, err := command.NewDockerCli()
	if err != nil {
		return fmt.Errorf("create docker client: %w", err)
	}
	// Initialise the Docker CLI with default options.
	if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil {
		return fmt.Errorf("initialise docker client: %w", err)
	}

	composeService := composev2.NewComposeService(dockerCli)
	buildOpts := composeapi.BuildOptions{
		Args:     composetypes.NewMappingWithEquals(opts.BuildArgs),
		Check:    opts.Check,
		Deps:     opts.Deps,
		NoCache:  opts.NoCache,
		Pull:     opts.Pull,
		Push:     opts.PushRegistry,
		Services: opts.Services,
	}

	if err = composeService.Build(ctx, project, buildOpts); err != nil {
		return err
	}

	if !opts.PushCluster {
		return nil
	}

	// Push built service images to cluster machines.
	builtServices, err := ServicesThatNeedBuild(project, opts.Services, opts.Deps)
	if err != nil {
		return fmt.Errorf("determine built services: %w", err)
	}
	if len(builtServices) == 0 {
		// No services were built, nothing to push.
		return nil
	}

	// Add a line break after the build output.
	fmt.Fprintln(cli.ProgressOut())

	clusterClient, err := cli.ConnectCluster(ctx)
	if err != nil {
		return fmt.Errorf("connect to cluster: %w", err)
	}
	defer clusterClient.Close()

	// Push one service image at a time.
	var errs []error
	for _, s := range builtServices {
		if s.Image == "" {
			// Skip services without an image name (shouldn't happen for services with build config).
			continue
		}

		// Push to the specified machines falling back to service x-machines.
		// If none specified, push to *all* cluster machines.
		var pushOpts client.PushImageOptions

		if len(opts.Machines) > 0 {
			pushOpts.Machines = opts.Machines
		} else if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
			pushOpts.Machines = machines
		}

		if len(pushOpts.Machines) == 0 {
			pushOpts.AllMachines = true
		}

		boldStyle := lipgloss.NewStyle().Bold(true)
		err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
			if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
				return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
			}
			return nil
		}, cli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
		// Collect errors to try pushing all images.
		if err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

// ServicesThatNeedBuild returns a list of services that require building.
// deps indicates whether to include services that are dependencies of the selected services.
// Implementation is based on the logic from docker/compose/v2/pkg/compose/build.go.
func ServicesThatNeedBuild(
	project *composetypes.Project, selectedServices []string, deps bool,
) ([]composetypes.ServiceConfig, error) {
	servicesToBuild := make([]composetypes.ServiceConfig, 0, len(project.Services))

	var policy composetypes.DependencyOption = composetypes.IgnoreDependencies
	if deps {
		policy = composetypes.IncludeDependencies
	}

	// Also include services used as build.additional_contexts with service: prefix.
	selectedServices = includeAdditionalContextsServices(project, selectedServices)
	// Some build dependencies we just introduced may not be enabled, enable them.
	var err error
	if project, err = project.WithServicesEnabled(selectedServices...); err != nil {
		return nil, err
	}
	if project, err = project.WithSelectedServices(selectedServices); err != nil {
		return nil, err
	}

	err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error {
		if service.Build != nil {
			servicesToBuild = append(servicesToBuild, *service)
		}
		return nil
	}, policy)
	if err != nil {
		return nil, err
	}

	return servicesToBuild, nil
}

// includeAdditionalContextsServices adds services referenced in build.additional_contexts to the list
// of selected services.
func includeAdditionalContextsServices(project *composetypes.Project, selectedServices []string) []string {
	servicesWithDependencies := mapset.NewSet(selectedServices...)
	for _, service := range selectedServices {
		s, ok := project.Services[service]
		if !ok {
			s = project.DisabledServices[service]
		}
		if s.Build != nil {
			for _, target := range s.Build.AdditionalContexts {
				if name, found := strings.CutPrefix(target, composetypes.ServicePrefix); found {
					servicesWithDependencies.Add(name)
				}
			}
		}
	}
	if servicesWithDependencies.Cardinality() > len(selectedServices) {
		return includeAdditionalContextsServices(project, servicesWithDependencies.ToSlice())
	}

	return servicesWithDependencies.ToSlice()
}
