package api

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

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"

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

// validDiagnosticKinds is the COARSE category whitelist. Mirrors the
// CHECK constraint in migration 0036 — keep both in sync (per the
// enum-CHECK-whitelist rule). The finer event discriminator + all
// event-specific fields live inside each event's payload, so new event
// variants do NOT require a new category here.
var validDiagnosticKinds = map[string]struct{}{
	"connectivity": {},
	"upnp_sync":    {}, // genuinely UPnP/Sonos-specific (drops, resync)
	"playback":     {}, // route + player-state, any output (phone/BT/UPnP)
	"power":        {},
	"lifecycle":    {},
	"heartbeat":    {},
	"http":         {},
}

// maxDiagnosticBatch caps a single ingest call. The client buffers and
// flushes in batches of ~100; 500 leaves generous headroom while
// bounding a misbehaving client's per-request work.
const maxDiagnosticBatch = 500

// Diagnostics list paging. Larger than the generic parsePaging caps
// because the admin timeline is meant to be exported as a slice and
// handed off for analysis — a heartbeat-dense window has many rows.
const (
	defaultDiagnosticPageSize = 500
	maxDiagnosticPageSize     = 5000
)

// diagnosticEventIn is one event in a POST /api/diagnostics batch.
// OccurredAt is the client device clock as epoch milliseconds (may be
// skewed — the server also stamps received_at). Payload is opaque JSON
// carrying the event's sub-type + fields.
type diagnosticEventIn struct {
	Kind       string          `json:"kind"`
	OccurredAt int64           `json:"occurred_at"`
	Payload    json.RawMessage `json:"payload"`
}

// reportDiagnosticsRequest is the body of POST /api/diagnostics — one
// batch from one device.
type reportDiagnosticsRequest struct {
	ClientID   string              `json:"client_id"`
	AppVersion *string             `json:"app_version,omitempty"`
	OsVersion  *string             `json:"os_version,omitempty"`
	Events     []diagnosticEventIn `json:"events"`
}

// handleReportDiagnostics implements POST /api/diagnostics. Any signed-in
// user can call it, but events are only stored when the account's
// debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the
// client can safely drop the batch (it gates locally on /api/me, this is
// the race-safe backstop for "admin just disabled debug").
func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	if !user.DebugModeEnabled {
		w.WriteHeader(http.StatusNoContent)
		return
	}
	var req reportDiagnosticsRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if strings.TrimSpace(req.ClientID) == "" {
		writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
		return
	}
	if len(req.Events) == 0 {
		writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0})
		return
	}
	if len(req.Events) > maxDiagnosticBatch {
		writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch"))
		return
	}
	for i := range req.Events {
		if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid {
			writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind"))
			return
		}
	}

	err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error {
		q := dbq.New(tx)
		for i := range req.Events {
			ev := req.Events[i]
			payload := ev.Payload
			if len(payload) == 0 {
				payload = json.RawMessage("{}")
			}
			if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{
				UserID:     user.ID,
				ClientID:   req.ClientID,
				AppVersion: req.AppVersion,
				OsVersion:  req.OsVersion,
				Kind:       ev.Kind,
				Payload:    payload,
				OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true},
			}); err != nil {
				return err
			}
		}
		return nil
	})
	if err != nil {
		writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err))
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)})
}

// adminDiagnosticView is one row in the admin timeline response. Payload
// is passed through verbatim as raw JSON so the SPA can render / export
// the structured event without the server re-shaping it.
type adminDiagnosticView struct {
	ID         string          `json:"id"`
	UserID     string          `json:"user_id"`
	Username   string          `json:"username"`
	ClientID   string          `json:"client_id"`
	AppVersion *string         `json:"app_version,omitempty"`
	OsVersion  *string         `json:"os_version,omitempty"`
	Kind       string          `json:"kind"`
	Payload    json.RawMessage `json:"payload"`
	OccurredAt string          `json:"occurred_at"`
	ReceivedAt string          `json:"received_at"`
}

// handleListAdminDiagnostics implements GET /api/admin/diagnostics with
// optional filters user_id, client_id, kind, from, to (RFC3339 or epoch
// millis) plus limit/offset. Newest-first; the SPA reverses for a
// chronological timeline + export.
func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	params, ok := h.buildDiagnosticsFilter(w, q)
	if !ok {
		return
	}
	rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params)
	if err != nil {
		writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err))
		return
	}
	out := make([]adminDiagnosticView, 0, len(rows))
	for _, row := range rows {
		out = append(out, adminDiagnosticView{
			ID:         uuidToString(row.ID),
			UserID:     uuidToString(row.UserID),
			Username:   row.Username,
			ClientID:   row.ClientID,
			AppVersion: row.AppVersion,
			OsVersion:  row.OsVersion,
			Kind:       row.Kind,
			Payload:    json.RawMessage(row.Payload),
			OccurredAt: formatTimestamp(row.OccurredAt),
			ReceivedAt: formatTimestamp(row.ReceivedAt),
		})
	}
	writeJSON(w, http.StatusOK, out)
}

// buildDiagnosticsFilter parses the query string into a query param
// struct, writing a 400 and returning ok=false on a malformed time.
func (h *handlers) buildDiagnosticsFilter(
	w http.ResponseWriter, q map[string][]string,
) (dbq.ListAdminDiagnosticsParams, bool) {
	get := func(k string) string {
		if v, present := q[k]; present && len(v) > 0 {
			return v[0]
		}
		return ""
	}
	limit, offset, err := parseDiagPaging(get("limit"), get("offset"))
	if err != nil {
		writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset"))
		return dbq.ListAdminDiagnosticsParams{}, false
	}
	from, okFrom := parseDiagTime(get("from"))
	to, okTo := parseDiagTime(get("to"))
	if !okFrom || !okTo {
		writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp"))
		return dbq.ListAdminDiagnosticsParams{}, false
	}
	params := dbq.ListAdminDiagnosticsParams{
		ClientID: optionalQuery(get("client_id")),
		Kind:     optionalQuery(get("kind")),
		FromTs:   from,
		ToTs:     to,
		Off:      int32(offset),
		Lim:      int32(limit),
	}
	if uid, valid := parseUUID(get("user_id")); valid {
		params.UserID = uid
	}
	return params, true
}

// adminDiagnosticDeviceView is one device in the "who is reporting"
// overview / device filter dropdown.
type adminDiagnosticDeviceView struct {
	ClientID   string `json:"client_id"`
	UserID     string `json:"user_id"`
	Username   string `json:"username"`
	AppVersion string `json:"app_version"`
	OsVersion  string `json:"os_version"`
	LastSeen   string `json:"last_seen"`
	EventCount int64  `json:"event_count"`
}

// handleListAdminDiagnosticDevices implements GET
// /api/admin/diagnostics/devices?user_id=. Lists distinct reporting
// devices so the admin UI can populate its device filter.
func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) {
	var userID pgtype.UUID
	if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid {
		userID = uid
	}
	rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID)
	if err != nil {
		writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err))
		return
	}
	out := make([]adminDiagnosticDeviceView, 0, len(rows))
	for _, row := range rows {
		out = append(out, adminDiagnosticDeviceView{
			ClientID:   row.ClientID,
			UserID:     uuidToString(row.UserID),
			Username:   row.Username,
			AppVersion: row.AppVersion,
			OsVersion:  row.OsVersion,
			LastSeen:   formatTimestamp(row.LastSeen),
			EventCount: row.EventCount,
		})
	}
	writeJSON(w, http.StatusOK, out)
}

// adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode.
type adminDebugModeReq struct {
	Enabled bool `json:"enabled"`
}

// handleAdminDebugModeToggle implements PUT
// /api/admin/users/{id}/debug-mode — flip an account's diagnostics
// opt-in remotely while a bug is live. Echoes the updated user.
func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) {
	targetID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	var req adminDebugModeReq
	if !decodeBody(w, r, &req) {
		return
	}
	updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{
		ID:               targetID,
		DebugModeEnabled: req.Enabled,
	})
	if err != nil {
		writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err))
		return
	}
	h.logger.Info("admin: debug mode toggled",
		"target_user", uuidToString(targetID), "enabled", req.Enabled)
	writeJSON(w, http.StatusOK, adminUserView{
		ID:                  uuidToString(updated.ID),
		Username:            updated.Username,
		DisplayName:         updated.DisplayName,
		IsAdmin:             updated.IsAdmin,
		AutoApproveRequests: updated.AutoApproveRequests,
		DebugModeEnabled:    updated.DebugModeEnabled,
		CreatedAt:           updated.CreatedAt.Time.UTC().Format(time.RFC3339),
	})
}

// parseDiagPaging reads limit/offset with diagnostics-specific caps.
// Blank → defaults; out-of-range clamps; non-numeric → error.
func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) {
	limit = defaultDiagnosticPageSize
	if s := strings.TrimSpace(rawLimit); s != "" {
		n, perr := strconv.Atoi(s)
		if perr != nil {
			return 0, 0, perr
		}
		if n < 1 {
			n = 1
		}
		if n > maxDiagnosticPageSize {
			n = maxDiagnosticPageSize
		}
		limit = n
	}
	if s := strings.TrimSpace(rawOffset); s != "" {
		n, perr := strconv.Atoi(s)
		if perr != nil {
			return 0, 0, perr
		}
		if n > 0 {
			offset = n
		}
	}
	return limit, offset, nil
}

// optionalQuery returns nil for an absent/blank query value so the sqlc
// NULL-or-equals filter treats it as "no filter."
func optionalQuery(raw string) *string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil
	}
	return &raw
}

// parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL
// filter, ok=true). Returns ok=false only on a non-empty unparseable value.
func parseDiagTime(raw string) (pgtype.Timestamptz, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return pgtype.Timestamptz{}, true
	}
	if t, err := time.Parse(time.RFC3339, raw); err == nil {
		return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true
	}
	if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
		return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true
	}
	return pgtype.Timestamptz{}, false
}
