# This file is part of torf.
#
# torf is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# torf is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with torf.  If not, see <https://www.gnu.org/licenses/>.

import os


class TorfError(Exception):
    """Base exception for all exceptions raised by torf"""
    def __init__(self, msg, *posargs, **kwargs):
        super().__init__(msg)
        self.posargs = posargs
        self.kwargs = kwargs


class URLError(TorfError):
    """Invalid URL"""
    def __init__(self, url):
        self._url = url
        super().__init__(f'{url}: Invalid URL', url)

    @property
    def url(self):
        """The invalid URL"""
        return self._url


class PieceSizeError(TorfError):
    """Invalid piece size"""
    def __init__(self, size, min=None, max=None):
        self._size = size
        self._min = min
        self._max = max
        if min is not None and max is not None:
            super().__init__(f'Piece size must be between {min} and {max}: {size}',
                             size, min=min, max=max)
        else:
            super().__init__(f'Piece size must be divisible by 16 KiB: {size}',
                             size)

    @property
    def size(self):
        """The invalid piece size"""
        return self._size

    @property
    def min(self):
        """Smallest allowed piece size or ``None``"""
        return self._min

    @property
    def max(self):
        """Largest allowed piece size or ``None``"""
        return self._max


class MetainfoError(TorfError):
    """Invalid torrent metainfo"""
    def __init__(self, msg):
        super().__init__(f'Invalid metainfo: {msg}', msg)


class BdecodeError(TorfError):
    """Failed to decode bencoded byte sequence"""
    def __init__(self, filepath=None):
        self._filepath = filepath
        if filepath is None:
            super().__init__('Invalid metainfo format')
        else:
            super().__init__(f'{filepath}: Invalid torrent file format', filepath)

    @property
    def filepath(self):
        """Path of the offending torrent file or ``None``"""
        return self._filepath


class MagnetError(TorfError):
    """Invalid magnet URI or value"""
    def __init__(self, uri, reason=None):
        self._uri = uri
        self._reason = reason
        if reason is not None:
            super().__init__(f'{uri}: {reason}', uri, reason=reason)
        else:
            super().__init__(f'{uri}: Invalid magnet URI', uri)

    @property
    def uri(self):
        """The invalid URI"""
        return self._uri

    @property
    def reason(self):
        """Why URI is invalid"""
        return self._reason


class PathError(TorfError):
    """General invalid or unexpected path"""
    def __init__(self, path, msg):
        self._path = path
        super().__init__(f'{path}: {msg}', path, msg)

    @property
    def path(self):
        """Path of the offending file or directory"""
        return self._path


class CommonPathError(TorfError):
    """Files don't share parent directory"""
    def __init__(self, filepaths):
        self._filepaths = filepaths
        filepaths_str = ', '.join(str(fp) for fp in filepaths)
        super().__init__(f'No common parent path: {filepaths_str}', filepaths)

    @property
    def filepaths(self):
        """Paths to offending files"""
        return self._filepaths


class VerifyIsDirectoryError(TorfError):
    """Expected file but found directory"""
    def __init__(self, path):
        self._path = path
        super().__init__(f'{path}: Is a directory', path)

    @property
    def path(self):
        """Path of the offending directory"""
        return self._path


class VerifyNotDirectoryError(TorfError):
    """Expected (link to) directory, but found something else"""
    def __init__(self, path):
        self._path = path
        super().__init__(f'{path}: Not a directory', path)

    @property
    def path(self):
        """Path of the offending non-directory"""
        return self._path


class VerifyFileSizeError(TorfError):
    """Unexpected file size"""
    def __init__(self, filepath, actual_size, expected_size):
        self._filepath = filepath
        self._actual_size = actual_size
        self._expected_size = expected_size
        if actual_size > expected_size:
            super().__init__(f'{filepath}: Too big: {actual_size} instead of {expected_size} bytes',
                             filepath, actual_size=actual_size, expected_size=expected_size)
        elif actual_size < expected_size:
            super().__init__(f'{filepath}: Too small: {actual_size} instead of {expected_size} bytes',
                             filepath, actual_size=actual_size, expected_size=expected_size)
        else:
            raise RuntimeError(f'Unjustified: actual_size={actual_size} == expected_size={expected_size}')

    @property
    def filepath(self):
        """Path of the offending file"""
        return self._filepath

    @property
    def actual_size(self):
        """Size as reported by the file system"""
        return self._actual_size

    @property
    def expected_size(self):
        """Size as specified in the metainfo"""
        return self._expected_size


class VerifyContentError(TorfError):
    """On-disk data does not match hashes in metainfo"""
    def __init__(self, filepath, piece_index, piece_size, file_sizes):
        self._filepath = filepath
        self._piece_index = piece_index
        self._piece_size = piece_size
        msg = f'Corruption in piece {piece_index + 1}'

        if len(file_sizes) < 1:
            raise RuntimeError('file_sizes argument is empty: {file_sizes!r}')
        elif len(file_sizes) == 1:
            corrupt_files = (file_sizes[0][0],)
        else:
            corrupt_files = []

            # Find the slice in the whole stream of files that contains the
            # corruption (piece_index=0 is the first piece)
            err_i_beg = piece_index * piece_size
            err_i_end = err_i_beg + piece_size

            # Find the files that are covered by the corrupt piece
            cur_pos = 0
            for filepath,filesize in file_sizes:
                # `file` is possibly corrupt if:
                # 1. The corrupt piece STARTS between the beginning and the end
                #    of the file in the stream.
                # 2. The corrupt piece ENDS between the beginning and the end
                #    of the file in the stream.
                # 3. Both beginning and end of the file are between beginning
                #    and end of the corrupt piece (i.e. file fits in one piece).
                file_i_beg = cur_pos
                file_i_end = file_i_beg + filesize
                if (file_i_beg <= err_i_beg < file_i_end or
                    file_i_beg < err_i_end <= file_i_end or
                    (file_i_beg >= err_i_beg and file_i_end < err_i_end)):
                    corrupt_files.append(filepath)
                cur_pos += filesize

            if len(corrupt_files) == 1:
                msg += f' in {corrupt_files[0]}'
            else:
                msg += (', at least one of these files is corrupt: ' +
                        ', '.join(str(f) for f in corrupt_files))

        self._files = tuple(corrupt_files)
        super().__init__(msg, filepath, piece_index, piece_size, file_sizes)

    @property
    def filepath(self):
        """Path to file that caused the piece corruption"""
        return self._filepath

    @property
    def piece_index(self):
        """Index of the corrupt piece in the stream of concatenated files"""
        return self._piece_index

    @property
    def piece_size(self):
        """Size of the corrupt piece in bytes"""
        return self._piece_size

    @property
    def files(self):
        """Potentially corrupt neighboring files"""
        return self._files


class ReadError(TorfError):
    """Unreadable file or stream"""
    def __init__(self, errno, path=None):
        self._errno = errno
        self._path = path
        msg = os.strerror(errno) if errno else 'Unable to read'
        if path is None:
            super().__init__(f'{msg}', errno)
        else:
            super().__init__(f'{path}: {msg}', errno, path)

    @property
    def path(self):
        """Path of the offending file or ``None``"""
        return self._path

    @property
    def errno(self):
        """POSIX error number from errno.h"""
        return self._errno


class MemoryError(TorfError, MemoryError):
    """
    Out of memory

    See also :class:`MemoryError`.
    """


class WriteError(TorfError):
    """Unwritable file or stream"""
    def __init__(self, errno, path=None):
        self._errno = errno
        self._path = path
        msg = os.strerror(errno) if errno else 'Unable to write'
        if path is None:
            super().__init__(f'{msg}', path)
        else:
            super().__init__(f'{path}: {msg}', errno, path)

    @property
    def path(self):
        """Path of the offending file or ``None``"""
        return self._path

    @property
    def errno(self):
        """POSIX error number from errno.h"""
        return self._errno


class ConnectionError(TorfError):
    """Unwritable file or stream"""
    def __init__(self, url, msg='Failed'):
        self._url = url
        self._msg = str(msg)
        super().__init__(f'{url}: {msg}', url, msg)

    @property
    def url(self):
        """URL that caused the exception"""
        return self._url
