// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package core

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"

	"github.com/h2non/filetype"
	"github.com/hashicorp/go-getter"
	"github.com/hashicorp/go-hclog"
	"github.com/hashicorp/vagrant-plugin-sdk/core"
	"github.com/hashicorp/vagrant-plugin-sdk/helper/path"
	"github.com/hashicorp/vagrant-plugin-sdk/localizer"
	"github.com/hashicorp/vagrant-plugin-sdk/proto/vagrant_plugin_sdk"
	"github.com/hashicorp/vagrant/internal/server/proto/vagrant_server"
	"google.golang.org/protobuf/types/known/emptypb"
)

const (
	TempPrefix   = "vagrant-box-add-temp-"
	VagrantSlash = "-VAGRANTSLASH-"
	VagrantColon = "-VAGRANTCOLON-"
)

type BoxCollection struct {
	basis     *Basis
	directory string
	logger    hclog.Logger
}

func NewBoxCollection(basis *Basis, dir string, logger hclog.Logger) (bc *BoxCollection, err error) {
	bc = &BoxCollection{
		basis:     basis,
		directory: dir,
		logger:    logger,
	}
	err = bc.RecoverBoxes()
	return
}

// This adds a new box to the system.
// There are some exceptional cases:
//   - BoxAlreadyExists - The box you're attempting to add already exists.
//   - BoxProviderDoesntMatch - If the given box provider doesn't match the
//     actual box provider in the untarred box.
//   - BoxUnpackageFailure - An invalid tar file.
func (b *BoxCollection) Add(p path.Path, name, version, metadataURL string, force bool, providers ...string) (box core.Box, err error) {
	if _, err := os.Stat(p.String()); err != nil {
		return nil, fmt.Errorf("Could not add box, unable to find path %s", p.String())
	}
	exists, err := b.Find(name, version, providers...)
	if err != nil {
		return nil, err
	}

	if exists != nil && !force {
		return nil, fmt.Errorf("Box already exits, can't add %s v%s", name, version)
	} else {
		if exists != nil {
			// If the box already exists but force is enabled, then delete the box
			exists.Destroy()
		}
	}

	tempDir := filepath.Join(b.basis.dir.TempDir().String(), "box-extractor")
	err = os.MkdirAll(tempDir, 0755)
	if err != nil {
		return nil, err
	} // delete tempdir when finished
	defer os.RemoveAll(tempDir)
	b.logger.Debug("Unpacking box")
	boxFile, err := os.Open(p.String())
	if err != nil {
		return nil, err
	}
	buffer := make([]byte, 512)
	n, err := boxFile.Read(buffer)
	if err != nil && err != io.EOF {
		return nil, err
	}
	io.MultiReader(bytes.NewBuffer(buffer[:n]), boxFile)
	typ, err := filetype.Match(buffer)
	ext := typ.Extension
	if typ.Extension == "gz" {
		ext = "tar.gz"
	}
	decompressor := getter.Decompressors[ext]
	err = decompressor.Decompress(tempDir, p.String(), true, os.ModeDir)
	if err != nil {
		return nil, err
	}

	// Check if the box is a V1 Vagrant box
	if b.isV1Box(tempDir) {
		b.basis.ui.Output(
			localizer.LocalizeMsg("adding_v1_box", map[string]string{"BoxName": name}),
		)
		tempDir, err = b.upgradeV1Box(tempDir)
		if err != nil {
			return nil, err
		}
	}

	newBox, err := NewBox(
		BoxWithBasis(b.basis),
		BoxWithBox(&vagrant_server.Box{
			Name:      name,
			Version:   version,
			Directory: tempDir,
		}),
	)
	if err != nil {
		return nil, err
	}
	provider := newBox.box.Provider

	if providers != nil {
		foundProvider := false
		for _, p := range providers {
			if p == provider {
				foundProvider = true
				break
			}
		}
		if !foundProvider {
			return nil, fmt.Errorf("could not add box %s, provider '%s' does not match the expected providers %s", p.String(), provider, providers)
		}
	}

	destDir := filepath.Join(b.directory, b.generateDirectoryName(name), version, provider)
	b.logger.Debug("Box directory: %s", destDir)
	os.MkdirAll(destDir, 0755)
	// Copy the contents of the tempdir to the final dir
	err = filepath.Walk(tempDir, func(path string, info os.FileInfo, erro error) (err error) {
		destPath, err := validateNewPath(filepath.Join(destDir, info.Name()), destDir)
		if err != nil {
			return err
		}
		if info.IsDir() {
			err = os.MkdirAll(destPath, info.Mode())
			return err
		} else {
			data, err := os.Open(path)
			if err != nil {
				return err
			}
			defer data.Close()
			dest, err := os.Create(destPath)
			if err != nil {
				return err
			}
			defer dest.Close()
			if err != nil {
				return err
			}
			if _, err := io.Copy(dest, data); err != nil {
				return err
			}
		}
		return
	})

	newBox, err = NewBox(
		BoxWithBasis(b.basis),
		BoxWithBox(&vagrant_server.Box{
			Name:        name,
			Version:     version,
			Directory:   destDir,
			Provider:    provider,
			MetadataUrl: metadataURL,
		}),
	)
	newBox.Save()
	return newBox, nil
}

// This returns an array of all the boxes on the system
func (b *BoxCollection) All() (boxes []core.Box, err error) {
	resp, err := b.basis.client.ListBoxes(
		b.basis.ctx,
		&emptypb.Empty{},
	)
	boxes = []core.Box{}
	for _, boxRef := range resp.Boxes {
		box, err := NewBox(
			BoxWithBasis(b.basis),
			BoxWithRef(boxRef, b.basis.ctx),
		)
		if err != nil {
			return nil, err
		}
		boxes = append(boxes, box)
	}

	return
}

// Find a box in the collection with the given name, version and provider.
func (b *BoxCollection) Find(name, version string, providers ...string) (box core.Box, err error) {
	// If no providers are spcified then search for any provider
	if len(providers) == 0 {
		providers = append(providers, "")
	}
	for _, provider := range providers {
		resp, err := b.basis.client.FindBox(
			b.basis.ctx,
			&vagrant_server.FindBoxRequest{
				Box: &vagrant_plugin_sdk.Ref_Box{
					Name: name, Version: version, Provider: provider,
				},
			},
		)
		if err != nil {
			return nil, err
		}
		if resp.Box != nil {
			// Return the first box that is found
			return NewBox(
				BoxWithBasis(b.basis),
				BoxWithBox(resp.Box),
			)
		}
	}
	return
}

// Cleans the directory for a box by removing the folders that are
// empty.
func (b *BoxCollection) Clean(name string) (err error) {
	path := filepath.Join(b.directory, name)
	return os.RemoveAll(path)
}

func (b *BoxCollection) RecoverBoxes() (err error) {
	resp, err := b.basis.client.ListBoxes(
		b.basis.ctx,
		&emptypb.Empty{},
	)
	if err != nil {
		return err
	}
	// Ensure that each box exists
	for _, boxRef := range resp.Boxes {
		box, erro := b.basis.client.GetBox(b.basis.ctx, &vagrant_server.GetBoxRequest{Box: boxRef})
		// If the box directory does not exist, then the box doesn't exist.
		if _, err := os.Stat(box.Box.Directory); err != nil {
			// Remove the box
			_, erro := b.basis.client.DeleteBox(b.basis.ctx, &vagrant_server.DeleteBoxRequest{Box: boxRef})
			if erro != nil {
				return erro
			}
		}
		if erro != nil {
			return erro
		}
	}

	return
}

func (b *BoxCollection) generateDirectoryName(path string) (out string) {
	out = strings.ReplaceAll(path, ":", VagrantColon)
	return strings.ReplaceAll(out, "/", VagrantSlash)
}

func validateNewPath(path string, parentPath string) (newPath string, err error) {
	newPath, err = filepath.Abs(path)
	if err != nil {
		return "", err
	}
	// Ensure that the newPath is within the parentPath
	if !strings.HasPrefix(newPath, parentPath) {
		return "", fmt.Errorf("could not add box outside of box directory %s", parentPath)
	}
	return
}

// Checks is the given directory represents a V1 box
func (b *BoxCollection) isV1Box(dir string) bool {
	// If there is a box.ovf file then there is a good chance that this is a V1 box
	boxOvfPath := filepath.Join(dir, "box.ovf")
	if _, err := os.Stat(boxOvfPath); errors.Is(err, os.ErrNotExist) {
		return false
	}
	// If a metadata.json file exists then this is not a V1 box
	metadataPath := filepath.Join(dir, "metadata.json")
	if _, err := os.Stat(metadataPath); err == nil {
		return false
	}
	return true
}

// Upgrade the V1 box. This will destroy the contents of the old box
// in order to build the new box. The provider for the new box will
// be defaulted to be virtualbox.
func (b *BoxCollection) upgradeV1Box(dir string) (newDir string, err error) {
	newDir, err = ioutil.TempDir(b.basis.dir.TempDir().String(), "box-update")
	if err != nil {
		return "", err
	}

	// Move contents of dir into tempDir
	files, err := filepath.Glob(filepath.Join(dir, "*"))
	if err != nil {
		return "", err
	}
	for _, f := range files {
		rel, err := filepath.Rel(dir, f)
		if err != nil {
			continue
		}

		if s, _ := os.Stat(f); s.IsDir() {
			err = os.MkdirAll(filepath.Join(newDir, rel), os.ModePerm)
			if err != nil {
				return "", err
			}
		} else {
			err = os.Rename(f, filepath.Join(newDir, rel))
			if err != nil {
				return "", err
			}
		}
	}

	// Write the metadata.json file if it does not exist
	metadataFile := filepath.Join(newDir, "metadata.json")
	if _, err := os.Stat(metadataFile); errors.Is(err, os.ErrNotExist) {
		file, _ := json.MarshalIndent(
			map[string]string{"provider": "virtualbox"}, "", " ",
		)
		err = ioutil.WriteFile(metadataFile, file, 0644)
		if err != nil {
			return "", err
		}
	}
	return
}

var _ core.BoxCollection = (*BoxCollection)(nil)
