# You need an image testsuite to run this, for information see:
# kivy/tools/image-testsuite/README.md

import os
import re
import sys
import unittest
from collections import defaultdict
from kivy.core.image import ImageLoader

DEBUG = False
ASSETDIR = 'image-testsuite'
LOADERS = {x.__name__: x for x in ImageLoader.loaders}

# Kivy image test protocol v0: Pixel values
v0_PIXELS = {  # NOTE: 't' is not included here, see match_prediction()
    'w': [0xFF, 0xFF, 0xFF], 'x': [0x00, 0x00, 0x00], 'r': [0xFF, 0x00, 0x00],
    'g': [0x00, 0xFF, 0x00], 'b': [0x00, 0x00, 0xFF], 'y': [0xFF, 0xFF, 0x00],
    'c': [0x00, 0xFF, 0xFF], 'p': [0xFF, 0x00, 0xFF], '0': [0x00, 0x00, 0x00],
    '1': [0x11, 0x11, 0x11], '2': [0x22, 0x22, 0x22], '3': [0x33, 0x33, 0x33],
    '4': [0x44, 0x44, 0x44], '5': [0x55, 0x55, 0x55], '6': [0x66, 0x66, 0x66],
    '7': [0x77, 0x77, 0x77], '8': [0x88, 0x88, 0x88], '9': [0x99, 0x99, 0x99],
    'A': [0xAA, 0xAA, 0xAA], 'B': [0xBB, 0xBB, 0xBB], 'C': [0xCC, 0xCC, 0xCC],
    'D': [0xDD, 0xDD, 0xDD], 'E': [0xEE, 0xEE, 0xEE], 'F': [0xFF, 0xFF, 0xFF]}

# Kivy image test protocol v0: File name
# width x height _ pattern _ alpha _ fmtinfo _ testname _ encoder . ext
v0_FILE_RE = re.compile(r'^v0_(\d+)x(\d+)_' r'([wxrgbycptA-F0-9]+)_'
                        r'([0-9a-fA-F]{2})_' r'([a-zA-Z0-9-]+)_'
                        r'([a-zA-Z0-9-]+)_' r'([a-zA-Z0-9-]+)'
                        r'\.([a-z]+)$')


def asset(*fn):
    return os.path.abspath(os.path.join(os.path.dirname(__file__), *fn))


def has_alpha(fmt):
    return fmt in ('rgba', 'bgra', 'argb', 'abgr')


def bytes_per_pixel(fmt):
    if fmt in ('rgb', 'bgr'):
        return 3
    if fmt in ('rgba', 'bgra', 'argb', 'abgr'):
        return 4
    raise Exception('bytes_per_pixel: unknown format {}'.format(fmt))


def get_pixel_alpha(pix, fmt):
    if fmt in ('rgba', 'bgra'):
        return pix[3]
    elif fmt in ('abgr', 'argb'):
        return pix[0]
    return 0xFF


# Converts (predicted) rgba pixels to the format claimed by image loader
def rgba_to(pix_in, target_fmt, w, h, pitch=None):
    if not isinstance(pix_in, (bytes, bytearray)):
        pix_in = bytearray(pix_in)
    assert w > 0 and h > 0, "Must specify w and h"
    assert len(pix_in) == w * h * 4, "Invalid rgba data {}".format(pix_in)
    assert target_fmt in ('rgba', 'bgra', 'argb', 'abgr', 'rgb', 'bgr')

    if target_fmt == 'rgba':
        return pix_in

    pixels = [pix_in[i:i + 4] for i in range(0, len(pix_in), 4)]
    if target_fmt == 'bgra':
        return b''.join([bytes(p[:3][::-1] + p[3:]) for p in pixels])
    elif target_fmt == 'abgr':
        return b''.join([bytes(p[3:] + p[:3][::-1]) for p in pixels])
    elif target_fmt == 'argb':
        return b''.join([bytes(p[3:] + p[:3]) for p in pixels])

    # rgb/bgr, default to 4 byte alignment
    if pitch is None:
        pitch = ((3 * w) + 3) & ~3
    # Assume pitch 0 == unaligned
    elif pitch == 0:
        pitch = 3 * w

    out = b''
    padding = b'\x00' * (pitch - w * 3)
    for row in [pix_in[i:i + w * 4] for i in range(0, len(pix_in), w * 4)]:
        pixelrow = [row[i:i + 4] for i in range(0, len(row), 4)]
        if target_fmt == 'rgb':
            out += b''.join([bytes(p[:3]) for p in pixelrow])
        elif target_fmt == 'bgr':
            out += b''.join([bytes(p[:3][::-1]) for p in pixelrow])
        out += padding
    return out


def match_prediction(pixels, fmt, fd, pitch):
    assert len(fd['alpha']) == 2
    assert len(fd['pattern']) > 0

    bpp = bytes_per_pixel(fmt)
    rowlen = fd['w'] * bpp
    if pitch is None:
        pitch = (rowlen + 3) & ~3
    elif pitch == 0:
        pitch = fd['w'] * bpp
    pitchalign = pitch - rowlen

    errors = []
    fail = errors.append

    if len(pixels) != pitch * fd['h']:
        fail("Pitch error: pitch {} * {} height != {} pixelbytes"
             .format(pitch, fd['h'], len(pixels)))

    ptr = 0
    pixnum = 0
    for char in fd['pattern']:
        pix = list(bytearray(pixels[ptr:ptr + bpp]))
#        print("PIXNUM {} ptr={} bpp={} : {}".format(pixnum, ptr, bpp, pix))
        if len(pix) != bpp:
            fail("Want {} bytes per pixel, got {}: {}"
                 .format(bpp, len(pix), pix))
            break

        if char == 't':
            if get_pixel_alpha(pix, fmt) != 0:
                fail("pixel {} nonzero 't' pixel alpha {:02X}: {}".format(
                      pixnum, get_pixel_alpha(pix, fmt), pix))
        else:
            srcpix = v0_PIXELS[char] + list(bytearray.fromhex(fd['alpha']))
            predict = rgba_to(srcpix, fmt, 1, 1, pitch=0)
            predict = list(bytearray(predict))
            if not predict or not pix or predict != pix:
                fail("pixel {} {} format mismatch: want {} ({}) -- got {}"
                     .format(pixnum, fmt, predict, char, pix))

        if pitchalign and (pixnum + 1) % fd['w'] == 0:
            check = list(bytearray(pixels[ptr + bpp:ptr + bpp + pitchalign]))
            if check != [0] * pitchalign:
                fail("Want {} 0x00 pitch align pixnum={}, pos={} got: {}"
                     .format(pitchalign, pixnum, ptr + bpp, check))
            ptr += pitchalign
        ptr += bpp
        pixnum += 1

    if ptr != len(pixels):
        fail("Excess data: pixnum={} ptr={} bytes={}, bpp={} pitchalign={}"
             .format(pixnum, ptr, len(pixels), bpp, pitchalign))
    return (len(errors) == 0, errors)


class _TestContext(object):
    def __init__(self, loadercls):
        self.loadercls = loadercls
        self._fd = None
        self._fn = None
        self._ok = 0
        self._skip = 0
        self._fail = 0
        self._stats = defaultdict(dict)

    @property
    def stats(self):
        return self._stats

    @property
    def results(self):
        return (self._ok, self._skip, self._fail, self._stats)

    def start(self, fn, fd):
        assert not self._fn, "unexpected ctx.start(), already started"
        assert isinstance(fd, dict)
        self._fn = fn
        self._fd = fd

    def end(self, fn=None):
        assert not fn or self._fn == fn, "unexpected ctx.end(), fn mismatch"
        self._fn = None
        self._fd = None

    def ok(self, info):
        assert self._fn, "unexpected ctx.ok(), fn=None"
        self._ok += 1
        self.dbg('PASS', info)
        self._incstat('ok')
        self.end(self._fn)

    def skip(self, info):
        assert self._fn, "unexpected ctx.skip(), fn=None"
        self._skip += 1
        self.dbg('SKIP', info)
        self._incstat('skip')
        self.end(self._fn)

    def fail(self, info):
        assert self._fn, "unexpected ctx.fail(), fn=None"
        self._fail += 1
        self.dbg('FAIL', info)
        self._incstat('fail')
        self.end(self._fn)

    def dbg(self, msgtype, info):
        assert self._fn, "unexpected ctx.dbg(), fn=None"
        if DEBUG:
            print("{} {} {}: {}"
                  .format(self.loadercls.__name__, msgtype, self._fn, info))

    def _incstat(self, s):
        assert self._fd, "unexpected ctx._incstat(), fd=None"
        fd = self._fd

        def IS(key):
            self._stats.setdefault(s, defaultdict(int))[key] += 1

        IS('total')
        IS('extension:{}'.format(fd['ext']))
        IS('encoder:{}'.format(fd['encoder']))
        IS('fmtinfo:{}'.format(fd['fmtinfo']))
        IS('testname:{}'.format(fd['testname']))
        IS('testname+ext:{}+{}'.format(fd['testname'], fd['ext']))
        IS('encoder+ext:{}+{}'.format(fd['encoder'], fd['ext']))
        IS('encoder+testname:{}+{}'.format(fd['encoder'], fd['testname']))
        IS('fmtinfo+ext:{}+{}'.format(fd['fmtinfo'], fd['ext']))


@unittest.skipIf(not os.path.isdir(asset(ASSETDIR)),
                 "Need 'make image-testsuite' to run test")
class ImageLoaderTestCase(unittest.TestCase):
    def setUp(self):
        self._context = None
        self._prepare_images()

    def tearDown(self):
        if not DEBUG or not self._context:
            return
        ctx = self._context
        il = ctx.loadercls.__name__
        stats = ctx.stats
        keys = set([k for x in stats.values() for k in x.keys()])
        sg = stats.get
        for k in sorted(keys):
            ok, skip, fail = sg('ok', {}), sg('skip', {}), sg('fail', {})
            print("REPORT {} {}: ok={}, skip={}, fail={}".format(
                  il, k, ok.get(k, 0), skip.get(k, 0), fail.get(k, 0)))

    def _prepare_images(self):
        if hasattr(self, '_image_files'):
            return
        self._image_files = {}
        for filename in os.listdir(asset(ASSETDIR)):
            matches = v0_FILE_RE.match(filename)
            if not matches:
                continue
            w, h, pat, alpha, fmtinfo, tst, encoder, ext = matches.groups()
            self._image_files[filename] = {
                'filename': filename,
                'w': int(w),
                'h': int(h),
                'pattern': pat,
                'alpha': alpha,
                'fmtinfo': fmtinfo,
                'testname': tst,
                'encoder': encoder,
                'ext': ext,
                'require_alpha': 'BINARY' in tst or 'ALPHA' in tst,
            }

    def _test_imageloader(self, loadercls, extensions=None):
        if not loadercls:
            return
        if not extensions:
            extensions = loadercls.extensions()

        ctx = _TestContext(loadercls)
        self._context = ctx
        for filename in sorted(self._image_files.keys()):
            filedata = self._image_files[filename]

            if filedata['ext'] not in extensions:
                continue
            try:
                ctx.start(filename, filedata)
                result = loadercls(asset(ASSETDIR, filename), keep_data=True)
                if not result:
                    raise Exception('invalid result')
            except:
                ctx.skip('Error loading file, result=None')
                continue
            self._test_image(filedata, ctx, loadercls, result)
            ctx.end()

        ok, skip, fail, stats = ctx.results
        if fail:
            self.fail('{}: {} passed, {} skipped, {} failed'
                      .format(loadercls.__name__, ok, skip, fail))
        return ctx

    def _test_image(self, fd, ctx, loadercls, imgdata):
        w, h, pixels, pitch = imgdata._data[0].get_mipmap(0)
        fmt = imgdata._data[0].fmt

        # required for FFPy memview
        # FIXME: bytearray() for py2 compat, I can't be bothered to research
        if not isinstance(pixels, bytes):
            pixels = bytearray(pixels)

        def debug():
            if not DEBUG:
                return
            print("    format: {}x{} {}".format(w, h, fmt))
            print("     pitch: got {}, want {}".format(pitch, want_pitch))
            print("      want: {} in {}".format(fd['pattern'], fmt))
            print("       got: {}".format(bytearray(pixels)))

        # Assume pitch 0 = unaligned
        want_pitch = (pitch == 0) and bytes_per_pixel(fmt) * w or pitch
        if pitch == 0 and bytes_per_pixel(fmt) * w * h != len(pixels):
            ctx.dbg("PITCH", "pitch=0, expected fmt={} to be "
                             "unaligned @ ({}bpp) = {} bytes, got {}"
                             .format(fmt, bytes_per_pixel(fmt),
                                     bytes_per_pixel(fmt) * w * h,
                                     len(pixels)))
        elif pitch and want_pitch != pitch:
            ctx.dbg("PITCH", "fmt={}, pitch={}, expected {}"
                             .format(fmt, pitch, want_pitch))

        success, msgs = match_prediction(pixels, fmt, fd, pitch)
        if not success:
            if not msgs:
                ctx.fail("Unknown error")
            elif len(msgs) == 1:
                ctx.fail(msgs[0])
            else:
                for m in msgs:
                    ctx.dbg('PREDICT', m)
                ctx.fail('{} errors, see debug output: {}'
                         .format(len(msgs), msgs[-1]))
            debug()
        elif fd['require_alpha'] and not has_alpha(fmt):
            ctx.fail('Missing expected alpha channel')
            debug()
        elif fd['w'] != w:
            ctx.fail('Width mismatch, want {} got {}'
                     .format(fd['w'], w))
            debug()
        elif fd['h'] != h:
            ctx.fail('Height mismatch, want {} got {}'
                     .format(fd['h'], h))
            debug()
        elif w != 1 and h != 1:
            ctx.fail('v0 test protocol mandates w=1 or h=1')
            debug()
        else:
            ctx.ok("Passed test as {}x{} {}".format(w, h, fmt))
        sys.stdout.flush()

    def test_ImageLoaderSDL3(self):
        loadercls = LOADERS.get('ImageLoaderSDL3')
        # GIF format not listed as supported in sdl3 loader
        if loadercls:
            exts = list(loadercls.extensions()) + ['gif']
            ctx = self._test_imageloader(loadercls, exts)

    def test_ImageLoaderPIL(self):
        loadercls = LOADERS.get('ImageLoaderPIL')
        ctx = self._test_imageloader(loadercls)

    def test_ImageLoaderFFPy(self):
        loadercls = LOADERS.get('ImageLoaderFFPy')
        ctx = self._test_imageloader(loadercls)

    def test_ImageLoaderGIF(self):
        loadercls = LOADERS.get('ImageLoaderGIF')
        ctx = self._test_imageloader(loadercls)

    def test_ImageLoaderDDS(self):
        loadercls = LOADERS.get('ImageLoaderDDS')
        ctx = self._test_imageloader(loadercls)

    def test_ImageLoaderTex(self):
        loadercls = LOADERS.get('ImageLoaderTex')
        ctx = self._test_imageloader(loadercls)

    def test_ImageLoaderImageIO(self):
        loadercls = LOADERS.get('ImageLoaderImageIO')
        ctx = self._test_imageloader(loadercls)

    def test_missing_tests(self):
        for loader in ImageLoader.loaders:
            key = 'test_{}'.format(loader.__name__)
            msg = "Missing ImageLoader test case: {}".format(key)
            self.assertTrue(hasattr(self, key), msg)
            self.assertTrue(callable(getattr(self, key)), msg)


class ConverterTestCase(unittest.TestCase):
    def test_internal_converter_2x1(self):
        correct = {
            'rgba': b'\x01\x02\x03\xA1\x04\x05\x06\xA2',
            'abgr': b'\xA1\x03\x02\x01\xA2\x06\x05\x04',
            'bgra': b'\x03\x02\x01\xA1\x06\x05\x04\xA2',
            'argb': b'\xA1\x01\x02\x03\xA2\x04\x05\x06',
            'rgb': b'\x01\x02\x03\x04\x05\x06',
            'bgr': b'\x03\x02\x01\x06\x05\x04',
            'rgb_align4': b'\x01\x02\x03\x04\x05\x06\x00\x00',
            'bgr_align4': b'\x03\x02\x01\x06\x05\x04\x00\x00'}
        src = correct.get
        rgba = src('rgba')
        self.assertEqual(rgba_to(rgba, 'rgba', 2, 1, 0), src('rgba'))
        self.assertEqual(rgba_to(rgba, 'abgr', 2, 1, 0), src('abgr'))
        self.assertEqual(rgba_to(rgba, 'bgra', 2, 1, 0), src('bgra'))
        self.assertEqual(rgba_to(rgba, 'argb', 2, 1, 0), src('argb'))
        self.assertEqual(rgba_to(rgba, 'rgb', 2, 1, 0), src('rgb'))
        self.assertEqual(rgba_to(rgba, 'bgr', 2, 1, 0), src('bgr'))
        self.assertEqual(rgba_to(rgba, 'rgb', 2, 1, None), src('rgb_align4'))
        self.assertEqual(rgba_to(rgba, 'bgr', 2, 1, None), src('bgr_align4'))

    def test_internal_converter_3x1(self):
        pad6 = b'\x00' * 6
        correct = {
            'rgba': b'\x01\x02\x03\xFF\x04\x05\x06\xFF\x07\x08\x09\xFF',
            'abgr': b'\xFF\x03\x02\x01\xFF\x06\x05\x04\xFF\x09\x08\x07',
            'bgra': b'\x03\x02\x01\xFF\x06\x05\x04\xFF\x09\x08\x07\xFF',
            'argb': b'\xFF\x01\x02\x03\xFF\x04\x05\x06\xFF\x07\x08\x09',
            'rgb_align2': b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x00',
            'bgr_align2': b'\x03\x02\x01\x06\x05\x04\x09\x08\x07\x00',
            'rgb_align8': b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x00' + pad6,
            'bgr_align8': b'\x03\x02\x01\x06\x05\x04\x09\x08\x07\x00' + pad6}
        src = correct.get
        rgba = src('rgba')
        self.assertEqual(rgba_to(rgba, 'bgra', 3, 1, 0), src('bgra'))
        self.assertEqual(rgba_to(rgba, 'argb', 3, 1, 0), src('argb'))
        self.assertEqual(rgba_to(rgba, 'abgr', 3, 1, 0), src('abgr'))
        self.assertEqual(rgba_to(rgba, 'rgb', 3, 1, 10), src('rgb_align2'))
        self.assertEqual(rgba_to(rgba, 'bgr', 3, 1, 10), src('bgr_align2'))
        self.assertEqual(rgba_to(rgba, 'rgb', 3, 1, 16), src('rgb_align8'))
        self.assertEqual(rgba_to(rgba, 'bgr', 3, 1, 16), src('bgr_align8'))

    def test_internal_converter_1x3(self):
        pad5 = b'\x00' * 5
        correct = {
            'rgba': b'\x01\x02\x03\xFF\x04\x05\x06\xFF\x07\x08\x09\xFF',
            'rgb_raw': b'\x01\x02\x03\x04\x05\x06\x07\x08\x09',
            'bgr_raw': b'\x03\x02\x01\x06\x05\x04\x09\x08\x07',
            'rgb_align2': b'\x01\x02\x03\x00\x04\x05\x06\x00\x07\x08\x09\x00',
            'bgr_align2': b'\x03\x02\x01\x00\x06\x05\x04\x00\x09\x08\x07\x00',
            'rgb_align4': b'\x01\x02\x03\x00\x04\x05\x06\x00\x07\x08\x09\x00',
            'bgr_align4': b'\x03\x02\x01\x00\x06\x05\x04\x00\x09\x08\x07\x00',
            'rgb_align8': (b'\x01\x02\x03' + pad5 +
                           b'\x04\x05\x06' + pad5 +
                           b'\x07\x08\x09' + pad5),
            'bgr_align8': (b'\x03\x02\x01' + pad5 +
                           b'\x06\x05\x04' + pad5 +
                           b'\x09\x08\x07' + pad5),
        }
        src = correct.get
        rgba = src('rgba')
        self.assertEqual(rgba_to(rgba, 'rgb', 1, 3, 4), src('rgb_align2'))
        self.assertEqual(rgba_to(rgba, 'bgr', 1, 3, 4), src('bgr_align2'))
        self.assertEqual(rgba_to(rgba, 'rgb', 1, 3, None), src('rgb_align4'))
        self.assertEqual(rgba_to(rgba, 'bgr', 1, 3, None), src('bgr_align4'))
        self.assertEqual(rgba_to(rgba, 'rgb', 1, 3, 0), src('rgb_raw'))
        self.assertEqual(rgba_to(rgba, 'bgr', 1, 3, 0), src('bgr_raw'))
        self.assertEqual(rgba_to(rgba, 'rgb', 1, 3, 8), src('rgb_align8'))
        self.assertEqual(rgba_to(rgba, 'bgr', 1, 3, 8), src('bgr_align8'))


if __name__ == '__main__':
    accept_filter = ['ImageLoader{}'.format(x) for x in sys.argv[1:]]
    if accept_filter:
        LOADERS = {x: LOADERS[x] for x in accept_filter}

    DEBUG = True
    unittest.main(argv=sys.argv[:1])
