--!strict
-- DreamSyncClient.lua
--
-- OOP wrapper around the DreamSync HTTP API for use inside a Roblox
-- game server. Drop this ModuleScript into ServerScriptService (or
-- ServerStorage) and require it from a Script.
--
-- Quick start:
--
--   local DreamSync = require(game.ServerScriptService.DreamSyncClient)
--   local client = DreamSync.new({
--       apiKey  = "ds_live_xxxxxxxxxxxxxxxxxxxxxxxx",
--       guildId = "123456789012345678",
--   })
--
--   local result = client:LookupDiscord("987654321098765432")
--   if result.ok then
--       print("Roblox ID:", result.data.robloxId)
--   else
--       warn("lookup failed:", result.error)
--   end
--
-- Requirements:
--   - Game Settings > Security > "Allow HTTP Requests" must be ON.
--   - The API key must be created in the DreamSync dashboard under
--     the API section, scoped to this guild, with the scopes you
--     intend to use (see method docs below for required scopes).
--   - Never expose your API key to clients. Only use this module
--     from server-side scripts.

local HttpService = game:GetService("HttpService")

-- Default base URL. Pass `baseUrl` in the constructor to override (e.g.
-- when pointing at a self-hosted DreamSync instance).
local DEFAULT_BASE_URL = "https://api.dreamsync.link/api/v1"

-- Default request timeout. Roblox's HttpService doesn't expose a
-- per-request timeout, so this is enforced via task.delay + cancel.
local DEFAULT_TIMEOUT_SECONDS = 10

export type ClientOptions = {
    apiKey: string,
    guildId: string,
    baseUrl: string?,
    timeoutSeconds: number?,
}

export type Result<T> = {
    ok: boolean,
    statusCode: number,
    data: T?,
    error: string?,
}

export type BanRecord = {
    guildId: string,
    robloxId: string,
    cachedUsername: string?,
    reason: string?,
    displayReason: string?,
    bannedByDiscordId: string,
    enforceDiscord: boolean,
    createdAt: string,
    liftedAt: string?,
}

export type BanUniverseOutcome = {
    banIntegrationId: string,
    universeId: string,
    label: string,
    status: string, -- 'success' | 'failed'
    error: string?,
}

export type BanCreateOptions = {
    robloxId: number | string,
    reason: string,
    durationSeconds: number?,    -- nil = permanent
    banIntegrationIds: { string }?, -- nil = all configured
    syncDiscord: boolean?,        -- defaults to true server-side
}

export type BanLiftOptions = {
    robloxId: number | string,
    reason: string?,
    banIntegrationIds: { string }?,
    syncDiscord: boolean?,
}

export type ModerationReport = {
    robloxId: number | string,
    action: string,        -- 'ban' or 'unban'
    universeId: number | string,
    reason: string?,
}

local DreamSyncClient = {}
DreamSyncClient.__index = DreamSyncClient

export type Client = typeof(setmetatable(
    {} :: {
        _apiKey: string,
        _guildId: string,
        _baseUrl: string,
        _timeout: number,
    },
    DreamSyncClient
))

-- ============================================================
-- Constructor
-- ============================================================

function DreamSyncClient.new(opts: ClientOptions): Client
    assert(type(opts) == "table", "DreamSync.new expects a table of options")
    assert(type(opts.apiKey) == "string" and #opts.apiKey > 0, "apiKey is required")
    assert(type(opts.guildId) == "string" and #opts.guildId > 0, "guildId is required")

    local self = setmetatable({
        _apiKey = opts.apiKey,
        _guildId = opts.guildId,
        _baseUrl = opts.baseUrl or DEFAULT_BASE_URL,
        _timeout = opts.timeoutSeconds or DEFAULT_TIMEOUT_SECONDS,
    }, DreamSyncClient)

    return self
end

-- ============================================================
-- Internal request helpers
-- ============================================================

local function encodeQueryParam(key: string, value: any): string
    return HttpService:UrlEncode(key) .. "=" .. HttpService:UrlEncode(tostring(value))
end

local function buildQuery(query: { [string]: any }?): string
    if not query then return "" end
    local parts = {}
    for k, v in pairs(query) do
        if v ~= nil then
            table.insert(parts, encodeQueryParam(k, v))
        end
    end
    if #parts == 0 then return "" end
    return "?" .. table.concat(parts, "&")
end

-- Coerce a number/string Roblox or Discord id to a string. The API
-- always takes IDs as strings to avoid 64-bit precision loss.
local function toIdString(id: number | string): string
    if type(id) == "number" then
        return string.format("%.0f", id)
    end
    return id
end

local function decode(body: string): any
    if body == nil or body == "" then return nil end
    local ok, decoded = pcall(HttpService.JSONDecode, HttpService, body)
    if ok then return decoded end
    return body
end

function DreamSyncClient._request<T>(
    self: Client,
    method: string,
    path: string,
    body: any?,
    query: { [string]: any }?
): Result<T>
    local url = self._baseUrl .. path .. buildQuery(query)
    local headers: { [string]: string } = {
        ["Authorization"] = "Bearer " .. self._apiKey,
        ["Accept"] = "application/json",
    }

    local request: { [string]: any } = {
        Url = url,
        Method = method,
        Headers = headers,
    }

    if body ~= nil then
        headers["Content-Type"] = "application/json"
        request.Body = HttpService:JSONEncode(body)
    end

    local ok, response = pcall(HttpService.RequestAsync, HttpService, request)
    if not ok then
        return {
            ok = false,
            statusCode = 0,
            error = "RequestAsync failed: " .. tostring(response),
        }
    end

    local decoded = decode(response.Body)
    if response.Success then
        return {
            ok = true,
            statusCode = response.StatusCode,
            data = decoded,
        }
    end

    local errMessage: string
    if type(decoded) == "table" and decoded.error and decoded.error.message then
        errMessage = decoded.error.message
    elseif type(decoded) == "table" and decoded.error and decoded.error.code then
        errMessage = decoded.error.code
    else
        errMessage = "HTTP " .. tostring(response.StatusCode)
    end

    return {
        ok = false,
        statusCode = response.StatusCode,
        data = decoded,
        error = errMessage,
    }
end

-- ============================================================
-- Lookups (guild-scoped)
--   Required scopes: link:read
-- ============================================================

-- Look up the Roblox account linked to a Discord user, scoped to this
-- guild. Use this for Free/Pro tiers — the result is restricted to
-- DreamSync members of the calling guild.
function DreamSyncClient.LookupDiscord(self: Client, discordId: number | string): Result<any>
    return self:_request(
        "GET",
        "/guilds/" .. self._guildId .. "/discord-to-roblox/" .. toIdString(discordId)
    )
end

-- Look up Discord users linked to a Roblox account, scoped to this
-- guild. Free/Pro tier semantics — same caveats as LookupDiscord.
function DreamSyncClient.LookupRoblox(self: Client, robloxId: number | string): Result<any>
    return self:_request(
        "GET",
        "/guilds/" .. self._guildId .. "/roblox-to-discord/" .. toIdString(robloxId)
    )
end

-- ============================================================
-- Updates
--   Required scopes: members:update
-- ============================================================

-- Trigger DreamSync to recompute role bindings + nickname for a Discord
-- member. Useful after a custom in-game event that should immediately
-- flip a role binding (e.g. the player just hit max level).
function DreamSyncClient.UpdateMember(self: Client, discordId: number | string): Result<any>
    return self:_request(
        "POST",
        "/guilds/" .. self._guildId .. "/update/" .. toIdString(discordId)
    )
end

-- ============================================================
-- Bans
--   Required scopes: bans:read for List/Get, bans:write for Create/Lift
-- ============================================================

-- List active bans (or include lifted with `includeLifted = true`).
-- Cursor-paginated; `nextCursor` in the response feeds the next call.
function DreamSyncClient.ListBans(
    self: Client,
    opts: { includeLifted: boolean?, limit: number?, cursor: string? }?
): Result<{ items: { BanRecord }, nextCursor: string? }>
    return self:_request("GET", "/guilds/" .. self._guildId .. "/bans", nil, opts)
end

-- Fetch a single ban record by Roblox user ID. Returns 404 if no ban
-- exists for that user in this guild.
function DreamSyncClient.GetBan(self: Client, robloxId: number | string): Result<BanRecord>
    return self:_request(
        "GET",
        "/guilds/" .. self._guildId .. "/bans/" .. toIdString(robloxId)
    )
end

-- Create a Roblox ban. Fans out to all configured ban integrations
-- unless `banIntegrationIds` is provided. `syncDiscord` defaults to
-- true server-side, set false to skip the Discord-side ban.
function DreamSyncClient.CreateBan(
    self: Client,
    options: BanCreateOptions
): Result<BanRecord & { universes: { BanUniverseOutcome }, discordSync: string }>
    assert(options ~= nil and options.robloxId ~= nil, "robloxId is required")
    assert(type(options.reason) == "string" and #options.reason > 0, "reason is required")

    local robloxId = toIdString(options.robloxId)
    local body = {
        reason = options.reason,
        durationSeconds = options.durationSeconds,
        banIntegrationIds = options.banIntegrationIds,
        syncDiscord = options.syncDiscord,
    }
    return self:_request("POST", "/guilds/" .. self._guildId .. "/bans/" .. robloxId, body)
end

-- Lift (unban) a Roblox ban. Same fan-out semantics as CreateBan.
function DreamSyncClient.LiftBan(
    self: Client,
    options: BanLiftOptions
): Result<{ lifted: boolean, universes: { BanUniverseOutcome }, discordSync: string }>
    assert(options ~= nil and options.robloxId ~= nil, "robloxId is required")

    local robloxId = toIdString(options.robloxId)
    local body = {
        reason = options.reason,
        banIntegrationIds = options.banIntegrationIds,
        syncDiscord = options.syncDiscord,
    }
    return self:_request("DELETE", "/guilds/" .. self._guildId .. "/bans/" .. robloxId, body)
end

-- ============================================================
-- Moderation report
--   Required scopes: bans:write
-- ============================================================

-- Report a Roblox-side ban or unban so DreamSync can mirror it onto
-- Discord (Roblox→Discord sync). Use this when your game server bans
-- someone via your own logic and you want the Discord ban to follow.
-- DreamSync only acts if the guild has Roblox→Discord sync enabled
-- and (in SELECTED mode) the source universe is on the allow-list.
function DreamSyncClient.ReportModeration(
    self: Client,
    report: ModerationReport
): Result<{ applied: boolean, reason: string, error: string? }>
    assert(report ~= nil, "report is required")
    assert(report.action == "ban" or report.action == "unban", "action must be 'ban' or 'unban'")

    local body = {
        robloxId = toIdString(report.robloxId),
        action = report.action,
        universeId = toIdString(report.universeId),
        reason = report.reason,
    }
    return self:_request("POST", "/guilds/" .. self._guildId .. "/moderation/report", body)
end

-- ============================================================
-- Developer-tier global lookups
--   Required scopes: link:read:global
--   Plan required:   Developer
-- ============================================================

-- Global Discord→Roblox lookup not scoped to this guild. Returns the
-- linked Roblox ID for any DreamSync user. Developer plan only.
function DreamSyncClient.LookupDiscordGlobal(self: Client, discordId: number | string): Result<any>
    return self:_request("GET", "/discord-to-roblox/" .. toIdString(discordId))
end

-- Global Roblox→Discord lookup. Developer plan only.
function DreamSyncClient.LookupRobloxGlobal(self: Client, robloxId: number | string): Result<any>
    return self:_request("GET", "/roblox-to-discord/" .. toIdString(robloxId))
end

-- ============================================================
-- Role checks (Developer-tier)
--   Required scopes: roles:check
-- ============================================================

-- Check whether the Discord user has a given Discord role in the
-- caller's guild. Returns `{ hasRole = true|false }`.
function DreamSyncClient.DiscordHasRole(
    self: Client,
    discordId: number | string,
    roleId: string
): Result<{ hasRole: boolean }>
    return self:_request(
        "GET",
        "/guilds/" .. self._guildId
            .. "/discord-has-role/" .. toIdString(discordId)
            .. "/" .. roleId
    )
end

-- Check whether the Roblox user (linked to a member of this guild)
-- has a given Discord role. Returns 404 if the Roblox user isn't a
-- DreamSync-linked member of this guild.
function DreamSyncClient.RobloxHasRole(
    self: Client,
    robloxId: number | string,
    roleId: string
): Result<{ hasRole: boolean }>
    return self:_request(
        "GET",
        "/guilds/" .. self._guildId
            .. "/roblox-has-role/" .. toIdString(robloxId)
            .. "/" .. roleId
    )
end

return DreamSyncClient
