# 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, timedelta

# Disable logging for a cleaner testing output
import logging
from timeit import default_timer

import pytest

from apprise import AppriseAsset, NotifyImageSize, NotifyType
from apprise.plugins import NotifyBase

logging.disable(logging.CRITICAL)


def test_notify_base():
    """
    API: NotifyBase() object

    """

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

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

    # Bad port information
    nb = NotifyBase(port="invalid")
    assert nb.port is None

    nb = NotifyBase(port=10)
    assert nb.port == 10

    assert isinstance(nb.url(), str)
    assert str(nb) == nb.url()

    with pytest.raises(NotImplementedError):
        # Each sub-module is that inherits this as a parent is required to
        # over-ride this function. So direct calls to this throws a not
        # implemented error intentionally
        nb.send("test message")

    # Throttle overrides..
    nb = NotifyBase()
    nb.request_rate_per_sec = 0.0
    start_time = default_timer()
    nb.throttle()
    elapsed = default_timer() - start_time
    # Should be a very fast response time since we set it to zero but we'll
    # check for less then 500 to be fair as some testing systems may be slower
    # then other
    assert elapsed < 0.5

    # Concurrent calls should achieve the same response
    start_time = default_timer()
    nb.throttle()
    elapsed = default_timer() - start_time
    assert elapsed < 0.5

    nb = NotifyBase()
    nb.request_rate_per_sec = 1.0

    # Set our time to now
    start_time = default_timer()
    nb.throttle()
    elapsed = default_timer() - start_time
    # A first call to throttle (Without telling it a time previously ran) does
    # not block for any length of time; it just merely sets us up for
    # concurrent calls to block
    assert elapsed < 0.5

    # Concurrent calls could take up to the rate_per_sec though...
    start_time = default_timer()
    nb.throttle(last_io=datetime.now())
    elapsed = default_timer() - start_time
    assert elapsed > 0.48 and elapsed < 1.5

    nb = NotifyBase()
    nb.request_rate_per_sec = 1.0

    # Set our time to now
    start_time = default_timer()
    nb.throttle(last_io=datetime.now())
    elapsed = default_timer() - start_time
    # because we told it that we had already done a previous action (now)
    # the throttle holds out until the right time has passed
    assert elapsed > 0.48 and elapsed < 1.5

    # Concurrent calls could take up to the rate_per_sec though...
    start_time = default_timer()
    nb.throttle(last_io=datetime.now())
    elapsed = default_timer() - start_time
    assert elapsed > 0.48 and elapsed < 1.5

    nb = NotifyBase()
    start_time = default_timer()
    nb.request_rate_per_sec = 1.0
    # Force a time in the past
    nb.throttle(last_io=(datetime.now() - timedelta(seconds=20)))
    elapsed = default_timer() - start_time
    # Should be a very fast response time since we set it to zero but we'll
    # check for less then 500 to be fair as some testing systems may be slower
    # then other
    assert elapsed < 0.5

    # Force a throttle time
    start_time = default_timer()
    nb.throttle(wait=0.5)
    elapsed = default_timer() - start_time
    assert elapsed > 0.48 and elapsed < 1.5

    # our NotifyBase wasn't initialized with an ImageSize so this will fail
    assert nb.image_url(notify_type=NotifyType.INFO) is None
    assert nb.image_path(notify_type=NotifyType.INFO) is None
    assert nb.image_raw(notify_type=NotifyType.INFO) is None

    # Color handling
    assert nb.color(notify_type="invalid") == AppriseAsset.default_html_color
    assert isinstance(
        nb.color(notify_type=NotifyType.INFO, color_type=None), str
    )
    assert isinstance(
        nb.color(notify_type=NotifyType.INFO, color_type=int), int
    )
    assert isinstance(
        nb.color(notify_type=NotifyType.INFO, color_type=tuple), tuple
    )

    # Ascii Handling
    assert nb.ascii(notify_type="invalid") == AppriseAsset.default_ascii_chars
    assert nb.ascii(NotifyType.INFO) == "[i]"
    assert nb.ascii(NotifyType.SUCCESS) == "[+]"
    assert nb.ascii(NotifyType.WARNING) == "[~]"
    assert nb.ascii(NotifyType.FAILURE) == "[!]"

    # Create an object
    nb = NotifyBase()
    # Force an image size since the default doesn't have one
    nb.image_size = NotifyImageSize.XY_256

    # We'll get an object this time around
    assert nb.image_url(notify_type=NotifyType.INFO) is not None
    assert nb.image_path(notify_type=NotifyType.INFO) is not None
    assert nb.image_raw(notify_type=NotifyType.INFO) is not None

    # Static function testing
    assert (
        NotifyBase.escape_html("<content>'\t \n</content>")
        == "&lt;content&gt;&apos;&emsp;&nbsp;\n&lt;/content&gt;"
    )

    assert (
        NotifyBase.escape_html(
            "<content>'\t \n</content>", convert_new_lines=True
        )
        == "&lt;content&gt;&apos;&emsp;&nbsp;<br/>&lt;/content&gt;"
    )

    # Test invalid data
    assert NotifyBase.split_path(None) == []
    assert NotifyBase.split_path(object()) == []
    assert NotifyBase.split_path(42) == []

    assert NotifyBase.split_path(
        "/path/?name=Dr%20Disrespect", unquote=False
    ) == [
        "path",
        "?name=Dr%20Disrespect",
    ]

    assert NotifyBase.split_path(
        "/path/?name=Dr%20Disrespect", unquote=True
    ) == [
        "path",
        "?name=Dr Disrespect",
    ]

    # a slash found inside the path, if escaped properly will not be broken
    # by split_path while additional concatinated slashes are ignored
    # FYI: %2F = /
    assert NotifyBase.split_path(
        "/%2F///%2F%2F////%2F%2F%2F////", unquote=True
    ) == [
        "/",
        "//",
        "///",
    ]

    # Test invalid data
    assert NotifyBase.parse_list(None) == []
    assert NotifyBase.parse_list(object()) == []
    assert NotifyBase.parse_list(42) == []

    result = NotifyBase.parse_list(
        ",path,?name=Dr%20Disrespect", unquote=False
    )
    assert isinstance(result, list)
    assert len(result) == 2
    assert "path" in result
    assert "?name=Dr%20Disrespect" in result

    result = NotifyBase.parse_list(",path,?name=Dr%20Disrespect", unquote=True)
    assert isinstance(result, list)
    assert len(result) == 2
    assert "path" in result
    assert "?name=Dr Disrespect" in result

    # by parse_list while additional concatinated slashes are ignored
    # FYI: %2F = /
    # In this lit there are actually 4 entries, however parse_list
    # eliminates duplicates in addition to unquoting content by default
    result = NotifyBase.parse_list(
        ",%2F,%2F%2F, , , ,%2F%2F%2F, %2F", unquote=True
    )
    assert isinstance(result, list)
    assert len(result) == 3
    assert "/" in result
    assert "//" in result
    assert "///" in result

    # Phone number parsing
    assert NotifyBase.parse_phone_no(None) == []
    assert NotifyBase.parse_phone_no(object()) == []
    assert NotifyBase.parse_phone_no(42) == []

    result = NotifyBase.parse_phone_no(
        "+1-800-123-1234,(800) 123-4567", unquote=False
    )
    assert isinstance(result, list)
    assert len(result) == 2
    assert "+1-800-123-1234" in result
    assert "(800) 123-4567" in result

    # %2B == +
    result = NotifyBase.parse_phone_no(
        "%2B1-800-123-1234,%2B1%20800%20123%204567", unquote=True
    )
    assert isinstance(result, list)
    assert len(result) == 2
    assert "+1-800-123-1234" in result
    assert "+1 800 123 4567" in result

    # Give nothing, get nothing
    assert NotifyBase.escape_html("") == ""
    assert NotifyBase.escape_html(None) == ""
    assert NotifyBase.escape_html(object()) == ""

    # Test quote
    assert NotifyBase.unquote("%20") == " "
    assert NotifyBase.quote(" ") == "%20"
    assert NotifyBase.unquote(None) == ""
    assert NotifyBase.quote(None) == ""


def test_notify_base_urls():
    """
    API: NotifyBase() URLs

    """

    # Test verify switch whih is used as part of the SSL Verification
    # by default all SSL sites are verified unless this flag is set to
    # something like 'No', 'False', 'Disabled', etc.  Boolean values are
    # pretty forgiving.
    results = NotifyBase.parse_url("https://localhost:8080/?verify=No")
    assert "verify" in results
    assert results["verify"] is False

    results = NotifyBase.parse_url("https://localhost:8080/?verify=Yes")
    assert "verify" in results
    assert results["verify"] is True

    # The default is to verify
    results = NotifyBase.parse_url("https://localhost:8080")
    assert "verify" in results
    assert results["verify"] is True

    # Password Handling

    # pass keyword over-rides default password
    results = NotifyBase.parse_url("https://user:pass@localhost")
    assert "password" in results
    assert results["password"] == "pass"

    # pass keyword over-rides default password
    results = NotifyBase.parse_url(
        "https://user:pass@localhost?pass=newpassword"
    )
    assert "password" in results
    assert results["password"] == "newpassword"

    # password keyword can also optionally be used
    results = NotifyBase.parse_url(
        "https://user:pass@localhost?password=passwd"
    )
    assert "password" in results
    assert results["password"] == "passwd"

    # pass= override password=
    # password keyword can also optionally be used
    results = NotifyBase.parse_url(
        "https://user:pass@localhost?pass=pw1&password=pw2"
    )
    assert "password" in results
    assert results["password"] == "pw1"

    # Options
    results = NotifyBase.parse_url("https://localhost?format=invalid")
    assert "format" not in results
    results = NotifyBase.parse_url("https://localhost?format=text")
    assert "format" in results
    assert results["format"] == "text"
    results = NotifyBase.parse_url("https://localhost?format=markdown")
    assert "format" in results
    assert results["format"] == "markdown"
    results = NotifyBase.parse_url("https://localhost?format=html")
    assert "format" in results
    assert results["format"] == "html"

    results = NotifyBase.parse_url("https://localhost?overflow=invalid")
    assert "overflow" not in results
    results = NotifyBase.parse_url("https://localhost?overflow=upstream")
    assert "overflow" in results
    assert results["overflow"] == "upstream"
    results = NotifyBase.parse_url("https://localhost?overflow=split")
    assert "overflow" in results
    assert results["overflow"] == "split"
    results = NotifyBase.parse_url("https://localhost?overflow=truncate")
    assert "overflow" in results
    assert results["overflow"] == "truncate"

    # User Handling

    # user keyword over-rides default password
    results = NotifyBase.parse_url("https://user:pass@localhost")
    assert "user" in results
    assert results["user"] == "user"

    # user keyword over-rides default password
    results = NotifyBase.parse_url("https://user:pass@localhost?user=newuser")
    assert "user" in results
    assert results["user"] == "newuser"

    # Test invalid urls
    assert NotifyBase.parse_url("https://:@/") is None
    assert NotifyBase.parse_url("http://:@") is None
    assert NotifyBase.parse_url("http://@") is None
    assert NotifyBase.parse_url("http:///") is None
    assert NotifyBase.parse_url("http://:test/") is None
    assert NotifyBase.parse_url("http://pass:test/") is None


def test_notify_base_runtime_deps():
    """NotifyBase.runtime_deps() returns an empty tuple by default."""

    assert NotifyBase.runtime_deps() == ()
    assert isinstance(NotifyBase.runtime_deps(), tuple)


def test_notify_base_enable_disable():
    """NotifyBase.enable() and disable() toggle the enabled flag."""

    class ToggleNotification(NotifyBase):
        protocol = "toggle"

        def notify(self, *args, **kwargs):
            return True

        def url(self, **kwargs):
            return "toggle://"

    # Starts enabled
    assert ToggleNotification.enabled is True

    # disable() sets it False
    ToggleNotification.disable()
    assert ToggleNotification.enabled is False

    # enable() restores it
    ToggleNotification.enable()
    assert ToggleNotification.enabled is True

    # Calling disable() twice is idempotent
    ToggleNotification.disable()
    ToggleNotification.disable()
    assert ToggleNotification.enabled is False

    # Calling enable() twice is idempotent
    ToggleNotification.enable()
    ToggleNotification.enable()
    assert ToggleNotification.enabled is True

    # Changes are on the class, not on instances
    instance = ToggleNotification(host="localhost")
    ToggleNotification.disable()
    assert instance.enabled is False
    ToggleNotification.enable()
    assert instance.enabled is True


def test_notify_base_runtime_deps_override():
    """Subclasses can override runtime_deps() to declare their dependencies."""

    class DepFreePlugin(NotifyBase):
        protocol = "depfree"

        def notify(self, *args, **kwargs):
            return True

        def url(self, **kwargs):
            return "depfree://"

    class SingleDepPlugin(NotifyBase):
        protocol = "singledep"

        def notify(self, *args, **kwargs):
            return True

        def url(self, **kwargs):
            return "singledep://"

        @staticmethod
        def runtime_deps():
            return ("fakelibA",)

    class MultiDepPlugin(NotifyBase):
        protocol = "multidep"

        def notify(self, *args, **kwargs):
            return True

        def url(self, **kwargs):
            return "multidep://"

        @staticmethod
        def runtime_deps():
            return ("fakelibA", "fakelibB")

    # No override -> inherits the empty tuple from NotifyBase
    assert DepFreePlugin.runtime_deps() == ()

    # Single-dep override
    assert SingleDepPlugin.runtime_deps() == ("fakelibA",)

    # Multi-dep override preserves order
    deps = MultiDepPlugin.runtime_deps()
    assert deps == ("fakelibA", "fakelibB")
    assert deps[0] == "fakelibA"
    assert deps[1] == "fakelibB"
