using System.Net;
using System.Text;
using System.Text.Json;
using ClaudeDo.Worker;
using ClaudeDo.Worker.Online;
using Microsoft.Extensions.Logging.Abstractions;

namespace ClaudeDo.Worker.Tests.Online;

/// <summary>
/// Tests for <see cref="ZitadelAuthProvider"/> using a stub <see cref="HttpMessageHandler"/>.
/// Token-store tests use a real temp-dir <see cref="DpapiTokenStore"/> (DPAPI) and are
/// Windows-only.
/// </summary>
public sealed class ZitadelAuthProviderTests : IDisposable
{
    // ── Stubs ───────────────────────────────────────────────────────────────

    private sealed class StubHandler : HttpMessageHandler
    {
        public record Expectation(string UrlContains, HttpStatusCode Status, string Body);

        private readonly Queue<Expectation> _queue = new();
        public List<HttpRequestMessage> Requests { get; } = new();

        public void Enqueue(string urlContains, HttpStatusCode status, string body)
            => _queue.Enqueue(new Expectation(urlContains, status, body));

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
        {
            Requests.Add(request);

            if (_queue.Count == 0)
                return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
                {
                    Content = new StringContent("Unexpected request", Encoding.UTF8, "text/plain"),
                });

            var exp = _queue.Dequeue();
            return Task.FromResult(new HttpResponseMessage(exp.Status)
            {
                Content = new StringContent(exp.Body, Encoding.UTF8, "application/json"),
            });
        }
    }

    private sealed class StubHttpClientFactory : IHttpClientFactory
    {
        private readonly StubHandler _handler;
        public StubHttpClientFactory(StubHandler handler) => _handler = handler;
        public HttpClient CreateClient(string name) => new(_handler) { BaseAddress = null };
    }

    // ── Helpers ─────────────────────────────────────────────────────────────

    private readonly string _tokenPath = Path.Combine(
        Path.GetTempPath(), $"zitadel_token_{Guid.NewGuid():N}.bin");

    public void Dispose()
    {
        try { File.Delete(_tokenPath); } catch { }
    }

    private static string DiscoveryJson(string tokenEndpoint) =>
        JsonSerializer.Serialize(new { token_endpoint = tokenEndpoint });

    private static string TokenJson(string accessToken, int expiresIn = 3600, string? refreshToken = null)
    {
        var obj = new Dictionary<string, object>
        {
            ["access_token"] = accessToken,
            ["expires_in"]   = expiresIn,
        };
        if (refreshToken is not null) obj["refresh_token"] = refreshToken;
        return JsonSerializer.Serialize(obj);
    }

    private static OnlineInboxConfig MakeConfig() => new()
    {
        Enabled  = true,
        ApiBaseUrl = "https://api.example.com",
        Zitadel  = new ZitadelClientConfig
        {
            Authority = "https://auth.example.com",
            ClientId  = "client-abc",
            Scopes    = "openid offline_access",
        },
    };

    private (ZitadelAuthProvider Provider, StubHandler Handler, DpapiTokenStore Store) Build()
    {
        var handler = new StubHandler();
        var factory = new StubHttpClientFactory(handler);
        var store   = new DpapiTokenStore(_tokenPath);
        var config  = MakeConfig();
        var provider = new ZitadelAuthProvider(
            factory, new OnlineRefreshTokenStore(store), config, NullLogger<ZitadelAuthProvider>.Instance);
        return (provider, handler, store);
    }

    // ── Tests ────────────────────────────────────────────────────────────────

    [Fact]
    public async Task NoStoredToken_ReturnsNull_WithNoHttpCall()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, _) = Build();

        var result = await provider.GetAccessTokenAsync();

        Assert.Null(result);
        Assert.Empty(handler.Requests);
    }

    [Fact]
    public async Task SuccessfulRefresh_ReturnsAccessToken()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, store) = Build();
        store.Save("initial-refresh-token");

        handler.Enqueue(".well-known/openid-configuration", HttpStatusCode.OK,
            DiscoveryJson("https://auth.example.com/oauth/token"));
        handler.Enqueue("oauth/token", HttpStatusCode.OK,
            TokenJson("access-token-xyz", expiresIn: 3600));

        var result = await provider.GetAccessTokenAsync();

        Assert.Equal("access-token-xyz", result);
    }

    [Fact]
    public async Task SuccessfulRefresh_CachesToken_SecondCallMakesNoRequest()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, store) = Build();
        store.Save("initial-refresh-token");

        handler.Enqueue(".well-known", HttpStatusCode.OK,
            DiscoveryJson("https://auth.example.com/oauth/token"));
        handler.Enqueue("oauth/token", HttpStatusCode.OK,
            TokenJson("access-token-xyz", expiresIn: 3600));

        var first  = await provider.GetAccessTokenAsync();
        var second = await provider.GetAccessTokenAsync(); // should use cache

        Assert.Equal("access-token-xyz", first);
        Assert.Equal("access-token-xyz", second);
        // Only two HTTP requests: discovery + token; no third request.
        Assert.Equal(2, handler.Requests.Count);
    }

    [Fact]
    public async Task ChangedRefreshToken_InvalidatesCache_AndRefreshesForNewUser()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, store) = Build();
        store.Save("admin-refresh");

        // First user (admin): discovery + token.
        handler.Enqueue(".well-known", HttpStatusCode.OK,
            DiscoveryJson("https://auth.example.com/oauth/token"));
        handler.Enqueue("oauth/token", HttpStatusCode.OK,
            TokenJson("admin-access", expiresIn: 3600));

        var adminToken = await provider.GetAccessTokenAsync();
        Assert.Equal("admin-access", adminToken);

        // Re-sign-in as a different user writes a new refresh token to the store.
        store.Save("normal-refresh");

        // Even though the cached admin token is still within its expiry window, the changed
        // refresh token must force a new exchange (no second discovery — it's cached).
        handler.Enqueue("oauth/token", HttpStatusCode.OK,
            TokenJson("normal-access", expiresIn: 3600));

        var normalToken = await provider.GetAccessTokenAsync();

        Assert.Equal("normal-access", normalToken);
        Assert.Equal(3, handler.Requests.Count); // discovery + admin token + normal token
    }

    [Fact]
    public async Task RotatedRefreshToken_IsPersisted()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, store) = Build();
        store.Save("original-refresh");

        handler.Enqueue(".well-known", HttpStatusCode.OK,
            DiscoveryJson("https://auth.example.com/oauth/token"));
        handler.Enqueue("oauth/token", HttpStatusCode.OK,
            TokenJson("access-token", expiresIn: 3600, refreshToken: "rotated-refresh"));

        await provider.GetAccessTokenAsync();

        var persisted = store.Read();
        Assert.Equal("rotated-refresh", persisted);
    }

    [Fact]
    public async Task InvalidGrant_ReturnsNull_DoesNotCrash()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, store) = Build();
        store.Save("bad-refresh-token");

        handler.Enqueue(".well-known", HttpStatusCode.OK,
            DiscoveryJson("https://auth.example.com/oauth/token"));
        handler.Enqueue("oauth/token", HttpStatusCode.BadRequest,
            JsonSerializer.Serialize(new { error = "invalid_grant", error_description = "token expired" }));

        var result = await provider.GetAccessTokenAsync();

        Assert.Null(result);
    }

    [Fact]
    public async Task TokenEndpoint_ReadFromDiscovery()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, store) = Build();
        store.Save("refresh-token");

        const string expectedEndpoint = "https://auth.example.com/custom/token";
        handler.Enqueue(".well-known", HttpStatusCode.OK,
            DiscoveryJson(expectedEndpoint));
        handler.Enqueue("custom/token", HttpStatusCode.OK,
            TokenJson("access-token"));

        await provider.GetAccessTokenAsync();

        // Second request (token POST) should go to the discovered endpoint.
        Assert.Equal(2, handler.Requests.Count);
        Assert.Contains("custom/token", handler.Requests[1].RequestUri!.AbsoluteUri);
    }

    [Fact]
    public async Task DiscoveryCached_SecondRefreshSkipsDiscovery()
    {
        if (!OperatingSystem.IsWindows()) return;

        var (provider, handler, store) = Build();
        store.Save("refresh-token");

        // First call: discovery + token (access token expires immediately — use short expiry).
        handler.Enqueue(".well-known", HttpStatusCode.OK,
            DiscoveryJson("https://auth.example.com/oauth/token"));
        handler.Enqueue("oauth/token", HttpStatusCode.OK,
            TokenJson("access-1", expiresIn: -100)); // already expired

        await provider.GetAccessTokenAsync();

        // Second call: cache is stale, but discovery must NOT be re-fetched.
        handler.Enqueue("oauth/token", HttpStatusCode.OK,
            TokenJson("access-2", expiresIn: 3600));

        var second = await provider.GetAccessTokenAsync();

        Assert.Equal("access-2", second);
        // Exactly 3 requests: discovery, token(1), token(2) — no second discovery.
        Assert.Equal(3, handler.Requests.Count);
        Assert.DoesNotContain(handler.Requests.Skip(1), r =>
            r.RequestUri!.AbsoluteUri.Contains(".well-known"));
    }
}
