# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

from datetime import datetime, timezone as _tz, tzinfo
from inspect import cleandoc

# Disable logging for a cleaner testing output
import logging
from typing import Any
from unittest.mock import Mock

import pytest
from pytest_mock import MockerFixture
import requests
import yaml

from apprise import Apprise, AppriseAsset, AppriseConfig, ConfigFormat
from apprise.config import ConfigBase
from apprise.plugins.email import NotifyEmail
from apprise.utils.time import zoneinfo

logging.disable(logging.CRITICAL)


@pytest.fixture
def requests_remote_config(mocker: MockerFixture) -> Mock:
    """
    Patch requests.post globally.

    The config loader will still go through its normal HTTP logic, but all
    outbound GETs will receive controlled in-memory responses.
    """

    def fake_post(url: str, *args: Any, **kwargs: Any) -> requests.Response:
        if url == "http://localhost:8000/get/test-001":
            body = cleandoc("""
                json://localhost
                form://localhost
                """)
        elif url == "http://localhost:8000/get/test-002":
            body = cleandoc("""
                xml://localhost
                """)
        else:
            pytest.fail(f"Unexpected URL fetched: {url!r}")

        resp = requests.Response()
        resp.status_code = requests.codes.ok
        resp.url = url
        resp._content = body.encode("utf-8")  # type: ignore[attr-defined]
        resp.encoding = "utf-8"
        return resp

    # Patch the actual requests.post symbol that ConfigHTTP uses internally
    mock_post: Mock = mocker.patch("requests.post", side_effect=fake_post)
    return mock_post


def test_config_base():
    """
    API: ConfigBase() object

    """

    # invalid types throw exceptions
    with pytest.raises(TypeError):
        ConfigBase(**{"format": "invalid"})

    # Config format types are not the same as ConfigBase ones
    with pytest.raises(TypeError):
        ConfigBase(**{"format": "markdown"})

    cb = ConfigBase(**{"format": "yaml"})
    assert isinstance(cb, ConfigBase)

    cb = ConfigBase(**{"format": "text"})
    assert isinstance(cb, ConfigBase)

    # Set encoding
    cb = ConfigBase(encoding="utf-8", format="text")
    assert isinstance(cb, ConfigBase)

    # read is not supported in the base object; only the children
    assert cb.read() is None

    # There are no servers loaded on a freshly created object
    assert len(cb.servers()) == 0

    # Unsupported URLs are not parsed
    assert ConfigBase.parse_url(url="invalid://") is None

    # Valid URL & Valid Format
    results = ConfigBase.parse_url(
        url="file://relative/path?format=yaml&encoding=latin-1"
    )
    assert isinstance(results, dict)
    # These are moved into the root
    assert results.get("format") == "yaml"
    assert results.get("encoding") == "latin-1"

    # But they also exist in the qsd location
    assert isinstance(results.get("qsd"), dict)
    assert results["qsd"].get("encoding") == "latin-1"
    assert results["qsd"].get("format") == "yaml"

    # Valid URL & Invalid Format
    results = ConfigBase.parse_url(
        url="file://relative/path?format=invalid&encoding=latin-1"
    )
    assert isinstance(results, dict)
    # Only encoding is moved into the root
    assert "format" not in results
    assert results.get("encoding") == "latin-1"

    # But they will always exist in the qsd location
    assert isinstance(results.get("qsd"), dict)
    assert results["qsd"].get("encoding") == "latin-1"
    assert results["qsd"].get("format") == "invalid"


def test_config_base_detect_config_format():
    """
    API: ConfigBase.detect_config_format

    """

    # Garbage Handling
    for garbage in (object(), None, 42):
        # A response is always correctly returned
        assert ConfigBase.detect_config_format(garbage) is None

    # Empty files are valid
    assert ConfigBase.detect_config_format("") is ConfigFormat.TEXT

    # Valid Text Configuration
    assert (
        ConfigBase.detect_config_format("""
    # A comment line over top of a URL
    mailto://userb:pass@gmail.com
    """)
        is ConfigFormat.TEXT
    )

    # A text file that has semi-colon as comment characters
    # is valid too
    assert (
        ConfigBase.detect_config_format("""
    ; A comment line over top of a URL
    mailto://userb:pass@gmail.com
    """)
        is ConfigFormat.TEXT
    )

    # Valid YAML Configuration
    assert (
        ConfigBase.detect_config_format("""
    # A comment line over top of a URL
    version: 1
    """)
        is ConfigFormat.YAML
    )

    # Just a whole lot of blank lines...
    assert ConfigBase.detect_config_format("\n\n\n") is ConfigFormat.TEXT

    # Invalid Config
    assert ConfigBase.detect_config_format("3") is None


def test_config_base_config_parse():
    """
    API: ConfigBase.config_parse

    """

    # Garbage Handling
    for garbage in (object(), None, 42):
        # A response is always correctly returned
        result = ConfigBase.config_parse(garbage)
        # response is a tuple...
        assert isinstance(result, tuple)
        # containing 2 items (plugins, config)
        assert len(result) == 2
        # In the case of garbage in, we get garbage out; both lists are empty
        assert result == ([], [])

    # Valid Text Configuration
    result = ConfigBase.config_parse(
        """
    # A comment line over top of a URL
    mailto://userb:pass@gmail.com
    """,
        asset=AppriseAsset(),
    )
    # We expect to parse 1 entry from the above
    assert isinstance(result, tuple)
    assert len(result) == 2
    # The first element is the number of notification services processed
    assert len(result[0]) == 1
    # If we index into the item, we can check to see the tags associate
    # with it
    assert len(result[0][0].tags) == 0

    # The second is the number of configuration include lines parsed
    assert len(result[1]) == 0

    # Valid Configuration
    result = ConfigBase.config_parse(
        """
# if no version is specified then version 1 is presumed
version: 1

#
# Define your notification urls:
#
urls:
  - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b
  - mailto://test:password@gmail.com
  - json://localhost:
      - tag: devops, admin
    """,
        asset=AppriseAsset(),
    )

    # We expect to parse 2 entries from the above
    assert isinstance(result, tuple)
    assert len(result) == 2
    assert isinstance(result[0], list)
    assert len(result[0]) == 3
    assert len(result[0][0].tags) == 0
    assert len(result[0][1].tags) == 0
    assert len(result[0][2].tags) == 2

    # Test case where we pass in a bad format
    result = ConfigBase.config_parse(
        """
    ; A comment line over top of a URL
    mailto://userb:pass@gmail.com
    """,
        config_format="invalid-format",
    )

    # This is not parseable despite the valid text
    assert isinstance(result, tuple)
    assert isinstance(result[0], list)
    assert len(result[0]) == 0

    result, _ = ConfigBase.config_parse(
        """
    ; A comment line over top of a URL
    mailto://userb:pass@gmail.com
    """,
        config_format=ConfigFormat.TEXT,
    )

    # Parseable
    assert isinstance(result, list)
    assert len(result) == 1


def test_config_base_discord_bug_report_01():
    """
    API: ConfigBase.config_parse user feedback

    A Discord report that a tag was not correctly assigned to a URL when
    presented in the following format
       urls:
         - json://myhost:
           - tag: test
             userid: test
    """
    result, config = ConfigBase.config_parse(
        """
    urls:
      - json://myhost:
        - tag: test
          userid: test
    """,
        asset=AppriseAsset(),
    )

    # We expect to parse 4 entries from the above
    assert isinstance(result, list)
    assert isinstance(config, list)
    assert len(result) == 1
    assert len(result[0].tags) == 1
    assert "test" in result[0].tags


def test_config_base_config_parse_text():
    """
    API: ConfigBase.config_parse_text object

    """

    # Garbage Handling
    for garbage in (object(), None, 42):
        # A response is always correctly returned
        result = ConfigBase.config_parse_text(garbage)
        # response is a tuple...
        assert isinstance(result, tuple)
        # containing 2 items (plugins, config)
        assert len(result) == 2
        # In the case of garbage in, we get garbage out; both lists are empty
        assert result == ([], [])

    # Valid Configuration
    result, config = ConfigBase.config_parse_text(
        """
    # A completely invalid token on json string (it gets ignored)
    # but the URL is still valid
    json://localhost?invalid-token=nodashes

    # A comment line over top of a URL
    mailto://userb:pass@gmail.com

    # Test a URL using it's native format; in this case Ryver
    https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG

    # Invalid URL as it's not associated with a plugin
    # or a native url
    https://not.a.native.url/

    # A line with mulitiple tag assignments to it
    taga,tagb=kde://

    # An include statement to Apprise API with trailing spaces:
    include http://localhost:8080/notify/apprise

    # A relative include statement (with trailing spaces)
    include apprise.cfg     """,
        asset=AppriseAsset(),
    )

    # We expect to parse 4 entries from the above
    assert isinstance(result, list)
    assert isinstance(config, list)
    assert len(result) == 4
    assert len(result[0].tags) == 0

    # Our last element will have 2 tags associated with it
    assert len(result[-1].tags) == 2
    assert "taga" in result[-1].tags
    assert "tagb" in result[-1].tags

    assert len(config) == 2
    assert "http://localhost:8080/notify/apprise" in config
    assert "apprise.cfg" in config

    # Here is a similar result set however this one has an invalid line
    # in it which invalidates the entire file
    result, config = ConfigBase.config_parse_text("""
    # A comment line over top of a URL
    mailto://userc:pass@gmail.com

    # A line with mulitiple tag assignments to it
    taga,tagb=windows://

    I am an invalid line that does not follow any of the Apprise file rules!
    """)

    # We expect to parse 0 entries from the above because the invalid line
    # invalidates the entire configuration file. This is for security reasons;
    # we don't want to point at files load content in them just because they
    # resemble an Apprise configuration.
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # More invalid data
    result, config = ConfigBase.config_parse_text("""
    # An invalid URL
    invalid://user:pass@gmail.com

    # A tag without a url
    taga=

    # A very poorly structured url
    sns://:@/

    # Just 1 token provided
    sns://T1JJ3T3L2/

    # Even with the above invalid entries, we can still
    # have valid include lines
    include file:///etc/apprise.cfg

    # An invalid include (nothing specified afterwards)
    include

    # An include of a config type we don't support
    include invalid://
    """)

    # We expect to parse 0 entries from the above
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Test case where a comment is on it's own line with nothing else
    result, config = ConfigBase.config_parse_text("#")
    # We expect to parse 0 entries from the above
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Verify our tagging works when multiple tags are provided
    result, config = ConfigBase.config_parse_text("""
    tag1, tag2, tag3=json://user:pass@localhost
    """)

    assert isinstance(result, list)
    assert len(result) == 1
    assert len(result[0].tags) == 3
    assert "tag1" in result[0].tags
    assert "tag2" in result[0].tags
    assert "tag3" in result[0].tags


def test_config_base_config_tag_groups_text():
    """
    API: ConfigBase.config_tag_groups_text object

    """

    # Valid Configuration
    result, config = ConfigBase.config_parse_text(
        """
    # Tag assignments
    groupA, groupB = tagB, tagC

    # groupB doubles down as it takes the entries initialized above
    # plus the added ones defined below
    groupB = tagA, tagB, tagD
    groupC = groupA, groupB, groupC, tagE

    # Tag that recursively looks to more tags
    groupD = groupC

    # Assigned ourselves
    groupX = groupX

    # Set up a recursive loop
    groupE = groupF
    groupF = groupE

    # Set up a larger recursive loop
    groupG = groupH
    groupH = groupI
    groupI = groupJ
    groupJ = groupK
    groupK = groupG

    # Bad assignments
    groupM = , , ,
     , ,   = , , ,

    # int's and floats are okay
    1 = 2
    a = 5

    # A comment line over top of a URL
    4, groupB = mailto://userb:pass@gmail.com

    # Tag Assignments
    tagA,groupB=json://localhost

    # More Tag Assignments
    tagC,groupB=xml://localhost

    # More Tag Assignments
    groupD=form://localhost

    """,
        asset=AppriseAsset(),
    )

    # We expect to parse 4 entries from the above
    assert isinstance(result, list)
    assert isinstance(config, list)
    assert len(result) == 4

    # Our first element is our group tags
    assert len(result[0].tags) == 2
    assert "groupb" in result[0].tags
    assert "4" in result[0].tags

    # No additional configuration is loaded
    assert len(config) == 0

    apobj = Apprise()
    assert apobj.add(result)
    # We match against 1 entry
    assert len(list(apobj.find("tagA"))) == 1
    assert len(list(apobj.find("tagB"))) == 0
    assert len(list(apobj.find("groupA"))) == 1
    assert len(list(apobj.find("groupB"))) == 3
    assert len(list(apobj.find("groupC"))) == 2
    assert len(list(apobj.find("groupD"))) == 3

    # Invalid Assignment
    result, config = ConfigBase.config_parse_text("""
    # Must have something to equal or it's a bad line
    group =

    # A tag Assignments that is never gotten to as the line
    # above is bad
    groupD=form://localhost
    """)

    # We expect to parse 0 entries from the above
    assert isinstance(result, list)
    assert isinstance(config, list)
    assert len(result) == 0
    assert len(config) == 0

    # Invalid Assignment
    result, config = ConfigBase.config_parse_text("""
    # Rundant assignment
    group = group

    # Our group assignment
    group=windows://

    """)

    # the redundant assignment does us no harm; but it doesn't grant us any
    # value either
    assert isinstance(result, list)
    assert len(result) == 1

    # Our first element is our group tags
    assert len(result[0].tags) == 1
    assert "group" in result[0].tags

    # There were no include entries defined
    assert len(config) == 0

    # More invalid data
    result, config = ConfigBase.config_parse_text("""
    # A tag without a url or group assignment
    taga=

    """)

    # We expect to parse 0 entries from the above
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    result, config = ConfigBase.config_parse_text("""
    # A tag without a url or group assignment
    taga= %%INVALID
    """)

    # We expect to parse 0 entries from the above
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0


def test_config_base_config_parse_text_with_url():
    """
    API: ConfigBase.config_parse_text object_with_url

    """
    # Here is a similar result set however this one has an invalid line
    # in it which invalidates the entire file
    result, config = ConfigBase.config_parse_text("""
    # Test a URL that has a URL as an argument
    json://user:pass@localhost?+arg=http://example.com?arg2=1&arg3=3
    """)

    # No tag is parsed, but our URL successfully parses as is

    assert isinstance(result, list)
    assert len(result) == 1
    assert len(result[0].tags) == 0

    # Verify our URL is correctly captured
    assert "%2Barg=http%3A%2F%2Fexample.com%3Farg2%3D1" in result[0].url()
    assert "json://user:pass@localhost/" in result[0].url()

    # There were no include entries defined
    assert len(config) == 0

    # Pass in our configuration again
    result, config = ConfigBase.config_parse_text(result[0].url())

    # Verify that our results repeat themselves
    assert isinstance(result, list)
    assert len(result) == 1
    assert len(result[0].tags) == 0
    assert "%2Barg=http%3A%2F%2Fexample.com%3Farg2%3D1" in result[0].url()
    assert "json://user:pass@localhost/" in result[0].url()

    assert len(config) == 0


def test_config_base_config_parse_yaml():
    """
    API: ConfigBase.config_parse_yaml object

    """

    # general reference used below
    asset = AppriseAsset()

    # Garbage Handling
    for garbage in (object(), None, "", 42):
        # A response is always correctly returned
        result = ConfigBase.config_parse_yaml(garbage)
        # response is a tuple...
        assert isinstance(result, tuple)
        # containing 2 items (plugins, config)
        assert len(result) == 2
        # In the case of garbage in, we get garbage out; both lists are empty
        assert result == ([], [])

    # Invalid Version
    result, config = ConfigBase.config_parse_yaml("version: 2a", asset=asset)

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Invalid Syntax (throws a ScannerError)
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

urls
""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Missing url token
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # No urls defined
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

urls:
""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Invalid url defined
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

# Invalid URL definition; yet the answer to life at the same time
urls: 43
""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Invalid url/schema
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

urls:
  - invalid://

""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Invalid url/schema
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

urls:
  - invalid://:
    - a: b

""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Invalid url/schema
    result, config = ConfigBase.config_parse_yaml(
        """
# Include entry with nothing associated with it
include:

urls:
  - just some free text that isn't valid:
    - a garbage entry to go with it

""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Invalid url/schema
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

urls:
  - not even a proper url

""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Invalid url/schema
    result, config = ConfigBase.config_parse_yaml(
        """
urls:
  # a very invalid sns entry
  - sns://T1JJ3T3L2/
  - sns://:@/:
    - invalid: test
  - sns://T1JJ3T3L2/:
    - invalid: test
    - _invalid: Token can not start with an underscore

  # some strangeness
  -
    -
      - test

""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # Valid Configuration
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

# Including by dict
include:
  # File includes
  - file:///absolute/path/
  - relative/path
  # Trailing colon shouldn't disrupt include
  - http://test.com:

  # invalid (numeric)
  - 4

  # some strangeness
  -
    -
      - test

#
# Define your notification urls:
#
urls:
  - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b
  - mailto://test:password@gmail.com
  - https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG
  - https://not.a.native.url/

    # A completely invalid token on json string (it gets ignored)
    # but the URL is still valid
  - json://localhost?invalid-token=nodashes

""",
        asset=asset,
    )

    # We expect to parse 4 entries from the above
    # The Ryver one is in a native form and the 4th one is invalid
    assert isinstance(result, list)
    assert len(result) == 4
    assert len(result[0].tags) == 0

    # There were 3 include entries
    assert len(config) == 3
    assert "file:///absolute/path/" in config
    assert "relative/path" in config
    assert "http://test.com" in config

    # Valid Configuration
    result, config = ConfigBase.config_parse_yaml(
        """
# A single line include is supported
include: http://localhost:8080/notify/apprise

urls:
  # The following generates 1 service
  - json://localhost:
       tag: my-custom-tag, my-other-tag

  # The following also generates 1 service
  - json://localhost:
    - tag: my-custom-tag, my-other-tag

  # How to stack multiple entries (this generates 2):
  - mailto://user:123abc@yahoo.ca:
    - to: test@examle.com
    - to: test2@examle.com

      # This is an illegal entry; the schema can not be changed
      schema: json

  # accidently left a colon at the end of the url; no problem
  # we'll accept it
  - mailto://oscar:pass@gmail.com:

  # A Ryver URL (using Native format); still accepted
  - https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG:

  # An invalid URL with colon (ignored)
  - https://not.a.native.url/:

  # A telegram entry (returns a None in parse_url())
  - tgram://invalid

""",
        asset=asset,
    )

    # We expect to parse 6 entries from the above because the tgram:// entry
    # would have failed to be loaded
    assert isinstance(result, list)
    assert len(result) == 6
    assert len(result[0].tags) == 2

    # Our single line included
    assert len(config) == 1
    assert "http://localhost:8080/notify/apprise" in config

    # Global Tags
    result, config = ConfigBase.config_parse_yaml(
        """
# Global Tags stacked as a list
tag:
  - admin
  - devops

urls:
  - json://localhost
  - dbus://
""",
        asset=asset,
    )

    # We expect to parse 2 entries from the above
    assert isinstance(result, list)
    assert len(result) == 2

    # There were no include entries defined
    assert len(config) == 0

    # all entries will have our global tags defined in them
    for entry in result:
        assert "admin" in entry.tags
        assert "devops" in entry.tags

    # Global Tags
    result, config = ConfigBase.config_parse_yaml(
        """
# Global Tags
tag: admin, devops

urls:
  # The following tags will get added to the global set
  - json://localhost:
    - tag: string-tag, my-other-tag, text

  # Tags can be presented in this list format too:
  - dbus://:
    - tag:
      - list-tag
      - dbus
""",
        asset=asset,
    )

    # all entries will have our global tags defined in them
    for entry in result:
        assert "admin" in entry.tags
        assert "devops" in entry.tags

    # We expect to parse 2 entries from the above
    assert isinstance(result, list)
    assert len(result) == 2

    # json:// has 2 globals + 3 defined
    assert len(result[0].tags) == 5
    assert "text" in result[0].tags

    # json:// has 2 globals + 2 defined
    assert len(result[1].tags) == 4
    assert "list-tag" in result[1].tags

    # There were no include entries defined
    assert len(config) == 0

    # An invalid set of entries
    result, config = ConfigBase.config_parse_yaml(
        """
urls:
  # The following tags will get added to the global set
  - json://localhost:
    -
      -
        - entry
""",
        asset=asset,
    )

    # We expect to parse 0 entries from the above
    assert isinstance(result, list)
    assert len(result) == 0

    # There were no include entries defined
    assert len(config) == 0

    # An asset we'll manipulate; set some system flags
    asset = AppriseAsset(_uid="abc123", _recursion=1)

    # Global Tags
    result, config = ConfigBase.config_parse_yaml(
        """
# Test the creation of our apprise asset object
asset:
  app_id: AppriseTest
  app_desc: Apprise Test Notifications
  app_url: http://nuxref.com
  async_mode: no

  # System flags should never get set
  _uid: custom_id
  _recursion: 100

  # Support setting empty values
  image_url_mask:
  image_url_logo:

  image_path_mask: tmp/path

  # Timezone (supports tz keyword too)
  tz: America/Montreal

  # invalid entry
  theme:
    -
      -
        - entry

  # Now for some invalid entries
  invalid: entry
  __init__: can't be over-ridden
  nolists:
    - we don't support these entries
    - in the apprise object

urls:
  - json://localhost:
""",
        asset=asset,
    )

    # We expect to parse 1 entries from the above
    assert isinstance(result, list)
    assert len(result) == 1

    # There were no include entries defined
    assert len(config) == 0

    assert asset.app_id == "AppriseTest"
    assert asset.app_desc == "Apprise Test Notifications"
    assert asset.app_url == "http://nuxref.com"

    # Verify our system flags retain only the value they were initialized to
    assert asset._uid == "abc123"
    assert asset._recursion == 1

    # Boolean types stay boolean
    assert asset.async_mode is False

    # Our TimeZone
    assert isinstance(asset.tzinfo, tzinfo)
    assert asset.tzinfo.key == zoneinfo("America/Montreal").key

    # the theme was not updated and remains the same as it was
    assert asset.theme == AppriseAsset().theme

    # Empty string assignment
    assert isinstance(asset.image_url_mask, str)
    assert asset.image_url_mask == ""
    assert isinstance(asset.image_url_logo, str)
    assert asset.image_url_logo == ""

    # For on-lookers looking through this file; here is a perfectly formatted
    # YAML configuration file for your reference so you can see it without
    # all of the errors like the ones identified above
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed. Thus this is a
# completely optional field. It's a good idea to just add this line because it
# will help with future ambiguity (if it ever occurs).
version: 1

# Define an Asset object if you wish (Optional)
asset:
  app_id: AppriseTest
  app_desc: Apprise Test Notifications
  app_url: http://nuxref.com

  # An invalid timezone
  timezone: invalid

# Optionally define some global tags to associate with ALL of your
# urls below.
tag: admin, devops

# Define your URLs (Mandatory!)
urls:
  # Either on-line each entry like this:
  - json://localhost

  # Or add a colon to the end of the URL where you can optionally provide
  # over-ride entries.  One of the most likely entry to be used here
  # is the tag entry.  This gets extended to the global tag (if defined)
  # above
  - xml://localhost:
    - tag: customer

  # The more elements you specify under a URL the more times the URL will
  # get replicated and used. Hence this entry actually could be considered
  # 2 URLs being called with just the destination email address changed:
  - mailto://george:password@gmail.com:
     - to: jason@hotmail.com
     - to: fred@live.com

  # Again... to re-iterate, the above mailto:// would actually fire two (2)
  # separate emails each with a different destination address specified.
  # Be careful when defining your arguments and differentiating between
  # when to use the dash (-) and when not to.  Each time you do, you will
  # cause another instance to be created.

  # Defining more then 1 element to a muti-set is easy, it looks like this:
  - mailto://jackson:abc123@hotmail.com:
     - to: jeff@gmail.com
       tag: jeff, customer

     - to: chris@yahoo.com
       tag: chris, customer
""",
        asset=asset,
    )

    # okay, here is how we get our total based on the above (read top-down)
    # +1  json:// entry
    # +1  xml:// entry
    # +2  mailto:// entry to jason@hotmail.com and fred@live.com
    # +2  mailto:// entry to jeff@gmail.com and chris@yahoo.com
    # = 6
    assert len(result) == 6

    # all six entries will have our global tags defined in them
    for entry in result:
        assert "admin" in entry.tags
        assert "devops" in entry.tags

    # Entries can be directly accessed as they were added

    # our json:// had no additional tags added; so just the global ones
    # So just 2; admin and devops (these were already validated above in the
    # for loop
    assert len(result[0].tags) == 2

    # our xml:// object has 1 tag added (customer)
    assert len(result[1].tags) == 3
    assert "customer" in result[1].tags

    # You get the idea, here is just a direct mapping to the remaining entries
    # in the same order they appear above
    assert len(result[2].tags) == 2
    assert len(result[3].tags) == 2

    assert len(result[4].tags) == 4
    assert "customer" in result[4].tags
    assert "jeff" in result[4].tags

    assert len(result[5].tags) == 4
    assert "customer" in result[5].tags
    assert "chris" in result[5].tags

    # There were no include entries defined
    assert len(config) == 0

    # Valid Configuration (multi inline configuration entries)
    result, config = ConfigBase.config_parse_yaml(
        """
# A configuration file that contains 2 includes separated by a comma and/or
# space:
include: http://localhost:8080/notify/apprise, http://localhost/apprise/cfg

""",
        asset=asset,
    )

    # We will have loaded no results
    assert isinstance(result, list)
    assert len(result) == 0

    # But our two configuration files will be present:
    assert len(config) == 2
    assert "http://localhost:8080/notify/apprise" in config
    assert "http://localhost/apprise/cfg" in config

    # Valid Configuration (another way of specifying more then one include)
    result, config = ConfigBase.config_parse_yaml(
        """
# A configuration file that contains 4 includes on their own
# lines beneath the keyword `include`:
include:
   http://localhost:8080/notify/apprise
   http://localhost/apprise/cfg01
   http://localhost/apprise/cfg02
   http://localhost/apprise/cfg03

""",
        asset=asset,
    )

    # We will have loaded no results
    assert isinstance(result, list)
    assert len(result) == 0

    # But our 4 configuration files will be present:
    assert len(config) == 4
    assert "http://localhost:8080/notify/apprise" in config
    assert "http://localhost/apprise/cfg01" in config
    assert "http://localhost/apprise/cfg02" in config
    assert "http://localhost/apprise/cfg03" in config

    # Test a configuration with an invalid schema with options
    result, config = ConfigBase.config_parse_yaml(
        """
    urls:
      - invalid://:
          tag: 'invalid'
          :name: 'Testing2'
          :body: 'test body2'
          :title: 'test title2'
""",
        asset=asset,
    )

    # We will have loaded no results
    assert isinstance(result, list)
    assert len(result) == 0

    # Valid Configuration (we allow comma separated entries for
    # each defined bullet)
    result, config = ConfigBase.config_parse_yaml(
        """
# A configuration file that contains 4 includes on their own
# lines beneath the keyword `include`:
include:
   - http://localhost:8080/notify/apprise, http://localhost/apprise/cfg01
     http://localhost/apprise/cfg02
   - http://localhost/apprise/cfg03

""",
        asset=asset,
    )

    # We will have loaded no results
    assert isinstance(result, list)
    assert len(result) == 0

    # But our 4 configuration files will be present:
    assert len(config) == 4
    assert "http://localhost:8080/notify/apprise" in config
    assert "http://localhost/apprise/cfg01" in config
    assert "http://localhost/apprise/cfg02" in config
    assert "http://localhost/apprise/cfg03" in config


def test_config_base_config_parse_yaml_includes(
    requests_remote_config: Mock,
) -> None:
    """
    API: ConfigBase.config_parse_yaml_includes

    Verify that HTTP include entries are fetched via requests.get and that
    the remote config bodies are parsed into json:// and xml:// notifiers.
    """

    # general reference used below
    asset = AppriseAsset()

    # Initialize our apprise configuration
    ac = AppriseConfig(asset=asset, recursion=1)

    # Add our entry
    ac.add_config(
        cleandoc("""
        # Include our Apprise Configuration from 2 locations
        include:
           - http://localhost:8000/get/test-001
           - http://localhost:8000/get/test-002

        # no further URLs defined
    """)
    )

    # Force a fresh parse and get the loaded plugin
    servers = ac.servers()

    # the following will return
    assert len(servers) == 3

    # representation for NotifyBase subclasses.
    urls = {n.url() for n in servers}

    # The *exact* URL string may include extra params depending on defaults,
    # so we check using containment instead of strict equality.
    assert any(u.startswith("json://localhost") for u in urls)
    assert any(u.startswith("xml://localhost") for u in urls)
    assert any(u.startswith("form://localhost") for u in urls)


def test_yaml_vs_text_tagging():
    """
    API: ConfigBase YAML vs TEXT tagging
    """

    yaml_result, _ = ConfigBase.config_parse_yaml("""
    urls:
      - mailtos://lead2gold:yesqbrulvaelyxve@gmail.com:
         tag: mytag
    """)
    assert yaml_result

    text_result, _ = ConfigBase.config_parse_text("""
    mytag=mailtos://lead2gold:yesqbrulvaelyxve@gmail.com
    """)
    assert text_result

    # Now we compare our results and verify they are the same
    assert len(yaml_result) == len(text_result)
    assert isinstance(yaml_result[0], NotifyEmail)
    assert isinstance(text_result[0], NotifyEmail)
    assert "mytag" in text_result[0]
    assert "mytag" in yaml_result[0]


def test_config_base_config_tag_groups_yaml_01():
    """
    API: ConfigBase.config_tag_groups_yaml #1 object

    """

    # general reference used below
    asset = AppriseAsset()

    # Valid Configuration
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

groups:
  - group1: tagB, tagC, tagNotAssigned
  - group2:
      - tagA
      - tagC
  - group3:
      - tagD: optional comment
      - tagA: optional comment #2

  # No assignment
  - group4

  # No assignment type 2
  - group5:

  # Integer assignment
  - group6: 3
  - group6: 3, 4, 5, test
  - group6: 3.5, tagC

  # Recursion
  - groupA: groupB
  - groupB: groupA
  # And Again... (just because)
  - groupA: groupB
  - groupB: groupA

  # Self assignment
  - groupX: groupX

  # Set up a larger recursive loop
  - groupG: groupH
  - groupH: groupI, groupJ
  - groupI: groupJ, groupG
  - groupJ: groupK, groupH, groupI
  - groupK: groupG

  # No tags assigned
  - groupK: ",,  , ,"
  - " , ": ",, , ,"

  # Multi Assignments
  - groupL, groupM: tagD, tagA
  - 4, groupN:
     - tagD
     - tagE, TagA

  # Add one more tag to groupL making it different then GroupM by 1
  - groupL: tagB
#
# Define your notification urls:
#
urls:
  - form://localhost:
     - tag: tagA
  - mailto://test:password@gmail.com:
     - tag: tagB
  - xml://localhost:
     - tag: tagC
  - json://localhost:
     - tag: tagD, tagA

""",
        asset=asset,
    )

    # We expect to parse 4 entries from the above
    assert isinstance(result, list)
    assert isinstance(config, list)
    assert len(result) == 4

    # Our first element is our group tags
    assert len(result[0].tags) == 5
    assert "group2" in result[0].tags
    assert "group3" in result[0].tags
    assert "groupl" in result[0].tags
    assert "groupm" in result[0].tags
    assert "taga" in result[0].tags

    # No additional configuration is loaded
    assert len(config) == 0

    apobj = Apprise()
    assert apobj.add(result)
    # We match against 1 entry
    assert len(list(apobj.find("tagA"))) == 2
    assert len(list(apobj.find("tagB"))) == 1
    assert len(list(apobj.find("tagC"))) == 1
    assert len(list(apobj.find("tagD"))) == 1
    assert len(list(apobj.find("group1"))) == 2
    assert len(list(apobj.find("group2"))) == 3
    assert len(list(apobj.find("group3"))) == 2
    assert len(list(apobj.find("group4"))) == 0
    assert len(list(apobj.find("group5"))) == 0
    # json:// -- group6 -> 4 -> TagA
    # xml://  -- group6 -> TagC
    assert len(list(apobj.find("group6"))) == 2
    assert len(list(apobj.find("4"))) == 1
    assert len(list(apobj.find("groupN"))) == 1


def test_config_base_config_tag_groups_yaml_02():
    """
    API: ConfigBase.config_tag_groups_yaml #2 object

    """

    # general reference used below
    asset = AppriseAsset()

    # Valid Configuration
    result, config = ConfigBase.config_parse_yaml(
        """
# if no version is specified then version 1 is presumed
version: 1

groups:
  group1: tagB, tagC, tagNotAssigned
  group2:
    - tagA
    - tagC
  group3:
    - tagD: optional comment
    - tagA: optional comment #2

  # No assignment type 2
  group5:

  # Integer assignment (since it's not a list, the last element prevails
  # and replaces the above); '4' does not get appended as it would in
  # the event this was a list instead
  group6: 3
  group6: 3, 4, 5, test
  group6: 3.5, tagC

  # Recursion
  groupA: groupB
  groupB: groupA
  # And Again... (just because)
  groupA: groupB
  groupB: groupA

  # Self assignment
  groupX: groupX

  # Set up a larger recursive loop
  groupG: groupH
  groupH: groupI, groupJ
  groupI: groupJ, groupG
  groupJ: groupK, groupH, groupI
  groupK: groupG

  # No tags assigned
  groupK: ",,  , ,"
  " , ": ",, , ,"

  # Multi Assignments
  groupL, groupM: tagD, tagA
  4, groupN:
   - tagD
   - tagE, TagA

  # Add one more tag to groupL making it different then GroupM by 1
  groupL: tagB
#
# Define your notification urls:
#
urls:
  - form://localhost:
     - tag: tagA
  - mailto://test:password@gmail.com:
     - tag: tagB
  - xml://localhost:
     - tag: tagC
  - json://localhost:
     - tag: tagD, tagA

""",
        asset=asset,
    )

    # We expect to parse 4 entries from the above
    assert isinstance(result, list)
    assert isinstance(config, list)
    assert len(result) == 4

    # Our first element is our group tags
    assert len(result[0].tags) == 5
    assert "group2" in result[0].tags
    assert "group3" in result[0].tags
    assert "groupl" in result[0].tags
    assert "groupm" in result[0].tags
    assert "taga" in result[0].tags

    # No additional configuration is loaded
    assert len(config) == 0

    apobj = Apprise()
    assert apobj.add(result)
    # We match against 1 entry
    assert len(list(apobj.find("tagA"))) == 2
    assert len(list(apobj.find("tagB"))) == 1
    assert len(list(apobj.find("tagC"))) == 1
    assert len(list(apobj.find("tagD"))) == 1
    assert len(list(apobj.find("group1"))) == 2
    assert len(list(apobj.find("group2"))) == 3
    assert len(list(apobj.find("group3"))) == 2
    assert len(list(apobj.find("group4"))) == 0
    assert len(list(apobj.find("group5"))) == 0
    # NOT json:// -- group6 -> 4 -> TagA (not appended because dict storage)
    #                          ^
    #                          |
    #            See: test_config_base_config_tag_groups_yaml_01 (above)
    #                 dict storage (as this tests for) causes last entry to
    #                 prevail; previous assignments are lost
    #
    # xml://  -- group6 -> TagC
    assert len(list(apobj.find("group6"))) == 1
    assert len(list(apobj.find("4"))) == 1
    assert len(list(apobj.find("groupN"))) == 1
    assert len(list(apobj.find("groupK"))) == 0


def test_config_base_config_parse_yaml_globals():
    """
    API: ConfigBase.config_parse_yaml globals

    """

    # general reference used below
    asset = AppriseAsset()

    # Invalid Syntax (throws a ScannerError)
    results, config = ConfigBase.config_parse_yaml(
        cleandoc("""
    urls:
      - jsons://localhost1:
         - to: jeff@gmail.com
           tag: jeff, customer
           cto: 30
           rto: 30
           verify: no

      - jsons://localhost2?cto=30&rto=30&verify=no:
         - to: json@gmail.com
           tag: json, customer
    """),
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(results, list)

    # Our results loaded
    assert len(results) == 2
    assert len(config) == 0

    # Now verify that our global variables correctly initialized
    for entry in results:
        assert entry.verify_certificate is False
        assert entry.socket_read_timeout == 30
        assert entry.socket_connect_timeout == 30


# This test fails on CentOS 8.x so it was moved into it's own function
# so it could be bypassed. The ability to use lists in YAML files didn't
# appear to happen until later on; it's certainly not available in v3.12
# which was what shipped with CentOS v8 at the time.
@pytest.mark.skipif(
    int(yaml.__version__.split(".")[0]) <= 3,
    reason="requires pyaml v4.x or higher.",
)
def test_config_base_config_parse_yaml_list():
    """
    API: ConfigBase.config_parse_yaml list parsing

    """

    # general reference used below
    asset = AppriseAsset()

    # Invalid url/schema
    result, config = ConfigBase.config_parse_yaml(
        """
# no lists... just no
urls: [milk, pumpkin pie, eggs, juice]

# Including by list is okay
include: [file:///absolute/path/, relative/path, http://test.com]

""",
        asset=asset,
    )

    # Invalid data gets us an empty result set
    assert isinstance(result, list)
    assert len(result) == 0

    # There were 3 include entries
    assert len(config) == 3
    assert "file:///absolute/path/" in config
    assert "relative/path" in config
    assert "http://test.com" in config


def test_yaml_asset_timezone_and_asset_tokens(tmpdir):
    """
    Covers: valid tz, reserved keys, invalid key, bool coercion, None->"",
    invalid type for string, and %z formatting path used later by plugins.
    """
    cfg = tmpdir.join("asset-tz.yml")
    cfg.write(
        """
version: 1
asset:
  tz: "  america/toronto  "     # case-insensitive + whitespace cleanup
  _private: "ignored"           # reserved (starts with _)
  name_: "ignored"              # reserved (ends with _)
  not_a_field: "ignored"        # invalid asset key
  secure_logging: "yes"         # string -> bool via parse_bool
  app_id: null                  # None becomes empty string
  app_desc: [ "list" ]          # invalid type for string -> warning path
urls:
  - json://localhost
"""
    )

    ac = AppriseConfig(paths=str(cfg))
    # Force a fresh parse and get the loaded plugin
    servers = ac.servers()
    assert len(servers) == 1

    plugin = servers[0]
    asset = plugin.asset

    # tz was accepted and normalised
    # lower() is required since Mac and Window are not case sensitive and will
    # See output as it was passed in and not corrected per IANA
    assert getattr(asset.tzinfo, "key", None).lower() == "america/toronto"
    # boolean coercion applied
    assert asset.secure_logging is True
    # None -> ""
    assert asset.app_id == ""


def test_yaml_asset_timezone_invalid_and_precedence(tmpdir):
    """
    If 'timezone' is present but invalid, it takes precedence over 'tz'
    and MUST NOT set the asset to the 'tz' value. We assert that London
    was not applied. We deliberately avoid asserting the exact fallback,
    since environments may surface a system tz (datetime.timezone) that
    lacks a `.key` attribute.
    """
    cfg = tmpdir.join("asset-tz-invalid.yml")
    cfg.write(
        """
version: 1
asset:
  timezone: null                # invalid (will be seen as "None")
  tz: Europe/London             # would be valid, but 'timezone' wins
urls:
  - json://localhost
"""
    )

    base_asset = AppriseAsset(timezone="UTC")
    ac = AppriseConfig(paths=str(cfg))
    servers = ac.servers(asset=base_asset)
    assert len(servers) == 1

    tzinfo = servers[0].asset.tzinfo

    # The key assertion: 'tz' MUST NOT have been applied
    assert getattr(tzinfo, "key", "").lower() != "europe/london"

    # Sanity check that something sensible is set
    # Compare offsets at a fixed instant instead of object identity
    dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc)
    assert tzinfo.utcoffset(dt) is not None


@pytest.mark.parametrize(
    "garbage_yaml",
    [
        "123",
        "3.1415",
        "true",
        "[UTC]",
        "{x: UTC}",
    ],
)
def test_yaml_asset_tz_garbage_types_only(tmpdir, garbage_yaml):
    """
    If only 'tz' is present and it is non-string, it is ignored.
    We assert it didn't become a real IANA zone (e.g., Europe/London),
    and that the tzinfo is usable.
    """
    cfg = tmpdir.join("asset-tz-garbage-only.yml")
    cfg.write(
        f"""
version: 1
asset:
  tz: {garbage_yaml}            # non-string -> warning path
urls:
  - json://localhost
"""
    )

    base_asset = AppriseAsset(timezone="UTC")
    ac = AppriseConfig(paths=str(cfg))
    servers = ac.servers(asset=base_asset)
    assert len(servers) == 1

    tzinfo = servers[0].asset.tzinfo

    # 1) Did not “accidentally” become a valid IANA from elsewhere.
    assert getattr(tzinfo, "key", "").lower() != "europe/london"

    # 2) tzinfo is usable (offset resolves at a fixed instant).
    dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc)
    assert tzinfo.utcoffset(dt) is not None
    # also stable tzname resolution
    assert isinstance(tzinfo.tzname(dt), str)


def test_config_base_parse_yaml_file05_tags_alias_dict_form(tmpdir):
    """
    API: ConfigBase.parse_yaml_file (#5)

    Validate `tags` is accepted as an alias of `tag` in the dict form:
      - "schema://...":
           tags: a-tag

    Also implicitly verifies `tags` does not leak into plugin kwargs, because
    plugin instantiation would fail if an unexpected kwarg is passed through.
    """
    t = tmpdir.mkdir("tags-alias-dict-form").join("apprise.yml")
    t.write("""urls:
  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:
      tags: test1
  - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn:
      tags: test2
  - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb:
      tags: test3
""")

    ac = AppriseConfig(paths=str(t))

    # The number of configuration files that exist
    assert len(ac) == 1

    # All entries should load
    assert len(ac.servers()) == 3

    a = Apprise()
    assert a.add(servers=ac) is True
    assert len(a) == 3

    # Verify tag matching works
    assert sum(1 for _ in a.find("no-match")) == 0
    assert sum(1 for _ in a.find("all")) == 3
    assert sum(1 for _ in a.find("test1")) == 1
    assert sum(1 for _ in a.find("test2")) == 1
    assert sum(1 for _ in a.find("test3")) == 1
    assert sum(1 for _ in a.find("test1, test3")) == 2


def test_config_base_parse_yaml_file06_tags_alias_list_form(tmpdir):
    """
    API: ConfigBase.parse_yaml_file (#6)

    Validate `tags` is accepted as an alias of `tag` in the list-of-dicts form:
      - "schema://...":
          - tags: a-tag
          - tags: another-tag

    Expected behaviour: expands into multiple entries (one per list item),
    and each is tagged appropriately.
    """
    t = tmpdir.mkdir("tags-alias-list-form").join("apprise.yml")
    t.write("""urls:
  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:
    - tags: test1
    - tags: test2
  - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb:
    - tags: test3
""")

    ac = AppriseConfig(paths=str(t))

    # The number of configuration files that exist
    assert len(ac) == 1

    # First URL expands to 2, second expands to 1
    assert len(ac.servers()) == 3

    a = Apprise()
    assert a.add(servers=ac) is True
    assert len(a) == 3

    # Verify tag matching works across expanded entries
    assert sum(1 for _ in a.find("test1")) == 1
    assert sum(1 for _ in a.find("test2")) == 1
    assert sum(1 for _ in a.find("test3")) == 1
    assert sum(1 for _ in a.find("test1, test2")) == 2


def test_config_base_parse_yaml_file07_tag_priority_over_tags(tmpdir):
    """
    API: ConfigBase.parse_yaml_file (#7)

    Validate priority: when both `tag` and `tags` are present, `tag` wins.

    This must remain true to preserve the original documented behaviour.
    """
    t = tmpdir.mkdir("tag-priority-over-tags").join("apprise.yml")
    t.write("""urls:
  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:
      tag: primary
      tags: secondary
""")

    ac = AppriseConfig(paths=str(t))

    # The number of configuration files that exist
    assert len(ac) == 1

    # Entry should load successfully
    assert len(ac.servers()) == 1

    a = Apprise()
    assert a.add(servers=ac) is True
    assert len(a) == 1

    # Tag priority check
    assert sum(1 for _ in a.find("primary")) == 1
    assert sum(1 for _ in a.find("secondary")) == 0


def test_yaml_sibling_token_overrides_basic():
    """
    API: ConfigBase - YAML sibling-key token overrides (basic cases).

    Keys at the same indentation level as the schema URL key
    (sibling form) must behave identically to child-indented tokens.
    Both of these forms must load the same plugin with the same attrs:

      # sibling form (same indentation)
      - mailtos://_:
        smtp: smtp.example.com
        from: sender@example.com

      # child form (one level deeper)
      - mailtos://_:
          smtp: smtp.example.com
          from: sender@example.com
    """
    # --- sibling form (the historically broken form) ---
    result_sibling, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://_:
    user: sender@example.com
    password: secret
    smtp: smtp.example.com
    from: sender@example.com
    to: recipient@example.com
    tag: myteam
""")

    # --- child form (always worked) ---
    result_child, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://_:
      user: sender@example.com
      password: secret
      smtp: smtp.example.com
      from: sender@example.com
      to: recipient@example.com
      tag: myteam
""")

    # Both forms must load exactly one plugin instance
    assert len(result_sibling) == 1
    assert len(result_child) == 1

    # Both results must be NotifyEmail instances
    assert isinstance(result_sibling[0], NotifyEmail)
    assert isinstance(result_child[0], NotifyEmail)

    for obj in (result_sibling[0], result_child[0]):
        # SMTP server from sibling/child token
        assert obj.smtp_host == "smtp.example.com"

        # from: maps to from_addr via template_args
        assert obj.from_addr[1] == "sender@example.com"

        # to: maps to targets
        assert any(t[1] == "recipient@example.com" for t in obj.targets)

        # password: sets the credential directly
        assert obj.password == "secret"

        # tag: applies correctly
        assert "myteam" in obj.tags


def test_yaml_sibling_token_priority_over_url():
    """
    API: ConfigBase - sibling tokens override URL-embedded values.

    Priority order (lowest to highest):
      3 - URL path (user:pass@host:port)
      2 - URL query string (?smtp=...)
      1 - YAML sibling/child tokens (highest)
    """
    # URL embeds one smtp server; sibling token overrides it
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com?smtp=old.smtp.example.com:
    smtp: new.smtp.example.com
    to: recipient@example.com
    tag: priority-test
""")

    assert len(result) == 1
    assert isinstance(result[0], NotifyEmail)

    # Sibling token must win over the URL query-string value
    assert result[0].smtp_host == "new.smtp.example.com"
    assert "priority-test" in result[0].tags


def test_yaml_child_token_priority_over_sibling():
    """
    API: ConfigBase - child tokens (indented) override sibling tokens.

    When a URL entry has BOTH a child value (indented under the key)
    AND sibling keys, child tokens take priority on a per-key basis.
    """
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com:
      smtp: child.smtp.example.com
    smtp: sibling.smtp.example.com
    to: recipient@example.com
""")

    assert len(result) == 1
    assert isinstance(result[0], NotifyEmail)

    # Child token (child.smtp.example.com) must beat sibling token
    assert result[0].smtp_host == "child.smtp.example.com"


def test_yaml_sibling_token_with_multiple_targets():
    """
    API: ConfigBase - multiple targets via sibling 'to:' token.

    'to:' in YAML tokens is a kwarg equivalent to ?to= in a URL.
    Multiple recipients must be expressed as a comma-separated string
    or as a YAML list under the 'to:' key -- NOT as multiple YAML
    entries each containing a separate 'to:' (that would create
    multiple plugin instances rather than one plugin with many targets).

    Both the comma-separated and YAML-list forms should resolve to a
    single plugin instance whose 'targets' list contains all recipients.
    """
    # Comma-separated form: equivalent to ?to=user1,user2 in the URL
    result_csv, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com:
    smtp: smtp.example.com
    to: user1@example.com, user2@example.com
    tag: shared
""")

    # YAML list form: same semantics, different syntax
    result_list, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com:
    smtp: smtp.example.com
    to:
      - user1@example.com
      - user2@example.com
    tag: shared
""")

    # Each form must load exactly one plugin instance
    assert len(result_csv) == 1
    assert len(result_list) == 1

    for obj in (result_csv[0], result_list[0]):
        assert isinstance(obj, NotifyEmail)

        # Sibling smtp: applies to the single instance
        assert obj.smtp_host == "smtp.example.com"

        # Both recipients must appear in targets
        assert any(t[1] == "user1@example.com" for t in obj.targets)
        assert any(t[1] == "user2@example.com" for t in obj.targets)

        # Sibling tag: applies correctly
        assert "shared" in obj.tags


def test_yaml_sibling_token_shared_across_list_expansion():
    """
    API: ConfigBase - sibling tokens propagate to every list-expansion entry.

    When the URL key has a YAML list as its child value (each list item
    becomes a separate plugin instance) AND sibling keys supply shared
    settings, the sibling tokens are applied as a base to every instance
    before per-entry overrides are merged.

    This is distinct from 'to: email1, email2' (multiple targets for one
    instance) -- here each list entry is an independent plugin instance
    with its own per-entry settings, all sharing the sibling tokens.
    """
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com:
      - to: manager@example.com
        tag: boss
      - to: coworker@example.com
        tag: team
    smtp: smtp.example.com
""")

    # One plugin instance per list entry
    assert len(result) == 2
    assert all(isinstance(r, NotifyEmail) for r in result)

    # Sibling smtp: must be applied to every instance
    assert result[0].smtp_host == "smtp.example.com"
    assert result[1].smtp_host == "smtp.example.com"

    # Per-entry tags must be respected
    assert "boss" in result[0].tags
    assert "team" in result[1].tags


def test_yaml_sibling_token_per_entry_overrides_sibling():
    """
    API: ConfigBase - per-entry tokens override sibling tokens for same key.

    When a list-expansion entry defines the same key as a sibling token,
    the per-entry value wins for that instance only.  Other instances with
    no per-entry value for that key still receive the sibling fallback.

    Scenario:
      - sibling smtp: smtp1.example.com   (shared fallback for all entries)
      - entry 1: to=manager, tag=boss     (no per-entry smtp -> uses sibling)
      - entry 2: to=coworker, smtp=smtp2, tag=team  (overrides sibling smtp)

    Expected result: 2 instances, each with correct smtp and tags.
    """
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com:
      - to: manager@example.com
        tag: boss
      - to: coworker@example.com
        smtp: smtp2.example.com
        tag: team
    smtp: smtp1.example.com
""")

    # One instance per list entry
    assert len(result) == 2
    assert all(isinstance(r, NotifyEmail) for r in result)

    # Instance 0: no per-entry smtp, so sibling smtp1 is used
    assert any(t[1] == "manager@example.com" for t in result[0].targets)
    assert result[0].smtp_host == "smtp1.example.com"
    assert "boss" in result[0].tags

    # Instance 1: per-entry smtp2 overrides the sibling smtp1
    assert any(t[1] == "coworker@example.com" for t in result[1].targets)
    assert result[1].smtp_host == "smtp2.example.com"
    assert "team" in result[1].tags


def test_yaml_sibling_alias_does_not_overwrite_child_canonical():
    """
    API: ConfigBase - child alias key wins over sibling canonical key.

    When a sibling provides a canonical key ('password') and the child dict
    provides the URL_TOKEN_ALIASES shorthand for the same key ('pass'), the
    child value must win.

    Before the fix, both dicts were merged raw before _special_token_handler()
    ran the URL_TOKEN_ALIASES step.  The combined dict contained both 'pass'
    and 'password', so the alias-conflict branch discarded 'pass' and kept
    the sibling's 'password' -- the wrong winner.

    The fix normalizes sibling and child token dicts separately first, so
    each alias is resolved in the context of its own dict only.  After
    normalization both dicts carry 'password', and the priority merge
    (sibling as base, child overrides) produces the correct winner.
    """
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com:
      pass: child_pass
      to: recipient@example.com
    password: sibling_pass
""")

    # One instance created
    assert len(result) == 1
    assert isinstance(result[0], NotifyEmail)

    # Child 'pass' (alias) must win over sibling 'password' (canonical)
    assert result[0].password == "child_pass"


def test_yaml_alias_populates_null_canonical():
    """
    API: ConfigBase - alias populates canonical when canonical is null.

    """
    # canonical 'password:' is null (no value) + alias 'pass: secret'
    # alias must win because null == not provided
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://user@localhost:
      pass: secret
      password:
""")
    assert len(result) == 1
    assert result[0].password == "secret"

    # Canonical with a real value still wins over the alias
    result2, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://user@localhost:
      pass: alias_val
      password: real_val
""")
    assert len(result2) == 1
    assert result2[0].password == "real_val"


def test_yaml_sibling_token_order_independent():
    """
    API: ConfigBase - sibling tokens are captured regardless of YAML key order.

    YAML does not guarantee mapping key order.  If a sibling token appears
    before the URL key in the mapping dict (e.g. due to editor or template
    quirks), it must still be captured.  The old dict(it) approach only
    captured items *after* the URL key in the iterator; the fix uses the
    full url.items() dict so all non-URL-key items are included.
    """
    # Write the YAML directly so the key order is explicit and no
    # yaml.dump call is required.  smtp appears BEFORE the URL key,
    # to appears AFTER -- both must be captured as sibling tokens.
    content = """
urls:
  - smtp: smtp.example.com
    mailtos://sender@example.com:
    to: recipient@example.com
"""

    result, _ = ConfigBase.config_parse_yaml(content)

    # Both pre-URL (smtp) and post-URL (to) siblings must be applied
    assert len(result) == 1
    assert isinstance(result[0], NotifyEmail)
    assert result[0].smtp_host == "smtp.example.com"
    assert any(t[1] == "recipient@example.com" for t in result[0].targets)


def test_yaml_sibling_password_token():
    """
    API: ConfigBase - 'password' and 'pass' keys both set plugin password.

    YAML tokens bypass parse_url(), so the URL-path shorthand 'pass' must
    be normalized to 'password' by URL_TOKEN_ALIASES inside
    _special_token_handler.  Verify that both spellings work in sibling
    and child indentation forms.
    """
    # --- 'password:' sibling form ---
    result_sibling, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://testhost:
    user: alice
    password: s3cr3t
    tag: pw-test
""")

    # --- 'password:' child form ---
    result_child, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://testhost:
      user: alice
      password: s3cr3t
      tag: pw-test
""")

    # --- 'pass:' sibling form (alias normalization) ---
    result_pass_sibling, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://testhost:
    user: alice
    pass: s3cr3t
    tag: pw-test
""")

    # --- 'pass:' child form (alias normalization) ---
    result_pass_child, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://testhost:
      user: alice
      pass: s3cr3t
      tag: pw-test
""")

    for result in (
        result_sibling,
        result_child,
        result_pass_sibling,
        result_pass_child,
    ):
        assert len(result) == 1

    for obj in (
        result_sibling[0],
        result_child[0],
        result_pass_sibling[0],
        result_pass_child[0],
    ):
        assert obj.user == "alice"
        assert obj.password == "s3cr3t"
        assert "pw-test" in obj.tags

    # When both 'pass:' and 'password:' appear, 'password:' (canonical) wins
    result_both, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://testhost:
      user: alice
      password: canonical
      pass: shorthand
""")
    assert len(result_both) == 1
    assert result_both[0].password == "canonical"


def test_yaml_sibling_no_silent_drop_on_null_child():
    """
    API: ConfigBase - sibling tokens are never silently discarded.

    Previously, when the URL key had a null child value (the common
    case when users used same-level indentation), all sibling keys
    were silently ignored and the URL failed to instantiate.  Confirm
    that the failure mode described in issue #1635 no longer occurs.
    """
    # Mirrors the bug-report YAML (correcting 'rom' typo to 'from')
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://_:
    user: sender@example.com
    password: secret
    smtp: smtp.example.com
    from: sender@example.com
    to: recipient@example.com
    tag:
      - dev
      - prod
""")

    # Must load successfully (previously raised TypeError and returned [])
    assert len(result) == 1
    assert isinstance(result[0], NotifyEmail)

    obj = result[0]
    assert obj.smtp_host == "smtp.example.com"
    assert obj.from_addr[1] == "sender@example.com"
    assert any(t[1] == "recipient@example.com" for t in obj.targets)
    assert "dev" in obj.tags
    assert "prod" in obj.tags


def test_yaml_sibling_token_unknown_schema():
    """
    API: ConfigBase - sibling tokens with an unregistered schema.

    When the schema is not in N_MGR the _special_token_handler call
    inside the 'elif sibling_tokens' branch is skipped (the False arm
    of 'if schema in N_MGR').  The branch still executes fully; it
    just applies sibling tokens as-is.  Because the schema is unknown
    no plugin is instantiated and the result list is empty.
    """
    # 'xyzunknown' is not a registered Apprise plugin, so the
    # 'if schema in N_MGR' guard evaluates to False.
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - xyzunknown://placeholder:
    mytoken: value
    otherkey: data
""")

    # The sibling branch ran (no exception), but no plugin loaded
    assert isinstance(result, list)
    assert len(result) == 0


def test_yaml_child_dict_schema_key_ignored():
    """
    API: ConfigBase - 'schema' key in a child dict token is stripped.

    The schema that determines which plugin is instantiated comes from the
    URL key itself.  Allowing a child dict to override 'schema' via a
    token would cause the wrong plugin constructor to be called with
    fields parsed for a different URL type, leading to silent failures.

    The list-expansion branch explicitly strips 'schema' from each entry.
    This test verifies the single-child-dict branch does the same.
    """
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - mailtos://sender@example.com:
      schema: json
      to: recipient@example.com
""")

    # The email plugin must load; the child 'schema: json' must be ignored
    assert len(result) == 1
    assert isinstance(result[0], NotifyEmail)


def test_yaml_sibling_before_url_no_spurious_warning():
    """
    API: ConfigBase - sibling tokens before the URL key emit no warning.

    YAML mappings do not guarantee key order.  A sibling token (e.g.
    'smtp:') may appear before the URL key in the serialised YAML.  The
    scan loop that finds the URL key must skip non-schema keys silently;
    it must NOT emit an 'Ignored entry' warning because those keys are
    collected later as valid sibling tokens.
    """
    from unittest import mock as _mock

    with _mock.patch("apprise.config.base.ConfigBase.logger") as mock_log:
        result, _ = ConfigBase.config_parse_yaml("""
urls:
  - smtp: smtp.example.com
    mailtos://sender@example.com:
    to: recipient@example.com
""")

    # Both siblings are applied; no spurious "Ignored entry" warning fired
    assert len(result) == 1
    assert isinstance(result[0], NotifyEmail)
    assert result[0].smtp_host == "smtp.example.com"
    assert any(t[1] == "recipient@example.com" for t in result[0].targets)

    # Confirm no "Ignored entry" warning was logged
    for call_args in mock_log.warning.call_args_list:
        assert "Ignored entry" not in str(call_args)


def test_yaml_token_priority_over_qsd():
    """
    API: ConfigBase - YAML tokens take priority over URL query-string params.

    The documented priority is: YAML tokens > ?key=value qsd > URL path.
    A second call to post_process_parse_url_results() runs after YAML tokens
    are merged.  Without stripping qsd first, that call would re-apply qsd
    overrides and silently overwrite YAML-token values, breaking the
    documented priority.
    """
    # The URL embeds ?pass= in the query string; the YAML sibling token
    # 'password:' should take priority and survive to the plugin.
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://user:urlpass@localhost?pass=qsdpass:
    password: yamlpass
""")
    assert len(result) == 1
    # YAML token must win over qsd ?pass= and URL-path password
    assert result[0].password == "yamlpass"


def test_yaml_token_priority_over_qsd_non_credential_fields():
    """
    API: ConfigBase - YAML tokens take priority over qsd for non-credential
    fields (verify, redirect, etc.).

    post_process_parse_url_results() processes not only user/password but
    also verify, redirect, rto, cto, and port from qsd.  All of these
    must honour the same YAML > qsd > URL-path priority rule.

    The fix strips ALL qsd (not just credential keys) before the second
    post_process call for URLBase-based plugins, so qsd can never
    overwrite any YAML-token value regardless of which field it sets.
    """
    # URL has ?verify=no but the YAML sibling says verify: yes --
    # YAML token must win; the plugin should have verify_certificate=True
    result, _ = ConfigBase.config_parse_yaml("""
urls:
  - json://user:pass@localhost?verify=no:
    verify: yes
""")
    assert len(result) == 1
    assert result[0].verify_certificate is True


def test_yaml_priority_all_urlbase_globals_via_plugin_details():
    """
    API: ConfigBase - YAML priority verified dynamically for every
    URLBase-level global arg via plugin.details().

    Verify that the priority sequence (YAML > qsd > URL path)
    is respected for every global arg defined.
    """
    from apprise import plugins
    from apprise.config.base import N_MGR
    from apprise.url import URLBase

    # Discover args via plugin.details() for a minimal URLBase plugin.
    # json:// has no required service credentials and is always available.
    details = plugins.details(N_MGR["json"])

    # Type-driven conflict pairs: (qsd_str, yaml_str).
    # YAML should win -- expected value is derived from yaml_str.
    type_conflict = {
        "bool": ("no", "yes"),
        "float": ("5.0", "10.0"),
        "int": ("5", "10"),
    }

    # Intersect plugin.details() args with URLBase.template_args to
    # isolate the globals shared by every URLBase plugin.
    # _lookup_default is the URLBase instance attribute name for the arg.
    urlbase_meta = URLBase.template_args
    test_cases = []
    for name, arg_meta in details["args"].items():
        if name not in urlbase_meta:
            continue
        arg_type = arg_meta.get("type", "string")
        if arg_type not in type_conflict:
            continue
        attr_name = urlbase_meta[name].get("_lookup_default")
        if not attr_name:
            continue
        qsd_val, yaml_val = type_conflict[arg_type]
        test_cases.append((name, attr_name, arg_type, qsd_val, yaml_val))

    # Guard: the 4 known URLBase globals must always be discovered.
    # If URLBase.template_args loses an entry this fires immediately.
    found = {t[0] for t in test_cases}
    assert found >= {"cto", "redirect", "rto", "verify"}, (
        "Expected URLBase globals cto/redirect/rto/verify in "
        "plugin.details(); got: {}".format(sorted(found))
    )

    # Build a YAML config that sets every global in both qsd AND as a
    # YAML sibling token with a conflicting value so the winner is
    # unambiguous.
    qsd_str = "&".join("{}={}".format(n, qv) for n, _, _, qv, _ in test_cases)
    siblings = "\n    ".join(
        "{}: {}".format(n, yv) for n, _, _, _, yv in test_cases
    )
    yaml_cfg = (
        "urls:\n  - json://user:pass@localhost?{qsd}:\n    {siblings}\n"
    ).format(qsd=qsd_str, siblings=siblings)

    result, _ = ConfigBase.config_parse_yaml(yaml_cfg)
    assert len(result) == 1
    inst = result[0]

    # Verify YAML wins for every global.
    for name, attr_name, arg_type, qsd_val, yaml_val in test_cases:
        actual = getattr(inst, attr_name)
        if arg_type == "bool":
            expected = True  # yaml_val is always "yes"
        elif arg_type == "float":
            expected = float(yaml_val)
        elif arg_type == "int":
            expected = int(yaml_val)
        else:
            expected = yaml_val
        assert actual == expected, (
            "YAML token '{}' should override qsd "
            "(YAML={!r}, qsd={!r}) but got {!r} for '{}'".format(
                name, yaml_val, qsd_val, actual, attr_name
            )
        )
