package dev.photosync.core.thumbnail;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
 * The decoder is a transcription of a bit-packed format, so these tests aim at
 * the things a transcription gets wrong: which bits a field lives in, how the
 * shared nibble stream is walked, and the colour-space conversion at the end.
 *
 * <p>Hashes are built here rather than captured from a server, because setting
 * the three AC scales to zero collapses the whole DCT to its DC term -- giving a
 * flat image whose exact colour can be worked out by hand.
 */
class ThumbHashTest {

    @Test
    @DisplayName("a hash with no AC energy decodes to its DC colour, everywhere")
    void flatImage() {
        // l_dc = 42/63, p_dc = 63 -> +1.0, q_dc = 10 -> -0.68254, l_scale = 0.
        // blue  = 0.66667 - 2/3 * 1.0            = 0.0
        // red   = (3*0.66667 - 0.0 + -0.68254)/2 = 0.65873 -> 168
        // green = red - q                        = 1.34127 -> 255 (clamped)
        byte[] hash = hash(42, 63, 10, 0, 7, 0, 0);

        ThumbImage image = ThumbHash.decode(hash).orElseThrow();

        assertEquals(32, image.width());
        assertEquals(32, image.height());
        for (int y = 0; y < image.height(); y++) {
            for (int x = 0; x < image.width(); x++) {
                int pixel = image.pixel(x, y);
                assertEquals(255, alpha(pixel), "opaque when the hash carries no alpha");
                assertNear(168, red(pixel), "red");
                assertNear(255, green(pixel), "green");
                assertNear(0, blue(pixel), "blue");
            }
        }
    }

    @Test
    @DisplayName("a landscape hash is decoded at its own aspect ratio")
    void aspectRatio() {
        // Landscape with no alpha pins lx to 7 and reads ly from the low nibble:
        // 7/3 -> 32 x round(32 / 2.3333) = 32 x 14.
        ThumbImage image = ThumbHash.decode(landscape(3)).orElseThrow();

        assertEquals(32, image.width());
        assertEquals(14, image.height());
    }

    @Test
    @DisplayName("AC coefficients actually reach the pixels")
    void acCoefficientsVaryTheImage() {
        // Same DC as the flat case but with full luminance scale, so the AC
        // nibbles -- which are 0x5A filler -- must show up as variation.
        ThumbImage image = ThumbHash.decode(hash(42, 63, 10, 31, 7, 63, 63)).orElseThrow();

        int first = image.pixel(0, 0);
        boolean varies = false;
        for (int y = 0; y < image.height() && !varies; y++) {
            for (int x = 0; x < image.width(); x++) {
                if (image.pixel(x, y) != first) {
                    varies = true;
                    break;
                }
            }
        }
        assertTrue(varies, "a non-zero l_scale must produce a non-flat image");
        assertNotEquals(0, image.argb().length);
    }

    @Test
    @DisplayName("malformed input yields no image rather than an exception")
    void rejectsGarbage() {
        assertEquals(Optional.empty(), ThumbHash.decode((String) null));
        assertEquals(Optional.empty(), ThumbHash.decode(""));
        assertEquals(Optional.empty(), ThumbHash.decode("not base64 at all!!"));
        assertEquals(Optional.empty(), ThumbHash.decode(new byte[] {1, 2, 3}));
        // Well-formed header, but the AC nibble stream is truncated.
        assertEquals(Optional.empty(), ThumbHash.decode(new byte[] {(byte) 0xEA, (byte) 0xAF, 0, 7, 0, 0x5A}));
    }

    /** Builds a portrait, alpha-less hash from the raw field values. */
    private static byte[] hash(int lDc, int pDc, int qDc, int lScale, int lCount, int pScale, int qScale) {
        int header24 = (lDc & 63) | ((pDc & 63) << 6) | ((qDc & 63) << 12) | ((lScale & 31) << 18);
        int header16 = (lCount & 7) | ((pScale & 63) << 3) | ((qScale & 63) << 9);
        byte[] hash = new byte[24];
        hash[0] = (byte) header24;
        hash[1] = (byte) (header24 >> 8);
        hash[2] = (byte) (header24 >> 16);
        hash[3] = (byte) header16;
        hash[4] = (byte) (header16 >> 8);
        for (int i = 5; i < hash.length; i++) {
            hash[i] = 0x5A;
        }
        return hash;
    }

    private static byte[] landscape(int lyCount) {
        byte[] hash = hash(42, 63, 10, 0, lyCount, 0, 0);
        hash[4] |= (byte) 0x80;
        return hash;
    }

    private static void assertNear(int expected, int actual, String channel) {
        assertTrue(Math.abs(expected - actual) <= 2,
                () -> channel + " expected around " + expected + " but was " + actual);
    }

    private static int alpha(int argb) {
        return (argb >>> 24) & 0xFF;
    }

    private static int red(int argb) {
        return (argb >>> 16) & 0xFF;
    }

    private static int green(int argb) {
        return (argb >>> 8) & 0xFF;
    }

    private static int blue(int argb) {
        return argb & 0xFF;
    }
}
