using System.Diagnostics;
using System.Text.Json;

namespace ZB.MOM.WW.Overview.Polling;

/// <summary>
/// One probe of one health endpoint: whether anything answered, what it answered, and how long it
/// took.
/// </summary>
/// <param name="Reachable">
/// True when the endpoint answered with a parseable canonical body. False for timeouts, refused
/// connections, DNS failures, and bodies that are not the canonical health JSON.
/// </param>
/// <param name="StatusCode">The HTTP status code, when one was received.</param>
/// <param name="Report">The parsed body, when it parsed.</param>
/// <param name="Latency">Wall-clock time for the request.</param>
/// <param name="Error">A short reason when <paramref name="Reachable"/> is false.</param>
public sealed record HealthProbeResult(
    bool Reachable,
    int? StatusCode,
    ZbHealthReport? Report,
    TimeSpan Latency,
    string? Error);

/// <summary>
/// GETs a family health endpoint and parses the canonical <c>ZbHealthWriter</c> body.
/// </summary>
/// <remarks>
/// Both 200 and 503 are treated as SUCCESSFUL probes: an Unhealthy tier still returns a full body,
/// and that body is exactly what the dashboard needs to show which check failed. Only a genuine
/// non-answer (transport failure) or an unparseable body is a failed probe.
/// </remarks>
public sealed class ZbHealthReportClient
{
    private static readonly JsonSerializerOptions SerializerOptions = new()
    {
        PropertyNameCaseInsensitive = true,
    };

    private readonly HttpClient _httpClient;

    /// <summary>Initializes a new <see cref="ZbHealthReportClient"/>.</summary>
    /// <param name="httpClient">The HTTP client used for probes.</param>
    public ZbHealthReportClient(HttpClient httpClient) =>
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));

    /// <summary>Probes <paramref name="url"/> and parses the response body.</summary>
    /// <param name="url">Absolute URL of a health endpoint.</param>
    /// <param name="timeout">Per-request timeout.</param>
    /// <param name="cancellationToken">Token cancelled when the host is shutting down.</param>
    /// <returns>The probe result; this never throws for an unreachable endpoint.</returns>
    public Task<HealthProbeResult> ProbeAsync(
        string url,
        TimeSpan timeout,
        CancellationToken cancellationToken = default) =>
        SendAsync(url, timeout, bodyRequired: true, cancellationToken);

    /// <summary>
    /// Probes <paramref name="url"/> for its status code alone, parsing the body only if one
    /// happens to be there.
    /// </summary>
    /// <param name="url">Absolute URL of a health endpoint.</param>
    /// <param name="timeout">Per-request timeout.</param>
    /// <param name="cancellationToken">Token cancelled when the host is shutting down.</param>
    /// <returns>The probe result; this never throws for an unreachable endpoint.</returns>
    /// <remarks>
    /// For the active tier the status code IS the answer — 200 means active, 503 means standby —
    /// and the body carries nothing the dashboard uses. Demanding a parseable body here would
    /// report every node behind anything that answers the tier with an empty response (a bare
    /// <c>IActiveNodeGate</c> endpoint, a load balancer, a proxy) as "role unknown" while it is
    /// plainly telling us the role.
    /// </remarks>
    public Task<HealthProbeResult> ProbeStatusAsync(
        string url,
        TimeSpan timeout,
        CancellationToken cancellationToken = default) =>
        SendAsync(url, timeout, bodyRequired: false, cancellationToken);

    private async Task<HealthProbeResult> SendAsync(
        string url,
        TimeSpan timeout,
        bool bodyRequired,
        CancellationToken cancellationToken)
    {
        var startedAt = Stopwatch.GetTimestamp();

        // Linked, not a bare timeout token: shutdown must cancel an in-flight probe immediately,
        // and the two causes have to stay distinguishable so a shutdown is not misreported as a
        // timed-out instance.
        using var timeoutSource = new CancellationTokenSource(timeout);
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token);

        try
        {
            using var response = await _httpClient
                .GetAsync(url, HttpCompletionOption.ResponseContentRead, linked.Token)
                .ConfigureAwait(false);

            var body = await response.Content.ReadAsStringAsync(linked.Token).ConfigureAwait(false);
            var statusCode = (int)response.StatusCode;

            ZbHealthReport? report;
            try
            {
                report = JsonSerializer.Deserialize<ZbHealthReport>(body, SerializerOptions);
            }
            catch (JsonException ex)
            {
                // Answered, but not with this contract — a reverse proxy error page, an HTML login
                // redirect, a wrong port. That is a registry/plumbing problem, not a sick app, so
                // it lands as Unreachable rather than Down.
                return bodyRequired
                    ? Failed(startedAt, statusCode, $"response body is not canonical health JSON: {ex.Message}")
                    : new HealthProbeResult(true, statusCode, null, Stopwatch.GetElapsedTime(startedAt), null);
            }

            if (report is null && bodyRequired)
                return Failed(startedAt, statusCode, "response body deserialized to null");

            return new HealthProbeResult(true, statusCode, report, Stopwatch.GetElapsedTime(startedAt), null);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // Host shutdown, not an instance fault — let the caller abandon the sweep.
            throw;
        }
        catch (OperationCanceledException)
        {
            return Failed(startedAt, null, $"timed out after {timeout.TotalSeconds:0.##}s");
        }
        catch (HttpRequestException ex)
        {
            return Failed(startedAt, null, ex.Message);
        }
    }

    private static HealthProbeResult Failed(long startedAt, int? statusCode, string error) =>
        new(false, statusCode, null, Stopwatch.GetElapsedTime(startedAt), error);
}
