package httpapi

import (
	"encoding/json"
	"log/slog"
	"net/http"
	"strings"
	"time"

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

// 代铸 (proxy-mint). The unified-session-model endpoint that makes every login — browser
// global-SSO and native device-authorize alike — a cdrop-managed device session, so all
// clients share one device list and one management surface.
//
// The caller has already been verified at the edge (X-Auth-Subject) by either a global-SSO
// cookie (browser) or a device-authorize bootstrap token (desktop/native). cdrop vouches for
// that subject and asks the broker (R2: idempotent by user+app+meta) to mint or rotate a
// device session bound to the client's stable device_id. Re-logins with the same device_id
// rotate the same session instead of piling up — the same trust model as scan-login collect.

type deviceSessionReq struct {
	// DeviceID is the client's stable opaque id (persisted client-side). Empty on first
	// contact — the server then mints one and returns it for the client to persist.
	DeviceID   string `json:"device_id"`
	DeviceName string `json:"device_name"`
	DeviceType string `json:"device_type"` // browser | macos | windows | linux | ios
}

type deviceSessionResp struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	ExpiresIn    int    `json:"expires_in"`
	DeviceID     string `json:"device_id"`
	DeviceName   string `json:"device_name"`
	// UserID + Name + Avatar are the verified identity (X-Auth-Subject / X-Auth-Name /
	// X-Auth-Avatar). They let a native client — which only ever sees nameless machine
	// tokens — populate its identity with the real display name and picture instead of the
	// subject UUID, without a /api/me round-trip.
	UserID string `json:"user_id"`
	Name   string `json:"name"`
	Avatar string `json:"avatar,omitempty"`
}

// handleDeviceSession mints (or idempotently rotates) the caller's cdrop device session.
func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
	// CSRF: a cookie-authenticated browser fetch carries a matching Origin; a cross-site
	// forgery would not (blocking forged mints that would reintroduce phantom devices). The
	// desktop's Go-initiated call sends no Origin and is allowed.
	if !s.sameOrigin(r) {
		writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
		return
	}
	claims, _ := jwtauth.ClaimsFromContext(r.Context())

	var req deviceSessionReq
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}

	deviceID := strings.TrimSpace(req.DeviceID)
	if deviceID == "" {
		var err error
		if deviceID, err = newDeviceID(); err != nil {
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
			return
		}
	} else if !validDeviceID(deviceID) {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device_id"})
		return
	}

	deviceName := jwtauth.SanitizeDeviceName(req.DeviceName)
	if deviceName == "" {
		deviceName = "New device"
	}
	deviceType := qrDeviceType(req.DeviceType)

	// Mint at the caller's current trust tier: an SSO / device-authorize login is full, a
	// restricted guest stays guest. This stops a borrowed (guest) browser from minting
	// itself a full device session.
	tier := "full"
	if claims.Guest() {
		tier = "guest"
	}
	accessTTL, refreshTTL := s.tierTTLs(tier)

	sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{
		UserID:     claims.UserID,
		Tier:       tier,
		AccessTTL:  accessTTL,
		RefreshTTL: refreshTTL,
		Sliding:    true,
		Label:      deviceName,
		Meta:       deviceID,
	})
	if err != nil {
		slog.Error("device-session mint failed", "err", err, "user", claims.UserID)
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "mint failed"})
		return
	}

	now := time.Now().Unix()
	if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{
		DeviceID:  deviceID,
		UserID:    claims.UserID,
		Name:      deviceName,
		Type:      deviceType,
		Tier:      tier,
		BrokerSid: sess.SID,
		CreatedAt: now,
		LastSeen:  now,
	}); err != nil {
		// The session is minted and usable; a failed cache-row write only costs the local
		// type/presence overlay, so proceed rather than strand the device without tokens.
		slog.Error("device-session cache write failed", "err", err, "user", claims.UserID, "device", deviceID)
	}

	name := claims.Name
	if name == "" {
		name = claims.UserID
	}
	expiresIn := int(sess.AccessExpires - now)
	if expiresIn < 0 {
		expiresIn = 0
	}
	writeJSON(w, http.StatusOK, deviceSessionResp{
		AccessToken:  sess.Access,
		RefreshToken: sess.Refresh,
		ExpiresIn:    expiresIn,
		DeviceID:     deviceID,
		DeviceName:   deviceName,
		UserID:       claims.UserID,
		Name:         name,
		Avatar:       claims.Avatar,
	})
}

// validDeviceID accepts a cdrop device_id: the "dev_" prefix plus pure [A-Za-z0-9_-], capped
// in length. This both recognises cdrop's own ids (newDeviceID) and guarantees the value is
// control-byte-free, so it is safe to pass to the broker as meta (echoed into X-Auth-Meta).
func validDeviceID(id string) bool {
	if !strings.HasPrefix(id, "dev_") || len(id) > 128 {
		return false
	}
	for _, c := range id {
		switch {
		case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
		default:
			return false
		}
	}
	return true
}
