# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

import json
import shlex
import unittest.mock
from collections.abc import Callable
from contextlib import contextmanager
from enum import Enum
from functools import partial
from pathlib import Path
from textwrap import dedent
from typing import Any, cast

import pytest
import toml
import yaml
from packaging.version import Version

from pants.base.build_environment import get_buildroot
from pants.base.deprecated import CodeRemovedError, warn_or_error
from pants.engine.fs import FileContent
from pants.option.custom_types import UnsetBool, file_option, shell_str, target_option
from pants.option.errors import (
    BooleanConversionError,
    BooleanOptionNameWithNo,
    DefaultValueType,
    HelpType,
    InvalidKwarg,
    InvalidMemberType,
    MemberTypeNotAllowed,
    MutuallyExclusiveOptionError,
    NoOptionNames,
    OptionAlreadyRegistered,
    OptionNameDoubleDash,
    ParseError,
    UnknownFlagsError,
)
from pants.option.global_options import GlobalOptions
from pants.option.native_options import parse_dest
from pants.option.option_types import OptionInfo, StrOption
from pants.option.options import Options
from pants.option.options_fingerprinter import OptionEncoder
from pants.option.ranked_value import Rank, RankedValue
from pants.option.scope import GLOBAL_SCOPE, ScopeInfo
from pants.option.subsystem import Subsystem
from pants.util.contextutil import pushd, temporary_dir, temporary_file, temporary_file_path

_FAKE_CUR_VERSION = "1.0.0.dev0"


def global_scope() -> ScopeInfo:
    return ScopeInfo(GLOBAL_SCOPE, GlobalOptions)


def task(scope: str) -> ScopeInfo:
    return ScopeInfo(scope, is_goal=True)


def intermediate(scope: str) -> ScopeInfo:
    return ScopeInfo(scope)


def subsystem(scope: str) -> ScopeInfo:
    return ScopeInfo(scope)


def create_options(
    scopes: list[str],
    register_fn: Callable[[Options], None],
    args: list[str] | None = None,
    *,
    env: dict[str, str] | None = None,
    config: dict[str, dict[str, Any]] | None = None,
    extra_scope_infos: list[ScopeInfo] | None = None,
) -> Options:
    options = Options.create(
        args=["pants", *(args or ())],
        env=env or {},
        config_sources=[FileContent("pants.toml", toml.dumps(config or {}).encode())],
        known_scope_infos=[
            *(ScopeInfo(scope, is_goal=scope != GLOBAL_SCOPE) for scope in scopes),
            *(extra_scope_infos or ()),
        ],
    )
    register_fn(options)
    return options


# ----------------------------------------------------------------------------------------
# Boolean handling.
# ----------------------------------------------------------------------------------------


def register_bool_opts(opts: Options) -> None:
    opts.register(GLOBAL_SCOPE, "--default-missing", type=bool)
    opts.register(GLOBAL_SCOPE, "--default-true", type=bool, default=True)
    opts.register(GLOBAL_SCOPE, "--default-false", type=bool, default=False)
    opts.register(GLOBAL_SCOPE, "--unset", type=bool, default=UnsetBool)


def test_bool_explicit_values() -> None:
    def register(opt: Options) -> None:
        opt.register(GLOBAL_SCOPE, "--opt", type=bool)

    def assert_val(arg: str, expected: bool) -> None:
        global_options = create_options(
            [GLOBAL_SCOPE], register, [f"--opt={arg}"]
        ).for_global_scope()
        assert global_options.opt is expected

    assert_val("false", False)
    assert_val("False", False)
    assert_val("true", True)
    assert_val("True", True)


def test_bool_defaults() -> None:
    opts = create_options([GLOBAL_SCOPE], register_bool_opts).for_global_scope()
    assert opts.default_missing is False
    assert opts.default_true is True
    assert opts.default_false is False

    assert opts.unset is None


def test_bool_args() -> None:
    opts = create_options(
        [GLOBAL_SCOPE],
        register_bool_opts,
        [
            "--default-missing",
            "--default-true",
            "--default-false",
            "--unset",
        ],
    ).for_global_scope()
    assert opts.default_missing is True
    assert opts.default_true is True
    assert opts.default_false is True

    assert opts.unset is True


def test_bool_negate() -> None:
    opts = create_options(
        [GLOBAL_SCOPE],
        register_bool_opts,
        [
            "--no-default-missing",
            "--no-default-true",
            "--no-default-false",
            "--no-unset",
        ],
    ).for_global_scope()
    assert opts.default_missing is False
    assert opts.default_true is False
    assert opts.default_false is False

    assert opts.unset is False


@pytest.mark.parametrize("val", [False, True])
def test_bool_config(val: bool) -> None:
    opt_names = (
        "default_missing",
        "default_true",
        "default_false",
    )
    opts = create_options(
        [GLOBAL_SCOPE], register_bool_opts, config={"GLOBAL": {opt: val for opt in opt_names}}
    ).for_global_scope()
    for opt in opt_names:
        assert opts[opt] is val, f"option {opt} has value {opts[opt]} but expected {val}"


@pytest.mark.parametrize(
    ("val", "err"),
    (
        (11, "Expected \\[GLOBAL\\] opt to be a bool but given 11"),
        ("AlmostTrue", "Problem parsing \\[GLOBAL\\] opt bool value:"),
    ),
)
def test_bool_invalid_value(val: Any, err: str) -> None:
    def register(opts: Options) -> None:
        opts.register(GLOBAL_SCOPE, "--opt", type=bool)

    with pytest.raises(ParseError, match=err):
        create_options([GLOBAL_SCOPE], register, config={"GLOBAL": {"opt": val}}).for_global_scope()


# ----------------------------------------------------------------------------------------
# Type checks
# ----------------------------------------------------------------------------------------


@contextmanager
def no_exception():
    """use in tests as placeholder for a pytest.raises, when no exception is expected."""
    yield None


@pytest.mark.parametrize(
    "option_kwargs, assert_expected",
    [
        (
            dict(type=str, default=""),
            no_exception(),
        ),
        (
            dict(type=str, default=42),
            pytest.raises(
                DefaultValueType, match=r"Default value int\(42\) does not match option type str\."
            ),
        ),
        (
            dict(type=bool, default=True),
            no_exception(),
        ),
        (
            dict(type=bool, default=True),
            no_exception(),
        ),
        (
            dict(type=bool, default="not a bool"),
            pytest.raises(
                BooleanConversionError, match=r'Got "not a bool"\. Expected "True" or "False"\.'
            ),
        ),
        (
            dict(type=int, default=1.0),
            pytest.raises(
                DefaultValueType,
                match=r"Default value float\(1\.0\) does not match option type int\. \[option --opt in global scope\]\.",
            ),
        ),
        (
            dict(type=list, member_type=int, default="[1, 2, 3]"),
            no_exception(),
        ),
        (
            dict(type=list, member_type=int, default="[1, 2.1, 3]"),
            pytest.raises(
                DefaultValueType,
                match=r"Default member value type mismatch\.\n\n    Member value float\(2\.1\) does not match list option type int\.",
            ),
        ),
        (
            dict(type=list, member_type=float, default="[1.1, 2.0, 3.3]"),
            no_exception(),
        ),
        (
            dict(type=list, member_type=float, default="[1.1, 2.2, '3.3']"),
            pytest.raises(
                DefaultValueType,
                match=r"Member value str\('3\.3'\) does not match list option type float\.",
            ),
        ),
        (
            dict(type=dict, default="{'foo': 'bar'}"),
            no_exception(),
        ),
        (
            dict(type=dict, default="['foo', 'bar']"),
            pytest.raises(ParseError, match=r"Invalid dict value: \['foo', 'bar'\]"),
        ),
    ],
)
def test_default_value_type_assert(option_kwargs, assert_expected):
    def register(opts: Options) -> None:
        opts.register(GLOBAL_SCOPE, "--opt", **option_kwargs)

    with assert_expected:
        create_options([GLOBAL_SCOPE], register).for_scope(GLOBAL_SCOPE)


# ----------------------------------------------------------------------------------------
# Deprecations.
# ----------------------------------------------------------------------------------------


def test_deprecated_options(caplog) -> None:
    def register(opts: Options) -> None:
        opts.register(
            GLOBAL_SCOPE, "--old1", removal_version="999.99.9.dev0", removal_hint="Stop it."
        )
        opts.register(
            GLOBAL_SCOPE,
            "--bool1",
            type=bool,
            removal_version="999.99.9.dev0",
            removal_hint="¡Basta!",
        )
        opts.register("scope", "--valid")
        opts.register(
            "scope", "--old2", removal_version="999.99.9.dev0", removal_hint="Stop with the scope."
        )
        opts.register(
            "scope",
            "--bool2",
            type=bool,
            removal_version="999.99.9.dev0",
            removal_hint="¡Basta but scoped!",
        )

    def assert_deprecated(
        scope: str,
        opt: str,
        args: list[str],
        *,
        expected: str | bool,
        env: dict[str, str] | None = None,
        config: dict[str, dict[str, str]] | None = None,
    ) -> None:
        caplog.clear()
        warn_or_error.clear()  # type: ignore[attr-defined]
        opts = create_options([GLOBAL_SCOPE, "scope"], register, args, env=env, config=config)
        assert opts.for_scope(scope)[opt] == expected
        assert len(caplog.records) == 1
        assert "is scheduled to be removed in version" in caplog.text
        assert opt in caplog.text

    assert_deprecated(GLOBAL_SCOPE, "old1", ["--old1=x"], expected="x")
    assert_deprecated(GLOBAL_SCOPE, "bool1", ["--bool1"], expected=True)
    assert_deprecated(GLOBAL_SCOPE, "bool1", ["--no-bool1"], expected=False)
    assert_deprecated("scope", "old2", ["scope", "--old2=x"], expected="x")
    assert_deprecated("scope", "old2", ["--scope-old2=x"], expected="x")
    assert_deprecated("scope", "bool2", ["scope", "--bool2"], expected=True)
    assert_deprecated("scope", "bool2", ["scope", "--no-bool2"], expected=False)
    assert_deprecated("scope", "bool2", ["--scope-bool2"], expected=True)
    assert_deprecated("scope", "bool2", ["--no-scope-bool2"], expected=False)

    assert_deprecated(GLOBAL_SCOPE, "old1", [], env={"PANTS_GLOBAL_OLD1": "x"}, expected="x")
    assert_deprecated("scope", "old2", [], env={"PANTS_SCOPE_OLD2": "x"}, expected="x")

    assert_deprecated(GLOBAL_SCOPE, "old1", [], config={"GLOBAL": {"old1": "x"}}, expected="x")
    assert_deprecated("scope", "old2", [], config={"scope": {"old2": "x"}}, expected="x")

    # Make sure the warnings don't come out for regular options.
    caplog.clear()
    warn_or_error.clear()  # type: ignore[attr-defined]
    assert (
        create_options([GLOBAL_SCOPE, "scope"], register, ["--scope-valid=x"])
        .for_scope("scope")
        .valid
        == "x"
    )
    assert not caplog.records


def test_deprecated_options_error() -> None:
    def register(opts: Options) -> None:
        opts.register(GLOBAL_SCOPE, "--expired", removal_version="0.0.1.dev0")

    opts = create_options([GLOBAL_SCOPE], register, [])
    with pytest.raises(CodeRemovedError):
        opts.for_scope(scope=GLOBAL_SCOPE)


@unittest.mock.patch("pants.base.deprecated.PANTS_SEMVER", Version(_FAKE_CUR_VERSION))
def test_deprecated_options_start_version(caplog) -> None:
    def register(opts: Options) -> None:
        opts.register(
            GLOBAL_SCOPE,
            "--delayed",
            removal_version="999.99.9.dev0",
            deprecation_start_version="500.0.0.dev0",
        )
        opts.register(
            GLOBAL_SCOPE,
            "--past-start",
            removal_version="999.99.9.dev0",
            deprecation_start_version=_FAKE_CUR_VERSION,
        )

    caplog.clear()
    assert (
        create_options([GLOBAL_SCOPE], register, ["--delayed=x"]).for_global_scope().delayed == "x"
    )
    assert not caplog.records

    assert (
        create_options([GLOBAL_SCOPE], register, ["--past-start=x"]).for_global_scope().past_start
        == "x"
    )
    assert len(caplog.records) == 1
    assert "is scheduled to be removed in version" in caplog.text
    assert "past_start" in caplog.text


def test_scope_deprecation(caplog) -> None:
    # This test demonstrates that two different new scopes can deprecate the same
    # old scope. I.e., it's possible to split an old scope's options among multiple new scopes.
    class Subsystem1(Subsystem):
        options_scope = "new1"
        deprecated_options_scope = "deprecated"
        deprecated_options_scope_removal_version = "9999.9.9.dev0"

        foo = StrOption(default=None, help="")
        bar = StrOption(default=None, help="")
        baz = StrOption(default=None, help="")

    class Subsystem2(Subsystem):
        options_scope = "new2"
        deprecated_options_scope = "deprecated"
        deprecated_options_scope_removal_version = "9999.9.9.dev0"

        qux = StrOption(default=None, help="")

    def register(opts: Options) -> None:
        opts.register(Subsystem1.options_scope, "--foo")
        opts.register(Subsystem1.options_scope, "--bar")
        opts.register(Subsystem1.options_scope, "--baz")
        opts.register(Subsystem2.options_scope, "--qux")

    opts = create_options(
        [GLOBAL_SCOPE],
        register,
        ["--new1-baz=vv"],
        extra_scope_infos=[Subsystem1.get_scope_info(), Subsystem2.get_scope_info()],
        config={
            Subsystem1.options_scope: {"foo": "xx"},
            Subsystem1.deprecated_options_scope: {
                "foo": "yy",
                "bar": "zz",
                "baz": "ww",
                "qux": "uu",
            },
        },
    )

    caplog.clear()
    vals1 = opts.for_scope(Subsystem1.options_scope)
    assert len(caplog.records) == 1
    assert Subsystem1.deprecated_options_scope in caplog.text
    assert "foo" in caplog.text
    # Deprecated scope takes precedence at equal rank, but new scope takes precedence at higher
    # rank.
    assert vals1.foo == "yy"
    assert vals1.bar == "zz"
    assert vals1.baz == "vv"

    caplog.clear()
    vals2 = opts.for_scope(Subsystem2.options_scope)
    assert len(caplog.records) == 1
    assert Subsystem1.deprecated_options_scope in caplog.text
    assert "qux" in caplog.text
    assert vals2.qux == "uu"


def _create_config(
    config: dict[str, dict[str, str]] | None = None,
    config2: dict[str, dict[str, str]] | None = None,
) -> list[FileContent]:
    return [
        FileContent("test_config.toml", toml.dumps(config or {}).encode()),
        FileContent("test_config2.toml", toml.dumps(config2 or {}).encode()),
    ]


def _parse(
    *,
    flags: str = "",
    env: dict[str, str] | None = None,
    config: dict[str, dict[str, Any]] | None = None,
    config2: dict[str, dict[str, Any]] | None = None,
    allow_unknown_options=False,
) -> Options:
    args = ["pants", *shlex.split(flags)]

    options = Options.create(
        args=args,
        env=env or {},
        config_sources=_create_config(config, config2),
        known_scope_infos=_known_scope_infos,
        allow_unknown_options=allow_unknown_options,
    )
    _register(options)
    return options


_known_scope_infos = [
    ScopeInfo(GLOBAL_SCOPE),
    *[
        ScopeInfo(scope, is_goal=True)
        for scope in (
            "anotherscope",
            "compile",
            "compile.java",
            "stale",
            "test",
            "test.junit",
            "passconsumer",
            "simple",
            "simple-dashed",
            "scoped.a.bit",
            "scoped.and-dashed",
            "fromfile",
            "fingerprinting",
            "enum-opt",
            "separate-enum-opt-scope",
            "other-enum-scope",
        )
    ],
]


class SomeEnumOption(Enum):
    a_value = "a-value"
    another_value = "another-value"
    yet_another = "yet-another"
    one_more = "one-more"


def _register(options):
    def register_global(*args, **kwargs):
        options.register(GLOBAL_SCOPE, *args, **kwargs)

    register_global("--verbose", type=bool, help="Verbose output.")
    register_global("--num", type=int, default=99)

    # NB: `-l` on the global scope is the only short arg we allow. We need to make sure it
    # works.
    register_global("-l", "--level", type=str, help="What level to use.")

    # Test that we can use the same name on the global scope and another scope.
    options.register("anotherscope", "--num", type=int, default=99)

    register_global("--y", type=list, member_type=int)
    register_global(
        "--v2", help="Two-letter long-form option, used to test option name suggestions."
    )
    register_global("--config-override", type=list)

    register_global("--pants-foo")
    register_global("--bar-baz")
    register_global("--store-true-flag", type=bool)

    # Choices.
    register_global("--str-choices", choices=["foo", "bar"])
    register_global("--int-choices", choices=[42, 99], type=list, member_type=int)

    # Custom types.
    register_global("--listy", type=list, member_type=int, default="[1, 2, 3]")
    register_global("--dicty", type=dict, default='{"a": "b"}')
    register_global("--targety", type=target_option, default="//:a")
    register_global(
        "--target-listy", type=list, member_type=target_option, default=["//:a", "//:b"]
    )
    register_global("--filey", type=file_option, default=None)
    register_global("--file-listy", type=list, member_type=file_option)
    register_global(
        "--shell-str-listy",
        type=list,
        member_type=shell_str,
        default="--default1 --default2=test",
    )

    # Mutual Exclusive options
    register_global("--mutex-foo", mutually_exclusive_group="mutex")
    register_global("--mutex-bar", mutually_exclusive_group="mutex")
    register_global("--mutex-baz", mutually_exclusive_group="mutex")

    register_global("--new-name")
    register_global("--old-name", mutually_exclusive_group="new_name")

    # Test mutual exclusive options with a scope
    options.register("stale", "--mutex-a", mutually_exclusive_group="scope_mutex")
    options.register("stale", "--mutex-b", mutually_exclusive_group="scope_mutex")
    options.register("stale", "--crufty-old", mutually_exclusive_group="crufty_new")
    options.register("stale", "--crufty-new")

    # For scoped fingerprintable test
    options.register("compile", "--modifycompile")
    options.register("compile", "--modifylogs", fingerprint=False)
    options.register(
        "compile",
        "--modifypassthrough",
        passthrough=True,
        type=list,
        member_type=str,
    )

    # For scoped env vars test
    options.register("simple", "--spam")
    options.register("simple-dashed", "--spam")
    options.register("scoped.a.bit", "--spam")
    options.register("scoped.and-dashed", "--spam")

    # For fromfile test
    options.register("fromfile", "--string")
    options.register("fromfile", "--intvalue", type=int)
    options.register("fromfile", "--dictvalue", type=dict)
    options.register("fromfile", "--listvalue", type=list)
    options.register("fromfile", "--passthru-listvalue", type=list, passthrough=True)
    options.register("fromfile", "--appendvalue", type=list, member_type=int)

    # For fingerprint tests
    register_global("--implicitly-fingerprinted")
    register_global("--explicitly-fingerprinted", fingerprint=True)
    register_global("--explicitly-not-fingerprinted", fingerprint=False)
    register_global("--implicitly-not-daemoned")
    register_global("--explicitly-not-daemoned", daemon=False)
    register_global("--explicitly-daemoned", daemon=True)

    # For enum tests
    options.register("enum-opt", "--some-enum", type=SomeEnumOption)
    options.register("other-enum-scope", "--some-list-enum", type=list, member_type=SomeEnumOption)
    options.register(
        "other-enum-scope",
        "--some-list-enum-with-default",
        type=list,
        member_type=SomeEnumOption,
        default=[SomeEnumOption.yet_another],
    )

    # For testing the default value
    options.register(
        "separate-enum-opt-scope",
        "--some-enum-with-default",
        default=SomeEnumOption.a_value,
        type=SomeEnumOption,
    )


def test_env_var_of_type_int() -> None:
    create_options_object = partial(
        Options.create,
        config_sources=_create_config(),
        known_scope_infos=_known_scope_infos,
        args=shlex.split("pants"),
    )
    options = create_options_object(env={"PANTS_FOO_BAR": "123"})
    options.register(GLOBAL_SCOPE, "--foo-bar", type=int)
    assert 123 == options.for_global_scope().foo_bar

    options = create_options_object(env={"PANTS_FOO_BAR": "[123,456]"})
    options.register(GLOBAL_SCOPE, "--foo-bar", type=list, member_type=int)
    assert [123, 456] == options.for_global_scope().foo_bar


def test_arg_scoping() -> None:
    # Some basic smoke tests.
    options = _parse(flags="--verbose")
    assert options.for_global_scope().verbose is True
    options = _parse(flags="--verbose compile path/to/tgt")
    assert ["path/to/tgt"] == options.specs
    assert options.for_global_scope().verbose is True

    options = _parse(flags="-linfo")
    assert options.for_global_scope().level == "info"
    options = _parse(flags="--level=info compile path/to/tgt")
    assert ["path/to/tgt"] == options.specs
    assert options.for_global_scope().level == "info"

    # Scoping of different values of options with the same name in different scopes.
    options = _parse(flags="--num=11 anotherscope --num=22")
    assert 11 == options.for_global_scope().num
    assert 22 == options.for_scope("anotherscope").num

    # Test list-typed option.
    global_options = _parse(config={"DEFAULT": {"y": [88, -99]}}).for_global_scope()
    assert [88, -99] == global_options.y

    global_options = _parse(
        flags="--y=5 --y=-6 --y=77", config={"DEFAULT": {"y": [88, -99]}}
    ).for_global_scope()
    assert [88, -99, 5, -6, 77] == global_options.y

    global_options = _parse().for_global_scope()
    assert [] == global_options.y

    global_options = _parse(env={"PANTS_CONFIG_OVERRIDE": "['123','456']"}).for_global_scope()
    assert ["123", "456"] == global_options.config_override

    global_options = _parse(env={"PANTS_CONFIG_OVERRIDE": "['']"}).for_global_scope()
    assert [""] == global_options.config_override

    global_options = _parse(
        flags="--listy='[1, 2]'", config={"DEFAULT": {"listy": "[3, 4]"}}
    ).for_global_scope()
    assert [1, 2] == global_options.listy

    # Test dict-typed option.
    global_options = _parse(flags='--dicty=\'{"c": "d"}\'').for_global_scope()
    assert {"c": "d"} == global_options.dicty

    # Test target-typed option.
    global_options = _parse().for_global_scope()
    assert "//:a" == global_options.targety
    global_options = _parse(flags="--targety=//:foo").for_global_scope()
    assert "//:foo" == global_options.targety

    # Test list-of-target-typed option.
    global_options = _parse(flags='--target-listy=\'["//:foo", "//:bar"]\'').for_global_scope()
    assert ["//:foo", "//:bar"] == global_options.target_listy

    # Test file-typed option.
    with temporary_file_path() as fp:
        global_options = _parse(flags=f'--filey="{fp}"').for_global_scope()
        assert fp == global_options.filey

    # Test list-of-file-typed option.
    with temporary_file_path() as fp1:
        with temporary_file_path() as fp2:
            global_options = _parse(
                flags=f'--file-listy="{fp1}" --file-listy="{fp2}"'
            ).for_global_scope()
            assert [fp1, fp2] == global_options.file_listy


def test_unconsumed_args() -> None:
    with pytest.raises(UnknownFlagsError):
        # We set allow_unknown_options=True to ensure that it's the native
        # parser that checks for unconsumed args, and not the legacy parser.
        _parse(flags="--unregistered-option compile", allow_unknown_options=True).verify_args()


def test_list_option() -> None:
    def check(
        *,
        expected: list[int],
        flags: str = "",
        env_val: str | None = None,
        config_default: str | None = None,
        config_val: str | None = None,
        config2_val: str | None = None,
    ) -> None:
        env = {"PANTS_GLOBAL_LISTY": env_val} if env_val else None
        config = {}
        if config_default:
            config["DEFAULT"] = {"listy": config_default}
        if config_val:
            config["GLOBAL"] = {"listy": config_val}
        config2 = {"GLOBAL": {"listy": config2_val}} if config2_val else None
        global_options = _parse(
            flags=flags, env=env, config=config, config2=config2
        ).for_global_scope()
        assert global_options.listy == expected

    default = [1, 2, 3]
    check(expected=default)

    # Appending to the default.
    check(flags="--listy=4", expected=[*default, 4])
    check(flags="--listy=4 --listy=5", expected=[*default, 4, 5])
    check(flags="--listy=+[4,5]", expected=[*default, 4, 5])

    # Filtering from the default.
    check(flags="--listy=-[2]", expected=[1, 3])

    # Replacing the default.
    check(flags="--listy=[4,5]", expected=[4, 5])

    # Appending across env, config and flags (in the right order).
    check(
        flags="--listy=+[8,9]",
        env_val="+[6,7]",
        config_default="+[100]",
        config_val="+[4,5],+[45]",
        expected=[*default, 100, 4, 5, 45, 6, 7, 8, 9],
    )
    check(
        config_val="+[4,5],-[4]",
        expected=[*default, 5],
    )
    check(
        config_default="[100, 101]",
        config_val="+[4,5],-[4,101]",
        expected=[100, 5],
    )
    check(
        config_default="[100, 101]",
        config_val="[4,5]",
        expected=[4, 5],
    )

    # Appending and filtering across env, config and flags (in the right order).
    check(
        flags="--listy=-[1,5,6]",
        env_val="+[6,7]",
        config_val="+[4,5]",
        config2_val="+[99]",
        expected=[2, 3, 4, 99, 7],
    )
    check(
        flags="--listy=+[8,9]",
        env_val="-[4,5]",
        config_val="+[4,5],-[3]",
        expected=[1, 2, 8, 9],
    )
    # Appending a value from a fromfile.
    with temporary_file(binary_mode=False) as fp:
        fp.write("-[3]")
        fp.close()
        check(
            flags="--listy=+[8,9]",
            env_val="-[4,5]",
            config_val="+[4,5]",
            config2_val=f"@{fp.name}",
            expected=[1, 2, 8, 9],
        )

    # Overwriting from env, then appending and filtering.
    check(
        flags="--listy=+[8,9],-[6]",
        env_val="[6,7]",
        config_val="+[4,5]",
        expected=[7, 8, 9],
    )

    # Overwriting from config, then appending.
    check(
        flags="--listy=+[8,9]",
        env_val="+[6,7]",
        config_val="[4,5]",
        config2_val="-[4]",
        expected=[5, 6, 7, 8, 9],
    )

    # Overwriting from flags.
    check(
        flags="--listy=[8,9]",
        env_val="+[6,7]",
        config_val="+[4,5],-[8]",
        expected=[8, 9],
    )

    # Filtering all instances of repeated values.
    check(
        flags="--listy=-[5]",
        config_val="[1, 2, 5, 3, 4, 5, 6, 5, 5]",
        expected=[1, 2, 3, 4, 6],
    )

    # Filtering a value even though it was appended again at a higher rank.
    check(
        flags="--listy=+[4]",
        env_val="-[4]",
        config_val="+[4,5]",
        expected=[*default, 5],
    )

    # Filtering a value even though it was appended again at the same rank.
    check(
        env_val="-[4],+[4]",
        config_val="+[4,5]",
        expected=[*default, 5],
    )

    # Overwriting cancels filters.
    check(env_val="[4]", config_val="-[4]", expected=[4])


def test_target_list_option() -> None:
    def check(
        *,
        expected: list[str],
        flags: str = "",
        env_val: str | None = None,
        config_val: str | None = None,
    ) -> None:
        env = {"PANTS_GLOBAL_TARGET_LISTY": env_val} if env_val else None
        config = {"GLOBAL": {"target_listy": config_val}} if config_val else None
        global_options = _parse(flags=flags, env=env, config=config).for_global_scope()
        assert global_options.target_listy == expected

    default = ["//:a", "//:b"]
    specified_args = ["//:c", "//:d"]
    all_args = [*default, *specified_args]

    check(expected=default)

    check(flags="--target-listy=//:c --target-listy=//:d", expected=all_args)
    check(flags='--target-listy=\'+["//:c", "//:d"]\'', expected=all_args)
    check(flags='--target-listy=\'["//:c", "//:d"]\'', expected=specified_args)

    check(env_val="//:c", expected=[*default, "//:c"])
    check(env_val='+["//:c", "//:d"]', expected=all_args)
    check(env_val='["//:c", "//:d"]', expected=specified_args)

    check(config_val="//:c", expected=[*default, "//:c"])
    check(config_val='+["//:c", "//:d"]', expected=all_args)
    check(config_val='["//:c", "//:d"]', expected=specified_args)


def test_shell_str_list() -> None:
    def check(
        *,
        expected: list[str],
        flags: str = "",
        env_val: str | None = None,
        config_val: str | None = None,
    ) -> None:
        env = {"PANTS_GLOBAL_SHELL_STR_LISTY": env_val} if env_val else None
        config = {"GLOBAL": {"shell_str_listy": config_val}} if config_val else None
        global_options = _parse(flags=flags, env=env, config=config).for_global_scope()
        assert global_options.shell_str_listy == expected

    default = ["--default1", "--default2=test"]
    specified_args = ["arg1", "arg2=foo", "--arg3"]
    all_args = [*default, *specified_args]

    check(expected=default)

    check(flags="--shell-str-listy='arg1 arg2=foo' --shell-str-listy='--arg3'", expected=all_args)
    check(flags="""--shell-str-listy='+["arg1 arg2=foo", "--arg3"]'""", expected=all_args)
    check(flags="""--shell-str-listy='["arg1 arg2=foo", "--arg3"]'""", expected=specified_args)

    check(env_val="arg1 arg2=foo --arg3", expected=all_args)
    check(env_val='+["arg1 arg2=foo", "--arg3"]', expected=all_args)
    check(env_val='["arg1 arg2=foo", "--arg3"]', expected=specified_args)

    check(config_val="arg1 arg2=foo --arg3", expected=all_args)
    check(config_val='+["arg1 arg2=foo", "--arg3"]', expected=all_args)
    check(config_val='["arg1 arg2=foo", "--arg3"]', expected=specified_args)


def test_dict_option() -> None:
    def check(
        *,
        expected: dict[str, str],
        flags: str = "",
        config_default: str | None = None,
        config_val: str | None = None,
        config2_val: str | None = None,
    ) -> None:
        config = {}
        if config_default:
            config["DEFAULT"] = {"dicty": config_default}
        if config_val:
            config["GLOBAL"] = {"dicty": config_val}
        config2 = {"GLOBAL": {"dicty": config2_val}} if config2_val else None
        global_options = _parse(flags=flags, config=config, config2=config2).for_global_scope()
        assert global_options.dicty == expected

    default = {"a": "b"}
    specified_args = {"c": "d"}
    all_args = {**default, **specified_args}

    check(expected=default)

    check(flags='--dicty=\'{"c": "d"}\'', expected=specified_args)
    check(flags='--dicty=\'+{"c": "d"}\'', expected=all_args)

    check(config_val='{"c": "d"}', expected=specified_args)
    check(config_val='+{"c": "d"}', expected=all_args)
    check(
        config_val='+{"c": "d"}',
        config2_val='+{"e": "f"}',
        flags='--dicty=\'+{"g": "h"}\'',
        expected={**all_args, "e": "f", "g": "h"},
    )
    check(
        config_val='+{"c": "d"}',
        flags='--dicty=\'+{"e": "f"}\'',
        expected={**all_args, "e": "f"},
    )
    check(
        config_default='+{"x": "y"}',
        config_val='+{"c": "d"}',
        flags='--dicty=\'+{"e": "f"}\'',
        expected={**all_args, "x": "y", "e": "f"},
    )

    # Check that highest rank wins if we have multiple values for the same key.
    check(config_val='+{"a": "b+", "c": "d"}', expected={"a": "b+", "c": "d"})
    check(
        config_val='+{"a": "b+", "c": "d"}',
        flags='--dicty=\'+{"a": "b++"}\'',
