# -*- coding: utf-8 -*-
#
# Copyright (C) 2008 Edgewall Software
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at http://trac.edgewall.com/license.html.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at http://trac.edgewall.org/.

import os.path
import sys

from genshi.builder import tag

from trac.admin import IAdminCommandProvider, IAdminPanelProvider
from trac.config import ListOption
from trac.core import *
from trac.perm import IPermissionRequestor
from trac.util import as_bool, is_path_below
from trac.util.text import breakable_path, normalize_whitespace, print_table, \
                           printerr, printout
from trac.util.translation import _, ngettext, tag_
from trac.versioncontrol import DbRepositoryProvider, InvalidRepository, \
                                NoSuchChangeset, RepositoryManager, is_default
from trac.versioncontrol.cache import CachedRepository
from trac.web.chrome import Chrome, add_notice, add_warning


class VersionControlAdmin(Component):
    """trac-admin command provider for version control administration."""

    implements(IAdminCommandProvider, IPermissionRequestor)

    # IAdminCommandProvider methods

    def get_admin_commands(self):
        yield ('changeset added', '<repos> <rev> [rev] [...]',
               """Notify trac about changesets added to a repository

               This command should be called from a post-commit hook. It will
               trigger a cache update and notify components about the addition.
               """,
               self._complete_repos, self._do_changeset_added)
        yield ('changeset modified', '<repos> <rev> [rev] [...]',
               """Notify trac about changesets modified in a repository

               This command should be called from a post-revprop hook after
               revision properties like the commit message, author or date
               have been changed. It will trigger a cache update for the given
               revisions and notify components about the change.
               """,
               self._complete_repos, self._do_changeset_modified)
        yield ('repository list', '',
               'List source repositories',
               None, self._do_list)
        yield ('repository resync', '<repos> [rev]',
               """Re-synchronize trac with repositories

               When [rev] is specified, only that revision is synchronized.
               Otherwise, the complete revision history is synchronized. Note
               that this operation can take a long time to complete.
               If synchronization gets interrupted, it can be resumed later
               using the `sync` command.

               To synchronize all repositories, specify "*" as the repository.
               """,
               self._complete_repos, self._do_resync)
        yield ('repository sync', '<repos> [rev]',
               """Resume synchronization of repositories

               It works like `resync`, except that it doesn't clear the already
               synchronized changesets, so it's a better way to resume an
               interrupted `resync`.

               See `resync` help for detailed usage.
               """,
               self._complete_repos, self._do_sync)

    def get_reponames(self):
        rm = RepositoryManager(self.env)
        return [reponame or '(default)' for reponame
                in rm.get_all_repositories()]

    def _complete_repos(self, args):
        if len(args) == 1:
            return self.get_reponames()

    def _do_changeset_added(self, reponame, first_rev, *revs):
        if is_default(reponame):
            reponame = ''
        rm = RepositoryManager(self.env)
        errors = rm.notify('changeset_added', reponame, (first_rev,) + revs)
        for error in errors:
            printerr(error)
        return 2 if errors else 0

    def _do_changeset_modified(self, reponame, first_rev, *revs):
        if is_default(reponame):
            reponame = ''
        rm = RepositoryManager(self.env)
        errors = rm.notify('changeset_modified', reponame, (first_rev,) + revs)
        for error in errors:
            printerr(error)
        return 2 if errors else 0

    def _do_list(self):
        rm = RepositoryManager(self.env)
        values = []
        for (reponame, info) in sorted(rm.get_all_repositories().iteritems()):
            alias = ''
            if 'alias' in info:
                alias = info['alias'] or '(default)'
            values.append((reponame or '(default)', info.get('type', ''),
                           alias, info.get('dir', '')))
        print_table(values, [_('Name'), _('Type'), _('Alias'), _('Directory')])

    def _sync(self, reponame, rev, clean):
        rm = RepositoryManager(self.env)
        if reponame == '*':
            if rev is not None:
                raise TracError(_('Cannot synchronize a single revision '
                                  'on multiple repositories'))
            repositories = rm.get_real_repositories()
        else:
            if is_default(reponame):
                reponame = ''
            repos = rm.get_repository(reponame)
            if repos is None:
                raise TracError(_("Repository '%(repo)s' not found",
                                  repo=reponame or '(default)'))
            if rev is not None:
                repos.sync_changeset(rev)
                printout(_('%(rev)s resynced on %(reponame)s.', rev=rev,
                           reponame=repos.reponame or '(default)'))
                return
            repositories = [repos]

        for repos in sorted(repositories, key=lambda r: r.reponame):
            printout(_('Resyncing repository history for %(reponame)s... ',
                       reponame=repos.reponame or '(default)'))
            repos.sync(self._sync_feedback, clean=clean)
            for cnt, in self.env.db_query(
                    "SELECT count(rev) FROM revision WHERE repos=%s",
                    (repos.id,)):
                printout(ngettext('%(num)s revision cached.',
                                  '%(num)s revisions cached.', num=cnt))
            if not isinstance(repos, CachedRepository):
                printout(_("%(reponame)s is not a cached repository.",
                           reponame=repos.reponame or '(default)'))
        printout(_('Done.'))

    def _sync_feedback(self, rev):
        sys.stdout.write(' [%s]\r' % rev)
        sys.stdout.flush()

    def _do_resync(self, reponame, rev=None):
        self._sync(reponame, rev, clean=True)

    def _do_sync(self, reponame, rev=None):
        self._sync(reponame, rev, clean=False)

    # IPermissionRequestor methods

    def get_permission_actions(self):
        return [('VERSIONCONTROL_ADMIN', ['BROWSER_VIEW', 'CHANGESET_VIEW',
                                          'FILE_VIEW', 'LOG_VIEW'])]


class RepositoryAdminPanel(Component):
    """Web admin panel for repository administration."""

    implements(IAdminPanelProvider)

    allowed_repository_dir_prefixes = ListOption('versioncontrol',
        'allowed_repository_dir_prefixes', '',
        doc="""Comma-separated list of allowed prefixes for repository
        directories when adding and editing repositories in the repository
        admin panel. If the list is empty, all repository directories are
        allowed. (''since 0.12.1'')""")

    # IAdminPanelProvider methods

    def get_admin_panels(self, req):
        types = RepositoryManager(self.env).get_supported_types()
        if types and 'VERSIONCONTROL_ADMIN' \
                      in req.perm('admin', 'versioncontrol/repository'):
            yield ('versioncontrol', _('Version Control'), 'repository',
                   _('Repositories'))

    def render_admin_panel(self, req, category, page, path_info):
        # Retrieve info for all repositories
        rm = RepositoryManager(self.env)
        all_repos = rm.get_all_repositories()
        db_provider = self.env[DbRepositoryProvider]

        if path_info:
            # Detail view
            reponame = path_info if not is_default(path_info) else ''
            info = all_repos.get(reponame)
            if info is None:
                raise TracError(_("Repository '%(repo)s' not found",
                                  repo=path_info))
            if req.method == 'POST':
                if req.args.get('cancel'):
                    req.redirect(req.href.admin(category, page))

                elif db_provider and req.args.get('save'):
                    # Modify repository
                    changes = {}
                    valid = True
                    for field in db_provider.repository_attrs:
                        value = normalize_whitespace(req.args.get(field))
                        if (value is not None
                            or field in ('hidden', 'sync_per_request')) \
                                and value != info.get(field):
                            changes[field] = value
                    if 'dir' in changes and not \
                            self._check_dir(req, changes['dir']):
                        valid = False
                    if valid and changes:
                        db_provider.modify_repository(reponame, changes)
                        add_notice(req, _('Your changes have been saved.'))
                        name = req.args.get('name')
                        resync = tag.code('trac-admin "%s" repository resync '
                                          '"%s"' % (self.env.path,
                                                    name or '(default)'))
                        if 'dir' in changes:
                            msg = tag_('You should now run %(resync)s to '
                                       'synchronize Trac with the repository.',
                                       resync=resync)
                            add_notice(req, msg)
                        elif 'type' in changes:
                            msg = tag_('You may have to run %(resync)s to '
                                       'synchronize Trac with the repository.',
                                       resync=resync)
                            add_notice(req, msg)
                        if name and name != path_info and not 'alias' in info:
                            cset_added = tag.code('trac-admin "%s" changeset '
                                                  'added "%s" $REV'
                                                  % (self.env.path,
                                                     name or '(default)'))
                            msg = tag_('You will need to update your '
                                       'post-commit hook to call '
                                       '%(cset_added)s with the new '
                                       'repository name.',
                                       cset_added=cset_added)
                            add_notice(req, msg)
                    if valid:
                        req.redirect(req.href.admin(category, page))

            Chrome(self.env).add_wiki_toolbars(req)
            data = {'view': 'detail', 'reponame': reponame}

        else:
            # List view
            if req.method == 'POST':
                # Add a repository
                if db_provider and req.args.get('add_repos'):
                    name = req.args.get('name')
                    type_ = req.args.get('type')
                    # Avoid errors when copy/pasting paths
                    dir = normalize_whitespace(req.args.get('dir', ''))
                    if name is None or type_ is None or not dir:
                        add_warning(req, _('Missing arguments to add a '
                                           'repository.'))
                    elif self._check_dir(req, dir):
                        try:
                            db_provider.add_repository(name, dir, type_)
                        except self.env.db_exc.IntegrityError:
                            name = name or '(default)'
                            raise TracError(_('The repository "%(name)s" '
                                              'already exists.', name=name))
                        name = name or '(default)'
                        add_notice(req, _('The repository "%(name)s" has been '
                                          'added.', name=name))
                        resync = tag.code('trac-admin "%s" repository resync '
                                           '"%s"' % (self.env.path, name))
                        msg = tag_('You should now run %(resync)s to '
                                   'synchronize Trac with the repository.',
                                   resync=resync)
                        add_notice(req, msg)
                        cset_added = tag.code('trac-admin "%s" changeset '
                                              'added "%s" $REV'
                                              % (self.env.path, name))
                        doc = tag.a(_("documentation"),
                                    href=req.href.wiki('TracRepositoryAdmin')
                                         + '#Synchronization')
                        msg = tag_('You should also set up a post-commit hook '
                                   'on the repository to call %(cset_added)s '
                                   'for each committed changeset. See the '
                                   '%(doc)s for more information.',
                                   cset_added=cset_added, doc=doc)
                        add_notice(req, msg)
                        req.redirect(req.href.admin(category, page))

                # Add a repository alias
                elif db_provider and req.args.get('add_alias'):
                    name = req.args.get('name')
                    alias = req.args.get('alias')
                    if name is not None and alias is not None:
                        try:
                            db_provider.add_alias(name, alias)
                        except self.env.db_exc.IntegrityError:
                            raise TracError(_('The alias "%(name)s" already '
                                              'exists.',
                                              name=name or '(default)'))
                        add_notice(req, _('The alias "%(name)s" has been '
                                          'added.', name=name or '(default)'))
                        req.redirect(req.href.admin(category, page))
                    add_warning(req, _('Missing arguments to add an '
                                       'alias.'))

                # Refresh the list of repositories
                elif req.args.get('refresh'):
                    req.redirect(req.href.admin(category, page))

                # Remove repositories
                elif db_provider and req.args.get('remove'):
                    sel = req.args.getlist('sel')
                    if sel:
                        for name in sel:
                            db_provider.remove_repository(name)
                        add_notice(req, _('The selected repositories have '
                                          'been removed.'))
                        req.redirect(req.href.admin(category, page))
                    add_warning(req, _('No repositories were selected.'))

            data = {'view': 'list'}

        # Find repositories that are editable
        db_repos = {}
        if db_provider is not None:
            db_repos = dict(db_provider.get_repositories())

        # Prepare common rendering data
        repositories = dict((reponame, self._extend_info(reponame, info.copy(),
                                                         reponame in db_repos))
                            for (reponame, info) in all_repos.iteritems())
        types = sorted([''] + rm.get_supported_types())
        data.update({'types': types,
                     'default_type': rm.default_repository_type,
                     'repositories': repositories})

        return 'admin_repositories.html', data

    def _extend_info(self, reponame, info, editable):
        """Extend repository info for rendering."""
        info['name'] = reponame
        info['hidden'] = as_bool(info.get('hidden'))
        info['sync_per_request'] = as_bool(info.get('sync_per_request'))
        info['editable'] = editable
        if 'alias' not in info:
            if info.get('dir') is not None:
                info['prettydir'] = breakable_path(info['dir']) or ''
            try:
                repos = RepositoryManager(self.env).get_repository(reponame)
            except InvalidRepository as e:
                info['error'] = e
            except TracError:
                pass  # Probably "unsupported connector"
            else:
                youngest_rev = repos.get_youngest_rev()
                info['rev'] = youngest_rev
                try:
                    info['display_rev'] = repos.display_rev(youngest_rev)
                except NoSuchChangeset:
                    pass
        return info

    def _check_dir(self, req, dir):
        """Check that a repository directory is valid, and add a warning
        message if not.
        """
        if not os.path.isabs(dir):
            add_warning(req, _('The repository directory must be an absolute '
                               'path.'))
            return False
        prefixes = [os.path.join(self.env.path, prefix)
                    for prefix in self.allowed_repository_dir_prefixes]
        if prefixes and not any(is_path_below(dir, prefix)
                                for prefix in prefixes):
            add_warning(req, _('The repository directory must be located '
                               'below one of the following directories: '
                               '%(dirs)s', dirs=', '.join(prefixes)))
            return False
        return True
