/*
Copyright 2021 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
    http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package localstorage

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"reflect"
	"strconv"
	"strings"

	securejoin "github.com/cyphar/filepath-securejoin"
	"github.com/google/uuid"

	"github.com/dapr/components-contrib/bindings"
	"github.com/dapr/components-contrib/metadata"
	"github.com/dapr/kit/logger"
)

const (
	fileNameMetadataKey = "fileName"
)

// List of root paths that are disallowed
var disallowedRootPaths = []string{
	filepath.Clean("/proc"),
	filepath.Clean("/sys"),
	filepath.Clean("/boot"),
	// See: https://github.com/dapr/components-contrib/issues/2444
	filepath.Clean("/var/run/secrets"),
}

// LocalStorage allows saving files to disk.
type LocalStorage struct {
	metadata *Metadata
	logger   logger.Logger
}

// Metadata defines the metadata.
type Metadata struct {
	RootPath string `json:"rootPath"`
}

type createResponse struct {
	FileName string `json:"fileName"`
}

// NewLocalStorage returns a new LocalStorage instance.
func NewLocalStorage(logger logger.Logger) bindings.OutputBinding {
	return &LocalStorage{logger: logger}
}

// Init performs metadata parsing.
func (ls *LocalStorage) Init(_ context.Context, metadata bindings.Metadata) error {
	m, err := ls.parseMetadata(metadata)
	if err != nil {
		return fmt.Errorf("failed to parse metadata: %w", err)
	}
	ls.metadata = m

	err = os.MkdirAll(ls.metadata.RootPath, 0o777)
	if err != nil {
		return fmt.Errorf("unable to create directory specified by 'rootPath' %s: %w", ls.metadata.RootPath, err)
	}

	return nil
}

func (ls *LocalStorage) parseMetadata(meta bindings.Metadata) (*Metadata, error) {
	var m Metadata
	err := metadata.DecodeMetadata(meta.Properties, &m)
	if err != nil {
		return nil, err
	}

	m.RootPath, err = validateRootPath(m.RootPath)
	if err != nil {
		return nil, err
	}

	return &m, nil
}

func validateRootPath(rootPath string) (string, error) {
	var err error

	if rootPath == "" {
		return "", errors.New("property rootPath must not be empty")
	}

	// If the root path is relative, resolve it as absolute
	rootPath = filepath.Clean(rootPath)
	if !filepath.IsAbs(rootPath) {
		var cwd string
		cwd, err = os.Getwd()
		if err != nil {
			return "", fmt.Errorf("failed to obtain current working directory: %w", err)
		}
		rootPath = filepath.Join(cwd, rootPath)
	}

	// Resolve symlinks
	// If rootPath doesn't (yet) exist, we need to check its parent folders
	checkPath := rootPath
	sep := string(os.PathSeparator)
	// Per docs of filepath.Dir and filepath.Clean (invoked earlier): "The returned path does not end in a separator unless it is the root directory."
	var fi os.FileInfo
	for !strings.HasSuffix(checkPath, sep) {
		fi, err = os.Stat(checkPath)
		if err == nil {
			if !fi.IsDir() {
				return "", errors.New("property rootPath represents a file and not a directory")
			}
			break
		} else if os.IsNotExist(err) {
			checkPath = filepath.Dir(checkPath)
			continue
		} else {
			return "", fmt.Errorf("error getting stat for path %s: %w", checkPath, err)
		}
	}
	resolvedCheckPath, err := filepath.EvalSymlinks(checkPath)
	if err != nil {
		return "", fmt.Errorf("failed to evaluate symlinks in rootPath: %w", err)
	}
	rootPath = filepath.Join(resolvedCheckPath, rootPath[len(checkPath):])

	// Check if the path is in a disallowed location
	for _, p := range disallowedRootPaths {
		if rootPath == p || strings.HasPrefix(rootPath, p+sep) {
			return "", errors.New("property rootPath points to a disallowed location")
		}
	}

	return rootPath, nil
}

// Operations enumerates supported binding operations.
func (ls *LocalStorage) Operations() []bindings.OperationKind {
	return []bindings.OperationKind{
		bindings.CreateOperation,
		bindings.GetOperation,
		bindings.ListOperation,
		bindings.DeleteOperation,
	}
}

func (ls *LocalStorage) create(filename string, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
	d, err := strconv.Unquote(string(req.Data))
	if err == nil {
		req.Data = []byte(d)
	}

	decoded, err := base64.StdEncoding.DecodeString(string(req.Data))
	if err == nil {
		req.Data = decoded
	}

	absPath, relPath, err := getSecureAbsRelPath(ls.metadata.RootPath, filename)
	if err != nil {
		return nil, fmt.Errorf("error getting absolute path for file %s: %w", filename, err)
	}

	dir := filepath.Dir(absPath)
	err = os.MkdirAll(dir, 0o777)
	if err != nil {
		return nil, fmt.Errorf("error creating directory %s: %w", dir, err)
	}

	f, err := os.Create(absPath)
	if err != nil {
		return nil, fmt.Errorf("error creating file %s: %w", absPath, err)
	}
	defer f.Close()

	numBytes, err := f.Write(req.Data)
	if err != nil {
		return nil, fmt.Errorf("error writing to file %s: %w", absPath, err)
	}

	ls.logger.Debugf("wrote file: %s. numBytes: %d", absPath, numBytes)

	resp := createResponse{
		FileName: relPath,
	}

	b, err := json.Marshal(resp)
	if err != nil {
		return nil, fmt.Errorf("error encoding response as JSON: %w", err)
	}

	return &bindings.InvokeResponse{
		Data: b,
	}, nil
}

func (ls *LocalStorage) get(filename string, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
	absPath, _, err := getSecureAbsRelPath(ls.metadata.RootPath, filename)
	if err != nil {
		return nil, fmt.Errorf("error getting absolute path for file %s: %w", filename, err)
	}

	f, err := os.Open(absPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("file not found: %s", absPath)
		}
		return nil, fmt.Errorf("error opening path %s: %w", absPath, err)
	}
	defer f.Close()

	b, err := io.ReadAll(f)
	if err != nil {
		return nil, fmt.Errorf("error reading file %s: %w", absPath, err)
	}

	ls.logger.Debugf("read file: %s. size: %d bytes", absPath, len(b))

	return &bindings.InvokeResponse{
		Data: b,
	}, nil
}

func (ls *LocalStorage) delete(filename string, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
	absPath, _, err := getSecureAbsRelPath(ls.metadata.RootPath, filename)
	if err != nil {
		return nil, fmt.Errorf("error getting absolute path for file %s: %w", filename, err)
	}

	err = os.Remove(absPath)
	if err != nil {
		return nil, fmt.Errorf("error deleting file %s: %w", absPath, err)
	}

	ls.logger.Debugf("removed file: %s", absPath)

	return nil, nil
}

func (ls *LocalStorage) list(filename string, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
	absPath, _, err := getSecureAbsRelPath(ls.metadata.RootPath, filename)
	if err != nil {
		return nil, fmt.Errorf("error getting absolute path for file %s: %w", filename, err)
	}

	fi, err := os.Stat(absPath)
	if err != nil {
		return nil, fmt.Errorf("error getting stats for path %s: %w", absPath, err)
	}

	if !fi.IsDir() {
		return nil, fmt.Errorf("unable to list files as the file specified is not a directory: %s", absPath)
	}

	files, err := walkPath(absPath)
	if err != nil {
		return nil, fmt.Errorf("error listing files in the directory %s: %w", absPath, err)
	}

	b, err := json.Marshal(files)
	if err != nil {
		return nil, fmt.Errorf("error encoding response as JSON: %w", err)
	}

	return &bindings.InvokeResponse{
		Data: b,
	}, nil
}

func getSecureAbsRelPath(rootPath string, filename string) (absPath string, relPath string, err error) {
	absPath, err = securejoin.SecureJoin(rootPath, filename)
	if err != nil {
		return
	}
	relPath, err = filepath.Rel(rootPath, absPath)
	if err != nil {
		return
	}

	return
}

func walkPath(root string) ([]string, error) {
	var files []string
	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if !info.IsDir() {
			files = append(files, path)
		}

		return nil
	})

	return files, err
}

// Invoke is called for output bindings.
func (ls *LocalStorage) Invoke(_ context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
	filename := req.Metadata[fileNameMetadataKey]
	if filename == "" && req.Operation == bindings.CreateOperation {
		u, err := uuid.NewRandom()
		if err != nil {
			return nil, fmt.Errorf("failed to generate UUID: %w", err)
		}
		filename = u.String()
	}

	switch req.Operation {
	case bindings.CreateOperation:
		return ls.create(filename, req)
	case bindings.GetOperation:
		return ls.get(filename, req)
	case bindings.DeleteOperation:
		return ls.delete(filename, req)
	case bindings.ListOperation:
		return ls.list(filename, req)
	default:
		return nil, fmt.Errorf("unsupported operation %s", req.Operation)
	}
}

// GetComponentMetadata returns the metadata of the component.
func (ls *LocalStorage) GetComponentMetadata() (metadataInfo metadata.MetadataMap) {
	metadataStruct := Metadata{}
	metadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo, metadata.BindingType)
	return
}
