package file

import (
	"context"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"sync"

	lru "github.com/hashicorp/golang-lru/v2"
	ignore "github.com/sabhiram/go-gitignore"
	"github.com/stashapp/stash/pkg/logger"
)

const stashIgnoreFilename = ".stashignore"

// entriesCacheSize is the size of the LRU cache for collected ignore entries.
// This cache stores the computed list of ignore entries per directory, avoiding
// repeated directory tree walks for files in the same directory.
const entriesCacheSize = 500

// StashIgnoreFilter implements PathFilter to exclude files/directories
// based on .stashignore files with gitignore-style patterns.
type StashIgnoreFilter struct {
	// cache stores compiled ignore patterns per directory.
	cache sync.Map // map[string]*ignoreEntry
	// entriesCache stores collected ignore entries per (dir, libraryRoot) pair.
	// This avoids recomputing the entry list for every file in the same directory.
	entriesCache *lru.Cache[string, []*ignoreEntry]
}

// ignoreEntry holds the compiled ignore patterns for a directory.
type ignoreEntry struct {
	// patterns is the compiled gitignore matcher for this directory.
	patterns *ignore.GitIgnore
	// dir is the directory this entry applies to.
	dir string
}

// NewStashIgnoreFilter creates a new StashIgnoreFilter.
func NewStashIgnoreFilter() *StashIgnoreFilter {
	// Create the LRU cache for collected entries.
	// Ignore error as it only fails if size <= 0.
	entriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize)
	return &StashIgnoreFilter{
		entriesCache: entriesCache,
	}
}

// Accept returns true if the path should be included in the scan.
// It checks for .stashignore files in the directory hierarchy and
// applies gitignore-style pattern matching.
// The libraryRoot parameter bounds the search for .stashignore files -
// only directories within the library root are checked.
// zipFilepath is the path of the zip file if the file is inside a zip.
// .stashignore files will not be read within zip files.
func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string, zipFilePath string) bool {
	// If no library root provided, accept the file (safety fallback).
	if libraryRoot == "" {
		return true
	}

	// Get the directory containing this path.
	dir := filepath.Dir(path)

	// If the file is inside a zip, use the zip file's directory as the base for .stashignore lookup.
	if zipFilePath != "" {
		dir = filepath.Dir(zipFilePath)
	}

	// Collect all applicable ignore entries from library root to this directory.
	entries := f.collectIgnoreEntries(dir, libraryRoot)

	// If no .stashignore files found, accept the file.
	if len(entries) == 0 {
		return true
	}

	// Check each ignore entry in order (from root to most specific).
	// Later entries can override earlier ones with negation patterns.
	ignored := false
	for _, entry := range entries {
		// Get path relative to the ignore file's directory.
		entryRelPath, err := filepath.Rel(entry.dir, path)
		if err != nil {
			continue
		}
		entryRelPath = filepath.ToSlash(entryRelPath)
		if info.IsDir() {
			entryRelPath += "/"
		}

		if entry.patterns.MatchesPath(entryRelPath) {
			ignored = true
		}
	}

	return !ignored
}

// collectIgnoreEntries gathers all ignore entries from library root to the given directory.
// It walks up the directory tree from dir to libraryRoot and returns entries in order
// from root to most specific. Results are cached to avoid repeated computation for
// files in the same directory.
func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry {
	// Clean paths for consistent comparison and cache key generation.
	dir = filepath.Clean(dir)
	libraryRoot = filepath.Clean(libraryRoot)

	// Build cache key from dir and libraryRoot.
	cacheKey := dir + "\x00" + libraryRoot

	// Check the entries cache first.
	if cached, ok := f.entriesCache.Get(cacheKey); ok {
		return cached
	}

	// Try subdirectory shortcut: if parent's entries are cached, extend them.
	if dir != libraryRoot {
		parent := filepath.Dir(dir)
		if isPathInOrEqual(libraryRoot, parent) {
			parentKey := parent + "\x00" + libraryRoot
			if parentEntries, ok := f.entriesCache.Get(parentKey); ok {
				// Parent is cached - just check if current dir has a .stashignore.
				entries := parentEntries
				if entry := f.getOrLoadIgnoreEntry(dir); entry != nil {
					// Copy parent slice and append to avoid mutating cached slice.
					entries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1)
					copy(entries, parentEntries)
					entries = append(entries, entry)
				}
				f.entriesCache.Add(cacheKey, entries)
				return entries
			}
		}
	}

	// No cache hit - compute from scratch.
	// Walk up from dir to library root, collecting directories.
	var dirs []string
	current := dir
	for {
		// Check if we're still within the library root.
		if !isPathInOrEqual(libraryRoot, current) {
			break
		}

		dirs = append(dirs, current)

		// Stop if we've reached the library root.
		if current == libraryRoot {
			break
		}

		parent := filepath.Dir(current)
		if parent == current {
			// Reached filesystem root without finding library root.
			break
		}
		current = parent
	}

	// Reverse to get root-to-leaf order.
	for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 {
		dirs[i], dirs[j] = dirs[j], dirs[i]
	}

	// Check each directory for .stashignore files.
	var entries []*ignoreEntry
	for _, d := range dirs {
		if entry := f.getOrLoadIgnoreEntry(d); entry != nil {
			entries = append(entries, entry)
		}
	}

	// Cache the result.
	f.entriesCache.Add(cacheKey, entries)

	return entries
}

// isPathInOrEqual checks if path is equal to or inside root.
func isPathInOrEqual(root, path string) bool {
	if path == root {
		return true
	}
	// Check if path starts with root + separator.
	return strings.HasPrefix(path, root+string(filepath.Separator))
}

// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it.
func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry {
	// Check cache first.
	if cached, ok := f.cache.Load(dir); ok {
		entry := cached.(*ignoreEntry)
		if entry.patterns == nil {
			return nil // Cached negative result.
		}
		return entry
	}

	// Try to load .stashignore from this directory.
	stashIgnorePath := filepath.Join(dir, stashIgnoreFilename)
	patterns, err := f.loadIgnoreFile(stashIgnorePath)
	if err != nil {
		if !os.IsNotExist(err) {
			logger.Warnf("Failed to load .stashignore from %s: %v", dir, err)
		}
		f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})
		return nil
	}
	if patterns == nil {
		// File exists but has no patterns (empty or only comments).
		f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})
		return nil
	}

	logger.Debugf("Loaded .stashignore from %s", dir)

	entry := &ignoreEntry{
		patterns: patterns,
		dir:      dir,
	}
	f.cache.Store(dir, entry)
	return entry
}

// loadIgnoreFile loads and compiles a .stashignore file.
func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	lines := strings.Split(string(data), "\n")
	var patterns []string

	for _, line := range lines {
		// Trim trailing whitespace (but preserve leading for patterns).
		line = strings.TrimRight(line, " \t\r")

		// Skip empty lines.
		if line == "" {
			continue
		}

		// Skip comments (but not escaped #).
		if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "\\#") {
			continue
		}

		patterns = append(patterns, line)
	}

	if len(patterns) == 0 {
		// File exists but has no patterns (e.g., only comments).
		return nil, nil
	}

	return ignore.CompileIgnoreLines(patterns...), nil
}
