# Copyright 2017-2021 TensorHub, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import absolute_import
from __future__ import division

import datetime
import inspect
import json
import logging
import os
import re

import six

# IMPORTANT: Keep expensive imports out of this list. This module is
# used by several commands and any latency here will be automatically
# applied to those commands. If the import is used once or twice, move
# it into the applicable function(s). If it's used more than once or
# twice, move the command impl into a separate module (see
# publish_impl for example).

from guild import cli
from guild import cmd_impl_support
from guild import config
from guild import exit_code
from guild import flag_util
from guild import op_util
from guild import remote_run_support
from guild import run as runlib
from guild import run_util
from guild import util
from guild import var
from guild import yaml_util

from . import remote_impl_support

log = logging.getLogger("guild")

RUN_DETAIL = [
    "id",
    "operation",
    "from",
    "status",
    "started",
    "stopped",
    "marked",
    "label",
    "sourcecode_digest",
    "vcs_commit",
    "run_dir",
    "command",
    "exit_status",
    "pid",
]

ALL_RUNS_ARG = [":"]
LATEST_RUN_ARG = ["1"]

CORE_RUN_ATTRS = [
    "cmd",
    "comments",
    "compare",
    "deps",
    "env",
    "exit_status",
    "flags",
    "host",
    "id",
    "initialized",
    "label",
    "marked",
    "objective",
    "op",
    "pip_freeze",
    "platform",
    "random_seed",
    "run_params",
    "sourcecode_digest",
    "started",
    "stopped",
    "tags",
    "user",
    "user_flags",
    "vcs_commit",
]

LEGACY_RUN_ATTRS = [
    "resolved_deps",
    "opdef",
]

RUNS_PER_GROUP = 20

FILTERABLE = [
    ("completed", "status_completed"),
    ("error", "status_error"),
    ("pending", "status_pending"),
    ("running", "status_running"),
    ("staged", "status_staged"),
    ("terminated", "status_terminated"),
]

if not os.getenv("SHELL"):
    # Windows command prompt wants a space buffer to avoid wrapping.
    STYLE_TABLE_WIDTH_ADJ = -1
else:
    STYLE_TABLE_WIDTH_ADJ = 0

STOP_TIMEOUT = 30
CHILD_TERM_TIMEOUT = 5


def runs_for_args(args, ctx=None):
    filtered = filtered_runs(args, ctx)
    return select_runs(filtered, args.runs, ctx)


def filtered_runs(args, ctx=None):
    if getattr(args, "remote", None):
        return remote_impl_support.filtered_runs(args)
    else:
        return var.runs(
            _runs_root_for_args(args),
            sort=["-timestamp"],
            filter=_runs_filter(args, ctx),
        )


def _runs_root_for_args(args):
    archive = getattr(args, "archive", None)
    deleted = getattr(args, "deleted", False)
    if archive and deleted:
        cli.error("--archive and --deleted cannot both be used")
    if archive:
        return archive
    else:
        return var.runs_dir(deleted=deleted)


def _runs_filter(args, ctx):
    filters = []
    _apply_status_filter(args, filters)
    _apply_ops_filter(args, filters)
    _apply_labels_filter(args, filters)
    _apply_tags_filter(args, filters)
    _apply_comments_filter(args, filters)
    _apply_marked_filter(args, filters)
    _apply_started_filter(args, ctx, filters)
    _apply_sourcecode_digest_filter(args, filters)
    return var.run_filter("all", filters)


def _apply_status_filter(args, filters):
    status_filters = [
        var.run_filter("attr", "status", status)
        for status, args_attr in FILTERABLE
        if getattr(args, args_attr, False)
    ]
    if status_filters:
        filters.append(var.run_filter("any", status_filters))


def _apply_ops_filter(args, filters):
    if args.filter_ops:
        filters.append(_op_run_filter(args.filter_ops))


def _op_run_filter(op_refs):
    def f(run):
        opspec = run_util.format_operation(run, nowarn=True)
        return any((_compare_op(ref, opspec) for ref in op_refs))

    return f


def _compare_op(ref, opspec):
    if ref.startswith("^") or ref.endswith("$"):
        return _re_match(ref, opspec)
    else:
        return _opspec_match(ref, opspec)


def _re_match(pattern, target):
    try:
        return re.search(pattern, target)
    except re.error:
        return False


def _opspec_match(ref, opspec):
    ref_parts = _split_opspec(ref)
    opspec_parts = _split_opspec(opspec)
    assert len(ref_parts) == 3 and len(opspec_parts) == 3, (ref_parts, opspec_parts)
    for ref_part, opspec_part in zip(ref_parts, opspec_parts):
        if not _opspec_part_match(ref_part, opspec_part):
            return False
    return True


def _split_opspec(opspec):
    parsed = op_util.parse_opspec(opspec)
    if parsed:
        model, op = parsed
        pkg, model = _split_model_pkg(model)
        return pkg, model, op
    return None, None, None


def _split_model_pkg(model):
    if model:
        parts = model.split("/", 1)
        if len(parts) == 2:
            return parts
    return None, model


def _opspec_part_match(ref, part):
    if not ref:
        return True
    if not part:
        return False
    if "*" in ref:
        return _opspec_part_fnmatch(ref, part)
    else:
        return ref == part


def _opspec_part_fnmatch(ref, part):
    from fnmatch import fnmatch

    return fnmatch(part, ref)


def _apply_labels_filter(args, filters):
    if args.filter_labels and args.filter_unlabeled:
        cli.error("--label and --unlabeled cannot both be used")
    if args.filter_labels:
        filters.append(_labels_filter(args.filter_labels))
    elif args.filter_unlabeled:
        filters.append(_unlabeled_filter())


def _labels_filter(filter_vals):
    def f(run):
        run_label = str(run.get("label", "")).strip()
        return any((_match_label(s, run_label) for s in filter_vals))

    return f


def _match_label(s, run_label):
    if s == "-":
        return not run_label
    return s in run_label


def _unlabeled_filter():
    def f(run):
        return not run.get("label", "").strip()

    return f


def _apply_tags_filter(args, filters):
    if args.filter_tags:
        filters.append(_tags_filter(args.filter_tags))


def _tags_filter(tags):
    def f(run):
        run_tags = run.get("tags") or []
        return any((t in run_tags for t in tags))

    return f


def _apply_comments_filter(args, filters):
    if args.filter_comments:
        filters.append(_comments_filter(args.filter_comments))


def _comments_filter(filter_vals):
    def f(run):
        comment_text = _run_comments_text(run)
        return any((_match_comments(s, comment_text) for s in filter_vals))

    return f


def _run_comments_text(run):
    comments = run.get("comments") or []
    return "\n".join([_run_comment_filter_text(comment) for comment in comments])


def _run_comment_filter_text(comment):
    return "\n".join(
        [
            (comment.get("user") or "").lower(),
            (comment.get("host") or "").lower(),
            (comment.get("body") or "").lower(),
        ]
    )


def _match_comments(s, comment_text):
    if s == "-":
        return not comment_text
    return s.lower() in comment_text


def _apply_marked_filter(args, filters):
    if args.filter_marked and args.filter_unmarked:
        cli.error("--marked and --unmarked cannot both be used")
    if args.filter_marked:
        filters.append(_marked_filter())
    if args.filter_unmarked:
        filters.append(_marked_filter(False))


def _marked_filter(test_for=True):
    def f(run):
        marked = bool(run.get("marked"))
        return marked if test_for is True else not marked

    return f


def _apply_started_filter(args, ctx, filters):
    if args.filter_started:
        start, end = _parse_timerange(args.filter_started, ctx)
        log.debug("time range filter: %s to %s", start, end)
        filters.append(_started_filter(start, end))


def _parse_timerange(spec, ctx):
    from guild import timerange

    try:
        return timerange.parse_spec(spec)
    except ValueError as e:
        cli.error("invalid RANGE: %s%s" % (e, _range_help_suffix(ctx)))


def _apply_sourcecode_digest_filter(args, filters):
    if args.filter_digest:
        filters.append(_digest_filter(args.filter_digest))


def _digest_filter(prefix):
    def f(run):
        return run.get("sourcecode_digest", "").startswith(prefix)

    return f


def _range_help_suffix(ctx):
    if not ctx:
        return ""
    return "\nTry '%s --help' for help specifying time ranges." % ctx.command_path


def _started_filter(start, end):
    def f(run):
        started = run.timestamp
        if not started:
            log.debug("%s no timestamp, skipping", run.id)
            return False
        started = datetime.datetime.fromtimestamp(started // 1000000)
        if start and started < start:
            log.debug("%s timestamp %s < %s, skipping", run.id, started, start)
            return False
        if end and started >= end:
            log.debug("%s timestamp %s >= %s, skipping", run.id, started, start)
            return False
        log.debug("%s timestamp %s in range", run.id, started)
        return True

    return f


def select_runs(runs, select_specs, ctx=None):
    if not select_specs:
        return runs
    selected = []
    for spec in select_specs:
        try:
            slice_start, slice_end = _parse_slice(spec)
        except ValueError:
            selected.append(_find_run_by_id(spec, runs, ctx))
        else:
            if _in_range(slice_start, slice_end, runs):
                selected.extend(runs[slice_start:slice_end])
            else:
                selected.append(_find_run_by_id(spec, runs, ctx))
    return selected


def _parse_slice(spec):
    try:
        index = int(spec)
    except ValueError:
        m = re.match("(\\d+)?:(\\d+)?", spec)
        if m:
            try:
                return (_slice_part(m.group(1), decr=True), _slice_part(m.group(2)))
            except ValueError:
                pass
        raise ValueError(spec)
    else:
        return index - 1, index


def _slice_part(s, decr=False):
    if s is None:
        return None
    elif decr:
        return int(s) - 1
    else:
        return int(s)


def _find_run_by_id(id_part, runs, ctx):
    matches = [run for run in runs if run.id.startswith(id_part)]
    return cmd_impl_support.one_run(matches, id_part, ctx)


def _in_range(slice_start, slice_end, l):
    return (slice_start is None or slice_start >= 0) and (
        slice_end is None or slice_end <= len(l)
    )


def list_runs(args, ctx=None):
    if args.remote:
        remote_impl_support.list_runs(args)
    else:
        _check_list_runs_args(args, ctx)
        _list_runs(args, ctx)


def _check_list_runs_args(args, ctx):
    cmd_impl_support.check_incompatible_args(
        [
            ("comments", "verbose"),
            ("comments", "json"),
            ("json", "verbose"),
            ("archive", "deleted"),
        ],
        args,
        ctx,
    )


def _list_runs(args, ctx):
    if args.archive and not os.path.exists(args.archive):
        cli.error("%s does not exist" % args.archive)
    runs = filtered_runs(args, ctx=ctx)
    if args.comments:
        _list_runs_comments(_limit_runs(runs, args), comment_index_format=False)
    elif args.json:
        if args.limit or args.more or args.all:
            cli.note("--json option always shows all runs")
        _list_runs_json(runs)
    else:
        _list_runs_(_limit_runs(runs, args), args)


def _list_runs_json(runs):
    runs_data = [_listed_run_json_data(run) for run in runs]
    cli.out(json.dumps(runs_data))


def _listed_run_json_data(run):
    run_data = _run_data(
        run,
        (
            "exit_status",
            "cmd",
            "comments",
            "marked",
            "label",
            "started",
            "status",
            "stopped",
            "tags",
        ),
    )
    _apply_batch_proto(run, run_data)
    return run_data


def _run_data(run, attrs):
    data = {
        "id": run.id,
        "run_dir": run.path,
        "opref": str(run.opref) if run.opref else "",
    }
    data.update({name: _run_attr(run, name) for name in attrs})
    return data


def _run_attr(run, name):
    base_attrs = ("status",)
    if name in base_attrs:
        return getattr(run, name)
    else:
        return run.get(name)


def _apply_batch_proto(run, data):
    proto_dir = run.guild_path("proto")
    if os.path.exists(proto_dir):
        proto = runlib.for_dir(proto_dir)
        data["batch_proto"] = _listed_run_json_data(proto)


def _list_runs_(runs, args):
    formatted = format_runs(_limit_runs(runs, args))
    cols = [
        "index",
        "op_desc",
        "started",
        "status_with_remote",
        "label",
    ]
    detail = RUN_DETAIL if args.verbose else None
    cli.table(formatted, cols=cols, detail=detail, max_width_adj=STYLE_TABLE_WIDTH_ADJ)


def _limit_runs(runs, args):
    if args.all:
        if args.limit is not None:
            cli.error("--all and --limit cannot both be used")
        return runs
    if args.limit and args.limit > 0:
        return runs[: args.limit]
    limited = runs[: (args.more + 1) * RUNS_PER_GROUP]
    if len(limited) < len(runs):
        cli.note(
            "Showing the first %i runs (%i total) - use --all "
            "to show all or -m to show more" % (len(limited), len(runs))
        )
    return limited


def format_runs(runs):
    formatted = []
    for i, run in enumerate(runs):
        try:
            formatted_run = run_util.format_run(run, i + 1)
        except Exception:
            log.exception("formatting run in %s", run.path)
        else:
            formatted.append(formatted_run)
    _apply_op_desc(formatted)
    return formatted


def _apply_op_desc(formatted):
    for fmt_run in formatted:
        op_desc = _op_desc_base(fmt_run)
        marked_suffix = " [marked]" if fmt_run["marked"] == "yes" else ""
        fmt_run["op_desc"] = op_desc + marked_suffix


def _op_desc_base(fmt_run, apply_style=True):
    op = fmt_run["operation"]
    op_dir = _run_op_dir(fmt_run["_run"])
    if not op_dir:
        return _empty_style(op, apply_style)
    return "%s%s" % (op, _styled_op_dir_suffix(op_dir, apply_style))


def _run_op_dir(run):
    run = run.batch_proto or run
    opref = run.opref
    if opref.pkg_type == "guildfile":
        return os.path.dirname(opref.pkg_name)
    elif opref.pkg_type == "script":
        return opref.pkg_name
    else:
        return None


def _empty_style(s, apply_style):
    # Pad a string with an empty style for alignment in tables.
    if apply_style:
        return s + cli.style("", dim=True)
    return s


def _styled_op_dir_suffix(op_dir, apply_style):
    cwd = os.path.abspath(config.cwd())
    if util.compare_paths(op_dir, cwd):
        return _empty_style("", apply_style)
    shortened_op_dir = run_util.shorten_op_dir(op_dir, cwd)
    return _dim_style(" (%s)" % shortened_op_dir, apply_style)


def _dim_style(s, apply_style):
    if apply_style:
        return cli.style(s, dim=True)
    return s


def format_run(run):
    formatted = format_runs([run])
    if not formatted:
        raise ValueError("error formatting %s" % run)
    assert len(formatted) == 1, formatted
    return formatted[0]


def _no_selected_runs_exit(help_msg=None):
    help_msg = (
        help_msg or "No matching runs\n" "Try 'guild runs list' to list available runs."
    )
    cli.out(help_msg, err=True)
    raise SystemExit(0)


def runs_op(
    args,
    ctx,
    preview_msg,
    confirm_prompt,
    no_runs_help,
    op_callback,
    default_runs_arg=None,
    confirm_default=False,
    runs_callback=None,
):
    get_selected = runs_callback or runs_op_selected
    selected = get_selected(args, ctx, default_runs_arg)
    if not selected:
        _no_selected_runs_exit(no_runs_help)
    formatted = None  # expensive, lazily init as needed
    if not args.yes:
        formatted = formatted = format_runs(selected)
        cli.out(preview_msg, err=True)
        cols = [
            "short_index",
            "op_desc",
            "started",
            "status_with_remote",
            "label",
        ]
        cli.table(formatted, cols=cols, indent=2, err=True)
    fmt_confirm_prompt = confirm_prompt.format(count=len(selected))
    if not args.yes and not cli.confirm(fmt_confirm_prompt, confirm_default):
        raise SystemExit(exit_code.ABORTED)
    # pylint: disable=deprecated-method
    if len(inspect.getargspec(op_callback).args) == 2:
        formatted = formatted = format_runs(selected)
        op_callback(selected, formatted)
    else:
        op_callback(selected)


def runs_op_selected(args, ctx, default_runs_arg=None):
    default_runs_arg = default_runs_arg or ALL_RUNS_ARG
    runs_arg = _remove_duplicates(args.runs or default_runs_arg)
    filtered = filtered_runs(args, ctx)
    return select_runs(filtered, runs_arg, ctx)


def _remove_duplicates(vals):
    deduped = []
    for val in vals:
        if val not in deduped:
            deduped.append(val)
    return deduped


def delete_runs(args, ctx=None):
    if args.remote:
        remote_impl_support.delete_runs(args)
    else:
        _delete_runs(args, ctx)


def _delete_runs(args, ctx):
    if args.permanent:
        preview = cmd_impl_support.format_warn(
            "WARNING: You are about to permanently delete the following runs:"
        )
        confirm = "Permanently delete {count} run(s)?"
    else:
        preview = "You are about to delete the following runs:"
        confirm = "Delete {count} run(s)?"
    no_runs_help = "Nothing to delete."

    def delete(selected):
        stoppable = [
            run for run in selected if run.status == "running" and not run.remote
        ]
        if stoppable and not args.yes:
            cli.out(
                cmd_impl_support.format_warn(
                    "WARNING: One or more runs are still running "
                    "and will be stopped before being deleted."
                ),
                err=True,
            )
            if not cli.confirm("Really delete these runs?"):
                raise SystemExit(exit_code.ABORTED)
        for run in stoppable:
            _stop_run(run, no_wait=True)
        var.delete_runs(selected, args.permanent)
        if args.permanent:
            cli.out("Permanently deleted %i run(s)" % len(selected), err=True)
        else:
            cli.out("Deleted %i run(s)" % len(selected), err=True)

    runs_op(
        args,
        ctx,
        preview,
        confirm,
        no_runs_help,
        delete,
        confirm_default=not args.permanent,
    )


def purge_runs(args, ctx):
    if args.remote:
        remote_impl_support.purge_runs(args)
    else:
        _purge_runs(args, ctx)


def _purge_runs(args, ctx):
    preview = cmd_impl_support.format_warn(
        "WARNING: You are about to permanently delete the following runs:"
    )
    confirm = "Permanently delete {count} run(s)?"
    no_runs_help = "Nothing to purge."

    def purge(selected):
        var.purge_runs(selected)
        cli.out("Permanently deleted %i run(s)" % len(selected), err=True)

    runs_op(args.copy(deleted=True), ctx, preview, confirm, no_runs_help, purge)


def restore_runs(args, ctx):
    if args.remote:
        remote_impl_support.restore_runs(args)
    else:
        _restore_runs(args, ctx)


def _restore_runs(args, ctx):
    preview = "You are about to restore the following runs:"
    confirm = "Restore {count} run(s)?"
    no_runs_help = "Nothing to restore."

    def restore(selected):
        var.restore_runs(selected)
        cli.out("Restored %i run(s)" % len(selected), err=True)

    runs_op(
        args.copy(deleted=True),
        ctx,
        preview,
        confirm,
        no_runs_help,
        restore,
        confirm_default=True,
    )


def run_info(args, ctx):
    if args.remote:
        remote_impl_support.run_info(args)
    else:
        _run_info(args, ctx)


def _run_info(args, ctx):
    run = one_run(args, ctx)
    _print_run_info(run, args)


def one_run(args, ctx):
    filtered = filtered_runs(args, ctx=ctx)
    if not filtered:
        cli.error("no matching runs")
    runspec = args.run or "1"
    selected = select_runs(filtered, [runspec], ctx)
    return cmd_impl_support.one_run(selected, runspec, ctx)


def _print_run_info(run, args):
    data = _run_info_data(run, args)
    if args.json:
        _print_run_info_json(data)
    else:
        _print_run_info_ordered(data)


def _run_info_data(run, args):
    data = []
    _append_attr_data(run, args.private_attrs, data)
    data.append(("tags", run.get("tags") or []))
    data.append(("flags", run.get("flags") or {}))
    proto = run.batch_proto
    if proto:
        data.append(("proto-flags", proto.get("flags") or {}))
    data.append(("scalars", _scalar_info(run, args)))
    if args.comments:
        data.append(("comments", _format_comments_for_run_info(run)))
    if args.env:
        data.append(("environment", run.get("env") or {}))
    if args.deps:
        data.append(("dependencies", run.get("deps") or {}))
    if args.private_attrs and args.json:
        _maybe_append_proto_data(run, data)
    return data


def _format_comments_for_run_info(run):
    return [
        _format_comment_for_run_info(comment) for comment in (run.get("comments") or [])
    ]


def _format_comment_for_run_info(comment):
    if not isinstance(comment, dict):
        return repr(comment)
    return {
        "user": comment.get("user") or "",
        "host": comment.get("host") or "",
        "time": util.format_timestamp(comment.get("time")),
        "body": (comment.get("body") or "").strip(),
    }


def _append_attr_data(run, include_private, data):
    fmt_run = format_run(run)
    for name in RUN_DETAIL:
        data.append((name, fmt_run[name]))
    for name in other_attr_names(run, include_private):
        data.append((name, run_util.format_attr(run.get(name))))
    if include_private:
        data.append(("opref", str(run.opref)))
        data.append(("op", run.get("op")))


def other_attr_names(run, include_private=False):
    core_attrs = CORE_RUN_ATTRS + LEGACY_RUN_ATTRS
    if include_private:
        include = lambda x: x not in core_attrs
    else:
        include = lambda x: x[0] != "_" and x not in core_attrs
    return [name for name in sorted(run.attr_names()) if include(name)]


def _scalar_info(run, args):
    try:
        return _scalar_info_(run, args)
    except Exception as e:
        if log.getEffectiveLevel() <= logging.DEBUG:
            log.exception("get scalars")
        return cmd_impl_support.format_warn("ERROR: %s" % e)


def _scalar_info_(run, args):
    return {
        key: val
        for key, val in _iter_scalars(run, args)
        if args.all_scalars or filter_default_scalar(key)
    }


def filter_default_scalar(key):
    _prefix, tag = _split_scalar_key(key)
    return not tag.startswith("sys/")


def _split_scalar_key(key):
    parts = key.split("#", 1)
    return ("", parts[0]) if len(parts) == 1 else (parts[0], parts[1])


def _iter_scalars(run, args):
    from guild import index as indexlib  # expensive

    for s in indexlib.iter_run_scalars(run):
        key = run_util.run_scalar_key(s)
        if args.all_scalars:
            yield key, _scalar_vals(s, args)
        else:
            yield key, _scalar_last_val(s, args)


def _scalar_vals(s, args):
    return {
        "first": _scalar_val(s, "first_val", "first_step", args.json),
        "last": _scalar_val(s, "last_val", "last_step", args.json),
        "min": _scalar_val(s, "min_val", "min_step", args.json),
        "max": _scalar_val(s, "max_val", "min_step", args.json),
        "avg": _scalar_val(s, "avg_val", "count", args.json),
        "total": _scalar_val(s, "total", "count", args.json),
    }


def _scalar_last_val(s, args):
    return _scalar_val(s, "last_val", "last_step", args.json)


def _scalar_val(s, val_key, step_key, format_json):
    val = s[val_key]
    step = s[step_key]
    if format_json:
        return val, step
    else:
        return _format_scalar_val(val, step)


def _format_scalar_val(val, step):
    if isinstance(val, float):
        return "%f (step %i)" % (val, step)
    # Defensive here - val should None but we don't assert because
    # this is a summary op.
    val = "nan" if val is None else val
    return "%s (step %i)" % (val, step)


def _comments_info(run, args):
    return [
        _format_comment_info(comment, args) for comment in run.get("comments") or []
    ]


def _format_comment_info(comment, args):
    if args.json:
        return comment
    return "%s %s\n%s" % (
        _format_comment_user(comment),
        util.format_timestamp(comment.get("time")),
        comment.get("body") or "",
    )


def _res_sources_paths(sources):
    paths = []
    for source_paths in sources.values():
        paths.extend(source_paths)
    return sorted(paths)


def _maybe_append_proto_data(run, data):
    proto = run.batch_proto
    if proto:
        proto_data = []
        _append_attr_data(proto, True, proto_data)
        data.append(("proto-run", proto_data))


def _print_run_info_json(data):
    data = _tuple_lists_to_dict(data)
    cli.out(json.dumps(data))


def _tuple_lists_to_dict(data):
    if isinstance(data, list):
        if data and isinstance(data[0], tuple):
            return {name: _tuple_lists_to_dict(val) for name, val in data}
        else:
            return [_tuple_lists_to_dict(val) for val in data]
    else:
        return data


def _print_run_info_ordered(data):
    for name, val in data:
        if isinstance(val, list):
            _print_run_info_list(name, val)
        elif isinstance(val, dict):
            _print_run_info_dict(name, val)
        else:
            cli.out("%s: %s" % (name, val))


def _print_run_info_list(name, val):
    cli.out("%s:" % name)
    for item in val:
        if isinstance(item, dict):
            cli.out("  -")
            for item_name, item_val in sorted(item.items()):
                encoded = _fix_quoted_string(flag_util.encode_flag_val(item_val))
                if "\n" in encoded:
                    cli.out(_indent("%s: |" % item_name, 4))
                    cli.out(_indent(_unindent(encoded), 6))
                else:
                    cli.out(_indent("%s: %s" % (item_name, encoded), 4))
        else:
            cli.out("  - %s" % flag_util.encode_flag_val(item))


def _print_run_info_dict(name, val):
    cli.out("%s:" % name)
    for item_name, item_val in _sort_run_info_attr(name, val):
        if isinstance(item_val, list):
            cli.out("  %s:" % item_name)
            for item_item in item_val:
                cli.out("    - %s" % flag_util.encode_flag_val(item_item))
        elif isinstance(item_val, dict):
            cli.out("  %s:" % item_name)
            # Use full YAML formatting for config blocks.
            cli.out(_indent(yaml_util.encode_yaml(item_val), 4))
        else:
            cli.out("  %s: %s" % (item_name, flag_util.encode_flag_val(item_val)))


def _sort_run_info_attr(name, val):
    if name == "scalars":
        return _sort_run_info_scalars(val)
    else: