package mem

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/Files-com/files-sdk-go/v3/fsmount/internal/cache"
	"github.com/Files-com/files-sdk-go/v3/fsmount/internal/log"

	lru "github.com/hashicorp/golang-lru/v2"
)

const (
	// DefaultCacheCapacity is the maximum size of the cache in bytes if not provided
	// at cache initialization. Equivalent to 1GiB
	DefaultCapacity = 1 << 30

	// MinCapacity is the minimum size of the cache. Equivalent to 256MiB
	MinCapacity = 256 << 20

	// MaxCapacity is the maximum size of the cache. Equivalent to 5GiB
	MaxCapacity = 5 << 30

	// DefaultMaxAge is the maximum age of cache files, if not provided
	// at cache initialization. 0 means no expiration. Equivalent to 12 hours.
	DefaultMaxAge = 12 * time.Hour

	// DefaultMaxFileCount is the maximum number of files in the cache if not provided
	// at cache initialization. 0 means unbounded.
	DefaultMaxFileCount = 1000

	// DefaultMaintenanceInterval is the interval at which cache maintenance tasks are performed,
	// if not provided at cache initialization.
	DefaultMaintenanceInterval = 5 * time.Minute

	// unboundedFileCount is a large number used to represent an unbounded file count in the LRU cache.
	//
	// Techically the LRU cache is bounded, so this is a sufficiently large number to approximate
	// unbounded behavior. Equivalent to 4,294,967,295
	unboundedFileCount = 1 << 32

	// pageSize is the size of each byte slice that is allocated to hold the contents of a file.
	// Equivalent to 128KiB.
	pageSize = 128 << 10

	// maxEvictionAttempts is the maximum number of consecutive pinned files
	// checked during eviction before concluding that all files are pinned.
	maxEvictionAttempts = 10

	// partialKeyPrefix separates in-memory partial entries from canonical cache entries.
	// NUL cannot appear in valid filesystem paths, and this prefix is never written to disk.
	partialKeyPrefix = "\x00partial\x00"
)

// page is a convenient way to think about a chunk of an individual file
type page = []byte

// entry represents a single item in the cache
type entry struct {
	// pages is the map of offsets -> pages
	pages map[int64]page

	// logical file size (max written ofst)
	size int64

	// the last time the file was written to the cache
	mod time.Time

	// metadata marks the entry as a completed cache entry for a specific remote version.
	metadata cache.EntryMetadata
}

// MemoryCache implements a simple in memory LRU cache for file data with an interface that roughly
// matches the FUSE Read/Write methods.
//
// TODO:
//   - allow concurrent reads/writes to different files in the cache by moving lock coordination
//     out of the caller and lock by path within the cache with a RWMutex map. This will require
//     maintaining a reservation of bytes so that concurrent goroutines can't exhaust memory that
//     a different goroutine evicted for its Write operation.
type MemoryCache struct {
	Capacity            int64
	MaxFileCount        int64
	MaxAge              time.Duration
	MaintenanceInterval time.Duration

	writeMu sync.Mutex

	// filesMu protects access to the files map
	filesMu sync.RWMutex

	// files represents the mapping from a path to its entry
	files map[string]*entry

	// Global LRU across *pages* (or across files—see below)
	lru *lru.Cache[string, struct{}]

	stats cache.Stats

	// used to log cache operations
	log log.Logger

	// Track pinned files (reference counted) to prevent eviction of files with open handles
	pinnedFiles   map[string]int
	pinnedFilesMu sync.Mutex

	maintMu     sync.Mutex
	maintActive bool
	maintCancel context.CancelFunc
	wg          sync.WaitGroup
}

func NewMemoryCache(opts ...Option) (*MemoryCache, error) {
	mc := &MemoryCache{
		Capacity:            DefaultCapacity,
		MaintenanceInterval: DefaultMaintenanceInterval,
		MaxAge:              DefaultMaxAge,
		MaxFileCount:        DefaultMaxFileCount,
		files:               make(map[string]*entry),
		pinnedFiles:         make(map[string]int),
	}

	// apply options
	for _, opt := range opts {
		opt(mc)
	}

	// validate the options before initializing LRU/scan
	if err := mc.validateOpts(); err != nil {
		return nil, err
	}

	// do this after applying options in case the caller set a different max file count
	// or provided their own LRU cache
	if mc.lru == nil {
		// protect against zero or negative max file count
		var trackCap int64
		if mc.MaxFileCount > 0 {
			trackCap = mc.MaxFileCount
		} else {
			// Unbounded file-count; use a large number since the LRU cache cannot be truly unbounded.
			trackCap = unboundedFileCount
		}
		lru, err := lru.NewWithEvict(int(trackCap), mc.onEvict)
		if err != nil {
			return nil, fmt.Errorf("memoryCache: error creating LRU cache: %w", err)
		}
		mc.lru = lru
	}
	mc.stats = cache.Stats{
		CapacityBytes: mc.Capacity,
		MaxFileCount:  mc.MaxFileCount,
	}

	return mc, nil
}

func (mc *MemoryCache) Write(path string, buff []byte, ofst int64) (int, error) {
	if len(buff) == 0 {
		return 0, nil
	}

	// prevent concurrent access to the map of files
	mc.filesMu.Lock()
	ent, ok := mc.files[path]
	newFile := false
	if !ok {
		newFile = true
		ent = &entry{pages: make(map[int64]page), mod: time.Now()}
	}
	mc.filesMu.Unlock()

	// make sure the entry is not toward the end of the LRU before trimming for capacity
	_, _ = mc.lru.Get(path)

	// lock writes to avoid another goroutine modifying the capacity during this Write
	mc.writeMu.Lock()
	defer mc.writeMu.Unlock()

	// Enforce capacity limits when configured by evicting old unpinned files.
	// The limits are "soft", so writes should always succeed, but a best effort is made to evict
	// unpinned files to stay near the configured limits.
	need := bytesNeededForWrite(ent, ofst, int64(len(buff)))
	evictionAttempts := 0

	// Try to evict unpinned files to make room, but don't fail if eviction is not possible.
	for !mc.hasCapacityDelta(need, newFile) {
		oldestKey, _, ok := mc.lru.GetOldest()
		if !ok {
			// LRU is empty - allow the write to proceed
			break
		}

		// Don't evict the file currently being written to
		if oldestKey == path {
			break
		}

		// Check if the oldest file is pinned
		if mc.isPinned(oldestKey) {
			evictionAttempts++
			if evictionAttempts >= maxEvictionAttempts {
				// Likely all files are pinned. Allow the write to proceed.
				break
			}
			// Try to remove it anyway - onEvict will re-add it and provide the next oldest
			mc.lru.Remove(oldestKey)
			continue
		}

		// Remove the oldest entry (which is not the current file and is not pinned)
		evictionAttempts = 0 // Reset counter since an evictable file was found
		mc.lru.Remove(oldestKey)
	}

	mc.filesMu.Lock()
	// Get was used above to avoid trimming the current Write's data, but the file may not have
	// been in the cache to being with
	if newFile {
		mc.files[path] = ent
		mc.stats.FileCount.Add(1)
	}

	// Write into pages (allocating as needed, updating SizeBytes on *new* pages)
	n := 0
	for n < len(buff) {
		abs := ofst + int64(n)
		pageIdx := abs / pageSize
		pageOff := int(abs % pageSize)

		// allocate page if missing
		page, ok := ent.pages[pageIdx]
		if !ok {
			page = make([]byte, pageSize)
			ent.pages[pageIdx] = page
			mc.stats.SizeBytes.Add(int64(len(page))) // count newly allocated memory
		}

		remainingInSrc := len(buff) - n
		remainingInPage := pageSize - pageOff

		// the number of bytes that should get copied from buff to the page
		// this iteration
		chunkLen := min(remainingInSrc, remainingInPage)
		copy(page[pageOff:pageOff+chunkLen], buff[n:n+chunkLen])
		n += chunkLen
	}

	if ofst+int64(n) > ent.size {
		ent.size = ofst + int64(n)
	}
	ent.mod = time.Now()
	mc.filesMu.Unlock()
	_ = mc.lru.Add(path, struct{}{})
	mc.stats.WriteCount.Add(1)
	mc.stats.WriteBytes.Add(int64(n))
	return n, nil
}

// Read reads data from the cached file at the given path into buff starting at the provided offset.
func (mc *MemoryCache) Read(path string, buff []byte, ofst int64) (n int, err error) {
	if len(buff) == 0 {
		return 0, nil
	}

	mc.filesMu.RLock()
	ent, ok := mc.files[path]
	if !ok {
		mc.filesMu.RUnlock()
		return 0, nil // cache miss
	}
	if ofst >= ent.size {
		mc.filesMu.RUnlock()
		return 0, nil // EOF
	}

	// clamp requested length to EOF
	maxReadable := ent.size - ofst
	want := min(int64(len(buff)), maxReadable)

	read := 0
	for read < int(want) {
		abs := ofst + int64(read)
		pageIdx := abs / pageSize
		pageOff := int(abs % pageSize)

		page, ok := ent.pages[pageIdx]
		if !ok {
			// hit a hole (not cached yet) – stop so caller can fetch from remote
			break
		}

		can := min(int(want)-read, pageSize-pageOff, len(buff)-read)
		copy(buff[read:read+can], page[pageOff:pageOff+can])
		read += can
	}
	mc.filesMu.RUnlock()

	if read > 0 {
		mc.lru.Add(path, struct{}{})
	}
	mc.stats.ReadCount.Add(1)
	mc.stats.ReadBytes.Add(int64(read))
	return read, nil
}

func (mc *MemoryCache) ReadComplete(path string, meta cache.EntryMetadata, buff []byte, ofst int64) (n int, err error) {
	mc.filesMu.RLock()
	ent, ok := mc.files[path]
	if !ok {
		mc.filesMu.RUnlock()
		return 0, nil
	}
	stored := ent.metadata
	mc.filesMu.RUnlock()

	if !stored.Complete {
		return 0, nil
	}
	if !stored.Matches(meta) {
		mc.Delete(path)
		return 0, nil
	}

	return mc.Read(path, buff, ofst)
}

func (mc *MemoryCache) ReadPartial(path string, buff []byte, ofst int64) (n int, err error) {
	return mc.Read(memoryPartialKey(path), buff, ofst)
}

func (mc *MemoryCache) Commit(path string, meta cache.EntryMetadata) error {
	mc.writeMu.Lock()
	defer mc.writeMu.Unlock()

	mc.filesMu.Lock()
	ent, ok := mc.files[path]
	if !ok {
		if meta.Size != 0 {
			mc.filesMu.Unlock()
			return fmt.Errorf("memoryCache: cache entry not found for commit: %s", path)
		}
		ent = &entry{pages: make(map[int64]page), mod: time.Now()}
		mc.files[path] = ent
		mc.stats.FileCount.Add(1)
	}
	if ent.size < meta.Size {
		mc.filesMu.Unlock()
		return fmt.Errorf("memoryCache: cache entry size mismatch for %s: got %d, want %d", path, ent.size, meta.Size)
	}
	if ent.size > meta.Size {
		freed := truncateEntryToSize(ent, meta.Size)
		mc.stats.SizeBytes.Add(-freed)
	}
	meta.Path = path
	meta.Complete = true
	ent.metadata = meta
	ent.mod = time.Now()
	mc.filesMu.Unlock()

	_ = mc.lru.Add(path, struct{}{})
	return nil
}

func (mc *MemoryCache) WritePartial(path string, buff []byte, ofst int64) (n int, err error) {
	return mc.Write(memoryPartialKey(path), buff, ofst)
}

func truncateEntryToSize(ent *entry, size int64) int64 {
	var freed int64
	for pageIdx, pg := range ent.pages {
		if pageIdx*pageSize >= size {
			freed += int64(len(pg))
			delete(ent.pages, pageIdx)
		}
	}
	if size > 0 {
		lastPageIdx := (size - 1) / pageSize
		if pg, ok := ent.pages[lastPageIdx]; ok {
			keep := int(size % pageSize)
			if keep == 0 {
				keep = pageSize
			}
			clear(pg[keep:])
		}
	}
	ent.size = size
	return freed
}

// Delete removes the cached file at the given path from the cache.
func (mc *MemoryCache) Delete(path string) bool {
	if mc.isPinned(path) {
		return false
	}

	// Try to remove via LRU so onEvict frees memory & updates stats
	if ok := mc.lru.Remove(path); ok {
		return true
	}

	mc.filesMu.Lock()
	defer mc.filesMu.Unlock()

	// If it wasn't in the LRU (edge case), free manually.
	if ent, ok := mc.files[path]; ok {
		var freed int64
		for _, pg := range ent.pages {
			freed += int64(len(pg))
		}
		delete(mc.files, path)
		mc.stats.SizeBytes.Add(-freed)
		mc.stats.FileCount.Add(-1)
		return true
	}
	return false
}

func (mc *MemoryCache) DeletePartial(path string) bool {
	return mc.Delete(memoryPartialKey(path))
}

// Stats returns the current cache statistics.
func (mc *MemoryCache) Stats() *cache.Stats {
	s := &cache.Stats{
		CapacityBytes: mc.stats.CapacityBytes,
		MaxFileCount:  mc.stats.MaxFileCount,
		LoadDuration:  mc.stats.LoadDuration,
		LruCount:      mc.stats.LruCount,
	}

	s.SizeBytes.Store(mc.stats.SizeBytes.Load())
	s.FileCount.Store(mc.stats.FileCount.Load())
	s.ReadBytes.Store(mc.stats.ReadBytes.Load())
	s.ReadCount.Store(mc.stats.ReadCount.Load())
	s.WriteBytes.Store(mc.stats.WriteBytes.Load())
	s.WriteCount.Store(mc.stats.WriteCount.Load())

	// Snapshot pinned files information
	mc.pinnedFilesMu.Lock()
	s.PinnedCount = len(mc.pinnedFiles)
	s.PinnedPaths = make(map[string]int, len(mc.pinnedFiles))
	for path, count := range mc.pinnedFiles {
		s.PinnedPaths[path] = count
	}
	mc.pinnedFilesMu.Unlock()

	// Get LRU keys ordered from oldest to newest
	// lru.Keys() already returns a copy, so no need to copy again
	s.LruKeys = mc.lru.Keys()

	s.CapacityBytesRemaining = s.CapacityBytes - s.SizeBytes.Load()
	s.FileCountRemaining = s.MaxFileCount - s.FileCount.Load()
	return s
}

// Pin increments the reference count for a file, preventing it from being evicted.
// This should be called when a file handle is opened.
// Note: MemoryCache uses path directly as the key (same as LRU and files map).
func (mc *MemoryCache) Pin(path string) {
	mc.pinnedFilesMu.Lock()
	defer mc.pinnedFilesMu.Unlock()

	mc.pinnedFiles[path]++
	mc.log.Trace("MemoryCache: pinned %s (count: %d)", path, mc.pinnedFiles[path])
}

func (mc *MemoryCache) PinPartial(path string) {
	mc.Pin(memoryPartialKey(path))
}

// Unpin decrements the reference count for a file.
// When the count reaches zero, the file becomes eligible for eviction.
// This should be called when a file handle is closed.
func (mc *MemoryCache) Unpin(path string) {
	mc.pinnedFilesMu.Lock()

	mc.pinnedFiles[path]--
	fullyUnpinned := mc.pinnedFiles[path] <= 0
	if fullyUnpinned {
		delete(mc.pinnedFiles, path)
		mc.log.Trace("MemoryCache: unpinned %s (fully released)", path)
	} else {
		mc.log.Trace("MemoryCache: unpinned %s (count: %d)", path, mc.pinnedFiles[path])
	}

	mc.pinnedFilesMu.Unlock()

	// If this file was fully unpinned and the cache is over capacity, try to trim the cache
	// back to the configured limit to respect the user's settings. This ensures the
	// cache doesn't stay bloated after files are closed.
	if fullyUnpinned && (mc.Capacity > 0 || mc.MaxFileCount > 0) {
		mc.writeMu.Lock()
		defer mc.writeMu.Unlock()

		// First, try to evict the file that was just unpinned since it just became eligible
		if !mc.hasCapacityDelta(1, false) {
			mc.lru.Remove(path)
		}

		// Then evict other oldest files until the cache is back under capacity
		evictionAttempts := 0
		for !mc.hasCapacityDelta(1, false) {
			oldestKey, _, ok := mc.lru.GetOldest()
			if !ok {
				// LRU is empty, nothing more to evict
				break
			}

			// Don't attempt to evict pinned files
			if mc.isPinned(oldestKey) {
				evictionAttempts++
				if evictionAttempts >= maxEvictionAttempts {
					// Likely all remaining files are pinned, cache cannot be reduced further
					break
				}
				// Try to remove it anyway - onEvict will re-add it and provide the next oldest
				mc.lru.Remove(oldestKey)
				continue
			}

			// Remove the oldest entry (which is not pinned)
			evictionAttempts = 0 // Reset counter since an evictable file was found
			mc.lru.Remove(oldestKey)
		}
	}
}

func (mc *MemoryCache) UnpinPartial(path string) {
	mc.Unpin(memoryPartialKey(path))
}

// isPinned checks if a file is currently pinned (has open file handles).
// The path parameter should match the keys used in LRU and files map.
func (mc *MemoryCache) isPinned(path string) bool {
	mc.pinnedFilesMu.Lock()
	defer mc.pinnedFilesMu.Unlock()

	count, exists := mc.pinnedFiles[path]
	return exists && count > 0
}

// StartMaintenance starts the maintenance goroutine if it is not already running.
func (mc *MemoryCache) StartMaintenance() {
	mc.maintMu.Lock()
	if mc.maintActive {
		mc.maintMu.Unlock()
		return
	}

	ctx, cancel := context.WithCancel(context.Background())
	mc.maintCancel = cancel
	mc.maintActive = true
	mc.maintMu.Unlock()
	mc.wg.Add(1)
	go func() {
		defer mc.wg.Done()
		mc.maintenanceLoop(ctx)
	}()
}

// StopMaintenance stops the maintenance goroutine if it is running.
func (mc *MemoryCache) StopMaintenance() {
	mc.maintMu.Lock()
	if !mc.maintActive {
		mc.maintMu.Unlock()
		return
	}
	cancel := mc.maintCancel
	mc.maintMu.Unlock()
	// Trigger shutdown and wait for the goroutine to exit cleanly.
	if cancel != nil {
		cancel()
	}
	mc.wg.Wait()

	mc.maintMu.Lock()
	// Clear struct state under the lock so a concurrent Start can proceed.
	mc.maintCancel = nil
	mc.maintActive = false
	mc.maintMu.Unlock()
}

func (dc *MemoryCache) hasCapacityDelta(delta int64, newFile bool) bool {
	bytesOK := dc.Capacity == 0 || dc.stats.SizeBytes.Load()+delta <= dc.Capacity
	filesOK := dc.MaxFileCount == 0 || !newFile || dc.stats.FileCount.Load() < dc.MaxFileCount
	return bytesOK && filesOK
}

// bytesNeededForWrite calculates the number of new bytes that will need to be allocated
// to perform a write of n bytes at the given offset in the entry. Pages are allocated at
// a fixed pageSize, so this function counts the number of pages that don't yet exist and
// would need to be allocated, multiplied by pageSize. Pages that already exist contribute
// zero bytes to the calculation, even if the write only uses part of those pages.
func bytesNeededForWrite(ent *entry, ofst, n int64) int64 {
	var need, pos int64
	for pos < n {
		abs := ofst + pos
		pageIdx := abs / pageSize
		pageOff := int(abs % pageSize)
		toCopy := int64(min(int(n-pos), pageSize-pageOff))
		if _, ok := ent.pages[pageIdx]; !ok {
			need += int64(pageSize)
		}
		pos += toCopy
	}
	return need
}

// maintenanceLoop runs periodic maintenance tasks until the context is cancelled.
func (mc *MemoryCache) maintenanceLoop(ctx context.Context) {
	ticker := time.NewTicker(mc.MaintenanceInterval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			mc.runMaintenanceOnce(ctx)
		}
	}
}

func (mc *MemoryCache) runMaintenanceOnce(_ context.Context) {
	mc.log.Debug("MemoryCache: performing maintenance")
	start := time.Now()

	if mc.MaxAge == 0 {
		mc.log.Debug("MemoryCache: maintenance complete: max age is 0, nothing to do ")
		return
	}

	// Snapshot keys under read lock, then remove outside the lock
	mc.filesMu.RLock()
	agedOut := make([]string, 0, len(mc.files))
	for path, ent := range mc.files {
		if time.Since(ent.mod) > mc.MaxAge {
			if mc.isPinned(path) {
				continue
			}
			agedOut = append(agedOut, path)
		}
	}
	mc.filesMu.RUnlock()

	for _, path := range agedOut {
		mc.lru.Remove(path)
	}
	mc.log.Debug("MemoryCache: maintenance complete, duration=%v", time.Since(start))
}

func (mc *MemoryCache) onEvict(path string, value struct{}) {
	// NEVER evict pinned files (files with open handles)
	if mc.isPinned(path) {
		mc.log.Trace("MemoryCache: skipping eviction of pinned file: %s", path)
		// Re-add to LRU to allow retry later
		_ = mc.lru.Add(path, struct{}{})
		return
	}

	mc.log.Trace("MemoryCache: evicting %s", path)
	// look up entry in files map
	mc.filesMu.Lock()
	defer mc.filesMu.Unlock()
	ent, ok := mc.files[path]
	if !ok {
		mc.log.Trace("MemoryCache: evicting %s but not found in files map", path)
		return
	}
	// calculate size to be freed
	var sizeFreed int64
	for _, page := range ent.pages {
		sizeFreed += int64(len(page))
	}
	// delete entry from files map
	delete(mc.files, path)
	// update stats
	mc.stats.SizeBytes.Add(-sizeFreed)
	mc.stats.FileCount.Add(-1)
}

func (mc *MemoryCache) validateOpts() error {
	// Hard errors: invalid limits
	if mc.Capacity < MinCapacity {
		return fmt.Errorf("memoryCache: capacity cannot be less than the minimum: %d, provided: %d", MinCapacity, mc.Capacity)
	}
	if mc.Capacity > MaxCapacity {
		return fmt.Errorf("memoryCache: capacity cannot be greater than the maximum: %d, provided: %d", MaxCapacity, mc.Capacity)
	}
	// Ensure logger
	if mc.log == nil {
		mc.log = &log.NoOpLogger{}
	}
	return nil
}

func memoryPartialKey(path string) string {
	return partialKeyPrefix + path
}
