#
#  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 os

from ._cas.casremote import BlobNotFound
from ._assetcache import AssetCache
from ._exceptions import AssetCacheError, CASError, CASRemoteError, SourceCacheError
from . import utils
from ._protos.buildstream.v2 import source_pb2

REMOTE_ASSET_SOURCE_URN_TEMPLATE = "urn:fdc:buildstream.build:2020:source:{}"


# Class that keeps config of remotes and deals with caching of sources.
#
# Args:
#    context (Context): The Buildstream context
#
class ElementSourcesCache(AssetCache):
    def __init__(self, context):
        super().__init__(context)

        self._basedir = os.path.join(context.cachedir, "elementsources")
        os.makedirs(self._basedir, exist_ok=True)

    # load_proto():
    #
    # Load source proto from local cache.
    #
    # Args:
    #    sources (ElementSources): The sources whose proto we want to load
    #
    def load_proto(self, sources):
        ref = sources.get_cache_key()
        path = self._source_path(ref)

        if not os.path.exists(path):
            return None

        source_proto = source_pb2.Source()
        with open(path, "r+b") as f:
            source_proto.ParseFromString(f.read())
            return source_proto

    def store_proto(self, sources, proto):
        ref = sources.get_cache_key()
        path = self._source_path(ref)

        with utils.save_file_atomic(path, "w+b") as f:
            f.write(proto.SerializeToString())

    # pull():
    #
    # Attempts to pull sources from configured remote source caches.
    #
    # Args:
    #    sources (ElementSources): The sources we want to fetch
    #
    # Returns:
    #    (bool): True if pull successful, False if not
    #
    def pull(self, sources, plugin):
        project = sources.get_project()

        ref = sources.get_cache_key()
        display_key = sources.get_brief_display_key()

        uri = REMOTE_ASSET_SOURCE_URN_TEMPLATE.format(ref)

        index_remotes, storage_remotes = self.get_remotes(project.name, False)

        source_digest = None
        errors = []
        # Start by pulling our source proto, so that we know which
        # blobs to pull
        for remote in index_remotes:
            remote.init()
            try:
                plugin.status("Pulling source {} <- {}".format(display_key, remote))
                response = remote.fetch_blob([uri])
                if response:
                    source_digest = response.blob_digest
                    break

                plugin.info("Remote ({}) does not have source {} cached".format(remote, display_key))
            except AssetCacheError as e:
                plugin.warn("Could not pull from remote {}: {}".format(remote, e))
                errors.append(e)

        if errors and not source_digest:
            raise SourceCacheError(
                "Failed to pull source {}".format(display_key),
                detail="\n".join(str(e) for e in errors),
                temporary=True,
            )

        # If we don't have a source proto, we can't pull source files
        if not source_digest:
            return False

        errors = []
        for remote in storage_remotes:
            remote.init()
            try:
                plugin.status("Pulling data for source {} <- {}".format(display_key, remote))

                if self._pull_source_storage(ref, source_digest, remote):
                    plugin.info("Pulled source {} <- {}".format(display_key, remote))
                    return True

                plugin.info("Remote ({}) does not have source {} cached".format(remote, display_key))
            except BlobNotFound as e:
                # Not all blobs are available on this remote
                plugin.info("Remote cas ({}) does not have blob {} cached".format(remote, e.blob))
                continue
            except CASError as e:
                plugin.warn("Could not pull from remote {}: {}".format(remote, e))
                errors.append(e)

        if errors:
            raise SourceCacheError(
                "Failed to pull source {}".format(display_key), detail="\n".join(str(e) for e in errors)
            )

        return False

    # push():
    #
    # Push sources to remote repository.
    #
    # Args:
    #    sources (ElementSources): The sources to be pushed
    #
    # Returns:
    #   (bool): True if any remote was updated, False if no pushes were required
    #
    # Raises:
    #   (SourceCacheError): if there was an error
    #
    def push(self, sources, plugin):
        project = sources.get_project()

        ref = sources.get_cache_key()
        display_key = sources.get_brief_display_key()

        uri = REMOTE_ASSET_SOURCE_URN_TEMPLATE.format(ref)

        index_remotes, storage_remotes = self.get_remotes(project.name, True)

        source_proto = self.load_proto(sources)
        source_digest = self.cas.add_object(buffer=source_proto.SerializeToString())

        pushed = False

        # First push our files to all storage remotes, so that they
        # can perform file checks on their end
        for remote in storage_remotes:
            remote.init()
            plugin.status("Pushing data from source {} -> {}".format(display_key, remote))

            if self._push_source_blobs(source_proto, source_digest, remote):
                plugin.info("Pushed data from source {} -> {}".format(display_key, remote))
            else:
                plugin.info("Remote ({}) already has all data of source {} cached".format(remote, display_key()))

        for remote in index_remotes:
            remote.init()
            plugin.status("Pushing source {} -> {}".format(display_key, remote))

            if self._push_source_proto(uri, source_proto, source_digest, remote):
                plugin.info("Pushed source {} -> {}".format(display_key, remote))
                pushed = True
            else:
                plugin.info("Remote ({}) already has source {} cached".format(remote, display_key))

        return pushed

    def _get_source(self, ref):
        path = self._source_path(ref)
        source_proto = source_pb2.Source()
        try:
            with open(path, "r+b") as f:
                source_proto.ParseFromString(f.read())
                return source_proto
        except FileNotFoundError as e:
            raise SourceCacheError("Attempted to access unavailable source: {}".format(e)) from e

    def _source_path(self, ref):
        return os.path.join(self._basedir, ref)

    # _push_source_blobs()
    #
    # Push the blobs that make up an source to the remote server.
    #
    # Args:
    #    source_proto: The source proto whose blobs to push.
    #    source_digest: The digest of the source proto.
    #    remote (CASRemote): The remote to push the blobs to.
    #
    # Returns:
    #    (bool) - True if we uploaded anything, False otherwise.
    #
    # Raises:
    #    SourceCacheError: If we fail to push blobs (*unless* they're
    #    already there or we run out of space on the server).
    #
    def _push_source_blobs(self, source_proto, source_digest, remote):
        try:
            # Push source files
            self.cas._send_directory(remote, source_proto.files)
            # Push source proto
            self.cas.send_blobs(remote, [source_digest])

        except CASRemoteError as cas_error:
            if cas_error.reason != "cache-too-full":
                raise SourceCacheError("Failed to push source blobs: {}".format(cas_error))
            return False

        return True

    # _push_source_proto()
    #
    # Pushes the source proto to remote.
    #
    # Args:
    #    source_proto: The source proto.
    #    source_digest: The digest of the source proto.
    #    remote (AssetRemote): Remote to push to
    #
    # Returns:
    #    (bool): Whether we pushed the source.
    #
    # Raises:
    #    SourceCacheError: If the push fails for any reason except the
    #    source already existing.
    #
    def _push_source_proto(self, uri, source_proto, source_digest, remote):
        try:
            response = remote.fetch_blob([uri])
            # Skip push if source is already on the server
            if response and response.blob_digest == source_digest:
                return False
        except AssetCacheError as e:
            raise SourceCacheError("Error checking source cache: {}".format(e), temporary=True) from e

        referenced_directories = [source_proto.files]

        try:
            remote.push_blob(
                [uri],
                source_digest,
                references_directories=referenced_directories,
            )
        except AssetCacheError as e:
            raise SourceCacheError("Failed to push source: {}".format(e), temporary=True)

        return True

    # _pull_source_storage():
    #
    # Pull source blobs from the given remote.
    #
    # Args:
    #    key (str): The specific key for the source to pull
    #    remote (CASRemote): remote to pull from
    #
    # Returns:
    #    (bool): True if we pulled any blobs.
    #
    # Raises:
    #    SourceCacheError: If the pull failed for any reason except the
    #    blobs not existing on the server.
    #
    def _pull_source_storage(self, key, source_digest, remote):
        try:
            # Fetch and parse source proto
            self.cas.fetch_blobs(remote, [source_digest])
            source = source_pb2.Source()
            with self.cas.open(source_digest, "rb") as f:
                source.ParseFromString(f.read())

            # Write the source proto to cache
            source_path = os.path.join(self._basedir, key)
            with utils.save_file_atomic(source_path, mode="wb") as f:
                f.write(source.SerializeToString())

            self.cas.fetch_directory(remote, source.files)
        except BlobNotFound:
            return False
        except CASRemoteError as e:
            raise SourceCacheError("Failed to pull source: {}".format(e), temporary=True)

        return True

    def _push_source(self, source_ref, remote):
        uri = REMOTE_ASSET_SOURCE_URN_TEMPLATE.format(source_ref)

        try:
            remote.init()
            source_proto = self._get_source(source_ref)
            remote.push_directory([uri], source_proto.files)
            return True

        except AssetCacheError as e:
            raise SourceCacheError("Failed to push source: {}".format(e), temporary=True)
        return False
