"""

    AUTHOR:

        Khushal P Soonderji

    DATE:

        Saturday, 28th Dec., 2024

    OBJECTIVE:

        To handle all interactions with Safaricom's M-Pesa Express payment gateway from one place.

    REFERENCES:

        01. Official Documentation: https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate

    DOWNLOADS:

        N/A

"""


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                   IMPORT                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")

# My async utils:
from utils_v2.string import json
from utils_v2.string import regex
from utils_v2.database.async_mysql_v2 import AsyncMySQL
from utils_v2.database.async_mongo_v2 import AsyncMongo
from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache

# Controllers:
from controllers_v2.finstitutions.payments.base import PaymentsController

# Models:
from models.core.user import CoreUserInfoModel
from models.core.auth_token import CoreAuthTokenModel
from models.core.payment import CorePaymentModel, PaymentEvent, CustomerDetails
from models.api.finstitutions.payments.request import (
    PGPaymentRequestData,
    PaymentRequestOneResult,
    PaymentCallbackResult
)

# Payment Client:
from utils_v2.payments.safaricom.models.auth import MPesaExpressAuthorization
from utils_v2.payments.safaricom.controllers.m_pesa_express import SafaricomMPesaExpress

# To work with datatypes:
from typing import List, Any

# To make HTTP requests:
import httpx


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                           MACROS / ONE-TIME INIT                                              ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# --- Nothing Yet


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                VARIABLES                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# --- Nothing Yet


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                  FUNCTIONS                                                    ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# --- Nothing Yet


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                   CLASSES                                                     ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


class SafaricomMPesaExpressPaymentsController(PaymentsController):

    # ┏┓
    # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓
    # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛

    def __init__(
            self,
            cache: AsyncRedisCache = None,
            http_client: httpx.AsyncClient = None,
            alert_url: str = None,
            debug: bool = True,
            debug_prefix: str = "Sfrcm. M-Pesa Exp. (C) | ",
            debug_only_errors: bool = True
    ):

        """
        This is the foundational controller for all trading services. Use this for any smaller common tasks where you
        may not know the exact client beforehand.
        :param cache: The object to use for caching results from database calls.
        :param http_client: The HTTP client
        :param debug: Whether, or not, you would like to print debugging messages:
        :param debug_prefix: The prefix to print with the debugging messages.
        :param debug_only_errors: Whether you would like to print only error messages or all messages.
        :return: None.
        """

        # Invoke the parent's constructor:
        super().__init__(
            cache = cache,
            alert_url = alert_url,
            http_client = http_client,
            base_filter = None,
            debug = debug,
            debug_prefix = debug_prefix,
            debug_only_errors = debug_only_errors
        )

    # ┓┏  ┓
    # ┣┫┏┓┃┏┓┏┓┏┓┏
    # ┛┗┗ ┗┣┛┗ ┛ ┛
    #      ┛

    @staticmethod
    def clean_phone_no(value: str) -> str:

        """
        To clean-up input Kenyan phone nos.
        :param value: The phone no. to clean, provided as a string.
        :return: The cleaned phone no.
        """

        return regex.replace(
            text = value,
            pattern = r"[^0-9]",
            substitute_text = ""
        )

    # ┳┓            ┏┓
    # ┣┫┏┓┏┓┓┏┏┓┏╋  ┃┃┏┓┓┏┏┳┓┏┓┏┓╋┏
    # ┛┗┗ ┗┫┗┻┗ ┛┗  ┣┛┗┻┗┫┛┗┗┗ ┛┗┗┛
    #      ┗             ┛

    async def request_payment(
            self,
            sql_conn: AsyncMySQL,
            mongo_data_conn: AsyncMongo,
            auth_token: CoreAuthTokenModel,
            user_info: CoreUserInfoModel,
            payment_request: PGPaymentRequestData
    ) -> PaymentRequestOneResult:

        """
        To request payment from someone through a payment gateway.
        :param sql_conn: Only needed when a payment gateway says that the credentials are invalid and the account needs
            to be disabled.
        :param mongo_data_conn: The database connection to use to perform this activity.
        :param auth_token: The token that has to be used to fetch the data.
        :param user_info: The info. of your user, so that you can identify who requested the payment.
        :param payment_request: The data that came in with the APi call.
        :return: The structured response form the payment gateway.
        """

        # Start by assuming failure:
        result = PaymentRequestOneResult()

        # Initialize the payment request by creating a placeholder record in the database:
        payment_id = await self.init_payment(
            mongo_data_conn = mongo_data_conn,
            auth_token = auth_token,
            user_info = user_info,
            payment_request = payment_request,
            tags = ["Payment", "Safaricom", "M-Pesa Express", "Kenya"]
        )

        # Initialize the third-party client:
        client = SafaricomMPesaExpress(
            auth = MPesaExpressAuthorization(
                consumerKey = auth_token.auth["consumerKey"],
                consumerSecret = auth_token.auth["consumerSecret"],
                businessShortCode = auth_token.auth["businessShortCode"],
                appPasskey = auth_token.auth["appPasskey"]
            ),
            http_client = self._http_client
        )

        # Make the payment request:
        client_response = await client.request_payment(
            amount = payment_request.amount,
            party_a = self.clean_phone_no(
                payment_request.customerNo
            ) if isinstance(payment_request.customerNo, str) else payment_request.customerNo,
            type = "CustomerPayBillOnline",
            reference = str(payment_id),
            description = payment_request.description,
            callback_url = f"https://api.thecaoffice.com/converse/finstitutions/payments/callback/safaricom/mpesaexpress",
            payer_no = self.clean_phone_no(
                payment_request.payerNo
            ) if isinstance(payment_request.payerNo, str) else payment_request.payerNo,
            party_b = auth_token.auth["businessShortCode"]
        )

        # Add this event to the payment's document:
        event_note_success = await self.add_event_by_payment_id(
            mongo_data_conn = mongo_data_conn,
            payment_id = payment_id,
            event = PaymentEvent(
                paymentStatus = "initiated" if client_response.success else "initFailed",
                message = f"PG: {client_response.message}",
                initByPG = False,
                httpCode = client_response.httpCode,
                headers = await client_response.get_headers(),
                payload = await client_response.get_json()
            ),
            client_reference_id = client_response.referenceId
        )

        # Done here:
        result.success = client_response.success and event_note_success
        result.message = " ".join([
            "Payment requested successfully." if client_response.success
            else f"Payment request FAILED (PG: '{client_response.message}').",
            " " if event_note_success
            else "Event noting FAILED.",
        ]).strip()
        return result

    async def handle_payment_callback(
            self,
            sql_conn: AsyncMySQL,
            mongo_data_conn: AsyncMongo,
            inbound_data: dict,
            inbound_headers: dict
    ) -> PaymentCallbackResult:

        """
        Whenever the payment gateway sends an update about a requested payment, we use this method to update our records
        as per the specification of the third-party payment gateway.
        :param sql_conn: Only needed when a payment gateway says that the credentials are invalid and the account needs
            to be disabled.
        :param mongo_data_conn: The database connection to use to perform this activity.
        :param inbound_data: The data sent by the payment gateway in their update.
        :param inbound_headers: The headers sent by the payment gateway in their update.
        :return: A structured response about the process of updating the payment event.
        """

        # Start by assuming failure:
        result = PaymentCallbackResult()

        # Map out the documented codes provided by the payment gateway.
        # URL: https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate
        code_map = {
            0: {
                "status": "settled",
                "message": "Payment successful :)"
            },  # ... Success
            1037: {
                "status": "failed",
                "message": "The payment gateway could not reach your customer."
            },  # ... DS Timeout. User could not be reached.
            1025: {
                "status": "failed",
                "message": "There was a system error in the payment gateway (1025)."
            },  # ... System error while trying to send the push request.
            9999: {
                "status": "failed",
                "message": "There was a system error in the payment gateway (9999)."
            },  # ... System error while trying to send the push request.
            1032: {
                "status": "rejected",
                "message": "Your customer declined the payment request."
            },  # ... Request Cancelled by the user.
            1: {
                "status": "failed",
                "message": "Your customer has insufficient balance."
            },  # ... The user has insufficient balance.
            2001: {
                "status": "failed",
                "message": "The payment gateway says your credentials are invalid."
            },  # ... Invalid credentials of the initiator.
            1019: {
                "status": "failed",
                "message": "The transaction expired before your customer processed it."
            },  # ... Transaction expired.
            1001: {
                "status": "failed",
                "message": "Your customer is already in the middle of some transaction on the payment gateway."
            },  # ... The payer is already making some transaction.
        }

        # Figure out which of the above codes is relevant to you:
        pg_reference_id = inbound_data["Body"]["stkCallback"]["CheckoutRequestID"]
        pg_result_code = int(inbound_data["Body"]["stkCallback"]["ResultCode"])
        pg_result_desc = inbound_data["Body"]["stkCallback"]["ResultDesc"]
        relevant_code = code_map.get(
            pg_result_code,
            {
                "status": "unknown",
                "message": f"Unknown code '{pg_result_code}' from the payment gateway. PG: '{pg_result_desc}'"
            }
        )

        # For now, we just insert the event into the record:
        event_note_success = await self.add_event_by_client_reference_id(
            mongo_data_conn = mongo_data_conn,
            event = PaymentEvent(
                paymentStatus = relevant_code["status"],
                message = relevant_code["message"],
                initByPG = True,
                ipAddr = inbound_headers["Remote-IP"],
                httpCode = None,
                headers = inbound_headers,
                payload = inbound_data
            ),
            client_reference_id = pg_reference_id
        )

        # In case the payment collector's credentials have changed,
        # we mark his account as disabled:
        # if pg_result_code in [2001]:
        #     payment = await self.get_payment_by_client_reference_id(
        #         mongo_data_conn = mongo_data_conn,
        #         client_reference_id = pg_reference_id
        #     )
        #     auth_token = await self.get_token_from_id(
        #         mongo_data_conn = mongo_data_conn,
        #         token_id = payment.tokenId
        #     )
        #     auth_token.status = "disabled"
        #     await self.set_token(
        #         sql_conn = sql_conn,
        #         mongo_data_conn = mongo_data_conn,
        #         auth_token = auth_token
        #     )

        # Done here:
        if event_note_success:
            result.success = True
            result.message = f"Payment event noted."
        else:
            result.success = False
            result.message = f"Payment event NOT noted."
        return result


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                               MAIN PROGRAM                                                    ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


if __name__ == "__main__":

    pass
