using System.Globalization;
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Enums;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;

namespace SatelliteProvider.Services.TileDownloader;

public class TileService : ITileService
{
    private static readonly TimeSpan TileCacheAbsolute = TimeSpan.FromHours(1);
    private static readonly TimeSpan TileCacheSliding = TimeSpan.FromMinutes(30);
    private static readonly TimeSpan TileResponseMaxAge = TimeSpan.FromDays(1);
    private const string TileImageContentType = "image/jpeg";

    private readonly ISatelliteDownloader _downloader;
    private readonly ITileRepository _tileRepository;
    private readonly IMemoryCache _cache;
    private readonly MapConfig _mapConfig;
    private readonly ILogger<TileService> _logger;

    public TileService(
        ISatelliteDownloader downloader,
        ITileRepository tileRepository,
        IMemoryCache cache,
        IOptions<MapConfig> mapConfig,
        ILogger<TileService> logger)
    {
        _downloader = downloader;
        _tileRepository = tileRepository;
        _cache = cache;
        _mapConfig = mapConfig.Value;
        _logger = logger;
    }

    public async Task<List<TileMetadata>> DownloadAndStoreTilesAsync(
        double latitude,
        double longitude,
        double sizeMeters,
        int zoomLevel,
        CancellationToken cancellationToken = default)
    {
        var existingTiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
        var existingTilesList = existingTiles.ToList();

        var centerPoint = new GeoPoint(latitude, longitude);

        var existingTileInfos = existingTilesList
            .Select(t => new ExistingTileInfo(t.Latitude, t.Longitude, t.TileZoom))
            .ToList();

        var downloadedTiles = await _downloader.GetTilesWithMetadataAsync(
            centerPoint,
            sizeMeters / 2,
            zoomLevel,
            existingTileInfos,
            cancellationToken);

        var result = new List<TileMetadata>();

        foreach (var existingTile in existingTilesList)
        {
            result.Add(MapToMetadata(existingTile));
        }

        foreach (var downloadedTile in downloadedTiles)
        {
            var tileEntity = BuildTileEntity(downloadedTile);
            await _tileRepository.InsertAsync(tileEntity);
            result.Add(MapToMetadata(tileEntity));
        }

        return result;
    }

    public async Task<TileMetadata?> GetTileAsync(Guid id)
    {
        var tile = await _tileRepository.GetByIdAsync(id);
        return tile != null ? MapToMetadata(tile) : null;
    }

    public async Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(
        double latitude,
        double longitude,
        double sizeMeters,
        int zoomLevel)
    {
        var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
        return tiles.Select(MapToMetadata);
    }

    public async Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default)
    {
        var cacheKey = $"tile_{z}_{x}_{y}";
        var etag = $"\"{z}_{x}_{y}\"";

        if (_cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null)
        {
            return new TileBytes(cachedBytes, TileImageContentType, etag, TileResponseMaxAge);
        }

        string filePath;
        var existing = await _tileRepository.GetByTileCoordinatesAsync(z, x, y);
        if (existing != null && File.Exists(existing.FilePath))
        {
            filePath = existing.FilePath;
        }
        else
        {
            var tileCenter = GeoUtils.TileToWorldPos(x, y, z);
            var downloaded = await _downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z, cancellationToken);
            var entity = BuildTileEntity(downloaded);
            await _tileRepository.InsertAsync(entity);
            filePath = entity.FilePath;
        }

        var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
        _cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TileCacheAbsolute,
            SlidingExpiration = TileCacheSliding
        });

        return new TileBytes(bytes, TileImageContentType, etag, TileResponseMaxAge);
    }

    public async Task<TileMetadata> DownloadAndStoreSingleTileAsync(
        double latitude,
        double longitude,
        int zoomLevel,
        CancellationToken cancellationToken = default)
    {
        var downloaded = await _downloader.DownloadSingleTileAsync(latitude, longitude, zoomLevel, cancellationToken);
        var entity = BuildTileEntity(downloaded);
        await _tileRepository.InsertAsync(entity);
        return MapToMetadata(entity);
    }

    public async Task<TileInventoryResponse> GetInventoryAsync(TileInventoryRequest request, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(request);
        cancellationToken.ThrowIfCancellationRequested();

        var tiles = request.Tiles;
        var hashes = request.LocationHashes;
        // Defensive guards. The HTTP handler rejects these cases with HTTP
        // 400 before reaching the service; this preserves the same invariant
        // for any future non-HTTP caller (and keeps unit-tests grounded).
        var hasTiles = tiles is { Count: > 0 };
        var hasHashes = hashes is { Count: > 0 };
        if (hasTiles == hasHashes)
        {
            throw new ArgumentException(
                "TileInventoryRequest must populate exactly one of `Tiles` or `LocationHashes` (and not both).",
                nameof(request));
        }

        // Build the (request entry → location_hash) mapping. When the caller
        // supplied coords, compute UUIDv5 server-side; when they supplied
        // pre-computed hashes, use them verbatim. We keep both representations
        // in lockstep so the response can echo the request entry's coord
        // triple back to the caller (Tiles input branch) or zero them out
        // (LocationHashes input branch).
        var entries = new List<(int Zoom, int X, int Y, Guid Hash)>(hasTiles ? tiles!.Count : hashes!.Count);
        if (hasTiles)
        {
            foreach (var coord in tiles!)
            {
                var hash = Uuidv5.LocationHashForTile(coord.Z, coord.X, coord.Y);
                entries.Add((coord.Z, coord.X, coord.Y, hash));
            }
        }
        else
        {
            foreach (var hash in hashes!)
            {
                entries.Add((0, 0, 0, hash));
            }
        }

        var distinctHashes = entries.Select(e => e.Hash).Distinct().ToArray();
        var rows = await _tileRepository.GetTilesByLocationHashesAsync(distinctHashes);

        var results = new List<TileInventoryEntry>(entries.Count);
        foreach (var (zoom, x, y, hash) in entries)
        {
            if (rows.TryGetValue(hash, out var tile))
            {
                results.Add(new TileInventoryEntry
                {
                    Z = hasTiles ? zoom : tile.TileZoom,
                    X = hasTiles ? x : tile.TileX,
                    Y = hasTiles ? y : tile.TileY,
                    LocationHash = hash,
                    Present = true,
                    Id = tile.Id,
                    CapturedAt = tile.CapturedAt,
                    Source = tile.Source,
                    FlightId = tile.FlightId,
                    ResolutionMPerPx = tile.TileSizePixels > 0 ? tile.TileSizeMeters / tile.TileSizePixels : null
                });
            }
            else
            {
                results.Add(new TileInventoryEntry
                {
                    Z = zoom,
                    X = x,
                    Y = y,
                    LocationHash = hash,
                    Present = false
                });
            }
        }

        return new TileInventoryResponse { Results = results };
    }

    private TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded)
    {
        var now = DateTime.UtcNow;
        var source = TileSourceConverter.ToWireValue(TileSource.GoogleMaps);
        // AZ-503: deterministic UUIDv5 over (z, x, y, source, flight_id-or-zero).
        // google_maps tiles have no flight_id so the name fragment uses the
        // canonical all-zeros UUID; the same Python-side serialization in
        // gps-denied-onboard produces byte-identical IDs.
        var idName = string.Create(CultureInfo.InvariantCulture,
            $"{downloaded.ZoomLevel}/{downloaded.X}/{downloaded.Y}/{source}/{Guid.Empty}");
        var id = Uuidv5.Create(Uuidv5.TileNamespace, idName);
        var locationHash = Uuidv5.LocationHashForTile(downloaded.ZoomLevel, downloaded.X, downloaded.Y);
        // content_sha256 is computed from the actual JPEG body on disk. Google Maps
        // downloads land on disk before this method runs (FilePath is set by the
        // downloader), so a single read here is safe and avoids re-streaming. If
        // the file is missing for any reason, leave ContentSha256 null and rely on
        // the application invariant of "NOT NULL for AZ-503+ inserts" surfacing
        // the problem in tests rather than silently inserting a sentinel digest.
        byte[]? contentSha256 = null;
        if (File.Exists(downloaded.FilePath))
        {
            using var stream = File.OpenRead(downloaded.FilePath);
            contentSha256 = SHA256.HashData(stream);
        }

        return new TileEntity
        {
            Id = id,
            TileZoom = downloaded.ZoomLevel,
            TileX = downloaded.X,
            TileY = downloaded.Y,
            Latitude = downloaded.CenterLatitude,
            Longitude = downloaded.CenterLongitude,
            TileSizeMeters = downloaded.TileSizeMeters,
            TileSizePixels = _mapConfig.TileSizePixels,
            ImageType = "jpg",
            MapsVersion = null,
            Version = null,
            FilePath = downloaded.FilePath,
            Source = source,
            CapturedAt = now,
            CreatedAt = now,
            UpdatedAt = now,
            FlightId = null,
            LocationHash = locationHash,
            ContentSha256 = contentSha256,
            LegacyId = null
        };
    }

    private static TileMetadata MapToMetadata(TileEntity entity)
    {
        return new TileMetadata
        {
            Id = entity.Id,
            TileZoom = entity.TileZoom,
            TileX = entity.TileX,
            TileY = entity.TileY,
            Latitude = entity.Latitude,
            Longitude = entity.Longitude,
            TileSizeMeters = entity.TileSizeMeters,
            TileSizePixels = entity.TileSizePixels,
            ImageType = entity.ImageType,
            Version = entity.Version,
            FilePath = entity.FilePath,
            CreatedAt = entity.CreatedAt,
            UpdatedAt = entity.UpdatedAt
        };
    }
}
