from collections import defaultdict
from contextlib import contextmanager
import ast
import functools
import inspect
import os

import pytest

from plover.registry import registry
from plover.resource import ASSET_SCHEME
from plover.steno import normalize_steno
from plover.steno_dictionary import StenoDictionary

from .dict import make_dict
from .parametrize import parametrize


class _DictionaryTests:

    @staticmethod
    def make_dict(contents):
        return contents

    @contextmanager
    def tmp_dict(self, tmp_path, contents):
        contents = self.make_dict(contents)
        with make_dict(tmp_path, contents, extension=self.DICT_EXTENSION) as dict_path:
            yield dict_path

    @contextmanager
    def sample_dict(self, tmp_path):
        with self.tmp_dict(tmp_path, self.DICT_SAMPLE) as dict_path:
            yield dict_path

    @staticmethod
    def parse_entries(entries):
        return {
            normalize_steno(k): v
            for k, v in ast.literal_eval('{' + entries + '}').items()
        }

    def test_readonly_writable_file(self, tmp_path):
        '''
        Writable file: match class read-only attribute.
        '''
        with self.sample_dict(tmp_path) as dict_path:
            d = self.DICT_CLASS.load(str(dict_path))
            assert d.readonly == self.DICT_CLASS.readonly

    def test_readonly_readonly_file(self, tmp_path):
        '''
        Read-only file: read-only dictionary.
        '''
        with self.sample_dict(tmp_path) as dict_path:
            dict_path.chmod(0o440)
            try:
                d = self.DICT_CLASS.load(str(dict_path))
                assert d.readonly
            finally:
                # Deleting the file will fail on Windows
                # if we don't restore write permission.
                dict_path.chmod(0o660)

    def test_readonly_asset(self, tmp_path, monkeypatch):
        '''
        Assets are always read-only.
        '''
        with self.sample_dict(tmp_path) as dict_path:
            fake_asset = ASSET_SCHEME + 'fake:' + dict_path.name
            def fake_asset_only(r, v=None):
                assert r.startswith(ASSET_SCHEME + 'fake:')
                return v
            monkeypatch.setattr('plover.resource._asset_filename',
                                functools.partial(fake_asset_only,
                                                  v=str(dict_path)))
            d = self.DICT_CLASS.load(fake_asset)
        assert d.readonly

    VALID_KEY = ('TEFT', '-G')

    @pytest.mark.parametrize('method_name, args', (
        ('__delitem__', (VALID_KEY,)),
        ('__setitem__', (VALID_KEY, 'pouet!')),
        ('clear'      , ()),
        ('save'       , ()),
        ('update'     , ()),
    ))
    def test_readonly_no_change_allowed(self, tmp_path, method_name, args):
        '''
        Don't allow changing a read-only dictionary.
        '''
        with self.sample_dict(tmp_path) as dict_path:
            d = self.DICT_CLASS.load(str(dict_path))
        d.readonly = True
        method = getattr(d, method_name)
        with pytest.raises(AssertionError):
            method(*args)

    def _test_entrypoint(self):
        '''
        Check a corresponding `plover.dictionary` entrypoint exists.
        '''
        plugin = registry.get_plugin('dictionary', self.DICT_EXTENSION)
        assert plugin.obj == self.DICT_CLASS

    DUMMY = object()
    MISSING_KEY = "ceci n'est pas une clef"
    MISSING_TRANSLATION = "ceci n'est pas une translation"

    def _test_load(self, tmp_path, contents, expected):
        '''
        Test `load` implementation.
        '''
        with self.tmp_dict(tmp_path, contents) as dict_path:
            expected_timestamp = dict_path.stat().st_mtime
            if inspect.isclass(expected):
                # Expect an exception.
                with pytest.raises(expected):
                    self.DICT_CLASS.load(str(dict_path))
                return
            # Except a successful load.
            d = self.DICT_CLASS.load(str(dict_path))
        # Parse entries:
        entries = self.parse_entries(expected)
        # - expected: must be present
        expected_entries = {k: v for k, v in entries.items()
                            if v is not None}
        # - unexpected: must not be present
        unexpected_entries = {k for k, v in entries.items()
                              if v is None}
        # Basic checks.
        assert d.readonly == self.DICT_CLASS.readonly
        assert d.timestamp == expected_timestamp
        if self.DICT_SUPPORT_SEQUENCE_METHODS:
            assert sorted(d.items()) == sorted(expected_entries.items())
            assert sorted(d) == sorted(expected_entries)
            assert len(d) == len(expected_entries)
        else:
            assert tuple(d.items()) == ()
            assert tuple(d) == ()
            assert len(d) == 0
        for k, v in expected_entries.items():
            assert k in d
            assert d[k] == v
            assert d.get(k) == v
            assert d.get(k, self.DUMMY) == v
        for k in unexpected_entries:
            assert k not in d
            with pytest.raises(KeyError):
                d[k]
            assert d.get(k) == None
            assert d.get(k, self.DUMMY) == self.DUMMY
        assert self.MISSING_KEY not in d
        assert d.get(self.MISSING_KEY, self.DUMMY) is self.DUMMY
        assert d.get(self.MISSING_KEY) == None
        with pytest.raises(KeyError):
            d[self.MISSING_KEY]
        # Longest key check.
        expected_longest_key = functools.reduce(max, (len(k) for k in expected_entries), 0)
        assert d.longest_key == expected_longest_key
        # Reverse lookup checks.
        expected_reverse = defaultdict(set)
        if self.DICT_SUPPORT_REVERSE_LOOKUP:
            for k, v in expected_entries.items():
                expected_reverse[v].add(k)
        for v, key_set in expected_reverse.items():
            assert d.reverse_lookup(v) == key_set
        assert d.reverse_lookup(self.MISSING_TRANSLATION) == set()
        # Case reverse lookup checks.
        expected_casereverse = defaultdict(set)
        if self.DICT_SUPPORT_CASEREVERSE_LOOKUP:
            for v in expected_entries.values():
                expected_casereverse[v.lower()].add(v)
        for v, value_set in expected_casereverse.items():
            assert d.casereverse_lookup(v) == value_set
        assert d.casereverse_lookup(self.MISSING_TRANSLATION) == set()


class _ReadOnlyDictionaryTests:

    def test_readonly_no_create_allowed(self, tmp_path):
        '''
        Don't allow creating a read-only dictionary.
        '''
        with self.sample_dict(tmp_path) as dict_path:
            with pytest.raises(ValueError):
                self.DICT_CLASS.create(str(dict_path))


_TEST_DICTIONARY_UPDATE_DICT = {
    ('S-G',): 'something',
    ('SPH-G',): 'something',
    ('SPH*G',): 'Something',
    ('SPH', 'THEUPBG'): 'something',
}
_TEST_DICTIONARY_UPDATE_STENODICT = StenoDictionary()
_TEST_DICTIONARY_UPDATE_STENODICT.update(_TEST_DICTIONARY_UPDATE_DICT)

class _WritableDictionaryTests:

    def test_longest_key(self):
        '''
        Check `longest_key` support.
        '''
        assert self.DICT_SUPPORT_SEQUENCE_METHODS
        d = self.DICT_CLASS()
        assert d.longest_key == 0
        d[('S',)] = 'a'
        assert d.longest_key == 1
        d[('S', 'S', 'S', 'S')] = 'b'
        assert d.longest_key == 4
        d[('S', 'S')] = 'c'
        assert d.longest_key == 4
        assert d[('S', 'S')] == 'c'
        del d[('S', 'S', 'S', 'S')]
        assert d.longest_key == 2
        del d[('S',)]
        assert d.longest_key == 2
        if self.DICT_SUPPORT_REVERSE_LOOKUP:
            assert d.reverse_lookup('c') == {('S', 'S')}
        else:
            assert d.reverse_lookup('c') == set()
        if self.DICT_SUPPORT_CASEREVERSE_LOOKUP:
            assert d.casereverse_lookup('c') == {'c'}
        else:
            assert d.casereverse_lookup('c') == set()
        d.clear()
        assert d.longest_key == 0
        assert d.reverse_lookup('c') == set()
        assert d.casereverse_lookup('c') == set()
        d[('S', 'S')] = 'c'
        assert d.longest_key == 2

    def test_casereverse_del(self):
        '''
        Check deletion correctly updates `casereverse_lookup` data.
        '''
        d = self.DICT_CLASS()
        d[('S-G',)] = 'something'
        d[('SPH-G',)] = 'something'
        if self.DICT_SUPPORT_CASEREVERSE_LOOKUP:
            assert d.casereverse_lookup('something') == {'something'}
        else:
            assert d.casereverse_lookup('something') == set()
        del d[('S-G',)]
        if self.DICT_SUPPORT_CASEREVERSE_LOOKUP:
            assert d.casereverse_lookup('something') == {'something'}
        else:
            assert d.casereverse_lookup('something') == set()
        del d[('SPH-G',)]
        assert d.casereverse_lookup('something') == set()

    @parametrize((
        lambda: (dict(_TEST_DICTIONARY_UPDATE_DICT), False, True),
        lambda: (dict(_TEST_DICTIONARY_UPDATE_DICT), False, False),
        lambda: (list(_TEST_DICTIONARY_UPDATE_DICT.items()), False, True),
        lambda: (list(_TEST_DICTIONARY_UPDATE_DICT.items()), False, False),
        lambda: (list(_TEST_DICTIONARY_UPDATE_DICT.items()), True, True),
        lambda: (list(_TEST_DICTIONARY_UPDATE_DICT.items()), True, False),
        lambda: (_TEST_DICTIONARY_UPDATE_STENODICT, False, True),
        lambda: (_TEST_DICTIONARY_UPDATE_STENODICT, False, False),
    ))
    def test_update(self, update_from, use_iter, start_empty):
        '''
        Check `update` does the right thing, including consuming iterators once.
        '''
        d = self.DICT_CLASS()
        if not start_empty:
            d.update({
                ('SPH*G',): 'not something',
                ('STHEUPBG',): 'something',
                ('EF', 'REU', 'TH*EUPBG'): 'everything',
            })
            assert d[('STHEUPBG',)] == 'something'
            assert d[('EF', 'REU', 'TH*EUPBG')] == 'everything'
            if self.DICT_SUPPORT_REVERSE_LOOKUP:
                assert d.reverse_lookup('not something') == {('SPH*G',)}
            else:
                assert d.reverse_lookup('not something') == set()
            if self.DICT_SUPPORT_REVERSE_LOOKUP:
                assert d.casereverse_lookup('not something') == {'not something'}
            else:
                assert d.casereverse_lookup('not something') == set()
            assert d.longest_key == 3
        if use_iter:
            update_from = iter(update_from)
        d.update(update_from)
        assert d[('S-G',)] == 'something'
        assert d[('SPH-G',)] == 'something'
        assert d[('SPH*G',)] == 'Something'
        assert d[('SPH', 'THEUPBG')] == 'something'
        if not start_empty:
            assert d[('STHEUPBG',)] == 'something'
            assert d[('EF', 'REU', 'TH*EUPBG')] == 'everything'
            assert d.reverse_lookup('not something') == set()
            if self.DICT_SUPPORT_REVERSE_LOOKUP:
                assert d.reverse_lookup('something') == {('STHEUPBG',), ('S-G',), ('SPH-G',), ('SPH', 'THEUPBG')}
            else:
                assert d.reverse_lookup('something') == set()
            if self.DICT_SUPPORT_CASEREVERSE_LOOKUP:
                assert d.casereverse_lookup('something') == {'something', 'Something'}
            else:
                assert d.casereverse_lookup('something') == set()
            assert d.longest_key == 3
        else:
            if self.DICT_SUPPORT_REVERSE_LOOKUP:
                assert d.reverse_lookup('something') == {('S-G',), ('SPH-G',), ('SPH', 'THEUPBG')}
            else:
                assert d.reverse_lookup('something') == set()
            if self.DICT_SUPPORT_CASEREVERSE_LOOKUP:
                assert d.casereverse_lookup('something') == {'something', 'Something'}
            else:
                assert d.casereverse_lookup('something') == set()
            assert d.longest_key == 2

    INVALID_CONTENTS = b"ceci n'est pas un dictionaire"

    def _test_save(self, tmp_path, entries, expected):
        '''
        Test `save` implementation.
        '''
        dict_entries = self.parse_entries(entries)
        with self.tmp_dict(tmp_path, self.INVALID_CONTENTS) as dict_path:
            st = dict_path.stat()
            old_timestamp = st.st_mtime - 1
            os.utime(str(dict_path), (st.st_atime, old_timestamp))
            # Create...
            d = self.DICT_CLASS.create(str(dict_path))
            # ...must not change the target file...
            assert d.timestamp == 0
            assert dict_path.read_bytes() == self.INVALID_CONTENTS
            # ...even on update...
            d.update(dict_entries)
            assert d.timestamp == 0
            assert dict_path.read_bytes() == self.INVALID_CONTENTS
            # ...until save is called.
            d.save()
            assert d.timestamp > old_timestamp
            if expected is None:
                assert dict_path.read_bytes() != self.INVALID_CONTENTS
            else:
                assert dict_path.read_bytes() == expected
            d = self.DICT_CLASS.load(str(dict_path))
            assert sorted(d.items()) == sorted(dict_entries.items())


def _wrap_method(method):
    @functools.wraps(method)
    def wrapper(*args, **kwargs):
        return method(*args, **kwargs)
    wrapper.__signature__ = inspect.signature(method)
    return wrapper


def dictionary_test(cls):

    """
    Torture tests for dictionary implementations.

    Usage:

    ```python

    @dictionary_test
    class TestMyDictionaryClass:

        # Mandatory: implementation class.
        DICT_CLASS = MyDictionaryClass

        # Mandatory: extension.
        DICT_EXTENSION = 'json'

        # Mandatory: valid sample dictionary contents.
        DICT_SAMPLE = b'{}'

        # Optional: if `True`, will check the implementation class
        # is registered as a valid `plover.dictionary` entrypoint.
        DICT_REGISTERED = True

        # Optional: if `False`, `len`, `__iter__`, and `items`
        # are not supported, and must respectively return:
        # 0 and empty sequences.
        # Note: only supported for read-only implementations,
        # writable implementations must support all sequence
        # methods.
        DICT_SUPPORT_SEQUENCE_METHODS = False

        # Optional: if `False`, then `reverse_lookup` is not
        # supported, and must return an empty set.
        DICT_SUPPORT_REVERSE_LOOKUP = False

        # Optional: if `False`, then `casereverse_lookup` is not
        # supported, and must return an empty set.
        # Note: if supported, then `DICT_SUPPORT_REVERSE_LOOKUP`
        # must be too.
        DICT_SUPPORT_CASEREVERSE_LOOKUP = False

        # Optional: load tests.
        DICT_LOAD_TESTS = (
            lambda: (
                # Dictionary file contents.
                b'''
                ''',
                # Expected entries, in Python-dictionary like format.
                '''
                "TEFT": 'test',
                'TEFT/-G': "testing",
                '''
            ),
        )

        # Optional: save tests.
        # Note: only for writable implementations.
        DICT_SAVE_TESTS = (
            lambda: (
                # Initial entries, in Python-dictionary like format.
                '''
                "TEFT": 'test',
                'TEFT/-G': "testing",
                '''
                # Expected saved dictionary contents, or `None`
                # to skip the byte-for-byte test of the resulting
                # file contents.
                b'''
                ''',
            ),
        )

        # Optional: if implemented, will be called to format
        # the contents before saving to a dictionary file,
        # including for load and save tests.
        @staticmethod
        def make_dict(self, contents):
            return contents

    """

    DICT_SUPPORT_SEQUENCE_METHODS = getattr(cls, 'DICT_SUPPORT_SEQUENCE_METHODS', True)
    DICT_SUPPORT_REVERSE_LOOKUP = getattr(cls, 'DICT_SUPPORT_REVERSE_LOOKUP', True)
    DICT_SUPPORT_CASEREVERSE_LOOKUP = getattr(cls, 'DICT_SUPPORT_CASEREVERSE_LOOKUP', DICT_SUPPORT_REVERSE_LOOKUP)
    assert DICT_SUPPORT_REVERSE_LOOKUP or not DICT_SUPPORT_CASEREVERSE_LOOKUP

    base_classes = [cls, _DictionaryTests]
    if cls.DICT_CLASS.readonly:
        base_classes.append(_ReadOnlyDictionaryTests)
    else:
        base_classes.append(_WritableDictionaryTests)

    class_dict = {
        'DICT_SUPPORT_SEQUENCE_METHODS': DICT_SUPPORT_SEQUENCE_METHODS,
        'DICT_SUPPORT_REVERSE_LOOKUP': DICT_SUPPORT_REVERSE_LOOKUP,
        'DICT_SUPPORT_CASEREVERSE_LOOKUP': DICT_SUPPORT_CASEREVERSE_LOOKUP,
    }

    if getattr(cls, 'DICT_REGISTERED', False):
        class_dict['test_entrypoint'] = _wrap_method(_DictionaryTests._test_entrypoint)

    if hasattr(cls, 'DICT_LOAD_TESTS'):
        class_dict['test_load'] = parametrize(cls.DICT_LOAD_TESTS, arity=2)(
            _wrap_method(_DictionaryTests._test_load))

    if hasattr(cls, 'DICT_SAVE_TESTS'):
        assert not cls.DICT_CLASS.readonly
        class_dict['test_save'] = parametrize(cls.DICT_SAVE_TESTS, arity=2)(
            _wrap_method(_WritableDictionaryTests._test_save))

    return type(cls.__name__, tuple(base_classes), class_dict)
