// Copyright © WireMock.Net

using System.Diagnostics;
using System.Net;
using Microsoft.AspNetCore.Http;
using WireMock.Constants;
using WireMock.Exceptions;
using WireMock.Http;
using WireMock.Matchers;
using WireMock.Owin.ActivityTracing;
using WireMock.Owin.Mappers;
using WireMock.ResponseBuilders;
using WireMock.Serialization;
using WireMock.Settings;
using WireMock.Util;

namespace WireMock.Owin;

internal class WireMockMiddleware(
#pragma warning disable CS9113 // Parameter is unread.
    RequestDelegate next,
#pragma warning restore CS9113 // Parameter is unread.
    IWireMockMiddlewareOptions options,
    IAdminPaths adminPaths,
    IOwinRequestMapper requestMapper,
    IOwinResponseMapper responseMapper,
    IMappingMatcher mappingMatcher,
    IWireMockMiddlewareLogger logger,
    IGuidUtils guidUtils,
    IDateTimeUtils dateTimeUtils,
    IResponseMessageBuilder responseMessageBuilder
)
{
    private readonly object _lock = new();

    public Task Invoke(HttpContext ctx)
    {
        if (options.HandleRequestsSynchronously.GetValueOrDefault(false))
        {
            lock (_lock)
            {
                return InvokeInternalAsync(ctx);
            }
        }

        return InvokeInternalAsync(ctx);
    }

    private async Task InvokeInternalAsync(HttpContext ctx)
    {
        // Store options in HttpContext for providers to access (e.g., WebSocketResponseProvider)
        ctx.Items[nameof(IWireMockMiddlewareOptions)] = options;
        ctx.Items[nameof(IWireMockMiddlewareLogger)] = logger;
        ctx.Items[nameof(IGuidUtils)] = guidUtils;
        ctx.Items[nameof(IDateTimeUtils)] = dateTimeUtils;

        var request = await requestMapper.MapAsync(ctx, options).ConfigureAwait(false);

        var logRequest = false;
        IResponseMessage? response = null;
        (MappingMatcherResult? Match, MappingMatcherResult? Partial) result = (null, null);

        var tracingEnabled = options.ActivityTracingOptions is not null;
        var excludeAdmin = options.ActivityTracingOptions?.ExcludeAdminRequests ?? true;
        Activity? activity = null;

        // Check if we should trace this request (optionally exclude admin requests)
        var shouldTrace = tracingEnabled && !(excludeAdmin && adminPaths.Includes(request.Path));

        if (shouldTrace)
        {
            activity = WireMockActivitySource.StartRequestActivity(request.Method, request.Path);
            WireMockActivitySource.EnrichWithRequest(activity, request, options.ActivityTracingOptions);
        }

        try
        {
            foreach (var mapping in options.Mappings.Values)
            {
                if (mapping.Scenario is null)
                {
                    continue;
                }

                // Set scenario start
                if (!options.ScenarioStateStore.ContainsKey(mapping.Scenario) && mapping.IsStartState)
                {
                    options.ScenarioStateStore.TryAdd(mapping.Scenario, new ScenarioState
                    {
                        Name = mapping.Scenario
                    });
                }
            }

            result = mappingMatcher.FindBestMatch(request);

            var targetMapping = result.Match?.Mapping;
            if (targetMapping == null)
            {
                logRequest = true;
                options.Logger.Warn("HttpStatusCode set to 404 : No matching mapping found");
                response = responseMessageBuilder.Create(HttpStatusCode.NotFound, WireMockConstants.NoMatchingFound);
                return;
            }

            logRequest = targetMapping.LogMapping;

            if (targetMapping.IsAdminInterface && options.AuthenticationMatcher != null && request.Headers != null)
            {
                var authorizationHeaderPresent = request.Headers.TryGetValue(HttpKnownHeaderNames.Authorization, out var authorization);
                if (!authorizationHeaderPresent)
                {
                    options.Logger.Error("HttpStatusCode set to 401, authorization header is missing.");
                    response = responseMessageBuilder.Create(HttpStatusCode.Unauthorized, null);
                    return;
                }

                var authorizationHeaderMatchResult = options.AuthenticationMatcher.IsMatch(authorization!.ToString());
                if (!MatchScores.IsPerfect(authorizationHeaderMatchResult.Score))
                {
                    options.Logger.Error("HttpStatusCode set to 401, authentication failed.", authorizationHeaderMatchResult.Exception ?? throw new WireMockException("Authentication failed"));
                    response = responseMessageBuilder.Create(HttpStatusCode.Unauthorized, null);
                    return;
                }
            }

            // Transition scenario state immediately after matching, before any delay (global or
            // per-mapping) so that concurrent retries arriving during a delay period see the
            // updated state and match the correct next mapping instead of re-matching this one.
            if (targetMapping.Scenario != null)
            {
                UpdateScenarioState(targetMapping);
            }

            if (!targetMapping.IsAdminInterface && options.RequestProcessingDelay > TimeSpan.Zero)
            {
                await Task.Delay(options.RequestProcessingDelay.Value).ConfigureAwait(false);
            }

            var (theResponse, theOptionalNewMapping) = await targetMapping.ProvideResponseAsync(ctx, request).ConfigureAwait(false);
            response = theResponse;

            if (targetMapping.Provider is Response responseBuilder && !targetMapping.IsAdminInterface && theOptionalNewMapping != null)
            {
                if (responseBuilder?.ProxyAndRecordSettings?.SaveMapping == true || targetMapping.Settings.ProxyAndRecordSettings?.SaveMapping == true)
                {
                    options.Mappings.TryAdd(theOptionalNewMapping.Guid, theOptionalNewMapping);
                }

                if (responseBuilder?.ProxyAndRecordSettings?.SaveMappingToFile == true || targetMapping.Settings.ProxyAndRecordSettings?.SaveMappingToFile == true)
                {
                    var matcherMapper = new MatcherMapper(targetMapping.Settings);
                    var mappingConverter = new MappingConverter(matcherMapper);
                    var mappingToFileSaver = new MappingToFileSaver(targetMapping.Settings, mappingConverter);

                    mappingToFileSaver.SaveMappingToFile(theOptionalNewMapping);
                }
            }

            if (!targetMapping.IsAdminInterface && targetMapping.Webhooks?.Length > 0)
            {
                await SendToWebhooksAsync(targetMapping, request, response).ConfigureAwait(false);
            }
        }
        catch (Exception ex)
        {
            options.Logger.Error($"Providing a Response for Mapping '{result.Match?.Mapping.Guid}' failed. HttpStatusCode set to 500. Exception: {ex}");
            WireMockActivitySource.RecordException(activity, ex);

            response = responseMessageBuilder.Create(500, ex.Message);
        }
        finally
        {
            logger.LogRequestAndResponse(logRequest, request, response, result.Match, result.Partial, activity);

            try
            {
                await responseMapper.MapAsync(response, ctx.Response).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                options.Logger.Error("HttpStatusCode set to 404 : No matching mapping found", ex);

                var notFoundResponse = responseMessageBuilder.Create(HttpStatusCode.NotFound, WireMockConstants.NoMatchingFound);
                await responseMapper.MapAsync(notFoundResponse, ctx.Response).ConfigureAwait(false);
            }
        }
    }

    private async Task SendToWebhooksAsync(IMapping mapping, IRequestMessage request, IResponseMessage response)
    {
        var tasks = new List<Func<Task>>();
        for (int index = 0; index < mapping.Webhooks?.Length; index++)
        {
            var httpClientForWebhook = HttpClientBuilder.Build(mapping.Settings.WebhookSettings ?? new WebhookSettings());
            var webhookSender = new WebhookSender(mapping.Settings);
            var webhookRequest = mapping.Webhooks[index].Request;
            var webHookIndex = index;

            tasks.Add(async () =>
            {
                try
                {
                    var result = await webhookSender.SendAsync(httpClientForWebhook, mapping, webhookRequest, request, response).ConfigureAwait(false);
                    if (!result.IsSuccessStatusCode)
                    {
                        var content = await result.Content.ReadAsStringAsync().ConfigureAwait(false);
                        options.Logger.Warn($"Sending message to Webhook [{webHookIndex}] from Mapping '{mapping.Guid}' failed. HttpStatusCode: {result.StatusCode} Content: {content}");
                    }
                }
                catch (Exception ex)
                {
                    options.Logger.Error($"Sending message to Webhook [{webHookIndex}] from Mapping '{mapping.Guid}' failed. Exception: {ex}");
                }
            });
        }

        if (mapping.UseWebhooksFireAndForget == true)
        {
            try
            {
                // Do not wait
                await Task.Run(() =>
                {
                    Task.WhenAll(tasks.Select(async task => await task.Invoke())).ConfigureAwait(false);
                });
            }
            catch
            {
                // Ignore
            }
        }
        else
        {
            await Task.WhenAll(tasks.Select(async task => await task.Invoke())).ConfigureAwait(false);
        }
    }

    private void UpdateScenarioState(IMapping mapping)
    {
        options.ScenarioStateStore.Update(mapping.Scenario!, scenario =>
        {
            // Increase the number of times this state has been executed
            scenario.Counter++;

            // Only if the number of times this state is executed equals the required StateTimes, proceed to next state and reset the counter to 0
            if (scenario.Counter == (mapping.TimesInSameState ?? 1))
            {
                scenario.NextState = mapping.NextState;
                scenario.Counter = 0;
            }

            // Else just update Started and Finished
            scenario.Started = true;
            scenario.Finished = mapping.NextState == null;
        });
    }
}
