// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Catalogger.Backend.Database.Models;
using Catalogger.Backend.Database.Repositories;
using NodaTime;

namespace Catalogger.Backend.Api;

public class DiscordRequestService
{
    private readonly ILogger _logger;
    private readonly HttpClient _httpClient;
    private readonly ApiCache _apiCache;
    private readonly Config _config;
    private readonly IClock _clock;
    private readonly ApiTokenRepository _tokenRepository;

    private static readonly JsonSerializerOptions JsonOptions =
        new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };

    public DiscordRequestService(
        ILogger logger,
        ApiCache apiCache,
        Config config,
        IClock clock,
        ApiTokenRepository tokenRepository
    )
    {
        _logger = logger.ForContext<DiscordRequestService>();
        _apiCache = apiCache;
        _config = config;
        _clock = clock;
        _tokenRepository = tokenRepository;

        _httpClient = new HttpClient();
        _httpClient.DefaultRequestHeaders.Add(
            "User-Agent",
            "DiscordBot (https://codeberg.org/starshine/catalogger, v1)"
        );
        _httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
    }

    private async Task<T> GetAsync<T>(Uri uri, string? token)
    {
        _logger.Information(
            "Sending request to {Uri}, authenticated? {Authed}",
            uri,
            token != null
        );

        var req = new HttpRequestMessage(HttpMethod.Get, uri);
        if (token != null)
            req.Headers.Add("Authorization", token);

        var resp = await _httpClient.SendAsync(req);
        if (!resp.IsSuccessStatusCode)
        {
            var errorText = await resp.Content.ReadAsStringAsync();
            _logger.Error("Error requesting {Uri} from Discord API: {Error}", uri, errorText);
        }
        resp.EnsureSuccessStatusCode();

        var entity = await resp.Content.ReadFromJsonAsync<T>(JsonOptions);
        if (entity == null)
            throw new CataloggerError("Could not deserialize JSON from Discord API");
        return entity;
    }

    private static readonly Uri DiscordUserUri = new("https://discord.com/api/v10/users/@me");
    private static readonly Uri DiscordGuildsUri =
        new("https://discord.com/api/v10/users/@me/guilds");
    private static readonly Uri DiscordTokenUri = new("https://discord.com/api/oauth2/token");

    public async Task<User> GetMeAsync(string token) => await GetAsync<User>(DiscordUserUri, token);

    public async Task<List<Guild>> GetGuildsAsync(string token) =>
        await GetAsync<List<Guild>>(DiscordGuildsUri, token);

    public async Task<User> GetMeAsync(ApiToken token)
    {
        var user = await _apiCache.GetUserAsync(token.UserId);
        if (user != null)
            return user;

        await MaybeRefreshDiscordTokenAsync(token);

        user = await GetMeAsync($"Bearer {token.AccessToken}");
        await _apiCache.SetUserAsync(user);
        return user;
    }

    public async Task<List<Guild>> GetGuildsAsync(ApiToken token)
    {
        var guilds = await _apiCache.GetGuildsAsync(token.UserId);
        if (guilds != null)
            return guilds;

        await MaybeRefreshDiscordTokenAsync(token);

        guilds = await GetGuildsAsync($"Bearer {token.AccessToken}");
        await _apiCache.SetGuildsAsync(token.UserId, guilds);
        return guilds;
    }

    public async Task<(ApiToken Token, User MeUser, List<Guild> Guilds)> RequestDiscordTokenAsync(
        string code,
        CancellationToken ct = default
    )
    {
        var redirectUri = $"{_config.Web.BaseUrl}/callback";
        var resp = await _httpClient.PostAsync(
            DiscordTokenUri,
            new FormUrlEncodedContent(
                new Dictionary<string, string>
                {
                    { "client_id", _config.Discord.ApplicationId.ToString() },
                    { "client_secret", _config.Discord.ClientSecret },
                    { "grant_type", "authorization_code" },
                    { "code", code },
                    { "redirect_uri", redirectUri },
                }
            ),
            ct
        );
        if (!resp.IsSuccessStatusCode)
        {
            var respBody = await resp.Content.ReadAsStringAsync(ct);
            _logger.Error(
                "Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
                (int)resp.StatusCode,
                respBody
            );
            throw new CataloggerError("Invalid Discord OAuth response");
        }
        var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(JsonOptions, ct);
        if (token == null)
            throw new CataloggerError("Discord token response was null");

        var meUser = await GetMeAsync($"Bearer {token.AccessToken}");
        var meGuilds = await GetGuildsAsync($"Bearer {token.AccessToken}");

        var apiToken = await _tokenRepository.CreateAsync(
            ApiUtils.RandomToken(64),
            meUser.Id,
            token.AccessToken,
            token.RefreshToken,
            token.ExpiresIn
        );

        return (apiToken, meUser, meGuilds);
    }

    public async Task MaybeRefreshDiscordTokenAsync(ApiToken token)
    {
        if (_clock.GetCurrentInstant() < token.ExpiresAt - Duration.FromDays(1))
        {
            _logger.Debug(
                "Discord token {TokenId} expires at {ExpiresAt}, not refreshing",
                token.Id,
                token.ExpiresAt
            );
            return;
        }

        if (token.RefreshToken == null)
        {
            _logger.Warning(
                "Discord token {TokenId} for user {UserId} is almost expired but has no refresh token, cannot refresh",
                token.Id,
                token.UserId
            );
            return;
        }

        var resp = await _httpClient.PostAsync(
            DiscordTokenUri,
            new FormUrlEncodedContent(
                new Dictionary<string, string>
                {
                    { "client_id", _config.Discord.ApplicationId.ToString() },
                    { "client_secret", _config.Discord.ClientSecret },
                    { "grant_type", "refresh_token" },
                    { "refresh_token", token.RefreshToken },
                }
            )
        );
        if (!resp.IsSuccessStatusCode)
        {
            var respBody = await resp.Content.ReadAsStringAsync();
            _logger.Error(
                "Received error status {StatusCode} when refreshing OAuth token: {ErrorBody}",
                (int)resp.StatusCode,
                respBody
            );

            return;
        }

        var discordToken = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(JsonOptions);
        if (discordToken == null)
        {
            _logger.Warning("Discord response for refreshing {TokenId} was null", token.Id);
            return;
        }

        _logger.Information(
            "Updating token {TokenId} with new access token and refresh token, expiring in {ExpiresIn}",
            token.Id,
            Duration.FromSeconds(discordToken.ExpiresIn)
        );

        token.AccessToken = discordToken.AccessToken;
        token.RefreshToken = discordToken.RefreshToken;
        token.ExpiresAt = _clock.GetCurrentInstant() + Duration.FromSeconds(discordToken.ExpiresIn);

        await _tokenRepository.UpdateAsync(
            token.Id,
            discordToken.AccessToken,
            discordToken.RefreshToken,
            discordToken.ExpiresIn
        );
    }

    [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
    private record DiscordTokenResponse(string AccessToken, string? RefreshToken, int ExpiresIn);
}

public record User(string Id, string Username, string Discriminator, string? Avatar)
{
    public string Tag => Discriminator != "0" ? $"{Username}#{Discriminator}" : Username;
    public string AvatarUrl =>
        Avatar == null
            ? "https://cdn.discordapp.com/embed/avatars/0.png?size=256"
            : $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.webp?size=256";
}

public record Guild(string Id, string Name, string? Icon, string? Permissions)
{
    public string IconUrl =>
        Icon == null
            ? "https://cdn.discordapp.com/embed/avatars/0.png?size=256"
            : $"https://cdn.discordapp.com/icons/{Id}/{Icon}.webp?size=256";

    public bool CanManage =>
        ulong.TryParse(Permissions, out var perms)
        && ((perms & Administrator) == Administrator || (perms & ManageGuild) == ManageGuild);

    private const ulong Administrator = 1 << 3;
    private const ulong ManageGuild = 1 << 5;
}
