from __future__ import print_function
from __future__ import absolute_import
# Written by Ross Cohen
# Maintained by Chris Hutchinson
# see LICENSE.txt for license information

from builtins import next
from builtins import str
from builtins import range
from future.utils import raise_
from .bencode import bdecode, bencode
from .db import db
from .history import roothandle, dmerge, rename_conflict_check
from .history import handle_name_at_point, __handle_name_at_point
from .history import _handle_name_at_point
from .history import handle_contents_at_point
from .history import handle_last_modified
from .history import _name_use_count, _children_count
from .history import write_diff, write_index, db_get, db_put, HistoryError
from .history import clean_merge_point, simplify_precursors, repo_head
from .history import _is_ancestor
from .merge import find_resolution
import os
from os import path
from .path import breakup
import sha
import stat
from time import time
import zlib

class CommitError(Exception):
    pass

def new_handle(co, txn):
    """Create a temporary handle for new files in the working copy"""
    num = bdecode(co.linforepo.get('lasthandle', txn=txn)) + 1
    handle = sha.new(str(num)).digest()
    co.linforepo.put('lasthandle', bencode(num), txn=txn)
    return handle

def create_handle(precursors, hinfo):
    """Create a permanent identifier for use in a changeset description"""
    binfo = bencode({'precursors': precursors, 'handle': hinfo})
    return sha.new(binfo).digest()

def handle_name(co, handle, txn):
    """Returns a dict specifying name, parent and other info"""
    if handle in co.handle_name_cache:
        return co.handle_name_cache[handle]

    # XXX: reading this repeatedly is excessive
    heads = bdecode(co.linforepo.get('heads', txn=txn))
    einfo = {}
    if co.editsdb.has_key(handle, txn):
        einfo = bdecode(co.editsdb.get(handle, txn=txn))
        if 'name' in einfo:
            einfo['points'] = ['1']
            for head in heads:
                pinfo = __handle_name_at_point(co, handle, head, txn)
                if pinfo is None:
                    continue
                einfo['points'] = dmerge(einfo['points'], pinfo['points'])
            einfo['rename point'] = ['1']
            co.handle_name_cache[handle] = einfo
            return einfo
    state = None
    for point in heads:
        pinfo = __handle_name_at_point(co, handle, point, txn)
        if pinfo is None:
            continue
        if 'delete' in pinfo:
            co.handle_name_cache[handle] = pinfo
            return pinfo
        if state is None:
            state = pinfo
            continue
        conflict, rename_points = rename_conflict_check(state, pinfo)
        if conflict == 'remote':
            state['name'] = pinfo['name']
            state['parent'] = pinfo['parent']
        state['rename point'] = rename_points
        state['points'] = dmerge(state['points'], pinfo['points'])
    if 'delete' in einfo:
        state['delete'] = einfo['delete']
    co.handle_name_cache[handle] = state
    return state

def _filename_to_handle(co, parent, name, txn, deletes=0):
    """Given a parent handle and a name, return all associated handles"""
    handles = []
    cursor = co.allnamesdb.cursor(txn=txn)
    lookup = parent + name
    try:
        key, value = cursor.set(lookup)
    except (db.DBNotFoundError, TypeError):
        key = None
    while key == lookup:
        hinfo = handle_name(co, value, txn)
        if hinfo is not None and hinfo['name'] == name and hinfo['parent'] == parent:
            if 'delete' not in hinfo:
                handles.append(value)

            elif deletes == 1 and value in co.editsdb:
                if 'delete' in bdecode(co.editsdb.get(value, txn=txn)):
                    handles.append(value)

        # next_dup() is broken
        foo = next(cursor)
        if foo is None:
            break
        key, value = foo

    cursor.close()
    return handles

def filename_to_handle(co, file, txn=None, deletes=0):
    """Given a full file path, return the associated handle"""
    if file == '':
        return roothandle
    lpath = breakup(file)
    handle = roothandle
    for name in lpath:
        handles = _filename_to_handle(co, handle, name, txn, deletes=deletes)
        if handles == []:
            return None
        handle = handles[0]
    return handle

def _handle_to_filename(co, handle, names, txn):
    """Same as handle_to_filename, but takes an override mapping for handles"""
    lpath, hseen = [], {}
    while handle != roothandle:
        if handle in names:
            info = names[handle]
        else:
            info = handle_name(co, handle, txn)
            if info is None:
                lpath.insert(0, '???')
                break
        if handle in hseen:
            lpath.insert(0, info['name'] + '(loop)')
            break
        lpath.insert(0, info['name'])
        hseen[handle] = 1
        handle = info['parent']
    try:
        return path.join(*lpath)
    except TypeError:
        pass
    return ''

def handle_to_filename(co, handle, txn=None):
    """Convert the given handle to a full path name."""
    return _handle_to_filename(co, handle, {}, txn)

def set_edit(co, handle, info, txn):
    """Specify that a new attribute on a file is being edited."""
    if handle in co.editsdb:
        oinfo = bdecode(co.editsdb.get(handle, txn=txn))
        oinfo.update(info)
        info = oinfo
    co.editsdb.put(handle, bencode(info), txn=txn)

def unset_edit(co, handle, info, txn):
    """Remove an edit attribute on a file."""
    oinfo = bdecode(co.editsdb.get(handle, txn=txn))
    for attr in info:
        del oinfo[attr]
    if oinfo == {}:
        co.editsdb.delete(handle, txn=txn)
    else:
        co.editsdb.put(handle, bencode(oinfo), txn=txn)

def _add_file(co, rep, parent, required, ltxn):
    mode = os.lstat(path.join(co.local, rep)).st_mode
    handle = filename_to_handle(co, rep)
    if handle:
        #info = bdecode(co.staticdb.get(handle))
        info = db_get(co, co.staticdb, handle, None)
        if info['type'] == 'dir' and not stat.S_ISDIR(mode):
            print('error - %s already added as a %s' % (rep, info['type']))
            return None
        if info['type'] == 'file' and not stat.S_ISREG(mode):
            print('error - %s already added as a %s' % (rep, info['type']))
            return None
        if required:
            print('warning - %s already added' % (rep,))
    else:
        print('adding: ' + rep)
        if stat.S_ISDIR(mode):
            type = 'dir'
        elif stat.S_ISREG(mode):
            type = 'file'
        else:
            print('error - unrecognized file type for %s' % (rep,))
            return None
        if rep == '':
            handle = roothandle
        else:
            handle = new_handle(co, ltxn)
        #co.staticdb.put(handle, bencode({'type': type}), txn=ltxn)
        db_put(co, co.staticdb, handle, {'type': type}, ltxn)
        info = {'name': path.split(rep)[1], 'parent': parent, 'add': {}}
        set_edit(co, handle, info, ltxn)
        co.allnamesdb.put(parent + info['name'], handle, flags=db.DB_NODUPDATA, txn=ltxn)
        if type == 'file':
            co.modtimesdb.put(handle, bencode(0), txn=ltxn)
            co.filenamesdb.put(rep, handle, txn=ltxn)
    return handle

def _set_name(co, handle, parent, name, txn):
    linfo = {'parent': parent, 'name': name}
    co.handle_name_cache[handle] = linfo

    set_edit(co, handle, linfo, txn)
    try:
        co.allnamesdb.put(parent + name, handle, flags=db.DB_NODUPDATA, txn=txn)
    except db.DBKeyExistError:
        pass

def name_use_count(co, handle, txn):
    """Returns a list of handles with the same name as the given handle."""
    def _name_func(co, handle, foo, txn):
        return handle_name(co, handle, txn)
    return _name_use_count(co, handle_name(co, handle, txn), None, _name_func, txn)

def rename_race(co, handle, names, txn):
    #def _name_func(co, handle, names, txn):
    #    if names.has_key(handle):
    #        return names[handle]
    #    return None
    #named = _name_use_count(co, info, names, _name_func, txn)
    info = handle_name(co, handle, txn)
    cursor = co.allnamesdb.cursor(txn=txn)
    lookup = info['parent'] + info['name']
    try:
        key, value = cursor.set(lookup)
    except (db.DBNotFoundError, TypeError):
        return None
    while key == lookup:
        if value in names:
            vinfo = names[value]
            if vinfo['parent'] == info['parent'] and vinfo['name'] == info['name']:
                cursor.close()
                return value
        foo = next(cursor)
        if foo is None:
            break
        key, value = foo
    cursor.close()
    return None

def children_count(co, handle, txn, deletes=0):
    """Returns a list of children of the specified handle."""
    def _name_func(co, handle, foo, txn):
        hinfo = handle_name(co, handle, txn)
        if hinfo is not None and 'delete' in hinfo and deletes == 1:
            # if the editsdb has it, then it was just deleted
            if co.editsdb.has_key(handle, txn):
                del hinfo['delete']
        return hinfo
    return _children_count(co, handle, None, _name_func, txn)

def _parent_loop_check(co, handle, names, txn):
    """Same as parent_loop_check, but takes a dict of handle mapping overrides."""
    hseen = {}
    while handle != roothandle:
        if handle in hseen:
            return handle
        hseen[handle] = 1
        if handle in names:
            pinfo = names[handle]
        else:
            pinfo = handle_name(co, handle, txn)
        if 'delete' in pinfo:
            return handle
        handle = pinfo['parent']
    return None

def parent_loop_check(co, handle, txn):
    """Check whether this handle exists under itself in the directory tree."""
    return _parent_loop_check(co, handle, {}, txn)

def _rename_safe_check(co, handle, names, txn):
    temp = names[handle]
    del names[handle]
    result = _parent_loop_check(co, handle, names, txn)
    names[handle] = temp
    if result is None:
        return 1
    return None

def unique_name(co, parent, file, txn):
    """Generate a name not already in use by the system."""
    if _filename_to_handle(co, parent, file, txn) == []:
        return file
    post = 2
    while _filename_to_handle(co, parent, file + str(post), txn) != []:
        post = post + 1
    return file + str(post)

def conflicts_in_file(co, file):
    h = open(path.join(co.local, file), 'rb')
    lines = h.read().split('\n')
    h.close()
    for l in lines:
        if     l == '<<<<<<< local' or \
               l == '=======' or \
               l == '>>>>>>> remote':
            return 1
    return 0

def find_update_files(co, rhead, named, txn):
    rnamed = []
    for handle in named:
        linfo = handle_name(co, handle, txn)
        rnamed.append((handle, linfo, __handle_name_at_point(co, handle, rhead, txn)))
    return rnamed

def mark_modified_files(co, txn):
    if 'edit-mode' in co.varsdb:
        if co.varsdb.get('edit-mode') == '1':
            return
    modtimesdb, editsdb, local = co.modtimesdb, co.editsdb, co.local
    for lfile, handle in co.filenamesdb.items(txn):
        if editsdb.has_key(handle, txn):
            info = bdecode(editsdb.get(handle, txn=txn))
            if 'hash' in info or 'add' in info:
                continue
        #info = bdecode(co.staticdb.get(handle, txn=txn))
        info = db_get(co, co.staticdb, handle, txn)
        if info['type'] != 'file':
            continue
        lfile = path.join(local, lfile)
        try:
            mtime = path.getmtime(lfile)
        except OSError:
            continue
        minfo = bdecode(modtimesdb.get(handle, txn=txn))
        if mtime != minfo:
            modtimesdb.put(handle, bencode(mtime), txn=txn)
            set_edit(co, handle, {'hash': 1}, txn)

def gen_diff(co, handle, precursors, lines, txn):
    pres, plen = simplify_precursors(co, handle, co.contents, precursors, txn)

    file_points = []
    for pre, index in pres:
        info = handle_contents_at_point(co, handle, pre, txn)
        file_points.append((info['lines'], info['line points'], info['points']))

    result, ms, newlines = find_resolution(file_points, lines)

    # explanation of conditions:
    # 1: check for a merge
    # 2: check if new lines were added by the user
    # 3: safety for the 4th condition
    # 4: check if the first match in the first (only) file covers everything
    if len(pres) > 1 or \
           len(newlines) != 0 or \
           not len(ms[0]) or \
           ms[0][0][2] != len(file_points[0][0]):

        # create a set of correct matches, minus ones which are optimized out
        matches = [[] for i in range(plen)]
        i = 0
        for pre, index in pres:
            matches[index] = ms[i]
            i += 1

        return {'matches': matches, 'newlines': newlines}

    return None

def gen_changeset(co, files, comment, repohead, txn, tstamp=None):
    def per_file_hash(co, handle, hinfo, precursors, lfile, txn):
        try:
            h = open(lfile, 'rb')
        except IOError:
            raise_(HistoryError, 'Could not open file ' + lfile)
        lines = h.read().split('\n')
        h.close()

        dinfo = gen_diff(co, handle, precursors, lines, txn)
        if 'add' in hinfo:
            dinfo['add'] = 1
        if 'delete' in hinfo:
            dinfo['delete'] = 1
        try:
            diff = bencode(dinfo)
        except ValueError:
            return None
        co.modtimesdb.put(handle, bencode(path.getmtime(lfile)), txn=txn)
        hinfo['hash'] = sha.new(diff).digest()
        return zlib.compress(diff, 6)

    precursors = bdecode(co.linforepo.get('heads'))

    # include the last known repository head in the list of changes. this is
    # extra useful info and also forces a merge change which a comment can
    # then be attached to.
    # XXX: can do the wrong thing in odd merge-and-not-update cases
    if repohead is not None and repohead not in precursors:
        while not _is_ancestor(co, repohead, precursors, txn):
            info = bdecode(co.lcrepo.get(repohead, txn=txn))
            try:
                repohead = info['precursors'][0]
            except IndexError:
                repohead = rootnode

        if repohead not in precursors:
            precursors.insert(0, repohead)

    changeset = {'precursors': precursors}

    changeset['handles'] = handles = {}
    adds, nedits, edits, types, names = {}, [], [], {}, {}
    for handle, linfo in files:
        if 'add' in linfo:
            adds[handle] = 1
        #types[handle] = bdecode(co.staticdb.get(handle))['type']
        types[handle] = db_get(co, co.staticdb, handle, None)['type']

        handles[handle] = cinfo = {}
        if 'delete' in linfo:
            assert 'add' not in linfo
            assert 'hash' not in linfo
            cinfo['delete'] = 1
        elif     'name' in linfo or \
                 'nmerge' in linfo:
            nedits.append((handle, linfo))

        if 'add' in linfo:
            assert 'hash' not in linfo
            cinfo['add'] = {'type': types[handle]}
        elif     'hash' in linfo or \
                 'cmerge' in linfo:
            assert types[handle] == 'file'
            edits.append(handle)
        co.editsdb.delete(handle, txn=txn)

    # generate the name diffs
    for handle, linfo in nedits:
        # check if this is really a merge or not
        # XXX: theoretically we can trust the 'nmerge' flag as set (and
        # cleared) by _update_helper()
        merge = False
        change = prev_change = None
        for head in precursors:
            change = handle_last_modified(co, co.names, handle, head, txn)
            if change is None:
                continue

            if prev_change is None:
                prev_change = change
                continue

            left_anc = _is_ancestor(co, prev_change, [change], txn)
            right_anc = _is_ancestor(co, change, [prev_change], txn)

            if left_anc:
                prev_change = change
            elif not right_anc:
                merge = True
                break

        # XXX: sanity check for now, we have to do most of the work anyway
        assert not (('nmerge' in linfo) ^ merge)

        # no merge, but maybe the user made an explicit change
        if 'nmerge' not in linfo and change is not None:
            old_info = handle_name_at_point(co, handle, change, txn, lookup=False)
            if old_info['name'] == linfo['name'] and \
               old_info['parent'] == linfo['parent']:
                continue

        # looks like we need to include an explicit name change
        cinfo = handles[handle]
        hinfo = handle_name(co, handle, txn)
        cinfo['parent'] = hinfo['parent']
        cinfo['name'] = hinfo['name']
        names[handle] = cinfo

    # generate the diffs
    indices = {}
    for handle in edits:
        lfile = path.join(co.local, _handle_to_filename(co, handle, names, txn))
        diff = per_file_hash(co, handle, handles[handle], precursors, lfile, txn)
        if diff is None:
            continue
        indices[handle] = write_diff(co, handle, diff, txn)

    # clear out things which didn't actually have changes
    for handle, linfo in files:
        if handles[handle] == {}:
            del handles[handle]

    # change all the temporary IDs to permanent, verifiable ones
    ladds, nmap = list(adds.keys()), {}
    while len(ladds):
        handle = ladds.pop()
        # check if this handle was already dealt with
        if handle not in adds:
            continue
        parent = handles[handle]['parent']
        # if the parent was also added, it needs to be renumbered first
        if parent in adds:
            ladds.extend((handle, parent))
            continue
        hinfo = handles[handle]
        # if the parent has been renumbered, pick up the change
        if parent in nmap:
            hinfo['parent'] = nmap[parent]
        # generate the permanent ID
        if types[handle] == 'file':
            # generate diffs
            fname = _handle_to_filename(co, handle, names, txn)
            lfile = path.join(co.local, fname)
            diff = per_file_hash(co, handle, handles[handle], [], lfile, txn)
            newhandle = create_handle(precursors, hinfo)
            indices[newhandle] = write_diff(co, newhandle, diff, txn)
            # update the db accordingly
            co.modtimesdb.delete(handle, txn=txn)
            co.modtimesdb.put(newhandle, bencode(path.getmtime(lfile)), txn=txn)
            co.filenamesdb.put(fname, newhandle, txn=txn)
        else:
            newhandle = create_handle(precursors, hinfo)
        handles[newhandle] = handles[handle]
        names[newhandle] = names[handle]
        types[newhandle] = types[handle]
        del handles[handle]
        del adds[handle]

        # more db updating
        co.staticdb.delete(handle, txn=txn)
        #co.staticdb.put(newhandle, bencode({'type': types[handle]}), txn=txn)
        db_put(co, co.staticdb, newhandle, {'type': types[handle]}, txn)
        nmap[handle] = newhandle
        # XXX: clean up allnamesdb

    # do reparenting of all the non-added files
    for handle in list(names.keys()):
        if handle in nmap:
            continue
        hinfo = handles[handle]
        if 'delete' in hinfo:
            continue
        if hinfo['parent'] in nmap:
            hinfo['parent'] = nmap[hinfo['parent']]

    if changeset['handles'] == {} and len(changeset['precursors']) == 1:
        return None

    # fill in a few other pieces of information
    if comment is not None:
        changeset['comment'] = comment
    changeset['user'] = co.user
    if tstamp is None:
        tstamp = time()
    changeset['time'] = int(tstamp)

    # put together the changeset and calculate the point
    bchangeset = bencode(changeset)
    point = sha.new(bchangeset).digest()

    # write the file locations of the diffs to the db
    for handle, index in list(indices.items()):
        write_index(co, point, handle, index, txn)

    # write the new change to the db and make it the new head
    co.lcrepo.put(point, bchangeset, txn=txn)
    co.linforepo.put('heads', bencode([point]), txn=txn)
    #co.editsdb.truncate(txn=txn)
    return point

def find_commit_files(co, handles):
    r = []
    for handle, expanded in handles:
        lfile = handle_to_filename(co, handle)
        if handle not in co.editsdb:
            if not expanded:
                print('warning - %s is not opened for edit' % (lfile,))
            continue

        linfo = bdecode(co.editsdb.get(handle))
        if 'hash' in linfo:
            if conflicts_in_file(co, lfile):
                raise_(CommitError, 'conflicts in %s, must resolve first' % (lfile,))
        r.append((handle, linfo))
    return r
