using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Models.DTOs;

namespace OTSSignsOrchestrator.Core.Services;

/// <summary>
/// Provisions SAML applications in Authentik via its REST API and retrieves
/// IdP metadata (entity ID, signing certificate, SSO/SLO URLs).
///
/// Workflow per instance:
///   1. Resolve the authorization + invalidation flow UUIDs (cached or from settings).
///   2. Create a SAML provider (POST /api/v3/providers/saml/).
///   3. Create an Authentik application linked to that provider (POST /api/v3/core/applications/).
///   4. Fetch SAML metadata XML (GET /api/v3/providers/saml/{id}/metadata/).
///   5. Parse XML to extract entityId, x509cert, SSO/SLO URLs.
/// </summary>
public class AuthentikService : IAuthentikService
{
    private readonly IHttpClientFactory _httpFactory;
    private readonly SettingsService _settings;
    private readonly ILogger<AuthentikService> _logger;

    // Cache flow UUIDs so we only look them up once per app lifetime.
    private string? _cachedAuthorizationFlowUuid;
    private string? _cachedInvalidationFlowUuid;

    // XML namespaces used in SAML metadata
    private static readonly XNamespace Md = "urn:oasis:names:tc:SAML:2.0:metadata";
    private static readonly XNamespace Ds = "http://www.w3.org/2000/09/xmldsig#";

    public AuthentikService(
        IHttpClientFactory httpFactory,
        SettingsService settings,
        ILogger<AuthentikService> logger)
    {
        _httpFactory = httpFactory;
        _settings = settings;
        _logger = logger;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Public API
    // ─────────────────────────────────────────────────────────────────────────

    public async Task<AuthentikSamlConfig> ProvisionSamlAsync(
        string instanceAbbrev,
        string instanceBaseUrl,
        CancellationToken ct = default)
    {
        _logger.LogInformation("[Authentik] Provisioning SAML for instance {Abbrev}", instanceAbbrev);

        var (baseUrl, client) = await CreateAuthenticatedClientAsync();
        var slug = $"ds-{instanceAbbrev}";
        var providerName = $"OTS Signs — {instanceAbbrev.ToUpperInvariant()} (SAML)";
        var samlBaseUrl = instanceBaseUrl.TrimEnd('/') + "/saml";

        // ── 1. Check if application already exists ────────────────────────
        var existingProviderId = await TryGetExistingProviderIdAsync(client, baseUrl, slug, ct);
        int providerId;

        if (existingProviderId.HasValue)
        {
            _logger.LogInformation("[Authentik] Application '{Slug}' already exists (provider={Id}), reusing", slug, existingProviderId.Value);
            providerId = existingProviderId.Value;
        }
        else
        {
            // ── 2. Resolve flow UUIDs ─────────────────────────────────────
            var authFlowUuid = await ResolveFlowUuidAsync(client, baseUrl,
                SettingsService.AuthentikAuthorizationFlowSlug,
                "default-provider-authorization-implicit-consent", ct);

            var invalidFlowUuid = await ResolveFlowUuidAsync(client, baseUrl,
                SettingsService.AuthentikInvalidationFlowSlug,
                "default-provider-invalidation-flow", ct);

            // ── 3. Find or create SAML provider ──────────────────────────
            var existingSamlProvider = await TryGetExistingSamlProviderAsync(client, providerName, ct);
            if (existingSamlProvider.HasValue)
            {
                _logger.LogInformation("[Authentik] SAML provider '{Name}' already exists (id={Id}), reusing",
                    providerName, existingSamlProvider.Value);
                providerId = existingSamlProvider.Value;
            }
            else
            {
                providerId = await CreateSamlProviderAsync(client, baseUrl,
                    instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, ct);
            }

            // ── 4. Create application linked to provider ──────────────────
            await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct);
        }

        // ── 5. Ensure provider has a signing keypair (required for metadata) ──
        await EnsureProviderHasSigningKeypairAsync(client, providerId, ct);

        // ── 5b. Ensure property mappings are attached (required for valid SAML responses) ──
        await EnsureProviderHasPropertyMappingsAsync(client, providerId, ct);

        // ── 6. Fetch and parse metadata ───────────────────────────────────
        var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct);
        config.ApplicationSlug = slug;

        _logger.LogInformation(
            "[Authentik] SAML provisioned for {Abbrev}: provider={ProviderId}, entityId={EntityId}",
            instanceAbbrev, config.ProviderId, config.IdpEntityId);

        return config;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Settings UI helpers
    // ─────────────────────────────────────────────────────────────────────────

    /// <inheritdoc />
    public async Task<(bool Success, string Message)> TestConnectionAsync(
        string? overrideUrl = null, string? overrideApiKey = null,
        CancellationToken ct = default)
    {
        try
        {
            var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey);
            var resp = await client.GetAsync("/api/v3/core/users/me/", ct);
            if (!resp.IsSuccessStatusCode)
                return (false, $"Authentik returned HTTP {(int)resp.StatusCode}.");

            return (true, "Connected to Authentik successfully.");
        }
        catch (Exception ex)
        {
            return (false, ex.Message);
        }
    }

    /// <inheritdoc />
    public async Task<List<AuthentikFlowItem>> ListFlowsAsync(
        string? overrideUrl = null, string? overrideApiKey = null,
        CancellationToken ct = default)
    {
        var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey);
        var resp = await client.GetAsync("/api/v3/flows/instances/?page_size=200", ct);
        resp.EnsureSuccessStatusCode();

        var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikFlowDetailed>>(cancellationToken: ct);
        return json?.Results?.Select(f => new AuthentikFlowItem
        {
            Pk = f.Pk,
            Slug = f.Slug,
            Name = f.Name,
            Designation = f.Designation,
        }).OrderBy(f => f.Slug).ToList() ?? new();
    }

    /// <inheritdoc />
    public async Task<List<AuthentikKeypairItem>> ListKeypairsAsync(
        string? overrideUrl = null, string? overrideApiKey = null,
        CancellationToken ct = default)
    {
        var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey);
        var resp = await client.GetAsync("/api/v3/crypto/certificatekeypairs/?page_size=200", ct);
        resp.EnsureSuccessStatusCode();

        var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikKeypairDetailed>>(cancellationToken: ct);
        return json?.Results?.Select(k => new AuthentikKeypairItem
        {
            Pk = k.Pk,
            Name = k.Name,
        }).OrderBy(k => k.Name).ToList() ?? new();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // HTTP client setup
    // ─────────────────────────────────────────────────────────────────────────

    private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync(
        string? overrideUrl = null, string? overrideApiKey = null)
    {
        var authentikUrl = overrideUrl ?? await _settings.GetAsync(SettingsService.AuthentikUrl);
        var apiKey = overrideApiKey ?? await _settings.GetAsync(SettingsService.AuthentikApiKey);

        if (string.IsNullOrWhiteSpace(authentikUrl))
            throw new InvalidOperationException("Authentik URL is not configured. Set it in Settings → Authentik.");
        if (string.IsNullOrWhiteSpace(apiKey))
            throw new InvalidOperationException("Authentik API Key is not configured. Set it in Settings → Authentik.");

        var baseUrl = authentikUrl.TrimEnd('/');
        var client = _httpFactory.CreateClient("AuthentikApi");
        client.BaseAddress = new Uri(baseUrl);
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        return (baseUrl, client);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Check for existing application
    // ─────────────────────────────────────────────────────────────────────────

    private async Task<int?> TryGetExistingProviderIdAsync(
        HttpClient client, string baseUrl, string slug, CancellationToken ct)
    {
        try
        {
            var resp = await client.GetAsync($"/api/v3/core/applications/?slug={slug}", ct);
            if (!resp.IsSuccessStatusCode) return null;

            var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikApplication>>(cancellationToken: ct);

            // Authentik's ?slug= filter may do a partial/contains match,
            // so verify the slug matches exactly on the client side.
            var app = json?.Results?.FirstOrDefault(a =>
                string.Equals(a.Slug, slug, StringComparison.OrdinalIgnoreCase));

            if (app?.Provider != null)
            {
                return app.Provider;
            }

            if (json?.Results is { Count: > 0 })
            {
                _logger.LogDebug(
                    "[Authentik] API returned {Count} application(s) for slug query '{Slug}', but none matched exactly. Slugs returned: {Slugs}",
                    json.Results.Count, slug, string.Join(", ", json.Results.Select(a => a.Slug)));
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "[Authentik] Could not check for existing application '{Slug}'", slug);
        }

        return null;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Check for existing SAML provider (orphaned from a previous failed run)
    // ─────────────────────────────────────────────────────────────────────────

    private async Task<int?> TryGetExistingSamlProviderAsync(
        HttpClient client, string providerName, CancellationToken ct)
    {
        try
        {
            var resp = await client.GetAsync("/api/v3/providers/saml/?page_size=200", ct);
            if (!resp.IsSuccessStatusCode) return null;

            var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikSamlProvider>>(cancellationToken: ct);
            var match = json?.Results?.FirstOrDefault(p =>
                string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));

            return match?.Pk;
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "[Authentik] Could not check for existing SAML provider '{Name}'", providerName);
        }

        return null;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Keypair resolution
    // ─────────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Fetches all certificate keypairs from Authentik and returns the ID of
    /// the first one found (preferring one whose name contains "authentik"
    /// or "self-signed"). Returns null if none exist.
    /// </summary>
    private async Task<string?> ResolveDefaultKeypairAsync(HttpClient client, CancellationToken ct)
    {
        try
        {
            var resp = await client.GetAsync("/api/v3/crypto/certificatekeypairs/?has_key=true&page_size=200", ct);
            if (!resp.IsSuccessStatusCode) return null;

            var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikKeypairDetailed>>(cancellationToken: ct);
            if (json?.Results == null || json.Results.Count == 0) return null;

            // Prefer Authentik's auto-generated self-signed keypair
            var preferred = json.Results.FirstOrDefault(k =>
                k.Name.Contains("authentik", StringComparison.OrdinalIgnoreCase) ||
                k.Name.Contains("self-signed", StringComparison.OrdinalIgnoreCase));

            var selected = preferred ?? json.Results[0];
            _logger.LogInformation("[Authentik] Auto-selected signing keypair: '{Name}' (id={Id})", selected.Name, selected.Pk);
            return selected.Pk;
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "[Authentik] Could not auto-detect signing keypair");
            return null;
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // SAML property mappings
    // ─────────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Fetches built-in SAML property mappings from Authentik and returns their
    /// IDs. These are attached to the SAML provider so that the SAML response
    /// includes actual &lt;Attribute&gt; elements inside the &lt;AttributeStatement&gt;.
    /// Without at least one mapping, Authentik sends an empty AttributeStatement
    /// which fails strict XML schema validation in php-saml / Xibo.
    /// </summary>
    private async Task<List<string>> ResolveSamlPropertyMappingIdsAsync(
        HttpClient client, CancellationToken ct)
    {
        // Authentik 2024+ moved the endpoint to /propertymappings/provider/saml/.
        // Try the new endpoint first, then fall back to the legacy one.
        var endpoints = new[]
        {
            "/api/v3/propertymappings/provider/saml/?page_size=200",
            "/api/v3/propertymappings/saml/?page_size=200",
        };

        foreach (var endpoint in endpoints)
        {
            try
            {
                var resp = await client.GetAsync(endpoint, ct);
                if (!resp.IsSuccessStatusCode)
                {
                    _logger.LogDebug("[Authentik] Property mappings endpoint {Endpoint} returned HTTP {Status}",
                        endpoint, (int)resp.StatusCode);
                    continue;
                }

                var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikPropertyMapping>>(cancellationToken: ct);
                if (json?.Results == null || json.Results.Count == 0)
                {
                    _logger.LogDebug("[Authentik] Property mappings endpoint {Endpoint} returned 0 results", endpoint);
                    continue;
                }

                _logger.LogDebug("[Authentik] Found {Count} SAML property mapping(s) from {Endpoint}",
                    json.Results.Count, endpoint);

                // Return all managed (built-in) mappings — these are Authentik's default
                // attribute mappings for username, email, name, groups, UPN, and uid.
                var managed = json.Results
                    .Where(m => !string.IsNullOrWhiteSpace(m.Managed))
                    .Select(m => m.Pk)
                    .ToList();

                if (managed.Count > 0)
                {
                    _logger.LogInformation("[Authentik] Using {Count} managed SAML property mapping(s)", managed.Count);
                    return managed;
                }

                // Fall back to all available mappings if no managed ones found
                _logger.LogInformation("[Authentik] No managed mappings found — using all {Count} available mapping(s)", json.Results.Count);
                return json.Results.Select(m => m.Pk).ToList();
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "[Authentik] Error fetching property mappings from {Endpoint}", endpoint);
            }
        }

        _logger.LogWarning("[Authentik] Could not resolve SAML property mappings from any endpoint");
        return new List<string>();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Ensure provider has a signing keypair
    // ─────────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Checks whether the given SAML provider already has a signing keypair.
    /// If not, resolves one (from settings or auto-detect) and PATCHes it onto the provider.
    /// Authentik returns a 500 on the metadata endpoint when no keypair is assigned.
    /// </summary>
    private async Task EnsureProviderHasSigningKeypairAsync(
        HttpClient client, int providerId, CancellationToken ct)
    {
        try
        {
            // Fetch the provider details to check for an existing signing_kp
            var getResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct);
            if (!getResp.IsSuccessStatusCode) return;

            var body = await getResp.Content.ReadAsStringAsync(ct);
            using var doc = JsonDocument.Parse(body);

            // signing_kp is null when no keypair is assigned
            if (doc.RootElement.TryGetProperty("signing_kp", out var signingKp) &&
                signingKp.ValueKind != JsonValueKind.Null)
            {
                _logger.LogDebug("[Authentik] Provider {Id} already has a signing keypair", providerId);
                return;
            }

            _logger.LogInformation("[Authentik] Provider {Id} has no signing keypair — patching one", providerId);

            // Resolve a keypair
            var kpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId);
            if (string.IsNullOrWhiteSpace(kpId))
                kpId = await ResolveDefaultKeypairAsync(client, ct);

            if (string.IsNullOrWhiteSpace(kpId))
            {
                _logger.LogWarning("[Authentik] No signing keypair available to patch onto provider {Id}", providerId);
                return;
            }

            var patchPayload = JsonSerializer.Serialize(new Dictionary<string, object>
            {
                ["signing_kp"] = kpId,
                ["sign_assertion"] = true,
                ["sign_response"] = true,
            });
            var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json");
            var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/providers/saml/{providerId}/")
            {
                Content = patchContent,
            };

            var patchResp = await client.SendAsync(patchReq, ct);
            if (patchResp.IsSuccessStatusCode)
            {
                _logger.LogInformation("[Authentik] Signing keypair patched onto provider {Id}", providerId);
            }
            else
            {
                var err = await patchResp.Content.ReadAsStringAsync(ct);
                _logger.LogWarning("[Authentik] Failed to patch signing keypair onto provider {Id} (HTTP {Status}): {Error}",
                    providerId, (int)patchResp.StatusCode, err);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[Authentik] Could not ensure signing keypair for provider {Id}", providerId);
        }
    }

    /// <summary>
    /// Checks whether the given SAML provider has property mappings. If not,
    /// resolves the default set and PATCHes them onto the provider. Without
    /// mappings, Authentik sends an empty &lt;AttributeStatement&gt; which fails
    /// strict XML schema validation in php-saml / Xibo.
    /// </summary>
    private async Task EnsureProviderHasPropertyMappingsAsync(
        HttpClient client, int providerId, CancellationToken ct)
    {
        try
        {
            var getResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct);
            if (!getResp.IsSuccessStatusCode) return;

            var body = await getResp.Content.ReadAsStringAsync(ct);
            using var doc = JsonDocument.Parse(body);

            // Check if property_mappings already has entries
            if (doc.RootElement.TryGetProperty("property_mappings", out var mappingsProp) &&
                mappingsProp.ValueKind == JsonValueKind.Array && mappingsProp.GetArrayLength() > 0)
            {
                _logger.LogDebug("[Authentik] Provider {Id} already has {Count} property mapping(s)",
                    providerId, mappingsProp.GetArrayLength());
                return;
            }

            _logger.LogInformation("[Authentik] Provider {Id} has no property mappings — patching defaults", providerId);

            var mappingIds = await ResolveSamlPropertyMappingIdsAsync(client, ct);
            if (mappingIds.Count == 0)
            {
                _logger.LogWarning("[Authentik] No SAML property mappings available to patch onto provider {Id}", providerId);
                return;
            }

            var patchPayload = JsonSerializer.Serialize(new Dictionary<string, object>
            {
                ["property_mappings"] = mappingIds,
            });
            var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json");
            var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/providers/saml/{providerId}/")
            {
                Content = patchContent,
            };

            var patchResp = await client.SendAsync(patchReq, ct);
            if (patchResp.IsSuccessStatusCode)
            {
                _logger.LogInformation("[Authentik] Property mappings patched onto provider {Id}", providerId);
            }
            else
            {
                var err = await patchResp.Content.ReadAsStringAsync(ct);
                _logger.LogWarning("[Authentik] Failed to patch property mappings onto provider {Id} (HTTP {Status}): {Error}",
                    providerId, (int)patchResp.StatusCode, err);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[Authentik] Could not ensure property mappings for provider {Id}", providerId);
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Flow resolution
    // ─────────────────────────────────────────────────────────────────────────

    private async Task<string> ResolveFlowUuidAsync(
        HttpClient client, string baseUrl,
        string settingsKey, string defaultFlowSlug,
        CancellationToken ct)
    {
        // Check if user has configured a specific flow slug in settings
        var configuredSlug = await _settings.GetAsync(settingsKey);
        var slug = string.IsNullOrWhiteSpace(configuredSlug) ? defaultFlowSlug : configuredSlug;

        // Return cached value if available
        if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug && _cachedAuthorizationFlowUuid != null)
            return _cachedAuthorizationFlowUuid;
        if (settingsKey == SettingsService.AuthentikInvalidationFlowSlug && _cachedInvalidationFlowUuid != null)
            return _cachedInvalidationFlowUuid;

        _logger.LogDebug("[Authentik] Resolving flow UUID for slug '{Slug}'", slug);

        // Fetch all flows and filter client-side — some Authentik versions don't
        // support the ?slug= query parameter or return empty results despite the
        // flow existing.
        var resp = await client.GetAsync("/api/v3/flows/instances/?page_size=200", ct);
        resp.EnsureSuccessStatusCode();

        var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikFlow>>(cancellationToken: ct);
        var match = json?.Results?.FirstOrDefault(f =>
            string.Equals(f.Slug, slug, StringComparison.OrdinalIgnoreCase));

        if (match == null)
        {
            var available = json?.Results?.Select(f => f.Slug) ?? Enumerable.Empty<string>();
            _logger.LogWarning("[Authentik] Flow '{Slug}' not found. Available flows: {Flows}",
                slug, string.Join(", ", available));
            throw new InvalidOperationException(
                $"Authentik flow '{slug}' not found. Ensure the flow exists or configure the correct slug in Settings → Authentik.");
        }

        var uuid = match.Pk;

        // Cache for subsequent calls
        if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug)
            _cachedAuthorizationFlowUuid = uuid;
        else if (settingsKey == SettingsService.AuthentikInvalidationFlowSlug)
            _cachedInvalidationFlowUuid = uuid;

        return uuid;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // SAML Provider creation
    // ─────────────────────────────────────────────────────────────────────────

    private async Task<int> CreateSamlProviderAsync(
        HttpClient client, string baseUrl,
        string abbrev, string samlBaseUrl,
        string authFlowUuid, string invalidFlowUuid,
        CancellationToken ct)
    {
        _logger.LogInformation("[Authentik] Creating SAML provider for {Abbrev}", abbrev);

        var payload = new Dictionary<string, object>
        {
            ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()} (SAML)",
            ["authorization_flow"] = authFlowUuid,
            ["invalidation_flow"] = invalidFlowUuid,
            ["acs_url"] = $"{samlBaseUrl}/acs",
            ["sp_binding"] = "post",
            ["issuer"] = $"ds-{abbrev}",
            ["audience"] = $"{samlBaseUrl}/metadata",
            ["default_relay_state"] = "",
            ["sls_url"] = $"{samlBaseUrl}/sls",
            ["sls_binding"] = "redirect",
        };

        // Attach SAML property mappings so attributes (username, email, etc.)
        // are included in the response. Without these Authentik sends an empty
        // <AttributeStatement> which fails strict schema validation in php-saml.
        var mappingIds = await ResolveSamlPropertyMappingIdsAsync(client, ct);
        if (mappingIds.Count > 0)
        {
            payload["property_mappings"] = mappingIds;
            _logger.LogInformation("[Authentik] Attaching {Count} SAML property mapping(s) to provider", mappingIds.Count);
        }
        else
            _logger.LogWarning("[Authentik] No SAML property mappings found — SAML responses may fail schema validation");

        // Attach signing keypair — required for metadata generation.
        // Use the configured keypair, or auto-detect one from Authentik.
        var signingKpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId);
        if (string.IsNullOrWhiteSpace(signingKpId))
        {
            _logger.LogDebug("[Authentik] No signing keypair configured — auto-detecting from Authentik");
            signingKpId = await ResolveDefaultKeypairAsync(client, ct);
        }
        if (!string.IsNullOrWhiteSpace(signingKpId))
        {
            payload["signing_kp"] = signingKpId;
            // Authentik requires at least one of these when a signing keypair is set
            payload["sign_assertion"] = true;
            payload["sign_response"] = true;
        }
        else
            _logger.LogWarning("[Authentik] No signing keypair found — metadata generation may fail");

        var jsonBody = JsonSerializer.Serialize(payload);
        _logger.LogDebug("[Authentik] SAML provider request body: {Body}", jsonBody);
        var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");

        var resp = await client.PostAsync("/api/v3/providers/saml/", content, ct);

        if (!resp.IsSuccessStatusCode)
        {
            var errorBody = await resp.Content.ReadAsStringAsync(ct);
            throw new InvalidOperationException(
                $"Failed to create Authentik SAML provider (HTTP {(int)resp.StatusCode}): {errorBody}");
        }

        var result = await resp.Content.ReadFromJsonAsync<AuthentikSamlProvider>(cancellationToken: ct);
        if (result == null)
            throw new InvalidOperationException("Authentik returned null when creating SAML provider.");

        _logger.LogInformation("[Authentik] SAML provider created: id={ProviderId}", result.Pk);
        return result.Pk;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Application creation
    // ─────────────────────────────────────────────────────────────────────────

    private async Task CreateApplicationAsync(
        HttpClient client, string baseUrl,
        string abbrev, string slug, int providerId,
        CancellationToken ct)
    {
        _logger.LogInformation("[Authentik] Creating application '{Slug}' linked to provider {ProviderId}", slug, providerId);

        var payload = new Dictionary<string, object>
        {
            ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()}",
            ["slug"] = slug,
            ["provider"] = providerId,
        };

        var jsonBody = JsonSerializer.Serialize(payload);
        _logger.LogDebug("[Authentik] Application request body: {Body}", jsonBody);
        var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");

        var resp = await client.PostAsync("/api/v3/core/applications/", content, ct);

        if (!resp.IsSuccessStatusCode)
        {
            var errorBody = await resp.Content.ReadAsStringAsync(ct);
            throw new InvalidOperationException(
                $"Failed to create Authentik application '{slug}' (HTTP {(int)resp.StatusCode}): {errorBody}");
        }

        _logger.LogInformation("[Authentik] Application '{Slug}' created", slug);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Metadata retrieval & parsing
    // ─────────────────────────────────────────────────────────────────────────

    private async Task<AuthentikSamlConfig> FetchAndParseMetadataAsync(
        HttpClient client, string baseUrl, int providerId, CancellationToken ct)
    {
        _logger.LogDebug("[Authentik] Fetching SAML metadata for provider {ProviderId}", providerId);

        // Retry a few times — Authentik may need a moment after provider creation
        const int maxRetries = 3;
        for (int attempt = 1; attempt <= maxRetries; attempt++)
        {
            // The API endpoint returns JSON: { "metadata": "<xml>", "download_url": "..." }
            var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/");
            request.Headers.Accept.Clear();
            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            var resp = await client.SendAsync(request, ct);

            if (resp.IsSuccessStatusCode)
            {
                var jsonBody = await resp.Content.ReadAsStringAsync(ct);
                using var doc = JsonDocument.Parse(jsonBody);

                if (doc.RootElement.TryGetProperty("metadata", out var metadataProp))
                {
                    var xml = metadataProp.GetString();
                    if (!string.IsNullOrWhiteSpace(xml))
                        return ParseMetadataXml(xml, providerId);
                }

                _logger.LogWarning(
                    "[Authentik] Metadata response for provider {Id} missing 'metadata' field: {Body}",
                    providerId, jsonBody);
            }
            else
            {
                var body = await resp.Content.ReadAsStringAsync(ct);
                _logger.LogWarning(
                    "[Authentik] Metadata fetch attempt {Attempt}/{Max} failed (HTTP {Status}): {Body}",
                    attempt, maxRetries, (int)resp.StatusCode,
                    body.Length > 500 ? body[..500] + "…(truncated)" : body);
            }

            if (attempt < maxRetries)
                await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct);
        }

        // ── Fallback: assemble config from provider detail + keypair cert ──
        // Authentik's metadata endpoint can return 500 in some versions.
        // All the data we need is available from other endpoints.
        _logger.LogWarning(
            "[Authentik] Metadata endpoint failed after {Max} attempts — assembling config from provider detail + keypair",
            maxRetries);

        return await BuildConfigFromProviderDetailAsync(client, baseUrl, providerId, ct);
    }

    /// <summary>
    /// Builds the SAML config by reading the provider detail (SSO/SLO URLs)
    /// and the signing certificate from the keypair endpoint. This is used as
    /// a fallback when the <c>/metadata/</c> endpoint returns an error.
    /// </summary>
    private async Task<AuthentikSamlConfig> BuildConfigFromProviderDetailAsync(
        HttpClient client, string baseUrl, int providerId, CancellationToken ct)
    {
        // ── 1. Fetch provider detail ──────────────────────────────────────
        var provResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct);
        if (!provResp.IsSuccessStatusCode)
        {
            var err = await provResp.Content.ReadAsStringAsync(ct);
            throw new InvalidOperationException(
                $"Cannot fetch SAML provider {providerId} detail (HTTP {(int)provResp.StatusCode}): {err}");
        }

        var provBody = await provResp.Content.ReadAsStringAsync(ct);
        using var provDoc = JsonDocument.Parse(provBody);
        var prov = provDoc.RootElement;

        var config = new AuthentikSamlConfig { ProviderId = providerId };

        // Entity ID = the issuer we set on the provider
        config.IdpEntityId = prov.TryGetProperty("issuer", out var iss) && iss.ValueKind == JsonValueKind.String
            ? iss.GetString() ?? baseUrl
            : baseUrl;

        // SSO / SLO URLs are computed properties on the provider
        config.SsoUrlRedirect = GetStringProp(prov, "url_sso_redirect");
        config.SsoUrlPost     = GetStringProp(prov, "url_sso_post");
        config.SloUrlRedirect = GetStringProp(prov, "url_slo_redirect");
        config.SloUrlPost     = GetStringProp(prov, "url_slo_post");

        _logger.LogDebug(
            "[Authentik] Provider detail: entityId={EntityId}, ssoRedirect={Sso}, sloRedirect={Slo}",
            config.IdpEntityId, config.SsoUrlRedirect, config.SloUrlRedirect);

        // ── 2. Fetch X.509 certificate from the signing keypair ───────────
        if (prov.TryGetProperty("signing_kp", out var kpProp) && kpProp.ValueKind != JsonValueKind.Null)
        {
            var kpId = kpProp.GetString();
            if (!string.IsNullOrWhiteSpace(kpId))
            {
                config.IdpX509Cert = await FetchKeypairCertificateAsync(client, kpId, ct);
            }
        }

        if (string.IsNullOrWhiteSpace(config.IdpX509Cert))
            _logger.LogWarning("[Authentik] Could not retrieve X.509 certificate for provider {Id}", providerId);

        _logger.LogInformation(
            "[Authentik] Config assembled from provider detail: entityId={EntityId}, certLen={CertLen}",
            config.IdpEntityId, config.IdpX509Cert?.Length ?? 0);

        return config;
    }

    /// <summary>
    /// Fetches the PEM certificate from Authentik's keypair endpoints and
    /// returns the base64-encoded X.509 body (no PEM headers).
    /// Tries <c>/view_certificate/</c> first, then falls back to the regular
    /// keypair detail and <c>certificate_data</c>.
    /// </summary>
    private async Task<string> FetchKeypairCertificateAsync(
        HttpClient client, string keypairId, CancellationToken ct)
    {
        // Attempt 1: /view_certificate/ endpoint (returns detailed cert info)
        try
        {
            var resp = await client.GetAsync(
                $"/api/v3/crypto/certificatekeypairs/{keypairId}/view_certificate/", ct);
            if (resp.IsSuccessStatusCode)
            {
                var body = await resp.Content.ReadAsStringAsync(ct);
                using var doc = JsonDocument.Parse(body);
                // The cert field contains PEM-encoded certificate
                foreach (var fieldName in new[] { "cert", "certificate", "data" })
                {
                    if (doc.RootElement.TryGetProperty(fieldName, out var certProp) &&
                        certProp.ValueKind == JsonValueKind.String)
                    {
                        var pem = certProp.GetString();
                        if (!string.IsNullOrWhiteSpace(pem))
                        {
                            _logger.LogDebug("[Authentik] Certificate retrieved from view_certificate/{Field}", fieldName);
                            return StripPemHeaders(pem);
                        }
                    }
                }

                _logger.LogDebug("[Authentik] view_certificate response had no cert field: {Body}",
                    body.Length > 500 ? body[..500] : body);
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "[Authentik] view_certificate endpoint failed for keypair {Id}", keypairId);
        }

        // Attempt 2: regular keypair detail (some versions include certificate_data)
        try
        {
            var resp = await client.GetAsync(
                $"/api/v3/crypto/certificatekeypairs/{keypairId}/", ct);
            if (resp.IsSuccessStatusCode)
            {
                var body = await resp.Content.ReadAsStringAsync(ct);
                using var doc = JsonDocument.Parse(body);
                foreach (var fieldName in new[] { "certificate_data", "cert", "certificate" })
                {
                    if (doc.RootElement.TryGetProperty(fieldName, out var certProp) &&
                        certProp.ValueKind == JsonValueKind.String)
                    {
                        var pem = certProp.GetString();
                        if (!string.IsNullOrWhiteSpace(pem))
                        {
                            _logger.LogDebug("[Authentik] Certificate retrieved from keypair detail/{Field}", fieldName);
                            return StripPemHeaders(pem);
                        }
                    }
                }

                _logger.LogDebug("[Authentik] Keypair detail response fields: {Fields}",
                    string.Join(", ", EnumeratePropertyNames(doc.RootElement)));
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "[Authentik] Keypair detail fetch failed for {Id}", keypairId);
        }

        return string.Empty;
    }

    private static string StripPemHeaders(string pem)
    {
        return pem
            .Replace("-----BEGIN CERTIFICATE-----", "")
            .Replace("-----END CERTIFICATE-----", "")
            .Replace("\n", "")
            .Replace("\r", "")
            .Trim();
    }

    private static string GetStringProp(JsonElement el, string name)
    {
        return el.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String
            ? prop.GetString() ?? string.Empty
            : string.Empty;
    }

    private static IEnumerable<string> EnumeratePropertyNames(JsonElement el)
    {
        if (el.ValueKind == JsonValueKind.Object)
        {
            foreach (var p in el.EnumerateObject())
                yield return p.Name;
        }
    }

    private AuthentikSamlConfig ParseMetadataXml(string xml, int providerId)
    {
        var doc = XDocument.Parse(xml);
        var root = doc.Root
            ?? throw new InvalidOperationException("SAML metadata XML has no root element.");

        var config = new AuthentikSamlConfig
        {
            ProviderId = providerId,
            IdpEntityId = root.Attribute("entityID")?.Value ?? "authentik",
        };

        // Extract x509 certificate (signing key)
        var certElement = root.Descendants(Ds + "X509Certificate").FirstOrDefault();
        if (certElement != null)
        {
            // Remove whitespace/newlines from cert — some SAML libs expect a single line
            config.IdpX509Cert = certElement.Value.Replace("\n", "").Replace("\r", "").Trim();
        }

        // Extract SSO URLs
        foreach (var sso in root.Descendants(Md + "SingleSignOnService"))
        {
            var binding = sso.Attribute("Binding")?.Value;
            var location = sso.Attribute("Location")?.Value;
            if (location == null) continue;

            if (binding?.Contains("HTTP-Redirect") == true)
                config.SsoUrlRedirect = location;
            else if (binding?.Contains("HTTP-POST") == true)
                config.SsoUrlPost = location;
        }

        // Extract SLO URLs
        foreach (var slo in root.Descendants(Md + "SingleLogoutService"))
        {
            var binding = slo.Attribute("Binding")?.Value;
            var location = slo.Attribute("Location")?.Value;
            if (location == null) continue;

            if (binding?.Contains("HTTP-Redirect") == true)
                config.SloUrlRedirect = location;
            else if (binding?.Contains("HTTP-POST") == true)
                config.SloUrlPost = location;
        }

        _logger.LogDebug(
            "[Authentik] Metadata parsed: entityId={EntityId}, ssoRedirect={SsoUrl}, sloRedirect={SloUrl}, certLen={CertLen}",
            config.IdpEntityId, config.SsoUrlRedirect, config.SloUrlRedirect, config.IdpX509Cert.Length);

        return config;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // API response DTOs (internal)
    // ─────────────────────────────────────────────────────────────────────────

    private class AuthentikListResponse<T>
    {
        [JsonPropertyName("pagination")]
        public object? Pagination { get; set; }

        [JsonPropertyName("results")]
        public List<T> Results { get; set; } = new();
    }

    private class AuthentikFlow
    {
        [JsonPropertyName("pk")]
        public string Pk { get; set; } = string.Empty;

        [JsonPropertyName("slug")]
        public string Slug { get; set; } = string.Empty;

        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;
    }

    private class AuthentikFlowDetailed
    {
        [JsonPropertyName("pk")]
        public string Pk { get; set; } = string.Empty;

        [JsonPropertyName("slug")]
        public string Slug { get; set; } = string.Empty;

        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;

        [JsonPropertyName("designation")]
        public string Designation { get; set; } = string.Empty;
    }

    private class AuthentikKeypairDetailed
    {
        [JsonPropertyName("pk")]
        public string Pk { get; set; } = string.Empty;

        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;
    }

    private class AuthentikSamlProvider
    {
        [JsonPropertyName("pk")]
        public int Pk { get; set; }

        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;

        [JsonPropertyName("url_sso_redirect")]
        public string? UrlSsoRedirect { get; set; }

        [JsonPropertyName("url_sso_post")]
        public string? UrlSsoPost { get; set; }

        [JsonPropertyName("url_slo_redirect")]
        public string? UrlSloRedirect { get; set; }

        [JsonPropertyName("url_slo_post")]
        public string? UrlSloPost { get; set; }
    }

    private class AuthentikApplication
    {
        [JsonPropertyName("pk")]
        public string Pk { get; set; } = string.Empty;

        [JsonPropertyName("slug")]
        public string Slug { get; set; } = string.Empty;

        [JsonPropertyName("provider")]
        public int? Provider { get; set; }
    }

    private class AuthentikPropertyMapping
    {
        [JsonPropertyName("pk")]
        public string Pk { get; set; } = string.Empty;

        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;

        /// <summary>
        /// Non-null for built-in managed mappings, e.g. "goauthentik.io/providers/saml/upn".
        /// </summary>
        [JsonPropertyName("managed")]
        public string? Managed { get; set; }
    }
}
