import shutil
import os, sys
import time
import logging
from io import StringIO

from webassets.loaders import PythonLoader, YAMLLoader
from webassets.bundle import get_all_bundle_files
from webassets.exceptions import BuildError
from webassets.updater import TimestampUpdater
from webassets.merge import MemoryHunk
from webassets.version import get_manifest
from webassets.cache import FilesystemCache


__all__ = ('CommandError', 'CommandLineEnvironment', 'main')


# logging has WARNING as default level, for the CLI we want INFO. Set this
# as early as possible, so that user customizations will not be overwritten.
logging.getLogger('webassets.script').setLevel(logging.INFO)


class CommandError(Exception):
    pass


class Command(object):
    """Base-class for a command used by :class:`CommandLineEnvironment`.

    Each command being a class opens up certain possibilities with respect to
    subclassing and customizing the default CLI.
    """

    def __init__(self, cmd_env):
        self.cmd = cmd_env

    def __getattr__(self, name):
        # Make stuff from cmd environment easier to access
        return getattr(self.cmd, name)

    def __call__(self, *args, **kwargs):
        raise NotImplementedError()


class BuildCommand(Command):

    def __call__(self, bundles=None, output=None, directory=None, no_cache=None,
              manifest=None, production=None):
        """Build assets.

        ``bundles``
            A list of bundle names. If given, only this list of bundles
            should be built.

        ``output``
            List of (bundle, filename) 2-tuples. If given, only these
            bundles will be built, using the custom output filenames.
            Cannot be used with ``bundles``.

        ``directory``
            Custom output directory to use for the bundles. The original
            basenames defined in the bundle ``output`` attribute will be
            used. If the ``output`` of the bundles are pointing to different
            directories, they will be offset by their common prefix.
            Cannot be used with ``output``.

        ``no_cache``
            If set, a cache (if one is configured) will not be used.

        ``manifest``
            If set, the given manifest instance will be used, instead of
            any that might have been configured in the Environment. The value
            passed will be resolved through ``get_manifest()``. If this fails,
            a file-based manifest will be used using the given value as the
            filename.

        ``production``
            If set to ``True``, then :attr:`Environment.debug`` will forcibly
            be disabled (set to ``False``) during the build.
        """

        # Validate arguments
        if bundles and output:
            raise CommandError(
                'When specifying explicit output filenames you must '
                'do so for all bundles you want to build.')
        if directory and output:
            raise CommandError('A custom output directory cannot be '
                               'combined with explicit output filenames '
                               'for individual bundles.')

        if production:
            # TODO: Reset again (refactor commands to be classes)
            self.environment.debug = False

        # TODO: Oh how nice it would be to use the future options stack.
        if manifest is not None:
            try:
                manifest = get_manifest(manifest, env=self.environment)
            except ValueError:
                manifest = get_manifest(
                    # abspath() is important, or this will be considered
                    # relative to Environment.directory.
                    "file:%s" % os.path.abspath(manifest),
                    env=self.environment)
            self.environment.manifest = manifest

        # Use output as a dict.
        if output:
            output = dict(output)

        # Validate bundle names
        bundle_names = bundles if bundles else (output.keys() if output else [])
        for name in bundle_names:
            if not name in self.environment:
                raise CommandError(
                    'I do not know a bundle name named "%s".' % name)

        # Make a list of bundles to build, and the filename to write to.
        if bundle_names:
            # TODO: It's not ok to use an internal property here.
            bundles = [(n,b) for n, b in self.environment._named_bundles.items()
                             if n in bundle_names]
        else:
            # Includes unnamed bundles as well.
            bundles = [(None, b) for b in self.environment]

        # Determine common prefix for use with ``directory`` option.
        if directory:
            prefix = os.path.commonprefix(
                [os.path.normpath(b.resolve_output())
                 for _, b in bundles if b.output])
            # dirname() gives the right value for a single file.
            prefix = os.path.dirname(prefix)

        to_build = []
        for name, bundle in bundles:
            # TODO: We really should support this. This error here
            # is just in place of a less understandable error that would
            # otherwise occur.
            if bundle.is_container and directory:
                raise CommandError(
                    'A custom output directory cannot currently be '
                    'used with container bundles.')

            # Determine which filename to use, if not the default.
            overwrite_filename = None
            if output:
                overwrite_filename = output[name]
            elif directory:
                offset = os.path.normpath(
                    bundle.resolve_output())[len(prefix)+1:]
                overwrite_filename = os.path.join(directory, offset)
            to_build.append((bundle, overwrite_filename, name,))

        # Build.
        built = []
        for bundle, overwrite_filename, name in to_build:
            if name:
                # A name is not necessary available of the bundle was
                # registered without one.
                self.log.info("Building bundle: %s (to %s)" % (
                    name, overwrite_filename or bundle.output))
            else:
                self.log.info("Building bundle: %s" % bundle.output)

            try:
                if not overwrite_filename:
                    with bundle.bind(self.environment):
                        bundle.build(force=True, disable_cache=no_cache)
                else:
                    # TODO: Rethink how we deal with container bundles here.
                    # As it currently stands, we write all child bundles
                    # to the target output, merged (which is also why we
                    # create and force writing to a StringIO instead of just
                    # using the ``Hunk`` objects that build() would return
                    # anyway.
                    output = StringIO()
                    with bundle.bind(self.environment):
                        bundle.build(force=True, output=output,
                            disable_cache=no_cache)
                    if directory:
                        # Only auto-create directories in this mode.
                        output_dir = os.path.dirname(overwrite_filename)
                        if not os.path.exists(output_dir):
                            os.makedirs(output_dir)
                    MemoryHunk(output.getvalue()).save(overwrite_filename)
                built.append(bundle)
            except BuildError as e:
                self.log.error("Failed, error was: %s" % e)
        if len(built):
            self.event_handlers['post_build']()
        if len(built) != len(to_build):
            return 2


class WatchCommand(Command):

    def __call__(self, loop=None):
        """Watch assets for changes.

        ``loop``
            A callback, taking no arguments, to be called once every loop
            iteration. Can be useful to integrate the command with other code.
            If not specified, the loop will call ``time.sleep()``.
        """
        # TODO: This should probably also restart when the code changes.
        mtimes = {}

        try:
            # Before starting to watch for changes, also recognize changes
            # made while we did not run, and apply those immediately.
            for bundle in self.environment:
                print('Bringing up to date: %s' % bundle.output)
                bundle.build(force=False)

            self.log.info("Watching %d bundles for changes..." %
                          len(self.environment))

            while True:
                changed_bundles = self.check_for_changes(mtimes)

                built = []
                for bundle in changed_bundles:
                    print("Building bundle: %s ..." % bundle.output, end=' ')
                    sys.stdout.flush()
                    try:
                        bundle.build(force=True)
                        built.append(bundle)
                    except BuildError as e:
                        print("")
                        print("Failed: %s" % e)
                    else:
                        print("done")

                if len(built):
                    self.event_handlers['post_build']()

                do_end = loop() if loop else time.sleep(0.1)
                if do_end:
                    break
        except KeyboardInterrupt:
            pass

    def check_for_changes(self, mtimes):
        # Do not update original mtimes dict right away, so that we detect
        # all bundle changes if a file is in multiple bundles.
        _new_mtimes = mtimes.copy()

        changed_bundles = set()
        # TODO: An optimization was lost here, skipping a bundle once
        # a single file has been found to have changed. Bring back.
        for filename, bundles_to_update in self.yield_files_to_watch():
            stat = os.stat(filename)
            mtime = stat.st_mtime
            if sys.platform == "win32":
                mtime -= stat.st_ctime

            if mtimes.get(filename, mtime) != mtime:
                if callable(bundles_to_update):
                    # Hook for when file has changed
                    try:
                        bundles_to_update = bundles_to_update()
                    except EnvironmentError:
                        # EnvironmentError is what the hooks is allowed to
                        # raise for a temporary problem, like an invalid config
                        import traceback
                        traceback.print_exc()
                        # Don't update anything, wait for another change
                        bundles_to_update = set()

                if bundles_to_update is True:
                    # Indicates all bundles should be rebuilt for the change
                    bundles_to_update = set(self.environment)
                changed_bundles |= bundles_to_update
                _new_mtimes[filename] = mtime
            _new_mtimes[filename] = mtime

        mtimes.update(_new_mtimes)
        return changed_bundles

    def yield_files_to_watch(self):
        for bundle in self.environment:
            for filename in get_all_bundle_files(bundle):
                yield filename, set([bundle])


class CleanCommand(Command):

    def __call__(self):
        """Delete generated assets.
        """
        self.log.info('Cleaning generated assets...')
        for bundle in self.environment:
            if not bundle.output:
                continue
            file_path = bundle.resolve_output(self.environment)
            if os.path.exists(file_path):
                os.unlink(file_path)
                self.log.info("Deleted asset: %s" % bundle.output)
        if isinstance(self.environment.cache, FilesystemCache):
            shutil.rmtree(self.environment.cache.directory)


class CheckCommand(Command):

    def __call__(self):
        """Check to see if assets need to be rebuilt.

        A non-zero exit status will be returned if any of the input files are
        newer (based on mtime) than their output file. This is intended to be
        used in pre-commit hooks.
        """
        needsupdate = False
        updater = self.environment.updater
        if not updater:
            self.log.debug('no updater configured, using TimestampUpdater')
            updater = TimestampUpdater()
        for bundle in self.environment:
            self.log.info('Checking asset: %s', bundle.output)
            if updater.needs_rebuild(bundle, self.environment):
                self.log.info('  needs update')
                needsupdate = True
        if needsupdate:
            sys.exit(-1)


class CommandLineEnvironment(object):
    """Implements the core functionality for a command line frontend to
    ``webassets``, abstracted in a way to allow frameworks to integrate the
    functionality into their own tools, for example, as a Django management
    command, or a command for ``Flask-Script``.
    """

    def __init__(self, env, log, post_build=None, commands=None):
        self.environment = env
        self.log = log
        self.event_handlers = dict(post_build=lambda: True)
        if callable(post_build):
            self.event_handlers['post_build'] = post_build

        # Instantiate each command
        command_def = self.DefaultCommands.copy()
        command_def.update(commands or {})
        self.commands = {}
        for name, construct in command_def.items():
            if not construct:
                continue
            if not isinstance(construct, (list, tuple)):
                construct = [construct, (), {}]
            self.commands[name] = construct[0](
                self, *construct[1], **construct[2])

    def __getattr__(self, item):
        # Allow method-like access to commands.
        if item in self.commands:
            return self.commands[item]
        raise AttributeError(item)

    def invoke(self, command, args):
        """Invoke ``command``, or throw a CommandError.

        This is essentially a simple validation mechanism. Feel free
        to call the individual command methods manually.
        """
        try:
            function = self.commands[command]
        except KeyError as e:
            raise CommandError('unknown command: %s' % e)
        else:
            return function(**args)

    # List of commands installed
    DefaultCommands = {
        'build': BuildCommand,
        'watch': WatchCommand,
        'clean': CleanCommand,
        'check': CheckCommand
    }


class GenericArgparseImplementation(object):
    """Generic command line utility to interact with an webassets environment.

    This is effectively a reference implementation of a command line utility
    based on the ``CommandLineEnvironment`` class. Implementers may find it
    feasible to simple base their own command line utility on this, rather than
    implementing something custom on top of ``CommandLineEnvironment``. In
    fact, if that is possible, you are encouraged to do so for greater
    consistency across implementations.
    """

    class WatchCommand(WatchCommand):
        """Extended watch command that also looks at the config file itself."""

        def __init__(self, cmd_env, argparse_ns):
            WatchCommand.__init__(self, cmd_env)
            self.ns = argparse_ns

        def yield_files_to_watch(self):
            for result in WatchCommand.yield_files_to_watch(self):
                yield result
            # If the config changes, rebuild all bundles
            if getattr(self.ns, 'config', None):
                yield self.ns.config, self.reload_config

        def reload_config(self):
            try:
                self.cmd.environment = YAMLLoader(self.ns.config).load_environment()
            except Exception as e:
                raise EnvironmentError(e)
            return True


    def __init__(self, env=None, log=None, prog=None, no_global_options=False):
        try:
            import argparse
        except ImportError:
            raise RuntimeError(
                'The webassets command line now requires the '
                '"argparse" library on Python versions <= 2.6.')
        else:
            self.argparse = argparse
        self.env = env
        self.log = log
        self._construct_parser(prog, no_global_options)

    def _construct_parser(self, prog=None, no_global_options=False):
        self.parser = parser = self.argparse.ArgumentParser(
            description="Manage assets.",
            prog=prog)

        if not no_global_options:
            # Start with the base arguments that are valid for any command.
            # XXX: Add those to the subparser?
            parser.add_argument("-v", dest="verbose", action="store_true",
                help="be verbose")
            parser.add_argument("-q", action="store_true", dest="quiet",
                help="be quiet")
            if self.env is None:
                loadenv = parser.add_mutually_exclusive_group()
                loadenv.add_argument("-c", "--config", dest="config",
                    help="read environment from a YAML file")
                loadenv.add_argument("-m", "--module", dest="module",
                    help="read environment from a Python module")

        # Add subparsers.
        subparsers = parser.add_subparsers(dest='command')
        for command in CommandLineEnvironment.DefaultCommands.keys():
            command_parser = subparsers.add_parser(command)
            maker = getattr(self, 'make_%s_parser' % command, False)
            if maker:
                maker(command_parser)

    @staticmethod
    def make_build_parser(parser):
        parser.add_argument(
            'bundles', nargs='*', metavar='BUNDLE',
            help='Optional bundle names to process. If none are '
                 'specified, then all known bundles will be built.')
        parser.add_argument(
            '--output', '-o', nargs=2, action='append',
            metavar=('BUNDLE', 'FILE'),
            help='Build the given bundle, and use a custom output '
                 'file. Can be given multiple times.')
        parser.add_argument(
            '--directory', '-d',
            help='Write built files to this directory, using the '
                 'basename defined by the bundle. Will offset '
                 'the original bundle output paths on their common '
                 'prefix. Cannot be used with --output.')
        parser.add_argument(
            '--no-cache', action='store_true',
            help='Do not use a cache that might be configured.')
        parser.add_argument(
            '--manifest',
            help='Write a manifest to the given file. Also supports '
                 'the id:arg format, if you want to use a different '
                 'manifest implementation.')
        parser.add_argument(
            '--production', action='store_true',
            help='Forcably turn off debug mode for the build. This '
                 'only has an effect if debug is set to "merge".')

    def _setup_logging(self, ns):
        if self.log:
            log = self.log
        else:
            log = logging.getLogger('webassets.script')
            if not log.handlers:
                # In theory, this could run multiple times (e.g. tests)
                handler = logging.StreamHandler()
                log.addHandler(handler)
                # Note that setting the level filter at the handler level is
                # better than the logger level, since this is "our" handler,
                # we create it, for the purposes of having a default output.
                # The logger itself the user may be modifying.
                handler.setLevel(logging.DEBUG if ns.verbose else (
                    logging.WARNING if ns.quiet else logging.INFO))
        return log

    def _setup_assets_env(self, ns, log):
        env = self.env
        if env is None:
            assert not (ns.module and ns.config)
            if ns.module:
                env = PythonLoader(ns.module).load_environment()
            if ns.config:
                env = YAMLLoader(ns.config).load_environment()
        return env

    def _setup_cmd_env(self, assets_env, log, ns):
        return CommandLineEnvironment(assets_env, log, commands={
            'watch': (GenericArgparseImplementation.WatchCommand, (ns,), {})
        })

    def _prepare_command_args(self, ns):
        # Prepare a dict of arguments cleaned of values that are not
        # command-specific, and which the command method would not accept.
        args = vars(ns).copy()
        for action in self.parser._actions:
            dest = action.dest
            if dest in args:
                del args[dest]
        return args

    def run_with_ns(self, ns):
        log = self._setup_logging(ns)
        env = self._setup_assets_env(ns, log)
        if env is None:
            raise CommandError(
                "Error: No environment given or found. Maybe use -m?")
        cmd = self._setup_cmd_env(env, log, ns)

        # Run the selected command
        args = self._prepare_command_args(ns)
        return cmd.invoke(ns.command, args)

    def run_with_argv(self, argv):
        try:
            ns = self.parser.parse_args(argv)
        except SystemExit as e:
            # We do not want the main() function to exit the program.
            # See run() instead.
            return e.args[0]

        return self.run_with_ns(ns)

    def main(self, argv):
        """Parse the given command line.

        The commandline is expected to NOT including what would be sys.argv[0].
        """
        try:
            return self.run_with_argv(argv)
        except CommandError as e:
            print(e)
            return 1


def main(argv, env=None):
    """Execute the generic version of the command line interface.

    You only need to work directly with ``GenericArgparseImplementation`` if
    you desire to customize things.

    If no environment is given, additional arguments will be supported to allow
    the user to specify/construct the environment on the command line.
    """
    return GenericArgparseImplementation(env).main(argv)


def run():
    """Runs the command line interface via ``main``, then exits the process
    with a proper return code."""
    sys.exit(main(sys.argv[1:]) or 0)


if __name__ == '__main__':
    run()
