# pyflyby/_cmdline.py.
# Copyright (C) 2011, 2012, 2013, 2014, 2015, 2018 Karl Chen.
# License: MIT http://opensource.org/licenses/MIT



from   builtins                 import input
import optparse
import os
import signal
import sys
from   textwrap                 import dedent
import traceback
from   typing                   import List


from   pyflyby._file            import (FileText, Filename, atomic_write_file,
                                        expand_py_files_from_args, read_file)
from   pyflyby._importstmt      import ImportFormatParams
from   pyflyby._log             import logger
from   pyflyby._util            import cached_attribute, indent


def hfmt(s):
    return dedent(s).strip()

def maindoc():
    import __main__
    return (__main__.__doc__ or '').strip()


def _sigpipe_handler(*args):
    # The parent process piped our stdout and closed the pipe before we
    # finished writing, e.g. "tidy-imports ... | head" or "tidy-imports ... |
    # less".  Exit quietly - squelch the "close failed in file object
    # destructor" message would otherwise be raised.
    raise SystemExit(1)


def parse_args(
    addopts=None, import_format_params=False, modify_action_params=False, defaults=None
):
    """
    Do setup for a top-level script and parse arguments.
    """

    if defaults is None:
        defaults = {}

    ### Setup.
    # Register a SIGPIPE handler.
    signal.signal(signal.SIGPIPE, _sigpipe_handler)
    ### Parse args.
    parser = optparse.OptionParser(usage='\n'+maindoc())

    def log_level_callbacker(level):
        def callback(option, opt_str, value, parser):
            logger.set_level(level)
        return callback

    def debug_callback(option, opt_str, value, parser):
        logger.set_level("DEBUG")

    parser.add_option("--debug", action="callback",
                      callback=debug_callback,
                      help="Debug mode (noisy and fail fast).")

    parser.add_option("--verbose", action="callback",
                      callback=log_level_callbacker("DEBUG"),
                      help="Be noisy.")

    parser.add_option("--quiet", action="callback",
                      callback=log_level_callbacker("ERROR"),
                      help="Be quiet.")

    parser.add_option("--version", action="callback",
                      callback=lambda *args: print_version_and_exit(),
                      help="Print pyflyby version and exit.")

    if modify_action_params:
        group = optparse.OptionGroup(parser, "Action options")
        action_diff = action_external_command('pyflyby-diff')
        def parse_action(v):
            V = v.strip().upper()
            if V == 'PRINT':
                return action_print
            elif V == 'REPLACE':
                return action_replace
            elif V == 'QUERY':
                return action_query()
            elif V == "DIFF":
                return action_diff
            elif V.startswith("QUERY:"):
                return action_query(v[6:])
            elif V.startswith("EXECUTE:"):
                return action_external_command(v[8:])
            elif V == "IFCHANGED":
                return action_ifchanged
            elif V == "EXIT1":
                return action_exit1
            else:
                raise Exception(
                    "Bad argument %r to --action; "
                    "expected PRINT or REPLACE or QUERY or IFCHANGED or EXIT1 "
                    "or EXECUTE:..." % (v,))

        def set_actions(actions):
            actions = tuple(actions)
            parser.values.actions = actions

        def action_callback(option, opt_str, value, parser):
            action_args = value.split(',')
            set_actions([parse_action(v) for v in action_args])

        def action_callbacker(actions):
            def callback(option, opt_str, value, parser):
                set_actions(actions)
            return callback

        group.add_option(
            "--actions", type='string', action='callback',
            callback=action_callback,
            metavar='PRINT|REPLACE|IFCHANGED|QUERY|DIFF|EXIT1:EXECUTE:mycommand',
            help=hfmt('''
                   Comma-separated list of action(s) to take.  If PRINT, print
                   the changed file to stdout.  If REPLACE, then modify the
                   file in-place.  If EXECUTE:mycommand, then execute
                   'mycommand oldfile tmpfile'.  If DIFF, then execute
                   'pyflyby-diff'.  If QUERY, then query user to continue.
                   If IFCHANGED, then continue actions only if file was
                   changed.  If EXIT1, then exit with exit code 1 after all
                   files/actions are processed.'''))
        group.add_option(
            "--print", "-p", action='callback',
            callback=action_callbacker([action_print]),
            help=hfmt('''
                Equivalent to --action=PRINT (default when stdin or stdout is
                not a tty) '''))
        group.add_option(
            "--diff", "-d", action='callback',
            callback=action_callbacker([action_diff]),
            help=hfmt('''Equivalent to --action=DIFF'''))
        group.add_option(
            "--replace", "-r", action='callback',
            callback=action_callbacker([action_ifchanged, action_replace]),
            help=hfmt('''Equivalent to --action=IFCHANGED,REPLACE'''))
        group.add_option(
            "--diff-replace", "-R", action='callback',
            callback=action_callbacker([action_ifchanged, action_diff, action_replace]),
            help=hfmt('''Equivalent to --action=IFCHANGED,DIFF,REPLACE'''))
        actions_interactive = [
            action_ifchanged, action_diff,
            action_query("Replace {filename}?"), action_replace]
        group.add_option(
            "--interactive", "-i", action='callback',
            callback=action_callbacker(actions_interactive),
            help=hfmt('''
               Equivalent to --action=IFCHANGED,DIFF,QUERY,REPLACE (default
               when stdin & stdout are ttys) '''))
        if os.isatty(0) and os.isatty(1):
            default_actions = actions_interactive
        else:
            default_actions = [action_print]
        parser.set_default('actions', tuple(default_actions))
        parser.add_option_group(group)

        parser.add_option(
            '--symlinks', action='callback', nargs=1, type=str,
            dest='symlinks', callback=symlink_callback, help="--symlinks should be one of: " + symlinks_help,
        )
        parser.set_defaults(symlinks='error')

    if import_format_params:
        group = optparse.OptionGroup(parser, "Pretty-printing options")
        group.add_option('--align-imports', '--align', type='str', default="32",
                         metavar='N',
                         help=hfmt('''
                             Whether and how to align the 'import' keyword in
                             'from modulename import aliases...'.  If 0, then
                             don't align.  If 1, then align within each block
                             of imports.  If an integer > 1, then align at
                             that column, wrapping with a backslash if
                             necessary.  If a comma-separated list of integers
                             (tab stops), then pick the column that results in
                             the fewest number of lines total per block.'''))
        group.add_option('--from-spaces', type='int', default=3, metavar='N',
                         help=hfmt('''
                             The number of spaces after the 'from' keyword.
                             (Must be at least 1; default is 3.)'''))
        group.add_option('--separate-from-imports', action='store_true',
                         default=False,
                         help=hfmt('''
                             Separate 'from ... import ...'
                             statements from 'import ...' statements.'''))
        group.add_option('--no-separate-from-imports', action='store_false',
                         dest='separate_from_imports',
                         help=hfmt('''
                            (Default) Don't separate 'from ... import ...'
                            statements from 'import ...' statements.'''))
        group.add_option('--align-future', action='store_true',
                         default=False,
                         help=hfmt('''
                             Align the 'from __future__ import ...' statement
                             like others.'''))
        group.add_option('--no-align-future', action='store_false',
                         dest='align_future',
                         help=hfmt('''
                             (Default) Don't align the 'from __future__ import
                             ...' statement.'''))
        group.add_option('--width', type='int', default=None, metavar='N',
                         help=hfmt('''
                             Maximum line length (default: 79).'''))
        group.add_option('--black', action='store_true', default=False,
                         help=hfmt('''
                             Use black to format imports. If this option is
                             used, all other formatting options are ignored,
                             except width'''))
        group.add_option('--hanging-indent', type='choice', default='never',
                         choices=['never','auto','always'],
                         metavar='never|auto|always',
                         dest='hanging_indent',
                         help=hfmt('''
                             How to wrap import statements that don't fit on
                             one line.
                             If --hanging-indent=always, then always indent
                             imported tokens at column 4 on the next line.
                             If --hanging-indent=never (default), then align
                             import tokens after "import (" (by default column
                             40); do so even if some symbols are so long that
                             this would exceed the width (by default 79)).
                             If --hanging-indent=auto, then use hanging indent
                             only if it is necessary to prevent exceeding the
                             width (by default 79).
                         '''))
        def uniform_callback(option, opt_str, value, parser):
            parser.values.separate_from_imports = False
            parser.values.from_spaces           = 3
            parser.values.align_imports         = '32'
        group.add_option('--uniform', '-u', action="callback",
                         callback=uniform_callback,
                         help=hfmt('''
                             (Default) Shortcut for --no-separate-from-imports
                             --from-spaces=3 --align-imports=32.'''))
        def unaligned_callback(option, opt_str, value, parser):
            parser.values.separate_from_imports = True
            parser.values.from_spaces           = 1
            parser.values.align_imports         = '0'
        group.add_option('--unaligned', '-n', action="callback",
                         callback=unaligned_callback,
                         help=hfmt('''
                             Shortcut for --separate-from-imports
                             --from-spaces=1 --align-imports=0.'''))

        parser.add_option_group(group)
    if addopts is not None:
        addopts(parser)
    # This is the only way to provide a default value for an option with a
    # callback.
    if modify_action_params:
        args = ["--symlinks=error"] + sys.argv[1:]
    else:
        args = None
    options, args = parser.parse_args(args=args)
    if import_format_params:
        align_imports_args = [int(x.strip())
                              for x in options.align_imports.split(",")]
        if len(align_imports_args) == 1 and align_imports_args[0] == 1:
            align_imports = True
        elif len(align_imports_args) == 1 and align_imports_args[0] == 0:
            align_imports = False
        else:
            align_imports = tuple(sorted(set(align_imports_args)))
        options.params = ImportFormatParams(
            align_imports         =align_imports,
            from_spaces           =options.from_spaces,
            separate_from_imports =options.separate_from_imports,
            max_line_length       =options.width,
            use_black             =options.black,
            align_future          =options.align_future,
            hanging_indent        =options.hanging_indent,
            )
    return options, args


def _default_on_error(filename):
    raise SystemExit("bad filename %s" % (filename,))


def filename_args(args: List[str], on_error=_default_on_error):
    """
    Return list of filenames given command-line arguments.

    :rtype:
      ``list`` of `Filename`
    """
    if args:
        for a in args:
            assert isinstance(a, str)
        return expand_py_files_from_args([Filename(f) for f in args], on_error)
    elif not os.isatty(0):
        return [Filename.STDIN]
    else:
        syntax()


def print_version_and_exit(extra=None):
    from pyflyby._version import __version__
    msg = "pyflyby %s" % (__version__,)
    progname = os.path.realpath(sys.argv[0])
    if os.path.exists(progname):
        msg += " (%s)" % (os.path.basename(progname),)
    print(msg)
    if extra:
        print(extra)
    raise SystemExit(0)


def syntax(message=None, usage=None):
    if message:
        logger.error(message)
    outmsg = ((usage or maindoc()) +
              '\n\nFor usage, see: %s --help' % (sys.argv[0],))
    print(outmsg, file=sys.stderr)
    raise SystemExit(1)


class AbortActions(Exception):
    pass


class Exit1(Exception):
    pass


class Modifier(object):
    def __init__(self, modifier, filename):
        self.modifier = modifier
        self.filename = filename
        self._tmpfiles = []

    @cached_attribute
    def input_content(self):
        return read_file(self.filename)

    # TODO: refactor to avoid having these heavy-weight things inside a
    # cached_attribute, which causes annoyance while debugging.
    @cached_attribute
    def output_content(self):
        return FileText(self.modifier(self.input_content), filename=self.filename)

    def _tempfile(self):
        from tempfile import NamedTemporaryFile
        f = NamedTemporaryFile()
        self._tmpfiles.append(f)
        return f, Filename(f.name)


    @cached_attribute
    def output_content_filename(self):
        f, fname = self._tempfile()
        f.write(bytes(self.output_content.joined, "utf-8"))
        f.flush()
        return fname

    @cached_attribute
    def input_content_filename(self):
        if isinstance(self.filename, Filename):
            return self.filename
        # If the input was stdin, and the user wants a diff, then we need to
        # write it to a temp file.
        f, fname = self._tempfile()
        f.write(bytes(self.input_content, "utf-8"))
        f.flush()
        return fname


    def __del__(self):
        for f in self._tmpfiles:
            f.close()


def process_actions(filenames:List[str], actions, modify_function,
                    reraise_exceptions=()):
    errors = []
    def on_error_filename_arg(arg):
        print("%s: bad filename %s" % (sys.argv[0], arg), file=sys.stderr)
        errors.append("%s: bad filename" % (arg,))
    filenames = filename_args(filenames, on_error=on_error_filename_arg)
    exit_code = 0
    for filename in filenames:
        try:
            m = Modifier(modify_function, filename)
            for action in actions:
                action(m)
        except AbortActions:
            continue
        except reraise_exceptions:
            raise
        except Exit1:
            exit_code = 1
        except Exception as e:
            errors.append("%s: %s: %s" % (filename, type(e).__name__, e))
            type_e = type(e)
            try:
                tb = sys.exc_info()[2]
                if str(filename) not in str(e):
                    try:
                        e = type_e("While processing %s: %s" % (filename, e))
                        pass
                    except TypeError:
                        # Exception takes more than one argument
                        pass
                if logger.debug_enabled:
                    raise
                traceback.print_exception(type(e), e, tb)
            finally:
                tb = None # avoid refcycles involving tb
            continue
    if errors:
        msg = "\n%s: encountered the following problems:\n" % (sys.argv[0],)
        for er in errors:
            lines = er.splitlines()
            msg += "    " + lines[0] + '\n'.join(
                ("            %s"%line for line in lines[1:]))
        raise SystemExit(msg)
    else:
        raise SystemExit(exit_code)


def action_print(m):
    output_content = m.output_content
    sys.stdout.write(output_content.joined)


def action_ifchanged(m):
    if m.output_content.joined == m.input_content.joined:
        logger.debug("unmodified: %s", m.filename)
        raise AbortActions


def action_replace(m):
    if m.filename == Filename.STDIN:
        raise Exception("Can't replace stdio in-place")
    logger.info("%s: *** modified ***", m.filename)
    atomic_write_file(m.filename, m.output_content)


def action_exit1(m):
    logger.debug("action_exit1")
    raise Exit1


def action_external_command(command):
    import subprocess
    def action(m):
        bindir = os.path.dirname(os.path.realpath(sys.argv[0]))
        env = os.environ
        env['PATH'] = env['PATH'] + ":" + bindir
        fullcmd = "%s %s %s" % (
            command, m.input_content_filename, m.output_content_filename)
        logger.debug("Executing external command: %s", fullcmd)
        ret = subprocess.call(fullcmd, shell=True, env=env)
        logger.debug("External command returned %d", ret)
    return action


def action_query(prompt="Proceed?"):
    def action(m):
        p = prompt.format(filename=m.filename)
        print()
        print("%s [y/N] " % (p), end="")
        try:
            if input().strip().lower().startswith('y'):
                return True
        except KeyboardInterrupt:
            print("KeyboardInterrupt", file=sys.stderr)
            raise SystemExit(1)
        print("Aborted")
        raise AbortActions
    return action

def symlink_callback(option, opt_str, value, parser):
    parser.values.actions = tuple(i for i in parser.values.actions if i not in
    symlink_callbacks.values())
    if value in symlink_callbacks:
        parser.values.actions = (symlink_callbacks[value],) + parser.values.actions
    else:
        raise optparse.OptionValueError("--symlinks must be one of 'error', 'follow', 'skip', or 'replace'. Got %r" % value)

symlinks_help = """\
--symlinks=error (default; gives an error on symlinks),
--symlinks=follow (follows symlinks),
--symlinks=skip (skips symlinks),
--symlinks=replace (replaces symlinks with the target file\
"""

# Warning, the symlink actions will only work if they are run first.
# Otherwise, output_content may already be cached
def symlink_error(m):
    if m.filename == Filename.STDIN:
        return symlink_follow(m)
    if m.filename.islink:
        raise SystemExit("""\
Error: %s appears to be a symlink. Use one of the following options to allow symlinks:
%s
""" % (m.filename, indent(symlinks_help, '    ')))

def symlink_follow(m):
    if m.filename == Filename.STDIN:
        return
    if m.filename.islink:
        logger.info("Following symlink %s" % m.filename)
        m.filename = m.filename.realpath

def symlink_skip(m):
    if m.filename == Filename.STDIN:
        return symlink_follow(m)
    if m.filename.islink:
        logger.info("Skipping symlink %s" % m.filename)
        raise AbortActions

def symlink_replace(m):
    if m.filename == Filename.STDIN:
        return symlink_follow(m)
    if m.filename.islink:
        logger.info("Replacing symlink %s" % m.filename)
        # The current behavior automatically replaces symlinks, so do nothing

symlink_callbacks = {
    'error': symlink_error,
    'follow': symlink_follow,
    'skip': symlink_skip,
    'replace': symlink_replace,
}
