using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Rooms.V2;
///
/// api.rec.net/api/rooms/* — DB-backed implementation of the watch's
/// Rooms tab.
///
/// Replaces the previous stubs in MiscStubsController for:
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
/// roomserver/rooms/{id}, v1/personaldetails/{id}, v2/personaldetails/{id}.
///
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
/// fields plus optional VR-low / mobile / mic-mute flags).
///
[ApiController]
public class RoomsController(
RoomService rooms,
PlaylistService playlists,
DorkNetDbContext db,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
DomainConfig domain,
NotificationService notifications,
ServerSettingsService serverSettings,
ILogger logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
// ── Browse / Hot / Search ────────────────────────────────────────────
///
/// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab.
/// `roomScoreType` is ignored (we always sort by HotScore). `tags` is
/// passed through as a substring filter against the comma-separated
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
///
[HttpGet("api/rooms/v1/hot")]
public async Task HotV1(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
[HttpGet("rooms/hot")]
public async Task HotRoomServer(
[FromQuery] string? tag,
[FromQuery] string? tags,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
var all = await rooms.HotAsync(tag ?? tags, take: 200);
var page = all
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.Select(RoomService.ToWireRoom)
.ToList();
return Ok(new
{
Results = page,
TotalResults = all.Count,
});
}
[HttpGet("rooms/topcreators")]
[HttpGet("roomserver/rooms/topcreators")]
public async Task TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.Take(250)
.ToListAsync();
var page = rows
.GroupBy(r => r.CreatorPlayerId)
.Select(g => g.First())
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToList();
return Ok(await BuildRoomServerListAsync(page));
}
[HttpGet("rooms/contestwinners")]
[HttpGet("roomserver/rooms/contestwinners")]
public async Task ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.Where(r =>
EF.Functions.Like(r.TagsCsv, "%contest%") ||
EF.Functions.Like(r.TagsCsv, "%winner%") ||
EF.Functions.Like(r.TagsCsv, "%featured%"))
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
if (rows.Count == 0)
{
rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
}
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/fromcreators")]
[HttpPost("rooms/fromcreators")]
[HttpGet("roomserver/rooms/fromcreators")]
[HttpPost("roomserver/rooms/fromcreators")]
public async Task FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var creatorIds = Request.Query["creatorId"]
.Concat(Request.Query["creatorIds"])
.Concat(Request.Query["accountId"])
.Concat(Request.Query["accountIds"])
.Concat(Request.Query["id"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToList();
var query = PublicRoomQuery();
if (creatorIds.Count > 0)
query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId));
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.UpdatedAt)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/magic_door")]
[HttpGet("roomserver/rooms/magic_door")]
public async Task MagicDoor()
{
var room = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Id)
.FirstOrDefaultAsync();
if (room is null) return NotFound();
return Ok((await BuildRoomServerListAsync(new[] { room })).First());
}
///
/// GET /roomserver/rooms/recommendations — the home-tab
/// "Recommended" carousel. Wire shape is NOT a flat
/// List<Room>; the watch's
/// EJDCNGBEICB.JDKLBNBIEAH at EJDCNGBEICB.txt:1349,1430
/// returns IPromise<List<ANNKHNFLMNP>> and
/// at
/// ANNKHNFLMNP.txt:43-97 reads SeedRoom (sub-object,
/// KLCOGEIGEBJ i.e. full Room) and Rooms (list of
/// Rooms) as required keys. Returning [{room}, …] raised
/// KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ.
///
/// We group the hot list into one "card" per room — that gives the
/// watch one row per featured room with that room as its own
/// SeedRoom and a single-element Rooms list. Cheap, semantically
/// correct, and avoids the deserializer-crashing empty-Rooms
/// edge case the watch hits when a group has no rooms.
///
[HttpGet("roomserver/rooms/recommendations")]
[HttpGet("rooms/recommendations")]
public async Task Recommendations(
[FromQuery(Name = "splitTestId")] int? splitTestId,
[FromQuery(Name = "splitTestValue")] int? splitTestValue)
{
var hot = await rooms.HotAsync(null, take: 12);
if (hot.Count == 0) return Ok(Array.Empty