using System.Collections.Frozen;

namespace Mbproxy.Bcd;

/// <summary>
/// A hit returned by <see cref="BcdTagMap.TryGetForRange"/>.
/// <see cref="OffsetWords"/> is the zero-based word offset of the tag's low register
/// within the requested read range [startAddress, startAddress+qty).
/// </summary>
public readonly record struct RangeHit(int OffsetWords, BcdTag Tag);

/// <summary>
/// Immutable, address-keyed lookup of BCD tags resolved for a single PLC.
/// All hot-path methods are allocation-free on the no-hit path.
/// </summary>
public sealed class BcdTagMap
{
    // ── Empty singleton ──────────────────────────────────────────────────────

    /// <summary>An empty map with no tags. Returned when no tags are configured.</summary>
    public static BcdTagMap Empty { get; } = new(FrozenDictionary<ushort, BcdTag>.Empty);

    // Reusable empty list for the no-hit path in TryGetForRange — zero allocation.
    private static readonly IReadOnlyList<RangeHit> s_emptyHits =
        Array.Empty<RangeHit>();

    // ── State ────────────────────────────────────────────────────────────────

    // FrozenDictionary gives O(1) lookup with minimal overhead after construction.
    private readonly FrozenDictionary<ushort, BcdTag> _map;

    internal BcdTagMap(FrozenDictionary<ushort, BcdTag> map) => _map = map;

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

    /// <summary>Number of BCD tags in this map.</summary>
    public int Count => _map.Count;

    /// <summary>All tags in the map (for telemetry / status page).</summary>
    public IEnumerable<BcdTag> All => _map.Values;

    /// <summary>
    /// O(1) point lookup by Modbus register address.
    /// Allocation-free regardless of hit or miss.
    /// </summary>
    public bool TryGet(ushort address, out BcdTag tag)
        => _map.TryGetValue(address, out tag!);

    /// <summary>
    /// Resolves the effective cache TTL for an FC03/FC04 read over the range
    /// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
    ///
    /// <para>Returns 0 (uncached) when:</para>
    /// <list type="bullet">
    ///   <item><description>The range covers no configured BCD tags (nothing to cache for, conservatively).</description></item>
    ///   <item><description>Any covered tag has <see cref="BcdTag.CacheTtlMs"/> = 0 (the
    ///         conservative-by-design "if any tag is uncached, the whole read is uncached" rule).</description></item>
    /// </list>
    ///
    /// <para>Otherwise returns the minimum non-zero TTL across all covered tags.</para>
    ///
    /// <para>Allocation-free in every path (delegates to <see cref="TryGetForRange"/> which
    /// is allocation-free on no-hit and allocates only the hit list on hit).</para>
    /// </summary>
    public int ResolveCacheTtlMs(ushort startAddress, ushort qty)
    {
        if (!TryGetForRange(startAddress, qty, out var hits) || hits.Count == 0)
            return 0;

        int min = int.MaxValue;
        foreach (var hit in hits)
        {
            int ttl = hit.Tag.CacheTtlMs;
            if (ttl <= 0) return 0;
            if (ttl < min) min = ttl;
        }
        return min == int.MaxValue ? 0 : min;
    }

    /// <summary>
    /// Returns every BCD tag whose register footprint intersects
    /// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
    ///
    /// A 16-bit tag at address A intersects when A is in [start, start+qty).
    /// A 32-bit tag at address A intersects when A or A+1 is in [start, start+qty)
    /// — i.e. when A &lt; start+qty AND A+1 &gt;= start.
    ///
    /// <see cref="RangeHit.OffsetWords"/> is the zero-based word position of the tag's
    /// low register relative to <paramref name="startAddress"/> (may be negative for a
    /// 32-bit tag whose low word starts before the range, but whose high word is in range).
    ///
    /// Hits are returned sorted ascending by <see cref="RangeHit.OffsetWords"/>.
    /// On the no-hit path this method does not allocate.
    /// </summary>
    public bool TryGetForRange(ushort startAddress, ushort qty,
        out IReadOnlyList<RangeHit> hits)
    {
        if (_map.Count == 0 || qty == 0)
        {
            hits = s_emptyHits;
            return false;
        }

        int rangeEnd = startAddress + qty;   // exclusive upper bound (int to avoid overflow)
        List<RangeHit>? result = null;

        foreach (var kvp in _map)
        {
            var tag = kvp.Value;
            int addr = tag.Address;

            bool intersects;
            if (tag.IsThirtyTwoBit)
            {
                // 32-bit tag occupies [addr, addr+2).
                // Intersects when addr < rangeEnd AND addr+2 > startAddress.
                intersects = addr < rangeEnd && (addr + 2) > startAddress;
            }
            else
            {
                // 16-bit tag occupies [addr, addr+1).
                intersects = addr >= startAddress && addr < rangeEnd;
            }

            if (intersects)
            {
                result ??= new List<RangeHit>(4);
                result.Add(new RangeHit(addr - startAddress, tag));
            }
        }

        if (result is null || result.Count == 0)
        {
            hits = s_emptyHits;
            return false;
        }

        // Sort ascending by offset so the rewrite pipeline can iterate in wire order.
        result.Sort(static (a, b) => a.OffsetWords.CompareTo(b.OffsetWords));
        hits = result;
        return true;
    }
}
