
import os, struct
from binascii import hexlify, unhexlify

from bup import git, vfs, vint
from bup.helpers import debug1, debug2, linereader, lines_until_sentinel, log
from bup.io import path_msg
from bup.metadata import Metadata
from bup.vint import \
    read_bvec, read_vint, read_vuint, write_bvec, write_vint, write_vuint
from bup.vfs import Item, Chunky, RevList, Root, Tags, Commit, FakeLink


# See also RemoteRepo._config_get_fallback().  The current assumption
# is that None is the appropriate value for an option whenever the
# server does not support config-get.

valid_config_opts = \
    frozenset((b'bup.repo.id',
               b'bup.split.trees',
               b'bup.split.files',
               b'pack.packsizelimit',
               b'core.compression',
               b'pack.compression'))


def read_item(port):
    """Read an encoded VFS item from port. Throw EOFError for EOF."""
    def read_oid(port, kind):
        bv = read_bvec(port)
        if bv is None:
            raise EOFError(f'EOF while reading {kind} OID')
        return bv
    def read_m(port, kind, has_meta):
        if has_meta:
            m = Metadata.read(port)
            if m is None:
                raise EOFError(f'EOF while reading {kind} metadata')
        else:
            m = read_vuint(port)
            if m is None:
                raise EOFError(f'EOF while reading {kind} integer mode')
        return m
    # pylint: disable-next=unbalanced-tuple-unpacking
    kind, has_meta = vint.recv(port, 'sV')
    if kind == b'Item':
        oid, meta = read_oid(port, kind), read_m(port, kind, has_meta)
        return Item(oid=oid, meta=meta)
    if kind == b'Chunky':
        oid, meta = read_oid(port, kind), read_m(port, kind, has_meta)
        return Chunky(oid=oid, meta=meta)
    if kind == b'RevList':
        oid, meta = read_oid(port, kind), read_m(port, kind, has_meta)
        return RevList(oid=oid, meta=meta)
    if kind == b'Root':
        return Root(meta=read_m(port, kind, has_meta))
    if kind == b'Tags':
        return Tags(meta=read_m(port, kind, has_meta))
    if kind == b'Commit':
        # pylint: disable-next=unbalanced-tuple-unpacking
        oid, coid = vint.recv(port, 'ss')
        meta = read_m(port, kind, has_meta)
        return Commit(oid=oid, coid=coid, meta=meta)
    if kind == b'FakeLink':
        target = read_bvec(port)
        if target is None:
            raise EOFError(f'EOF while reading {kind} target')
        meta = read_m(port, kind, has_meta)
        return FakeLink(target=target, meta=meta)
    assert False

def write_item(port, item):
    kind = type(item)
    name = bytes(kind.__name__.encode('ascii'))
    meta = item.meta
    has_meta = 1 if isinstance(meta, Metadata) else 0
    if kind in (Item, Chunky, RevList):
        assert len(item.oid) == 20
        if has_meta:
            vint.send(port, 'sVs', name, has_meta, item.oid)
            Metadata.write(meta, port, include_path=False)
        else:
            vint.send(port, 'sVsV', name, has_meta, item.oid, item.meta)
    elif kind in (Root, Tags):
        if has_meta:
            vint.send(port, 'sV', name, has_meta)
            Metadata.write(meta, port, include_path=False)
        else:
            vint.send(port, 'sVV', name, has_meta, item.meta)
    elif kind == Commit:
        assert len(item.oid) == 20
        assert len(item.coid) == 20
        if has_meta:
            vint.send(port, 'sVss', name, has_meta, item.oid, item.coid)
            Metadata.write(meta, port, include_path=False)
        else:
            vint.send(port, 'sVssV', name, has_meta, item.oid, item.coid,
                      item.meta)
    elif kind == FakeLink:
        if has_meta:
            vint.send(port, 'sVs', name, has_meta, item.target)
            Metadata.write(meta, port, include_path=False)
        else:
            vint.send(port, 'sVsV', name, has_meta, item.target, item.meta)
    else:
        assert False

def write_resolution(port, resolution):
    write_vuint(port, len(resolution))
    for name, item in resolution:
        write_bvec(port, name)
        if item:
            port.write(b'\x01')
            write_item(port, item)
        else:
            port.write(b'\x00')

def read_resolution(port):
    # resolution must exist; raise EOFError if not
    n = read_vuint(port)
    if n is None:
        raise EOFError('EOF while reading VFS resolve path length')
    result = []
    for _ in range(n):
        name = read_bvec(port)
        if name is None:
            raise EOFError('EOF while reading VFS resolve path name')
        have_item = ord(port.read(1))
        if have_item is None:
            raise EOFError('EOF while reading VFS resolve path item indicator')
        assert have_item in (0, 1)
        item = read_item(port) if have_item else None
        result.append((name, item))
    return tuple(result)

def write_ioerror(port, ex):
    assert isinstance(ex, vfs.IOError)
    write_vuint(port,
                (1 if ex.errno is not None else 0)
                | (2 if ex.strerror is not None else 0)
                | (4 if ex.terminus is not None else 0))
    if ex.errno is not None:
        write_vint(port, ex.errno)
    if ex.strerror is not None:
        write_bvec(port, ex.strerror.encode('utf-8'))
    if ex.terminus is not None:
        write_resolution(port, ex.terminus)

def read_ioerror(port):
    mask = read_vuint(port)
    if mask is None:
        raise EOFError('EOF while reading IOError mask')
    no, msg, term = None, None, None
    if 1 & mask:
        no = read_vint(port)
        if no is None:
            raise EOFError('EOF while reading IOError errno')
    if 2 & mask:
        msg = read_bvec(port).decode('utf-8')
        if msg is None:
            raise EOFError('EOF while reading IOError message')
    if 4 & mask:
        term = read_resolution(port)
        if msg is None:
            raise EOFError('EOF while reading IOError terminus')
    return vfs.IOError(errno=no, message=msg, terminus=term)

def _command(fn):
    fn.bup_server_command = True
    return fn

class CommandDenied(Exception): pass

class Server:
    def __init__(self, conn, backend, *, commands='all',
                 vet_init_dir=None, vet_set_dir=None, vet_update_ref=None):
        """When commands is a sequence of command names (bytes),
        enable those commands.  When it is 'all', enable all commands.
        The help and quit commands are always enabled, and the
        init-dir and set-dir commands are also enabled, at least for
        now, because earlier versions of the client unconditionally
        required them during initialization.

        The vet_* arguments can provide a function that should accept
        COMMAND's decoded arguments, and raise a CommandDenied
        exception if the invocation is not acceptable.

        """
        all_cmds = self._get_commands()
        if commands == 'all':
            self._commands = frozenset(all_cmds)
        else:
            for cmd in commands: assert cmd in all_cmds, commands
            # init-dir, set-dir, list-indexes, and send-index were
            # unconditionally required by Client() before 0.34, so
            # always include them for now.
            self._commands = frozenset((b'help', b'quit',
                                        b'init-dir', b'set-dir',
                                        b'list-indexes', b'send-index',
                                        *commands))
        self.conn = conn
        self._backend = backend
        self.suspended = False
        self.repo = None
        self._deduplicate_writes = True
        self._vet_init_dir = vet_init_dir
        self._vet_set_dir = vet_set_dir
        self._vet_update_ref = vet_update_ref

    def _get_commands(self):
        commands = []
        for name in dir(self):
            fn = getattr(self, name)
            if getattr(fn, 'bup_server_command', False):
                commands.append(name.replace('_', '-').encode('ascii'))
        return commands

    @_command
    def quit(self, args):
        # implementation is actually not here
        pass

    @_command
    def help(self, args_):
        self.conn.write(b'Commands:\n    %s\n' % b'\n    '.join(sorted(self._commands)))
        self.conn.ok()

    def init_session(self, repo_dir=None):
        if self.repo:
            if repo_dir:
                self.repo.close()
                self.repo = None
                self.suspended = False
        else:
            self.repo = self._backend(repo_dir, server=True)
            dw = self.repo.config_get(b'bup.server.deduplicate-writes', opttype='bool')
            self._deduplicate_writes = True if dw is None else dw
            debug1('bup server: (%sdeduplicating) serving %s\n'
                   % ('' if self._deduplicate_writes else 'not ',
                      path_msg(self.repo.repo_dir)))

    @_command
    def init_dir(self, path):
        if self._vet_init_dir: self._vet_init_dir(self.repo, path)
        self._backend.create(path)
        self.init_session(path)
        self.conn.ok()

    @_command
    def set_dir(self, path):
        if self._vet_set_dir: self._vet_set_dir(self.repo, path)
        self.init_session(path)
        self.conn.ok()

    @_command
    def list_indexes(self, junk_):
        assert self._deduplicate_writes is not None
        self.init_session()
        suffix = b'' if self._deduplicate_writes else b' load'
        for f in self.repo.list_indexes():
            # must end with .idx to not confuse everything, so filter
            # here ... even if the subclass might not yield anything
            # else to start with
            if f.endswith(b'.idx'):
                self.conn.write(b'%s%s\n' % (f, suffix))
        self.conn.ok()

    def _send_size(self, size):
        self.conn.write(struct.pack('!I', size))

    @_command
    def send_index(self, name):
        self.init_session()
        assert(name.find(b'/') < 0)
        assert(name.endswith(b'.idx'))
        self.repo.send_index(name, self.conn, self._send_size)
        self.conn.ok()

    def _check(self, expected, actual, msg):
        if expected != actual:
            self.repo.abort_writing()
            raise Exception(msg % (expected, actual))

    @_command
    def receive_objects_v2(self, junk_):
        self.init_session()
        if self.suspended:
            self.suspended = False
        # FIXME: this goes together with the direct accesses below
        self.repo._ensure_packwriter()
        suggested = set()
        while 1:
            ns = self.conn.read(4)
            if not ns:
                self.repo.abort_writing()
                raise Exception('object read: expected length header, got EOF\n')
            n = struct.unpack('!I', ns)[0]
            #debug2('expecting %d bytes\n' % n)
            if not n:
                # FIXME: don't be lazy and count ourselves, or something, at least
                # don't access self.repo internals
                debug1('bup server: received %d object%s.\n'
                    % (self.repo._packwriter.object_count(),
                       self.repo._packwriter.object_count() != 1 and "s" or ''))
                fullpath = self.repo.finish_writing()
                if fullpath:
                    name = os.path.split(fullpath)[1]
                    self.conn.write(b'%s.idx\n' % name)
                self.conn.ok()
                return
            if n == 0xffffffff:
                debug2('bup server: receive-objects suspending.\n')
                self.suspended = True
                self.conn.ok()
                return

            shar = self.conn.read(20)
            crcr = struct.unpack('!I', self.conn.read(4))[0]
            n -= 20 + 4
            buf = self.conn.read(n)  # object sizes in bup are reasonably small
            #debug2('read %d bytes\n' % n)
            self._check(n, len(buf), 'object read: expected %d bytes, got %d\n')
            assert self._deduplicate_writes is not None
            if self._deduplicate_writes:
                result = self.repo.exists(shar, want_source=True)
                if result:
                    oldpack = result.pack
                    assert(oldpack.endswith(b'.idx'))
                    name = os.path.split(oldpack)[1]
                    if not name in suggested:
                        debug1("bup server: suggesting index %s\n"
                               % git.shorten_hash(name).decode('ascii'))
                        debug1("bup server:   because of object %s\n"
                               % shar.hex())
                        self.conn.write(b'index %s\n' % name)
                        suggested.add(name)
                    continue
            # FIXME: figure out the right abstraction for this; or better yet,
            #        make the protocol aware of the object type
            crc = self.repo._packwriter._store.write((buf,), sha=shar)[1]
            self._check(crcr, crc, 'object read: expected crc %d, got %d\n')
        assert False  # should be unreachable

    @_command
    def read_ref(self, refname):
        self.init_session()
        r = self.repo.read_ref(refname)
        self.conn.write(b'%s\n' % hexlify(r or b''))
        self.conn.ok()

    @_command
    def update_ref(self, refname):
        newval = unhexlify(self.conn.readline().strip())
        oldval = unhexlify(self.conn.readline().strip())
        if self._vet_update_ref:
            self._vet_update_ref(self.repo, refname, newval, oldval)
        self.init_session()
        self.repo.update_ref(refname, newval, oldval)
        self.conn.ok()

    @_command
    def join(self, id):
        self.init_session()
        try:
            for blob in self.repo.join(id):
                self.conn.write(struct.pack('!I', len(blob)))
                self.conn.write(blob)
        except KeyError as e:
            msg = str(e)
            log(f'server: error: {msg}\n')
            self.conn.write(b'\0\0\0\0')
            self.conn.error(msg)
        else:
            self.conn.write(b'\0\0\0\0')
            self.conn.ok()

    cat = join # apocryphal alias

    @_command
    def cat_batch(self, dummy_):
        self.init_session()
        # For now, avoid potential deadlock by just reading them all
        for ref in tuple(lines_until_sentinel(self.conn, b'\n', Exception)):
            ref = ref[:-1]
            oidx, kind, size, it = self.repo.cat(ref)
            if not oidx:
                self.conn.write(b'missing\n')
                continue
            self.conn.write(b'%s %s %d\n' % (oidx, kind, size))
            for buf in it:
                self.conn.write(buf)
        self.conn.ok()

    @_command
    def refs(self, args):
        limit_to_heads, limit_to_tags = args.split()
        assert limit_to_heads in (b'0', b'1')
        assert limit_to_tags in (b'0', b'1')
        limit_to_heads = int(limit_to_heads)
        limit_to_tags = int(limit_to_tags)
        self.init_session()
        patterns = tuple(x[:-1] for x in lines_until_sentinel(self.conn, b'\n', Exception))
        for name, oid in self.repo.refs(patterns, limit_to_heads, limit_to_tags):
            assert b'\n' not in name
            self.conn.write(b'%s %s\n' % (hexlify(oid), name))
        self.conn.write(b'\n')
        self.conn.ok()

    @_command
    def rev_list(self, _):
        self.init_session()
        # (broken) count support was removed in
        # 89cd30c0d82c2b71e6fe14de7ad210f635b38f91
        count = self.conn.readline()
        if not count:
            raise Exception('Unexpected EOF while reading rev-list count')
        assert count == b'\n', count
        fmt = self.conn.readline()
        if not fmt:
            raise Exception('Unexpected EOF while reading rev-list format')
        fmt = None if fmt == b'\n' else fmt[:-1]
        refs = tuple(x[:-1] for x in lines_until_sentinel(self.conn, b'\n', Exception))

        try:
            for buf in self.repo.rev_list_raw(refs, fmt):
                self.conn.write(buf)
            self.conn.write(b'\n')
            self.conn.ok()
        except git.GitError as e:
            self.conn.error(str(e))
            raise

    @_command
    def resolve(self, args):
        self.init_session()
        (flags,) = args.split()
        flags = int(flags)
        want_meta = bool(flags & 1)
        follow = bool(flags & 2)
        have_parent = bool(flags & 4)
        parent = read_resolution(self.conn) if have_parent else None
        path = read_bvec(self.conn)
        if path is None:
            raise EOFError('EOF while reading resolved VFS path length')
        if not path:
            raise Exception('Empty resolve path')
        try:
            res = list(self.repo.resolve(path, parent, want_meta, follow))
        except vfs.IOError as ex:
            res = ex
        if isinstance(res, vfs.IOError):
            self.conn.write(b'\0')  # error
            write_ioerror(self.conn, res)
        else:
            self.conn.write(b'\1')  # success
            write_resolution(self.conn, res)
        self.conn.ok()

    @_command
    def config_get(self, args):
        self.init_session()
        assert not args
        # pylint: disable-next=unbalanced-tuple-unpacking
        key, opttype = vint.recv(self.conn, 'ss')
        # git is case-insensitve, and the client sends lower-case
        if key in valid_config_opts:
            opttype = None if not opttype else opttype.decode('ascii')
            val = self.repo.config_get(key, opttype=opttype)
            if val is None:
                write_vuint(self.conn, 0)
            elif isinstance(val, bool):
                write_vuint(self.conn, 1 if val else 2)
            elif isinstance(val, int):
                vint.send(self.conn, 'Vv', 3, val)
            elif isinstance(val, bytes):
                vint.send(self.conn, 'Vs', 4, val)
            else:
                raise TypeError(f'Unrecognized result type {type(val)}')
        else:
            write_vuint(self.conn, 5)
        self.conn.ok()

    def handle(self):
        commands = self._commands

        # FIXME: this protocol is totally lame and not at all future-proof.
        # (Especially since we abort completely as soon as *anything* bad happens)
        lr = linereader(self.conn)
        for _line in lr:
            line = _line.strip()
            if not line:
                continue
            debug1('bup server: command: %r\n' % line)
            words = line.split(b' ', 1)
            cmd = words[0]

            if not cmd in commands:
                raise Exception('unknown server command: %r\n' % line)

            rest = len(words) > 1 and words[1] or ''
            if cmd == b'quit':
                break

            cmdattr = cmd.replace(b'-', b'_').decode('ascii', errors='replace')
            getattr(self, cmdattr)(rest)

        debug1('bup server: done\n')

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        try:
            if self.suspended:
                self.repo.finish_writing()
        finally:
            if self.repo:
                self.repo.close()
