package intelligence

import (
	"context"
	"database/sql"
	"errors"
	"math/rand/v2"
	"sort"
	"time"
)

// DiskForecast is the Monte Carlo disk-fill projection.
type DiskForecast struct {
	CurrentBytes      int64   `json:"current_bytes"`
	CapacityBytes     int64   `json:"capacity_bytes"`
	DailyGrabRate     float64 `json:"daily_grab_rate"`     // mean grabs/day over the sample window
	MeanGrabSizeBytes int64   `json:"mean_grab_size_bytes"`
	HistorySamples    int     `json:"history_samples"`
	P10DaysToFull     int     `json:"p10_days_to_full"` // pessimistic (10th percentile)
	P50DaysToFull     int     `json:"p50_days_to_full"` // median
	P90DaysToFull     int     `json:"p90_days_to_full"` // optimistic
	Trials            int     `json:"trials"`
}

// ForecastConfig tunes the projection.
type ForecastConfig struct {
	// HistoryDays is how far back to sample empirical grab rate + sizes.
	HistoryDays int
	// Trials is the Monte Carlo trial count. PROJECT.md §8.2 hints 1000.
	Trials int
	// HorizonDays caps simulation length per trial (avoids unbounded loops
	// when grab rate is near zero).
	HorizonDays int
	// CapacityBytes is the disk capacity. Phase 7 requires this from the
	// caller (e.g. operator config); Phase 15 may auto-detect.
	CapacityBytes int64
	// CurrentBytes is current disk usage. Same caveat.
	CurrentBytes int64
	// Seed produces deterministic runs in tests.
	Seed uint64
}

// Forecast runs Trials simulations and returns the percentile spread.
//
// Each trial draws a per-day grab count from a Poisson approximation of the
// empirical rate and a per-grab size from the empirical distribution
// (uniform pick among observed sizes). The loop stops when the trial's
// projected usage crosses CapacityBytes OR the horizon expires.
func Forecast(ctx context.Context, db *sql.DB, cfg ForecastConfig) (DiskForecast, error) {
	if cfg.CapacityBytes <= 0 {
		return DiskForecast{}, errors.New("forecast: capacity_bytes required")
	}
	if cfg.HistoryDays <= 0 {
		cfg.HistoryDays = 30
	}
	if cfg.Trials <= 0 {
		cfg.Trials = 1000
	}
	if cfg.HorizonDays <= 0 {
		cfg.HorizonDays = 365
	}

	cutoff := time.Now().Add(-time.Duration(cfg.HistoryDays) * 24 * time.Hour).Unix()

	// Collect empirical sizes of grabs that were linked to a torrent (so
	// we know the size). filter_performance.info_hash JOIN torrents.size_bytes.
	rows, err := db.QueryContext(ctx, `
		SELECT t.size_bytes
		FROM filter_performance fp
		JOIN torrents t ON t.info_hash = fp.info_hash
		WHERE fp.grabbed_at >= ? AND t.size_bytes > 0
	`, cutoff)
	if err != nil {
		return DiskForecast{}, err
	}
	defer rows.Close()
	var sizes []int64
	for rows.Next() {
		var s int64
		if err := rows.Scan(&s); err != nil {
			return DiskForecast{}, err
		}
		sizes = append(sizes, s)
	}

	if len(sizes) == 0 {
		// No history → no forecast.
		return DiskForecast{
			CurrentBytes:   cfg.CurrentBytes,
			CapacityBytes:  cfg.CapacityBytes,
			HistorySamples: 0,
			Trials:         cfg.Trials,
		}, nil
	}

	// Empirical daily rate: grabs / history window.
	dailyRate := float64(len(sizes)) / float64(cfg.HistoryDays)
	var totalSize int64
	for _, s := range sizes {
		totalSize += s
	}
	meanSize := totalSize / int64(len(sizes))

	seed := cfg.Seed
	if seed == 0 {
		seed = uint64(time.Now().UnixNano())
	}
	rng := rand.New(rand.NewPCG(seed, seed^0x9E3779B97F4A7C15))

	trialResults := make([]int, 0, cfg.Trials)
	remainingCapacity := cfg.CapacityBytes - cfg.CurrentBytes
	if remainingCapacity <= 0 {
		// Already full. Return a trivial forecast.
		return DiskForecast{
			CurrentBytes:      cfg.CurrentBytes,
			CapacityBytes:     cfg.CapacityBytes,
			DailyGrabRate:     dailyRate,
			MeanGrabSizeBytes: meanSize,
			HistorySamples:    len(sizes),
			P10DaysToFull:     0,
			P50DaysToFull:     0,
			P90DaysToFull:     0,
			Trials:            cfg.Trials,
		}, nil
	}

	for trial := 0; trial < cfg.Trials; trial++ {
		var consumed int64
		var day int
		for day = 0; day < cfg.HorizonDays && consumed < remainingCapacity; day++ {
			// Poisson(dailyRate) approximation via cumulative-knuth for
			// small means (dailyRate typically << 50 at single-operator
			// scale).
			grabs := poisson(rng, dailyRate)
			for i := 0; i < grabs; i++ {
				idx := rng.IntN(len(sizes))
				consumed += sizes[idx]
				if consumed >= remainingCapacity {
					break
				}
			}
		}
		trialResults = append(trialResults, day)
	}

	sort.Ints(trialResults)
	p10 := trialResults[int(0.10*float64(len(trialResults)))]
	p50 := trialResults[int(0.50*float64(len(trialResults)))]
	p90 := trialResults[int(0.90*float64(len(trialResults)))]

	return DiskForecast{
		CurrentBytes:      cfg.CurrentBytes,
		CapacityBytes:     cfg.CapacityBytes,
		DailyGrabRate:     dailyRate,
		MeanGrabSizeBytes: meanSize,
		HistorySamples:    len(sizes),
		P10DaysToFull:     p10,
		P50DaysToFull:     p50,
		P90DaysToFull:     p90,
		Trials:            cfg.Trials,
	}, nil
}

// poisson returns a Poisson-distributed integer with parameter λ using
// Knuth's accumulating-product method. Sufficient for λ < ~30; switches
// to a normal approximation above that to keep iterations bounded.
func poisson(rng *rand.Rand, lambda float64) int {
	if lambda <= 0 {
		return 0
	}
	if lambda < 30 {
		L := mathExp(-lambda)
		k := 0
		p := 1.0
		for p > L {
			k++
			p *= rng.Float64()
		}
		return k - 1
	}
	// Normal approximation for large λ.
	x := rng.NormFloat64()*mathSqrt(lambda) + lambda
	if x < 0 {
		return 0
	}
	return int(x + 0.5)
}

// mathExp and mathSqrt avoid pulling in math when the only need is two
// scalar ops; the Go compiler inlines these. Using math.Exp / math.Sqrt
// directly is fine — the redirect keeps the import surface minimal.
func mathExp(x float64) float64 { return _exp(x) }
func mathSqrt(x float64) float64 { return _sqrt(x) }
