using System.Text;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline
/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added
/// later by extending <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
/// </summary>
public enum OpcUaSecurityProfile
{
    /// <summary>No signing or encryption. Dev / isolated networks only.</summary>
    None,
    /// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
    Basic256Sha256Sign,
    /// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
    Basic256Sha256SignAndEncrypt,
}

/// <summary>Configuration options for OPC UA application hosting.</summary>
public sealed class OpcUaApplicationHostOptions
{
    /// <summary>Gets or sets the application name (default "OtOpcUa").</summary>
    public string ApplicationName { get; set; } = "OtOpcUa";

    /// <summary>Gets or sets the application URI (default "urn:OtOpcUa").</summary>
    public string ApplicationUri { get; set; } = "urn:OtOpcUa";

    /// <summary>Gets or sets the product URI (default "https://zb.com/otopcua").</summary>
    public string ProductUri { get; set; } = "https://zb.com/otopcua";

    /// <summary>Listening port for the binary endpoint (default 4840).</summary>
    public int OpcUaPort { get; set; } = 4840;

    /// <summary>Hostname or IP advertised in endpoint descriptions.</summary>
    public string PublicHostname { get; set; } = "0.0.0.0";

    /// <summary>Application config XML path; when set, loaded instead of building from defaults.</summary>
    public string? ApplicationConfigPath { get; set; }

    /// <summary>
    /// Root of the application's PKI hierarchy. Sub-stores (<c>own</c>, <c>issuer</c>,
    /// <c>trusted</c>, <c>rejected</c>) are created under this path on first start. Defaults
    /// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
    /// </summary>
    public string PkiStoreRoot { get; set; } = "pki";

    /// <summary>
    /// Transport-security profiles exposed by the server. The SDK publishes one endpoint
    /// descriptor per profile and clients choose at session open. Default = all three
    /// baseline profiles (None + Basic256Sha256 in both modes); production deployments
    /// typically drop None.
    /// </summary>
    public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
    {
        OpcUaSecurityProfile.None,
        OpcUaSecurityProfile.Basic256Sha256Sign,
        OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
    };

    /// <summary>
    /// When true, unknown client certificates are auto-added to the trusted store on first
    /// connection. Convenient for dev; should be false in production (operators promote via
    /// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
    /// </summary>
    public bool AutoAcceptUntrustedClientCertificates { get; set; }

    /// <summary>
    /// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
    /// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
    /// deployments so OPC UA clients can discover the partner endpoint via the standard
    /// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
    /// is always element 0.
    /// </summary>
    public IList<string> PeerApplicationUris { get; set; } = new List<string>();
}

/// <summary>
/// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap.
/// Owns the <see cref="ApplicationInstance"/> + <see cref="ApplicationConfiguration"/> lifetime,
/// builds the <see cref="ApplicationConfiguration"/> programmatically (security profiles + user
/// token policies + PKI stores), starts a <see cref="StandardServer"/>, attaches the
/// <c>ImpersonateUser</c> hook for UserName-token authentication via
/// <see cref="IOpcUaUserAuthenticator"/>, and publishes the redundancy peer URIs through
/// <c>Server.ServerArray</c>. See <c>docs/OpcUaServer.md</c> and <c>docs/security.md</c>.
/// </summary>
public sealed class OpcUaApplicationHost : IAsyncDisposable
{
    private readonly OpcUaApplicationHostOptions _options;
    private readonly IOpcUaUserAuthenticator _userAuthenticator;
    private readonly ILogger<OpcUaApplicationHost> _logger;
    private ApplicationInstance? _application;
    private StandardServer? _server;
    private ImpersonateEventHandler? _impersonateHandler;

    /// <summary>Initializes a new instance of the OPC UA application host.</summary>
    /// <param name="options">The host configuration options.</param>
    /// <param name="logger">The logger for diagnostic output.</param>
    /// <param name="userAuthenticator">An optional user authenticator for UserName tokens; uses null implementation if not provided.</param>
    public OpcUaApplicationHost(
        OpcUaApplicationHostOptions options,
        ILogger<OpcUaApplicationHost> logger,
        IOpcUaUserAuthenticator? userAuthenticator = null)
    {
        _options = options;
        _logger = logger;
        _userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
    }

    /// <summary>Gets the OPC Foundation application instance, or null if not yet started.</summary>
    public ApplicationInstance? ApplicationInstance => _application;

    /// <summary>Gets the OPC UA server instance, or null if not yet started.</summary>
    public StandardServer? Server => _server;

    /// <summary>Starts the OPC UA application and server.</summary>
    /// <param name="server">The standard server instance to start.</param>
    /// <param name="cancellationToken">A cancellation token for the operation.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
    {
        _server = server;
        // 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor
        // is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps
        // the SDK's trace off our ILogger (the host keeps its own _logger) — sufficient for the bootstrap.
        _application = new ApplicationInstance(DefaultTelemetry.Create(_ => { }))
        {
            ApplicationName = _options.ApplicationName,
            ApplicationType = ApplicationType.Server,
            ConfigSectionName = "OtOpcUa",
        };

        _ = await BuildConfigurationAsync(cancellationToken);
        await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
        await _application.StartAsync(server).ConfigureAwait(false);

        AttachUserAuthenticator();
        PopulateServerArray();

        _logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
            _options.PublicHostname, _options.OpcUaPort);
    }

    /// <summary>
    /// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
    /// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
    /// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
    /// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
    /// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
    ///
    /// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
    /// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
    /// LDAP binds typically complete in &lt;100 ms; if a backing store ever gets that slow
    /// it should not block the OPC UA stack — callers must enforce their own timeouts inside
    /// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
    /// </summary>
    private void AttachUserAuthenticator()
    {
        var sessionManager = _server?.CurrentInstance?.SessionManager;
        if (sessionManager is null)
        {
            _logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
            return;
        }

        _impersonateHandler = OnImpersonateUser;
        sessionManager.ImpersonateUser += _impersonateHandler;
    }

    /// <summary>
    /// Publishes <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> via the OPC UA
    /// standard <c>Server.ServerArray</c> property (NodeId i=2254) so warm-redundancy clients
    /// can discover the partner endpoint.
    ///
    /// The wire-served value of <c>Server.ServerArray</c> comes from
    /// <see cref="IServerInternal.ServerUris"/> (an <see cref="Opc.Ua.StringTable"/>) via the
    /// SDK's <c>OnReadServerArray</c> callback — writes to
    /// <c>ServerObject.ServerArray.Value</c> are NOT what clients read. The SDK auto-populates
    /// slot 0 with the local <c>ApplicationUri</c> on <c>ApplicationInstance.Start</c>; we
    /// append the configured peers at slots 1, 2, … here.
    ///
    /// The address-space property is also mirrored for in-process readers (the unit-test
    /// observation seam) and as a defensive belt-and-braces measure.
    /// </summary>
    private void PopulateServerArray()
    {
        var internalData = _server?.CurrentInstance;
        if (internalData is null) return;

        // Wire path: append peers to IServerInternal.ServerUris — this is what
        // OnReadServerArray serves to remote clients reading VariableIds.Server_ServerArray.
        var serverUris = internalData.ServerUris;
        var existing = new HashSet<string>(StringComparer.Ordinal);
        for (uint i = 0; i < (uint)serverUris.Count; i++)
        {
            var existingUri = serverUris.GetString(i);
            if (existingUri is not null) existing.Add(existingUri);
        }

        foreach (var peer in _options.PeerApplicationUris)
        {
            if (string.IsNullOrWhiteSpace(peer)) continue;
            if (existing.Contains(peer)) continue;
            serverUris.Append(peer);
            existing.Add(peer);
        }

        // In-process mirror: ServerObject.ServerArray.Value is consulted by some tests and
        // tooling that read the SDK's address-space model directly rather than going through
        // a session. Harmless on the wire (the SDK ignores it) but useful in-VM.
        var serverObject = internalData.ServerObject;
        if (serverObject is not null)
        {
            var uris = new List<string> { _options.ApplicationUri };
            foreach (var peer in _options.PeerApplicationUris)
            {
                if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
                    uris.Add(peer);
            }
            serverObject.ServerArray.Value = uris.ToArray();
        }
    }

    private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
        HandleImpersonation(_userAuthenticator, args, _logger);

    /// <summary>
    /// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
    /// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
    /// and logging.
    /// </summary>
    /// <remarks>
    /// <b>SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378).</b>
    /// The SDK raises this handler SYNCHRONOUSLY — it is a plain <c>void</c> delegate, and there is no
    /// async impersonation callback in this SDK version. Worse, <c>SessionManager.ActivateSessionAsync</c>
    /// invokes it while holding a single <c>SessionManager</c>-wide event lock (<c>m_eventLock</c>), so a
    /// handler that blocks does not merely stall one SDK request thread — it SERIALIZES every session
    /// activation server-wide (Anonymous and X509 included, since even those paths must take the lock to
    /// raise the handler). Consequently the <see cref="IOpcUaUserAuthenticator"/> block-bridge below
    /// (<c>GetAwaiter().GetResult()</c> under <see cref="CancellationToken.None"/>) is irreducible: the
    /// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
    /// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
    /// inside the authenticator (<c>LdapOpcUaUserAuthenticator</c>, arch-review 03/S2) — NOT here.
    /// </remarks>
    /// <param name="authenticator">The user authenticator to validate credentials.</param>
    /// <param name="args">The impersonation event arguments to process.</param>
    /// <param name="logger">The logger for diagnostic output.</param>
    internal static void HandleImpersonation(
        IOpcUaUserAuthenticator authenticator,
        ImpersonateEventArgs args,
        ILogger logger)
    {
        if (args.NewIdentity is not UserNameIdentityToken token)
        {
            // Anonymous + X509 tokens — let the SDK's default validation stand.
            return;
        }

        string password;
        try
        {
            // 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords
            // are UTF-8 on the wire.
            var decryptedBytes = token.DecryptedPassword;
            password = decryptedBytes is null ? string.Empty : Encoding.UTF8.GetString(decryptedBytes);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
            args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
                "UserName token decryption failed");
            return;
        }

        OpcUaUserAuthResult result;
        try
        {
            result = authenticator
                .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
                .GetAwaiter().GetResult();
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
            args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
                "Authentication failed");
            return;
        }

        if (!result.Success)
        {
            logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
                token.UserName, result.Error);
            args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
                result.Error ?? "Invalid credentials");
            return;
        }

        args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
        logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
            token.UserName, string.Join(",", result.Roles));
    }

    /// <summary>
    /// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
    /// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
    /// PKI tree; subsequent boots reuse the existing cert. Replaces v1's manual "you must
    /// pre-create the PKI directory tree" friction. Partial slice of follow-up F13 — the
    /// remaining endpoint-security, user-token validator, and observability wiring stays in
    /// the follow-up queue.
    /// </summary>
    private async Task EnsureApplicationCertificateAsync(CancellationToken cancellationToken)
    {
        // silent: false → SDK logs cert creation events through its own trace plumbing.
        // minimumKeySize/lifetimeInMonths: 0 → use SDK defaults (2048-bit, 12-month lifetime).
        var ok = await _application!.CheckApplicationInstanceCertificatesAsync(
            false, null, cancellationToken).ConfigureAwait(false);
        if (!ok)
        {
            throw new InvalidOperationException(
                $"OPC UA application certificate validation failed for {_options.ApplicationName}. " +
                $"Cert store root: {Path.GetFullPath(_options.PkiStoreRoot)}");
        }
    }

    private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
    {
        if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath))
        {
            return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct);
        }

        var serverConfig = new ServerConfiguration
        {
            BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
            MinRequestThreadCount = 5,
            MaxRequestThreadCount = 100,
            MaxQueuedRequestCount = 200,
        };

        foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
        {
            serverConfig.SecurityPolicies.Add(policy);
        }
        foreach (var token in BuildUserTokenPolicies())
        {
            serverConfig.UserTokenPolicies.Add(token);
        }

        var config = new ApplicationConfiguration
        {
            ApplicationName = _options.ApplicationName,
            ApplicationUri = _options.ApplicationUri,
            ProductUri = _options.ProductUri,
            ApplicationType = ApplicationType.Server,
            ServerConfiguration = serverConfig,
            SecurityConfiguration = new SecurityConfiguration
            {
                ApplicationCertificate = new CertificateIdentifier
                {
                    StoreType = "Directory",
                    StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
                    SubjectName = $"CN={_options.ApplicationName}",
                },
                TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
                TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
                RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
                AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
            },
            TransportQuotas = new TransportQuotas(),
            ClientConfiguration = new ClientConfiguration(),
            TraceConfiguration = new TraceConfiguration(),
        };

        await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false);
        _application!.ApplicationConfiguration = config;
        return config;
    }

    /// <summary>
    /// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
    /// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
    /// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> time. Empty input
    /// falls back to a single None entry so the server doesn't refuse to start with no
    /// listening endpoints — the misconfiguration is logged and very visible.
    /// </summary>
    /// <param name="profiles">The security profiles to build policies for.</param>
    /// <returns>The SDK security policies for the requested profiles.</returns>
    internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
    {
        var seen = new HashSet<OpcUaSecurityProfile>();
        var any = false;
        foreach (var profile in profiles)
        {
            if (!seen.Add(profile)) continue;
            any = true;
            yield return profile switch
            {
                OpcUaSecurityProfile.None => new ServerSecurityPolicy
                {
                    SecurityMode = MessageSecurityMode.None,
                    SecurityPolicyUri = SecurityPolicies.None,
                },
                OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy
                {
                    SecurityMode = MessageSecurityMode.Sign,
                    SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
                },
                OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy
                {
                    SecurityMode = MessageSecurityMode.SignAndEncrypt,
                    SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
                },
                _ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
            };
        }
        if (!any)
        {
            yield return new ServerSecurityPolicy
            {
                SecurityMode = MessageSecurityMode.None,
                SecurityPolicyUri = SecurityPolicies.None,
            };
        }
    }

    /// <summary>
    /// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with
    /// the server certificate (see docs/security.md "UserName token encryption") so the
    /// policy works on None endpoints too. The real LDAP-bound validator is wired through
    /// <see cref="IOpcUaUserAuthenticator"/> on <c>StandardServer.SessionManager.ImpersonateUser</c>
    /// (see <see cref="AttachUserAuthenticator"/> / <see cref="HandleImpersonation"/>); the Host
    /// project supplies the LDAP adapter, and <see cref="NullOpcUaUserAuthenticator"/> denies all
    /// UserName logins when none is registered.
    /// </summary>
    /// <returns>The Anonymous and UserName token policies.</returns>
    internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
    {
        yield return new UserTokenPolicy(UserTokenType.Anonymous)
        {
            PolicyId = "anonymous",
            SecurityPolicyUri = SecurityPolicies.None,
        };
        yield return new UserTokenPolicy(UserTokenType.UserName)
        {
            PolicyId = "username_basic256sha256",
            SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
        };
    }

    /// <summary>Disposes the application host and cleans up resources.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async ValueTask DisposeAsync()
    {
        if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
        {
            try { sessionManager.ImpersonateUser -= _impersonateHandler; }
            catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
        }
        _impersonateHandler = null;

        if (_application is not null)
        {
            // 1.5.378: ApplicationInstance.Stop() → StopAsync().
            try { await _application.StopAsync().ConfigureAwait(false); }
            catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
        }
    }
}
