#!/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 [discovery] scan [OPTIONS] [HOSTS ...]

Print information about running suites.

By default, it will obtain a listing of running suites for the current user
from the file system, before connecting to the suites to obtain information.
Use the -o/--suite-owner option to get information of running suites for other
users.

If a list of HOSTS is specified, it will obtain a listing of running suites by
scanning all ports in the relevant range for running suites on the specified
hosts. If the -a/--all option is specified, it will use the global
configuration "[suite servers]scan hosts" setting to determine a list of hosts
to scan.

Suite passphrases are not needed to get identity information (name and owner).
Titles, descriptions, state totals, and cycle point state totals may also be
revealed publicly, depending on global and suite authentication settings. Suite
passphrases still grant full access regardless of what is revealed publicly.

WARNING: a suite suspended with Ctrl-Z will cause port scans to hang until the
connection times out (see --comms-timeout)."""

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 json

from cylc.cfgspec.glbl_cfg import glbl_cfg
from cylc.hostuserutil import get_user
from cylc.network.port_scan import (
    get_scan_items_from_fs, re_compile_filters, scan_many)
from cylc.option_parsers import CylcOptionParser as COP
from cylc.suite_status import (
    KEY_DESCRIPTION, KEY_META, KEY_NAME, KEY_OWNER, KEY_STATES,
    KEY_TITLE, KEY_UPDATE_TIME)
from cylc.task_state import TASK_STATUSES_ORDERED
from cylc.task_state_prop import get_status_prop


class TitleFormatter(object):
    """Format title line, in bold or not if .no_bold set to False."""

    def __init__(self, no_bold=False):
        self.no_bold = no_bold

    def __call__(self, line):
        """Add terminal control characters for bold text, if .no_bold=False"""
        if self.no_bold:
            return line
        else:
            return "\033[1m" + line + "\033[0m"


def main():
    """Implement "cylc scan"."""
    parser = COP(
        __doc__,
        comms=True,
        noforce=True,
        argdoc=[(
            "[HOSTS ...]", "Hosts to scan instead of the configured hosts.")]
    )

    parser.add_option(
        "-a", "--all",
        help="Scan all port ranges in known hosts.",
        action="store_true", default=False, dest="all_ports")

    parser.add_option(
        "-n", "--name",
        metavar="PATTERN",
        help="List suites with name matching PATTERN (regular expression). "
             "Defaults to any name. Can be used multiple times.",
        action="append", dest="patterns_name", default=[])

    parser.add_option(
        "-o", "--suite-owner",
        metavar="PATTERN",
        help="List suites with owner matching PATTERN (regular expression). "
             "Defaults to current user. Use '.*' to match all known users. "
             "Can be used multiple times.",
        action="append", dest="patterns_owner", default=[])

    parser.add_option(
        "-d", "--describe",
        help="Print suite metadata if available.",
        action="store_true", default=False, dest="describe")

    parser.add_option(
        "-s", "--state-totals",
        help="Print number of tasks in each state if available "
             "(total, and by cycle point).",
        action="store_true", default=False, dest="print_totals")

    parser.add_option(
        "-f", "--full",
        help="Print all available information about each suite.",
        action="store_true", default=False, dest="print_full")

    parser.add_option(
        "-c", "--color", "--colour",
        help="Print task state summaries using terminal color control codes.",
        action="store_true", default=False, dest="color")

    parser.add_option(
        "-b", "--no-bold",
        help="Don't use any bold text in the command output.",
        action="store_true", default=False, dest="no_bold")

    parser.add_option(
        "--print-ports",
        help="Print the port range from the global config file.",
        action="store_true", default=False, dest="print_ports")

    parser.add_option(
        "--comms-timeout", metavar="SEC",
        help="Set a timeout for network connections "
             "to each running suite. The default is 5 seconds.",
        action="store", default=None, dest="comms_timeout")

    parser.add_option(
        "--old", "--old-format",
        help='Legacy output format ("suite owner host port").',
        action="store_true", default=False, dest="old_format")

    parser.add_option(
        "-r", "--raw", "--raw-format",
        help='Parsable format ("suite|owner|host|property|value").',
        action="store_true", default=False, dest="raw_format")

    parser.add_option(
        "-j", "--json", "--json-format",
        help="JSON format.",
        action="store_true", default=False, dest="json_format"
    )

    options, args = parser.parse_args()

    if options.print_ports:
        run_ports_list = glbl_cfg().get(['suite servers', 'run ports'])
        print run_ports_list[0], '<= port <=', run_ports_list[-1]
        sys.exit(0)

    indent = "   "

    bold = TitleFormatter(options.no_bold)

    if options.print_full:
        options.describe = options.print_totals = True

    if options.color:
        options.print_totals = True

    # Check for incompatible formatting options
    incompatibles = [options.raw_format, options.json_format]
    format_ops = [options.old_format, options.describe, options.print_totals,
                  options.color]
    if incompatibles.count(True) == 2:
        parser.error("--raw and --json are incompatible formatting options.")
    if incompatibles.count(True) == 1 and format_ops.count(True) >= 1:
        parser.error("Incompatible format options: --%s forbidden with any "
                     "of --old-format, --describe, --state-totals or --color."
                     % ("raw" if incompatibles[0] else "json"))

    state_legend = ""
    if options.color:
        n_states = len(TASK_STATUSES_ORDERED)
        for index, state in enumerate(TASK_STATUSES_ORDERED):
            state_legend += get_status_prop(state, 'ascii_ctrl')
            if index == n_states / 2:
                state_legend += "\n"
        state_legend = state_legend.rstrip()

    if options.patterns_name:
        patterns_name = options.patterns_name
    else:
        # Any suite name.
        patterns_name = ['.*']
    patterns_owner = None
    if options.patterns_owner:
        patterns_owner = options.patterns_owner
    # Compile and check "name" and "owner" regular expressions
    try:
        cre_owner, cre_name = re_compile_filters(patterns_owner, patterns_name)
    except ValueError as exc:
        parser.error(str(exc))
    if options.all_ports:
        args.extend(
            glbl_cfg().get(["suite servers", "scan hosts"]) or ["localhost"])
    if not args:
        args = get_scan_items_from_fs(cre_owner)
    if not args:
        return

    json_filter = []
    skip_one = True
    for host, port, suite_identity in scan_many(args, options.comms_timeout):
        name = suite_identity[KEY_NAME]
        owner = suite_identity[KEY_OWNER]

        if cre_name and not cre_name.match(name):
            continue
        if (cre_owner is None and owner != get_user() or
                cre_owner and not cre_owner.match(owner)):
            continue
        if options.json_format:
            json_filter.append([host, port, suite_identity])
            continue

        if options.old_format:
            print name, owner, host, port
            continue

        if options.raw_format or options.describe:
            # Extracting required data for these options before processing
            try:
                meta_items = suite_identity[KEY_META]
            except KeyError:
                # Compat:<=7.5.0
                meta_items = {
                    "title": suite_identity.get(KEY_TITLE),
                    "description": suite_identity.get(KEY_DESCRIPTION)
                }

        if options.raw_format:
            suite_identity.get(KEY_UPDATE_TIME)
            clean_meta_items = {}
            for key, value in meta_items.items():
                if value:
                    clean_meta_items.update({
                        key: ' '.join([x.strip() for x in
                                       str(value).split('\n') if x])})
            totals = suite_identity.get(KEY_STATES)
            print "%s|%s|%s|port|%s" % (name, owner, host, port)
            for key, value in clean_meta_items.items():
                print "%s|%s|%s|%s|%s" % (name, owner, host, key, value)
            if totals is None:
                continue
            for point, state_line in get_point_state_count_lines(*totals):
                key = KEY_STATES
                if point:
                    key = "%s:%s" % (KEY_STATES, point)
                print "%s|%s|%s|%s|%s" % (name, owner, host, key, state_line)
            continue

        line = '%s %s@%s:%s' % (name, owner, host, port)
        if options.describe or options.print_totals:
            if skip_one:
                skip_one = False
                if state_legend != "":
                    print state_legend + "\n"
            else:
                print
            print bold(line)
        else:
            print line

        if options.describe:
            # Deal with title first for case of just withheld statement
            if meta_items["title"] is None:
                print(indent + bold("(description and state totals withheld)"))
                continue
            if not meta_items["title"]:
                print(indent + bold("Title:\n") + indent * 2 + "(no title)")
            else:
                print(indent + bold("Title:\n") + indent * 2 +
                      '"%s"' % meta_items["title"])
            for metaitem, metavalue in meta_items.items():
                if metaitem != "title":
                    if metaitem == "description" or metaitem == "group":
                        print indent + bold(metaitem.capitalize() + ":")
                    else:
                        print indent + bold(metaitem + ":")
                    if not metavalue:
                        lines = "(no %s)" % metaitem
                    else:
                        lines = '"%s"' % metavalue
                    line1 = True
                    for line in lines.split('\n'):
                        line = line.lstrip()
                        if not line1:
                            # Indent under the double quote.
                            line = " " + line
                        line1 = False
                        print indent * 2 + line

        totals = suite_identity.get(KEY_STATES)

        if options.print_totals:
            if totals is None:
                print indent + bold("(state totals withheld)")
                continue
            print indent + bold("Task state totals:")
            point_state_lines = get_point_state_count_lines(
                *totals, use_color=options.color)
            for point, state_line in point_state_lines:
                point_prefix = ""
                if point:
                    point_prefix = "%s " % point
                print indent * 2 + "%s%s" % (point_prefix, state_line)

    if options.json_format:
        print json.dumps(json_filter, indent=4)


def get_point_state_count_lines(state_count_totals, state_count_cycles,
                                use_color=False):
    """Yield (point, state_summary_text) tuples."""
    line = ""
    for state, tot in sorted(state_count_totals.items()):
        if use_color:
            subst = " %d " % tot
            line += get_status_prop(state, 'ascii_ctrl', subst)
        else:
            line += '%s:%d ' % (state, tot)
    yield ("", line.strip())

    for point_string, state_count_cycle in sorted(state_count_cycles.items()):
        line = ""
        for state, tot in sorted(state_count_cycle.items()):
            if use_color:
                subst = " %d " % tot
                line += get_status_prop(state, 'ascii_ctrl', subst)
            else:
                line += '%s:%d ' % (state, tot)
        yield (point_string, line.strip())


if __name__ == "__main__":
    main()
