// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package getter

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

	urlhelper "github.com/hashicorp/go-getter/helper/url"
)

// ErrSymlinkCopy means that a copy of a symlink was encountered on a request with DisableSymlinks enabled.
var ErrSymlinkCopy = errors.New("copying of symlinks has been disabled")

// Client is a client for downloading things.
//
// Top-level functions such as Get are shortcuts for interacting with a client.
// Using a client directly allows more fine-grained control over how downloading
// is done, as well as customizing the protocols supported.
type Client struct {
	// Ctx for cancellation
	Ctx context.Context

	// Src is the source URL to get.
	//
	// Dst is the path to save the downloaded thing as. If Dir is set to
	// true, then this should be a directory. If the directory doesn't exist,
	// it will be created for you.
	//
	// Pwd is the working directory for detection. If this isn't set, some
	// detection may fail. Client will not default pwd to the current
	// working directory for security reasons.
	Src string
	Dst string
	Pwd string

	// Mode is the method of download the client will use. See ClientMode
	// for documentation.
	Mode ClientMode

	// Umask is used to mask file permissions when storing local files or decompressing
	// an archive
	Umask os.FileMode

	// Detectors is the list of detectors that are tried on the source.
	// If this is nil, then the default Detectors will be used.
	Detectors []Detector

	// Decompressors is the map of decompressors supported by this client.
	// If this is nil, then the default value is the Decompressors global.
	Decompressors map[string]Decompressor

	// Getters is the map of protocols supported by this client. If this
	// is nil, then the default Getters variable will be used.
	Getters map[string]Getter

	// Dir, if true, tells the Client it is downloading a directory (versus
	// a single file). This distinction is necessary since filenames and
	// directory names follow the same format so disambiguating is impossible
	// without knowing ahead of time.
	//
	// WARNING: deprecated. If Mode is set, that will take precedence.
	Dir bool

	// ProgressListener allows to track file downloads.
	// By default a no op progress listener is used.
	ProgressListener ProgressTracker

	// Insecure controls whether a client verifies the server's
	// certificate chain and host name. If Insecure is true, crypto/tls
	// accepts any certificate presented by the server and any host name in that
	// certificate. In this mode, TLS is susceptible to machine-in-the-middle
	// attacks unless custom verification is used. This should be used only for
	// testing or in combination with VerifyConnection or VerifyPeerCertificate.
	// This is identical to tls.Config.InsecureSkipVerify.
	Insecure bool

	// Disable symlinks
	DisableSymlinks bool

	Options []ClientOption
}

// umask returns the effective umask for the Client, defaulting to the process umask
func (c *Client) umask() os.FileMode {
	if c == nil {
		return 0
	}
	return c.Umask
}

// mode returns file mode umasked by the Client umask
func (c *Client) mode(mode os.FileMode) os.FileMode {
	m := mode & ^c.umask()
	return m
}

// Get downloads the configured source to the destination.
func (c *Client) Get() error {
	if err := c.Configure(c.Options...); err != nil {
		return err
	}

	// Store this locally since there are cases we swap this
	mode := c.Mode
	if mode == ClientModeInvalid {
		if c.Dir {
			mode = ClientModeDir
		} else {
			mode = ClientModeFile
		}
	}

	src, err := Detect(c.Src, c.Pwd, c.Detectors)
	if err != nil {
		return err
	}

	// Determine if we have a forced protocol, i.e. "git::http://..."
	force, src := getForcedGetter(src)

	// If there is a subdir component, then we download the root separately
	// and then copy over the proper subdir.
	var realDst string
	dst := c.Dst
	src, subDir := SourceDirSubdir(src)
	if subDir != "" {
		// Check if the subdirectory is attempting to traverse updwards, outside of
		// the cloned repository path.
		subDir = filepath.Clean(subDir)
		if containsDotDot(subDir) {
			return fmt.Errorf("subdirectory component contain path traversal out of the repository")
		}
		// Prevent absolute paths, remove a leading path separator from the subdirectory
		if subDir[0] == os.PathSeparator {
			subDir = subDir[1:]
		}

		td, tdcloser, err := mkdirTemp("", "getter")
		if err != nil {
			return err
		}
		defer func() { _ = tdcloser.Close() }()

		realDst = dst
		dst = td
	}

	u, err := urlhelper.Parse(src)
	if err != nil {
		return err
	}
	if force == "" {
		force = u.Scheme
	}

	g, ok := c.Getters[force]
	if !ok {
		return fmt.Errorf(
			"download not supported for scheme '%s'", force)
	}

	// We have magic query parameters that we use to signal different features
	q := u.Query()

	// Determine if we have an archive type
	archiveV := q.Get("archive")
	if archiveV != "" {
		// Delete the paramter since it is a magic parameter we don't
		// want to pass on to the Getter
		q.Del("archive")
		u.RawQuery = q.Encode()

		// If we can parse the value as a bool and it is false, then
		// set the archive to "-" which should never map to a decompressor
		if b, err := strconv.ParseBool(archiveV); err == nil && !b {
			archiveV = "-"
		}
	}
	if archiveV == "" {
		// We don't appear to... but is it part of the filename?
		matchingLen := 0
		for k := range c.Decompressors {
			if strings.HasSuffix(u.Path, "."+k) && len(k) > matchingLen {
				archiveV = k
				matchingLen = len(k)
			}
		}
	}

	// If we have a decompressor, then we need to change the destination
	// to download to a temporary path. We unarchive this into the final,
	// real path.
	var decompressDst string
	var decompressDir bool
	decompressor := c.Decompressors[archiveV]
	if decompressor != nil {
		// Create a temporary directory to store our archive. We delete
		// this at the end of everything.
		td, err := os.MkdirTemp("", "getter")
		if err != nil {
			return fmt.Errorf(
				"Error creating temporary directory for archive: %s", err)
		}
		defer func() { _ = os.RemoveAll(td) }()

		// Swap the download directory to be our temporary path and
		// store the old values.
		decompressDst = dst
		decompressDir = mode != ClientModeFile
		dst = filepath.Join(td, "archive")
		mode = ClientModeFile
	}

	// Determine checksum if we have one
	checksum, err := c.extractChecksum(u)
	if err != nil {
		return fmt.Errorf("invalid checksum: %s", err)
	}

	// Delete the query parameter if we have it.
	q.Del("checksum")
	u.RawQuery = q.Encode()

	if mode == ClientModeAny {
		// Ask the getter which client mode to use
		mode, err = g.ClientMode(u)
		if err != nil {
			return err
		}

		// Destination is the base name of the URL path in "any" mode when
		// a file source is detected.
		if mode == ClientModeFile {
			filename := filepath.Base(u.Path)

			// Determine if we have a custom file name
			if v := q.Get("filename"); v != "" {
				// Delete the query parameter if we have it.
				q.Del("filename")
				u.RawQuery = q.Encode()

				filename = v
			}

			if containsDotDot(filename) {
				return fmt.Errorf("filename query parameter contain path traversal")
			}

			dst = filepath.Join(dst, filename)
		}
	}

	// If we're not downloading a directory, then just download the file
	// and return.
	if mode == ClientModeFile {
		getFile := true
		if checksum != nil {
			if err := checksum.checksum(dst); err == nil {
				// don't get the file if the checksum of dst is correct
				getFile = false
			}
		}
		if getFile {
			err := g.GetFile(dst, u)
			if err != nil {
				return err
			}

			if checksum != nil {
				if err := checksum.checksum(dst); err != nil {
					return err
				}
			}
		}

		if decompressor != nil {
			// We have a decompressor, so decompress the current destination
			// into the final destination with the proper mode.
			err := decompressor.Decompress(decompressDst, dst, decompressDir, c.umask())
			if err != nil {
				return err
			}

			// Swap the information back
			dst = decompressDst
			if decompressDir {
				mode = ClientModeAny
			} else {
				mode = ClientModeFile
			}
		}

		// We check the dir value again because it can be switched back
		// if we were unarchiving. If we're still only Get-ing a file, then
		// we're done.
		if mode == ClientModeFile {
			return nil
		}
	}

	// If we're at this point we're either downloading a directory or we've
	// downloaded and unarchived a directory and we're just checking subdir.
	// In the case we have a decompressor we don't Get because it was Get
	// above.
	if decompressor == nil {
		// If we're getting a directory, then this is an error. You cannot
		// checksum a directory. TODO: test
		if checksum != nil {
			return fmt.Errorf(
				"checksum cannot be specified for directory download")
		}

		// We're downloading a directory, which might require a bit more work
		// if we're specifying a subdir.
		err := g.Get(dst, u)
		if err != nil {
			err = fmt.Errorf("error downloading '%s': %s", RedactURL(u), err)
			return err
		}
	}

	// If we have a subdir, copy that over
	if subDir != "" {
		if err := os.RemoveAll(realDst); err != nil {
			return err
		}
		if err := os.MkdirAll(realDst, c.mode(0755)); err != nil {
			return err
		}

		// Process any globs
		subDir, err := SubdirGlob(dst, subDir)
		if err != nil {
			return err
		}

		return copyDir(c.Ctx, realDst, subDir, false, c.DisableSymlinks, c.umask())
	}

	return nil
}
