// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package taildrop

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

	"tailscale.com/ipn"
	"tailscale.com/syncs"
	"tailscale.com/tstime"
	"tailscale.com/types/logger"
)

// deleteDelay is the amount of time to wait before we delete a file.
// A shorter value ensures timely deletion of deleted and partial files, while
// a longer value provides more opportunity for partial files to be resumed.
const deleteDelay = time.Hour

// fileDeleter manages asynchronous deletion of files after deleteDelay.
type fileDeleter struct {
	logf  logger.Logf
	clock tstime.DefaultClock
	dir   string
	event func(string) // called for certain events; for testing only

	mu     sync.Mutex
	queue  list.List
	byName map[string]*list.Element

	emptySignal chan struct{} // signal that the queue is empty
	group       syncs.WaitGroup
	shutdownCtx context.Context
	shutdown    context.CancelFunc
}

// deleteFile is a specific file to delete after deleteDelay.
type deleteFile struct {
	name     string
	inserted time.Time
}

func (d *fileDeleter) Init(m *Manager, eventHook func(string)) {
	d.logf = m.opts.Logf
	d.clock = m.opts.Clock
	d.dir = m.opts.Dir
	d.event = eventHook

	d.byName = make(map[string]*list.Element)
	d.emptySignal = make(chan struct{})
	d.shutdownCtx, d.shutdown = context.WithCancel(context.Background())

	// From a cold-start, load the list of partial and deleted files.
	//
	// Only run this if we have ever received at least one file
	// to avoid ever touching the taildrop directory on systems (e.g., MacOS)
	// that pop up a security dialog window upon first access.
	if m.opts.State == nil {
		return
	}
	if b, _ := m.opts.State.ReadState(ipn.TaildropReceivedKey); len(b) == 0 {
		return
	}
	d.group.Go(func() {
		d.event("start full-scan")
		defer d.event("end full-scan")
		rangeDir(d.dir, func(de fs.DirEntry) bool {
			switch {
			case d.shutdownCtx.Err() != nil:
				return false // terminate early
			case !de.Type().IsRegular():
				return true
			case strings.HasSuffix(de.Name(), partialSuffix):
				// Only enqueue the file for deletion if there is no active put.
				nameID := strings.TrimSuffix(de.Name(), partialSuffix)
				if i := strings.LastIndexByte(nameID, '.'); i > 0 {
					key := incomingFileKey{ClientID(nameID[i+len("."):]), nameID[:i]}
					m.incomingFiles.LoadFunc(key, func(_ *incomingFile, loaded bool) {
						if !loaded {
							d.Insert(de.Name())
						}
					})
				} else {
					d.Insert(de.Name())
				}
			case strings.HasSuffix(de.Name(), deletedSuffix):
				// Best-effort immediate deletion of deleted files.
				name := strings.TrimSuffix(de.Name(), deletedSuffix)
				if os.Remove(filepath.Join(d.dir, name)) == nil {
					if os.Remove(filepath.Join(d.dir, de.Name())) == nil {
						break
					}
				}
				// Otherwise, enqueue the file for later deletion.
				d.Insert(de.Name())
			}
			return true
		})
	})
}

// Insert enqueues baseName for eventual deletion.
func (d *fileDeleter) Insert(baseName string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.shutdownCtx.Err() != nil {
		return
	}
	if _, ok := d.byName[baseName]; ok {
		return // already queued for deletion
	}
	d.byName[baseName] = d.queue.PushBack(&deleteFile{baseName, d.clock.Now()})
	if d.queue.Len() == 1 && d.shutdownCtx.Err() == nil {
		d.group.Go(func() { d.waitAndDelete(deleteDelay) })
	}
}

// waitAndDelete is an asynchronous deletion goroutine.
// At most one waitAndDelete routine is ever running at a time.
// It is not started unless there is at least one file in the queue.
func (d *fileDeleter) waitAndDelete(wait time.Duration) {
	tc, ch := d.clock.NewTimer(wait)
	defer tc.Stop() // cleanup the timer resource if we stop early
	d.event("start waitAndDelete")
	defer d.event("end waitAndDelete")
	select {
	case <-d.shutdownCtx.Done():
	case <-d.emptySignal:
	case now := <-ch:
		d.mu.Lock()
		defer d.mu.Unlock()

		// Iterate over all files to delete, and delete anything old enough.
		var next *list.Element
		var failed []*list.Element
		for elem := d.queue.Front(); elem != nil; elem = next {
			next = elem.Next()
			file := elem.Value.(*deleteFile)
			if now.Sub(file.inserted) < deleteDelay {
				break // everything after this is recently inserted
			}

			// Delete the expired file.
			if name, ok := strings.CutSuffix(file.name, deletedSuffix); ok {
				if err := os.Remove(filepath.Join(d.dir, name)); err != nil && !os.IsNotExist(err) {
					d.logf("could not delete: %v", redactError(err))
					failed = append(failed, elem)
					continue
				}
			}
			if err := os.Remove(filepath.Join(d.dir, file.name)); err != nil && !os.IsNotExist(err) {
				d.logf("could not delete: %v", redactError(err))
				failed = append(failed, elem)
				continue
			}
			d.queue.Remove(elem)
			delete(d.byName, file.name)
			d.event("deleted " + file.name)
		}
		for _, elem := range failed {
			elem.Value.(*deleteFile).inserted = now // retry after deleteDelay
			d.queue.MoveToBack(elem)
		}

		// If there are still some files to delete, retry again later.
		if d.queue.Len() > 0 && d.shutdownCtx.Err() == nil {
			file := d.queue.Front().Value.(*deleteFile)
			retryAfter := deleteDelay - now.Sub(file.inserted)
			d.group.Go(func() { d.waitAndDelete(retryAfter) })
		}
	}
}

// Remove dequeues baseName from eventual deletion.
func (d *fileDeleter) Remove(baseName string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if elem := d.byName[baseName]; elem != nil {
		d.queue.Remove(elem)
		delete(d.byName, baseName)
		// Signal to terminate any waitAndDelete goroutines.
		if d.queue.Len() == 0 {
			select {
			case <-d.shutdownCtx.Done():
			case d.emptySignal <- struct{}{}:
			}
		}
	}
}

// Shutdown shuts down the deleter.
// It blocks until all goroutines are stopped.
func (d *fileDeleter) Shutdown() {
	d.mu.Lock() // acquire lock to ensure no new goroutines start after shutdown
	d.shutdown()
	d.mu.Unlock()
	d.group.Wait()
}