import sys
import os
import time
import uuid
import shlex
import threading
import shutil
import subprocess
import logging
import inspect
import ctypes

import runpy
import requests
import psutil

# pylint: disable=no-member
import multiprocess

from dash.testing.errors import (
    NoAppFoundError,
    TestingTimeoutError,
    ServerCloseError,
    DashAppLoadingError,
)
from dash.testing import wait

logger = logging.getLogger(__name__)


def import_app(app_file, application_name="app"):
    """Import a dash application from a module. The import path is in dot
    notation to the module. The variable named app will be returned.

    :Example:

        >>> app = import_app("my_app.app")

    Will import the application in module `app` of the package `my_app`.

    :param app_file: Path to the app (dot-separated).
    :type app_file: str
    :param application_name: The name of the dash application instance.
    :raise: dash_tests.errors.NoAppFoundError
    :return: App from module.
    :rtype: dash.Dash
    """
    try:
        app_module = runpy.run_module(app_file)
        app = app_module[application_name]
    except KeyError as app_name_missing:
        logger.exception("the app name cannot be found")
        raise NoAppFoundError(
            f"No dash `app` instance was found in {app_file}"
        ) from app_name_missing
    return app


class BaseDashRunner:
    """Base context manager class for running applications."""

    _next_port = 58050

    def __init__(self, keep_open, stop_timeout, scheme="http", host="localhost"):
        self.scheme = scheme
        self.host = host
        self.port = 8050
        self.started = None
        self.keep_open = keep_open
        self.stop_timeout = stop_timeout
        self._tmp_app_path = None

    def start(self, *args, **kwargs):
        raise NotImplementedError  # pragma: no cover

    def stop(self):
        raise NotImplementedError  # pragma: no cover

    @staticmethod
    def accessible(url):
        try:
            requests.get(url)
        except requests.exceptions.RequestException:
            return False
        return True

    def __call__(self, *args, **kwargs):
        return self.start(*args, **kwargs)

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, traceback):
        if self.started and not self.keep_open:
            try:
                logger.info("killing the app runner")
                self.stop()
            except TestingTimeoutError as cannot_stop_server:
                raise ServerCloseError(
                    f"Cannot stop server within {self.stop_timeout}s timeout"
                ) from cannot_stop_server
        logger.info("__exit__ complete")

    @property
    def url(self):
        """The default server url."""
        return f"{self.scheme}://{self.host}:{self.port}"

    @property
    def is_windows(self):
        return sys.platform == "win32"

    @property
    def tmp_app_path(self):
        return self._tmp_app_path


class KillerThread(threading.Thread):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._old_threads = list(threading._active.keys())  # type: ignore[reportAttributeAccessIssue]; pylint: disable=W0212

    def kill(self):
        # Kill all the new threads.
        for thread_id in list(threading._active):  # type: ignore[reportAttributeAccessIssue]; pylint: disable=W0212
            if thread_id in self._old_threads:
                continue

            res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
                ctypes.c_long(thread_id), ctypes.py_object(SystemExit)
            )
            if res == 0:
                raise ValueError(f"Invalid thread id: {thread_id}")
            if res > 1:
                ctypes.pythonapi.PyThreadState_SetAsyncExc(
                    ctypes.c_long(thread_id), None
                )
                raise SystemExit("Stopping thread failure")


class ThreadedRunner(BaseDashRunner):
    """Runs a dash application in a thread.

    This is the default flavor to use in dash integration tests.
    """

    def __init__(self, keep_open=False, stop_timeout=3):
        super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
        self.thread = None
        self._app = None  # Store app reference for graceful shutdown

    def running_and_accessible(self, url):
        if self.thread.is_alive():  # type: ignore[reportOptionalMemberAccess]
            return self.accessible(url)
        raise DashAppLoadingError("Thread is not alive.")

    # pylint: disable=arguments-differ
    def start(self, app, start_timeout=3, **kwargs):
        """Start the app server in threading flavor."""
        self._app = app  # Store app reference for graceful shutdown

        def run():
            app.scripts.config.serve_locally = True
            app.css.config.serve_locally = True

            options = kwargs.copy()
            options["dev_tools_disable_version_check"] = True

            if "port" not in kwargs:
                options["port"] = self.port = BaseDashRunner._next_port
                BaseDashRunner._next_port += 1
            else:
                self.port = options["port"]

            try:
                module = app.server.__class__.__module__
                # FastAPI support
                if module.startswith("fastapi"):
                    app.run(**options)
                # Quart support (ASGI - runs its own async event loop)
                elif module.startswith("quart"):
                    app.run(**options)
                # Flask fallback (WSGI - needs threaded mode)
                else:
                    app.run(threaded=True, **options)
            except SystemExit:
                logger.info("Server stopped")
            except Exception as error:
                logger.exception(error)
                raise error

        retries = 0

        while not self.started and retries < 3:
            try:
                if self.thread:
                    if self.thread.is_alive():
                        self.stop()
                    else:
                        self.thread.kill()

                self.thread = KillerThread(target=run)
                self.thread.daemon = True
                self.thread.start()
                # wait until server is able to answer http request
                wait.until(
                    lambda: self.running_and_accessible(self.url), timeout=start_timeout
                )
                self.started = self.thread.is_alive()
            except Exception as err:  # pylint: disable=broad-except
                logger.exception(err)
                self.started = False
                retries += 1
                time.sleep(1)

        self.started = self.thread.is_alive()  # type: ignore[reportOptionalMemberAccess]
        if not self.started:
            raise DashAppLoadingError("threaded server failed to start")

    def stop(self):
        # For FastAPI apps with uvicorn, use graceful shutdown
        if self._app and hasattr(self._app, "_uvicorn_server"):
            server = self._app._uvicorn_server  # pylint: disable=protected-access
            server.should_exit = True
            self.thread.join(timeout=self.stop_timeout)  # type: ignore[reportOptionalMemberAccess]
        else:
            # Fall back to killing threads for Flask/other backends
            self.thread.kill()  # type: ignore[reportOptionalMemberAccess]
            self.thread.join()  # type: ignore[reportOptionalMemberAccess]
            wait.until_not(self.thread.is_alive, self.stop_timeout)  # type: ignore[reportOptionalMemberAccess]
        self._app = None
        self.started = False


class MultiProcessRunner(BaseDashRunner):
    def __init__(self, keep_open=False, stop_timeout=3):
        super().__init__(keep_open, stop_timeout)
        self.proc = None

    # pylint: disable=arguments-differ
    def start(self, app, start_timeout=3, **kwargs):
        self.port = kwargs.get("port", 8050)

        def target():
            app.scripts.config.serve_locally = True
            app.css.config.serve_locally = True

            options = kwargs.copy()

            try:
                module = app.server.__class__.__module__
                # FastAPI support
                if module.startswith("fastapi"):
                    app.run(**options)
                # Quart support (ASGI - runs its own async event loop)
                elif module.startswith("quart"):
                    app.run(**options)
                # Flask fallback (WSGI - needs threaded mode)
                else:
                    app.run(threaded=True, **options)
            except SystemExit:
                logger.info("Server stopped")
                raise
            except Exception as error:
                logger.exception(error)
                raise error

        self.proc = multiprocess.Process(target=target)  # type: ignore[reportAttributeAccessIssue]; pylint: disable=not-callable
        self.proc.start()

        wait.until(lambda: self.accessible(self.url), timeout=start_timeout)
        self.started = True

    def stop(self):
        process = psutil.Process(self.proc.pid)  # type: ignore[reportOptionalMemberAccess]

        for proc in process.children(recursive=True):
            try:
                proc.kill()
            except psutil.NoSuchProcess:
                pass

        try:
            process.kill()
        except psutil.NoSuchProcess:
            pass

        try:
            process.wait(1)
        except (psutil.TimeoutExpired, psutil.NoSuchProcess):
            pass


class ProcessRunner(BaseDashRunner):
    """Runs a dash application in a waitress-serve subprocess.

    This flavor is closer to production environment but slower.
    """

    def __init__(self, keep_open=False, stop_timeout=3):
        super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
        self.proc = None

    # pylint: disable=arguments-differ
    def start(
        self,
        app_module=None,
        application_name="app",
        raw_command=None,
        port=8050,
        start_timeout=3,
    ):
        """Start the server with waitress-serve in process flavor."""
        if not (app_module or raw_command):  # need to set a least one
            logging.error(
                "the process runner needs to start with at least one valid command"
            )
            return
        self.port = port
        args = shlex.split(
            raw_command
            if raw_command
            else f"waitress-serve --listen=0.0.0.0:{port} {app_module}:{application_name}.server",
            posix=not self.is_windows,
        )

        logger.debug("start dash process with %s", args)

        try:
            self.proc = subprocess.Popen(  # pylint: disable=consider-using-with
                args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
            )
            # wait until server is able to answer http request
            wait.until(lambda: self.accessible(self.url), timeout=start_timeout)

        except (OSError, ValueError):
            logger.exception("process server has encountered an error")
            self.started = False
            self.stop()
            return

        self.started = True

    def stop(self):
        if self.proc:
            try:
                logger.info("proc.terminate with pid %s", self.proc.pid)
                self.proc.terminate()
                if self.tmp_app_path and os.path.exists(self.tmp_app_path):
                    logger.debug("removing temporary app path %s", self.tmp_app_path)
                    shutil.rmtree(self.tmp_app_path)

                self.proc.communicate(
                    timeout=self.stop_timeout  # pylint: disable=unexpected-keyword-arg
                )
            except subprocess.TimeoutExpired:
                logger.exception(
                    "subprocess terminate not success, trying to kill "
                    "the subprocess in a safe manner"
                )
                self.proc.kill()
                self.proc.communicate()
        logger.info("process stop completes!")


class RRunner(ProcessRunner):
    def __init__(self, keep_open=False, stop_timeout=3):
        super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
        self.proc = None

    # pylint: disable=arguments-differ
    def start(self, app, start_timeout=2, cwd=None):  # type: ignore[reportIncompatibleMethodOverride]
        """Start the server with subprocess and Rscript."""

        if os.path.isfile(app) and os.path.exists(app):
            # app is already a file in a dir - use that as cwd
            if not cwd:
                cwd = os.path.dirname(app)
                logger.info("RRunner inferred cwd from app path: %s", cwd)
        else:
            # app is a string chunk, we make a temporary folder to store app.R
            # and its relevant assets
            tmp_dir = "/tmp" if not self.is_windows else os.getenv("TEMP")
            tmp_dir = str(tmp_dir)  # to satisfy type checking
            hex_id = uuid.uuid4().hex
            self._tmp_app_path = os.path.join(tmp_dir, hex_id)
            try:
                os.mkdir(self.tmp_app_path)  # type: ignore[reportArgumentType]
            except OSError:
                logger.exception("cannot make temporary folder %s", self.tmp_app_path)
            path = os.path.join(self.tmp_app_path, "app.R")  # type: ignore[reportCallIssue]

            logger.info("RRunner start => app is R code chunk")
            logger.info("make a temporary R file for execution => %s", path)
            logger.debug("content of the dashR app")
            logger.debug("%s", app)

            with open(path, "w", encoding="utf-8") as fp:
                fp.write(app)

            app = path

            # try to find the path to the calling script to use as cwd
            if not cwd:
                for entry in inspect.stack():
                    if "/dash/testing/" not in entry[1].replace("\\", "/"):
                        cwd = os.path.dirname(os.path.realpath(entry[1]))
                        logger.warning("get cwd from inspect => %s", cwd)
                        break
            if cwd:
                logger.info("RRunner inferred cwd from the Python call stack: %s", cwd)

                # try copying all valid sub folders (i.e. assets) in cwd to tmp
                # note that the R assets folder name can be any valid folder name
                assets = [
                    os.path.join(cwd, _)
                    for _ in os.listdir(cwd)
                    if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _))
                ]

                for asset in assets:
                    target = os.path.join(self.tmp_app_path, os.path.basename(asset))  # type: ignore[reportCallIssue]
                    if os.path.exists(target):
                        logger.debug("delete existing target %s", target)
                        shutil.rmtree(target)
                    logger.debug("copying %s => %s", asset, self.tmp_app_path)
                    shutil.copytree(asset, target)
                    logger.debug("copied with %s", os.listdir(target))

            else:
                logger.warning(
                    "RRunner found no cwd in the Python call stack. "
                    "You may wish to specify an explicit working directory "
                    "using something like: "
                    "dashr.run_server(app, cwd=os.path.dirname(__file__))"
                )

        logger.info("Run dashR app with Rscript => %s", app)

        args = shlex.split(
            f"Rscript -e 'source(\"{os.path.realpath(app)}\")'",
            posix=not self.is_windows,
        )
        logger.debug("start dash process with %s", args)

        try:
            self.proc = subprocess.Popen(  # pylint: disable=consider-using-with
                args,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                cwd=self.tmp_app_path if self.tmp_app_path else cwd,
            )
            # wait until server is able to answer http request
            wait.until(lambda: self.accessible(self.url), timeout=start_timeout)

        except (OSError, ValueError):
            logger.exception("process server has encountered an error")
            self.started = False
            return

        self.started = True


class JuliaRunner(ProcessRunner):
    def __init__(self, keep_open=False, stop_timeout=3):
        super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
        self.proc = None

    # pylint: disable=arguments-differ
    def start(self, app, start_timeout=30, cwd=None):  # type: ignore[reportIncompatibleMethodOverride]
        """Start the server with subprocess and julia."""

        if os.path.isfile(app) and os.path.exists(app):
            # app is already a file in a dir - use that as cwd
            if not cwd:
                cwd = os.path.dirname(app)
                logger.info("JuliaRunner inferred cwd from app path: %s", cwd)
        else:
            # app is a string chunk, we make a temporary folder to store app.jl
            # and its relevant assets
            tmp_dir = "/tmp" if not self.is_windows else os.getenv("TEMP")
            assert isinstance(tmp_dir, str)  # to satisfy typing
            hex_id = uuid.uuid4().hex
            self._tmp_app_path = os.path.join(tmp_dir, hex_id)
            assert isinstance(self.tmp_app_path, str)  # to satisfy typing
            try:
                os.mkdir(self.tmp_app_path)
            except OSError:
                logger.exception("cannot make temporary folder %s", self.tmp_app_path)
            path = os.path.join(self.tmp_app_path, "app.jl")

            logger.info("JuliaRunner start => app is Julia code chunk")
            logger.info("make a temporary Julia file for execution => %s", path)
            logger.debug("content of the Dash.jl app")
            logger.debug("%s", app)

            with open(path, "w", encoding="utf-8") as fp:
                fp.write(app)

            app = path

            # try to find the path to the calling script to use as cwd
            if not cwd:
                for entry in inspect.stack():
                    if "/dash/testing/" not in entry[1].replace("\\", "/"):
                        cwd = os.path.dirname(os.path.realpath(entry[1]))
                        logger.warning("get cwd from inspect => %s", cwd)
                        break
            if cwd:
                logger.info(
                    "JuliaRunner inferred cwd from the Python call stack: %s", cwd
                )

                # try copying all valid sub folders (i.e. assets) in cwd to tmp
                # note that the R assets folder name can be any valid folder name
                assets = [
                    os.path.join(cwd, _)
                    for _ in os.listdir(cwd)
                    if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _))
                ]

                for asset in assets:
                    target = os.path.join(self.tmp_app_path, os.path.basename(asset))
                    if os.path.exists(target):
                        logger.debug("delete existing target %s", target)
                        shutil.rmtree(target)
                    logger.debug("copying %s => %s", asset, self.tmp_app_path)
                    shutil.copytree(asset, target)
                    logger.debug("copied with %s", os.listdir(target))

            else:
                logger.warning(
                    "JuliaRunner found no cwd in the Python call stack. "
                    "You may wish to specify an explicit working directory "
                    "using something like: "
                    "dashjl.run_server(app, cwd=os.path.dirname(__file__))"
                )

        logger.info("Run Dash.jl app with julia => %s", app)

        args = shlex.split(
            f"julia --project {os.path.realpath(app)}", posix=not self.is_windows
        )
        logger.debug("start Dash.jl process with %s", args)

        try:
            self.proc = subprocess.Popen(  # pylint: disable=consider-using-with
                args,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                cwd=self.tmp_app_path if self.tmp_app_path else cwd,
            )
            # wait until server is able to answer http request
            wait.until(lambda: self.accessible(self.url), timeout=start_timeout)

        except (OSError, ValueError):
            logger.exception("process server has encountered an error")
            self.started = False
            return

        self.started = True
