# -*- coding: utf-8; -*-
################################################################################
#
#  Rattail -- Retail Software Framework
#  Copyright © 2010-2023 Lance Edgar
#
#  This file is part of Rattail.
#
#  Rattail 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.
#
#  Rattail 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
#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Console Commands
"""

import importlib
import os
import sys
import json
import platform
import argparse
import datetime
import socket
import shutil
import subprocess
import warnings
import logging
from getpass import getpass

import humanize

from rattail import __version__
from rattail.core import Object
from rattail.util import load_entry_points, load_object
from rattail.progress import ConsoleProgress, SocketProgress
from rattail.config import make_config, parse_list
from rattail.util import progress_loop
from rattail.time import make_utc
from rattail.db.config import configure_versioning


log = logging.getLogger(__name__)


class ArgumentParser(argparse.ArgumentParser):
    """
    Custom argument parser.

    This overrides some of the parsing logic which is specific to the primary
    command object.
    """

    def parse_args(self, args=None, namespace=None):
        args, argv = self.parse_known_args(args, namespace)
        args.argv = argv
        return args


def date_argument(string):
    """
    Validate and coerce a date argument.

    This function is designed be used as the ``type`` parameter when calling
    ``ArgumentParser.add_argument()``, e.g.::

       parser = ArgumentParser()
       parser.add_argument('--date', type=date_argument)
    """
    try:
        date = datetime.datetime.strptime(string, '%Y-%m-%d').date()
    except ValueError:
        raise argparse.ArgumentTypeError("Date must be in YYYY-MM-DD format")
    return date


def dict_argument(string):
    """
    Coerce the given string to a Python dictionary.  The string is assumed to
    be JSON-encoded; this function merely invokes ``json.loads()`` on it.

    This function is designed be used as the ``type`` parameter when calling
    ``ArgumentParser.add_argument()``, e.g.::

       parser = ArgumentParser()
       parser.add_argument('--date', type=dict_argument)
    """
    try:
        return json.loads(string)
    except json.decoder.JSONDecodeError:
        raise argparse.ArgumentTypeError("Argument must be valid JSON-encoded string")


def list_argument(string):
    """
    Coerce the given string to a list of strings, splitting on whitespace
    and/or commas.

    This function is designed be used as the ``type`` parameter when calling
    ``ArgumentParser.add_argument()``, e.g.::

       parser = ArgumentParser()
       parser.add_argument('--things', type=list_argument)
    """
    return parse_list(string)


class Command(Object):
    """
    The primary command for the application.

    This effectively *is* the ``rattail`` console application.  It mostly
    provides the structure for subcommands, which really do all the work.

    This command is designed to be subclassed, should your application need
    similar functionality.
    """
    name = 'rattail'
    version = __version__
    description = "Rattail Software Framework"
    long_description = """
Rattail is a retail software framework.

Copyright (c) 2010-2015 Lance Edgar <lance@edbob.org>

This program comes with ABSOLUTELY NO WARRANTY.  This is free software,
and you are welcome to redistribute it under certain conditions.
See the file COPYING.txt for more information.
"""

    stdout = sys.stdout
    stderr = sys.stderr

    def __init__(self, **kwargs):
        super(Command, self).__init__(**kwargs)
        self.subcommands = load_entry_points('{}.commands'.format(self.name.replace('-', '_')))

    def __str__(self):
        return self.name

    @property
    def db_config_section(self):
        """
        Name of section in config file which should have database connection
        info.  This defaults to ``'rattail.db'`` but may be overridden so the
        command framework can sit in front of a non-Rattail database if needed.

        This is used to auto-configure a "default" database engine for the app,
        when any command is invoked.
        """
        return 'rattail.db'

    @property
    def db_session_factory(self):
        """
        Reference to the "primary" ``Session`` class, which will be configured
        automatically during app startup.  Defaults to :class:`rattail.db.Session`.
        """
        from rattail.db import Session
        return Session

    @property
    def db_model(self):
        """
        Reference to the Python module which is to be used as the primary data
        model.  Defaults to ``rattail.db.model``.
        """
        return self.config.get_model()

    def iter_subcommands(self):
        """
        Iterate over the subcommands.

        This is a generator which yields each associated :class:`Subcommand`
        class sorted by :attr:`Subcommand.name`.
        """
        for name in sorted(self.subcommands):
            yield self.subcommands[name]

    def print_help(self):
        """
        Print help text for the primary command.

        The output will include a list of available subcommands.
        """
        # TODO: this should leverage parser config...
        self.stdout.write("""{0.description}

Usage: {0.name} [options] <command> [command-options]

Options:
  -c PATH, --config=PATH
                    Config path (may be specified more than once)
  -n, --no-init     Do not read any config files
  -P, --progress    Show progress indicators (where relevant)
  -R, --runas       Optional username to impersonate when running command
  -V, --version     Display program version and exit

Commands:\n""".format(self))

        for cmd in self.iter_subcommands():
            self.stdout.write("  {0:<16s}  {1}\n".format(cmd.name, cmd.description))

        self.stdout.write("\nTry '{0} help <command>' for more help.\n".format(self.name))

    def run(self, *args):
        """
        Parse command line arguments and execute appropriate subcommand.
        """
        parser = ArgumentParser(
            prog=self.name,
            description=self.description,
            add_help=False,
            )

        parser.add_argument('-c', '--config', action='append', dest='config_paths',
                            metavar='PATH')

        # TODO: i think these aren't really being used in practice..?
        parser.add_argument('-n', '--no-init', action='store_true', default=False)
        parser.add_argument('--no-extend-config', dest='extend_config', action='store_false')

        parser.add_argument('--verbose', action='store_true')
        parser.add_argument('-P', '--progress', action='store_true', default=False)
        parser.add_argument('--progress-socket',
                            help="Optional socket (e.g. localhost:8487) to which progress info should be written.")
        parser.add_argument('--runas', '-R', metavar='USERNAME',
                            help="Optional username to impersonate when running the command.  "
                            "This is only relevant for / used by certain commands.")
        parser.add_argument('--stdout', metavar='PATH', type=argparse.FileType('w'),
                            help="Optional path to which STDOUT should be effectively redirected.")
        parser.add_argument('--stderr', metavar='PATH', type=argparse.FileType('w'),
                            help="Optional path to which STDERR should be effectively redirected.")

        # data versioning
        parser.add_argument('--versioning', action='store_true',
                            help="Force *enable* of data versioning.  If set, then --no-versioning "
                            "cannot also be set.  If neither is set, config will determine whether "
                            "or not data versioning should be enabled.")
        parser.add_argument('--no-versioning', action='store_true',
                            help="Force *disable* of data versioning.  If set, then --versioning "
                            "cannot also be set.  If neither is set, config will determine whether "
                            "or not data versioning should be enabled.")

        parser.add_argument('-V', '--version', action='version',
                            version="%(prog)s {0}".format(self.version))
        parser.add_argument('command', nargs='*')

        # Parse args and determine subcommand.
        args = parser.parse_args(list(args))
        if not args or not args.command:
            self.print_help()
            sys.exit(1)

        # TODO: can we make better args so this is handled by argparse somehow?
        if args.versioning and args.no_versioning:
            sys.stderr.write("Cannot pass both --versioning and --no-versioning\n")
            sys.exit(1)

        # Show (sub)command help if so instructed, or unknown subcommand.
        cmd = args.command.pop(0)
        if cmd == 'help':
            if len(args.command) != 1:
                self.print_help()
                return
            cmd = args.command[0]
            if cmd not in self.subcommands:
                self.print_help()
                return
            cmd = self.subcommands[cmd](parent=self)
            cmd.parser.print_help()
            return
        elif cmd in self.subcommands:
            if '-h' in args.argv or '--help' in args.argv:
                cmd = self.subcommands[cmd](parent=self)
                cmd.parser.print_help()
                return
        else:
            self.print_help()
            sys.exit(1)

        # Okay, we should be done needing to print help messages.  Now it's
        # safe to redirect STDOUT/STDERR, if necessary.
        if args.stdout:
            self.stdout = args.stdout
        if args.stderr:
            self.stderr = args.stderr

        # if args say not to "init" then we make a sort of empty config
        if args.no_init:
            self.config = make_config([], extend=False, versioning=False)

        else: # otherwise we make a proper config, and maybe turn on versioning
            logging.basicConfig()
            self.config = make_config(args.config_paths, extend=args.extend_config, versioning=False)
            if args.versioning:
                configure_versioning(self.config, force=True)
            elif not args.no_versioning:
                configure_versioning(self.config)

        # import our primary data model now, just in case it hasn't fully been
        # imported yet.  this it to be sure association proxies and the like
        # are fully wired up in the case of extensions
        # TODO: what about web apps etc.? i guess i was only having the problem
        # for some importers, e.g. basic CORE API -> Rattail w/ the schema
        # extensions in place from rattail-corepos
        try:
            self.config.get_model()
        except ImportError:
            pass

        # instantiate the subcommand
        subcmd = self.subcommands[cmd](self, self.config)

        # figure out if/how subcommand should show progress
        subcmd.show_progress = args.progress
        subcmd.progress = None
        if subcmd.show_progress:
            if args.progress_socket:
                host, port = args.progress_socket.split(':')
                subcmd.progress = SocketProgress(host, int(port))
            else:
                subcmd.progress = ConsoleProgress

        # maybe should be verbose
        subcmd.verbose = args.verbose

        # TODO: make this default to something from config?
        subcmd.runas_username = args.runas or None

        # and finally, run the subcommand
        log.debug("running '%s %s' with args: %s", self.name, subcmd.name, args.argv)
        subcmd._run(*(args.command + args.argv))


class Subcommand(object):
    """
    Base class for application subcommands.
    """
    name = 'UNDEFINED'
    description = 'UNDEFINED'

    def __init__(self, parent=None, config=None, show_progress=None):
        self.parent = parent
        self.config = config
        if self.config:
            self.app = self.config.get_app()
        self.stdout = getattr(parent, 'stdout', sys.stdout)
        self.stderr = getattr(parent, 'stderr', sys.stderr)
        self.show_progress = show_progress
        self.progress = ConsoleProgress if show_progress else None
        self.parser = argparse.ArgumentParser(
            prog='{0} {1}'.format(getattr(self.parent, 'name', 'UNDEFINED'), self.name),
            description=self.description)
        self.add_parser_args(self.parser)

    def __repr__(self):
        return "Subcommand(name={0})".format(repr(self.name))

    @property
    def model(self):
        return self.parent.db_model

    def add_parser_args(self, parser):
        """
        Configure additional arguments for the subcommand argument parser.
        """
        pass

    def make_session(self):
        session = self.parent.db_session_factory()
        user = self.get_runas_user(session=session)
        if user:
            session.set_continuum_user(user)
        return session

    def finalize_session(self, session, dry_run=False, success=True):
        """
        Wrap up the given session, per the given arguments.  This is meant to
        provide a simple convenience, for commands which must do work within a
        DB session, but would like to support a "dry run" mode.
        """
        if dry_run:
            session.rollback()
            log.info("dry run, so transaction was aborted")
        else:
            session.commit()
            log.info("transaction was committed")
        session.close()

    def get_runas_user(self, session=None, username=None):
        """
        Returns a proper User object, which the app should "run as"
        """
        from sqlalchemy import orm
        from rattail.db.util import short_session

        if username is None:
            if hasattr(self, 'runas_username'):
                username = self.runas_username
            if not username and self.config:
                username = self.config.get('rattail', 'runas.default')
        if username:
            user = None
            with short_session(session) as s:
                try:
                    user = s.query(self.model.User).filter_by(username=username).one()
                except orm.exc.NoResultFound:
                    pass
                else:
                    if not session:
                        s.expunge(user)
            return user

    def progress_loop(self, func, items, factory=None, **kwargs):
        return progress_loop(func, items, factory or self.progress, **kwargs)
            
    def _run(self, *args):
        args = self.parser.parse_args(list(args))
        return self.run(args)

    def run(self, args):
        """
        Run the subcommand logic.
        """
        raise NotImplementedError

    def require_prompt_toolkit(self):
        try:
            import prompt_toolkit
        except ImportError:
            value = input("\nprompt_toolkit is not installed.  shall i install it? [Yn] ")
            value = value.strip()
            if value and not self.config.parse_bool(value):
                self.stderr.write("prompt_toolkit is required; aborting\n")
                sys.exit(1)

            subprocess.check_call(['pip', 'install', 'prompt_toolkit'])

    def require_rich(self):
        try:
            import rich
        except ImportError:
            value = input("\nrich is not installed.  shall i install it? [Yn] ")
            value = value.strip()
            if value and not self.config.parse_bool(value):
                self.stderr.write("rich is required; aborting\n")
                sys.exit(1)

            subprocess.check_call(['pip', 'install', 'rich'])

    def rprint(self, *args, **kwargs):
        self.require_rich()

        # TODO: this could look different once python2 is out of the
        # picture; but must avoid `print` keyword for python2
        import rich
        rprint = getattr(rich, 'print')
        return rprint(*args, **kwargs)

    def basic_prompt(self, info, default=None, is_password=False, is_bool=False,
                     required=False):
        self.require_prompt_toolkit()

        from prompt_toolkit import prompt
        from prompt_toolkit.styles import Style

        # message formatting styles
        style = Style.from_dict({
            '': '',
            'bold': 'bold',
        })

        # build prompt message
        message = [
            ('', '\n'),
            ('class:bold', info),
        ]
        if default is not None:
            if is_bool:
                message.append(('', ' [{}]: '.format('Y' if default else 'N')))
            else:
                message.append(('', ' [{}]: '.format(default)))
        else:
            message.append(('', ': '))

        # prompt user for input
        try:
            text = prompt(message, style=style, is_password=is_password)
        except (KeyboardInterrupt, EOFError):
            self.rprint("\n\t[bold yellow]operation canceled by user[/bold yellow]\n",
                        file=self.stderr)
            sys.exit(2)

        if is_bool:
            if text == '':
                return default
            elif text.upper() == 'Y':
                return True
            elif text.upper() == 'N':
                return False
            self.rprint("\n\t[bold yellow]ambiguous, please try again[/bold yellow]\n")
            return self.basic_prompt(info, default, is_bool=True)

        if required and not text and not default:
            return self.basic_prompt(info, default, is_password=is_password,
                                     required=True)

        return text or default


class CheckDatabase(Subcommand):
    """
    Do basic sanity checks on a Rattail DB
    """
    name = 'checkdb'
    description = __doc__.strip()

    def run(self, args):
        import sqlalchemy as sa

        try:
            with self.config.rattail_engine.begin() as cxn:
                cxn.execute(sa.text("select version()"))
        except sa.exc.OperationalError as e:
            self.stderr.write("\nfailed to connect to DB!\n\n{}\n".format(e))
            sys.exit(1)

        self.stdout.write("All checks passed.\n")


class CloneDatabase(Subcommand):
    """
    Clone (supported) data from a source DB to a target DB
    """
    name = 'clonedb'
    description = __doc__.strip()

    def add_parser_args(self, parser):
        parser.add_argument('source_engine',
                            help="SQLAlchemy engine URL for the source database.")
        parser.add_argument('target_engine',
                            help="SQLAlchemy engine URL for the target database.")
        parser.add_argument('-m', '--model', default='rattail.db.model',
                            help="Dotted path of Python module which contains the data model.")
        parser.add_argument('-C', '--classes', nargs='*',
                            help="Model classes which should be cloned.  Possible values here "
                            "depends on which module contains the data model.  If no classes "
                            "are specified, all available will be cloned.")

    def run(self, args):
        from sqlalchemy import create_engine, orm

        model = importlib.import_module(args.model)
        classes = args.classes
        assert classes

        source_engine = create_engine(args.source_engine)
        target_engine = create_engine(args.target_engine)
        model.Base.metadata.drop_all(bind=target_engine)
        model.Base.metadata.create_all(bind=target_engine)

        Session = orm.sessionmaker()
        src_session = Session(bind=source_engine)
        dst_session = Session(bind=target_engine)

        for clsname in classes:
            log.info("cloning data for model: %s", clsname)
            cls = getattr(model, clsname)
            src_query = src_session.query(cls)
            count = src_query.count()
            log.debug("found %d %s records to clone", count, clsname)
            if not count:
                continue

            mapper = orm.class_mapper(cls)
            key_query = src_session.query(*mapper.primary_key)

            prog = None
            if self.progress:
                prog = self.progress("Cloning data for model: {0}".format(clsname), count)
            for i, key in enumerate(key_query, 1):

                src_instance = src_query.get(key)
                dst_session.merge(src_instance)
                dst_session.flush()

                if prog:
                    prog.update(i)
            if prog:
                prog.destroy()

        src_session.close()
        dst_session.commit()
        dst_session.close()


class EmailBouncer(Subcommand):
    """
    Interacts with the email bouncer daemon.  This command expects a
    subcommand; one of the following:

    * ``rattail bouncer start``
    * ``rattail bouncer stop``
    """
    name = 'bouncer'
    description = "Manage the email bouncer daemon"

    def add_parser_args(self, parser):
        subparsers = parser.add_subparsers(title='subcommands')

        start = subparsers.add_parser('start', help="Start service")
        start.set_defaults(subcommand='start')
        stop = subparsers.add_parser('stop', help="Stop service")
        stop.set_defaults(subcommand='stop')

        parser.add_argument('-p', '--pidfile', metavar='PATH', default='/var/run/rattail/bouncer.pid',
                            help="Path to PID file.")
        parser.add_argument('--daemonize', action='store_true',
                            help="DEPRECATED")
        parser.add_argument('--no-daemonize',
                            '-D', '--do-not-daemonize',
                            action='store_false', dest='daemonize',
                            help="DEPRECATED")

    def run(self, args):
        from rattail.bouncer.daemon import BouncerDaemon

        daemon = BouncerDaemon(args.pidfile, config=self.config)
        if args.subcommand == 'stop':
            daemon.stop()
        else: # start
            try:
                daemon.start(daemonize=False)
            except KeyboardInterrupt:
                self.stderr.write("Interrupted.\n")


class DateOrganize(Subcommand):
    """
    Organize files in a given directory, according to date
    """
    name = 'date-organize'
    description = __doc__.strip()

    def add_parser_args(self, parser):
        parser.add_argument('folder', metavar='PATH',
                            help="Path to directory containing files which are "
                            "to be organized by date.")

    def run(self, args):
        today = datetime.date.today()
        for filename in sorted(os.listdir(args.folder)):
            path = os.path.join(args.folder, filename)
            if os.path.isfile(path):
                mtime = datetime.datetime.fromtimestamp(os.path.getmtime(path))
                if mtime.date() < today:
                    datedir = mtime.strftime(os.sep.join(('%Y', '%m', '%d')))
                    datedir = os.path.join(args.folder, datedir)
                    if not os.path.exists(datedir):
                        os.makedirs(datedir)
                    shutil.move(path, datedir)


class FileMonitorCommand(Subcommand):
    """
    Interacts with the file monitor service; called as ``rattail filemon``.
    This command expects a subcommand; one of the following:

    * ``rattail filemon start``
    * ``rattail filemon stop``

    On Windows platforms, the following additional subcommands are available:

    * ``rattail filemon install``
    * ``rattail filemon uninstall`` (or ``rattail filemon remove``)

    .. note::
       The Windows Vista family of operating systems requires you to launch
       ``cmd.exe`` as an Administrator in order to have sufficient rights to
       run the above commands.

    .. See :doc:`howto.use_filemon` for more information.
    """

    name = 'filemon'
    description = "Manage the file monitor daemon"

    def add_parser_args(self, parser):
        subparsers = parser.add_subparsers(title='subcommands')

        start = subparsers.add_parser('start', help="Start service")
        start.set_defaults(subcommand='start')
        stop = subparsers.add_parser('stop', help="Stop service")
        stop.set_defaults(subcommand='stop')

        if sys.platform in ('linux', 'linux2'):
            parser.add_argument('-p', '--pidfile',
                                help="Path to PID file.", metavar='PATH')
            parser.add_argument('--daemonize', action='store_true',
                                help="DEPRECATED")
            parser.add_argument('--no-daemonize',
                                '-D', '--do-not-daemonize',
                                action='store_false', dest='daemonize',
                                help="DEPRECATED")

        elif sys.platform == 'win32': # pragma no cover

            install = subparsers.add_parser('install', help="Install service")
            install.set_defaults(subcommand='install')
            install.add_argument('-a', '--auto-start', action='store_true',
                                 help="Configure service to start automatically.")
            install.add_argument('-U', '--username',
                                 help="User account under which the service should run.")

            remove = subparsers.add_parser('remove', help="Uninstall (remove) service")
            remove.set_defaults(subcommand='remove')

            uninstall = subparsers.add_parser('uninstall', help="Uninstall (remove) service")
            uninstall.set_defaults(subcommand='remove')

    def run(self, args):
        if sys.platform in ('linux', 'linux2'):
            from rattail.filemon import linux as filemon

            if args.subcommand == 'start':
                filemon.start_daemon(self.config, args.pidfile)

            elif args.subcommand == 'stop':
                filemon.stop_daemon(self.config, args.pidfile)

        elif sys.platform == 'win32': # pragma no cover
            self.run_win32(args)

        else:
            self.stderr.write("File monitor is not supported on platform: {0}\n".format(sys.platform))
            sys.exit(1)

    def run_win32(self, args): # pragma no cover
        from rattail.win32 import require_elevation
        from rattail.win32 import service
        from rattail.win32 import users
        from rattail.filemon import win32 as filemon

        require_elevation()

        options = []
        if args.subcommand == 'install':

            username = args.username
            if username:
                if '\\' in username:
                    server, username = username.split('\\')
                else:
                    server = socket.gethostname()
                if not users.user_exists(username, server):
                    sys.stderr.write("User does not exist: {0}\\{1}\n".format(server, username))
                    sys.exit(1)

                password = ''
                while password == '':
                    password = getpass(str("Password for service user: ")).strip()
                options.extend(['--username', r'{0}\{1}'.format(server, username)])
                options.extend(['--password', password])

            if args.auto_start:
                options.extend(['--startup', 'auto'])

        service.execute_service_command(filemon, args.subcommand, *options)

        # If installing with custom user, grant "logon as service" right.
        if args.subcommand == 'install' and args.username:
            users.allow_logon_as_service(username)

        # TODO: Figure out if the following is even required, or if instead we
        # should just be passing '--startup delayed' to begin with?

        # If installing auto-start service on Windows 7, we should update
        # its startup type to be "Automatic (Delayed Start)".
        # TODO: Improve this check to include Vista?
        if args.subcommand == 'install' and args.auto_start:
            if platform.release() == '7':
                name = filemon.RattailFileMonitor._svc_name_
                service.delayed_auto_start_service(name)


class MailMonitorCommand(Subcommand):
    """
    Interacts with the mail monitor service; called as ``rattail
    mailmon``.  This command expects a subcommand; one of the
    following:

    * ``rattail mailmon start``
    * ``rattail mailmon stop``
    """
    name = 'mailmon'
    description = "Manage the mail monitor daemon"

    def add_parser_args(self, parser):
        subparsers = parser.add_subparsers(title='subcommands')

        start = subparsers.add_parser('start', help="Start service")
        start.set_defaults(subcommand='start')

        stop = subparsers.add_parser('stop', help="Stop service")
        stop.set_defaults(subcommand='stop')

        parser.add_argument('-p', '--pidfile', metavar='PATH', default='/var/run/rattail/mailmon.pid',
                            help="Path to PID file.")

    def run(self, args):
        from rattail.mailmon.daemon import MailMonitorDaemon

        daemon = MailMonitorDaemon(args.pidfile, config=self.config)
        if args.subcommand == 'stop':
            daemon.stop()
        else: # start
            try:
                daemon.start(daemonize=False)
            except KeyboardInterrupt:
                self.stderr.write("Interrupted.\n")


class MakeAppDir(Subcommand):
    """
    Create a conventional 'app' dir for a virtual environment
    """
    name = 'make-appdir'
    description = __doc__.strip()

    def add_parser_args(self, parser):
        parser.add_argument('--path', metavar='PATH',
                            help="Path where the app folder is to be established.  If not "
                            "specified, a default will be assumed based on the virtual "
                            "environment, e.g. '/envs/rattail/app'.")
        parser.add_argument('-U', '--user', metavar='USERNAME',
                            help="Linux username which should be given ownership to the various "
                            "data folders which are to be created.  This is used when the app(s) "
                            "are to normally be ran as the 'rattail' user for instance.  Use "
                            "of this option probably requires 'sudo' or equivalent.")

    def run(self, args):
        if args.path:
            app_path = os.path.abspath(args.path)
            if os.path.basename(app_path) != 'app':
                app_path = os.path.join(app_path, 'app')
        else:
            app_path = os.path.join(sys.prefix, 'app')
        app_path = app_path.rstrip('/')

        self.app.make_appdir(app_path)

        # TODO: this bit should probably be part of app method
        if args.user:
            import pwd
            pwdata = pwd.getpwnam(args.user)
            folders = [
                'data',
                os.path.join('data', 'uploads'),
                'log',
                'sessions',
                'work',
            ]
            for name in folders:
                path = os.path.join(app_path, name)
                os.chown(path, pwdata.pw_uid, pwdata.pw_gid)

        self.stdout.write("Rattail app dir generated at: {}\n".format(app_path))


class MakeConfig(Subcommand):
    """
    Generate stub config file(s) where you want them
    """
    name = 'make-config'
    description = __doc__.strip()

    def add_parser_args(self, parser):
        parser.add_argument('-T', '--type', metavar='NAME', default='rattail',
                            help="Type of config file to create; defaults to 'rattail' "
                            "which will generate 'rattail.conf'")
        parser.add_argument('-O', '--output', metavar='PATH', default='.',
                            help="Path where the config file is to be generated.  This can "
                            "be the full path including filename, or just the folder, in which "
                            "case the filename is inferred from 'type'.  Default is to current "
                            "working folder.")
        parser.add_argument('--list-types', action='store_true',
                            help="List the types of config files this tool can "
                            "generate.")

    def run(self, args):
        if args.list_types:
            self.list_types()
            return

        template_path = self.app.find_config_template(args.type)
        if not template_path:
            self.stderr.write("config template not found for type: {}\n".format(args.type))
            sys.exit(1)

        output_path = os.path.abspath(args.output)
        if os.path.isdir(output_path):
            output_path = os.path.join(output_path, '{}.conf'.format(args.type))
        if os.path.exists(output_path):
            self.stderr.write("ERROR! output file already exists: {}\n".format(output_path))
            sys.exit(2)

        config_path = self.app.make_config_file(args.type, output_path,
                                                template_path=template_path)
        self.stdout.write("Config file generated at: {}\n".format(config_path))

    def list_types(self):
        templates = self.app.get_all_config_templates()
        self.stdout.write("CONFIG TEMPLATES:\n")
        self.stdout.write("=========================\n")
        for name, path in templates.items():
            self.stdout.write("{:25s} {}\n".format(name, path))
        self.stdout.write("=========================\n")


class MakeUser(Subcommand):
    """
    Create a new user account in a given system
    """
    name = 'make-user'
    description = __doc__.strip()

    def add_parser_args(self, parser):
        parser.add_argument('username',
                            help="Username for the new user.")
        parser.add_argument('--system', default='rattail',
                            help="System in which to create the new user; defaults to "
                            "rattail; must be one of: rattail, windows")
        parser.add_argument('-A', '--admin', action='store_true',
                            help="Whether the new user should have admin rights within "
                            "the system (if applicable).")
        parser.add_argument('--password',
                            help="Password to set for the new user.  If not specified, "
                            "you may be prompted for one.")
        parser.add_argument('--no-password', action='store_true',
                            help="Do not ask for, or try to set, a password for the new user.")
        parser.add_argument('--full-name',
                            help="Full (display) name for the new user (if applicable).")
        parser.add_argument('--comment',
                            help="Comment string for the new user (if applicable).")
        parser.add_argument('--groups',
                            help="Optional list of groups (roles) to which the new user "
                            "should be assigned.")

    def run(self, args):
        mkuser = getattr(self, 'mkuser_{}'.format(args.system), None)
        if mkuser:
            if mkuser(args):
                self.stdout.write("created new user in '{}' system: {}\n".format(
                    args.system, args.username))
        else:
            self.stderr.write("don't know how to make user for '{}' system\n".format(args.system))
            sys.exit(1)

    def user_exists(self, args):
        self.stdout.write("user already exists in '{}' system: {}\n".format(
            args.system, args.username))
        sys.exit(1)

    def obtain_password(self, args):
        if args.password:
            return args.password
        try:
            password = None
            while not password:
                password = getpass(str("enter password for new user: ")).strip()
        except KeyboardInterrupt:
            self.stderr.write("\noperation canceled by user\n")
            sys.exit(2)
        return password

    def mkuser_rattail(self, args):
        from sqlalchemy import orm
        from rattail.db import auth

        session = self.make_session()
        model = self.parent.db_model
        if session.query(model.User).filter_by(username=args.username).count():
            session.close()
            return self.user_exists(args)

        roles = []
        if args.groups:
            for name in parse_list(args.groups):
                try:
                    role = session.query(model.Role)\
                                  .filter(model.Role.name == name)\
                                  .one()
                except orm.exc.NoResultFound:
                    self.stderr.write("Role not found: {}\n".format(name))
                    session.close()
                    sys.exit(4)
                else:
                    roles.append(role)

        user = model.User(username=args.username)
        if not args.no_password:
            auth.set_user_password(user, self.obtain_password(args))

        if args.admin:
            user.roles.append(auth.administrator_role(session))
        for role in roles:
            user.roles.append(role)

        if args.full_name:
            kwargs = {'display_name': args.full_name}
            words = args.full_name.split()
            if len(words) == 2:
                kwargs.update({'first_name': words[0], 'last_name': words[1]})
            user.person = model.Person(**kwargs)

        session.add(user)
        session.commit()
        session.close()
        return True

    def mkuser_windows(self, args):
        if sys.platform != 'win32':
            self.stderr.write("sorry, only win32 platform is supported\n")
            sys.exit(1)

        from rattail.win32 import users
        from rattail.win32 import require_elevation

        if args.no_password:
            self.stderr.write("sorry, a password is required when making a 'win32' user\n")
            sys.exit(1)

        require_elevation()

        if users.user_exists(args.username):
            return self.user_exists(args)

        return users.create_user(args.username, self.obtain_password(args),
                                 full_name=args.full_name, comment=args.comment)


class MakeUUID(Subcommand):
    """
    Generate a new UUID
    """
    name = 'make-uuid'
    description = __doc__.strip()

    def run(self, args):
        from rattail.core import get_uuid

        self.stdout.write("{}\n".format(get_uuid()))


class PalmCommand(Subcommand):
    """
    Manages registration for the HotSync Manager conduit; called as::

       rattail palm
    """

    name = 'palm'
    description = "Manage the HotSync Manager conduit registration"

    def add_parser_args(self, parser):
        subparsers = parser.add_subparsers(title='subcommands')

        register = subparsers.add_parser('register', help="Register Rattail conduit")
        register.set_defaults(subcommand='register')

        unregister = subparsers.add_parser('unregister', help="Unregister Rattail conduit")
        unregister.set_defaults(subcommand='unregister')

    def run(self, args):
        from rattail import palm
        from rattail.win32 import require_elevation
        from rattail.exceptions import PalmError

        require_elevation()

        if args.subcommand == 'register':
            try:
                palm.register_conduit()
            except PalmError as error:
                sys.stderr.write("{}\n".format(error))

        elif args.subcommand == 'unregister':
            try:
                palm.unregister_conduit()
            except PalmError as error:
                sys.stderr.write("{}\n".format(error))
                

class RunAndMail(Subcommand):
    """
    Run a command as subprocess, and email the result/output
    """
    name = 'run-n-mail'
    description = __doc__.strip()

    def add_parser_args(self, parser):

        parser.add_argument('--skip-if-empty', action='store_true',
                            help="Skip sending the email if the command generates no output.")

        parser.add_argument('--key', default='run_n_mail',
                            help="Config key for email settings")
        # TODO: these all seem like good ideas, but not needed yet?
        # parser.add_argument('--from', '-F', metavar='ADDRESS',
        #                     help="Override value of From: header")
        # parser.add_argument('--to', '-T', metavar='ADDRESS',
        #                     help="Override value of To: header (may specify more than once)")
        # parser.add_argument('--cc', metavar='ADDRESS',
        #                     help="Override value of Cc: header (may specify more than once)")
        # parser.add_argument('--bcc', metavar='ADDRESS',
        #                     help="Override value of Bcc: header (may specify more than once)")
        parser.add_argument('--subject', '-S',
                            help="Override value of Subject: header (i.e. value after prefix)")
        parser.add_argument('cmd', metavar='COMMAND',
                            help="Command which should be ran, and result of which will be emailed")

        parser.add_argument('--keep-exit-code', action='store_true',
                            help="Exit with same return code as subprocess.  If "
                            "this is not specified, `run-n-mail` will normally "
                            "exit with code 0 regardless of what happens with "
                            "the subprocess.")

    def run(self, args):
        from rattail.mail import send_email

        cmd = parse_list(args.cmd)
        log.info("will run command as subprocess: %s", cmd)
        run_began = make_utc()

        try:
            # TODO: must we allow for shell=True in some situations? (clearly not yet)
            output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
            retcode = 0
            log.info("command completed successfully")
        except subprocess.CalledProcessError as error:
            output = error.output
            retcode = error.returncode
            log.warning("command exited with code: %s", retcode)

        output = output.decode(errors='replace')

        run_ended = make_utc()
        runtime = run_ended - run_began
        runtime_pretty = humanize.naturaldelta(runtime)

        if args.skip_if_empty and not output:
            log.info("command had no output, so will skip sending email")
            # return

        else: # send email
            kwargs = {}
            if args.subject:
                kwargs['subject_template'] = args.subject
            send_email(self.config, args.key, {
                'cmd': cmd,
                'run_began': run_began,
                'run_ended': run_ended,
                'runtime': runtime,
                'runtime_pretty': runtime_pretty,
                'retcode': retcode,
                'output': output,
            }, **kwargs)

        if retcode and args.keep_exit_code:
            sys.exit(retcode)


class RunSQL(Subcommand):
    """
    Run (first statement of) a SQL script against a database
    """
    name = 'runsql'
    description = __doc__.strip()

    def add_parser_args(self, parser):
        parser.add_argument('engine',
                            help="SQLAlchemy engine URL for the database.")
        parser.add_argument('script', type=argparse.FileType('r'),
                            help="Path to file which contains a SQL script.")
        parser.add_argument('--max-width', type=int, default=80,
                            help="Max table width when displaying results.")

    def run(self, args):
        import sqlalchemy as sa
        import texttable

        sql = []
        for line in args.script:
            line = line.strip()
            if line and not line.startswith('--'):
                sql.append(line)
                if line.endswith(';'):
                    break

        sql = ' '.join(sql)
        engine = sa.create_engine(args.engine)

        with engine.begin() as cxn:
            result = cxn.execute(sa.text(sql))
            rows = result.fetchall()
            if rows:
                table = texttable.Texttable(max_width=args.max_width)

                # force all columns to be treated as text.  that seems a bit
                # heavy-handed but is definitely the simplest way to deal with
                # issues such as a UPC being displayed in scientific notation
                table.set_cols_dtype(['t' for col in rows[0]])

                # add a header row, plus all data rows
                table.add_rows([rows[0].keys()] + rows)

                self.stdout.write("{}\n".format(table.draw()))


class Upgrade(Subcommand):
    """
    Upgrade the local Rattail app
    """
    name = 'upgrade'
    description = __doc__.strip()

    def add_parser_args(self, parser):
        parser.add_argument('--system', default='rattail',
                            help="System to which the upgrade applies.")

        parser.add_argument('--description',
                            help="Description for the new/matched upgrade.")
        parser.add_argument('--enabled', action='store_true', default=True,
                            help="Indicate the enabled flag should be ON for the new/matched upgrade.  "
                            "Note that this is the default if you do not specify.")
        parser.add_argument('--no-enabled', action='store_false', dest='enabled',
                            help="Indicate the enabled flag should be OFF for the new/matched upgrade.")
        parser.add_argument('--create', action='store_true',
                            help="Create a new upgrade with the given attributes.")
        parser.add_argument('--execute', action='store_true',
                            help="Execute the upgrade.  Note that if you do not specify "
                            "--create then the upgrade matching the given attributes "
                            "will be read from the database.  If such an upgrade is not "
                            "found or is otherwise invalid (e.g. already executed), "
                            "the command will fail.")

        parser.add_argument('--keep-exit-code', action='store_true',
                            help="Exit with same return code as subprocess.  If "
                            "this is not specified, this command will normally "
                            "exit with code 0 regardless of what happens with "
                            "the subprocess.  (only applicable with --execute)")

        parser.add_argument('--dry-run', action='store_true',
                            help="Go through the full motions and allow logging etc. to "
                            "occur, but rollback (abort) the transaction at the end.")

    def run(self, args):
        from sqlalchemy import orm

        if not args.create and not args.execute:
            self.stderr.write("Must specify --create and/or --execute\n")
            sys.exit(1)

        session = self.make_session()
        model = self.model
        user = self.get_runas_user(session)

        if args.create:
            upgrade = model.Upgrade()
            upgrade.system = args.system or 'rattail'
            upgrade.description = args.description
            upgrade.created = make_utc()
            upgrade.created_by = user
            upgrade.enabled = args.enabled
            session.add(upgrade)
            session.flush()
            log.info("user '%s' created new upgrade: %s", user.username, upgrade)

        else:
            upgrades = session.query(model.Upgrade)\
                              .filter(model.Upgrade.enabled == args.enabled)
            if args.description:
                upgrades = upgrades.filter(model.Upgrade.description == args.description)
            try:
                upgrade = upgrades.one()
            except orm.exc.NoResultFound:
                self.stderr.write("no matching upgrade found\n")
                session.rollback()
                session.close()
                sys.exit(1)
            except orm.exc.MultipleResultsFound:
                self.stderr.write("found {} matching upgrades\n".format(upgrades.count()))
                session.rollback()
                session.close()
                sys.exit(1)

        if args.execute:
            if upgrade.executed:
                self.stderr.write("upgrade has already been executed: {}\n".format(upgrade))
                session.rollback()
                session.close()
                sys.exit(1)
            if not upgrade.enabled:
                self.stderr.write("upgrade is not enabled for execution: {}\n".format(upgrade))
                session.rollback()
                session.close()
                sys.exit(1)

            # execute upgrade
            handler = self.app.get_upgrade_handler()
            log.info("will now execute upgrade: %s", upgrade)
            if not args.dry_run:
                handler.mark_executing(upgrade)
                session.commit()
                handler.do_execute(upgrade, user, progress=self.progress)
            log.info("user '%s' executed upgrade: %s", user.username, upgrade)

        if args.dry_run:
            session.rollback()
            log.info("dry run, so transaction was rolled back")
        else:
            session.commit()
            log.info("transaction was committed")
        session.close()

        if (args.execute and not args.dry_run
            and args.keep_exit_code and upgrade.exit_code):
            sys.exit(upgrade.exit_code)


def main(*args):
    """
    The primary entry point for the Rattail command system.
    """
    if args:
        args = list(args)
    else:
        args = sys.argv[1:]

    cmd = Command()
    cmd.run(*args)
