using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;

/// <summary>See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2.</summary>
public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
{
    /// <summary>Whole-open bound: construct + connect + discover (+ settle). Separate from the
    /// 20 s per-call browse timeout, which only covers serving from memory.</summary>
    public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);

    /// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
    /// in discovery may be equally wedged in shutdown).</summary>
    public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);

    /// <summary>Node-count cap the capturing builder enforces before marking the tree truncated.</summary>
    public const int NodeCap = 50_000;

    /// <summary>Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot.</summary>
    public const int MaxConcurrentCaptures = 4;

    /// <summary>Per-type declarative config patch guaranteeing browse mode at open time (§5.1).
    /// A dictionary entry per config-gated driver — not code.</summary>
    internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["AbCip"]   = """{ "EnableControllerBrowse": true }""",
            ["TwinCAT"] = """{ "EnableControllerBrowse": true }""",
            ["FOCAS"]   = """{ "FixedTree": { "Enabled": true } }""",
            // OpcUaClient/BACnet/MTConnect: no patch — their discovery is unconditional.
        };

    private readonly IDriverFactory _driverFactory;
    private readonly ILogger<DiscoveryDriverBrowser> _logger;
    private readonly TimeSpan _openTimeout;
    private readonly TimeSpan _shutdownTimeout;
    private readonly ConcurrentDictionary<string, Lazy<Task<CapturedTree>>> _inflight = new();
    private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures);

    /// <summary>Production constructor: standard open + shutdown bounds.</summary>
    /// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
    /// <param name="logger">Logger for shutdown-timeout warnings.</param>
    public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
        : this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }

    // Test seam: short timeouts.
    internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
        TimeSpan openTimeout, TimeSpan shutdownTimeout)
    {
        _driverFactory = driverFactory;
        _logger = logger;
        _openTimeout = openTimeout;
        _shutdownTimeout = shutdownTimeout;
    }

    /// <inheritdoc />
    public bool CanBrowse(string driverType, string configJson)
    {
        IDriver? probe = null;
        try
        {
            probe = _driverFactory.TryCreate(driverType, $"browse-can-{Guid.NewGuid():N}",
                PatchForBrowse(driverType, configJson));
            return probe is ITagDiscovery { SupportsOnlineDiscovery: true };
        }
        catch
        {
            // TryCreate parses configJson and can throw on mid-authoring JSON. UI affordance
            // gate — must never throw into the page (§4.2).
            return false;
        }
        finally
        {
            if (probe is not null) _ = BoundedShutdownAsync(probe, driverType); // best-effort, never leak
        }
    }

    /// <inheritdoc />
    public async Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
    {
        var key = CaptureKey(driverType, configJson);
        // Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races.
        var capture = _inflight.GetOrAdd(key,
            _ => new Lazy<Task<CapturedTree>>(() => RunCaptureAsync(key, driverType, configJson)));
        // The shared capture runs independent of any single caller (its own OpenTimeout bounds it);
        // each caller — including a queued one — observes ITS OWN ct via WaitAsync.
        var tree = await capture.Value.WaitAsync(ct).ConfigureAwait(false);
        return new CapturedTreeBrowseSession(tree);
    }

    private async Task<CapturedTree> RunCaptureAsync(string key, string driverType, string configJson)
    {
        try
        {
            await _captureSlots.WaitAsync().ConfigureAwait(false); // FIFO-ish queue for a slot
            try
            {
                return await CaptureAsync(driverType, configJson, CancellationToken.None).ConfigureAwait(false);
            }
            finally
            {
                _captureSlots.Release();
            }
        }
        finally
        {
            _inflight.TryRemove(key, out _); // in-flight only — no result caching (Refresh = fresh capture)
        }
    }

    internal static string CaptureKey(string driverType, string configJson) =>
        driverType.ToLowerInvariant() + ":" +
        Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
            System.Text.Encoding.UTF8.GetBytes(configJson ?? "")));

    private async Task<CapturedTree> CaptureAsync(string driverType, string configJson, CancellationToken ct)
    {
        var patched = PatchForBrowse(driverType, configJson);
        var driver = _driverFactory.TryCreate(driverType, $"browse-{Guid.NewGuid():N}", patched)
            ?? throw new InvalidOperationException(
                $"Driver type '{driverType}' is not available in this host.");
        try
        {
            if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery)
                throw new InvalidOperationException(
                    $"Driver type '{driverType}' has no online discovery — author tags manually.");

            using var openCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            openCts.CancelAfter(_openTimeout);

            await driver.InitializeAsync(patched, openCts.Token).ConfigureAwait(false);
            return await DiscoverTreeAsync(disc, driverType, openCts.Token).ConfigureAwait(false);
        }
        finally
        {
            await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false);
        }
    }

    private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1);

    private async Task<CapturedTree> DiscoverTreeAsync(ITagDiscovery disc, string driverType, CancellationToken ct)
    {
        if (disc.RediscoverPolicy != DiscoveryRediscoverPolicy.UntilStable)
        {
            var builder = new CapturingAddressSpaceBuilder(NodeCap);
#pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path: the driver is a throwaway instance we construct/connect/discover/discard for the picker. CapabilityInvoker (retry/breaker/telemetry) governs live polling, not this admin-side capture.
            await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
            return builder.Build();
        }

        // UntilStable (FOCAS): the discovered shape fills in asynchronously after connect —
        // re-capture until non-empty and stable across two consecutive passes, bounded by the
        // open-timeout. Mirrors the deploy-time contract in DriverInstanceActor.
        CapturedTree? previous = null;
        while (true)
        {
            var builder = new CapturingAddressSpaceBuilder(NodeCap);
            try
            {
#pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path (see above).
                await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
                var current = builder.Build();
                if (current.Count > 0 && previous?.Count == current.Count) return current;
                previous = current;
                await Task.Delay(SettleInterval, ct).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (previous is { Count: > 0 })
            {
                _logger.LogWarning(
                    "UntilStable settle for {DriverType} hit the open-timeout before stabilising; returning the last capture ({Count} nodes)",
                    driverType, previous.Count);
                return previous; // best effort: a fresh-but-unsettled tree beats a failed open
            }
        }
    }

    private async Task BoundedShutdownAsync(IDriver driver, string driverType)
    {
        try
        {
            using var cts = new CancellationTokenSource(_shutdownTimeout);
            // WaitAsync: a token-ignoring wedged driver is ABANDONED (dropped), never awaited forever.
            await driver.ShutdownAsync(cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex,
                "Browse-capture shutdown for {DriverType} failed/timed out; instance abandoned", driverType);
        }
    }

    internal static string PatchForBrowse(string driverType, string configJson)
    {
        if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
        var config = JsonNode.Parse(string.IsNullOrWhiteSpace(configJson) ? "{}" : configJson)
            ?.AsObject() ?? new JsonObject();
        Merge(config, JsonNode.Parse(patch)!.AsObject());
        return config.ToJsonString();
    }

    private static void Merge(JsonObject target, JsonObject patch)
    {
        foreach (var (key, value) in patch)
        {
            if (value is JsonObject po && target[key] is JsonObject to) Merge(to, po);
            else target[key] = value?.DeepClone();
        }
    }
}
