# Copyright 2017-2023 Posit Software, PBC
#
# 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.

import logging

import click

from guild import click_util

from . import ac_support

log = logging.getLogger("guild")


def ac_run(ctx, param, incomplete):
    if ctx.params.get("remote"):
        return []
    # ensure that other_run follows the same logic as run, without needing to make
    #    that logic know about other_run
    if param.name == "other_run":
        ctx.params["run"] = ctx.params["other_run"]
    runs = runs_for_ctx(ctx)
    return sorted([run.id for run in runs if run.id.startswith(incomplete)])


def ac_local_run(ctx, _param, incomplete):
    runs = runs_for_ctx(ctx)
    return sorted([run.id for run in runs if run.id.startswith(incomplete)])


def runs_for_ctx(ctx):
    from guild import config
    from . import runs_impl

    args = _runs_args_for_ctx(ctx)
    with config.SetGuildHome(ctx.parent.params.get("guild_home")):
        try:
            return runs_impl.runs_for_args(args, ctx=ctx)
        except SystemExit:
            # Raised when cannot find runs for args.
            return []


def _runs_args_for_ctx(ctx):
    args = click_util.Args(**ctx.params)
    if not hasattr(args, "runs"):
        maybe_run = getattr(args, "run", None)
        args.runs = (maybe_run,) if maybe_run else ()
    return args


def run_for_ctx(ctx):
    runs = runs_for_ctx(ctx)
    return runs[0] if runs else None


def ac_operation(ctx, _param, incomplete):
    from guild import run_util

    if ctx.params.get("remote"):
        return []
    runs = runs_for_ctx(ctx)
    ops = {run_util.format_operation(run, nowarn=True) for run in runs}
    return sorted([op for op in ops if op.startswith(incomplete)])


def ac_label(ctx, _param, incomplete):
    if ctx.params.get("remote"):
        return []
    runs = runs_for_ctx(ctx)
    labels = {run.get("label") or "" for run in runs}
    return sorted([_quote_label(l) for l in labels if l and l.startswith(incomplete)])


def _quote_label(l):
    return f"\"{l}\""


def ac_tag(ctx, _param, incomplete):
    if ctx.params.get("remote"):
        return []
    # Reset tags to avoid limiting results based on selected tags.
    ctx.params["tags"] = []
    runs = runs_for_ctx(ctx)
    return [tag for tag in _all_tags_sorted(runs) if tag.startswith(incomplete)]


def _all_tags_sorted(runs):
    tags = set()
    for run in runs:
        tags.update(run.get("tags") or [])
    return sorted(tags)


def ac_digest(ctx, _param, incomplete):
    if ctx.params.get("remote"):
        return []
    runs = runs_for_ctx(ctx)
    digests = {run.get("sourcecode_digest") or "" for run in runs}
    return sorted([d for d in digests if d and d.startswith(incomplete)])


def ac_archive(_ctx, _param, incomplete):
    return ac_support.ac_dir(incomplete) + ac_support.ac_filename(["zip"], incomplete)


def runs_arg(fn):
    """### Specify Runs

    You may use one or more `RUN` arguments to indicate which runs
    apply to the command. `RUN` may be a run ID, a run ID prefix, or a
    one-based index corresponding to a run returned by the list
    command.

    Indexes may also be specified in ranges in the form `START:END`
    where `START` is the start index and `END` is the end
    index. Either `START` or `END` may be omitted. If `START` is
    omitted, all runs up to `END` are selected. If `END` id omitted,
    all runs from `START` on are selected. If both `START` and `END`
    are omitted (i.e. the ``:`` char is used by itself) all runs are
    selected.

    """
    click_util.append_params(
        fn,
        [
            click.Argument(
                ("runs",),
                metavar="[RUN...]",
                nargs=-1,
                shell_complete=ac_run,
            )
        ],
    )
    return fn


def run_arg(fn):
    """### Specify a Run

    You may specify a run using a run ID, a run ID prefix, or a
    one-based index corresponding to a run returned by the `list`
    command.

    """
    click_util.append_params(
        fn,
        [
            click.Argument(
                ("run",),
                metavar="[RUN]",
                required=False,
                shell_complete=ac_run,
            )
        ],
    )
    return fn


def common_filters(fn):
    """### Filter by Operation

    Runs may be filtered by operation using `--operation`.  A run is
    only included if any part of its full operation name, including
    the package and model name, matches the value.

    Use `--operation` multiple times to include more runs.

    ### Filter by Label

    Use `--label` to only include runs with labels containing a
    specified value. To select runs that do not contain a label,
    specify a dash '-' for `VAL`.

    Use `--label` multiple times to include more runs.

    ### Filter by Tag

    Use `--tag` to only include runs with a specified tag. Tags must
    match completely and are case sensitive.

    Use `--tag` multiple times to include more runs.

    ### Filter by Marked and Unmarked

    Use `--marked` to only include marked runs.

    Use `--unmarked` to only include unmarked runs. This option may
    not be used with `--marked`.

    ### Filter by Expression

    Use `--filter` to limit runs that match a filter
    expressions. Filter expressions compare run attributes, flag
    values, or scalars to target values. They may include multiple
    expressions with logical operators.

    For example, to match runs with flag `batch-size` equal to 100
    that have `loss` less than 0.8, use:

        --filter 'batch-size = 10 and loss < 0.8'

    **IMPORTANT:** You must quote EXPR if it contains spaces or
    characters that the shell uses (e.g. '<' or '>').

    Target values may be numbers, strings or lists containing numbers
    and strings. Strings that contain spaces must be quoted, otherwise
    a target string values does not require quotes. Lists are defined
    using square braces where each item is separated by a comma.

    Comparisons may use the following operators: '=', '!=' (or '<>'),
    '<', '<=', '>', '>='. Text comparisons may use 'contains' to test
    for case-insensitive string membership. A value may be tested for
    membership or not in a list using 'in' or 'not in'
    respectively. An value may be tested for undefined using 'is
    undefined' or defined using 'is not undefined'.

    Logical operators include 'or' and 'and'. An expression may be
    negated by preceding it with 'not'. Parentheses may be used to
    control the order of precedence when expressions are evaluated.

    If a value reference matches more than one type of run information
    (e.g. a flag is named 'label', which is also a run attribute), the
    value is read in order of run attribute, then flag value, then
    scalar. To disambiguate the reference, use a prefix `attr:`,
    `flag:`, or `scalar:` as needed. For example, to filter using a
    flag value named 'label', use 'flag:label'.

    Other examples:

      \b
      `operation = train and acc > 0.9`
      `operation = train and (acc > 0.9 or loss < 0.3)`
      `batch-size = 100 or batch-size = 200`
      `batch-size in [100,200]`
      `batch-size not in [400,800]`
      `batch-size is undefined`
      `batch-size is not undefined`
      `label contains best and operation not in [test,deploy]`
      `status in [error,terminated]`

    **NOTE:** Comments and tags are not supported in filter
    expressions at this time. Use `--comment` and `--tag` options
    along with filter expressions to further refine a selection.

    ### Filter by Run Start Time

    Use `--started` to limit runs to those that have started within a
    specified time range.

    **IMPORTANT:** You must quote RANGE values that contain
    spaces. For example, to filter runs started within the last hour,
    use the option:

        --started 'last hour'

    You can specify a time range using several different forms:

      \b
      `after DATETIME`
      `before DATETIME`
      `between DATETIME and DATETIME`
      `last N minutes|hours|days`
      `today|yesterday`
      `this week|month|year`
      `last week|month|year`
      `N days|weeks|months|years ago`

    `DATETIME` may be specified as a date in the format ``YY-MM-DD``
    (the leading ``YY-`` may be omitted) or as a time in the format
    ``HH:MM`` (24 hour clock). A date and time may be specified
    together as `DATE TIME`.

    When using ``between DATETIME and DATETIME``, values for
    `DATETIME` may be specified in either order.

    When specifying values like ``minutes`` and ``hours`` the trailing
    ``s`` may be omitted to improve readability. You may also use
    ``min`` instead of ``minutes`` and ``hr`` instead of ``hours``.

    Examples:

      \b
      `after 7-1`
      `after 9:00`
      `between 1-1 and 4-30`
      `between 10:00 and 15:00`
      `last 30 min`
      `last 6 hours`
      `today`
      `this week`
      `last month`
      `3 weeks ago`

    ### Filter by Source Code Digest

    To show runs for a specific source code digest, use `-g` or
    `--digest` with a complete or partial digest value.

    """
    click_util.append_params(
        fn,
        [
            click.Option(
                ("-F", "--filter", "filter_expr"),
                metavar="EXPR",
                help=(
                    "Filter runs using a filter expression. See Filter by "
                    "Expression above for details."
                ),
            ),
            click.Option(
                ("-Fo", "--operation", "filter_ops"),
                metavar="VAL",
                help="Filter runs with operations matching `VAL`.",
                multiple=True,
                shell_complete=ac_operation,
            ),
            click.Option(
                ("-Fl", "--label", "filter_labels"),
                metavar="VAL",
                help=(
                    "Filter runs with labels matching VAL. To show unlabeled "
                    "runs, use --unlabeled."
                ),
                multiple=True,
                shell_complete=ac_label,
            ),
            click.Option(
                ("-Fu", "--unlabeled", "filter_unlabeled"),
                help="Filter runs without labels.",
                is_flag=True,
            ),
            click.Option(
                ("-Ft", "--tag", "filter_tags"),
                metavar="TAG",
                help="Filter runs with TAG.",
                multiple=True,
                shell_complete=ac_tag,
            ),
            click.Option(
                ("-Fc", "--comment", "filter_comments"),
                metavar="VAL",
                help="Filter runs with comments matching VAL.",
                multiple=True,
            ),
            click.Option(
                ("-Fm", "--marked", "filter_marked"),
                help="Filter marked runs.",
                is_flag=True,
            ),
            click.Option(
                ("-Fn", "--unmarked", "filter_unmarked"),
                help="Filter unmarked runs.",
                is_flag=True,
            ),
            click.Option(
                ("-Fs", "--started", "filter_started"),
                metavar="RANGE",
                help=(
                    "Filter runs started within RANGE. See above "
                    "for valid time ranges."
                ),
            ),
            click.Option(
                ("-Fd", "--digest", "filter_digest"),
                metavar="VAL",
                help=("Filter runs with a matching source code digest."),
                shell_complete=ac_digest,
            ),
        ],
    )
    return fn


def _callbacks(*cbs):
    def f(ctx, param, value):
        for cb in cbs:
            value = cb(ctx, param, value)
        return value

    return f


def status_filters(fn):
    """### Filter by Run Status

    Runs may also be filtered by specifying one or more status
    filters: `--running`, `--completed`, `--error`, and
    `--terminated`. These may be used together to include runs that
    match any of the filters. For example to only include runs that
    were either terminated or exited with an error, use ``--terminated
    --error``, or the short form ``-Set``.

    You may combine more than one status character with ``-S`` to
    expand the filter. For example, ``-Set`` shows only runs with
    terminated or error status.

    Status filters are applied before `RUN` indexes are resolved. For
    example, a run index of ``1`` is the latest run that matches the
    status filters.
    """
    click_util.append_params(
        fn,
        [
            click.Option(
                ("-Sr", "--running/--not-running", "status_running"),
                help="Filter runs that are still running.",
                is_flag=True,
                default=None,
                callback=_apply_status_chars,
            ),
            click.Option(
                ("-Sc", "--completed/--not-completed", "status_completed"),
                help="Filter completed runs.",
                is_flag=True,
                default=None,
                callback=_apply_status_chars,
            ),
            click.Option(
                ("-Se", "--error/--not-error", "status_error"),
                help="Filter runs that exited with an error.",
                is_flag=True,
                default=None,
                callback=_apply_status_chars,
            ),
            click.Option(
                ("-St", "--terminated/--not-terminated", "status_terminated"),
                help="Filter runs terminated by the user.",
                is_flag=True,
                default=None,
                callback=_apply_status_chars,
            ),
            click.Option(
                ("-Sp", "--pending/--not-pending", "status_pending"),
                help="Filter pending runs.",
                is_flag=True,
                default=None,
                callback=_apply_status_chars,
            ),
            click.Option(
                ("-Ss", "--staged/--not-staged", "status_staged"),
                help="Filter staged runs.",
                is_flag=True,
                default=None,
                callback=_apply_status_chars,
            ),
            click.Option(
                # Used by _apply_status_chars to implicitly set status
                # flags using one or more chars.
                ("-S", "status_chars"),
                hidden=True,
                callback=_validate_status_chars,
            ),
        ],
    )
    return fn


def _apply_status_chars(ctx, param, value):
    if value:
        return value
    status_chars = ctx.params.get("status_chars")
    if not status_chars:
        return value
    status_char = _param_status_char(param)
    if status_char in status_chars:
        return True
    return value


def _param_status_char(param):
    for opt in param.opts:
        if opt.startswith("-S"):
            char = opt[2:]
            assert len(char) == 1, param.opts
            return char
    assert False, param.opts


def _validate_status_chars(ctx, _param, value):
    if not value:
        return value
    for char in value:
        if char not in "rcetps":
            raise SystemExit(
                f"unrecognized status char '{char}' in option '-S'\n"
                f"Try '{ctx.command_path} --help' for more information."
            )
    return value


@click_util.render_doc
def all_filters(fn):
    """
    {{ common_filters }}
    {{ status_filters }}
    """
    click_util.append_params(
        fn,
        [
            common_filters,
            status_filters,
        ],
    )
    return fn


def archive_option(help):
    """### Show Archived Runs

    Use `--archive` to show runs in an archive directory. PATH may be
    a directory or a zip file created using 'guild export'.
    """
    assert isinstance(help, str), "@archive_option must be called with help"

    def wrapper(fn):
        click_util.append_params(
            fn,
            [
                click.Option(
                    ("-A", "--archive"),
                    metavar="PATH",
                    help=help,
                    shell_complete=ac_archive,
                )
            ],
        )
        return fn

    return wrapper
