#
#  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.
#
#  Authors:
#        Tristan Maat <tristan.maat@codethink.co.uk>

import os
from . import utils
from . import _yaml

from ._exceptions import LoadError
from .exceptions import LoadErrorReason

BST_WORKSPACE_FORMAT_VERSION = 4
BST_WORKSPACE_PROJECT_FORMAT_VERSION = 1
WORKSPACE_PROJECT_FILE = ".bstproject.yaml"


# WorkspaceProject()
#
# An object to contain various helper functions and data required for
# referring from a workspace back to buildstream.
#
# Args:
#    directory (str): The directory that the workspace exists in.
#
class WorkspaceProject:
    def __init__(self, directory):
        self._projects = []
        self._directory = directory

    # get_default_project_path()
    #
    # Retrieves the default path to a project.
    #
    # Returns:
    #    (str): The path to a project
    #
    def get_default_project_path(self):
        return self._projects[0]["project-path"]

    # get_default_element()
    #
    # Retrieves the name of the element that owns this workspace.
    #
    # Returns:
    #    (str): The name of an element
    #
    def get_default_element(self):
        return self._projects[0]["element-name"]

    # to_dict()
    #
    # Turn the members data into a dict for serialization purposes
    #
    # Returns:
    #    (dict): A dict representation of the WorkspaceProject
    #
    def to_dict(self):
        ret = {
            "projects": self._projects,
            "format-version": BST_WORKSPACE_PROJECT_FORMAT_VERSION,
        }
        return ret

    # from_dict()
    #
    # Loads a new WorkspaceProject from a simple dictionary
    #
    # Args:
    #    directory (str): The directory that the workspace exists in
    #    dictionary (dict): The dict to generate a WorkspaceProject from
    #
    # Returns:
    #   (WorkspaceProject): A newly instantiated WorkspaceProject
    #
    @classmethod
    def from_dict(cls, directory, dictionary):
        # Only know how to handle one format-version at the moment.
        format_version = int(dictionary["format-version"])
        assert format_version == BST_WORKSPACE_PROJECT_FORMAT_VERSION, "Format version {} not found in {}".format(
            BST_WORKSPACE_PROJECT_FORMAT_VERSION, dictionary
        )

        workspace_project = cls(directory)
        for item in dictionary["projects"]:
            workspace_project.add_project(item["project-path"], item["element-name"])

        return workspace_project

    # load()
    #
    # Loads the WorkspaceProject for a given directory.
    #
    # Args:
    #    directory (str): The directory
    # Returns:
    #    (WorkspaceProject): The created WorkspaceProject, if in a workspace, or
    #    (NoneType): None, if the directory is not inside a workspace.
    #
    @classmethod
    def load(cls, directory):
        workspace_file = os.path.join(directory, WORKSPACE_PROJECT_FILE)
        if os.path.exists(workspace_file):
            data_dict = _yaml.roundtrip_load(workspace_file)

            return cls.from_dict(directory, data_dict)
        else:
            return None

    # write()
    #
    # Writes the WorkspaceProject to disk
    #
    def write(self):
        os.makedirs(self._directory, exist_ok=True)
        _yaml.roundtrip_dump(self.to_dict(), self.get_filename())

    # get_filename()
    #
    # Returns the full path to the workspace local project file
    #
    def get_filename(self):
        return os.path.join(self._directory, WORKSPACE_PROJECT_FILE)

    # add_project()
    #
    # Adds an entry containing the project's path and element's name.
    #
    # Args:
    #    project_path (str): The path to the project that opened the workspace.
    #    element_name (str): The name of the element that the workspace belongs to.
    #
    def add_project(self, project_path, element_name):
        assert project_path and element_name
        self._projects.append({"project-path": project_path, "element-name": element_name})


# WorkspaceProjectCache()
#
# A class to manage workspace project data for multiple workspaces.
#
class WorkspaceProjectCache:
    def __init__(self):
        self._projects = {}  # Mapping of a workspace directory to its WorkspaceProject

    # get()
    #
    # Returns a WorkspaceProject for a given directory, retrieving from the cache if
    # present.
    #
    # Args:
    #    directory (str): The directory to search for a WorkspaceProject.
    #
    # Returns:
    #    (WorkspaceProject): The WorkspaceProject that was found for that directory.
    #    or      (NoneType): None, if no WorkspaceProject can be found.
    #
    def get(self, directory):
        try:
            workspace_project = self._projects[directory]
        except KeyError:
            workspace_project = WorkspaceProject.load(directory)
            if workspace_project:
                self._projects[directory] = workspace_project

        return workspace_project

    # add()
    #
    # Adds the project path and element name to the WorkspaceProject that exists
    # for that directory
    #
    # Args:
    #    directory (str): The directory to search for a WorkspaceProject.
    #    project_path (str): The path to the project that refers to this workspace
    #    element_name (str): The element in the project that was refers to this workspace
    #
    # Returns:
    #    (WorkspaceProject): The WorkspaceProject that was found for that directory.
    #
    def add(self, directory, project_path, element_name):
        workspace_project = self.get(directory)
        if not workspace_project:
            workspace_project = WorkspaceProject(directory)
            self._projects[directory] = workspace_project

        workspace_project.add_project(project_path, element_name)
        return workspace_project

    # remove()
    #
    # Removes the project path and element name from the WorkspaceProject that exists
    # for that directory.
    #
    # NOTE: This currently just deletes the file, but with support for multiple
    # projects opening the same workspace, this will involve decreasing the count
    # and deleting the file if there are no more projects.
    #
    # Args:
    #    directory (str): The directory to search for a WorkspaceProject.
    #
    def remove(self, directory):
        workspace_project = self.get(directory)
        if not workspace_project:
            raise LoadError(
                "Failed to find a {} file to remove".format(WORKSPACE_PROJECT_FILE), LoadErrorReason.MISSING_FILE
            )
        path = workspace_project.get_filename()
        try:
            os.unlink(path)
        except FileNotFoundError:
            pass


# Workspace()
#
# An object to contain various helper functions and data required for
# workspaces.
#
# last_build and path are intended to be public
# properties, but may be best accessed using this classes' helper
# methods.
#
# Args:
#    toplevel_project (Project): Top project. Will be used for resolving relative workspace paths.
#    path (str): The path that should host this workspace
#    last_build (str): The key of the last attempted build of this workspace
#
class Workspace:
    def __init__(self, toplevel_project, *, last_build=None, path=None):
        self.last_build = last_build
        self._path = path

        self._toplevel_project = toplevel_project
        self._key = None

    # to_dict()
    #
    # Convert a list of members which get serialized to a dict for serialization purposes
    #
    # Returns:
    #     (dict) A dict representation of the workspace
    #
    def to_dict(self):
        ret = {"path": self._path}
        if self.last_build is not None:
            ret["last_build"] = self.last_build
        return ret

    # from_dict():
    #
    # Loads a new workspace from a simple dictionary, the dictionary
    # is expected to be generated from Workspace.to_dict(), or manually
    # when loading from a YAML file.
    #
    # Args:
    #    toplevel_project (Project): Top project. Will be used for resolving relative workspace paths.
    #    dictionary: A simple dictionary object
    #
    # Returns:
    #    (Workspace): A newly instantiated Workspace
    #
    @classmethod
    def from_dict(cls, toplevel_project, dictionary):

        # Just pass the dictionary as kwargs
        return cls(toplevel_project, **dictionary)

    # differs()
    #
    # Checks if two workspaces are different in any way.
    #
    # Args:
    #    other (Workspace): Another workspace instance
    #
    # Returns:
    #    True if the workspace differs from 'other', otherwise False
    #
    def differs(self, other):
        return self.to_dict() != other.to_dict()

    # get_absolute_path():
    #
    # Returns: The absolute path of the element's workspace.
    #
    def get_absolute_path(self):
        return os.path.join(self._toplevel_project.directory, self._path)


# Workspaces()
#
# A class to manage Workspaces for multiple elements.
#
# Args:
#    toplevel_project (Project): Top project used to resolve paths.
#    workspace_project_cache (WorkspaceProjectCache): The cache of WorkspaceProjects
#
class Workspaces:
    def __init__(self, toplevel_project, workspace_project_cache):
        self._toplevel_project = toplevel_project
        self._workspace_project_cache = workspace_project_cache

        # A project without a directory can happen
        if toplevel_project.directory:
            self._bst_directory = os.path.join(toplevel_project.directory, ".bst2")
            self._workspaces = self._load_config()
        else:
            self._bst_directory = None
            self._workspaces = {}

    # list()
    #
    # Generator function to enumerate workspaces.
    #
    # Yields:
    #    A tuple in the following format: (str, Workspace), where the
    #    first element is the name of the workspaced element.
    def list(self):
        for element in self._workspaces.keys():
            yield (element, self._workspaces[element])

    # create_workspace()
    #
    # Create a workspace in the given path for the given element, and potentially
    # checks-out the target into it.
    #
    # Args:
    #    target (Element) - The element to create a workspace for
    #    path (str) - The path in which the workspace should be kept
    #    checkout (bool): Whether to check-out the element's sources into the directory
    #
    def create_workspace(self, target, path, *, checkout):
        element_name = target._get_full_name()
        project_dir = self._toplevel_project.directory
        if path.startswith(project_dir):
            workspace_path = os.path.relpath(path, project_dir)
        else:
            workspace_path = path

        self._workspaces[element_name] = Workspace(self._toplevel_project, path=workspace_path)

        if checkout:
            with target.timed_activity("Staging sources to {}".format(path)):
                target._open_workspace()

        workspace_project = self._workspace_project_cache.add(path, project_dir, element_name)
        project_file_path = workspace_project.get_filename()

        if os.path.exists(project_file_path):
            target.warn("{} was staged from this element's sources".format(WORKSPACE_PROJECT_FILE))
        workspace_project.write()

        self.save_config()

    # get_workspace()
    #
    # Get the path of the workspace source associated with the given
    # element's source at the given index
    #
    # Args:
    #    element_name (str) - The element name whose workspace to return
    #
    # Returns:
    #    (None|Workspace)
    #
    def get_workspace(self, element_name):
        if element_name not in self._workspaces:
            return None
        return self._workspaces[element_name]

    # update_workspace()
    #
    # Update the datamodel with a new Workspace instance
    #
    # Args:
    #    element_name (str): The name of the element to update a workspace for
    #    workspace_dict (Workspace): A serialized workspace dictionary
    #
    # Returns:
    #    (bool): Whether the workspace has changed as a result
    #
    def update_workspace(self, element_name, workspace_dict):
        assert element_name in self._workspaces

        workspace = Workspace.from_dict(self._toplevel_project, workspace_dict)
        if self._workspaces[element_name].differs(workspace):
            self._workspaces[element_name] = workspace
            return True

        return False

    # delete_workspace()
    #
    # Remove the workspace from the workspace element. Note that this
    # does *not* remove the workspace from the stored yaml
    # configuration, call save_config() afterwards.
    #
    # Args:
    #    element_name (str) - The element name whose workspace to delete
    #
    def delete_workspace(self, element_name):
        workspace = self.get_workspace(element_name)
        del self._workspaces[element_name]

        # Remove from the cache if it exists
        try:
            self._workspace_project_cache.remove(workspace.get_absolute_path())
        except LoadError as e:
            # We might be closing a workspace with a deleted directory
            if e.reason == LoadErrorReason.MISSING_FILE:
                pass
            else:
                raise

    # save_config()
    #
    # Dump the current workspace element to the project configuration
    # file. This makes any changes performed with delete_workspace or
    # create_workspace permanent
    #
    def save_config(self):
        assert utils._is_in_main_thread()

        config = {
            "format-version": BST_WORKSPACE_FORMAT_VERSION,
            "workspaces": {element: workspace.to_dict() for element, workspace in self._workspaces.items()},
        }
        os.makedirs(self._bst_directory, exist_ok=True)
        _yaml.roundtrip_dump(config, self._get_filename())

    # _load_config()
    #
    # Loads and parses the workspace configuration
    #
    # Returns:
    #    (dict) The extracted workspaces
    #
    # Raises: LoadError if there was a problem with the workspace config
    #
    def _load_config(self):
        workspace_file = self._get_filename()
        try:
            node = _yaml.load(workspace_file, shortname="workspaces.yml")
        except LoadError as e:
            if e.reason == LoadErrorReason.MISSING_FILE:
                # Return an empty dict if there was no workspace file
                return {}

            raise

        return self._parse_workspace_config(node)

    # _parse_workspace_config_format()
    #
    # If workspace config is in old-style format, i.e. it is using
    # source-specific workspaces, try to convert it to element-specific
    # workspaces.
    #
    # Args:
    #    workspaces (dict): current workspace config, usually output of _load_workspace_config()
    #
    # Returns:
    #    (dict) The extracted workspaces
    #
    # Raises: LoadError if there was a problem with the workspace config
    #
    def _parse_workspace_config(self, workspaces):
        try:
            version = workspaces.get_int("format-version", default=0)
        except ValueError:
            raise LoadError(
                "Format version is not an integer in workspace configuration", LoadErrorReason.INVALID_DATA
            )

        if version < 4:
            # bst 1.x workspaces do not separate source and build files.
            raise LoadError(
                "Workspace configuration format version {} not supported."
                "Please recreate this workspace.".format(version),
                LoadErrorReason.INVALID_DATA,
            )

        if 4 <= version <= BST_WORKSPACE_FORMAT_VERSION:
            workspaces = workspaces.get_mapping("workspaces", default={})
            res = {element: self._load_workspace(node) for element, node in workspaces.items()}
        else:
            raise LoadError(
                "Workspace configuration format version {} not supported."
                "Your version of buildstream may be too old. Max supported version: {}".format(
                    version, BST_WORKSPACE_FORMAT_VERSION
                ),
                LoadErrorReason.INVALID_DATA,
            )

        return res

    # _load_workspace():
    #
    # Loads a new workspace from a YAML node
    #
    # Args:
    #    node: A YAML dict
    #
    # Returns:
    #    (Workspace): A newly instantiated Workspace
    #
    def _load_workspace(self, node):
        dictionary = {
            "path": node.get_str("path"),
            "last_build": node.get_str("last_build", default=None),
        }
        return Workspace.from_dict(self._toplevel_project, dictionary)

    # _get_filename():
    #
    # Get the workspaces.yml file path.
    #
    # Returns:
    #    (str): The path to workspaces.yml file.
    def _get_filename(self):
        return os.path.join(self._bst_directory, "workspaces.yml")
