using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.Enums;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
using SatelliteProvider.Services.RouteManagement;
using SatelliteProvider.Services.RouteManagement.TileProvision;

namespace SatelliteProvider.Tests;

public class RouteTileDeliveryOrchestratorTests
{
    [Fact]
    public async Task DeliverAsync_InvalidLatitude_Throws()
    {
        var expander = new RouteTileExpander(
            new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })),
            new GeofenceGridCalculator());
        var orchestrator = CreateOrchestrator(expander, Mock.Of<ITileRepository>(), Mock.Of<ITileService>());
        var job = new RouteTileDeliveryJob(
            Guid.NewGuid(),
            [(91.0, 37.0), (47.0, 37.0)],
            400,
            18,
            [],
            false,
            []);

        var act = () => orchestrator.DeliverAsync(job, new RecordingSink(), CancellationToken.None);

        await act.Should().ThrowAsync<ArgumentException>()
            .WithMessage("*lat must be between -90 and 90*");
    }

    [Fact]
    public async Task DeliverAsync_TooManyWaypoints_Throws()
    {
        var expander = new RouteTileExpander(
            new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })),
            new GeofenceGridCalculator());
        var orchestrator = CreateOrchestrator(expander, Mock.Of<ITileRepository>(), Mock.Of<ITileService>());
        var waypoints = Enumerable.Range(0, 501).Select(i => (47.0 + i * 0.0001, 37.0)).ToList();
        var job = new RouteTileDeliveryJob(
            Guid.NewGuid(),
            waypoints,
            400,
            18,
            [],
            false,
            []);

        var act = () => orchestrator.DeliverAsync(job, new RecordingSink(), CancellationToken.None);

        await act.Should().ThrowAsync<ArgumentException>()
            .WithMessage("*at most 500*");
    }

    [Fact]
    public async Task DeliverAsync_AllTilesSkippedByClient_EmitsManifestAndCompleteWithZeroDelivered()
    {
        var expander = new RouteTileExpander(
            new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })),
            new GeofenceGridCalculator());

        var tileRepo = new Mock<ITileRepository>();
        tileRepo
            .Setup(r => r.GetTilesByLocationHashesAsync(It.IsAny<IReadOnlyList<Guid>>()))
            .ReturnsAsync(new Dictionary<Guid, TileEntity>());

        var tileService = new Mock<ITileService>();
        var orchestrator = new RouteTileDeliveryOrchestrator(
            expander,
            tileRepo.Object,
            tileService.Object,
            Options.Create(new MapConfig { TileSizePixels = 256, AllowedZoomLevels = [18] }),
            Options.Create(new ProcessingConfig { MaxConcurrentDownloads = 2 }),
            Options.Create(new TileProvisionConfig { MaxTilesPerBatch = 100 }),
            NullLogger<RouteTileDeliveryOrchestrator>.Instance);

        var waypoints = new List<(double Lat, double Lon)> { (47.0, 37.0), (47.001, 37.001) };
        var candidates = expander.Expand(waypoints, 400, 18, [], false);
        candidates.Should().NotBeEmpty();

        var clientTiles = candidates.Select(c =>
        {
            var center = GeoUtils.TileToWorldPos(c.X, c.Y, c.Z);
            var resolution = TileResolutionHelper.ResolutionMetersPerPixel(c.Z, center.Lat, 256);
            return new ClientTileSnapshot(c.Z, c.X, c.Y, resolution, DateTime.UtcNow.AddHours(1), null);
        }).ToList();

        var sink = new RecordingSink();
        var job = new RouteTileDeliveryJob(
            Guid.NewGuid(),
            waypoints,
            400,
            18,
            [],
            false,
            clientTiles);

        await orchestrator.DeliverAsync(job, sink, CancellationToken.None);

        sink.Manifest.Should().NotBeNull();
        sink.Manifest!.Value.Total.Should().Be((uint)candidates.Count);
        sink.Manifest.Value.Skipped.Should().Be((uint)candidates.Count);
        sink.Manifest.Value.ToDeliver.Should().Be(0);
        sink.Complete.Should().NotBeNull();
        sink.Complete!.Value.Delivered.Should().Be(0);
        sink.Complete.Value.SkippedClient.Should().Be((uint)candidates.Count);
        sink.Batches.Should().BeEmpty();
        tileService.Verify(
            s => s.DownloadAndStoreSingleTileAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(), It.IsAny<CancellationToken>()),
            Times.Never);
    }

    [Fact]
    public async Task DeliverAsync_CachedTileOnDisk_EmitsBatchWithoutDownload()
    {
        var tilesDir = Path.Combine(Path.GetTempPath(), "sp-grpc-test-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(tilesDir);
        try
        {
            var expander = new RouteTileExpander(
                new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })),
                new GeofenceGridCalculator());

            var waypoints = new List<(double Lat, double Lon)> { (47.0, 37.0), (47.001, 37.001) };
            var candidates = expander.Expand(waypoints, 400, 18, [], false);
            var candidate = candidates[0];

            var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
            var tilePath = Path.Combine(tilesDir, "tile.jpg");
            await File.WriteAllBytesAsync(tilePath, jpeg);
            var hash = System.Security.Cryptography.SHA256.HashData(jpeg);
            var center = GeoUtils.TileToWorldPos(candidate.X, candidate.Y, candidate.Z);
            var locationHash = Uuidv5.LocationHashForTile(candidate.Z, candidate.X, candidate.Y);

            var entity = new TileEntity
            {
                Id = Guid.NewGuid(),
                TileZoom = candidate.Z,
                TileX = candidate.X,
                TileY = candidate.Y,
                Latitude = center.Lat,
                Longitude = center.Lon,
                TileSizeMeters = 100,
                TileSizePixels = 256,
                ImageType = "jpg",
                FilePath = tilePath,
                Source = TileSourceConverter.GoogleMapsWireValue,
                CapturedAt = DateTime.UtcNow,
                CreatedAt = DateTime.UtcNow,
                UpdatedAt = DateTime.UtcNow,
                LocationHash = locationHash,
                ContentSha256 = hash,
            };

            var tileRepo = new Mock<ITileRepository>();
            tileRepo
                .Setup(r => r.GetTilesByLocationHashesAsync(It.IsAny<IReadOnlyList<Guid>>()))
                .ReturnsAsync((IReadOnlyList<Guid> hashes) =>
                {
                    var dict = new Dictionary<Guid, TileEntity>();
                    if (hashes.Contains(locationHash))
                    {
                        dict[locationHash] = entity;
                    }

                    return dict;
                });

            var tileService = new Mock<ITileService>();
            var orchestrator = new RouteTileDeliveryOrchestrator(
                expander,
                tileRepo.Object,
                tileService.Object,
                Options.Create(new MapConfig { TileSizePixels = 256, AllowedZoomLevels = [18] }),
                Options.Create(new ProcessingConfig { MaxConcurrentDownloads = 2 }),
                Options.Create(new TileProvisionConfig { MaxTilesPerBatch = 100 }),
                NullLogger<RouteTileDeliveryOrchestrator>.Instance);

            var sink = new RecordingSink();
            var job = new RouteTileDeliveryJob(Guid.NewGuid(), waypoints, 400, 18, [], false, []);

            await orchestrator.DeliverAsync(job, sink, CancellationToken.None);

            sink.Manifest.Should().NotBeNull();
            sink.Manifest!.Value.ToDeliver.Should().BeGreaterThan(0);
            sink.Batches.Should().NotBeEmpty();
            sink.Batches.SelectMany(b => b).Should().Contain(t => t.Candidate.X == candidate.X && t.Candidate.Y == candidate.Y);
            sink.Complete.Should().NotBeNull();
            sink.Complete!.Value.Delivered.Should().BeGreaterThan(0u);
        }
        finally
        {
            if (Directory.Exists(tilesDir))
            {
                Directory.Delete(tilesDir, recursive: true);
            }
        }
    }

    private static RouteTileDeliveryOrchestrator CreateOrchestrator(
        RouteTileExpander expander,
        ITileRepository tileRepo,
        ITileService tileService)
    {
        return new RouteTileDeliveryOrchestrator(
            expander,
            tileRepo,
            tileService,
            Options.Create(new MapConfig { TileSizePixels = 256, AllowedZoomLevels = [18] }),
            Options.Create(new ProcessingConfig { MaxConcurrentDownloads = 2 }),
            Options.Create(new TileProvisionConfig { MaxTilesPerBatch = 100 }),
            NullLogger<RouteTileDeliveryOrchestrator>.Instance);
    }

    private sealed class RecordingSink : IRouteTileDeliverySink
    {
        public (uint Total, uint Skipped, uint ToDeliver)? Manifest { get; private set; }
        public List<IReadOnlyList<PreparedTileDelivery>> Batches { get; } = new();
        public (uint Delivered, uint SkippedClient, uint SkippedServer)? Complete { get; private set; }

        public ValueTask WriteManifestAsync(uint totalCandidates, uint skippedByClient, uint toDeliver, CancellationToken cancellationToken)
        {
            Manifest = (totalCandidates, skippedByClient, toDeliver);
            return ValueTask.CompletedTask;
        }

        public ValueTask WriteBatchAsync(uint batchSeq, IReadOnlyList<PreparedTileDelivery> tiles, CancellationToken cancellationToken)
        {
            Batches.Add(tiles);
            return ValueTask.CompletedTask;
        }

        public ValueTask WriteProgressAsync(uint delivered, uint total, uint downloading, CancellationToken cancellationToken)
            => ValueTask.CompletedTask;

        public ValueTask WriteCompleteAsync(uint delivered, uint skippedClient, uint skippedServerFilter, CancellationToken cancellationToken)
        {
            Complete = (delivered, skippedClient, skippedServerFilter);
            return ValueTask.CompletedTask;
        }

        public ValueTask WriteErrorAsync(string code, string message, bool retryable, CancellationToken cancellationToken)
            => ValueTask.CompletedTask;
    }
}
