// Package watch provides a debounce-coalesced fsnotify watcher for Go source
// trees. Both server and desktop dev modes use it to trigger rebuilds.
package watch

import (
	"context"
	"fmt"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/fsnotify/fsnotify"
)

// Dir tags a watch target with the kind of rebuild it should trigger.
type Dir struct {
	Path   string
	Target ChangeSet
}

// ChangeSet is a bitset of services whose source has changed.
type ChangeSet uint8

const (
	ChangeNone      ChangeSet = 0
	ChangeAgentTeam ChangeSet = 1 << iota
)

// Has reports whether s contains c.
func (c ChangeSet) Has(target ChangeSet) bool { return c&target != 0 }

// Merge returns s | other.
func (c ChangeSet) Merge(other ChangeSet) ChangeSet { return c | other }

// SourceRoots are top-level directories whose .go / go.mod changes should
// trigger a rebuild. Watching more granularly burns FDs.
var SourceRoots = []string{"cmd", "internal", "guardrails", "scripts", "e2e"}

var skipDirNames = map[string]bool{
	"vendor":       true,
	"node_modules": true,
	"dist":         true,
	"testdata":     true,
}

// ExtraFromEnv parses AGENTTEAM_DEV_EXTRA_WATCH into a list of Dir entries. Each
// entry may be "path" (defaults to ChangeAgentTeam) or "path=target". The retired
// "meshx" target remains a compatibility alias for the unified AgentTeam rebuild.
func ExtraFromEnv(repoRoot string) ([]Dir, error) {
	raw := os.Getenv("AGENTTEAM_DEV_EXTRA_WATCH")
	if raw == "" {
		return nil, nil
	}
	var dirs []Dir
	for _, entry := range strings.Split(raw, ",") {
		entry = strings.TrimSpace(entry)
		if entry == "" {
			continue
		}
		path := entry
		if idx := strings.LastIndex(entry, "="); idx > 0 {
			switch entry[idx+1:] {
			case "agentteam", "meshx":
				path = entry[:idx]
			default:
				return nil, fmt.Errorf("AGENTTEAM_DEV_EXTRA_WATCH target %q must be agentteam or meshx", entry[idx+1:])
			}
		}
		if !filepath.IsAbs(path) {
			path = filepath.Join(repoRoot, path)
		}
		dirs = append(dirs, Dir{Path: path, Target: ChangeAgentTeam})
	}
	return dirs, nil
}

// AddSourceWatches registers the repo root, the SourceRoots subtrees, and any
// extra dirs with w.
func AddSourceWatches(w *fsnotify.Watcher, repoRoot string, extraDirs []Dir) {
	w.Add(repoRoot)
	for _, name := range SourceRoots {
		base := filepath.Join(repoRoot, name)
		if _, err := os.Stat(base); err != nil {
			continue
		}
		filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
			if err != nil || !d.IsDir() {
				return nil
			}
			if path != base && shouldSkipDir(d) {
				return filepath.SkipDir
			}
			w.Add(path)
			return nil
		})
	}
	for _, wd := range extraDirs {
		if _, err := os.Stat(wd.Path); err != nil {
			continue
		}
		slog.Info("watching extra dir", "path", wd.Path, "target", wd.Target)
		filepath.WalkDir(wd.Path, func(path string, d fs.DirEntry, err error) error {
			if err != nil || !d.IsDir() {
				return nil
			}
			if path != wd.Path && shouldSkipDir(d) {
				return filepath.SkipDir
			}
			w.Add(path)
			return nil
		})
	}
}

func shouldSkipDir(d fs.DirEntry) bool {
	name := d.Name()
	if strings.HasPrefix(name, ".") {
		return true
	}
	return skipDirNames[name]
}

// IsRelevant reports whether name is a .go file or go.mod/go.sum.
func IsRelevant(name string) bool {
	base := filepath.Base(name)
	ext := filepath.Ext(name)
	return ext == ".go" || base == "go.mod" || base == "go.sum"
}

// IsSourceMutation reports whether an fsnotify event can change source bytes or
// directory membership. Attribute-only events are intentionally ignored: on
// macOS the kqueue backend may emit Chmod when watched file descriptors close,
// and treating that as a source edit can make a successful rebuild trigger the
// next rebuild indefinitely.
func IsSourceMutation(op fsnotify.Op) bool {
	return op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0
}

// ChangeTarget categorizes a changed file path into the service(s) it affects.
// Returns ChangeNone for paths outside the source roots (e.g. docs/, .git/).
func ChangeTarget(repoRoot, name string, extraDirs []Dir) ChangeSet {
	rel := relPath(repoRoot, name)
	if rel == "." || rel == "" {
		return ChangeNone
	}
	parts := strings.Split(rel, string(filepath.Separator))
	if len(parts) == 0 {
		return ChangeNone
	}
	switch parts[0] {
	case "web", ".data", ".git", "docs", "specs", "skills", "x", "dev", "desktop":
		return ChangeNone
	case "cmd", "internal", "guardrails", "scripts", "e2e":
		return ChangeAgentTeam
	case "go.mod", "go.sum":
		return ChangeAgentTeam
	default:
		if strings.HasSuffix(parts[0], ".go") {
			return ChangeAgentTeam
		}
	}
	for _, wd := range extraDirs {
		if rel2, err := filepath.Rel(wd.Path, name); err == nil && rel2 != "." && !strings.HasPrefix(rel2, "..") {
			return wd.Target
		}
	}
	return ChangeNone
}

func relPath(repoRoot, path string) string {
	if r, err := filepath.Rel(repoRoot, path); err == nil && !strings.HasPrefix(r, "..") {
		return r
	}
	return path
}

// Debouncer coalesces rapid file-change events so a single rebuild runs per
// idle window. Close waits for any pending callback to finish.
type Debouncer struct {
	ctx          context.Context
	cancel       context.CancelFunc
	delay        time.Duration
	timerFactory TimerFactory

	mu     sync.Mutex
	timer  Timer
	epoch  uint64
	closed bool
	wg     sync.WaitGroup
}

// Timer is the minimal *time.Timer interface Debouncer uses.
type Timer interface {
	Stop() bool
}

// TimerFactory returns a Timer that fires fn after delay.
type TimerFactory func(delay time.Duration, fn func()) Timer

// RealTimerFactory uses time.AfterFunc.
func RealTimerFactory(delay time.Duration, fn func()) Timer {
	return time.AfterFunc(delay, fn)
}

// NewDebouncer constructs a debouncer whose callbacks fire delay after the
// most recent Schedule call. parent cancellation prevents future callbacks.
func NewDebouncer(parent context.Context, delay time.Duration, factory TimerFactory) *Debouncer {
	ctx, cancel := context.WithCancel(parent)
	return &Debouncer{ctx: ctx, cancel: cancel, delay: delay, timerFactory: factory}
}

// Schedule (re)schedules fn to fire after d.delay. If a previous fn is still
// pending, it is cancelled.
func (d *Debouncer) Schedule(fn func(context.Context)) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.closed {
		return
	}
	if d.timer != nil && d.timer.Stop() {
		d.wg.Done()
	}
	d.epoch++
	epoch := d.epoch
	d.wg.Add(1)
	d.timer = d.timerFactory(d.delay, func() {
		defer d.wg.Done()
		d.mu.Lock()
		if d.closed {
			d.mu.Unlock()
			return
		}
		if d.epoch != epoch {
			d.mu.Unlock()
			return
		}
		d.timer = nil
		d.mu.Unlock()
		fn(d.ctx)
	})
}

// Close cancels future callbacks and waits for any in-flight callback to finish.
func (d *Debouncer) Close() {
	d.mu.Lock()
	if d.closed {
		d.mu.Unlock()
		return
	}
	d.closed = true
	d.cancel()
	if d.timer != nil && d.timer.Stop() {
		d.wg.Done()
	}
	d.timer = nil
	d.mu.Unlock()
	d.wg.Wait()
}
