# SPDX-FileCopyrightText: 2024 Luca Weiss
# SPDX-License-Identifier: GPL-3.0-only

from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from enum import Enum
import base64
import dataclasses
import json
import os
import random
import shutil
import subprocess
import time


class ESimError(Exception):
    """eSIM management exception"""
    pass


class ESimProfileState(Enum):
    ENABLED = "enabled"
    DISABLED = "disabled"


class ESimProfileClass(Enum):
    TEST = "test"
    PROVISIONING = "provisioning"
    OPERATIONAL = "operational"


@dataclass
class ESimProfile:
    """eSIM profile"""
    _backend: ESimBackend

    iccid: str  # ICCID of Profile
    isdp_aid: str  # Aid of Profile
    state: ESimProfileState  # State of Profile, "Enabled" or "Disabled"
    nickname: str | None  # Nickname of Profile
    service_provider_name: str  # Telecom operators of Profile
    name: str  # Name of Profile
    icon_type: str | None  # Profile icon data struct, "none", "png", "jpg"
    icon: bytes | None  # Profile icon data
    profile_class: ESimProfileClass  # Type of Profile

    def enable(self) -> None:
        self._backend.enable_profile(self.iccid)

    def disable(self) -> None:
        self._backend.disable_profile(self.iccid)

    def delete(self) -> None:
        self._backend.delete_profile(self.iccid)

    def set_nickname(self, nickname: str) -> None:
        self._backend.set_profile_nickname(self.iccid, nickname)
        # Generally it doesn't seem to be a good idea to try and mimic what the
        # backend will do to the profile, but for this it's useful for the
        # nickname editing function to update the state here.
        # Maybe this needs to be expanded to other properties in the future.
        self.nickname = nickname

    @classmethod
    def _decode_icon(cls, v) -> bytes | None:
        if v is None:
            return None

        return base64.b64decode(v)

    @classmethod
    def from_dict(cls, backend: ESimBackend, data) -> ESimProfile:
        return cls(
            _backend=backend,
            iccid=data.get('iccid'),
            isdp_aid=data.get('isdpAid'),
            state=ESimProfileState(data.get('profileState')),
            nickname=data.get('profileNickname'),
            service_provider_name=data.get('serviceProviderName'),
            name=data.get('profileName'),
            icon_type=data.get('iconType'),
            icon=ESimProfile._decode_icon(data.get('icon')),
            profile_class=ESimProfileClass(data.get('profileClass')),
        )


@dataclass
class ESimNotification:
    """eSIM notification"""
    _backend: ESimBackend

    seq_number: int
    profile_management_operation: str
    notification_address: str
    iccid: str

    def process(self, remove: bool = False) -> Iterator[LpacBackend.LpacProgressTypes]:
        return self._backend.process_notification([self.seq_number], remove)

    def remove(self) -> Iterator[LpacBackend.LpacProgressTypes]:
        return self._backend.remove_notification(self.seq_number)

    @classmethod
    def from_dict(cls, backend: ESimBackend, data) -> ESimNotification:
        return cls(
            _backend=backend,
            seq_number=data.get('seqNumber'),
            profile_management_operation=data.get('profileManagementOperation'),
            notification_address=data.get('notificationAddress'),
            iccid=data.get('iccid'),
        )


class ESimManager:
    """eSIM manager"""

    def __init__(self, backends: list[ESimBackend]):
        self._backends: dict[int, ESimBackend] = {}
        for backend in backends:
            slot = backend.get_slot()
            if slot in self._backends:
                raise RuntimeError(f"Cannot have two backends with the same slot '{slot}'")
            self._backends[slot] = backend

    def get_chip_info(self, slot: int) -> dict:
        """Returns various info about the eSIM chip"""
        return self._backends[slot].get_chip_info()

    def get_profiles(self, slot: int) -> list[ESimProfile]:
        """Return the list of profiles on the eSIM"""
        return self._backends[slot].get_profiles()

    # TODO: Make return type somewhat generic
    def download_profile(self, slot: int, smdp_server: str, matching_id: str) -> Iterator[LpacBackend.LpacProgressTypes]:
        """Download a profile onto the eSIM"""
        return self._backends[slot].download_profile(smdp_server, matching_id)

    # TODO: Make return type somewhat generic
    def download_profile_code(self, slot: int, activation_code: str) -> Iterator[LpacBackend.LpacProgressTypes]:
        """Download a profile onto the eSIM"""
        return self._backends[slot].download_profile_code(activation_code)

    def get_notifications(self, slot: int) -> list[ESimNotification]:
        """Return the list of pending notifications on the eSIM"""
        return self._backends[slot].get_notifications()


class ESimBackend:
    """Interface for eSIM backend"""

    class Type(Enum):
        QMI_QRTR = "qmi_qrtr"

    @staticmethod
    def get_operational_backends() -> list[ESimBackend]:
        """
        Return a list of zero to two ESimBackend instances, which should be usable
        """
        if os.getenv("LPA_GTK_BACKEND") == "dummy":
            return [DummyBackend(1), DummyBackend(2)]

        # QMI_QRTR backend
        backends = []
        for slot in [1, 2]:
            backend = LpacBackend(slot, LpacBackend.Type.QMI_QRTR)
            if backend.is_operational():
                backends.append(backend)
        if backends:
            return backends

        # Unfortunately we've got nothing.
        return []


    def is_operational(self) -> bool:
        """Self-check whether the backend would work on the selected slot"""
        try:
            self.get_chip_info()
            return True
        except Exception:
            return False

    def get_slot(self) -> int:
        """Return the slot number of this backend"""
        raise NotImplementedError

    def get_profiles(self) -> list[ESimProfile]:
        """Return the list of profiles on the eSIM"""
        raise NotImplementedError

    # TODO: Make return type somewhat generic
    def download_profile(self, smdp_server: str, matching_id: str) -> Iterator[LpacBackend.LpacProgressTypes]:
        """Download a profile onto the eSIM"""
        raise NotImplementedError

    # TODO: Make return type somewhat generic
    def download_profile_code(self, activation_code: str) -> Iterator[LpacBackend.LpacProgressTypes]:
        """Download a profile onto the eSIM"""
        raise NotImplementedError

    def enable_profile(self, iccid: str) -> None:
        """Enable the profile with the given iccid"""
        raise NotImplementedError

    def disable_profile(self, iccid: str) -> None:
        """Disable the profile with the given iccid"""
        raise NotImplementedError

    def delete_profile(self, iccid: str) -> None:
        """Delete the profile with the given iccid"""
        raise NotImplementedError

    def set_profile_nickname(self, iccid: str, nickname: str) -> None:
        """Set the nickname for the profile with the given iccid"""
        raise NotImplementedError

    def get_chip_info(self) -> dict:
        """Returns various info about the eSIM chip"""
        raise NotImplementedError

    def get_notifications(self) -> list[ESimNotification]:
        """Return the list of pending notifications on the eSIM"""
        raise NotImplementedError

    # TODO: Make return type somewhat generic
    def process_notifications(self, remove: bool) -> Iterator[LpacBackend.LpacProgressTypes]:
        """Process all notifications on the eSIM"""
        raise NotImplementedError

    # TODO: Make return type somewhat generic
    def process_notification(self, seq_numbers: list[int], remove: bool) -> Iterator[LpacBackend.LpacProgressTypes]:
        """Process the notification by sending it to the backend"""
        raise NotImplementedError

    # TODO: Make return type somewhat generic
    def remove_notification(self, seq_number: int) -> Iterator[LpacBackend.LpacProgressTypes]:
        """Remove a notification from the eSIM after it has been processed"""
        raise NotImplementedError


class LpacBackend(ESimBackend):
    """Implementation of ESimBackend using lpac"""

    @dataclass
    class LpacData:
        message: str
        data: str | dict | None

    @dataclass
    class LpacProgress:
        message: str
        data: str | dict | None

    @dataclass
    class LpacMisc:
        value: str

    LpacProgressTypes = LpacData | LpacProgress | LpacMisc

    def __init__(self, slot: int, backend_type: LpacBackend.Type):
        """Initialize the lpac eSIM backend with the given slot number"""
        self.slot = slot
        self.backend_type = backend_type
        self.lpac_exe = shutil.which("lpac")
        if not self.lpac_exe:
            raise ESimError("Required executable 'lpac' not found.")

    def __get_lpac_env(self) -> dict[str, str]:
        """
        Return the appropriate set of environment variables for LPAC to use.
        """
        env = os.environ.copy()

        def __set_env(key: str, value: str):
            if key in env:
                print(f"INFO: {key} set in env to '{env[key]}', not overwriting it.")
            else:
                env[key] = value

        if self.backend_type == ESimBackend.Type.QMI_QRTR:
            __set_env("LPAC_APDU", "qmi_qrtr")
            __set_env("LPAC_APDU_QMI_UIM_SLOT", str(self.slot))
            __set_env("UIM_SLOT", str(self.slot))  # deprecated in favor of LPAC_APDU_QMI_UIM_SLOT
        else:
            raise RuntimeError(f"Unhandled type {self.backend_type}")

        return env

    def __parse_output(self, output: dict) -> LpacProgressTypes:
        data_type = output["type"]
        payload = output["payload"]

        if data_type == "lpa":
            code = payload["code"]
            if code != 0:
                raise ESimError(f"Error from lpac: {code}, message={payload["message"]} data={payload["data"]}")
            return LpacBackend.LpacData(message=payload["message"], data=payload["data"])
        if data_type == "progress":
            code = payload["code"]
            if code != 0:
                raise ESimError(f"Error from lpac: {code}, message={payload["message"]} data={payload["data"]}")
            return LpacBackend.LpacProgress(message=payload["message"], data=payload["data"])

        raise ESimError(f"Unknown lpac output type {data_type}")

    def __run_lpac(self, lpac_args) -> dict:
        try:
            output = subprocess.check_output([self.lpac_exe] + lpac_args, env=self.__get_lpac_env())
        except subprocess.CalledProcessError as e:
            raise ESimError(e) from e
        res = self.__parse_output(json.loads(output))
        if not isinstance(res, LpacBackend.LpacData):
            raise ESimError(f"Unexpected data: {res}")
        return res.data

    def __run_lpac_stream(self, lpac_args) -> Iterator[LpacProgressTypes]:
        with subprocess.Popen([self.lpac_exe] + lpac_args, env=self.__get_lpac_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process:
            for line_obj in process.stdout:
                line = line_obj.decode('utf8')
                try:
                    obj = json.loads(line)
                    yield self.__parse_output(obj)
                except json.decoder.JSONDecodeError:
                    # Also propagate non-JSON values through the stack so they can be displayed.
                    # This contains e.g. curl output in error cases
                    yield LpacBackend.LpacMisc(line.strip())

        if process.returncode != 0:
            raise ESimError(f"Unexpected lpac exit code: {process.returncode}")

    def get_slot(self) -> int:
        return self.slot

    def get_profiles(self) -> list[ESimProfile]:
        profiles = []
        for profile_data in self.__run_lpac(["profile", "list"]):
            profile = ESimProfile.from_dict(self, profile_data)
            profiles.append(profile)

        return profiles

    def download_profile(self, smdp_server: str, matching_id: str) -> Iterator[LpacProgressTypes]:
        res = self.__run_lpac_stream(["profile", "download", "-s", smdp_server, "-m", matching_id])
        return res

    def download_profile_code(self, activation_code: str) -> Iterator[LpacProgressTypes]:
        res = self.__run_lpac_stream(["profile", "download", "-a", activation_code])
        return res

    def enable_profile(self, iccid: str) -> None:
        self.__run_lpac(["profile", "enable", iccid])

    def disable_profile(self, iccid: str) -> None:
        self.__run_lpac(["profile", "disable", iccid])

    def delete_profile(self, iccid: str) -> None:
        self.__run_lpac(["profile", "delete", iccid])

    def set_profile_nickname(self, iccid: str, nickname: str) -> None:
        self.__run_lpac(["profile", "nickname", iccid, nickname])

    def get_chip_info(self) -> dict:
        return self.__run_lpac(["chip", "info"])

    def get_notifications(self) -> list[ESimNotification]:
        notifications = []
        for notification_data in self.__run_lpac(["notification", "list"]):
            notification = ESimNotification.from_dict(self, notification_data)
            notifications.append(notification)

        return notifications

    def process_notifications(self, remove: bool) -> Iterator[LpacProgressTypes]:
        command = ["notification", "process", "-a"]
        if remove:
            command.append("-r")
        res = self.__run_lpac_stream(command)
        return res

    def process_notification(self, seq_numbers: list[int], remove: bool) -> Iterator[LpacProgressTypes]:
        command = ["notification", "process"]
        if remove:
            command.append("-r")
        for seq_number in seq_numbers:
            command.append(str(seq_number))
        res = self.__run_lpac_stream(command)
        return res

    def remove_notification(self, seq_number: int) -> Iterator[LpacProgressTypes]:
        res = self.__run_lpac_stream(["notification", "remove", str(seq_number)])
        return res


class DummyBackend(ESimBackend):
    """Implementation of ESimBackend for testing purposes"""

    # base64-encoded png for dummy profiles - image size: 64x64px, file size: under 1kB
    DUMMY_PNG = ("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAF6AAABegEyucj8AAAA" +
                 "GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAFh0RVh0Q29weXJpZ2h0AENDMCBQ" +
                 "dWJsaWMgRG9tYWluIERlZGljYXRpb24gaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvcHVibGlj" +
                 "ZG9tYWluL3plcm8vMS4wL8bjvfkAAAKGSURBVHic7VpdcwMhCLzr9H9n8svt081Yg7CsIGl6+5iM" +
                 "AnvKp2dr7fjP+KpWoBo3AdUKVOMmoFqBatwEVCtQjX9PwHe1AhfO5ylmZO3RzlS575AJzozXEEVM" +
                 "OQG98ZJRHnIYUt6GAI/yKCnInqUEMMZbe42w9n4bJ7iK0VD0lHxsGLwIsYhIOQGj0OxQZuF8nm2m" +
                 "QwgBFsuRd92D9minqRvjBJH7NTuCPQm7iNHkwARYRltGaOt3ESDJmhKwajCizMo+rFyIAMn4SEXf" +
                 "iYQXJ7jjXo7+YfTSVnociV8EMEWJBc2Y3kvPZGdXiWIYXNlcIzE79WX2Xc4DmJCoJSZWmIwmmCIA" +
                 "DWmSs0N+Q6DJkQiekSPWAtp9lAywvqbkBzIiTa8LVQxpClgKewVb2JUlQtVgrwzDMoqomiLdBzCQ" +
                 "jLvuquYHsgutLeUwA+2UoaQgeCHg+hpaqPIKXWl2omvZ8EifgJ4o6T92X1T2+Bt76iAnaDm93jmi" +
                 "xktpsSYD2c8j/8JST5AROCKj/vBAJCArzO0E+mE+risspcXHMScEJiDasWnOc+ZcM+COAt7w2K/z" +
                 "7rljYjwlgM0HjsPXT7SMZBIhD7bMBVacap8mo32BtFpAS34ilBllIGutvoClQ2gUsJKR6hGZBPUE" +
                 "aF8DPY4SmLZ4FnlpeUBEMmVdt4gxm9sJan09bzi71iJykWqPAR0FLCe1YxYYMXilCNCiAVMee9dE" +
                 "jtag6fB0sEhmaplhdJSRcgIuRHWMvOu2pMKriBivs/NCD9muK9ALQZWy1jFY6Se+7BX1QoQRHg0m" +
                 "F3C9EdoR2naDfiT1Vw0eUf5WuBof1xP04iagWoFq3ARUK1CNm4BqBarxA9CjiRv86BZfAAAAAElF" +
                 "TkSuQmCC")

    def __init__(self, slot: int):
        self.slot = slot

        self.profiles = [self._generate_profile()]

        self.notifications = [
            ESimNotification(
                _backend=self,
                seq_number=1,
                profile_management_operation="install",
                notification_address="example.org",
                iccid="89001010001234567063",
            )
        ]

    def _generate_profile(self) -> ESimProfile:
        dummy_nr = random.randint(1000, 9999)
        return ESimProfile(
            _backend=self,
            iccid=f"8900101000123456{dummy_nr}",
            isdp_aid="a0000005591010ffffffff8900ffff01",
            state=ESimProfileState.DISABLED,
            nickname=None,
            service_provider_name=f"Dummy Provider {dummy_nr}",
            name=f"Dummy Profile {dummy_nr}",
            icon_type="png",
            icon=ESimProfile._decode_icon(self.DUMMY_PNG),
            profile_class=ESimProfileClass.OPERATIONAL,
        )

    def _find_profile(self, iccid: str) -> ESimProfile:
        for profile in self.profiles:
            if profile.iccid == iccid:
                return profile
        raise IndexError(f"Profile with ICCID {iccid} not found")

    def _find_notification(self, seq_number: int) -> ESimNotification:
        for notification in self.notifications:
            if notification.seq_number == seq_number:
                return notification
        raise IndexError(f"Notification with Sequence Number {seq_number} not found")

    def get_slot(self) -> int:
        return self.slot

    def get_profiles(self) -> list[ESimProfile]:
        # Return copies of profiles to mimic lpac backend. There a profile is
        # essentially immutable from ESimBackend perspective.
        return [dataclasses.replace(p) for p in self.profiles]

    def download_profile(self, smdp_server: str, matching_id: str) -> Iterator[LpacBackend.LpacProgressTypes]:
        yield LpacBackend.LpacProgress("es10b_get_euicc_challenge_and_info", None)
        time.sleep(0.5)
        yield LpacBackend.LpacProgress("es9p_initiate_authentication", None)
        time.sleep(0.5)
        yield LpacBackend.LpacProgress("es10b_authenticate_server", None)
        time.sleep(0.5)
        yield LpacBackend.LpacProgress("es9p_authenticate_client", None)
        time.sleep(0.5)
        yield LpacBackend.LpacProgress("es10b_prepare_download", None)
        time.sleep(0.5)
        yield LpacBackend.LpacProgress("es9p_get_bound_profile_package", None)
        time.sleep(0.5)
        yield LpacBackend.LpacProgress("es10b_load_bound_profile_package", None)
        time.sleep(0.5)
        yield LpacBackend.LpacData(message="success", data=None)
        self.profiles.append(self._generate_profile())

    def get_notifications(self) -> list[ESimNotification]:
        return self.notifications

    def process_notifications(self, remove: bool) -> Iterator[LpacBackend.LpacProgressTypes]:
        for notification in self.notifications:
            yield LpacBackend.LpacProgress("es10b_retrieve_notifications_list", str(notification.seq_number))
            time.sleep(0.5)
            yield LpacBackend.LpacProgress("es9p_handle_notification", str(notification.seq_number))
            time.sleep(0.5)
            if remove:
                self.notifications.remove(notification)
        yield LpacBackend.LpacData(message="success", data=None)

    def process_notification(self, seq_numbers: list[int], remove: bool) -> Iterator[LpacBackend.LpacProgressTypes]:
        for seq_number in seq_numbers:
            notification = self._find_notification(seq_number)
            yield LpacBackend.LpacProgress("es10b_retrieve_notifications_list", str(seq_number))
            time.sleep(0.5)
            yield LpacBackend.LpacProgress("es9p_handle_notification", str(seq_number))
            time.sleep(0.5)
            if remove:
                self.notifications.remove(notification)
        yield LpacBackend.LpacData(message="success", data=None)

    def remove_notification(self, seq_number: int) -> Iterator[LpacBackend.LpacProgressTypes]:
        notification = self._find_notification(seq_number)
        yield LpacBackend.LpacProgress("es10b_remove_notification_from_list", str(seq_number))
        time.sleep(0.5)
        yield LpacBackend.LpacData(message="success", data=None)
        self.notifications.remove(notification)

    def download_profile_code(self, activation_code: str) -> Iterator[LpacBackend.LpacProgressTypes]:
        return self.download_profile("foo", "bar")

    def enable_profile(self, iccid: str) -> None:
        profile = self._find_profile(iccid)
        profile.state = ESimProfileState.ENABLED

    def disable_profile(self, iccid: str) -> None:
        profile = self._find_profile(iccid)
        profile.state = ESimProfileState.DISABLED

    def delete_profile(self, iccid: str) -> None:
        profile = self._find_profile(iccid)
        self.profiles.remove(profile)

    def set_profile_nickname(self, iccid: str, nickname: str | None) -> None:
        profile = self._find_profile(iccid)
        profile.nickname = nickname

    def get_chip_info(self) -> dict:
        # Return completely bogus information but in a similar format with nesting etc
        return {
            "key": "value",
            "child_obj": {
                "key2": "value2",
            },
            "child_array_string": [
                "foo", "bar"
            ],
            "child_array_object": [
                {"key": "value"},
                {"key_int": 99},
                {"key_null": None},
                {"key_nospace": "0123456789012345678901234567890123456789"},
                {"key_long": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."},
            ],
        }


# TODO: Remove, testing code
def main():
    from pprint import pprint

    def print_stream(val):
        if isinstance(val, LpacBackend.LpacProgress):
            print(f"Got progress! {val}")
        elif isinstance(val, LpacBackend.LpacData):
            print(f"Got final data: {val}")
        elif isinstance(val, LpacBackend.LpacMisc):
            print(f"Got misc data: {val}")
        else:
            raise RuntimeError(f"Unexpected val: {val}")

    slot = 2
    backend = LpacBackend(slot, ESimBackend.Type.QMI_QRTR)
    # backend = DummyBackend(slot)
    manager = ESimManager([backend])

    pprint(manager.get_profiles(slot))

    pprint(manager.get_chip_info(slot))

    for val in manager.download_profile(slot, "rsp-eu.example.org", "1234567890ABCDEF1234567890ABCDEF"):
        print_stream(val)

    notifications = manager.get_notifications(slot)
    for notification in notifications:
        pprint(notification)
        for val in notification.process(remove=False):
            print_stream(val)
        for val in notification.remove():
            print_stream(val)

    pprint(manager.get_notifications(slot))


if __name__ == '__main__':
    main()
