"""Abstract class for a service running on a server"""

from abc import ABC, abstractmethod
from typing import List, Optional
from os.path import exists

from selfprivacy_api import utils
from selfprivacy_api.services.config_item import ServiceConfigItem
from selfprivacy_api.utils.default_subdomains import DEFAULT_SUBDOMAINS
from selfprivacy_api.utils import ReadUserData, WriteUserData, get_domain
from selfprivacy_api.utils.waitloop import wait_until_true
from selfprivacy_api.utils.block_devices import BlockDevice, BlockDevices

from selfprivacy_api.jobs import Job, Jobs, JobStatus, report_progress
from selfprivacy_api.jobs.upgrade_system import rebuild_system

from selfprivacy_api.models.services import ServiceStatus, ServiceDnsRecord
from selfprivacy_api.services.generic_size_counter import get_storage_usage
from selfprivacy_api.services.owned_path import OwnedPath, Bind
from selfprivacy_api.services.moving import (
    check_binds,
    check_volume,
    unbind_folders,
    bind_folders,
    ensure_folder_ownership,
    MoveError,
    move_data_to_volume,
)


DEFAULT_START_STOP_TIMEOUT = 5 * 60


class Service(ABC):
    """
    Service here is some software that is hosted on the server and
    can be installed, configured and used by a user.
    """

    config_items: dict[str, "ServiceConfigItem"] = {}

    @staticmethod
    @abstractmethod
    def get_id() -> str:
        """
        The unique id of the service.
        """
        pass

    @staticmethod
    @abstractmethod
    def get_display_name() -> str:
        """
        The name of the service that is shown to the user.
        """
        pass

    @staticmethod
    @abstractmethod
    def get_description() -> str:
        """
        The description of the service that is shown to the user.
        """
        pass

    @staticmethod
    @abstractmethod
    def get_svg_icon() -> str:
        """
        The monochrome svg icon of the service.
        """
        pass

    @classmethod
    def get_url(cls) -> Optional[str]:
        """
        The url of the service if it is accessible from the internet browser.
        """
        domain = get_domain()
        subdomain = cls.get_subdomain()
        return f"https://{subdomain}.{domain}"

    @classmethod
    def get_subdomain(cls) -> Optional[str]:
        """
        The assigned primary subdomain for this service.
        """
        name = cls.get_id()
        with ReadUserData() as user_data:
            if "modules" in user_data:
                if name in user_data["modules"]:
                    if "subdomain" in user_data["modules"][name]:
                        return user_data["modules"][name]["subdomain"]

        return DEFAULT_SUBDOMAINS.get(name)

    @classmethod
    def get_user(cls) -> Optional[str]:
        """
        The user that owns the service's files.
        Defaults to the service's id.
        """
        return cls.get_id()

    @classmethod
    def get_group(cls) -> Optional[str]:
        """
        The group that owns the service's files.
        Defaults to the service's user.
        """
        return cls.get_user()

    @staticmethod
    def is_always_active() -> bool:
        """`True` if the service cannot be stopped, which is true for api itself"""
        return False

    @staticmethod
    @abstractmethod
    def is_movable() -> bool:
        """`True` if the service can be moved to the non-system volume."""
        pass

    @staticmethod
    @abstractmethod
    def is_required() -> bool:
        """`True` if the service is required for the server to function."""
        pass

    @staticmethod
    def can_be_backed_up() -> bool:
        """`True` if the service can be backed up."""
        return True

    @staticmethod
    @abstractmethod
    def get_backup_description() -> str:
        """
        The text shown to the user that exlplains what data will be
        backed up.
        """
        pass

    @classmethod
    def is_enabled(cls) -> bool:
        """
        `True` if the service is enabled.
        `False` if it is not enabled or not defined in file
        If there is nothing in the file, this is equivalent to False
        because NixOS won't enable it then.
        """
        name = cls.get_id()
        with ReadUserData() as user_data:
            return user_data.get("modules", {}).get(name, {}).get("enable", False)

    @classmethod
    def is_installed(cls) -> bool:
        """
        `True` if the service is installed.
        `False` if there is no module data in user data
        """
        name = cls.get_id()
        with ReadUserData() as user_data:
            return user_data.get("modules", {}).get(name, {}) != {}

    @staticmethod
    @abstractmethod
    def get_status() -> ServiceStatus:
        """The status of the service, reported by systemd."""
        pass

    @classmethod
    def _set_enable(cls, enable: bool):
        name = cls.get_id()
        with WriteUserData() as user_data:
            if "modules" not in user_data:
                user_data["modules"] = {}
            if name not in user_data["modules"]:
                user_data["modules"][name] = {}
            user_data["modules"][name]["enable"] = enable

    @classmethod
    def enable(cls):
        """Enable the service. Usually this means enabling systemd unit."""
        cls._set_enable(True)

    @classmethod
    def disable(cls):
        """Disable the service. Usually this means disabling systemd unit."""
        cls._set_enable(False)

    @staticmethod
    @abstractmethod
    def stop():
        """Stop the service. Usually this means stopping systemd unit."""
        pass

    @staticmethod
    @abstractmethod
    def start():
        """Start the service. Usually this means starting systemd unit."""
        pass

    @staticmethod
    @abstractmethod
    def restart():
        """Restart the service. Usually this means restarting systemd unit."""
        pass

    @classmethod
    def get_configuration(cls):
        return {
            key: cls.config_items[key].as_dict(cls.get_id()) for key in cls.config_items
        }

    @classmethod
    def set_configuration(cls, config_items):
        for key, value in config_items.items():
            if key not in cls.config_items:
                raise ValueError(f"Key {key} is not valid for {cls.get_id()}")
            if cls.config_items[key].validate_value(value) is False:
                raise ValueError(f"Value {value} is not valid for {key}")
        for key, value in config_items.items():
            cls.config_items[key].set_value(
                value,
                cls.get_id(),
            )

    @staticmethod
    @abstractmethod
    def get_logs():
        pass

    @classmethod
    def get_storage_usage(cls) -> int:
        """
        Calculate the real storage usage of folders occupied by service
        Calculate using pathlib.
        Do not follow symlinks.
        """
        storage_used = 0
        for folder in cls.get_folders():
            storage_used += get_storage_usage(folder)
        return storage_used

    @classmethod
    def has_folders(cls) -> int:
        """
        If there are no folders on disk, moving is noop
        """
        for folder in cls.get_folders():
            if exists(folder):
                return True
        return False

    @classmethod
    def get_dns_records(cls, ip4: str, ip6: Optional[str]) -> List[ServiceDnsRecord]:
        subdomain = cls.get_subdomain()
        display_name = cls.get_display_name()
        if subdomain is None:
            return []
        dns_records = [
            ServiceDnsRecord(
                type="A",
                name=subdomain,
                content=ip4,
                ttl=3600,
                display_name=display_name,
            )
        ]
        if ip6 is not None:
            dns_records.append(
                ServiceDnsRecord(
                    type="AAAA",
                    name=subdomain,
                    content=ip6,
                    ttl=3600,
                    display_name=f"{display_name} (IPv6)",
                )
            )
        return dns_records

    @classmethod
    def get_drive(cls) -> str:
        """
        Get the name of the drive/volume where the service is located.
        Example values are `sda1`, `vda`, `sdb`.
        """
        root_device: str = BlockDevices().get_root_block_device().name
        if not cls.is_movable():
            return root_device
        with utils.ReadUserData() as userdata:
            if userdata.get("useBinds", False):
                return (
                    userdata.get("modules", {})
                    .get(cls.get_id(), {})
                    .get(
                        "location",
                        root_device,
                    )
                )
            else:
                return root_device

    @classmethod
    def get_folders(cls) -> List[str]:
        """
        get a plain list of occupied directories
        Default extracts info from overriden get_owned_folders()
        """
        if cls.get_owned_folders == Service.get_owned_folders:
            raise NotImplementedError(
                "you need to implement at least one of get_folders() or get_owned_folders()"
            )
        return [owned_folder.path for owned_folder in cls.get_owned_folders()]

    @classmethod
    def get_owned_folders(cls) -> List[OwnedPath]:
        """
        Get a list of occupied directories with ownership info
        Default extracts info from overriden get_folders()
        """
        if cls.get_folders == Service.get_folders:
            raise NotImplementedError(
                "you need to implement at least one of get_folders() or get_owned_folders()"
            )
        return [cls.owned_path(path) for path in cls.get_folders()]

    @staticmethod
    def get_foldername(path: str) -> str:
        return path.split("/")[-1]

    # TODO: with better json utils, it can be one line, and not a separate function
    @classmethod
    def set_location(cls, volume: BlockDevice):
        """
        Only changes userdata
        """

        service_id = cls.get_id()
        with WriteUserData() as user_data:
            if "modules" not in user_data:
                user_data["modules"] = {}
            if service_id not in user_data["modules"]:
                user_data["modules"][service_id] = {}
            user_data["modules"][service_id]["location"] = volume.name

    def binds(self) -> List[Bind]:
        owned_folders = self.get_owned_folders()

        return [
            Bind.from_owned_path(folder, self.get_drive()) for folder in owned_folders
        ]

    def assert_can_move(self, new_volume):
        """
        Checks if the service can be moved to new volume
        Raises errors if it cannot
        """
        service_name = self.get_display_name()
        if not self.is_movable():
            raise MoveError(f"{service_name} is not movable")

        with ReadUserData() as user_data:
            if not user_data.get("useBinds", False):
                raise MoveError("Server is not using binds.")

        current_volume_name = self.get_drive()
        if current_volume_name == new_volume.name:
            raise MoveError(f"{service_name} is already on volume {new_volume}")

        check_volume(new_volume, space_needed=self.get_storage_usage())

        binds = self.binds()
        if binds == []:
            raise MoveError("nothing to move")

        # It is ok if service is uninitialized, we will just reregister it
        if self.has_folders():
            check_binds(current_volume_name, binds)

    def do_move_to_volume(
        self,
        new_volume: BlockDevice,
        job: Job,
    ):
        """
        Move a service to another volume.
        Note: It may be much simpler to write it per bind, but a bit less safe?
        """
        service_name = self.get_display_name()
        binds = self.binds()

        report_progress(10, job, "Unmounting folders from old volume...")
        unbind_folders(binds)

        report_progress(20, job, "Moving data to new volume...")
        binds = move_data_to_volume(binds, new_volume, job)

        report_progress(70, job, f"Making sure {service_name} owns its files...")
        try:
            ensure_folder_ownership(binds)
        except Exception as error:
            # We have logged it via print and we additionally log it here in the error field
            # We are continuing anyway but Job has no warning field
            Jobs.update(
                job,
                JobStatus.RUNNING,
                error=f"Service {service_name} will not be able to write files: "
                + str(error),
            )

        report_progress(90, job, f"Mounting {service_name} data...")
        bind_folders(binds)

        report_progress(95, job, f"Finishing moving {service_name}...")
        self.set_location(new_volume)

    def move_to_volume(self, volume: BlockDevice, job: Job) -> Job:
        service_name = self.get_display_name()

        report_progress(0, job, "Performing pre-move checks...")

        self.assert_can_move(volume)
        if not self.has_folders():
            self.set_location(volume)
            Jobs.update(
                job=job,
                status=JobStatus.FINISHED,
                result=f"{service_name} moved successfully (no folders).",
                status_text=f"NOT starting {service_name}",
                progress=100,
            )
            return job

        report_progress(5, job, f"Stopping {service_name}...")
        assert self is not None
        with StoppedService(self):
            report_progress(9, job, "Stopped service, starting the move...")
            self.do_move_to_volume(volume, job)

            report_progress(98, job, "Move complete, rebuilding...")
            rebuild_system(job, upgrade=False)

            Jobs.update(
                job=job,
                status=JobStatus.FINISHED,
                result=f"{service_name} moved successfully.",
                status_text=f"Starting {service_name}...",
                progress=100,
            )

        return job

    @classmethod
    def owned_path(cls, path: str):
        """Default folder ownership"""
        service_name = cls.get_display_name()

        try:
            owner = cls.get_user()
            if owner is None:
                # TODO: assume root?
                # (if we do not want to do assumptions, maybe not declare user optional?)
                raise LookupError(f"no user for service: {service_name}")
            group = cls.get_group()
            if group is None:
                raise LookupError(f"no group for service: {service_name}")
        except Exception as error:
            raise LookupError(
                f"when deciding a bind for folder {path} of service {service_name}, error: {str(error)}"
            )

        return OwnedPath(
            path=path,
            owner=owner,
            group=group,
        )

    def pre_backup(self):
        pass

    def post_restore(self):
        pass


class StoppedService:
    """
    A context manager that stops the service if needed and reactivates it
    after you are done if it was active

    Example:
        ```
            assert service.get_status() == ServiceStatus.ACTIVE
            with StoppedService(service) [as stopped_service]:
                assert service.get_status() == ServiceStatus.INACTIVE
        ```
    """

    def __init__(self, service: Service):
        self.service = service
        self.original_status = service.get_status()

    def __enter__(self) -> Service:
        self.original_status = self.service.get_status()
        if (
            self.original_status not in [ServiceStatus.INACTIVE, ServiceStatus.FAILED]
            and not self.service.is_always_active()
        ):
            try:
                self.service.stop()
                wait_until_true(
                    lambda: self.service.get_status()
                    in [ServiceStatus.INACTIVE, ServiceStatus.FAILED],
                    timeout_sec=DEFAULT_START_STOP_TIMEOUT,
                )
            except TimeoutError as error:
                raise TimeoutError(
                    f"timed out waiting for {self.service.get_display_name()} to stop"
                ) from error
        return self.service

    def __exit__(self, type, value, traceback):
        if (
            self.original_status in [ServiceStatus.ACTIVATING, ServiceStatus.ACTIVE]
            and not self.service.is_always_active()
        ):
            try:
                self.service.start()
                wait_until_true(
                    lambda: self.service.get_status() == ServiceStatus.ACTIVE,
                    timeout_sec=DEFAULT_START_STOP_TIMEOUT,
                )
            except TimeoutError as error:
                raise TimeoutError(
                    f"timed out waiting for {self.service.get_display_name()} to start"
                ) from error