// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package doctor

import (
	"context"
	"errors"
	"io/fs"
	"strings"

	"code.gitea.io/gitea/models/git"
	"code.gitea.io/gitea/models/packages"
	"code.gitea.io/gitea/models/repo"
	"code.gitea.io/gitea/models/user"
	"code.gitea.io/gitea/modules/base"
	"code.gitea.io/gitea/modules/log"
	packages_module "code.gitea.io/gitea/modules/packages"
	"code.gitea.io/gitea/modules/setting"
	"code.gitea.io/gitea/modules/storage"
	"code.gitea.io/gitea/modules/util"
)

type commonStorageCheckOptions struct {
	storer     storage.ObjectStorage
	isOrphaned func(path string, obj storage.Object, stat fs.FileInfo) (bool, error)
	name       string
}

func commonCheckStorage(ctx context.Context, logger log.Logger, autofix bool, opts *commonStorageCheckOptions) error {
	totalCount, orphanedCount := 0, 0
	totalSize, orphanedSize := int64(0), int64(0)

	var pathsToDelete []string
	if err := opts.storer.IterateObjects("", func(p string, obj storage.Object) error {
		defer obj.Close()

		totalCount++
		stat, err := obj.Stat()
		if err != nil {
			return err
		}
		totalSize += stat.Size()

		orphaned, err := opts.isOrphaned(p, obj, stat)
		if err != nil {
			return err
		}
		if orphaned {
			orphanedCount++
			orphanedSize += stat.Size()
			if autofix {
				pathsToDelete = append(pathsToDelete, p)
			}
		}
		return nil
	}); err != nil {
		logger.Error("Error whilst iterating %s storage: %v", opts.name, err)
		return err
	}

	if orphanedCount > 0 {
		if autofix {
			var deletedNum int
			for _, p := range pathsToDelete {
				if err := opts.storer.Delete(p); err != nil {
					log.Error("Error whilst deleting %s from %s storage: %v", p, opts.name, err)
				} else {
					deletedNum++
				}
			}
			logger.Info("Deleted %d/%d orphaned %s(s)", deletedNum, orphanedCount, opts.name)
		} else {
			logger.Warn("Found %d/%d (%s/%s) orphaned %s(s)", orphanedCount, totalCount, base.FileSize(orphanedSize), base.FileSize(totalSize), opts.name)
		}
	} else {
		logger.Info("Found %d (%s) %s(s)", totalCount, base.FileSize(totalSize), opts.name)
	}
	return nil
}

type checkStorageOptions struct {
	All          bool
	Attachments  bool
	LFS          bool
	Avatars      bool
	RepoAvatars  bool
	RepoArchives bool
	Packages     bool
}

// checkStorage will return a doctor check function to check the requested storage types for "orphaned" stored object/files and optionally delete them
func checkStorage(opts *checkStorageOptions) func(ctx context.Context, logger log.Logger, autofix bool) error {
	return func(ctx context.Context, logger log.Logger, autofix bool) error {
		if err := storage.Init(); err != nil {
			logger.Error("storage.Init failed: %v", err)
			return err
		}

		if opts.Attachments || opts.All {
			if err := commonCheckStorage(ctx, logger, autofix,
				&commonStorageCheckOptions{
					storer: storage.Attachments,
					isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
						exists, err := repo.ExistAttachmentsByUUID(ctx, stat.Name())
						return !exists, err
					},
					name: "attachment",
				}); err != nil {
				return err
			}
		}

		if opts.LFS || opts.All {
			if !setting.LFS.StartServer {
				logger.Info("LFS isn't enabled (skipped)")
				return nil
			}
			if err := commonCheckStorage(ctx, logger, autofix,
				&commonStorageCheckOptions{
					storer: storage.LFS,
					isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
						// The oid of an LFS stored object is the name but with all the path.Separators removed
						oid := strings.ReplaceAll(path, "/", "")
						exists, err := git.ExistsLFSObject(ctx, oid)
						return !exists, err
					},
					name: "LFS file",
				}); err != nil {
				return err
			}
		}

		if opts.Avatars || opts.All {
			if err := commonCheckStorage(ctx, logger, autofix,
				&commonStorageCheckOptions{
					storer: storage.Avatars,
					isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
						exists, err := user.ExistsWithAvatarAtStoragePath(ctx, path)
						return !exists, err
					},
					name: "avatar",
				}); err != nil {
				return err
			}
		}

		if opts.RepoAvatars || opts.All {
			if err := commonCheckStorage(ctx, logger, autofix,
				&commonStorageCheckOptions{
					storer: storage.RepoAvatars,
					isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
						exists, err := repo.ExistsWithAvatarAtStoragePath(ctx, path)
						return !exists, err
					},
					name: "repo avatar",
				}); err != nil {
				return err
			}
		}

		if opts.RepoArchives || opts.All {
			if err := commonCheckStorage(ctx, logger, autofix,
				&commonStorageCheckOptions{
					storer: storage.RepoArchives,
					isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
						exists, err := repo.ExistsRepoArchiverWithStoragePath(ctx, path)
						if err == nil || errors.Is(err, util.ErrInvalidArgument) {
							// invalid arguments mean that the object is not a valid repo archiver and it should be removed
							return !exists, nil
						}
						return !exists, err
					},
					name: "repo archive",
				}); err != nil {
				return err
			}
		}

		if opts.Packages || opts.All {
			if !setting.Packages.Enabled {
				logger.Info("Packages isn't enabled (skipped)")
				return nil
			}
			if err := commonCheckStorage(ctx, logger, autofix,
				&commonStorageCheckOptions{
					storer: storage.Packages,
					isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
						key, err := packages_module.RelativePathToKey(path)
						if err != nil {
							// If there is an error here then the relative path does not match a valid package
							// Therefore it is orphaned by default
							return true, nil
						}

						exists, err := packages.ExistPackageBlobWithSHA(ctx, string(key))

						return !exists, err
					},
					name: "package blob",
				}); err != nil {
				return err
			}
		}

		return nil
	}
}

func init() {
	Register(&Check{
		Title:                      "Check if there are orphaned storage files",
		Name:                       "storages",
		IsDefault:                  false,
		Run:                        checkStorage(&checkStorageOptions{All: true}),
		AbortIfFailed:              false,
		SkipDatabaseInitialization: false,
		Priority:                   1,
	})

	Register(&Check{
		Title:                      "Check if there are orphaned attachments in storage",
		Name:                       "storage-attachments",
		IsDefault:                  false,
		Run:                        checkStorage(&checkStorageOptions{Attachments: true}),
		AbortIfFailed:              false,
		SkipDatabaseInitialization: false,
		Priority:                   1,
	})

	Register(&Check{
		Title:                      "Check if there are orphaned lfs files in storage",
		Name:                       "storage-lfs",
		IsDefault:                  false,
		Run:                        checkStorage(&checkStorageOptions{LFS: true}),
		AbortIfFailed:              false,
		SkipDatabaseInitialization: false,
		Priority:                   1,
	})

	Register(&Check{
		Title:                      "Check if there are orphaned avatars in storage",
		Name:                       "storage-avatars",
		IsDefault:                  false,
		Run:                        checkStorage(&checkStorageOptions{Avatars: true, RepoAvatars: true}),
		AbortIfFailed:              false,
		SkipDatabaseInitialization: false,
		Priority:                   1,
	})

	Register(&Check{
		Title:                      "Check if there are orphaned archives in storage",
		Name:                       "storage-archives",
		IsDefault:                  false,
		Run:                        checkStorage(&checkStorageOptions{RepoArchives: true}),
		AbortIfFailed:              false,
		SkipDatabaseInitialization: false,
		Priority:                   1,
	})

	Register(&Check{
		Title:                      "Check if there are orphaned package blobs in storage",
		Name:                       "storage-packages",
		IsDefault:                  false,
		Run:                        checkStorage(&checkStorageOptions{Packages: true}),
		AbortIfFailed:              false,
		SkipDatabaseInitialization: false,
		Priority:                   1,
	})
}
