package storage

import (
	"log/slog"
	"sort"
)

const secondsPerDay = 86400

// unixDay returns the current UTC day number (floor(unix / 86400)).
func unixDay() int64 { return nowUnix() / secondsPerDay }

// UnixDay is the exported current UTC day number, for callers building
// day-windowed queries (e.g. the trending rail's 7-day cutoff).
func UnixDay() int64 { return unixDay() }

// RecordPageView increments the all-time and per-day view counter for a coarse
// path label ("home", a channel slug, …). Fire-and-forget: a failure here must
// never affect serving a page, so errors are logged and swallowed.
func RecordPageView(path string) {
	exec("record page view",
		`INSERT INTO page_views (path, day, views) VALUES (?, ?, 1)
		 ON CONFLICT(path, day) DO UPDATE SET views = views + 1`,
		path, unixDay())
}

// RecordVisitor records a (already-hashed, salted) visitor token for today's
// unique estimate. Duplicate tokens within the same day are ignored.
func RecordVisitor(token string) {
	exec("record visitor",
		`INSERT OR IGNORE INTO daily_visitors (day, visitor) VALUES (?, ?)`,
		unixDay(), token)
}

// RecordStoryView bumps the per-day read counter for a single story. Called
// when a visitor opens the story in reader mode. Fire-and-forget like the other
// metrics writes: a failure here must never affect serving the article.
func RecordStoryView(id int64) {
	exec("record story view",
		`INSERT INTO story_views (story_id, day, views) VALUES (?, ?, 1)
		 ON CONFLICT(story_id, day) DO UPDATE SET views = views + 1`,
		id, unixDay())
}

// StoryViewTotals returns all-time read counts for the given story ids, as a
// map keyed by id. Ids with no recorded views are simply absent from the map
// (callers treat missing as zero). Best-effort: on error it returns whatever it
// managed to read, so a metrics hiccup never blanks a page.
func StoryViewTotals(ids []int64) map[int64]int {
	out := make(map[int64]int, len(ids))
	if len(ids) == 0 {
		return out
	}
	ph, args := intInClause(ids)
	rows, err := Get().Query(
		`SELECT story_id, SUM(views) FROM story_views
		 WHERE story_id IN (`+ph+`) GROUP BY story_id`, args...)
	if err != nil {
		slog.Error("metrics: story view totals query failed", "err", err)
		return out
	}
	defer rows.Close()
	for rows.Next() {
		var id int64
		var n int
		if err := rows.Scan(&id, &n); err != nil {
			slog.Error("metrics: scan story view total failed", "err", err)
			continue
		}
		out[id] = n
	}
	if err := rows.Err(); err != nil {
		slog.Error("metrics: story view totals iteration failed", "err", err)
	}
	return out
}

// intInClause builds a "?, ?, …" placeholder string and matching args slice for
// a SQL IN (…) over int64 ids.
func intInClause(ids []int64) (string, []any) {
	args := make([]any, len(ids))
	for i, id := range ids {
		args[i] = id
	}
	return placeholders(len(ids)), args
}

// PathStat is one row of the per-page usage breakdown.
type PathStat struct {
	Path  string
	Total int // all-time views
	Today int // views since the start of the current UTC day
}

// DayStat is one day's unique-visitor estimate.
type DayStat struct {
	Day     int64 // unix day number
	Uniques int
}

// MetricsSummary is the aggregate usage readout surfaced by !petestats.
type MetricsSummary struct {
	Pages        []PathStat // per-path, busiest first
	TotalViews   int        // all-time, all paths
	ViewsToday   int        // all paths, current UTC day
	UniquesToday int        // distinct visitor tokens, current UTC day
	Last7Days    []DayStat  // daily uniques, oldest → newest (note: salt rotates
	// daily, so these CANNOT be summed into a cross-day unique count)
}

// GetMetricsSummary assembles the usage readout. Best-effort: any sub-query
// failure leaves that field at its zero value rather than failing the whole
// command.
func GetMetricsSummary() MetricsSummary {
	today := unixDay()
	var m MetricsSummary

	rows, err := Get().Query(
		`SELECT path,
		        SUM(views)                                    AS total,
		        COALESCE(SUM(CASE WHEN day = ? THEN views END), 0) AS today
		 FROM page_views
		 GROUP BY path`, today)
	if err != nil {
		slog.Error("metrics: page_views query failed", "err", err)
	} else {
		defer rows.Close()
		for rows.Next() {
			var p PathStat
			if err := rows.Scan(&p.Path, &p.Total, &p.Today); err != nil {
				slog.Error("metrics: scan page stat failed", "err", err)
				continue
			}
			m.Pages = append(m.Pages, p)
			m.TotalViews += p.Total
			m.ViewsToday += p.Today
		}
	}
	sort.Slice(m.Pages, func(i, j int) bool {
		if m.Pages[i].Total != m.Pages[j].Total {
			return m.Pages[i].Total > m.Pages[j].Total
		}
		return m.Pages[i].Path < m.Pages[j].Path
	})

	if err := Get().QueryRow(
		`SELECT COUNT(*) FROM daily_visitors WHERE day = ?`, today,
	).Scan(&m.UniquesToday); err != nil {
		slog.Error("metrics: uniques-today query failed", "err", err)
	}

	since := today - 6 // inclusive 7-day window
	vrows, err := Get().Query(
		`SELECT day, COUNT(*) FROM daily_visitors
		 WHERE day >= ? GROUP BY day ORDER BY day`, since)
	if err != nil {
		slog.Error("metrics: 7-day uniques query failed", "err", err)
	} else {
		defer vrows.Close()
		for vrows.Next() {
			var d DayStat
			if err := vrows.Scan(&d.Day, &d.Uniques); err != nil {
				slog.Error("metrics: scan day stat failed", "err", err)
				continue
			}
			m.Last7Days = append(m.Last7Days, d)
		}
	}

	return m
}
