using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.Images;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using Image = SixLabors.ImageSharp.Image;

namespace ErsatzTV.Infrastructure.Images;

public class RemoteImageValidator : IRemoteImageValidator
{
    public async Task Validate(Stream stream, Uri uri, CancellationToken cancellationToken)
    {
        using Image _ = await DecodeAndValidate(stream, uri, cancellationToken);
    }

    /// <summary>
    ///     Decodes a remote image only after the header says decoding it is affordable.
    /// </summary>
    /// <remarks>
    ///     The fetcher's byte cap does NOT bound this: a decompression bomb is small on the wire and
    ///     huge in memory. A 4 KB PNG can declare 30000x30000 (~3.6 GB), and a 60 KiB GIF can
    ///     declare 2500x2500 across 600 frames (~14 GiB). The budget is therefore on the PRODUCT of
    ///     dimensions and frames, read from the header before the decoder allocates.
    ///     Local images are deliberately not checked — they are files an operator put on disk, not
    ///     bytes an arbitrary host returned. (ersatztv#511)
    /// </remarks>
    public static async Task<Image> DecodeAndValidate(Stream stream, Uri uri, CancellationToken cancellationToken)
    {
        if (!stream.CanSeek)
        {
            // Identify consumes the stream, so the decode below needs to rewind it. Fail with the
            // real reason rather than letting Position throw NotSupportedException, which the
            // caller's blanket catch would report as a generic initialization failure.
            throw new InvalidOperationException(
                $"Remote image {uri} was returned on a non-seekable stream; IRemoteImageFetcher must "
                + "return a fully buffered, seekable stream");
        }

        // MaxFrames = 1 on the IDENTIFY is not a limit, it is a workaround: a default Identify
        // throws InvalidImageContentException on most APNGs — including files ImageSharp's own
        // PngEncoder wrote, which Image.Load then reads back perfectly (measured: 13 of 16 shapes).
        // Without this, adding the header pre-pass would silently disable every animated-PNG logo
        // that worked before this change. Only Width/Height are read below, and those stay correct.
        ImageInfo info = await Image.IdentifyAsync(
            new DecoderOptions { MaxFrames = 1 },
            stream,
            cancellationToken);

        // DIMENSIONS from the header are trustworthy; the FRAME COUNT is not, and is deliberately
        // not used as a budget input. Measured on ImageSharp 3.1.12: an APNG reports
        // FrameMetadataCollection.Count == 0 while the decoder happily produces 600 frames, so a
        // header-derived frame budget is enforced on a number the decoder does not honor — a
        // 134 KiB file decodes to ~36 GiB. (Second adversarial re-review; ersatztv#511.)
        RemoteImageDecodeBudget.EnsureDimensionsAffordable(info.Width, info.Height, uri);

        int affordableFrames = RemoteImageDecodeBudget.AffordableFrames(info.Width, info.Height);

        stream.Position = 0;

        // MaxFrames is enforced BY THE DECODER, so it holds whatever the header claimed — measured
        // as honored by every animated decoder here (APNG, GIF, WebP, TIFF). Ask for two more than
        // the budget allows so that an animation exactly AT the limit still decodes in full, while
        // anything over it is present in the decoded image for the post-decode check below to
        // reject. Slop is at most two frames: MaxFrames = N yields N frames for GIF/WebP/TIFF but
        // N-1 for APNG, so the exact count varies by format and only the upper bound matters.
        var decoderOptions = new DecoderOptions { MaxFrames = (uint)(affordableFrames + 2) };

        Image image = await Image.LoadAsync(decoderOptions, stream, cancellationToken);

        try
        {
            // re-verify against REALITY rather than against the header. this is the check that
            // actually holds; everything above it only avoids decoding when we can tell in advance.
            RemoteImageDecodeBudget.EnsureDecodeAffordable(image.Width, image.Height, image.Frames.Count, uri);
            return image;
        }
        catch
        {
            image.Dispose();
            throw;
        }
    }
}
