#!/usr/bin/python
# -*- coding: utf-8 -*-

#  Copyright © 2011-2014  B. Clausius <barcc@gmx.de>
#
#  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 3 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, see <http://www.gnu.org/licenses/>.

from __future__ import print_function, division

import sys, os
import time
import re
from cStringIO import StringIO
from time import strftime, localtime
from xml.sax.saxutils import escape

import bzrlib
from bzrlib import (
    errors,
    osutils,
    urlutils,
    missing,
    bzrdir,
    transport,
    )
import bzrlib.workingtree
import bzrlib.branch
from bzrlib.config import parse_username
import bzrlib.diff
import bzrlib.log
import bzrlib.plugin
bzrlib.plugin.load_plugins()

import foreign
import utils
from utils import NULL_REVISION


def _repo_rel_url(repo_url, inner_url):
    """Return path with common prefix of repository path removed.
    
    If path is not part of the repository, the original path is returned.
    If path is equal to the repository, the current directory marker '.' is
    returned.
    Otherwise, a relative path is returned, with trailing '/' stripped.
    """
    inner_url = urlutils.normalize_url(inner_url)
    repo_url = urlutils.normalize_url(repo_url)
    if inner_url == repo_url:
        return '.'
    result = urlutils.relative_url(repo_url, inner_url)
    if result != inner_url:
        result = result.rstrip('/')
    return result
    
def _unescape_url(url):
    return url and urlutils.unescape(url)
    
def _open_wt(wt_basedir):
    try:
        wt, unused_wt_path = bzrlib.workingtree.WorkingTree.open_containing(wt_basedir)
    except (errors.NotBranchError, errors.NoWorkingTree):
        return None
    return wt
    
    
def get_info(reply, location):
    """ Get info about branch, working tree, and repository
    """
    wt = _open_wt(location)
    if wt is None:
        # TODO: report error
        return
    branch = wt.branch
    repository = branch.repository
    control = wt.bzrdir
    
    for name, value in get_location_info(repository, branch, wt):
        reply('set_info', 'location', name, value, bool(value))
    for key, (prefix1, unused_prefix2, location_method_name, autohide) in _related_branches.items():
        location = _unescape_url(getattr(branch, location_method_name)())
        reply('set_info', 'related_branches', prefix1+key, location, bool(location) if autohide else None)
    #for name, lock in [
    #            ("working_tree_lock_status", wt and wt.get_physical_lock_status()),
    #            ("branch_lock_status", branch and branch.get_physical_lock_status()),
    #            ("repository_lock_status", repository and repository.get_physical_lock_status()),
    #        ]:
    #    reply('set_info', 'lock_status', name, 'locked' if lock else '', lock)
    for name, format in [
                ('label_control_format', control and control._format.get_format_description()),
                ('label_working_tree_format', wt and wt._format.get_format_description()),
                ('label_branch_format', branch and branch._format.get_format_description()),
                ('label_repository_format', repository and repository._format.get_format_description()),
            ]:
        reply('set_info', 'format', name, format, bool(format))
    for result in get_branch_stats(branch):
        reply('set_info', 'branch_history', *result)
    for result in get_repository_stats(repository):
        reply('set_info', 'revision_store', *result)
    for location_type in _related_branches.keys():
        reply('set_info', *get_missing_revisions(wt.branch, location_type))
        
def get_location_info(repository, branch=None, working=None):
    """ Get known locations for working, branch and repository.
    """
    repository_path = repository.bzrdir.root_transport.base
    working_path = None
    branch_path = None
    shared_repository = None
    repository_branch = None
    checkout_of_branch = None
    repository_checkout = None
    checkout_root = None
    branch_root = None
    light_checkout_root = None
    repository_branch = None
    
    if working and branch:
        working_path = working.bzrdir.root_transport.base
        branch_path = branch.bzrdir.root_transport.base
        if working_path != branch_path:
            # lightweight checkout
            if repository.is_shared():
                # lightweight checkout of branch in shared repository
                shared_repository = repository_path
                repository_branch = _repo_rel_url(repository_path, branch_path)
            else:
                # lightweight checkout of standalone branch
                checkout_of_branch = branch_path
            light_checkout_root = working_path
        elif repository.is_shared():
            # branch with tree inside shared repository
            shared_repository = repository_path
            repository_checkout = _repo_rel_url(repository_path, branch_path)
        elif branch.get_bound_location():
            # normal checkout
            checkout_root = working_path
            checkout_of_branch = _unescape_url(branch.get_bound_location())
        else:
            # standalone
            branch_root = working_path
    elif branch:
        branch_path = branch.bzrdir.root_transport.base
        if repository.is_shared():
            # branch is part of shared repository
            shared_repository = repository_path
            repository_branch = _repo_rel_url(repository_path, branch_path)
        else:
            # standalone branch
            branch_root = branch_path
    else:
        # shared repository
        assert repository.is_shared()
        shared_repository = repository_path
        
    yield 'label_light_co_root', light_checkout_root
    yield 'label_shared_repo', shared_repository
    yield 'label_repo_branch', repository_branch
    yield 'label_repo_co', repository_checkout
    yield 'label_co_root', checkout_root
    yield 'label_co_of_branch', checkout_of_branch
    yield 'label_branch_root', branch_root
        
_related_branches = {
        #key:               (prefix1,   prefix2,    location_method_name,   autohide),
        'bound_location':   ('label_',  '_',        'get_bound_location',   True),
        #'master_branch':    ('label_',  '_',        'get_master_branch',    True),
        'parent_branch':    ('entry_',  'label_',   'get_parent',           False),
        'public_branch':    ('label_',  '_',        'get_public_branch',    True),
        'push_location':    ('entry_',  'button_',  'get_push_location',    False),
        'submit_branch':    ('entry_',  'label_',   'get_submit_branch',    False),
    }
    
def get_missing_revisions(branch, location_type):
    unused_prefix1, prefix2, location_method_name, unused_autohide = _related_branches[location_type]
    location = _unescape_url(getattr(branch, location_method_name)())
    if location:
        try:
            remote_branch = bzrlib.branch.Branch.open_containing(location)[0]
            local_extra, remote_extra = missing.find_unmerged(branch, remote_branch)
        except errors.NotBranchError:
            value = 'Push' if location_type == 'push_location' else 'No branch'
        except (NotImplementedError, IOError):
            value = 'Push' if location_type == 'push_location' else 'missing revisions unknown'
        else:
            local_extra, remote_extra = len(local_extra), len(remote_extra)
            if location_type == 'push_location':
                value = 'Push {} revision{}'.format(local_extra, '' if local_extra==1 else 's')
            else:
                if local_extra + remote_extra == 0:
                    value = 'no missing revisions'
                elif local_extra == 0:
                    value = '{} remote revision{}'.format(remote_extra, '' if remote_extra==1 else 's')
                elif remote_extra == 0:
                    value = '{} local revision{}'.format(local_extra, '' if local_extra==1 else 's')
                else:
                    value = '{} local, {} remote revision{}'.format(
                            local_extra, remote_extra, '' if local_extra==remote_extra==1 else 's')
    else:
        value = 'Push' if location_type == 'push_location' else 'No branch'
    return 'related_branches', prefix2+location_type, value, None
        
def get_branch_stats(branch):
    """ Get statistics about a branch.
    """
    branch.lock_read()
    try:
        revno, last_revid = branch.last_revision_info()
        yield "label_revisions_in_branch", str(revno), revno > 0
        repository = branch.repository
        
        #TODO: get_revision_history should update this field
        committers = {}
        yield "label_number_of_commiters", str(len(committers)), bool(committers)
        
        if revno > 0:
            firstrev = repository.get_revision(branch.get_rev_id(1))
            lastrev = repository.get_revision(last_revid)
            
            time_of_first_revision = osutils.format_date(firstrev.timestamp, firstrev.timezone)
            time_of_last_revision = osutils.format_date(lastrev.timestamp, lastrev.timezone)
        else:
            time_of_first_revision = time_of_last_revision = None
    finally:
        branch.unlock()
    yield "label_time_of_first_revision", time_of_first_revision, bool(time_of_first_revision)
    yield "label_time_of_last_revision", time_of_last_revision, bool(time_of_last_revision)
    
def get_repository_stats(repository):
    """ Get statistics about a repository.
    """
    func_total_revision_count = lambda: repository.gather_stats().get('revisions', None)
    #TODO: number_of_branches
    #func_number_of_branches = lambda: len(repository.find_branches())
    func_number_of_branches = lambda: False
    for name, func in [
                ('label_total_revision_count', func_total_revision_count),
                ('label_number_of_branches', func_number_of_branches),
            ]:
        value = func()
        yield name, str(value), bool(value)
        
class LogFormatter (bzrlib.log.LogFormatter):
    supports_delta = False
    supports_merge_revisions = False
    preferred_levels = 1
    supports_tags = False
    supports_diff = False
    supports_signatures = False
    
    def __init__(self, reply):
        bzrlib.log.LogFormatter.__init__(self, to_file=None, show_ids=False,
                    show_timezone='original', levels=None,
                    show_advice=False, author_list_handler=None)
        self.reply = reply
        
    def begin_log(self):
        self.first_line = 0
        self.history = [(NULL_REVISION, None, '<i>Current State</i>', None, None, None, [], None)]
        
    def log_revision(self, revision):
        self.history.append((revision.rev.revision_id, revision.revno,
                             escape(revision.rev.get_summary()), escape(revision.rev.message),
                             parse_username(revision.rev.committer)[0],
                             strftime("%Y-%m-%d %H:%M", localtime(revision.rev.timestamp)),
                             [], #TODO: tags
                             ", ".join([parse_username(author)[0]
                                            for author in revision.rev.get_apparent_authors()]),
                             ))
        if len(self.history) >= 1000:
            self.reply('set_revision_history', self.first_line, self.history)
            self.first_line += len(self.history)
            self.history = []
            
    def end_log(self):
        self.reply('set_revision_history', self.first_line, self.history)
        
def get_revision_history(reply, location):
    wt = _open_wt(location)
    if wt is None:
        reply('set_revision_history', 0, [(None, None, '<i>Not a branch</i>', None, None, None, [], None)])
        return
    rqst = bzrlib.log.make_log_request_dict()
    logger = bzrlib.log.Logger(wt.branch, rqst)
    logformatter = LogFormatter(reply)
    logger.show(logformatter)
    
def get_filelist(reply, location, revid_old, revid_new):
    wt = _open_wt(location)
    if wt is None:
        # TODO: report error
        return
    if revid_old is None:
        revid_old = wt.last_revision()
    else:
    #TODO: ensure that revids are str not unicode to avoid problems with bzr-git
        revid_old = str(revid_old)
    revid_new = str(revid_new)
    wt.lock_read()
    try:
        try:
            old_tree = wt.branch.repository.revision_tree(revid_old)
            if revid_new != NULL_REVISION:
                new_tree = wt.branch.repository.revision_tree(revid_new)
            else:
                new_tree = wt
        except errors.NoSuchRevision:
            return
        delta = new_tree.changes_from(old_tree, want_unchanged=True)
        
        def format_display_path(path, kind, mask=None):
            if kind == 'directory':
                path += '/'
            elif kind == 'symlink':
                path += '@'
            if mask and path != (mask[2:] if mask.startswith('./') else mask):
                path = '{0} ({1})'.format(path, mask)
            return path
        def gen_added():
            for i, (rel_path, file_id, kind) in enumerate(delta.added):
                yield i, rel_path, file_id, kind, None
        def gen_removed():
            for i, (rel_path, file_id, kind) in enumerate(delta.removed):
                yield i, rel_path, file_id, kind, None
        def gen_renamed():
            for i, (unused_rel_path1, rel_path2, file_id, kind,
                    unused_text_modified, unused_meta_modified) in enumerate(delta.renamed):
                yield i, rel_path2, file_id, kind, None
        def gen_kind_changed():
            for i, (rel_path, file_id, unused_old_kind, new_kind) in enumerate(delta.kind_changed):
                yield i, rel_path, file_id, new_kind, None
        def gen_modified():
            for i, (rel_path, file_id, kind,
                    unused_text_modified, unused_meta_modified) in enumerate(delta.modified):
                yield i, rel_path, file_id, kind, None
        def gen_unchanged():
            for i, (rel_path, file_id, kind) in enumerate(delta.unchanged):
                yield i, rel_path, file_id, kind, None
            #delta.unversioned
        def gen_ignored():
            for i, (rel_path, mask) in enumerate(wt.ignored_files()):
                yield i, rel_path, None, '', mask
        def gen_unknown():
            for i, rel_path in enumerate(wt.unknowns()):
                yield i, rel_path, None, '', None
            
        for group, gen in [('added', gen_added), ('removed', gen_removed), ('renamed', gen_renamed),
                           ('kind_changed', gen_kind_changed), ('modified', gen_modified),
                           ('unchanged', gen_unchanged), ('ignored', gen_ignored), ('unknown', gen_unknown)]:
            delta_list = []
            for i, rel_path, file_id, kind, mask in gen():
                if i >= 50:
                    reply('set_files', revid_old, revid_new, group, True, delta_list)
                    break
                delta_list.append((format_display_path(rel_path, kind, mask), rel_path, file_id))
            else:
                reply('set_files', revid_old, revid_new, group, False, delta_list)
    finally:
        wt.unlock()
        reply('set_files_end')
        
def get_diff(reply, location, rel_path, revid_old, revid_new):
    wt = _open_wt(location)
    if wt is None:
        reply('set_diff', revid_old, revid_new, rel_path, 'File is not in a branch', None)
        return
    if revid_old is None:
        revid_old = wt.last_revision()
    else:
    #TODO: ensure that revids are str not unicode to avoid problems with bzr-git
        revid_old = str(revid_old)
    revid_new = str(revid_new)
    if revid_new == NULL_REVISION:
        rev_tree = wt
    else:
        rev_tree = wt.branch.repository.revision_tree(revid_new)
    parent_tree = wt.branch.repository.revision_tree(revid_old)
    
    s = StringIO()
    try:
        bzrlib.diff.show_diff_trees(parent_tree, rev_tree, s,
                    None if rel_path == "" else [rel_path],
                    old_label='', new_label='')
    except errors.PathsNotVersionedError:
        reply('set_diff', revid_old, revid_new, rel_path, 'File is not versioned', None)
        return
    diff_text = s.getvalue()
    diff_map = parse_diff(diff_text)
    
    try:
        diff_text = diff_text.decode('utf-8')
    except UnicodeDecodeError:
        try:
            diff_text = diff_text.decode('latin1')
        except UnicodeDecodeError:
            diff_text = diff_text.encode('string_escape').decode('utf-8')
    
    reply('set_diff', revid_old, revid_new, rel_path, diff_text, diff_map)
    
re_hunk = re.compile('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@')
def parse_diff(diff):
    diff = diff.splitlines()
    lineno_map = {}
    e = enumerate(diff)
    
    for dline, line in e:
        if line[:3] in ['===', '---', '+++']:
            filename = None
        #if line.startswith('=== modified file') or line.startswith('=== added file'):
        #    dline, line = e.next()
        #    if line.startswith('--- '):
        #        dline, line = e.next()
        if line.startswith('+++ '):
            filename, tab, unused_date = line[4:].rpartition('\t')
            assert tab == '\t'
            continue
            
        match = re_hunk.match(line)
        if match is not None:
            sline, slen, tline, tlen = [int(i) for i in match.groups()]
            if slen or tlen:
                for dline, line in e:
                    if line[0] == ' ':
                        lineno_map[dline] = (filename, tline, ' ')
                        tline += 1
                        sline = tline
                        tlen -= 1
                        slen -= 1
                    elif line[0] == '-':
                        lineno_map[dline] = (filename, tline, '-')
                        sline += 1
                        slen -= 1
                    elif line[0] == '+':
                        lineno_map[dline] = (filename, tline, '+')
                        tline += 1
                        tlen -= 1
                    else:
                        break
                    if slen == 0 == tlen:
                        break
    return lineno_map
    
def ping(reply):
    reply('pong')
    
    
def do_push(reply, location, use_existing_dir, create_prefix):
    """Push the branch at location"""
    wt = _open_wt(location)
    if wt is None:
        return
        
    # compare to bzrlib.builtins.cmd_push.run
    overwrite=False
    
    branch = wt.branch
    
    #TODO: Warn about uncommitted changes
    #      use wt.check_changed_or_out_of_date(...)
    #      'push_strict' in branch config
    
    location = branch.get_push_location()
    if not location:
        reply('emit', 'push-result', 'no_push_location', "No push location known or specified.")
        return
        
    revision_id = None
    
    # compare to bzrlib.push._show_push_branch
    to_transport = transport.get_transport(location)
    try:
        dir_to = bzrdir.BzrDir.open_from_transport(to_transport)
    except errors.NotBranchError:
        dir_to = None

    if dir_to is None:
        try:
            br_to = branch.create_clone_on_transport(to_transport,
                revision_id=revision_id, stacked_on=None,
                create_prefix=create_prefix, use_existing_dir=use_existing_dir,
                no_tree=False)
        except errors.FileExists as err:
            if err.path.endswith('/.bzr'):
                reply('emit', 'push-result', 'invalid_target_bzr_dir',
                    'Target directory "%s" already contains a .bzr directory, but it is not valid.'
                    % location)
                return
            if not use_existing_dir:
                # Question to push there anyway
                reply('emit', 'push-result', 'unversioned_target_dir_exists', location)
                return
            # This shouldn't occur
            raise
        except errors.NoSuchFile:
            if not create_prefix:
                # Question to create all leading parent directories
                reply('emit', 'push-result', 'parent_directory_not_exists', location)
                return
            # This shouldn't occur
            raise
        except errors.TooManyRedirections:
            reply('emit', 'push-result', 'too_many_redirections',
                'Too many redirections trying to make "%s".' % location)
            return
        else:
            br_to.lock_read()
            try:
                graph = br_to.repository.get_graph()
                history = list(graph.iter_lefthand_ancestry(br_to.last_revision(), [NULL_REVISION]))
            finally:
                br_to.unlock()
            rev_count = len(history)
    else:
        try:
            push_result = dir_to.push_branch(branch, revision_id, overwrite, False, False)
        except errors.DivergedBranches:
            #TODO: Show info from "bzr help diverged-branches"
            reply('emit', 'push-result', 'diverged_branches', 'These branches have diverged.')
            return
        except errors.NoRoundtrippingSupport as e:
            reply('emit', 'push-result', 'NoRoundtrippingSupport',
                'It is not possible to losslessly push to "%s". You may want to use dpush instead.'
                % e.target_branch.mapping.vcs.abbreviation)
            return
        except errors.NoRepositoryPresent:
            # we have a bzrdir but no branch or repository
            reply('emit', 'push-result', 'NoRepositoryPresent',
                'At "%s" you have a valid .bzr'
                ' control directory, but not a branch or repository. This'
                ' is an unsupported configuration. Please move the target'
                ' directory out of the way and try again.' % location)
            return
        else:
            try:
                try:
                    old_revno = push_result.branch_push_result.old_revno
                except AttributeError:
                    old_revno = push_result.old_revno
                try:
                    new_revno = push_result.branch_push_result.new_revno
                except AttributeError:
                    new_revno = push_result.new_revno
                rev_count = new_revno - old_revno
            except AttributeError:
                rev_count = ''
        #TODO:
        #if push_result.workingtree_updated == False:
        #    reply('emit', 'push-result', 'workingtree_not_updated',
        #        'This transport does not update the working tree of: "%s".'
        #        % push_result.target_branch.base)
        #    return
    reply('emit', 'push-result', 'OK', '{} {} pushed.'.format(rev_count, 'revision' if rev_count==1 else 'revisions'))
    reply('set_info', *get_missing_revisions(wt.branch, 'push_location'))
    
def do_revert(reply, wt_basedir, files):
    #TODO: add arguments: revid_old, revid_new; revert to a specific revision
    if files is None:
        utils.revert(wt_basedir, None)
        files = []  #TODO: obtain reverted files
    else:
        files_rel = [osutils.relpath(wt_basedir, path) for path in files]
        utils.revert(wt_basedir, files_rel)
    reply('emit', 'reload-files', files)
    reply('emit', 'revisionhistory-changed')
    
def do_add(reply, wt_basedir, rel_files):
    #FIXME: do not try to add removed files
    #TODO: handle OSError (rel_files is None and kind_changed files are in the wt)
    utils.add(wt_basedir, rel_files)
    if rel_files is None:
        reply('reset_diffs')
    else:
        for rel_path in rel_files:
            get_diff(reply, wt_basedir, rel_path, None, NULL_REVISION)
    reply('emit', 'revisionhistory-changed')
    
def do_ignore(reply, wt_basedir, files):
    files = [osutils.relpath(wt_basedir, path) for path in files]
    utils.ignore(wt_basedir, files)
    reply('emit', 'revisionhistory-changed')
    
def get_stats(reply, wt_basedir):
    wt = _open_wt(wt_basedir)
    if wt is None:
        # TODO: report error
        return
    for result in get_branch_stats(wt.branch):
        reply('set_info', 'branch_history', *result)
    for result in get_repository_stats(wt.branch.repository):
        reply('set_info', 'revision_store', *result)
    for location_type in _related_branches.keys():
        reply('set_info', *get_missing_revisions(wt.branch, location_type))
        
def do_uncommit(reply, wt_basedir, known_files):
    message = utils.save_commitmessage(wt_basedir, None)
    old_message = utils.uncommit(wt_basedir)
    if message:
        old_message = message + '\n' + '='*10 + '\n' + old_message
    save_commitmessage(reply, wt_basedir, old_message)
    get_revision_history(reply, wt_basedir)
    get_pending_revisions(reply, wt_basedir)
    for known_file in known_files:
        get_diff(reply, wt_basedir, known_file, None, NULL_REVISION)
    get_stats(reply, wt_basedir)
    
def set_location(reply, wt_basedir, widget_name, value):
    if widget_name == 'entry_parent_branch':
        value = utils.set_parent(wt_basedir, value)
    elif widget_name == 'entry_push_location':
        value = utils.set_push_location(wt_basedir, value)
    elif widget_name == 'entry_submit_branch':
        value = utils.set_submit_branch(wt_basedir, value)
    else:
        assert False
    reply('set_info', 'related_branches', widget_name, value, None)
    #TODO: update missing revisions only for this location
    
def get_wt_basedir(reply, path):
    wt_basedir, wt_path = utils.find_wt_basedir(path)
    reply('set_wt_basedir', path, wt_basedir, wt_path)
    
def do_commit(reply, wt_basedir, message, specific_files, local, unknowns, pointless, known_files):
    result = utils.commit(wt_basedir, message, specific_files, local, unknowns, pointless)
    reply('emit', 'commit-result', result)
    if result != 'success':
        return
    save_commitmessage(reply, wt_basedir, u'')
    get_revision_history(reply, wt_basedir)
    get_pending_revisions(reply, wt_basedir)
    for known_file in known_files:
        get_diff(reply, wt_basedir, known_file, None, NULL_REVISION)
    get_stats(reply, wt_basedir)
    
def save_commitmessage(reply, wt_basedir, message):
    message = utils.save_commitmessage(wt_basedir, message)
    if message is not None:
        reply('emit', 'commitmessage-changed', message)
    
def get_pending_revisions(reply, wt_basedir):
    result = utils.get_pending_revisions(wt_basedir)
    if result is None:
        result = False, None
    reply('emit', 'pending-revisions-changed', *result)
    
def init_repository(reply, location, current_path):
    error = foreign.init_repository(location)
    reply('init_bzr_result', 'Initialize repository', error, location, current_path)
    
def init_branch(reply, location, current_path):
    error = foreign.init_branch(location)
    reply('init_bzr_result', 'Initialize branch', error, location, current_path)
    

