# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

"""
Tests for L{twisted.python.zipstream}
"""

import random
import struct
import zipfile
from hashlib import md5

from twisted.python import zipstream, filepath
from twisted.trial import unittest


class FileEntryMixin:
    """
    File entry classes should behave as file-like objects
    """

    def getFileEntry(self, contents):
        """
        Return an appropriate zip file entry
        """
        filename = self.mktemp()
        with zipfile.ZipFile(filename, "w", self.compression) as z:
            z.writestr("content", contents)
        z = zipstream.ChunkingZipFile(filename, "r")
        return z.readfile("content")

    def test_isatty(self):
        """
        zip files should not be ttys, so isatty() should be false
        """
        with self.getFileEntry("") as fileEntry:
            self.assertFalse(fileEntry.isatty())

    def test_closed(self):
        """
        The C{closed} attribute should reflect whether C{close()} has been
        called.
        """
        with self.getFileEntry("") as fileEntry:
            self.assertFalse(fileEntry.closed)
        self.assertTrue(fileEntry.closed)

    def test_readline(self):
        """
        C{readline()} should mirror L{file.readline} and return up to a single
        delimiter.
        """
        with self.getFileEntry(b"hoho\nho") as fileEntry:
            self.assertEqual(fileEntry.readline(), b"hoho\n")
            self.assertEqual(fileEntry.readline(), b"ho")
            self.assertEqual(fileEntry.readline(), b"")

    def test_next(self):
        """
        Zip file entries should implement the iterator protocol as files do.
        """
        with self.getFileEntry(b"ho\nhoho") as fileEntry:
            self.assertEqual(fileEntry.next(), b"ho\n")
            self.assertEqual(fileEntry.next(), b"hoho")
            self.assertRaises(StopIteration, fileEntry.next)

    def test_readlines(self):
        """
        C{readlines()} should return a list of all the lines.
        """
        with self.getFileEntry(b"ho\nho\nho") as fileEntry:
            self.assertEqual(fileEntry.readlines(), [b"ho\n", b"ho\n", b"ho"])

    def test_iteration(self):
        """
        C{__iter__()} and C{xreadlines()} should return C{self}.
        """
        with self.getFileEntry("") as fileEntry:
            self.assertIs(iter(fileEntry), fileEntry)
            self.assertIs(fileEntry.xreadlines(), fileEntry)

    def test_readWhole(self):
        """
        C{.read()} should read the entire file.
        """
        contents = b"Hello, world!"
        with self.getFileEntry(contents) as entry:
            self.assertEqual(entry.read(), contents)

    def test_readPartial(self):
        """
        C{.read(num)} should read num bytes from the file.
        """
        contents = "0123456789"
        with self.getFileEntry(contents) as entry:
            one = entry.read(4)
            two = entry.read(200)
        self.assertEqual(one, b"0123")
        self.assertEqual(two, b"456789")

    def test_tell(self):
        """
        C{.tell()} should return the number of bytes that have been read so
        far.
        """
        contents = "x" * 100
        with self.getFileEntry(contents) as entry:
            entry.read(2)
            self.assertEqual(entry.tell(), 2)
            entry.read(4)
            self.assertEqual(entry.tell(), 6)


class DeflatedZipFileEntryTests(FileEntryMixin, unittest.TestCase):
    """
    DeflatedZipFileEntry should be file-like
    """

    compression = zipfile.ZIP_DEFLATED


class ZipFileEntryTests(FileEntryMixin, unittest.TestCase):
    """
    ZipFileEntry should be file-like
    """

    compression = zipfile.ZIP_STORED


class ZipstreamTests(unittest.TestCase):
    """
    Tests for twisted.python.zipstream
    """

    def setUp(self):
        """
        Creates junk data that can be compressed and a test directory for any
        files that will be created
        """
        self.testdir = filepath.FilePath(self.mktemp())
        self.testdir.makedirs()
        self.unzipdir = self.testdir.child("unzipped")
        self.unzipdir.makedirs()

    def makeZipFile(self, contents, directory=""):
        """
        Makes a zip file archive containing len(contents) files.  Contents
        should be a list of strings, each string being the content of one file.
        """
        zpfilename = self.testdir.child("zipfile.zip").path
        with zipfile.ZipFile(zpfilename, "w") as zpfile:
            for i, content in enumerate(contents):
                filename = str(i)
                if directory:
                    filename = directory + "/" + filename
                zpfile.writestr(filename, content)
        return zpfilename

    def test_invalidMode(self):
        """
        A ChunkingZipFile opened in write-mode should not allow .readfile(),
        and raise a RuntimeError instead.
        """
        with zipstream.ChunkingZipFile(self.mktemp(), "w") as czf:
            self.assertRaises(RuntimeError, czf.readfile, "something")

    def test_closedArchive(self):
        """
        A closed ChunkingZipFile should raise a L{RuntimeError} when
        .readfile() is invoked.
        """
        czf = zipstream.ChunkingZipFile(self.makeZipFile(["something"]), "r")
        czf.close()
        self.assertRaises(RuntimeError, czf.readfile, "something")

    def test_invalidHeader(self):
        """
        A zipfile entry with the wrong magic number should raise BadZipfile for
        readfile(), but that should not affect other files in the archive.
        """
        fn = self.makeZipFile(["test contents", "more contents"])
        with zipfile.ZipFile(fn, "r") as zf:
            zeroOffset = zf.getinfo("0").header_offset
        # Zero out just the one header.
        with open(fn, "r+b") as scribble:
            scribble.seek(zeroOffset, 0)
            scribble.write(b"0" * 4)
        with zipstream.ChunkingZipFile(fn) as czf:
            self.assertRaises(zipfile.BadZipfile, czf.readfile, "0")
            with czf.readfile("1") as zfe:
                self.assertEqual(zfe.read(), b"more contents")

    def test_filenameMismatch(self):
        """
        A zipfile entry with a different filename than is found in the central
        directory should raise BadZipfile.
        """
        fn = self.makeZipFile([b"test contents", b"more contents"])
        with zipfile.ZipFile(fn, "r") as zf:
            info = zf.getinfo("0")
            info.filename = "not zero"
        with open(fn, "r+b") as scribble:
            scribble.seek(info.header_offset, 0)
            scribble.write(info.FileHeader())

        with zipstream.ChunkingZipFile(fn) as czf:
            self.assertRaises(zipfile.BadZipfile, czf.readfile, "0")
            with czf.readfile("1") as zfe:
                self.assertEqual(zfe.read(), b"more contents")

    def test_unsupportedCompression(self):
        """
        A zipfile which describes an unsupported compression mechanism should
        raise BadZipfile.
        """
        fn = self.mktemp()
        with zipfile.ZipFile(fn, "w") as zf:
            zi = zipfile.ZipInfo("0")
            zf.writestr(zi, "some data")
            # Mangle its compression type in the central directory; can't do
            # this before the writestr call or zipfile will (correctly) tell us
            # not to pass bad compression types :)
            zi.compress_type = 1234

        with zipstream.ChunkingZipFile(fn) as czf:
            self.assertRaises(zipfile.BadZipfile, czf.readfile, "0")

    def test_extraData(self):
        """
        readfile() should skip over 'extra' data present in the zip metadata.
        """
        fn = self.mktemp()
        with zipfile.ZipFile(fn, "w") as zf:
            zi = zipfile.ZipInfo("0")
            extra_data = b"hello, extra"
            zi.extra = struct.pack("<hh", 42, len(extra_data)) + extra_data
            zf.writestr(zi, b"the real data")
        with zipstream.ChunkingZipFile(fn) as czf, czf.readfile("0") as zfe:
            self.assertEqual(zfe.read(), b"the real data")

    def test_unzipIterChunky(self):
        """
        L{twisted.python.zipstream.unzipIterChunky} returns an iterator which
        must be exhausted to completely unzip the input archive.
        """
        numfiles = 10
        contents = ["This is test file %d!" % i for i in range(numfiles)]
        contents = [i.encode("ascii") for i in contents]
        zpfilename = self.makeZipFile(contents)
        list(zipstream.unzipIterChunky(zpfilename, self.unzipdir.path))
        self.assertEqual(set(self.unzipdir.listdir()), set(map(str, range(numfiles))))

        for child in self.unzipdir.children():
            num = int(child.basename())
            self.assertEqual(child.getContent(), contents[num])

    def test_unzipIterChunkyDirectory(self):
        """
        The path to which a file is extracted by L{zipstream.unzipIterChunky}
        is determined by joining the C{directory} argument to C{unzip} with the
        path within the archive of the file being extracted.
        """
        numfiles = 10
        contents = ["This is test file %d!" % i for i in range(numfiles)]
        contents = [i.encode("ascii") for i in contents]
        zpfilename = self.makeZipFile(contents, "foo")
        list(zipstream.unzipIterChunky(zpfilename, self.unzipdir.path))
        fileContents = {str(num).encode("ascii") for num in range(numfiles)}
        self.assertEqual(set(self.unzipdir.child(b"foo").listdir()), fileContents)

        for child in self.unzipdir.child(b"foo").children():
            num = int(child.basename())
            self.assertEqual(child.getContent(), contents[num])

    # XXX these tests are kind of gross and old, but I think unzipIterChunky is
    # kind of a gross function anyway.  We should really write an abstract
    # copyTo/moveTo that operates on FilePath and make sure ZipPath can support
    # it, then just deprecate / remove this stuff.
    def _unzipIterChunkyTest(self, compression, chunksize, lower, upper):
        """
        unzipIterChunky should unzip the given number of bytes per iteration.
        """
        junk = b""
        for n in range(1000):
            num = round(random.random(), 12)
            numEncoded = str(num).encode("ascii")
            junk += b" " + numEncoded

        junkmd5 = md5(junk).hexdigest()

        tempdir = filepath.FilePath(self.mktemp())
        tempdir.makedirs()
        zfpath = tempdir.child("bigfile.zip").path
        self._makebigfile(zfpath, compression, junk)
        uziter = zipstream.unzipIterChunky(zfpath, tempdir.path, chunksize=chunksize)
        r = next(uziter)
        # test that the number of chunks is in the right ballpark;
        # this could theoretically be any number but statistically it
        # should always be in this range
        approx = lower < r < upper
        self.assertTrue(approx)
        for r in uziter:
            pass
        self.assertEqual(r, 0)
        with tempdir.child("zipstreamjunk").open() as f:
            newmd5 = md5(f.read()).hexdigest()
            self.assertEqual(newmd5, junkmd5)

    def test_unzipIterChunkyStored(self):
        """
        unzipIterChunky should unzip the given number of bytes per iteration on
        a stored archive.
        """
        self._unzipIterChunkyTest(zipfile.ZIP_STORED, 500, 35, 45)

    def test_chunkyDeflated(self):
        """
        unzipIterChunky should unzip the given number of bytes per iteration on
        a deflated archive.
        """
        self._unzipIterChunkyTest(zipfile.ZIP_DEFLATED, 972, 23, 27)

    def _makebigfile(self, filename, compression, junk):
        """
        Create a zip file with the given file name and compression scheme.
        """
        with zipfile.ZipFile(filename, "w", compression) as zf:
            for i in range(10):
                fn = "zipstream%d" % i
                zf.writestr(fn, "")
            zf.writestr("zipstreamjunk", junk)
