package api

import (
	"context"
	"encoding/json"
	"net/http"
	"strings"
	"time"

	"github.com/ponzischeme89/memby/server/internal/store"
)

// maxAnalyticsBatch caps one upload. The TV batches events and flushes periodically, so
// a larger payload than this means something has gone wrong client-side.
const maxAnalyticsBatch = 200

// maxDwellMs discards implausible dwell times — a TV left on a row overnight says
// nothing about what anyone was looking at.
const maxDwellMs = 30 * 60 * 1000

type rowEventPayload struct {
	RowID      string `json:"rowId"`
	RowKind    string `json:"rowKind"`
	Event      string `json:"event"`
	ItemID     string `json:"itemId"`
	DwellMs    int    `json:"dwellMs"`
	OccurredAt string `json:"occurredAt"`
}

type analyticsRequest struct {
	Events []rowEventPayload `json:"events"`
}

type journeyEventPayload struct {
	UserID     string `json:"userId"`
	JourneyID  string `json:"journeyId"`
	Sequence   int    `json:"sequence"`
	Category   string `json:"category"`
	Action     string `json:"action"`
	Screen     string `json:"screen"`
	Feature    string `json:"feature"`
	Source     string `json:"source"`
	Target     string `json:"target"`
	ItemID     string `json:"itemId"`
	ItemType   string `json:"itemType"`
	Outcome    string `json:"outcome"`
	OccurredAt string `json:"occurredAt"`
}

type journeyAnalyticsRequest struct {
	Events []journeyEventPayload `json:"events"`
}

var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications")
var journeyActions = allowedAnalyticsValues(
	"journey_start", "journey_end", "screen_view", "open", "close", "select",
	"submit", "request", "start", "stop", "complete", "abandon", "change",
	"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
	"mark_unplayed", "retry", "dismiss", "switch",
)
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")

func allowedAnalyticsValues(values ...string) map[string]bool {
	out := make(map[string]bool, len(values))
	for _, value := range values {
		out[value] = true
	}
	return out
}

// handleJourneyAnalytics accepts privacy-bounded journey steps. The payload's user id is
// only a profile-switch guard: authority always comes from the bearer session.
func (s *Server) handleJourneyAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
	var req journeyAnalyticsRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	if len(req.Events) > maxAnalyticsBatch {
		req.Events = req.Events[:maxAnalyticsBatch]
	}
	now := time.Now().UTC()
	events := make([]store.JourneyEvent, 0, len(req.Events))
	for _, payload := range req.Events {
		if event, ok := toJourneyEvent(payload, sess.EmbyUserID, now); ok {
			events = append(events, event)
		}
	}
	if err := s.store.InsertJourneyEvents(r.Context(), events); err != nil {
		s.loggerFor(r.Context()).Warn("journey analytics write failed", "error", err)
	}
	w.WriteHeader(http.StatusNoContent)
}

func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (store.JourneyEvent, bool) {
	if payload.UserID != userID || !safeAnalyticsValue(payload.JourneyID, 80) ||
		payload.Sequence < 0 || !journeyCategories[payload.Category] || !journeyActions[payload.Action] ||
		!journeyOutcomes[payload.Outcome] {
		return store.JourneyEvent{}, false
	}
	fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType}
	for _, field := range fields {
		if !safeAnalyticsValue(field, 100) {
			return store.JourneyEvent{}, false
		}
	}
	occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
	return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
		Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
		Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
		Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType,
		Outcome: payload.Outcome}, true
}

func safeAnalyticsValue(value string, max int) bool {
	if len(value) > max {
		return false
	}
	for _, char := range value {
		if !(char == '-' || char == '_' || char == '.' || char == ':' ||
			char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
			return false
		}
	}
	return true
}

func analyticsOccurredAt(value string, now time.Time) time.Time {
	if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil &&
		parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
		return parsed.UTC()
	}
	return now
}

// handleRowAnalytics accepts a batch of row engagement events from a TV.
//
// Fire-and-forget by design: the client does not retry, and a rejected event is never
// worth surfacing on screen. Bad events are dropped individually rather than failing the
// batch.
func (s *Server) handleRowAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
	var req analyticsRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	if len(req.Events) > maxAnalyticsBatch {
		req.Events = req.Events[:maxAnalyticsBatch]
	}

	now := time.Now().UTC()
	events := make([]store.RowEvent, 0, len(req.Events))
	for _, payload := range req.Events {
		event, ok := toRowEvent(payload, sess.EmbyUserID, now)
		if !ok {
			continue
		}
		events = append(events, event)
	}

	if err := s.store.InsertRowEvents(r.Context(), events); err != nil {
		s.loggerFor(r.Context()).Warn("row analytics write failed", "error", err)
		// Still a 204: telemetry must never make the TV think something is broken.
	}
	for _, event := range events {
		if event.Event == store.RowEventSelect {
			_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
			if s.forYou != nil {
				s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
			}
			break
		}
	}
	w.WriteHeader(http.StatusNoContent)
}

func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.RowEvent, bool) {
	if payload.RowID == "" {
		return store.RowEvent{}, false
	}
	switch payload.Event {
	case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect:
	default:
		return store.RowEvent{}, false
	}

	occurredAt := analyticsOccurredAt(payload.OccurredAt, now)

	dwell := payload.DwellMs
	if dwell < 0 {
		dwell = 0
	}
	if dwell > maxDwellMs {
		dwell = maxDwellMs
	}

	return store.RowEvent{
		OccurredAt: occurredAt,
		UserID:     userID,
		RowID:      payload.RowID,
		RowKind:    payload.RowKind,
		Event:      payload.Event,
		ItemID:     payload.ItemID,
		DwellMs:    dwell,
	}, true
}
