package ratelimit

import (
	"sort"
	"sync"
	"time"
)

// Reason labels why a client IP was banned; it is the reason attribute of the
// gateway_abuse_banned_total metric and a field of the ban report to the backend.
type Reason string

const (
	// ReasonRejections is a ban earned by sustained rate-limiter rejections: the IP
	// accumulated BanConfig.Threshold strikes within BanConfig.Window.
	ReasonRejections Reason = "rejections"
	// ReasonTripwire is an instant ban from a honeypot decoy-path hit.
	ReasonTripwire Reason = "tripwire"
	// ReasonHoneytoken is an instant ban from a planted credential being presented.
	ReasonHoneytoken Reason = "honeytoken"
)

// Ban durations for the high-confidence reasons. A rejection ban uses the
// configured BanConfig.Duration; a tripwire or honeytoken hit is near
// zero-false-positive, so it earns a markedly longer ban.
const (
	tripwireBanDuration   = time.Hour
	honeytokenBanDuration = 24 * time.Hour
)

// BanConfig tunes the temporary IP ban. Enabled gates the whole mechanism — kept
// off where the real client IP is not visible (e.g. every client arriving as one
// shared NAT address); when false every method is inert.
type BanConfig struct {
	// Enabled turns enforcement on. While false Strike/BanNow record nothing,
	// Banned is always false and Active is empty.
	Enabled bool
	// Threshold is the strike count within Window that earns a rejection ban.
	Threshold int
	// Window is the rolling window the strikes accumulate over.
	Window time.Duration
	// Duration is the length of a rejection ban (tripwire/honeytoken use their own).
	Duration time.Duration
}

// Ban is a snapshot of one active ban for the periodic report and the admin view.
// Its JSON shape is the gateway→backend ban-sync wire contract.
type Ban struct {
	IP      string    `json:"ip"`
	Reason  Reason    `json:"reason"`
	Since   time.Time `json:"since"`
	Expires time.Time `json:"expires"`
}

// Banlist is the gateway's in-memory temporary IP ban: a fail2ban-style block fed
// by sustained rate-limiter rejections (Strike) and by instant honeypot /
// honeytoken hits (BanNow), enforced on the hot path by Banned and cleared either
// by lapse or by an operator Unban. Entries are swept lazily so an expired ban
// does not leak memory. Like the rate limiter it is single-instance and resets on
// restart by design.
type Banlist struct {
	cfg BanConfig
	now func() time.Time

	mu        sync.Mutex
	entries   map[string]*banEntry
	lastSweep time.Time
}

// banEntry is one IP's ban state: a live ban (until set) or, before the
// threshold, only the recent strike times.
type banEntry struct {
	reason  Reason
	since   time.Time
	until   time.Time   // zero while only accumulating strikes
	strikes []time.Time // strike times within the window; pruned on each strike
}

// NewBanlist constructs a Banlist with cfg.
func NewBanlist(cfg BanConfig) *Banlist {
	return &Banlist{cfg: cfg, now: time.Now, entries: make(map[string]*banEntry)}
}

// Strike records one rate-limiter rejection for ip and reports whether it earned a
// ban (Threshold strikes within Window). It is a no-op on a disabled banlist.
func (b *Banlist) Strike(ip string) bool {
	if !b.cfg.Enabled {
		return false
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	now := b.now()
	b.sweepLocked(now)
	e := b.entries[ip]
	if e == nil {
		e = &banEntry{}
		b.entries[ip] = e
	}
	if now.Before(e.until) {
		return false // already banned (a banned IP normally never reaches here)
	}
	cutoff := now.Add(-b.cfg.Window)
	kept := e.strikes[:0]
	for _, t := range e.strikes {
		if t.After(cutoff) {
			kept = append(kept, t)
		}
	}
	e.strikes = append(kept, now)
	if len(e.strikes) >= b.cfg.Threshold {
		e.reason = ReasonRejections
		e.since = now
		e.until = now.Add(b.cfg.Duration)
		e.strikes = nil
		return true
	}
	return false
}

// BanNow bans ip immediately for reason and reports whether a ban is now in effect
// (true on an enabled banlist, false when disabled).
func (b *Banlist) BanNow(ip string, reason Reason) bool {
	if !b.cfg.Enabled {
		return false
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	now := b.now()
	b.sweepLocked(now)
	e := b.entries[ip]
	if e == nil {
		e = &banEntry{}
		b.entries[ip] = e
	}
	e.reason = reason
	e.since = now
	e.until = now.Add(banDuration(reason, b.cfg.Duration))
	e.strikes = nil
	return true
}

// Banned reports whether ip is currently banned. It is always false on a disabled
// banlist.
func (b *Banlist) Banned(ip string) bool {
	if !b.cfg.Enabled {
		return false
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	e := b.entries[ip]
	return e != nil && b.now().Before(e.until)
}

// Unban clears any ban and accumulated strikes for ip.
func (b *Banlist) Unban(ip string) {
	b.mu.Lock()
	defer b.mu.Unlock()
	delete(b.entries, ip)
}

// Active returns a snapshot of the currently-banned IPs, most recently banned
// first. It is empty on a disabled banlist.
func (b *Banlist) Active() []Ban {
	if !b.cfg.Enabled {
		return nil
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	now := b.now()
	out := make([]Ban, 0, len(b.entries))
	for ip, e := range b.entries {
		if now.Before(e.until) {
			out = append(out, Ban{IP: ip, Reason: e.reason, Since: e.since, Expires: e.until})
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
	return out
}

// banDuration maps a reason to its ban length.
func banDuration(reason Reason, rejectionDuration time.Duration) time.Duration {
	switch reason {
	case ReasonTripwire:
		return tripwireBanDuration
	case ReasonHoneytoken:
		return honeytokenBanDuration
	default:
		return rejectionDuration
	}
}

// sweepLocked discards lapsed bans and stale strike-only entries, at most once per
// sweepInterval. The caller holds b.mu.
func (b *Banlist) sweepLocked(now time.Time) {
	if now.Sub(b.lastSweep) < sweepInterval {
		return
	}
	b.lastSweep = now
	cutoff := now.Add(-b.cfg.Window)
	for ip, e := range b.entries {
		if !e.until.IsZero() {
			if !now.Before(e.until) {
				delete(b.entries, ip) // ban lapsed
			}
			continue
		}
		if len(e.strikes) == 0 || !e.strikes[len(e.strikes)-1].After(cutoff) {
			delete(b.entries, ip) // strike-only entry gone stale
		}
	}
}
