# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4; coding:utf-8 -*-
#
# Copyright 2002 Ben Escoto
# Copyright 2007 Kenneth Loafman
#
# This file is part of duplicity.
#
# duplicity is free software; you can redistribute it and/or modify
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# duplicity 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 duplicity; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA

"""
A library for interfacing with the librsync library to generate and apply deltas,
calculate signatures, and construct file-like operations.

This module provides a Python interface for the librsync library, enabling users
to calculate signatures, generate deltas, and incrementally apply deltas to files.
It uses both a Python implementation and a compiled C implementation depending on
availability. The core operations are modelled through file-like objects for
incremental processing.

Classes:

- librsyncError: Custom exception class for librsync-related errors.
- LikeFile: Abstract file-like object used as a base for signature, delta, and
  patched files.
- DeltaFile: File-like object to generate deltas based on a signature and a new file.
- PatchedFile: File-like object to apply deltas incrementally to create a patched file.
- SigGenerator: Class used to calculate signatures with an interface similar to
  the md5 module.

Functions:

- get_version: Retrieve the version of the underlying librsync library.

Attributes:

- blocksize: Integer denoting the block size used in librsync operations.
"""

import array
import os

from duplicity import log


def get_version():
    """
    Return the version of librsync
    """
    return _librsync.get_version()


# fmt:off
try:
    from . import _librsync
    log.Info(f"Using compiled-C version of _librsync {get_version()}")
except (ImportError, Exception):
    from . import _librsync_ctypes as _librsync
    log.Info(f"Using pure-Python version of _librsync {get_version()}")
# fmt:on

if os.environ.get("READTHEDOCS") == "True":
    import unittest.mock as mock
    import duplicity

    duplicity._librsync = mock.MagicMock()

blocksize = _librsync.RS_JOB_BLOCKSIZE


class librsyncError(Exception):
    """Signifies error in internal librsync processing (bad signature, etc.)

    underlying _librsync.librsyncError's are regenerated using this
    class because the C-created exceptions are by default
    unPickleable.  There is probably a way to fix this in _librsync,
    but this scheme was easier.

    """

    pass


class LikeFile(object):
    """
    File-like object used by SigFile, DeltaFile, and PatchFile
    """

    mode = "rb"

    # This will be replaced in subclasses by an object with
    # appropriate cycle() method
    maker = None

    def __init__(self, infile, need_seek=None):
        """
        LikeFile initializer - zero buffers, set eofs off
        """
        self.check_file(infile, need_seek)
        self.infile = infile
        self.closed = self.infile_closed = None
        self.inbuf = b""
        self.outbuf = array.array("b")
        self.eof = self.infile_eof = None

    def check_file(self, file, need_seek=None):
        """
        Raise type error if file doesn't have necessary attributes
        """
        if not hasattr(file, "read"):
            raise TypeError("Basis file must have a read() method")
        if not hasattr(file, "close"):
            raise TypeError("Basis file must have a close() method")
        if need_seek and not hasattr(file, "seek"):
            raise TypeError("Basis file must have a seek() method")

    def read(self, length=-1):
        """
        Build up self.outbuf, return first length bytes
        """
        if length == -1:
            while not self.eof:
                self._add_to_outbuf_once()
            real_len = len(self.outbuf)
        else:
            while not self.eof and len(self.outbuf) < length:
                self._add_to_outbuf_once()
            real_len = min(length, len(self.outbuf))

        return_val = self.outbuf[:real_len].tobytes()
        del self.outbuf[:real_len]
        return return_val

    def _add_to_outbuf_once(self):
        """
        Add one cycle's worth of output to self.outbuf
        """
        if not self.infile_eof:
            self._add_to_inbuf()
        try:
            self.eof, len_inbuf_read, cycle_out = self.maker.cycle(self.inbuf)
        except _librsync.librsyncError as e:
            raise librsyncError(str(e))
        self.inbuf = self.inbuf[len_inbuf_read:]
        self.outbuf.frombytes(cycle_out)

    def _add_to_inbuf(self):
        """
        Make sure len(self.inbuf) >= blocksize
        """
        assert not self.infile_eof, "infile_eof already set"
        while len(self.inbuf) < blocksize:
            new_in = self.infile.read(blocksize)
            if not new_in:
                self.infile_eof = 1
                assert not self.infile.close(), "self.infile failed to close"
                self.infile_closed = 1
                break
            self.inbuf += new_in

    def close(self):
        """
        Close infile
        """
        if not self.infile_closed:
            assert not self.infile.close(), "self.infile failed to close"
        self.closed = 1


class DeltaFile(LikeFile):
    """
    File-like object which incrementally generates a librsync delta
    """

    def __init__(self, signature, new_file):
        """
        Initialize a DeltaFile.

        :param signature: The signature to use, either as bytes or a file-like object.
        :param new_file: The new file to generate a delta for (file-like object).
        :raises librsyncError: If the signature is invalid.
        """
        LikeFile.__init__(self, new_file)
        if isinstance(signature, bytes):
            sig_string = signature
        else:
            self.check_file(signature)
            sig_string = signature.read()
            assert not signature.close(), "signature failed to close"
        try:
            self.maker = _librsync.new_deltamaker(sig_string)
        except _librsync.librsyncError as e:
            raise librsyncError(str(e))


class PatchedFile(LikeFile):
    """
    File-like object which applies a librsync delta incrementally
    """

    def __init__(self, basis_file, delta_file):
        """
        Initialize a PatchedFile.

        :param basis_file: The original file to patch. Must be a true Python
            file or have a ``fileno()`` method (except when using the ctypes backend).
        :param delta_file: The delta file to apply (file-like object).
        :raises TypeError: If ``basis_file`` is not a valid file object.
        :raises librsyncError: If an error occurs during patching.
        """
        LikeFile.__init__(self, delta_file)
        if hasattr(_librsync, "SigMaker"):
            # ctypes backend doesn't need fileno()
            self.maker = _librsync.new_patchmaker(basis_file)
        else:
            try:
                basis_file.fileno()
            except Exception as e:
                """tempfile.TemporaryFile() only guarantees a true file
                object on posix platforms. on cygwin/windows a file-like
                object whose file attribute is the underlying true file
                object is returned.
                """
                if hasattr(basis_file, "file") and hasattr(basis_file.file, "fileno"):
                    basis_file = basis_file.file
                else:
                    raise TypeError(
                        _(
                            "basis_file must be a (true) file or an object whose "
                            "file attribute is the underlying true file object"
                        )
                    )
            try:
                self.maker = _librsync.new_patchmaker(basis_file)
            except _librsync.librsyncError as e:
                raise librsyncError(str(e))


class SigGenerator(object):
    """Calculate signature.

    Interface is like md5 module

    """

    def __init__(self, blocksize=_librsync.RS_DEFAULT_BLOCK_LEN):
        """
        Return new signature instance
        """
        try:
            self.sig_maker = _librsync.new_sigmaker(blocksize)
        except _librsync.librsyncError as e:
            raise librsyncError(str(e))
        self.gotsig = None
        self.buffer = b""
        self.sigstring_list = []

    def update(self, buf):
        """
        Add data to be included in the signature calculation.

        :param bytes buf: The data to add.
        :raises librsyncError: If the signature has already been generated or
            if an internal error occurs.
        """
        if self.gotsig:
            raise librsyncError("SigGenerator already provided signature")
        self.buffer += buf
        while len(self.buffer) >= blocksize:
            if self.process_buffer():
                raise librsyncError("Premature EOF received from sig_maker")

    def process_buffer(self):
        """
        Run self.buffer through sig_maker, add to self.sig_string
        """
        try:
            eof, len_buf_read, cycle_out = self.sig_maker.cycle(self.buffer)
        except _librsync.librsyncError as e:
            raise librsyncError(str(e))
        self.buffer = self.buffer[len_buf_read:]
        self.sigstring_list.append(cycle_out)
        return eof

    def getsig(self):
        """
        Return the calculated signature over all added data.

        :returns: The signature as bytes.
        :rtype: bytes
        """
        while not self.process_buffer():
            pass  # keep running until eof
        return b"".join(self.sigstring_list)
