#!/usr/bin/env python

# Copyright (C) 2008-2016 NIWA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import re
import sys


def prelude():
    """Ensure cylc library is at the front of "sys.path"."""
    lib = os.path.join(
        os.path.dirname(os.path.realpath(__file__)), '..', 'lib')
    if lib in sys.path:
        sys.path.remove(lib)
    sys.path.insert(0, lib)


prelude()


try:
    os.getcwd()
except OSError as exc:
    # The current working directory has been deleted (or filesystem
    # problems of some kind...). This results in Pyro not being found,
    # immediately below. We cannot just chdir to $HOME as gcylc does
    # because that would break relative directory path command arguments
    # (cylc reg SUITE PATH).
    sys.exit(str(exc))

# Import cylc to initialise CYLC_DIR and the path for python (__init__.py).
import cylc
from parsec.OrderedDict import OrderedDict


class CommandError(Exception):

    def __init__(self, msg):
        self.msg = msg

    def __str__(self):
        return repr(self.msg)


class CommandNotFoundError(CommandError):
    pass


class CommandNotUniqueError(CommandError):
    pass


def is_help(str):
    if (str == '-h' or str == '--help' or str == '--hlep' or str == 'help' or
            str == 'hlep' or str == '?'):
        return True
    else:
        return False


def match_dict(abbrev, categories, title):
    # allow any unique abbreviation to cylc categories
    matches = []
    for cat in categories.keys():
        for alias in categories[cat]:
            if re.match('^' + abbrev + '.*', alias):
                if cat not in matches:
                    matches.append(cat)
    if len(matches) == 0:
        raise CommandNotFoundError(title + ' not found: ' + abbrev)
    elif len(matches) > 1:
        # multiple matches
        res = ''
        for cat in matches:
            res += ' ' + '|'.join(categories[cat])
        raise CommandNotUniqueError(
            title + ' "' + abbrev + '" not unique:' + res)
    else:
        return matches[0]


def match_command(abbrev):
    # allow any unique abbreviation to commands when no category is specified
    matches = []
    finished_matching = False
    for dct in [admin_commands,
                license_commands,
                database_commands,
                preparation_commands,
                information_commands,
                discovery_commands,
                control_commands,
                utility_commands,
                hook_commands,
                task_commands]:
        for com in dct.keys():
            if com == abbrev:
                matches = [com]
                finished_matching = True
                break
            for alias in dct[com]:
                if re.match('^' + abbrev + '.*', alias):
                    if com not in matches:
                        matches.append(com)
            if finished_matching:
                break
    if len(matches) == 0:
        raise CommandNotFoundError('COMMAND not found: ' + abbrev)
    elif len(matches) > 1:
        # multiple matches
        res = ''
        for com in matches:
            res += ' ' + '|'.join(all_commands[com])
        raise CommandNotUniqueError(
            'COMMAND "' + abbrev + '" not unique:' + res)
    else:
        return matches[0]


def pretty_print(incom, choose_dict, indent=True, numbered=False, sort=False):
    # pretty print commands or topics from a dict:
    # (com[item] = description)

    if indent:
        spacer = ' '
    else:
        spacer = ''

    label = {}
    choose = []
    longest = 0
    for item in choose_dict:
        choose.append(item)
        lbl = '|'.join(choose_dict[item])
        label[item] = lbl
        if len(lbl) > longest:
            longest = len(lbl)

    count = 0
    pad = False
    if len(choose) > 9:
        pad = True

    if sort:
        choose.sort()
    for item in choose:
        if item not in incom:
            raise SystemExit("ERROR: summary for '" + item + "' not found")

        print spacer,
        if numbered:
            count += 1
            if pad and count < 10:
                digit = ' ' + str(count)
            else:
                digit = str(count)
            print digit + '/',
        print "%s %s %s" % (
            label[item],
            '.' * (longest - len(label[item])) + '...',
            incom[item])

# BEGIN MAIN

# categories[category] = [aliases]
categories = OrderedDict()
categories['all'] = ['all']
categories['database'] = ['db', 'database']
categories['preparation'] = ['preparation']
categories['information'] = ['information']
categories['discovery'] = ['discovery']
categories['control'] = ['control']
categories['utility'] = ['utility']
categories['task'] = ['task']
categories['hook'] = ['hook']
categories['admin'] = ['admin']
categories['license'] = ['license', 'GPL']

information_commands = OrderedDict()

information_commands['gscan'] = ['gscan', 'gsummary']
information_commands['gpanel'] = ['gpanel']
information_commands['gui'] = ['gui', 'gcylc']
information_commands['list'] = ['list', 'ls']
information_commands['dump'] = ['dump']
information_commands['cat-state'] = ['cat-state']
information_commands['show'] = ['show']
information_commands['cat-log'] = ['cat-log', 'log']
information_commands['get-suite-version'] = [
    'get-suite-version', 'get-cylc-version']
information_commands['version'] = ['version']

information_commands['documentation'] = ['documentation', 'browse']
information_commands['monitor'] = ['monitor']
information_commands['get-suite-config'] = ['get-suite-config', 'get-config']
information_commands['get-site-config'] = [
    'get-site-config', 'get-global-config']
information_commands['get-gui-config'] = ['get-gui-config']

control_commands = OrderedDict()
control_commands['gui'] = ['gui']
# NOTE: don't change 'run' to 'start' or the category [control]
# becomes compulsory to disambiguate from 'cylc [task] started'.
# Keeping 'start' as an alias however: 'cylc con start'.
control_commands['run'] = ['run', 'start']
control_commands['stop'] = ['stop', 'shutdown']
control_commands['restart'] = ['restart']
control_commands['trigger'] = ['trigger']
control_commands['insert'] = ['insert']
control_commands['remove'] = ['remove']
control_commands['poll'] = ['poll']
control_commands['kill'] = ['kill']
control_commands['hold'] = ['hold']
control_commands['release'] = ['release', 'unhold']
control_commands['reset'] = ['reset']
control_commands['spawn'] = ['spawn']
control_commands['nudge'] = ['nudge']
control_commands['reload'] = ['reload']
control_commands['set-runahead'] = ['set-runahead']
control_commands['set-verbosity'] = ['set-verbosity']
control_commands['broadcast'] = ['broadcast', 'bcast']
control_commands['ext-trigger'] = ['ext-trigger', 'external-trigger']

utility_commands = OrderedDict()
utility_commands['cycle-point'] = [
    'cycle-point', 'cyclepoint', 'datetime', 'cycletime']
utility_commands['random'] = ['random', 'rnd']
utility_commands['scp-transfer'] = ['scp-transfer']
utility_commands['suite-state'] = ['suite-state']
utility_commands['ls-checkpoints'] = ['ls-checkpoints']

hook_commands = OrderedDict()
hook_commands['email-suite'] = ['email-suite']
hook_commands['email-task'] = ['email-task']
hook_commands['job-logs-retrieve'] = ['job-logs-retrieve']
hook_commands['check-triggering'] = ['check-triggering']

admin_commands = OrderedDict()
admin_commands['test-db'] = ['test-db']
admin_commands['test-battery'] = ['test-battery']
admin_commands['import-examples'] = ['import-examples']
admin_commands['upgrade-db'] = ['upgrade-db']
admin_commands['upgrade-run-dir'] = ['upgrade-run-dir']
admin_commands['check-software'] = ['check-software']

license_commands = OrderedDict()
license_commands['warranty'] = ['warranty']
license_commands['conditions'] = ['conditions']

database_commands = OrderedDict()
database_commands['register'] = ['register']
database_commands['reregister'] = ['reregister', 'rename']
database_commands['unregister'] = ['unregister']
database_commands['copy'] = ['copy', 'cp']
database_commands['print'] = ['print']
database_commands['get-directory'] = ['get-directory']
database_commands['refresh'] = ['refresh']

preparation_commands = OrderedDict()
preparation_commands['edit'] = ['edit']
preparation_commands['view'] = ['view']
preparation_commands['validate'] = ['validate']
preparation_commands['5to6'] = ['5to6']
preparation_commands['list'] = ['list', 'ls']
preparation_commands['search'] = ['search', 'grep']
preparation_commands['graph'] = ['graph']
preparation_commands['graph-diff'] = ['graph-diff']
preparation_commands['diff'] = ['diff', 'compare']
preparation_commands['jobscript'] = ['jobscript']

discovery_commands = OrderedDict()
discovery_commands['ping'] = ['ping']
discovery_commands['scan'] = ['scan']
discovery_commands['check-versions'] = ['check-versions']

task_commands = OrderedDict()
task_commands['submit'] = ['submit', 'single']
task_commands['message'] = ['message', 'task-message']
task_commands['jobs-kill'] = ['jobs-kill']
task_commands['jobs-poll'] = ['jobs-poll']
task_commands['jobs-submit'] = ['jobs-submit']
task_commands['job-submit'] = ['job-submit']

all_commands = OrderedDict()
for dct in [
        database_commands,
        preparation_commands,
        information_commands,
        discovery_commands,
        control_commands,
        utility_commands,
        task_commands,
        admin_commands,
        hook_commands,
        license_commands]:
    for com in dct.keys():
        all_commands[com] = dct[com]

general_usage = """
Cylc ("silk") is a suite engine and metascheduler that specializes in
cycling weather and climate forecasting suites and related processing
(but it can also be used for one-off workflows of non-cycling tasks).
For detailed documentation see the Cylc User Guide (cylc doc --help).

Version __CYLC_VERSION__

The graphical user interface for cylc is "gcylc" (a.k.a. "cylc gui").

USAGE:
  % cylc -v,--version                   # print cylc version
  % cylc version                        # (ditto, by command)
  % cylc help,--help,-h,?               # print this help page

  % cylc help CATEGORY                  # print help by category
  % cylc CATEGORY help                  # (ditto)

  % cylc help [CATEGORY] COMMAND        # print command help
  % cylc [CATEGORY] COMMAND help,--help # (ditto)

  % cylc [CATEGORY] COMMAND [options] SUITE [arguments]
  % cylc [CATEGORY] COMMAND [options] SUITE TASK [arguments]"""

# topic summaries
catsum = OrderedDict()
catsum['all'] = "The complete command set."
catsum['admin'] = "Cylc installation, testing, and example suites."
catsum['license'] = "Software licensing information (GPL v3.0)."
catsum['database'] = "Suite name registration, copying, deletion, etc."
catsum['information'] = "Interrogate suite definitions and running suites."
catsum['preparation'] = "Suite editing, validation, visualization, etc."
catsum['discovery'] = "Detect running suites."
catsum['control'] = "Suite start up, monitoring, and control."
catsum['task'] = "The task messaging interface."
catsum['hook'] = "Suite and task event hook scripts."
catsum['utility'] = "Cycle arithmetic and templating, etc."

usage = general_usage + """

Commands and categories can both be abbreviated. Use of categories is
optional, but they organize help and disambiguate abbreviated commands:
  % cylc control trigger SUITE TASK     # trigger TASK in SUITE
  % cylc trigger SUITE TASK             # ditto
  % cylc con trig SUITE TASK            # ditto
  % cylc c t SUITE TASK                 # ditto

CYLC SUITE NAMES AND YOUR REGISTRATION DATABASE
  Suites are addressed by hierarchical names such as suite1, nwp.oper,
nwp.test.LAM2, etc. in a "name registration database" ($HOME/.cylc/REGDB)
that simply associates names with the suite definition locations.  The
'--db=' command option can be used to view and copy suites from other
users, with access governed by normal filesystem permissions.

TASK IDENTIFICATION IN CYLC SUITES
  Tasks are identified by NAME.CYCLE_POINT where POINT is either a
  date-time or an integer.
  Date-time cycle points are in an ISO 8601 date-time format, typically
  CCYYMMDDThhmm followed by a time zone - e.g. 20101225T0600Z.
  Integer cycle points (including those for one-off suites) are integers
  - just '1' for one-off suites.

HOW TO DRILL DOWN TO COMMAND USAGE HELP:
  % cylc help           # list all available categories (this page)
  % cylc help prep      # list commands in category 'preparation'
  % cylc help prep edit # command usage help for 'cylc [prep] edit'

Command CATEGORIES:"""

# Some commands and categories are aliased (db|database, cp|copy) and
# some common typographical errors are corrected (e.g. cycl => cylc).

# command summaries
comsum = OrderedDict()
# admin
comsum['test-db'] = 'Run an automated suite name database test'
comsum['test-battery'] = 'Run a battery of self-diagnosing test suites'
comsum['import-examples'] = 'Import example suites your suite name database'
comsum['upgrade-db'] = 'Upgrade a pre-cylc-5.4 suite name database'
comsum['upgrade-run-dir'] = 'Upgrade a pre-cylc-6 suite run directory'
comsum['check-software'] = 'Check required software is installed.'
# license
comsum['warranty'] = 'Print the GPLv3 disclaimer of warranty'
comsum['conditions'] = 'Print the GNU General Public License v3.0'
# database
comsum['register'] = 'Register a suite for use'
comsum['reregister'] = 'Change the name of a suite'
comsum['unregister'] = 'Unregister and optionally delete suites'
comsum['copy'] = 'Copy a suite or a group of suites'
comsum['print'] = 'Print registered suites'
comsum['get-directory'] = 'Retrieve suite definition directory paths'
comsum['refresh'] = 'Report invalid registrations and update suite titles'
# preparation
comsum['edit'] = 'Edit suite definitions, optionally inlined'
comsum['view'] = 'View suite definitions, inlined and Jinja2 processed'
comsum['validate'] = 'Parse and validate suite definitions'
comsum['5to6'] = 'Improve the cylc 6 compatibility of a cylc 5 suite file'
comsum['search'] = 'Search in suite definitions'
comsum['graph'] = 'Plot suite dependency graphs and runtime hierarchies'
comsum['graph-diff'] = 'Compare two suite dependencies or runtime hierarchies'
comsum['diff'] = 'Compare two suite definitions and print differences'
# information
comsum['list'] = 'List suite tasks and family namespaces'
comsum['dump'] = 'Print the state of tasks in a running suite'
comsum['cat-state'] = 'Print the state of tasks from the state dump'
comsum['show'] = 'Print task state (prerequisites and outputs etc.)'
comsum['cat-log'] = 'Print various suite and task log files'
comsum['documentation'] = 'Display cylc documentation (User Guide etc.)'
comsum['monitor'] = 'An in-terminal suite monitor (see also gcylc)'
comsum['get-suite-config'] = 'Print suite configuration items'
comsum['get-site-config'] = 'Print site/user configuration items'
comsum['get-gui-config'] = 'Print gcylc configuration items'
comsum['get-suite-version'] = 'Print the cylc version of a suite daemon'
comsum['version'] = 'Print the cylc release version'
comsum['gscan'] = 'Scan GUI for monitoring multiple suites'
comsum['gpanel'] = 'Internal interface for GNOME 2 panel applet'
# control
comsum['gui'] = '(a.k.a. gcylc) cylc GUI for suite control etc.'
comsum['run'] = 'Start a suite at a given cycle point'
comsum['stop'] = 'Shut down running suites'
comsum['restart'] = 'Restart a suite from a previous state'
comsum['trigger'] = 'Manually trigger or re-trigger a task'
comsum['insert'] = 'Insert tasks into a running suite'
comsum['remove'] = 'Remove tasks from a running suite'
comsum['poll'] = 'Poll submitted or running tasks'
comsum['kill'] = 'Kill submitted or running tasks'
comsum['hold'] = 'Hold (pause) suites or individual tasks'
comsum['release'] = 'Release (unpause) suites or individual tasks'
comsum['reset'] = 'Force one or more tasks to change state.'
comsum['spawn'] = 'Force one or more tasks to spawn their successors.'
comsum['nudge'] = 'Cause the cylc task processing loop to be invoked'
comsum['reload'] = 'Reload the suite definition at run time'
comsum['set-runahead'] = 'Change the runahead limit in a running suite.'
comsum['set-verbosity'] = 'Change a running suite\'s logging verbosity'
comsum['ext-trigger'] = 'Report an external trigger event to a suite'
# discovery
comsum['ping'] = 'Check that a suite is running'
comsum['scan'] = 'Scan a host for running suites'
comsum['check-versions'] = 'Compare cylc versions on task host accounts'
# task
comsum['submit'] = 'Run a single task just as its parent suite would'
comsum['message'] = '(task messaging) Report task messages'
comsum['broadcast'] = 'Change suite [runtime] settings on the fly'
comsum['jobs-kill'] = '(Internal) Kill task jobs'
comsum['jobs-poll'] = '(Internal) Retrieve status for task jobs'
comsum['jobs-submit'] = '(Internal) Submit task jobs'
comsum['job-submit'] = '(Internal) Submit a job'

# utility
comsum['cycle-point'] = 'Cycle point arithmetic and filename templating'
comsum['random'] = 'Generate a random integer within a given range'
comsum['jobscript'] = 'Generate a task job script and print it to stdout'
comsum['scp-transfer'] = 'Scp-based file transfer for cylc suites'
comsum['suite-state'] = 'Query the task states in a suite'
comsum['ls-checkpoints'] = 'Display task pool etc at given events'

# hook
comsum['email-task'] = 'A task event hook script that sends email alerts'
comsum['email-suite'] = 'A suite event hook script that sends email alerts'
comsum['job-logs-retrieve'] = (
    '(Internal) Retrieve logs from a remote host for a task job')
comsum['check-triggering'] = 'A suite shutdown event hook for cylc testing'


def typo(str):
    corrected = str
    if str == 'gcycl':
        corrected = 'gcylc'
    return corrected


def category_help(category):
    coms = eval(category + '_commands')
    alts = '|'.join(categories[category])
    print 'CATEGORY: ' + alts + ' - ' + catsum[category]
    if category == 'database':
        print "Suite name registrations are held under $HOME/.cylc/REGDB."
    print
    print 'HELP: cylc [' + alts + '] COMMAND help,--help'
    print '  You can abbreviate ' + alts + ' and COMMAND.'
    print '  The category ' + alts + ' may be omitted.'
    print
    print 'COMMANDS:'
    pretty_print(comsum, coms, sort=True)


def set_environment_vars(args):
    """
    Set --env=key=val arguments as environment variables & remove
    from argument list
    """
    regex = re.compile('\A--env=(\S+)=(\S+)\Z')
    for arg in args:
        match = regex.match(arg)
        if match is None:
            continue
        os.environ[match.group(1)] = match.group(2)
    return filter(lambda i: not regex.search(i), args)

# no arguments: print help and exit
if len(sys.argv) == 1:
    from cylc.version import CYLC_VERSION
    print usage.replace("__CYLC_VERSION__", CYLC_VERSION)
    pretty_print(catsum, categories)
    sys.exit(1)

args = sys.argv[1:]

# Set environment variables from arguments like --env=key=val
args = set_environment_vars(args)

if len(args) == 1:
    if args[0] == 'categories':
        # secret argument for document processing
        keys = catsum.keys()
        keys.sort()
        for key in keys:
            print key
        sys.exit(0)
    if args[0] == 'commands':
        # secret argument for document processing
        keys = comsum.keys()
        keys.sort()
        for key in keys:
            print key
        sys.exit(0)
    if args[0].startswith('category='):
        # secret argument for gcylc
        category = args[0][9:]
        commands = eval(category + '_commands')
        for command in commands:
            print command
        sys.exit(0)
    if is_help(args[0]):
        # cylc help
        from cylc.version import CYLC_VERSION
        print usage.replace("__CYLC_VERSION__", CYLC_VERSION)
        pretty_print(catsum, categories)
        sys.exit(0)
    if (args[0] == '-v' or args[0] == '--version'):
        from cylc.version import CYLC_VERSION
        print CYLC_VERSION
        sys.exit(0)

    # cylc CATEGORY with no args => category help
    try:
        category = match_dict(args[0], categories, 'CATEGORY')
    except CommandError, x:
        # No matching category
        # (no need to print this, the exception will recur below)
        # Carry on in case of a no-argument command (e.g. 'cylc scan')
        pass
    else:
        category_help(category)
        sys.exit(0)

command_args = []

if len(args) == 2 and (is_help(args[0]) or is_help(args[1])):
    # TWO ARGUMENTS, one help
    # cylc help CATEGORY
    # cylc CATEGORY help
    # cylc help COMMAND
    # cylc COMMAND help
    if is_help(args[1]):
        item = args[0]
    else:
        item = args[1]
    try:
        category = match_dict(item, categories, 'CATEGORY')
    except CommandError, x:
        # no matching category, try command
        try:
            command = match_command(typo(item))
        except CommandError, y:
            print >> sys.stderr, x
            raise SystemExit(y)
        else:
            # cylc COMMAND --help
            command_args = ['--help']
    else:
        # cylc help CATEGORY
        category_help(category)
        sys.exit(0)

elif len(args) == 3 and (is_help(args[0]) or is_help(args[2])):
    # cylc help CATEGORY COMMAND
    # cylc CATEGORY COMMAND help
    if is_help(args[2]):
        category = args[0]
        command = args[1]
    else:
        category = args[1]
        command = args[2]
    try:
        category = match_dict(category, categories, 'CATEGORY')
    except CommandError, x:
        raise SystemExit(x)

    coms = eval(category + '_commands')
    try:
        command = match_dict(command, coms, category + ' COMMAND')
    except CommandNotUniqueError, y:
        print y
        sys.exit(1)
    except CommandNotFoundError, y:
        print y
        print 'COMMANDS available in CATEGORY "' + category + '":'
        print coms.keys()
        sys.exit(1)

    # cylc COMMAND --help
    command_args = ['--help']

else:
    # two or more args, neither of first two are help
    # cylc CATEGORY COMMAND [ARGS]
    # cylc COMMAND [ARGS]
    try:
        category = args[0]
        category = match_dict(category, categories, 'CATEGORY')
    except CommandError, x:
        # no matching category, try command
        try:
            command = args[0]
            command = match_command(typo(command))
        except CommandError, y:
            print >> sys.stderr, x
            raise SystemExit(y)
        else:
            # cylc COMMAND [ARGS]
            command_args = args[1:]
    else:
        # cylc CATEGORY COMMAND [ARGS]
        coms = eval(category + '_commands')
        command = args[1]
        try:
            command = match_dict(command, coms, category + ' COMMAND')
        except CommandNotUniqueError, y:
            print y
            sys.exit(1)
        except CommandNotFoundError, y:
            print y
            print 'COMMANDS available in CATEGORY "' + category + '":'
            print coms.keys()
            sys.exit(1)

        else:
            # cylc COMMAND [ARGS]
            if len(args) > 1:
                command_args = args[2:]
            else:
                command_args = []

args_new = []
for item in command_args:
    if is_help(item):
        # transform all legal help options to '--help'
        args_new.append('--help')
    elif item.startswith('--owner'):
        # deprecate '--owner' to '--user'
        args_new.append(item.replace('--owner', '--user'))
    else:
        args_new.append(item)
args = args_new

cmd = sys.argv[0] + '-' + command

# Replace the current process with that of the sub-command.
try:
    os.execvp(cmd, [cmd] + args)
except OSError, exc:
    if exc.filename is None:
        exc.filename = cmd
    raise SystemExit(exc)
