// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.FunctionalTests.Helpers;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Xunit;

namespace Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.Tests
{
    public class DatabaseErrorPageMiddlewareTest
    {
        [Fact]
        public async Task Successful_requests_pass_thru()
        {
            using var host = new HostBuilder()
                .ConfigureWebHost(webHostBuilder =>
                {
                    webHostBuilder
                    .UseTestServer()
#pragma warning disable CS0618 // Type or member is obsolete
                    .Configure(app => app
                    .UseDatabaseErrorPage()
#pragma warning restore CS0618 // Type or member is obsolete
                    .UseMiddleware<SuccessMiddleware>());
                }).Build();

            await host.StartAsync();

            var server = host.GetTestServer();

            HttpResponseMessage response = await server.CreateClient().GetAsync("http://localhost/");

            Assert.Equal("Request Handled", await response.Content.ReadAsStringAsync());
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        }

        class SuccessMiddleware
        {
            public SuccessMiddleware(RequestDelegate next)
            { }

            public virtual async Task Invoke(HttpContext context)
            {
                context.Response.StatusCode = (int)HttpStatusCode.OK;
                await context.Response.WriteAsync("Request Handled");
            }
        }

        [Fact]
        public async Task Non_database_exceptions_pass_thru()
        {
            using var host = new HostBuilder()
                .ConfigureWebHost(webHostBuilder =>
                {
                    webHostBuilder
                    .UseTestServer()
#pragma warning disable CS0618 // Type or member is obsolete
                    .Configure(app => app
                    .UseDatabaseErrorPage()
#pragma warning restore CS0618 // Type or member is obsolete
                    .UseMiddleware<ExceptionMiddleware>());
                }).Build();

            await host.StartAsync();

            var server = host.GetTestServer();

            var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
                await server.CreateClient().GetAsync("http://localhost/"));

            Assert.Equal("Exception requested from TestMiddleware", ex.Message);
        }

        class ExceptionMiddleware
        {
            public ExceptionMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                throw new InvalidOperationException("Exception requested from TestMiddleware");
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Existing_database_not_using_migrations_exception_passes_thru()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                using var host = await SetupServer<BloggingContext, DatabaseErrorButNoMigrationsMiddleware>(database);
                using var server = host.GetTestServer();
                var ex = await Assert.ThrowsAsync<DbUpdateException>(async () =>
                    await server.CreateClient().GetAsync("http://localhost/"));

                Assert.Equal("SQLite Error 1: 'no such table: Blogs'.", ex.InnerException.Message);
            }
        }

        class DatabaseErrorButNoMigrationsMiddleware
        {
            public DatabaseErrorButNoMigrationsMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                var db = context.RequestServices.GetService<BloggingContext>();
                db.Database.EnsureCreated();
                db.Database.ExecuteSqlRaw("ALTER TABLE Blogs RENAME TO Bloogs");

                db.Blogs.Add(new Blog());
                db.SaveChanges();
                throw new Exception("SaveChanges should have thrown");
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Error_page_displayed_no_migrations()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                using var host = await SetupServer<BloggingContext, NoMigrationsMiddleware>(database);
                using var server = host.GetTestServer();
                HttpResponseMessage response = await server.CreateClient().GetAsync("http://localhost/");

                Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
                var content = await response.Content.ReadAsStringAsync();
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_NoDbOrMigrationsTitle"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_NoDbOrMigrationsInfo"), content);
                Assert.Contains(typeof(BloggingContext).Name, content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_AddMigrationCommandPMC").Replace(">", "&gt;"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_ApplyMigrationsCommandPMC").Replace(">", "&gt;"), content);
            }
        }

        class NoMigrationsMiddleware
        {
            public NoMigrationsMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                var db = context.RequestServices.GetService<BloggingContext>();
                db.Blogs.Add(new Blog());
                db.SaveChanges();
                throw new Exception("SaveChanges should have thrown");
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task No_exception_on_diagnostic_event_received_when_null_state()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                using (var server = await SetupServer<BloggingContext, NoMigrationsMiddleware>(database))
                {
                    using (var db = server.Services.GetService<BloggingContext>())
                    {
                        db.Blogs.Add(new Blog());

                        try
                        {
                            db.SaveChanges();
                        }
                        catch (Exception e)
                        {
                            Assert.Equal("DbUpdateException", e.GetType().Name);
                        }
                    }
                }
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Error_page_displayed_pending_migrations()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                using var host = await SetupServer<BloggingContextWithMigrations, PendingMigrationsMiddleware>(database);
                using var server = host.GetTestServer();
                HttpResponseMessage response = await server.CreateClient().GetAsync("http://localhost/");

                Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);

                var content = await response.Content.ReadAsStringAsync();
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_PendingMigrationsTitle"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_PendingMigrationsInfo"), content);
                Assert.Contains(typeof(BloggingContextWithMigrations).Name, content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_ApplyMigrationsCommandPMC").Replace(">", "&gt;"), content);
                Assert.Contains("<li>111111111111111_MigrationOne</li>", content);
                Assert.Contains("<li>222222222222222_MigrationTwo</li>", content);

                Assert.DoesNotContain(StringsHelpers.GetResourceString("DatabaseErrorPage_AddMigrationCommandPMC").Replace(">", "&gt;"), content);
            }
        }

        class PendingMigrationsMiddleware
        {
            public PendingMigrationsMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                var db = context.RequestServices.GetService<BloggingContextWithMigrations>();
                db.Blogs.Add(new Blog());
                db.SaveChanges();
                throw new Exception("SaveChanges should have thrown");
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Error_page_displayed_pending_model_changes()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                using var host = await SetupServer<BloggingContextWithPendingModelChanges, PendingModelChangesMiddleware>(database);
                using var server = host.GetTestServer();
                HttpResponseMessage response = await server.CreateClient().GetAsync("http://localhost/");

                Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);

                var content = await response.Content.ReadAsStringAsync();
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_PendingChangesTitle"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_PendingChangesInfo"), content);
                Assert.Contains(typeof(BloggingContextWithPendingModelChanges).Name, content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_AddMigrationCommandCLI").Replace(">", "&gt;"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_AddMigrationCommandPMC").Replace(">", "&gt;"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_ApplyMigrationsCommandCLI").Replace(">", "&gt;"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_ApplyMigrationsCommandPMC").Replace(">", "&gt;"), content);
            }
        }

        class PendingModelChangesMiddleware
        {
            public PendingModelChangesMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                var db = context.RequestServices.GetService<BloggingContextWithPendingModelChanges>();
                db.Database.Migrate();

                db.Blogs.Add(new Blog());
                db.SaveChanges();
                throw new Exception("SaveChanges should have thrown");
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Error_page_then_apply_migrations()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                using var host = await SetupServer<BloggingContextWithMigrations, ApplyMigrationsMiddleware>(database);
                using var server = host.GetTestServer();
                var client = server.CreateClient();

                var expectedMigrationsEndpoint = "/ApplyDatabaseMigrations";
                var expectedContextType = typeof(BloggingContextWithMigrations).AssemblyQualifiedName;

                // Step One: Initial request with database failure
                HttpResponseMessage response = await client.GetAsync("http://localhost/");
                Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
                var content = await response.Content.ReadAsStringAsync();

                // Ensure the url we're going to test is what the page is using in it's JavaScript
                var javaScriptEncoder = JavaScriptEncoder.Default;
                Assert.Contains("req.open(\"POST\", \"" + JavaScriptEncode(expectedMigrationsEndpoint) + "\", true);", content);
                Assert.Contains("data-assemblyname=\"" + JavaScriptEncode(expectedContextType) + "\"", content);

                // Step Two: Request to migrations endpoint
                var formData = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>
                {
                    new KeyValuePair<string, string>("context", expectedContextType)
                });

                response = await client.PostAsync("http://localhost" + expectedMigrationsEndpoint, formData);
                content = await response.Content.ReadAsStringAsync();
                Console.WriteLine(content);
                Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);

                // Step Three: Successful request after migrations applied
                response = await client.GetAsync("http://localhost/");
                content = await response.Content.ReadAsStringAsync();
                Assert.Equal("Saved a Blog", content);
            }
        }

        class ApplyMigrationsMiddleware
        {
            public ApplyMigrationsMiddleware(RequestDelegate next)
            { }

            public virtual async Task Invoke(HttpContext context)
            {
                var db = context.RequestServices.GetService<BloggingContextWithMigrations>();
                db.Blogs.Add(new Blog());
                db.SaveChanges();
                await context.Response.WriteAsync("Saved a Blog");
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Customize_migrations_end_point()
        {
            var migrationsEndpoint = "/MyCustomEndPoints/ApplyMyMigrationsHere";

            using (var database = SqlTestStore.CreateScratch())
            {
                using var host = new HostBuilder()
                .ConfigureWebHost(webHostBuilder =>
                {
                    webHostBuilder
                    .UseTestServer()
                    .Configure(app =>
                    {
#pragma warning disable CS0618 // Type or member is obsolete
                        app.UseDatabaseErrorPage(new DatabaseErrorPageOptions
                        {
                            MigrationsEndPointPath = new PathString(migrationsEndpoint)
                        });
#pragma warning restore CS0618 // Type or member is obsolete

                        app.UseMiddleware<PendingMigrationsMiddleware>();
                    })
                    .ConfigureServices(services =>
                    {
                        services.AddDbContext<BloggingContextWithMigrations>(
                            optionsBuilder => optionsBuilder.UseSqlite(database.ConnectionString));
                    });
                }).Build();

                await host.StartAsync();

                var server = host.GetTestServer();

                HttpResponseMessage response = await server.CreateClient().GetAsync("http://localhost/");

                Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);

                var content = await response.Content.ReadAsStringAsync();
                Assert.Contains("req.open(\"POST\", \"" + JavaScriptEncode(migrationsEndpoint) + "\", true);", content);
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Pass_thru_when_context_not_in_services()
        {
            var logProvider = new TestLoggerProvider();

            using var host = new HostBuilder()
                .ConfigureWebHost(webHostBuilder =>
                {
                    webHostBuilder
                    .UseTestServer()
                    .Configure(app =>
                    {
#pragma warning disable CS0618 // Type or member is obsolete
                        app.UseDatabaseErrorPage();
#pragma warning restore CS0618 // Type or member is obsolete
                        app.UseMiddleware<ContextNotRegisteredInServicesMiddleware>();
#pragma warning disable CS0618 // Type or member is obsolete
                        app.ApplicationServices.GetService<ILoggerFactory>().AddProvider(logProvider);
#pragma warning restore CS0618 // Type or member is obsolete
                    });
                }).Build();

            await host.StartAsync();


            try
            {
                using var server = host.GetTestServer();
                await server.CreateClient().GetAsync("http://localhost/");
            }
            catch (Exception exception)
            {
                Assert.True(
                    exception.GetType().Name == "SqliteException"
                    || exception.InnerException?.GetType().Name == "SqliteException");
            }

            Assert.Contains(logProvider.Logger.Messages.ToList(), m =>
                m.StartsWith(StringsHelpers.GetResourceString("FormatDatabaseErrorPageMiddleware_ContextNotRegistered", typeof(BloggingContext)), StringComparison.Ordinal));
        }

        class ContextNotRegisteredInServicesMiddleware
        {
            public ContextNotRegisteredInServicesMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                using (var database = SqlTestStore.CreateScratch())
                {
                    var optionsBuilder = new DbContextOptionsBuilder()
                        .UseLoggerFactory(context.RequestServices.GetService<ILoggerFactory>())
                        .UseSqlite(database.ConnectionString);

                    var db = new BloggingContext(optionsBuilder.Options);
                    db.Blogs.Add(new Blog());
                    db.SaveChanges();
                    throw new Exception("SaveChanges should have thrown");
                }
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Pass_thru_when_exception_in_logic()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                var logProvider = new TestLoggerProvider();

                using var host = await SetupServer<BloggingContextWithSnapshotThatThrows, ExceptionInLogicMiddleware>(database, logProvider);

                try
                {
                    using var server = host.GetTestServer();
                    await server.CreateClient().GetAsync("http://localhost/");
                }
                catch (Exception exception)
                {
                    Assert.True(
                        exception.GetType().Name == "SqliteException"
                        || exception.InnerException?.GetType().Name == "SqliteException");
                }

                Assert.Contains(logProvider.Logger.Messages.ToList(), m =>
                    m.StartsWith(StringsHelpers.GetResourceString("DatabaseErrorPageMiddleware_Exception"), StringComparison.Ordinal));
            }
        }

        class ExceptionInLogicMiddleware
        {
            public ExceptionInLogicMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                var db = context.RequestServices.GetService<BloggingContextWithSnapshotThatThrows>();
                db.Blogs.Add(new Blog());
                db.SaveChanges();
                throw new Exception("SaveChanges should have thrown");
            }
        }

        [ConditionalFact]
        [OSSkipCondition(OperatingSystems.Linux)]
        [OSSkipCondition(OperatingSystems.MacOSX)]
        public async Task Error_page_displayed_when_exception_wrapped()
        {
            using (var database = SqlTestStore.CreateScratch())
            {
                using var host = await SetupServer<BloggingContext, WrappedExceptionMiddleware>(database);
                using var server = host.GetTestServer();
                HttpResponseMessage response = await server.CreateClient().GetAsync("http://localhost/");

                Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
                var content = await response.Content.ReadAsStringAsync();
                Assert.Contains("I wrapped your exception", content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_NoDbOrMigrationsTitle"), content);
                Assert.Contains(StringsHelpers.GetResourceString("DatabaseErrorPage_NoDbOrMigrationsInfo"), content);
                Assert.Contains(typeof(BloggingContext).Name, content);
            }
        }

        class WrappedExceptionMiddleware
        {
            public WrappedExceptionMiddleware(RequestDelegate next)
            { }

            public virtual Task Invoke(HttpContext context)
            {
                var db = context.RequestServices.GetService<BloggingContext>();
                db.Blogs.Add(new Blog());
                try
                {
                    db.SaveChanges();
                    throw new Exception("SaveChanges should have thrown");
                }
                catch (Exception ex)
                {
                    throw new Exception("I wrapped your exception", ex);
                }
            }
        }

        private static async Task<IHost> SetupServer<TContext, TMiddleware>(SqlTestStore database, ILoggerProvider logProvider = null)
            where TContext : DbContext
        {
            var host = new HostBuilder()
                .ConfigureWebHost(webHostBuilder =>
                {
                    webHostBuilder
                    .UseTestServer()
                    .Configure(app =>
                    {
#pragma warning disable CS0618 // Type or member is obsolete
                        app.UseDatabaseErrorPage();
#pragma warning restore CS0618 // Type or member is obsolete

                        app.UseMiddleware<TMiddleware>();

                        if (logProvider != null)
                        {
#pragma warning disable CS0618 // Type or member is obsolete
                            app.ApplicationServices.GetService<ILoggerFactory>().AddProvider(logProvider);
#pragma warning restore CS0618 // Type or member is obsolete
                        }
                    })
                    .ConfigureServices(services =>
                    {
                        services.AddDbContext<TContext>(optionsBuilder => optionsBuilder.UseSqlite(database.ConnectionString));
                    });
                }).Build();

            await host.StartAsync();

            return host;
        }

        private static readonly UrlEncoder _urlEncoder = UrlEncoder.Default;
        private static string UrlEncode(string content)
        {
            return _urlEncoder.Encode(content);
        }

        private static readonly JavaScriptEncoder _javaScriptEncoder = JavaScriptEncoder.Default;
        private static string JavaScriptEncode(string content)
        {
            return _javaScriptEncoder.Encode(content);
        }
    }
}
