using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.StoreAndForward;

namespace ScadaLink.ExternalSystemGateway;

/// <summary>
/// WP-6: HTTP/REST client that invokes external APIs.
/// WP-7: Dual call modes — Call (synchronous) and CachedCall (S&amp;F on transient failure).
/// WP-8: Error classification applied to HTTP responses and exceptions.
/// </summary>
public class ExternalSystemClient : IExternalSystemClient
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IExternalSystemRepository _repository;
    private readonly StoreAndForwardService? _storeAndForward;
    private readonly ILogger<ExternalSystemClient> _logger;
    private readonly ExternalSystemGatewayOptions _options;

    public ExternalSystemClient(
        IHttpClientFactory httpClientFactory,
        IExternalSystemRepository repository,
        ILogger<ExternalSystemClient> logger,
        StoreAndForwardService? storeAndForward = null,
        IOptions<ExternalSystemGatewayOptions>? options = null)
    {
        _httpClientFactory = httpClientFactory;
        _repository = repository;
        _logger = logger;
        _storeAndForward = storeAndForward;
        _options = options?.Value ?? new ExternalSystemGatewayOptions();
    }

    /// <summary>
    /// WP-7: Synchronous call — all failures returned to caller.
    /// </summary>
    public async Task<ExternalCallResult> CallAsync(
        string systemName,
        string methodName,
        IReadOnlyDictionary<string, object?>? parameters = null,
        CancellationToken cancellationToken = default)
    {
        var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
        if (system == null || method == null)
        {
            return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
        }

        try
        {
            var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
            return new ExternalCallResult(true, response, null);
        }
        catch (TransientExternalSystemException ex)
        {
            return new ExternalCallResult(false, null, $"Transient error: {ex.Message}");
        }
        catch (PermanentExternalSystemException ex)
        {
            return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
        }
    }

    /// <summary>
    /// WP-7: CachedCall — attempt immediate, transient failure goes to S&amp;F, permanent returned to script.
    /// </summary>
    public async Task<ExternalCallResult> CachedCallAsync(
        string systemName,
        string methodName,
        IReadOnlyDictionary<string, object?>? parameters = null,
        string? originInstanceName = null,
        CancellationToken cancellationToken = default)
    {
        var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
        if (system == null || method == null)
        {
            return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
        }

        try
        {
            var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
            return new ExternalCallResult(true, response, null);
        }
        catch (PermanentExternalSystemException ex)
        {
            // Permanent failures returned to script, never buffered
            return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
        }
        catch (TransientExternalSystemException)
        {
            // Transient failure — hand to S&F
            if (_storeAndForward == null)
            {
                return new ExternalCallResult(false, null, "Transient error and store-and-forward not available");
            }

            var payload = JsonSerializer.Serialize(new
            {
                SystemName = systemName,
                MethodName = methodName,
                Parameters = parameters
            });

            // attemptImmediateDelivery: false — this method already made the HTTP
            // attempt above; letting EnqueueAsync re-invoke the handler would
            // dispatch the same request a second time.
            //
            // The per-system retry settings are passed through verbatim — a
            // configured MaxRetries of 0 means "never retry" and must NOT be
            // collapsed to the S&F default (ExternalSystemGateway-004).
            await _storeAndForward.EnqueueAsync(
                StoreAndForwardCategory.ExternalSystem,
                systemName,
                payload,
                originInstanceName,
                system.MaxRetries,
                system.RetryDelay > TimeSpan.Zero ? system.RetryDelay : null,
                attemptImmediateDelivery: false);

            return new ExternalCallResult(true, null, null, WasBuffered: true);
        }
    }

    /// <summary>
    /// WP-7/10: Delivers a buffered ExternalSystem call during a store-and-forward
    /// retry sweep. Returns true on success, false on permanent failure (the message
    /// is parked); throws <see cref="TransientExternalSystemException"/> on a
    /// transient failure so the engine retries.
    /// </summary>
    public async Task<bool> DeliverBufferedAsync(
        StoreAndForwardMessage message, CancellationToken cancellationToken = default)
    {
        var payload = JsonSerializer.Deserialize<CachedCallPayload>(message.PayloadJson);
        if (payload == null || string.IsNullOrEmpty(payload.SystemName) || string.IsNullOrEmpty(payload.MethodName))
        {
            _logger.LogError("Buffered ExternalSystem message {Id} has an unreadable payload; parking.", message.Id);
            return false;
        }

        var (system, method) = await ResolveSystemAndMethodAsync(
            payload.SystemName, payload.MethodName, cancellationToken);
        if (system == null || method == null)
        {
            _logger.LogError(
                "Buffered call to '{System}'/'{Method}' cannot be delivered — the system or method no longer exists; parking.",
                payload.SystemName, payload.MethodName);
            return false;
        }

        var parameters = payload.Parameters?.ToDictionary(kv => kv.Key, kv => (object?)kv.Value);
        try
        {
            await InvokeHttpAsync(system, method, parameters, cancellationToken);
            return true;
        }
        catch (PermanentExternalSystemException ex)
        {
            _logger.LogError(ex, "Buffered call to '{System}' failed permanently; parking.", payload.SystemName);
            return false;
        }
        // TransientExternalSystemException propagates — the S&F engine retries.
    }

    private sealed record CachedCallPayload(
        string SystemName,
        string MethodName,
        Dictionary<string, JsonElement>? Parameters);

    /// <summary>
    /// WP-6: Executes the HTTP request against the external system.
    /// </summary>
    internal async Task<string?> InvokeHttpAsync(
        ExternalSystemDefinition system,
        ExternalSystemMethod method,
        IReadOnlyDictionary<string, object?>? parameters,
        CancellationToken cancellationToken)
    {
        var client = _httpClientFactory.CreateClient($"ExternalSystem_{system.Name}");

        var url = BuildUrl(system.EndpointUrl, method.Path, parameters, method.HttpMethod);

        // The request and response own IDisposable resources (StringContent, the
        // response content stream). Dispose both, including on the exception paths
        // (ExternalSystemGateway-005).
        using var request = new HttpRequestMessage(new HttpMethod(method.HttpMethod), url);

        // Apply authentication
        ApplyAuth(request, system);

        // For POST/PUT/PATCH, send parameters as JSON body
        if (method.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
            method.HttpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase) ||
            method.HttpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase))
        {
            if (parameters != null && parameters.Count > 0)
            {
                request.Content = new StringContent(
                    JsonSerializer.Serialize(parameters),
                    Encoding.UTF8,
                    "application/json");
            }
        }

        // Enforce the per-call timeout. ExternalSystemDefinition has no per-system
        // Timeout field yet, so the configured DefaultHttpTimeout is the effective
        // round-trip limit (the design's "timeout applies to the HTTP request
        // round-trip" guarantee). A linked CTS lets us distinguish a timeout from a
        // caller-initiated cancellation: only the timeout is reclassified as transient.
        using var timeoutCts = new CancellationTokenSource(_options.DefaultHttpTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken, timeoutCts.Token);

        HttpResponseMessage response;
        try
        {
            response = await client.SendAsync(request, linkedCts.Token);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // The caller asked to abandon the work — do not reclassify as transient.
            throw;
        }
        catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
        {
            // Our own timeout elapsed — a transient failure per the design.
            throw ErrorClassifier.AsTransient(
                $"Timeout calling {system.Name} after {_options.DefaultHttpTimeout.TotalSeconds:0.##}s", ex);
        }
        catch (Exception ex) when (ErrorClassifier.IsTransient(ex))
        {
            throw ErrorClassifier.AsTransient($"Connection error to {system.Name}: {ex.Message}", ex);
        }

        using (response)
        {
            // The timeout also covers reading the response body (the design's
            // "round-trip" guarantee), so the linked token is used for the read too.
            string body;
            try
            {
                body = await response.Content.ReadAsStringAsync(linkedCts.Token);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
            {
                throw ErrorClassifier.AsTransient(
                    $"Timeout reading response from {system.Name} after {_options.DefaultHttpTimeout.TotalSeconds:0.##}s", ex);
            }

            if (response.IsSuccessStatusCode)
            {
                return body;
            }

            // Bound the external error body before embedding it into a
            // script-visible message / event-log entry — a misbehaving or hostile
            // endpoint must not be able to inflate every error string
            // (ExternalSystemGateway-007).
            var errorBody = Truncate(body, MaxErrorBodyChars);

            if (ErrorClassifier.IsTransient(response.StatusCode))
            {
                throw ErrorClassifier.AsTransient(
                    $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}");
            }

            throw new PermanentExternalSystemException(
                $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}",
                (int)response.StatusCode);
        }
    }

    /// <summary>
    /// Upper bound (characters) on an external error response body echoed into a
    /// script-visible error message — see ExternalSystemGateway-007.
    /// </summary>
    private const int MaxErrorBodyChars = 2048;

    private static string Truncate(string value, int maxChars)
    {
        if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
        {
            return value;
        }

        return value.Substring(0, maxChars) + $"… [truncated, {value.Length} chars total]";
    }

    private static string BuildUrl(string baseUrl, string path, IReadOnlyDictionary<string, object?>? parameters, string httpMethod)
    {
        // A method that targets the base URL itself has an empty (or "/") path.
        // Appending a trailing "/" in that case yields ".../api/" which some
        // servers treat as a distinct resource — only append a segment when the
        // method actually defines a non-empty relative path (ExternalSystemGateway-006).
        var trimmedBase = baseUrl.TrimEnd('/');
        var trimmedPath = path.Trim().TrimStart('/');
        var url = string.IsNullOrEmpty(trimmedPath)
            ? trimmedBase
            : trimmedBase + "/" + trimmedPath;

        // For GET/DELETE, append parameters as query string
        if ((httpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) ||
             httpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) &&
            parameters != null && parameters.Count > 0)
        {
            var queryString = string.Join("&",
                parameters.Where(p => p.Value != null)
                    .Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value?.ToString() ?? "")}"));
            url += "?" + queryString;
        }

        return url;
    }

    private static void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
    {
        if (string.IsNullOrEmpty(system.AuthConfiguration))
            return;

        switch (system.AuthType.ToLowerInvariant())
        {
            case "apikey":
                // Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
                var parts = system.AuthConfiguration.Split(':', 2);
                if (parts.Length == 2)
                {
                    request.Headers.TryAddWithoutValidation(parts[0], parts[1]);
                }
                else
                {
                    request.Headers.TryAddWithoutValidation("X-API-Key", system.AuthConfiguration);
                }
                break;

            case "basic":
                // Auth config format: "username:password"
                var basicParts = system.AuthConfiguration.Split(':', 2);
                if (basicParts.Length == 2)
                {
                    var encoded = Convert.ToBase64String(
                        Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}"));
                    request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
                }
                break;
        }
    }

    private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync(
        string systemName,
        string methodName,
        CancellationToken cancellationToken)
    {
        var systems = await _repository.GetAllExternalSystemsAsync(cancellationToken);
        var system = systems.FirstOrDefault(s => s.Name.Equals(systemName, StringComparison.OrdinalIgnoreCase));
        if (system == null)
            return (null, null);

        var methods = await _repository.GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken);
        var method = methods.FirstOrDefault(m => m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase));

        return (system, method);
    }
}
