package recommend

import (
	"math"
	"strings"
	"time"
)

// ContextAffinityProfile captures what a viewer tends to watch in broad local-time
// windows. It is intentionally compact: seven weekdays by four day parts is enough to
// learn household routines without pretending that a small history is a precise model.
type ContextAffinityProfile struct {
	Slots map[string]ContextAffinityBucket `json:"slots,omitempty"`
}

type ContextAffinityBucket struct {
	Samples       int                `json:"samples"`
	GenreWeights  map[string]float64 `json:"genres,omitempty"`
	StudioWeights map[string]float64 `json:"studios,omitempty"`
}

func NewContextAffinityProfile() ContextAffinityProfile {
	return ContextAffinityProfile{Slots: map[string]ContextAffinityBucket{}}
}

// Add records one matched Tracearr session. Completion and recency determine how much
// taste evidence it contributes, while Samples controls confidence separately.
func (p *ContextAffinityProfile) Add(
	item Item,
	started time.Time,
	completion float64,
	recencyPosition int,
	location *time.Location,
) {
	if started.IsZero() {
		return
	}
	if p.Slots == nil {
		p.Slots = map[string]ContextAffinityBucket{}
	}
	if location == nil {
		location = time.Local
	}
	local := started.In(location)
	key := contextSlotKey(local.Weekday(), dayPart(local.Hour()))
	bucket := p.Slots[key]
	if bucket.GenreWeights == nil {
		bucket.GenreWeights = map[string]float64{}
	}
	if bucket.StudioWeights == nil {
		bucket.StudioWeights = map[string]float64{}
	}
	bucket.Samples++
	weight := (0.2 + clamp01(completion)) * math.Pow(0.985, float64(recencyPosition))
	for _, genre := range item.Genres {
		if genre = strings.TrimSpace(genre); genre != "" {
			bucket.GenreWeights[genre] += weight
		}
	}
	for _, studio := range item.Studios {
		if name := strings.TrimSpace(studio.Name); name != "" {
			bucket.StudioWeights[name] += weight * 0.4
		}
	}
	p.Slots[key] = bucket
}

// Score returns a bounded contextual affinity source score and its confidence. Exact
// weekday/time behavior matters most; neighboring time windows and the same time on
// other days provide progressively weaker fallbacks. No match simply returns zero.
func (p ContextAffinityProfile) Score(
	item Item,
	now time.Time,
	location *time.Location,
) (float64, float64) {
	if len(p.Slots) == 0 {
		return 0, 0
	}
	if location == nil {
		location = time.Local
	}
	local := now.In(location)
	part := dayPart(local.Hour())
	var score, sampleWeight float64
	for key, bucket := range p.Slots {
		weekday, bucketPart, ok := parseContextSlotKey(key)
		if !ok {
			continue
		}
		weight := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
		if weight == 0 {
			continue
		}
		var affinity float64
		for _, genre := range item.Genres {
			affinity += weightFold(bucket.GenreWeights, genre)
		}
		if n := len(item.Genres); n > 1 {
			affinity /= math.Sqrt(float64(n))
		}
		for _, studio := range item.Studios {
			affinity += weightFold(bucket.StudioWeights, studio.Name)
		}
		score += affinity * weight
		sampleWeight += float64(bucket.Samples) * weight
	}
	if sampleWeight == 0 {
		return 0, 0
	}
	// Five effective sessions are enough for the full (still bounded) contextual nudge.
	return score / sampleWeight, math.Min(1, sampleWeight/5)
}

// Contextualized returns a copy of the base profile with a modest current-time taste
// nudge. It is used by dynamic shelves; the prepared For You pool applies the same
// signal directly to candidate placement.
func (p ContextAffinityProfile) Contextualized(
	base Profile,
	now time.Time,
	location *time.Location,
) Profile {
	out := base
	out.GenreWeights = cloneWeights(base.GenreWeights)
	out.StudioWeights = cloneWeights(base.StudioWeights)
	probe := Item{Genres: keysFromWeights(out.GenreWeights)}
	_, confidence := p.Score(probe, now, location)
	if confidence == 0 {
		return out
	}
	local := now
	if location != nil {
		local = now.In(location)
	}
	part := dayPart(local.Hour())
	for key, bucket := range p.Slots {
		weekday, bucketPart, ok := parseContextSlotKey(key)
		if !ok {
			continue
		}
		similarity := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
		if similarity == 0 {
			continue
		}
		scale := 0.35 * confidence * similarity / math.Max(1, float64(bucket.Samples))
		for genre, weight := range bucket.GenreWeights {
			out.GenreWeights[genre] += weight * scale
		}
		for studio, weight := range bucket.StudioWeights {
			out.StudioWeights[studio] += weight * scale
		}
	}
	return out
}

func contextSlotSimilarity(currentDay time.Weekday, currentPart int, day time.Weekday, part int) float64 {
	dayDistance := int(currentDay) - int(day)
	if dayDistance < 0 {
		dayDistance = -dayDistance
	}
	if dayDistance > 3 {
		dayDistance = 7 - dayDistance
	}
	partDistance := currentPart - part
	if partDistance < 0 {
		partDistance = -partDistance
	}
	switch {
	case dayDistance == 0 && partDistance == 0:
		return 1
	case dayDistance == 0 && partDistance == 1:
		return 0.35
	case dayDistance == 1 && partDistance == 0:
		return 0.25
	case partDistance == 0:
		return 0.12
	default:
		return 0
	}
}

func dayPart(hour int) int {
	switch {
	case hour >= 5 && hour < 11:
		return 0
	case hour >= 11 && hour < 17:
		return 1
	case hour >= 17 && hour < 22:
		return 2
	default:
		return 3
	}
}

func contextSlotKey(day time.Weekday, part int) string {
	return string(rune('0'+day)) + ":" + string(rune('0'+part))
}

func parseContextSlotKey(key string) (time.Weekday, int, bool) {
	if len(key) != 3 || key[1] != ':' || key[0] < '0' || key[0] > '6' ||
		key[2] < '0' || key[2] > '3' {
		return 0, 0, false
	}
	return time.Weekday(key[0] - '0'), int(key[2] - '0'), true
}

func clamp01(value float64) float64 {
	if value < 0 {
		return 0
	}
	if value > 1 {
		return 1
	}
	return value
}

func cloneWeights(source map[string]float64) map[string]float64 {
	out := make(map[string]float64, len(source))
	for key, value := range source {
		out[key] = value
	}
	return out
}

func keysFromWeights(source map[string]float64) []string {
	out := make([]string, 0, len(source))
	for key := range source {
		out = append(out, key)
	}
	return out
}
