package api

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

	"github.com/jackc/pgx/v5"

	"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

type eventRequest struct {
	Type             string  `json:"type"`
	TrackID          string  `json:"track_id"`
	PlayEventID      string  `json:"play_event_id"`
	DurationPlayedMs *int32  `json:"duration_played_ms"`
	PositionMs       *int32  `json:"position_ms"`
	At               *string `json:"at"`
	ClientID         *string `json:"client_id"`
	// Source tags which surface a play_started came from. "for_you"
	// / "discover" feed the system-playlist rotation dedup (#415);
	// absent / "" for library, user-playlist, radio, Subsonic.
	Source *string `json:"source"`
	// DeviceClass is the client's self-reported device class (#1551),
	// normalized + stored per play to condition radio on the current device.
	DeviceClass *string `json:"device_class"`
}

// knownDeviceClasses whitelists the device classes the context-affinity facet
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
// "other", and empty/absent to "" (stored NULL → excluded from the device
// dimension). Kept permissive (no DB CHECK) so a new client class is one
// whitelist entry, not a migration.
var knownDeviceClasses = map[string]bool{
	"mobile": true, "tablet": true, "desktop": true,
	"web": true, "tv": true, "watch": true,
}

func normalizeDeviceClass(dc *string) string {
	if dc == nil {
		return ""
	}
	s := strings.ToLower(strings.TrimSpace(*dc))
	if s == "" {
		return ""
	}
	if knownDeviceClasses[s] {
		return s
	}
	return "other"
}

type playStartedResponse struct {
	PlayEventID string `json:"play_event_id"`
	SessionID   string `json:"session_id"`
}

// eventFutureSkew is the tolerated client-clock drift into the future
// before a timestamp is treated as bogus and replaced with now.
const eventFutureSkew = 5 * time.Minute

// clampEventTime bounds a client-supplied event timestamp to sanity:
// no earlier than the account's creation (offline replays can
// legitimately be days old, but no play can predate the user) and no
// later than now + a small skew allowance. Unbounded client clocks
// previously let a skewed device write arbitrarily old plays, which
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
	if at.Before(userCreatedAt) {
		return userCreatedAt
	}
	if at.After(now.Add(eventFutureSkew)) {
		return now
	}
	return at
}

type okResponse struct {
	OK bool `json:"ok"`
}

func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	var req eventRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
		return
	}
	at := time.Now().UTC()
	if req.At != nil && *req.At != "" {
		parsed, err := time.Parse(time.RFC3339, *req.At)
		if err != nil {
			writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
			return
		}
		at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
	}
	clientID := ""
	if req.ClientID != nil {
		clientID = *req.ClientID
	}
	switch req.Type {
	case "play_started":
		h.handleEventPlayStarted(w, r, user, req, at, clientID)
	case "play_ended":
		h.handleEventPlayEnded(w, r, user, req, at)
	case "play_skipped":
		h.handleEventPlaySkipped(w, r, user, req, at)
	case "play_offline":
		h.handleEventPlayOffline(w, r, user, req, at, clientID)
	default:
		writeErr(w, apierror.BadRequest("bad_request", "unknown event type"))
	}
}

func (h *handlers) handleEventPlayStarted(
	w http.ResponseWriter, r *http.Request,
	user dbq.User, req eventRequest, at time.Time, clientID string,
) {
	trackID, ok := parseUUID(req.TrackID)
	if !ok {
		writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
		return
	}
	if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
			return
		}
		h.logger.Error("api: events: lookup track", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}
	source := ""
	if req.Source != nil {
		source = *req.Source
	}
	deviceClass := normalizeDeviceClass(req.DeviceClass)
	res, err := h.events.RecordPlayStartedWithSource(
		r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
	if err != nil {
		h.logger.Error("api: events: play_started", "err", err)
		writeErr(w, apierror.InternalMsg("record failed", err))
		return
	}
	writeJSON(w, http.StatusOK, playStartedResponse{
		PlayEventID: uuidToString(res.PlayEventID),
		SessionID:   uuidToString(res.SessionID),
	})
}

// handleEventPlayOffline records a complete play that happened with
// no/flaky connectivity, replayed from the Flutter offline mutation
// queue (#426 part B). `at` is the original (client) play-start time
// — parsed by handleEvents from req.At — so history lands on the
// real timeline, not replay time. duration_played_ms drives the skip
// rule; source advances #415 rotation for system-playlist plays.
func (h *handlers) handleEventPlayOffline(
	w http.ResponseWriter, r *http.Request,
	user dbq.User, req eventRequest, at time.Time, clientID string,
) {
	trackID, ok := parseUUID(req.TrackID)
	if !ok {
		writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
		return
	}
	if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
		writeErr(w, apierror.BadRequest("bad_request", "duration_played_ms required and must be >= 0"))
		return
	}
	if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
			return
		}
		h.logger.Error("api: events: offline lookup track", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}
	source := ""
	if req.Source != nil {
		source = *req.Source
	}
	if err := h.events.RecordOfflinePlay(
		r.Context(), user.ID, trackID, clientID, source,
		normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
	); err != nil {
		h.logger.Error("api: events: play_offline", "err", err)
		writeErr(w, apierror.InternalMsg("record failed", err))
		return
	}
	writeJSON(w, http.StatusOK, okResponse{OK: true})
}

func (h *handlers) handleEventPlayEnded(
	w http.ResponseWriter, r *http.Request,
	user dbq.User, req eventRequest, at time.Time,
) {
	playEventID, ok := parseUUID(req.PlayEventID)
	if !ok {
		writeErr(w, apierror.BadRequest("bad_request", "invalid play_event_id"))
		return
	}
	if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
		writeErr(w, apierror.BadRequest("bad_request", "duration_played_ms required and must be >= 0"))
		return
	}
	ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "play_event not found"})
			return
		}
		h.logger.Error("api: events: lookup play_event", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}
	if ev.UserID != user.ID {
		writeErr(w, apierror.Forbidden("forbidden", "play_event belongs to a different user"))
		return
	}
	if err := h.events.RecordPlayEnded(r.Context(), playEventID, *req.DurationPlayedMs, at); err != nil {
		h.logger.Error("api: events: play_ended", "err", err)
		writeErr(w, apierror.InternalMsg("record failed", err))
		return
	}
	writeJSON(w, http.StatusOK, okResponse{OK: true})
}

func (h *handlers) handleEventPlaySkipped(
	w http.ResponseWriter, r *http.Request,
	user dbq.User, req eventRequest, at time.Time,
) {
	playEventID, ok := parseUUID(req.PlayEventID)
	if !ok {
		writeErr(w, apierror.BadRequest("bad_request", "invalid play_event_id"))
		return
	}
	if req.PositionMs == nil || *req.PositionMs < 0 {
		writeErr(w, apierror.BadRequest("bad_request", "position_ms required and must be >= 0"))
		return
	}
	ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "play_event not found"})
			return
		}
		h.logger.Error("api: events: lookup play_event", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}
	if ev.UserID != user.ID {
		writeErr(w, apierror.Forbidden("forbidden", "play_event belongs to a different user"))
		return
	}
	if err := h.events.RecordPlaySkipped(r.Context(), playEventID, *req.PositionMs, at); err != nil {
		h.logger.Error("api: events: play_skipped", "err", err)
		writeErr(w, apierror.InternalMsg("record failed", err))
		return
	}
	writeJSON(w, http.StatusOK, okResponse{OK: true})
}
