// Source: https://github.com/distribution/distribution

// Copyright 2014 https://github.com/distribution/distribution 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 driver

import (
	"context"
	"errors"
	"path/filepath"
	"sort"
	"strings"

	"github.com/rs/zerolog/log"
)

// ErrSkipDir is used as a return value from onFileFunc to indicate that
// the directory named in the call is to be skipped. It is not returned
// as an error by any function.
var ErrSkipDir = errors.New("skip this directory")

// ErrFilledBuffer is used as a return value from onFileFunc to indicate
// that the requested number of entries has been reached and the walk can
// stop.
var ErrFilledBuffer = errors.New("we have enough entries")

// WalkFn is called once per file by Walk.
type WalkFn func(fileInfo FileInfo) error

// WalkFallback traverses a filesystem defined within driver, starting
// from the given path, calling f on each file. It uses the List method and Stat to drive itself.
// If the returned error from the WalkFn is ErrSkipDir the directory will not be entered and Walk
// will continue the traversal. If the returned error from the WalkFn is ErrFilledBuffer, the walk
// stops.
func WalkFallback(
	ctx context.Context,
	driver StorageDriver,
	from string,
	f WalkFn,
	options ...func(*WalkOptions),
) error {
	walkOptions := &WalkOptions{}
	for _, o := range options {
		o(walkOptions)
	}

	startAfterHint := walkOptions.StartAfterHint
	// Ensure that we are checking the hint is contained within from by adding a "/".
	// Add to both in case the hint and form are the same, which would still count.
	rel, err := filepath.Rel(from, startAfterHint)
	if err != nil || strings.HasPrefix(rel, "..") {
		// The startAfterHint is outside from, so check if we even need to walk anything
		// Replace any path separators with \x00 so that the sort works in a depth-first way
		if strings.ReplaceAll(startAfterHint, "/", "\x00") < strings.ReplaceAll(from, "/", "\x00") {
			_, err := doWalkFallback(ctx, driver, from, "", f)
			return err
		}
		return nil
	}
	// The startAfterHint is within from.
	// Walk up the tree until we hit from - we know it is contained.
	// Ensure startAfterHint is never deeper than a child of the base
	// directory so that doWalkFallback doesn't have to worry about
	// depth-first comparisons
	base := startAfterHint
	for strings.HasPrefix(base, from) {
		_, err = doWalkFallback(ctx, driver, base, startAfterHint, f)
		if !(errors.As(err, &PathNotFoundError{}) || err == nil) {
			return err
		}
		if base == from {
			break
		}
		startAfterHint = base
		base, _ = filepath.Split(startAfterHint)
		if len(base) > 1 {
			base = strings.TrimSuffix(base, "/")
		}
	}
	return nil
}

// doWalkFallback performs a depth first walk using recursion.
// from is the directory that this iteration of the function should walk.
// startAfterHint is the child within from to start the walk after.
// It should only ever be a child of from, or the empty string.
func doWalkFallback(
	ctx context.Context,
	driver StorageDriver,
	from string,
	startAfterHint string,
	f WalkFn,
) (bool, error) {
	children, err := driver.List(ctx, from)
	if err != nil {
		return false, err
	}
	sort.Strings(children)
	for _, child := range children {
		// The startAfterHint has been sanitised in WalkFallback and will either be
		// empty, or be suitable for an <= check for this _from_.
		if child <= startAfterHint {
			continue
		}

		fileInfo, err := driver.Stat(ctx, child)
		if err != nil {
			if errors.As(err, &PathNotFoundError{}) {
				// repository was removed in between listing and enumeration. Ignore it.
				log.Ctx(ctx).Info().Interface("path", child).Msg("ignoring deleted path")
			} else {
				return false, err
			}
		}
		err = f(fileInfo)
		switch {
		case err == nil && fileInfo.IsDir():
			if ok, err := doWalkFallback(ctx, driver, child, startAfterHint, f); err != nil || !ok {
				return ok, err
			}
		case errors.Is(err, ErrFilledBuffer):
			return false, nil // no error but stop iteration
		case err != nil:
			return false, err
		}
	}
	return true, nil
}
