// 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 Catalogger.Backend.Api;
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Bot;
using Catalogger.Backend.Bot.Commands;
using Catalogger.Backend.Bot.Responders.Messages;
using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Cache.RedisCache;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Redis;
using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Services;
using NodaTime;
using Prometheus;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Services;
using Remora.Discord.Gateway.Extensions;
using Remora.Discord.Interactivity.Services;
using Remora.Rest.Core;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.SystemConsole.Themes;

namespace Catalogger.Backend.Extensions;

public static class StartupExtensions
{
    /// <summary>
    /// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls.
    /// </summary>
    public static WebApplicationBuilder AddSerilog(
        this WebApplicationBuilder builder,
        Config config
    )
    {
        var logCfg = new LoggerConfiguration()
            .Enrich.FromLogContext()
            .MinimumLevel.Is(config.Logging.LogEventLevel)
            // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead.
            // Serilog doesn't disable the built-in logs, so we do it here.
            .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
            .MinimumLevel.Override(
                "Microsoft.EntityFrameworkCore.Database.Command",
                config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal
            )
            .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
            .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
            .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
            // The default theme doesn't support light mode
            .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen, applyThemeToRedirectedOutput: true);

        if (config.Logging.SeqLogUrl != null)
        {
            logCfg.WriteTo.Seq(
                config.Logging.SeqLogUrl,
                restrictedToMinimumLevel: LogEventLevel.Verbose
            );
        }

        // AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually.
        builder.Services.AddSerilog().AddSingleton(Log.Logger = logCfg.CreateLogger());

        return builder;
    }

    public static Config AddConfiguration(this WebApplicationBuilder builder)
    {
        builder.Configuration.Sources.Clear();
        builder.Configuration.AddConfiguration();

        var config = builder.Configuration.Get<Config>() ?? new();
        builder.Services.AddSingleton(config);
        return config;
    }

    public static IConfigurationBuilder AddConfiguration(this IConfigurationBuilder builder)
    {
        var file = Environment.GetEnvironmentVariable("CATALOGGER_CONFIG_FILE") ?? "config.ini";

        return builder
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddIniFile(file, optional: false, reloadOnChange: false)
            .AddEnvironmentVariables();
    }

    public static IServiceCollection AddCustomServices(this IServiceCollection services) =>
        services
            .AddSingleton<IClock>(SystemClock.Instance)
            .AddDatabasePool()
            .AddScoped<DatabaseMigrator>()
            .AddScoped<MessageRepository>()
            .AddScoped<GuildRepository>()
            .AddScoped<InviteRepository>()
            .AddScoped<WatchlistRepository>()
            .AddScoped<TimeoutRepository>()
            .AddSingleton<GuildCache>()
            .AddSingleton<RoleCache>()
            .AddSingleton<ChannelCache>()
            .AddSingleton<UserCache>()
            .AddSingleton<AuditLogCache>()
            .AddSingleton<EmojiCache>()
            .AddSingleton<PluralkitApiService>()
            .AddSingleton<NewsService>()
            .AddScoped<IEncryptionService, EncryptionService>()
            .AddSingleton<TimeoutService>()
            .AddSingleton<MetricsCollectionService>()
            .AddSingleton<WebhookExecutorService>()
            .AddSingleton<PkMessageHandler>()
            .AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance)
            .AddTransient<PermissionResolverService>()
            .AddSingleton<GuildFetchService>()
            // Background services
            // GuildFetchService is added as a separate singleton as it's also injected into other services.
            .AddHostedService(serviceProvider =>
                serviceProvider.GetRequiredService<GuildFetchService>()
            )
            .AddHostedService<StatusUpdateService>()
            .AddHostedService<BackgroundTasksService>();

    public static IHostBuilder AddShardedDiscordService(
        this IHostBuilder builder,
        Func<IServiceProvider, string> tokenFactory
    ) =>
        builder.ConfigureServices(
            (_, services) =>
                services
                    .AddDiscordGateway(tokenFactory)
                    .AddSingleton<ShardedGatewayClient>()
                    .AddHostedService<ShardedDiscordService>()
        );

    /// <summary>
    /// The dashboard API is only enabled when Redis is configured, as it heavily relies on it.
    /// This method only adds API-related services when Redis is found as otherwise we'll get missing dependency errors.
    /// The actual API definition
    /// </summary>
    public static IServiceCollection MaybeAddDashboardServices(
        this IServiceCollection services,
        Config config
    )
    {
        if (config.Database.Redis == null)
            return services;

        return services
            .AddScoped<ApiTokenRepository>()
            .AddScoped<ApiCache>()
            .AddScoped<DiscordRequestService>()
            .AddScoped<AuthenticationMiddleware>()
            .AddScoped<ErrorMiddleware>();
    }

    public static IServiceCollection MaybeAddRedisCaches(
        this IServiceCollection services,
        Config config
    )
    {
        if (config.Database.Redis == null)
        {
            return services
                .AddSingleton<IWebhookCache, InMemoryWebhookCache>()
                .AddSingleton<IMemberCache, InMemoryMemberCache>()
                .AddSingleton<IInviteCache, InMemoryInviteCache>();
        }

        return services
            .AddSingleton<RedisService>()
            .AddSingleton<IWebhookCache, RedisWebhookCache>()
            .AddSingleton<IMemberCache, RedisMemberCache>()
            .AddSingleton<IInviteCache, RedisInviteCache>();
    }

    public static async Task Initialize(this WebApplication app)
    {
        await BuildInfo.ReadBuildInfo();

        await using var scope = app.Services.CreateAsyncScope();
        var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<Program>();
        logger.Information(
            "Starting Catalogger.NET {Version} ({Hash})",
            BuildInfo.Version,
            BuildInfo.Hash
        );

        CataloggerMetrics.Startup = scope
            .ServiceProvider.GetRequiredService<IClock>()
            .GetCurrentInstant();

        DatabasePool.ConfigureDapper();

        await using var migrator = scope.ServiceProvider.GetRequiredService<DatabaseMigrator>();
        await migrator.MigrateUp();

        var config = scope.ServiceProvider.GetRequiredService<Config>();
        var slashService = scope.ServiceProvider.GetRequiredService<SlashService>();
        var timeoutService = scope.ServiceProvider.GetRequiredService<TimeoutService>();

        if (config.Discord.TestMode)
            logger.Warning(
                "Catalogger is running in test mode. This means no logs will be sent and no commands will be responded to."
            );

        if (config.Discord.ApplicationId == 0)
        {
            logger.Warning(
                "Application ID not set in config. Fetching and setting it now, but for future restarts, please add it to config.ini as Discord.ApplicationId."
            );
            var restApi = scope.ServiceProvider.GetRequiredService<IDiscordRestApplicationAPI>();
            var application = await restApi.GetCurrentApplicationAsync().GetOrThrow();
            config.Discord.ApplicationId = application.ID.ToUlong();
            logger.Information(
                "Current application ID is {ApplicationId}",
                config.Discord.ApplicationId
            );
        }

        if (config.Discord.SyncCommands)
        {
            if (config.Discord.CommandsGuildId != null)
            {
                logger.Information(
                    "Syncing application commands with guild {GuildId}",
                    config.Discord.CommandsGuildId
                );
                await slashService.UpdateSlashCommandsAsync(
                    guildID: DiscordSnowflake.New(config.Discord.CommandsGuildId.Value)
                );
            }
            else
            {
                logger.Information("Syncing application commands globally");
                await slashService.UpdateSlashCommandsAsync();
            }
        }
        else
            logger.Information(
                "Not syncing slash commands, Discord.SyncCommands is false or unset"
            );

        // Initialize the timeout service by loading all the timeouts currently in the database.
        await timeoutService.InitializeAsync();
    }

    public static void MaybeAddDashboard(this WebApplication app)
    {
        using var scope = app.Services.CreateScope();
        var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<Program>();
        var config = scope.ServiceProvider.GetRequiredService<Config>();

        if (config.Database.Redis == null)
        {
            logger.Warning(
                "Redis URL is not set. The dashboard relies on Redis, so it will not be usable."
            );
            return;
        }

        app.UseSerilogRequestLogging();
        app.UseRouting();
        app.UseHttpMetrics();
        app.UseSwagger();
        app.UseSwaggerUI();
        app.UseCors();
        app.UseMiddleware<ErrorMiddleware>();
        app.UseMiddleware<AuthenticationMiddleware>();
        app.MapControllers();
    }
}
