# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2016, 2019 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.


import logging
import os
import subprocess
import time
from typing import BinaryIO, Tuple

from snapcraft import file_utils
from snapcraft.internal.deltas.errors import (
    DeltaFormatOptionError,
    DeltaGenerationError,
    DeltaGenerationTooBigError,
)


logger = logging.getLogger(__name__)


delta_format_options = ["xdelta3"]


class BaseDeltasGenerator:
    """Class for delta generation

    This class is responsible for the snap delta file generation
    """

    delta_size_min_pct = 90

    def __init__(
        self,
        *,
        source_path: str,
        target_path: str,
        delta_tool: str,
        delta_format: str,
        delta_file_extname: str = "delta"
    ) -> None:
        self.source_path = source_path
        self.target_path = target_path
        self.delta_format = delta_format
        self.delta_file_extname = delta_file_extname
        self.delta_tool_path = file_utils.get_tool_path(delta_tool)

        # some pre-checks
        self._check_properties()
        self._check_file_existence()

    def _check_properties(self):
        if self.delta_format not in delta_format_options:
            raise DeltaFormatOptionError(
                delta_format=self.delta_format, format_options_list=delta_format_options
            )

    def _check_file_existence(self):
        if not os.path.exists(self.source_path):
            raise ValueError(
                "source file {!r} does not exist, "
                "please specify a valid source file".format(self.source_path)
            )

        if not os.path.exists(self.target_path):
            raise ValueError(
                "target file {!r} does not exist, "
                "please specify a valid target file".format(self.target_path)
            )

    def _check_delta_size_constraint(self, delta_path: str) -> None:
        """Ensure delta is sufficiently smaller than target snap.

        Although bandwidth is still saved in the case of uploading a delta
        nearly as large as the target snap, as there is a cost for delta
        reconstitution in the delta-service, pushing the full snap may be
        faster.

        Further benchmarking may help to determine the most appropriate value
        for `delta_size_min_pct`.
        """
        target_size = os.path.getsize(self.target_path)
        delta_size = os.path.getsize(delta_path)

        ratio = int((delta_size / target_size) * 100)
        if ratio >= self.delta_size_min_pct:
            raise DeltaGenerationTooBigError(
                delta_min_percentage=100 - self.delta_size_min_pct
            )

    def find_unique_file_name(self, path_hint: str) -> str:
        """Return a path on disk similar to 'path_hint' that does not exist.

        This function can be used to ensure that 'path_hint' points to a file
        on disk that does not exist. The returned filename may be a modified
        version of 'path_hint' if 'path_hint' already exists.
        """
        target = path_hint
        counter = 0
        while os.path.exists(target):
            target = "{}-{}".format(path_hint, counter)
            counter += 1
        return target

    def _setup_std_output(
        self, delta_file: str
    ) -> Tuple[str, str, BinaryIO, str, BinaryIO]:
        """Helper to setup the stdout and stderr for subprocess"""
        workdir = "/tmp/"
        _, delta_name = os.path.split(delta_file)

        stdout_path = self.find_unique_file_name(
            os.path.join(workdir, "{}.out".format(delta_name))
        )
        stdout_file = open(stdout_path, "wb")

        stderr_path = self.find_unique_file_name(
            os.path.join(workdir, "{}.err".format(delta_name))
        )
        stderr_file = open(stderr_path, "wb")

        return workdir, stdout_path, stdout_file, stderr_path, stderr_file

    def _update_progress_indicator(self, proc, progress_indicator) -> None:
        """Update the progress indicator"""
        # the caller should start the progressbar outside
        ret = None
        count = 0
        ret = proc.poll()
        while ret is None:
            if count >= progress_indicator.maxval:
                progress_indicator.start()
                count = 0
            progress_indicator.update(count)
            count += 1
            time.sleep(0.2)
            ret = proc.poll()
        print("")
        # the caller should finish the progressbar outside

    def make_delta(
        self, output_dir: str = None, progress_indicator=None, is_for_test=False
    ) -> str:
        """Call the delta generation tool to create the delta file.

        returns: generated delta file path
        """
        logger.info(
            "Generating delta for {!r}.".format(os.path.basename(self.target_path))
        )

        if output_dir is not None:
            # consider creating the delta file in the specified output_dir
            # with generated filename.
            if not os.path.exists(output_dir):
                os.makedirs(output_dir, exist_ok=True)

            _, _file_name = os.path.split(self.target_path)
            full_filename = os.path.join(output_dir, _file_name)
            delta_file = self.find_unique_file_name(
                "{}.{}".format(full_filename, self.delta_file_extname)
            )
        else:
            # create the delta file under the target_path with
            # the generated filename.
            delta_file = self.find_unique_file_name(
                "{}.{}".format(self.target_path, self.delta_file_extname)
            )

        delta_cmd = self.get_delta_cmd(self.source_path, self.target_path, delta_file)

        (
            workdir,
            stdout_path,
            stdout_file,
            stderr_path,
            stderr_file,
        ) = self._setup_std_output(delta_file)

        proc = subprocess.Popen(
            delta_cmd, stdout=stdout_file, stderr=stderr_file, cwd=workdir
        )

        if progress_indicator:
            self._update_progress_indicator(proc, progress_indicator)
        else:
            proc.wait()

        stdout_file.close()
        stderr_file.close()

        if proc.returncode != 0:
            _stdout = _stderr = ""
            with open(stdout_path) as f:
                _stdout = f.read()
            with open(stderr_path) as f:
                _stderr = f.read()

            # cleanup the testcase std logs
            if is_for_test:
                os.remove(stdout_path)
                os.remove(stderr_path)

            raise DeltaGenerationError(
                delta_format=self.delta_format,
                stdout_path=stdout_path,
                stdout=_stdout,
                stderr_path=stderr_path,
                stderr=_stderr,
                returncode=proc.returncode,
            )

        self._check_delta_size_constraint(delta_file)

        self.log_delta_file(delta_file)

        # is used for log file cleanup in unittest
        if is_for_test:
            os.remove(stdout_path)
            os.remove(stderr_path)

        return delta_file

    # ------------------------------------------------------
    # the methods need to be implemented in subclass
    # ------------------------------------------------------
    def get_delta_cmd(self, source_path, target_path, delta_file):
        """Get the delta generation command line"""
        raise NotImplementedError

    def is_returncode_unexpected(self, proc):
        """Check if the subprocess return code is expected"""
        raise NotImplementedError

    def log_delta_file(self, delta_file):
        """Log the delta generation result"""
        pass
