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

using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.Metrics.Testing;
using Microsoft.Extensions.Hosting;

namespace Microsoft.AspNetCore.Diagnostics;

public class DeveloperExceptionPageMiddlewareTest : LoggedTest
{
    [Fact]
    public async Task ExceptionIsSetOnProblemDetailsContext()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureServices(services =>
            {
                services.AddProblemDetails(configure =>
                {
                    configure.CustomizeProblemDetails = (context) =>
                    {
                        if (context.Exception is not null)
                        {
                            context.ProblemDetails.Extensions.Add("OriginalExceptionMessage", context.Exception.Message);
                        }
                    };
                });
            })
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });

                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();
        var request = new HttpRequestMessage(HttpMethod.Get, "/path");
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        // Act
        var response = await server.CreateClient().SendAsync(request);

        // Assert
        var body = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        var originalExceptionMessage = ((JsonElement)body.Extensions["OriginalExceptionMessage"]).GetString();
        Assert.Equal("Test exception", originalExceptionMessage);
    }

    [Fact]
    public async Task ExceptionHandlerFeatureIsAvailableInCustomizeProblemDetailsWhenUsingExceptionPage()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureServices(services =>
            {
                services.AddRouting();
                services.AddProblemDetails(configure =>
                {
                    configure.CustomizeProblemDetails = (context) =>
                    {
                        var feature = context.HttpContext.Features.Get<IExceptionHandlerFeature>();
                        context.ProblemDetails.Extensions.Add("OriginalExceptionMessage", feature?.Error.Message);
                        context.ProblemDetails.Extensions.Add("EndpointDisplayName", feature?.Endpoint?.DisplayName);
                        context.ProblemDetails.Extensions.Add("RouteValue", feature?.RouteValues?["id"]);
                        context.ProblemDetails.Extensions.Add("Path", feature?.Path);
                    };
                });
            })
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.UseRouting();
                    app.UseEndpoints(endpoint =>
                    {
                        endpoint.MapGet("/test/{id}", (int id) =>
                        {
                            throw new Exception("Test exception");
                        });
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();
        var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/test/1");
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        // Act
        var response = await server.CreateClient().SendAsync(request);

        // Assert
        var body = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        var originalExceptionMessage = ((JsonElement)body.Extensions["OriginalExceptionMessage"]).GetString();
        var endpointDisplayName = ((JsonElement)body.Extensions["EndpointDisplayName"]).GetString();
        var routeValue = ((JsonElement)body.Extensions["RouteValue"]).GetString();
        var path = ((JsonElement)body.Extensions["Path"]).GetString();
        Assert.Equal("Test exception", originalExceptionMessage);
        Assert.Contains("/test/{id}", endpointDisplayName);
        Assert.Equal("1", routeValue);
        Assert.Equal("/test/1", path);
    }

    [Fact]
    public async Task ExceptionHandlerPathFeatureIsAvailableInCustomizeProblemDetailsWhenUsingExceptionPage()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureServices(services =>
            {
                services.AddRouting();
                services.AddProblemDetails(configure =>
                {
                    configure.CustomizeProblemDetails = (context) =>
                    {
                        var feature = context.HttpContext.Features.Get<IExceptionHandlerPathFeature>();
                        context.ProblemDetails.Extensions.Add("OriginalExceptionMessage", feature?.Error.Message);
                        context.ProblemDetails.Extensions.Add("EndpointDisplayName", feature?.Endpoint?.DisplayName);
                        context.ProblemDetails.Extensions.Add("RouteValue", feature?.RouteValues?["id"]);
                        context.ProblemDetails.Extensions.Add("Path", feature?.Path);
                    };
                });
            })
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.UseRouting();
                    app.UseEndpoints(endpoint =>
                    {
                        endpoint.MapGet("/test/{id}", (int id) =>
                        {
                            throw new Exception("Test exception");
                        });
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();
        var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/test/1");
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        // Act
        var response = await server.CreateClient().SendAsync(request);

        // Assert
        var body = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        var originalExceptionMessage = ((JsonElement)body.Extensions["OriginalExceptionMessage"]).GetString();
        var endpointDisplayName = ((JsonElement)body.Extensions["EndpointDisplayName"]).GetString();
        var routeValue = ((JsonElement)body.Extensions["RouteValue"]).GetString();
        var path = ((JsonElement)body.Extensions["Path"]).GetString();
        Assert.Equal("Test exception", originalExceptionMessage);
        Assert.Contains("/test/{id}", endpointDisplayName);
        Assert.Equal("1", routeValue);
        Assert.Equal("/test/1", path);
    }

    [Fact]
    public async Task UnhandledErrorsWriteToDiagnosticWhenUsingExceptionPage()
    {
        // Arrange
        DiagnosticListener diagnosticListener = null;
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    diagnosticListener = app.ApplicationServices.GetRequiredService<DiagnosticListener>();
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();
        var listener = new TestDiagnosticListener();
        diagnosticListener.SubscribeWithAdapter(listener);

        // Act
        await server.CreateClient().GetAsync("/path");

        // Assert
        Assert.NotNull(listener.DiagnosticUnhandledException?.HttpContext);
        Assert.NotNull(listener.DiagnosticUnhandledException?.Exception);
        Assert.Null(listener.DiagnosticHandledException?.HttpContext);
        Assert.Null(listener.DiagnosticHandledException?.Exception);
    }

    [Fact]
    public async Task ErrorPageWithAcceptHeaderForHtmlReturnsHtml()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();

        // Act
        var client = server.CreateClient();
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
        var response = await client.GetAsync("/path");

        // Assert
        Assert.Equal(StatusCodes.Status500InternalServerError, (int)response.StatusCode);

        var responseText = await response.Content.ReadAsStringAsync();
        Assert.Equal("text/html", response.Content.Headers.ContentType.MediaType);
        Assert.Contains("<html", responseText);
        Assert.Contains("Test exception", responseText);
    }

    [Fact]
    public async Task ErrorPageWithoutAcceptHeaderForHtmlReturnsPlainText()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();

        // Act
        var response = await server.CreateClient().GetAsync("/path");

        // Assert
        Assert.Equal(StatusCodes.Status500InternalServerError, (int)response.StatusCode);

        var responseText = await response.Content.ReadAsStringAsync();
        Assert.Equal("text/plain", response.Content.Headers.ContentType.MediaType);
        Assert.Equal("utf-8", response.Content.Headers.ContentType.CharSet);
        Assert.Contains("Test exception", responseText);
        Assert.DoesNotContain("<html", responseText);
    }

    [Fact]
    public async Task StatusCodeFromBadHttpRequestExceptionIsPreserved()
    {
        const int statusCode = 418;

        // Arrange
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new BadHttpRequestException("Not found!", statusCode);
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();

        // Act
        var response = await server.CreateClient().GetAsync("/path");

        // Assert
        Assert.Equal(statusCode, (int)response.StatusCode);

        var responseText = await response.Content.ReadAsStringAsync();
        Assert.Contains("Not found!", responseText);
    }

    [Fact]
    public async Task ExceptionPageFiltersAreApplied()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .ConfigureServices(services =>
                {
                    services.AddSingleton<IDeveloperPageExceptionFilter, ExceptionMessageFilter>();
                })
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();

        // Act
        var response = await server.CreateClient().GetAsync("/path");

        // Assert
        Assert.Equal("Test exception", await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task ExceptionFilterCallingNextWorks()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .ConfigureServices(services =>
                {
                    services.AddSingleton<IDeveloperPageExceptionFilter, PassThroughExceptionFilter>();
                    services.AddSingleton<IDeveloperPageExceptionFilter, AlwaysBadFormatExceptionFilter>();
                    services.AddSingleton<IDeveloperPageExceptionFilter, ExceptionMessageFilter>();
                })
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();

        // Act
        var response = await server.CreateClient().GetAsync("/path");

        // Assert
        Assert.Equal("Bad format exception!", await response.Content.ReadAsStringAsync());

        await host.StartAsync();
    }

    [Fact]
    public async Task ExceptionPageFiltersAreAppliedInOrder()
    {
        // Arrange
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .ConfigureServices(services =>
                {
                    services.AddSingleton<IDeveloperPageExceptionFilter, AlwaysThrowSameMessageFilter>();
                    services.AddSingleton<IDeveloperPageExceptionFilter, ExceptionMessageFilter>();
                    services.AddSingleton<IDeveloperPageExceptionFilter, ExceptionToStringFilter>();
                })
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();

        // Act
        var response = await server.CreateClient().GetAsync("/path");

        // Assert
        Assert.Equal("An error occurred", await response.Content.ReadAsStringAsync());
    }

    public static TheoryData CompilationExceptionData
    {
        get
        {
            var variations = new TheoryData<List<CompilationFailure>>();
            var failures = new List<CompilationFailure>();
            var diagnosticMessages = new List<DiagnosticMessage>();
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(@"c:\sourcefilepath.cs", "source file content", "compiled content", diagnosticMessages)
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(null, "source file content", "compiled content", diagnosticMessages)
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(@"c:\sourcefilepath.cs", null, "compiled content", diagnosticMessages)
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(@"c:\sourcefilepath.cs", "source file content", null, diagnosticMessages)
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(null, null, null, diagnosticMessages)
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(@"c:\sourcefilepath.cs", "source file content", "compiled content", diagnosticMessages),
                    new CompilationFailure(@"c:\sourcefilepath.cs", null, "compiled content", diagnosticMessages)
                });
            variations.Add(null);
            variations.Add(new List<CompilationFailure>()
                {
                    null
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(@"c:\sourcefilepath.cs", "source file content", "compiled content", diagnosticMessages),
                    null
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(@"c:\sourcefilepath.cs", "source file content", "compiled content", null)
                });
            variations.Add(new List<CompilationFailure>()
                {
                    new CompilationFailure(@"c:\sourcefilepath.cs", "source file content", "compiled content", new List<DiagnosticMessage>(){ null })
                });
            return variations;
        }
    }

    [Theory]
    [MemberData(nameof(CompilationExceptionData))]
    public async Task NullInfoInCompilationException_ShouldNotThrowExceptionGeneratingExceptionPage(
        List<CompilationFailure> failures)
    {
        // Arrange
        DiagnosticListener diagnosticListener = null;
        using var host = new HostBuilder()
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    diagnosticListener = app.ApplicationServices.GetRequiredService<DiagnosticListener>();
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new CustomCompilationException(failures);
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();
        var listener = new TestDiagnosticListener();
        diagnosticListener.SubscribeWithAdapter(listener);

        // Act
        await server.CreateClient().GetAsync("/path");

        // Assert
        Assert.NotNull(listener.DiagnosticUnhandledException?.HttpContext);
        Assert.NotNull(listener.DiagnosticUnhandledException?.Exception);
        Assert.Null(listener.DiagnosticHandledException?.HttpContext);
        Assert.Null(listener.DiagnosticHandledException?.Exception);
    }

    [Fact]
    public async Task UnhandledError_ExceptionNameTagAdded()
    {
        // Arrange
        var meterFactory = new TestMeterFactory();
        using var requestDurationCollector = new MetricCollector<double>(meterFactory, "Microsoft.AspNetCore.Hosting", "http.server.request.duration");
        using var requestExceptionCollector = new MetricCollector<long>(meterFactory, DiagnosticsMetrics.MeterName, "aspnetcore.diagnostics.exceptions");

        using var host = new HostBuilder()
            .ConfigureServices(s =>
            {
                s.AddSingleton<IMeterFactory>(meterFactory);
                s.AddSingleton(LoggerFactory);
            })
            .ConfigureWebHost(webHostBuilder =>
            {
                webHostBuilder
                .UseTestServer()
                .Configure(app =>
                {
                    app.UseDeveloperExceptionPage();
                    app.Run(context =>
                    {
                        throw new Exception("Test exception");
                    });
                });
            }).Build();

        await host.StartAsync();

        var server = host.GetTestServer();

        // Act
        var response = await server.CreateClient().GetAsync("/path");
        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);

        await requestDurationCollector.WaitForMeasurementsAsync(minCount: 1).DefaultTimeout();

        // Assert
        Assert.Collection(
            requestDurationCollector.GetMeasurementSnapshot(),
            m =>
            {
                Assert.True(m.Value > 0);
                Assert.Equal(500, (int)m.Tags["http.response.status_code"]);
                Assert.Equal("System.Exception", (string)m.Tags["error.type"]);
            });
        Assert.Collection(requestExceptionCollector.GetMeasurementSnapshot(),
            m => AssertRequestException(m, "System.Exception", "unhandled"));
    }

    private static void AssertRequestException(CollectedMeasurement<long> measurement, string exceptionName, string result, string handler = null)
    {
        Assert.Equal(1, measurement.Value);
        Assert.Equal(exceptionName, (string)measurement.Tags["error.type"]);
        Assert.Equal(result, measurement.Tags["aspnetcore.diagnostics.exception.result"].ToString());
        if (handler == null)
        {
            Assert.False(measurement.Tags.ContainsKey("aspnetcore.diagnostics.handler.type"));
        }
        else
        {
            Assert.Equal(handler, (string)measurement.Tags["aspnetcore.diagnostics.handler.type"]);
        }
    }

    public class CustomCompilationException : Exception, ICompilationException
    {
        public CustomCompilationException(IEnumerable<CompilationFailure> compilationFailures)
        {
            CompilationFailures = compilationFailures;
        }

        public IEnumerable<CompilationFailure> CompilationFailures { get; }
    }

    public class ExceptionMessageFilter : IDeveloperPageExceptionFilter
    {
        public Task HandleExceptionAsync(ErrorContext context, Func<ErrorContext, Task> next)
        {
            return context.HttpContext.Response.WriteAsync(context.Exception.Message);
        }
    }

    public class ExceptionToStringFilter : IDeveloperPageExceptionFilter
    {
        public Task HandleExceptionAsync(ErrorContext context, Func<ErrorContext, Task> next)
        {
            return context.HttpContext.Response.WriteAsync(context.Exception.ToString());
        }
    }

    public class AlwaysThrowSameMessageFilter : IDeveloperPageExceptionFilter
    {
        public Task HandleExceptionAsync(ErrorContext context, Func<ErrorContext, Task> next)
        {
            return context.HttpContext.Response.WriteAsync("An error occurred");
        }
    }

    public class AlwaysBadFormatExceptionFilter : IDeveloperPageExceptionFilter
    {
        public Task HandleExceptionAsync(ErrorContext context, Func<ErrorContext, Task> next)
        {
            return next(new ErrorContext(context.HttpContext, new FormatException("Bad format exception!")));
        }
    }

    public class PassThroughExceptionFilter : IDeveloperPageExceptionFilter
    {
        public Task HandleExceptionAsync(ErrorContext context, Func<ErrorContext, Task> next)
        {
            return next(context);
        }
    }
}
