# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, 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.
#
# To generate a private key file for your service account:
#
#  1. In the Firebase console, open Settings > Service Accounts.
#  2. Click Generate New Private Key, then confirm by clicking Generate Key.
#  3. Securely store the JSON file containing the key.

import base64
import calendar
from datetime import datetime, timedelta, timezone
import json
from json.decoder import JSONDecodeError
from urllib.parse import urlencode as _urlencode

from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat import backends
from cryptography.hazmat.primitives import asymmetric, hashes, serialization
import requests

from ...logger import logger


class GoogleOAuth:
    """A OAuth simplified implimentation to Google's Firebase Cloud
    Messaging."""

    scopes = [
        "https://www.googleapis.com/auth/firebase.messaging",
    ]

    # 1 hour in seconds (the lifetime of our token)
    access_token_lifetime_sec = timedelta(seconds=3600)

    # The default URI to use if one is not found
    default_token_uri = "https://oauth2.googleapis.com/token"

    # Taken right from google.auth.helpers:
    clock_skew = timedelta(seconds=10)

    def __init__(
        self, user_agent=None, timeout=(5, 4), verify_certificate=True
    ):
        """Initialize our OAuth object."""

        # Wether or not to verify ssl
        self.verify_certificate = verify_certificate

        # Our (connect, read) timeout
        self.request_timeout = timeout

        # assign our user-agent if defined
        self.user_agent = user_agent

        # initialize our other object variables
        self.__reset()

    def __reset(self):
        """Reset object internal variables."""

        # Google Keyfile Encoding
        self.encoding = "utf-8"

        # Our retrieved JSON content (unmangled)
        self.content = None

        # Our generated key information we cache once loaded
        self.private_key = None

        # Our keys we build using the provided content
        self.__refresh_token = None
        self.__access_token = None
        self.__access_token_expiry = datetime.now(timezone.utc)

    def load(self, path):
        """Generate our SSL details."""

        # Reset our objects
        self.content = None
        self.private_key = None
        self.__access_token = None
        self.__access_token_expiry = datetime.now(timezone.utc)

        try:
            with open(path, encoding=self.encoding) as fp:
                self.content = json.loads(fp.read())

        except OSError:
            logger.debug(f"FCM keyfile {path} could not be accessed")
            return False

        except JSONDecodeError as e:
            logger.debug(
                f"FCM keyfile {path} generated a JSONDecodeError: {e}"
            )
            return False

        if not isinstance(self.content, dict):
            logger.debug(f"FCM keyfile {path} is incorrectly structured")
            self.__reset()
            return False

        # Verify we've got the correct tokens in our content to work with
        is_valid = next(
            (
                False
                for k in (
                    "client_email",
                    "private_key_id",
                    "private_key",
                    "type",
                    "project_id",
                )
                if not self.content.get(k)
            ),
            True,
        )

        if not is_valid:
            logger.debug(f"FCM keyfile {path} is missing required information")
            self.__reset()
            return False

        # Verify our service_account type
        if self.content.get("type") != "service_account":
            logger.debug(f"FCM keyfile {path} is not of type service_account")
            self.__reset()
            return False

        # Prepare our private key which is in PKCS8 PEM format
        try:
            self.private_key = serialization.load_pem_private_key(
                self.content.get("private_key").encode(self.encoding),
                password=None,
                backend=backends.default_backend(),
            )

        except (TypeError, ValueError):
            # ValueError: If the PEM data could not be decrypted or if its
            #             structure could not be decoded successfully.
            # TypeError:  If a password was given and the private key was
            #             not encrypted. Or if the key was encrypted but
            #             no password was supplied.
            logger.error("FCM provided private key is invalid.")
            self.__reset()
            return False

        except UnsupportedAlgorithm:
            # If the serialized key is of a type that is not supported by
            # the backend.
            logger.error("FCM provided private key is not supported")
            self.__reset()
            return False

        # We've done enough validation to move on
        return True

    @property
    def access_token(self):
        """Returns our access token (if it hasn't expired yet)

        - if we do not have one we'll fetch one.
        - if it expired, we'll renew it
        - if a key simply can't be acquired, then we return None
        """

        if not self.private_key or not self.content:
            # invalid content (or not loaded)
            logger.error(
                "No FCM JSON keyfile content loaded to generate a access "
                "token with."
            )
            return None

        if self.__access_token_expiry > datetime.now(timezone.utc):
            # Return our no-expired key
            return self.__access_token

        # If we reach here we need to prepare our payload
        token_uri = self.content.get("token_uri", self.default_token_uri)
        service_email = self.content.get("client_email")
        key_identifier = self.content.get("private_key_id")

        # Generate our Assertion
        now = datetime.now(timezone.utc)
        expiry = now + self.access_token_lifetime_sec

        payload = {
            # The number of seconds since the UNIX epoch.
            "iat": calendar.timegm(now.utctimetuple()),
            "exp": calendar.timegm(expiry.utctimetuple()),
            # The issuer must be the service account email.
            "iss": service_email,
            # The audience must be the auth token endpoint's URI
            "aud": token_uri,
            # Our token scopes
            "scope": " ".join(self.scopes),
        }

        # JWT Details
        header = {
            "typ": "JWT",
            "alg": (
                "RS256"
                if isinstance(self.private_key, asymmetric.rsa.RSAPrivateKey)
                else "ES256"
            ),
            # Key Identifier
            "kid": key_identifier,
        }

        # Encodes base64 strings removing any padding characters.
        segments = [
            base64.urlsafe_b64encode(
                json.dumps(header).encode(self.encoding)
            ).rstrip(b"="),
            base64.urlsafe_b64encode(
                json.dumps(payload).encode(self.encoding)
            ).rstrip(b"="),
        ]

        signing_input = b".".join(segments)
        signature = self.private_key.sign(
            signing_input,
            asymmetric.padding.PKCS1v15(),
            hashes.SHA256(),
        )

        # Finally append our segment
        segments.append(base64.urlsafe_b64encode(signature).rstrip(b"="))
        assertion = b".".join(segments)

        http_payload = _urlencode({
            "assertion": assertion,
            "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
        })

        http_headers = {
            "Content-Type": "application/x-www-form-urlencoded",
        }
        if self.user_agent:
            http_headers["User-Agent"] = self.user_agent

        logger.info("Refreshing FCM Access Token")
        try:
            r = requests.post(
                token_uri,
                data=http_payload,
                headers=http_headers,
                verify=self.verify_certificate,
                timeout=self.request_timeout,
            )
            if r.status_code != requests.codes.ok:
                # We had a problem
                logger.warning(
                    f"Failed to update FCM Access Token error={r.status_code}."
                )

                logger.debug("Response Details:\r\n%s", r.content)
                return None

        except requests.RequestException as e:
            logger.warning(
                "A Connection error occurred refreshing FCM Access Token."
            )
            logger.debug("Socket Exception: %s", str(e))
            return None

        # If we get here, we made our request successfully, now we need
        # to parse out the data
        response = json.loads(r.content)
        self.__access_token = response["access_token"]
        self.__refresh_token = response.get(
            "refresh_token", self.__refresh_token
        )

        if "expires_in" in response:
            delta = timedelta(seconds=int(response["expires_in"]))
            self.__access_token_expiry = (
                delta + datetime.now(timezone.utc) - self.clock_skew
            )

        else:
            # Allow some grace before we expire
            self.__access_token_expiry = expiry - self.clock_skew

        logger.debug(
            "Access Token successfully acquired: %s", self.__access_token
        )

        # Return our token
        return self.__access_token

    @property
    def project_id(self):
        """Returns the project id found in the file."""
        return None if not self.content else self.content.get("project_id")
