package recommend

import (
	"encoding/json"
	"hash/fnv"
	"math"
	"sort"
	"strings"
	"time"
)

// WeightedConfig is intentionally data, not code: operators can tune the algorithm
// without changing its shape or retraining an opaque model.
type WeightedConfig struct {
	MinimumEvidence       int
	ExplorationRate       float64
	MaxPrimaryGenre       int
	MaxLeadPerson         int
	NewReleaseDays        int
	ImpressionFloor       int
	ImpressionPenalty     float64
	IgnoredPenalty        float64
	CompletionWeight      float64
	AbandonmentWeight     float64
	RecencyHalfLifeDays   float64
	CommunityPriorWeight  float64
	HouseholdPriorWeight  float64
	CompatibilityWeight   float64
	ContextWeight         float64
	RuntimeContextWeight  float64
	ExplicitPositiveBoost float64
	ExplicitNegativeScore float64
}

func DefaultWeightedConfig() WeightedConfig {
	return WeightedConfig{
		MinimumEvidence: 2, ExplorationRate: 0.08,
		MaxPrimaryGenre: 3, MaxLeadPerson: 2, NewReleaseDays: 180,
		ImpressionFloor: 3, ImpressionPenalty: 0.12, IgnoredPenalty: 0.28,
		CompletionWeight: 1, AbandonmentWeight: 0.55, RecencyHalfLifeDays: 45,
		CommunityPriorWeight: 0.35, HouseholdPriorWeight: 0.45,
		CompatibilityWeight: 0.7, ContextWeight: 0.8, RuntimeContextWeight: 0.65,
		ExplicitPositiveBoost: 3.5, ExplicitNegativeScore: -1_000,
	}
}

type Person struct {
	ID              string `json:"Id"`
	Name            string `json:"Name"`
	Type            string `json:"Type"`
	Role            string `json:"Role"`
	PrimaryImageTag string `json:"PrimaryImageTag"`
}

// Affinity retains its evidence count so a single accidental play cannot silently
// become a durable preference.
type Affinity struct {
	Weight   float64 `json:"weight"`
	Evidence int     `json:"evidence"`
}

type WeightedProfile struct {
	Genres           map[string]Affinity `json:"genres,omitempty"`
	Studios          map[string]Affinity `json:"studios,omitempty"`
	Actors           map[string]Affinity `json:"actors,omitempty"`
	Directors        map[string]Affinity `json:"directors,omitempty"`
	Franchises       map[string]Affinity `json:"franchises,omitempty"`
	RuntimeRanges    map[string]Affinity `json:"runtimeRanges,omitempty"`
	AgeRatings       map[string]Affinity `json:"ageRatings,omitempty"`
	CommunityRatings map[string]Affinity `json:"communityRatings,omitempty"`
	ReleasePeriods   map[string]Affinity `json:"releasePeriods,omitempty"`
	ContentTypes     map[string]Affinity `json:"contentTypes,omitempty"`

	Seen               map[string]bool    `json:"seen,omitempty"`
	ExplicitPositive   map[string]bool    `json:"explicitPositive,omitempty"`
	ExplicitNegative   map[string]bool    `json:"explicitNegative,omitempty"`
	TypicalSessionMins map[string]float64 `json:"typicalSessionMinutes,omitempty"`
	SessionEvidence    map[string]int     `json:"sessionEvidence,omitempty"`
	SourceEvents       int                `json:"sourceEvents"`
}

type ViewingEvidence struct {
	Item           Item
	Completion     float64
	Repeat         int
	OccurredAt     time.Time
	SessionMinutes int
	Favorite       bool
}

type OnboardingPreferences struct {
	Completed    bool           `json:"completed"`
	Prompted     bool           `json:"prompted,omitempty"`
	Ratings      map[string]int `json:"ratings,omitempty"`
	Genres       []string       `json:"genres,omitempty"`
	Studios      []string       `json:"studios,omitempty"`
	Actors       []string       `json:"actors,omitempty"`
	Actresses    []string       `json:"actresses,omitempty"`
	Directors    []string       `json:"directors,omitempty"`
	ContentTypes []string       `json:"contentTypes,omitempty"`
}

func (p *WeightedProfile) ApplyOnboarding(preferences OnboardingPreferences, minimumEvidence int) {
	p.ensureAffinityMaps()
	if minimumEvidence < 1 {
		minimumEvidence = 1
	}
	add := func(target map[string]Affinity, values []string) {
		for _, key := range values {
			key = normalizeDimension(key)
			if key != "" {
				target[key] = Affinity{Weight: 0.8, Evidence: minimumEvidence}
			}
		}
	}
	add(p.Genres, preferences.Genres)
	add(p.Studios, preferences.Studios)
	add(p.Actors, preferences.Actors)
	add(p.Actors, preferences.Actresses)
	add(p.Directors, preferences.Directors)
	add(p.ContentTypes, preferences.ContentTypes)
}

// ApplyOnboardingRating turns a deliberate 1–5 title rating into immediate profile
// evidence. Unlike an accidental play, an explicit rating is trusted enough to satisfy
// MinimumEvidence on its own.
func (p *WeightedProfile) ApplyOnboardingRating(item Item, rating, minimumEvidence int) {
	p.ensureAffinityMaps()
	if p.Seen == nil {
		p.Seen = map[string]bool{}
	}
	if item.ID != "" {
		p.Seen[item.ID] = true
	}
	if item.SeriesID != "" {
		p.Seen[item.SeriesID] = true
	}
	if rating < 1 || rating > 5 || rating == 3 {
		return
	}
	if minimumEvidence < 1 {
		minimumEvidence = 1
	}
	// Two stars either side of neutral maps to a strong but bounded ±2.4 signal.
	total := float64(rating-3) * 1.2
	for range minimumEvidence {
		addItemAffinities(p, item, total/float64(minimumEvidence))
	}
}

// BuildWeightedProfile treats Tracearr/Emby events as evidence. Completion, repetition
// and recency affect strength, while Affinity.Evidence enforces the repeated-pattern
// threshold at scoring time.
func BuildWeightedProfile(events []ViewingEvidence, now time.Time, location *time.Location) WeightedProfile {
	return BuildWeightedProfileWithConfig(events, now, location, DefaultWeightedConfig())
}

func BuildWeightedProfileWithConfig(
	events []ViewingEvidence,
	now time.Time,
	location *time.Location,
	cfg WeightedConfig,
) WeightedProfile {
	if cfg.MinimumEvidence < 1 {
		cfg = DefaultWeightedConfig()
	}
	p := WeightedProfile{
		Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
		Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
		Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
		AgeRatings: map[string]Affinity{}, CommunityRatings: map[string]Affinity{},
		ReleasePeriods: map[string]Affinity{},
		ContentTypes:   map[string]Affinity{}, Seen: map[string]bool{},
		ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
		TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
	}
	if location == nil {
		location = time.Local
	}
	sessionTotals := map[string]float64{}
	for _, event := range events {
		if event.Item.ID == "" {
			continue
		}
		p.SourceEvents++
		p.Seen[event.Item.ID] = event.Completion > 0
		if event.Item.SeriesID != "" && event.Completion > 0 {
			p.Seen[event.Item.SeriesID] = true
		}
		completion := clamp01(event.Completion)
		strength := evidenceStrength(completion, cfg)
		if !event.OccurredAt.IsZero() {
			ageDays := math.Max(0, now.Sub(event.OccurredAt).Hours()/24)
			strength *= math.Pow(0.5, ageDays/math.Max(1, cfg.RecencyHalfLifeDays))
		}
		if event.Repeat > 1 {
			strength *= 1 + math.Min(1.2, math.Log2(float64(event.Repeat))*0.45)
		}
		if event.Favorite {
			strength += 0.8
			p.ExplicitPositive[event.Item.ID] = true
		}
		addItemAffinities(&p, event.Item, strength)
		if event.SessionMinutes > 0 && !event.OccurredAt.IsZero() {
			slot := contextSlotKey(event.OccurredAt.In(location).Weekday(), dayPart(event.OccurredAt.In(location).Hour()))
			sessionTotals[slot] += float64(event.SessionMinutes)
			p.SessionEvidence[slot]++
		}
	}
	for slot, total := range sessionTotals {
		p.TypicalSessionMins[slot] = total / float64(p.SessionEvidence[slot])
	}
	return p
}

func evidenceStrength(completion float64, cfg WeightedConfig) float64 {
	switch {
	case completion >= 0.9:
		return cfg.CompletionWeight
	case completion >= 0.5:
		return cfg.CompletionWeight * 0.45
	case completion >= 0.15:
		return -cfg.AbandonmentWeight
	default:
		// A very brief start is weak evidence, not a strong dislike.
		return -0.08
	}
}

func addItemAffinities(p *WeightedProfile, item Item, weight float64) {
	for _, value := range item.Genres {
		addAffinity(p.Genres, value, weight)
	}
	for _, value := range item.Studios {
		addAffinity(p.Studios, value.Name, weight)
	}
	for _, person := range item.People {
		switch strings.ToLower(strings.TrimSpace(person.Type)) {
		case "actor":
			addAffinity(p.Actors, person.Name, weight*0.65)
		case "director":
			addAffinity(p.Directors, person.Name, weight*0.8)
		}
	}
	addAffinity(p.Franchises, item.Franchise(), weight*0.85)
	addAffinity(p.RuntimeRanges, runtimeRange(item.RuntimeMinutes()), weight*0.55)
	addAffinity(p.AgeRatings, item.OfficialRating, weight*0.45)
	addAffinity(p.CommunityRatings, communityRatingRange(item.CommunityRating), weight*0.35)
	addAffinity(p.ReleasePeriods, releasePeriod(item.ProductionYear), weight*0.5)
	addAffinity(p.ContentTypes, item.Type, weight*0.6)
}

// ApplyExplicitPreference lets More Like This / Not for Me influence adjacent titles.
// The item itself is always boosted/excluded; metadata still needs repeated negative
// actions before it becomes a broader dislike because normal minimum-evidence rules
// remain in force.
func (p *WeightedProfile) ApplyExplicitPreference(item Item, positive bool) {
	p.ensureAffinityMaps()
	if p.ExplicitPositive == nil {
		p.ExplicitPositive = map[string]bool{}
	}
	if p.ExplicitNegative == nil {
		p.ExplicitNegative = map[string]bool{}
	}
	if positive {
		p.ExplicitPositive[item.ID] = true
		addItemAffinities(p, item, 1.5)
		return
	}
	p.ExplicitNegative[item.ID] = true
	addItemAffinities(p, item, -1.2)
}

func (p *WeightedProfile) ensureAffinityMaps() {
	if p.Genres == nil {
		p.Genres = map[string]Affinity{}
	}
	if p.Studios == nil {
		p.Studios = map[string]Affinity{}
	}
	if p.Actors == nil {
		p.Actors = map[string]Affinity{}
	}
	if p.Directors == nil {
		p.Directors = map[string]Affinity{}
	}
	if p.Franchises == nil {
		p.Franchises = map[string]Affinity{}
	}
	if p.RuntimeRanges == nil {
		p.RuntimeRanges = map[string]Affinity{}
	}
	if p.AgeRatings == nil {
		p.AgeRatings = map[string]Affinity{}
	}
	if p.CommunityRatings == nil {
		p.CommunityRatings = map[string]Affinity{}
	}
	if p.ReleasePeriods == nil {
		p.ReleasePeriods = map[string]Affinity{}
	}
	if p.ContentTypes == nil {
		p.ContentTypes = map[string]Affinity{}
	}
}

func addAffinity(values map[string]Affinity, key string, weight float64) {
	key = normalizeDimension(key)
	if key == "" {
		return
	}
	value := values[key]
	value.Weight += weight
	value.Evidence++
	values[key] = value
}

type ItemExposure struct {
	Impressions int       `json:"impressions"`
	Focuses     int       `json:"focuses"`
	Selects     int       `json:"selects"`
	LastShown   time.Time `json:"lastShown,omitempty"`
}

type RankIntent struct {
	ID              string
	ItemTypes       []string
	UnseenOnly      bool
	NewReleasesOnly bool
	MaxRuntimeMins  int
	PreferShort     bool
	HiddenLibrary   bool
	SearchRelevance map[string]float64
	HouseholdScores map[string]float64
	Compatibility   map[string]float64
	Now             time.Time
	Location        *time.Location
}

type ScoreExplanation struct {
	Total       float64            `json:"total"`
	Components  map[string]float64 `json:"components"`
	Reasons     []string           `json:"reasonCodes"`
	Exploration bool               `json:"exploration,omitempty"`
}

type RankedItem struct {
	Item        Item
	Explanation ScoreExplanation
}

// WeightedRank applies the same scoring foundation to any page or row. RankIntent only
// changes eligibility and emphasis; it never creates a separate recommendation model.
func WeightedRank(
	profile WeightedProfile,
	candidates []Item,
	exposures map[string]ItemExposure,
	intent RankIntent,
	cfg WeightedConfig,
	limit int,
) []RankedItem {
	if cfg.MinimumEvidence < 1 {
		cfg = DefaultWeightedConfig()
	}
	now := intent.Now
	if now.IsZero() {
		now = time.Now()
	}
	type scored struct {
		item Item
		exp  ScoreExplanation
	}
	values := make([]scored, 0, len(candidates))
	seenIDs := map[string]bool{}
	for _, item := range candidates {
		if item.ID == "" || seenIDs[item.ID] || !eligibleForIntent(profile, item, intent, cfg, now) {
			continue
		}
		seenIDs[item.ID] = true
		exp := scoreWeightedItem(profile, item, exposures[item.ID], intent, cfg, now)
		if exp.Total <= cfg.ExplicitNegativeScore/2 {
			continue
		}
		values = append(values, scored{item: item, exp: exp})
	}
	sort.SliceStable(values, func(i, j int) bool {
		if values[i].exp.Total != values[j].exp.Total {
			return values[i].exp.Total > values[j].exp.Total
		}
		return values[i].item.Name < values[j].item.Name
	})

	out := make([]RankedItem, 0, minPositive(limit, len(values)))
	genreCounts, peopleCounts := map[string]int{}, map[string]int{}
	deferred := make([]scored, 0)
	for _, value := range values {
		genre := primaryGenre(value.item)
		person := leadPerson(value.item)
		if cfg.MaxPrimaryGenre > 0 && genre != "" && genreCounts[genre] >= cfg.MaxPrimaryGenre ||
			cfg.MaxLeadPerson > 0 && person != "" && peopleCounts[person] >= cfg.MaxLeadPerson {
			deferred = append(deferred, value)
			continue
		}
		out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
		genreCounts[genre]++
		peopleCounts[person]++
		if limit > 0 && len(out) == limit {
			break
		}
	}
	for _, value := range deferred {
		if limit > 0 && len(out) == limit {
			break
		}
		out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
	}
	applyExploration(out, cfg.ExplorationRate)
	return out
}

func eligibleForIntent(
	profile WeightedProfile,
	item Item,
	intent RankIntent,
	cfg WeightedConfig,
	now time.Time,
) bool {
	if profile.ExplicitNegative[item.ID] {
		return false
	}
	if intent.UnseenOnly && (profile.Seen[item.ID] || item.UserData.Played ||
		item.UserData.PlaybackPositionTicks > 0) {
		return false
	}
	if len(intent.ItemTypes) > 0 && !containsFold(intent.ItemTypes, item.Type) {
		return false
	}
	if intent.MaxRuntimeMins > 0 && item.RuntimeMinutes() > intent.MaxRuntimeMins {
		return false
	}
	if intent.NewReleasesOnly {
		released, ok := item.ReleaseDate()
		if !ok || released.After(now) || released.Before(now.AddDate(0, 0, -cfg.NewReleaseDays)) {
			return false
		}
	}
	return true
}

func scoreWeightedItem(
	profile WeightedProfile,
	item Item,
	exposure ItemExposure,
	intent RankIntent,
	cfg WeightedConfig,
	now time.Time,
) ScoreExplanation {
	c := map[string]float64{}
	reasons := []string{}
	c["genre"] = affinitySum(profile.Genres, item.Genres, cfg.MinimumEvidence)
	c["studio"] = affinitySum(profile.Studios, studioNames(item), cfg.MinimumEvidence)
	c["actor"] = affinitySum(profile.Actors, peopleNames(item, "actor"), cfg.MinimumEvidence)
	c["director"] = affinitySum(profile.Directors, peopleNames(item, "director"), cfg.MinimumEvidence)
	c["franchise"] = affinitySum(profile.Franchises, []string{item.Franchise()}, cfg.MinimumEvidence)
	c["runtime"] = affinitySum(profile.RuntimeRanges, []string{runtimeRange(item.RuntimeMinutes())}, cfg.MinimumEvidence)
	c["ageRating"] = affinitySum(profile.AgeRatings, []string{item.OfficialRating}, cfg.MinimumEvidence)
	c["communityRatingAffinity"] = affinitySum(
		profile.CommunityRatings,
		[]string{communityRatingRange(item.CommunityRating)},
		cfg.MinimumEvidence,
	)
	c["releasePeriod"] = affinitySum(profile.ReleasePeriods, []string{releasePeriod(item.ProductionYear)}, cfg.MinimumEvidence)
	c["contentType"] = affinitySum(profile.ContentTypes, []string{item.Type}, cfg.MinimumEvidence)
	c["communityRating"] = item.CommunityRating / 10 * cfg.CommunityPriorWeight
	c["household"] = intent.HouseholdScores[item.ID] * cfg.HouseholdPriorWeight
	c["compatibility"] = intent.Compatibility[item.ID] * cfg.CompatibilityWeight
	c["searchRelevance"] = intent.SearchRelevance[item.ID]
	if profile.ExplicitPositive[item.ID] {
		c["explicit"] = cfg.ExplicitPositiveBoost
		reasons = append(reasons, "explicit_more_like_this")
	}
	if exposure.Impressions >= cfg.ImpressionFloor {
		ignored := maxInt(0, exposure.Impressions-exposure.Focuses-exposure.Selects)
		c["impressionFatigue"] = -float64(exposure.Impressions-cfg.ImpressionFloor+1)*cfg.ImpressionPenalty -
			float64(ignored)*cfg.IgnoredPenalty
		reasons = append(reasons, "impression_fatigue")
	}
	slot := currentContextSlot(now, intent.Location)
	if profile.SessionEvidence[slot] >= cfg.MinimumEvidence && item.RuntimeMinutes() > 0 {
		typical := profile.TypicalSessionMins[slot]
		delta := math.Abs(float64(item.RuntimeMinutes()) - typical)
		c["sessionFit"] = math.Max(-1, 1-delta/math.Max(20, typical)) * cfg.RuntimeContextWeight
		if c["sessionFit"] > 0.25 {
			reasons = append(reasons, "fits_session_length")
		}
	}
	if intent.PreferShort && item.RuntimeMinutes() > 0 {
		c["rowIntent"] = 1 / math.Max(1, float64(item.RuntimeMinutes())/30)
	}
	if intent.HiddenLibrary && !profile.Seen[item.ID] {
		c["rowIntent"] += 0.7
		reasons = append(reasons, "relevant_unseen")
	}
	for _, key := range []string{"genre", "studio", "actor", "director", "franchise"} {
		if c[key] > 0.1 {
			reasons = append(reasons, "affinity_"+strings.ToLower(key))
		}
	}
	if profile.SourceEvents < cfg.MinimumEvidence {
		reasons = append(reasons, "cold_start_priors")
	}
	total := 0.0
	for _, value := range c {
		total += value
	}
	return ScoreExplanation{Total: total, Components: c, Reasons: uniqueStrings(reasons)}
}

// EnrichRankedItem keeps diagnostics on the backend response while retaining Emby's
// original item contract.
func EnrichRankedItem(item RankedItem) json.RawMessage {
	var payload map[string]any
	if json.Unmarshal(item.Item.Raw, &payload) != nil || payload == nil {
		payload = map[string]any{"Id": item.Item.ID, "Name": item.Item.Name, "Type": item.Item.Type}
	}
	payload["MembyRecommendationScore"] = item.Explanation.Total
	payload["MembyRecommendationComponents"] = item.Explanation.Components
	payload["MembyRecommendationReasonCodes"] = item.Explanation.Reasons
	payload["MembyExploration"] = item.Explanation.Exploration
	raw, _ := json.Marshal(payload)
	return raw
}

func affinitySum(values map[string]Affinity, keys []string, minimum int) float64 {
	score := 0.0
	for _, key := range keys {
		value := values[normalizeDimension(key)]
		if value.Evidence >= minimum {
			score += value.Weight / math.Sqrt(float64(value.Evidence))
		}
	}
	if len(keys) > 1 {
		score /= math.Sqrt(float64(len(keys)))
	}
	return score
}

func applyExploration(items []RankedItem, rate float64) {
	if len(items) < 4 || rate <= 0 {
		return
	}
	count := int(math.Round(float64(len(items)) * math.Min(0.2, rate)))
	for n := 0; n < count; n++ {
		from := len(items) - 1 - n
		to := minPositive(3+n*5, from)
		if from <= to {
			continue
		}
		value := items[from]
		copy(items[to+1:from+1], items[to:from])
		value.Explanation.Exploration = true
		value.Explanation.Reasons = append(value.Explanation.Reasons, "adjacent_exploration")
		items[to] = value
	}
}

func (i Item) Franchise() string {
	if value := strings.TrimSpace(i.CollectionName); value != "" {
		return value
	}
	// A conservative fallback only strips common sequel suffixes. It avoids inventing
	// franchises from unrelated titles that happen to share one word.
	parts := strings.Fields(i.Name)
	if len(parts) > 1 {
		last := strings.Trim(strings.ToLower(parts[len(parts)-1]), ":.-")
		if isRomanNumeral(last) || strings.HasPrefix(last, "part") {
			return strings.Join(parts[:len(parts)-1], " ")
		}
	}
	return ""
}

func (i Item) ReleaseDate() (time.Time, bool) {
	for _, value := range []string{i.PremiereDate, i.DateCreated} {
		if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)); err == nil {
			return parsed, true
		}
	}
	if i.ProductionYear > 0 {
		return time.Date(i.ProductionYear, 1, 1, 0, 0, 0, 0, time.UTC), true
	}
	return time.Time{}, false
}

func runtimeRange(minutes int) string {
	switch {
	case minutes <= 0:
		return ""
	case minutes <= 25:
		return "short"
	case minutes <= 50:
		return "episode"
	case minutes <= 100:
		return "feature"
	case minutes <= 150:
		return "long-feature"
	default:
		return "epic"
	}
}

func releasePeriod(year int) string {
	switch {
	case year <= 0:
		return ""
	case year < 1980:
		return "classic"
	case year < 2000:
		return "1980s-1990s"
	case year < 2015:
		return "2000s-early-2010s"
	default:
		return "recent"
	}
}

func communityRatingRange(rating float64) string {
	switch {
	case rating <= 0:
		return ""
	case rating < 6:
		return "under-6"
	case rating < 7.5:
		return "6-to-7.4"
	case rating < 8.5:
		return "7.5-to-8.4"
	default:
		return "8.5-plus"
	}
}

func currentContextSlot(now time.Time, location *time.Location) string {
	if location != nil {
		now = now.In(location)
	}
	return contextSlotKey(now.Weekday(), dayPart(now.Hour()))
}

func normalizeDimension(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func studioNames(item Item) []string {
	out := make([]string, 0, len(item.Studios))
	for _, value := range item.Studios {
		out = append(out, value.Name)
	}
	return out
}
func peopleNames(item Item, kind string) []string {
	out := []string{}
	for _, value := range item.People {
		if strings.EqualFold(value.Type, kind) {
			out = append(out, value.Name)
		}
	}
	return out
}
func primaryGenre(item Item) string {
	if len(item.Genres) == 0 {
		return ""
	}
	return normalizeDimension(item.Genres[0])
}
func leadPerson(item Item) string {
	for _, value := range item.People {
		if strings.EqualFold(value.Type, "actor") {
			return normalizeDimension(value.Name)
		}
	}
	return ""
}
func containsFold(values []string, wanted string) bool {
	for _, value := range values {
		if strings.EqualFold(value, wanted) {
			return true
		}
	}
	return false
}
func uniqueStrings(values []string) []string {
	seen, out := map[string]bool{}, []string{}
	for _, value := range values {
		if value != "" && !seen[value] {
			seen[value] = true
			out = append(out, value)
		}
	}
	return out
}
func isRomanNumeral(value string) bool {
	if value == "" {
		return false
	}
	for _, r := range value {
		if !strings.ContainsRune("ivxlcdm", r) {
			return false
		}
	}
	return true
}
func minPositive(a, b int) int {
	if a <= 0 || b < a {
		return b
	}
	return a
}
func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}

// stableFraction is kept for deterministic future exploration bucketing.
func stableFraction(value string) float64 {
	h := fnv.New32a()
	_, _ = h.Write([]byte(value))
	return float64(h.Sum32()) / float64(math.MaxUint32)
}
