#!/usr/bin/env python3

# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program 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 2
# of the License, or (at your option) any later version.
#
# 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, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****

"""
This is the entry point for command line access.
"""

import os
import sys
import json

# ------------------
# Ensure module path
path = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "modules"))
if path not in sys.path:
    sys.path.append(path)
del path
# --------

import logging
log = logging.getLogger("bam_cli")


def fatal(msg):
    if __name__ == "__main__":
        sys.stderr.write("fatal: ")
        sys.stderr.write(msg)
        sys.stderr.write("\n")
        sys.exit(1)
    else:
        raise RuntimeError(msg)


class bam_config:
    # fake module
    __slots__ = ()

    def __new__(cls, *args, **kwargs):
        raise RuntimeError("%s should not be instantiated" % cls)

    CONFIG_DIR = ".bam"
    # can infact be any file in the session
    SESSION_FILE = ".bam_paths_remap.json"

    @staticmethod
    def find_basedir(cwd=None, path_suffix=None, abort=False, test_subpath=CONFIG_DIR, descr="<unknown>"):
        """
        Return the config path (or None when not found)
        Actually should raise an error?
        """

        if cwd is None:
            cwd = os.getcwd()

        parent = (os.path.normpath(
                  os.path.abspath(
                  cwd)))

        parent_prev = None

        while parent != parent_prev:
            test_dir = os.path.join(parent, test_subpath)
            if os.path.exists(test_dir):
                if path_suffix is not None:
                    test_dir = os.path.join(test_dir, path_suffix)
                return test_dir

            parent_prev = parent
            parent = os.path.dirname(parent)

        if abort is True:
            fatal("Not a %s (or any of the parent directories): %s" % (descr, test_subpath))

        return None

    @staticmethod
    def find_rootdir(cwd=None, path_suffix=None, abort=False, test_subpath=CONFIG_DIR, descr="<unknown>"):
        """
        find_basedir(), without '.bam' suffix
        """
        path = bam_config.find_basedir(
                cwd=cwd,
                path_suffix=path_suffix,
                abort=abort,
                test_subpath=test_subpath,
                )

        return path[:-(len(test_subpath) + 1)]

    def find_sessiondir(cwd=None, abort=False):
        """
        from:  my_project/my_session/some/subdir
        to:    my_project/my_session
        where: my_project/.bam/  (is the basedir)
        """
        session_rootdir = bam_config.find_basedir(
                cwd=cwd,
                test_subpath=bam_config.SESSION_FILE,
                abort=abort,
                descr="bam session"
                )

        if session_rootdir is not None:
            return session_rootdir[:-len(bam_config.SESSION_FILE)]
        else:
            if abort:
                if not os.path.isdir(session_rootdir):
                    fatal("Expected a directory (%r)" % session_rootdir)
            return None

    @staticmethod
    def load(id_="config", cwd=None, abort=False):
        filepath = bam_config.find_basedir(
                cwd=cwd,
                path_suffix=id_,
                descr="bam repository",
                )
        if abort is True:
            if filepath is None:
                fatal("Not a bam repository (or any of the parent directories): .bam")

        with open(filepath, 'r') as f:
            return json.load(f)

    @staticmethod
    def write(id_="config", data=None, cwd=None):
        filepath = bam_config.find_basedir(
                cwd=cwd,
                path_suffix=id_,
                descr="bam repository",
                )

        from bam.utils.system import write_json_to_file
        write_json_to_file(filepath, data)

    @staticmethod
    def write_bamignore(cwd=None):
        path = bam_config.find_rootdir(cwd=cwd)
        if path:
            filepath = os.path.join(path, ".bamignore")
            with open(filepath, 'w') as f:
                f.write(r".*\.blend\d+$")

    @staticmethod
    def create_bamignore_filter(id_=".bamignore", cwd=None):
        path = bam_config.find_rootdir()
        bamignore = os.path.join(path, id_)
        if os.path.isfile(bamignore):
            with open(bamignore, 'r', encoding='utf-8') as f:
                compiled_patterns = []

                import re
                for i, l in enumerate(f):
                    l = l.rstrip()
                    if l:
                        try:
                            p = re.compile(l)
                        except re.error as e:
                            fatal("%s:%d file contains an invalid regular expression, %s" %
                                  (bamignore, i + 1, str(e)))
                        compiled_patterns.append(p)

                if compiled_patterns:
                    def filter_ignore(f):
                        for pattern in filter_ignore.compiled_patterns:
                            if re.match(pattern, f):
                                return False
                        return True
                    filter_ignore.compiled_patterns = compiled_patterns

                    return filter_ignore

        return None


class bam_session:
    # fake module
    __slots__ = ()

    def __new__(cls, *args, **kwargs):
        raise RuntimeError("%s should not be instantiated" % cls)

    def session_path_to_cache(
            path,
            cachedir=None,
            session_rootdir=None,
            paths_remap_relbase=None,
            abort=True):
        """
        Given an absolute path, give us the cache-path on disk.
        """

        if session_rootdir is None:
            session_rootdir = bam_config.find_sessiondir(path, abort=abort)

        if paths_remap_relbase is None:
            with open(os.path.join(session_rootdir, ".bam_paths_remap.json")) as fp:
                paths_remap = json.load(fp)
                paths_remap_relbase = paths_remap.get(".", "")
                del fp, paths_remap

        cachedir = os.path.join(bam_config.find_rootdir(cwd=session_rootdir, abort=True), ".cache")
        path_rel = os.path.relpath(path, session_rootdir)
        if path_rel[0] == "_":
            path_cache = os.path.join(cachedir, path_rel[1:])
        else:
            path_cache = os.path.join(cachedir, paths_remap_relbase, path_rel)
        path_cache = os.path.normpath(path_cache)
        return path_cache

    @staticmethod
    def request_url(req_path):
        cfg = bam_config.load()
        result = "%s/%s" % (cfg['url'], req_path)
        return result

    @staticmethod
    def status(session_rootdir,
               paths_uuid_update=None):

        paths_add = {}
        paths_remove = {}
        paths_modified = {}

        from bam.utils.system import uuid_from_file

        session_rootdir = os.path.abspath(session_rootdir)

        # don't commit metadata
        paths_used = {
            os.path.join(session_rootdir, ".bam_paths_uuid.json"),
            os.path.join(session_rootdir, ".bam_paths_remap.json"),
            os.path.join(session_rootdir, ".bam_deps_remap.json"),
            os.path.join(session_rootdir, ".bam_paths_edit.data"),
            os.path.join(session_rootdir, ".bam_tmp.zip"),
            }

        paths_uuid = bam_session.load_paths_uuid(session_rootdir)

        for f_rel, sha1 in paths_uuid.items():
            f_abs = os.path.join(session_rootdir, f_rel)
            if os.path.exists(f_abs):
                sha1_modified = uuid_from_file(f_abs)
                if sha1_modified != sha1:
                    paths_modified[f_rel] = f_abs
                if paths_uuid_update is not None:
                    paths_uuid_update[f_rel] = sha1_modified
                paths_used.add(f_abs)
            else:
                paths_remove[f_rel] = f_abs

        # ----
        # find new files
        def iter_files(path, filename_check=None):
            for dirpath, dirnames, filenames in os.walk(path):

                # skip '.svn'
                if dirpath.startswith(".") and dirpath != ".":
                    continue

                for filename in filenames:
                    filepath = os.path.join(dirpath, filename)
                    if filename_check is None or filename_check(filepath):
                        yield filepath

        bamignore_filter = bam_config.create_bamignore_filter()

        for f_abs in iter_files(session_rootdir, bamignore_filter):
            if f_abs not in paths_used:
                # we should be clever - add the file to a useful location based on some rules
                # (category, filetype & tags?)

                f_rel = os.path.relpath(f_abs, session_rootdir)

                paths_add[f_rel] = f_abs

                if paths_uuid_update is not None:
                    paths_uuid_update[f_rel] = uuid_from_file(f_abs)

        return paths_add, paths_remove, paths_modified

    @staticmethod
    def load_paths_uuid(session_rootdir):
        with open(os.path.join(session_rootdir, ".bam_paths_uuid.json")) as f:
            return json.load(f)

    @staticmethod
    def is_dirty(session_rootdir):
        paths_add, paths_remove, paths_modified = bam_session.status(session_rootdir)
        return any((paths_add, paths_modified, paths_remove))

    @staticmethod
    def binary_edits_apply_single(
            blendfile_abs,  # str
            blendfile,  # bytes
            binary_edits,
            session_rootdir,
            paths_uuid_update=None,
            ):

        sys.stdout.write("  operating on: %r\n" % blendfile_abs)
        sys.stdout.flush()
        # we don't want to read, just edit whats there.
        with open(blendfile_abs, 'rb+') as fh_blend:
            for ofs, data in binary_edits:
                # sys.stdout.write("\n%r\n" % data)
                sys.stdout.flush()
                # ensure we're writing to the correct location.
                # fh_blend.seek(ofs)
                # sys.stdout.write(repr(b'existing data: ' + fh_blend.read(len(data) + 1)))
                fh_blend.seek(ofs)
                fh_blend.write(data)
        sys.stdout.write("\n")
        sys.stdout.flush()

        if paths_uuid_update is not None:
            # update hash!
            # we could do later, but the file is fresh in cache, so do now
            from bam.utils.system import uuid_from_file
            f_rel = os.path.relpath(blendfile_abs, session_rootdir)
            paths_uuid_update[f_rel] = uuid_from_file(blendfile_abs)
            del uuid_from_file

    @staticmethod
    def binary_edits_apply_all(
            session_rootdir,
            # collection of local paths or None (to apply all binary edits)
            paths=None,
            update_uuid=False,
            ):

        # sanity check
        if paths is not None:
            for path in paths:
                assert(type(path) is bytes)
                assert(not os.path.isabs(path))
                assert(os.path.exists(os.path.join(session_rootdir, path.decode('utf-8'))))

        with open(os.path.join(session_rootdir, ".bam_paths_remap.json")) as fp:
            paths_remap = json.load(fp)
            paths_remap_relbase = paths_remap.get(".", "")
            paths_remap_reverse = {v: k for k, v in paths_remap.items()}
            del paths_remap

        with open(os.path.join(session_rootdir, ".bam_paths_edit.data"), 'rb') as fh:
            import pickle
            binary_edits_all = pickle.load(fh)
            paths_uuid_update = {} if update_uuid else None
            for blendfile, binary_edits in binary_edits_all.items():
                if binary_edits:
                    if paths is not None and blendfile not in paths:
                        continue

                    # get the absolute path as it is in the main repo
                    # then remap back to our local checkout
                    blendfile_abs_remote = os.path.normpath(os.path.join(paths_remap_relbase, blendfile.decode('utf-8')))
                    blendfile_abs = os.path.join(session_rootdir, paths_remap_reverse[blendfile_abs_remote])

                    bam_session.binary_edits_apply_single(
                            blendfile_abs,
                            blendfile,
                            binary_edits,
                            session_rootdir,
                            paths_uuid_update,
                            )
            del pickle
            del binary_edits_all

        if update_uuid and paths_uuid_update:
            # freshen the UUID's based on the replayed binary_edits
            from bam.utils.system import write_json_to_file
            paths_uuid = bam_session.load_paths_uuid(session_rootdir)
            paths_uuid.update(paths_uuid_update)
            write_json_to_file(os.path.join(session_rootdir, ".bam_paths_uuid.json"), paths_uuid)
            del write_json_to_file
            del paths_uuid

    @staticmethod
    def binary_edits_update_single(
            blendfile_abs,
            binary_edits,
            # callback, takes a filepath
            remap_filepath_cb,
            ):
        """
        After committing a blend file, we need to re-create the binary edits.
        """
        from bam.blend import blendfile_path_walker
        for fp, (rootdir, fp_blend_basename) in blendfile_path_walker.FilePath.visit_from_blend(
                blendfile_abs,
                readonly=True,
                recursive=False,
                ):
            f_rel_orig = fp.filepath
            f_rel = remap_filepath_cb(f_rel_orig)
            fp.filepath_assign_edits(f_rel, binary_edits)


class bam_commands:
    """
    Sub-commands from the command-line map directly to these methods.
    """
    # fake module
    __slots__ = ()

    def __new__(cls, *args, **kwargs):
        raise RuntimeError("%s should not be instantiated" % cls)

    @staticmethod
    def init(url, directory_name=None):
        import urllib.parse

        if "@" in url:
            # first & last :)
            username, url = url.rpartition('@')[0::2]
        else:
            import getpass
            username = getpass.getuser()
            print("Using username:", username)
            del getpass

        parsed_url = urllib.parse.urlsplit(url)

        proj_dirname = os.path.basename(parsed_url.path)
        if directory_name:
            proj_dirname = directory_name
        proj_dirname_abs = os.path.join(os.getcwd(), proj_dirname)

        if os.path.exists(proj_dirname_abs):
            fatal("Cannot create project %r already exists" % proj_dirname_abs)

        # Create the project directory inside the current directory
        os.mkdir(proj_dirname_abs)
        # Create the .bam directory
        bam_basedir = os.path.join(proj_dirname_abs, bam_config.CONFIG_DIR)
        os.mkdir(bam_basedir)

        # Add a config file with project url, username and password
        bam_config.write(
                data={
                    "url": url,
                    "user": username,
                    "password": "",
                    "config_version": 1
                    },
                cwd=proj_dirname_abs)

        # Create the default .bamignore
        # TODO (fsiddi) get this data from the project config on the server
        bam_config.write_bamignore(cwd=proj_dirname_abs)

        print("Project %r initialized" % proj_dirname)

    @staticmethod
    def create(session_name):
        rootdir = bam_config.find_rootdir(abort=True)

        session_rootdir = os.path.join(rootdir, session_name)

        if os.path.exists(session_rootdir):
            fatal("session path exists %r" % session_rootdir)
        if rootdir != bam_config.find_rootdir(cwd=session_rootdir):
            fatal("session is located outside %r" % rootdir)

        def write_empty(f, data):
            with open(os.path.join(session_rootdir, f), 'wb') as f:
                f.write(data)

        os.makedirs(session_rootdir)

        write_empty(".bam_paths_uuid.json", b'{}')
        write_empty(".bam_paths_remap.json", b'{}')
        write_empty(".bam_deps_remap.json", b'{}')

        print("Session %r created" % session_name)

    @staticmethod
    def checkout(
            path,
            output_dir=None,
            session_rootdir_partial=None,
            all_deps=False,
            ):

        # ---------
        # constants
        CHUNK_SIZE = 1024

        cfg = bam_config.load(abort=True)

        if output_dir is None:
            # fallback to the basename
            session_rootdir = os.path.splitext(os.path.basename(path))[0]
        else:
            output_dir = os.path.realpath(output_dir)
            if os.sep in output_dir.rstrip(os.sep):
                # are we a subdirectory?
                # (we know this exists, since we have config already)
                project_rootdir = bam_config.find_rootdir(abort=True)
                if ".." in os.path.relpath(output_dir, project_rootdir).split(os.sep):
                    fatal("Output %r is outside the project path %r" % (output_dir, project_rootdir))
                del project_rootdir
            session_rootdir = output_dir
        del output_dir

        if bam_config.find_sessiondir(cwd=session_rootdir):
            fatal("Can't checkout in existing session. Use update.")

        payload = {
            "filepath": path,
            "command": "checkout",
            "arguments": json.dumps({
                "all_deps": all_deps,
                }),
            }

        # --------------------------------------------------------------------
        # First request we simply get a list of files to download
        #
        import requests
        r = requests.get(
                bam_session.request_url("file"),
                params=payload,
                auth=(cfg['user'], cfg['password']),
                stream=True,
                )

        if r.status_code not in {200, }:
            # TODO(cam), make into reusable function?
            print("Error %d:\n%s" % (r.status_code, next(r.iter_content(chunk_size=1024)).decode('utf-8')))
            return

        # TODO(cam) how to tell if we get back a message payload? or real data???
        dst_dir_data = payload['filepath'].split('/')[-1]

        if 1:
            dst_dir_data += ".zip"

        with open(dst_dir_data, 'wb') as f:
            import struct
            ID_MESSAGE = 1
            ID_PAYLOAD = 2
            head = r.raw.read(4)
            if head != b'BAM\0':
                fatal("bad header from server")

            while True:
                msg_type, msg_size = struct.unpack("<II", r.raw.read(8))
                if msg_type == ID_MESSAGE:
                    sys.stdout.write(r.raw.read(msg_size).decode('utf-8'))
                    sys.stdout.flush()
                elif msg_type == ID_PAYLOAD:
                    # payload
                    break

            tot_size = 0
            for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
                if chunk:  # filter out keep-alive new chunks
                    tot_size += len(chunk)
                    f.write(chunk)
                    f.flush()

                    sys.stdout.write("\rdownload: [%03d%%]" % ((100 * tot_size) // msg_size))
                    sys.stdout.flush()
            del struct

        # ---------------
        # extract the zip
        import zipfile
        with open(dst_dir_data, 'rb') as zip_file:
            zip_handle = zipfile.ZipFile(zip_file)
            zip_handle.extractall(session_rootdir)
        del zipfile, zip_file

        os.remove(dst_dir_data)
        sys.stdout.write("\nwritten: %r\n" % session_rootdir)

        # ----
        # Update cache
        cachedir = os.path.join(bam_config.find_rootdir(cwd=session_rootdir, abort=True), ".cache")
        # os.makedirs(cachedir, exist_ok=True)

        # --------------------------------------------------------------------
        # Second request we simply download the files..
        #
        # which we don't have in cache,
        # note that its possible we have all in cache and don't need to make a second request.
        files = []
        with open(os.path.join(session_rootdir, ".bam_paths_remap.json")) as fp:
            from bam.utils.system import uuid_from_file
            paths_remap = json.load(fp)

            paths_uuid = bam_session.load_paths_uuid(session_rootdir)

            for f_src, f_dst in paths_remap.items():
                if f_src == ".":
                    continue

                uuid = paths_uuid.get(f_src)
                if uuid is not None:
                    f_dst_abs = os.path.join(cachedir, f_dst)
                    if os.path.exists(f_dst_abs):
                        # check if we need to download this file?
                        uuid_exists = uuid_from_file(f_dst_abs)
                        assert(type(uuid) is type(uuid_exists))
                        if uuid == uuid_exists:
                            continue

                files.append(f_dst)

            del uuid_from_file

        if files:
            payload = {
                "command": "checkout_download",
                "arguments": json.dumps({
                    "files": files,
                    }),
                }
            import requests
            r = requests.get(
                    bam_session.request_url("file"),
                    params=payload,
                    auth=(cfg['user'], cfg['password']),
                    stream=True,
                    )

            if r.status_code not in {200, }:
                # TODO(cam), make into reusable function?
                print("Error %d:\n%s" % (r.status_code, next(r.iter_content(chunk_size=1024)).decode('utf-8')))
                return

            # TODO(cam) how to tell if we get back a message payload? or real data???
            # needed so we don't read past buffer bounds
            def iter_content_size(r, size, chunk_size=CHUNK_SIZE):
                while size >= chunk_size:
                    size -= chunk_size
                    yield r.raw.read(chunk_size)
                if size:
                    yield r.raw.read(size)


            import struct
            ID_MESSAGE = 1
            ID_PAYLOAD = 2
            ID_PAYLOAD_APPEND = 3
            ID_PAYLOAD_EMPTY = 4
            ID_DONE = 5
            head = r.raw.read(4)
            if head != b'BAM\0':
                fatal("bad header from server")

            file_index = 0
            is_header_read = True
            while True:
                if is_header_read:
                    msg_type, msg_size = struct.unpack("<II", r.raw.read(8))
                else:
                    is_header_read = True

                if msg_type == ID_MESSAGE:
                    sys.stdout.write(r.raw.read(msg_size).decode('utf-8'))
                    sys.stdout.flush()
                elif msg_type == ID_PAYLOAD_EMPTY:
                    file_index += 1
                elif msg_type == ID_PAYLOAD:
                    f_rel = files[file_index]
                    f_abs = os.path.join(cachedir, files[file_index])
                    file_index += 1

                    # server also prints... we could do this a bit different...
                    sys.stdout.write("file: %r" % f_rel)
                    sys.stdout.flush()

                    os.makedirs(os.path.dirname(f_abs), exist_ok=True)

                    with open(f_abs, "wb") as f:
                        while True:
                            tot_size = 0
                            # No need to worry about filling memory,
                            # total chunk size is capped by the server
                            chunks = []
                            # for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
                            for chunk in iter_content_size(r, msg_size, chunk_size=CHUNK_SIZE):
                                if chunk:  # filter out keep-alive new chunks
                                    tot_size += len(chunk)
                                    # f.write(chunk)
                                    # f.flush()
                                    chunks.append(chunk)

                                    sys.stdout.write("\rdownload: [%03d%%]" % ((100 * tot_size) // msg_size))
                                    sys.stdout.flush()
                            assert(tot_size == msg_size)

                            # decompress all chunks
                            import lzma
                            f.write(lzma.decompress(b''.join(chunks)))
                            f.flush()
                            del chunks

                            # take care! - re-reading the next header to see if
                            # we're appending to this file or not
                            msg_type, msg_size = struct.unpack("<II", r.raw.read(8))
                            if msg_type == ID_PAYLOAD_APPEND:
                                continue
                            # otherwise continue the outer loop, without re-reading the header

                            # don't re-read the header next iteration
                            is_header_read = False
                            break

                elif msg_type == ID_DONE:
                    break
                elif msg_type == ID_PAYLOAD_APPEND:
                    # Should only handle in a read-loop above
                    raise Exception("Invalid state for message-type %d" % msg_type)
                else:
                    raise Exception("Unknown message-type %d" % msg_type)
            del struct


        del files

        # ------------
        # Update Cache
        #
        # TODO, remove stale cache
        # we need this to map to project level paths
        #
        # Copy cache into our session before applying binary edits.
        with open(os.path.join(session_rootdir, ".bam_paths_remap.json")) as fp:
            paths_remap = json.load(fp)
            for f_dst, f_src in paths_remap.items():
                if f_dst == ".":
                    continue

                f_src_abs = os.path.join(cachedir, f_src)

                # this should 'almost' always be true
                if os.path.exists(f_src_abs):

                    f_dst_abs = os.path.join(session_rootdir, f_dst)
                    os.makedirs(os.path.dirname(f_dst_abs), exist_ok=True)

                    import shutil
                    # print("from        ", f_dst_abs, os.path.exists(f_dst_abs))
                    # print("to          ", f_src_abs, os.path.exists(f_src_abs))
                    # print("CREATING:   ", f_src_abs)
                    shutil.copyfile(f_src_abs, f_dst_abs)
                    del shutil
        # import time
        # time.sleep(10000)

        del paths_remap, cachedir
        # ...done updating cache
        # ----------------------

        # -------------------
        # replay binary edits
        #
        # We've downloaded the files pristine from their repo.
        # This means we can use local cache and avoid re-downloading.
        #
        # But for files to work locally we have to apply binary edits given to us by the server.

        sys.stdout.write("replaying edits...\n")
        bam_session.binary_edits_apply_all(session_rootdir, paths=None, update_uuid=True)

        # ...done with binary edits
        # -------------------------

    @staticmethod
    def update(paths):
        # Load project configuration
        # cfg = bam_config.load(abort=True)

        # TODO(cam) multiple paths
        session_rootdir = bam_config.find_sessiondir(paths[0], abort=True)
        # so as to avoid off-by-one errors string mangling
        session_rootdir = session_rootdir.rstrip(os.sep)

        paths_uuid = bam_session.load_paths_uuid(session_rootdir)

        if not paths_uuid:
            print("Nothing to update!")
            return

        if bam_session.is_dirty(session_rootdir):
            fatal("Local changes detected, commit before checking out!")

        # -------------------------------------------------------------------------------
        # TODO(cam) don't guess this important info
        files = [f for f in os.listdir(session_rootdir) if not f.startswith(".")]
        files_blend = [f for f in files if f.endswith(".blend")]
        if files_blend:
            f = files_blend[0]
        else:
            f = files[0]
        with open(os.path.join(session_rootdir, ".bam_paths_remap.json")) as fp:
            paths_remap = json.load(fp)
            paths_remap_relbase = paths_remap.get(".", "")
        path = os.path.join(paths_remap_relbase, f)
        # -------------------------------------------------------------------------------

        # merge sessions
        session_tmp = session_rootdir + ".tmp"
        bam_commands.checkout(
                path,
                output_dir=session_tmp,
                session_rootdir_partial=session_rootdir,
                )

        for dirpath, dirnames, filenames in os.walk(session_tmp):
            for filename in filenames:
                filepath = os.path.join(dirpath, filename)
                f_src = filepath
                f_dst = session_rootdir + filepath[len(session_tmp):]
                os.rename(f_src, f_dst)
        import shutil
        shutil.rmtree(session_tmp)

    @staticmethod
    def revert(paths):
        # Copy files back from the cache
        # a relatively lightweight operation

        def _get_from_path(session_rootdir, cachedir, paths_remap, path_abs):
            print("====================")
            print(path_abs)
            path_abs = os.path.normpath(path_abs)
            print(paths_remap)
            for f_src, f_dst in paths_remap.items():
                if f_src == ".":
                    continue
                print("-----------------")
                f_src_abs = os.path.join(session_rootdir, f_src)
                #if os.path.samefile(f_src_abs, path_abs):
                print(f_src_abs)
                print(f_src)
                print(f_dst)
                if f_src_abs == path_abs:
                    f_dst_abs = os.path.join(cachedir, f_dst)
                    return f_src, f_src_abs, f_dst_abs
            return None, None, None

        # 2 passes, once to check, another to execute
        for pass_ in range(2):
            for path in paths:
                path = os.path.normpath(os.path.abspath(path))
                if os.path.isdir(path):
                    fatal("Reverting a directory not yet supported (%r)" % path)

                # possible we try revert different session's files
                session_rootdir = bam_config.find_sessiondir(path, abort=True)
                cachedir = os.path.join(bam_config.find_rootdir(cwd=session_rootdir, abort=True), ".cache")
                if not os.path.exists(cachedir):
                    fatal("Local cache missing (%r)" %
                          cachedir)

                path_rel = os.path.relpath(path, session_rootdir)

                with open(os.path.join(session_rootdir, ".bam_paths_uuid.json")) as fp:
                    paths_uuid = json.load(fp)
                    if paths_uuid.get(path_rel) is None:
                        fatal("Given path isn't in the session, skipping (%s)" %
                              path_abs)

                # first pass is sanity check only
                if pass_ == 0:
                    continue

                with open(os.path.join(session_rootdir, ".bam_paths_remap.json")) as fp:
                    paths_remap = json.load(fp)
                    paths_remap_relbase = paths_remap.get(".", "")
                    del fp, paths_remap

                path_cache = bam_session.session_path_to_cache(
                        path,
                        cachedir=cachedir,
                        session_rootdir=session_rootdir,
                        paths_remap_relbase=paths_remap_relbase,
                        )

                if not os.path.exists(path_cache):
                    fatal("Given path missing cache disk (%s)" %
                          path_cache)

                if pass_ == 1:
                    # for real
                    print("  Reverting %r" % path)
                    os.makedirs(os.path.dirname(path), exist_ok=True)
                    import shutil
                    shutil.copyfile(path_cache, path)

                    bam_session.binary_edits_apply_all(
                            session_rootdir,
                            paths={path_rel.encode('utf-8')},
                            update_uuid=False,
                            )

    @staticmethod
    def commit(paths, message):
        from bam.utils.system import write_json_to_file, write_json_to_zip
        import requests

        # Load project configuration
        cfg = bam_config.load(abort=True)

        session_rootdir = bam_config.find_sessiondir(paths[0], abort=True)

        cachedir = os.path.join(bam_config.find_rootdir(cwd=session_rootdir, abort=True), ".cache")
        basedir = bam_config.find_basedir(
                cwd=session_rootdir,
                descr="bam repository",
                )
        basedir_temp = os.path.join(basedir, "tmp")

        if os.path.isdir(basedir_temp):
            fatal("Path found, "
                  "another commit in progress, or remove with path! (%r)" %
                  basedir_temp)

        if not os.path.exists(os.path.join(session_rootdir, ".bam_paths_uuid.json")):
            fatal("Path not a project session, (%r)" %
                  session_rootdir)


        # make a zipfile from session
        paths_uuid = bam_session.load_paths_uuid(session_rootdir)

        # No longer used
        """
        with open(os.path.join(session_rootdir, ".bam_deps_remap.json")) as f:
            deps_remap = json.load(f)
        """

        paths_uuid_update = {}

        paths_add, paths_remove, paths_modified = bam_session.status(session_rootdir, paths_uuid_update)

        if not any((paths_add, paths_modified, paths_remove)):
            print("Nothing to commit!")
            return

        # we need to update paths_remap as we go
        with open(os.path.join(session_rootdir, ".bam_paths_remap.json")) as f:
            paths_remap = json.load(f)
            paths_remap_relbase = paths_remap.get(".", "")
            paths_remap_relbase_bytes = paths_remap_relbase.encode("utf-8")

        def remap_filepath_bytes(f_rel):
            assert(type(f_rel) is bytes)
            f_rel_in_proj = paths_remap.get(f_rel.decode("utf-8"))
            if f_rel_in_proj is None:
                if paths_remap_relbase_bytes:
                    if f_rel.startswith(b'_'):
                        f_rel_in_proj = f_rel[1:]
                    else:
                        f_rel_in_proj = os.path.join(paths_remap_relbase_bytes, f_rel)
                else:
                    if f_rel.startswith(b'_'):
                        # we're already project relative
                        f_rel_in_proj = f_rel[1:]
                    else:
                        f_rel_in_proj = f_rel
            else:
                f_rel_in_proj = f_rel_in_proj.encode("utf-8")
            return f_rel_in_proj

        def remap_filepath(f_rel):
            assert(type(f_rel) is str)
            f_rel_in_proj = paths_remap.get(f_rel)
            if f_rel_in_proj is None:
                if paths_remap_relbase:
                    if f_rel.startswith("_"):
                        f_rel_in_proj = f_rel[1:]
                    else:
                        f_rel_in_proj = os.path.join(paths_remap_relbase, f_rel)
                else:
                    if f_rel.startswith("_"):
                        # we're already project relative
                        f_rel_in_proj = f_rel[1:]
                    else:
                        f_rel_in_proj = f_rel

            return f_rel_in_proj

        def remap_cb(f, data):
            # check for the absolute path hint
            if f.startswith(b'//_'):
                proj_base_b = data
                return b'//' + os.path.relpath(f[3:], proj_base_b)
            return None

        def remap_file(f_rel, f_abs):
            f_abs_remap = os.path.join(basedir_temp, f_rel)
            dir_remap = os.path.dirname(f_abs_remap)
            os.makedirs(dir_remap, exist_ok=True)

            # final location in the project
            f_rel_in_proj = remap_filepath(f_rel)
            proj_base_b = os.path.dirname(f_rel_in_proj).encode("utf-8")

            from bam.blend import blendfile_pack_restore
            blendfile_pack_restore.blendfile_remap(
                    f_abs.encode('utf-8'),
                    dir_remap.encode('utf-8'),
                    deps_remap_cb=remap_cb,
                    deps_remap_cb_userdata=proj_base_b,
                    )
            return f_abs_remap

        for f_rel, f_abs in list(paths_modified.items()):
            if f_abs.endswith(".blend"):
                f_abs_remap = remap_file(f_rel, f_abs)
                if os.path.exists(f_abs_remap):
                    paths_modified[f_rel] = f_abs_remap

        for f_rel, f_abs in list(paths_add.items()):
            if f_abs.endswith(".blend"):
                f_abs_remap = remap_file(f_rel, f_abs)
                if os.path.exists(f_abs_remap):
                    paths_add[f_rel] = f_abs_remap

        """
                deps = deps_remap.get(f_rel)
                if deps:
                    # ----
                    # remap!
                    f_abs_remap = os.path.join(basedir_temp, f_rel)
                    dir_remap = os.path.dirname(f_abs_remap)
                    os.makedirs(dir_remap, exist_ok=True)
                    import blendfile_pack_restore
                    blendfile_pack_restore.blendfile_remap(
                            f_abs.encode('utf-8'),
                            dir_remap.encode('utf-8'),
                            deps,
                            )
                    if os.path.exists(f_abs_remap):
                        f_abs = f_abs_remap
                        paths_modified[f_rel] = f_abs
        """

        # -------------------------
        print("Now make a zipfile")
        import zipfile
        temp_zip = os.path.join(session_rootdir, ".bam_tmp.zip")
        with zipfile.ZipFile(temp_zip, 'w', zipfile.ZIP_DEFLATED) as zip_handle:
            for paths_dict, op in ((paths_modified, 'M'), (paths_add, 'A')):
                for (f_rel, f_abs) in paths_dict.items():
                    print("  packing (%s): %r" % (op, f_abs))
                    zip_handle.write(f_abs, arcname=f_rel)

            # make a paths remap that only includes modified files
            # TODO(cam), from 'packer.py'

            paths_remap_subset = {
                    f_rel: f_rel_in_proj
                    for f_rel, f_rel_in_proj in paths_remap.items() if f_rel in paths_modified}
            paths_remap_subset.update({
                    f_rel: remap_filepath(f_rel)
                    for f_rel in paths_add})

            # paths_remap_subset.update(paths_remap_subset_add)
            write_json_to_zip(zip_handle, ".bam_paths_remap.json", paths_remap_subset)

            # build a list of path manipulation operations
            paths_ops = {}
            # paths_remove ...
            for f_rel, f_abs in paths_remove.items():
                # TODO
                f_abs_remote = paths_remap[f_rel]
                paths_ops[f_abs_remote] = 'D'

            write_json_to_zip(zip_handle, ".bam_paths_ops.json", paths_ops)
            log.debug(paths_ops)


        # --------------
        # Commit Request
        payload = {
            "command": "commit",
            "arguments": json.dumps({
                'message': message,
                }),
            }
        files = {
            "file": open(temp_zip, 'rb'),
            }

        with files["file"]:
            r = requests.put(
                    bam_session.request_url("file"),
                    params=payload,
                    auth=(cfg["user"], cfg["password"]),
                    files=files)

        os.remove(temp_zip)

        try:
            r_json = r.json()
            print(r_json.get("message", "<empty>"))
        except Exception:
            print(r.text)

        # TODO, handle error cases
        ok = True
        if ok:

            # ----------
            # paths_uuid
            paths_uuid.update(paths_uuid_update)
            write_json_to_file(os.path.join(session_rootdir, ".bam_paths_uuid.json"), paths_uuid_update)

            # -----------
            # paths_remap
            paths_remap.update(paths_remap_subset)
            for k in paths_remove:
                del paths_remap[k]
            write_json_to_file(os.path.join(session_rootdir, ".bam_paths_remap.json"), paths_remap)
            del write_json_to_file

            # ------------------
            # Update Local Cache
            #
            # We now have 'pristine' files in basedir_temp, the commit went fine.
            # So move these into local cache AND we have to remake the binary_edit data.
            # since files were modified, if we don't do this - we wont be able to revert or avoid
            # re-downloading the files later.
            binary_edits_all_update = {}
            binary_edits_all_remove = set()
            for paths_dict, op in ((paths_modified, 'M'), (paths_add, 'A')):
                for f_rel, f_abs in paths_dict.items():
                    print("  caching (%s): %r" % (op, f_abs))
                    f_dst_abs = os.path.join(cachedir, f_rel)
                    os.makedirs(os.path.dirname(f_dst_abs), exist_ok=True)
                    if f_abs.startswith(basedir_temp):
                        os.rename(f_abs, f_dst_abs)
                    else:
                        import shutil
                        shutil.copyfile(f_abs, f_dst_abs)
                        del shutil
                    binary_edits = binary_edits_all_update[f_rel.encode('utf-8')] = []

                    # update binary_edits
                    if f_rel.endswith(".blend"):
                        bam_session.binary_edits_update_single(
                                f_dst_abs,
                                binary_edits,
                                remap_filepath_cb=remap_filepath_bytes,
                                )
            for f_rel, f_abs in paths_remove.items():
                binary_edits_all_remove.add(f_rel)

            paths_edit_abs = os.path.join(session_rootdir, ".bam_paths_edit.data")
            if binary_edits_all_update or binary_edits_all_remove:
                if os.path.exists(paths_edit_abs):
                    with open(paths_edit_abs, 'rb') as fh:
                        import pickle
                        binary_edits_all = pickle.load(fh)
                        del pickle
                else:
                    binary_edits_all = {}

                if binary_edits_all_remove and binary_edits_all:
                    for f_rel in binary_edits_all_remove:
                        if f_rel in binary_edits_all:
                            try:
                                del binary_edits_all[f_rel]
                            except KeyError:
                                pass
                if binary_edits_all_update:
                    binary_edits_all.update(binary_edits_all_update)

            import pickle
            with open(paths_edit_abs, 'wb') as fh:
                print()
                pickle.dump(binary_edits_all, fh, pickle.HIGHEST_PROTOCOL)
            del binary_edits_all
            del paths_edit_abs
            del pickle

        # ------------------------------
        # Cleanup temp dir to finish off
        if os.path.exists(basedir_temp):
            import shutil
            shutil.rmtree(basedir_temp)
            del shutil

    @staticmethod
    def status(paths, use_json=False):
        # TODO(cam) multiple paths
        path = paths[0]
        del paths

        session_rootdir = bam_config.find_sessiondir(path, abort=True)
        paths_add, paths_remove, paths_modified = bam_session.status(session_rootdir)

        if not use_json:
            for f in sorted(paths_add):
                print("  A: %s" % f)
            for f in sorted(paths_modified):
                print("  M: %s" % f)
            for f in sorted(paths_remove):
                print("  D: %s" % f)
        else:
            ret = []
            for f in sorted(paths_add):
                ret.append(("A", f))
            for f in sorted(paths_modified):
                ret.append(("M", f))
            for f in sorted(paths_remove):
                ret.append(("D", f))

            print(json.dumps(ret))

    @staticmethod
    def list_dir(paths, use_full=False, use_json=False):
        import requests

        # Load project configuration
        cfg = bam_config.load(abort=True)

        # TODO(cam) multiple paths
        path = paths[0]
        del paths

        payload = {
            "path": path,
            }
        r = requests.get(
                bam_session.request_url("file_list"),
                params=payload,
                auth=(cfg['user'], cfg['password']),
                stream=True,
                )

        r_json = r.json()
        items = r_json.get("items_list")
        if items is None:
            fatal(r_json.get("message", "<empty>"))

        items.sort()

        if use_json:
            ret = []
            for (name_short, name_full, file_type) in items:
                ret.append((name_short, file_type))

            print(json.dumps(ret))
        else:
            def strip_dot_slash(f):
                return f[2:] if f.startswith("./") else f

            for (name_short, name_full, file_type) in items:
                if file_type == "dir":
                    print("  %s/" % (strip_dot_slash(name_full) if use_full else name_short))
            for (name_short, name_full, file_type) in items:
                if file_type != "dir":
                    print("  %s" % (strip_dot_slash(name_full) if use_full else name_short))

    @staticmethod
    def deps(paths, recursive=False, use_json=False):

        def deps_path_walker():
            from bam.blend import blendfile_path_walker
            for blendfile_src in paths:
                blendfile_src = blendfile_src.encode('utf-8')
                yield from blendfile_path_walker.FilePath.visit_from_blend(
                        blendfile_src,
                        readonly=True,
                        recursive=recursive,
                        )

        def status_walker():
            for fp, (rootdir, fp_blend_basename) in deps_path_walker():
                f_rel = fp.filepath
                f_abs = fp.filepath_absolute

                yield (
                    # blendfile-src
                    os.path.join(fp.basedir, fp_blend_basename).decode('utf-8'),
                    # fillepath-dst
                    f_rel.decode('utf-8'),
                    f_abs.decode('utf-8'),
                    # filepath-status
                    "OK" if os.path.exists(f_abs) else "MISSING FILE",
                    )

        if use_json:
            is_first = True
            # print in parts, so we don't block the output
            print("[")
            for f_src, f_dst, f_dst_abs, f_status in status_walker():
                if is_first:
                    is_first = False
                else:
                    print(",")

                print(json.dumps((f_src, f_dst, f_dst_abs, f_status)), end="")
            print("]")
        else:
            for f_src, f_dst, f_dst_abs, f_status in status_walker():
                print("  %r -> (%r = %r) %s" % (f_src, f_dst, f_dst_abs, f_status))

    @staticmethod
    def pack(
            paths,
            output,
            mode,
            repository_base_path=None,
            all_deps=False,
            use_quiet=False,
            warn_remap_externals=False,
            compress_level=-1,
            filename_filter=None,
            ):
        # Local packing (don't use any project/session stuff)
        from .blend import blendfile_pack

        # TODO(cam) multiple paths
        path = paths[0]
        del paths

        if output is None:
            fatal("Output path must be given when packing with: --mode=FILE")

        if os.path.isdir(output):
            if mode == "ZIP":
                output = os.path.join(output, os.path.splitext(path)[0] + ".zip")
            else:  # FILE
                output = os.path.join(output, os.path.basename(path))

        if use_quiet:
            report = lambda msg: None
        else:
            report = lambda msg: print(msg, end="")

        if repository_base_path is not None:
            repository_base_path = repository_base_path.encode('utf-8')

        # replace var with a pattern matching callback
        if filename_filter:
            # convert string into regex callback
            # "*.txt;*.png;*.rst" --> r".*\.txt$|.*\.png$|.*\.rst$"
            import re
            import fnmatch

            compiled_pattern = re.compile(
                    b'|'.join(fnmatch.translate(f).encode('utf-8')
                              for f in filename_filter.split(";") if f),
                    re.IGNORECASE,
                    )

            def filename_filter(f):
                return (not filename_filter.compiled_pattern.match(f))
            filename_filter.compiled_pattern = compiled_pattern

            del compiled_pattern
            del re, fnmatch

        for msg in blendfile_pack.pack(
                path.encode('utf-8'),
                output.encode('utf-8'),
                mode=mode,
                all_deps=all_deps,
                repository_base_path=repository_base_path,
                compress_level=compress_level,
                report=report,
                warn_remap_externals=warn_remap_externals,
                use_variations=True,
                filename_filter=filename_filter,
                ):
            pass

    @staticmethod
    def copy(
            paths,
            output,
            base,
            all_deps=False,
            use_quiet=False,
            filename_filter=None,
            ):
        # Local packing (don't use any project/session stuff)
        from .blend import blendfile_copy
        from bam.utils.system import is_subdir

        paths = [os.path.abspath(path) for path in paths]
        base = os.path.abspath(base)
        output = os.path.abspath(output)

        # check all blends are in the base path
        for path in paths:
            if not is_subdir(path, base):
                fatal("Input blend file %r is not a sub directory of %r" % (path, base))

        if use_quiet:
            report = lambda msg: None
        else:
            report = lambda msg: print(msg, end="")

        # replace var with a pattern matching callback
        if filename_filter:
            # convert string into regex callback
            # "*.txt;*.png;*.rst" --> r".*\.txt$|.*\.png$|.*\.rst$"
            import re
            import fnmatch

            compiled_pattern = re.compile(
                    b'|'.join(fnmatch.translate(f).encode('utf-8')
                              for f in filename_filter.split(";") if f),
                    re.IGNORECASE,
                    )

            def filename_filter(f):
                return (not filename_filter.compiled_pattern.match(f))
            filename_filter.compiled_pattern = compiled_pattern

            del compiled_pattern
            del re, fnmatch

        for msg in blendfile_copy.copy_paths(
                [path.encode('utf-8') for path in paths],
                output.encode('utf-8'),
                base.encode('utf-8'),
                all_deps=all_deps,
                report=report,
                filename_filter=filename_filter,
                ):
            pass

    @staticmethod
    def remap_start(
            paths,
            use_json=False,
            ):
        filepath_remap = "bam_remap.data"

        for p in paths:
            if not os.path.exists(p):
                fatal("Path %r not found!" % p)
        paths = [p.encode('utf-8') for p in paths]

        if os.path.exists(filepath_remap):
            fatal("Remap in progress, run with 'finish' or remove %r" % filepath_remap)

        from bam.blend import blendfile_path_remap
        remap_data = blendfile_path_remap.start(
                paths,
                use_json=use_json,
                )

        with open(filepath_remap, 'wb') as fh:
            import pickle
            pickle.dump(remap_data, fh, pickle.HIGHEST_PROTOCOL)
            del pickle

    @staticmethod
    def remap_finish(
            paths,
            force_relative=False,
            dry_run=False,
            use_json=False,
            ):
        filepath_remap = "bam_remap.data"

        for p in paths:
            if not os.path.exists(p):
                fatal("Path %r not found!" % p)
        # bytes needed for blendfile_path_remap API
        paths = [p.encode('utf-8') for p in paths]

        if not os.path.exists(filepath_remap):
            fatal("Remap not started, run with 'start', (%r not found)" % filepath_remap)

        with open(filepath_remap, 'rb') as fh:
            import pickle
            remap_data = pickle.load(fh)
            del pickle

        from bam.blend import blendfile_path_remap
        blendfile_path_remap.finish(
                paths, remap_data,
                force_relative=force_relative,
                dry_run=dry_run,
                use_json=use_json,
                )

        if not dry_run:
            os.remove(filepath_remap)

    @staticmethod
    def remap_reset(
            use_json=False,
            ):
        filepath_remap = "bam_remap.data"
        if os.path.exists(filepath_remap):
            os.remove(filepath_remap)
        else:
            fatal("remapping not started, nothing to do!")


# -----------------------------------------------------------------------------
# Argument Parser

def init_argparse_common(
        subparse,
        use_json=False,
        use_all_deps=False,
        use_quiet=False,
        use_compress_level=False,
        use_exclude=False,
        ):
    import argparse

    if use_json:
        subparse.add_argument(
                "-j", "--json", dest="json", action='store_true',
                help="Generate JSON output",
                )
    if use_all_deps:
        subparse.add_argument(
                "-a", "--all-deps", dest="all_deps", action='store_true',
                help="Follow all dependencies (unused indirect dependencies too)",
                )
    if use_quiet:
        subparse.add_argument(
                "-q", "--quiet", dest="use_quiet", action='store_true',
                help="Suppress status output",
                )
    if use_compress_level:
        class ChoiceToZlibLevel(argparse.Action):
            def __call__(self, parser, namespace, value, option_string=None):
                setattr(namespace, self.dest, {"default": -1, "fast": 1, "best": 9, "store": 0}[value[0]])

        subparse.add_argument(
                "-c", "--compress", dest="compress_level", nargs=1, default=-1, metavar='LEVEL',
                action=ChoiceToZlibLevel,
                choices=('default', 'fast', 'best', 'store'),
                help="Compression level for resulting archive",
                )
    if use_exclude:
        subparse.add_argument(
                "-e", "--exclude", dest="exclude", metavar='PATTERN(S)', required=False,
                default="",
                help="""
                Optionally exclude files from the pack.

                Using Unix shell-style wildcards *(case insensitive)*.
                ``--exclude="*.png"``

                Multiple patterns can be passed using the  ``;`` separator.
                ``--exclude="*.txt;*.avi;*.wav"``
                """
                )


def create_argparse_init(subparsers):
    subparse = subparsers.add_parser("init",
            help="Initialize a new project directory")
    subparse.add_argument(
            dest="url",
            help="Project repository url",
            )
    subparse.add_argument(
            dest="directory_name", nargs="?",
            help="Directory name",
            )
    subparse.set_defaults(
            func=lambda args:
            bam_commands.init(args.url, args.directory_name),
            )


def create_argparse_create(subparsers):
    subparse = subparsers.add_parser(
            "create", aliases=("cr",),
            help="Create a new empty session directory",
            )
    subparse.add_argument(
            dest="session_name", nargs=1,
            help="Name of session directory",
            )
    subparse.set_defaults(
            func=lambda args:
            bam_commands.create(args.session_name[0]),
            )


def create_argparse_checkout(subparsers):
    subparse = subparsers.add_parser(
            "checkout", aliases=("co",),
            help="Checkout a remote path in an existing project",
            )
    subparse.add_argument(
            dest="path", type=str, metavar='REMOTE_PATH',
            help="Path to checkout on the server",
            )
    subparse.add_argument(
            "-o", "--output", dest="output", type=str, metavar='DIRNAME',
            help="Local name to checkout the session into (optional, falls back to path name)",
            )

    init_argparse_common(subparse, use_all_deps=True)

    subparse.set_defaults(
            func=lambda args:
            bam_commands.checkout(args.path, args.output, args.all_deps),
            )


def create_argparse_update(subparsers):
    subparse = subparsers.add_parser(
            "update", aliases=("up",),
            help="Update a local session with changes from the remote project",
            )
    subparse.add_argument(
            dest="paths", nargs="*",
            help="Path(s) to operate on",
            )
    subparse.set_defaults(
            func=lambda args:
            bam_commands.update(args.paths or ["."]),
            )


def create_argparse_revert(subparsers):
    subparse = subparsers.add_parser(
            "revert", aliases=("rv",),
            help="Reset local changes back to the state at time of checkout",
            )
    subparse.add_argument(
            dest="paths", nargs="+",
            help="Path(s) to operate on",
            )
    subparse.set_defaults(
            func=lambda args:
            bam_commands.revert(args.paths or ["."]),
            )


def create_argparse_commit(subparsers):
    subparse = subparsers.add_parser(
            "commit", aliases=("ci",),
            help="Commit changes from a session to the remote project",
            )
    subparse.add_argument(
            "-m", "--message", dest="message", metavar='MESSAGE',
            required=True,
            help="Commit message",
            )
    subparse.add_argument(
            dest="paths", nargs="*",
            help="paths to commit",
            )
    subparse.set_defaults(
            func=lambda args:
            bam_commands.commit(args.paths or ["."], args.message),
            )


def create_argparse_status(subparsers):
    subparse = subparsers.add_parser(
            "status", aliases=("st",),
            help="Show any edits made in the local session",
            )
    subparse.add_argument(
            dest="paths", nargs="*",
            help="Path(s) to operate on",
            )

    init_argparse_common(subparse, use_json=True)

    subparse.set_defaults(
            func=lambda args:
            bam_commands.status(args.paths or ["."], use_json=args.json),
            )


def create_argparse_list(subparsers):
    subparse = subparsers.add_parser(
            "list", aliases=("ls",),
            help="List the contents of a remote directory",
            )
    subparse.add_argument(
            dest="paths", nargs="*",
            help="Path(s) to operate on",
            )
    subparse.add_argument(
            "-f", "--full", dest="full", action='store_true',
            help="Show the full paths",
            )

    init_argparse_common(subparse, use_json=True)

    subparse.set_defaults(
            func=lambda args:
            bam_commands.list_dir(
                    args.paths or ["."],
                    use_full=args.full,
                    use_json=args.json,
                    ),
                    )


def create_argparse_deps(subparsers):
    subparse = subparsers.add_parser(
            "deps", aliases=("dp",),
            help="List dependencies for file(s)",
            )
    subparse.add_argument(
            dest="paths", nargs="+",
            help="Path(s) to operate on",
            )
    subparse.add_argument(
            "-r", "--recursive", dest="recursive", action='store_true',
            help="Scan dependencies recursively",
            )

    init_argparse_common(subparse, use_json=True)

    subparse.set_defaults(
            func=lambda args:
            bam_commands.deps(
                    args.paths, args.recursive,
                    use_json=args.json),
                    )


def create_argparse_pack(subparsers):
    import argparse
    subparse = subparsers.add_parser(
            "pack", aliases=("pk",),
            help="Pack a blend file and its dependencies into an archive",
            description=
    """
    You can simply pack a blend file like this to create a zip-file of the same name.

    .. code-block:: sh

       bam pack /path/to/scene.blend

    You may also want to give an explicit output directory.

    This command is used for packing a ``.blend`` file into a ``.zip`` file for redistribution.

    .. code-block:: sh

       # pack a blend with maximum compression for online downloads
       bam pack /path/to/scene.blend --output my_scene.zip --compress=best

    You may also pack a .blend while keeping your whole repository hierarchy by passing
    the path to the top directory of the repository, and ask to be warned about dependencies paths
    outside of that base path:

    .. code-block:: sh

       bam pack --repo="/path/to/repo" --warn-external /path/to/repo/path/to/scene.blend

    """,
            formatter_class=argparse.RawDescriptionHelpFormatter,
            )
    subparse.add_argument(
            dest="paths", nargs="+",
            help="Path(s) to operate on",
            )
    subparse.add_argument(
            "-o", "--output", dest="output", metavar='FILE', required=False,
            help="Output file or a directory when multiple inputs are passed",
            )
    subparse.add_argument(
            "-m", "--mode", dest="mode", metavar='MODE', required=False,
            default='ZIP',
            choices=('ZIP', 'FILE'),
            help="Output file or a directory when multiple inputs are passed",
            )
    subparse.add_argument(
            "--repo", dest="repository_base_path", metavar='DIR', required=False,
            help="Base directory from which you want to keep existing hierarchy (usually to repository directory),"
                 "will default to packed blend file's directory if not specified",
            )
    subparse.add_argument(
            "--warn-external", dest="warn_remap_externals", action='store_true',
            help="Warn for every dependency outside of given repository base path",
            )

    init_argparse_common(subparse, use_all_deps=True, use_quiet=True, use_compress_level=True, use_exclude=True)

    subparse.set_defaults(
            func=lambda args:
            bam_commands.pack(
                    args.paths,
                    args.output or
                    ((os.path.splitext(args.paths[0])[0] + ".zip")
                     if args.mode == 'ZIP' else None),
                    args.mode,
                    repository_base_path=args.repository_base_path or None,
                    all_deps=args.all_deps,
                    use_quiet=args.use_quiet,
                    warn_remap_externals=args.warn_remap_externals,
                    compress_level=args.compress_level,
                    filename_filter=args.exclude,
                    ),
            )


def create_argparse_copy(subparsers):
    import argparse
    subparse = subparsers.add_parser(
            "copy", aliases=("cp",),
            help="Copy blend file(s) and their dependencies to a new location (maintaining the directory structure).",
            description=
    """
    The line below will copy ``scene.blend`` to ``/destination/to/scene.blend``.

    .. code-block:: sh

       bam copy /path/to/scene.blend --base=/path --output=/destination

    .. code-block:: sh

       # you can also copy multiple files
       bam copy /path/to/scene.blend /path/other/file.blend --base=/path --output /other/destination
    """,
            formatter_class=argparse.RawDescriptionHelpFormatter,
            )
    subparse.add_argument(
            dest="paths", nargs="+",
            help="Path(s) to blend files to operate on",
            )
    subparse.add_argument(
            "-o", "--output", dest="output", metavar='DIR', required=True,
            help="Output directory where where files will be copied to",
            )
    subparse.add_argument(
            "-b", "--base", dest="base", metavar='DIR', required=True,
            help="Base directory for input paths (files outside this path will be omitted)",
            )

    init_argparse_common(subparse, use_all_deps=True, use_quiet=True, use_exclude=True)

    subparse.set_defaults(
            func=lambda args:
            bam_commands.copy(
                    args.paths,
                    args.output,
                    args.base,
                    all_deps=args.all_deps,
                    use_quiet=args.use_quiet,
                    filename_filter=args.exclude,
                    ),
            )


def create_argparse_remap(subparsers):
    import argparse

    subparse = subparsers.add_parser(
            "remap",
            help="Remap blend file paths",
            description=
    """
    This command is a 3 step process:

    - first run ``bam remap start .`` which stores the current state of your project (recursively).
    - then re-arrange the files on the filesystem (rename, relocate).
    - finally run ``bam remap finish`` to apply the changes, updating the ``.blend`` files internal paths.


    .. code-block:: sh

       cd /my/project

       bam remap start .
       mv photos textures
       mv house_v14_library.blend house_libraray.blend
       bam remap finish

    .. note::

       Remapping creates a file called ``bam_remap.data`` in the current directory.
       You can relocate the entire project to a new location but on executing ``finish``,
       this file must be accessible from the current directory.

    .. note::

       This command depends on files unique contents,
       take care not to modify the files once remap is started.
    """,
            formatter_class=argparse.RawDescriptionHelpFormatter,
            )

    subparse_remap_commands = subparse.add_subparsers(
            title="Remap commands",
            description='valid subcommands',
            help='additional help',
            )
    sub_subparse = subparse_remap_commands.add_parser(
            "start",
            help="Start remapping the blend files",
            )

    sub_subparse.add_argument(
            dest="paths", nargs="*",
            help="Path(s) to operate on",
            )
    init_argparse_common(sub_subparse, use_json=True)

    sub_subparse.set_defaults(
            func=lambda args:
            bam_commands.remap_start(
                    args.paths or ["."],
                    use_json=args.json,
                    ),
                    )

    sub_subparse = subparse_remap_commands.add_parser(
            "finish",
            help="Finish remapping the blend files",
            )
    sub_subparse.add_argument(
            dest="paths", nargs="*",
            help="Path(s) to operate on",
            )
    sub_subparse.add_argument(
            "-r", "--force-relative", dest="force_relative", action='store_true',
            help="Make all remapped paths relative (even if they were originally absolute)",
            )
    sub_subparse.add_argument(
            "-d", "--dry-run", dest="dry_run", action='store_true',
            help="Just print output as if the paths are being run",
            )
    init_argparse_common(sub_subparse, use_json=True)

    sub_subparse.set_defaults(
            func=lambda args:
            bam_commands.remap_finish(
                    args.paths or ["."],
                    force_relative=args.force_relative,
                    dry_run=args.dry_run,
                    use_json=args.json,
                    ),
                    )

    sub_subparse = subparse_remap_commands.add_parser(
            "reset",
            help="Cancel path remapping",
            )
    init_argparse_common(sub_subparse, use_json=True)

    sub_subparse.set_defaults(
            func=lambda args:
            bam_commands.remap_reset(
                    use_json=args.json,
                    ),
            )


def create_argparse():
    import argparse

    usage_text = (
        "BAM!\n" +
        __doc__
        )

    parser = argparse.ArgumentParser(
            prog="bam",
            description=usage_text,
            )

    subparsers = parser.add_subparsers(
            title='subcommands',
            description='valid subcommands',
            help='additional help',
            )

    create_argparse_init(subparsers)
    create_argparse_create(subparsers)
    create_argparse_checkout(subparsers)
    create_argparse_commit(subparsers)
    create_argparse_update(subparsers)
    create_argparse_revert(subparsers)
    create_argparse_status(subparsers)
    create_argparse_list(subparsers)

    # non-bam project commands
    create_argparse_deps(subparsers)
    create_argparse_pack(subparsers)
    create_argparse_copy(subparsers)
    create_argparse_remap(subparsers)

    return parser


def main(argv=None):
    if argv is None:
        argv = sys.argv[1:]

    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)-15s %(levelname)8s %(name)s %(message)s',
    )

    parser = create_argparse()
    args = parser.parse_args(argv)

    # call subparser callback
    if not hasattr(args, "func"):
        parser.print_help()
        return

    args.func(args)


if __name__ == "__main__":
    raise Exception("This module can't be executed directly, Call '../bam_cli.py'")
