package crashpool

import (
	"encoding/json"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"time"
)

// QuarantineEntry records one file that crashed, hung, or panicked the
// parser.
type QuarantineEntry struct {
	RelPath string `json:"rel_path"`
	Reason  string `json:"reason"`
	// MtimeNano pins the file revision that failed. When the file's
	// current mtime differs the file is treated as changed and gets
	// one retry instead of being skipped.
	MtimeNano int64  `json:"mtime_nano"`
	FirstSeen string `json:"first_seen"`
	LastSeen  string `json:"last_seen"`
	Attempts  int    `json:"attempts"`
}

// Quarantine is a persistent set of files that have crashed parsing. It
// is JSON-backed so a file that SIGSEGVs the parser survives daemon
// restarts and is skipped until its content changes.
//
// The zero value is not usable; construct with LoadQuarantine. A nil
// *Quarantine is safe for every method (no-op / not-quarantined), so
// callers can keep crash isolation optional without nil checks.
type Quarantine struct {
	mu      sync.Mutex
	path    string
	entries map[string]*QuarantineEntry
}

// LoadQuarantine reads the quarantine file at path, tolerating a
// missing or corrupt file (treated as empty). path is where Save will
// persist; an empty path makes Save a no-op (in-memory only).
func LoadQuarantine(path string) *Quarantine {
	q := &Quarantine{path: path, entries: map[string]*QuarantineEntry{}}
	if path == "" {
		return q
	}
	data, err := os.ReadFile(path) //nolint:gosec // path is daemon-internal
	if err != nil {
		return q
	}
	var list []*QuarantineEntry
	if json.Unmarshal(data, &list) != nil {
		return q
	}
	for _, e := range list {
		if e != nil && e.RelPath != "" {
			q.entries[e.RelPath] = e
		}
	}
	return q
}

// IsQuarantined reports whether relPath is a known-bad file at the
// given mtime. It returns true only when the file is unchanged since
// it failed — a changed file (different mtime) is not quarantined so
// it gets retried.
func (q *Quarantine) IsQuarantined(relPath string, mtimeNano int64) bool {
	if q == nil {
		return false
	}
	q.mu.Lock()
	defer q.mu.Unlock()
	e, ok := q.entries[relPath]
	return ok && e.MtimeNano == mtimeNano
}

// Add records relPath as quarantined. Re-adding bumps the attempt
// count and refreshes the mtime + reason.
func (q *Quarantine) Add(relPath, reason string, mtimeNano int64) {
	if q == nil || relPath == "" {
		return
	}
	now := time.Now().UTC().Format(time.RFC3339)
	q.mu.Lock()
	defer q.mu.Unlock()
	if e, ok := q.entries[relPath]; ok {
		e.Reason = reason
		e.MtimeNano = mtimeNano
		e.LastSeen = now
		e.Attempts++
		return
	}
	q.entries[relPath] = &QuarantineEntry{
		RelPath:   relPath,
		Reason:    reason,
		MtimeNano: mtimeNano,
		FirstSeen: now,
		LastSeen:  now,
		Attempts:  1,
	}
}

// Forget drops relPath from the quarantine — used when a previously
// bad file parses cleanly on retry.
func (q *Quarantine) Forget(relPath string) {
	if q == nil {
		return
	}
	q.mu.Lock()
	defer q.mu.Unlock()
	delete(q.entries, relPath)
}

// Entries returns a stable, sorted snapshot of the quarantine.
func (q *Quarantine) Entries() []QuarantineEntry {
	if q == nil {
		return nil
	}
	q.mu.Lock()
	defer q.mu.Unlock()
	out := make([]QuarantineEntry, 0, len(q.entries))
	for _, e := range q.entries {
		out = append(out, *e)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].RelPath < out[j].RelPath })
	return out
}

// Len returns the number of quarantined files.
func (q *Quarantine) Len() int {
	if q == nil {
		return 0
	}
	q.mu.Lock()
	defer q.mu.Unlock()
	return len(q.entries)
}

// Save persists the quarantine to its backing file. A no-op when the
// quarantine was loaded with an empty path.
func (q *Quarantine) Save() error {
	if q == nil || q.path == "" {
		return nil
	}
	entries := q.Entries()
	data, err := json.MarshalIndent(entries, "", "  ")
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(q.path), 0o755); err != nil {
		return err
	}
	// Unique temp name so concurrent Saves — the indexer re-indexes
	// files in parallel through one shared quarantine — never clobber
	// each other's in-flight write before the rename.
	f, err := os.CreateTemp(filepath.Dir(q.path), ".quarantine-*.tmp")
	if err != nil {
		return err
	}
	tmp := f.Name()
	if _, err := f.Write(data); err != nil {
		_ = f.Close()
		_ = os.Remove(tmp)
		return err
	}
	if err := f.Close(); err != nil {
		_ = os.Remove(tmp)
		return err
	}
	return os.Rename(tmp, q.path)
}
