package ratelimit

import (
	"bufio"
	"encoding/binary"
	"fmt"
	"io"
	"net"
	"sort"
	"strings"
	"sync"
	"time"
)

// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against.
type ipRange struct{ lo, hi uint32 }

// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced
// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an
// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is
// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it).
// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false.
type Blocklist struct {
	enabled bool
	allow   []ipRange // sorted, non-overlapping; from config, immutable after construction

	mu        sync.RWMutex
	ranges    []ipRange // sorted, non-overlapping; the current feed
	fetchedAt time.Time // last successful SetCIDRs; zero = never loaded
}

// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set
// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it.
func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist {
	return &Blocklist{enabled: enabled, allow: toRanges(allow)}
}

// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is
// false on a disabled or empty blocklist, and false for any non-IPv4 address.
func (b *Blocklist) Blocked(ip string) bool {
	if !b.enabled {
		return false
	}
	v, ok := ipv4ToUint32(ip)
	if !ok {
		return false
	}
	b.mu.RLock()
	defer b.mu.RUnlock()
	if len(b.ranges) == 0 || rangesContain(b.allow, v) {
		return false
	}
	return rangesContain(b.ranges, v)
}

// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored.
func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) {
	r := toRanges(cidrs)
	b.mu.Lock()
	b.ranges = r
	b.fetchedAt = at
	b.mu.Unlock()
}

// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps
// climbing. Blocked then returns false until a fresh feed loads.
func (b *Blocklist) Clear() {
	b.mu.Lock()
	b.ranges = nil
	b.mu.Unlock()
}

// Len returns the number of ranges currently enforced.
func (b *Blocklist) Len() int {
	b.mu.RLock()
	defer b.mu.RUnlock()
	return len(b.ranges)
}

// LastFetch returns the time of the last successful feed load (zero if never).
func (b *Blocklist) LastFetch() time.Time {
	b.mu.RLock()
	defer b.mu.RUnlock()
	return b.fetchedAt
}

// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics.
type RefreshOutcome int

const (
	// RefreshUpdated: a new feed was fetched and applied.
	RefreshUpdated RefreshOutcome = iota
	// RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept.
	RefreshKept
	// RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open).
	RefreshDropped
)

// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it
// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open
// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock.
func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome {
	if fetchErr == nil {
		bl.SetCIDRs(cidrs, now)
		return RefreshUpdated
	}
	last := bl.LastFetch()
	if last.IsZero() || now.Sub(last) > maxStaleness {
		bl.Clear()
		return RefreshDropped
	}
	return RefreshKept
}

// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail,
// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and
// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed.
func ParseDROP(r io.Reader) ([]*net.IPNet, error) {
	var out []*net.IPNet
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
	for sc.Scan() {
		line := sc.Text()
		if i := strings.IndexByte(line, ';'); i >= 0 {
			line = line[:i]
		}
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		if !strings.Contains(line, "/") {
			line += "/32"
		}
		_, ipnet, err := net.ParseCIDR(line)
		if err != nil || ipnet.IP.To4() == nil {
			continue
		}
		out = append(out, ipnet)
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("ratelimit: read blocklist: %w", err)
	}
	return out, nil
}

// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs
// are dropped. Sorting by lo lets [rangesContain] binary-search.
func toRanges(cidrs []*net.IPNet) []ipRange {
	out := make([]ipRange, 0, len(cidrs))
	for _, n := range cidrs {
		if n == nil {
			continue
		}
		ip4 := n.IP.To4()
		if ip4 == nil {
			continue
		}
		ones, bits := n.Mask.Size()
		if bits != 32 {
			continue
		}
		lo := binary.BigEndian.Uint32(ip4)
		hi := lo | (uint32(0xffffffff) >> uint(ones))
		out = append(out, ipRange{lo: lo, hi: hi})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo })
	return out
}

// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a
// binary search for the last range whose lo is at most v.
func rangesContain(ranges []ipRange, v uint32) bool {
	i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v })
	return i > 0 && ranges[i-1].hi >= v
}

// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4.
func ipv4ToUint32(s string) (uint32, bool) {
	ip := net.ParseIP(s)
	if ip == nil {
		return 0, false
	}
	ip4 := ip.To4()
	if ip4 == nil {
		return 0, false
	}
	return binary.BigEndian.Uint32(ip4), true
}
