using System.Data;
using System.Globalization;
using System.Net;
using Argus.Contracts;
using Argus.Data;
using Argus.Domain;
using Argus.Infrastructure;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using EntityTagHeaderValue = Microsoft.Net.Http.Headers.EntityTagHeaderValue;

var builder = WebApplication.CreateBuilder(args);
const int MaxRetainedReleasesPerApp = 1;
var configuredUrls =
    builder.Configuration["ASPNETCORE_URLS"] ??
    builder.Configuration["URLS"] ??
    "http://0.0.0.0:5105";

builder.WebHost.UseUrls(configuredUrls);

builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
});
builder.Services.AddDbContextPool<ArgusDbContext>(options =>
    options.UseSqlite(
        builder.Configuration.GetConnectionString("ArgusDb") ??
        "Data Source=Data/argus.db"));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
    options.KnownProxies.Add(IPAddress.Loopback);
    options.KnownProxies.Add(IPAddress.IPv6Loopback);
});
builder.Services.AddSingleton<StoragePaths>();
builder.Services.AddSingleton<PackageStorageService>();

var app = builder.Build();

app.Services.GetRequiredService<StoragePaths>().EnsureCreated();

await using (var scope = app.Services.CreateAsyncScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<ArgusDbContext>();
    await SqliteSchemaMigrator.EnsureCurrentSchemaAsync(dbContext);

    var storageService = scope.ServiceProvider.GetRequiredService<PackageStorageService>();
    await PruneOldReleasesAsync(dbContext, storageService, MaxRetainedReleasesPerApp);
}

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseForwardedHeaders();
app.UseResponseCompression();
app.UseDefaultFiles();
app.UseStaticFiles();

app.MapGet("/health", async (ArgusDbContext dbContext, CancellationToken cancellationToken) =>
{
    var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
    return canConnect
        ? Results.Ok(new { status = "ok" })
        : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
});

var api = app.MapGroup("/api");

api.MapGet("/apps", async (ArgusDbContext dbContext, CancellationToken cancellationToken) =>
{
    var apps = await dbContext.Apps
        .AsNoTracking()
        .Include(x => x.Releases)
        .Where(x => x.IsListed)
        .OrderBy(x => x.Name)
        .ToListAsync(cancellationToken);

    return Results.Ok(apps.Select(MapListItem));
});

api.MapGet("/apps/{slug}", async (string slug, ArgusDbContext dbContext, CancellationToken cancellationToken) =>
{
    if (!SlugUtility.IsValid(slug))
    {
        return Results.NotFound();
    }

    var catalogApp = await dbContext.Apps
        .AsNoTracking()
        .Include(x => x.Releases)
        .SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken);

    return catalogApp is null
        ? Results.NotFound()
        : Results.Ok(MapDetailItem(catalogApp));
});

api.MapGet("/apps/{slug}/manifest", async (
    string slug,
    string? platform,
    string? channel,
    ArgusDbContext dbContext,
    CancellationToken cancellationToken) =>
{
    if (!SlugUtility.IsValid(slug))
    {
        return Results.NotFound();
    }

    var normalizedPlatform = NormalizeFilter(platform);
    var normalizedChannel = NormalizeFilter(channel);
    var catalogApp = await dbContext.Apps
        .AsNoTracking()
        .Include(x => x.Releases.Where(release =>
            (normalizedPlatform == null || release.Platform == normalizedPlatform) &&
            (normalizedChannel == null || release.Channel == normalizedChannel)))
        .SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken);

    if (catalogApp is null)
    {
        return Results.NotFound();
    }

    var latestRelease = SelectLatestRelease(catalogApp, normalizedPlatform, normalizedChannel);
    if (latestRelease is null)
    {
        return Results.NotFound();
    }

    return Results.Ok(new AppManifestDto(
        catalogApp.Slug,
        catalogApp.Name,
        catalogApp.Summary,
        catalogApp.Description,
        catalogApp.RepositoryUrl,
        catalogApp.HomepageUrl,
        MapReleaseItem(catalogApp.Slug, latestRelease)));
});

api.MapGet("/apps/{slug}/download/latest", async Task<IResult> (
    string slug,
    string? platform,
    string? channel,
    HttpContext httpContext,
    ArgusDbContext dbContext,
    PackageStorageService storageService,
    CancellationToken cancellationToken) =>
{
    if (!SlugUtility.IsValid(slug))
    {
        return Results.NotFound();
    }

    var normalizedPlatform = NormalizeFilter(platform);
    var normalizedChannel = NormalizeFilter(channel);
    var catalogApp = await dbContext.Apps
        .AsNoTracking()
        .Include(x => x.Releases.Where(release =>
            (normalizedPlatform == null || release.Platform == normalizedPlatform) &&
            (normalizedChannel == null || release.Channel == normalizedChannel)))
        .SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken);

    if (catalogApp is null)
    {
        return Results.NotFound();
    }

    var latestRelease = SelectLatestRelease(catalogApp, normalizedPlatform, normalizedChannel);
    if (latestRelease is null)
    {
        return Results.NotFound();
    }

    var physicalPath = storageService.ResolvePhysicalPath(latestRelease.StoredRelativePath);
    if (!File.Exists(physicalPath))
    {
        return Results.NotFound();
    }

    SetPackageCacheHeaders(httpContext, immutable: false);
    return Results.File(
        physicalPath,
        latestRelease.ContentType,
        latestRelease.OriginalFileName,
        lastModified: File.GetLastWriteTimeUtc(physicalPath),
        entityTag: CreatePackageEntityTag(latestRelease.Sha256),
        enableRangeProcessing: true);
});

api.MapGet("/apps/{slug}/releases/{releaseId:guid}/download", async Task<IResult> (
    string slug,
    Guid releaseId,
    HttpContext httpContext,
    ArgusDbContext dbContext,
    PackageStorageService storageService,
    CancellationToken cancellationToken) =>
{
    if (!SlugUtility.IsValid(slug))
    {
        return Results.NotFound();
    }

    var release = await dbContext.Releases
        .AsNoTracking()
        .Where(x => x.Id == releaseId && x.CatalogApp.Slug == slug && x.CatalogApp.IsListed)
        .Select(x => new
        {
            x.StoredRelativePath,
            x.ContentType,
            x.OriginalFileName,
            x.Sha256
        })
        .SingleOrDefaultAsync(
            cancellationToken);

    if (release is null)
    {
        return Results.NotFound();
    }

    var physicalPath = storageService.ResolvePhysicalPath(release.StoredRelativePath);
    if (!File.Exists(physicalPath))
    {
        return Results.NotFound();
    }

    SetPackageCacheHeaders(httpContext, immutable: true);
    return Results.File(
        physicalPath,
        release.ContentType,
        release.OriginalFileName,
        lastModified: File.GetLastWriteTimeUtc(physicalPath),
        entityTag: CreatePackageEntityTag(release.Sha256),
        enableRangeProcessing: true);
});

api.MapGet("/releases/recent", async (ArgusDbContext dbContext, CancellationToken cancellationToken) =>
{
    var releases = (await dbContext.Releases
        .FromSqlRaw(
            """
            SELECT "Releases".*
            FROM "Releases"
            INNER JOIN "Apps" ON "Apps"."Id" = "Releases"."CatalogAppId"
            WHERE "Apps"."IsListed" = 1
            ORDER BY "Releases"."PublishedAt" DESC, "Releases"."Id" DESC
            LIMIT 12
            """)
        .AsNoTracking()
        .Include(x => x.CatalogApp)
        .ToListAsync(cancellationToken))
        .OrderByDescending(x => x.PublishedAt)
        .ThenByDescending(x => x.Id)
        .ToList();

    var items = releases.Select(release => new ReleaseFeedItemDto(
        release.CatalogApp.Slug,
        release.CatalogApp.Name,
        MapReleaseItem(release.CatalogApp.Slug, release)));

    return Results.Ok(items);
});

app.Run();

static AppListItemDto MapListItem(CatalogApp catalogApp)
{
    var latestRelease = OrderReleases(catalogApp.Releases)
        .FirstOrDefault();

    return new AppListItemDto(
        catalogApp.Slug,
        catalogApp.Name,
        catalogApp.Summary,
        catalogApp.RepositoryUrl,
        catalogApp.HomepageUrl,
        catalogApp.UpdatedAt,
        latestRelease is null ? null : MapReleaseItem(catalogApp.Slug, latestRelease),
        catalogApp.Releases.Count);
}

static AppDetailDto MapDetailItem(CatalogApp catalogApp)
{
    var releases = OrderReleases(catalogApp.Releases)
        .Select(release => MapReleaseItem(catalogApp.Slug, release))
        .ToArray();

    return new AppDetailDto(
        catalogApp.Slug,
        catalogApp.Name,
        catalogApp.Summary,
        catalogApp.Description,
        catalogApp.RepositoryUrl,
        catalogApp.HomepageUrl,
        catalogApp.CreatedAt,
        catalogApp.UpdatedAt,
        catalogApp.IsListed,
        releases);
}

static AppReleaseDto MapReleaseItem(string slug, AppRelease release) =>
    new(
        release.Id,
        release.Version,
        release.Channel,
        release.Platform,
        release.PackageKind,
        $"/api/apps/{slug}/releases/{release.Id}/download",
        release.OriginalFileName,
        release.ContentType,
        release.PackageSizeBytes,
        release.Sha256,
        release.PublishedAt,
        release.Notes);

static AppRelease? SelectLatestRelease(CatalogApp catalogApp, string? platform, string? channel)
{
    return OrderReleases(catalogApp.Releases
        .Where(x => platform is null || x.Platform == platform)
        .Where(x => channel is null || x.Channel == channel))
        .FirstOrDefault();
}

static IOrderedEnumerable<AppRelease> OrderReleases(IEnumerable<AppRelease> releases) =>
    releases
        .OrderByDescending(x => x.Version, Comparer<string>.Create(SemanticVersionUtility.Compare))
        .ThenByDescending(x => x.PublishedAt)
        .ThenByDescending(x => x.Id);

static void DeleteStoredPackages(PackageStorageService storageService, IEnumerable<string> storedRelativePaths)
{
    foreach (var storedRelativePath in storedRelativePaths)
    {
        storageService.TryDelete(storedRelativePath);
    }
}

static async Task PruneOldReleasesAsync(
    ArgusDbContext dbContext,
    PackageStorageService storageService,
    int maxRetainedReleasesPerApp,
    CancellationToken cancellationToken = default,
    Guid? catalogAppId = null)
{
    if (maxRetainedReleasesPerApp < 1)
    {
        throw new ArgumentOutOfRangeException(nameof(maxRetainedReleasesPerApp));
    }

    var releases = await LoadReleasePruneCandidatesAsync(dbContext, catalogAppId, cancellationToken);
    var releasesToPrune = releases
        .GroupBy(x => x.CatalogAppId, StringComparer.OrdinalIgnoreCase)
        .SelectMany(group => group
            .OrderByDescending(x => x.PublishedAt)
            .ThenByDescending(x => x.Id)
            .Skip(maxRetainedReleasesPerApp))
        .ToArray();

    if (releasesToPrune.Length == 0)
    {
        return;
    }

    await DeleteReleaseRowsAsync(dbContext, releasesToPrune.Select(x => x.RowId), cancellationToken);
    DeleteStoredPackages(storageService, releasesToPrune.Select(x => x.StoredRelativePath));
}

static async Task<IReadOnlyList<ReleasePruneCandidate>> LoadReleasePruneCandidatesAsync(
    ArgusDbContext dbContext,
    Guid? catalogAppId,
    CancellationToken cancellationToken)
{
    var connection = dbContext.Database.GetDbConnection();
    var mustClose = connection.State != ConnectionState.Open;

    if (mustClose)
    {
        await connection.OpenAsync(cancellationToken);
    }

    try
    {
        await using var command = connection.CreateCommand();
        command.CommandText =
            """
            SELECT rowid, "CatalogAppId", "StoredRelativePath", "PublishedAt", "Id"
            FROM "Releases"
            WHERE $catalogAppId IS NULL OR lower("CatalogAppId") = lower($catalogAppId)
            """;

        var catalogAppIdParameter = command.CreateParameter();
        catalogAppIdParameter.ParameterName = "$catalogAppId";
        catalogAppIdParameter.Value = catalogAppId?.ToString("D") ?? (object)DBNull.Value;
        command.Parameters.Add(catalogAppIdParameter);

        var candidates = new List<ReleasePruneCandidate>();
        await using var reader = await command.ExecuteReaderAsync(cancellationToken);
        while (await reader.ReadAsync(cancellationToken))
        {
            candidates.Add(new ReleasePruneCandidate(
                reader.GetInt64(0),
                reader.GetString(1),
                reader.GetString(2),
                DateTimeOffset.Parse(reader.GetString(3), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
                Guid.Parse(reader.GetString(4))));
        }

        return candidates;
    }
    finally
    {
        if (mustClose)
        {
            await connection.CloseAsync();
        }
    }
}

static async Task DeleteReleaseRowsAsync(
    ArgusDbContext dbContext,
    IEnumerable<long> rowIds,
    CancellationToken cancellationToken)
{
    var connection = dbContext.Database.GetDbConnection();
    var mustClose = connection.State != ConnectionState.Open;

    if (mustClose)
    {
        await connection.OpenAsync(cancellationToken);
    }

    await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
    try
    {
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = """DELETE FROM "Releases" WHERE rowid = $rowid""";

        var rowIdParameter = command.CreateParameter();
        rowIdParameter.ParameterName = "$rowid";
        command.Parameters.Add(rowIdParameter);

        foreach (var rowId in rowIds)
        {
            rowIdParameter.Value = rowId;
            await command.ExecuteNonQueryAsync(cancellationToken);
        }

        await transaction.CommitAsync(cancellationToken);
    }
    catch
    {
        await transaction.RollbackAsync(cancellationToken);
        throw;
    }
    finally
    {
        if (mustClose)
        {
            await connection.CloseAsync();
        }
    }
}

static void SetPackageCacheHeaders(HttpContext httpContext, bool immutable)
{
    httpContext.Response.Headers.CacheControl = immutable
        ? "public,max-age=31536000,immutable,no-transform"
        : "private,no-cache,no-transform";
}

static EntityTagHeaderValue CreatePackageEntityTag(string sha256) =>
    new($"\"{sha256}\"");

static string? NormalizeFilter(string? value) =>
    string.IsNullOrWhiteSpace(value) ? null : IdentifierUtility.Normalize(value, string.Empty);

sealed record ReleasePruneCandidate(
    long RowId,
    string CatalogAppId,
    string StoredRelativePath,
    DateTimeOffset PublishedAt,
    Guid Id);
