using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;  // OpcUaClientDriverOptions + NamespaceMap

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;

/// <summary>
///     Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
///     Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
///     trust decisions cannot poison the runtime driver's cert store.
/// </summary>
public sealed class OpcUaClientDriverBrowser : IDriverBrowser
{
    // Kept identical to OpcUaClientDriverFactoryExtensions.JsonOptions and
    // OpcUaClientDriverProbe._opts so the browser parses a given DriverConfig JSON
    // the same way as the factory and the probe. The JsonStringEnumConverter lets
    // enum-valued knobs (SecurityPolicy / SecurityMode / AuthType / TargetNamespaceKind)
    // be authored as their string names — the natural form for AdminUI-emitted JSON.
    // JsonStringEnumConverter also accepts numeric ordinals, so existing numeric configs
    // continue to work without migration.
    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
        PropertyNameCaseInsensitive = true,
        Converters = { new JsonStringEnumConverter() },
    };

    private readonly ILogger<OpcUaClientDriverBrowser> _logger;

    /// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
    /// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
    public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
    {
        _logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
    }

    /// <inheritdoc />
    public string DriverType => "OpcUaClient";

    /// <inheritdoc />
    public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
    {
        var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, JsonOpts)
            ?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");

        var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
        if (string.IsNullOrWhiteSpace(endpoint))
            throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");

        if (opts.AuthType == OpcUaAuthType.Certificate)
            throw new InvalidOperationException(
                "Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");

        if (opts.AutoAcceptCertificates)
            _logger.LogWarning(
                "AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
                "browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
                "driver's PKI store instead.",
                endpoint);

        var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
        var identity = BuildBrowseUserIdentity(opts);

        var perEndpointBudget = TimeSpan.FromSeconds(
            Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
        using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        connectCts.CancelAfter(perEndpointBudget);

        var endpointDesc = await SelectEndpointAsync(
            appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
        var endpointCfg = EndpointConfiguration.Create(appConfig);
        endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
        var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);

        var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
            appConfig,
            configuredEndpoint,
            updateBeforeConnect: false,
            sessionName: "OtOpcUa AdminUI Browse",
            (uint)opts.SessionTimeout.TotalMilliseconds,
            identity,
            preferredLocales: null,
            connectCts.Token).ConfigureAwait(false);

        try
        {
            var nsMap = NamespaceMap.FromSession(session);
            var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
                ? ObjectIds.ObjectsFolder
                : NodeId.Parse(session.MessageContext, opts.BrowseRoot);

            _logger.LogInformation(
                "AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
                endpoint, opts.SecurityPolicy, opts.SecurityMode);

            return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
        }
        catch
        {
            try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
            try { session.Dispose(); } catch { /* best-effort */ }
            throw;
        }
    }

    /// <summary>
    ///     Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
    ///     the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
    ///     trust decisions don't leak into the deployed driver's cert store.
    /// </summary>
    /// <param name="ct">Cancellation token for the configuration build.</param>
    private static async Task<ApplicationConfiguration> BuildBrowseAppConfigurationAsync(CancellationToken ct)
    {
        // The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
        // locally rather than plumbing a telemetry context all the way through — the browser
        // emits no per-request telemetry of its own and the SDK's internal fallback is fine
        // for a transient picker session (mirrors the runtime driver's same suppression).
#pragma warning disable CS0618
        var app = new ApplicationInstance
        {
            ApplicationName = "OtOpcUa AdminUI Browse",
            ApplicationType = ApplicationType.Client,
        };
#pragma warning restore CS0618

        var pkiRoot = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "OtOpcUa", "adminui-browse-pki");

        var config = new ApplicationConfiguration
        {
            ApplicationName = "OtOpcUa AdminUI Browse",
            ApplicationType = ApplicationType.Client,
            ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
            SecurityConfiguration = new SecurityConfiguration
            {
                ApplicationCertificate = new CertificateIdentifier
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "own"),
                    SubjectName = "CN=OtOpcUa AdminUI Browse",
                },
                TrustedPeerCertificates = new CertificateTrustList
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "trusted"),
                },
                TrustedIssuerCertificates = new CertificateTrustList
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "issuers"),
                },
                RejectedCertificateStore = new CertificateTrustList
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "rejected"),
                },
                AutoAcceptUntrustedCertificates = false,
            },
            TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
            DisableHiResClock = true,
        };
        await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
        app.ApplicationConfiguration = config;
        await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
            .ConfigureAwait(false);
        return config;
    }

    /// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
    /// <param name="opts">Driver options carrying the form's auth fields.</param>
    private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
    {
        OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
        OpcUaAuthType.Username => new UserIdentity(
            opts.Username ?? string.Empty,
            System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
        _ => new UserIdentity(new AnonymousIdentityToken()),
    };

    /// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
    /// <param name="appConfig">Application configuration used by the discovery client.</param>
    /// <param name="url">Remote endpoint URL to query.</param>
    /// <param name="policy">Required security policy.</param>
    /// <param name="mode">Required message security mode.</param>
    /// <param name="ct">Cancellation token for the discovery call.</param>
    private static async Task<EndpointDescription> SelectEndpointAsync(
        ApplicationConfiguration appConfig, string url,
        OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
    {
        using var client = await DiscoveryClient.CreateAsync(
            appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
        var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);

        var wantedPolicy = MapPolicy(policy);
        var wantedMode = mode switch
        {
            OpcUaSecurityMode.None => MessageSecurityMode.None,
            OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
            OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
            _ => throw new ArgumentOutOfRangeException(nameof(mode)),
        };
        var match = all.FirstOrDefault(e =>
            e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
        if (match is null)
        {
            var advertised = string.Join(", ", all.Select(e =>
                $"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
            throw new InvalidOperationException(
                $"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
                $"Server advertises: {advertised}");
        }
        return match;
    }

    /// <summary>Convert the driver options enum to the OPC UA policy URI.</summary>
    /// <param name="p">The driver security policy to map.</param>
    private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
    {
        OpcUaSecurityPolicy.None => SecurityPolicies.None,
        OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
        OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
        OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
        OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
        OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
        _ => throw new ArgumentOutOfRangeException(nameof(p)),
    };

    /// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
    /// <param name="uri">Full policy URI to shorten.</param>
    private static string ShortName(string uri) =>
        uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
}
