package storage

import (
	"database/sql"
	"errors"
	"math"
	"sort"
	"strings"
)

// Ranking weights for the "For you" feed. Bookmarks are a stronger signal of
// interest than a plain read, and channel affinity outweighs source affinity
// (a reader follows topics more than outlets). Recency keeps the feed fresh so
// a heavily-read channel doesn't surface week-old stories over today's news.
const (
	affinityReadWeight     = 1.0
	affinityBookmarkWeight = 3.0

	forYouChannelWeight = 1.0
	forYouSourceWeight  = 0.7
	forYouRecencyWeight = 0.9

	// forYouCandidatePool bounds how many recent unread stories we score in Go.
	forYouCandidatePool = 400
	// forYouRecencyHalfLifeHours sets how fast the recency term decays.
	forYouRecencyHalfLifeHours = 48.0
)

// userAffinity builds normalized channel and source affinity scores (each 0..1)
// for a signed-in user from their read + bookmark history. Bookmarks count for
// more than plain reads. total is the number of signal-bearing rows; when it is
// zero the user has no history yet and both maps are empty.
func userAffinity(sub string) (channel, source map[string]float64, total int, err error) {
	channel = make(map[string]float64)
	source = make(map[string]float64)
	if sub == "" {
		return channel, source, 0, nil
	}
	rows, err := Get().Query(
		`SELECT s.channel, s.source, u.read_at, u.bookmarked_at
		 FROM user_story_state u
		 JOIN stories s ON s.id = u.story_id
		 WHERE u.user_sub = ?
		   AND s.channel IS NOT NULL
		   AND s.channel NOT IN ('_discarded', '_duplicate')`, sub)
	if err != nil {
		return nil, nil, 0, err
	}
	defer rows.Close()
	for rows.Next() {
		var ch, src string
		var readAt, markAt sql.NullInt64
		if err := rows.Scan(&ch, &src, &readAt, &markAt); err != nil {
			return nil, nil, 0, err
		}
		w := 0.0
		if readAt.Valid {
			w += affinityReadWeight
		}
		if markAt.Valid {
			w += affinityBookmarkWeight
		}
		if w == 0 {
			continue
		}
		channel[ch] += w
		source[src] += w
		total++
	}
	if err := rows.Err(); err != nil {
		return nil, nil, 0, err
	}
	normalizeMax(channel)
	normalizeMax(source)
	return channel, source, total, nil
}

// normalizeMax scales a map's values so the largest becomes 1.0, leaving an
// all-zero (or empty) map untouched.
func normalizeMax(m map[string]float64) {
	var max float64
	for _, v := range m {
		if v > max {
			max = v
		}
	}
	if max == 0 {
		return
	}
	for k := range m {
		m[k] /= max
	}
}

// ForYou returns a personalized ranking of recent, unread stories for a
// signed-in user, blending channel affinity, source affinity, and recency. It
// returns (nil, nil) when the user has no read/bookmark history yet, so callers
// can fall back to the plain latest feed.
func ForYou(sub string, limit int) ([]Story, error) {
	if limit <= 0 {
		return nil, nil
	}
	chAff, srcAff, total, err := userAffinity(sub)
	if err != nil {
		return nil, err
	}
	if total == 0 {
		return nil, nil
	}
	rows, err := Get().Query(
		`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, COALESCE(s.published_at, s.seen_at),
		        EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
		 FROM stories s
		 WHERE s.classified = 1
		   AND s.channel IS NOT NULL
		   AND s.channel NOT IN ('_discarded', '_duplicate')
		   AND s.id NOT IN (
		        SELECT story_id FROM user_story_state
		        WHERE user_sub = ? AND read_at IS NOT NULL)
		 ORDER BY COALESCE(s.published_at, s.seen_at) DESC
		 LIMIT ?`, sub, forYouCandidatePool)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	now := float64(nowUnix())
	type scored struct {
		s     Story
		score float64
	}
	var cands []scored
	for rows.Next() {
		var s Story
		var effectiveAt int64
		if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &effectiveAt, &s.Posted); err != nil {
			return nil, err
		}
		ageHours := (now - float64(effectiveAt)) / 3600.0
		if ageHours < 0 {
			ageHours = 0
		}
		recency := math.Exp(-ageHours / forYouRecencyHalfLifeHours)
		score := forYouChannelWeight*chAff[s.Channel] +
			forYouSourceWeight*srcAff[s.Source] +
			forYouRecencyWeight*recency
		cands = append(cands, scored{s, score})
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	// Candidates arrive newest-first, and a stable sort keeps that order for
	// equal scores, so ties break toward the more recent story.
	sort.SliceStable(cands, func(i, j int) bool { return cands[i].score > cands[j].score })
	if len(cands) > limit {
		cands = cands[:limit]
	}
	out := make([]Story, 0, len(cands))
	for _, c := range cands {
		out = append(out, c.s)
	}
	return out, nil
}

// relatedRecencyWindowSeconds bounds "related" results to roughly the same
// window the story feed lives in (stories are pruned at 30 days anyway).
const relatedRecencyWindowSeconds = 30 * 86400

// relatedStopwords are high-frequency, low-signal tokens dropped when building
// the "related" FTS query so matches key off the story's actual subject matter.
var relatedStopwords = map[string]bool{
	"the": true, "and": true, "for": true, "with": true, "that": true,
	"this": true, "from": true, "has": true, "have": true, "are": true,
	"was": true, "were": true, "will": true, "its": true, "into": true,
	"out": true, "how": true, "why": true, "what": true, "who": true,
	"you": true, "your": true, "not": true, "but": true, "all": true,
	"new": true, "now": true, "more": true, "after": true, "over": true,
	"about": true, "says": true, "said": true, "of": true, "to": true,
	"in": true, "is": true, "on": true, "at": true, "by": true, "as": true,
	"an": true, "be": true, "it": true, "or": true, "if": true, "we": true,
	"he": true, "do": true, "up": true, "so": true, "no": true, "my": true,
}

// RelatedStories returns classified stories textually similar to the given
// story, ranked by FTS5 relevance and excluding the story itself. The match
// query is an OR of significant tokens from the seed story's headline and lede,
// so it surfaces stories that share subject matter rather than requiring every
// term (which is what SearchStories' implicit-AND query would demand).
func RelatedStories(storyID int64, limit int) ([]Story, error) {
	if storyID <= 0 || limit <= 0 {
		return nil, nil
	}
	var headline, lede sql.NullString
	err := Get().QueryRow(`SELECT headline, lede FROM stories WHERE id = ?`, storyID).Scan(&headline, &lede)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	fts := buildRelatedFTSQuery(headline.String + " " + lede.String)
	if fts == "" {
		return nil, nil
	}
	cutoff := nowUnix() - relatedRecencyWindowSeconds
	rows, err := Get().Query(
		`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
		        EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
		 FROM stories_fts f
		 JOIN stories s ON s.id = f.rowid
		 WHERE f.stories_fts MATCH ?
		   AND s.id != ?
		   AND s.classified = 1
		   AND s.channel IS NOT NULL
		   AND s.channel NOT IN ('_discarded', '_duplicate')
		   AND s.seen_at >= ?
		 ORDER BY bm25(stories_fts) ASC, s.seen_at DESC
		 LIMIT ?`, fts, storyID, cutoff, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Story
	for rows.Next() {
		var s Story
		if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
			return nil, err
		}
		out = append(out, s)
	}
	return out, rows.Err()
}

// buildRelatedFTSQuery tokenizes a story's headline+lede into a de-duplicated,
// stopword-filtered OR query of quoted terms, capped so a long lede can't build
// a pathological query. Returns "" when nothing usable remains.
func buildRelatedFTSQuery(raw string) string {
	var tokens []string
	var cur strings.Builder
	seen := make(map[string]bool)
	flush := func() {
		if cur.Len() == 0 {
			return
		}
		t := strings.ToLower(cur.String())
		cur.Reset()
		if len([]rune(t)) < 2 || relatedStopwords[t] || seen[t] {
			return
		}
		seen[t] = true
		tokens = append(tokens, t)
	}
	for _, r := range raw {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
			cur.WriteRune(r)
		case r > 127:
			cur.WriteRune(r)
		default:
			flush()
		}
	}
	flush()
	if len(tokens) == 0 {
		return ""
	}
	const maxTokens = 16
	if len(tokens) > maxTokens {
		tokens = tokens[:maxTokens]
	}
	for i, t := range tokens {
		tokens[i] = `"` + t + `"`
	}
	return strings.Join(tokens, " OR ")
}
