using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;

/// <summary>
///     Lists the configured tag + virtual-tag path strings a virtual-tag script author can pass to
///     <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>. A later Monaco task uses this to
///     autocomplete those string literals.
/// </summary>
public interface IScriptTagCatalog
{
    /// <summary>Distinct configured tag + virtual-tag paths (the strings a script passes to
    /// ctx.GetTag/SetVirtualTag), optionally filtered by a literal prefix.</summary>
    /// <param name="filter">Case-insensitive StartsWith prefix; <c>null</c>/empty returns all (bounded).</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>The matching configured tag + virtual-tag paths.</returns>
    Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct);

    /// <summary>Exact (case-sensitive, Ordinal) lookup of a resolvable tag path; null when not a known configured path.</summary>
    /// <param name="path">The exact tag/virtual-tag path to look up.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>The resolved tag info, or <see langword="null"/> when <paramref name="path"/> is not a known configured path.</returns>
    Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);

    /// <summary>v3: the owning equipment's <c>UnsTagReference</c> effective names (its
    /// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the resolvable set for
    /// <c>{{equip}}/&lt;RefName&gt;</c> completion + diagnostics, matching what the compose seams substitute
    /// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has
    /// no references.</summary>
    /// <param name="equipmentId">The equipment whose references to list; blank returns empty.</param>
    /// <param name="filter">Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded).</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>Distinct reference effective names.</returns>
    Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct);
}

/// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary>
public sealed record ScriptTagInfo(string Path, string Kind, string DataType, string? DriverInstanceId);

/// <summary>
///     Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
///     <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — in v3 that is a raw tag's <b>RawPath</b>, plus the
///     leaf <c>Name</c> (best-effort) for virtual tags.
/// </summary>
/// <remarks>
///     <para>
///         <b>Fidelity over breadth.</b> This projects exactly what the live runtime resolves, and nothing
///         else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
///         (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
///         <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
///         <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
///         — and in v3 a driver's wire reference <b>is the RawPath</b>
///         (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
///     </para>
///     <para>
///         <b>This replaced a stale v2 projection (Gitea #490).</b> The catalog used to emit
///         <c>Tag.TagConfig.FullName</c> — the pre-v3 wire address — which since v3 is <i>not</i> the mux
///         key. The consequence was worse than the missing feature the issue reported: completion offered
///         paths that could never resolve at runtime, while a <b>correct</b> absolute RawPath got no
///         completion and hovered as "not a known configured tag path". Both directions are now right.
///     </para>
///     <para>
///         RawPaths are built through the shared <see cref="RawPathResolver"/> — the same byte-parity
///         authority <c>DraftValidator</c> and <c>AddressSpaceComposer</c> use — so a suggested path is
///         identical to the one the deploy gate and the runtime compute. A tag whose ancestry chain is
///         broken (unknown device / driver, invalid segment) resolves to <see langword="null"/> and is
///         omitted: the runtime could not route it either, so offering it would be a lie.
///     </para>
///     <para>
///         The <c>{{equip}}/&lt;RefName&gt;</c> form is served separately by
///         <see cref="GetEquipmentReferenceNamesAsync"/>; the compose seams substitute those references to
///         the same RawPaths listed here.
///     </para>
///     <para>
///         The catalog is <b>fleet-wide</b> (no cluster filter), matching the runtime's key space: the
///         deployed artifact is fleet-wide and the mux is keyed by RawPath alone. Two clusters using the
///         same RawPath collapse to one suggestion, which is correct — the string is what resolves.
///     </para>
///     <para>
///         Each call creates and disposes its own context via the pooled factory — the same pattern
///         <c>UnsTreeService</c> uses — so the service is safe to register Scoped per Blazor circuit.
///     </para>
/// </remarks>
public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IScriptTagCatalog
{
    /// <summary>Upper bound on returned suggestions — keeps the completion list responsive on large fleets.</summary>
    private const int MaxResults = 200;

    /// <inheritdoc />
    public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
    {
        var entries = await BuildEntriesAsync(ct);

        var paths = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);

        IEnumerable<string> query = paths;
        if (!string.IsNullOrWhiteSpace(filter))
        {
            query = query.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
        }

        return query
            .OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
            .Take(MaxResults)
            .ToList();
    }

    /// <inheritdoc />
    public async Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
    {
        var entries = await BuildEntriesAsync(ct);

        // Exact, case-SENSITIVE (Ordinal) match: the live runtime resolves a ctx.GetTag("X") literal
        // via DependencyMuxActor keyed with StringComparer.Ordinal, so a case-mismatched path would
        // not resolve at runtime — hover must report it as "not a known configured tag path".
        return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal));
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
    {
        if (string.IsNullOrEmpty(equipmentId)) return Array.Empty<string>();
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var refs = await db.UnsTagReferences.AsNoTracking()
            .Where(r => r.EquipmentId == equipmentId)
            .Select(r => new { r.TagId, r.DisplayNameOverride })
            .ToListAsync(ct);
        if (refs.Count == 0) return Array.Empty<string>();

        var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
        var tagNameById = await db.Tags.AsNoTracking()
            .Where(t => tagIds.Contains(t.TagId))
            .Select(t => new { t.TagId, t.Name })
            .ToDictionaryAsync(t => t.TagId, t => t.Name, ct);

        // Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set).
        var names = new HashSet<string>(StringComparer.Ordinal);
        foreach (var r in refs)
        {
            var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
            if (!string.IsNullOrEmpty(eff)) names.Add(eff!);
        }

        IEnumerable<string> q = names;
        if (!string.IsNullOrWhiteSpace(filter))
            q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
        return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList();
    }

    /// <summary>
    ///     Loads the raw topology + Tags + VirtualTags (untracked) and projects one
    ///     <see cref="ScriptTagInfo"/> per resolvable key — the SINGLE source of the projection shared by
    ///     <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and <see cref="GetTagInfoAsync"/>
    ///     (exact Ordinal lookup).
    /// </summary>
    private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        // The raw topology, fleet-wide, in the four ancestry maps RawPathResolver takes. Building the
        // resolver here (rather than reimplementing the join) is what keeps a suggested path
        // byte-identical to the one the deploy gate and the runtime compute.
        var folders = (await db.RawFolders.AsNoTracking()
                .Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
                .ToListAsync(ct))
            .ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);

        var drivers = (await db.DriverInstances.AsNoTracking()
                .Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
                .ToListAsync(ct))
            .ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);

        var devices = (await db.Devices.AsNoTracking()
                .Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
                .ToListAsync(ct))
            .ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);

        var groups = (await db.TagGroups.AsNoTracking()
                .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
                .ToListAsync(ct))
            .ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);

        var resolver = new RawPathResolver(folders, drivers, devices, groups);

        var tagRows = await db.Tags
            .AsNoTracking()
            .Select(t => new { t.DeviceId, t.TagGroupId, t.Name, t.DataType })
            .ToListAsync(ct);

        var vtagRows = await db.VirtualTags
            .AsNoTracking()
            .Select(v => new { v.Name, v.DataType })
            .ToListAsync(ct);

        var entries = new List<ScriptTagInfo>(tagRows.Count + vtagRows.Count);

        foreach (var t in tagRows)
        {
            // v3: the runtime GetTag key IS the RawPath. A null result means a broken/unknown ancestry
            // chain — the runtime could not route that tag either, so it is omitted rather than guessed.
            var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
            if (path is null) continue;

            var driverInstanceId = devices.TryGetValue(t.DeviceId, out var dev) ? dev.DriverInstanceId : null;
            entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, driverInstanceId));
        }

        foreach (var v in vtagRows)
        {
            // Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is
            // unconfirmed, so emit the leaf Name only.
            entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null));
        }

        return entries;
    }
}
