package scanner

import (
	"context"
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"time"

	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
)

type (
	dirStats struct {
		Path            string
		ModTime         time.Time
		Images          []string
		ImagesUpdatedAt time.Time
		HasPlaylist     bool
		AudioFilesCount uint32
	}
)

func walkDirTree(ctx context.Context, rootFolder string) (<-chan dirStats, <-chan error) {
	results := make(chan dirStats)
	errC := make(chan error)
	go func() {
		defer close(results)
		defer close(errC)
		err := walkFolder(ctx, rootFolder, rootFolder, results)
		if err != nil {
			log.Error(ctx, "There were errors reading directories from filesystem", "path", rootFolder, err)
			errC <- err
		}
		log.Debug(ctx, "Finished reading directories from filesystem", "path", rootFolder)
	}()
	return results, errC
}

func walkFolder(ctx context.Context, rootPath string, currentFolder string, results chan<- dirStats) error {
	children, stats, err := loadDir(ctx, currentFolder)
	if err != nil {
		return err
	}
	for _, c := range children {
		err := walkFolder(ctx, rootPath, c, results)
		if err != nil {
			return err
		}
	}

	dir := filepath.Clean(currentFolder)
	log.Trace(ctx, "Found directory", "dir", dir, "audioCount", stats.AudioFilesCount,
		"images", stats.Images, "hasPlaylist", stats.HasPlaylist)
	stats.Path = dir
	results <- *stats

	return nil
}

func loadDir(ctx context.Context, dirPath string) ([]string, *dirStats, error) {
	var children []string
	stats := &dirStats{}

	dirInfo, err := os.Stat(dirPath)
	if err != nil {
		log.Error(ctx, "Error stating dir", "path", dirPath, err)
		return nil, nil, err
	}
	stats.ModTime = dirInfo.ModTime()

	dir, err := os.Open(dirPath)
	if err != nil {
		log.Error(ctx, "Error in Opening directory", "path", dirPath, err)
		return children, stats, err
	}
	defer dir.Close()

	for _, entry := range fullReadDir(ctx, dir) {
		isDir, err := isDirOrSymlinkToDir(dirPath, entry)
		// Skip invalid symlinks
		if err != nil {
			log.Error(ctx, "Invalid symlink", "dir", filepath.Join(dirPath, entry.Name()), err)
			continue
		}
		if isDir && !isDirIgnored(dirPath, entry) && isDirReadable(ctx, dirPath, entry) {
			children = append(children, filepath.Join(dirPath, entry.Name()))
		} else {
			fileInfo, err := entry.Info()
			if err != nil {
				log.Error(ctx, "Error getting fileInfo", "name", entry.Name(), err)
				return children, stats, err
			}
			if fileInfo.ModTime().After(stats.ModTime) {
				stats.ModTime = fileInfo.ModTime()
			}
			switch {
			case model.IsAudioFile(entry.Name()):
				stats.AudioFilesCount++
			case model.IsValidPlaylist(entry.Name()):
				stats.HasPlaylist = true
			case model.IsImageFile(entry.Name()):
				stats.Images = append(stats.Images, entry.Name())
				if fileInfo.ModTime().After(stats.ImagesUpdatedAt) {
					stats.ImagesUpdatedAt = fileInfo.ModTime()
				}
			}
		}
	}
	return children, stats, nil
}

// fullReadDir reads all files in the folder, skipping the ones with errors.
// It also detects when it is "stuck" with an error in the same directory over and over.
// In this case, it stops and returns whatever it was able to read until it got stuck.
// See discussion here: https://github.com/navidrome/navidrome/issues/1164#issuecomment-881922850
func fullReadDir(ctx context.Context, dir fs.ReadDirFile) []os.DirEntry {
	var allEntries []os.DirEntry
	var prevErrStr = ""
	for {
		entries, err := dir.ReadDir(-1)
		allEntries = append(allEntries, entries...)
		if err == nil {
			break
		}
		log.Warn(ctx, "Skipping DirEntry", err)
		if prevErrStr == err.Error() {
			log.Error(ctx, "Duplicate DirEntry failure, bailing", err)
			break
		}
		prevErrStr = err.Error()
	}
	sort.Slice(allEntries, func(i, j int) bool { return allEntries[i].Name() < allEntries[j].Name() })
	return allEntries
}

// isDirOrSymlinkToDir returns true if and only if the dirEnt represents a file
// system directory, or a symbolic link to a directory. Note that if the dirEnt
// is not a directory but is a symbolic link, this method will resolve by
// sending a request to the operating system to follow the symbolic link.
// originally copied from github.com/karrick/godirwalk, modified to use dirEntry for
// efficiency for go 1.16 and beyond
func isDirOrSymlinkToDir(baseDir string, dirEnt fs.DirEntry) (bool, error) {
	if dirEnt.IsDir() {
		return true, nil
	}
	if dirEnt.Type()&os.ModeSymlink == 0 {
		return false, nil
	}
	// Does this symlink point to a directory?
	fileInfo, err := os.Stat(filepath.Join(baseDir, dirEnt.Name()))
	if err != nil {
		return false, err
	}
	return fileInfo.IsDir(), nil
}

var ignoredDirs = []string{
	"$RECYCLE.BIN",
	"#snapshot",
}

// isDirIgnored returns true if the directory represented by dirEnt contains an
// `ignore` file (named after skipScanFile)
func isDirIgnored(baseDir string, dirEnt fs.DirEntry) bool {
	// allows Album folders for albums which eg start with ellipses
	name := dirEnt.Name()
	if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") {
		return true
	}
	if slices.IndexFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) != -1 {
		return true
	}
	_, err := os.Stat(filepath.Join(baseDir, name, consts.SkipScanFile))
	return err == nil
}

// isDirReadable returns true if the directory represented by dirEnt is readable
func isDirReadable(ctx context.Context, baseDir string, dirEnt os.DirEntry) bool {
	path := filepath.Join(baseDir, dirEnt.Name())

	dir, err := os.Open(path)
	if err != nil {
		log.Warn("Skipping unreadable directory", "path", path, err)
		return false
	}

	err = dir.Close()
	if err != nil {
		log.Warn(ctx, "Error closing directory", "path", path, err)
	}

	return true
}