from __future__ import annotations

import contextlib
import time
import warnings
from abc import ABC, abstractmethod
from pathlib import Path
from subprocess import SubprocessError
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast

from loguru import logger as default_logger

from beekeepy._communication.url import HttpUrl, P2PUrl, WsUrl
from beekeepy._executable.abc.executable import ArgumentT, ConfigT, Executable
from beekeepy._remote_handle.app_status_probe import AppStatusProbe
from beekeepy._runnable_handle.match_ports import PortMatchingResult, match_ports
from beekeepy._runnable_handle.settings import RunnableHandleSettings
from beekeepy.exceptions import (
    ApiNotFoundError,
    CommunicationError,
    FailedToDetectReservedPortsError,
    FailedToStartExecutableError,
)
from beekeepy.interfaces import Stopwatch

if TYPE_CHECKING:
    from loguru import Logger

    from schemas.apis.app_status_api import GetAppStatus


ExecutableT = TypeVar("ExecutableT", bound=Executable[Any, Any])
SettingsT = TypeVar("SettingsT", bound=RunnableHandleSettings)
T = TypeVar("T")


class RunnableHandle(ABC, Generic[ExecutableT, ConfigT, ArgumentT, SettingsT]):
    def __init__(self, *args: Any, logger: Logger | None = None, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self._logger = logger or default_logger
        self._exec = self._construct_executable()

    @property
    def pid(self) -> int:
        """Returns pid of started executable. Note: Proxy method to Executable.pid."""
        return self._exec.pid

    @property
    def arguments(self) -> ArgumentT:
        """Returns arguments for given binary. Note: Proxy method to Executable.arguments."""
        return cast(ArgumentT, self._exec.arguments)

    @property
    def config(self) -> ConfigT:
        """Returns config for given binary. Note: Proxy method to Executable.config."""
        return cast(ConfigT, self._exec.config)

    def is_running(self) -> bool:
        """Returns is process running. Note: Proxy method to Executable.is_running."""
        return self._exec.is_running()

    def detach(self) -> int:
        """Detaches process and allows to keep it after closing python script."""
        return self._exec.detach()

    def close(self) -> None:
        """Closes running process. If process is not running, method does nothing."""
        if self.is_running():
            self._exec.close(timeout_secs=self._get_settings().close_timeout.total_seconds())

    def get_help_text(self) -> str:
        """Returns help printed by executable."""
        self.__show_warning_if_executable_already_running()
        return self._exec.get_help_text()

    def get_version(self) -> str:
        """Returns version string printed by executable."""
        self.__show_warning_if_executable_already_running()
        return self._exec.version()

    def generate_default_config_from_executable(self) -> ConfigT:
        """Returns config generated by executable."""
        self.__show_warning_if_executable_already_running()
        return cast(ConfigT, self._exec.generate_default_config())

    def _run(
        self,
        *,
        environment_variables: dict[str, str] | None = None,
        perform_unification: bool = True,
        blocking: bool = False,
        save_config: bool = True,
        timeout: float | None = None,
    ) -> None:
        """
        Runs executable and unifies arguments.

        Note: This method should be called by RunnableHandleChild.run, which is not defined by this interface!

        Keyword Arguments:
            environment_variables -- additional environment variables to set before launching executable
            additional_cli_arguments -- arguments to add to executable invocation
            perform_unification -- if set to true, chosen values will be written to config and cli arguments
        """
        settings = self._get_settings().copy()

        settings.working_directory = self.__choose_working_directory(settings=settings)
        settings.http_endpoint = self.__choose_http_endpoint(settings=settings)

        if perform_unification:
            self._unify_cli_arguments(settings.working_directory, settings.http_endpoint)
            self._unify_config(settings.working_directory, settings.http_endpoint)

        timeout = timeout or settings.initialization_timeout.total_seconds()

        with Stopwatch() as sw:
            try:
                self._exec._run(
                    blocking=blocking,
                    environ=environment_variables,
                    propagate_sigint=settings.propagate_sigint,
                    save_config=save_config,
                    timeout=timeout,
                )
                if blocking:
                    return
            except SubprocessError as e:
                raise FailedToStartExecutableError(f"{timeout= :.4f}, wait time={sw.lap:.4f}") from e
            sw.reset()
            try:
                ports = self._wait_for_app_to_start()
            except TimeoutError as e:
                raise FailedToDetectReservedPortsError(
                    f"timeout={settings.initialization_timeout.total_seconds():.4f}, wait time={sw.lap:.4f}"
                ) from e
            self._setup_ports(ports)

    @abstractmethod
    def _construct_executable(self) -> ExecutableT:
        """Returns executable instance."""

    @abstractmethod
    def _get_settings(self) -> SettingsT:
        """Returns settings hold by child class. Used only for read-only purposes."""

    def _get_working_directory_from_cli_arguments(self) -> Path | None:
        """Returns working directory from specified cli arguments in executable (if specified)."""
        return None

    def _get_http_endpoint_from_cli_arguments(self) -> HttpUrl | None:
        """Returns http endpoint from specified cli arguments in executable (if specified)."""
        return None

    def _get_working_directory_from_config(self) -> Path | None:
        """Returns working directory from specified config in executable (if specified)."""
        return None

    def _get_http_endpoint_from_config(self) -> HttpUrl | None:
        """Returns http endpoint from specified config in executable (if specified)."""
        return None

    @abstractmethod
    def _unify_cli_arguments(self, working_directory: Path, http_endpoint: HttpUrl) -> None:
        """
        Writes selected values to given cli arguments.

        Args:
            working_directory -- chosen working path to be set in cli arguments.
            http_endpoint -- chosen http endpoint to be set in cli arguments.
        """

    @abstractmethod
    def _unify_config(self, working_directory: Path, http_endpoint: HttpUrl) -> None:
        """
        Writes selected values to config in executable.

        Args:
            working_directory -- chosen working path to be set in config.
            http_endpoint -- chosen http endpoint to be set in config.
        """

    def _setup_ports(self, ports: PortMatchingResult) -> None:
        """
        Setup ports after startup.

        Args:
            ports -- list of ports reserved by started application.
        """

    def _wait_for_app_to_start(self) -> PortMatchingResult:
        """Waits for application to start."""

        def ignore_timeout_error() -> PortMatchingResult | None:
            with contextlib.suppress(TimeoutError):
                return self.__discover_ports()
            return None

        with Stopwatch() as stopwatch:
            while stopwatch.lap <= self._get_settings().initialization_timeout.total_seconds() and not bool(
                discovered_ports := ignore_timeout_error()
            ):
                if not self._exec.is_running():
                    raise FailedToStartExecutableError
                time.sleep(0.1)

        if discovered_ports is not None and bool(discovered_ports):
            self._logger.debug(f"Discovery of ports took {stopwatch.seconds_delta :2f} seconds")
            self._logger.info(f"{discovered_ports=}")
            return discovered_ports
        raise TimeoutError(f"Timeout after {stopwatch.seconds_delta :2f} waiting for application to start")

    def __choose_working_directory(self, settings: SettingsT) -> Path:
        return self.__choose_value(
            default_value=Path.cwd(),
            argument_value=self._get_working_directory_from_cli_arguments(),
            config_value=self._get_working_directory_from_config(),
            settings_value=settings.working_directory,
        )

    def __choose_http_endpoint(self, settings: SettingsT) -> HttpUrl:
        return self.__choose_value(
            default_value=HttpUrl("http://0.0.0.0:0"),
            argument_value=self._get_http_endpoint_from_cli_arguments(),
            config_value=self._get_http_endpoint_from_config(),
            settings_value=settings.http_endpoint,
        )

    def __show_warning_if_executable_already_running(self) -> None:
        if self.is_running():
            warnings.warn("Invoking executable that is already running!", stacklevel=2)

    @classmethod
    def __choose_value(
        cls,
        default_value: T,
        argument_value: T | None = None,
        config_value: T | None = None,
        settings_value: T | None = None,
    ) -> T:
        if argument_value is not None:
            return argument_value
        if config_value is not None:
            return config_value
        if settings_value is not None:
            return settings_value
        return default_value

    def __discover_ports(self) -> PortMatchingResult:
        reserved_ports = self._exec.reserved_ports()
        matched_ports = match_ports(reserved_ports)
        self._logger.debug(f"Potentially matched ports: {matched_ports}")
        if matched_ports.http is None:
            warnings.warn("Given executable probably does not provide http network access", stacklevel=3)
            return matched_ports

        settings = self._get_settings()
        handle = AppStatusProbe(
            settings=RunnableHandleSettings(
                http_endpoint=matched_ports.http,
                timeout=settings.timeout,
                max_retries=settings.max_retries,
                period_between_retries=settings.period_between_retries,
            )
        )
        status: None | GetAppStatus = None
        try:
            status = handle.api.get_app_status()
        except ApiNotFoundError:
            self._logger.warning(
                "HTTP port detected, but cannot obtain further information. app_status_api plugin is not enabled!"
            )
            return matched_ports
        except CommunicationError as e:
            raise FailedToStartExecutableError("Cannot communicate with application on detected HTTP port") from e

        assert status is not None, "Error has not been caught and further port discovery started"
        http = status.webservers.HTTP
        assert http, "Http cannot be None, as AppStatusProbe is already connected via http"
        assert (
            http.port == matched_ports.http.port
        ), "Http cannot differ from detected ports, because it is already connected"

        ws = status.webservers.WS
        if ws and matched_ports.websocket and ws.port != matched_ports.websocket.port:
            matched_ports.websocket = WsUrl.factory(port=ws.port)

        p2p = status.webservers.P2P
        if p2p and p2p.port not in [x.port for x in matched_ports.p2p]:
            matched_ports.p2p = [P2PUrl.factory(port=p2p.port)]

        return matched_ports
