# 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
from json import dumps, loads

# Disable logging for a cleaner testing output
import logging
import os
from unittest import mock

from helpers import AppriseURLTester
import pytest
import requests

from apprise import Apprise, AppriseAttachment, NotifyType
from apprise.plugins.office365 import NotifyOffice365

logging.disable(logging.CRITICAL)

# Attachment Directory
TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var")

# Our Testing URLs
apprise_url_tests = (
    ##################################
    # NotifyOffice365
    ##################################
    (
        "o365://",
        {
            # Missing tenant, client_id, secret, and targets!
            "instance": TypeError,
        },
    ),
    (
        "o365://:@/",
        {
            # invalid url
            "instance": TypeError,
        },
    ),
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format(
            # invalid tenant
            tenant=",",
            cid="ab-cd-ef-gh",
            aid="user@example.com",
            secret="abcd/123/3343/@jack/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            # Expected failure
            "instance": TypeError,
        },
    ),
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format(
            tenant="tenant",
            # invalid client id
            cid="ab.",
            aid="user2@example.com",
            secret="abcd/123/3343/@jack/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            # Expected failure
            "instance": TypeError,
        },
    ),
    (
        "o365://{tenant}/{cid}/{secret}/{targets}".format(
            # email not required if mode is set to self
            tenant="tenant",
            cid="ab-cd-ef-gh",
            secret="abcd/123/3343/@jack/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # Test what happens if a batch send fails to return a messageCount
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "mail": "user@example.ca",
            },
        },
    ),
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            aid="user@example.edu",
            secret="abcd/123/3343/@jack/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # Test what happens if a batch send fails to return a messageCount
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                # For 'From:' Lookup
                "mail": "user@example.ca",
            },
            # Our expected url(privacy=True) startswith() response:
            "privacy_url": (
                "azure://user@example.edu/t...t/a...h/****/email1@test.ca/"
            ),
            "force_debug": True,
        },
    ),
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            # Source can also be Object ID
            aid="hg-fe-dc-ba",
            secret="abcd/123/3343/@jack/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # Test what happens if a batch send fails to return a messageCount
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "mail": "user@example.ca",
                "displayName": "John",
            },
            # Our expected url(privacy=True) startswith() response:
            "privacy_url": (
                "azure://hg-fe-dc-ba/t...t/a...h/****/email1@test.ca/"
            ),
        },
    ),
    # ObjectID Specified, but no targets
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            # Source can also be Object ID
            aid="hg-fe-dc-ba",
            secret="abcd/123/3343/@jack/test",
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # Test what happens if a batch send fails to return a messageCount
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "mail": "user@example.ca",
            },
            # No emails detected
            "notify_response": False,
            # Our expected url(privacy=True) startswith() response:
            "privacy_url": "azure://hg-fe-dc-ba/t...t/a...h/****",
        },
    ),
    # ObjectID Specified, but no targets
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            # Source can also be Object ID
            aid="hg-fe-dc-ba",
            secret="abcd/123/3343/@jack/test",
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # Test what happens if a batch send fails to return a messageCount
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "userPrincipalName": "user@example.ca",
            },
            # No emails detected
            "notify_response": False,
            # Our expected url(privacy=True) startswith() response:
            "privacy_url": "azure://hg-fe-dc-ba/t...t/a...h/****",
        },
    ),
    # test our arguments
    (
        "o365://_/?oauth_id={cid}&oauth_secret={secret}&tenant={tenant}"
        "&to={targets}&from={aid}".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            aid="user@example.ca",
            secret="abcd/123/3343/@jack/test",
            targets="email1@test.ca",
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # Test what happens if a batch send fails to return a messageCount
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "mail": "user@example.ca",
            },
            # Our expected url(privacy=True) startswith() response:
            "privacy_url": (
                "azure://user@example.ca/t...t/a...h/****/email1@test.ca/"
            ),
        },
    ),
    # Test invalid JSON (no tenant defaults to email domain)
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            aid="user@example.com",
            secret="abcd/123/3343/@jack/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # invalid JSON response
            "requests_response_text": "{",
            "notify_response": False,
        },
    ),
    # No Targets specified
    (
        "o365://{aid}/{tenant}/{cid}/{secret}".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            aid="user@example.com",
            secret="abcd/123/3343/@jack/test",
        ),
        {
            # We're valid and good to go
            "instance": NotifyOffice365,
            # There were no targets to notify; so we use our own email
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "userPrincipalName": "user@example.ca",
            },
        },
    ),
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format(
            tenant="tenant",
            cid="zz-zz-zz-zz",
            aid="user@example.com",
            secret="abcd/abc/dcba/@john/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            "instance": NotifyOffice365,
            # throw a bizarre code forcing us to fail to look it up
            "response": False,
            "requests_response_code": 999,
        },
    ),
    (
        "o365://{tenant}:{aid}/{cid}/{secret}/{targets}".format(
            tenant="tenant",
            cid="01-12-23-34",
            aid="user@example.com",
            secret="abcd/321/4321/@test/test",
            targets="/".join(["email1@test.ca"]),
        ),
        {
            "instance": NotifyOffice365,
            # Throws a series of i/o exceptions with this flag
            # is set and tests that we gracefully handle them
            "test_requests_exceptions": True,
        },
    ),
    # reply_to support — plain address
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}"
        "?reply_to=reply@example.org".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            aid="user@example.edu",
            secret="abcd/123/3343/@jack/test",
            targets="email1@test.ca",
        ),
        {
            "instance": NotifyOffice365,
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "mail": "user@example.ca",
            },
            "privacy_url": (
                "azure://user@example.edu/t...t/a...h/****/email1@test.ca/"
            ),
        },
    ),
    # reply_to with URL-encoded (+) spaces in name
    (
        "o365://{aid}/{tenant}/{cid}/{secret}/{targets}"
        "?reply_to=Reply+Person+reply@example.org".format(
            tenant="tenant",
            cid="ab-cd-ef-gh",
            aid="user@example.edu",
            secret="abcd/123/3343/@jack/test",
            targets="email1@test.ca",
        ),
        {
            "instance": NotifyOffice365,
            "requests_response_text": {
                "expires_in": 2000,
                "access_token": "abcd1234",
                "mail": "user@example.ca",
            },
            "privacy_url": (
                "azure://user@example.edu/t...t/a...h/****/email1@test.ca/"
            ),
        },
    ),
)


def test_plugin_office365_urls():
    """NotifyOffice365() Apprise URLs."""

    # Run our general tests
    AppriseURLTester(tests=apprise_url_tests).run_all()


@mock.patch("requests.get")
@mock.patch("requests.post")
def test_plugin_office365_general(mock_get, mock_post):
    """NotifyOffice365() General Testing."""

    # Initialize some generic (but valid) tokens
    email = "user@example.net"
    tenant = "ff-gg-hh-ii-jj"
    client_id = "aa-bb-cc-dd-ee"
    secret = "abcd/1234/abcd@ajd@/test"
    targets = "target@example.com"

    # Prepare Mock return object
    payload = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
        # For 'From:' Lookup
        "mail": "abc@example.ca",
        # For our Draft Email ID:
        "id": "draft-id-no",
    }
    response = mock.Mock()
    response.content = dumps(payload)
    response.status_code = requests.codes.ok
    mock_post.return_value = response
    mock_get.return_value = response

    # Instantiate our object
    obj = Apprise.instantiate(f"o365://{email}/{tenant}/{secret}/{targets}")

    assert isinstance(obj, NotifyOffice365)

    # Test our URL generation
    assert isinstance(obj.url(), str)

    # Test our notification
    assert obj.notify(title="title", body="test") is True

    # Instantiate our object
    obj = Apprise.instantiate(
        "o365://{email}/{tenant}/{client_id}/{secret}/{targets}"
        "?bcc={bcc}&cc={cc}".format(
            tenant=tenant,
            email=email,
            client_id=client_id,
            secret=secret,
            targets=targets,
            # Test the cc and bcc list (use good and bad email)
            cc="Chuck Norris cnorris@yahoo.ca, Sauron@lotr.me, invalid@!",
            bcc="Bruce Willis bwillis@hotmail.com, Frodo@lotr.me invalid@!",
        )
    )

    assert isinstance(obj, NotifyOffice365)

    # Test our URL generation
    assert isinstance(obj.url(), str)

    # Test our notification
    assert obj.notify(title="title", body="test") is True

    with pytest.raises(TypeError):
        # No secret
        NotifyOffice365(
            email=email,
            client_id=client_id,
            tenant=tenant,
            secret=None,
            targets=None,
        )

    # One of the targets are invalid
    obj = NotifyOffice365(
        email=email,
        client_id=client_id,
        tenant=tenant,
        secret=secret,
        targets=("Management abc@gmail.com", "garbage"),
    )
    # Test our notification (this will work and only notify abc@gmail.com)
    assert obj.notify(title="title", body="test") is True

    # all of the targets are invalid
    obj = NotifyOffice365(
        email=email,
        client_id=client_id,
        tenant=tenant,
        secret=secret,
        targets=("invalid", "garbage"),
    )

    # Test our notification (which will fail because of no entries)
    assert obj.notify(title="title", body="test") is False


@mock.patch("requests.get")
@mock.patch("requests.post")
def test_plugin_office365_authentication(mock_get, mock_post):
    """NotifyOffice365() Authentication Testing."""

    # Initialize some generic (but valid) tokens
    tenant = "ff-gg-hh-ii-jj"
    email = "user@example.net"
    client_id = "aa-bb-cc-dd-ee"
    secret = "abcd/1234/abcd@ajd@/test"
    targets = "target@example.com"

    # Prepare Mock return object
    authentication_okay = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
    }
    authentication_failure = {
        "error": "invalid_scope",
        "error_description": "AADSTS70011: Blah... Blah Blah... Blah",
        "error_codes": [70011],
        "timestamp": "2020-01-09 02:02:12Z",
        "trace_id": "255d1aef-8c98-452f-ac51-23d051240864",
        "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7",
    }
    response = mock.Mock()
    response.content = dumps(authentication_okay)
    response.status_code = requests.codes.ok
    mock_post.return_value = response
    mock_get.return_value = response

    # Instantiate our object
    obj = Apprise.instantiate(
        f"azure://{email}/{tenant}/{client_id}/{secret}/{targets}"
    )

    assert isinstance(obj, NotifyOffice365)

    # Authenticate
    assert obj.authenticate() is True

    # We're already authenticated
    assert obj.authenticate() is True

    # Expire our token
    obj.token_expiry = datetime.now()

    # Re-authentiate
    assert obj.authenticate() is True

    # Change our response
    response.status_code = 400

    # We'll fail to send a notification now...
    assert obj.notify(title="title", body="test") is False

    # Expire our token
    obj.token_expiry = datetime.now()

    # Set a failure response
    response.content = dumps(authentication_failure)

    # We will fail to authenticate at this point
    assert obj.authenticate() is False

    # Notifications will also fail in this case
    assert obj.notify(title="title", body="test") is False

    # We will fail to authenticate with invalid data

    invalid_auth_entries = authentication_okay.copy()
    invalid_auth_entries["expires_in"] = "garbage"
    response.content = dumps(invalid_auth_entries)
    response.status_code = requests.codes.ok
    assert obj.authenticate() is False

    invalid_auth_entries["expires_in"] = None
    response.content = dumps(invalid_auth_entries)
    assert obj.authenticate() is False

    invalid_auth_entries["expires_in"] = ""
    response.content = dumps(invalid_auth_entries)
    assert obj.authenticate() is False

    del invalid_auth_entries["expires_in"]
    response.content = dumps(invalid_auth_entries)
    assert obj.authenticate() is False


@mock.patch("requests.put")
@mock.patch("requests.get")
@mock.patch("requests.post")
def test_plugin_office365_queries(mock_post, mock_get, mock_put):
    """NotifyOffice365() General Queries."""

    # Initialize some generic (but valid) tokens
    source = "abc-1234-object-id"
    tenant = "ff-gg-hh-ii-jj"
    client_id = "aa-bb-cc-dd-ee"
    secret = "abcd/1234/abcd@ajd@/test"
    targets = "target@example.ca"

    # Prepare Mock return object
    payload = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
        # For 'From:' Lookup (email)
        "mail": "user@example.edu",
        # For 'From:' Lookup (name)
        "displayName": "John",
        # For our Draft Email ID:
        "id": "draft-id-no",
        # For FIle Uploads
        "uploadUrl": "https://my.url.path/",
    }

    okay_response = mock.Mock()
    okay_response.content = dumps(payload)
    okay_response.status_code = requests.codes.ok
    mock_post.return_value = okay_response
    mock_put.return_value = okay_response

    bad_response = mock.Mock()
    bad_response.content = dumps(payload)
    bad_response.status_code = requests.codes.forbidden

    # Assign our GET a bad response so we fail to look up the user
    mock_get.return_value = bad_response

    #
    # Early exit: source is an Object ID (GUID) and lookup fails
    #
    guid_source = "e2a9db46-5714-44c2-87bf-c33bf236f42a"

    not_found_payload = {
        "error": {
            "code": "Request_ResourceNotFound",
            "message": "Resource not found.",
        }
    }
    not_found_response = mock.Mock()
    not_found_response.content = dumps(not_found_payload)
    not_found_response.status_code = requests.codes.not_found

    # Force the sender lookup to fail for a GUID based source
    mock_get.return_value = not_found_response

    obj = Apprise.instantiate(
        f"azure://{guid_source}/{tenant}/{client_id}{secret}/{targets}"
    )
    assert isinstance(obj, NotifyOffice365)

    # Notify must exit early since the source Object ID could not be resolved
    assert (
        obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
        is False
    )

    # 1) Authenticate (POST token)
    assert mock_post.call_count == 1
    # 2) Attempt user lookup (GET)
    assert mock_get.call_count == 1

    mock_post.reset_mock()
    mock_get.reset_mock()

    # Restore GET failure behaviour used by remaining tests
    mock_get.return_value = bad_response

    # Instantiate our object
    obj = Apprise.instantiate(
        f"azure://{source}/{tenant}/{client_id}{secret}/{targets}"
    )

    assert isinstance(obj, NotifyOffice365)

    # We can still send a notification even if we can't look up the email
    assert (
        obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
        is True
    )

    assert mock_post.call_count == 2
    assert (
        mock_post.call_args_list[0][0][0]
        == f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == "https://graph.microsoft.com/v1.0/users/abc-1234-object-id/sendMail"
    )
    payload = loads(mock_post.call_args_list[1][1]["data"])
    assert payload == {
        "message": {
            "subject": "title",
            "body": {
                "contentType": "HTML",
                "content": "body",
            },
            "toRecipients": [
                {"emailAddress": {"address": "target@example.ca"}}
            ],
        },
        "saveToSentItems": "true",
    }
    mock_post.reset_mock()

    # Now test a case where we just couldn't get any email details from the
    # payload returned

    # Prepare Mock return object
    temp_payload = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
        # For our Draft Email ID:
        "id": "draft-id-no",
        # For FIle Uploads
        "uploadUrl": "https://my.url.path/",
    }

    bad_response.content = dumps(temp_payload)
    bad_response.status_code = requests.codes.okay
    mock_get.return_value = bad_response

    obj = Apprise.instantiate(
        f"azure://{source}/{tenant}/{client_id}{secret}/{targets}"
    )

    assert isinstance(obj, NotifyOffice365)

    # We can still send a notification even if we can't look up the email
    assert (
        obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
        is True
    )


@mock.patch("requests.put")
@mock.patch("requests.get")
@mock.patch("requests.post")
def test_plugin_office365_attachments(mock_post, mock_get, mock_put):
    """NotifyOffice365() Attachments."""

    # Initialize some generic (but valid) tokens
    source = "user@example.net"
    tenant = "ff-gg-hh-ii-jj"
    client_id = "aa-bb-cc-dd-ee"
    secret = "abcd/1234/abcd@ajd@/test"
    targets = "target@example.com"

    # Prepare Mock return object
    payload = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
        # For 'From:' Lookup
        "mail": "user@example.edu",
        # For our Draft Email ID:
        "id": "draft-id-no",
        # For FIle Uploads
        "uploadUrl": "https://my.url.path/",
    }
    okay_response = mock.Mock()
    okay_response.content = dumps(payload)
    okay_response.status_code = requests.codes.ok
    mock_post.return_value = okay_response
    mock_get.return_value = okay_response
    mock_put.return_value = okay_response

    # Instantiate our object
    obj = Apprise.instantiate(
        f"azure://{source}/{tenant}/{client_id}{secret}/{targets}"
    )

    assert isinstance(obj, NotifyOffice365)

    # Test Valid Attachment
    path = os.path.join(TEST_VAR_DIR, "apprise-test.gif")
    attach = AppriseAttachment(path)
    assert (
        obj.notify(
            body="body",
            title="title",
            notify_type=NotifyType.INFO,
            attach=attach,
        )
        is True
    )

    assert mock_post.call_count == 2
    assert (
        mock_post.call_args_list[0][0][0]
        == f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
    )
    assert (
        mock_post.call_args_list[0][1]["headers"].get("Content-Type")
        == "application/x-www-form-urlencoded"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/sendMail"
    )
    assert (
        mock_post.call_args_list[1][1]["headers"].get("Content-Type")
        == "application/json"
    )
    mock_post.reset_mock()

    # Test Authentication Failure
    obj = Apprise.instantiate(
        "azure://{source}/{tenant}/{client_id}{secret}/{targets}".format(
            client_id=client_id,
            tenant=tenant,
            source="object-id-requiring-lookup",
            secret=secret,
            targets=targets,
        )
    )

    bad_response = mock.Mock()
    bad_response.content = dumps(payload)
    bad_response.status_code = requests.codes.forbidden
    mock_post.return_value = bad_response

    assert isinstance(obj, NotifyOffice365)
    # Authentication will fail
    assert (
        obj.notify(
            body="auth-fail", title="title", notify_type=NotifyType.INFO
        )
        is False
    )
    assert mock_post.call_count == 1
    assert (
        mock_post.call_args_list[0][0][0]
        == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token"
    )
    mock_post.reset_mock()

    #
    # Test invalid attachment
    #

    # Instantiate our object
    obj = Apprise.instantiate(
        f"azure://{source}/{tenant}/{client_id}{secret}/{targets}"
    )

    assert isinstance(obj, NotifyOffice365)

    mock_post.return_value = okay_response
    path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg")
    assert (
        obj.notify(
            body="body",
            title="title",
            notify_type=NotifyType.INFO,
            attach=path,
        )
        is False
    )
    assert mock_post.call_count == 0
    mock_post.reset_mock()

    with mock.patch("base64.b64encode", side_effect=OSError()):
        # We can't send the message if we fail to parse the data
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is False
        )
    assert mock_post.call_count == 0
    mock_post.reset_mock()

    #
    # Test case where we can't authenticate
    #
    obj = Apprise.instantiate(
        f"azure://{source}/{tenant}/{client_id}{secret}/{targets}"
    )

    # Force a smaller attachment size forcing us to create an attachment
    obj.outlook_attachment_inline_max = 50

    assert isinstance(obj, NotifyOffice365)

    path = os.path.join(TEST_VAR_DIR, "apprise-test.gif")
    attach = AppriseAttachment(path)
    mock_post.return_value = bad_response
    assert obj.upload_attachment(attach[0], "id") is False
    assert mock_post.call_count == 1
    assert (
        mock_post.call_args_list[0][0][0]
        == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token"
    )

    mock_post.reset_mock()

    mock_post.side_effect = (okay_response, bad_response)
    mock_post.return_value = None
    assert obj.upload_attachment(attach[0], "id") is False
    assert mock_post.call_count == 2
    assert (
        mock_post.call_args_list[0][0][0]
        == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/"
        + "message/id/attachments/createUploadSession"
    )

    mock_post.reset_mock()
    # Return our status
    mock_post.side_effect = None

    # Prepare Mock return object
    payload_no_upload_url = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
        # For 'From:' Lookup
        "mail": "user@example.edu",
        # For our Draft Email ID:
        "id": "draft-id-no",
    }
    tmp_response = mock.Mock()
    tmp_response.content = dumps(payload_no_upload_url)
    tmp_response.status_code = requests.codes.ok
    mock_post.return_value = tmp_response

    assert obj.upload_attachment(attach[0], "id") is False
    assert mock_post.call_count == 1
    assert (
        mock_post.call_args_list[0][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/"
        + "message/id/attachments/createUploadSession"
    )

    mock_post.reset_mock()
    # Return our status
    mock_post.side_effect = None
    mock_post.return_value = okay_response

    obj = Apprise.instantiate(
        f"azure://{source}/{tenant}/{client_id}{secret}/{targets}"
    )

    # Force a smaller attachment size forcing us to create an attachment
    obj.outlook_attachment_inline_max = 50

    assert isinstance(obj, NotifyOffice365)

    # We now have to prepare sepparate session attachments using draft emails
    assert (
        obj.notify(
            body="body",
            title="title-test",
            notify_type=NotifyType.INFO,
            attach=attach,
        )
        is True
    )

    # Large Attachments
    assert mock_post.call_count == 4
    assert (
        mock_post.call_args_list[0][0][0]
        == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/messages"
    )
    assert (
        mock_post.call_args_list[2][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/"
        + "message/draft-id-no/attachments/createUploadSession"
    )
    assert (
        mock_post.call_args_list[3][0][0]
        == "https://graph.microsoft.com/v1.0/users/"
        + f"{source}/messages/draft-id-no/send"
    )
    mock_post.reset_mock()

    #
    # Handle another case where can't upload the attachment at all
    #
    path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg")
    bad_attach = AppriseAttachment(path)
    assert obj.upload_attachment(bad_attach[0], "id") is False

    mock_post.reset_mock()
    #
    # Handle test case where we can't send the draft email after everything
    # has been prepared
    #
    mock_post.return_value = None
    mock_post.side_effect = (okay_response, okay_response, bad_response)
    assert (
        obj.notify(
            body="body",
            title="title-test",
            notify_type=NotifyType.INFO,
            attach=attach,
        )
        is False
    )

    assert mock_post.call_count == 3
    assert (
        mock_post.call_args_list[0][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/messages"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/"
        + "message/draft-id-no/attachments/createUploadSession"
    )
    assert (
        mock_post.call_args_list[2][0][0]
        == "https://graph.microsoft.com/v1.0/users/"
        + f"{source}/messages/draft-id-no/send"
    )
    mock_post.reset_mock()
    mock_post.side_effect = None
    mock_post.return_value = okay_response

    #
    # Handle test case where we can not upload chunks
    #
    mock_put.return_value = bad_response

    # We now have to prepare sepparate session attachments using draft emails
    assert (
        obj.notify(
            body="body",
            title="title-no-chunk",
            notify_type=NotifyType.INFO,
            attach=attach,
        )
        is False
    )

    assert mock_post.call_count == 2
    assert (
        mock_post.call_args_list[0][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/messages"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/"
        + "message/draft-id-no/attachments/createUploadSession"
    )

    mock_put.return_value = okay_response
    mock_post.reset_mock()

    # Prepare Mock return object
    payload_missing_id = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
        # For 'From:' Lookup
        "mail": "user@example.edu",
        # For FIle Uploads
        "uploadUrl": "https://my.url.path/",
    }
    temp_response = mock.Mock()
    temp_response.content = dumps(payload_missing_id)
    temp_response.status_code = requests.codes.ok
    mock_post.return_value = temp_response

    # We could not acquire an attachment id, so we'll fail to send our
    # notification
    assert (
        obj.notify(
            body="body",
            title="title-test",
            notify_type=NotifyType.INFO,
            attach=attach,
        )
        is False
    )

    # Large Attachments
    assert mock_post.call_count == 1
    assert (
        mock_post.call_args_list[0][0][0]
        == "https://graph.microsoft.com/v1.0/users/user@example.net/messages"
    )

    mock_post.reset_mock()

    # Reset attachment size
    obj.outlook_attachment_inline_max = 50 * 1024 * 1024
    assert (
        obj.notify(
            body="body",
            title="title",
            notify_type=NotifyType.INFO,
            attach=attach,
        )
        is True
    )

    # already authenticated
    assert mock_post.call_count == 1
    assert (
        mock_post.call_args_list[0][0][0]
        == f"https://graph.microsoft.com/v1.0/users/{source}/sendMail"
    )
    mock_post.reset_mock()


@mock.patch("requests.get")
@mock.patch("requests.post")
def test_plugin_office365_reply_to(mock_post, mock_get):
    """NotifyOffice365() reply_to parameter support."""

    tenant = "ff-gg-hh-ii-jj"
    email = "user@example.net"
    client_id = "aa-bb-cc-dd-ee"
    secret = "abcd/1234/abcd@ajd@/test"
    targets = "target@example.com"

    payload = {
        "token_type": "Bearer",
        "expires_in": 6000,
        "access_token": "abcd1234",
        "mail": "user@example.net",
    }
    okay_response = mock.Mock()
    okay_response.content = dumps(payload)
    okay_response.status_code = requests.codes.ok
    mock_post.return_value = okay_response
    mock_get.return_value = okay_response

    # named reply_to via direct instantiation
    obj = NotifyOffice365(
        source=email,
        tenant=tenant,
        client_id=client_id,
        secret=secret,
        targets=[targets],
        reply_to=["Reply Person <reply@example.org>"],
    )

    assert isinstance(obj, NotifyOffice365)
    assert len(obj.reply_to) == 1
    assert "reply@example.org" in obj.reply_to

    # url() round-trip includes reply_to
    url = obj.url()
    assert "reply_to=" in url

    # send() adds replyTo with name to the payload
    assert obj.notify(title="title", body="test") is True
    assert mock_post.call_count == 2
    sent_payload = loads(mock_post.call_args_list[1][1]["data"])
    assert "replyTo" in sent_payload["message"]
    assert (
        sent_payload["message"]["replyTo"][0]["emailAddress"]["address"]
        == "reply@example.org"
    )
    assert (
        sent_payload["message"]["replyTo"][0]["emailAddress"]["name"]
        == "Reply Person"
    )
    mock_post.reset_mock()

    # reply_to without a name via URL query string
    obj2 = Apprise.instantiate(
        "o365://{email}/{tenant}/{client_id}/{secret}/{targets}"
        "?reply_to=reply@example.org".format(
            tenant=tenant,
            email=email,
            client_id=client_id,
            secret=secret,
            targets=targets,
        )
    )

    assert isinstance(obj2, NotifyOffice365)
    assert obj2.notify(title="t", body="b") is True
    sent_payload = loads(mock_post.call_args_list[1][1]["data"])
    assert "replyTo" in sent_payload["message"]
    assert "name" not in sent_payload["message"]["replyTo"][0]["emailAddress"]
    mock_post.reset_mock()

    # invalid reply_to address is dropped silently
    obj3 = NotifyOffice365(
        source=email,
        tenant=tenant,
        client_id=client_id,
        secret=secret,
        targets=[targets],
        reply_to=["not-a-valid-email"],
    )
    assert isinstance(obj3, NotifyOffice365)
    assert len(obj3.reply_to) == 0
    # No replyTo in payload when reply_to is empty
    assert obj3.notify(title="t", body="b") is True
    sent_payload = loads(mock_post.call_args_list[1][1]["data"])
    assert "replyTo" not in sent_payload["message"]
    mock_post.reset_mock()

    # parse_url picks up ?reply_to=
    parsed = NotifyOffice365.parse_url(
        "o365://{email}/{tenant}/{client_id}/{secret}/{targets}"
        "?reply_to=reply@example.org".format(
            tenant=tenant,
            email=email,
            client_id=client_id,
            secret=secret,
            targets=targets,
        )
    )
    assert parsed is not None
    assert "reply_to" in parsed
    assert "reply@example.org" in parsed["reply_to"]


@mock.patch("requests.get")
@mock.patch("requests.post")
def test_plugin_office365_personal_mode(mock_post, mock_get):
    """NotifyOffice365() personal mode (refresh_token / consumer accounts)."""
    from apprise.plugins.office365 import (
        Office365Mode,
    )

    client_id = "aa-bb-cc-dd-ee"
    seed_token = "seed-refresh-token-abc123"
    new_token = "rotated-refresh-token-xyz789"
    access_token = "access-token-live"

    auth_payload = {
        "token_type": "Bearer",
        "expires_in": 3600,
        "access_token": access_token,
        "refresh_token": new_token,
    }
    okay_response = mock.Mock()
    okay_response.content = dumps(auth_payload)
    okay_response.status_code = requests.codes.ok
    bad_response = mock.Mock()
    bad_response.content = dumps({"error": "bad"})
    bad_response.status_code = requests.codes.internal_server_error

    mock_post.return_value = okay_response
    mock_get.return_value = okay_response

    #
    # Auto-detection: @live.com triggers personal mode
    #
    obj = NotifyOffice365(
        source="user@live.com",
        client_id=client_id,
        secret=seed_token,
    )
    assert isinstance(obj, NotifyOffice365)
    assert obj.mode == Office365Mode.PERSONAL
    assert obj.tenant is None
    assert obj.from_email == "user@live.com"
    assert obj.source_is_object_id is False

    # All known personal domains trigger personal mode
    for domain in ("hotmail.com", "outlook.com", "live.com", "msn.com"):
        o = NotifyOffice365(
            source=f"user@{domain}",
            client_id=client_id,
            secret=seed_token,
        )
        assert o.mode == Office365Mode.PERSONAL, (
            f"expected personal mode for {domain}"
        )

    #
    # Explicit mode=personal on a non-personal domain
    #
    obj_explicit = NotifyOffice365(
        source="user@example.com",
        client_id=client_id,
        secret=seed_token,
        mode=Office365Mode.PERSONAL,
    )
    assert obj_explicit.mode == Office365Mode.PERSONAL

    #
    # Explicit mode=org on a personal domain — still requires valid email
    # source (no tenant needed for the personal flow, but org requires tenant)
    #
    with pytest.raises(TypeError):
        # tenant is None but mode=org demands a valid tenant
        NotifyOffice365(
            source="user@live.com",
            client_id=client_id,
            secret=seed_token,
            mode=Office365Mode.ORG,
        )

    #
    # Personal mode requires source to be a valid email
    #
    with pytest.raises(TypeError):
        NotifyOffice365(
            source="not-an-email",
            client_id=client_id,
            secret=seed_token,
            mode=Office365Mode.PERSONAL,
        )

    #
    # Invalid mode string raises TypeError
    #
    with pytest.raises(TypeError):
        NotifyOffice365(
            source="user@live.com",
            client_id=client_id,
            secret=seed_token,
            mode="invalid-mode",
        )

    #
    # send() uses me/sendMail and me/messages (not users/{source}/...)
    #
    obj = NotifyOffice365(
        source="user@live.com",
        client_id=client_id,
        secret=seed_token,
        targets=["target@example.com"],
    )
    assert obj.mode == Office365Mode.PERSONAL
    assert obj.notify(title="title", body="body") is True

    # POST 0: token refresh  POST 1: sendMail
    assert mock_post.call_count == 2
    assert (
        mock_post.call_args_list[0][0][0]
        == "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == "https://graph.microsoft.com/v1.0/me/sendMail"
    )
    mock_post.reset_mock()

    #
    # Refresh token rotation: store holds the new token; url() emits it
    #
    stored = obj.store.get("refresh_token")
    assert stored == new_token

    generated_url = obj.url()
    assert new_token in generated_url
    assert seed_token not in generated_url
    assert "mode=personal" in generated_url

    # Privacy URL masks the token
    private_url = obj.url(privacy=True)
    assert new_token not in private_url
    assert seed_token not in private_url

    #
    # Auth failure (bad token response) propagates correctly
    #
    mock_post.return_value = bad_response
    obj2 = NotifyOffice365(
        source="user@hotmail.com",
        client_id=client_id,
        secret=seed_token,
    )
    assert obj2.notify(title="t", body="b") is False
    mock_post.reset_mock()
    mock_post.return_value = okay_response

    #
    # Auth response missing expires_in
    #
    broken_payload = {"access_token": access_token}
    broken_response = mock.Mock()
    broken_response.content = dumps(broken_payload)
    broken_response.status_code = requests.codes.ok
    mock_post.return_value = broken_response
    obj3 = NotifyOffice365(
        source="user@live.com",
        client_id=client_id,
        secret=seed_token,
    )
    assert obj3.notify(title="t", body="b") is False
    mock_post.reset_mock()
    mock_post.return_value = okay_response

    #
    # parse_url: personal domain auto-detects without mode= param
    #
    parsed = NotifyOffice365.parse_url(
        f"o365://user@live.com/{client_id}/{seed_token}/"
    )
    assert parsed is not None
    assert parsed["mode"] == Office365Mode.PERSONAL
    assert parsed["tenant"] is None
    assert parsed["client_id"] == client_id
    assert parsed["secret"] == seed_token

    # Reconstructed object from parse_url
    obj4 = Apprise.instantiate(
        f"o365://user@live.com/{client_id}/{seed_token}/"
    )
    assert isinstance(obj4, NotifyOffice365)
    assert obj4.mode == Office365Mode.PERSONAL

    #
    # parse_url: explicit ?mode=personal on non-personal domain
    #
    parsed2 = NotifyOffice365.parse_url(
        f"o365://user@example.com/{client_id}/{seed_token}/?mode=personal"
    )
    assert parsed2 is not None
    assert parsed2["mode"] == Office365Mode.PERSONAL

    #
    # parse_url: explicit ?mode=org on personal domain
    #
    parsed3 = NotifyOffice365.parse_url(
        f"o365://user@live.com/tenant-id/{client_id}/{seed_token}/?mode=org"
    )
    assert parsed3 is not None
    assert parsed3["mode"] == Office365Mode.ORG
    assert parsed3["tenant"] == "tenant-id"

    #
    # url() round-trip for personal mode
    #
    obj5 = NotifyOffice365(
        source="user@outlook.com",
        client_id=client_id,
        secret=seed_token,
        targets=["recipient@example.com"],
    )
    url = obj5.url()
    assert "mode=personal" in url
    assert "outlook.com" in url
    assert client_id in url
    # No tenant segment
    assert "tenant" not in url

    # Round-trip: parse and reconstruct
    parsed4 = NotifyOffice365.parse_url(url)
    assert parsed4 is not None
    assert parsed4["mode"] == Office365Mode.PERSONAL
    obj6 = Apprise.instantiate(url)
    assert isinstance(obj6, NotifyOffice365)
    assert obj6.mode == Office365Mode.PERSONAL

    #
    # parse_url: unrecognised ?mode= value falls back to auto-detection
    # (covers the branch where _mode is NOT in OFFICE365_MODES)
    #
    parsed5 = NotifyOffice365.parse_url(
        f"o365://user@live.com/{client_id}/{seed_token}/?mode=bogus-value"
    )
    assert parsed5 is not None
    # bogus mode is ignored; auto-detection kicks in → personal
    assert parsed5["mode"] == Office365Mode.PERSONAL

    #
    # Personal mode supports cc/bcc/reply_to and emits them via url()
    #
    mock_post.reset_mock()
    mock_post.return_value = okay_response
    obj7 = NotifyOffice365(
        source="user@outlook.com",
        client_id=client_id,
        secret=seed_token,
        targets=["target@example.com"],
        cc=["Carbon Copy <cc@example.com>"],
        bcc=["Blind Copy <bcc@example.com>"],
        reply_to=["Reply Person <reply@example.org>"],
    )
    assert isinstance(obj7, NotifyOffice365)

    personal_url = obj7.url()
    assert "mode=personal" in personal_url
    assert "cc=Carbon%20Copy%3Acc%40example.com" in personal_url
    assert "bcc=Blind%20Copy%3Abcc%40example.com" in personal_url
    assert "reply_to=Reply%20Person%3Areply%40example.org" in personal_url

    assert obj7.notify(title="title", body="body") is True
    assert mock_post.call_count == 2
    assert (
        mock_post.call_args_list[1][0][0]
        == "https://graph.microsoft.com/v1.0/me/sendMail"
    )
    sent_payload = loads(mock_post.call_args_list[1][1]["data"])
    assert sent_payload["message"]["ccRecipients"][0]["emailAddress"] == {
        "address": "cc@example.com",
        "name": "Carbon Copy",
    }
    assert sent_payload["message"]["bccRecipients"][0]["emailAddress"] == {
        "address": "bcc@example.com",
        "name": "Blind Copy",
    }
    assert sent_payload["message"]["replyTo"][0]["emailAddress"] == {
        "address": "reply@example.org",
        "name": "Reply Person",
    }

    #
    # Auth succeeds but server returns no new refresh_token
    # (covers the branch where `if new_refresh:` is falsy)
    #
    no_rotate_payload = {
        "token_type": "Bearer",
        "expires_in": 3600,
        "access_token": access_token,
        # no "refresh_token" key
    }
    no_rotate_response = mock.Mock()
    no_rotate_response.content = dumps(no_rotate_payload)
    no_rotate_response.status_code = requests.codes.ok
    send_response = mock.Mock()
    send_response.content = dumps({})
    send_response.status_code = requests.codes.accepted

    mock_post.side_effect = [no_rotate_response, send_response]
    obj_nr = NotifyOffice365(
        source="user@live.com",
        client_id=client_id,
        secret=seed_token,
        targets=["target@example.com"],
    )
    assert obj_nr.notify(title="t", body="b") is True
    # No new token stored — seed_token still in url()
    assert obj_nr.store.get("refresh_token") is None
    mock_post.reset_mock()
    mock_post.side_effect = None
    mock_post.return_value = okay_response


@mock.patch("requests.put")
@mock.patch("requests.get")
@mock.patch("requests.post")
def test_plugin_office365_personal_large_attachment(
    mock_post, mock_get, mock_put
):
    """Personal mode large-attachment path (me/messages draft + send)."""
    from apprise.plugins.office365 import Office365Mode

    client_id = "aa-bb-cc-dd-ee"
    seed_token = "seed-refresh-token-abc123"
    new_token = "rotated-refresh-token-xyz789"
    access_token = "access-token-live"

    # Response that covers auth tokens, draft ID, and upload URL
    payload = {
        "token_type": "Bearer",
        "expires_in": 3600,
        "access_token": access_token,
        "refresh_token": new_token,
        "id": "draft-id-no",
        "uploadUrl": "https://upload.url.example/session",
    }
    okay_response = mock.Mock()
    okay_response.content = dumps(payload)
    okay_response.status_code = requests.codes.ok
    mock_post.return_value = okay_response
    mock_get.return_value = okay_response
    mock_put.return_value = okay_response

    path = os.path.join(TEST_VAR_DIR, "apprise-test.gif")
    attach = AppriseAttachment(path)

    obj = NotifyOffice365(
        source="user@live.com",
        client_id=client_id,
        secret=seed_token,
        targets=["target@example.com"],
    )
    assert obj.mode == Office365Mode.PERSONAL

    # Force all attachments to be "large" so the draft path is used
    obj.outlook_attachment_inline_max = 50

    assert (
        obj.notify(
            body="body",
            title="title",
            notify_type=NotifyType.INFO,
            attach=attach,
        )
        is True
    )

    # POST 0: token refresh  POST 1: draft create  POST 2: upload session
    # POST 3: send draft
    assert mock_post.call_count == 4
    assert (
        mock_post.call_args_list[0][0][0]
        == "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
    )
    assert (
        mock_post.call_args_list[1][0][0]
        == "https://graph.microsoft.com/v1.0/me/messages"
    )
    assert (
        mock_post.call_args_list[2][0][0]
        == "https://graph.microsoft.com/v1.0/me/"
        "message/draft-id-no/attachments/createUploadSession"
    )
    assert (
        mock_post.call_args_list[3][0][0] == "https://graph.microsoft.com/v1.0"
        "/me/messages/draft-id-no/send"
    )
