#!/usr/bin/env python3
#
# Copyright (c) 2024 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

import inspect
import os
import sys
import tempfile
from contextlib import contextmanager
from io import StringIO
from typing import Any, Literal, Sequence, TypedDict, Union

import pytest
from _pytest.mark.structures import ParameterSet
from mock import patch
from moulinette import Moulinette
from yunohost import app, domain, user
from yunohost.utils import form
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.form import (
    FORBIDDEN_PASSWORD_CHARS,
    OPTIONS,
    READONLY_TYPES,
    BaseChoicesOption,
    BaseInputOption,
    BaseReadonlyOption,
    BooleanOption,
    DomainOption,
    FileOption,
    WebPathOption,
    ask_questions_and_parse_answers,
    evaluate_simple_js_expression,
)

"""
Argument default format:
{
    "the_id": {
        "type": "one_of_the_available_type",  // "sting" is not specified
        "ask": {
            "en": "the question in english",
            "fr": "the question in french"
        },
        "help": {
            "en": "some help text in english",
            "fr": "some help text in french"
        },
        "example": "an example value", // optional
        "default", "some stuff", // optional, not available for all types
        "optional": true // optional, will skip if not answered
    }
}

User answers:
{"the_id": "value", ...}
"""


# ╭───────────────────────────────────────────────────────╮
# │  ┌─╮╭─┐╶┬╴╭─╴╷ ╷╶┬╴╭╮╷╭─╮                             │
# │  ├─╯├─┤ │ │  ├─┤ │ ││││╶╮                             │
# │  ╵  ╵ ╵ ╵ ╰─╴╵ ╵╶┴╴╵╰╯╰─╯                             │
# ╰───────────────────────────────────────────────────────╯


@contextmanager
def patch_isatty(isatty):
    with patch.object(os, "isatty", return_value=isatty):
        yield


@contextmanager
def patch_interface(interface: Literal["api", "cli"] = "api"):
    with (
        patch.object(Moulinette.interface, "type", interface),
        patch_isatty(interface == "cli"),
    ):
        yield


@contextmanager
def patch_prompt(return_value):
    with (
        patch_interface("cli"),
        patch.object(Moulinette, "prompt", return_value=return_value) as prompt,
    ):
        yield prompt


@pytest.fixture
def patch_no_tty():
    with patch_isatty(False):
        yield


@pytest.fixture
def patch_with_tty():
    with patch_isatty(True):
        yield


@pytest.fixture
def patch_cli_retries():
    with patch.object(form, "MAX_RETRIES", 0):
        yield


# ╭───────────────────────────────────────────────────────╮
# │  ╭─╴╭─╴┌─╴╭╮╷╭─┐┌─╮╶┬╴╭─╮╭─╴                          │
# │  ╰─╮│  ├─╴│││├─┤├┬╯ │ │ │╰─╮                          │
# │  ╶─╯╰─╴╰─╴╵╰╯╵ ╵╵ ╰╶┴╴╰─╯╶─╯                          │
# ╰───────────────────────────────────────────────────────╯


MinScenario = tuple[Any, Union[Literal["FAIL"], Any]]
PartialScenario = tuple[Any, Union[Literal["FAIL"], Any], dict[str, Any]]
FullScenario = tuple[Any, Union[Literal["FAIL"], Any], dict[str, Any], dict[str, Any]]

Scenario = Union[
    MinScenario,
    PartialScenario,
    FullScenario,
    "InnerScenario",
]


class InnerScenario(TypedDict, total=False):
    scenarios: Sequence[Scenario]
    raw_options: Sequence[dict[str, Any]]
    data: Sequence[dict[str, Any]]


# ╭───────────────────────────────────────────────────────╮
# │ Scenario generators/helpers                           │
# ╰───────────────────────────────────────────────────────╯


def get_hydrated_scenarios(raw_options, scenarios, data=[{}]):
    """
    Normalize and hydrate a mixed list of scenarios to proper tuple/pytest.param flattened list values.

    Example::
        scenarios = [
            {
                "raw_options": [{}, {"optional": True}],
                "scenarios": [
                    ("", "value", {"default": "value"}),
                    *unchanged("value", "other"),
                ]
            },
            *all_fails(-1, 0, 1, raw_options={"optional": True}),
            *xfail(scenarios=[(True, "True"), (False, "False)], reason="..."),
        ]
        # Is exactly the same as
        scenarios = [
            ("", "value", {"default": "value"}),
            ("", "value", {"optional": True, "default": "value"}),
            ("value", "value", {}),
            ("value", "value", {"optional": True}),
            ("other", "other", {}),
            ("other", "other", {"optional": True}),
            (-1, FAIL, {"optional": True}),
            (0, FAIL, {"optional": True}),
            (1, FAIL, {"optional": True}),
            pytest.param(True, "True", {}, marks=pytest.mark.xfail(reason="...")),
            pytest.param(False, "False", {}, marks=pytest.mark.xfail(reason="...")),
        ]
    """
    hydrated_scenarios = []
    for raw_option in raw_options:
        for mocked_data in data:
            for scenario in scenarios:
                if isinstance(scenario, dict):
                    merged_raw_options = [
                        {**raw_option, **raw_opt}
                        for raw_opt in scenario.get("raw_options", [{}])
                    ]
                    hydrated_scenarios += get_hydrated_scenarios(
                        merged_raw_options,
                        scenario["scenarios"],
                        scenario.get("data", [mocked_data]),
                    )
                elif isinstance(scenario, ParameterSet):
                    intake, output, custom_raw_option = (
                        scenario.values
                        if len(scenario.values) == 3
                        else (*scenario.values, {})
                    )
                    merged_raw_option = {**raw_option, **custom_raw_option}
                    hydrated_scenarios.append(
                        pytest.param(
                            intake,
                            output,
                            merged_raw_option,
                            mocked_data,
                            marks=scenario.marks,
                        )
                    )
                elif isinstance(scenario, tuple):
                    intake, output, custom_raw_option = (
                        scenario if len(scenario) == 3 else (*scenario, {})
                    )
                    merged_raw_option = {**raw_option, **custom_raw_option}
                    hydrated_scenarios.append(
                        (intake, output, merged_raw_option, mocked_data)
                    )
                else:
                    raise Exception(
                        "Test scenario should be tuple(intake, output, raw_option), pytest.param(intake, output, raw_option) or dict(raw_options, scenarios, data)"
                    )

    return hydrated_scenarios


def generate_test_name(intake, output, raw_option, data):
    values_as_str = []
    for value in (intake, output):
        if isinstance(value, str) and value != FAIL:
            values_as_str.append(f"'{value}'")
        elif inspect.isclass(value) and issubclass(value, Exception):
            values_as_str.append(value.__name__)
        else:
            values_as_str.append(value)
    name = f"{values_as_str[0]} -> {values_as_str[1]}"

    keys = [
        "=".join(
            [
                key,
                (
                    str(raw_option[key])
                    if not isinstance(raw_option[key], str)
                    else f"'{raw_option[key]}'"
                ),
            ]
        )
        for key in raw_option.keys()
        if key not in ("id", "type")
    ]
    if keys:
        name += " (" + ",".join(keys) + ")"
    return name


def pytest_generate_tests(metafunc):
    """
    Pytest test factory that, for each `BaseTest` subclasses, parametrize its
    methods if it requires it by checking the method's parameters.
    For those and based on their `cls.scenarios`, a series of `pytest.param` are
    automaticly injected as test values.
    """
    if metafunc.cls and issubclass(metafunc.cls, BaseTest):
        argnames = []
        argvalues = []
        ids = []
        fn_params = inspect.signature(metafunc.function).parameters

        for params in [
            ["intake", "expected_output", "raw_option", "data"],
            ["intake", "expected_normalized", "raw_option", "data"],
            ["intake", "expected_humanized", "raw_option", "data"],
        ]:
            if all(param in fn_params for param in params):
                argnames += params
                if params[1] == "expected_output":
                    # Hydrate scenarios with generic raw_option data
                    argvalues += get_hydrated_scenarios(
                        [metafunc.cls.raw_option], metafunc.cls.scenarios
                    )
                    ids += [
                        (
                            generate_test_name(*args.values)
                            if isinstance(args, ParameterSet)
                            else generate_test_name(*args)
                        )
                        for args in argvalues
                    ]
                elif params[1] == "expected_normalized":
                    argvalues += metafunc.cls.normalized
                    ids += [
                        f"{metafunc.cls.raw_option['type']}-normalize-{scenario[0]}"
                        for scenario in metafunc.cls.normalized
                    ]
                elif params[1] == "expected_humanized":
                    argvalues += metafunc.cls.humanized
                    ids += [
                        f"{metafunc.cls.raw_option['type']}-normalize-{scenario[0]}"
                        for scenario in metafunc.cls.humanized
                    ]

                metafunc.parametrize(argnames, argvalues, ids=ids)


# ╭───────────────────────────────────────────────────────╮
# │ Scenario helpers                                      │
# ╰───────────────────────────────────────────────────────╯

FAIL = YunohostValidationError


def nones(
    *nones, output, raw_option: dict[str, Any] = {}, fail_if_required: bool = True
) -> list[PartialScenario]:
    """
    Returns common scenarios for ~None values.
    - required and required + as default -> `FAIL`
    - optional and optional + as default -> `expected_output=None`
    """
    return [
        (none, FAIL if fail_if_required else output, base_raw_option | raw_option)  # type: ignore
        for none in nones
        for base_raw_option in ({}, {"default": none})
    ] + [
        (none, output, base_raw_option | raw_option)
        for none in nones
        for base_raw_option in ({"optional": True}, {"optional": True, "default": none})
    ]


def unchanged(*args, raw_option: dict[str, Any] = {}) -> list[PartialScenario]:
    """
    Returns a series of params for which output is expected to be the same as its intake

    Example::
        # expect `"value"` to output as `"value"`, etc.
        unchanged("value", "yes", "none")

    """
    return [(arg, arg, raw_option.copy()) for arg in args]


def all_as(*args, output, raw_option: dict[str, Any] = {}) -> list[PartialScenario]:
    """
    Returns a series of params for which output is expected to be the same single value

    Example::
        # expect all values to output as `True`
        all_as("y", "yes", 1, True, output=True)
    """
    return [(arg, output, raw_option.copy()) for arg in args]


def all_fails(
    *args, raw_option: dict[str, Any] = {}, error=FAIL
) -> list[PartialScenario]:
    """
    Returns a series of params for which output is expected to be failing with validation error
    """
    return [(arg, error, raw_option.copy()) for arg in args]


def xpass(*, scenarios: list[Scenario], reason="unknown") -> list[Scenario]:
    """
    Return a pytest param for which test should have fail but currently passes.
    """
    return [
        pytest.param(
            *scenario,
            marks=pytest.mark.xfail(
                reason=f"Currently valid but probably shouldn't. details: {reason}."
            ),
        )
        for scenario in scenarios
    ]


def xfail(*, scenarios: list[Scenario], reason="unknown") -> list[Scenario]:
    """
    Return a pytest param for which test should have passed but currently fails.
    """
    return [
        pytest.param(
            *scenario,
            marks=pytest.mark.xfail(
                reason=f"Currently invalid but should probably pass. details: {reason}."
            ),
        )
        for scenario in scenarios
    ]


# ╭───────────────────────────────────────────────────────╮
# │  ╶┬╴┌─╴╭─╴╶┬╴╭─╴                                      │
# │   │ ├─╴╰─╮ │ ╰─╮                                      │
# │   ╵ ╰─╴╶─╯ ╵ ╶─╯                                      │
# ╰───────────────────────────────────────────────────────╯


def _fill_or_prompt_one_option(raw_option, intake):
    raw_option = raw_option.copy()
    id_ = raw_option.pop("id")
    options = {id_: raw_option}
    answers = {id_: intake} if intake is not None else {}

    options, form = ask_questions_and_parse_answers(options, answers)
    return (options[0], form[id_] if isinstance(options[0], BaseInputOption) else None)


def _test_value_is_expected_output(value, expected_output):
    """
    Properly compares bools and None
    """
    if isinstance(expected_output, bool) or expected_output is None:
        assert value is expected_output
    else:
        assert value == expected_output


def _test_intake(raw_option, intake, expected_output):
    option, value = _fill_or_prompt_one_option(raw_option, intake)

    _test_value_is_expected_output(value, expected_output)


def _test_intake_may_fail(raw_option, intake, expected_output):
    if inspect.isclass(expected_output) and issubclass(expected_output, Exception):
        with pytest.raises(expected_output):
            _fill_or_prompt_one_option(raw_option, intake)
    else:
        _test_intake(raw_option, intake, expected_output)


@pytest.mark.usefixtures("patch_cli_retries")  # To avoid chain error logging
class BaseTest:
    raw_option: dict[str, Any] = {}
    prefill: dict[Literal["raw_option", "prefill", "intake"], Any]
    scenarios: list[Scenario]

    # fmt: off
    # scenarios = [
    #     *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
    #     *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
    #     *nones(None, "", output=""),
    # ]
    # fmt: on
    # TODO
    # - pattern (also on Date for example to see if it override the default pattern)
    # - example
    # - visible
    # - redact
    # - regex
    # - hooks

    @classmethod
    def get_raw_option(cls, raw_option={}, **kwargs):
        base_raw_option = cls.raw_option.copy()
        base_raw_option.update(**raw_option)
        base_raw_option.update(**kwargs)
        return base_raw_option

    @classmethod
    def _test_basic_attrs(self):
        raw_option = self.get_raw_option(optional=True)

        if raw_option["type"] in READONLY_TYPES:
            del raw_option["optional"]

        if raw_option["type"] == "select":
            raw_option["choices"] = ["one"]

        id_ = raw_option["id"]
        option, value = _fill_or_prompt_one_option(raw_option, None)

        is_special_readonly_option = isinstance(option, BaseReadonlyOption)

        assert isinstance(option, OPTIONS[raw_option["type"]])
        assert option.type == raw_option["type"]
        assert option.id == id_
        assert option.ask == id_
        assert option.readonly is (True if is_special_readonly_option else False)
        assert option.visible is True
        # assert option.bind is None

        if is_special_readonly_option:
            assert value is None

        return (raw_option, option, value)

    @pytest.mark.usefixtures("patch_no_tty")
    def test_basic_attrs(self):
        """
        Test basic options factories and BaseOption default attributes values.
        """
        # Intermediate method since pytest doesn't like tests that returns something.
        # This allow a test class to call `_test_basic_attrs` then do additional checks
        self._test_basic_attrs()

    def test_options_prompted_with_ask_help(self, prefill_data=None):
        """
        Test that assert that moulinette prompt is called with:
        - `message` with translated string and possible choices list
        -  help` with translated string
        - `prefill` is the expected string value from a custom default
        - `is_password` is true for `password`s only
        - `is_multiline` is true for `text`s only
        - `autocomplete` is option choices

        Ran only once with `cls.prefill` data
        """
        if prefill_data is None:
            prefill_data = self.prefill

        base_raw_option = prefill_data["raw_option"]
        prefill = prefill_data["prefill"]

        # FIXME could patch prompt with prefill if we switch to "do not apply default if value is None|''"
        with patch_prompt("") as prompt:
            raw_option = self.get_raw_option(
                raw_option=base_raw_option,
                ask={"en": "Can i haz question?"},
                help={"en": "Here's help!"},
            )
            option, value = _fill_or_prompt_one_option(raw_option, None)

            expected_message = option.ask
            choices = []

            if isinstance(option, BaseChoicesOption) and option.choices is not None:
                choices = option.choices
                if choices:
                    expected_message += f" [{' | '.join(choices)}]"
            if option.type == "boolean":
                expected_message += " [yes | no]"

            prompt.assert_called_with(
                message=expected_message,
                is_password=option.type == "password",
                confirm=False,  # FIXME no confirm?
                prefill=prefill,
                is_multiline=option.type == "text",
                autocomplete=choices,
                help=option.help,
            )

    def test_scenarios(self, intake, expected_output, raw_option, data):
        with patch_interface("api"):
            _test_intake_may_fail(
                raw_option,
                intake,
                expected_output,
            )


# ╭───────────────────────────────────────────────────────╮
# │ DISPLAY_TEXT                                          │
# ╰───────────────────────────────────────────────────────╯


class TestDisplayText(BaseTest):
    raw_option = {"type": "display_text", "id": "display_text_id"}
    prefill = {
        "raw_option": {},
        "prefill": " custom default",
    }
    # fmt: off
    scenarios = [
        (None, None, {"ask": "Some text\na new line"}),
        (None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
    ]
    # fmt: on

    def test_options_prompted_with_ask_help(self, prefill_data=None):
        pytest.skip(reason="no prompt for display types")

    def test_scenarios(self, intake, expected_output, raw_option, data):
        _id = raw_option.pop("id")
        answers = {_id: intake} if intake is not None else {}
        options = None
        with patch_interface("cli"):
            if inspect.isclass(expected_output) and issubclass(
                expected_output, Exception
            ):
                with pytest.raises(expected_output):
                    ask_questions_and_parse_answers({_id: raw_option}, answers)
            else:
                with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
                    options, form = ask_questions_and_parse_answers(
                        {_id: raw_option}, answers
                    )
                    assert stdout.getvalue() == f"{options[0].ask}\n"


# ╭───────────────────────────────────────────────────────╮
# │ MARKDOWN                                              │
# ╰───────────────────────────────────────────────────────╯


class TestMarkdown(TestDisplayText):
    raw_option = {"type": "markdown", "id": "markdown_id"}
    # in cli this option is exactly the same as "display_text", no markdown support for now


# ╭───────────────────────────────────────────────────────╮
# │ ALERT                                                 │
# ╰───────────────────────────────────────────────────────╯


class TestAlert(TestDisplayText):
    raw_option = {"type": "alert", "id": "alert_id"}
    prefill = {
        "raw_option": {"ask": " Custom info message"},
        "prefill": " custom default",
    }
    # fmt: off
    scenarios = [
        (None, None, {"ask": "Some text\na new line"}),
        (None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
        *[(None, None, {"ask": "question", "style": style}) for style in ("success", "info", "warning", "danger")],
        (None, YunohostError, {"ask": "question", "style": "nimp"}),
    ]
    # fmt: on

    def test_scenarios(self, intake, expected_output, raw_option, data):
        style = raw_option.get("style", "info")
        colors = {"danger": "31", "warning": "33", "info": "36", "success": "32"}
        answers = {"alert_id": intake} if intake is not None else {}

        with patch_interface("cli"):
            if inspect.isclass(expected_output) and issubclass(
                expected_output, Exception
            ):
                with pytest.raises(expected_output):
                    ask_questions_and_parse_answers(
                        {"display_text_id": raw_option}, answers
                    )
            else:
                with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
                    options, form = ask_questions_and_parse_answers(
                        {"display_text_id": raw_option}, answers
                    )
                    ask = options[0].ask
                    if style in colors:
                        color = colors[style]
                        title = style.title() + (":" if style != "success" else "!")
                        assert (
                            stdout.getvalue()
                            == f"\x1b[{color}m\x1b[1m{title}\x1b[m {ask}\n"
                        )
                    else:
                        # FIXME should fail
                        stdout.getvalue() == f"{ask}\n"


# ╭───────────────────────────────────────────────────────╮
# │ BUTTON                                                │
# ╰───────────────────────────────────────────────────────╯


# TODO


# ╭───────────────────────────────────────────────────────╮
# │ STRING                                                │
# ╰───────────────────────────────────────────────────────╯


class TestString(BaseTest):
    raw_option = {"type": "string", "id": "string_id"}
    prefill = {
        "raw_option": {"default": " custom default"},
        "prefill": " custom default",
    }
    # fmt: off
    scenarios = [
        *nones(None, "", output=""),
        # basic typed values
        (False, "False"),
        (True, "True"),
        (0, "0"),
        (1, "1"),
        (-1, "-1"),
        (1337, "1337"),
        (13.37, "13.37"),
        *all_fails([], ["one"], {}),
        *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
        # test strip
        ("value", "value"),
        ("value\n", "value"),
        ("  \n value\n", "value"),
        ("  \\n value\\n", "\\n value\\n"),
        ("  \tvalue\t", "value"),
        (r" ##value \n \tvalue\n  ", r"##value \n \tvalue\n"),
        *xpass(scenarios=[
            ("value\nvalue", "value\nvalue"),
            (" ##value \n \tvalue\n  ", "##value \n \tvalue"),
        ], reason=r"should fail or without `\n`?"),
        # readonly
        ("overwrite", "expected value", {"readonly": True, "default": "expected value"}),  # FIXME do we want to fail instead?
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ TEXT                                                  │
# ╰───────────────────────────────────────────────────────╯


class TestText(BaseTest):
    raw_option = {"type": "text", "id": "text_id"}
    prefill = {
        "raw_option": {"default": "some value\nanother line "},
        "prefill": "some value\nanother line ",
    }
    # fmt: off
    scenarios = [
        *nones(None, "", output=""),
        # basic typed values
        (False, "False"),
        (True, "True"),
        (0, "0"),
        (1, "1"),
        (-1, "-1"),
        (1337, "1337"),
        (13.37, "13.37"),
        *all_fails([], ["one"], {}),
        *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
        ("value", "value"),
        ("value\n value", "value\n value"),
        ("value", FAIL, {"pattern": {"regexp": r'^[A-F]\d\d$', "error": "Provide a room like F12 : one uppercase and 2 numbers"}}),
        ("F12", "F12", {"pattern": {"regexp": r'^[A-F]\d\d$', "error": "Provide a room like F12 : one uppercase and 2 numbers"}}),
        # test no strip
        *xpass(scenarios=[
            ("value\n", "value"),
            ("  \n value\n", "value"),
            ("  \\n value\\n", "\\n value\\n"),
            ("  \tvalue\t", "value"),
            (" ##value \n \tvalue\n  ", "##value \n \tvalue"),
            (r" ##value \n \tvalue\n  ", r"##value \n \tvalue\n"),
        ], reason="Should not be stripped"),
        # readonly
        ("overwrite", "expected value", {"readonly": True, "default": "expected value"}),
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ PASSWORD                                              │
# ╰───────────────────────────────────────────────────────╯


class TestPassword(BaseTest):
    raw_option = {"type": "password", "id": "password_id"}
    prefill = {
        "raw_option": {"default": None, "optional": True},
        "prefill": "",
    }
    # fmt: off
    scenarios = [
        *all_fails(False, True, 0, 1, -1, 1337, 13.37, raw_option={"optional": True}),
        *all_fails([], ["one"], {}, raw_option={"optional": True}, error=AttributeError),  # FIXME those fails with AttributeError
        *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
        *nones(None, "", output=""),
        ("s3cr3t!!", YunohostError, {"default": "SUPAs3cr3t!!"}),  # default is forbidden
        *xpass(scenarios=[
            ("s3cr3t!!", "s3cr3t!!", {"example": "SUPAs3cr3t!!"}),  # example is forbidden
        ], reason="Should fail; example is forbidden"),
        *xpass(scenarios=[
            (" value \n moarc0mpl1cat3d\n  ", "value \n moarc0mpl1cat3d"),
            (" some_ value", "some_ value"),
        ], reason="Should output exactly the same"),
        ("s3cr3t!!", "s3cr3t!!"),
        ("secret", FAIL),
        *[("supersecret" + char, FAIL) for char in FORBIDDEN_PASSWORD_CHARS],  # FIXME maybe add ` \n` to the list?
        # readonly
        ("s3cr3t!!", YunohostError, {"readonly": True}),  # readonly is forbidden
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ COLOR                                                 │
# ╰───────────────────────────────────────────────────────╯


class TestColor(BaseTest):
    raw_option = {"type": "color", "id": "color_id"}
    prefill = {
        "raw_option": {"default": "red"},
        "prefill": "red",
    }
    # fmt: off
    scenarios = [
        *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
        *all_fails("none", "_none", "False", "True", "0", "1", "-1", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
        *nones(None, "", output=""),
        # custom valid
        (" #fe1  ", "#fe1"),
        ("#000000", "#000"),
        ("#000", "#000"),
        ("#ABCDEF", "#abcdef"),
        ('1337', "#1337"),  # rgba=(17, 51, 51, 0.47)
        ("000000", "#000"),
        ("#feaf", "#fea"),  # `#feaf` is `#fea` with alpha at `f|100%` -> equivalent to `#fea`
        # named
        ("red", "#f00"),
        ("yellow", "#ff0"),
        # custom fail
        ("#12", FAIL),
        ("#gggggg", FAIL),
        ("#01010101af", FAIL),
        # readonly
        ("#ffff00", "#000", {"readonly": True, "default": "#000"}),
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ NUMBER | RANGE                                        │
# ╰───────────────────────────────────────────────────────╯
# Testing only number since "range" is only for webadmin (slider instead of classic intake).


class TestNumber(BaseTest):
    raw_option = {"type": "number", "id": "number_id"}
    prefill = {
        "raw_option": {"default": 10},
        "prefill": "10",
    }
    # fmt: off
    scenarios = [
        *all_fails([], ["one"], {}),
        *all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value"),

        *nones(None, "", output=None),
        *unchanged(0, 1, -1, 1337),
        *all_as(False, "0", 0, output=0),  # FIXME should `False` fail instead?
        *all_as(True, "1", 1, output=1),  # FIXME should `True` fail instead?
        *all_as("1337", 1337, output=1337),
        *xfail(scenarios=[
            ("-1", -1)
        ], reason="should output as `-1` instead of failing"),
        *all_fails(13.37, "13.37"),

        *unchanged(10, 5000, 10000, raw_option={"min": 10, "max": 10000}),
        *all_fails(9, 10001, raw_option={"min": 10, "max": 10000}),

        *all_as(None, "", output=0, raw_option={"default": 0}),
        *all_as(None, "", output=0, raw_option={"default": 0, "optional": True}),
        (-10, -10, {"default": 10}),
        (-10, -10, {"default": 10, "optional": True}),
        # readonly
        (1337, 10000, {"readonly": True, "default": "10000"}),
    ]
    # fmt: on
    # FIXME should `step` be some kind of "multiple of"?


# ╭───────────────────────────────────────────────────────╮
# │ BOOLEAN                                               │
# ╰───────────────────────────────────────────────────────╯


class TestBoolean(BaseTest):
    raw_option = {"type": "boolean", "id": "boolean_id"}
    prefill = {
        "raw_option": {"default": True},
        "prefill": "yes",
    }
    # fmt: off
    truthy_values = (True, 1, "1", "True", "true", "Yes", "yes", "y", "on")
    falsy_values = (False, 0, "0", "False", "false", "No", "no", "n", "off")
    scenarios = [
        *all_as(None, "", output=0),
        *all_fails("none", "None"),  # FIXME should output as `0` (default) like other none values when required?
        *all_as(None, "", output=0, raw_option={"optional": True}),  # FIXME should output as `None`?
        *all_as("none", "None", output=None, raw_option={"optional": True}),
        {
            "raw_options": [
                {"default": None},
                {"default": ""},
                {"default": "none"},
                {"default": "None"}
            ],
            "scenarios": [
                # All none values fails if default is overriden
                *all_fails(None, "", "none", "None"),
                # All none values ends up as None if default is overriden
                *all_as(None, "", "none", "None", output=None, raw_option={"optional": True}),
            ]
        },
        # Unhandled types should fail
        *all_fails(1337, "1337", "string", [], "[]", ",", "one,two"),
        *all_fails(1337, "1337", "string", [], "[]", ",", "one,two", {"optional": True}),
        # Required
        *all_as(*truthy_values, output=1),
        *all_as(*falsy_values, output=0),
        # Optional
        *all_as(*truthy_values, output=1, raw_option={"optional": True}),
        *all_as(*falsy_values, output=0, raw_option={"optional": True}),
        # test values as default, as required option without intake
        *[(None, 1, {"default": true for true in truthy_values})],
        *[(None, 0, {"default": false for false in falsy_values})],
        # custom boolean output
        ("", "disallow", {"yes": "allow", "no": "disallow"}),  # required -> default to False -> `"disallow"`
        ("n", "disallow", {"yes": "allow", "no": "disallow"}),
        ("y", "allow", {"yes": "allow", "no": "disallow"}),
        ("", False, {"yes": True, "no": False}),  # required -> default to False -> `False`
        ("n", False, {"yes": True, "no": False}),
        ("y", True, {"yes": True, "no": False}),
        ("", -1, {"yes": 1, "no": -1}),  # required -> default to False -> `-1`
        ("n", -1, {"yes": 1, "no": -1}),
        ("y", 1, {"yes": 1, "no": -1}),
        {
            "raw_options": [
                {"yes": "no", "no": "yes", "optional": True},
                {"yes": False, "no": True, "optional": True},
                {"yes": "0", "no": "1", "optional": True},
            ],
            # "no" for "yes" and "yes" for "no" should fail
            "scenarios": all_fails("", "y", "n", error=AssertionError),
        },
        # readonly
        (1, 0, {"readonly": True, "default": 0}),
    ]


# ╭───────────────────────────────────────────────────────╮
# │ DATE                                                  │
# ╰───────────────────────────────────────────────────────╯


class TestDate(BaseTest):
    raw_option = {"type": "date", "id": "date_id"}
    prefill = {
        "raw_option": {"default": "2024-12-29"},
        "prefill": "2024-12-29",
    }
    # fmt: off
    scenarios = [
        # Those passes since False|True are parsed as 0|1 then int|float are considered a timestamp in seconds which ends up as default Unix date
        *all_as(False, True, 0, 1, 1337, 13.37, "0", "1", "1337", "13.37", output="1970-01-01"),
        # Those are negative one second timestamp ending up as Unix date - 1 sec (so day change)
        *all_as(-1, "-1", output="1969-12-31"),
        *all_fails([], ["one"], {}, raw_option={"optional": True}),
        *all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
        *nones(None, "", output=""),
        # custom valid
        ("2070-12-31", "2070-12-31"),
        ("2024-02-29", "2024-02-29"),
        *xfail(scenarios=[
            ("2025-06-15T13:45:30", "2025-06-15"),
            ("2025-06-15 13:45:30", "2025-06-15")
        ], reason="iso date repr should be valid and extra data striped"),
        (1749938400, "2025-06-14"),
        (1749938400.0, "2025-06-14"),
        ("1749938400", "2025-06-14"),
        ("1749938400.0", "2025-06-14"),
        # custom invalid
        ("29-12-2070", FAIL),
        ("12-01-10", FAIL),
        ("2022-02-29", FAIL),
        # readonly
        ("2070-12-31", "2024-02-29", {"readonly": True, "default": "2024-02-29"}),
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ TIME                                                  │
# ╰───────────────────────────────────────────────────────╯


class TestTime(BaseTest):
    raw_option = {"type": "time", "id": "time_id"}
    prefill = {
        "raw_option": {"default": "12:26"},
        "prefill": "12:26",
    }
    # fmt: off
    scenarios = [
        # Those passes since False|True are parsed as 0|1 then int|float are considered a timestamp in seconds but we don't take seconds into account so -> 00:00
        *all_as(False, True, 0, 1, 13.37, "0", "1", "13.37", output="00:00"),
        # 1337 seconds == 22 minutes
        *all_as(1337, "1337", output="00:22"),
        # Negative timestamp fails
        *all_fails(-1, "-1", error=OverflowError),  # FIXME should handle that as a validation error
        # *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
        *all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
        *nones(None, "", output=""),
        # custom valid
        *unchanged("00:00", "08:00", "12:19", "20:59", "23:59"),
        ("3:00", "03:00"),
        ("23:1", "23:01"),
        ("22:35:05", "22:35"),
        ("22:35:03.514", "22:35"),
        # custom invalid
        ("24:00", FAIL),
        ("23:005", FAIL),
        # readonly
        ("00:00", "08:00", {"readonly": True, "default": "08:00"}),
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ EMAIL                                                 │
# ╰───────────────────────────────────────────────────────╯


class TestEmail(BaseTest):
    raw_option = {"type": "email", "id": "email_id"}
    prefill = {
        "raw_option": {"default": "Abc@example.tld"},
        "prefill": "Abc@example.tld",
    }
    # fmt: off
    scenarios = [
        *all_fails(False, True, 0, 1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
        *all_fails("none", "_none", "False", "True", "0", "1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),

        *nones(None, "", output=""),
        ("\n Abc@example.tld  ", "Abc@example.tld"),
        *xfail(scenarios=[("admin@ynh.local", "admin@ynh.local")], reason="Should this pass?"),
        # readonly
        ("Abc@example.tld", "admin@ynh.org", {"readonly": True, "default": "admin@ynh.org"}),

        # Next examples are from https://github.com/JoshData/python-email-validator/blob/main/tests/test_syntax.py
        # valid email values
        *unchanged(
            "Abc@example.tld",
            "Abc.123@test-example.com",
            "user+mailbox/department=shipping@example.tld",
            "伊昭傑@郵件.商務",
            "राम@मोहन.ईन्फो",
            "юзер@екзампл.ком",
            "θσερ@εχαμπλε.ψομ",
            "葉士豪@臺網中心.tw",
            "jeff@臺網中心.tw",
            "葉士豪@臺網中心.台灣",
            "jeff葉@臺網中心.tw",
            "ñoñó@example.tld",
            "甲斐黒川日本@example.tld",
            "чебурашкаящик-с-апельсинами.рф@example.tld",
            "उदाहरण.परीक्ष@domain.with.idn.tld",
            "ιωάννης@εεττ.gr",
        ),
        # invalid email (Hiding because our current regex is very permissive)
        *all_fails(
            "my@localhost",
            "my@.leadingdot.com",
            "my@．leadingfwdot.com",
            "my@twodots..com",
            "my@twofwdots．．.com",
            "my@trailingdot.com.",
            "my@trailingfwdot.com．",
            "me@-leadingdash",
            "me@－leadingdashfw",
            "me@trailingdash-",
            "me@trailingdashfw－",
            "my@baddash.-.com",
            "my@baddash.-a.com",
            "my@baddash.b-.com",
            "my@baddashfw.－.com",
            "my@baddashfw.－a.com",
            "my@baddashfw.b－.com",
            "my@example\n.com",
            "me@x!",
            "me@x ",
            ".leadingdot@domain.com",
            "twodots..here@domain.com",
            "trailingdot.@domain.email",
            "me@⒈wouldbeinvalid.com",
            "@example.com",
            "m\ny@example.com",
            "my\n@example.com",
            "11111111112222222222333333333344444444445555555555666666666677777@example.com",
            "111111111122222222223333333333444444444455555555556666666666777777@example.com",
            "me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444444444455555555556.com",
            "me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com",
            "me@中1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com",
            "my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info",
            "my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333.info",
            "my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info",
            "my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info",
            "my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info",
            "me@bad-tld-1",
            "me@bad.tld-2",
            "me@xn--0.tld",
            "me@yy--0.tld",
            "me@yy－－0.tld",
        )
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ PATH                                                  │
# ╰───────────────────────────────────────────────────────╯


class TestWebPath(BaseTest):
    raw_option = {"type": "path", "id": "path_id"}
    prefill = {
        "raw_option": {"default": "some_path"},
        "prefill": "some_path",
    }
    # fmt: off
    scenarios = [
        *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),

        *nones(None, "", output=""),
        # custom valid
        ("/", "/"),
        ("/one/two", "/one/two"),
        *[
            (v, "/" + v)
            for v in ("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value")
        ],
        ("value\n", "/value"),
        ("//value", "/value"),
        ("///value///", "/value"),
        *xpass(scenarios=[
            ("value\nvalue", "/value\nvalue"),
            ("value value", "/value value"),
            ("value//value", "/value//value"),
        ], reason="Should fail"),
        *xpass(scenarios=[
            ("./here", "/./here"),
            ("../here", "/../here"),
            ("/somewhere/../here", "/somewhere/../here"),
        ], reason="Should fail or flattened"),

        *xpass(scenarios=[
            ("/one?withquery=ah", "/one?withquery=ah"),
        ], reason="Should fail or query string removed"),
        *xpass(scenarios=[
            ("https://example.com/folder", "/https://example.com/folder")
        ], reason="Should fail or scheme+domain removed"),
        # readonly
        ("/overwrite", "/value", {"readonly": True, "default": "/value"}),
        # FIXME should path have forbidden_chars?
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ URL                                                   │
# ╰───────────────────────────────────────────────────────╯


class TestUrl(BaseTest):
    raw_option = {"type": "url", "id": "url_id"}
    prefill = {
        "raw_option": {"default": "https://domain.tld"},
        "prefill": "https://domain.tld",
    }
    # fmt: off
    scenarios = [
        *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
        *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),

        *nones(None, "", output=""),
        ("http://some.org/folder/file.txt", "http://some.org/folder/file.txt"),
        ('  https://www.example.com \n', 'https://www.example.com'),
        # readonly
        ("https://overwrite.org", "https://example.org", {"readonly": True, "default": "https://example.org"}),
        # rest is taken from https://github.com/pydantic/pydantic/blob/main/tests/test_networks.py
        # valid
        *unchanged(
            # Those are valid but not sure how they will output with pydantic
            'http://example.org',
            'https://example.org/whatever/next/',
            'https://example.org',

            'https://foo_bar.example.com/',
            'http://example.co.jp',
            'http://www.example.com/a%C2%B1b',
            'http://www.example.com/~username/',
            'http://info.example.com?fred',
            'http://info.example.com/?fred',
            'http://xn--mgbh0fb.xn--kgbechtv/',
            'http://example.com/blue/red%3Fand+green',
            'http://www.example.com/?array%5Bkey%5D=value',
            'http://xn--rsum-bpad.example.org/',
            'http://123.45.67.8/',
            'http://123.45.67.8:8329/',
            'http://[2001:db8::ff00:42]:8329',
            'http://[2001::1]:8329',
            'http://[2001:db8::1]/',
            'http://www.example.com:8000/foo',
            'http://www.cwi.nl:80/%7Eguido/Python.html',
            'https://www.python.org/путь',
            'http://андрей@example.com',
            'https://exam_ple.com/',
            'http://twitter.com/@handle/',
            'http://11.11.11.11.example.com/action',
            'http://abc.11.11.11.11.example.com/action',
            'http://example.org/path#',
            'http://example.org/path#fragment',
            'http://example.org/path?query#',
            'http://example.org/path?query#fragment',
            'https://foo_bar.example.com/',
            'https://exam_ple.com/',
            'HTTP://EXAMPLE.ORG',
            'https://example.org',
            'https://example.org?a=1&b=2',
            'https://example.org#a=3;b=3',
            'https://example.xn--p1ai',
            'https://example.xn--vermgensberatung-pwb',
            'https://example.xn--zfr164b',
        ),
        *xfail(scenarios=[
            ('http://test', 'http://test'),
            ('http://localhost', 'http://localhost'),
            ('http://localhost/', 'http://localhost/'),
            ('http://localhost:8000', 'http://localhost:8000'),
            ('http://localhost:8000/', 'http://localhost:8000/'),
            ('http://example#', 'http://example#'),
            ('http://example/#', 'http://example/#'),
            ('http://example/#fragment', 'http://example/#fragment'),
            ('http://example/?#', 'http://example/?#'),
        ], reason="Should this be valid?"),
        # invalid
        *all_fails(
            'ftp://example.com/',
            "$https://example.org",
            "../icons/logo.gif",
            "abc",
            "..",
            "/",
            "+http://example.com/",
            "ht*tp://example.com/",
            "http:///",
            "http://??",
            "https://example.org more",
            "http://2001:db8::ff00:42:8329",
            "http://[192.168.1.1]:8329",
            "http://example.com:99999",
        ),
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ FILE                                                  │
# ╰───────────────────────────────────────────────────────╯


@pytest.fixture
def file_clean():
    FileOption.clean_upload_dirs()
    yield
    FileOption.clean_upload_dirs()


@contextmanager
def patch_file_cli(intake):
    upload_dir = tempfile.mkdtemp(prefix="ynh_test_option_file")
    _, filename = tempfile.mkstemp(dir=upload_dir)
    with open(filename, "w") as f:
        f.write(intake)

    yield filename
    os.system(f"rm -f {filename}")


@contextmanager
def patch_file_api(intake):
    from base64 import b64encode

    with patch_interface("api"):
        yield b64encode(intake.encode())


def _test_file_intake_may_fail(raw_option, intake, expected_output):
    if inspect.isclass(expected_output) and issubclass(expected_output, Exception):
        with pytest.raises(expected_output):
            _fill_or_prompt_one_option(raw_option, intake)

    option, value = _fill_or_prompt_one_option(raw_option, intake)

    # The file is supposed to be copied somewhere else
    assert value != intake
    assert value.startswith("/tmp/ynh_filequestion_")
    assert os.path.exists(value)
    with open(value) as f:
        assert f.read() == expected_output

    FileOption.clean_upload_dirs()

    assert not os.path.exists(value)


file_content1 = "helloworld"
file_content2 = """
{
    "testy": true,
    "test": ["one"]
}
"""


class TestFile(BaseTest):
    raw_option = {"type": "file", "id": "file_id"}
    # Prefill data is generated in `cls.test_options_prompted_with_ask_help`
    # fmt: off
    scenarios = [
        *nones(None, "", output=""),
        *unchanged(file_content1, file_content2),
        # other type checks are done in `test_wrong_intake`
    ]
    # fmt: on
    # TODO test readonly
    # TODO test accept

    @pytest.mark.usefixtures("patch_no_tty")
    def test_basic_attrs(self):
        raw_option, option, value = self._test_basic_attrs()

        accept = raw_option.get("accept", None)  # accept default
        assert option.accept == accept

    def test_options_prompted_with_ask_help(self):
        with patch_file_cli(file_content1) as default_filename:
            super().test_options_prompted_with_ask_help(
                prefill_data={
                    "raw_option": {
                        "default": default_filename,
                    },
                    "prefill": default_filename,
                }
            )

    @pytest.mark.usefixtures("file_clean")
    def test_scenarios(self, intake, expected_output, raw_option, data):
        if intake in (None, ""):
            with patch_prompt(intake):
                _test_intake_may_fail(raw_option, None, expected_output)
            with patch_isatty(False):
                _test_intake_may_fail(raw_option, intake, expected_output)
        else:
            with patch_file_cli(intake) as filename:
                with patch_prompt(filename):
                    _test_file_intake_may_fail(raw_option, None, expected_output)
            with patch_file_api(intake) as b64content:
                with patch_isatty(False):
                    _test_file_intake_may_fail(raw_option, b64content, expected_output)

    @pytest.mark.parametrize(
        "path",
        [
            "/tmp/inexistant_file.txt",
            "/tmp",
            "/tmp/",
        ],
    )
    def test_wrong_cli_filename(self, path):
        with patch_prompt(path):
            with pytest.raises(YunohostValidationError):
                _fill_or_prompt_one_option(self.raw_option, None)

    @pytest.mark.parametrize(
        "intake",
        [
            # fmt: off
            False,
            True,
            0,
            1,
            -1,
            1337,
            13.37,
            [],
            ["one"],
            {},
            "none",
            "_none",
            "False",
            "True",
            "0",
            "1",
            "-1",
            "1337",
            "13.37",
            "[]",
            ",",
            "['one']",
            "one,two",
            r"{}",
            "value",
            "value\n",
            # fmt: on
        ],
    )
    def test_wrong_intake(self, intake):
        with pytest.raises(YunohostValidationError):
            with patch_prompt(intake):
                _fill_or_prompt_one_option(self.raw_option, None)
            with patch_isatty(False):
                _fill_or_prompt_one_option(self.raw_option, intake)


# ╭───────────────────────────────────────────────────────╮
# │ SELECT                                                │
# ╰───────────────────────────────────────────────────────╯


class TestSelect(BaseTest):
    raw_option = {"type": "select", "id": "select_id"}
    prefill = {
        "raw_option": {"default": "one", "choices": ["one", "two"]},
        "prefill": "one",
    }
    # fmt: off
    scenarios = [
        {
            # ["one", "two"]
            "raw_options": [
                {"choices": ["one", "two"]},
                {"choices": {"one": "verbose one", "two": "verbose two"}},
            ],
            "scenarios": [
                *nones(None, "", output=""),
                *unchanged("one", "two"),
                ("three", FAIL),
            ]
        },
        # custom bash style list as choices (only strings for now)
        ("one", "one", {"choices": "one,two"}),
        {
            # [-1, 0, 1]
            "raw_options": [
                {"choices": [-1, 0, 1, 10]},
            ],
            "scenarios": [
                *nones(None, "", output=""),
                *unchanged(-1, 0, 1, 10),
                *xfail(scenarios=[
                    ("-1", -1),
                    ("0", 0),
                    ("1", 1),
                    ("10", 10),
                ], reason="str -> int not handled"),
                *all_fails("100", 100),
            ]
        },
        {
            "raw_options": [
                {"choices": {-1: "verbose -one", 0: "verbose zero", 1: "verbose one", 10: "verbose ten"}},
                {"choices": {"-1": "verbose -one", "0": "verbose zero", "1": "verbose one", "10": "verbose ten"}},
            ],
            "scenarios": [
                *nones(None, "", output=""),
                *all_fails(-1, 0, 1, 10),  # Should pass? converted to str?
                *unchanged("-1", "0", "1", "10"),
                *all_fails("100", 100),
            ]
        },
        # [True, False, None]
        *unchanged(True, False, raw_option={"choices": [True, False, None]}),  # FIXME we should probably forbid None in choices
        (None, FAIL, {"choices": [True, False, None]}),
        {
            # mixed types
            "raw_options": [{"choices": ["one", 2, True]}],
            "scenarios": [
                *xpass(scenarios=[
                    ("one", "one"),
                    (2, 2),
                    (True, True),
                ], reason="mixed choices, should fail"),
                *all_fails("2", "True", "y"),
            ]
        },
        {
            "raw_options": [{"choices": ""}, {"choices": []}],
            "scenarios": [
                # FIXME those should fail at option level (wrong default, dev error)
                *all_fails(None, ""),
                *xpass(scenarios=[
                    ("", "", {"optional": True}),
                    (None, "", {"optional": True}),
                ], reason="empty choices, should fail at option instantiation"),
            ]
        },
        # readonly
        ("one", "two", {"readonly": True, "choices": ["one", "two"], "default": "two"}),
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ TAGS                                                  │
# ╰───────────────────────────────────────────────────────╯


#  [], ["one"], {}
class TestTags(BaseTest):
    raw_option = {"type": "tags", "id": "tags_id"}
    prefill = {
        "raw_option": {"default": ["one", "two"]},
        "prefill": "one,two",
    }
    # fmt: off
    scenarios = [
        *nones(None, [], "", ",", output=""),
        {
            "raw_options": [
                {},
                {"choices": ["one", "two"]}
            ],
            "scenarios": [
                *unchanged("one", "one,two"),
                (["one"], "one"),
                (["one", "two"], "one,two"),
            ]
        },
        ("three", FAIL, {"choices": ["one", "two"]}),
        *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", "['one']", "one,two", r"{}", "value"),
        (" value\n", "value"),
        ([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], "False,True,-1,0,1,1337,13.37,[],['one'],{}"),
        *(([t], str(t)) for t in (False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {})),
        # basic types (not in a list) should fail
        *all_fails(True, False, -1, 0, 1, 1337, 13.37, {}),
        # Mixed choices should fail
        ([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], YunohostError, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
        ("False,True,-1,0,1,1337,13.37,[],['one'],{}", YunohostError, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
        *all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}, error=YunohostError),
        *all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}, error=YunohostError),
        # readonly
        ("one", "one,two", {"readonly": True, "choices": ["one", "two"], "default": "one,two"}),
    ]
    # fmt: on


# ╭───────────────────────────────────────────────────────╮
# │ DOMAIN                                                │
# ╰───────────────────────────────────────────────────────╯

main_domain = "ynh.local"
domains1 = ["ynh.local"]
domains2 = ["another.org", "ynh.local", "yet.another.org"]


@contextmanager
def patch_domains(*, domains, main_domain):
    """
    Data mocking for DomainOption:
    - yunohost.domain.domain_list
    """
    with (
        patch.object(
            domain,
            "domain_list",
            return_value={"domains": domains, "main": main_domain},
        ),
        patch.object(domain, "_get_maindomain", return_value=main_domain),
    ):
        yield


class TestDomain(BaseTest):
    raw_option = {"type": "domain", "id": "domain_id"}
    prefill = {
        "raw_option": {
            "default": None,
        },
        "prefill": main_domain,
    }
    # fmt: off
    scenarios = [
        # Probably not needed to test common types since those are not available as choices
        # Also no scenarios with no domains since it should not be possible
        {
            "data": [{"main_domain": domains1[0], "domains": domains1}],
            "scenarios": [
                *nones(None, "", output=domains1[0], fail_if_required=False),
                (domains1[0], domains1[0], {}),
                ("doesnt_exist.pouet", FAIL, {}),
                ("fake.com", FAIL, {"choices": ["fake.com"]}),
                # readonly
                (domains1[0], YunohostError, {"readonly": True}),  # readonly is forbidden
            ]
        },
        {
            "data": [{"main_domain": domains2[1], "domains": domains2}],
            "scenarios": [
                *nones(None, "", output=domains2[1], fail_if_required=False),
                (domains2[1], domains2[1], {}),
                (domains2[0], domains2[0], {}),
                ("doesnt_exist.pouet", FAIL, {}),
                ("fake.com", FAIL, {"choices": ["fake.com"]}),
            ]
        },

    ]
    # fmt: on

    def test_options_prompted_with_ask_help(self, prefill_data=None):
        with patch_domains(domains=[main_domain], main_domain=main_domain):
            super().test_options_prompted_with_ask_help(prefill_data=prefill_data)

    def test_scenarios(self, intake, expected_output, raw_option, data):
        with patch_domains(**data):
            super().test_scenarios(intake, expected_output, raw_option, data)


# ╭───────────────────────────────────────────────────────╮
# │ APP                                                   │
# ╰───────────────────────────────────────────────────────╯

installed_webapp = {
    "is_default": True,
    "name": "My webapp",
    "id": "my_webapp",
    "domain_path": "/ynh-dev",
}
installed_non_webapp = {
    "is_default": False,
    "name": "My non webapp",
    "id": "my_non_webapp",
}


@contextmanager
def patch_apps(*, apps):
    """
    Data mocking for AppOption:
    - yunohost.app.app_list
    """
    with patch.object(app, "app_list", return_value={"apps": apps}):
        yield


class TestApp(BaseTest):
    raw_option = {"type": "app", "id": "app_id"}
    # fmt: off
    scenarios = [
        # Probably not needed to test common types since those are not available as choices
        {
            "data": [
                {"apps": []},
                {"apps": [installed_webapp]},
                {"apps": [installed_webapp, installed_non_webapp]},
            ],
            "scenarios": [
                # FIXME there are currently 3 different nones (`None`, `""` and `_none`), choose one?
                *nones(None, "", output=""),  # FIXME Should return chosen none?
                *xpass(scenarios=[
                    ("_none", "_none"),
                    ("_none", "_none", {"default": "_none"}),
                ], reason="should fail; is required"),
                *xpass(scenarios=[
                    ("_none", "_none", {"optional": True}),
                    ("_none", "_none", {"optional": True, "default": "_none"})
                ], reason="Should output chosen none value"),
                ("fake_app", FAIL),
                ("fake_app", FAIL, {"choices": ["fake_app"]}),
            ]
        },
        {
            "data": [
                {"apps": [installed_webapp]},
                {"apps": [installed_webapp, installed_non_webapp]},
            ],
            "scenarios": [
                (installed_webapp["id"], installed_webapp["id"]),
                (installed_webapp["id"], installed_webapp["id"], {"filter": "is_webapp"}),
                (installed_webapp["id"], FAIL, {"filter": "is_webapp == false"}),
                (installed_webapp["id"], FAIL, {"filter": "id != 'my_webapp'"}),
                (None, "", {"filter": "id == 'fake_app'", "optional": True}),
            ]
        },
        {
            "data": [{"apps": [installed_webapp, installed_non_webapp]}],
            "scenarios": [
                (installed_non_webapp["id"], installed_non_webapp["id"]),
                (installed_non_webapp["id"], FAIL, {"filter": "is_webapp"}),
                # readonly
                (installed_non_webapp["id"], YunohostError, {"readonly": True}),  # readonly is forbidden
            ]
        },
    ]
    # fmt: on

    @pytest.mark.usefixtures("patch_no_tty")
    def test_basic_attrs(self):
        with patch_apps(apps=[]):
            raw_option, option, value = self._test_basic_attrs()

            assert option.choices == {"_none": "---"}
            assert option.filter is None

        with patch_apps(apps=[installed_webapp, installed_non_webapp]):
            raw_option, option, value = self._test_basic_attrs()

            assert option.choices == {
                "_none": "---",
                "my_webapp": "My webapp (/ynh-dev)",
                "my_non_webapp": "My non webapp (my_non_webapp)",
            }
            assert option.filter is None

    def test_options_prompted_with_ask_help(self, prefill_data=None):
        with patch_apps(apps=[installed_webapp, installed_non_webapp]):
            super().test_options_prompted_with_ask_help(
                prefill_data={
                    "raw_option": {"default": installed_webapp["id"]},
                    "prefill": installed_webapp["id"],
                }
            )
            super().test_options_prompted_with_ask_help(
                prefill_data={"raw_option": {"optional": True}, "prefill": ""}
            )

    def test_scenarios(self, intake, expected_output, raw_option, data):
        with patch_apps(**data):
            super().test_scenarios(intake, expected_output, raw_option, data)


# ╭───────────────────────────────────────────────────────╮
# │ USER                                                  │
# ╰───────────────────────────────────────────────────────╯

admin_username = "admin_user"
admin_user = {
    "username": admin_username,
    "mail": "a@ynh.local",
    "fullname": "john doe",
    "groups": ["admins"],
}
regular_username = "normal_user"
regular_user = {
    "username": regular_username,
    "mail": "z@ynh.local",
    "fullname": "john doe",
    "groups": [],
}


@contextmanager
def patch_users(*, users):
    """
    Data mocking for UserOption:
    - yunohost.user.user_list
    - yunohost.user.user_info
    - yunohost.domain._get_maindomain
    """
    with patch.object(user, "user_list", return_value={"users": users}):
        yield


class TestUser(BaseTest):
    raw_option = {"type": "user", "id": "user_id"}
    # fmt: off
    scenarios = [
        # No tests for empty users since it should not happens
        {
            "data": [
                {"users": {admin_username: admin_user}},
                {"users": {admin_username: admin_user, regular_username: regular_user}},
            ],
            "scenarios": [
                # FIXME User option is not really nullable, even if optional
                *nones(None, "", output=admin_username, fail_if_required=False),
                ("fake_user", FAIL),
                ("fake_user", FAIL, {"choices": ["fake_user"]}),
            ]
        },
        {
            "data": [
                {"users": {admin_username: admin_user, regular_username: regular_user}},
            ],
            "scenarios": [
                *xpass(scenarios=[
                    ("", regular_username, {"default": regular_username})
                ], reason="Should throw 'no default allowed'"),
                # readonly
                (admin_username, YunohostError, {"readonly": True}),  # readonly is forbidden
            ]
        },
    ]
    # fmt: on

    @pytest.mark.usefixtures("patch_no_tty")
    def test_basic_attrs(self):
        with patch_users(users={admin_username: admin_user}):
            self._test_basic_attrs()

    def test_options_prompted_with_ask_help(self, prefill_data=None):
        with patch_users(
            users={admin_username: admin_user, regular_username: regular_user}
        ):
            super().test_options_prompted_with_ask_help(
                prefill_data={"raw_option": {}, "prefill": admin_username}
            )
            # FIXME This should fail, not allowed to set a default
            super().test_options_prompted_with_ask_help(
                prefill_data={
                    "raw_option": {"default": regular_username},
                    "prefill": regular_username,
                }
            )

    def test_scenarios(self, intake, expected_output, raw_option, data):
        with patch_users(**data):
            super().test_scenarios(intake, expected_output, raw_option, data)


# ╭───────────────────────────────────────────────────────╮
# │ GROUP                                                 │
# ╰───────────────────────────────────────────────────────╯

groups1 = {"all_users": {}, "visitors": {}, "admins": {}}
groups2 = {"all_users": {}, "visitors": {}, "admins": {}, "custom_group": {}}


@contextmanager
def patch_groups(*, groups):
    """
    Data mocking for GroupOption:
    - yunohost.user.user_group_list
    """
    with patch.object(user, "user_group_list", return_value={"groups": groups}):
        yield


class TestGroup(BaseTest):
    raw_option = {"type": "group", "id": "group_id"}
    # fmt: off
    scenarios = [
        # No tests for empty groups since it should not happens
        {
            "data": [
                {"groups": groups1},
                {"groups": groups2},
            ],
            "scenarios": [
                # FIXME Group option is not really nullable, even if optional
                *nones(None, "", output="all_users", fail_if_required=False),
                ("admins", "admins"),
                ("fake_group", FAIL),
                ("fake_group", FAIL, {"choices": ["fake_group"]}),
            ]
        },
        {
            "data": [
                {"groups": groups2},
            ],
            "scenarios": [
                ("custom_group", "custom_group"),
                *all_as("", None, output="visitors", raw_option={"default": "visitors"}),
                ("", YunohostError, {"default": "custom_group"}),  # Not allowed to set a default which is not a default group
                # readonly
                ("admins", YunohostError, {"readonly": True}),  # readonly is forbidden
            ]
        },
    ]
    # fmt: on

    def test_options_prompted_with_ask_help(self, prefill_data=None):
        with patch_groups(groups=groups2):
            super().test_options_prompted_with_ask_help(
                prefill_data={"raw_option": {}, "prefill": "all_users"}
            )
            super().test_options_prompted_with_ask_help(
                prefill_data={
                    "raw_option": {"default": "admins"},
                    "prefill": "admins",
                }
            )

    def test_scenarios(self, intake, expected_output, raw_option, data):
        with patch_groups(**data):
            super().test_scenarios(intake, expected_output, raw_option, data)


# ╭───────────────────────────────────────────────────────╮
# │ MULTIPLE                                              │
# ╰───────────────────────────────────────────────────────╯


@pytest.fixture
def patch_entities():
    with (
        patch_domains(domains=domains2, main_domain=main_domain),
        patch_apps(apps=[installed_webapp, installed_non_webapp]),
        patch_users(
            users={admin_username: admin_user, regular_username: regular_user},
        ),
        patch_groups(groups=groups2),
    ):
        yield


def test_options_empty():
    ask_questions_and_parse_answers({}, {}) == []


@pytest.mark.usefixtures("patch_entities", "file_clean")
def test_options_query_string():
    raw_options = {
        "string_id": {"type": "string"},
        "text_id": {"type": "text"},
        "password_id": {"type": "password"},
        "color_id": {"type": "color"},
        "number_id": {"type": "number"},
        "boolean_id": {"type": "boolean"},
        "date_id": {"type": "date"},
        "time_id": {"type": "time"},
        "email_id": {"type": "email"},
        "path_id": {"type": "path"},
        "url_id": {"type": "url"},
        "file_id": {"type": "file"},
        "select_id": {"type": "select", "choices": ["one", "two"]},
        "tags_id": {"type": "tags", "choices": ["one", "two"]},
        "domain_id": {"type": "domain"},
        "app_id": {"type": "app"},
        "user_id": {"type": "user"},
        "group_id": {"type": "group"},
    }

    results = {
        "string_id": "string",
        "text_id": "text\ntext",
        "password_id": "sUpRSCRT",
        "color_id": "#ff0",
        "number_id": 10,
        "boolean_id": 1,
        "date_id": "2030-03-06",
        "time_id": "20:55",
        "email_id": "coucou@ynh.org",
        "path_id": "/ynh-dev",
        "url_id": "https://yunohost.org",
        "file_id": file_content1,
        "select_id": "one",
        "tags_id": "one,two",
        "domain_id": main_domain,
        "app_id": installed_webapp["id"],
        "user_id": regular_username,
        "group_id": "admins",
    }

    @contextmanager
    def patch_query_string(file_repr):
        yield (
            "string_id= string"
            "&text_id=text\ntext"
            "&password_id=sUpRSCRT"
            "&color_id=#ffff00"
            "&number_id=10"
            "&boolean_id=y"
            "&date_id=2030-03-06"
            "&time_id=20:55"
            "&email_id=coucou@ynh.org"
            "&path_id=ynh-dev/"
            "&url_id=https://yunohost.org"
            f"&file_id={file_repr}"
            "&select_id=one"
            "&tags_id=one,two"
            # FIXME We can't test with parse.qs for now, next syntax is available only with config panels
            # "&tags_id=one"
            # "&tags_id=two"
            f"&domain_id={main_domain}"
            f"&app_id={installed_webapp['id']}"
            f"&user_id={regular_username}"
            "&group_id=admins"
            # not defined extra values are silently ignored
            "&fake_id=fake_value"
        )

    def _assert_correct_values(options, form, raw_options):
        for k, v in results.items():
            if k == "file_id":
                assert os.path.exists(form["file_id"]) and os.path.isfile(
                    form["file_id"]
                )
                with open(form["file_id"], "r") as f:
                    assert f.read() == file_content1
            else:
                assert form[k] == results[k]

        assert len(options) == len(raw_options.keys())
        assert "fake_id" not in form

    with patch_interface("api"), patch_file_api(file_content1) as b64content:
        with patch_query_string(b64content.decode("utf-8")) as query_string:
            options, form = ask_questions_and_parse_answers(raw_options, query_string)
            _assert_correct_values(options, form, raw_options)

    with patch_interface("cli"), patch_file_cli(file_content1) as filepath:
        with patch_query_string(filepath) as query_string:
            options, form = ask_questions_and_parse_answers(raw_options, query_string)
            _assert_correct_values(options, form, raw_options)


def test_question_string_default_type():
    questions = {"some_string": {}}
    answers = {"some_string": "some_value"}

    options, form = ask_questions_and_parse_answers(questions, answers)
    option = options[0]
    assert option.id == "some_string"
    assert option.type == "string"
    assert form[option.id] == "some_value"


def test_option_default_type_with_choices_is_select():
    questions = {
        "some_choices": {"choices": ["a", "b"]},
        # LEGACY (`choices` in option `string` used to be valid)
        # make sure this result as a `select` option
        "some_legacy": {"type": "string", "choices": ["a", "b"]},
    }
    answers = {"some_choices": "a", "some_legacy": "a"}

    options, form = ask_questions_and_parse_answers(questions, answers)
    for option in options:
        assert option.type == "select"
        assert form[option.id] == "a"


@pytest.mark.skip  # we should do something with this example
def test_question_string_input_test_ask_with_example():
    ask_text = "some question"
    example_text = "some example"
    questions = {
        "some_string": {
            "ask": ask_text,
            "example": example_text,
        }
    }
    answers = {}

    with (
        patch.object(Moulinette, "prompt", return_value="some_value") as prompt,
        patch.object(os, "isatty", return_value=True),
    ):
        ask_questions_and_parse_answers(questions, answers)
        assert ask_text in prompt.call_args[1]["message"]
        assert example_text in prompt.call_args[1]["message"]


@pytest.mark.skip  # we should do something with this example
def test_question_password_input_test_ask_with_example():
    ask_text = "some question"
    example_text = "some example"
    questions = {
        "some_password": {
            "type": "password",
            "ask": ask_text,
            "example": example_text,
        }
    }
    answers = {}

    with (
        patch.object(Moulinette, "prompt", return_value="some_value") as prompt,
        patch.object(os, "isatty", return_value=True),
    ):
        ask_questions_and_parse_answers(questions, answers)
        assert ask_text in prompt.call_args[1]["message"]
        assert example_text in prompt.call_args[1]["message"]


@pytest.mark.skip  # we should do something with this example
def test_question_path_input_test_ask_with_example():
    ask_text = "some question"
    example_text = "some example"
    questions = {
        "some_path": {
            "type": "path",
            "ask": ask_text,
            "example": example_text,
        }
    }
    answers = {}

    with (
        patch.object(Moulinette, "prompt", return_value="some_value") as prompt,
        patch.object(os, "isatty", return_value=True),
    ):
        ask_questions_and_parse_answers(questions, answers)
        assert ask_text in prompt.call_args[1]["message"]
        assert example_text in prompt.call_args[1]["message"]


@pytest.mark.skip  # we should do something with this example
def test_question_number_input_test_ask_with_example():
    ask_text = "some question"
    example_value = 1337
    questions = {
        "some_number": {
            "type": "number",
            "ask": ask_text,
            "example": example_value,
        }
    }
    answers = {}

    with (
        patch.object(Moulinette, "prompt", return_value="1111") as prompt,
        patch.object(os, "isatty", return_value=True),
    ):
        ask_questions_and_parse_answers(questions, answers)
        assert ask_text in prompt.call_args[1]["message"]
        assert example_value in prompt.call_args[1]["message"]


def test_normalize_boolean_nominal():
    assert BooleanOption.normalize("yes") == 1
    assert BooleanOption.normalize("Yes") == 1
    assert BooleanOption.normalize(" yes  ") == 1
    assert BooleanOption.normalize("y") == 1
    assert BooleanOption.normalize("true") == 1
    assert BooleanOption.normalize("True") == 1
    assert BooleanOption.normalize("on") == 1
    assert BooleanOption.normalize("1") == 1
    assert BooleanOption.normalize(1) == 1

    assert BooleanOption.normalize("no") == 0
    assert BooleanOption.normalize("No") == 0
    assert BooleanOption.normalize(" no  ") == 0
    assert BooleanOption.normalize("n") == 0
    assert BooleanOption.normalize("false") == 0
    assert BooleanOption.normalize("False") == 0
    assert BooleanOption.normalize("off") == 0
    assert BooleanOption.normalize("0") == 0
    assert BooleanOption.normalize(0) == 0

    assert BooleanOption.normalize("") is None
    assert BooleanOption.normalize("   ") is None
    assert BooleanOption.normalize(" none   ") is None
    assert BooleanOption.normalize("None") is None
    assert BooleanOption.normalize("noNe") is None
    assert BooleanOption.normalize(None) is None


def test_normalize_boolean_humanize():
    assert BooleanOption.humanize("yes") == "yes"
    assert BooleanOption.humanize("true") == "yes"
    assert BooleanOption.humanize("on") == "yes"

    assert BooleanOption.humanize("no") == "no"
    assert BooleanOption.humanize("false") == "no"
    assert BooleanOption.humanize("off") == "no"


def test_normalize_boolean_invalid():
    with pytest.raises(YunohostValidationError):
        BooleanOption.normalize("yesno")
    with pytest.raises(YunohostValidationError):
        BooleanOption.normalize("foobar")
    with pytest.raises(YunohostValidationError):
        BooleanOption.normalize("enabled")


def test_normalize_boolean_special_yesno():
    customyesno = {"yes": "enabled", "no": "disabled"}

    assert BooleanOption.normalize("yes", customyesno) == "enabled"
    assert BooleanOption.normalize("true", customyesno) == "enabled"
    assert BooleanOption.normalize("enabled", customyesno) == "enabled"
    assert BooleanOption.humanize("yes", customyesno) == "yes"
    assert BooleanOption.humanize("true", customyesno) == "yes"
    assert BooleanOption.humanize("enabled", customyesno) == "yes"

    assert BooleanOption.normalize("no", customyesno) == "disabled"
    assert BooleanOption.normalize("false", customyesno) == "disabled"
    assert BooleanOption.normalize("disabled", customyesno) == "disabled"
    assert BooleanOption.humanize("no", customyesno) == "no"
    assert BooleanOption.humanize("false", customyesno) == "no"
    assert BooleanOption.humanize("disabled", customyesno) == "no"


def test_normalize_domain():
    assert DomainOption.normalize("https://yolo.swag/") == "yolo.swag"
    assert DomainOption.normalize("http://yolo.swag") == "yolo.swag"
    assert DomainOption.normalize("yolo.swag/") == "yolo.swag"


def test_normalize_path():
    assert WebPathOption.normalize("") == "/"
    assert WebPathOption.normalize("") == "/"
    assert WebPathOption.normalize("macnuggets") == "/macnuggets"
    assert WebPathOption.normalize("/macnuggets") == "/macnuggets"
    assert WebPathOption.normalize("   /macnuggets      ") == "/macnuggets"
    assert WebPathOption.normalize("/macnuggets") == "/macnuggets"
    assert WebPathOption.normalize("mac/nuggets") == "/mac/nuggets"
    assert WebPathOption.normalize("/macnuggets/") == "/macnuggets"
    assert WebPathOption.normalize("macnuggets/") == "/macnuggets"
    assert WebPathOption.normalize("////macnuggets///") == "/macnuggets"


def test_simple_evaluate():
    context = {
        "a1": 1,
        "b2": 2,
        "c10": 10,
        "foo": "bar",
        "comp": "1>2",
        "empty": "",
        "lorem": "Lorem ipsum dolor et si qua met!",
        "warning": "Warning! This sentence will fail!",
        "quote": "Je s'apelle Groot",
        "and_": "&&",
        "object": {"a": "Security risk"},
    }
    supported = {
        "42": 42,
        "9.5": 9.5,
        "'bopbidibopbopbop'": "bopbidibopbopbop",
        "true": True,
        "false": False,
        "null": None,
        # Math
        "1 * (2 + 3 * (4 - 3))": 5,
        "1 * (2 + 3 * (4 - 3)) > 10 - 2 || 3 * 2 > 9 - 2 * 3": True,
        "(9 - 2) * 3 - 10": 11,
        "12 - 2 * -2 + (3 - 4) * 3.1": 12.9,
        "9 / 12 + 12 * 3 - 5": 31.75,
        "9 / 12 + 12 * (3 - 5)": -23.25,
        "12 > 13.1": False,
        "12 < 14": True,
        "12 <= 14": True,
        "12 >= 14": False,
        "12 == 14": False,
        "12 % 5 > 3": False,
        "12 != 14": True,
        "9 - 1 > 10 && 3 * 5 > 10": False,
        "9 - 1 > 10 || 3 * 5 > 10": True,
        "a1 > 0 || a1 < -12": True,
        "a1 > 0 && a1 < -12": False,
        "a1 + 1 > 0 && -a1 > -12": True,
        "-(a1 + 1) < 0 || -(a1 + 2) > -12": True,
        "-a1 * 2": -2,
        "(9 - 2) * 3 - c10": 11,
        "(9 - b2) * 3 - c10": 11,
        "c10 > b2": True,
        # String
        "foo == 'bar'": True,
        "foo != 'bar'": False,
        'foo == "bar" && 1 > 0': True,
        "!!foo": True,
        "!foo": False,
        "foo": "bar",
        '!(foo > "baa") || 1 > 2': False,
        '!(foo > "baa") || 1 < 2': True,
        'empty == ""': True,
        '1 == "1"': True,
        '1.0 == "1"': True,
        '1 == "aaa"': False,
        "'I am ' + b2 + ' years'": "I am 2 years",
        "quote == 'Je s\\'apelle Groot'": True,
        "lorem == 'Lorem ipsum dolor et si qua met!'": True,
        "and_ == '&&'": True,
        "warning == 'Warning! This sentence will fail!'": True,
        # Match
        "match(lorem, '^Lorem [ia]psumE?')": bool,
        "match(foo, '^Lorem [ia]psumE?')": None,
        "match(lorem, '^Lorem [ia]psumE?') && 1 == 1": bool,
        # No code
        "": False,
        " ": False,
    }
    trigger_errors = {
        "object.a": YunohostError,  # Keep unsupported, for security reasons
        "a1 ** b2": YunohostError,  # Keep unsupported, for security reasons
        "().__class__.__bases__[0].__subclasses__()": YunohostError,  # Very dangerous code
        "a1 > 11 ? 1 : 0": SyntaxError,
        "c10 > b2 == false": YunohostError,  # JS and Python doesn't do the same thing for this situation
        "c10 > b2 == true": YunohostError,
    }

    for expression, result in supported.items():
        if result == bool:
            assert bool(evaluate_simple_js_expression(expression, context)), expression
        else:
            assert evaluate_simple_js_expression(expression, context) == result, (
                expression
            )

    for expression, error in trigger_errors.items():
        with pytest.raises(error):
            evaluate_simple_js_expression(expression, context)
