import difflib
import functools
import os.path
import shutil
import unittest

from support import GNATCOLL_TestCase
from mmap_tests.utils import *


TESTSUITE_ROOT = os.getcwd()
MMAP_ROOT = os.path.join(TESTSUITE_ROOT, 'mmap_tests')


def test_method(subdir):
    """
    Return a decorator for testcases' methods, used to make sure setUpClass is
    called once.

    This is needed as long as Python2.6 is supported (setUpClass is new in
    Python2.7)
    """
    def decorator(func):

        @functools.wraps(func)
        def wrapper(self):
            with Chdir(MMAP_ROOT):

                if not getattr(self, 'initialized', False):
                    self.setUpClass()
                self.initialized = True

                with Chdir(subdir):
                    return func(self)

        return wrapper

    return decorator


class MmapTestCase(GNATCOLL_TestCase):
    """Base class for GNATCOLL.Mmap testcases."""

    # Subdirectories to compile before starting tests.
    subdirs = []

    @classmethod
    def setUpClass(cls):
        """Build all test drivers in all subdirectories."""
        with Chdir(MMAP_ROOT):
            for subdir in cls.subdirs:
                with Chdir(subdir):
                    # TODO: adapt the support module to be able to call
                    # gprbuild() without an instance of GNATCOLL_TestCase.
                    gprbuild()

    def assertMultiLineEqual(self, expected, got):
        """
        Assert that expected equals got. Otherwise, print a diff on them.

        This is needed as long as Python2.6 is supported (assertMultiLineEqual
        is new in Python2.7)
        """
        self.assertEqual(expected, got,
            msg='\n'.join(difflib.unified_diff(expected, got,
                'expected', 'got')))


class LegacyTestCase(MmapTestCase):

    subdirs = ['map1', 'write']

    @test_method("map1")
    def test_full_mmap(self):
        self.runexec(["obj/test_mmap", "test_mmap.adb", "full", "mmap"])
        self.diff(expected="test_mmap.adb", actual=file("mmap.out").read())

    @test_method("map1")
    def test_partial_mmap(self):
        self.runexec(["obj/test_mmap", "test_mmap.adb", "partial", "mmap"])
        self.diff(expected="test_mmap.adb", actual=file("mmap.out").read())

    @test_method("map1")
    def test_full_read(self):
        self.runexec(["obj/test_mmap", "test_mmap.adb", "full", "read"])
        self.diff(expected="test_mmap.adb", actual=file("mmap.out").read())

    @test_method("map1")
    def test_partial_read(self):
        self.runexec(["obj/test_mmap", "test_mmap.adb", "partial", "read"])
        self.diff(expected="test_mmap.adb", actual=file("mmap.out").read())

    @test_method("map1")
    def test_length_32767(self):
        self.runexec(["obj/test_mmap", "mmap2.in", "full", "mmap"])
        self.diff(expected="mmap2.in", actual=file("mmap.out").read())

    @test_method("map1")
    def test_empty_file(self):
        self.runexec(["obj/test_mmap", "empty.txt", "full", "mmap"])
        self.diff(expected="", actual=file("mmap.out").read())

        self.runexec(["obj/test_mmap", "empty.txt", "full", "read"])
        self.diff(expected="", actual=file("mmap.out").read())


    @test_method("write")
    def test_write(self):
        self.gprbuild()
        file("mmap.in", "w").write(file("mmap1.in").read())
        self.runexec(["obj/test_mmap_write", "mmap"], "")
        self.diff(expected="mmap1.out", actual=file("mmap.in").read())

        file("mmap.in", "w").write(file("mmap1.in").read())
        self.runexec(["obj/test_mmap_write", "read"], "")
        self.diff(expected="mmap1.out", actual=file("mmap.in").read())


class MultipleRegionsTestCase(MmapTestCase):
    """
    Test the feature that allows one to map multiple regions of the same file
    simultaneously.
    """

    subdirs = ['multiple_regions']

    @classmethod
    def setUpClass(cls):
        with Chdir(MMAP_ROOT):
            super(MultipleRegionsTestCase, cls).setUpClass()
            with Chdir('multiple_regions'):
                cls.setup_files()
                cls.discover_mmap()

    @classmethod
    def setup_files(cls):
        # Prepare test material
        with open('empty.txt', 'wb'):
            pass
        with open('chars.txt', 'wb') as f:
            f.write('A')
            f.write('B' * 998)
            f.write('C')
            f.write('X')
            f.write('Y' * 998)
            f.write('Z')

    @classmethod
    def discover_mmap(cls):
        p = subprocess.Popen(
            ['obj/is_mmap_available'],
            stdout=subprocess.PIPE)
        stdout, stderr = p.communicate()
        cls.is_mmap_available = stdout.strip() == 'TRUE'

    @test_method('multiple_regions')
    def test_read_not_existing(self):
        """
        Trying to open a non-existing file should raise an
        Ada.IO_Exceptions.Name_Error.
        """
        self.run_driver('no-such-file', 'read', [],
            expected='Open: Name_Error')

    @test_method('multiple_regions')
    def test_write_not_existing(self):
        """
        Trying to open a non-existing file should raise an
        Ada.IO_Exceptions.Name_Error.
        """
        self.run_driver('no-such-file', 'write', [],
            expected='Open: Name_Error')

    @test_method('multiple_regions')
    def test_read_empty_file(self):
        """
        The mapped regions of an empty file must be at the required offset, but
        always empty.
        """
        self.run_driver('empty.txt', 'read', [
            Map(0,   0),
            Map(100, 0),
            Map(0,   100),
            Map(100, 100),
        ])

    @test_method('multiple_regions')
    def test_write_empty_file(self):
        self.run_driver('empty.txt', 'write', [
            Map(0, 0), Fill(1, 0, 'A'),
        ])

    @test_method('multiple_regions')
    def test_read_straigthforward(self):
        """
        Mapping regions fully contained in the file must work as expected.
        """
        self.run_driver('chars.txt', 'read', [
            Map(0,    1),
            Map(0,    1000),
            Map(1000, 2000),
        ])

    @test_method('multiple_regions')
    def test_read_remap_inside(self):
        """
        Remapping regions inside already existing regions must work as
        expected.
        """
        self.run_driver('chars.txt', 'read', [
            Map(0, 1000), Remap(0,    50),
            Map(0, 1000), Remap(100,  50),
            Map(0, 1000), Remap(900,  100),
            Map(0, 1000), Remap(800,  100),
        ])

    @test_method('multiple_regions')
    def test_read_remap_inside_rollback(self):
        """
        Remapping regions outside already existing regions must work as
        expected.
        """
        self.run_driver('chars.txt', 'read', [
            Map(0, 1000), Remap(0, 50), Remap(0, 1000),
        ])

    @test_method('multiple_regions')
    def test_read_remap_outside(self):
        self.run_driver('chars.txt', 'read', [
            Map(0, 1000), Remap(1000, 1000),
            Map(2000, 0), Remap(1000, 1000),
            Map(0, 1000), Remap(2000, 1000),
        ])

    @test_method('multiple_regions')
    def test_write_straightforward(self):
        self.run_driver('chars.txt', 'write', [
            Map(0, 1000),
            Fill(1,    0, 'I'),
            Fill(1,    1, 'J'),
            Fill(10,   1, 'K'),
            Fill(1000, 1, 'L'),
        ])

    @test_method('multiple_regions')
    def test_write_complete_fill(self):
        self.run_driver('chars.txt', 'write', [
            Map(0, 1000),
            Fill(1,    1000, 'I'),
            Fill(1,    1,    'J'),
            Fill(1000, 1,    'K'),
        ])

    @test_method('multiple_regions')
    def test_read_mutable(self):
        """
        Writing to mutable mapped regions of reading files should modify the
        buffer in memory, but not the underlying file.
        """
        self.run_driver('chars.txt', 'read', [
            Map(0, 1000, mutable=True),
            Fill(1, 1, 'A'),
        ], copy_file=True)
        self.assertMultiLineEqual(
            open('chars.txt', 'r').read(),
            open('temp.txt', 'r').read()
        )

    @test_method('multiple_regions')
    def test_read_remap_mutable(self):
        """Remapping some region must update its the mutability status."""
        self.run_driver('chars.txt', 'read', [
            Map(0, 1000, mutable=False),
            Remap(0, 1000, mutable=True),
            Fill(1, 1, 'A'),
        ], copy_file=True)
        self.assertMultiLineEqual(
            open('chars.txt', 'r').read(),
            open('temp.txt', 'r').read()
        )

    def run_driver(
        self, filename,
        mode, actions,
        expected=None,
        copy_file=False
    ):
        """Run the test driver.

        Make it map REGIONS from FILENAME. If EXPECTED is None, guess what is
        expected to output reading the FILENAME, otherwise use EXPECTED as the
        expected output. "Guessing" is basically just checking that regions
        memory mapping have the same behavior as regular file reading using
        Python's file manipulation primitives.

        If COPY_FILE is True, force the copy of FILENAME so that the test can
        harmlessly modify it.

        Test both mmap and manual read behaviors, and try both to read the file
        content before and after closing the mapped file.
        """

        # If the driver is supposed to write to the file, provide it with a
        # copy.
        if copy_file or (mode == 'write' and os.path.isfile(filename)):
            temp_filename = 'temp.txt'
        else:
            temp_filename = filename

        # GNATCOLL.Mmap does not allow working with mapped regions related to
        # writing closed files.
        close_after_read_set = (
            ('no-close-after-read', )
            if mode == 'write' else
            ('close-after-read', 'no-close-after-read')
        )

        for method in ('mmap', 'buffer'):
            for close_after_read in close_after_read_set:

                if temp_filename != filename:
                    shutil.copy(filename, temp_filename)

                # Format the expected driver output/file content if none is
                # provided. This can be done just reading the given file at the
                # given regions.
                if expected:
                    expected_output = expected
                    expected_content = None
                else:
                    regions, expected_content = interpret_actions(
                        temp_filename, mode, method, self.is_mmap_available,
                        actions)
                    expected_output = format_regions(temp_filename, regions)

                # Format the arguments to pass to the test driver.
                args = [
                    'obj/driver',
                    temp_filename, mode, method, close_after_read]
                args.extend(actions_to_args(actions))

                # Run it.
                self.runexec(args, expected=expected_output)

                # Then check that the content of the file is as expected.
                if temp_filename and expected_content is not None:
                    self.assertMultiLineEqual(
                        expected_content, open(temp_filename, 'r').read()
                    )
