// Copyright (c) 2020 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.

package archive

import (
	"archive/tar"
	"context"
	"io"
	"os"
	"os/exec"
	"path"
	"sort"
	"strings"
	"syscall"
	"time"

	"github.com/opentracing/opentracing-go"
	"golang.org/x/sys/unix"
	"golang.org/x/xerrors"

	"github.com/gitpod-io/gitpod/common-go/log"
	"github.com/gitpod-io/gitpod/common-go/tracing"
)

// TarConfig configures tarbal creation/extraction
type TarConfig struct {
	UIDMaps []IDMapping
	GIDMaps []IDMapping
}

// BuildTarbalOption configures the tarbal creation
type TarOption func(o *TarConfig)

// IDMapping maps user or group IDs
type IDMapping struct {
	ContainerID int
	HostID      int
	Size        int
}

// WithUIDMapping reverses the given user ID mapping during archive creation
func WithUIDMapping(mappings []IDMapping) TarOption {
	return func(o *TarConfig) {
		o.UIDMaps = mappings
	}
}

// WithGIDMapping reverses the given user ID mapping during archive creation
func WithGIDMapping(mappings []IDMapping) TarOption {
	return func(o *TarConfig) {
		o.GIDMaps = mappings
	}
}

// ExtractTarbal extracts an OCI compatible tar file src to the folder dst, expecting the overlay whiteout format
func ExtractTarbal(ctx context.Context, src io.Reader, dst string, opts ...TarOption) (err error) {
	type Info struct {
		UID, GID  int
		IsSymlink bool
		Xattrs    map[string]string
	}

	//nolint:staticcheck,ineffassign
	span, ctx := opentracing.StartSpanFromContext(ctx, "extractTarbal")
	span.LogKV("dst", dst)
	defer tracing.FinishSpan(span, &err)

	var cfg TarConfig
	start := time.Now()
	for _, opt := range opts {
		opt(&cfg)
	}

	pipeReader, pipeWriter := io.Pipe()
	teeReader := io.TeeReader(src, pipeWriter)

	tarReader := tar.NewReader(pipeReader)

	finished := make(chan bool)
	m := make(map[string]Info)

	unpackSpan := opentracing.StartSpan("unpackTarbal", opentracing.ChildOf(span.Context()))
	go func() {
		defer close(finished)
		for {
			hdr, err := tarReader.Next()
			if err == io.EOF {
				finished <- true
				return
			}

			if err != nil {
				log.WithError(err).Error("error reading tar")
				return
			}

			m[hdr.Name] = Info{
				UID:       hdr.Uid,
				GID:       hdr.Gid,
				IsSymlink: (hdr.Linkname != ""),
				//nolint:staticcheck
				Xattrs: hdr.Xattrs,
			}
		}
	}()

	// Be explicit about the tar flags. We want to restore the exact content without changes
	tarcmd := exec.Command(
		"tar",
		"--extract",
		"--preserve-permissions",
	)
	tarcmd.Dir = dst
	tarcmd.Stdin = teeReader

	var msg []byte
	msg, err = tarcmd.CombinedOutput()
	if err != nil {
		return xerrors.Errorf("tar %s: %s", dst, err.Error()+";"+string(msg))
	}

	log.WithField("log", string(msg)).Debug("decompressing tar stream log")

	<-finished
	tracing.FinishSpan(unpackSpan, &err)

	chownSpan := opentracing.StartSpan("chown", opentracing.ChildOf(span.Context()))
	// lets create a sorted list of pathes and chown depth first.
	paths := make([]string, 0, len(m))
	for path := range m {
		paths = append(paths, path)
	}
	sort.Sort(sort.Reverse(sort.StringSlice(paths)))

	// We need to remap the UID and GID between the host and the container to avoid permission issues.
	for _, p := range paths {
		v := m[p]

		if v.IsSymlink {
			continue
		}

		uid := toHostID(v.UID, cfg.UIDMaps)
		gid := toHostID(v.GID, cfg.GIDMaps)

		err = remapFile(path.Join(dst, p), uid, gid, v.Xattrs)
		if err != nil {
			log.WithError(err).WithField("uid", uid).WithField("gid", gid).WithField("path", p).Debug("cannot chown")
		}
	}
	tracing.FinishSpan(chownSpan, &err)

	log.WithField("duration", time.Since(start).Milliseconds()).Debug("untar complete")
	return nil
}

func toHostID(containerID int, idMap []IDMapping) int {
	for _, m := range idMap {
		if (containerID >= m.ContainerID) && (containerID <= (m.ContainerID + m.Size - 1)) {
			hostID := m.HostID + (containerID - m.ContainerID)
			return hostID
		}
	}
	return containerID
}

// remapFile changes the UID and GID of a file preserving existing file mode bits.
func remapFile(name string, uid, gid int, xattrs map[string]string) error {
	// current info of the file before any change
	fileInfo, err := os.Stat(name)
	if err != nil {
		return err
	}

	// nothing to do for symlinks
	if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
		return nil
	}

	// changing UID or GID can break files with suid/sgid
	err = os.Lchown(name, uid, gid)
	if err != nil {
		return err
	}

	// restore original permissions
	err = os.Chmod(name, fileInfo.Mode())
	if err != nil {
		return err
	}

	for key, value := range xattrs {
		// do not set trusted attributes
		if strings.HasPrefix(key, "trusted.") {
			continue
		}

		if strings.HasPrefix(key, "user.") {
			// This is a marker to match inodes, such as when an upper layer copies a lower layer file in overlayfs.
			// However, when restoring a content, the container in the workspace is not always running, so there is no problem ignoring the failure.
			if strings.HasSuffix(key, ".overlay.impure") || strings.HasSuffix(key, ".overlay.origin") {
				continue
			}
		}

		if err := unix.Lsetxattr(name, key, []byte(value), 0); err != nil {
			if err == syscall.ENOTSUP || err == syscall.EPERM {
				continue
			}

			log.WithField("name", key).WithField("value", value).WithField("file", name).WithError(err).Warn("restoring extended attributes")
		}
	}

	// restore file times
	fileTime := fileInfo.Sys().(*syscall.Stat_t)
	return os.Chtimes(name, timespecToTime(fileTime.Atim), timespecToTime(fileTime.Mtim))
}

func timespecToTime(ts syscall.Timespec) time.Time {
	return time.Unix(int64(ts.Sec), int64(ts.Nsec))
}
