package notify

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/rand"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/crevissepartners/projmux/internal/config"
)

// NotifyFileName is the basename of the queue file inside StateDir.
const NotifyFileName = "notify.json"

// lockFileSuffix is appended to the queue file path to derive the lock path.
const lockFileSuffix = ".lock"

// Default lock acquisition tunables. We use an O_CREATE|O_EXCL retry loop
// instead of golang.org/x/sys/unix.Flock so we do not introduce a new
// module dependency. Linux's atomic O_EXCL on a local filesystem gives us
// the mutual-exclusion guarantee we need; bounded retry + jittered backoff
// converts the busy state into a wait.
var (
	defaultLockMaxAttempts = 200
	defaultLockBaseDelay   = 2 * time.Millisecond
	defaultLockMaxDelay    = 50 * time.Millisecond
	defaultLockStaleAfter  = 30 * time.Second
)

// Clock returns the current time. Tests inject a fixed clock.
type Clock func() time.Time

// Store persists notifications to a JSON file inside StateDir.
type Store struct {
	path     string
	lockPath string
	clock    Clock

	// rng is used by the lock-acquisition backoff to add jitter. The store is
	// not safe for concurrent use across goroutines without external
	// serialization (this matches every other core/* store), so the rng is
	// only ever touched while we already hold the file lock or are racing to
	// acquire it; either way the simple seeded source is fine.
	rng *rand.Rand
}

// NewStore builds a Store rooted at the supplied path.
func NewStore(path string) *Store {
	return &Store{
		path:     path,
		lockPath: path + lockFileSuffix,
		clock:    time.Now,
		rng:      rand.New(rand.NewSource(time.Now().UnixNano())),
	}
}

// NewDefaultStore builds a Store that uses the canonical notify.json under
// the supplied projmux paths.
func NewDefaultStore(paths config.Paths) *Store {
	return NewStore(filepath.Join(paths.StateDir, NotifyFileName))
}

// Path returns the underlying queue file path.
func (s *Store) Path() string {
	return s.path
}

// SetClock replaces the time source. Intended for tests.
func (s *Store) SetClock(c Clock) {
	if c != nil {
		s.clock = c
	}
}

// PushResult is returned to callers after a successful Push.
type PushResult struct {
	ID         string
	QueueLen   int
	Replaced   bool // true if the push replaced an entry with the same ID.
	WasExpired bool // true if the existing entry had already expired.
}

// Push appends or replaces a notification. Validation is performed first so
// invalid input does not contend for the lock.
func (s *Store) Push(in PushInput) (Notification, PushResult, error) {
	if strings.TrimSpace(in.Text) == "" {
		return Notification{}, PushResult{}, ErrInvalidText
	}
	severity := in.Severity
	if severity == "" {
		severity = SeverityInfo
	}
	if err := ValidateSeverity(severity); err != nil {
		return Notification{}, PushResult{}, err
	}
	source := in.Source
	if source == "" {
		source = SourceExternal
	}
	if err := ValidateSource(source); err != nil {
		return Notification{}, PushResult{}, err
	}
	if strings.TrimSpace(in.Target.Session) == "" {
		return Notification{}, PushResult{}, fmt.Errorf("%w: missing session", ErrInvalidTarget)
	}

	ttl := in.TTL
	if ttl <= 0 {
		ttl = DefaultTTL
	}

	id := strings.TrimSpace(in.ID)
	autoGenerated := id == ""
	if autoGenerated {
		generated, err := generateID()
		if err != nil {
			return Notification{}, PushResult{}, err
		}
		id = generated
	}

	now := s.clock()
	entry := Notification{
		ID:        id,
		Text:      truncateText(strings.TrimSpace(in.Text)),
		Severity:  severity,
		Socket:    strings.TrimSpace(in.Target.Socket),
		Session:   strings.TrimSpace(in.Target.Session),
		Window:    strings.TrimSpace(in.Target.Window),
		Pane:      strings.TrimSpace(in.Target.Pane),
		Source:    source,
		Metadata:  sanitizeMetadata(in.Metadata),
		CreatedAt: now,
		ExpiresAt: now.Add(ttl),
	}

	var result PushResult
	err := s.withLock(func() error {
		entries, err := s.read()
		if err != nil {
			return err
		}

		// Dedupe by id when the caller supplied one. Auto-generated ids are
		// random so collisions are vanishingly unlikely; we still pass the
		// input through replaceOrAppend to keep behaviour uniform.
		entries, replaced, wasExpired := replaceOrAppend(entries, entry, now)
		if !autoGenerated {
			result.Replaced = replaced
			result.WasExpired = wasExpired
		}

		if err := s.write(entries); err != nil {
			return err
		}
		result.ID = id
		result.QueueLen = len(entries)
		return nil
	})
	if err != nil {
		return Notification{}, PushResult{}, err
	}
	return entry, result, nil
}

func sanitizeMetadata(in map[string]string) map[string]string {
	if len(in) == 0 {
		return nil
	}
	out := make(map[string]string, len(in))
	for k, v := range in {
		key := strings.TrimSpace(k)
		value := strings.TrimSpace(v)
		if key == "" || value == "" {
			continue
		}
		out[key] = value
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// List returns all pending entries in recency-desc order. Expiration metadata
// is retained for freshness displays only; explicit ack is the only removal
// path.
func (s *Store) List() ([]Notification, error) {
	var out []Notification
	err := s.withLock(func() error {
		entries, err := s.read()
		if err != nil {
			return err
		}
		out = sortRecencyDesc(entries)
		return nil
	})
	if err != nil {
		return nil, err
	}
	return out, nil
}

// AckAll removes every entry from the queue.
func (s *Store) AckAll() (int, error) {
	var removed int
	err := s.withLock(func() error {
		entries, err := s.read()
		if err != nil {
			return err
		}
		removed = len(entries)
		return s.write(nil)
	})
	if err != nil {
		return 0, err
	}
	return removed, nil
}

// Ack removes a single entry by id and returns ErrNotFound if absent. Note
// that an entry whose ttl already expired still counts as "found" because
// users may ack a stale id without first listing.
func (s *Store) Ack(id string) error {
	id = strings.TrimSpace(id)
	if id == "" {
		return ErrNotFound
	}
	return s.withLock(func() error {
		entries, err := s.read()
		if err != nil {
			return err
		}
		filtered := make([]Notification, 0, len(entries))
		found := false
		for _, e := range entries {
			if e.ID == id {
				found = true
				continue
			}
			filtered = append(filtered, e)
		}
		if !found {
			return ErrNotFound
		}
		return s.write(filtered)
	})
}

// replaceOrAppend writes entry into entries, replacing any existing entry
// with the same id. The replaced flag indicates whether a previous entry was
// overwritten (and was still live), while wasExpired indicates the previous
// entry existed but had already expired before the new push arrived.
func replaceOrAppend(entries []Notification, entry Notification, now time.Time) ([]Notification, bool, bool) {
	for i, existing := range entries {
		if existing.ID != entry.ID {
			continue
		}
		expired := !existing.ExpiresAt.IsZero() && !existing.ExpiresAt.After(now)
		entries[i] = entry
		return entries, !expired, expired
	}
	return append(entries, entry), false, false
}

// sortRecencyDesc returns a copy of entries sorted by CreatedAt descending,
// with id as a stable tiebreaker.
func sortRecencyDesc(entries []Notification) []Notification {
	out := make([]Notification, len(entries))
	copy(out, entries)
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].CreatedAt.Equal(out[j].CreatedAt) {
			return out[i].ID > out[j].ID
		}
		return out[i].CreatedAt.After(out[j].CreatedAt)
	})
	return out
}

// read parses the queue file. Missing/empty file decodes as an empty queue.
func (s *Store) read() ([]Notification, error) {
	file, err := os.Open(s.path)
	if errors.Is(err, os.ErrNotExist) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("open notify file: %w", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		return nil, fmt.Errorf("read notify file: %w", err)
	}
	if len(strings.TrimSpace(string(data))) == 0 {
		return nil, nil
	}
	var entries []Notification
	if err := json.Unmarshal(data, &entries); err != nil {
		return nil, fmt.Errorf("decode notify file: %w", err)
	}
	return entries, nil
}

// write replaces the queue file atomically.
func (s *Store) write(entries []Notification) error {
	dir := filepath.Dir(s.path)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("create notify state dir: %w", err)
	}

	if entries == nil {
		entries = []Notification{}
	}
	data, err := json.MarshalIndent(entries, "", "  ")
	if err != nil {
		return fmt.Errorf("encode notify file: %w", err)
	}
	data = append(data, '\n')

	tmp, err := os.CreateTemp(dir, "."+filepath.Base(s.path)+".tmp-*")
	if err != nil {
		return fmt.Errorf("create notify temp file: %w", err)
	}
	tmpName := tmp.Name()
	cleanup := true
	defer func() {
		if cleanup {
			_ = os.Remove(tmpName)
		}
	}()

	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("write notify temp file: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("close notify temp file: %w", err)
	}
	if err := os.Chmod(tmpName, 0o644); err != nil {
		return fmt.Errorf("chmod notify temp file: %w", err)
	}
	if err := os.Rename(tmpName, s.path); err != nil {
		return fmt.Errorf("rename notify temp file: %w", err)
	}
	cleanup = false
	return nil
}

// withLock acquires the sibling lock file via O_CREATE|O_EXCL with bounded
// retries and a jittered backoff. The lock path is removed on release. A
// stale lock older than defaultLockStaleAfter is broken to recover from a
// crashed peer.
func (s *Store) withLock(fn func() error) error {
	if err := os.MkdirAll(filepath.Dir(s.lockPath), 0o755); err != nil {
		return fmt.Errorf("create notify lock dir: %w", err)
	}

	if err := s.acquireLock(); err != nil {
		return err
	}
	defer func() {
		_ = os.Remove(s.lockPath)
	}()

	return fn()
}

func (s *Store) acquireLock() error {
	delay := defaultLockBaseDelay
	for range defaultLockMaxAttempts {
		f, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
		if err == nil {
			_, _ = fmt.Fprintf(f, "pid=%d\n", os.Getpid())
			_ = f.Close()
			return nil
		}
		if !errors.Is(err, os.ErrExist) {
			return fmt.Errorf("acquire notify lock: %w", err)
		}

		if s.tryBreakStaleLock() {
			continue
		}

		// Add jitter so multiple contenders do not synchronise.
		jitter := time.Duration(s.rng.Int63n(int64(defaultLockBaseDelay) + 1))
		time.Sleep(delay + jitter)
		if delay < defaultLockMaxDelay {
			delay *= 2
			if delay > defaultLockMaxDelay {
				delay = defaultLockMaxDelay
			}
		}
	}
	return fmt.Errorf("acquire notify lock: exhausted %d attempts on %s", defaultLockMaxAttempts, s.lockPath)
}

// tryBreakStaleLock removes the lock file if it is older than
// defaultLockStaleAfter. The check is best-effort: if the stat or remove
// fails we treat the lock as held and the caller will retry.
func (s *Store) tryBreakStaleLock() bool {
	info, err := os.Stat(s.lockPath)
	if err != nil {
		return false
	}
	if s.clock().Sub(info.ModTime()) < defaultLockStaleAfter {
		return false
	}
	if err := os.Remove(s.lockPath); err != nil {
		return false
	}
	return true
}
