package local

import (
	"context"
	"path"

	// Git still uses sha1 for the most part: https://git-scm.com/docs/hash-function-transition
	//nolint:gosec
	"crypto/sha1"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/validation/field"

	provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
	"github.com/grafana/grafana/apps/provisioning/pkg/repository"
	"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
)

type LocalFolderResolver struct {
	PermittedPrefixes []string
	HomePath          string
}

type InvalidLocalFolderError struct {
	Path           string
	AdditionalInfo string
}

var (
	_ error               = (*InvalidLocalFolderError)(nil)
	_ apierrors.APIStatus = (*InvalidLocalFolderError)(nil)
)

func (e *InvalidLocalFolderError) Error() string {
	return fmt.Sprintf("the path given ('%s') is invalid for a local repository (%s)", e.Path, e.AdditionalInfo)
}

func (e *InvalidLocalFolderError) Status() metav1.Status {
	return metav1.Status{
		Status:  metav1.StatusFailure,
		Code:    http.StatusBadRequest,
		Reason:  metav1.StatusReasonBadRequest,
		Message: e.Error(),
	}
}

func (r *LocalFolderResolver) LocalPath(p string) (string, error) {
	if len(r.PermittedPrefixes) == 0 {
		return "", &InvalidLocalFolderError{p, "no permitted prefixes were configured"}
	}

	originalPath := p
	if !path.IsAbs(p) {
		p = safepath.Join(r.HomePath, p)
	} else {
		p = safepath.Clean(p)
	}

	for _, permitted := range r.PermittedPrefixes {
		if safepath.InDir(p, permitted) {
			return p, nil
		}
	}
	return "", &InvalidLocalFolderError{originalPath, "the path matches no permitted prefix"}
}

var (
	_ repository.Repository = (*localRepository)(nil)
	_ repository.Writer     = (*localRepository)(nil)
	_ repository.Reader     = (*localRepository)(nil)
)

type localRepository struct {
	config   *provisioning.Repository
	resolver *LocalFolderResolver

	// validated path that can be read if not empty
	path string
}

func NewRepository(config *provisioning.Repository, resolver *LocalFolderResolver) *localRepository {
	r := &localRepository{
		config:   config,
		resolver: resolver,
	}

	if config.Spec.Local != nil {
		r.path, _ = resolver.LocalPath(config.Spec.Local.Path)
		if r.path != "" && !safepath.IsDir(r.path) {
			r.path += "/"
		}

		for i, permitted := range r.resolver.PermittedPrefixes {
			r.resolver.PermittedPrefixes[i] = safepath.Clean(permitted)
		}
	}

	return r
}

func (r *localRepository) Config() *provisioning.Repository {
	return r.config
}

// Test implements provisioning.Repository.
// NOTE: Validate has been called (and passed) before this function should be called
func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
	path := field.NewPath("spec", "local", "path")
	if r.config.Spec.Local.Path == "" {
		return repository.FromFieldError(field.Required(path, "no path is configured")), nil
	}

	_, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
	if err != nil {
		return repository.FromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
	}

	_, err = os.Stat(r.path)
	if errors.Is(err, os.ErrNotExist) {
		return repository.FromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
	}

	return &provisioning.TestResults{
		Code:    http.StatusOK,
		Success: true,
	}, nil
}

// Test implements provisioning.Repository.
func (r *localRepository) validateRequest(ref string) error {
	if ref != "" {
		return apierrors.NewBadRequest("local repository does not support ref")
	}

	return nil
}

// ReadResource implements provisioning.Repository.
func (r *localRepository) Read(ctx context.Context, filePath string, ref string) (*repository.FileInfo, error) {
	if err := r.validateRequest(ref); err != nil {
		return nil, err
	}

	actualPath := safepath.Join(r.path, filePath)
	info, err := os.Stat(actualPath)
	if errors.Is(err, os.ErrNotExist) {
		return nil, repository.ErrFileNotFound
	} else if err != nil {
		return nil, fmt.Errorf("stat file: %w", err)
	}

	if info.IsDir() {
		return &repository.FileInfo{
			Path: filePath,
			Modified: &metav1.Time{
				Time: info.ModTime(),
			},
		}, nil
	}

	//nolint:gosec
	data, err := os.ReadFile(actualPath)
	if err != nil {
		return nil, fmt.Errorf("read file: %w", err)
	}

	hash, _, err := r.calculateFileHash(actualPath)
	if err != nil {
		return nil, fmt.Errorf("calculate hash of file: %w", err)
	}

	return &repository.FileInfo{
		Path: filePath,
		Data: data,
		Hash: hash,
		Modified: &metav1.Time{
			Time: info.ModTime(),
		},
	}, nil
}

// ReadResource implements provisioning.Repository.
func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
	if err := r.validateRequest(ref); err != nil {
		return nil, err
	}

	// Return an empty list when folder does not exist
	_, err := os.Stat(r.path)
	if errors.Is(err, fs.ErrNotExist) {
		return []repository.FileTreeEntry{}, nil
	}

	rootlen := len(r.path)
	entries := make([]repository.FileTreeEntry, 0, 100)
	err = filepath.Walk(r.path, func(path string, info fs.FileInfo, err error) error {
		if err != nil {
			return err
		}
		entry := repository.FileTreeEntry{
			Path: strings.TrimLeft(path[rootlen:], "/"),
			Size: info.Size(),
		}
		if entry.Path == "" {
			return nil // skip the root file
		}

		if !info.IsDir() {
			entry.Blob = true
			entry.Hash, _, err = r.calculateFileHash(path)
			if err != nil {
				return fmt.Errorf("read and calculate hash of path %s: %w", path, err)
			}
		} else if !strings.HasSuffix(entry.Path, "/") {
			// ensure trailing slash for directories
			entry.Path = entry.Path + "/"
		}
		entries = append(entries, entry)
		return err
	})

	return entries, err
}

func (r *localRepository) calculateFileHash(path string) (string, int64, error) {
	// Treats https://securego.io/docs/rules/g304.html
	if !safepath.InDir(path, r.path) {
		return "", 0, repository.ErrFileNotFound
	}

	// We've already made sure the path is safe, so we'll ignore the gosec lint.
	//nolint:gosec
	file, err := os.OpenFile(path, os.O_RDONLY, 0)
	if err != nil {
		return "", 0, fmt.Errorf("open file: %w", err)
	}

	// TODO: Define what hashing algorithm we want to use for the entire repository. Maybe a config option?
	hasher := sha1.New()
	// TODO: context-aware io.Copy? Is that even possible with a reasonable impl?
	size, err := io.Copy(hasher, file)
	if err != nil {
		return "", 0, fmt.Errorf("copy file: %w", err)
	}
	// NOTE: EncodeToString (& hex.Encode for that matter) return lower-case hex.
	return hex.EncodeToString(hasher.Sum(nil)), size, nil
}

func (r *localRepository) Create(ctx context.Context, filepath string, ref string, data []byte, comment string) error {
	if err := r.validateRequest(ref); err != nil {
		return err
	}

	fpath := safepath.Join(r.path, filepath)
	_, err := os.Stat(fpath)
	if !errors.Is(err, os.ErrNotExist) {
		if err != nil {
			return apierrors.NewInternalError(fmt.Errorf("failed to check if file exists: %w", err))
		}
		return apierrors.NewAlreadyExists(schema.GroupResource{}, filepath)
	}

	if safepath.IsDir(fpath) {
		if data != nil {
			return apierrors.NewBadRequest("data cannot be provided for a directory")
		}

		if err := os.MkdirAll(fpath, 0700); err != nil {
			return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
		}

		return nil
	}

	if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
		return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
	}

	return os.WriteFile(fpath, data, 0600)
}

func (r *localRepository) Update(ctx context.Context, path string, ref string, data []byte, comment string) error {
	if err := r.validateRequest(ref); err != nil {
		return err
	}

	path = safepath.Join(r.path, path)
	if safepath.IsDir(path) {
		return apierrors.NewBadRequest("cannot update a directory")
	}

	f, err := os.Stat(path)
	if err != nil && errors.Is(err, os.ErrNotExist) {
		return repository.ErrFileNotFound
	}
	if f.IsDir() {
		return apierrors.NewBadRequest("path exists but it is a directory")
	}

	return os.WriteFile(path, data, 0600)
}

func (r *localRepository) Write(ctx context.Context, fpath, ref string, data []byte, comment string) error {
	if err := r.validateRequest(ref); err != nil {
		return err
	}

	fpath = safepath.Join(r.path, fpath)
	if safepath.IsDir(fpath) {
		return os.MkdirAll(fpath, 0700)
	}

	if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
		return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
	}

	return os.WriteFile(fpath, data, 0600)
}

func (r *localRepository) Delete(ctx context.Context, path string, ref string, comment string) error {
	if err := r.validateRequest(ref); err != nil {
		return err
	}

	fullPath := safepath.Join(r.path, path)

	if safepath.IsDir(path) {
		// if it is a folder, delete all of its contents
		return os.RemoveAll(fullPath)
	}

	return os.Remove(fullPath)
}

func (r *localRepository) Move(ctx context.Context, oldPath, newPath, ref, comment string) error {
	if err := r.validateRequest(ref); err != nil {
		return err
	}

	oldFullPath := safepath.Join(r.path, oldPath)
	newFullPath := safepath.Join(r.path, newPath)

	// Check if source exists
	sourceInfo, err := os.Stat(oldFullPath)
	if errors.Is(err, os.ErrNotExist) {
		return repository.ErrFileNotFound
	} else if err != nil {
		return fmt.Errorf("check source: %w", err)
	}

	// Check if destination already exists
	if _, err := os.Stat(newFullPath); !errors.Is(err, os.ErrNotExist) {
		if err != nil {
			return fmt.Errorf("check destination: %w", err)
		}
		return repository.ErrFileAlreadyExists
	}

	// Validate move types
	sourceIsDir := sourceInfo.IsDir()
	targetIsDir := safepath.IsDir(newPath)

	if sourceIsDir != targetIsDir {
		return apierrors.NewBadRequest("cannot move between file and directory types")
	}

	// Create destination directory if needed
	if !sourceIsDir {
		// For file moves, create the directory containing the file
		destParent := path.Dir(newFullPath)
		if err := os.MkdirAll(destParent, 0700); err != nil {
			return fmt.Errorf("create destination directory: %w", err)
		}
	} else {
		// For directory moves, create the parent directory of the destination
		// but not the destination directory itself (os.Rename will create it)
		// We need to be careful with trailing slashes in directory paths
		cleanNewPath := strings.TrimSuffix(newFullPath, "/")
		destParent := path.Dir(cleanNewPath)
		if destParent != "." && destParent != "/" && destParent != r.path {
			if err := os.MkdirAll(destParent, 0700); err != nil {
				return fmt.Errorf("create destination parent directory: %w", err)
			}
		}
	}

	// Move the file or directory
	if err := os.Rename(oldFullPath, newFullPath); err != nil {
		return fmt.Errorf("move: %w", err)
	}

	return nil
}
