package httpapi

import (
	"log/slog"
	"net/http"

	"github.com/go-chi/chi/v5"

	"commilitia.net/cdrop/internal/db"
	"commilitia.net/cdrop/internal/jwtauth"
)

// Session management. After the unified-session-model rework, every logged-in client —
// browser, desktop, QR-paired device — is one delegated device session in the broker. The
// broker (R1: GET /internal/sessions) is the single authoritative device list; cdrop no
// longer keeps a parallel authoritative session table. The local `devices` rows survive only
// as a type/presence cache: they supply each device's type for the list overlay and back the
// real-time presence view (which is cdrop's domain, keyed by device name). Listing reads R1
// and overlays type/online/current; revoking calls the broker (the session's source of truth)
// then drops the local cache row.

// requireFullSession rejects restricted guest sessions (scan-login borrow): they can
// transfer files but not manage devices, approve other devices, or revoke sessions.
// A full / dev session passes (AUTH Broker scope tier — Claims.Guest reads X-Auth-Scope).
func requireFullSession(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims, ok := jwtauth.ClaimsFromContext(r.Context())
		if !ok {
			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
			return
		}
		if claims.Guest() {
			writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"})
			return
		}
		next.ServeHTTP(w, r)
	})
}

type sessionView struct {
	ID         string `json:"id"` // device_id (the revoke handle exposed to the client)
	DeviceID   string `json:"device_id"`
	DeviceName string `json:"device_name"`
	Kind       string `json:"kind"`  // device type: browser | macos | windows | linux | ios
	Scope      string `json:"scope"` // full | guest
	Current    bool   `json:"current"`
	Online     bool   `json:"online"`
	CreatedAt  int64  `json:"created_at"`
	LastUsedAt int64  `json:"last_used_at"`
}

// handleSessionsList returns the caller's logged-in devices with their permission level
// (完整 / 受限访客) and live online flag, so the UI can show each and offer logout. The
// authoritative list is the broker's delegated device sessions (R1); cdrop overlays the
// device type (local cache), the online dot (hub presence, keyed by device name), and the
// "current" flag (the session whose meta is this request's X-Auth-Meta).
func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
	claims, _ := jwtauth.ClaimsFromContext(r.Context())

	sessions, err := s.broker.ListSessions(r.Context(), claims.UserID)
	if err != nil {
		slog.Error("list sessions failed", "err", err, "user", claims.UserID)
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "broker"})
		return
	}

	// device_id -> cached type, for the type overlay (a missing cache falls back to
	// "browser"). The cache is bounded by the background device sweeper (last_seen TTL); a
	// logged-out device's row is reaped there, not on this read path — this GET stays
	// side-effect-free, and the session list itself is always R1-authoritative regardless of
	// any stale cache row (the row only ever supplies a type for a device that is in R1).
	// device_id -> cached type + live name. The name overlay is what keeps the session list
	// consistent after a decoupled rename: the broker Label is only set at mint time and does
	// not follow a PATCH /api/devices rename, so we prefer the devices-table name (the same
	// source presence uses) and fall back to the broker Label only for a row-less session.
	typeByID := map[string]string{}
	nameByID := map[string]string{}
	if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil {
		for _, d := range devs {
			typeByID[d.DeviceID] = d.Type
			nameByID[d.DeviceID] = d.Name
		}
	}

	out := make([]sessionView, 0, len(sessions))
	for _, sess := range sessions {
		// A meta-less session is a non-cdrop-managed machine session — a desktop
		// device-authorize bootstrap that the desktop replaces via 代铸 right away. It has
		// no device_id, so it isn't a managed device and must not show as a phantom row.
		if sess.Meta == "" {
			continue
		}
		typ := typeByID[sess.Meta]
		if typ == "" {
			typ = "browser"
		}
		name := nameByID[sess.Meta]
		if name == "" {
			name = sess.Label
		}
		scope := "full"
		if jwtauth.ScopeTier(sess.Scope) == "guest" {
			scope = "guest"
		}
		out = append(out, sessionView{
			ID:         sess.Meta,
			DeviceID:   sess.Meta,
			DeviceName: name,
			Kind:       typ,
			Scope:      scope,
			Current:    sess.Meta == claims.DeviceID,
			Online:     s.hub.Online(claims.UserID, sess.Meta),
			CreatedAt:  sess.CreatedAt,
			LastUsedAt: sess.LastUsedAt,
		})
	}

	writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
}

// handleSessionRevoke logs out a device by id (= device_id): it resolves the device's broker
// session, revokes it, and drops the local cache row. Full session required (route-gated).
func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
	claims, _ := jwtauth.ClaimsFromContext(r.Context())
	id := chi.URLParam(r, "id")
	if id == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
		return
	}
	status, ok := s.revokeDevice(r, claims.UserID, id)
	if !ok {
		writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// revokeDevice tears down a device's broker session and local cache row, then kicks its live
// SSE and re-broadcasts presence. Returns the HTTP status to report and whether it succeeded;
// the caller writes the response. Shared by device deletion, session revocation, and logout
// (they are the same operation). The broker session id is resolved cache-first (the local row
// holds the stable broker_sid) and falls back to R1 — the authoritative list — so a missing or
// pruned cache row still revokes correctly and stays authorized to this user.
func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) {
	sid, name := "", ""
	localRowOwned := false
	if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID {
		sid = dev.BrokerSid
		name = dev.Name
		localRowOwned = true
	}
	if sid == "" {
		sessions, err := s.broker.ListSessions(r.Context(), userID)
		if err != nil {
			slog.Error("revoke: list sessions failed", "err", err, "user", userID)
			return http.StatusBadGateway, false
		}
		for _, sess := range sessions {
			if sess.Meta == deviceID {
				sid = sess.SID
				name = sess.Label
				break
			}
		}
	}
	if sid == "" {
		// No broker session for this device_id. If we still own a local cache row, it is a stale /
		// phantom entry — a synthetic test device (the Diag residue) or a row whose broker session
		// is long gone. Drop the local row + kick + republish so the user can always clear such a
		// device from their list; only a device_id we own nothing for is a genuine 404.
		if !localRowOwned {
			return http.StatusNotFound, false
		}
		if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
			DeviceID: deviceID,
			UserID:   userID,
		}); err != nil {
			slog.Warn("delete phantom device cache failed", "err", err, "user", userID, "device", deviceID)
		}
		s.hub.Kick(userID, deviceID, name)
		s.hub.PublishPresence(r.Context(), userID)
		return http.StatusNoContent, true
	}
	// Revoke the broker session first so the device can't refresh; a 404 (already gone) is
	// idempotent success inside RevokeSession.
	if err := s.broker.RevokeSession(r.Context(), sid); err != nil {
		slog.Error("broker revoke failed", "err", err, "user", userID, "sid", sid)
		return http.StatusBadGateway, false
	}
	if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
		DeviceID: deviceID,
		UserID:   userID,
	}); err != nil {
		// The session is already revoked; a failed cache delete is non-fatal (the sweeper
		// and the next list-prune clean it). Report success so the client sees the logout.
		slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID)
	}
	// Kick by the stable device_id (the hub key); name rides along for the code-less fallback
	// path inside Kick. This makes cross-device revoke land reliably (the prior name-keyed Kick
	// could miss a renamed device, leaving it able to keep refreshing — the "移除失败" symptom).
	s.hub.Kick(userID, deviceID, name)
	s.hub.PublishPresence(r.Context(), userID)
	return http.StatusNoContent, true
}

// handleLogout logs out the calling device by revoking its own broker session (so it can no
// longer refresh) and dropping its cache row. The client also discards its tokens. A caller
// with no managed device (no X-Auth-Meta) just succeeds — there is nothing server-side to
// revoke. Origin-checked for CSRF.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
	if !s.sameOrigin(r) {
		writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
		return
	}
	claims, _ := jwtauth.ClaimsFromContext(r.Context())
	if claims.DeviceID != "" {
		if status, ok := s.revokeDevice(r, claims.UserID, claims.DeviceID); !ok && status != http.StatusNotFound {
			slog.Warn("logout revoke failed", "status", status, "user", claims.UserID)
		}
	}
	w.WriteHeader(http.StatusNoContent)
}

func revokeErrorMsg(status int) string {
	switch status {
	case http.StatusNotFound:
		return "device not found"
	case http.StatusBadGateway:
		return "revoke failed"
	default:
		return "db"
	}
}
