package main

// Steam-user management + Steam profile proxy.
//
// Two surfaces:
//
//   1. Panel-user-side Steam linkage: every user row has a steam_id
//      column (set by the OpenID sign-in flow). This file exposes
//      CRUD-ish endpoints for managing those links from the panel:
//      list users, link a SteamID by hand (admin pasting a SteamID64
//      for a teammate who doesn't sign in via Steam), unlink.
//
//   2. Steam profile proxy: GET /api/steam/profile?steamid=X — server
//      fetches the public XML profile from steamcommunity.com and
//      returns just the avatar URL + display name. Browser-side fetch
//      can't hit steamcommunity directly because Steam doesn't send
//      CORS headers. The avatarMedium URL itself IS CORS-friendly
//      (Steam CDN), so the browser can <img src=…> it after this
//      proxy gives the URL.

import (
	"context"
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/dbledeez/panel/controller/internal/db"
	"github.com/dbledeez/panel/pkg/version"
)

// steamID64RE lives in auth.go (shared with the OpenID parsing path).

type userDTO struct {
	ID        int64  `json:"id"`
	Email     string `json:"email"`
	Role      string `json:"role"`
	SteamID   string `json:"steam_id,omitempty"`
	Disabled  bool   `json:"disabled,omitempty"`
	CreatedAt string `json:"created_at"`
}

// handleCreateSteamUser — POST /api/users. Admin creates a new panel
// user from a SteamID64 paste. The resulting user has no password
// (Steam OpenID-only sign-in). Email is derived from the Steam display
// name when not supplied — admins typically don't have / want to type
// emails for everyone they're inviting.
//
// Refuses if the SteamID is already linked to another panel user
// (one Steam account → one user, enforced by the unique constraint).
func (h *httpServer) handleCreateSteamUser(w http.ResponseWriter, r *http.Request) {
	me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
	if !ok || me.Role != "admin" {
		writeError(w, http.StatusForbidden, "forbidden", "admin only")
		return
	}
	var req struct {
		SteamID string `json:"steam_id"`
		Email   string `json:"email,omitempty"`
		Role    string `json:"role,omitempty"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if !steamID64RE.MatchString(req.SteamID) {
		writeError(w, http.StatusBadRequest, "bad_steam_id", "must be a 17-digit SteamID64")
		return
	}
	role := req.Role
	if role == "" {
		role = "user"
	}
	if role != "user" && role != "admin" {
		writeError(w, http.StatusBadRequest, "bad_role", "role must be 'user' or 'admin'")
		return
	}
	// Reject if Steam already linked.
	if existing, err := h.db.GetUserBySteamID(r.Context(), req.SteamID); err == nil && existing != nil {
		writeError(w, http.StatusConflict, "already_linked",
			fmt.Sprintf("Steam ID is already linked to %s", existing.Email))
		return
	}
	// Derive email from Steam display name if not supplied. Steam
	// nicknames can contain anything; we sanitize to a sensible local-
	// part. Falls back to steamid@steam.local on lookup failure.
	email := strings.TrimSpace(req.Email)
	if email == "" {
		email = h.deriveSteamEmail(r.Context(), req.SteamID)
	}
	id, err := h.db.CreateSteamUser(r.Context(), email, req.SteamID, role)
	if err != nil {
		// Likely a unique-constraint hit on email/steam_id; surface the
		// raw DB error since the panel UI shows it verbatim.
		writeError(w, http.StatusConflict, "create_failed", err.Error())
		return
	}
	h.log.Info("steam user created", "by_admin_id", me.ID, "new_user_id", id, "email", email, "steam_id", req.SteamID, "role", role)
	writeJSON(w, http.StatusCreated, map[string]any{
		"id":       id,
		"email":    email,
		"steam_id": req.SteamID,
		"role":     role,
	})
}

// deriveSteamEmail builds a placeholder email from a Steam profile.
// Tries the display name first ("Pete (76561198...)" → "pete@steam.local"),
// falls back to "steam-<id>@steam.local". Email column has a unique
// constraint, so we suffix collisions with the SteamID's last 6 chars
// — admins can edit later if they care.
func (h *httpServer) deriveSteamEmail(ctx context.Context, steamID string) string {
	suffix := steamID
	if len(steamID) > 6 {
		suffix = steamID[len(steamID)-6:]
	}
	body, err := steamProfileCacheStore.fetch(ctx, steamID)
	if err == nil {
		var prof steamProfileXML
		if xerr := xml.Unmarshal(body, &prof); xerr == nil && prof.SteamID != "" {
			// Slug the display name: lower, alphanumeric + dashes, max 20.
			s := strings.ToLower(prof.SteamID)
			var b strings.Builder
			for _, c := range s {
				if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
					b.WriteRune(c)
				} else if c == ' ' || c == '-' || c == '_' {
					b.WriteByte('-')
				}
			}
			slug := strings.Trim(b.String(), "-")
			if len(slug) > 20 {
				slug = slug[:20]
			}
			if slug != "" {
				return slug + "-" + suffix + "@steam.local"
			}
		}
	}
	return "steam-" + suffix + "@steam.local"
}

// handleListUsers — admin-only. Returns every user row with their
// linked SteamID (if any). The Steam Accounts modal renders this.
func (h *httpServer) handleListUsers(w http.ResponseWriter, r *http.Request) {
	me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
	if !ok || me.Role != "admin" {
		writeError(w, http.StatusForbidden, "forbidden", "admin only")
		return
	}
	rows, err := h.db.ListUsers(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	out := make([]userDTO, 0, len(rows))
	for _, u := range rows {
		out = append(out, userDTO{
			ID:        u.ID,
			Email:     u.Email,
			Role:      u.Role,
			SteamID:   u.SteamID,
			Disabled:  u.Disabled,
			CreatedAt: u.CreatedAt.Format(time.RFC3339),
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"users": out})
}

// handleLinkSteamID — POST /api/users/{id}/steam {steam_id: "..."}.
// Admin can link any user's Steam; non-admin can only link their own.
func (h *httpServer) handleLinkSteamID(w http.ResponseWriter, r *http.Request) {
	me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
	if !ok {
		writeError(w, http.StatusUnauthorized, "unauthenticated", "log in")
		return
	}
	idStr := r.PathValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_id", err.Error())
		return
	}
	if me.Role != "admin" && me.ID != id {
		writeError(w, http.StatusForbidden, "forbidden", "you can only link your own Steam account")
		return
	}
	var req struct {
		SteamID string `json:"steam_id"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if !steamID64RE.MatchString(req.SteamID) {
		writeError(w, http.StatusBadRequest, "bad_steam_id", "must be a 17-digit SteamID64 starting with 7656119")
		return
	}
	if err := h.db.LinkSteamIDToUser(r.Context(), id, req.SteamID); err != nil {
		writeError(w, http.StatusInternalServerError, "link", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"id": id, "steam_id": req.SteamID})
}

// handleUnlinkSteamID — DELETE /api/users/{id}/steam.
// Admin or self.
func (h *httpServer) handleUnlinkSteamID(w http.ResponseWriter, r *http.Request) {
	me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
	if !ok {
		writeError(w, http.StatusUnauthorized, "unauthenticated", "log in")
		return
	}
	idStr := r.PathValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_id", err.Error())
		return
	}
	if me.Role != "admin" && me.ID != id {
		writeError(w, http.StatusForbidden, "forbidden", "you can only unlink your own Steam account")
		return
	}
	if err := h.db.UnlinkSteamID(r.Context(), id); err != nil {
		writeError(w, http.StatusInternalServerError, "unlink", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "unlinked"})
}

// ---- Steam public profile proxy ----

// steamProfileXML is the subset of fields we surface to the panel UI.
// The full XML has dozens; only avatar URLs + display name are useful.
type steamProfileXML struct {
	XMLName       xml.Name `xml:"profile"`
	SteamID64     string   `xml:"steamID64"`
	SteamID       string   `xml:"steamID"`        // display name (can have HTML entities)
	OnlineState   string   `xml:"onlineState"`    // online | offline | in-game
	StateMessage  string   `xml:"stateMessage"`
	AvatarIcon    string   `xml:"avatarIcon"`
	AvatarMedium  string   `xml:"avatarMedium"`
	AvatarFull    string   `xml:"avatarFull"`
	CustomURL     string   `xml:"customURL"`
	VacBanned     int      `xml:"vacBanned"`
	TradeBanState string   `xml:"tradeBanState"`
}

// Cache profile fetches for a few minutes — Steam rate-limits hard if
// the panel hammers their server (ratelimit kicks in around 200/min
// per IP). Operators reloading the modal repeatedly shouldn't lock
// the panel out of Steam queries.
type steamProfileCache struct {
	mu     sync.Mutex
	byID   map[string]steamProfileCacheEntry
	maxAge time.Duration
}
type steamProfileCacheEntry struct {
	body []byte
	at   time.Time
}

var steamProfileCacheStore = &steamProfileCache{
	byID:   map[string]steamProfileCacheEntry{},
	maxAge: 5 * time.Minute,
}

// handleSteamProfileFetch — GET /api/steam/profile?steamid=X. Pulls the
// public XML profile and returns a slim JSON the UI can render: avatar
// URL, display name, online state.
func (h *httpServer) handleSteamProfileFetch(w http.ResponseWriter, r *http.Request) {
	if _, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow); !ok {
		writeError(w, http.StatusUnauthorized, "unauthenticated", "log in")
		return
	}
	steamID := r.URL.Query().Get("steamid")
	if !steamID64RE.MatchString(steamID) {
		writeError(w, http.StatusBadRequest, "bad_steam_id", "expected 17-digit SteamID64")
		return
	}
	body, err := steamProfileCacheStore.fetch(r.Context(), steamID)
	if err != nil {
		writeError(w, http.StatusBadGateway, "fetch", err.Error())
		return
	}
	var prof steamProfileXML
	if err := xml.Unmarshal(body, &prof); err != nil {
		writeError(w, http.StatusBadGateway, "parse", err.Error())
		return
	}
	if prof.SteamID64 == "" {
		// Steam's XML returns a different root element when the profile
		// is private/missing — the unmarshal succeeds but key fields are
		// empty. Surface a clear 404 instead of a broken-looking 200.
		writeError(w, http.StatusNotFound, "profile_not_found", "Steam returned no public profile for that ID (private or invalid)")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"steam_id_64":   prof.SteamID64,
		"display_name":  prof.SteamID,
		"avatar_icon":   prof.AvatarIcon,
		"avatar_medium": prof.AvatarMedium,
		"avatar_full":   prof.AvatarFull,
		"online_state":  prof.OnlineState,
		"state_message": prof.StateMessage,
		"custom_url":    prof.CustomURL,
	})
}

func (c *steamProfileCache) fetch(ctx context.Context, steamID string) ([]byte, error) {
	c.mu.Lock()
	if e, ok := c.byID[steamID]; ok && time.Since(e.at) < c.maxAge {
		c.mu.Unlock()
		return e.body, nil
	}
	c.mu.Unlock()
	url := fmt.Sprintf("https://steamcommunity.com/profiles/%s/?xml=1", steamID)
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("User-Agent", version.UserAgent())
	httpClient := &http.Client{Timeout: 8 * time.Second}
	resp, err := httpClient.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("steamcommunity.com returned %s", resp.Status)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
	if err != nil {
		return nil, err
	}
	c.mu.Lock()
	c.byID[steamID] = steamProfileCacheEntry{body: body, at: time.Now()}
	c.mu.Unlock()
	return body, nil
}
