#!/usr/bin/env python2

# THIS FILE IS PART OF THE CYLC SUITE ENGINE.
# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors.
#
# 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/>.

"""cylc [control] broadcast|bcast [OPTIONS] REG

Override [runtime] config in targeted namespaces in a running suite.

Uses for broadcast include making temporary changes to task behaviour,
and task-to-downstream-task communication via environment variables.

A broadcast can target any [runtime] namespace for all cycles or for a
specific cycle.  If a task is affected by specific-cycle and all-cycle
broadcasts at once, the specific takes precedence. If a task is affected
by broadcasts to multiple ancestor namespaces, the result is determined
by normal [runtime] inheritance. In other words, it follows this order:

all:root -> all:FAM -> all:task -> tag:root -> tag:FAM -> tag:task

Broadcasts persist, even across suite restarts, until they expire when
their target cycle point is older than the oldest current in the suite,
or until they are explicitly cancelled with this command.  All-cycle
broadcasts do not expire.

For each task the final effect of all broadcasts to all namespaces is
computed on the fly just prior to job submission.  The --cancel and
--clear options simply cancel (remove) active broadcasts, they do not
act directly on the final task-level result. Consequently, for example,
you cannot broadcast to "all cycles except Tn" with an all-cycle
broadcast followed by a cancel to Tn (there is no direct broadcast to Tn
to cancel); and you cannot broadcast to "all members of FAMILY except
member_n" with a general broadcast to FAMILY followed by a cancel to
member_n (there is no direct broadcast to member_n to cancel).

To broadcast a variable to all tasks (quote items with internal spaces):
  % cylc broadcast -s "[environment]VERSE = the quick brown fox" REG
To do the same with a file:
  % cat >'broadcast.rc' <<'__RC__'
  % [environment]
  %     VERSE = the quick brown fox
  % __RC__
  % cylc broadcast -F 'broadcast.rc' REG
To cancel the same broadcast:
  % cylc broadcast --cancel "[environment]VERSE" REG
If -F FILE was used, the same file can be used to cancel the broadcast:
  % cylc broadcast -G 'broadcast.rc' REG

Use -d/--display to see active broadcasts. Multiple --cancel options or
multiple --set and --set-file options can be used on the same command line.
Multiple --set and --set-file options are cumulative.

The --set-file=FILE option can be used when broadcasting multiple values, or
when the value contains newline or other metacharacters. If FILE is "-", read
from standard input.

Broadcast cannot change [runtime] inheritance.

See also 'cylc reload' - reload a modified suite definition at run time."""

import sys
if '--use-ssh' in sys.argv[1:]:
    sys.argv.remove('--use-ssh')
    from cylc.remote import remrun
    if remrun():
        sys.exit(0)

import re
from tempfile import NamedTemporaryFile

from cylc.broadcast_report import (
    get_broadcast_change_report, get_broadcast_bad_options_report)
from cylc.cfgspec.suite import SPEC, upg
from cylc.cfgvalidate import cylc_config_validate
import cylc.flags
from cylc.network.httpclient import SuiteRuntimeServiceClient
from cylc.option_parsers import CylcOptionParser as COP
from cylc.print_tree import print_tree
from cylc.task_id import TaskID
from parsec.config import ParsecConfig


REC_ITEM = re.compile(r'^\[([^\]]*)\](.*)$')


def get_padding(settings, level=0, padding=0):
    """Return the left padding for displaying a setting."""
    level += 1
    for key, val in settings.items():
        tmp = level * 2 + len(key)
        if tmp > padding:
            padding = tmp
        if isinstance(val, dict):
            padding = get_padding(val, level, padding)
    return padding


def get_rdict(left, right=None):
    """Check+transform left=right into a nested dict.

    left can be key, [key], [key1]key2, [key1][key2], [key1][key2]key3, etc.
    """
    if left == "inherit":
        raise ValueError(
            "ERROR: Inheritance cannot be changed by broadcast")
    rdict = {}
    cur_dict = rdict
    tail = left
    while tail:
        match = REC_ITEM.match(tail)
        if match:
            sect, tail = match.groups()
            if tail:
                # [sect]... = right
                cur_dict[sect.strip()] = {}
                cur_dict = cur_dict[sect.strip()]
            else:
                # [sect] = right
                cur_dict[sect.strip()] = right
        else:
            # item = right
            cur_dict[tail.strip()] = right
            tail = None
    upg({'runtime': {'__MANY__': rdict}}, 'test')
    cylc_config_validate(rdict, SPEC['runtime']['__MANY__'])
    return rdict


def files_to_settings(settings, setting_files, cancel_mode=False):
    """Parse setting files, and append to settings."""
    cfg = ParsecConfig(
        SPEC['runtime']['__MANY__'], validator=cylc_config_validate)
    for setting_file in setting_files:
        if setting_file == '-':
            with NamedTemporaryFile() as handle:
                handle.write(sys.stdin.read())
                handle.seek(0, 0)
                cfg.loadcfg(handle.name)
        else:
            cfg.loadcfg(setting_file)
    stack = [([], cfg.get(sparse=True))]
    while stack:
        keys, item = stack.pop()
        if isinstance(item, dict):
            for key, value in item.items():
                stack.append((keys + [key], value))
        else:
            settings.append({})
            cur_setting = settings[-1]
            while keys:
                key = keys.pop(0)
                if keys:
                    cur_setting[key] = {}
                    cur_setting = cur_setting[key]
                elif cancel_mode:
                    cur_setting[key] = None
                else:
                    cur_setting[key] = item


def main():
    """CLI for "cylc broadcast"."""
    parser = COP(__doc__, comms=True)

    parser.add_option(
        "-p", "--point", metavar="CYCLE_POINT",
        help="Target cycle point. More than one can be added. "
             "Defaults to '*' with --set and --cancel, "
             "and nothing with --clear.",
        action="append", dest="point_strings", default=[])

    parser.add_option(
        "-n", "--namespace", metavar="NAME",
        help="Target namespace. Defaults to 'root' with "
             "--set and --cancel, and nothing with --clear.",
        action="append", dest="namespaces", default=[])

    parser.add_option(
        "-s", "--set", metavar="[SEC]ITEM=VALUE",
        help="A [runtime] config item and value to broadcast.",
        action="append", dest="settings", default=[])

    parser.add_option(
        "-F", "--set-file", "--file", metavar="FILE",
        help="File with config to broadcast. Can be used multiple times.",
        action="append", dest="setting_files", default=[])

    parser.add_option(
        "-c", "--cancel", metavar="[SEC]ITEM",
        help="An item-specific broadcast to cancel.",
        action="append", dest="cancel", default=[])

    parser.add_option(
        "-G", "--cancel-file", metavar="FILE",
        help="File with broadcasts to cancel. Can be used multiple times.",
        action="append", dest="cancel_files", default=[])

    parser.add_option(
        "-C", "--clear",
        help="Cancel all broadcasts, or with -p/--point, "
             "-n/--namespace, cancel all broadcasts to targeted "
             "namespaces and/or cycle points. Use \"-C -p '*'\" "
             "to cancel all all-cycle broadcasts without canceling "
             "all specific-cycle broadcasts.",
        action="store_true", dest="clear", default=False)

    parser.add_option(
        "-e", "--expire", metavar="CYCLE_POINT",
        help="Cancel any broadcasts that target cycle "
             "points earlier than, but not inclusive of, CYCLE_POINT.",
        action="store", default=None, dest="expire")

    parser.add_option(
        "-d", "--display",
        help="Display active broadcasts.",
        action="store_true", default=False, dest="show")

    parser.add_option(
        "-k", "--display-task", metavar="TASKID",
        help="Print active broadcasts for a given task "
             "(" + TaskID.SYNTAX + ").",
        action="store", default=None, dest="showtask")

    parser.add_option(
        "-b", "--box",
        help="Use unicode box characters with -d, -k.",
        action="store_true", default=False, dest="unicode")

    parser.add_option(
        "-r", "--raw",
        help="With -d/--display or -k/--display-task, write out "
             "the broadcast config structure in raw Python form.",
        action="store_true", default=False, dest="raw")

    options, args = parser.parse_args()
    suite = args[0]

    pclient = SuiteRuntimeServiceClient(
        suite, options.owner, options.host, options.port,
        options.comms_timeout, my_uuid=options.set_uuid,
        print_uuid=options.print_uuid)

    if options.show or options.showtask:
        if options.showtask:
            try:
                TaskID.split(options.showtask)
            except ValueError:
                parser.error("TASKID must be " + TaskID.SYNTAX)
        settings = pclient.get_broadcast(task_id=options.showtask)
        padding = get_padding(settings) * ' '
        if options.raw:
            print str(settings)
        else:
            print_tree(settings, padding, options.unicode)
        sys.exit(0)

    if options.clear:
        modified_settings, bad_options = pclient.clear_broadcast({
            "point_strings": options.point_strings,
            "namespaces": options.namespaces})
        if modified_settings:
            print get_broadcast_change_report(
                modified_settings, is_cancel=True)
        sys.exit(get_broadcast_bad_options_report(bad_options))

    if options.expire:
        modified_settings, bad_options = pclient.expire_broadcast(
            cutoff=options.expire)
        if modified_settings:
            print get_broadcast_change_report(
                modified_settings, is_cancel=True)
        sys.exit(get_broadcast_bad_options_report(bad_options))

    # implement namespace and cycle point defaults here
    namespaces = options.namespaces
    if not namespaces:
        namespaces = ["root"]
    point_strings = options.point_strings
    if not point_strings:
        point_strings = ["*"]

    if options.cancel or options.cancel_files:
        settings = []
        for option_item in options.cancel:
            if "=" in option_item:
                raise ValueError(
                    "ERROR: --cancel=[SEC]ITEM does not take a value")
            option_item = option_item.strip()
            setting = get_rdict(option_item)
            settings.append(setting)
        files_to_settings(settings, options.cancel_files, options.cancel)
        modified_settings, bad_options = pclient.clear_broadcast({
            "point_strings": point_strings, "namespaces": namespaces,
            "cancel_settings": settings})
        if modified_settings:
            print get_broadcast_change_report(
                modified_settings, is_cancel=True)
        sys.exit(get_broadcast_bad_options_report(bad_options))

    if options.settings or options.setting_files:
        settings = []
        for option_item in options.settings:
            if "=" not in option_item:
                raise ValueError(
                    "ERROR: --set=[SEC]ITEM=VALUE requires a value")
            lhs, rhs = [s.strip() for s in option_item.split("=", 1)]
            setting = get_rdict(lhs, rhs)
            settings.append(setting)
        files_to_settings(settings, options.setting_files)
        modified_settings, bad_options = pclient.put_broadcast({
            "point_strings": point_strings, "namespaces": namespaces,
            "settings": settings})
        print get_broadcast_change_report(modified_settings)
        sys.exit(get_broadcast_bad_options_report(bad_options, is_set=True))


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        if cylc.flags.debug:
            raise
        sys.exit(str(exc))
