using Theriapolis.Core.World;

namespace Theriapolis.Core.Tactical;

/// <summary>
/// In-memory cache of generated tactical chunks. Generation happens lazily on
/// first access; eviction happens whenever the active set is recomputed by
/// <see cref="EnsureLoadedAround"/>.
///
/// The cache is bounded by <see cref="C.CHUNK_CACHE_SOFT_MAX"/>: anything above
/// that count and outside the active window gets dropped (delta flushed first).
///
/// Single-threaded; pre-warming uses background tasks but the streamer itself
/// is mutated only from the caller thread.
/// </summary>
public sealed class ChunkStreamer
{
    private readonly ulong _worldSeed;
    private readonly WorldState _world;
    private readonly IChunkDeltaStore _deltas;
    private readonly Dictionary<ChunkCoord, TacticalChunk> _cache = new();
    private readonly Dictionary<ChunkCoord, Task<TacticalChunk>> _inflight = new();
    private readonly Theriapolis.Core.Data.SettlementContent? _settlementContent;

    public IReadOnlyDictionary<ChunkCoord, TacticalChunk> Loaded => _cache;

    /// <summary>
    /// Phase 5 M5: fires after a chunk is freshly inserted into the cache.
    /// Subscribers (typically PlayScreen) use this to spawn NPCs from
    /// <see cref="TacticalChunk.Spawns"/>. Does not fire for chunks already
    /// in cache.
    /// </summary>
    public event System.Action<TacticalChunk>? OnChunkLoaded;

    /// <summary>
    /// Phase 5 M5: fires immediately before a chunk is evicted from cache.
    /// Subscribers despawn NPCs sourced from this chunk so the active actor
    /// list stays bounded.
    /// </summary>
    public event System.Action<TacticalChunk>? OnChunkEvicting;

    public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas)
        : this(worldSeed, world, deltas, settlementContent: null) { }

    /// <summary>
    /// Phase 6 M0 — pass <paramref name="settlementContent"/> to enable
    /// templated settlement stamping. <c>null</c> falls back to the
    /// Phase-4 plaza+wall-ring placeholder.
    /// </summary>
    public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas,
                         Theriapolis.Core.Data.SettlementContent? settlementContent)
    {
        _worldSeed = worldSeed;
        _world     = world;
        _deltas    = deltas;
        _settlementContent = settlementContent;
    }

    /// <summary>
    /// Returns the chunk for the given coord, generating + applying deltas if
    /// necessary. Synchronous: blocks the caller for ~1 ms in the worst case
    /// (chunk gen at 64×64 is well under our budget).
    /// </summary>
    public TacticalChunk Get(ChunkCoord cc)
    {
        if (_cache.TryGetValue(cc, out var cached)) return cached;

        // If a pre-warm Task is in flight for this coord, drain it.
        if (_inflight.TryGetValue(cc, out var task))
        {
            var fromTask = task.GetAwaiter().GetResult();
            _inflight.Remove(cc);
            _cache[cc] = fromTask;
            ApplyDelta(fromTask);
            OnChunkLoaded?.Invoke(fromTask);
            return fromTask;
        }

        var chunk = TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent);
        ApplyDelta(chunk);
        _cache[cc] = chunk;
        OnChunkLoaded?.Invoke(chunk);
        return chunk;
    }

    /// <summary>
    /// Returns the tactical tile at the given world-pixel coordinate, generating
    /// the containing chunk on demand. Out-of-world coords return a default
    /// "ocean" tile so callers don't need bounds checks.
    /// </summary>
    public TacticalTile SampleTile(int tx, int ty)
    {
        // Tactical tiles outside the world map are deep water — keeps the
        // player from walking off the edge.
        int worldPxW = C.WORLD_WIDTH_TILES  * C.TACTICAL_PER_WORLD_TILE;
        int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
        if ((uint)tx >= worldPxW || (uint)ty >= worldPxH)
            return new TacticalTile { Surface = TacticalSurface.DeepWater };

        var cc = ChunkCoord.ForTactical(tx, ty);
        var chunk = Get(cc);
        int lx = tx - chunk.OriginX;
        int ly = ty - chunk.OriginY;
        return chunk.Tiles[lx, ly];
    }

    /// <summary>
    /// Make sure every chunk overlapping the given world-pixel position +/- the
    /// given radius (in world tiles) is loaded; evict everything else above the
    /// soft cap. Pre-warms neighbours on the threadpool to hide latency on
    /// world-tile crossings.
    /// </summary>
    public void EnsureLoadedAround(Util.Vec2 worldPixelPos, int worldTileRadius)
    {
        int playerWX = (int)MathF.Floor(worldPixelPos.X / C.WORLD_TILE_PIXELS);
        int playerWY = (int)MathF.Floor(worldPixelPos.Y / C.WORLD_TILE_PIXELS);

        // Compute the active set of chunks: every chunk overlapping the
        // (2*radius+1)² world-tile window around the player.
        var active = new HashSet<ChunkCoord>();
        int worldTilesPerChunk = C.TACTICAL_CHUNK_SIZE / C.TACTICAL_PER_WORLD_TILE;
        int chunkRadius = worldTileRadius / worldTilesPerChunk + 1;
        var centre = ChunkCoord.ForWorldTile(playerWX, playerWY);
        for (int cy = centre.Y - chunkRadius; cy <= centre.Y + chunkRadius; cy++)
        for (int cx = centre.X - chunkRadius; cx <= centre.X + chunkRadius; cx++)
        {
            active.Add(new ChunkCoord(cx, cy));
        }

        // Synchronously make every active chunk live in the cache. Get()
        // handles all three paths: hit cache, drain a pre-warmed inflight
        // task, or generate fresh. The previous version skipped chunks in
        // _inflight, which left them stuck there indefinitely once they
        // entered the active set on a later frame — visible to renderers
        // that subscribe to OnChunkLoaded (e.g. the Godot port) as
        // permanently-uncached gaps. The MonoGame TacticalRenderer dodged
        // this by calling Get() inside its draw loop; M4 of the port made
        // that drain unnecessary by fixing it here.
        foreach (var cc in active)
        {
            if (!_cache.ContainsKey(cc))
                _ = Get(cc); // hits cache, drains inflight, or generates
        }

        for (int cy = centre.Y - chunkRadius - 1; cy <= centre.Y + chunkRadius + 1; cy++)
        for (int cx = centre.X - chunkRadius - 1; cx <= centre.X + chunkRadius + 1; cx++)
        {
            var cc = new ChunkCoord(cx, cy);
            if (active.Contains(cc)) continue;
            if (_cache.ContainsKey(cc) || _inflight.ContainsKey(cc)) continue;
            // Kick off a background generation; the result will be drained by
            // a future Get() call if it's needed.
            var t = Task.Run(() => TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent));
            _inflight[cc] = t;
        }

        EvictExcept(active);
    }

    private void EvictExcept(HashSet<ChunkCoord> keep)
    {
        // Evict cached chunks that aren't in the active set, oldest-first
        // (HashMap iteration order isn't stable; we just take whatever's first).
        if (_cache.Count <= C.CHUNK_CACHE_SOFT_MAX) return;

        var toEvict = new List<ChunkCoord>();
        foreach (var key in _cache.Keys)
            if (!keep.Contains(key)) toEvict.Add(key);

        foreach (var key in toEvict)
        {
            if (_cache.Count <= C.CHUNK_CACHE_SOFT_MAX) break;
            FlushAndDrop(key);
        }
    }

    /// <summary>
    /// Force-flush every loaded chunk's delta back to the store and clear the
    /// cache. Called at save time so nothing in-memory is lost.
    /// </summary>
    public void FlushAll()
    {
        foreach (var kv in _cache.ToArray())
            FlushAndDrop(kv.Key);
        _cache.Clear();
    }

    private void FlushAndDrop(ChunkCoord cc)
    {
        if (!_cache.TryGetValue(cc, out var chunk)) return;
        OnChunkEvicting?.Invoke(chunk);
        if (chunk.HasDelta)
        {
            // Compute the delta vs the deterministic baseline. Cheap because
            // the baseline is itself recomputable.
            var baseline = TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent);
            _deltas.Put(cc, ComputeDelta(baseline, chunk));
        }
        _cache.Remove(cc);
    }

    private void ApplyDelta(TacticalChunk fresh)
    {
        var d = _deltas.Get(fresh.Coord);
        if (d is null) return;
        foreach (var m in d.TileMods)
        {
            ref var t = ref fresh.Tiles[m.LocalX, m.LocalY];
            t.Surface = m.Surface;
            t.Deco    = m.Deco;
            t.Flags   = m.Flags;
        }
        if (d.SpawnsConsumed) fresh.Spawns.Clear();
        fresh.HasDelta = true;
    }

    private static ChunkDelta ComputeDelta(TacticalChunk baseline, TacticalChunk current)
    {
        var d = new ChunkDelta();
        for (int y = 0; y < C.TACTICAL_CHUNK_SIZE; y++)
        for (int x = 0; x < C.TACTICAL_CHUNK_SIZE; x++)
        {
            var b = baseline.Tiles[x, y];
            var c = current.Tiles[x, y];
            if (b.Surface != c.Surface || b.Deco != c.Deco || b.Flags != c.Flags || b.Variant != c.Variant)
                d.TileMods.Add(new TileMod(x, y, c.Surface, c.Deco, c.Flags));
        }
        if (current.Spawns.Count == 0 && baseline.Spawns.Count > 0)
            d.SpawnsConsumed = true;
        return d;
    }

    /// <summary>Mark a tile as user-modified so the streamer flushes it on eviction.</summary>
    public void RecordModification(int tx, int ty)
    {
        var cc = ChunkCoord.ForTactical(tx, ty);
        if (_cache.TryGetValue(cc, out var ch))
        {
            ch.HasDelta = true;
            ch.Dirty    = true;
        }
    }
}
