"""
A python class to represent a single comic, be it file or folder of images

Copyright 2012-2014  Anthony Beville

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""

import zipfile
import tarfile
import os
import struct
import sys
import tempfile
import subprocess
import platform
import logging

from natsort import natsorted
try:
    import rarfile
    rarsupport = True
except (ImportError, SyntaxError):
    rarsupport = False

import time

from io import StringIO
try:
    from PIL import Image
    pil_available = True
except ImportError:
    pil_available = False

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

sys.path.insert(0, os.path.abspath("."))

from comicapi.comicinfoxml import ComicInfoXml
from comicapi.comicbookinfo import ComicBookInfo
from comicapi.comet import CoMet
from comicapi.genericmetadata import GenericMetadata, PageType
from comicapi.filenameparser import FileNameParser
from PyPDF2 import PdfFileReader


class MetaDataStyle:
    CBI = 0
    CIX = 1
    COMET = 2
    name = ['ComicBookLover', 'ComicRack', 'CoMet']


class ZipArchiver:
    def __init__(self, path):
        self.path = path

    def getArchiveComment(self):
        zf = zipfile.ZipFile(self.path, 'r')
        comment = zf.comment
        zf.close()
        return comment

    def setArchiveComment(self, comment):
        return self.writeZipComment(self.path, comment)

    def readArchiveFile(self, archive_file):
        zf = zipfile.ZipFile(self.path, 'r')

        try:
            data = zf.read(archive_file)
        except zipfile.BadZipfile as e:
            errMsg = u"bad zipfile [{0}]: {1} :: {2}".format(
                e, self.path, archive_file)
            logger.info(errMsg)
            zf.close()
            raise IOError
        except Exception as e:
            zf.close()
            errMsg = u"bad zipfile [{0}]: {1} :: {2}".format(
                e, self.path, archive_file)
            logger.info(errMsg)
            raise IOError
        finally:
            zf.close()
        return data

    def removeArchiveFile(self, archive_file):
        try:
            self.rebuildZipFile([archive_file])
        except:
            return False
        else:
            return True

    def writeArchiveFile(self, archive_file, data):
        #  At the moment, no other option but to rebuild the whole
        #  zip archive w/o the indicated file. Very sucky, but maybe
        # another solution can be found
        try:
            self.rebuildZipFile([archive_file])

            # now just add the archive file as a new one
            zf = zipfile.ZipFile(
                self.path, mode='a', compression=zipfile.ZIP_DEFLATED)
            zf.writestr(archive_file, data)
            zf.close()
            return True
        except:
            return False

    def getArchiveFilenameList(self):
        try:
            zf = zipfile.ZipFile(self.path, 'r')
            namelist = zf.namelist()
            zf.close()
            return namelist
        except Exception as e:
            errMsg = u"Unable to get zipfile list [{0}]: {1}".format(
                e, self.path)
            logger.info(errMsg)
            return []

    # zip helper func
    def rebuildZipFile(self, exclude_list):

        # this recompresses the zip archive, without the files in the exclude_list
        # errMsg=u"Rebuilding zip {0} without {1}".format( self.path, exclude_list )

        # generate temp file
        tmp_fd, tmp_name = tempfile.mkstemp(dir=os.path.dirname(self.path))
        os.close(tmp_fd)

        zin = zipfile.ZipFile(self.path, 'r')
        zout = zipfile.ZipFile(tmp_name, 'w')
        for item in zin.infolist():
            buffer = zin.read(item.filename)
            if item.filename not in exclude_list:
                zout.writestr(item, buffer)

        # preserve the old comment
        zout.comment = zin.comment

        zout.close()
        zin.close()

        # replace with the new file
        os.remove(self.path)
        os.rename(tmp_name, self.path)

    def writeZipComment(self, filename, comment):
        """
        This is a custom function for writing a comment to a zip file,
        since the built-in one doesn't seem to work on Windows and Mac OS/X

        Fortunately, the zip comment is at the end of the file, and it's
        easy to manipulate.  See this website for more info:
        see: http://en.wikipedia.org/wiki/Zip_(file_format)#Structure
        """

        statinfo = os.stat(filename)
        file_length = statinfo.st_size

        try:
            fo = open(filename, "r+b")

            # the starting position, relative to EOF
            pos = -4

            found = False
            value = bytearray()

            # walk backwards to find the "End of Central Directory" record
            while (not found) and (-pos != file_length):
                # seek, relative to EOF
                fo.seek(pos, 2)

                value = fo.read(4)

                # look for the end of central directory signature
                if bytearray(value) == bytearray([0x50, 0x4b, 0x05, 0x06]):
                    found = True
                else:
                    # not found, step back another byte
                    pos = pos - 1
                # print pos,"{1} int: {0:x}".format(bytearray(value)[0], value)

            if found:

                # now skip forward 20 bytes to the comment length word
                pos += 20
                fo.seek(pos, 2)

                # Pack the length of the comment string
                format = "H"  # one 2-byte integer
                comment_length = struct.pack(
                    format, len(comment))  # pack integer in a binary string

                # write out the length
                fo.write(comment_length)
                fo.seek(pos + 2, 2)

                # write out the comment itself
                fo.write(comment)
                fo.truncate()
                fo.close()
            else:
                raise Exception('Failed to write comment to zip file!')
        except:
            return False
        else:
            return True

    def copyFromArchive(self, otherArchive):
        # Replace the current zip with one copied from another archive
        try:
            zout = zipfile.ZipFile(self.path, 'w')
            for fname in otherArchive.getArchiveFilenameList():
                data = otherArchive.readArchiveFile(fname)
                if data is not None:
                    zout.writestr(fname, data)
            zout.close()

            # preserve the old comment
            comment = otherArchive.getArchiveComment()
            if comment is not None:
                if not self.writeZipComment(self.path, comment):
                    return False
        except Exception as e:
            errMsg = u"Error while copying to {0}: {1}".format(self.path, e)
            logger.info(errMsg)
            return False
        else:
            return True


class TarArchiver:
    def __init__(self, path):
        self.path = path

    def getArchiveComment(self):
        tf = tarfile.TarFile(self.path, 'r')
        comment = tf.comment
        tf.close()
        return comment

    def setArchiveComment(self, comment):
        return self.writeTarComment(self.path, comment)

    def readArchiveFile(self, archive_file):
        tf = tarfile.TarFile(self.path, 'r')

        try:
            data = tf.extractfile(archive_file).read()
        except tarfile.TarError as e:
            errMsg = u"bad tarfile [{0}]: {1} :: {2}".format(
                e, self.path, archive_file)
            logger.info(errMsg)
            tf.close()
            raise IOError
        except Exception as e:
            tf.close()
            errMsg = u"bad tarfile [{0}]: {1} :: {2}".format(
                e, self.path, archive_file)
            logger.info(errMsg)
            raise IOError
        finally:
            tf.close()
        return data

    def removeArchiveFile(self, archive_file):
        try:
            self.rebuildTarFile([archive_file])
        except:
            return False
        else:
            return True

    def writeArchiveFile(self, archive_file, data):
        #  At the moment, no other option but to rebuild the whole
        #  zip archive w/o the indicated file. Very sucky, but maybe
        # another solution can be found
        try:
            self.rebuildTarFile([archive_file])

            # now just add the archive file as a new one
            tf = tarfile.Tarfile(
                self.path, mode='a')
            tf.writestr(archive_file, data)
            tf.close()
            return True
        except:
            return False

    def getArchiveFilenameList(self):
        try:
            tf = tarfile.TarFile(self.path, 'r')
            namelist = tf.getnames()
            tf.close()
            return namelist
        except Exception as e:
            errMsg = u"Unable to get tarfile list [{0}]: {1}".format(
                e, self.path)
            logger.info(errMsg)
            return []

    # zip helper func
    def rebuildTarFile(self, exclude_list):

        # this recompresses the zip archive, without the files in the exclude_list
        # errMsg=u"Rebuilding zip {0} without {1}".format( self.path, exclude_list )

        # generate temp file
        tmp_fd, tmp_name = tempfile.mkstemp(dir=os.path.dirname(self.path))
        os.close(tmp_fd)

        tin = tarfile.TarFile(self.path, 'r')
        tout = tarfile.TarFile(tmp_name, 'w')
        for item in tin.infolist():
            buffer = tin.read(item.filename)
            if (item.filename not in exclude_list):
                tout.writestr(item, buffer)

        # preserve the old comment
        tout.comment = tin.comment

        tout.close()
        tin.close()

        # replace with the new file
        os.remove(self.path)
        os.rename(tmp_name, self.path)

    def writeTarComment(self, filename, comment):
        """
        This is a custom function for writing a comment to a tar file,
        since the built-in one doesn't seem to work on Windows and Mac OS/X
        """

        statinfo = os.stat(filename)
        file_length = statinfo.st_size

        try:
            fo = open(filename, "r+b")

            # the starting position, relative to EOF
            pos = -4

            found = False
            value = bytearray()

            # walk backwards to find the "End of Central Directory" record
            while (not found) and (-pos != file_length):
                # seek, relative to EOF
                fo.seek(pos, 2)

                value = fo.read(4)

                # look for the end of central directory signature
                if bytearray(value) == bytearray([0x50, 0x4b, 0x05, 0x06]):
                    found = True
                else:
                    # not found, step back another byte
                    pos = pos - 1

            if found:

                # now skip forward 20 bytes to the comment length word
                pos += 20
                fo.seek(pos, 2)

                # Pack the length of the comment string
                format = "H"  # one 2-byte integer
                comment_length = struct.pack(
                    format, len(comment))  # pack integer in a binary string

                # write out the length
                fo.write(comment_length)
                fo.seek(pos + 2, 2)

                # write out the comment itself
                fo.write(comment)
                fo.truncate()
                fo.close()
            else:
                raise Exception('Failed to write comment to tar file!')
        except:
            return False
        else:
            return True

    def copyFromArchive(self, otherArchive):
        # Replace the current zip with one copied from another archive
        try:
            tout = tarfile.TarFile(self.path, 'w')
            for fname in otherArchive.getArchiveFilenameList():
                data = otherArchive.readArchiveFile(fname)
                if data is not None:
                    tout.writestr(fname, data)
            tout.close()

            # preserve the old comment
            comment = otherArchive.getArchiveComment()
            if comment is not None:
                if not self.writeTarComment(self.path, comment):
                    return False
        except Exception as e:
            errMsg = u"Error while copying to {0}: {1}".format(self.path, e)
            logger.info(errMsg)
            return False
        else:
            return True

# ------------------------------------------
# RAR implementation


if rarsupport:
    class RarArchiver:

        devnull = None

        def __init__(self, path, rar_exe_path):
            self.path = path
            self.rar_exe_path = rar_exe_path

            if RarArchiver.devnull is None:
                RarArchiver.devnull = open(os.devnull, "w")

            # windows only, keeps the cmd.exe from popping up
            if platform.system() == "Windows":
                #    self.startupinfo = subprocess.STARTUPINFO()
                #    self.startupinfo.dwFlags |= _subprocess.STARTF_USESHOWWINDOW
                self.startupinfo = None
            else:
                self.startupinfo = None

        def __del__(self):
            # RarArchiver.devnull.close()
            pass

        def getArchiveComment(self):

            rarc = self.getRARObj()
            return rarc.comment

        def setArchiveComment(self, comment):

            if self.rar_exe_path is not None:
                try:
                    # write comment to temp file
                    tmp_fd, tmp_name = tempfile.mkstemp()
                    f = os.fdopen(tmp_fd, 'w+b')
                    f.write(comment)
                    f.close()

                    working_dir = os.path.dirname(os.path.abspath(self.path))

                    # use external program to write comment to Rar archive
                    subprocess.call(
                        [
                            self.rar_exe_path, 'c', '-w' + working_dir, '-c-',
                            '-z' + tmp_name, self.path
                        ],
                        startupinfo=self.startupinfo,
                        stdout=RarArchiver.devnull)

                    if platform.system() == "Darwin":
                        time.sleep(1)

                    os.remove(tmp_name)
                except:
                    return False
                else:
                    return True
            else:
                return False

        def readArchiveFile(self, archive_file):

            # Make sure to escape brackets, since some funky stuff is going on
            # underneath with "fnmatch"

            rarc = self.getRARObj()

            tries = 0
            while tries < 7:
                try:
                    tries = tries + 1
                    data = rarc.read(archive_file)
                    entries = [(rarc.getinfo(archive_file), data)]

                    if entries[0][0].file_size != len(entries[0][1]):
                        errMsg = u"readArchiveFile(): " \
                                 u"[file is not expected size: {0} vs {1}]  {2}:{3} [attempt # {4}]\n".format(
                            entries[0][0].file_size, len(entries[0][1]), self.path, archive_file, tries)
                        logger.info(errMsg)
                        continue

                except (OSError, IOError) as e:
                    errMsg = u"readArchiveFile(): [{0}]  {1}:{2} attempt#{3}".format(
                        str(e), self.path, archive_file, tries)
                    logger.info(errMsg)
                    time.sleep(1)
                except Exception as e:
                    errMsg = u"Unexpected exception in readArchiveFile(): [{0}] for {1}:{2} attempt#{3}".format(
                        str(e), self.path, archive_file, tries)
                    logger.info(errMsg)
                    break

                else:
                    # Success"
                    # entries is a list of of tuples:  ( rarinfo, filedata)
                    if tries > 1:
                        errMsg = u"Attempted read_files() {0} times".format(tries)
                        logger.info(errMsg)
                    if len(entries) == 1:
                        return entries[0][1]
                    else:
                        raise IOError

            raise IOError

        def writeArchiveFile(self, archive_file, data):

            if self.rar_exe_path is not None:
                try:
                    tmp_folder = tempfile.mkdtemp()

                    tmp_file = os.path.join(tmp_folder, archive_file)

                    working_dir = os.path.dirname(os.path.abspath(self.path))

                    # TODO: will this break if 'archive_file' is in a subfolder. i.e. "foo/bar.txt"
                    # will need to create the subfolder above, I guess...
                    f = open(tmp_file, 'w')
                    f.write(data)
                    f.close()

                    # use external program to write file to Rar archive
                    subprocess.call(
                        [
                            self.rar_exe_path, 'a', '-w' + working_dir, '-c-',
                            '-ep', self.path, tmp_file
                        ],
                        startupinfo=self.startupinfo,
                        stdout=RarArchiver.devnull)

                    if platform.system() == "Darwin":
                        time.sleep(1)
                    os.remove(tmp_file)
                    os.rmdir(tmp_folder)
                except:
                    return False
                else:
                    return True
            else:
                return False

        def removeArchiveFile(self, archive_file):
            if self.rar_exe_path is not None:
                try:
                    # use external program to remove file from Rar archive
                    subprocess.call(
                        [self.rar_exe_path, 'd', '-c-', self.path, archive_file],
                        startupinfo=self.startupinfo,
                        stdout=RarArchiver.devnull)

                    if platform.system() == "Darwin":
                        time.sleep(1)
                except:
                    return False
                else:
                    return True
            else:
                return False

        def getArchiveFilenameList(self):

            rarc = self.getRARObj()
            # namelist = [ item.filename for item in rarc.infolist() ]
            # return namelist

            tries = 0
            while tries < 7:
                try:
                    tries = tries + 1
                    # namelist = [ item.filename for item in rarc.infolist() ]
                    namelist = []
                    for item in rarc.infolist():
                        if item.file_size != 0:
                            namelist.append(item.filename)

                except (OSError, IOError) as e:
                    errMsg = u"getArchiveFilenameList(): [{0}] {1} attempt#{2}".format(
                        str(e), self.path, tries)
                    logger.info(errMsg)
                    time.sleep(1)

                else:
                    # Success"
                    return namelist

            raise e

        def getRARObj(self):
            tries = 0
            while tries < 7:
                try:
                    tries = tries + 1
                    rarfile.UNRAR_TOOL = self.rar_exe_path
                    rarc = rarfile.RarFile(self.path)

                except (OSError, IOError) as e:
                    errMsg = u"getRARObj(): [{0}] {1} attempt#{2}".format(
                        str(e), self.path, tries)
                    logger.info(errMsg)
                    time.sleep(1)

                else:
                    # Success"
                    return rarc

            raise e


# ------------------------------------------
# Folder implementation
class FolderArchiver:
    def __init__(self, path):
        self.path = path
        self.comment_file_name = "ComicTaggerFolderComment.txt"

    def getArchiveComment(self):
        return self.readArchiveFile(self.comment_file_name)

    def setArchiveComment(self, comment):
        return self.writeArchiveFile(self.comment_file_name, comment)

    def readArchiveFile(self, archive_file):

        data = ""
        fname = os.path.join(self.path, archive_file)
        try:
            with open(fname, 'rb') as f:
                data = f.read()
                f.close()
        except IOError:
            pass

        return data

    def writeArchiveFile(self, archive_file, data):

        fname = os.path.join(self.path, archive_file)
        try:
            with open(fname, 'w+') as f:
                f.write(data)
                f.close()
        except:
            return False
        else:
            return True

    def removeArchiveFile(self, archive_file):

        fname = os.path.join(self.path, archive_file)
        try:
            os.remove(fname)
        except:
            return False
        else:
            return True

    def getArchiveFilenameList(self):
        return self.listFiles(self.path)

    def listFiles(self, folder):

        itemlist = list()

        for item in os.listdir(folder):
            itemlist.append(item)
            if os.path.isdir(item):
                itemlist.extend(self.listFiles(os.path.join(folder, item)))

        return itemlist


# ------------------------------------------
# Unknown implementation
class UnknownArchiver:
    def __init__(self, path):
        self.path = path

    def getArchiveComment(self):
        return ""

    def setArchiveComment(self, comment):
        return False

    def readArchiveFile(self):
        return ""

    def writeArchiveFile(self, archive_file, data):
        return False

    def removeArchiveFile(self, archive_file):
        return False

    def getArchiveFilenameList(self):
        return []


class PdfArchiver:
    def __init__(self, path):
        self.path = path

    def getArchiveComment(self):
        return ""

    def setArchiveComment(self, comment):
        return False

    def readArchiveFile(self, page_num):
        return subprocess.check_output([
            'mudraw', '-o', '-', self.path,
            str(int(os.path.basename(page_num)[:-4]))
        ])

    def writeArchiveFile(self, archive_file, data):
        return False

    def removeArchiveFile(self, archive_file):
        return False

    def getArchiveFilenameList(self):
        out = []
        pdf = PdfFileReader(open(self.path, 'rb'))
        for page in range(1, pdf.getNumPages() + 1):
            out.append("/%04d.jpg" % (page))
        return out


# ------------------------------------------------------------------
class ComicArchive:

    logo_data = None

    class ArchiveType:
        Zip, Rar, Tar, Folder, Pdf, Unknown = range(6)

    def __init__(self, path, rar_exe_path=None, default_image_path=None):
        self.path = path

        self.rar_exe_path = rar_exe_path
        self.ci_xml_filename = 'ComicInfo.xml'
        self.comet_default_filename = 'CoMet.xml'
        self.resetCache()
        self.default_image_path = default_image_path

        # Use file extension to decide which archive test we do first
        ext = os.path.splitext(path)[1].lower()

        self.archive_type = self.ArchiveType.Unknown
        self.archiver = UnknownArchiver(self.path)

        if rarsupport and ext == ".cbr" or ext == ".rar":
            if self.rarTest():
                self.archive_type = self.ArchiveType.Rar
                self.archiver = RarArchiver(
                    self.path, rar_exe_path=self.rar_exe_path)

            elif self.zipTest():
                self.archive_type = self.ArchiveType.Zip
                self.archiver = ZipArchiver(self.path)
        else:
            if self.zipTest():
                self.archive_type = self.ArchiveType.Zip
                self.archiver = ZipArchiver(self.path)

            if self.tarTest():
                self.archive_type = self.ArchiveType.Tar
                self.archiver = TarArchiver(self.path)

            elif self.rarTest():
                self.archive_type = self.ArchiveType.Rar
                self.archiver = RarArchiver(
                    self.path, rar_exe_path=self.rar_exe_path)
            elif os.path.basename(self.path)[-3:] == 'pdf':
                self.archive_type = self.ArchiveType.Pdf
                self.archiver = PdfArchiver(self.path)

        if ComicArchive.logo_data is None and self.default_image_path:
            fname = self.default_image_path
            with open(fname, 'rb') as fd:
                ComicArchive.logo_data = fd.read()

    # Clears the cached data
    def resetCache(self):
        self.has_cix = None
        self.has_cbi = None
        self.has_comet = None
        self.comet_filename = None
        self.page_count = None
        self.page_list = None
        self.cix_md = None
        self.cbi_md = None
        self.comet_md = None

    def loadCache(self, style_list):
        for style in style_list:
            self.readMetadata(style)

    def rename(self, path):
        self.path = path
        self.archiver.path = path

    def zipTest(self):
        return zipfile.is_zipfile(self.path)

    def tarTest(self):
        return tarfile.is_tarfile(self.path)

    def rarTest(self):
        try:
            rarc = rarfile.RarFile(self.path)
        except:  # InvalidRARArchive:
            return False
        else:
            return True

    def isZip(self):
        return self.archive_type == self.ArchiveType.Zip

    def isTar(self):
        return self.archive_type == self.ArchiveType.Tar

    def isRar(self):
        return self.archive_type == self.ArchiveType.Rar

    def isPdf(self):
        return self.archive_type == self.ArchiveType.Pdf

    def isFolder(self):
        return self.archive_type == self.ArchiveType.Folder

    def isWritable(self, check_rar_status=True):
        if self.archive_type == self.ArchiveType.Unknown:
            return False

        elif check_rar_status and self.isRar() and self.rar_exe_path is None:
            return False

        elif not os.access(self.path, os.W_OK):
            return False

        elif ((self.archive_type != self.ArchiveType.Folder)
              and (not os.access(
                  os.path.dirname(os.path.abspath(self.path)), os.W_OK))):
            return False

        return True

    def isWritableForStyle(self, data_style):

        if self.isRar() and data_style == MetaDataStyle.CBI:
            return False

        return self.isWritable()

    def seemsToBeAComicArchive(self):

        if ((self.isZip() or self.isRar() or self.isPdf() or self.isTar()
             )
                and (self.getNumberOfPages() > 0)):
            return True
        else:
            return False

    def readMetadata(self, style):

        if style == MetaDataStyle.CIX:
            return self.readCIX()
        elif style == MetaDataStyle.CBI:
            return self.readCBI()
        elif style == MetaDataStyle.COMET:
            return self.readCoMet()
        else:
            return GenericMetadata()

    def writeMetadata(self, metadata, style):

        retcode = None
        if style == MetaDataStyle.CIX:
            retcode = self.writeCIX(metadata)
        elif style == MetaDataStyle.CBI:
            retcode = self.writeCBI(metadata)
        elif style == MetaDataStyle.COMET:
            retcode = self.writeCoMet(metadata)
        return retcode

    def hasMetadata(self, style):

        if style == MetaDataStyle.CIX:
            return self.hasCIX()
        elif style == MetaDataStyle.CBI:
            return self.hasCBI()
        elif style == MetaDataStyle.COMET:
            return self.hasCoMet()
        else:
            return False

    def removeMetadata(self, style):
        retcode = True
        if style == MetaDataStyle.CIX:
            retcode = self.removeCIX()
        elif style == MetaDataStyle.CBI:
            retcode = self.removeCBI()
        elif style == MetaDataStyle.COMET:
            retcode = self.removeCoMet()
        return retcode

    def getPage(self, index):

        image_data = None

        filename = self.getPageName(index)

        if filename is not None:
            try:
                image_data = self.archiver.readArchiveFile(filename)
            except IOError:
                errMsg = u"Error reading in page. Substituting logo page."
                logger.info(errMsg)
                image_data = ComicArchive.logo_data

        return image_data

    def getPageName(self, index):

        if index is None:
            return None

        page_list = self.getPageNameList()

        num_pages = len(page_list)
        if num_pages == 0 or index >= num_pages:
            return None

        return page_list[index]

    def getScannerPageIndex(self):

        scanner_page_index = None

        # make a guess at the scanner page
        name_list = self.getPageNameList()
        count = self.getNumberOfPages()

        # too few pages to really know
        if count < 5:
            return None

        # count the length of every filename, and count occurences
        length_buckets = dict()
        for name in name_list:
            fname = os.path.split(name)[1]
            length = len(fname)
            if length in length_buckets:
                length_buckets[length] += 1
            else:
                length_buckets[length] = 1

        # sort by most common
        sorted_buckets = sorted(
            length_buckets.items(), key=lambda k, v: (v, k), reverse=True)

        # statistical mode occurence is first
        mode_length = sorted_buckets[0][0]

        # we are only going to consider the final image file:
        final_name = os.path.split(name_list[count - 1])[1]

        common_length_list = list()
        for name in name_list:
            if len(os.path.split(name)[1]) == mode_length:
                common_length_list.append(os.path.split(name)[1])

        prefix = os.path.commonprefix(common_length_list)

        if mode_length <= 7 and prefix == "":
            # probably all numbers
            if len(final_name) > mode_length:
                scanner_page_index = count - 1

        # see if the last page doesn't start with the same prefix as most others
        elif not final_name.startswith(prefix):
            scanner_page_index = count - 1

        return scanner_page_index

    def getPageNameList(self, sort_list=True):

        if self.page_list is None:
            # get the list file names in the archive, and sort
            files = self.archiver.getArchiveFilenameList()

            # seems like some archive creators are on  Windows, and don't know about case-sensitivity!
            if sort_list:

                def keyfunc(k):
                    # hack to account for some weird scanner ID pages
                    # basename=os.path.split(k)[1]
                    # if basename < '0':
                    #	k = os.path.join(os.path.split(k)[0], "z" + basename)
                    return k.lower()

                files = natsorted(files, key=keyfunc)  #, signed=False)

            # make a sub-list of image files
            self.page_list = []
            for name in files:
                if (name[-4:].lower() in [
                        ".jpg", "jpeg", ".png", ".gif", "webp"
                ] and os.path.basename(name)[0] != "."):
                    self.page_list.append(name)

        return self.page_list

    def getNumberOfPages(self):

        if self.page_count is None:
            self.page_count = len(self.getPageNameList())
        return self.page_count

    def readCBI(self):
        if self.cbi_md is None:
            raw_cbi = self.readRawCBI()
            if raw_cbi is None:
                self.cbi_md = GenericMetadata()
            else:
                self.cbi_md = ComicBookInfo().metadataFromString(raw_cbi)

            self.cbi_md.setDefaultPageList(self.getNumberOfPages())

        return self.cbi_md

    def readRawCBI(self):
        if not self.hasCBI():
            return None
        else:
            return self.archiver.getArchiveComment()

    def hasCBI(self):
        if self.has_cbi is None:

            # if ( not ( self.isZip() or self.isRar()) or not self.seemsToBeAComicArchive() ):
            if not self.seemsToBeAComicArchive():
                self.has_cbi = False
            else:
                comment = self.archiver.getArchiveComment()
                self.has_cbi = ComicBookInfo().validateString(comment)

        return self.has_cbi

    def writeCBI(self, metadata):
        if metadata is not None:
            self.applyArchiveInfoToMetadata(metadata)
            cbi_string = ComicBookInfo().stringFromMetadata(metadata)
            write_success = self.archiver.setArchiveComment(cbi_string)
            if write_success:
                self.has_cbi = True
                self.cbi_md = metadata
            self.resetCache()
            return write_success
        else:
            return False

    def removeCBI(self):
        if self.hasCBI():
            write_success = self.archiver.setArchiveComment("")
            if write_success:
                self.has_cbi = False
                self.cbi_md = None
            self.resetCache()
            return write_success
        return True

    def readCIX(self):
        if self.cix_md is None:
            raw_cix = self.readRawCIX()
            if raw_cix is None or raw_cix == "":
                self.cix_md = GenericMetadata()
            else:
                self.cix_md = ComicInfoXml().metadataFromString(raw_cix)

            # validate the existing page list (make sure count is correct)
            if len(self.cix_md.pages) != 0:
                if len(self.cix_md.pages) != self.getNumberOfPages():
                    # pages array doesn't match the actual number of images we're seeing
                    # in the archive, so discard the data
                    self.cix_md.pages = []

            if len(self.cix_md.pages) == 0:
                self.cix_md.setDefaultPageList(self.getNumberOfPages())

        return self.cix_md

    def readRawCIX(self):
        if not self.hasCIX():
            return None
        try:
            raw_cix = self.archiver.readArchiveFile(self.ci_xml_filename)
        except IOError:
            print("Error reading in raw CIX!")
            raw_cix = ""
        return raw_cix

    def writeCIX(self, metadata):

        if metadata is not None:
            self.applyArchiveInfoToMetadata(metadata, calc_page_sizes=True)
            cix_string = ComicInfoXml().stringFromMetadata(metadata)
            write_success = self.archiver.writeArchiveFile(
                self.ci_xml_filename, cix_string)
            if write_success:
                self.has_cix = True
                self.cix_md = metadata
            self.resetCache()
            return write_success
        else:
            return False

    def removeCIX(self):
        if self.hasCIX():
            write_success = self.archiver.removeArchiveFile(
                self.ci_xml_filename)
            if write_success:
                self.has_cix = False
                self.cix_md = None
            self.resetCache()
            return write_success
        return True

    def hasCIX(self):
        if self.has_cix is None:

            if not self.seemsToBeAComicArchive():
                self.has_cix = False
            elif self.ci_xml_filename in self.archiver.getArchiveFilenameList(
            ):
                self.has_cix = True
            else:
                self.has_cix = False
        return self.has_cix

    def readCoMet(self):
        if self.comet_md is None:
            raw_comet = self.readRawCoMet()
            if raw_comet is None or raw_comet == "":
                self.comet_md = GenericMetadata()
            else:
                self.comet_md = CoMet().metadataFromString(raw_comet)

            self.comet_md.setDefaultPageList(self.getNumberOfPages())
            # use the coverImage value from the comet_data to mark the cover in this struct
            # walk through list of images in file, and find the matching one for md.coverImage
            # need to remove the existing one in the default
            if self.comet_md.coverImage is not None:
                cover_idx = 0
                for idx, f in enumerate(self.getPageNameList()):
                    if self.comet_md.coverImage == f:
                        cover_idx = idx
                        break
                if cover_idx != 0:
                    del (self.comet_md.pages[0]['Type'])
                    self.comet_md.pages[cover_idx][
                        'Type'] = PageType.FrontCover

        return self.comet_md

    def readRawCoMet(self):
        if not self.hasCoMet():
            errMsg = u"{} doesn't have CoMet data!".format(self.path)
            logger.info(errMsg)
            return None

        try:
            raw_comet = self.archiver.readArchiveFile(self.comet_filename)
        except IOError:
            errMsg = u"Error reading in raw CoMet!"
            logger.info(errMsg)
            raw_comet = ""
        return raw_comet

    def writeCoMet(self, metadata):

        if metadata is not None:
            if not self.hasCoMet():
                self.comet_filename = self.comet_default_filename

            self.applyArchiveInfoToMetadata(metadata)
            # Set the coverImage value, if it's not the first page
            cover_idx = int(metadata.getCoverPageIndexList()[0])
            if cover_idx != 0:
                metadata.coverImage = self.getPageName(cover_idx)

            comet_string = CoMet().stringFromMetadata(metadata)
            write_success = self.archiver.writeArchiveFile(
                self.comet_filename, comet_string)
            if write_success:
                self.has_comet = True
                self.comet_md = metadata
            self.resetCache()
            return write_success
        else:
            return False

    def removeCoMet(self):
        if self.hasCoMet():
            write_success = self.archiver.removeArchiveFile(
                self.comet_filename)
            if write_success:
                self.has_comet = False
                self.comet_md = None
            self.resetCache()
            return write_success
        return True

    def hasCoMet(self):
        if self.has_comet is None:
            self.has_comet = False
            if not self.seemsToBeAComicArchive():
                return self.has_comet

            # look at all xml files in root, and search for CoMet data, get first
            for n in self.archiver.getArchiveFilenameList():
                if (os.path.dirname(n) == ""
                        and os.path.splitext(n)[1].lower() == '.xml'):
                    # read in XML file, and validate it
                    try:
                        data = self.archiver.readArchiveFile(n)
                    except:
                        data = ""
                        errMsg = u"Error reading in Comet XML for validation!"
                        logger.info(errMsg)
                    if CoMet().validateString(data):
                        # since we found it, save it!
                        self.comet_filename = n
                        self.has_comet = True
                        break

            return self.has_comet

    def applyArchiveInfoToMetadata(self, md, calc_page_sizes=False):
        md.pageCount = self.getNumberOfPages()

        if calc_page_sizes:
            for p in md.pages:
                idx = int(p['Image'])
                if pil_available:
                    if 'ImageSize' not in p or 'ImageHeight' not in p or 'ImageWidth' not in p:
                        data = self.getPage(idx)
                        if data is not None:
                            try:
                                im = Image.open(StringIO(data))
                                w, h = im.size

                                p['ImageSize'] = str(len(data))
                                p['ImageHeight'] = str(h)
                                p['ImageWidth'] = str(w)
                            except IOError:
                                p['ImageSize'] = str(len(data))

                else:
                    if 'ImageSize' not in p:
                        data = self.getPage(idx)
                        p['ImageSize'] = str(len(data))

    def metadataFromFilename(self, parse_scan_info=True):

        metadata = GenericMetadata()

        fnp = FileNameParser()
        fnp.parseFilename(self.path)

        if fnp.issue != "":
            metadata.issue = fnp.issue
        if fnp.series != "":
            metadata.series = fnp.series
        if fnp.volume != "":
            metadata.volume = fnp.volume
        if fnp.year != "":
            metadata.year = fnp.year
        if fnp.issue_count != "":
            metadata.issueCount = fnp.issue_count
        if parse_scan_info:
            if fnp.remainder != "":
                metadata.scanInfo = fnp.remainder

        metadata.isEmpty = False

        return metadata
