package extended

import (
	"errors"
	"fmt"
	"net/url"
	"path/filepath"
	"strings"

	exutil "github.com/openshift/machine-config-operator/test/extended-priv/util"
	logger "github.com/openshift/machine-config-operator/test/extended-priv/util/logext"
	e2e "k8s.io/kubernetes/test/e2e/framework"
)

// sanitizeProxy redacts userinfo (credentials) from a proxy URL for safe logging.
// If the value is not a valid URL it is returned as-is.
func sanitizeProxy(proxy string) string {
	u, err := url.Parse(proxy)
	if err != nil || u.User == nil {
		return proxy
	}
	u.User = url.User("<redacted>")
	return u.String()
}

// sanitizeCommand returns the command string with proxy credentials redacted for logging.
func (b *OsImageBuilderInNode) sanitizeCommand(cmd string) string {
	var oldnew []string
	for _, p := range []string{b.httpProxy, b.httpsProxy, b.noProxy} {
		if p != "" && sanitizeProxy(p) != p {
			oldnew = append(oldnew, p, sanitizeProxy(p))
		}
	}
	if len(oldnew) == 0 {
		return cmd
	}
	return strings.NewReplacer(oldnew...).Replace(cmd)
}

func logAndError(format string, args ...interface{}) error {
	msg := fmt.Sprintf(format, args...)
	logger.Errorf("%s", msg)
	return errors.New(msg)
}

// OsImageBuilderInNode encapsulates the functionality to build custom osImages inside a cluster node.
// Use NewOsImageBuilder() to create one, then call CreateAndDigestOsImage() and CleanUp().
type OsImageBuilderInNode struct {
	// Required — set via NewOsImageBuilder()
	node               *Node
	dockerFileCommands string

	// Options — set via WithInternalRegistry(), WithManifestBuild()
	useInternalRegistry bool
	buildAsManifest     bool

	// Resolved by prepareEnvironment()
	baseImage          string
	osImage            string
	dockerConfig       string
	tmpDir             string
	remoteTmpDir       string
	remoteDockerConfig string
	remoteDockerfile   string
	httpProxy          string
	httpsProxy         string
	noProxy            string
}

// BuildOption configures optional behavior for OsImageBuilderInNode.
type BuildOption func(*OsImageBuilderInNode)

// WithInternalRegistry configures the builder to push images to the cluster's internal registry.
func WithInternalRegistry() BuildOption {
	return func(b *OsImageBuilderInNode) { b.useInternalRegistry = true }
}

// WithManifestBuild configures the builder to produce a manifest list instead of a single image.
func WithManifestBuild() BuildOption {
	return func(b *OsImageBuilderInNode) { b.buildAsManifest = true }
}

// NewOsImageBuilder creates a new OsImageBuilderInNode for the given node and Dockerfile commands.
func NewOsImageBuilder(node *Node, dockerFileCommands string, opts ...BuildOption) *OsImageBuilderInNode {
	b := &OsImageBuilderInNode{
		node:               node,
		dockerFileCommands: dockerFileCommands,
	}
	for _, o := range opts {
		o(b)
	}
	return b
}

func (b *OsImageBuilderInNode) suppressOCOutput() func() {
	b.node.GetOC().NotShowInfo()
	return func() { b.node.GetOC().SetShowInfo() }
}

func (b *OsImageBuilderInNode) proxyEnvPrefix() string {
	return "NO_PROXY=" + b.noProxy + " HTTPS_PROXY=" + b.httpsProxy + " HTTP_PROXY=" + b.httpProxy
}

// prepareEnvironment sets up the build environment on the node by performing the following tasks:
// - Extracts pull secrets if no docker config is provided
// - Creates a temporary directory on the remote node
// - Sets up remote paths for kubeconfig and docker config
// - Copies the kubelet config.json to the remote location
// - Retrieves the base image from release info
// - Generates a unique tag for the new image
// - Configures the target OS image name (either internal registry or external repository)
// - Gathers cluster proxy settings
// Returns an error if any preparation step fails.
func (b *OsImageBuilderInNode) prepareEnvironment() error {
	var err error

	if b.dockerConfig == "" {
		logger.Infof("No docker config file was provided to the osImage builder. Generating a new docker config file")
		exutil.By("Extract pull-secret")
		pullSecret := GetPullSecret(b.node.oc.AsAdmin())
		tokenDir, err := pullSecret.Extract()
		if err != nil {
			return fmt.Errorf("error extracting pull-secret. Error: %s", err)
		}
		logger.Infof("Pull secret has been extracted to: %s\n", tokenDir)
		b.dockerConfig = filepath.Join(tokenDir, ".dockerconfigjson")
	}
	logger.Infof("Using docker config file: %s\n", b.dockerConfig)

	b.remoteTmpDir = filepath.Join("/root", e2e.TestContext.OutputDir, fmt.Sprintf("mco-test-%s", exutil.GetRandomString()))
	_, err = b.node.DebugNodeWithChroot("mkdir", "-p", b.remoteTmpDir)
	if err != nil {
		return fmt.Errorf("error creating tmp dir %s in node %s. Error: %s", b.remoteTmpDir, b.node.GetName(), err)
	}

	b.remoteDockerConfig = filepath.Join(b.remoteTmpDir, ".dockerconfigjson")
	b.remoteDockerfile = filepath.Join(b.remoteTmpDir, "Dockerfile")

	exutil.By("Prepare remote docker config file")
	logger.Infof("Copy cluster config.json file")
	_, cpErr := b.node.DebugNodeWithChroot("cp", "/var/lib/kubelet/config.json", b.remoteDockerConfig)
	if cpErr != nil {
		logger.Errorf("Error copying cluster config.json file to a temporary directory")
		return cpErr
	}

	pool, poolErr := b.node.GetPrimaryPool()
	if poolErr != nil {
		return fmt.Errorf("error getting the primary pool for node %s. Error: %s", b.node.GetName(), poolErr)
	}

	stream, streamErr := GetEffectiveOsImageStream(pool)
	if streamErr != nil {
		return fmt.Errorf("error getting the effective osImageStream for pool %s. Error: %s", pool.GetName(), streamErr)
	}
	logger.Infof("Builder node %s stream: %s", b.node.GetName(), stream)

	b.baseImage, err = GetLayeringBaseImageByStream(b.node.oc.AsAdmin(), stream, b.dockerConfig)
	if err != nil {
		return fmt.Errorf("error getting the base image to build new osImages. Error: %s", err)
	}

	uniqueTag, err := generateUniqueTag(b.node.oc.AsAdmin())
	if err != nil {
		return err
	}

	if b.useInternalRegistry {
		// The images must be created inside the MCO namespace or MCO will not have permissions to pull them
		b.osImage = fmt.Sprintf("%s/%s/%s:%s", InternalRegistrySvcURL, MachineConfigNamespace, "layering", uniqueTag)
		if err := b.preparePushToInternalRegistry(); err != nil {
			return err
		}
	} else if b.osImage == "" {
		b.osImage = getLayeringTestImageRepository(uniqueTag)
	}
	logger.Infof("Building image: %s", b.osImage)

	if b.tmpDir == "" {
		b.tmpDir = e2e.TestContext.OutputDir
	}

	logger.Infof("Gathering proxy information")
	proxy := NewResource(b.node.oc, "proxy", "cluster")
	if proxy.Exists() {
		b.httpProxy = proxy.GetOrFail(`{.status.httpProxy}`)
		b.httpsProxy = proxy.GetOrFail(`{.status.httpsProxy}`)
		b.noProxy = proxy.GetOrFail(`{.status.noProxy}`)
	}

	logger.Infof("OK!\n")

	return nil
}

// preparePushToInternalRegistry configures access to the cluster's internal registry by:
// - Creating a temporary namespace if it doesn't exist
// - Creating a service account with registry-admin permissions
// - Generating a token for the service account
// - Authenticating podman to the internal registry using the token and remote docker config
// Returns an error if namespace, service account creation, or registry login fails.
func (b *OsImageBuilderInNode) preparePushToInternalRegistry() error {
	logger.Infof("Create namespace to store the service account to access the internal registry")
	nsExistsErr := b.node.oc.Run("get").Args("namespace", layeringTestsTmpNamespace).Execute()
	if nsExistsErr != nil {
		err := b.node.oc.Run("create").Args("namespace", layeringTestsTmpNamespace).Execute()
		if err != nil {
			return fmt.Errorf("error creating namespace %s to store the tmp SAs. Error: %s",
				layeringTestsTmpNamespace, err)
		}
	} else {
		logger.Infof("Namespace %s already exists. Skip namespace creation", layeringTestsTmpNamespace)
	}

	logger.Infof("Create service account with registry admin permissions to store the imagestream")
	saExistsErr := b.node.oc.Run("get").Args("-n", layeringTestsTmpNamespace, "serviceaccount", layeringRegistryAdminSAName).Execute()
	if saExistsErr != nil {
		cErr := b.node.oc.Run("create").Args("-n", layeringTestsTmpNamespace, "serviceaccount", layeringRegistryAdminSAName).Execute()
		if cErr != nil {
			return fmt.Errorf("error creating ServiceAccount %s/%s: %s", layeringTestsTmpNamespace, layeringRegistryAdminSAName, cErr)
		}
	} else {
		logger.Infof("SA %s/%s already exists. Skip SA creation", layeringTestsTmpNamespace, layeringRegistryAdminSAName)
	}

	admErr := b.node.oc.Run("adm").Args("-n", layeringTestsTmpNamespace, "policy", "add-cluster-role-to-user", "registry-admin", "-z", layeringRegistryAdminSAName).Execute()
	if admErr != nil {
		return fmt.Errorf("error creating ServiceAccount %s: %s", layeringRegistryAdminSAName, admErr)
	}

	logger.Infof("Get SA token")
	saToken, err := b.node.oc.Run("create").Args("-n", layeringTestsTmpNamespace, "token", layeringRegistryAdminSAName).Output()
	if err != nil {
		logger.Errorf("Error getting token for SA %s", layeringRegistryAdminSAName)
		return err
	}
	logger.Infof("OK!\n")

	logger.Infof("Logging as registry admin to internal registry")
	defer b.suppressOCOutput()()
	loginOut, loginErr := b.node.DebugNodeWithChroot("podman", "login", InternalRegistrySvcURL, "-u", layeringRegistryAdminSAName, "-p", saToken, "--authfile", b.remoteDockerConfig)
	if loginErr != nil {
		return fmt.Errorf("error trying to login to internal registry:\nOutput:%s\nError:%s", loginOut, loginErr)
	}
	logger.Infof("OK!\n")

	return nil
}

// CleanUp will clean up all the helper resources created by the builder
func (b *OsImageBuilderInNode) CleanUp() error {
	logger.Infof("Cleanup image builder resources")

	if b.remoteTmpDir != "" {
		logger.Infof("Removing remote temp directory %s from node %s", b.remoteTmpDir, b.node.GetName())
		if _, err := b.node.DebugNodeWithChroot("rm", "-rf", b.remoteTmpDir); err != nil {
			logger.Errorf("Error removing remote tmp dir %s: %s", b.remoteTmpDir, err)
		}
	}

	if b.useInternalRegistry {
		logger.Infof("Removing namespace %s", layeringTestsTmpNamespace)
		err := b.node.oc.Run("delete").Args("namespace", layeringTestsTmpNamespace, "--ignore-not-found").Execute()
		if err != nil {
			return fmt.Errorf("error deleting namespace %s. Error: %s",
				layeringTestsTmpNamespace, err)
		}

	} else {
		logger.Infof("Not using internal registry, nothing to clean")
	}
	return nil
}

// buildImage builds a custom OS image on a cluster node using podman by:
// - Creating a Dockerfile by combining the base image with custom commands
// - Copying the Dockerfile to the remote node
// - Copying the CA bundle to the build directory if needed
// - Executing either a manifest build or single image build based on BuildAsManifest flag
// - Respecting cluster proxy settings and using the configured authentication file
// Returns an error if the Dockerfile creation, copy, or podman build fails.
func (b *OsImageBuilderInNode) buildImage() error {
	exutil.By("Get base osImage locally")

	logger.Infof("Base image: %s\n", b.baseImage)

	exutil.By("Prepare remote dockerFile directory")
	dockerFile := "FROM " + b.baseImage + "\n" + b.dockerFileCommands + "\n" + ExpirationDockerfileLabel
	logger.Infof(" Using Dockerfile:\n%s", dockerFile)

	localBuildDir, err := prepareDockerfileDirectory(b.tmpDir, dockerFile)
	if err != nil {
		return fmt.Errorf("error creating the build directory with the Dockerfile. Error: %s", err)
	}

	cpErr := b.node.CopyFromLocal(filepath.Join(localBuildDir, "Dockerfile"), b.remoteDockerfile)
	if cpErr != nil {
		return fmt.Errorf("error creating the Dockerfile in the remote node. Error: %s", cpErr)
	}
	logger.Infof("OK!\n")

	exutil.By("Build osImage")
	buildPath := filepath.Dir(b.remoteDockerfile)

	logger.Infof("Copy the /etc/pki/ca-trust/source/anchors/openshift-config-user-ca-bundle.crt file to the build dir, so that it can be used if needed")
	_, err = b.node.DebugNodeWithChroot("cp", "/etc/pki/ca-trust/source/anchors/openshift-config-user-ca-bundle.crt", buildPath)
	if err != nil {
		return err
	}

	imageFlag := "--tag"
	logLabel := "single image"
	if b.buildAsManifest {
		imageFlag = "--manifest"
		logLabel = "manifest"
	}
	logger.Infof("Building as %s", logLabel)
	buildCommand := b.proxyEnvPrefix() + " podman build --network host " + buildPath + " " + imageFlag + " " + b.osImage + " --authfile " + b.remoteDockerConfig
	logger.Infof("Executing build command: %s", b.sanitizeCommand(buildCommand))

	defer b.suppressOCOutput()()
	output, err := b.node.DebugNodeWithChroot("bash", "-c", buildCommand)
	if err != nil {
		return logAndError("Podman failed building image %s:\n%s\n%s", b.osImage, output, err)
	}

	logger.Debugf(output)
	logger.Infof("OK!\n")

	return nil
}

// pushImage pushes the built OS image to the registry by:
// - Executing "podman manifest push" for manifest builds or "podman push" for single image builds
// - Respecting cluster proxy settings and using the authentication file
// - Validating that the push was authorized by checking for unauthorized errors in the output
// Returns an error if the push command fails or if the push is unauthorized.
func (b *OsImageBuilderInNode) pushImage() error {
	exutil.By("Push osImage")
	var pushCommand string
	if b.buildAsManifest {
		pushCommand = b.proxyEnvPrefix() + " podman manifest push " + b.osImage + " docker://" + b.osImage + " --authfile " + b.remoteDockerConfig
		logger.Infof("Pushing as manifest")
	} else {
		pushCommand = b.proxyEnvPrefix() + " podman push " + b.osImage + " --authfile " + b.remoteDockerConfig
		logger.Infof("Pushing as single image")
	}
	logger.Infof("Executing push command: %s", b.sanitizeCommand(pushCommand))

	defer b.suppressOCOutput()()
	output, err := b.node.DebugNodeWithChroot("bash", "-c", pushCommand)
	if err != nil {
		return logAndError("Podman failed pushing image %s:\n%s\n%s", b.osImage, output, err)
	}

	// If we don't have permissions to push the image, the `oc debug` command will not return an error
	//  so we need to check manually that there is no unauthorized error
	if strings.Contains(output, "unauthorized: access to the requested resource is not authorized") {
		return logAndError("Podman was not authorized to push the image %s:\n%s\n", b.osImage, output)
	}

	logger.Debugf(output)
	logger.Infof("OK!\n")
	return nil
}

// removeImage removes the built OS image from the node to free up space
func (b *OsImageBuilderInNode) removeImage() error {
	exutil.By("Remove osImage")
	var rmOutput string
	var err error
	if b.buildAsManifest {
		logger.Infof("Removing manifest")
		rmOutput, err = b.node.DebugNodeWithChroot("podman", "manifest", "rm", b.osImage)
	} else {
		logger.Infof("Removing single image")
		rmOutput, err = b.node.DebugNodeWithChroot("podman", "rmi", b.osImage)
	}
	if err != nil {
		return logAndError("Podman failed removing image %s:\n%s\n%s", b.osImage, rmOutput, err)
	}

	logger.Debugf(rmOutput)
	logger.Infof("OK!\n")
	return nil
}

// digestImage inspects the pushed image and returns its digest reference
func (b *OsImageBuilderInNode) digestImage() (string, error) {
	exutil.By("Digest osImage")
	skopeoCommand := b.proxyEnvPrefix() + " skopeo inspect docker://" + b.osImage + " --authfile " + b.remoteDockerConfig
	logger.Infof("Executing skopeo command: %s", b.sanitizeCommand(skopeoCommand))

	defer b.suppressOCOutput()()
	inspectInfo, _, err := b.node.DebugNodeWithChrootStd("bash", "-c", skopeoCommand)
	if err != nil {
		return "", logAndError("Skopeo failed inspecting image %s:\n%s\n%s", b.osImage, inspectInfo, err)
	}

	logger.Debugf(inspectInfo)

	inspectJSON := JSON(inspectInfo)
	digestedImage := inspectJSON.Get("Name").ToString() + "@" + inspectJSON.Get("Digest").ToString()

	logger.Infof("Image %s was built and pushed properly", b.osImage)
	logger.Infof("Image %s was digested as %s", b.osImage, digestedImage)

	logger.Infof("OK!\n")
	return digestedImage, nil
}

// CreateAndDigestOsImage create the osImage and returns the image digested
func (b *OsImageBuilderInNode) CreateAndDigestOsImage() (string, error) {
	if err := b.prepareEnvironment(); err != nil {
		return "", err
	}
	if err := b.buildImage(); err != nil {
		return "", err
	}
	if err := b.pushImage(); err != nil {
		return "", err
	}
	if err := b.removeImage(); err != nil {
		return "", err
	}
	return b.digestImage()
}
